feat: unify mobile vehicle diagnostics
This commit is contained in:
@@ -409,18 +409,32 @@ test('allows provider maintenance but keeps canonical source policy read only',
|
|||||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
renderOperations(client);
|
renderOperations(client);
|
||||||
fireEvent.click(screen.getByRole('tab', { name: '单车诊断' }));
|
fireEvent.click(screen.getByRole('tab', { name: '单车诊断' }));
|
||||||
fireEvent.change(screen.getByLabelText('按车牌或 VIN 搜索诊断车辆'), { target: { value: '沪A' } });
|
const filterToggle = screen.getByRole('button', { name: '修改诊断车辆:尚未选择车辆' });
|
||||||
|
expect(filterToggle).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
fireEvent.click(filterToggle);
|
||||||
|
const filterDialog = await screen.findByRole('dialog', { name: '诊断车辆筛选' });
|
||||||
|
expect(document.querySelector('.v2-source-mobile-filter-sidesheet')).toHaveClass('semi-sidesheet-bottom');
|
||||||
|
expect(document.querySelector('.v2-source-mobile-filter-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(78dvh, 660px)' });
|
||||||
|
fireEvent.change(within(filterDialog).getByLabelText('按车牌或 VIN 搜索诊断车辆'), { target: { value: '沪A' } });
|
||||||
const candidateButton = await waitFor(() => {
|
const candidateButton = await waitFor(() => {
|
||||||
const button = document.querySelector<HTMLButtonElement>('.v2-source-candidates button');
|
const button = filterDialog.querySelector<HTMLButtonElement>('.v2-source-candidates button');
|
||||||
expect(button).toBeTruthy();
|
expect(button).toBeTruthy();
|
||||||
return button!;
|
return button!;
|
||||||
});
|
});
|
||||||
fireEvent.click(candidateButton);
|
fireEvent.click(candidateButton);
|
||||||
|
|
||||||
expect(await screen.findByText('协议融合快照只能维护提供方')).toBeInTheDocument();
|
expect(await screen.findByText('协议融合快照只能维护提供方')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: '修改诊断车辆:已选 沪A00001' })).toHaveAttribute('aria-expanded', 'false');
|
const selectedToggle = screen.getByRole('button', { name: '修改诊断车辆:已选 沪A00001' });
|
||||||
expect(document.querySelector('.v2-source-filter-panel')).toHaveClass('is-mobile-collapsed');
|
expect(selectedToggle).toHaveAttribute('aria-expanded', 'false');
|
||||||
expect(document.querySelector('.v2-source-search')).toHaveClass('is-mobile-collapsed');
|
expect(filterDialog).toHaveClass('semi-sidesheet-animation-content_hide_bottom');
|
||||||
|
fireEvent.click(selectedToggle);
|
||||||
|
const selectedSearch = within(filterDialog).getByLabelText('按车牌或 VIN 搜索诊断车辆');
|
||||||
|
expect(selectedSearch).toHaveValue('沪A00001');
|
||||||
|
fireEvent.change(selectedSearch, { target: { value: '其他车辆' } });
|
||||||
|
fireEvent.click(within(filterDialog).getByRole('button', { name: '关闭诊断车辆筛选' }));
|
||||||
|
fireEvent.click(selectedToggle);
|
||||||
|
expect(selectedSearch).toHaveValue('沪A00001');
|
||||||
|
fireEvent.click(within(filterDialog).getByRole('button', { name: '关闭诊断车辆筛选' }));
|
||||||
expect(document.querySelector('.v2-source-table')).not.toBeInTheDocument();
|
expect(document.querySelector('.v2-source-table')).not.toBeInTheDocument();
|
||||||
expect(document.querySelector('.v2-source-mobile-card.semi-card')).toBeInTheDocument();
|
expect(document.querySelector('.v2-source-mobile-card.semi-card')).toBeInTheDocument();
|
||||||
expect(document.querySelector('.v2-source-mobile-descriptions.semi-descriptions')).toBeInTheDocument();
|
expect(document.querySelector('.v2-source-mobile-descriptions.semi-descriptions')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useSearchParams } from 'react-router-dom';
|
|||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import type { VehicleCoverageRow, VehicleLocationSourceEvidence, VehicleSourceDiagnostic } from '../../api/types';
|
import type { VehicleCoverageRow, VehicleLocationSourceEvidence, VehicleSourceDiagnostic } from '../../api/types';
|
||||||
import { InlineError } from '../shared/AsyncState';
|
import { InlineError } from '../shared/AsyncState';
|
||||||
|
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
|
||||||
import { PlatformTime } from '../shared/PlatformTime';
|
import { PlatformTime } from '../shared/PlatformTime';
|
||||||
import { SegmentedTabs } from '../shared/SegmentedTabs';
|
import { SegmentedTabs } from '../shared/SegmentedTabs';
|
||||||
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
|
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
|
||||||
@@ -238,7 +239,7 @@ function SourceDiagnosticWorkspace() {
|
|||||||
const deferredKeyword = useDeferredValue(keyword.trim());
|
const deferredKeyword = useDeferredValue(keyword.trim());
|
||||||
const [selected, setSelected] = useState<VehicleCoverageRow>();
|
const [selected, setSelected] = useState<VehicleCoverageRow>();
|
||||||
const [candidateOffset, setCandidateOffset] = useState(0);
|
const [candidateOffset, setCandidateOffset] = useState(0);
|
||||||
const [filtersCollapsed, setFiltersCollapsed] = useState(false);
|
const [filtersCollapsed, setFiltersCollapsed] = useState(() => mobileLayout);
|
||||||
useEffect(() => setCandidateOffset(0), [deferredKeyword]);
|
useEffect(() => setCandidateOffset(0), [deferredKeyword]);
|
||||||
const candidates = useQuery({
|
const candidates = useQuery({
|
||||||
queryKey: ['ops-source-candidates', deferredKeyword, candidateOffset],
|
queryKey: ['ops-source-candidates', deferredKeyword, candidateOffset],
|
||||||
@@ -260,14 +261,29 @@ function SourceDiagnosticWorkspace() {
|
|||||||
setKeyword(vehicle.plate || vehicle.vin);
|
setKeyword(vehicle.plate || vehicle.vin);
|
||||||
if (mobileLayout) setFiltersCollapsed(true);
|
if (mobileLayout) setFiltersCollapsed(true);
|
||||||
};
|
};
|
||||||
const submit = (event: FormEvent) => {
|
const chooseFirst = () => {
|
||||||
event.preventDefault();
|
|
||||||
const first = candidates.data?.items[0];
|
const first = candidates.data?.items[0];
|
||||||
if (first) choose(first);
|
if (first) choose(first);
|
||||||
};
|
};
|
||||||
|
const submit = (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
chooseFirst();
|
||||||
|
};
|
||||||
|
const clearSelection = () => {
|
||||||
|
setKeyword('');
|
||||||
|
setSelected(undefined);
|
||||||
|
setFiltersCollapsed(true);
|
||||||
|
};
|
||||||
const data = diagnostic.data;
|
const data = diagnostic.data;
|
||||||
const editable = session.data?.role === 'admin';
|
const editable = session.data?.role === 'admin';
|
||||||
const selectedLabel = selected ? selected.plate || selected.vin : '';
|
const selectedLabel = selected ? selected.plate || selected.vin : '';
|
||||||
|
const browsingCandidates = Boolean(deferredKeyword) && deferredKeyword !== selectedLabel;
|
||||||
|
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
|
||||||
|
const closeMobileFilters = () => {
|
||||||
|
if (selectedLabel) setKeyword(selectedLabel);
|
||||||
|
setFiltersCollapsed(true);
|
||||||
|
};
|
||||||
|
useSideSheetA11y(mobileFiltersOpen, '.v2-source-mobile-filter-sidesheet', 'v2-source-mobile-filter-sheet', '诊断车辆筛选', '关闭诊断车辆筛选');
|
||||||
const filterStatus = selectedLabel
|
const filterStatus = selectedLabel
|
||||||
? `已选 ${selectedLabel}`
|
? `已选 ${selectedLabel}`
|
||||||
: deferredKeyword
|
: deferredKeyword
|
||||||
@@ -281,8 +297,53 @@ function SourceDiagnosticWorkspace() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['ops-source-readiness-v2'] })
|
queryClient.invalidateQueries({ queryKey: ['ops-source-readiness-v2'] })
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
const sourceSearchInput = <Input
|
||||||
|
aria-label="按车牌或 VIN 搜索诊断车辆"
|
||||||
|
prefix={<IconSearch />}
|
||||||
|
value={keyword}
|
||||||
|
onChange={setKeyword}
|
||||||
|
placeholder="输入车牌或 VIN,支持模糊搜索"
|
||||||
|
/>;
|
||||||
|
const sourceCandidateList = browsingCandidates ? <VehicleCandidateList
|
||||||
|
className="v2-source-candidates"
|
||||||
|
items={candidates.data?.items ?? []}
|
||||||
|
loading={candidates.isFetching}
|
||||||
|
loadingText="正在查询车辆…"
|
||||||
|
emptyText="没有匹配的已绑定车辆"
|
||||||
|
actionLabel="诊断"
|
||||||
|
showProtocols
|
||||||
|
onSelect={choose}
|
||||||
|
footer={(candidates.data?.total ?? 0) > 20 ? <><span>第 {Math.floor(candidateOffset / 20) + 1} / {Math.ceil((candidates.data?.total ?? 0) / 20)} 页</span><div><Button theme="light" size="small" disabled={candidateOffset === 0} onClick={() => setCandidateOffset(Math.max(0, candidateOffset - 20))}>上一页</Button><Button theme="light" size="small" disabled={candidateOffset + 20 >= (candidates.data?.total ?? 0)} onClick={() => setCandidateOffset(candidateOffset + 20)}>下一页</Button></div></> : undefined}
|
||||||
|
/> : null;
|
||||||
return <>
|
return <>
|
||||||
<WorkspaceFilterPanel
|
{mobileLayout ? <div className="v2-source-mobile-discovery">
|
||||||
|
<MobileFilterToggle
|
||||||
|
title="诊断车辆"
|
||||||
|
summary={selectedLabel ? `已选 ${selectedLabel}` : '尚未选择车辆'}
|
||||||
|
expanded={mobileFiltersOpen}
|
||||||
|
expandedLabel="关闭"
|
||||||
|
collapsedLabel="修改"
|
||||||
|
onToggle={() => mobileFiltersOpen ? closeMobileFilters() : setFiltersCollapsed(false)}
|
||||||
|
/>
|
||||||
|
<SideSheet
|
||||||
|
className="v2-source-mobile-filter-sidesheet"
|
||||||
|
visible={mobileFiltersOpen}
|
||||||
|
placement="bottom"
|
||||||
|
height="min(78dvh, 660px)"
|
||||||
|
aria-label="诊断车辆筛选"
|
||||||
|
title={<div className="v2-source-mobile-filter-title"><strong>选择诊断车辆</strong><span>按车牌或 VIN 搜索已绑定车辆</span></div>}
|
||||||
|
onCancel={closeMobileFilters}
|
||||||
|
footer={<div className="v2-source-mobile-filter-footer"><Button theme="light" disabled={!selected && !keyword} onClick={clearSelection}>清除选择</Button><Button theme="solid" disabled={!browsingCandidates || !candidates.data?.items.length} onClick={chooseFirst}>诊断首个结果</Button></div>}
|
||||||
|
>
|
||||||
|
<form className="v2-source-mobile-filter-form" onSubmit={submit}>
|
||||||
|
<section className="v2-source-mobile-filter-section" aria-label="诊断车辆搜索">
|
||||||
|
<header><strong>车辆范围</strong><span>{selectedLabel ? `当前诊断 ${selectedLabel},选择其他车辆后再切换` : '每次只读取一辆车的来源证据,不扫描整车队'}</span></header>
|
||||||
|
{sourceSearchInput}
|
||||||
|
</section>
|
||||||
|
{sourceCandidateList}
|
||||||
|
</form>
|
||||||
|
</SideSheet>
|
||||||
|
</div> : <WorkspaceFilterPanel
|
||||||
className="v2-source-filter-panel"
|
className="v2-source-filter-panel"
|
||||||
title="诊断车辆"
|
title="诊断车辆"
|
||||||
description="按车牌或 VIN 选择一辆车,仅按需加载该车来源证据"
|
description="按车牌或 VIN 选择一辆车,仅按需加载该车来源证据"
|
||||||
@@ -294,21 +355,11 @@ function SourceDiagnosticWorkspace() {
|
|||||||
onToggle={() => setFiltersCollapsed((value) => !value)}
|
onToggle={() => setFiltersCollapsed((value) => !value)}
|
||||||
>
|
>
|
||||||
<form className={`v2-source-search${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}>
|
<form className={`v2-source-search${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}>
|
||||||
<Input aria-label="按车牌或 VIN 搜索诊断车辆" prefix={<IconSearch />} value={keyword} onChange={(value) => { setKeyword(value); setSelected(undefined); }} placeholder="输入车牌或 VIN,支持模糊搜索" />
|
{sourceSearchInput}
|
||||||
<Button htmlType="submit" theme="solid" disabled={!candidates.data?.items.length}>诊断首个结果</Button>
|
<Button htmlType="submit" theme="solid" disabled={!browsingCandidates || !candidates.data?.items.length}>诊断首个结果</Button>
|
||||||
{deferredKeyword && !selected ? <VehicleCandidateList
|
{sourceCandidateList}
|
||||||
className="v2-source-candidates"
|
|
||||||
items={candidates.data?.items ?? []}
|
|
||||||
loading={candidates.isFetching}
|
|
||||||
loadingText="正在查询车辆…"
|
|
||||||
emptyText="没有匹配的已绑定车辆"
|
|
||||||
actionLabel="诊断"
|
|
||||||
showProtocols
|
|
||||||
onSelect={choose}
|
|
||||||
footer={(candidates.data?.total ?? 0) > 20 ? <><span>第 {Math.floor(candidateOffset / 20) + 1} / {Math.ceil((candidates.data?.total ?? 0) / 20)} 页</span><div><Button theme="light" size="small" disabled={candidateOffset === 0} onClick={() => setCandidateOffset(Math.max(0, candidateOffset - 20))}>上一页</Button><Button theme="light" size="small" disabled={candidateOffset + 20 >= (candidates.data?.total ?? 0)} onClick={() => setCandidateOffset(candidateOffset + 20)}>下一页</Button></div></> : undefined}
|
|
||||||
/> : null}
|
|
||||||
</form>
|
</form>
|
||||||
</WorkspaceFilterPanel>
|
</WorkspaceFilterPanel>}
|
||||||
<Card className="v2-source-diagnostic" bodyStyle={{ padding: 0 }}>
|
<Card className="v2-source-diagnostic" bodyStyle={{ padding: 0 }}>
|
||||||
<WorkspacePanelHeader
|
<WorkspacePanelHeader
|
||||||
title="单车多来源诊断"
|
title="单车多来源诊断"
|
||||||
|
|||||||
@@ -17235,3 +17235,146 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Semi UI single-vehicle diagnostic discovery.
|
||||||
|
* Keep the diagnosis evidence as the mobile workspace and move vehicle
|
||||||
|
* discovery into the same focused bottom-sheet pattern used elsewhere.
|
||||||
|
*/
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.v2-source-mobile-discovery {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-source-mobile-discovery > .v2-mobile-filter-toggle.semi-button {
|
||||||
|
min-height: 58px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-source-mobile-filter-sidesheet .semi-sidesheet-inner {
|
||||||
|
width: 100vw !important;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 18px 18px 0 0;
|
||||||
|
background: #f4f7fb;
|
||||||
|
box-shadow: 0 -20px 56px rgba(25, 45, 72, .2);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-source-mobile-filter-sidesheet.semi-sidesheet-bottom .semi-sidesheet-header {
|
||||||
|
min-height: 68px;
|
||||||
|
border-bottom: 1px solid #dfe7f0;
|
||||||
|
background: rgba(255, 255, 255, .98);
|
||||||
|
padding: 11px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-source-mobile-filter-sidesheet.semi-sidesheet-bottom .semi-sidesheet-body {
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #f4f7fb;
|
||||||
|
padding: 12px;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-source-mobile-filter-sidesheet.semi-sidesheet-bottom .semi-sidesheet-footer {
|
||||||
|
border-top: 1px solid #dfe7f0;
|
||||||
|
background: rgba(255, 255, 255, .98);
|
||||||
|
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-source-mobile-filter-title {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-source-mobile-filter-title > strong {
|
||||||
|
color: #253a52;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-source-mobile-filter-title > span {
|
||||||
|
color: #8090a3;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-source-mobile-filter-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-source-mobile-filter-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid #dfe7f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 12px;
|
||||||
|
box-shadow: 0 8px 24px rgba(31, 53, 80, .055);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-source-mobile-filter-section > header {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-source-mobile-filter-section > header > strong {
|
||||||
|
color: #2b4058;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-source-mobile-filter-section > header > span {
|
||||||
|
color: #7c8b9e;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-source-mobile-filter-section > .semi-input-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-source-mobile-filter-section > .semi-input-wrapper .semi-input {
|
||||||
|
height: 42px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-source-mobile-filter-sidesheet .v2-source-candidates.v2-vehicle-candidate-list {
|
||||||
|
position: static;
|
||||||
|
max-height: min(390px, 43dvh);
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #dfe7f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 8px 24px rgba(31, 53, 80, .055);
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-source-mobile-filter-sidesheet .v2-source-candidates > .v2-vehicle-option.semi-button {
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-source-mobile-filter-footer {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
grid-template-columns: minmax(0, .72fr) minmax(0, 1.28fr);
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-source-mobile-filter-footer > .semi-button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
border-radius: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user