polish Semi UI admin and operations workspaces

This commit is contained in:
lingniu
2026-07-18 01:57:06 +08:00
parent 1affd30366
commit f3c1391322
6 changed files with 258 additions and 40 deletions

View File

@@ -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();

View File

@@ -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,13 +252,19 @@ 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}>
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
@@ -264,6 +279,14 @@ function SourceDiagnosticWorkspace() {
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}
@@ -283,7 +306,8 @@ function SourceDiagnosticWorkspace() {
{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>;
</Card>
</>;
}
export default function OperationsPage() {

View File

@@ -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 } } });

View File

@@ -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}

View File

@@ -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');
}

View File

@@ -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;