Author SHA1 Message Date
shishengliang 3ef30215e0 feat(assets): 每天23:55采集资产状态快照
ci/woodpecker/push/woodpecker Pipeline was successful
2026-10-30 16:57:33 +08:00
31 changed files with 408 additions and 523 deletions
+22 -12
View File
@@ -14,7 +14,7 @@
- 复用资产仓储 `MAIN_SQL`、`transformRow`、`isOperatingVehicle`、`isInventoryVehicle`。
- 总数为未删除、未退出运营的车辆;运营为租赁/自营,库存为可运营/待运营;异动、待交车不另加。
- 月末最后一天 `Asia/Shanghai` 17:00 为计划时点;只允许在 17:00:00(含)到 17:01:00(不含)内开始读取。
- 每天 `Asia/Shanghai` 23:55 为计划时点,归属当天;只允许在 23:55:00(含)到 23:56:00(不含)内开始读取。每天独立保存完整逐车状态;月度展示月末当天、年度展示12月31日快照,不累加每日存量。
- `captured_at` 是程序记录的实际开始读取时间,`scheduled_for` 是计划时点;两者不宣称完全相同。
- 一致性读取可在窗口内开始后继续执行;持久化失败最多重试三次,始终使用同一份已取得数据,不重新读晚到的台账。
- 当月在截止前显示实时点;年末前当年年度点显示实时点。截止后只能显示归档、归档中、失败或缺失。12 月快照直接用作年度年末点。
@@ -33,9 +33,9 @@
## 存储
`bi_asset_history_settings` 保存首次实际启用时间。重复启用保留首次时间。
`bi_asset_history_settings.enabled_at` 保留旧采集首次启用时间;`daily_enabled_at` 保存每日采集首次实际启用时间。重复启用保留首次时间,升级前的日期不会被标记为漏采。
`bi_asset_history_batches` 保存年月、口径版本、计划及实际时间、来源、状态、失败码、全量校验计数及导入追溯信息。年月与口径版本唯一。正式归档为 `COMPLETE`,失败为 `FAILED`,错过为 `MISSED`。
`bi_asset_history_batches` 保存期间键、口径版本、计划及实际时间、来源、状态、失败码、全量校验计数及导入追溯信息。`period_key` 与口径版本唯一;新快照使用 YYYY-MM-DD,旧月度 YYYY-MM 键和明细原样保留。月度查询仅选月末日及旧月度记录;同月优先新日快照,但失败或缺失不会遮蔽旧 COMPLETE 归档。正式归档为 `COMPLETE`,失败为 `FAILED`,错过为 `MISSED`。
`bi_asset_history_vehicles` 保存车辆字符串 ID、原始运营/车辆状态、归档分类、公司、部门、负责人 ID 和冻结车辆 JSON。批次内车辆唯一;不以当前车辆表为外键。JSON 保留车牌、VIN、型号、品牌、业务归属等明细信息,排除无关的异动工作详情和待交车附件信息。
@@ -73,27 +73,37 @@ node --import tsx scripts/migrate-asset-history.ts --apply
如部分建表失败,修正原因后可重复执行;脚本使用 `CREATE TABLE IF NOT EXISTS`。新建空表不会伪造以前历史。
授权启用后,将服务进程环境变量设为 `ASSET_HISTORY_CAPTURE_ENABLED=1` 并按现有流程重启服务。调度器首次成功连接时记录 `enabled_at`,启用前的时点不会追补。如果在月末 17:00 之后才首次启用,即使仍处于该分钟,也从下一个月末开始采集。
每日采集升级(2026-10-30):先停止旧版采集服务,执行基础建表迁移(已有表不变),再审阅并执行一次增量迁移:
按本需求确认日期 2026-10-19,如果在 2026-10-31 17:00 前成功启用,首个计划时点为北京时间 **2026-10-31 17:00**;实际计划以数据库首次启用时间和服务时间计算,不硬编码。
```sh
node --import tsx scripts/migrate-asset-history.ts --daily
node --import tsx scripts/migrate-asset-history.ts --daily --apply
```
保持服务在计划时点在线。停止期间错过的 eligible 月份会在重启时记为 MISSED;已 FAILED 的记录保留诊断标记。记录到启用前没有归档,不能用重启时的实时数回填。日志前缀为 `[asset-history]`,包含年月及 captured/already-complete/locked/failed/missed 结果,不记录客户明细或连接凭据。
增量迁移把 `period_month CHAR(7)` 改为 `period_key CHAR(10)`,原唯一索引继续约束期间键和版本;新增可空 `daily_enabled_at`。这是一次性 ALTER,不能重复执行。应在目标库完成迁移后启动新版服务并设置采集开关;旧版程序不能直接使用新字段名。迁移不启动采集,也不修改旧归档的计划/实际时间。当前页面仍提供月度和年度浏览,普通日期快照保存在数据库中。
建议仅让受控服务账号写快照表;源表仍只需读权限。多实例可启用同一数据库下的同一采集配置,命名锁和数据库唯一约束共同保证幂等。启用开关为进程级配置,`enabled_at` 是历史启用记录,不能仅根据该记录判断某个进程现在是否运行调度器。
授权启用后,将服务进程环境变量设为 `ASSET_HISTORY_CAPTURE_ENABLED=1` 并按现有流程重启服务。调度器首次成功连接时记录 `daily_enabled_at`,启用前的时点不会追补。如果在当天 23:55 之后才首次启用,即使仍处于该分钟,也从次日开始采集。
按每日采集需求确认日期 2026-10-30,如果在当天 23:55 前成功启用,首个计划时点为北京时间 **2026-10-30 23:55**;实际计划以每日采集首次启用时间和服务时间计算,不硬编码。
保持服务在计划时点在线。停止期间错过的 eligible 日期会在重启时记为 MISSED;已 FAILED 的记录保留诊断标记。记录到启用前没有归档,不能用重启时的实时数回填。日志前缀为 `[asset-history]`,包含日期及 captured/already-complete/locked/failed/missed 结果,不记录客户明细或连接凭据。
建议仅让受控服务账号写快照表;源表仍只需读权限。多实例可启用同一数据库下的同一采集配置,命名锁和数据库唯一约束共同保证幂等。启用开关为进程级配置,`daily_enabled_at` 是历史启用记录,不能仅根据该记录判断某个进程现在是否运行调度器。
管理员可用以下只读 SQL 检查,不通过普通用户接口暴露全量计数:
```sql
SELECT enabled_at FROM bi_asset_history_settings WHERE id=1;
SELECT period_month, scheduled_for, captured_at, completed_at,
SELECT period_key, scheduled_for, captured_at, completed_at,
definition_version, source, status, failure_code,
total_count, operating_count, inventory_count
FROM bi_asset_history_batches ORDER BY period_month DESC LIMIT 12;
SELECT b.period_month, b.total_count, COUNT(v.vehicle_id) AS detail_count
FROM bi_asset_history_batches ORDER BY period_key DESC LIMIT 31;
SELECT b.period_key, b.total_count, COUNT(v.vehicle_id) AS detail_count
FROM bi_asset_history_batches b
LEFT JOIN bi_asset_history_vehicles v ON v.batch_id=b.id
WHERE b.status='COMPLETE'
GROUP BY b.id, b.period_month, b.total_count;
GROUP BY b.id, b.period_key, b.total_count;
```
## 回退
@@ -108,7 +118,7 @@ GROUP BY b.id, b.period_month, b.total_count;
提供仅表头模板 `docs/asset-history-import-template.csv`。使用 UTF-8 CSV,每行一辆车,同一文件只允许一个年月、一个口径版本和一个统计时点。
必填字段:`period_month`、`scheduled_for`、`captured_at`、`definition_version`、`source_reference`、`vehicle_id`、`operation_status`。空缺车牌/VIN使用空值,不造车辆 ID。ID 均按文本读取。`scheduled_for` 必须是指定年月最后一天北京时间 17:00,`captured_at` 必须来自可信历史来源,不能把导入执行时间当作历史读取时间。
必填字段:`period_month`、`scheduled_for`、`captured_at`、`definition_version`、`source_reference`、`vehicle_id`、`operation_status`。空缺车牌/VIN使用空值,不造车辆 ID。ID 均按文本读取。旧版月度导入契约的 `scheduled_for` 是指定年月最后一天北京时间 17:00(保留旧格式,不能作为每日自动采集规则),`captured_at` 必须来自可信历史来源,不能把导入执行时间当作历史读取时间。
其余字段见模板。部门、负责人、主体缺失须显式留空,不能用今日台账补齐;部门/个人权限因此可能看不到未归属的运营车辆,这是数据缺失而非自动扩大授权的理由。展示型号、品牌、原始车辆状态按历史来源保存;历史状态映射复用现有分类函数,不接受退出运营车辆或未知运营状态作为有效存量。归档分类由导入校验程序计算,不直接信任文件中的分类列。
@@ -0,0 +1,4 @@
-- Run once after 20261019_asset_history.sql; stop old capture workers before migration.
-- Existing YYYY-MM keys and archived vehicle rows remain unchanged.
ALTER TABLE bi_asset_history_batches CHANGE COLUMN period_month period_key CHAR(10) NOT NULL;
ALTER TABLE bi_asset_history_settings ADD COLUMN daily_enabled_at DATETIME(3) NULL;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ln-bi",
"version": "1.1.21",
"version": "1.1.19",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ln-bi",
"version": "1.1.21",
"version": "1.1.19",
"dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
"@hono/node-server": "^1.13.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ln-bi",
"private": true,
"version": "1.1.21",
"version": "1.1.19",
"type": "module",
"scripts": {
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
+2 -1
View File
@@ -1,5 +1,6 @@
import { readFile } from 'node:fs/promises';
const sql = await readFile(new URL('../migrations/20261019_asset_history.sql', import.meta.url), 'utf8');
const migration = process.argv.includes('--daily') ? '20261030_asset_history_daily.sql' : '20261019_asset_history.sql';
const sql = await readFile(new URL(`../migrations/${migration}`, import.meta.url), 'utf8');
if (!process.argv.includes('--apply')) {
console.log(sql);
console.log('Review only. Pass --apply only after approving the configured database migration.');
-120
View File
@@ -1,120 +0,0 @@
import { useId, useRef, useState } from 'react';
import { ArrowRight, CalendarDays, Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react';
export interface DateRange { start: string; end: string }
interface DateRangePickerProps {
value: DateRange;
onChange: (range: DateRange) => void;
quickPicks: Array<{ label: string; range: DateRange }>;
label?: string;
testId?: string;
className?: string;
maxDays?: number;
}
const weekdays = ['一', '二', '三', '四', '五', '六', '日'];
const ymd = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const parseDate = (value: string) => new Date(`${value}T12:00:00`);
const dayCount = (start: string, end: string) => Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86400000) + 1;
export function DateRangePicker({ value, onChange, quickPicks, label = '统计日期', testId, className = '', maxDays = Infinity }: DateRangePickerProps) {
const titleId = useId();
const dialog = useRef<HTMLDialogElement>(null);
const [draft, setDraft] = useState(value);
const [selecting, setSelecting] = useState<'start' | 'end'>('start');
const [month, setMonth] = useState(() => parseDate(value.start));
const [hovered, setHovered] = useState('');
const today = ymd(new Date());
const count = draft.end ? dayCount(draft.start, draft.end) : 0;
const valid = count > 0 && count <= maxDays;
const monthStart = new Date(month.getFullYear(), month.getMonth(), 1);
const previewEnd = draft.end || hovered || draft.start;
const rangeStart = draft.start < previewEnd ? draft.start : previewEnd;
const rangeEnd = draft.start > previewEnd ? draft.start : previewEnd;
const open = () => {
setDraft(value); setSelecting('start'); setMonth(parseDate(value.start)); setHovered('');
dialog.current?.showModal();
};
const choose = (date: string) => {
if (selecting === 'start') {
setDraft({ start: date, end: '' }); setSelecting('end');
} else {
setDraft(date < draft.start ? { start: date, end: draft.start } : { start: draft.start, end: date });
setSelecting('start');
}
setHovered('');
};
const calendar = (offset: number) => {
const current = new Date(monthStart.getFullYear(), monthStart.getMonth() + offset, 1);
const year = current.getFullYear(), m = current.getMonth();
const padding = (current.getDay() + 6) % 7;
const length = new Date(year, m + 1, 0).getDate();
return <div className={offset ? 'hidden sm:block' : ''} key={offset}>
<div className="mb-3 text-center text-sm font-bold text-slate-700">{year} 年 {m + 1} 月</div>
<div className="grid grid-cols-7 text-center">
{weekdays.map(day => <div key={day} className="py-2 text-[11px] font-medium text-slate-400">{day}</div>)}
{Array.from({ length: padding }, (_, i) => <div key={`blank-${i}`} />)}
{Array.from({ length }, (_, i) => {
const date = ymd(new Date(year, m, i + 1));
const endpoint = date === draft.start || date === draft.end;
const inRange = date >= rangeStart && date <= rangeEnd;
return <button key={date} type="button" aria-label={date} aria-pressed={endpoint}
onClick={() => choose(date)} onMouseEnter={() => { if (!draft.end) setHovered(date); }}
className={`relative my-0.5 h-10 text-xs font-semibold transition focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-blue-500 ${endpoint ? 'rounded-xl bg-blue-600 text-white shadow-sm' : inRange ? 'bg-blue-50 text-blue-700' : 'rounded-xl text-slate-600 hover:bg-slate-100'} ${date === today && !endpoint ? 'font-black text-blue-600' : ''}`}>
{i + 1}{date === today && <span className={`absolute bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full ${endpoint ? 'bg-white' : 'bg-blue-500'}`} />}
</button>;
})}
</div>
</div>;
};
return <>
<button type="button" data-testid={testId} aria-haspopup="dialog" onClick={open}
className={`flex w-full items-center gap-3 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-left shadow-sm transition hover:border-blue-300 hover:bg-blue-50/40 focus-visible:outline-2 focus-visible:outline-blue-500 ${className}`}>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600"><CalendarDays size={18} /></span>
<span className="min-w-0 flex-1">
<span className="block text-[10px] font-medium text-slate-400">{label}</span>
<span className="mt-1 flex items-center gap-2 text-[13px] font-bold text-slate-700"><span>{value.start.replaceAll('-', '/')}</span><ArrowRight size={13} className="shrink-0 text-slate-300" /><span>{value.end.replaceAll('-', '/')}</span></span>
</span>
<span className="hidden rounded-lg bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-500 sm:block">{dayCount(value.start, value.end)} 天</span>
<ChevronDown size={16} className="shrink-0 text-slate-400" />
</button>
<dialog ref={dialog} aria-labelledby={titleId} onClick={e => { if (e.target === e.currentTarget) dialog.current?.close(); }}
className="fixed inset-0 m-auto max-h-[90dvh] w-[calc(100%-24px)] max-w-[680px] overflow-y-auto rounded-3xl border-0 bg-white p-0 text-slate-700 shadow-2xl backdrop:bg-slate-950/30 backdrop:backdrop-blur-sm">
<div className="p-5 sm:p-6">
<div className="flex items-start justify-between gap-3">
<div><h3 id={titleId} className="text-base font-bold text-slate-900">选择统计日期</h3><p className="mt-1 text-xs text-slate-400">先选开始日期,再选结束日期</p></div>
<button type="button" aria-label="关闭日期选择" onClick={() => dialog.current?.close()} className="rounded-full p-2 text-slate-400 hover:bg-slate-100"><X size={18} /></button>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{quickPicks.map(pick => <button key={pick.label} type="button" onClick={() => { setDraft(pick.range); setMonth(parseDate(pick.range.start)); setSelecting('start'); setHovered(''); }}
className={`rounded-lg border px-3 py-2 text-xs font-medium transition ${draft.start === pick.range.start && draft.end === pick.range.end ? 'border-blue-200 bg-blue-50 text-blue-600' : 'border-slate-100 text-slate-500 hover:border-blue-200 hover:bg-blue-50'}`}>{pick.label}</button>)}
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
{(['start', 'end'] as const).map(field => <button key={field} type="button" onClick={() => { setSelecting(field); setHovered(''); setMonth(parseDate(draft[field] || draft.start)); }}
className={`rounded-xl border px-3 py-2.5 text-left transition ${selecting === field ? 'border-blue-400 bg-blue-50/60 ring-2 ring-blue-50' : 'border-slate-200 bg-slate-50/50'}`}>
<span className="block text-[10px] text-slate-400">{field === 'start' ? '开始日期' : '结束日期'}</span><span className="mt-1 block text-sm font-bold">{draft[field] || '请选择结束日期'}</span>
</button>)}
</div>
<div className="mt-5 flex items-center justify-between gap-2">
<button type="button" aria-label="上个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronLeft size={18} /></button>
<div className="flex gap-2">
<select aria-label="年份" value={monthStart.getFullYear()} onChange={e => setMonth(new Date(Number(e.target.value), monthStart.getMonth(), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
{Array.from({ length: Math.max(new Date().getFullYear() + 1, monthStart.getFullYear()) - Math.min(2016, monthStart.getFullYear()) + 1 }, (_, i) => Math.min(2016, monthStart.getFullYear()) + i).map(year => <option key={year} value={year}>{year} 年</option>)}
</select>
<select aria-label="月份" value={monthStart.getMonth()} onChange={e => setMonth(new Date(monthStart.getFullYear(), Number(e.target.value), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
{Array.from({ length: 12 }, (_, i) => <option key={i} value={i}>{i + 1} 月</option>)}
</select>
</div>
<button type="button" aria-label="下个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronRight size={18} /></button>
</div>
<div className="mt-3 grid gap-6 sm:grid-cols-2" onMouseLeave={() => setHovered('')}>{calendar(0)}{calendar(1)}</div>
</div>
<div className="sticky bottom-0 flex items-center justify-between gap-3 border-t border-slate-100 bg-white px-5 py-4 sm:px-6">
<span role="status" className={`text-xs ${count > maxDays ? 'text-red-500' : 'text-slate-400'}`}>{!draft.end ? '请选择结束日期' : count > maxDays ? `最多选择 ${maxDays} 天` : `已选 ${count} 天(包含首尾)`}</span>
<div className="flex shrink-0 gap-2">
<button type="button" onClick={() => dialog.current?.close()} className="rounded-xl px-3 py-2.5 text-xs font-semibold text-slate-500 hover:bg-slate-100">取消</button>
<button type="button" disabled={!valid} onClick={() => { onChange(draft); dialog.current?.close(); }} className="flex items-center gap-1 rounded-xl bg-blue-600 px-4 py-2.5 text-xs font-semibold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40"><Check size={14} />应用日期</button>
</div>
</div>
</dialog>
</>;
}
+8 -10
View File
@@ -8,17 +8,15 @@ 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}
inventoryRate={props.inventoryRate}
pendingRate={props.pendingRate}
setVehicleSelection={props.setVehicleSelection}
/>
<SummaryMetrics
summary={props.summary}
operatingRate={props.operatingRate}
inventoryRate={props.inventoryRate}
pendingRate={props.pendingRate}
setVehicleSelection={props.setVehicleSelection}
/>
<AssetHistorySection subject={props.selectedSubject} onSubjects={props.onHistorySubjects} />
</div>
<AssetHistorySection subject={props.selectedSubject} onSubjects={props.onHistorySubjects} />
<FlowStatisticsCard
flowRange={props.flowRange}
@@ -1,18 +1,118 @@
import { DateRangePicker } from '../../../../components/ui/DateRangePicker';
import { useRef, useState } from 'react';
import { ArrowRight, CalendarDays, Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react';
import { getRecentFlowRange, getWeeklyFlowRange, type DateRange } from '../../model';
const weekdays = ['一', '二', '三', '四', '五', '六', '日'];
const ymd = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const parseDate = (value: string) => new Date(`${value}T12:00:00`);
const shiftDay = (date: Date, offset: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + offset);
const dayCount = (start: string, end: string) => Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86400000) + 1;
export function FlowDateRangePicker({ value, onChange, dateLabel = '运维签章时间' }: { value: DateRange; onChange: (range: DateRange) => void; dateLabel?: string }) {
const dialog = useRef<HTMLDialogElement>(null);
const [draft, setDraft] = useState(value);
const [selecting, setSelecting] = useState<'start' | 'end'>('start');
const [month, setMonth] = useState(() => parseDate(value.start));
const [hovered, setHovered] = useState('');
const today = ymd(new Date());
return <DateRangePicker value={value} onChange={onChange}
label={`统计日期 · ${dateLabel}`} testId="asset-flow-date-range" className="mt-3" maxDays={370}
quickPicks={[
{ label: '今天', range: { start: today, end: today } },
{ label: '昨天', range: { start: ymd(shiftDay(new Date(), -1)), end: ymd(shiftDay(new Date(), -1)) } },
{ label: '业务周', range: getWeeklyFlowRange() },
{ label: '近 7 天', range: getRecentFlowRange() },
{ label: '近 30 天', range: { start: ymd(shiftDay(new Date(), -29)), end: today } },
]} />;
const count = draft.end ? dayCount(draft.start, draft.end) : 0;
const valid = count > 0 && count <= 370;
const monthStart = new Date(month.getFullYear(), month.getMonth(), 1);
const previewEnd = draft.end || hovered || draft.start;
const rangeStart = draft.start < previewEnd ? draft.start : previewEnd;
const rangeEnd = draft.start > previewEnd ? draft.start : previewEnd;
const quickPicks = [
{ label: '今天', range: { start: today, end: today } },
{ label: '昨天', range: { start: ymd(shiftDay(new Date(), -1)), end: ymd(shiftDay(new Date(), -1)) } },
{ label: '业务周', range: getWeeklyFlowRange() },
{ label: '近 7 天', range: getRecentFlowRange() },
{ label: '近 30 天', range: { start: ymd(shiftDay(new Date(), -29)), end: today } },
];
const open = () => {
setDraft(value); setSelecting('start'); setMonth(parseDate(value.start)); setHovered('');
dialog.current?.showModal();
};
const choose = (date: string) => {
if (selecting === 'start') {
setDraft({ start: date, end: '' }); setSelecting('end');
} else {
setDraft(date < draft.start ? { start: date, end: draft.start } : { start: draft.start, end: date });
setSelecting('start');
}
setHovered('');
};
const calendar = (offset: number) => {
const current = new Date(monthStart.getFullYear(), monthStart.getMonth() + offset, 1);
const year = current.getFullYear(), m = current.getMonth();
const padding = (current.getDay() + 6) % 7;
const length = new Date(year, m + 1, 0).getDate();
return <div className={offset ? 'hidden sm:block' : ''} key={offset}>
<div className="mb-3 text-center text-sm font-bold text-slate-700">{year} 年 {m + 1} 月</div>
<div className="grid grid-cols-7 text-center">
{weekdays.map(day => <div key={day} className="py-2 text-[11px] font-medium text-slate-400">{day}</div>)}
{Array.from({ length: padding }, (_, i) => <div key={`blank-${i}`} />)}
{Array.from({ length }, (_, i) => {
const date = ymd(new Date(year, m, i + 1));
const endpoint = date === draft.start || date === draft.end;
const inRange = date >= rangeStart && date <= rangeEnd;
return <button key={date} type="button" aria-label={date} aria-pressed={endpoint}
onClick={() => choose(date)} onMouseEnter={() => { if (!draft.end) setHovered(date); }}
className={`relative my-0.5 h-10 text-xs font-semibold transition focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-blue-500 ${endpoint ? 'rounded-xl bg-blue-600 text-white shadow-sm' : inRange ? 'bg-blue-50 text-blue-700' : 'rounded-xl text-slate-600 hover:bg-slate-100'} ${date === today && !endpoint ? 'font-black text-blue-600' : ''}`}>
{i + 1}{date === today && <span className={`absolute bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full ${endpoint ? 'bg-white' : 'bg-blue-500'}`} />}
</button>;
})}
</div>
</div>;
};
return <>
<button type="button" data-testid="asset-flow-date-range" aria-haspopup="dialog" onClick={open}
className="mt-3 flex w-full items-center gap-3 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-left shadow-sm transition hover:border-blue-300 hover:bg-blue-50/40 focus-visible:outline-2 focus-visible:outline-blue-500">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600"><CalendarDays size={18} /></span>
<span className="min-w-0 flex-1">
<span className="block text-[10px] font-medium text-slate-400">统计日期 · {dateLabel}</span>
<span className="mt-1 flex items-center gap-2 text-[13px] font-bold text-slate-700"><span>{value.start.replaceAll('-', '/')}</span><ArrowRight size={13} className="shrink-0 text-slate-300" /><span>{value.end.replaceAll('-', '/')}</span></span>
</span>
<span className="hidden rounded-lg bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-500 sm:block">{dayCount(value.start, value.end)} 天</span>
<ChevronDown size={16} className="shrink-0 text-slate-400" />
</button>
<dialog ref={dialog} aria-labelledby="flow-date-title" onClick={e => { if (e.target === e.currentTarget) dialog.current?.close(); }}
className="fixed inset-0 m-auto max-h-[90dvh] w-[calc(100%-24px)] max-w-[680px] overflow-y-auto rounded-3xl border-0 bg-white p-0 text-slate-700 shadow-2xl backdrop:bg-slate-950/30 backdrop:backdrop-blur-sm">
<div className="p-5 sm:p-6">
<div className="flex items-start justify-between gap-3">
<div><h3 id="flow-date-title" className="text-base font-bold text-slate-900">选择统计日期</h3><p className="mt-1 text-xs text-slate-400">先选开始日期,再选结束日期</p></div>
<button type="button" aria-label="关闭日期选择" onClick={() => dialog.current?.close()} className="rounded-full p-2 text-slate-400 hover:bg-slate-100"><X size={18} /></button>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{quickPicks.map(pick => <button key={pick.label} type="button" onClick={() => { setDraft(pick.range); setMonth(parseDate(pick.range.start)); setSelecting('start'); setHovered(''); }}
className={`rounded-lg border px-3 py-2 text-xs font-medium transition ${draft.start === pick.range.start && draft.end === pick.range.end ? 'border-blue-200 bg-blue-50 text-blue-600' : 'border-slate-100 text-slate-500 hover:border-blue-200 hover:bg-blue-50'}`}>{pick.label}</button>)}
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
{(['start', 'end'] as const).map(field => <button key={field} type="button" onClick={() => { setSelecting(field); setHovered(''); setMonth(parseDate(draft[field] || draft.start)); }}
className={`rounded-xl border px-3 py-2.5 text-left transition ${selecting === field ? 'border-blue-400 bg-blue-50/60 ring-2 ring-blue-50' : 'border-slate-200 bg-slate-50/50'}`}>
<span className="block text-[10px] text-slate-400">{field === 'start' ? '开始日期' : '结束日期'}</span><span className="mt-1 block text-sm font-bold">{draft[field] || '请选择结束日期'}</span>
</button>)}
</div>
<div className="mt-5 flex items-center justify-between gap-2">
<button type="button" aria-label="上个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronLeft size={18} /></button>
<div className="flex gap-2">
<select aria-label="年份" value={monthStart.getFullYear()} onChange={e => setMonth(new Date(Number(e.target.value), monthStart.getMonth(), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
{Array.from({ length: Math.max(new Date().getFullYear() + 1, monthStart.getFullYear()) - Math.min(2016, monthStart.getFullYear()) + 1 }, (_, i) => Math.min(2016, monthStart.getFullYear()) + i).map(year => <option key={year} value={year}>{year} 年</option>)}
</select>
<select aria-label="月份" value={monthStart.getMonth()} onChange={e => setMonth(new Date(monthStart.getFullYear(), Number(e.target.value), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
{Array.from({ length: 12 }, (_, i) => <option key={i} value={i}>{i + 1} 月</option>)}
</select>
</div>
<button type="button" aria-label="下个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronRight size={18} /></button>
</div>
<div className="mt-3 grid gap-6 sm:grid-cols-2" onMouseLeave={() => setHovered('')}>{calendar(0)}{calendar(1)}</div>
</div>
<div className="sticky bottom-0 flex items-center justify-between gap-3 border-t border-slate-100 bg-white px-5 py-4 sm:px-6">
<span role="status" className={`text-xs ${count > 370 ? 'text-red-500' : 'text-slate-400'}`}>{!draft.end ? '请选择结束日期' : count > 370 ? '最多选择 370 天' : `已选 ${count} 天(包含首尾)`}</span>
<div className="flex shrink-0 gap-2">
<button type="button" onClick={() => dialog.current?.close()} className="rounded-xl px-3 py-2.5 text-xs font-semibold text-slate-500 hover:bg-slate-100">取消</button>
<button type="button" disabled={!valid} onClick={() => { onChange(draft); dialog.current?.close(); }} className="flex items-center gap-1 rounded-xl bg-blue-600 px-4 py-2.5 text-xs font-semibold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40"><Check size={14} />应用日期</button>
</div>
</div>
</dialog>
</>;
}
@@ -10,7 +10,7 @@ export function SummaryMetrics({
return (
<>
{/* Header Summary - Ultra Compact */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 mb-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="rounded-xl border border-slate-100 bg-white/85 px-3 py-1 shadow-sm">
<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 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">
@@ -43,7 +43,7 @@ export function FaultAnalysisModal({ metric, onClose, source }: { metric: FaultO
}, [metric, revision, source?.url]);
const records = useMemo(() => data?.rows || [], [data]);
const handlerLabels = useMemo(() => new Map(records.flatMap(row => row.handlers.map(h => [h.value, h.label] as const))), [records]);
const label = (d: FaultAnalysisDimension, value: string) => d === 'plate' ? value : d === 'faultStatus' ? FAULT_CLOSURE_LABELS[value] || value : d === 'handler' ? handlerLabels.get(value) || '未指派' : d === 'level' ? value || '未标注' : d === 'customer' ? data?.customerLabels[value] || '未关联客户' : getDimensionValueLabel(d, value);
const label = (d: FaultAnalysisDimension, value: string) => d === 'plate' ? value : d === 'faultStatus' ? FAULT_CLOSURE_LABELS[value] || value : d === 'handler' ? handlerLabels.get(value) || '未指派' : d === 'customer' ? data?.customerLabels[value] || '未关联客户' : getDimensionValueLabel(d, value);
const options = available.map(key => {
const otherFilters = { ...filters }; delete otherFilters[key];
const counts = new Map<string, number>();
@@ -78,7 +78,7 @@ export function FaultAnalysisModal({ metric, onClose, source }: { metric: FaultO
<div className="analysis-scroll">
{error ? <div className="fault-message" role="alert">{error}<button onClick={() => setRevision(v => v + 1)}>重试</button></div> : !data ? <FaultAnalysisLoading /> : group ? <>
<div className="analysis-table-tools"><button className="analysis-button" onClick={() => { setGroup(null); setLimit(50); }}><ArrowLeft size={14} />返回分组</button><span>{groupLabel(group)} · {details.length.toLocaleString()} 单</span><label className="fault-detail-search"><Search size={14} /><input aria-label="搜索故障明细" placeholder="搜索单号、车牌、描述、处置人" value={search} onChange={e => { setSearch(e.target.value); setLimit(50); }} /></label></div>
<div className="analysis-group-table is-merged fault-detail-table"><table><thead><tr>{['车牌 / 故障单号', '故障程度', '运维处置人', '故障描述', '状态', '提报时间', '解决时间', '运营城市', '客户 / 部门'].map(h => <th key={h}>{h}</th>)}</tr></thead><tbody>{details.slice(0, limit).map(row => <tr key={row.id}><td><strong>{row.plate || '未关联车辆'}</strong><small>{row.code || '未编号'}</small></td><td>{row.level || '未标注'}</td><td>{row.handlers.map(h => h.label).join('、') || '未指派'}</td><td>{row.description || '—'}</td><td>{row.status}</td><td>{row.reportedAt || '—'}</td><td>{row.archivedAt || '—'}</td><td>{row.city}</td><td>{row.customerLabel}<small>{row.department}</small></td></tr>)}</tbody></table></div>
<div className="analysis-group-table is-merged fault-detail-table"><table><thead><tr>{['车牌 / 故障单号', '运维处置人', '故障描述', '状态', '提报时间', '解决时间', '运营城市', '客户 / 部门'].map(h => <th key={h}>{h}</th>)}</tr></thead><tbody>{details.slice(0, limit).map(row => <tr key={row.id}><td><strong>{row.plate || '未关联车辆'}</strong><small>{row.code || '未编号'}</small></td><td>{row.handlers.map(h => h.label).join('、') || '未指派'}</td><td>{row.description || '—'}</td><td>{row.status}</td><td>{row.reportedAt || '—'}</td><td>{row.archivedAt || '—'}</td><td>{row.city}</td><td>{row.customerLabel}<small>{row.department}</small></td></tr>)}</tbody></table></div>
{!details.length && <p className="fault-empty">没有符合条件的故障明细</p>}
{details.length > limit && <button className="analysis-button fault-more" onClick={() => setLimit(v => v + 50)}>加载更多明细(已显示 {limit} / {details.length})</button>}
</> : <>
@@ -50,7 +50,7 @@ export default function FaultManagement(){
</section>
<div className="fd-today-stats"><span>实时统计</span><button onClick={()=>drill('今日新增','reported')}>今日新增 <b>{t?.todayReported}</b></button><button onClick={()=>drill('本日解决','closed')}>本日解决 <b>{t?.todayClosed}</b></button></div>
<div className="fd-insight"><Info size={18}/><strong>{data.distributions.region[0]?`本期关注:${data.distributions.region[0].label}故障量最高,共 ${data.distributions.region[0].reported} 单;当前 ${t?.overdue} 单在办故障已逾期。`:'所选条件下暂无本期故障,可调整日期或筛选范围。'}</strong><span>按数量描述,不代表故障发生率。</span><button disabled={!t?.open} onClick={showAllOpen}>查看关联故障 <ArrowUpRight size={13}/></button></div>
{tab==='多维分析'&&<div className="fd-analysis-banner"><div><h2>组合维度,追溯故障</h2><p>支持运维处置人、车牌、故障程度、工单状态、车型、区域、客户等 13 个维度,多选筛选与明细下钻。</p></div><button className="fd-query" onClick={()=>drill('本期故障多维分析')}>打开多维分析</button></div>}
{tab==='多维分析'&&<div className="fd-analysis-banner"><div><h2>组合维度,追溯故障</h2><p>支持运维处置人、车牌、工单状态、车型、区域、客户等 12 个维度,多选筛选与明细下钻。</p></div><button className="fd-query" onClick={()=>drill('本期故障多维分析')}>打开多维分析</button></div>}
<DashboardPanels data={data} drill={drill}/>
<section className="fd-panel fd-focus" aria-label="重点故障跟进">
<div className="fd-panel-title">
+3 -4
View File
@@ -2,10 +2,10 @@ import type { AssetAnalysisDimension } from '../../../shared/assets/analytics';
import type { FaultAnalysisRecord } from '../../../shared/assets/faults';
import { DIMENSION_LABELS, getAvailableDimensions } from '../analytics-model';
export type FaultAnalysisDimension = AssetAnalysisDimension | 'handler' | 'plate' | 'faultStatus' | 'level';
export type FaultAnalysisDimension = AssetAnalysisDimension | 'handler' | 'plate' | 'faultStatus';
export type FaultAnalysisFilters = Partial<Record<FaultAnalysisDimension, string[]>>;
export const FAULT_ANALYSIS_DIMENSIONS: FaultAnalysisDimension[] = getAvailableDimensions('assets').flatMap(d => d === 'type' ? [d, 'handler', 'plate', 'faultStatus', 'level'] : [d]);
export const FAULT_ANALYSIS_LABELS = { ...DIMENSION_LABELS, handler: '运维处置人', plate: '车牌', faultStatus: '故障工单状态', level: '故障程度' };
export const FAULT_ANALYSIS_DIMENSIONS: FaultAnalysisDimension[] = getAvailableDimensions('assets').flatMap(d => d === 'type' ? [d, 'handler', 'plate', 'faultStatus'] : [d]);
export const FAULT_ANALYSIS_LABELS = { ...DIMENSION_LABELS, handler: '运维处置人', plate: '车牌', faultStatus: '故障工单状态' };
export const FAULT_CLOSURE_LABELS: Record<string, string> = { closed: '已闭环', unclosed: '未闭环' };
export interface FaultAnalysisGroup {
key: string; values: Partial<Record<FaultAnalysisDimension, string>>;
@@ -14,7 +14,6 @@ export interface FaultAnalysisGroup {
export function faultDimensionValues(row: FaultAnalysisRecord, dimension: FaultAnalysisDimension): string[] {
if (dimension === 'plate') return [row.plate?.trim() || '未关联车辆'];
if (dimension === 'faultStatus') return [row.status === '已归档' ? 'closed' : 'unclosed'];
if (dimension === 'level') return [row.level?.trim() || '未标注'];
return dimension === 'handler' ? [...new Set(row.handlers.length ? row.handlers.map(h => h.value) : ['unassigned'])] : [row.cell[dimension]];
}
export function filterFaultRecords(rows: FaultAnalysisRecord[], filters: FaultAnalysisFilters = {}): FaultAnalysisRecord[] {
@@ -69,17 +69,18 @@ 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 className="asset-history-cutoff" title="每月最后一天北京时间17:00采集;年度复用12月快照">月末 17:00</span>
<span title="每天北京时间23:55采集;月度取月末、年度取年末快照">每日 23:55</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: 18, right: 10, 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: 10, right: 14, 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} tickCount={4} width={36} tick={{ fontSize: 11, fill: '#64748b' }} axisLine={false} tickLine={false} />
<YAxis allowDecimals={false} width={44} 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);
@@ -91,19 +92,16 @@ 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><span className="asset-history-unit">台</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></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></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>{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>
{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> : 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></div> : <p>{point.comparisonLabel}</p>}
</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} />}
+61 -52
View File
@@ -1,43 +1,41 @@
.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 { --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 button:disabled,.asset-history-dialog button:disabled { cursor:default; opacity:.45; }
.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 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-toolbar>span { margin-left:auto; }
.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-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-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: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-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-tooltip p { margin-top:4px; }
.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 { 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 .operating { background:#347df5; }.asset-history-legend .inventory { background:#9ed1fa; }
.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-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-selected-heading { display:flex; flex-direction:column; align-items:flex-start; gap:3px; min-width:0; }
.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-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-counts { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); }
.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-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-increase { color:#bf2f30; }.asset-history-decrease { color:#14864a; }
.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-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-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; }
@@ -60,12 +58,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: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 { 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-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; }
@@ -81,18 +79,31 @@
}
@media(min-width:900px) {
.asset-history-controls { display:flex; align-items:center; gap:24px; flex-wrap:wrap; }
.asset-history-controls { display:flex; align-items:center; flex-wrap:wrap; gap:8px 24px; }
.asset-history-header { flex:1; gap:16px; }
.asset-history-toolbar { margin:0; }
.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; }
.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; }
}
@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: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: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: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-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>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; }
}
.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[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); }
@@ -104,19 +115,17 @@
.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 { flex-shrink:0; }
.asset-history-detail-button>svg { padding:3px; width:24px; height:24px; border-radius:7px; background:#eaf2ff; color:#527eb7; }
@media(hover:hover) {
.asset-history-modes button:not([aria-pressed=true]):hover::before { background:#e5edf9; }
.asset-history-modes button:not([aria-pressed=true]):hover { color:#1559e8; background:#e5edf9; }
.asset-history-select:hover { border-color:#adc5e8; }
.asset-history-refresh:hover { color:#2563eb; background:#eef5ff; }
.asset-history-detail-button:hover { color:#103eaa; background:#e9f1ff; }
.asset-history-detail-button:hover { background:#eef5ff; border-color:#bcd3f3; }
}
@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-modes button::before,.asset-history-select,.asset-history-select-icon,.asset-history-refresh,.asset-history-detail-button { transition:none; }
.asset-history-modes button,.asset-history-select,.asset-history-select-icon,.asset-history-refresh { transition:none; }
}
.asset-history :is(.asset-history-select,.asset-history-refresh,.asset-history-detail-button):active { background:#e6effe; }
+36 -89
View File
@@ -1,12 +1,11 @@
import { useEffect, useMemo, useState } from 'react';
import { Check, ChevronRight, Fuel, Layers, Plug, Search, TrendingUp, Truck, X } from 'lucide-react';
import { ChevronRight, Fuel, Plug, TrendingUp, Truck } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
import TrendBadge from './TrendBadge';
import { fetchHydrogenDaily } from './api';
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
import {
filterHydrogenRows,
getQuickRange,
getRangeModeLabel,
normalizeRange,
@@ -14,14 +13,12 @@ import {
type RangeMode,
} from './hydrogen-daily/model';
import DailyRangeControls from './daily-range/DailyRangeControls';
import { DateRangePicker } from '../../components/ui/DateRangePicker';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
export default function HydrogenDaily() {
const [pick, setPick] = useState<RangeMode>('last15');
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
const [customer, setCustomer] = useState<CustomerType>('all');
const [stationSearch, setStationSearch] = useState('');
const [customer, setCustomer] = useState<CustomerType>('lingniu');
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -31,7 +28,6 @@ export default function HydrogenDaily() {
useEffect(() => {
let cancelled = false;
setError(null);
setRows(null);
const query = pick === 'custom'
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
: { range: pick };
@@ -41,8 +37,6 @@ export default function HydrogenDaily() {
return () => { cancelled = true; };
}, [pick, customer, effectiveRange.start, effectiveRange.end]);
const filteredRows = useMemo(() => filterHydrogenRows(rows, stationSearch), [rows, stationSearch]);
const {
trendData,
totalKg,
@@ -52,7 +46,7 @@ export default function HydrogenDaily() {
peakDay,
lowDay,
zeroDays,
} = useMemo(() => summarizeHydrogenRows(filteredRows), [filteredRows]);
} = useMemo(() => summarizeHydrogenRows(rows), [rows]);
const scopeLabel = getRangeModeLabel(pick);
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
@@ -83,87 +77,38 @@ export default function HydrogenDaily() {
onCustomPick={() => setPick('custom')}
onDateRangeChange={updateDateRange}
onCustomerChange={setCustomer}
dateControl={
<DateRangePicker
value={effectiveRange}
label="加氢日期"
testId="hydrogen-date-range"
quickPicks={([
{ id: 'thisWeek', label: '本周' },
{ id: 'thisMonth', label: '本月' },
{ id: 'last15', label: '近 15 天' },
] as const).map(option => ({ label: option.label, range: getQuickRange(option.id) }))}
onChange={range => {
setDateRange(range);
const matching = (['thisWeek', 'thisMonth', 'last15'] as const).find(option => {
const preset = getQuickRange(option);
return preset.start === range.start && preset.end === range.end;
});
setPick(matching ?? 'custom');
}}
/>
}
>
<div className="mt-3 space-y-3">
<fieldset>
<legend className="mb-2 px-1 text-[11px] font-semibold text-slate-500">车辆归属</legend>
<div className="grid grid-cols-3 gap-2">
{([
{ value: 'all', label: '全部车辆', description: '内外部汇总', icon: Layers },
{ value: 'lingniu', label: '内部车辆', description: '羚牛车辆', icon: Truck },
{ value: 'external', label: '外部车辆', description: '佛山站外部订单', icon: Truck },
] as const).map(option => {
const Icon = option.icon;
return (
<label key={option.value} className="relative min-w-0 cursor-pointer">
<input type="radio" name="hydrogen-customer" aria-label={option.label} value={option.value} checked={customer === option.value}
onChange={() => setCustomer(option.value)} className="peer sr-only" />
<span className="flex h-full min-h-[94px] flex-col gap-2 rounded-2xl border border-slate-200 bg-white p-3 transition hover:border-blue-300 hover:bg-blue-50/40 peer-checked:border-blue-400 peer-checked:bg-blue-50/70 peer-checked:shadow-sm peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-blue-500">
<span className="flex items-center justify-between">
<Icon size={17} aria-hidden="true" className={customer === option.value ? 'text-blue-600' : 'text-slate-400'} />
<span aria-hidden="true" className={`flex h-4 w-4 items-center justify-center rounded-full ${customer === option.value ? 'bg-blue-600 text-white' : 'border border-slate-200 bg-white'}`}>
{customer === option.value && <Check size={11} strokeWidth={3} />}
</span>
</span>
<span className={`text-[12px] font-bold ${customer === option.value ? 'text-blue-700' : 'text-slate-700'}`}>{option.label}</span>
<span className="text-[10px] leading-tight text-slate-500">{option.description}</span>
</span>
</label>
);
})}
</div>
</fieldset>
<div className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2 focus-within:ring-2 focus-within:ring-blue-200">
<label htmlFor="hydrogen-station-search" className="block text-[10px] font-black text-slate-400">搜索加氢站</label>
<div className="mt-1 flex h-7 items-center gap-2">
<Search size={15} className="shrink-0 text-slate-400" aria-hidden="true" />
<input
id="hydrogen-station-search"
type="search"
value={stationSearch}
onChange={event => setStationSearch(event.target.value)}
placeholder="输入加氢站名称,例如:佛山"
className="min-w-0 flex-1 bg-transparent text-[12px] font-bold text-slate-800 outline-none placeholder:font-normal placeholder:text-slate-400 [&::-webkit-search-cancel-button]:hidden"
/>
{stationSearch && (
<button type="button" aria-label="清除加氢站搜索" onClick={() => setStationSearch('')} className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-slate-400 hover:bg-slate-200 hover:text-slate-700">
<X size={14} />
</button>
)}
</div>
</div>
</div>
</DailyRangeControls>
/>
<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={{ all: '全部', lingniu: '内部', external: '外部' }[customer]} helper={customer === 'external' ? '佛山南海羚牛加氢站 · 未关联车辆的订单' : '当前筛选口径'} tone="emerald" />
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" 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={stationSearch.trim() ? `搜索:${stationSearch.trim()}` : '按明细站点去重'} tone="slate" />
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" />
</div>
{/* 时段加氢量柱图 */}
{!error && trendData.length > 0 && totalKg > 0 && (
{/* 外部车辆:新系统数据还没准备好 */}
{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 && (
<SurfaceCard>
<div className="flex items-center justify-between px-4 pt-4 mb-2">
<span className="text-sm font-bold text-slate-700">每日加氢量</span>
@@ -239,7 +184,8 @@ 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">
@@ -258,11 +204,11 @@ export default function HydrogenDaily() {
{/* 主行 + 子行 */}
{error ? (
<div className="p-3"><ErrorState message={error} /></div>
) : filteredRows === null ? (
) : rows === null ? (
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
) : filteredRows.length === 0 || totalKg === 0 ? (
<div className="p-3"><EmptyState title="所选时段暂无加氢数据" description={stationSearch.trim() ? '当前时段和车辆归属下没有匹配的加氢站数据,请调整关键词或清除搜索' : customer === 'external' ? '佛山南海羚牛加氢站在该时段没有未关联车辆的加氢订单,请切换时间范围' : '请切换时间范围或车辆归属'} /></div>
) : filteredRows.map(r => {
) : rows.length === 0 ? (
<div className="p-3"><EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" /></div>
) : rows.map(r => {
const open = expanded.has(r.date);
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
const abnormalBg = isAbnormal
@@ -324,6 +270,7 @@ export default function HydrogenDaily() {
);
})}
</div>
)}
</div>
);
@@ -1,5 +1,4 @@
import { Truck } from 'lucide-react';
import type { ReactNode } from 'react';
import { SurfaceCard } from '../../../components/ui/surface';
import type { CustomerType, DateQuickPick } from '../types';
import { QUICK_PICK_OPTIONS, type RangeMode } from './model';
@@ -12,8 +11,6 @@ interface DailyRangeControlsProps {
onCustomPick: () => void;
onDateRangeChange: (field: 'start' | 'end', value: string) => void;
onCustomerChange: (customer: CustomerType) => void;
children?: ReactNode;
dateControl?: ReactNode;
}
export default function DailyRangeControls({
@@ -24,12 +21,9 @@ export default function DailyRangeControls({
onCustomPick,
onDateRangeChange,
onCustomerChange,
children,
dateControl,
}: DailyRangeControlsProps) {
return (
<SurfaceCard className="p-2 md:p-3">
{dateControl ?? <>
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{QUICK_PICK_OPTIONS.map(opt => (
<button
@@ -79,9 +73,7 @@ export default function DailyRangeControls({
</label>
</div>
</>}
{children ?? <div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
{(['lingniu', 'external'] as const).map(option => (
<button
key={option}
@@ -94,7 +86,7 @@ export default function DailyRangeControls({
{option === 'external' ? '外部车辆' : '羚牛车辆'}
</button>
))}
</div>}
</div>
</SurfaceCard>
);
}
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import type { HydrogenDailyRow } from '../types.js';
import {
filterHydrogenRows,
getQuickRange,
getRangeModeLabel,
normalizeRange,
@@ -83,53 +82,3 @@ test('空数据保持零值且没有峰谷日', () => {
zeroDays: 0,
});
});
const searchRows: HydrogenDailyRow[] = [
{ date: '2026-09-17', totalKg: 50, chainPct: -0.75, customerType: 'all', stations: [
{ name: '嘉兴站', kg: 50, pricePerKg: 30, chainPct: 0 },
] },
{ date: '2026-09-16', totalKg: 200, chainPct: 1, customerType: 'all', stations: [
{ name: '佛山南海羚牛加氢站', kg: 30, pricePerKg: 38, chainPct: 2 },
{ name: '佛山豪汇石油加氢站', kg: 10, pricePerKg: 35, chainPct: 0 },
{ name: '嘉兴站', kg: 160, pricePerKg: 30, chainPct: 1 },
] },
{ date: '2026-09-15', totalKg: 100, chainPct: 0, customerType: 'all', stations: [
{ name: '佛山南海羚牛加氢站', kg: 10, pricePerKg: 38, chainPct: 0 },
{ name: '嘉兴站', kg: 90, pricePerKg: 30, chainPct: 0 },
] },
];
test('站名搜索同步更新日明细、总量、趋势和环比,保留完整日期及原始数据', () => {
const before = structuredClone(searchRows);
const filtered = filterHydrogenRows(searchRows, ' 佛山 ')!;
assert.deepEqual(filtered.map(row => row.date), searchRows.map(row => row.date));
assert.deepEqual(filtered.map(row => row.totalKg), [0, 40, 10]);
assert.deepEqual(filtered.map(row => row.chainPct), [-1, 3, 0]);
assert.equal(filtered[1].stations.length, 2);
assert.equal(filtered[1].stations[0].pricePerKg, 38);
assert.equal(filtered[1].stations[0].chainPct, 2);
const summary = summarizeHydrogenRows(filtered);
assert.equal(summary.totalKg, 50);
assert.equal(summary.stationCount, 2);
assert.equal(summary.activeDays, 2);
assert.equal(summary.avgKg, 25);
assert.equal(summary.zeroDays, 1);
assert.deepEqual(summary.trendData.map(row => row.totalKg), [10, 40, 0]);
assert.deepEqual(searchRows, before);
});
test('无匹配关键词显示零结果,清除或空白搜索恢复全量,加载中保持加载状态', () => {
const missing = filterHydrogenRows(searchRows, '不存在的站点')!;
assert.equal(missing.length, 3);
assert.ok(missing.every(row => row.totalKg === 0 && row.chainPct === 0 && row.stations.length === 0));
assert.strictEqual(filterHydrogenRows(searchRows, ''), searchRows);
assert.strictEqual(filterHydrogenRows(searchRows, ' '), searchRows);
assert.equal(summarizeHydrogenRows(filterHydrogenRows(searchRows, '')).totalKg, 350);
assert.equal(filterHydrogenRows(null, '佛山'), null);
assert.deepEqual(filterHydrogenRows([], '佛山'), []);
});
test('完整站名可定位单站,搜索将特殊字符按普通文字匹配', () => {
assert.equal(summarizeHydrogenRows(filterHydrogenRows(searchRows, '佛山南海羚牛加氢站')).totalKg, 40);
assert.equal(summarizeHydrogenRows(filterHydrogenRows(searchRows, '%')).totalKg, 0);
});
@@ -9,28 +9,6 @@ export {
type RangeMode,
} from '../daily-range/model';
// 接口已返回所选车辆范围内的全部日/站点数据;搜索后统一重算日合计与环比。
export function filterHydrogenRows(rows: HydrogenDailyRow[] | null, stationSearch: string): HydrogenDailyRow[] | null {
const keyword = stationSearch.trim().toLocaleLowerCase();
if (!rows || !keyword) return rows;
const filtered = rows.map(row => {
const stations = row.stations.filter(station => station.name.toLocaleLowerCase().includes(keyword));
return {
...row,
stations,
totalKg: Math.round(stations.reduce((sum, station) => sum + station.kg, 0) * 100) / 100,
chainPct: 0,
};
});
let previousKg = 0;
for (const row of [...filtered].sort((a, b) => a.date.localeCompare(b.date))) {
row.chainPct = previousKg > 0 ? (row.totalKg - previousKg) / previousKg : 0;
previousKg = row.totalKg;
}
return filtered;
}
export function summarizeHydrogenRows(rows: HydrogenDailyRow[] | null) {
const source = rows ?? [];
// 图表固定按日期升序;复制数组避免改变接口返回及表格原始顺序。
+1 -1
View File
@@ -1,4 +1,4 @@
export type CustomerType = 'external' | 'lingniu' | 'all';
export type CustomerType = 'external' | 'lingniu';
export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15';
export interface HydrogenKpi {
+22 -8
View File
@@ -1,17 +1,17 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { monthCutoff, nextCapture, shanghaiMonth, shanghaiTimestamp } from '../../../shared/assets/history.js';
import { dayCutoff, shiftDay, monthCutoff, nextCapture, shanghaiMonth, shanghaiTimestamp } from '../../../shared/assets/history.js';
import { counts, freezeRows, makePoint, freezeLive } from './model.js';
import { testBatch, testRow, testVehicle } from './fixtures.js';
test('Shanghai month-end cutoff covers leap years, short/long months and cross-year boundaries', () => {
for (const [month, expected] of [['2024-02','2024-02-29'], ['2026-02','2026-02-28'], ['2026-04','2026-04-30'], ['2026-10','2026-10-31'], ['2026-12','2026-12-31']]) {
assert.equal(shanghaiTimestamp(monthCutoff(month)), `${expected}T17:00:00.000+08:00`);
assert.equal(monthCutoff(month).toISOString(), `${expected}T09:00:00.000Z`);
assert.equal(shanghaiTimestamp(monthCutoff(month)), `${expected}T23:55:00.000+08:00`);
assert.equal(monthCutoff(month).toISOString(), `${expected}T15:55:00.000Z`);
}
assert.equal(shanghaiMonth(new Date('2026-12-31T16:00:00Z')), '2027-01');
assert.equal(nextCapture(new Date('2026-12-31T09:00:00Z')), '2027-01-31T17:00:00.000+08:00');
assert.equal(nextCapture(new Date('2026-12-31T15:55:00Z')), '2027-01-01T23:55:00.000+08:00');
assert.throws(() => monthCutoff('2026-13'));
});
test('host TZ cannot change cutoff or Shanghai calendar', () => {
@@ -32,11 +32,25 @@ test('source rows preserve precise IDs and raw states; pending and abnormal do n
});
test('cutoff switches live to capturing/missing without fallback; a zero snapshot is archived', () => {
const live = freezeLive([testVehicle('1')]);
assert.equal(makePoint('2026-10', new Date('2026-10-31T08:59:59Z'), undefined, [], live).state, 'LIVE');
assert.equal(makePoint('2026-10', new Date('2026-10-31T09:00:00Z'), undefined, [], live).state, 'CAPTURING');
const missing = makePoint('2026-10', new Date('2026-10-31T09:01:00Z'), undefined, [], live);
assert.equal(makePoint('2026-10', new Date('2026-10-31T15:54:59Z'), undefined, [], live).state, 'LIVE');
assert.equal(makePoint('2026-10', new Date('2026-10-31T15:55:00Z'), undefined, [], live).state, 'CAPTURING');
const missing = makePoint('2026-10', new Date('2026-10-31T15:56:00Z'), undefined, [], live);
assert.equal(missing.state, 'MISSING'); assert.equal(missing.counts, null);
const archived = makePoint('2026-10', new Date('2026-10-31T09:00:20Z'), testBatch('2026-10'), [], live);
const archived = makePoint('2026-10', new Date('2026-10-31T15:55:20Z'), testBatch('2026-10'), [], live);
assert.equal(archived.state, 'ARCHIVED'); assert.deepEqual(archived.counts, { total:0,operating:0,inventory:0 });
assert.equal(makePoint('2026-11', new Date('2026-10-19T00:00Z')).state, 'FUTURE');
});
test('daily calendar handles leap days, year changes and next capture within a month', () => {
assert.equal(shiftDay('2024-02-28',1),'2024-02-29');
assert.equal(shiftDay('2026-02-28',1),'2026-03-01');
assert.equal(shiftDay('2026-12-31',1),'2027-01-01');
assert.equal(nextCapture(new Date('2026-10-30T23:54:59+08:00')),'2026-10-30T23:55:00.000+08:00');
assert.equal(nextCapture(new Date('2026-10-30T23:55:00+08:00')),'2026-10-31T23:55:00.000+08:00');
assert.throws(()=>dayCutoff('2026-02-29'));
});
test('legacy monthly archive keeps its original scheduled and captured times', () => {
const batch={...testBatch('2026-09'),scheduledFor:'2026-09-30T17:00:00+08:00',capturedAt:'2026-09-30T17:00:02+08:00'};
const point=makePoint('2026-09',new Date('2026-10-30T00:00:00+08:00'),batch,[]);
assert.equal(point.state,'ARCHIVED');assert.equal(point.scheduledFor,batch.scheduledFor);assert.equal(point.asOf,batch.capturedAt);
});
+2 -2
View File
@@ -47,7 +47,7 @@ export function counts(vehicles: Vehicle[]): HistoryCounts {
return { total: vehicles.length, operating: vehicles.filter(isOperatingVehicle).length, inventory: vehicles.filter(isInventoryVehicle).length };
}
export function makePoint(month: string, now: Date, batch?: HistoryBatch, data?: HistoryVehicle[], live?: HistoryVehicle[], annual = false): HistoryPoint {
const cutoff = monthCutoff(month);
const cutoff = batch ? new Date(batch.scheduledFor) : monthCutoff(month);
let state: HistoryPoint['state'] = 'MISSING';
if (cutoff > now) state = month.slice(0, 4) === shanghaiTimestamp(now).slice(0, 4) && (annual || month === shanghaiTimestamp(now).slice(0, 7)) ? 'LIVE' : 'FUTURE';
else if (batch?.status === 'COMPLETE') state = 'ARCHIVED';
@@ -58,7 +58,7 @@ export function makePoint(month: string, now: Date, batch?: HistoryBatch, data?:
return {
period: annual ? month.slice(0, 4) : month,
label: annual ? `${month.slice(0, 4)}年` : `${Number(month.slice(5))}月`, month,
scheduledFor: shanghaiTimestamp(cutoff),
scheduledFor: batch?.scheduledFor || shanghaiTimestamp(cutoff),
asOf: state === 'LIVE' ? shanghaiTimestamp(now) : state === 'ARCHIVED' ? batch!.capturedAt : null,
state, source: state === 'LIVE' ? 'LIVE' : state === 'ARCHIVED' ? batch!.source : null,
batchId: state === 'ARCHIVED' ? batch!.id : null, counts: pointCounts,
@@ -1,42 +1,51 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { HistoryScheduler, type HistoryCaptureStore } from './scheduler.js';
import { testBatch } from './fixtures.js';
function fixture(enabledAt='2026-10-19T13:00:00+08:00') {
function fixture(enabledAt='2026-10-30T13:00:00+08:00') {
const captures:string[]=[],missed:string[]=[],failed:string[]=[];
let activates=0;
const store:HistoryCaptureStore={
activate:async()=>{activates++;return{enabledAt};},batches:async()=>[],
activate:async()=>{activates++;return{enabledAt};},dailyBatches:async()=>[],
capture:async(month)=>{captures.push(month);return'captured';},
recordOutcome:async(month,status)=>{(status==='FAILED'?failed:missed).push(month);},
};
return {store,captures,missed,failed,activates:()=>activates};
}
test('scheduler captures only in month-end 17:00 minute and activation is persisted once',async()=>{
const f=fixture();let date=new Date('2026-10-31T16:59:59+08:00');
test('scheduler captures only in daily 23:55 minute and activation is persisted once',async()=>{
const f=fixture();let date=new Date('2026-10-31T23:54:59+08:00');
const scheduler=new HistoryScheduler(f.store,()=>date,()=>{});
await scheduler.tick();assert.deepEqual(f.captures,[]);
date=new Date('2026-10-31T17:00:00+08:00');await scheduler.tick();assert.deepEqual(f.captures,['2026-10']);assert.equal(f.activates(),1);
date=new Date('2026-10-31T23:55:00+08:00');await scheduler.tick();assert.deepEqual(f.captures,['2026-10-31']);assert.equal(f.activates(),1);
});
test('restart records missed eligible months without backfilling old live data or pre-activation periods',async()=>{
const f=fixture();const scheduler=new HistoryScheduler(f.store,()=>new Date('2027-01-15T08:00:00+08:00'),()=>{});
await scheduler.tick();assert.deepEqual(f.captures,[]);assert.deepEqual(f.missed,['2026-10','2026-11','2026-12']);
const late=fixture('2026-10-31T17:00:10+08:00');await new HistoryScheduler(late.store,()=>new Date('2026-10-31T17:00:20+08:00'),()=>{}).tick();
test('restart records missed eligible days without backfilling old live data or pre-activation periods',async()=>{
const f=fixture();const scheduler=new HistoryScheduler(f.store,()=>new Date('2026-11-02T08:00:00+08:00'),()=>{});
await scheduler.tick();assert.deepEqual(f.captures,[]);assert.deepEqual(f.missed,['2026-10-30','2026-10-31','2026-11-01']);
const late=fixture('2026-10-31T23:55:10+08:00');await new HistoryScheduler(late.store,()=>new Date('2026-10-31T23:55:20+08:00'),()=>{}).tick();
assert.deepEqual(late.captures,[]);assert.deepEqual(late.missed,[]);
});
test('completed December skips duplicate annual capture; failures stay marked without late retries',async()=>{
const f=fixture();f.store.batches=async()=>[testBatch('2026-10')];
test('completed days skip capture and existing failures are preserved after the window',async()=>{
const f=fixture();f.store.dailyBatches=async()=>[{day:'2026-10-30',status:'COMPLETE'}];
f.store.capture=async()=>{throw new Error('source unavailable');};
await new HistoryScheduler(f.store,()=>new Date('2026-12-31T17:00:20+08:00'),()=>{}).tick();
assert.deepEqual(f.missed,['2026-11']);assert.deepEqual(f.failed,['2026-12']);
const done=fixture('2026-12-01T00:00+08:00');done.store.batches=async()=>[testBatch('2026-12')];
await new HistoryScheduler(done.store,()=>new Date('2026-12-31T17:00:20+08:00'),()=>{}).tick();assert.deepEqual(done.captures,[]);
await new HistoryScheduler(f.store,()=>new Date('2026-10-31T23:55:20+08:00'),()=>{}).tick();
assert.deepEqual(f.missed,[]);assert.deepEqual(f.failed,['2026-10-31']);
f.store.dailyBatches=async()=>[{day:'2026-10-30',status:'COMPLETE'},{day:'2026-10-31',status:'FAILED'}];
await new HistoryScheduler(f.store,()=>new Date('2026-10-31T23:56:00+08:00'),()=>{}).tick();
assert.deepEqual(f.failed,['2026-10-31']);assert.deepEqual(f.missed,[]);
});
test('concurrent ticks and repeated start do not register duplicate jobs',async()=>{
const f=fixture();let release!:()=>void;
f.store.capture=async()=>{f.captures.push('2026-10');await new Promise<void>(r=>{release=r;});return'captured';};
const scheduler=new HistoryScheduler(f.store,()=>new Date('2026-10-31T17:00:20+08:00'),()=>{});
f.store.capture=async()=>{f.captures.push('2026-10-31');await new Promise<void>(r=>{release=r;});return'captured';};
const scheduler=new HistoryScheduler(f.store,()=>new Date('2026-10-31T23:55:20+08:00'),()=>{});
const stop=scheduler.start();scheduler.start();
await new Promise<void>(r=>setImmediate(r));await scheduler.tick();assert.equal(f.captures.length,1);
stop();release();await new Promise<void>(r=>setImmediate(r));
});
test('consecutive dates in one month and across month boundary capture independently',async()=>{
const f=fixture();
for(const day of ['2026-10-30','2026-10-31','2026-11-01']) {
await new HistoryScheduler(f.store,()=>new Date(`${day}T23:55:00+08:00`),()=>{}).tick();
}
assert.deepEqual(f.captures,['2026-10-30','2026-10-31','2026-11-01']);
});
+13 -13
View File
@@ -1,12 +1,12 @@
import { CAPTURE_WINDOW_MS, monthCutoff, shanghaiMonth, shiftMonth } from '../../../shared/assets/history.js';
import { CAPTURE_WINDOW_MS, dayCutoff, shanghaiDay, shiftDay } from '../../../shared/assets/history.js';
import type { CaptureResult } from './store.js';
import { HistoryDataError, type HistoryBatch, type HistorySettings } from './model.js';
export interface HistoryCaptureStore {
activate(now: Date): Promise<HistorySettings>;
batches(): Promise<HistoryBatch[]>;
capture(month: string, clock: () => Date): Promise<CaptureResult>;
recordOutcome(month: string, status: 'MISSED' | 'FAILED', code: string): Promise<void>;
dailyBatches(): Promise<Array<{ day: string; status: HistoryBatch['status'] }>>;
capture(day: string, clock: () => Date): Promise<CaptureResult>;
recordOutcome(day: string, status: 'MISSED' | 'FAILED', code: string): Promise<void>;
}
export class HistoryScheduler {
private timer: ReturnType<typeof setTimeout> | undefined;
@@ -24,20 +24,20 @@ export class HistoryScheduler {
const now = this.clock();
this.settings ||= await this.store.activate(now);
const enabled = new Date(this.settings.enabledAt);
const existing = new Map((await this.store.batches()).map(b => [b.month, b]));
for (let month = shanghaiMonth(enabled); month <= shanghaiMonth(now); month = shiftMonth(month, 1)) {
const cutoff = monthCutoff(month);
if (cutoff < enabled || cutoff > now || existing.get(month)?.status === 'COMPLETE') continue;
const existing = new Map((await this.store.dailyBatches()).map(b => [b.day, b]));
for (let day = shanghaiDay(enabled); day <= shanghaiDay(now); day = shiftDay(day, 1)) {
const cutoff = dayCutoff(day);
if (cutoff < enabled || cutoff > now || existing.get(day)?.status === 'COMPLETE') continue;
if (now.getTime() >= cutoff.getTime() + CAPTURE_WINDOW_MS) {
if (!existing.has(month)) { await this.store.recordOutcome(month, 'MISSED', 'CAPTURE_WINDOW_MISSED'); this.log(`[asset-history] missed ${month}`); }
if (!existing.has(day)) { await this.store.recordOutcome(day, 'MISSED', 'CAPTURE_WINDOW_MISSED'); this.log(`[asset-history] missed ${day}`); }
continue;
}
try { const result = await this.store.capture(month, this.clock); this.log(`[asset-history] ${month}: ${result}`); }
try { const result = await this.store.capture(day, this.clock); this.log(`[asset-history] ${day}: ${result}`); }
catch (error) {
const databaseCode = (error as { code?: unknown })?.code;
const code = error instanceof HistoryDataError ? 'INVALID_VEHICLE_DATA' : typeof databaseCode === 'string' && /^[A-Z0-9_]{1,48}$/.test(databaseCode) ? databaseCode : 'CAPTURE_FAILED';
await this.store.recordOutcome(month, 'FAILED', code);
this.log(`[asset-history] failed ${month} (${code}); no later live data will be substituted`);
await this.store.recordOutcome(day, 'FAILED', code);
this.log(`[asset-history] failed ${day} (${code}); no later live data will be substituted`);
}
}
} finally { this.ticking = false; }
@@ -50,7 +50,7 @@ export class HistoryScheduler {
try { await this.tick(); } catch { this.log('[asset-history] scheduler unavailable; check migration and database access'); }
if (!this.running) return;
const now = this.clock();
const untilCutoff = monthCutoff(shanghaiMonth(now)).getTime() - now.getTime();
const untilCutoff = dayCutoff(shanghaiDay(now)).getTime() - now.getTime();
this.timer = setTimeout(run, untilCutoff > 0 ? Math.min(untilCutoff, 60_000) : 15_000);
this.timer.unref?.();
};
@@ -84,7 +84,7 @@ test('missing storage degrades to live without any DDL; genuine database errors
});
test('once cutoff passes live values cannot replace missing history, even when capture was not enabled', async () => {
const {store}=setup();store.settingsValue=null;let liveCalls=0;
const service=new HistoryService(store,async()=>{liveCalls++;return[leased];},()=>new Date('2026-10-31T17:00:30+08:00'));
const service=new HistoryService(store,async()=>{liveCalls++;return[leased];},()=>new Date('2026-10-31T23:55:30+08:00'));
const result=await service.trend(user(),{});assert.equal(result.points[9].state,'MISSING');assert.equal(result.points[9].counts,null);
assert.equal(liveCalls,0);await assert.rejects(()=>service.details(user(),{month:'2026-10'}),HistoryPeriodUnavailable);
});
+26 -17
View File
@@ -14,7 +14,7 @@ class Database {
locked=1; existing: Array<{id:string;status:string}>=[];
committed:string[]=[]; staged:string[]=[];
failDetails=0; failReads=false; failCommitAck=false; beforeLock?:()=>void; afterSource?:()=>void; onReadTransaction?:()=>void;
released=false; clock=()=>new Date('2026-10-31T17:00:00.500+08:00');
released=false; clock=()=>new Date('2026-10-31T23:55:00.500+08:00');
async query(sql:string,_args?:unknown[]) {
this.commands.push(sql);
if(sql.includes('GET_LOCK')){this.beforeLock?.();return[[{acquired:this.locked}]];}
@@ -38,7 +38,7 @@ class Database {
store(){return new MysqlHistoryStore(this as unknown as Pool);}
}
test('capture uses uncached shared SQL and one consistent read; detail/header publish atomically',async()=>{
const db=new Database();assert.equal(await db.store().capture('2026-10',db.clock),'captured');
const db=new Database();assert.equal(await db.store().capture('2026-10-31',db.clock),'captured');
assert.deepEqual(db.committed,['batch','details','complete']);
assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1);
assert.ok(db.commands.indexOf('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY')<db.commands.indexOf(MAIN_SQL));
@@ -46,52 +46,52 @@ test('capture uses uncached shared SQL and one consistent read; detail/header pu
assert.equal(db.released,true);assert.ok(db.commands.at(-1)!.includes('RELEASE_LOCK'));
});
test('write retry preserves the exact captured rows and time, even after the minute expires',async()=>{
const db=new Database();db.failDetails=1;let now=new Date('2026-10-31T17:00:30+08:00');
db.afterSource=()=>{now=new Date('2026-10-31T18:00:00+08:00');};const clock=()=>now;
assert.equal(await db.store().capture('2026-10',clock),'captured');
const db=new Database();db.failDetails=1;let now=new Date('2026-10-31T23:55:30+08:00');
db.afterSource=()=>{now=new Date('2026-10-31T23:59:00+08:00');};const clock=()=>now;
assert.equal(await db.store().capture('2026-10-31',clock),'captured');
assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1);
assert.deepEqual(db.committed,['batch','details','complete']);
assert.equal(db.commands.filter(s=>s==='BEGIN').length,2);
});
test('lost commit acknowledgement does not overwrite or duplicate an already-published batch',async()=>{
const db=new Database();db.failCommitAck=true;
assert.equal(await db.store().capture('2026-10',db.clock),'already-complete');
assert.equal(await db.store().capture('2026-10-31',db.clock),'already-complete');
assert.deepEqual(db.committed,['batch','details','complete']);assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1);
});
test('source is not read if transaction establishment crosses the capture deadline',async()=>{
const db=new Database();let now=new Date('2026-10-31T17:00:59.900+08:00');
db.onReadTransaction=()=>{now=new Date('2026-10-31T17:01:00+08:00');};
assert.equal(await db.store().capture('2026-10',()=>now),'outside-window');
const db=new Database();let now=new Date('2026-10-31T23:55:59.900+08:00');
db.onReadTransaction=()=>{now=new Date('2026-10-31T23:56:00+08:00');};
assert.equal(await db.store().capture('2026-10-31',()=>now),'outside-window');
assert.equal(db.commands.includes(MAIN_SQL),false);assert.deepEqual(db.committed,[]);
});
test('persistent detail-write failure publishes neither partial header nor rows',async()=>{
const db=new Database();db.failDetails=5;
await assert.rejects(()=>db.store().capture('2026-10',db.clock),/write failed/);
await assert.rejects(()=>db.store().capture('2026-10-31',db.clock),/write failed/);
assert.deepEqual(db.committed,[]);assert.deepEqual(db.staged,[]);assert.equal(db.released,true);
assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1);
});
test('source failure and invalid rows cannot publish snapshots',async()=>{
const db=new Database();db.failReads=true;
await assert.rejects(()=>db.store().capture('2026-10',db.clock),/read failed/);assert.deepEqual(db.committed,[]);
await assert.rejects(()=>db.store().capture('2026-10-31',db.clock),/read failed/);assert.deepEqual(db.committed,[]);
db.failReads=false;db.rows=[testRow('duplicate'),testRow('duplicate')];
await assert.rejects(()=>db.store().capture('2026-10',db.clock),/重复/);assert.deepEqual(db.committed,[]);
await assert.rejects(()=>db.store().capture('2026-10-31',db.clock),/重复/);assert.deepEqual(db.committed,[]);
});
test('completed period, concurrent lock and expired window cannot issue a source read',async()=>{
for(const scenario of ['complete','lock','expired','delayed-lock','late-activation']){
const db=new Database();let clock=db.clock;
if(scenario==='complete')db.existing=[{id:'existing',status:'COMPLETE'}];
if(scenario==='lock')db.locked=0;
if(scenario==='expired')clock=()=>new Date('2026-10-31T17:01:00+08:00');
if(scenario==='delayed-lock')db.beforeLock=()=>{clock=()=>new Date('2026-10-31T17:01:00+08:00');};
if(scenario==='late-activation')db.settings='2026-10-31T17:00:01+08:00';
const result=await db.store().capture('2026-10',()=>clock());
if(scenario==='expired')clock=()=>new Date('2026-10-31T23:56:00+08:00');
if(scenario==='delayed-lock')db.beforeLock=()=>{clock=()=>new Date('2026-10-31T23:56:00+08:00');};
if(scenario==='late-activation')db.settings='2026-10-31T23:55:01+08:00';
const result=await db.store().capture('2026-10-31',()=>clock());
assert.ok(['already-complete','locked','outside-window'].includes(result));
assert.equal(db.commands.includes(MAIN_SQL),false,scenario);assert.deepEqual(db.committed,[]);
}
});
test('a legitimate empty source commits a zero-count complete batch without detail inserts',async()=>{
const db=new Database();db.rows=[];
assert.equal(await db.store().capture('2026-10',db.clock),'captured');assert.deepEqual(db.committed,['batch','complete']);
assert.equal(await db.store().capture('2026-10-31',db.clock),'captured');assert.deepEqual(db.committed,['batch','complete']);
});
test('missing storage reads never issue schema writes and report storage-unavailable',async()=>{
const calls:string[]=[];
@@ -101,3 +101,12 @@ test('missing storage reads never issue schema writes and report storage-unavail
await assert.rejects(()=>store.batches(),HistoryStorageUnavailable);
assert.ok(calls.every(sql=>sql.startsWith('SELECT')));
});
test('monthly reader selects month-end keys and retains completed legacy archives when a daily capture failed',async()=>{
let query='';
const row=(id:string,month:string,status:string)=>({id,month,status,source:'AUTO',total_count:0,operating_count:0,inventory_count:0});
const db={query:async(sql:string)=>{query=sql;return[[row('old-sep','2026-09','COMPLETE'),row('new-sep','2026-09','FAILED'),row('old-oct','2026-10','COMPLETE'),row('new-oct','2026-10','COMPLETE')]];}};
const batches=await new MysqlHistoryStore(db as unknown as Pool).batches();
assert.deepEqual(batches.map(b=>b.id),['old-sep','new-oct']);
assert.match(query,/LAST_DAY/);assert.match(query,/CHAR_LENGTH\(period_key\)=7/);
});
+29 -19
View File
@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto';
import type { Pool, PoolConnection, RowDataPacket } from 'mysql2/promise';
import { CAPTURE_WINDOW_MS, HISTORY_VERSION, monthCutoff, shanghaiTimestamp } from '../../../shared/assets/history.js';
import { CAPTURE_WINDOW_MS, HISTORY_VERSION, dayCutoff, shanghaiTimestamp } from '../../../shared/assets/history.js';
import type { VehicleRow } from '../../types.js';
import { MAIN_SQL } from '../vehicles/repository.js';
import { counts, freezeRows, HistoryStorageUnavailable, type HistoryBatch, type HistorySettings, type HistoryVehicle } from './model.js';
@@ -26,16 +26,21 @@ export class MysqlHistoryStore implements HistoryReader {
}
}
async settings(): Promise<HistorySettings | null> {
const rows = await this.read(`SELECT ${dateColumn('enabled_at')} AS enabledAt FROM bi_asset_history_settings WHERE id=1`);
return rows[0] ? { enabledAt: rows[0].enabledAt } : null;
const rows = await this.read(`SELECT ${dateColumn('daily_enabled_at')} AS enabledAt FROM bi_asset_history_settings WHERE id=1`);
return rows[0]?.enabledAt ? { enabledAt: rows[0].enabledAt } : null;
}
async batches(): Promise<HistoryBatch[]> {
const rows = await this.read(`SELECT id, period_month AS month, status, source,
const rows = await this.read(`SELECT id, LEFT(period_key,7) AS month, status, source,
total_count,operating_count,inventory_count,
${dateColumn('scheduled_for')} AS scheduledFor, ${dateColumn('captured_at')} AS capturedAt
FROM bi_asset_history_batches WHERE definition_version=? ORDER BY period_month`, [HISTORY_VERSION]);
return rows.map(row => ({ id: row.id, month: row.month, status: row.status, source: row.source, scheduledFor: row.scheduledFor, capturedAt: row.capturedAt,
FROM bi_asset_history_batches WHERE definition_version=? AND (CHAR_LENGTH(period_key)=7 OR period_key=DATE_FORMAT(LAST_DAY(CONCAT(LEFT(period_key,7),'-01')), '%Y-%m-%d')) ORDER BY period_key`, [HISTORY_VERSION]);
const batches = rows.map(row => ({ id: row.id, month: row.month, status: row.status, source: row.source, scheduledFor: row.scheduledFor, capturedAt: row.capturedAt,
counts: { total: row.total_count, operating: row.operating_count, inventory: row.inventory_count } }));
const byMonth = new Map<string, HistoryBatch>();
for (const batch of batches) {
if (batch.status === 'COMPLETE' || byMonth.get(batch.month)?.status !== 'COMPLETE') byMonth.set(batch.month, batch);
}
return [...byMonth.values()];
}
async vehicles(batchIds: string[]): Promise<Map<string, HistoryVehicle[]>> {
const result = new Map<string, HistoryVehicle[]>(batchIds.map(id => [id, []]));
@@ -49,24 +54,29 @@ export class MysqlHistoryStore implements HistoryReader {
return result;
}
async activate(now: Date): Promise<HistorySettings> {
await this.db.query('INSERT IGNORE INTO bi_asset_history_settings (id,enabled_at) VALUES (1,?)', [sqlTime(now)]);
await this.db.query(`INSERT INTO bi_asset_history_settings (id,enabled_at,daily_enabled_at) VALUES (1,?,?)
ON DUPLICATE KEY UPDATE daily_enabled_at=COALESCE(daily_enabled_at,VALUES(daily_enabled_at))`, [sqlTime(now), sqlTime(now)]);
return (await this.settings())!;
}
async recordOutcome(month: string, status: 'FAILED' | 'MISSED', code: string): Promise<void> {
async dailyBatches(): Promise<Array<{ day: string; status: HistoryBatch['status'] }>> {
const rows = await this.read('SELECT period_key AS day,status FROM bi_asset_history_batches WHERE definition_version=? AND CHAR_LENGTH(period_key)=10', [HISTORY_VERSION]);
return rows.map(row => ({ day: row.day, status: row.status }));
}
async recordOutcome(day: string, status: 'FAILED' | 'MISSED', code: string): Promise<void> {
// Never downgrade a completed snapshot, or replace a useful failure with MISSED.
await this.db.query(`INSERT INTO bi_asset_history_batches
(id,period_month,definition_version,scheduled_for,source,status,failure_code)
(id,period_key,definition_version,scheduled_for,source,status,failure_code)
VALUES (?,?,?,?,'AUTO',?,?) ON DUPLICATE KEY UPDATE
failure_code=IF(status='COMPLETE' OR (status='FAILED' AND VALUES(status)='MISSED'),failure_code,VALUES(failure_code)),
status=IF(status='COMPLETE' OR (status='FAILED' AND VALUES(status)='MISSED'),status,VALUES(status))`,
[randomUUID(), month, HISTORY_VERSION, sqlTime(monthCutoff(month)), status, code]);
[randomUUID(), day, HISTORY_VERSION, sqlTime(dayCutoff(day)), status, code]);
}
async capture(month: string, clock: () => Date = () => new Date()): Promise<CaptureResult> {
const cutoff = monthCutoff(month).getTime();
async capture(day: string, clock: () => Date = () => new Date()): Promise<CaptureResult> {
const cutoff = dayCutoff(day).getTime();
const inWindow = () => clock().getTime() >= cutoff && clock().getTime() < cutoff + CAPTURE_WINDOW_MS;
if (!inWindow()) return 'outside-window';
const connection = await this.db.getConnection();
const lock = `ln-bi:asset:${HISTORY_VERSION}:${month}`;
const lock = `ln-bi:asset:${HISTORY_VERSION}:${day}`;
let locked = false;
try {
const [locks] = await connection.query<RowDataPacket[]>("SELECT GET_LOCK(CONCAT(DATABASE(),':',?),0) AS acquired", [lock]);
@@ -74,7 +84,7 @@ export class MysqlHistoryStore implements HistoryReader {
if (!locked) return 'locked';
const settings = await this.settings();
if (!settings || new Date(settings.enabledAt).getTime() > cutoff || !inWindow()) return 'outside-window';
const [existing] = await connection.query<RowDataPacket[]>(`SELECT id,status FROM bi_asset_history_batches WHERE period_month=? AND definition_version=?`, [month, HISTORY_VERSION]);
const [existing] = await connection.query<RowDataPacket[]>(`SELECT id,status FROM bi_asset_history_batches WHERE period_key=? AND definition_version=?`, [day, HISTORY_VERSION]);
if (existing[0]?.status === 'COMPLETE') return 'already-complete';
await connection.query("SET time_zone='+08:00'");
await connection.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ');
@@ -90,7 +100,7 @@ export class MysqlHistoryStore implements HistoryReader {
// Retries below keep this exact frozen dataset. Never reread a later live ledger.
const id = existing[0]?.id || randomUUID();
for (let attempt = 0; ; attempt++) {
try { return await this.publish(connection, id, month, capturedAt, frozen, clock); }
try { return await this.publish(connection, id, day, capturedAt, frozen, clock); }
catch (error) { await connection.rollback(); if (attempt >= 2) throw error; }
}
} finally {
@@ -98,19 +108,19 @@ export class MysqlHistoryStore implements HistoryReader {
finally { connection.release(); }
}
}
private async publish(connection: PoolConnection, id: string, month: string, capturedAt: Date, vehicles: HistoryVehicle[], clock: () => Date) {
private async publish(connection: PoolConnection, id: string, day: string, capturedAt: Date, vehicles: HistoryVehicle[], clock: () => Date) {
const totals = counts(vehicles);
await connection.beginTransaction();
const [existing] = await connection.query<RowDataPacket[]>('SELECT id,status FROM bi_asset_history_batches WHERE period_month=? AND definition_version=? FOR UPDATE', [month, HISTORY_VERSION]);
const [existing] = await connection.query<RowDataPacket[]>('SELECT id,status FROM bi_asset_history_batches WHERE period_key=? AND definition_version=? FOR UPDATE', [day, HISTORY_VERSION]);
// Also handles a committed transaction whose acknowledgement was lost.
if (existing[0]?.status === 'COMPLETE') { await connection.commit(); return 'already-complete' as const; }
if (existing[0]) id = existing[0].id;
// Under the advisory lock, only FAILED/MISSED placeholders may be promoted.
await connection.query(`INSERT INTO bi_asset_history_batches
(id,period_month,definition_version,scheduled_for,captured_at,source,status,total_count,operating_count,inventory_count)
(id,period_key,definition_version,scheduled_for,captured_at,source,status,total_count,operating_count,inventory_count)
VALUES (?,?,?,?,?,'AUTO','FAILED',?,?,?) ON DUPLICATE KEY UPDATE captured_at=VALUES(captured_at),
total_count=VALUES(total_count),operating_count=VALUES(operating_count),inventory_count=VALUES(inventory_count)`,
[id, month, HISTORY_VERSION, sqlTime(monthCutoff(month)), sqlTime(capturedAt), totals.total, totals.operating, totals.inventory]);
[id, day, HISTORY_VERSION, sqlTime(dayCutoff(day)), sqlTime(capturedAt), totals.total, totals.operating, totals.inventory]);
for (let offset = 0; offset < vehicles.length; offset += 100) {
const chunk = vehicles.slice(offset, offset + 100);
await connection.query(`INSERT INTO bi_asset_history_vehicles
+1 -1
View File
@@ -39,7 +39,7 @@ export function registerHydrogenDailyRoute(
HYDROGEN_BASE_WHERE_B,
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
dateRangeClause(`b.${HYDROGEN_LOCAL}`),
customerClause(customer),
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
].join(' AND ');
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
+4 -26
View File
@@ -1,6 +1,5 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DatabaseSync } from 'node:sqlite';
import {
customerClause,
dateRangeClause,
@@ -8,31 +7,10 @@ import {
resolveDateRange,
} from './query-model.js';
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('客户范围继续让外部车辆返回空数据', () => {
assert.equal(customerClause('external'), '1=0');
assert.equal(customerClause('lingniu'), '1=1');
assert.equal(customerClause('all'), '1=1');
});
test('能源日期快捷区间继续按本地自然日计算', () => {
+3 -7
View File
@@ -8,14 +8,10 @@ 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 {
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}`;
if (customer === 'external') return '1=0';
if (customer === 'lingniu') return '1=1';
return '1=1';
}
+2 -7
View File
@@ -105,11 +105,6 @@ 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 = {
@@ -132,7 +127,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)),
[
@@ -143,7 +138,7 @@ test('四个处理器保持缓存和参数契约,加氢站点使用新主数
'256ac8309d5a',
'dac9e86fc527',
'a71da8f05765',
'd1620280f520',
'24b23780a259',
'226fd8f950a1',
'6e4031b10926',
'4fc77463294c',
+12 -3
View File
@@ -57,15 +57,24 @@ export function shanghaiTimestamp(now: Date): string { return new Date(now.getTi
export function monthCutoff(month: string): Date {
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month)) throw new Error('月份格式无效');
const [year, m] = month.split('-').map(Number);
return new Date(Date.UTC(year, m, 0, 9)); // 17:00 Asia/Shanghai, on the last day.
return new Date(Date.UTC(year, m, 0, 15, 55)); // 23:55 Asia/Shanghai, on the last day.
}
export function shiftMonth(month: string, offset: number): string {
const [year, m] = month.split('-').map(Number);
return new Date(Date.UTC(year, m - 1 + offset, 1)).toISOString().slice(0, 7);
}
export function shanghaiDay(now: Date): string { return shanghaiTimestamp(now).slice(0, 10); }
export function dayCutoff(day: string): Date {
const date = new Date(`${day}T23:55:00+08:00`);
if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !Number.isFinite(date.getTime()) || shanghaiDay(date) !== day) throw new Error('日期格式无效');
return date;
}
export function shiftDay(day: string, offset: number): string {
return shanghaiDay(new Date(dayCutoff(day).getTime() + offset * 86_400_000));
}
export function nextCapture(now: Date): string {
const month = shanghaiMonth(now);
return shanghaiTimestamp(monthCutoff(monthCutoff(month).getTime() > now.getTime() ? month : shiftMonth(month, 1)));
const day = shanghaiDay(now);
return shanghaiTimestamp(dayCutoff(dayCutoff(day) > now ? day : shiftDay(day, 1)));
}
export function historyTime(value: string | null): string {
if (!value) return '暂无时间';