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

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