feat: unify account permission workbenches

This commit is contained in:
lingniu
2026-07-19 06:07:26 +08:00
parent 860978fad8
commit 4bd1ad8a85
4 changed files with 297 additions and 37 deletions

View File

@@ -67,10 +67,14 @@ 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 accountSummary = screen.getByRole('list', { name: '客户账号详情摘要' });
expect(accountSummary).toHaveTextContent('登录账号@customer-east登录身份');
expect(accountSummary).toHaveTextContent('权限范围1 菜单 · 1 辆最小必要权限');
expect(accountSummary).toHaveTextContent('账号状态启用保存后最多 30 秒生效');
const editorSheet = document.querySelector<HTMLElement>('.v2-user-editor-sidesheet .semi-sidesheet-inner');
expect(editorSheet).toHaveAttribute('aria-label', '客户账号详情');
expect(editorSheet).toHaveStyle({ width: 'min(840px, 100vw)' });
expect(editorSheet?.closest('.semi-sidesheet')).toHaveClass('v2-workspace-editor-sidesheet');
expect(document.querySelector('.v2-user-editor-sidesheet .v2-user-editor-tabs.semi-tabs')).toBeInTheDocument();
expect(document.querySelector('.v2-user-editor-form')).toHaveAttribute('id', 'v2-user-editor-form');
expect(screen.getAllByRole('tab')).toHaveLength(3);
@@ -96,7 +100,14 @@ test('deduplicates vehicle candidates by VIN and renders granted vehicles plate
fireEvent.click(screen.getByRole('button', { name: '调整 粤A11111 有效期' }));
expect(await screen.findByLabelText('粤A11111 启用时间')).toHaveValue('2026-06-05T00:00');
expect(document.querySelectorAll('input[type="datetime-local"]')).toHaveLength(2);
expect(document.querySelector('.v2-user-grant-sidesheet .semi-sidesheet-inner')).toHaveAttribute('aria-label', '车辆授权有效期');
const grantSheet = document.querySelector('.v2-user-grant-sidesheet .semi-sidesheet-inner');
expect(grantSheet).toHaveAttribute('aria-label', '车辆授权有效期');
expect(grantSheet?.closest('.semi-sidesheet')).toHaveClass('v2-workspace-config-sidesheet');
const grantSummary = screen.getByRole('list', { name: '车辆授权有效期摘要' });
expect(grantSummary).toHaveTextContent('车牌粤A11111');
expect(grantSummary).toHaveTextContent('启用2026-06-05 00:00授权开始');
expect(grantSummary).toHaveTextContent('停用持续有效未设置结束时间');
expect(screen.getByRole('group', { name: '访问时间窗口' })).toHaveTextContent('只在授权区间内开放该车数据上海时间');
fireEvent.click(screen.getByRole('button', { name: '关闭车辆授权有效期' }));
expect(document.querySelectorAll('input[type="datetime-local"]')).toHaveLength(0);
expect(document.querySelector('.v2-user-avatar')).toHaveClass('semi-avatar');
@@ -314,7 +325,7 @@ test('keeps granted vehicles primary and reveals mobile assignment tools on dema
expect(screen.getByText('第 14 条,共 5 辆')).toBeInTheDocument();
expect(document.querySelectorAll('.v2-assigned-vehicle-card.is-mobile-compact')).toHaveLength(4);
expect(document.querySelector('.v2-assigned-vehicle-interval')).toHaveAttribute('aria-label', '授权有效期:启用 2026-06-05 00:00停用 持续有效');
expect(document.querySelector('.v2-user-editor-last-login')).toHaveTextContent('最近登录');
expect(document.querySelector('.v2-user-editor-sidesheet .v2-workspace-config-title small')).toHaveTextContent('@customer-east · 最近登录');
fireEvent.click(toggle);

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { IconChevronRight, IconDelete, IconPlus, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { Avatar, Button, Card, Checkbox, Collapse, Empty, Input, List, Modal, Popconfirm, Select, SideSheet, Spin, Switch, Tabs, Tag, Typography } from '@douyinfe/semi-ui';
import { Avatar, Button, Card, Checkbox, Collapse, Empty, Input, List, Modal, Popconfirm, Select, 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';
@@ -13,6 +13,7 @@ import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { WorkspaceSideSheet, type WorkspaceSideSheetBadgeColor } from '../shared/WorkspaceSideSheet';
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
import { PanelEmpty, PanelLoading } from '../shared/AsyncState';
@@ -105,6 +106,11 @@ function customerScopeLabel(scope: CustomerScopeFilter) {
return '全部状态';
}
function accessBadgeColor(color: CustomerAccessState['color']): WorkspaceSideSheetBadgeColor {
if (color === 'amber') return 'orange';
return color;
}
export default function UsersPage() {
const queryClient = useQueryClient();
const mobileLayout = useMobileLayout();
@@ -155,8 +161,6 @@ export default function UsersPage() {
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', '车辆授权有效期', '关闭车辆授权有效期');
useEffect(() => {
if (!creating && selected) {
@@ -514,29 +518,33 @@ export default function UsersPage() {
</div>
</Card>
</div>
<SideSheet
<WorkspaceSideSheet
className="v2-user-editor-sidesheet"
variant="editor"
visible={editorVisible}
width={mobileLayout ? '100%' : 'min(840px, 100vw)'}
aria-label="客户账号详情"
title={<div className="v2-user-editor-sheet-title">
<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>}
ariaLabel="客户账号详情"
closeLabel="关闭账号详情"
title={creating ? '创建客户账号' : selected?.displayName || '客户账号'}
description={creating ? '设置登录身份和最小必要权限' : `@${selected?.username || '—'} · 最近登录 ${formatTime(selected?.lastLoginAt)}`}
badge={draftAccessState.label}
badgeColor={accessBadgeColor(draftAccessState.color)}
summaryItems={[
{ label: '登录账号', value: draft.username ? `@${draft.username}` : '待设置', detail: creating ? '创建后不可修改' : '登录身份', tone: 'primary' },
{ label: '权限范围', value: `${draft.menuKeys.length} 菜单 · ${draft.vehicleGrants.length}`, detail: '最小必要权限', tone: draftAccessState.attention ? 'warning' : 'success' },
{
label: '账号状态',
value: <span className="v2-user-editor-status-summary"><Switch aria-label={draft.status === 'enabled' ? '账号启用' : '账号停用'} checked={draft.status === 'enabled'} onChange={(checked) => setDraft((value) => ({ ...value, status: checked ? 'enabled' : 'disabled' }))} /><span>{draft.status === 'enabled' ? '启用' : '停用'}</span></span>,
detail: '保存后最多 30 秒生效',
tone: draft.status === 'enabled' ? 'success' : 'neutral'
}
]}
onCancel={requestCloseEditor}
footer={<div className="v2-user-editor-sheet-footer">
<span className="v2-user-save-state" role="status">
footerNote={<span className="v2-user-save-state" role="status">
<Tag color={save.isError ? 'red' : hasUnsavedChanges ? 'amber' : 'green'} type="light" size="small">{save.isError ? '保存失败' : hasUnsavedChanges ? '待保存' : '已同步'}</Tag>
<Typography.Text className={`v2-user-feedback${save.isError ? ' is-error' : ''}`} type={save.isError ? 'danger' : 'tertiary'}>{feedback || (hasUnsavedChanges ? '更改仅保存在当前草稿,保存后最多 30 秒生效' : '账号与权限已和服务器同步')}</Typography.Text>
</span>
<Button theme="solid" htmlType="submit" form="v2-user-editor-form" disabled={save.isPending || !hasUnsavedChanges}>{save.isPending ? '正在保存…' : creating ? '创建账号' : '保存权限'}</Button>
</div>}
</span>}
primaryAction={{ label: creating ? '创建账号' : '保存权限', onClick: persistDraft, disabled: save.isPending || !hasUnsavedChanges, loading: save.isPending }}
>
{editorVisible ? <form id="v2-user-editor-form" className="v2-user-editor-form" onSubmit={submit}>
<Tabs className="v2-user-editor-tabs" activeKey={activeSection} onChange={(key) => setActiveSection(String(key) as EditorSection)}>
@@ -680,25 +688,39 @@ export default function UsersPage() {
</Tabs.TabPane>
</Tabs>
</form> : null}
</SideSheet>
<SideSheet
</WorkspaceSideSheet>
<WorkspaceSideSheet
className="v2-user-grant-sidesheet"
variant="config"
visible={Boolean(editingGrant)}
placement={mobileLayout ? 'bottom' : 'right'}
width={420}
aria-label="车辆授权有效期"
title={<div className="v2-user-grant-sheet-title"><strong></strong><span>{editingGrant ? `${editingGrantPlate || '未登记车牌'} · ${editingGrant.vin}` : '车辆授权时间窗口'}</span></div>}
height={mobileLayout ? 'min(82dvh, 680px)' : undefined}
ariaLabel="车辆授权有效期"
closeLabel="关闭车辆授权有效期"
title="车辆授权有效期"
description={editingGrant ? `${editingGrantPlate || '未登记车牌'} · ${editingGrant.vin}` : '设置车辆可访问时间窗口'}
badge={editingGrant?.validTo ? '定时停用' : '持续有效'}
badgeColor={editingGrant?.validTo ? 'orange' : 'green'}
summaryItems={editingGrant ? [
{ label: '车牌', value: editingGrantPlate || '未登记车牌', tone: 'primary' },
{ label: '启用', value: formatGrantTime(editingGrant.validFrom), detail: '授权开始' },
{ label: '停用', value: editingGrant.validTo ? formatGrantTime(editingGrant.validTo) : '持续有效', detail: editingGrant.validTo ? '授权结束' : '未设置结束时间', tone: editingGrant.validTo ? 'warning' : 'success' }
] : []}
onCancel={() => setEditingGrantVIN('')}
footer={<div className="v2-user-grant-sheet-footer"><Typography.Text type="tertiary"></Typography.Text><Button theme="solid" disabled={grantWindowInvalid} onClick={() => setEditingGrantVIN('')}></Button></div>}
footerNote="完成后仍需保存账号权限才会生效"
primaryAction={{ label: '完成', onClick: () => setEditingGrantVIN(''), disabled: grantWindowInvalid }}
>
{editingGrant ? <div className="v2-user-grant-form">
<Card className="v2-user-grant-summary" bodyStyle={{ padding: 0 }}>
<span><small></small><strong>{editingGrantPlate || '未登记车牌'}</strong></span>
<span><small>VIN</small><strong>{editingGrant.vin}</strong></span>
</Card>
<label><span></span><Input aria-label={`${editingGrantPlate || editingGrant.vin} 用时间`} type="datetime-local" required value={editingGrant.validFrom} onChange={(value) => updateGrant(editingGrant.vin, { validFrom: value })} /></label>
<label><span></span><Input aria-label={`${editingGrantPlate || editingGrant.vin} 停用时间`} type="datetime-local" min={editingGrant.validFrom} value={editingGrant.validTo} onChange={(value) => updateGrant(editingGrant.vin, { validTo: value })} /></label>
<div className="v2-user-grant-field-group" role="group" aria-labelledby="v2-user-grant-window-heading">
<header><span><strong id="v2-user-grant-window-heading">访</strong><small></small></span><Tag color="blue" type="light" size="small"></Tag></header>
<div>
<label><span></span><Input aria-label={`${editingGrantPlate || editingGrant.vin} 启用时间`} type="datetime-local" required value={editingGrant.validFrom} onChange={(value) => updateGrant(editingGrant.vin, { validFrom: value })} /></label>
<label><span></span><Input aria-label={`${editingGrantPlate || editingGrant.vin} 用时间`} type="datetime-local" min={editingGrant.validFrom} value={editingGrant.validTo} onChange={(value) => updateGrant(editingGrant.vin, { validTo: value })} /></label>
</div>
</div>
{grantWindowInvalid ? <p role="alert"></p> : <p></p>}
</div> : null}
</SideSheet>
</WorkspaceSideSheet>
</div>;
}

View File

@@ -263,7 +263,12 @@ describe('V2 production entry', () => {
expect(corePageSources.UsersPage).toContain('<Card');
expect(corePageSources.UsersPage).toContain('<Card className="v2-user-list"');
expect(corePageSources.UsersPage).toContain('className="v2-user-editor-sidesheet"');
expect(corePageSources.UsersPage).toContain('useSideSheetA11y(editorVisible');
expect(corePageSources.UsersPage).toContain("from '../shared/WorkspaceSideSheet'");
expect(corePageSources.UsersPage).toContain('<WorkspaceSideSheet\n className="v2-user-editor-sidesheet"');
expect(corePageSources.UsersPage).toContain('<WorkspaceSideSheet\n className="v2-user-grant-sidesheet"');
expect(corePageSources.UsersPage).toContain('variant="editor"');
expect(corePageSources.UsersPage).toContain('variant="config"');
expect(corePageSources.UsersPage).not.toContain('useSideSheetA11y(editorVisible');
expect(corePageSources.UsersPage).toContain('<List');
expect(corePageSources.UsersPage).toContain('<List.Item');
expect(corePageSources.UsersPage).toContain('className="v2-user-command-bar"');
@@ -273,7 +278,7 @@ describe('V2 production entry', () => {
expect(corePageSources.UsersPage).toContain('aria-expanded={selectedID === user.id && !creating}');
expect(corePageSources.UsersPage).toContain('<Tabs className="v2-user-editor-tabs"');
expect(corePageSources.UsersPage).toContain('<Tabs.TabPane');
expect(corePageSources.UsersPage).toContain("'关闭账号详情'");
expect(corePageSources.UsersPage).toContain('closeLabel="关闭账号详情"');
expect(corePageSources.UsersPage).toContain('<Card className="v2-user-editor-section v2-user-identity-section"');
expect(corePageSources.UsersPage).toContain('<Card className="v2-user-editor-section v2-user-menu-section"');
expect(corePageSources.UsersPage).toContain('v2-user-editor-section v2-user-vehicle-section');

View File

@@ -21395,3 +21395,225 @@
grid-column: auto;
}
}
/*
* Semi UI account editor workbenches: the account and grant editors now share
* the same title, summary and action hierarchy as the other configuration
* surfaces while preserving one focused scroll area.
*/
.v2-user-editor-sidesheet.v2-workspace-editor-sidesheet .semi-sidesheet-inner {
width: min(840px, 100vw) !important;
}
.v2-user-editor-sidesheet .v2-workspace-config-summary {
min-height: 72px;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 1.15fr) minmax(0, .9fr);
}
.v2-user-editor-sidesheet .v2-workspace-config-summary strong {
font-size: 13px;
}
.v2-user-editor-status-summary {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.v2-user-editor-status-summary > span {
overflow: hidden;
text-overflow: ellipsis;
}
.v2-user-editor-sidesheet .v2-workspace-config-content {
display: flex;
height: 100%;
}
.v2-user-editor-sidesheet .v2-user-editor-form {
width: 100%;
flex: 1 1 0;
}
.v2-user-editor-sidesheet .v2-workspace-config-footer > span {
display: flex;
min-width: 0;
}
.v2-user-editor-sidesheet .v2-workspace-config-footer > div {
min-width: 132px;
}
.v2-user-editor-sidesheet .v2-workspace-config-footer > div > .semi-button {
width: 100%;
min-width: 132px;
}
.v2-user-grant-sidesheet .v2-workspace-config-content {
padding: 14px;
}
.v2-user-grant-sidesheet .v2-workspace-config-summary {
grid-template-columns: minmax(0, .85fr) minmax(0, 1.1fr) minmax(0, 1.05fr);
}
.v2-user-grant-sidesheet .v2-workspace-config-summary strong {
font-size: 11px;
}
.v2-user-grant-form {
gap: 10px;
}
.v2-user-grant-field-group {
overflow: hidden;
border: 1px solid #dce5ef;
border-radius: 11px;
background: #fff;
box-shadow: 0 7px 22px rgba(31, 50, 76, .045);
}
.v2-user-grant-field-group > header {
display: flex;
min-height: 54px;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid #e5ebf2;
background: linear-gradient(180deg, #fff 0%, #fbfcfe 100%);
padding: 10px 12px;
}
.v2-user-grant-field-group > header > span {
display: grid;
min-width: 0;
gap: 3px;
}
.v2-user-grant-field-group > header strong {
color: #2a4058;
font-size: 13px;
}
.v2-user-grant-field-group > header small {
color: #8390a2;
font-size: 9px;
}
.v2-user-grant-field-group > header > .semi-tag {
min-height: 22px;
flex: 0 0 auto;
border-radius: 999px;
font-size: 8px;
font-weight: 700;
}
.v2-user-grant-field-group > div {
display: grid;
gap: 13px;
padding: 13px;
}
.v2-user-grant-field-group label {
display: grid;
gap: 7px;
color: #53677f;
font-size: 11px;
font-weight: 650;
}
.v2-user-grant-field-group label > .semi-input-wrapper {
min-height: 42px;
border-color: #d5dfeb;
border-radius: 8px;
background: #fff;
}
.v2-user-grant-sidesheet .v2-workspace-config-footer > div {
min-width: 96px;
}
@media (max-width: 680px) {
.v2-user-editor-sidesheet.v2-workspace-editor-sidesheet .semi-sidesheet-header {
min-height: 68px;
padding: 9px 11px 9px 13px;
}
.v2-user-editor-sidesheet .v2-workspace-config-title strong {
font-size: 17px;
}
.v2-user-editor-sidesheet .v2-workspace-config-summary {
min-height: 64px;
}
.v2-user-editor-sidesheet .v2-workspace-config-summary > span {
padding: 7px 8px;
}
.v2-user-editor-sidesheet .v2-workspace-config-summary strong {
font-size: 10px;
}
.v2-user-editor-sidesheet .v2-workspace-config-summary em {
font-size: 7px;
}
.v2-user-editor-status-summary {
gap: 4px;
}
.v2-user-editor-status-summary > span {
display: none;
}
.v2-user-editor-sidesheet .v2-workspace-config-footer > span {
display: flex;
flex: 0 0 auto;
}
.v2-user-editor-sidesheet .v2-workspace-config-footer > div {
min-width: 0;
flex: 1 1 0;
}
.v2-user-editor-sidesheet .v2-workspace-config-footer > div > .semi-button {
min-width: 0;
}
.v2-user-grant-sidesheet.v2-workspace-config-sidesheet.semi-sidesheet-bottom .semi-sidesheet-inner {
width: 100% !important;
max-width: 100%;
}
.v2-user-grant-sidesheet .v2-workspace-config-title small {
max-width: 245px;
}
.v2-user-grant-sidesheet .v2-workspace-config-summary {
min-height: 64px;
}
.v2-user-grant-sidesheet .v2-workspace-config-summary > span {
padding: 7px 8px;
}
.v2-user-grant-sidesheet .v2-workspace-config-summary strong {
font-size: 9px;
}
.v2-user-grant-sidesheet .v2-workspace-config-content {
padding: 9px;
}
.v2-user-grant-field-group > header {
min-height: 48px;
padding: 8px 10px;
}
.v2-user-grant-field-group > div {
gap: 10px;
padding: 10px;
}
}