refactor(ui): converge semi monitor list
This commit is contained in:
@@ -24,6 +24,16 @@ const vehicleCardArgsSpy = vi.hoisted(() => vi.fn());
|
||||
const monitorDataArgsSpy = vi.hoisted(() => vi.fn());
|
||||
const qrToDataURLSpy = vi.hoisted(() => vi.fn());
|
||||
const monitorQueryFlags = vi.hoisted(() => ({ isLoading: false, isFetching: false, isPlaceholderData: false }));
|
||||
const monitorSummaryFixture = vi.hoisted(() => ({
|
||||
totalVehicles: 2,
|
||||
locationVehicles: 2,
|
||||
noLocationVehicles: 0,
|
||||
onlineVehicles: 2,
|
||||
offlineVehicles: 0,
|
||||
drivingVehicles: 1,
|
||||
idleVehicles: 1,
|
||||
frameToday: 10
|
||||
}));
|
||||
const vehicleCardFixture = vi.hoisted(() => ({ detail: undefined as unknown }));
|
||||
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
|
||||
|
||||
@@ -61,7 +71,7 @@ vi.mock('../hooks/useMonitorData', () => ({
|
||||
useMonitorData: (...args: unknown[]) => {
|
||||
monitorDataArgsSpy(...args);
|
||||
return {
|
||||
summary: { data: { totalVehicles: 2, locationVehicles: 2, noLocationVehicles: 0, onlineVehicles: 2, offlineVehicles: 0, drivingVehicles: 1, idleVehicles: 1, frameToday: 10 } },
|
||||
summary: { data: monitorSummaryFixture },
|
||||
vehicles: { data: { items: vehicles, total: 2 }, isError: false, ...monitorQueryFlags },
|
||||
map: { data: monitorMap, isPlaceholderData: monitorQueryFlags.isPlaceholderData },
|
||||
selectedVehicle: { data: { items: [] } }
|
||||
@@ -78,6 +88,7 @@ afterEach(() => {
|
||||
monitorQueryFlags.isLoading = false;
|
||||
monitorQueryFlags.isFetching = false;
|
||||
monitorQueryFlags.isPlaceholderData = false;
|
||||
monitorSummaryFixture.frameToday = 10;
|
||||
vehicleCardFixture.detail = undefined;
|
||||
vehicleCardArgsSpy.mockClear();
|
||||
monitorDataArgsSpy.mockClear();
|
||||
@@ -435,6 +446,7 @@ test('mounts only the mobile list representation and removes its viewport listen
|
||||
dispatchEvent: vi.fn(() => false)
|
||||
} as unknown as MediaQueryList);
|
||||
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [vehicles[0]], total: 1, limit: 50, offset: 0 });
|
||||
monitorSummaryFixture.frameToday = 2_861_323;
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
@@ -452,8 +464,14 @@ test('mounts only the mobile list representation and removes its viewport listen
|
||||
fireEvent.click(screen.getByRole('tab', { name: /列表/ }));
|
||||
expect(await screen.findByText('车辆实时列表')).toBeInTheDocument();
|
||||
await waitFor(() => expect(view.container.querySelectorAll('.v2-monitor-mobile-cards .v2-monitor-mobile-card.semi-card')).toHaveLength(1));
|
||||
const mobileCard = view.container.querySelector('.v2-monitor-mobile-card');
|
||||
expect(mobileCard).toHaveAttribute('aria-label', '粤A12345 实时数据');
|
||||
expect(mobileCard?.querySelector('.v2-monitor-mobile-primary')).toHaveTextContent('当日里程18.6 km总里程1,234 km协议来源JT/T 808');
|
||||
expect(mobileCard?.querySelectorAll('.v2-monitor-mobile-primary > div')).toHaveLength(3);
|
||||
expect(mobileCard?.querySelector('.v2-monitor-mobile-location')).toHaveTextContent('113.260000, 23.130000解析位置');
|
||||
expect(view.container.querySelector('.v2-monitor-table-scroll')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '在地图中定位粤A12345' })).toHaveTextContent('地图');
|
||||
expect(screen.getByTitle('今日上报 2,861,323 条')).toHaveTextContent('今日上报286.1万条');
|
||||
expect(view.container.querySelector('.v2-monitor-mobile-card footer')).not.toBeInTheDocument();
|
||||
expect(addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
|
||||
|
||||
|
||||
@@ -89,6 +89,14 @@ function hasTodayMileage(vehicle: VehicleRealtimeRow): vehicle is VehicleRealtim
|
||||
return vehicle.todayMileageAvailable === true && vehicle.todayMileageKm != null;
|
||||
}
|
||||
|
||||
function formatSupportCount(value: number, compact: boolean) {
|
||||
if (!compact) return formatNumber(value);
|
||||
const absolute = Math.abs(value);
|
||||
if (absolute >= 100_000_000) return `${Number((value / 100_000_000).toFixed(1))}亿`;
|
||||
if (absolute >= 10_000) return `${Number((value / 10_000).toFixed(1))}万`;
|
||||
return formatNumber(value);
|
||||
}
|
||||
|
||||
function hasRealtimeSOC(vehicle: VehicleRealtimeRow) {
|
||||
return vehicle.socAvailable ?? (vehicle.primaryProtocol === 'GB32960' || vehicle.primaryProtocol === 'YUTONG_MQTT');
|
||||
}
|
||||
@@ -126,6 +134,51 @@ const MonitorAddressCell = memo(function MonitorAddressCell({ vehicle }: { vehic
|
||||
return <div className="v2-monitor-address-result"><span title={addressQuery.data?.formattedAddress}>{addressQuery.data?.formattedAddress || '暂无地址'}</span><small>{addressQuery.dataUpdatedAt ? `解析于 ${new Date(addressQuery.dataUpdatedAt).toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })}` : ''}</small>{moved ? <Button className="v2-monitor-address-update" size="small" theme="borderless" type="warning" title="车辆已移动,按需更新地址" onClick={() => setRequested(current)}>位置已移动 · 更新</Button> : null}</div>;
|
||||
});
|
||||
|
||||
type MonitorMobileVehicleCardProps = {
|
||||
row: VehicleRealtimeRow;
|
||||
onSelect: (vin: string) => void;
|
||||
};
|
||||
|
||||
const MonitorMobileVehicleCard = memo(function MonitorMobileVehicleCard({ row, onSelect }: MonitorMobileVehicleCardProps) {
|
||||
const identity = row.plate || '未绑定车牌';
|
||||
const location = hasRealtimeLocation(row);
|
||||
return <Card className="v2-monitor-mobile-card" bodyStyle={{ padding: 0 }} aria-label={`${identity} 实时数据`}>
|
||||
<header className="v2-monitor-mobile-card-header">
|
||||
<div className="v2-monitor-mobile-identity"><strong>{identity}</strong><span>{row.vin}</span></div>
|
||||
<div className="v2-monitor-card-heading-actions">
|
||||
<b>{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}{hasRealtimeSpeed(row) ? <small>km/h</small> : null}</b>
|
||||
<Button className="v2-monitor-card-locate" size="small" theme="light" type="primary" icon={<IconMapPin />} aria-label={`在地图中定位${identity}`} onClick={() => onSelect(row.vin)}>地图</Button>
|
||||
</div>
|
||||
</header>
|
||||
<dl className="v2-monitor-mobile-primary">
|
||||
<div><dt>当日里程</dt><dd className="is-today">{hasTodayMileage(row) ? `${formatNumber(row.todayMileageKm, 1)} km` : '—'}</dd></div>
|
||||
<div><dt>总里程</dt><dd>{hasRealtimeMileage(row) ? `${formatNumber(row.totalMileageKm, 1)} km` : '—'}</dd></div>
|
||||
<div><dt>协议来源</dt><dd><span className="v2-monitor-mobile-protocol"><ProtocolTag protocol={row.primaryProtocol} compact unknownLabel="未知协议" />{row.protocols.length > 1 ? <small>{row.protocols.length} 路</small> : null}</span></dd></div>
|
||||
</dl>
|
||||
<div className={`v2-monitor-mobile-location${location ? '' : ' is-unavailable'}`}>
|
||||
<span className="v2-monitor-mobile-coordinate"><IconMapPin aria-hidden="true" />{location ? <code>{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)}</code> : <small>暂无经纬度</small>}</span>
|
||||
<MonitorAddressCell vehicle={row} />
|
||||
</div>
|
||||
</Card>;
|
||||
}, (previous, next) => {
|
||||
const before = previous.row;
|
||||
const after = next.row;
|
||||
return previous.onSelect === next.onSelect
|
||||
&& before.vin === after.vin
|
||||
&& before.plate === after.plate
|
||||
&& before.speedAvailable === after.speedAvailable
|
||||
&& before.speedKmh === after.speedKmh
|
||||
&& before.todayMileageAvailable === after.todayMileageAvailable
|
||||
&& before.todayMileageKm === after.todayMileageKm
|
||||
&& before.mileageAvailable === after.mileageAvailable
|
||||
&& before.totalMileageKm === after.totalMileageKm
|
||||
&& before.primaryProtocol === after.primaryProtocol
|
||||
&& before.protocols.join('|') === after.protocols.join('|')
|
||||
&& before.locationAvailable === after.locationAvailable
|
||||
&& before.longitude === after.longitude
|
||||
&& before.latitude === after.latitude;
|
||||
});
|
||||
|
||||
function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, mobile, onSelect, onPage, onLimit }: { rows: VehicleRealtimeRow[]; total: number; page: number; totalPages: number; limit: number; loading: boolean; mobile: boolean; onSelect: (vin: string) => void; onPage: (page: number) => void; onLimit: (limit: number) => void }) {
|
||||
const columns = useMemo(() => [
|
||||
{ title: '车辆', dataIndex: 'plate', width: 210, className: 'v2-monitor-table-vehicle', render: (_value: string, row: VehicleRealtimeRow) => <Button className="v2-monitor-vehicle-action" theme="borderless" type="tertiary" title="在地图中定位" onClick={() => onSelect(row.vin)}><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></Button> },
|
||||
@@ -138,15 +191,13 @@ function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, mo
|
||||
], [onSelect]);
|
||||
return <Card className="v2-monitor-table-panel" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
className="v2-monitor-list-header"
|
||||
title="车辆实时列表"
|
||||
description="覆盖全部授权车辆;缺失值显示“—”,地址按需解析"
|
||||
meta={`${total.toLocaleString('zh-CN')} 辆车辆`}
|
||||
description={mobile ? '实时数据 · 地址按需解析' : '覆盖全部授权车辆;缺失值显示“—”,地址按需解析'}
|
||||
meta={`${total.toLocaleString('zh-CN')} 辆${mobile ? '' : '车辆'}`}
|
||||
/>
|
||||
{!mobile ? <div className="v2-monitor-table-scroll"><Table className="v2-monitor-table" columns={columns} dataSource={rows} rowKey="vin" pagination={false} empty={null} />{loading ? <div className="v2-monitor-table-loading" role="status"><Spin size="small" tip="正在更新车辆实时数据…" /></div> : null}{!loading && !rows.length ? <Empty className="v2-monitor-table-empty" title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}</div> : null}
|
||||
{mobile ? <div className="v2-monitor-mobile-cards">{rows.map((row) => <Card className="v2-monitor-mobile-card" key={row.vin}>
|
||||
<header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><div className="v2-monitor-card-heading-actions"><b>{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}{hasRealtimeSpeed(row) ? <small>km/h</small> : null}</b><Button className="v2-monitor-card-locate" size="small" theme="light" type="primary" icon={<IconMapPin />} aria-label={`在地图中定位${row.plate || row.vin}`} onClick={() => onSelect(row.vin)}>地图</Button></div></header>
|
||||
<dl><div><dt>当日里程</dt><dd className="is-today">{hasTodayMileage(row) ? `${formatNumber(row.todayMileageKm, 1)} km` : '—'}</dd></div><div><dt>总里程</dt><dd>{hasRealtimeMileage(row) ? `${formatNumber(row.totalMileageKm, 1)} km` : '—'}</dd></div><div><dt>协议来源</dt><dd><span className="v2-monitor-mobile-protocol"><ProtocolTag protocol={row.primaryProtocol} compact unknownLabel="未知协议" />{row.protocols.length > 1 ? <small>{row.protocols.length} 路</small> : null}</span></dd></div><div><dt>经纬度</dt><dd>{hasRealtimeLocation(row) ? <code>{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)}</code> : '—'}</dd></div><div className="is-address"><dt>地理位置</dt><dd><MonitorAddressCell vehicle={row} /></dd></div></dl>
|
||||
</Card>)}{loading ? <div className="v2-monitor-table-loading" role="status"><Spin size="small" tip="正在更新车辆实时数据…" /></div> : null}{!loading && !rows.length ? <Empty className="v2-monitor-table-empty" title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}</div> : null}
|
||||
{mobile ? <div className="v2-monitor-mobile-cards">{rows.map((row) => <MonitorMobileVehicleCard row={row} onSelect={onSelect} key={row.vin} />)}{loading ? <div className="v2-monitor-table-loading" role="status"><Spin size="small" tip="正在更新车辆实时数据…" /></div> : null}{!loading && !rows.length ? <Empty className="v2-monitor-table-empty" title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}</div> : null}
|
||||
<footer><TablePagination page={page} totalPages={totalPages} info={`共 ${total.toLocaleString('zh-CN')} 辆车辆`} onPageChange={onPage} pageSize={limit} pageSizeLabel="每页车辆数" onPageSizeChange={onLimit} pageSizeOptions={[{ value: 20, label: '20 辆/页' }, { value: 50, label: '50 辆/页' }, { value: 100, label: '100 辆/页' }]} /></footer>
|
||||
</Card>;
|
||||
}
|
||||
@@ -466,7 +517,7 @@ export default function MonitorPage() {
|
||||
className="v2-monitor-summary-rail"
|
||||
items={monitorMetrics}
|
||||
context={<div className="v2-monitor-summary-support">
|
||||
<span><small>今日上报</small><strong>{formatNumber(summary.data?.frameToday ?? 0)}<i>条</i></strong><em>数据活跃度</em></span>
|
||||
<span title={`今日上报 ${formatNumber(summary.data?.frameToday ?? 0)} 条`}><small>今日上报</small><strong>{formatSupportCount(summary.data?.frameToday ?? 0, mobileLayout)}<i>条</i></strong><em>数据活跃度</em></span>
|
||||
<span><small>无实时位置</small><strong>{formatNumber(summary.data?.noLocationVehicles ?? 0)}<i>辆</i></strong><em>辅助排查</em></span>
|
||||
</div>}
|
||||
/>
|
||||
|
||||
@@ -25489,3 +25489,400 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Semi UI monitor list convergence.
|
||||
* Realtime evidence stays dense and stable during polling: desktop rows read
|
||||
* as one continuous data surface, while phone cards keep four vehicles within
|
||||
* a typical viewport without clipping the supporting fleet metrics.
|
||||
*/
|
||||
.v2-monitor-table-panel.semi-card {
|
||||
border-color: #dce5ef;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 6px 22px rgba(31, 53, 80, .045);
|
||||
}
|
||||
|
||||
.v2-monitor-table-panel > .semi-card-body > .v2-monitor-list-header {
|
||||
min-height: 58px;
|
||||
border-bottom: 1px solid #e3e9f1;
|
||||
padding: 9px 13px;
|
||||
}
|
||||
|
||||
.v2-monitor-table-panel > .semi-card-body > .v2-monitor-list-header .v2-workspace-panel-copy {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.v2-monitor-table-panel > .semi-card-body > .v2-monitor-list-header .v2-workspace-panel-copy > h5 {
|
||||
color: #263b52;
|
||||
font-size: 15px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.v2-monitor-table-panel > .semi-card-body > .v2-monitor-list-header .v2-workspace-panel-meta {
|
||||
border-radius: 999px;
|
||||
background: #eef4fb;
|
||||
padding: 4px 8px;
|
||||
color: #56718f;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.v2-monitor-table .semi-table-tbody > .semi-table-row > .semi-table-row-cell {
|
||||
height: 58px;
|
||||
border-bottom-color: #e8edf3;
|
||||
}
|
||||
|
||||
.v2-monitor-table .semi-table-tbody > .semi-table-row:hover {
|
||||
background: #f6f9fd;
|
||||
}
|
||||
|
||||
.v2-monitor-table-vehicle strong {
|
||||
color: #253a51;
|
||||
font-size: 13px;
|
||||
font-weight: 740;
|
||||
}
|
||||
|
||||
.v2-monitor-table-vehicle span {
|
||||
color: #8290a2;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.v2-monitor-summary-rail.is-queue.has-context > .semi-card-body {
|
||||
min-height: 130px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.v2-monitor-summary-rail .v2-workspace-metric-list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.v2-monitor-summary-rail .v2-workspace-metric-list > span {
|
||||
box-sizing: border-box;
|
||||
min-height: 49px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: 11px 20px 10px;
|
||||
align-content: center;
|
||||
gap: 0;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
|
||||
.v2-monitor-summary-rail .v2-workspace-metric-list > span.is-secondary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.v2-monitor-summary-rail .v2-workspace-metric-list > span:nth-child(3),
|
||||
.v2-monitor-summary-rail .v2-workspace-metric-list > span:nth-child(6) {
|
||||
border-top: 1px solid #e5ebf2;
|
||||
}
|
||||
|
||||
.v2-monitor-summary-rail .v2-workspace-metric-list > span:nth-child(3) {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.v2-monitor-summary-rail .v2-workspace-metric-list > span > :is(small, strong, em) {
|
||||
min-width: 0;
|
||||
grid-column: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.v2-monitor-summary-rail .v2-workspace-metric-list > span > small {
|
||||
grid-row: 1;
|
||||
font-size: 9px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.v2-monitor-summary-rail .v2-workspace-metric-list > span > strong,
|
||||
.v2-monitor-summary-rail .v2-workspace-metric-list > span.is-secondary > strong {
|
||||
grid-row: 2;
|
||||
font-size: 18px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.v2-monitor-summary-rail .v2-workspace-metric-list > span > em {
|
||||
display: block;
|
||||
max-width: none;
|
||||
grid-row: 3;
|
||||
font-size: 8px;
|
||||
line-height: 11px;
|
||||
}
|
||||
|
||||
.v2-monitor-summary-rail .v2-workspace-metric-context {
|
||||
display: flex;
|
||||
min-height: 38px;
|
||||
border-top: 1px solid #e5ebf2;
|
||||
border-left: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.v2-monitor-summary-support {
|
||||
width: 100%;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.v2-monitor-summary-support > span {
|
||||
min-height: 38px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-rows: 16px;
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 9px;
|
||||
}
|
||||
|
||||
.v2-monitor-summary-support small {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 9px;
|
||||
line-height: 16px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.v2-monitor-summary-support strong {
|
||||
min-width: max-content;
|
||||
overflow: visible;
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.v2-monitor-summary-support strong > i {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.v2-monitor-summary-support em {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.v2-monitor-table-panel > .semi-card-body > .v2-monitor-list-header {
|
||||
min-height: 50px;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.v2-monitor-table-panel > .semi-card-body > .v2-monitor-list-header .v2-workspace-panel-copy {
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.v2-monitor-table-panel > .semi-card-body > .v2-monitor-list-header .v2-workspace-panel-copy > h5 {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.v2-monitor-table-panel > .semi-card-body > .v2-monitor-list-header .v2-workspace-panel-copy > .semi-typography:not(h5) {
|
||||
max-width: 210px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.v2-monitor-table-panel > .semi-card-body > .v2-monitor-list-header .v2-workspace-panel-meta {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.v2-monitor-mobile-cards {
|
||||
gap: 5px;
|
||||
padding: 5px;
|
||||
scrollbar-color: #b5c2d0 transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.v2-monitor-mobile-cards > .v2-monitor-mobile-card.semi-card {
|
||||
min-height: 109px;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
border-color: #dfe6ef;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(31, 53, 80, .025);
|
||||
contain-intrinsic-size: auto 109px;
|
||||
}
|
||||
|
||||
.v2-monitor-mobile-card > .semi-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.v2-monitor-mobile-card .v2-monitor-mobile-card-header {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
height: 38px;
|
||||
min-height: 38px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 4px 8px 3px 9px;
|
||||
}
|
||||
|
||||
.v2-monitor-mobile-identity {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.v2-monitor-mobile-card .v2-monitor-mobile-identity > strong {
|
||||
overflow: hidden;
|
||||
color: #283d54;
|
||||
font-size: 14px;
|
||||
font-weight: 750;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.v2-monitor-mobile-card .v2-monitor-mobile-identity > span {
|
||||
overflow: hidden;
|
||||
color: #8795a6;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 9px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.v2-monitor-card-heading-actions {
|
||||
gap: 6px !important;
|
||||
}
|
||||
|
||||
.v2-monitor-card-heading-actions > b {
|
||||
font-size: 15px;
|
||||
font-weight: 740;
|
||||
}
|
||||
|
||||
.v2-monitor-card-heading-actions > b small {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.v2-monitor-card-heading-actions > .v2-monitor-card-locate.semi-button {
|
||||
width: 46px;
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
border-radius: 7px;
|
||||
padding: 0 5px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.v2-monitor-mobile-cards .v2-monitor-mobile-primary {
|
||||
display: grid;
|
||||
grid-template-columns: .9fr 1.08fr 1.12fr;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
border-top: 1px solid #edf1f5;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.v2-monitor-mobile-cards .v2-monitor-mobile-primary > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 27px;
|
||||
align-content: center;
|
||||
gap: 1px;
|
||||
border-right: 1px solid #edf1f5;
|
||||
padding: 0 7px;
|
||||
}
|
||||
|
||||
.v2-monitor-mobile-cards .v2-monitor-mobile-primary > div:first-child {
|
||||
padding-left: 1px;
|
||||
}
|
||||
|
||||
.v2-monitor-mobile-cards .v2-monitor-mobile-primary > div:last-child {
|
||||
border-right: 0;
|
||||
padding-right: 1px;
|
||||
}
|
||||
|
||||
.v2-monitor-mobile-cards .v2-monitor-mobile-primary dt {
|
||||
color: #8996a7;
|
||||
font-size: 9px;
|
||||
line-height: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.v2-monitor-mobile-cards .v2-monitor-mobile-primary dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: #34485f;
|
||||
font-size: 10px;
|
||||
font-weight: 680;
|
||||
line-height: 14px;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.v2-monitor-mobile-cards .v2-monitor-mobile-primary dd.is-today {
|
||||
color: #1268df;
|
||||
}
|
||||
|
||||
.v2-monitor-mobile-cards .v2-monitor-mobile-primary .v2-monitor-mobile-protocol {
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.v2-monitor-mobile-cards .v2-monitor-mobile-primary .v2-protocol-tag.semi-tag {
|
||||
max-width: 82px;
|
||||
}
|
||||
|
||||
.v2-monitor-mobile-location {
|
||||
display: grid;
|
||||
box-sizing: border-box;
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-top: 1px solid #edf1f5;
|
||||
background: #f8fafc;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.v2-monitor-mobile-coordinate {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #77889b;
|
||||
}
|
||||
|
||||
.v2-monitor-mobile-coordinate > svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex: 0 0 auto;
|
||||
color: #6f87a3;
|
||||
}
|
||||
|
||||
.v2-monitor-mobile-coordinate > :is(code, small) {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #6c7d91;
|
||||
font-size: 9px;
|
||||
line-height: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.v2-monitor-mobile-location .v2-monitor-address-action.semi-button,
|
||||
.v2-monitor-mobile-location .v2-monitor-address-update.semi-button {
|
||||
min-width: 68px;
|
||||
height: 25px;
|
||||
min-height: 25px;
|
||||
border-radius: 7px;
|
||||
padding: 0 7px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.v2-monitor-mobile-location .v2-monitor-address-loading {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.v2-monitor-mobile-location .v2-monitor-address-result {
|
||||
display: grid;
|
||||
max-width: 150px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.v2-monitor-mobile-location .v2-monitor-address-result > :is(span, small) {
|
||||
overflow: hidden;
|
||||
font-size: 9px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +1,48 @@
|
||||
# 账号管理 Semi UI Design QA
|
||||
# 全局监控列表 Semi UI Design QA
|
||||
|
||||
- source visual truth: 当前生产版本 `/users`
|
||||
- source visual truth: 生产版本 `e944b797` 的 `/monitor?mode=list`
|
||||
- source screenshots:
|
||||
- `/tmp/users-desktop-before.png`
|
||||
- `/tmp/users-mobile-before.png`
|
||||
- desktop: `/tmp/semi-continuous-audit-20260719/04-monitor-list-desktop.png`
|
||||
- mobile: `/tmp/semi-continuous-audit-20260719/05-monitor-list-mobile.png`
|
||||
- implementation screenshots:
|
||||
- `/tmp/users-desktop-after.png`
|
||||
- `/tmp/users-mobile-after.png`
|
||||
- `/tmp/users-mobile-detail-after.png`
|
||||
- implementation paths:
|
||||
- `apps/web/src/v2/pages/UsersPage.tsx`
|
||||
- `apps/web/src/v2/pages/UsersPage.test.tsx`
|
||||
- `apps/web/src/v2/styles/workspace.css`
|
||||
- desktop: `/tmp/semi-continuous-audit-20260719/07-monitor-list-desktop-after.png`
|
||||
- mobile: `/tmp/semi-continuous-audit-20260719/06-monitor-list-mobile-after.png`
|
||||
- comparison evidence:
|
||||
- mobile before/after: `/tmp/semi-continuous-audit-20260719/monitor-mobile-comparison.png`
|
||||
- viewport and state:
|
||||
- desktop `1440 × 900`,生产基线截图原始高度为 `810px`,在对比图中补齐到 `900px`
|
||||
- desktop `1440 × 900`
|
||||
- mobile `390 × 844`
|
||||
- 使用生产管理员真实数据,共 5 个客户账号
|
||||
- 验证账号目录、账号选中状态、车辆授权详情和移动端侧滑面板
|
||||
|
||||
## Comparison Evidence
|
||||
|
||||
- desktop before/after: `/tmp/users-desktop-comparison.png`
|
||||
- mobile before/after: `/tmp/users-mobile-comparison.png`
|
||||
- directory focus before/after: `/tmp/users-directory-focus-comparison.png`
|
||||
- 三组对比图均已通过 `view_image` 并排检查。
|
||||
- 使用管理员生产数据,共 `1,024` 辆授权车辆
|
||||
- 列表视图、第一页、每页 `50` 辆
|
||||
|
||||
## Fidelity Ledger
|
||||
|
||||
| Surface | Required fidelity | Implementation evidence | Result |
|
||||
| --- | --- | --- | --- |
|
||||
| Directory hierarchy | 账号列表必须占据主工作区,不能由相互割裂的大卡片主导。 | 桌面端改为连续的证据表,行间使用轻分隔线、克制悬浮反馈与选中左侧强调线。 | Improved. |
|
||||
| Account identity | 客户名称、登录账号和身份来源需形成稳定扫描顺序。 | 名称与账号仍居首,并新增 Semi `Tag` 标注“本地”或外部身份源。 | Improved. |
|
||||
| Permission evidence | 菜单数、车辆数、状态和最近登录需保持对齐。 | 权限数字采用紧凑的浅色单元格表面;状态和时间列仍在原有网格位置。 | Preserved. |
|
||||
| Selected state | 当前编辑账号必须可快速定位。 | 选中行同时保留背景色和蓝色前导强调,详情侧滑面板与选中行一致。 | Improved. |
|
||||
| Mobile density | 移动端不能为了桌面连续表牺牲触控与信息密度。 | `390 × 844` 下保留独立卡片间距、紧凑身份标签和可点击整行。 | Preserved. |
|
||||
| Detail workflow | 打开账号、切换车辆权限、关闭面板必须完整可用。 | 桌面与移动端均验证侧滑面板;移动端车辆授权列表、状态、页签和底部动作无重叠。 | Passed. |
|
||||
| Asset fidelity | 品牌 Logo 与图标体系不得被临时图形替换。 | 保留现有官方 Logo、Semi 图标及组件;没有新增手绘 SVG、CSS 图标或装饰性位图。 | Preserved. |
|
||||
| Monitoring workflow | 搜索、筛选、地图/列表切换、分页和实时刷新周期不能被视觉重构改变。 | 保留原查询、路由和轮询实现,只重组列表渲染与响应式样式。 | Preserved. |
|
||||
| Fleet hierarchy | 车辆总数、当前在线、行驶和告警必须优先;今日上报与无实时位置属于辅助证据。 | 手机端主指标采用清晰的 `2 × 2` 网格;辅助指标独立放在底部并压缩大数。 | Improved. |
|
||||
| Vehicle evidence | 车牌、VIN、速度、当日里程、总里程、协议和位置必须在一眼可扫范围内。 | 手机卡片固定为身份、三项核心证据、位置三层;桌面继续使用连续 Semi Table。 | Improved. |
|
||||
| Mobile density | 单屏车辆数需要显著增加,但不牺牲字段可辨认性。 | 卡片高度从约 `147px` 降至 `109px`;列表视口可容纳约 `4.05` 张卡片,截图中从约 `2.5` 辆提升到 `4` 辆。 | Improved. |
|
||||
| Realtime performance | 高频刷新不能让全部车辆卡片无差别重绘。 | 移动卡片使用字段级 `memo` 比较;不可见卡片继续使用 `content-visibility` 和固有尺寸占位。 | Improved. |
|
||||
| Address cost | 地理位置不得随列表刷新自动消耗逆地理编码 API。 | 经纬度始终可见;地址仍由用户点击“解析位置”后按需请求。 | Preserved. |
|
||||
| Asset fidelity | 品牌和图标不得用临时图形替代。 | 沿用生产 Logo、Semi Icon、Card、Button、Table、Tag 与 Pagination。 | Preserved. |
|
||||
|
||||
## Interaction And Accessibility Checks
|
||||
|
||||
- 点击账号行可打开详情,关闭后回到原目录状态;
|
||||
- 车辆权限页签可点击,授权车辆卡片与状态信息完整;
|
||||
- 搜索与状态筛选入口保持原有行为;
|
||||
- 移动端侧滑面板没有遮挡页签、内容或底部动作;
|
||||
- 控制台无本次改动产生的运行时异常;仅存在 Semi UI 在 React StrictMode 下既有的 `findDOMNode` 弃用警告。
|
||||
- 手机端“筛选”可展开并可通过“收起”恢复列表;
|
||||
- 地图/列表使用原有可访问 tab 语义,车辆卡片具有“车牌 + 实时数据”标签;
|
||||
- “地图”和“解析位置”按钮均保留车辆级可访问名称;
|
||||
- `390px` 视口文档横向溢出为 `0px`,主指标和辅助指标无裁切;
|
||||
- 移动卡片实测高度 `109px`,第一页仍渲染 `50` 辆并由内部列表滚动;
|
||||
- 控制台未发现本次改动产生的运行时异常;仅保留 Semi UI 在 React StrictMode 下既有的 `findDOMNode` 弃用警告。
|
||||
|
||||
## Comparison History
|
||||
|
||||
### Pass 1 — fixed
|
||||
|
||||
- 原实现的桌面账号行被拆成多个带间距卡片,视觉体量偏大,证据列扫描节奏不连续。
|
||||
- 已收敛为连续目录表,并补充身份来源标签,让账号、授权和状态形成一条稳定阅读路径。
|
||||
- 修复后对比图中没有发现 P0、P1 或 P2 视觉与交互问题。
|
||||
- 当前唯一低风险残余是生产数据均为本地身份源,外部身份源标签由渲染逻辑覆盖,尚无生产样本可做视觉实证。
|
||||
- 生产手机端的“今日上报”和“无实时位置”数值被截断,信息层级与主要车队指标混在一起。
|
||||
- 生产车辆卡片纵向留白过多,`390 × 844` 首屏只能看到约 `2.5` 辆。
|
||||
- 已将辅助指标改成完整的紧凑数值,将每辆车的协议、里程与坐标收拢到固定证据网格。
|
||||
- 并排对比后未发现 P0、P1 或 P2 视觉与交互问题。
|
||||
|
||||
final result: passed
|
||||
|
||||
Reference in New Issue
Block a user