feat: preserve mileage assessment years

This commit is contained in:
kkfluous
2026-08-07 17:37:05 +08:00
parent 52d79ba260
commit 67a269651e
4 changed files with 91 additions and 16 deletions
+1
View File
@@ -159,6 +159,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
### 阶段 B:核心下钻闭环 ### 阶段 B:核心下钻闭环
- 已完成里程考核归因、车辆详情和每日汇报跳转。 - 已完成里程考核归因、车辆详情和每日汇报跳转。
- 里程考核年度已纳入 `mileageYear` URL 状态;目标、部门/客户归因和车辆清单下钻继承同一年度,无效年度会按目标真实年度归一。
- 已完成氢能站点、客户下钻代码。 - 已完成氢能站点、客户下钻代码。
- 已完成氢能每日趋势到加氢订单的 URL 下钻、分页和结构化故障状态;待数据源恢复后验收真实明细。 - 已完成氢能每日趋势到加氢订单的 URL 下钻、分页和结构化故障状态;待数据源恢复后验收真实明细。
- 已为电能总览与氢能每日趋势柱图统一 Enter/Space 键盘下钻;氢能有效日期柱提供可读名称、焦点和选中反馈。 - 已为电能总览与氢能每日趋势柱图统一 Enter/Space 键盘下钻;氢能有效日期柱提供可读名称、焦点和选中反馈。
+71 -13
View File
@@ -49,6 +49,10 @@ function getTargetAssessment(target: TargetSummary, selectedYear?: number): Targ
return target.yearlyAssessments.find(item => item.yearNumber === selectedYear) || target.yearlyAssessments[0]; return target.yearlyAssessments.find(item => item.yearNumber === selectedYear) || target.yearlyAssessments[0];
} }
function resolveAssessmentYear(target: TargetSummary, requestedYear?: number): number | undefined {
return getTargetAssessment(target, requestedYear)?.yearNumber;
}
function fmtDateLabel(date: string | null): string { function fmtDateLabel(date: string | null): string {
if (!date) return ''; if (!date) return '';
const [year, month, day] = date.split('-'); const [year, month, day] = date.split('-');
@@ -96,10 +100,12 @@ export default function StatisticsView() {
const [viewAllLoading, setViewAllLoading] = useState(false); const [viewAllLoading, setViewAllLoading] = useState(false);
const [viewAllHistoricalVehicles, setViewAllHistoricalVehicles] = useState<TargetVehicle[] | null>(null); const [viewAllHistoricalVehicles, setViewAllHistoricalVehicles] = useState<TargetVehicle[] | null>(null);
const targetVehicleRequestsRef = useRef(new Set<number>()); const targetVehicleRequestsRef = useRef(new Set<number>());
const initialDrillContextRef = useRef(drillContext);
const selectedTarget = targets.find(t => t.id === selectedTargetId); const selectedTarget = targets.find(t => t.id === selectedTargetId);
const selectedTargetVehicles = selectedTargetId === null ? [] : targetVehiclesMap[selectedTargetId] || []; const selectedTargetVehicles = selectedTargetId === null ? [] : targetVehiclesMap[selectedTargetId] || [];
const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null; const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null;
const selectedAssessmentYear = selectedAssessment?.yearNumber;
const selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0; const selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0;
const selectedRemaining = selectedAssessment?.remaining ?? 0; const selectedRemaining = selectedAssessment?.remaining ?? 0;
const selectedDaysLeft = selectedAssessment?.daysLeft ?? selectedTarget?.daysLeft ?? 0; const selectedDaysLeft = selectedAssessment?.daysLeft ?? selectedTarget?.daysLeft ?? 0;
@@ -136,6 +142,10 @@ export default function StatisticsView() {
}, []); }, []);
const selectTarget = useCallback((targetId: number) => { const selectTarget = useCallback((targetId: number) => {
const target = targets.find(item => item.id === targetId);
const assessmentYear = target
? resolveAssessmentYear(target, assessmentYearMap[targetId])
: undefined;
setSelectedTargetId(targetId); setSelectedTargetId(targetId);
setExpandedTargetId(targetId); setExpandedTargetId(targetId);
setViewAllTargetId(null); setViewAllTargetId(null);
@@ -143,9 +153,10 @@ export default function StatisticsView() {
commitDrillContext({ commitDrillContext({
level: 'overview', level: 'overview',
targetId, targetId,
assessmentYear,
sourcePriority: ['instrument'], sourcePriority: ['instrument'],
}, 'replace'); }, 'replace');
}, [commitDrillContext]); }, [assessmentYearMap, commitDrillContext, targets]);
const openAttributionGroup = useCallback((value: string) => { const openAttributionGroup = useCallback((value: string) => {
if (selectedTargetId === null) return; if (selectedTargetId === null) return;
@@ -157,11 +168,29 @@ export default function StatisticsView() {
commitDrillContext({ commitDrillContext({
level: 'breakdown', level: 'breakdown',
targetId: selectedTargetId, targetId: selectedTargetId,
assessmentYear: selectedAssessmentYear,
dimension: attributionDimension, dimension: attributionDimension,
dimensionValue: value, dimensionValue: value,
sourcePriority: ['instrument'], sourcePriority: ['instrument'],
}, 'push'); }, 'push');
}, [attributionDimension, commitDrillContext, selectedTarget?.targetName, selectedTargetId]); }, [attributionDimension, commitDrillContext, selectedAssessmentYear, selectedTarget?.targetName, selectedTargetId]);
const openTargetVehicles = useCallback((target: TargetSummary) => {
const assessmentYear = resolveAssessmentYear(target, assessmentYearMap[target.id]);
setViewAllTargetId(target.id);
setViewAllTargetName(target.targetName);
setViewAllSearch('');
setViewAllDate(getDefaultDate());
setViewAllHistoricalVehicles(null);
commitDrillContext({
level: 'breakdown',
targetId: target.id,
assessmentYear,
dimension: 'assessment-target',
dimensionValue: target.targetName,
sourcePriority: ['instrument'],
}, 'push');
}, [assessmentYearMap, commitDrillContext]);
const closeVehiclePanel = useCallback(() => { const closeVehiclePanel = useCallback(() => {
setViewAllTargetId(null); setViewAllTargetId(null);
@@ -170,10 +199,11 @@ export default function StatisticsView() {
commitDrillContext({ commitDrillContext({
level: 'overview', level: 'overview',
targetId: selectedTargetId || undefined, targetId: selectedTargetId || undefined,
assessmentYear: selectedAssessmentYear,
sourcePriority: ['instrument'], sourcePriority: ['instrument'],
}, 'replace'); }, 'replace');
} }
}, [commitDrillContext, drillContext.level, selectedTargetId]); }, [commitDrillContext, drillContext.level, selectedAssessmentYear, selectedTargetId]);
const openVehicleDiagnostic = useCallback((vehicle: TargetVehicle) => { const openVehicleDiagnostic = useCallback((vehicle: TargetVehicle) => {
const url = buildMileageDrillUrl( const url = buildMileageDrillUrl(
@@ -181,6 +211,7 @@ export default function StatisticsView() {
{ {
level: 'vehicle', level: 'vehicle',
targetId: viewAllTargetId || selectedTargetId || undefined, targetId: viewAllTargetId || selectedTargetId || undefined,
assessmentYear: selectedAssessmentYear,
plate: vehicle.plateNumber, plate: vehicle.plateNumber,
startDate: viewAllDate, startDate: viewAllDate,
endDate: viewAllDate, endDate: viewAllDate,
@@ -189,11 +220,12 @@ export default function StatisticsView() {
); );
window.history.pushState(null, '', url); window.history.pushState(null, '', url);
window.dispatchEvent(new HashChangeEvent('hashchange')); window.dispatchEvent(new HashChangeEvent('hashchange'));
}, [selectedTargetId, viewAllDate, viewAllTargetId]); }, [selectedAssessmentYear, selectedTargetId, viewAllDate, viewAllTargetId]);
// Load targets on mount // Load targets on mount
useEffect(() => { useEffect(() => {
fetchTargets().then(data => { fetchTargets().then(data => {
const initialContext = initialDrillContextRef.current;
const focused = data.find(item => item.targetName.includes('羚牛136')) || data[0]; const focused = data.find(item => item.targetName.includes('羚牛136')) || data[0];
const ordered = focused const ordered = focused
? [focused, ...data.filter(item => item.id !== focused.id)] ? [focused, ...data.filter(item => item.id !== focused.id)]
@@ -203,7 +235,20 @@ export default function StatisticsView() {
if (initialTarget) { if (initialTarget) {
setSelectedTargetId(initialTarget.id); setSelectedTargetId(initialTarget.id);
setExpandedTargetId(initialTarget.id); setExpandedTargetId(initialTarget.id);
setAssessmentYearMap(Object.fromEntries(ordered.map(item => [item.id, item.yearlyAssessments[0]?.yearNumber || 1]))); const initialYears = Object.fromEntries(ordered.map(item => [
item.id,
resolveAssessmentYear(item, item.id === initialContext.targetId ? initialContext.assessmentYear : undefined) || 1,
]));
setAssessmentYearMap(initialYears);
if (initialContext.targetId === initialTarget.id) {
const resolvedYear = initialYears[initialTarget.id];
if (resolvedYear && resolvedYear !== initialContext.assessmentYear) {
const normalizedContext = { ...initialContext, assessmentYear: resolvedYear };
window.history.replaceState(null, '', buildMileageDrillUrl(window.location, normalizedContext));
setDrillContext(normalizedContext);
}
}
} }
}).catch(() => {}); }).catch(() => {});
}, []); }, []);
@@ -236,7 +281,14 @@ export default function StatisticsView() {
const handlePopState = () => { const handlePopState = () => {
const next = parseMileageDrillContext(window.location.search); const next = parseMileageDrillContext(window.location.search);
setDrillContext(next); setDrillContext(next);
if (next.targetId) setSelectedTargetId(next.targetId); if (next.targetId) {
setSelectedTargetId(next.targetId);
const target = targets.find(item => item.id === next.targetId);
const resolvedYear = target ? resolveAssessmentYear(target, next.assessmentYear) : undefined;
if (resolvedYear) {
setAssessmentYearMap(prev => ({ ...prev, [next.targetId!]: resolvedYear }));
}
}
if (next.level === 'breakdown' && next.targetId && next.dimensionValue) { if (next.level === 'breakdown' && next.targetId && next.dimensionValue) {
setAttributionDimension(next.dimension === 'customer' ? 'customer' : 'department'); setAttributionDimension(next.dimension === 'customer' ? 'customer' : 'department');
setViewAllTargetId(next.targetId); setViewAllTargetId(next.targetId);
@@ -246,7 +298,7 @@ export default function StatisticsView() {
}; };
window.addEventListener('popstate', handlePopState); window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState);
}, []); }, [targets]);
const viewAllVehicles = useMemo(() => { const viewAllVehicles = useMemo(() => {
const vehicles = viewAllHistoricalVehicles const vehicles = viewAllHistoricalVehicles
@@ -536,7 +588,13 @@ export default function StatisticsView() {
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onChange={(e) => { onChange={(e) => {
e.stopPropagation(); e.stopPropagation();
setAssessmentYearMap(prev => ({ ...prev, [target.id]: Number(e.target.value) })); const assessmentYear = Number(e.target.value);
setAssessmentYearMap(prev => ({ ...prev, [target.id]: assessmentYear }));
commitDrillContext({
...drillContext,
targetId: target.id,
assessmentYear,
}, 'replace');
}} }}
className="bg-white border border-blue-100 rounded-lg px-2 py-1 text-[10px] font-bold text-blue-700 outline-none" className="bg-white border border-blue-100 rounded-lg px-2 py-1 text-[10px] font-bold text-blue-700 outline-none"
> >
@@ -625,9 +683,7 @@ export default function StatisticsView() {
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setViewAllTargetId(target.id); openTargetVehicles(target);
setViewAllTargetName(target.targetName);
setViewAllDate(getDefaultDate());
}} }}
className="text-[8px] text-blue-500 font-bold hover:underline" className="text-[8px] text-blue-500 font-bold hover:underline"
> >
@@ -803,8 +859,10 @@ export default function StatisticsView() {
<h3 className="text-lg font-black text-slate-900"> <h3 className="text-lg font-black text-slate-900">
{viewAllTargetName || selectedTarget?.targetName || '考核车辆'} {viewAllTargetName || selectedTarget?.targetName || '考核车辆'}
</h3> </h3>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1"></p> <p className="mt-1 text-xs font-bold uppercase tracking-widest text-slate-400">
{selectedBreakdownValue && ( {selectedAssessment ? `${selectedAssessment.label} · ` : ''}
</p>
{selectedBreakdownValue && drillContext.dimension !== 'assessment-target' && (
<p className="mt-1 text-[10px] font-bold text-blue-600">{selectedBreakdownValue}</p> <p className="mt-1 text-[10px] font-bold text-blue-600">{selectedBreakdownValue}</p>
)} )}
</div> </div>
+11 -3
View File
@@ -24,10 +24,11 @@ test('requires complete context for breakdown and vehicle levels', () => {
test('parses a target-scoped breakdown context', () => { test('parses a target-scoped breakdown context', () => {
assert.deepEqual( assert.deepEqual(
parseMileageDrillContext('?mileageLevel=breakdown&mileageTarget=5&mileageDimension=department&mileageValue=%E4%B8%9A%E5%8A%A1%E4%B8%80%E9%83%A8'), parseMileageDrillContext('?mileageLevel=breakdown&mileageTarget=5&mileageYear=2&mileageDimension=department&mileageValue=%E4%B8%9A%E5%8A%A1%E4%B8%80%E9%83%A8'),
{ {
level: 'breakdown', level: 'breakdown',
targetId: 5, targetId: 5,
assessmentYear: 2,
dimension: 'department', dimension: 'department',
dimensionValue: '业务一部', dimensionValue: '业务一部',
sourcePriority: ['instrument'], sourcePriority: ['instrument'],
@@ -35,12 +36,19 @@ test('parses a target-scoped breakdown context', () => {
); );
}); });
test('accepts assessment years only with a target', () => {
assert.equal(parseMileageDrillContext('?mileageTarget=5&mileageYear=3').assessmentYear, 3);
assert.equal(parseMileageDrillContext('?mileageYear=3').assessmentYear, undefined);
assert.equal(parseMileageDrillContext('?mileageTarget=5&mileageYear=0').assessmentYear, undefined);
});
test('builds a namespaced URL and preserves unrelated parameters', () => { test('builds a namespaced URL and preserves unrelated parameters', () => {
const url = buildMileageDrillUrl( const url = buildMileageDrillUrl(
{ pathname: '/asset', search: '?company=5&legacy=1', hash: '#mileage' }, { pathname: '/asset', search: '?company=5&legacy=1', hash: '#mileage' },
{ {
level: 'vehicle', level: 'vehicle',
targetId: 5, targetId: 5,
assessmentYear: 2,
plate: '粤A08190F', plate: '粤A08190F',
startDate: '2026-08-01', startDate: '2026-08-01',
endDate: '2026-08-07', endDate: '2026-08-07',
@@ -50,7 +58,7 @@ test('builds a namespaced URL and preserves unrelated parameters', () => {
assert.equal( assert.equal(
url, url,
'/asset?company=5&legacy=1&mileageLevel=vehicle&mileageTarget=5&mileagePlate=%E7%B2%A4A08190F&mileageStart=2026-08-01&mileageEnd=2026-08-07&mileageSource=instrument#mileage', '/asset?company=5&legacy=1&mileageLevel=vehicle&mileageTarget=5&mileageYear=2&mileagePlate=%E7%B2%A4A08190F&mileageStart=2026-08-01&mileageEnd=2026-08-07&mileageSource=instrument#mileage',
); );
}); });
@@ -58,7 +66,7 @@ test('clears entity drill parameters when returning to overview', () => {
const url = buildMileageDrillUrl( const url = buildMileageDrillUrl(
{ {
pathname: '/asset', pathname: '/asset',
search: '?mileageLevel=vehicle&mileagePlate=%E7%B2%A4A08190F&company=5', search: '?mileageLevel=vehicle&mileageTarget=5&mileageYear=2&mileagePlate=%E7%B2%A4A08190F&company=5',
hash: '#mileage', hash: '#mileage',
}, },
{ level: 'overview', sourcePriority: ['instrument'] }, { level: 'overview', sourcePriority: ['instrument'] },
+8
View File
@@ -11,6 +11,7 @@ export type MileageBreakdownDimension =
export interface MileageDrillContext { export interface MileageDrillContext {
level: MileageDrillLevel; level: MileageDrillLevel;
targetId?: number; targetId?: number;
assessmentYear?: number;
dimension?: MileageBreakdownDimension; dimension?: MileageBreakdownDimension;
dimensionValue?: string; dimensionValue?: string;
plate?: string; plate?: string;
@@ -54,6 +55,10 @@ export function parseMileageDrillContext(search: string): MileageDrillContext {
const params = new URLSearchParams(search); const params = new URLSearchParams(search);
const rawTargetId = Number(params.get('mileageTarget')); const rawTargetId = Number(params.get('mileageTarget'));
const targetId = Number.isInteger(rawTargetId) && rawTargetId > 0 ? rawTargetId : undefined; const targetId = Number.isInteger(rawTargetId) && rawTargetId > 0 ? rawTargetId : undefined;
const rawAssessmentYear = Number(params.get('mileageYear'));
const assessmentYear = Number.isInteger(rawAssessmentYear) && rawAssessmentYear > 0
? rawAssessmentYear
: undefined;
const plate = params.get('mileagePlate')?.trim() || undefined; const plate = params.get('mileagePlate')?.trim() || undefined;
const rawDimension = params.get('mileageDimension'); const rawDimension = params.get('mileageDimension');
const dimension = rawDimension && BREAKDOWN_DIMENSIONS.has(rawDimension as MileageBreakdownDimension) const dimension = rawDimension && BREAKDOWN_DIMENSIONS.has(rawDimension as MileageBreakdownDimension)
@@ -71,6 +76,7 @@ export function parseMileageDrillContext(search: string): MileageDrillContext {
sourcePriority: parseSourcePriority(params.get('mileageSource')), sourcePriority: parseSourcePriority(params.get('mileageSource')),
}; };
if (targetId) context.targetId = targetId; if (targetId) context.targetId = targetId;
if (targetId && assessmentYear) context.assessmentYear = assessmentYear;
if (level === 'breakdown') { if (level === 'breakdown') {
context.dimension = dimension; context.dimension = dimension;
context.dimensionValue = dimensionValue; context.dimensionValue = dimensionValue;
@@ -91,6 +97,7 @@ export function buildMileageDrillUrl(
for (const key of [ for (const key of [
'mileageLevel', 'mileageLevel',
'mileageTarget', 'mileageTarget',
'mileageYear',
'mileageDimension', 'mileageDimension',
'mileageValue', 'mileageValue',
'mileagePlate', 'mileagePlate',
@@ -103,6 +110,7 @@ export function buildMileageDrillUrl(
if (context.level !== 'overview') params.set('mileageLevel', context.level); if (context.level !== 'overview') params.set('mileageLevel', context.level);
if (context.targetId) params.set('mileageTarget', String(context.targetId)); if (context.targetId) params.set('mileageTarget', String(context.targetId));
if (context.targetId && context.assessmentYear) params.set('mileageYear', String(context.assessmentYear));
if (context.level === 'breakdown' && context.dimension && context.dimensionValue) { if (context.level === 'breakdown' && context.dimension && context.dimensionValue) {
params.set('mileageDimension', context.dimension); params.set('mileageDimension', context.dimension);
params.set('mileageValue', context.dimensionValue); params.set('mileageValue', context.dimensionValue);