refine Semi UI account access readiness

This commit is contained in:
lingniu
2026-07-18 19:41:01 +08:00
parent 532524b992
commit e95fbe5c19
3 changed files with 252 additions and 22 deletions

View File

@@ -49,14 +49,15 @@ test('deduplicates vehicle candidates by VIN and renders granted vehicles plate
expect(view.container.querySelector('.v2-page-heading')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-user-discovery-shell')).toContainElement(screen.getByLabelText('账号管理操作'));
expect(screen.getByRole('heading', { name: '账号范围', level: 5 })).toBeInTheDocument();
expect(screen.getByText('按客户名称、登录账号、客户标识与启停状态查找')).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(screen.getByRole('row', { name: '客户账号目录列' })).toHaveTextContent('客户账号菜单权限车辆权限最近登录状态');
expect(screen.getByText('启用 1')).toBeInTheDocument();
expect(screen.getByRole('row', { name: '客户账号目录列' })).toHaveTextContent('客户账号菜单权限车辆权限最近登录访问状态');
expect(screen.getByText('权限就绪 1')).toBeInTheDocument();
expect(screen.getByText('车辆授权 1')).toBeInTheDocument();
expect(screen.getByText('权限就绪')).toBeInTheDocument();
expect(view.container.querySelector('.v2-user-directory-metrics')).not.toBeInTheDocument();
expect(customer).toHaveClass('semi-button', 'v2-user-list-item');
expect(customer.closest('.semi-list-item')).toHaveClass('v2-user-list-row');
@@ -65,6 +66,7 @@ test('deduplicates vehicle candidates by VIN and renders granted vehicles plate
expect(document.querySelector('.v2-user-editor-sidesheet')).not.toBeInTheDocument();
fireEvent.click(customer);
await waitFor(() => expect(customer).toHaveAttribute('aria-expanded', 'true'));
expect(screen.getByLabelText('账号权限概览')).toHaveTextContent('@customer-east菜单 1车辆 1最近登录 尚未登录权限就绪');
const editorSheet = document.querySelector<HTMLElement>('.v2-user-editor-sidesheet .semi-sidesheet-inner');
expect(editorSheet).toHaveAttribute('aria-label', '客户账号详情');
expect(editorSheet).toHaveStyle({ width: 'min(840px, 100vw)' });
@@ -287,7 +289,7 @@ test('filters the customer directory by status and exposes the active result sco
expect(await screen.findByRole('button', { name: /选择客户 华东客户/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /选择客户 西部客户/ })).toBeInTheDocument();
fireEvent.click(screen.getByRole('combobox', { name: '账号状态' }));
fireEvent.click(screen.getByRole('combobox', { name: '访问状态' }));
const disabledOption = await waitFor(() => {
const option = [...document.querySelectorAll<HTMLElement>('.semi-select-option')]
.find((item) => item.textContent?.includes('停用账号'));
@@ -299,12 +301,26 @@ test('filters the customer directory by status and exposes the active result sco
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('启用 0')).toBeInTheDocument();
expect(screen.getByText('权限就绪 0')).toBeInTheDocument();
expect(screen.getByText('车辆授权 0')).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('combobox', { name: '访问状态' }));
const attentionOption = await waitFor(() => {
const option = [...document.querySelectorAll<HTMLElement>('.semi-select-option')]
.find((item) => item.textContent?.includes('待完善权限'));
expect(option).toBeInTheDocument();
return option!;
});
fireEvent.click(attentionOption);
await waitFor(() => expect(screen.queryByRole('button', { name: /选择客户 西部客户/ })).not.toBeInTheDocument());
expect(screen.getByRole('button', { name: /选择客户 华东客户/ })).toBeInTheDocument();
expect(screen.getByText('待分配车辆')).toBeInTheDocument();
expect(screen.getByText('待完善 1')).toBeInTheDocument();
});
test('uses a standard Semi empty state when no customer account exists', async () => {

View File

@@ -33,6 +33,14 @@ type Draft = {
};
type EditorSection = 'identity' | 'menus' | 'vehicles';
type CustomerScopeFilter = 'all' | Draft['status'] | 'attention';
type CustomerAccessState = {
key: 'ready' | 'missing-menus' | 'missing-vehicles' | 'disabled';
label: string;
shortLabel: string;
color: 'green' | 'amber' | 'grey';
attention: boolean;
};
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
@@ -74,6 +82,20 @@ function formatGrantTime(value?: string) {
return value.replace('T', ' ').slice(0, 16);
}
function customerAccessState(status: Draft['status'], menuCount: number, vehicleCount: number): CustomerAccessState {
if (status === 'disabled') return { key: 'disabled', label: '账号已停用', shortLabel: '停用', color: 'grey', attention: false };
if (menuCount === 0) return { key: 'missing-menus', label: '待开放菜单', shortLabel: '待菜单', color: 'amber', attention: true };
if (vehicleCount === 0) return { key: 'missing-vehicles', label: '待分配车辆', shortLabel: '待车辆', color: 'amber', attention: true };
return { key: 'ready', label: '权限就绪', shortLabel: '就绪', color: 'green', attention: false };
}
function customerScopeLabel(scope: CustomerScopeFilter) {
if (scope === 'enabled') return '启用账号';
if (scope === 'disabled') return '停用账号';
if (scope === 'attention') return '待完善权限';
return '全部状态';
}
export default function UsersPage() {
const queryClient = useQueryClient();
const mobileLayout = useMobileLayout();
@@ -81,12 +103,14 @@ export default function UsersPage() {
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 [customerStatus, setCustomerStatus] = useState<CustomerScopeFilter>('all');
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
const visibleCustomers = useMemo(() => {
const keyword = deferredCustomerKeyword.toLocaleLowerCase('zh-CN');
return customers.filter((user) => {
if (customerStatus !== 'all' && user.status !== customerStatus) return false;
const accessState = customerAccessState(user.status, user.menuKeys.length, user.vehicles.length);
if (customerStatus === 'attention' && !accessState.attention) return false;
if (customerStatus !== 'all' && customerStatus !== 'attention' && 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));
@@ -107,7 +131,9 @@ export default function UsersPage() {
const [vehicleLabels, setVehicleLabels] = useState<Record<string, string>>({});
const [feedback, setFeedback] = useState('');
const [editingGrantVIN, setEditingGrantVIN] = useState('');
const enabledCustomers = useMemo(() => visibleCustomers.filter((user) => user.status === 'enabled').length, [visibleCustomers]);
const attentionCustomerCount = useMemo(() => customers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length).attention).length, [customers]);
const readyCustomers = useMemo(() => visibleCustomers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length).key === 'ready').length, [visibleCustomers]);
const attentionCustomers = useMemo(() => visibleCustomers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length).attention).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] : '';
@@ -115,6 +141,7 @@ export default function UsersPage() {
const editorVisible = creating || Boolean(selected);
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(editorVisible, '.v2-user-editor-sidesheet', 'v2-user-editor-sheet', '客户账号详情', '关闭账号详情');
useSideSheetA11y(Boolean(editingGrant), '.v2-user-grant-sidesheet', 'v2-user-grant-window', '车辆授权有效期', '关闭车辆授权有效期');
@@ -311,15 +338,15 @@ export default function UsersPage() {
ariaLabel="账号管理操作"
title="客户访问治理"
description="菜单与车辆按最小权限开放,变更最多 30 秒生效"
status={`${customers.length} 个客户账号`}
status={`${customers.length} 个客户账号${attentionCustomerCount ? ` · ${attentionCustomerCount} 待完善` : ' · 权限就绪'}`}
meta={<Typography.Text type="tertiary"> · </Typography.Text>}
actions={<Button className="v2-workspace-mobile-tool-button is-primary" 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()}` : ''}`}
description="按客户名称、登录账号与权限可用状态查找"
mobileSummary={`${customerScopeLabel(customerStatus)}${customerKeyword.trim() ? ` · ${customerKeyword.trim()}` : ''}`}
expanded={!filtersCollapsed}
status={users.isPending ? '正在读取账号' : `${visibleCustomers.length} / ${customers.length} 个账号`}
statusColor={customerKeyword.trim() || customerStatus !== 'all' ? 'blue' : 'grey'}
@@ -332,7 +359,7 @@ export default function UsersPage() {
<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>
<span id="v2-user-status-filter-label">访</span>
<Select
aria-labelledby="v2-user-status-filter-label"
value={customerStatus}
@@ -340,7 +367,8 @@ export default function UsersPage() {
optionList={[
{ value: 'all', label: '全部状态' },
{ value: 'enabled', label: '启用账号' },
{ value: 'disabled', label: '停用账号' }
{ value: 'disabled', label: '停用账号' },
{ value: 'attention', label: '待完善权限' }
]}
/>
</label>
@@ -369,7 +397,8 @@ export default function UsersPage() {
description="当前范围内的账号、菜单与车辆授权摘要"
meta={<span className="v2-user-directory-summary">
<Tag color={visibleCustomers.length === customers.length ? 'grey' : 'blue'} type="light" size="small">{visibleCustomers.length} / {customers.length} </Tag>
<Typography.Text type="tertiary"> {enabledCustomers}</Typography.Text>
<Typography.Text className="is-ready" type="tertiary"> {readyCustomers}</Typography.Text>
{attentionCustomers ? <Tag className="is-attention" color="amber" type="light" size="small"> {attentionCustomers}</Tag> : null}
<Typography.Text type="tertiary"> {grantedVehicles}</Typography.Text>
</span>}
/>
@@ -380,31 +409,34 @@ export default function UsersPage() {
<span></span>
<span></span>
<span></span>
<span></span>
<span>访</span>
</div> : null}
<div className="v2-customer-list-scroll" role="region" aria-label="客户账号目录,可上下滚动" tabIndex={0}>
<List
className="v2-customer-list"
dataSource={visibleCustomers}
emptyContent={<Empty className="v2-user-filter-empty" title="没有匹配账号" description="请更换名称或账号关键词。" />}
renderItem={(user) => <List.Item key={user.id} className={`v2-user-list-row${selectedID === user.id && !creating ? ' is-active' : ''}`}>
emptyContent={<Empty className="v2-user-filter-empty" title="没有匹配账号" description="请调整关键词或访问状态筛选。" />}
renderItem={(user) => {
const accessState = customerAccessState(user.status, user.menuKeys.length, user.vehicles.length);
return <List.Item key={user.id} className={`v2-user-list-row is-access-${accessState.key}${accessState.attention ? ' has-attention' : ''}${selectedID === user.id && !creating ? ' is-active' : ''}`}>
<Button
className="v2-user-list-item"
theme="borderless"
type="tertiary"
aria-label={`选择客户 ${user.displayName},账号 ${user.username}${user.vehicles.length} 辆授权车`}
aria-label={`选择客户 ${user.displayName},账号 ${user.username}${user.vehicles.length} 辆授权车${accessState.label}`}
aria-pressed={selectedID === user.id && !creating}
aria-expanded={selectedID === user.id && !creating}
onClick={() => selectCustomer(user)}
>
<Avatar className="v2-user-avatar" color={user.status === 'enabled' ? 'light-blue' : 'grey'} shape="square" size="small">{user.displayName.slice(0, 1)}</Avatar>
<span className="v2-user-list-identity"><b>{user.displayName}</b><small>@{user.username}{user.customerRef ? ` · ${user.customerRef}` : ''}</small></span>
<span className="v2-user-list-facts is-menu"><small></small><b>{user.menuKeys.length} </b></span>
<span className="v2-user-list-facts is-menu"><small></small><b>{user.menuKeys.length}<i> / {customerMenus.length}</i></b></span>
<span className="v2-user-list-facts is-vehicle"><small></small><b>{user.vehicles.length} </b></span>
<span className="v2-user-list-facts is-login"><small></small><b>{formatTime(user.lastLoginAt)}</b></span>
<span className="v2-user-list-trailing"><Tag className={`v2-user-status-tag is-${user.status}`} color={user.status === 'enabled' ? 'green' : 'grey'} type="light" size="small">{user.status === 'enabled' ? '启用' : '停用'}</Tag><IconChevronRight /></span>
<span className="v2-user-list-trailing"><Tag className={`v2-user-status-tag is-${accessState.key}`} color={accessState.color} type="light" size="small"><span className="v2-user-status-tag-desktop">{accessState.label}</span><span className="v2-user-status-tag-mobile">{accessState.shortLabel}</span></Tag><IconChevronRight /></span>
</Button>
</List.Item>}
</List.Item>;
}}
/>
</div>
</>}
@@ -417,7 +449,13 @@ export default function UsersPage() {
width={mobileLayout ? '100%' : 'min(840px, 100vw)'}
aria-label="客户账号详情"
title={<div className="v2-user-editor-sheet-title">
<span><strong>{creating ? '创建客户账号' : selected?.displayName}</strong><small>{creating ? '设置登录身份和最小必要权限' : <><span>@{selected?.username} · {draft.menuKeys.length} · {draft.vehicleGrants.length} </span><span className="v2-user-editor-last-login"> · {formatTime(selected?.lastLoginAt)}</span></>}</small></span>
<span><strong>{creating ? '创建客户账号' : selected?.displayName}</strong>{creating ? <small></small> : <span className="v2-user-editor-context" aria-label="账号权限概览">
<span className="is-account">@{selected?.username}</span>
<span> <b>{draft.menuKeys.length}</b></span>
<span> <b>{draft.vehicleGrants.length}</b></span>
<span className="v2-user-editor-last-login"> <b>{formatTime(selected?.lastLoginAt)}</b></span>
<Tag className="v2-user-editor-access-tag" color={draftAccessState.color} type="light" size="small">{draftAccessState.label}</Tag>
</span>}</span>
<label className="v2-user-status"><Switch aria-label={draft.status === 'enabled' ? '账号启用' : '账号停用'} checked={draft.status === 'enabled'} onChange={(checked) => setDraft((value) => ({ ...value, status: checked ? 'enabled' : 'disabled' }))} /><span>{draft.status === 'enabled' ? '账号启用' : '账号停用'}</span></label>
</div>}
onCancel={requestCloseEditor}

View File

@@ -15512,3 +15512,179 @@
font-size: 7px;
}
}
/*
* Semi UI account access readiness.
* The directory now answers the operator's first question — can this customer
* actually use the platform — before exposing the underlying counts.
*/
.v2-user-directory-summary > .semi-tag.is-attention {
min-width: 70px;
justify-content: center;
}
.v2-user-directory-summary > .semi-typography.is-ready {
color: #397b60;
}
.v2-customer-list .v2-user-list-row.has-attention.semi-list-item {
border-color: #ead9b8;
background: linear-gradient(110deg, #fffdf8 0%, #fbfcfe 72%);
}
.v2-customer-list .v2-user-list-row.is-access-disabled.semi-list-item {
background: #f8fafc;
}
.v2-customer-list .v2-user-list-row.has-attention.semi-list-item:hover {
border-color: #ddc38f;
box-shadow: 0 7px 18px rgba(125, 87, 25, .07);
}
.v2-user-list-facts b > i {
color: #8a98aa;
font-size: 10px;
font-style: normal;
font-weight: 550;
}
.v2-user-status-tag.semi-tag {
min-width: 76px;
}
.v2-user-status-tag.is-ready.semi-tag {
color: #287456;
}
.v2-user-status-tag.is-missing-menus.semi-tag,
.v2-user-status-tag.is-missing-vehicles.semi-tag {
color: #8a611e;
}
.v2-user-status-tag-mobile {
display: none;
}
.v2-user-editor-sheet-title > span {
gap: 5px;
}
.v2-user-editor-context {
display: flex;
min-width: 0;
align-items: center;
gap: 0;
color: #728197;
font-size: 10px;
font-weight: 550;
line-height: 1.4;
white-space: nowrap;
}
.v2-user-editor-context > span {
position: relative;
min-width: 0;
}
.v2-user-editor-context > span + span {
margin-left: 9px;
padding-left: 10px;
}
.v2-user-editor-context > span + span::before {
position: absolute;
top: 50%;
left: 0;
width: 2px;
height: 2px;
border-radius: 50%;
background: #a8b4c3;
content: '';
transform: translateY(-50%);
}
.v2-user-editor-context > span > b {
color: #344c65;
font-size: inherit;
font-weight: 750;
}
.v2-user-editor-context > .is-account {
overflow: hidden;
max-width: 180px;
color: #5e7188;
text-overflow: ellipsis;
}
.v2-user-editor-access-tag.semi-tag {
min-height: 22px;
flex: 0 0 auto;
justify-content: center;
margin-left: 10px;
border-radius: 999px;
padding-inline: 8px;
font-size: 9px;
font-weight: 700;
}
@media (max-width: 980px) and (min-width: 681px) {
.v2-user-status-tag.semi-tag {
min-width: 68px;
}
.v2-user-editor-last-login {
display: none;
}
}
@media (max-width: 680px) {
.v2-user-directory-summary > .semi-tag.is-attention {
min-width: 60px;
}
.v2-user-status-tag.semi-tag {
min-width: 48px;
padding-inline: 6px;
}
.v2-user-status-tag-desktop {
display: none;
}
.v2-user-status-tag-mobile {
display: inline;
}
.v2-user-list-facts b > i {
font-size: 9px;
}
.v2-user-editor-sidesheet .semi-sidesheet-header {
min-height: 76px;
}
.v2-user-editor-context {
max-width: 262px;
font-size: 9px;
}
.v2-user-editor-context > span + span {
margin-left: 6px;
padding-left: 7px;
}
.v2-user-editor-context > .is-account {
max-width: 94px;
}
.v2-user-editor-last-login {
display: none;
}
.v2-user-editor-access-tag.semi-tag {
min-height: 20px;
margin-left: 7px;
padding-inline: 6px;
font-size: 8px;
}
}