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 09dc6c3f..56936a77 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 @@ -218,13 +218,18 @@ test('keeps the customer directory visible on mobile and opens details on demand const customer = await screen.findByRole('button', { name: /选择客户 华东客户/ }); 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(); + expect(screen.queryByRole('textbox', { name: '搜索客户账号' })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: '新建客户账号' })).toHaveClass('v2-workspace-mobile-tool-button', 'is-primary'); 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(screen.getByRole('button', { name: '关闭账号范围:全部状态' })).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByRole('textbox', { name: '搜索客户账号' })).toBeInTheDocument(); + expect(document.querySelector('.v2-user-mobile-filter-sidesheet .semi-sidesheet-inner')).toHaveAttribute('aria-label', '客户账号筛选'); + expect(document.querySelector('.v2-user-filter-panel')).not.toBeInTheDocument(); expect(document.querySelector('.v2-user-editor-sidesheet')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '关闭客户账号筛选' })); + await waitFor(() => expect(screen.getByRole('button', { name: '修改账号范围:全部状态' })).toHaveAttribute('aria-expanded', 'false')); fireEvent.click(customer); expect(await screen.findByRole('button', { name: '关闭账号详情' })).toBeInTheDocument(); expect(customer).toHaveAttribute('aria-expanded', 'true'); @@ -234,6 +239,50 @@ test('keeps the customer directory visible on mobile and opens details on demand expect(screen.getAllByRole('button', { name: '新建客户账号' })).toHaveLength(1); }); +test('applies mobile customer filters explicitly and discards unconfirmed draft changes', async () => { + layout.mobile = true; + 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('button', { name: '修改账号范围:全部状态' })); + fireEvent.change(screen.getByRole('textbox', { name: '搜索客户账号' }), { target: { value: '西部' } }); + + expect(screen.getByRole('button', { name: /选择客户 华东客户/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /选择客户 西部客户/ })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '关闭客户账号筛选' })); + await waitFor(() => expect(screen.getByRole('button', { name: '修改账号范围:全部状态' })).toBeInTheDocument()); + expect(screen.getByRole('button', { name: /选择客户 华东客户/ })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: '修改账号范围:全部状态' })); + expect(screen.getByRole('textbox', { name: '搜索客户账号' })).toHaveValue(''); + fireEvent.change(screen.getByRole('textbox', { name: '搜索客户账号' }), { target: { value: '西部' } }); + fireEvent.click(screen.getByRole('button', { name: '应用筛选' })); + + await waitFor(() => expect(screen.queryByRole('button', { name: /选择客户 华东客户/ })).not.toBeInTheDocument()); + expect(screen.getByRole('button', { name: /选择客户 西部客户/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '修改账号范围:全部状态 · 西部' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: '修改账号范围:全部状态 · 西部' })); + fireEvent.click(screen.getByRole('button', { name: '重置条件' })); + await waitFor(() => expect(screen.getByRole('button', { name: /选择客户 华东客户/ })).toBeInTheDocument()); + expect(screen.getByRole('button', { name: /选择客户 西部客户/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '修改账号范围:全部状态' })).toBeInTheDocument(); +}); + test('keeps granted vehicles primary and reveals mobile assignment tools on demand', async () => { layout.mobile = true; const vehicles = Array.from({ length: 5 }, (_, index) => ({ 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 9ef5659c..e2944ea0 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx @@ -6,6 +6,7 @@ import { api } from '../../api/client'; import type { AdminUser, CustomerUserInput, CustomerVehicleGrantInput } from '../../api/types'; import { useMobileLayout } from '../hooks/useMobileLayout'; import { useSideSheetA11y } from '../hooks/useSideSheetA11y'; +import { MobileFilterToggle } from '../shared/MobileFilterToggle'; import { TablePagination } from '../shared/TablePagination'; import { VehicleCandidateList } from '../shared/VehicleCandidateList'; import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel'; @@ -42,6 +43,13 @@ type CustomerAccessState = { attention: boolean; }; +const customerScopeOptions: Array<{ value: CustomerScopeFilter; label: string }> = [ + { value: 'all', label: '全部状态' }, + { value: 'enabled', label: '启用账号' }, + { value: 'disabled', label: '停用账号' }, + { value: 'attention', label: '待完善权限' } +]; + const shanghaiDateTimeFormatter = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }); @@ -104,6 +112,8 @@ export default function UsersPage() { const [customerKeyword, setCustomerKeyword] = useState(''); const deferredCustomerKeyword = useDeferredValue(customerKeyword.trim()); const [customerStatus, setCustomerStatus] = useState('all'); + const [draftCustomerKeyword, setDraftCustomerKeyword] = useState(''); + const [draftCustomerStatus, setDraftCustomerStatus] = useState('all'); const [filtersCollapsed, setFiltersCollapsed] = useState(true); const visibleCustomers = useMemo(() => { const keyword = deferredCustomerKeyword.toLocaleLowerCase('zh-CN'); @@ -139,9 +149,11 @@ export default function UsersPage() { const editingGrantPlate = editingGrant ? vehicleLabels[editingGrant.vin] : ''; const grantWindowInvalid = Boolean(editingGrant && (!editingGrant.validFrom || (editingGrant.validTo && editingGrant.validTo <= editingGrant.validFrom))); const editorVisible = creating || Boolean(selected); + const mobileFiltersOpen = mobileLayout && !filtersCollapsed; const vehicleComposerVisible = !mobileLayout || creating || draft.vehicleGrants.length === 0 || vehicleComposerOpen; const hasUnsavedChanges = useMemo(() => draftSignature(draft) !== draftSignature(baselineDraft), [baselineDraft, draft]); const draftAccessState = customerAccessState(draft.status, draft.menuKeys.length, draft.vehicleGrants.length); + useSideSheetA11y(mobileFiltersOpen, '.v2-user-mobile-filter-sidesheet', 'v2-user-mobile-filters', '客户账号筛选', '关闭客户账号筛选'); useSideSheetA11y(editorVisible, '.v2-user-editor-sidesheet', 'v2-user-editor-sheet', '客户账号详情', '关闭账号详情'); useSideSheetA11y(Boolean(editingGrant), '.v2-user-grant-sidesheet', 'v2-user-grant-window', '车辆授权有效期', '关闭车辆授权有效期'); @@ -330,6 +342,32 @@ export default function UsersPage() { event.preventDefault(); persistDraft(); }; + const openMobileFilters = () => { + setDraftCustomerKeyword(customerKeyword); + setDraftCustomerStatus(customerStatus); + setFiltersCollapsed(false); + }; + const closeMobileFilters = () => { + setDraftCustomerKeyword(customerKeyword); + setDraftCustomerStatus(customerStatus); + setFiltersCollapsed(true); + }; + const applyMobileFilters = () => { + setCustomerKeyword(draftCustomerKeyword); + setCustomerStatus(draftCustomerStatus); + setFiltersCollapsed(true); + }; + const resetMobileFilters = () => { + setDraftCustomerKeyword(''); + setDraftCustomerStatus('all'); + setCustomerKeyword(''); + setCustomerStatus('all'); + setFiltersCollapsed(true); + }; + const submitMobileFilters = (event: FormEvent) => { + event.preventDefault(); + applyMobileFilters(); + }; return
@@ -342,18 +380,58 @@ export default function UsersPage() { meta={本地账号 · 可扩展外部身份源} actions={} /> - + + 筛选客户账号按账号信息与权限状态缩小客户范围
} + onCancel={closeMobileFilters} + footer={
} + > +
+
+
账号范围筛选仅在应用后更新客户目录,关闭不会丢失当前结果。
+
+ +
- + }
diff --git a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css index eb01fa82..7a262c29 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css @@ -17378,3 +17378,146 @@ font-weight: 700; } } + +/* + * Semi UI account directory discovery. + * Keep the customer list in the first viewport and apply mobile filters only + * after an explicit confirmation, matching alerts, access and history. + */ +@media (max-width: 680px) { + .v2-user-mobile-discovery { + display: grid; + min-width: 0; + flex: 0 0 auto; + gap: 8px; + } + + .v2-user-mobile-discovery > .v2-mobile-filter-toggle.semi-button { + min-height: 58px; + margin: 0; + } + + .v2-user-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-user-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-user-mobile-filter-sidesheet.semi-sidesheet-bottom .semi-sidesheet-body { + min-height: 0; + overflow-y: auto; + background: #f4f7fb; + padding: 12px; + overscroll-behavior: contain; + } + + .v2-user-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-user-mobile-filter-title { + display: grid; + gap: 3px; + } + + .v2-user-mobile-filter-title > strong { + color: #253a52; + font-size: 15px; + line-height: 1.3; + } + + .v2-user-mobile-filter-title > span { + color: #8090a3; + font-size: 10px; + font-weight: 500; + line-height: 1.4; + } + + .v2-user-mobile-filter-form { + display: grid; + gap: 10px; + } + + .v2-user-mobile-filter-form > 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-user-mobile-filter-form > section > header { + display: grid; + min-width: 0; + gap: 3px; + } + + .v2-user-mobile-filter-form > section > header > strong { + color: #2b4058; + font-size: 13px; + line-height: 1.3; + } + + .v2-user-mobile-filter-form > section > header > span { + color: #7c8b9e; + font-size: 10px; + line-height: 1.45; + } + + .v2-user-mobile-filter-form > section > div { + display: grid; + gap: 11px; + } + + .v2-user-mobile-filter-form label { + display: grid; + min-width: 0; + gap: 6px; + color: #5f7188; + font-size: 11px; + font-weight: 650; + } + + .v2-user-mobile-filter-form label > .semi-input-wrapper, + .v2-user-mobile-filter-form label > .semi-select { + width: 100%; + min-height: 44px; + border-radius: 9px; + background: #fff; + } + + .v2-user-mobile-filter-form .semi-input, + .v2-user-mobile-filter-form .semi-select-selection-text { + height: 42px; + font-size: 14px; + } + + .v2-user-mobile-filter-footer { + display: grid; + width: 100%; + grid-template-columns: minmax(0, .72fr) minmax(0, 1.28fr); + gap: 9px; + } + + .v2-user-mobile-filter-footer > .semi-button { + width: 100%; + min-height: 44px; + border-radius: 9px; + font-weight: 700; + } +}