diff --git a/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.test.tsx index 945818d4..c99e7acb 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.test.tsx @@ -145,6 +145,54 @@ test('pages and filters large vehicle grants without nesting a vehicle-list scro expect(screen.getByText('第 1–1 条,共 1 辆')).toBeInTheDocument(); }); +test('makes draft state explicit, protects bulk removal and preserves edits across a same-revision refresh', async () => { + const customer = { + id: 7, + username: 'customer-east', + displayName: '华东客户', + userType: 'customer', + status: 'enabled', + customerRef: '', + tenantRef: '', + authProvider: 'local', + menuKeys: ['monitor', 'vehicles', 'tracks', 'statistics'], + vehicleVins: ['VIN001'], + vehicles: [{ vin: 'VIN001', plate: '粤A11111', validFrom: '2026-06-05T00:00:00+08:00', sourceSystem: 'manual', grantedBy: '平台管理员' }], + grantHistory: [], + createdAt: '2026-07-16T00:00:00Z', + updatedAt: '2026-07-16T00:00:00Z' + }; + mocks.adminUsers.mockResolvedValue([customer]); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + + fireEvent.click(await screen.findByRole('button', { name: /选择客户 华东客户/ })); + expect(screen.getByText('已同步')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '保存权限' })).toBeDisabled(); + + fireEvent.click(screen.getByRole('tab', { name: '登录身份' })); + const displayName = screen.getByDisplayValue('华东客户'); + fireEvent.change(displayName, { target: { value: '华东客户(更新)' } }); + expect(screen.getByText('待保存')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '保存权限' })).toBeEnabled(); + + client.setQueryData(['admin-users'], [{ ...customer }]); + await waitFor(() => expect(screen.getByDisplayValue('华东客户(更新)')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('tab', { name: /车辆权限/ })); + fireEvent.click(screen.getByRole('button', { name: '清空全部 1 辆车辆权限' })); + expect(await screen.findByText('清空 1 辆车辆权限?')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '确认清空' })); + await waitFor(() => expect(screen.queryByRole('button', { name: '移除 粤A11111' })).not.toBeInTheDocument()); + expect(screen.getByText('已从草稿移除 1 辆车,保存后生效')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: '关闭账号详情' })); + expect(await screen.findByText('放弃未保存的更改?')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'cancel' })); + expect(document.querySelector('.v2-user-editor-form')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '保存权限' })).toBeEnabled(); +}); + test('keeps the customer directory visible on mobile and opens details on demand', async () => { layout.mobile = true; mocks.adminUsers.mockResolvedValue([{ diff --git a/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx index 186cd0c4..935aa06e 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx @@ -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, Select, SideSheet, Spin, Switch, Tabs, Tag, Typography } from '@douyinfe/semi-ui'; +import { Avatar, Button, Card, Checkbox, Collapse, Empty, Input, List, Modal, Popconfirm, 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'; @@ -56,6 +56,14 @@ function draftFromUser(user?: AdminUser): Draft { }; } +function draftSignature(draft: Draft) { + return JSON.stringify({ + ...draft, + menuKeys: [...draft.menuKeys].sort(), + vehicleGrants: [...draft.vehicleGrants].sort((left, right) => left.vin.localeCompare(right.vin)) + }); +} + function formatTime(value?: string) { if (!value) return '尚未登录'; return new Date(value).toLocaleString('zh-CN', { hour12: false }); @@ -89,6 +97,7 @@ export default function UsersPage() { const [activeSection, setActiveSection] = useState('identity'); const selected = useMemo(() => customers.find((user) => user.id === selectedID), [customers, selectedID]); const [draft, setDraft] = useState(() => draftFromUser()); + const [baselineDraft, setBaselineDraft] = useState(() => draftFromUser()); const [vehicleKeyword, setVehicleKeyword] = useState(''); const deferredVehicleKeyword = useDeferredValue(vehicleKeyword.trim()); const [assignedKeyword, setAssignedKeyword] = useState(''); @@ -105,15 +114,18 @@ export default function UsersPage() { const grantWindowInvalid = Boolean(editingGrant && (!editingGrant.validFrom || (editingGrant.validTo && editingGrant.validTo <= editingGrant.validFrom))); const editorVisible = creating || Boolean(selected); const vehicleComposerVisible = !mobileLayout || creating || draft.vehicleGrants.length === 0 || vehicleComposerOpen; + const hasUnsavedChanges = useMemo(() => draftSignature(draft) !== draftSignature(baselineDraft), [baselineDraft, draft]); 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) { - setDraft(draftFromUser(selected)); + const nextDraft = draftFromUser(selected); + setDraft(nextDraft); + setBaselineDraft(nextDraft); setVehicleLabels(Object.fromEntries(selected.vehicles.map((vehicle) => [vehicle.vin, vehicle.plate]))); } - }, [creating, selected]); + }, [creating, selected?.id, selected?.updatedAt]); const candidates = useQuery({ queryKey: ['permission-vehicle-candidates', deferredVehicleKeyword], @@ -160,17 +172,23 @@ export default function UsersPage() { setFeedback(creating ? '客户账号已创建' : '账号与权限已更新'); setCreating(false); setSelectedID(result.id); - setDraft((value) => ({ ...value, password: '' })); + setDraft((value) => { + const savedDraft = { ...value, password: '' }; + setBaselineDraft(savedDraft); + return savedDraft; + }); await queryClient.invalidateQueries({ queryKey: ['admin-users'] }); }, onError: (error) => setFeedback(error instanceof Error ? error.message : '保存失败') }); const startCreate = () => { + const nextDraft = draftFromUser(); setCreating(true); setSelectedID(null); setActiveSection('identity'); - setDraft(draftFromUser()); + setDraft(nextDraft); + setBaselineDraft(nextDraft); setVehicleKeyword(''); setAssignedKeyword(''); setAssignedPage(1); @@ -181,9 +199,11 @@ export default function UsersPage() { setEditingGrantVIN(''); }; const closeEditor = () => { + const nextDraft = draftFromUser(); setCreating(false); setSelectedID(null); - setDraft(draftFromUser()); + setDraft(nextDraft); + setBaselineDraft(nextDraft); setVehicleKeyword(''); setAssignedKeyword(''); setAssignedPage(1); @@ -193,11 +213,27 @@ export default function UsersPage() { setFeedback(''); setEditingGrantVIN(''); }; + const requestCloseEditor = () => { + if (!hasUnsavedChanges) { + closeEditor(); + return; + } + Modal.confirm({ + title: '放弃未保存的更改?', + content: '登录身份、菜单或车辆权限的修改尚未保存,关闭后将恢复到上次保存状态。', + okText: '放弃更改', + cancelText: '继续编辑', + okType: 'danger', + onOk: closeEditor + }); + }; const selectCustomer = (user: AdminUser) => { + const nextDraft = draftFromUser(user); setCreating(false); setSelectedID(user.id); setActiveSection('vehicles'); - setDraft(draftFromUser(user)); + setDraft(nextDraft); + setBaselineDraft(nextDraft); setVehicleKeyword(''); setAssignedKeyword(''); setAssignedPage(1); @@ -207,7 +243,8 @@ export default function UsersPage() { setEditingGrantVIN(''); }; const toggleVIN = (vin: string) => { - if (!assigned.has(vin)) { + const isAssigned = assigned.has(vin); + if (!isAssigned) { setAssignedKeyword(''); setAssignedPage(1); } @@ -217,8 +254,20 @@ export default function UsersPage() { ? value.vehicleGrants.filter((grant) => grant.vin !== vin) : [...value.vehicleGrants, { vin, validFrom: dateTimeInput(new Date().toISOString()), validTo: '' }].sort((left, right) => left.vin.localeCompare(right.vin)) })); + setFeedback(isAssigned + ? `${vehicleLabels[vin] || vin} 已从授权草稿移除,保存后生效` + : `${vehicleLabels[vin] || vin} 已加入授权草稿,保存后生效`); if (editingGrantVIN === vin) setEditingGrantVIN(''); }; + const clearVehicleGrants = () => { + const removedCount = draft.vehicleGrants.length; + setDraft((value) => ({ ...value, vehicleGrants: [] })); + setAssignedKeyword(''); + setAssignedPage(1); + setVehicleComposerOpen(true); + setEditingGrantVIN(''); + setFeedback(`已从草稿移除 ${removedCount} 辆车,保存后生效`); + }; const updateGrant = (vin: string, patch: Partial) => setDraft((value) => ({ ...value, vehicleGrants: value.vehicleGrants.map((grant) => grant.vin === vin ? { ...grant, ...patch } : grant) })); @@ -371,10 +420,13 @@ export default function UsersPage() { {creating ? '创建客户账号' : selected?.displayName}{creating ? '设置登录身份和最小必要权限' : <>@{selected?.username} · {draft.menuKeys.length} 个菜单 · {draft.vehicleGrants.length} 辆车 · 最近登录 {formatTime(selected?.lastLoginAt)}} } - onCancel={closeEditor} + onCancel={requestCloseEditor} footer={
- {feedback || '权限变更最多 30 秒生效,保存前请核对车辆有效期'} - + + {save.isError ? '保存失败' : hasUnsavedChanges ? '待保存' : '已同步'} + {feedback || (hasUnsavedChanges ? '更改仅保存在当前草稿,保存后最多 30 秒生效' : '账号与权限已和服务器同步')} + +
} > {editorVisible ?
@@ -411,10 +463,17 @@ export default function UsersPage() { aria-label={vehicleComposerOpen ? '收起添加车辆' : '添加授权车辆'} onClick={() => setVehicleComposerOpen((value) => !value)} >{vehicleComposerOpen ? '收起' : '添加'} : null} - {draft.vehicleGrants.length ? : null} + {draft.vehicleGrants.length ? + + : null} : null} > {vehicleComposerVisible ?
diff --git a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css index 6a82064a..e938b7de 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css @@ -10096,6 +10096,369 @@ } } +/* + * Semi UI account governance refinement. + * Raise the reading scale to the same level as monitoring and operations, + * while making draft/save state and destructive scope changes unmistakable. + */ +.v2-user-command-bar .v2-workspace-command-copy > .semi-typography:first-child { + font-size: 15px; +} + +.v2-user-command-bar .v2-workspace-command-copy > .semi-typography:last-child { + font-size: 11px; +} + +.v2-user-discovery-shell .v2-user-filter-form { + min-height: 60px; + padding: 9px 10px; +} + +.v2-user-filter-form :is(.semi-input-wrapper, .semi-select), +.v2-user-filter-reset.semi-button { + min-height: 42px; +} + +.v2-user-filter-form .semi-input, +.v2-user-filter-form .semi-select-selection { + min-height: 40px; + font-size: 13px; +} + +.v2-user-directory-header.v2-workspace-panel-header { + min-height: 64px; + padding: 10px 15px; +} + +.v2-user-directory-header .v2-workspace-panel-copy strong { + font-size: 15px; +} + +.v2-user-directory-header .v2-workspace-panel-copy small { + font-size: 11px; +} + +.v2-user-directory-summary > .semi-tag, +.v2-user-directory-summary > .semi-typography { + font-size: 11px; +} + +.v2-user-directory-columns { + min-height: 38px; + color: #748397; + font-size: 10px; +} + +.v2-customer-list .v2-user-list-row.semi-list-item, +.v2-customer-list .v2-user-list-item.semi-button { + min-height: 72px; +} + +.v2-customer-list .v2-user-list-item.semi-button { + padding-block: 9px; +} + +.v2-user-avatar.semi-avatar { + width: 40px; + height: 40px; + border-radius: 10px; + font-size: 14px; +} + +.v2-customer-list .v2-user-list-item .semi-button-content, +.v2-user-directory-columns { + grid-template-columns: 40px minmax(180px, 1.45fr) minmax(76px, .45fr) minmax(76px, .45fr) minmax(160px, .9fr) auto; + gap: 12px; +} + +.v2-user-list-identity b { + color: #20364e; + font-size: 14px; +} + +.v2-user-list-identity small { + color: #728197; + font-size: 11px; +} + +.v2-user-list-facts b { + color: #3f536b; + font-size: 12px; +} + +.v2-user-list-facts.is-login b { + color: #5d7088; + font-size: 11px; +} + +.v2-user-status-tag.semi-tag { + min-width: 46px; + min-height: 24px; + font-size: 10px; +} + +.v2-user-editor-sidesheet .semi-sidesheet-inner { + width: min(900px, 100vw) !important; +} + +.v2-user-editor-sidesheet .semi-sidesheet-header { + min-height: 82px; + padding: 14px 20px; +} + +.v2-user-editor-sheet-title strong { + font-size: 20px; +} + +.v2-user-editor-sheet-title small { + font-size: 11px; +} + +.v2-user-editor-sheet-title .v2-user-status { + font-size: 11px; +} + +.v2-user-editor-sidesheet .v2-user-editor-tabs > .semi-tabs-bar { + min-height: 56px; + padding-inline: 20px; +} + +.v2-user-editor-sidesheet .v2-user-editor-tabs > .semi-tabs-bar .semi-tabs-tab { + min-height: 55px; + font-size: 12px; +} + +.v2-user-editor-sidesheet .v2-user-editor-tabs > .semi-tabs-content { + padding: 14px; +} + +.v2-user-editor-sidesheet .v2-user-editor-section > .semi-card-header { + min-height: 50px; +} + +.v2-user-editor-sidesheet .v2-user-editor-section > .semi-card-header .semi-card-header-wrapper-title h6, +.v2-user-editor-sidesheet .v2-user-editor-section > .semi-card-header .semi-card-header-wrapper-title > span { + font-size: 14px; +} + +.v2-user-editor-sidesheet .v2-user-editor-section > .semi-card-body { + padding: 15px; +} + +.v2-user-editor-sidesheet .v2-user-fields label, +.v2-user-editor-sidesheet .v2-vehicle-permission-tools label { + color: #52657d; + font-size: 12px; +} + +.v2-user-editor-sidesheet .v2-user-fields .semi-input-wrapper, +.v2-user-editor-sidesheet .v2-vehicle-permission-tools .semi-input-wrapper, +.v2-user-editor-sidesheet .v2-bulk-vin > .semi-button { + height: 42px; +} + +.v2-user-editor-sidesheet .v2-user-fields .semi-input, +.v2-user-editor-sidesheet .v2-vehicle-permission-tools .semi-input { + height: 40px; + font-size: 13px; +} + +.v2-user-editor-sidesheet .v2-user-menu-section .v2-menu-permissions label { + min-height: 78px; + padding: 14px; +} + +.v2-user-editor-sidesheet .v2-user-menu-section .v2-menu-permissions b { + font-size: 13px; +} + +.v2-user-editor-sidesheet .v2-user-menu-section .v2-menu-permissions small { + font-size: 11px; +} + +.v2-user-editor-sidesheet .v2-assigned-toolbar strong { + font-size: 13px; +} + +.v2-user-editor-sidesheet .v2-assigned-toolbar small { + font-size: 10px; +} + +.v2-user-editor-sidesheet .v2-assigned-toolbar .semi-input { + font-size: 12px; +} + +.v2-user-editor-sidesheet .v2-assigned-vins > .v2-assigned-vehicle-card.semi-card { + min-height: 108px; + border-color: #dfe7f0; + background: #fff; + padding: 12px 13px; + contain-intrinsic-size: auto 108px; +} + +.v2-user-editor-sidesheet .v2-assigned-vehicle-card > .semi-card-body > header b { + color: #243b54; + font-size: 13px; +} + +.v2-user-editor-sidesheet .v2-assigned-vehicle-card > .semi-card-body > header small { + color: #7c8ca0; + font-size: 10px; +} + +.v2-user-editor-sidesheet .v2-assigned-vehicle-interval small { + font-size: 10px; +} + +.v2-user-editor-sidesheet .v2-assigned-vehicle-interval strong { + color: #425970; + font-size: 11px; +} + +.v2-user-save-state { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; +} + +.v2-user-save-state > .semi-tag { + min-width: 50px; + min-height: 24px; + flex: 0 0 auto; + justify-content: center; + border-radius: 999px; + font-size: 10px; + font-weight: 700; +} + +.v2-user-save-state .v2-user-feedback.semi-typography { + min-width: 0; + overflow: hidden; + margin: 0; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-user-editor-sheet-footer > .semi-button:disabled { + box-shadow: none; +} + +@media (max-width: 980px) and (min-width: 681px) { + .v2-customer-list .v2-user-list-item .semi-button-content, + .v2-user-directory-columns { + grid-template-columns: 40px minmax(160px, 1fr) 76px 76px auto; + } +} + +@media (max-width: 680px) { + .v2-user-command-bar .v2-workspace-command-copy > .semi-typography:first-child { + font-size: 13px; + } + + .v2-user-directory-header .v2-workspace-panel-copy strong { + font-size: 14px; + } + + .v2-customer-list .v2-user-list-row.semi-list-item, + .v2-customer-list .v2-user-list-item.semi-button { + min-height: 78px; + } + + .v2-customer-list .v2-user-list-item .semi-button-content { + grid-template-columns: 38px minmax(0, 1fr) 62px auto; + gap: 4px 8px; + } + + .v2-user-avatar.semi-avatar { + width: 38px; + height: 38px; + font-size: 13px; + } + + .v2-user-list-identity b { + font-size: 14px; + } + + .v2-user-list-identity small { + font-size: 10px; + } + + .v2-user-list-facts small { + color: #8190a3; + font-size: 9px; + } + + .v2-user-list-facts b { + font-size: 11px; + } + + .v2-user-editor-sidesheet .semi-sidesheet-header { + min-height: 70px; + padding: 10px 11px 10px 13px; + } + + .v2-user-editor-sheet-title strong { + font-size: 17px; + } + + .v2-user-editor-sheet-title small { + font-size: 10px; + } + + .v2-user-editor-sidesheet .v2-user-editor-tabs > .semi-tabs-bar .semi-tabs-tab { + font-size: 11px; + } + + .v2-user-editor-sidesheet .v2-user-editor-tabs > .semi-tabs-content { + padding: 8px; + } + + .v2-user-editor-sidesheet .v2-user-editor-section > .semi-card-body { + padding: 10px; + } + + .v2-user-editor-sidesheet .v2-assigned-toolbar strong { + font-size: 12px; + } + + .v2-user-editor-sidesheet .v2-assigned-vins > .v2-assigned-vehicle-card.semi-card { + min-height: 106px; + padding: 10px 11px; + contain-intrinsic-size: auto 106px; + } + + .v2-user-editor-sidesheet .v2-assigned-vehicle-card > .semi-card-body > header b { + font-size: 13px; + } + + .v2-user-editor-sidesheet .v2-assigned-vehicle-card > .semi-card-body > header small { + font-size: 9px; + } + + .v2-user-editor-sidesheet .v2-assigned-vehicle-interval small { + font-size: 9px; + } + + .v2-user-editor-sidesheet .v2-assigned-vehicle-interval strong { + font-size: 10px; + } + + .v2-user-editor-sheet-footer { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + } + + .v2-user-save-state .v2-user-feedback.semi-typography { + display: none; + } + + .v2-user-editor-sheet-footer > .semi-button { + grid-column: 2; + } +} + /* Semi UI workspace migration: global monitor command bar and fleet overview. */ .v2-filterbar.semi-card { border-color: #dce5ef;