diff --git a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx index 0148ed47..a76c74e4 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx @@ -184,6 +184,8 @@ test('reconciles service identities with bound and identity-required vehicles', expect(screen.getByText('验收标准').closest('.semi-card')).toHaveClass('v2-ops-source-card'); expect(screen.queryByText('单车多来源诊断')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('tab', { name: '单车诊断' })); + expect(screen.getByRole('heading', { name: '诊断车辆', level: 5 })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: '诊断车辆', level: 5 }).closest('.semi-card')).toHaveClass('v2-source-filter-panel', 'v2-workspace-filter-panel'); expect(screen.getByText('先选择一辆车').closest('.semi-empty')).toHaveClass('v2-source-empty'); expect(screen.getByText('单车多来源诊断').closest('.semi-card')).toHaveClass('v2-source-diagnostic'); expect(screen.queryByText('统一车辆视角')).not.toBeInTheDocument(); @@ -252,6 +254,7 @@ test('fuzzy searches a vehicle and renders all source diagnosis evidence', async }); fireEvent.click(candidateButton); expect(await screen.findByText('当前推荐 G7')).toBeInTheDocument(); + expect(screen.getByText('1 个来源')).toBeInTheDocument(); expect(document.querySelectorAll('.v2-source-summary-card.semi-card')).toHaveLength(4); expect(document.querySelectorAll('.v2-source-summary-card')[1]?.querySelector('.semi-tag')).toHaveTextContent('JT808'); expect(screen.getByText('推荐说明').closest('.semi-card')).toHaveClass('v2-source-recommendation'); @@ -324,6 +327,9 @@ test('allows provider maintenance but keeps canonical source policy read only', fireEvent.click(candidateButton); expect(await screen.findByText('协议融合快照只能维护提供方')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '修改诊断车辆:已选 沪A00001' })).toHaveAttribute('aria-expanded', 'false'); + expect(document.querySelector('.v2-source-filter-panel')).toHaveClass('is-mobile-collapsed'); + expect(document.querySelector('.v2-source-search')).toHaveClass('is-mobile-collapsed'); expect(document.querySelector('.v2-source-table')).not.toBeInTheDocument(); expect(document.querySelector('.v2-source-mobile-card.semi-card')).toBeInTheDocument(); expect(document.querySelector('.v2-source-mobile-descriptions.semi-descriptions')).toBeInTheDocument(); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx index eb62264c..76881fca 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx @@ -8,6 +8,7 @@ import { InlineError } from '../shared/AsyncState'; import { PageHeader } from '../shared/PageHeader'; import { SegmentedTabs } from '../shared/SegmentedTabs'; import { VehicleCandidateList } from '../shared/VehicleCandidateList'; +import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel'; import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader'; import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy'; import { useMobileLayout } from '../hooks/useMobileLayout'; @@ -208,6 +209,7 @@ function SourceDiagnosticWorkspace() { const deferredKeyword = useDeferredValue(keyword.trim()); const [selected, setSelected] = useState(); const [candidateOffset, setCandidateOffset] = useState(0); + const [filtersCollapsed, setFiltersCollapsed] = useState(false); useEffect(() => setCandidateOffset(0), [deferredKeyword]); const candidates = useQuery({ queryKey: ['ops-source-candidates', deferredKeyword, candidateOffset], @@ -227,6 +229,7 @@ function SourceDiagnosticWorkspace() { const choose = (vehicle: VehicleCoverageRow) => { setSelected(vehicle); setKeyword(vehicle.plate || vehicle.vin); + if (mobileLayout) setFiltersCollapsed(true); }; const submit = (event: FormEvent) => { event.preventDefault(); @@ -235,6 +238,12 @@ function SourceDiagnosticWorkspace() { }; const data = diagnostic.data; const editable = session.data?.role === 'admin'; + const selectedLabel = selected ? selected.plate || selected.vin : ''; + const filterStatus = selectedLabel + ? `已选 ${selectedLabel}` + : deferredKeyword + ? candidates.isFetching ? '正在查找' : `${candidates.data?.total ?? 0} 辆候选` + : '等待选择车辆'; const onSourceSaved = (next: VehicleSourceDiagnostic) => { queryClient.setQueryData(['ops-source-diagnostic', next.evidence.vin], next); void Promise.all([ @@ -243,31 +252,45 @@ function SourceDiagnosticWorkspace() { queryClient.invalidateQueries({ queryKey: ['ops-source-readiness-v2'] }) ]); }; - return - } onClick={() => diagnostic.refetch()} disabled={diagnostic.isFetching}>刷新该车 : null} - /> -
- } value={keyword} onChange={(value) => { setKeyword(value); setSelected(undefined); }} placeholder="输入车牌或 VIN,支持模糊搜索" /> - - {deferredKeyword && !selected ? 20 ? <>第 {Math.floor(candidateOffset / 20) + 1} / {Math.ceil((candidates.data?.total ?? 0) / 20)} 页
: undefined} - /> : null} - - {diagnostic.isError ? diagnostic.refetch()} /> : null} - {!selected ? : null} - {selected && diagnostic.isPending ?
正在读取来源证据只查询当前车辆,不会扫描整车队。
: null} - {data ? <> + return <> + setFiltersCollapsed((value) => !value)} + > +
+ } value={keyword} onChange={(value) => { setKeyword(value); setSelected(undefined); }} placeholder="输入车牌或 VIN,支持模糊搜索" /> + + {deferredKeyword && !selected ? 20 ? <>第 {Math.floor(candidateOffset / 20) + 1} / {Math.ceil((candidates.data?.total ?? 0) / 20)} 页
: undefined} + /> : null} + +
+ + {data ? `${data.evidence.locationSources.length} 个来源` : selected ? '正在读取证据' : '等待选择车辆'}} + actions={selected ? : null} + /> + {diagnostic.isError ? diagnostic.refetch()} /> : null} + {!selected ? : null} + {selected && diagnostic.isPending ?
正在读取来源证据只查询当前车辆,不会扫描整车队。
: null} + {data ? <>
车辆{data.evidence.plate || selected?.plate || '未绑定车牌'}{data.evidence.vin} 当前推荐{data.evidence.recommendedLocationLabel || '暂无推荐'}{data.evidence.recommendedLocationProtocol || '—'} @@ -282,8 +305,9 @@ function SourceDiagnosticWorkspace() { {data.policy.audit.length ?
    {data.policy.audit.map((item) =>
  1. v{item.version}{item.summary}{item.changeType === 'provider' ? '提供方' : '策略'} · {item.actor} · {fmt(item.changedAt)}
  2. )}
:

该车辆尚无人工来源策略或提供方变更。

}
- : null} - ; + : null} + + ; } export default function OperationsPage() { diff --git a/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.test.tsx index 23f8d639..25330e02 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.test.tsx @@ -44,7 +44,11 @@ test('deduplicates vehicle candidates by VIN and renders granted vehicles plate const view = render(); const customer = await screen.findByRole('button', { name: /选择客户 华东客户/ }); + expect(screen.getByRole('heading', { name: '账号范围', level: 5 })).toBeInTheDocument(); + expect(screen.getByText('按客户名称、登录账号、客户标识与启停状态查找')).toBeInTheDocument(); + expect(screen.getAllByText('1 / 1 个账号')).toHaveLength(2); expect(view.container.querySelector('.v2-user-list.semi-card')).toBeInTheDocument(); + expect(view.container.querySelector('.v2-user-filter-panel.v2-workspace-filter-panel.semi-card')).toBeInTheDocument(); expect(view.container.querySelector('.v2-customer-list.semi-list')).toBeInTheDocument(); expect(customer).toHaveClass('semi-button', 'v2-user-list-item'); expect(customer.closest('.semi-list-item')).toHaveClass('v2-user-list-row'); @@ -145,6 +149,10 @@ test('keeps the customer directory visible on mobile and opens details on demand expect(view.container.querySelector('.v2-user-mobile-picker')).not.toBeInTheDocument(); expect(view.container.querySelector('.v2-customer-list.semi-list')).toBeInTheDocument(); expect(screen.getByRole('textbox', { name: '搜索客户账号' })).toBeInTheDocument(); + const filterToggle = screen.getByRole('button', { name: '修改账号范围:全部状态' }); + expect(filterToggle).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(filterToggle); + expect(screen.getByRole('button', { name: '收起账号范围:全部状态' })).toHaveAttribute('aria-expanded', 'true'); expect(view.container.querySelector('.v2-user-editor')).not.toBeInTheDocument(); fireEvent.click(customer); expect(await screen.findByRole('button', { name: '关闭账号详情' })).toBeInTheDocument(); @@ -155,6 +163,43 @@ test('keeps the customer directory visible on mobile and opens details on demand expect(screen.getAllByRole('button', { name: '新建客户账号' })).toHaveLength(1); }); +test('filters the customer directory by status and exposes the active result scope', async () => { + mocks.adminUsers.mockResolvedValue([ + { + id: 7, username: 'customer-east', displayName: '华东客户', userType: 'customer', status: 'enabled', + customerRef: 'east', tenantRef: '', authProvider: 'local', menuKeys: ['monitor'], vehicleVins: [], + vehicles: [], grantHistory: [], createdAt: '2026-07-16T00:00:00Z', updatedAt: '2026-07-16T00:00:00Z' + }, + { + id: 8, username: 'customer-west', displayName: '西部客户', userType: 'customer', status: 'disabled', + customerRef: 'west', tenantRef: '', authProvider: 'local', menuKeys: ['monitor'], vehicleVins: [], + vehicles: [], grantHistory: [], createdAt: '2026-07-16T00:00:00Z', updatedAt: '2026-07-16T00:00:00Z' + } + ]); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + + expect(await screen.findByRole('button', { name: /选择客户 华东客户/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /选择客户 西部客户/ })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('combobox', { name: '账号状态' })); + const disabledOption = await waitFor(() => { + const option = [...document.querySelectorAll('.semi-select-option')] + .find((item) => item.textContent?.includes('停用账号')); + expect(option).toBeInTheDocument(); + return option!; + }); + fireEvent.click(disabledOption); + + await waitFor(() => expect(screen.queryByRole('button', { name: /选择客户 华东客户/ })).not.toBeInTheDocument()); + expect(screen.getByRole('button', { name: /选择客户 西部客户/ })).toBeInTheDocument(); + expect(screen.getAllByText('1 / 2 个账号')).toHaveLength(2); + expect(screen.getByText('启用账号').nextSibling).toHaveTextContent('0'); + + fireEvent.click(screen.getByRole('button', { name: '清空账号筛选' })); + await waitFor(() => expect(screen.getByRole('button', { name: /选择客户 华东客户/ })).toBeInTheDocument()); + expect(screen.getByRole('button', { name: /选择客户 西部客户/ })).toBeInTheDocument(); +}); + test('uses a standard Semi empty state when no customer account exists', async () => { mocks.adminUsers.mockResolvedValue([]); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx index c3d28a46..4fa51bf4 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { IconChevronRight, IconClose, IconDelete, IconPlus, IconSearch } from '@douyinfe/semi-icons'; -import { Avatar, Button, Card, Checkbox, Collapse, Empty, Input, List, SideSheet, Spin, Switch, Tabs, Tag, Typography } from '@douyinfe/semi-ui'; +import { IconChevronRight, IconClose, IconDelete, IconPlus, IconRefresh, IconSearch } from '@douyinfe/semi-icons'; +import { Avatar, Button, Card, Checkbox, Collapse, Empty, Input, List, Select, SideSheet, Spin, Switch, Tabs, Tag, Typography } from '@douyinfe/semi-ui'; import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from 'react'; import { api } from '../../api/client'; import type { AdminUser, CustomerUserInput, CustomerVehicleGrantInput } from '../../api/types'; @@ -9,6 +9,7 @@ import { useSideSheetA11y } from '../hooks/useSideSheetA11y'; import { PageHeader } from '../shared/PageHeader'; import { TablePagination } from '../shared/TablePagination'; import { VehicleCandidateList } from '../shared/VehicleCandidateList'; +import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel'; import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader'; import { mergeVehicleCandidates } from '../shared/vehicleCandidates'; import { PanelEmpty, PanelLoading } from '../shared/AsyncState'; @@ -71,12 +72,18 @@ export default function UsersPage() { const users = useQuery({ queryKey: ['admin-users'], queryFn: ({ signal }) => api.adminUsers(signal), staleTime: 10_000 }); const customers = useMemo(() => (users.data ?? []).filter((user) => user.userType === 'customer'), [users.data]); const [customerKeyword, setCustomerKeyword] = useState(''); + const deferredCustomerKeyword = useDeferredValue(customerKeyword.trim()); + const [customerStatus, setCustomerStatus] = useState<'all' | Draft['status']>('all'); + const [filtersCollapsed, setFiltersCollapsed] = useState(true); const visibleCustomers = useMemo(() => { - const keyword = customerKeyword.trim().toLocaleLowerCase('zh-CN'); - if (!keyword) return customers; - return customers.filter((user) => [user.displayName, user.username, user.customerRef, user.tenantRef] - .some((value) => value?.toLocaleLowerCase('zh-CN').includes(keyword))); - }, [customerKeyword, customers]); + const keyword = deferredCustomerKeyword.toLocaleLowerCase('zh-CN'); + return customers.filter((user) => { + if (customerStatus !== 'all' && user.status !== customerStatus) return false; + if (!keyword) return true; + return [user.displayName, user.username, user.customerRef, user.tenantRef] + .some((value) => value?.toLocaleLowerCase('zh-CN').includes(keyword)); + }); + }, [customerStatus, customers, deferredCustomerKeyword]); const [selectedID, setSelectedID] = useState(null); const [creating, setCreating] = useState(false); const [activeSection, setActiveSection] = useState('identity'); @@ -90,8 +97,8 @@ export default function UsersPage() { const [vehicleLabels, setVehicleLabels] = useState>({}); const [feedback, setFeedback] = useState(''); const [editingGrantVIN, setEditingGrantVIN] = useState(''); - const enabledCustomers = useMemo(() => customers.filter((user) => user.status === 'enabled').length, [customers]); - const grantedVehicles = useMemo(() => customers.reduce((total, user) => total + user.vehicles.length, 0), [customers]); + const enabledCustomers = useMemo(() => visibleCustomers.filter((user) => user.status === 'enabled').length, [visibleCustomers]); + const grantedVehicles = useMemo(() => visibleCustomers.reduce((total, user) => total + user.vehicles.length, 0), [visibleCustomers]); const editingGrant = useMemo(() => draft.vehicleGrants.find((grant) => grant.vin === editingGrantVIN), [draft.vehicleGrants, editingGrantVIN]); const editingGrantPlate = editingGrant ? vehicleLabels[editingGrant.vin] : ''; const grantWindowInvalid = Boolean(editingGrant && (!editingGrant.validFrom || (editingGrant.validTo && editingGrant.validTo <= editingGrant.validFrom))); @@ -245,13 +252,58 @@ export default function UsersPage() { meta={最小权限原则} actions={} /> + setFiltersCollapsed((value) => !value)} + > +
+ +