polish Semi UI admin and operations workspaces
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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<VehicleCoverageRow>();
|
||||
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 <Card className="v2-source-diagnostic" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
title="单车多来源诊断"
|
||||
description="按需查询,不加载全量 RAW;普通客户无权访问。"
|
||||
actions={selected ? <Button theme="light" icon={<IconRefresh />} onClick={() => diagnostic.refetch()} disabled={diagnostic.isFetching}>刷新该车</Button> : null}
|
||||
/>
|
||||
<form className="v2-source-search" onSubmit={submit}>
|
||||
<Input aria-label="按车牌或 VIN 搜索诊断车辆" prefix={<IconSearch />} value={keyword} onChange={(value) => { setKeyword(value); setSelected(undefined); }} placeholder="输入车牌或 VIN,支持模糊搜索" />
|
||||
<Button htmlType="submit" theme="solid" disabled={!candidates.data?.items.length}>诊断首个结果</Button>
|
||||
{deferredKeyword && !selected ? <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}
|
||||
</form>
|
||||
{diagnostic.isError ? <InlineError message={diagnostic.error.message} onRetry={() => diagnostic.refetch()} /> : null}
|
||||
{!selected ? <Empty className="v2-source-empty" title="先选择一辆车" description="将展示所有协议和同协议多终端、推荐原因、上报周期与可审计策略。" /> : null}
|
||||
{selected && diagnostic.isPending ? <div className="v2-source-loading" role="status"><Spin size="large" /><strong>正在读取来源证据</strong><span>只查询当前车辆,不会扫描整车队。</span></div> : null}
|
||||
{data ? <>
|
||||
return <>
|
||||
<WorkspaceFilterPanel
|
||||
className="v2-source-filter-panel"
|
||||
title="诊断车辆"
|
||||
description="按车牌或 VIN 选择一辆车,仅按需加载该车来源证据"
|
||||
mobileSummary={selectedLabel ? `已选 ${selectedLabel}` : deferredKeyword ? `正在查找 ${deferredKeyword}` : '尚未选择车辆'}
|
||||
expanded={!filtersCollapsed}
|
||||
status={filterStatus}
|
||||
statusColor={selectedLabel ? 'blue' : 'grey'}
|
||||
collapsedLabel="修改"
|
||||
onToggle={() => setFiltersCollapsed((value) => !value)}
|
||||
>
|
||||
<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,支持模糊搜索" />
|
||||
<Button htmlType="submit" theme="solid" disabled={!candidates.data?.items.length}>诊断首个结果</Button>
|
||||
{deferredKeyword && !selected ? <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}
|
||||
</form>
|
||||
</WorkspaceFilterPanel>
|
||||
<Card className="v2-source-diagnostic" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
title="单车多来源诊断"
|
||||
description={selected ? `${selected.plate || '未绑定车牌'} · ${selected.vin}` : '协议、同协议多终端、推荐原因与可审计策略'}
|
||||
meta={<Tag color={data ? 'blue' : 'grey'} type="light" size="small">{data ? `${data.evidence.locationSources.length} 个来源` : selected ? '正在读取证据' : '等待选择车辆'}</Tag>}
|
||||
actions={selected ? <Button theme="light" icon={<IconRefresh />} onClick={() => diagnostic.refetch()} disabled={diagnostic.isFetching}>刷新该车</Button> : null}
|
||||
/>
|
||||
{diagnostic.isError ? <InlineError message={diagnostic.error.message} onRetry={() => diagnostic.refetch()} /> : null}
|
||||
{!selected ? <Empty className="v2-source-empty" title="先选择一辆车" description="将展示所有协议和同协议多终端、推荐原因、上报周期与可审计策略。" /> : null}
|
||||
{selected && diagnostic.isPending ? <div className="v2-source-loading" role="status"><Spin size="large" /><strong>正在读取来源证据</strong><span>只查询当前车辆,不会扫描整车队。</span></div> : null}
|
||||
{data ? <>
|
||||
<div className="v2-source-summary">
|
||||
<Card className="v2-source-summary-card"><small>车辆</small><strong>{data.evidence.plate || selected?.plate || '未绑定车牌'}</strong><span>{data.evidence.vin}</span></Card>
|
||||
<Card className="v2-source-summary-card"><small>当前推荐</small><strong>{data.evidence.recommendedLocationLabel || '暂无推荐'}</strong><Tag color="blue" type="light" size="small">{data.evidence.recommendedLocationProtocol || '—'}</Tag></Card>
|
||||
@@ -282,8 +305,9 @@ function SourceDiagnosticWorkspace() {
|
||||
<Card className="v2-source-audit" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="最近策略审计" description="仅记录实际变更,原始 source_key 不对前端暴露" />
|
||||
{data.policy.audit.length ? <ol>{data.policy.audit.map((item) => <li key={`${item.changeType}-${item.version}-${item.sourceRef}`}><b>v{item.version}</b><span>{item.summary}</span><em>{item.changeType === 'provider' ? '提供方' : '策略'} · {item.actor} · {fmt(item.changedAt)}</em></li>)}</ol> : <p>该车辆尚无人工来源策略或提供方变更。</p>}
|
||||
</Card>
|
||||
</> : null}
|
||||
</Card>;
|
||||
</> : null}
|
||||
</Card>
|
||||
</>;
|
||||
}
|
||||
|
||||
export default function OperationsPage() {
|
||||
|
||||
@@ -44,7 +44,11 @@ test('deduplicates vehicle candidates by VIN and renders granted vehicles plate
|
||||
const view = render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
|
||||
|
||||
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(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
|
||||
|
||||
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<HTMLElement>('.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 } } });
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState<EditorSection>('identity');
|
||||
@@ -90,8 +97,8 @@ export default function UsersPage() {
|
||||
const [vehicleLabels, setVehicleLabels] = useState<Record<string, string>>({});
|
||||
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={<Typography.Text type="tertiary">最小权限原则</Typography.Text>}
|
||||
actions={<Button theme="solid" icon={<IconPlus />} aria-label="新建客户账号" onClick={startCreate}>新建客户账号</Button>}
|
||||
/>
|
||||
<WorkspaceFilterPanel
|
||||
className="v2-user-filter-panel"
|
||||
title="账号范围"
|
||||
description="按客户名称、登录账号、客户标识与启停状态查找"
|
||||
mobileSummary={`${customerStatus === 'all' ? '全部状态' : customerStatus === 'enabled' ? '启用账号' : '停用账号'}${customerKeyword.trim() ? ` · ${customerKeyword.trim()}` : ''}`}
|
||||
expanded={!filtersCollapsed}
|
||||
status={users.isPending ? '正在读取账号' : `${visibleCustomers.length} / ${customers.length} 个账号`}
|
||||
statusColor={customerKeyword.trim() || customerStatus !== 'all' ? 'blue' : 'grey'}
|
||||
collapsedLabel="修改"
|
||||
onToggle={() => setFiltersCollapsed((value) => !value)}
|
||||
>
|
||||
<div className={`v2-user-filter-form${filtersCollapsed ? ' is-mobile-collapsed' : ''}`}>
|
||||
<label className="v2-user-filter-keyword">
|
||||
<span>客户账号</span>
|
||||
<Input className="v2-user-list-search" aria-label="搜索客户账号" prefix={<IconSearch />} showClear value={customerKeyword} onChange={setCustomerKeyword} placeholder="搜索名称、账号或客户标识" />
|
||||
</label>
|
||||
<label>
|
||||
<span id="v2-user-status-filter-label">账号状态</span>
|
||||
<Select
|
||||
aria-labelledby="v2-user-status-filter-label"
|
||||
value={customerStatus}
|
||||
onChange={(value) => setCustomerStatus(String(value) as typeof customerStatus)}
|
||||
optionList={[
|
||||
{ value: 'all', label: '全部状态' },
|
||||
{ value: 'enabled', label: '启用账号' },
|
||||
{ value: 'disabled', label: '停用账号' }
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
className="v2-user-filter-reset"
|
||||
theme="light"
|
||||
type="tertiary"
|
||||
icon={<IconRefresh />}
|
||||
aria-label="清空账号筛选"
|
||||
disabled={!customerKeyword.trim() && customerStatus === 'all'}
|
||||
onClick={() => {
|
||||
setCustomerKeyword('');
|
||||
setCustomerStatus('all');
|
||||
}}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
</WorkspaceFilterPanel>
|
||||
<div className={`v2-user-admin-grid${creating || selected ? ' is-editor-open' : ''}`}>
|
||||
<Card className="v2-user-list" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
className="v2-user-directory-header"
|
||||
title="客户权限目录"
|
||||
description="按客户查看账号状态、菜单和车辆授权范围"
|
||||
meta={<Tag color="blue" type="light" size="small">{customers.length} 个账号</Tag>}
|
||||
description="当前范围内的账号、菜单与车辆授权摘要"
|
||||
meta={<Tag color={visibleCustomers.length === customers.length ? 'grey' : 'blue'} type="light" size="small">{visibleCustomers.length} / {customers.length} 个账号</Tag>}
|
||||
/>
|
||||
<div className="v2-user-directory-body">
|
||||
<div className="v2-user-directory-metrics" aria-label="客户账号概览">
|
||||
@@ -260,7 +312,6 @@ export default function UsersPage() {
|
||||
<span><small>车辆授权</small><strong>{grantedVehicles}</strong></span>
|
||||
</div>
|
||||
{users.isPending ? <PanelLoading className="v2-user-list-loading" title="正在加载客户账号" description="账号目录和授权摘要就绪后会自动显示。" /> : customers.length === 0 ? <PanelEmpty className="v2-user-list-empty" title="还没有客户账号" description="创建客户账号后,可在这里配置菜单和车辆范围。" action={<Button theme="solid" onClick={startCreate}>创建第一个客户账号</Button>} /> : <>
|
||||
<Input className="v2-user-list-search" aria-label="搜索客户账号" prefix={<IconSearch />} showClear value={customerKeyword} onChange={setCustomerKeyword} placeholder="搜索名称、账号或客户标识" />
|
||||
<List
|
||||
className="v2-customer-list"
|
||||
dataSource={visibleCustomers}
|
||||
|
||||
@@ -440,7 +440,7 @@ describe('V2 production entry', () => {
|
||||
expect(v2Styles).toContain('.v2-track-current-card.semi-card { top: auto; right: 8px; bottom: 126px; left: 8px; width: auto; height: auto;');
|
||||
expect(workspaceFilterPanelSource).toContain("from './MobileFilterToggle'");
|
||||
expect(workspaceFilterPanelSource).toContain('<MobileFilterToggle');
|
||||
for (const name of ['HistoryPage', 'AlertsPage', 'AccessPage', 'StatisticsPage']) {
|
||||
for (const name of ['HistoryPage', 'AlertsPage', 'AccessPage', 'StatisticsPage', 'UsersPage', 'OperationsPage']) {
|
||||
expect(corePageSources[name]).toContain("from '../shared/WorkspaceFilterPanel'");
|
||||
expect(corePageSources[name]).toContain('<WorkspaceFilterPanel');
|
||||
}
|
||||
|
||||
@@ -1772,6 +1772,47 @@
|
||||
* access and operations: one panel header, one continuous metric rail and one
|
||||
* scroll owner. Large vehicle scopes are paged instead of nested-scrolled.
|
||||
*/
|
||||
.v2-user-filter-panel.v2-workspace-filter-panel.semi-card {
|
||||
width: 100%;
|
||||
max-width: 1480px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.v2-user-filter-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 1fr) 180px auto;
|
||||
align-items: end;
|
||||
gap: 10px;
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
|
||||
.v2-user-filter-form > label {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
color: #5d6c80;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.v2-user-filter-form :is(.semi-input-wrapper, .semi-select),
|
||||
.v2-user-filter-reset.semi-button {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.v2-user-filter-form .semi-input,
|
||||
.v2-user-filter-form .semi-select-selection {
|
||||
min-height: 38px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.v2-user-filter-reset.semi-button {
|
||||
width: auto;
|
||||
min-width: 88px;
|
||||
}
|
||||
|
||||
.v2-user-list.semi-card {
|
||||
overflow: hidden;
|
||||
border-color: #dce5ef;
|
||||
@@ -2021,6 +2062,29 @@
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.v2-user-filter-panel.is-mobile-collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.v2-user-filter-form {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 9px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.v2-user-filter-form.is-mobile-collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.v2-user-filter-form :is(.semi-input-wrapper, .semi-select),
|
||||
.v2-user-filter-reset.semi-button {
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.v2-user-filter-reset.semi-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.v2-user-directory-header.v2-workspace-panel-header {
|
||||
min-height: 56px;
|
||||
align-items: center;
|
||||
@@ -2139,6 +2203,20 @@
|
||||
* Match the access and alert workspaces: readable evidence tables on desktop,
|
||||
* progressive policy editing on mobile, and one consistent status hierarchy.
|
||||
*/
|
||||
.v2-source-filter-panel.v2-workspace-filter-panel.semi-card {
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.v2-source-filter-panel .v2-source-search {
|
||||
padding: 11px 12px 12px;
|
||||
}
|
||||
|
||||
.v2-source-filter-panel .v2-source-candidates {
|
||||
right: 128px;
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 681px) {
|
||||
.v2-ops-page {
|
||||
gap: 12px;
|
||||
@@ -2334,6 +2412,20 @@
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.v2-source-filter-panel.is-mobile-collapsed,
|
||||
.v2-source-search.is-mobile-collapsed {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.v2-source-filter-panel .v2-source-search {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.v2-source-filter-panel .v2-source-candidates {
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.v2-source-empty.semi-empty,
|
||||
.v2-source-loading {
|
||||
min-height: 160px;
|
||||
|
||||
Reference in New Issue
Block a user