refine Semi UI mileage workspace

This commit is contained in:
lingniu
2026-07-18 07:50:25 +08:00
parent ffb1283807
commit 40fcb6fc7b
2 changed files with 491 additions and 34 deletions

View File

@@ -1,7 +1,7 @@
import { IconArrowDown, IconArrowUp, IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Empty, Input, SideSheet, Spin, Switch, Table, Tag } from '@douyinfe/semi-ui';
import { useQuery } from '@tanstack/react-query';
import { FormEvent, type RefObject, useEffect, useMemo, useRef, useState } from 'react';
import { type CSSProperties, FormEvent, memo, type RefObject, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { DailyMileageRow, MileageStatistics, Page, VehicleRow } from '../../api/types';
@@ -233,22 +233,25 @@ function VehicleMultiSelect({ value, onChange }: { value: VehicleOption[]; onCha
function SummaryRail({ data, criteria, fleetTotal, loading }: { data?: MileageStatistics; criteria: Criteria; fleetTotal?: number; loading: boolean }) {
const days = inclusiveDays(criteria.dateFrom, criteria.dateTo);
const vehicleCount = criteria.vehicles.length ? data?.vehicleCount ?? 0 : fleetTotal ?? 0;
const summaryRange = criteria.dateFrom.slice(0, 4) === criteria.dateTo.slice(0, 4)
? `${criteria.dateFrom.replace(/-/g, '/')}${criteria.dateTo.slice(5).replace('-', '/')}`
: `${criteria.dateFrom.replace(/-/g, '/')}${criteria.dateTo.replace(/-/g, '/')}`;
const items = loading ? [
['已绑定主车辆', '—', '正在按新筛选范围查询'],
['统计天数', `${days}`, `${criteria.dateFrom}${criteria.dateTo}`],
['统计天数', `${days}`, summaryRange],
['区间总里程', '—', '正在计算区间汇总'],
['日均里程', '—', '正在计算有效车辆日']
] : [
['已绑定主车辆', `${vehicleCount}`, criteria.vehicles.length ? `已选择 ${criteria.vehicles.length}` : `档案口径 · ${data?.vehicleCount ?? 0} 辆有里程`],
['统计天数', `${days}`, `${criteria.dateFrom}${criteria.dateTo}`],
['统计天数', `${days}`, summaryRange],
['区间总里程', `${formatKm(data?.periodMileageKm)} km`, `${data?.recordCount ?? 0} 条车辆日记录`],
['日均里程', `${formatKm(data?.averageDailyMileageKm)} km`, '按有效车辆日平均']
];
const primary = items[2];
const secondary = [items[0], items[1], items[3]];
return <Card className="v2-mileage-summary" aria-label="里程查询统计信息" bodyStyle={{ padding: 0 }}>
<Card className="v2-mileage-summary-card is-primary" bodyStyle={{ padding: 0 }} aria-label={`${primary[0]}${primary[1]}${primary[2]}`}><small>{primary[0]}</small><strong className="v2-mileage-summary-value">{primary[1]}</strong><span>{primary[2]}</span></Card>
<CardGroup className="v2-mileage-summary-secondary" type="grid" spacing={0}>{secondary.map(([label, value, note]) => <Card className="v2-mileage-summary-card" bodyStyle={{ padding: 0 }} aria-label={`${label}${value}${note}`} key={label}><small>{label}</small><strong>{value}</strong><span>{note}</span></Card>)}</CardGroup>
<Card className="v2-mileage-summary-card is-primary" bodyStyle={{ padding: 0 }} aria-label={`${primary[0]}${primary[1]}${primary[2]}`}><small>{primary[0]}</small><strong className="v2-mileage-summary-value">{primary[1]}</strong><span title={primary[2]}>{primary[2]}</span></Card>
<CardGroup className="v2-mileage-summary-secondary" type="grid" spacing={0}>{secondary.map(([label, value, note], index) => <Card className={`v2-mileage-summary-card is-${['fleet', 'range', 'average'][index]}`} bodyStyle={{ padding: 0 }} aria-label={`${label}${value}${note}`} key={label}><small>{label}</small><strong>{value}</strong><span title={note}>{note}</span></Card>)}</CardGroup>
</Card>;
}
@@ -270,37 +273,41 @@ function dateLabel(date: string) {
return `${Number(month)}/${Number(day)}`;
}
function MileageTable({ rows, dates, scrollRef }: { rows: VehicleMileageMatrix[]; dates: string[]; scrollRef: RefObject<HTMLDivElement> }) {
let maxDailyMileage = 1;
for (const row of rows) {
for (const mileage of row.days.values()) maxDailyMileage = Math.max(maxDailyMileage, mileage);
}
const columns = [
{ title: '车牌', dataIndex: 'plate', className: 'is-plate', width: 120, render: (_value: string, row: VehicleMileageMatrix) => <strong>{row.plate || '未绑定'}</strong> },
...dates.map((date) => ({
title: dateLabel(date), dataIndex: date, className: 'is-number is-date', width: 96,
onHeaderCell: () => ({ title: date }),
onCell: (row?: VehicleMileageMatrix) => {
const mileage = row?.days.get(date);
const intensity = mileage && mileage > 0 ? .035 + mileage / maxDailyMileage * .13 : 0;
return {
className: `is-number is-date${mileage != null ? ' is-daily' : ' is-empty'}`,
title: mileage != null ? `来源:${row?.sources.get(date) || '—'}` : undefined,
style: intensity ? { backgroundColor: `rgba(37, 99, 235, ${intensity.toFixed(3)})` } : undefined
};
},
render: (_value: unknown, row: VehicleMileageMatrix) => {
const mileage = row.days.get(date);
return mileage != null ? `${formatKm(mileage)} km` : '—';
}
})),
{ title: '区间总里程', dataIndex: 'totalMileageKm', className: 'is-number is-total', width: 128, onCell: () => ({ className: 'is-number is-period is-total' }), render: (value: number) => `${formatKm(value)} km` }
];
const tableWidth = 120 + dates.length * 96 + 128;
return <div className="v2-mileage-table-wrap" ref={scrollRef}>
const MileageTable = memo(function MileageTable({ rows, dates, scrollRef }: { rows: VehicleMileageMatrix[]; dates: string[]; scrollRef: RefObject<HTMLDivElement> }) {
const { columns, tableWidth } = useMemo(() => {
let maxDailyMileage = 1;
for (const row of rows) {
for (const mileage of row.days.values()) maxDailyMileage = Math.max(maxDailyMileage, mileage);
}
return {
columns: [
{ title: '车牌', dataIndex: 'plate', className: 'is-plate', width: 120, render: (_value: string, row: VehicleMileageMatrix) => <strong>{row.plate || '未绑定'}</strong> },
...dates.map((date) => ({
title: dateLabel(date), dataIndex: date, className: 'is-number is-date', width: 96,
onHeaderCell: () => ({ title: date }),
onCell: (row?: VehicleMileageMatrix) => {
const mileage = row?.days.get(date);
const intensity = mileage && mileage > 0 ? .035 + mileage / maxDailyMileage * .13 : 0;
return {
className: `is-number is-date${mileage != null ? ' is-daily' : ' is-empty'}`,
title: mileage != null ? `来源:${row?.sources.get(date) || '—'}` : undefined,
style: intensity ? { backgroundColor: `rgba(37, 99, 235, ${intensity.toFixed(3)})` } : undefined
};
},
render: (_value: unknown, row: VehicleMileageMatrix) => {
const mileage = row.days.get(date);
return mileage != null ? `${formatKm(mileage)} km` : '—';
}
})),
{ title: '区间总里程', dataIndex: 'totalMileageKm', className: 'is-number is-total', width: 128, onCell: () => ({ className: 'is-number is-period is-total' }), render: (value: number) => `${formatKm(value)} km` }
],
tableWidth: 120 + dates.length * 96 + 128
};
}, [dates, rows]);
return <div className="v2-mileage-table-wrap" ref={scrollRef} style={{ '--v2-mileage-mobile-table-width': `${96 + dates.length * 78 + 104}px` } as CSSProperties}>
<Table className="v2-mileage-table" columns={columns} dataSource={rows} rowKey="vin" pagination={false} scroll={{ x: Math.max(556, tableWidth) }} />
</div>;
}
});
export default function StatisticsPage() {
const [searchParams, setSearchParams] = useSearchParams();

View File

@@ -10208,3 +10208,453 @@
min-height: 94px;
}
}
/*
* Semi UI mileage workspace convergence.
* Keep a single query/summary surface, one table scroll owner and the same
* restrained control, radius and evidence language used by the other
* operations workspaces.
*/
.v2-mileage-page {
gap: 10px;
padding: 14px 18px 16px;
}
.v2-mileage-query-panel.v2-workspace-filter-panel.semi-card {
overflow: visible;
border-color: #d8e3ef;
border-radius: 12px;
box-shadow: 0 8px 26px rgba(31, 53, 80, .055);
}
.v2-mileage-query-panel .v2-workspace-filter-heading {
min-height: 44px;
border-bottom-color: #e3eaf2;
background: linear-gradient(100deg, #f7faff 0%, #f9fbfd 58%, #fff 100%);
padding-inline: 13px;
}
.v2-mileage-query-panel .v2-mileage-filter {
gap: 10px;
background: #fff;
padding: 11px 12px 12px;
}
.v2-mileage-filter > label {
gap: 5px;
color: #607187;
font-size: 11px;
font-weight: 650;
}
.v2-mileage-multiselect,
.v2-mileage-filter > label > .semi-input-wrapper {
min-height: 38px;
border-color: #d8e2ed;
border-radius: 8px;
background: #fbfcfe;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .72);
}
.v2-mileage-multiselect:hover,
.v2-mileage-filter > label > .semi-input-wrapper:hover {
border-color: #b9c9dc;
background: #fff;
}
.v2-mileage-multiselect:focus-within,
.v2-mileage-filter > label > .semi-input-wrapper-focus {
border-color: #75a5ed;
background: #fff;
box-shadow: 0 0 0 3px rgba(37, 99, 235, .09);
}
.v2-mileage-selection > .semi-input-wrapper .semi-input,
.v2-mileage-filter > label > .semi-input-wrapper .semi-input {
color: #2d4058;
font-size: 12px;
}
.v2-mileage-filter > .v2-primary-button.semi-button {
min-height: 38px;
border-radius: 8px;
padding-inline: 19px;
font-size: 12px;
font-weight: 680;
box-shadow: 0 4px 12px rgba(18, 104, 223, .16);
}
.v2-mileage-source-trigger.semi-button {
min-height: 38px;
border-color: #d8e3ef;
border-radius: 8px;
background: #f8fbff;
color: #49647f;
font-size: 11px;
}
.v2-mileage-source-trigger.semi-button:hover,
.v2-mileage-source-trigger.semi-button[aria-expanded="true"] {
border-color: #aec6e4;
background: #f2f7ff;
color: #245da8;
}
.v2-mileage-source-trigger em {
color: #3d6594;
font-size: 9px;
}
.v2-mileage-ranges {
min-height: 38px;
gap: 2px;
border: 1px solid #e2e8f0;
border-radius: 9px;
background: #f5f7fa;
padding: 3px;
}
.v2-mileage-ranges .semi-button {
min-height: 30px;
border-radius: 6px;
font-size: 11px;
font-weight: 650;
}
.v2-mileage-ranges .semi-button:not(.semi-button-primary):hover {
background: #fff;
color: #1268df;
}
.v2-mileage-query-panel .v2-mileage-summary.semi-card {
border: 0;
border-top: 1px solid #e2e9f2;
border-radius: 0 0 11px 11px;
background: #fff;
box-shadow: none;
}
.v2-mileage-summary > .semi-card-body > .v2-mileage-summary-card.is-primary > .semi-card-body {
background: linear-gradient(128deg, #0c61e7 0%, #1c73ee 68%, #2c82ef 100%);
}
.v2-mileage-summary > .semi-card-body > .is-primary .v2-mileage-summary-value {
letter-spacing: -.035em;
}
.v2-mileage-summary-secondary.semi-card-group > .v2-mileage-summary-card > .semi-card-body {
justify-content: center;
padding: 11px 15px !important;
}
.v2-mileage-summary-secondary .v2-mileage-summary-card small {
color: #78879a;
font-size: 10px;
font-weight: 560;
}
.v2-mileage-summary-secondary .v2-mileage-summary-card strong {
margin: 5px 0 4px;
color: #22354d;
font-size: clamp(19px, 1.55vw, 25px);
font-weight: 760;
line-height: 1.08;
}
.v2-mileage-summary-secondary .v2-mileage-summary-card span {
display: -webkit-box;
min-height: 2.6em;
overflow: hidden;
color: #8492a4;
font-size: 9px;
line-height: 1.3;
text-overflow: ellipsis;
white-space: normal;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.v2-mileage-results.semi-card {
border-color: #d8e3ef;
border-radius: 12px;
box-shadow: 0 8px 26px rgba(31, 53, 80, .05);
}
.v2-mileage-results > .semi-card-body > .v2-workspace-panel-header {
min-height: 50px;
border-bottom-color: #e2e9f1;
background: linear-gradient(90deg, #fff 0%, #fbfcfe 100%);
padding-inline: 14px;
}
.v2-mileage-results .v2-workspace-panel-copy > h5.semi-typography {
color: #23384f;
font-size: 15px;
}
.v2-mileage-results .v2-workspace-panel-copy > .semi-typography,
.v2-mileage-results .v2-workspace-result-meta {
color: #748499;
font-size: 10px;
}
.v2-mileage-result-actions > .semi-button {
min-height: 32px;
border-radius: 8px;
font-size: 11px;
font-weight: 650;
}
.v2-mileage-table-wrap {
scrollbar-color: #9eb0c4 #f2f5f9;
scrollbar-gutter: stable;
scrollbar-width: auto;
}
.v2-mileage-table-wrap::-webkit-scrollbar {
width: 14px;
height: 14px;
}
.v2-mileage-table-wrap::-webkit-scrollbar-track {
border-radius: 999px;
background: #f2f5f9;
}
.v2-mileage-table-wrap::-webkit-scrollbar-thumb {
min-width: 56px;
min-height: 56px;
border: 3px solid #f2f5f9;
border-radius: 999px;
background: #9eb0c4;
}
.v2-mileage-table-wrap::-webkit-scrollbar-thumb:hover {
background: #7f95ae;
}
.v2-mileage-table-wrap::-webkit-scrollbar-corner {
background: #f2f5f9;
}
.v2-mileage-table .semi-table-thead > .semi-table-row > .semi-table-row-head {
height: 42px;
border-bottom-color: #dde6f0;
background: #f6f8fb;
color: #5a6d84;
font-size: 11px;
}
.v2-mileage-table .semi-table-tbody > .semi-table-row > .semi-table-row-cell {
height: 52px;
border-bottom-color: #e9eef4;
color: #43566d;
font-size: 12px;
}
.v2-mileage-table .semi-table-tbody > .semi-table-row:hover {
background: #f6faff;
}
.v2-mileage-table .semi-table-row-cell.is-plate strong {
color: #243950;
font-size: 12px;
font-weight: 720;
}
.v2-mileage-table .semi-table-row-head.is-total {
background: #edf5ff;
color: #175fbc;
}
.v2-mileage-table.semi-table-wrapper :is(.semi-table-row-head,.semi-table-row-cell).is-total,
.v2-mileage-table :is(.semi-table-row-head,.semi-table-row-cell).semi-table-cell-fixed-right {
border-left-color: #d5e2f1;
box-shadow: -6px 0 14px rgba(38, 71, 111, .045);
}
.v2-mileage-table .semi-table-row-cell.is-period {
color: #0e62cf;
font-size: 12px;
font-weight: 780;
}
.v2-mileage-results > .semi-card-body > footer {
min-height: 40px;
border-top-color: #e3eaf2;
background: #fbfcfe;
}
.v2-mileage-evidence {
min-height: 20px;
align-items: center;
justify-content: flex-start;
gap: 0;
color: #8290a2;
font-size: 9px;
}
.v2-mileage-evidence > span {
position: relative;
overflow: hidden;
padding-inline: 10px;
text-overflow: ellipsis;
}
.v2-mileage-evidence > span:first-child {
padding-left: 2px;
}
.v2-mileage-evidence > span + span::before {
position: absolute;
top: 50%;
left: 0;
width: 3px;
height: 3px;
border-radius: 50%;
background: #b4c0ce;
content: "";
transform: translateY(-50%);
}
@media (min-width: 681px) and (max-width: 1050px) {
.v2-mileage-query-panel .v2-mileage-filter {
row-gap: 10px;
}
.v2-mileage-summary > .semi-card-body {
grid-template-columns: minmax(285px, 1fr) minmax(0, 2fr);
}
}
@media (max-width: 680px) {
.v2-mileage-page {
gap: 7px;
padding: 8px;
}
.v2-mileage-query-panel.v2-workspace-filter-panel.semi-card {
border-radius: 11px;
box-shadow: 0 5px 18px rgba(31, 53, 80, .05);
}
.v2-mileage-query-panel .v2-mileage-filter {
gap: 9px;
padding: 10px;
}
.v2-mileage-filter > label > .semi-input-wrapper,
.v2-mileage-multiselect,
.v2-mileage-source-trigger.semi-button,
.v2-mileage-filter > .v2-primary-button.semi-button {
min-height: 42px;
}
.v2-mileage-query-panel .v2-mileage-summary.semi-card {
border-top: 0;
border-radius: 10px;
}
.v2-mileage-summary-secondary .v2-mileage-summary-card strong {
font-size: 16px;
}
.v2-mileage-summary-secondary .v2-mileage-summary-card span {
display: block;
min-height: 0;
overflow: hidden;
font-size: 8px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-mileage-results > .semi-card-body > .v2-workspace-panel-header {
display: grid;
min-height: 54px;
height: 54px;
grid-template-columns: 82px minmax(0, 1fr);
gap: 6px;
padding: 7px 9px;
}
.v2-mileage-results .v2-workspace-panel-copy {
width: 82px;
min-width: 82px;
max-width: 82px;
flex: none;
}
.v2-mileage-results .v2-workspace-panel-copy > h5.semi-typography {
width: auto;
overflow: visible;
font-size: 13px;
text-overflow: clip;
}
.v2-mileage-results .v2-workspace-panel-copy > .semi-typography:last-child,
.v2-mileage-results .v2-workspace-result-meta > span:last-child {
display: none;
}
.v2-mileage-results > .semi-card-body > .v2-workspace-panel-header > .v2-mileage-result-actions {
display: flex;
width: 100%;
min-width: 0;
max-width: none;
height: auto;
flex: none;
align-items: center;
justify-content: flex-end;
gap: 4px;
}
.v2-mileage-results .v2-mileage-result-actions > .v2-workspace-panel-meta {
flex: 0 0 auto;
font-size: 8px;
}
.v2-mileage-result-actions > .semi-button {
width: auto;
min-width: 0;
height: 32px;
flex: 0 0 auto;
padding-inline: 7px;
font-size: 9px;
}
.v2-mileage-table table {
width: max(100%, var(--v2-mileage-mobile-table-width)) !important;
}
.v2-mileage-table col.is-plate,
.v2-mileage-table :is(.semi-table-row-head,.semi-table-row-cell).is-plate {
width: 96px !important;
min-width: 96px !important;
max-width: 96px !important;
}
.v2-mileage-table col.is-date,
.v2-mileage-table :is(.semi-table-row-head,.semi-table-row-cell).is-date {
width: 78px !important;
min-width: 78px !important;
max-width: 78px !important;
}
.v2-mileage-table col.is-total,
.v2-mileage-table :is(.semi-table-row-head,.semi-table-row-cell).is-total {
width: 104px !important;
min-width: 104px !important;
max-width: 104px !important;
}
.v2-mileage-table .semi-table-thead > .semi-table-row > .semi-table-row-head {
height: 38px;
font-size: 10px;
}
.v2-mileage-table .semi-table-tbody > .semi-table-row > .semi-table-row-cell {
height: 44px;
font-size: 11px;
}
}