fix: deduplicate account vehicle permissions
This commit is contained in:
@@ -52,11 +52,17 @@ type authUser struct {
|
||||
ExternalSubject string `json:"externalSubject,omitempty"`
|
||||
MenuKeys []string `json:"menuKeys"`
|
||||
VehicleVINs []string `json:"vehicleVins"`
|
||||
Vehicles []authVehicleGrant `json:"vehicles"`
|
||||
LastLoginAt *time.Time `json:"lastLoginAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type authVehicleGrant struct {
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
}
|
||||
|
||||
type localCredential struct {
|
||||
authUser
|
||||
PasswordHash string
|
||||
@@ -385,6 +391,7 @@ func (s *authStore) listUsers(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer rows.Close()
|
||||
users := []authUser{}
|
||||
allVINs := []string{}
|
||||
for rows.Next() {
|
||||
var user authUser
|
||||
var external sql.NullString
|
||||
@@ -400,11 +407,75 @@ func (s *authStore) listUsers(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
user.MenuKeys = principal.MenuKeys
|
||||
user.VehicleVINs = principal.VehicleVINs
|
||||
allVINs = append(allVINs, principal.VehicleVINs...)
|
||||
users = append(users, user)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_LIST_FAILED", "无法读取账号列表", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
vehicleLabels, err := loadAuthVehicleGrants(r.Context(), s.db, normalizeVINs(allVINs))
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_LIST_FAILED", "无法读取车辆权限信息", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
for index := range users {
|
||||
users[index].Vehicles = make([]authVehicleGrant, 0, len(users[index].VehicleVINs))
|
||||
for _, vin := range users[index].VehicleVINs {
|
||||
vehicle := vehicleLabels[vin]
|
||||
if vehicle.VIN == "" {
|
||||
vehicle.VIN = vin
|
||||
}
|
||||
users[index].Vehicles = append(users[index].Vehicles, vehicle)
|
||||
}
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), users)
|
||||
}
|
||||
|
||||
func loadAuthVehicleGrants(ctx context.Context, db *sql.DB, vins []string) (map[string]authVehicleGrant, error) {
|
||||
result := make(map[string]authVehicleGrant, len(vins))
|
||||
for start := 0; start < len(vins); start += 250 {
|
||||
end := start + 250
|
||||
if end > len(vins) {
|
||||
end = len(vins)
|
||||
}
|
||||
part := vins[start:end]
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(part)), ",")
|
||||
args := make([]any, 0, len(part)*2)
|
||||
for range 2 {
|
||||
for _, vin := range part {
|
||||
args = append(args, vin)
|
||||
}
|
||||
}
|
||||
query := `SELECT vin, COALESCE(` +
|
||||
`MAX(CASE WHEN source_priority=0 THEN NULLIF(plate,'') END),` +
|
||||
`MAX(CASE WHEN source_priority=1 THEN NULLIF(plate,'') END),'') ` +
|
||||
`FROM (` +
|
||||
`SELECT UPPER(TRIM(vin)) AS vin,plate,0 AS source_priority FROM vehicle_identity_binding WHERE vin IN (` + placeholders + `) ` +
|
||||
`UNION ALL ` +
|
||||
`SELECT UPPER(TRIM(vin)) AS vin,plate,1 AS source_priority FROM vehicle_realtime_snapshot WHERE vin IN (` + placeholders + `)` +
|
||||
`) vehicle_grants GROUP BY vin`
|
||||
rows, err := db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var vehicle authVehicleGrant
|
||||
if err := rows.Scan(&vehicle.VIN, &vehicle.Plate); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
vehicle.VIN = strings.ToUpper(strings.TrimSpace(vehicle.VIN))
|
||||
vehicle.Plate = strings.TrimSpace(vehicle.Plate)
|
||||
result[vehicle.VIN] = vehicle
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *authStore) createCustomer(w http.ResponseWriter, r *http.Request, actor platform.Principal) {
|
||||
var input userMutation
|
||||
if !decodeAuthJSON(w, r, &input) {
|
||||
|
||||
@@ -36,11 +36,17 @@ export interface AdminUser {
|
||||
externalSubject?: string;
|
||||
menuKeys: string[];
|
||||
vehicleVins: string[];
|
||||
vehicles: AdminVehicleGrant[];
|
||||
lastLoginAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminVehicleGrant {
|
||||
vin: string;
|
||||
plate: string;
|
||||
}
|
||||
|
||||
export interface CustomerUserInput {
|
||||
username?: string;
|
||||
displayName: string;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import UsersPage from './UsersPage';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
adminUsers: vi.fn(),
|
||||
vehicleCoverage: vi.fn(),
|
||||
createCustomerUser: vi.fn(),
|
||||
updateCustomerUser: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../../api/client', () => ({ api: mocks }));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
Object.values(mocks).forEach((mock) => mock.mockReset());
|
||||
});
|
||||
|
||||
test('deduplicates vehicle candidates by VIN and renders granted vehicles plate first', async () => {
|
||||
mocks.adminUsers.mockResolvedValue([{
|
||||
id: 7,
|
||||
username: 'customer-east',
|
||||
displayName: '华东客户',
|
||||
userType: 'customer',
|
||||
status: 'enabled',
|
||||
customerRef: '',
|
||||
tenantRef: '',
|
||||
authProvider: 'local',
|
||||
menuKeys: ['monitor'],
|
||||
vehicleVins: ['VIN001'],
|
||||
vehicles: [{ vin: 'VIN001', plate: '粤A11111' }],
|
||||
createdAt: '2026-07-16T00:00:00Z',
|
||||
updatedAt: '2026-07-16T00:00:00Z'
|
||||
}]);
|
||||
const duplicatedVehicle = { vin: 'VIN002', plate: '粤A22222', protocols: ['GB32960', 'JT808'] };
|
||||
mocks.vehicleCoverage.mockResolvedValue({ items: [duplicatedVehicle, duplicatedVehicle], total: 1, limit: 20, offset: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /华东客户/ }));
|
||||
expect(await screen.findByRole('button', { name: '移除 粤A11111' })).toHaveTextContent('粤A11111VIN001');
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '按车牌或 VIN 搜索' }), { target: { value: '粤A22222' } });
|
||||
await waitFor(() => expect(mocks.vehicleCoverage).toHaveBeenCalled());
|
||||
const candidates = await screen.findAllByRole('button', { name: /粤A22222.*VIN002.*选择/ });
|
||||
expect(candidates).toHaveLength(1);
|
||||
expect((mocks.vehicleCoverage.mock.calls[0][0] as URLSearchParams).get('keyword')).toBe('粤A22222');
|
||||
|
||||
fireEvent.click(candidates[0]);
|
||||
expect(await screen.findByRole('button', { name: '移除 粤A22222' })).toHaveTextContent('粤A22222VIN002');
|
||||
});
|
||||
@@ -33,6 +33,17 @@ function formatTime(value?: string) {
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function uniqueVehicles<T extends { vin: string; plate: string }>(vehicles: T[]) {
|
||||
const byVIN = new Map<string, T>();
|
||||
for (const vehicle of vehicles) {
|
||||
const vin = vehicle.vin.trim().toUpperCase();
|
||||
const current = byVIN.get(vin);
|
||||
if (!vin || (current?.plate && !vehicle.plate)) continue;
|
||||
byVIN.set(vin, vehicle);
|
||||
}
|
||||
return [...byVIN.values()];
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const users = useQuery({ queryKey: ['admin-users'], queryFn: ({ signal }) => api.adminUsers(signal), staleTime: 10_000 });
|
||||
@@ -44,19 +55,32 @@ export default function UsersPage() {
|
||||
const [vehicleKeyword, setVehicleKeyword] = useState('');
|
||||
const deferredVehicleKeyword = useDeferredValue(vehicleKeyword.trim());
|
||||
const [bulkVINs, setBulkVINs] = useState('');
|
||||
const [vehicleLabels, setVehicleLabels] = useState<Record<string, string>>({});
|
||||
const [feedback, setFeedback] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!creating && selected) setDraft(draftFromUser(selected));
|
||||
if (!creating && selected) {
|
||||
setDraft(draftFromUser(selected));
|
||||
setVehicleLabels(Object.fromEntries(selected.vehicles.map((vehicle) => [vehicle.vin, vehicle.plate])));
|
||||
}
|
||||
}, [creating, selected]);
|
||||
|
||||
const candidates = useQuery({
|
||||
queryKey: ['permission-vehicle-candidates', deferredVehicleKeyword],
|
||||
queryFn: ({ signal }) => api.vehicles(new URLSearchParams({ keyword: deferredVehicleKeyword, limit: '20', offset: '0' }), signal),
|
||||
queryFn: ({ signal }) => api.vehicleCoverage(new URLSearchParams({ keyword: deferredVehicleKeyword, limit: '20', offset: '0' }), signal),
|
||||
enabled: deferredVehicleKeyword.length >= 1,
|
||||
staleTime: 30_000
|
||||
});
|
||||
const assigned = useMemo(() => new Set(draft.vehicleVins), [draft.vehicleVins]);
|
||||
const candidateVehicles = useMemo(() => uniqueVehicles(candidates.data?.items ?? []), [candidates.data?.items]);
|
||||
useEffect(() => {
|
||||
if (candidateVehicles.length === 0) return;
|
||||
setVehicleLabels((current) => {
|
||||
const next = { ...current };
|
||||
for (const vehicle of candidateVehicles) next[vehicle.vin] = vehicle.plate;
|
||||
return next;
|
||||
});
|
||||
}, [candidateVehicles]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -81,6 +105,7 @@ export default function UsersPage() {
|
||||
setDraft(draftFromUser());
|
||||
setVehicleKeyword('');
|
||||
setBulkVINs('');
|
||||
setVehicleLabels({});
|
||||
setFeedback('');
|
||||
};
|
||||
const selectCustomer = (user: AdminUser) => {
|
||||
@@ -125,8 +150,8 @@ export default function UsersPage() {
|
||||
<section><h4>菜单权限 <small>只能分配客户开放菜单</small></h4><div className="v2-menu-permissions">{customerMenus.map((menu) => <label key={menu.key} className={draft.menuKeys.includes(menu.key) ? 'is-selected' : ''}><input type="checkbox" checked={draft.menuKeys.includes(menu.key)} onChange={() => setDraft((value) => ({ ...value, menuKeys: value.menuKeys.includes(menu.key) ? value.menuKeys.filter((item) => item !== menu.key) : [...value.menuKeys, menu.key] }))} /><span><b>{menu.label}</b><small>{menu.description}</small></span></label>)}</div></section>
|
||||
<section><div className="v2-vehicle-permission-heading"><h4>车辆权限 <small>已选择 {draft.vehicleVins.length} 辆</small></h4>{draft.vehicleVins.length ? <button type="button" onClick={() => setDraft((value) => ({ ...value, vehicleVins: [] }))}>清空</button> : null}</div>
|
||||
<div className="v2-vehicle-permission-tools"><label><span>按车牌或 VIN 搜索</span><input value={vehicleKeyword} onChange={(event) => setVehicleKeyword(event.target.value)} placeholder="输入后显示候选车辆" /></label><label><span>批量粘贴 VIN</span><span className="v2-bulk-vin"><input value={bulkVINs} onChange={(event) => setBulkVINs(event.target.value)} placeholder="空格、逗号或换行分隔" /><button type="button" disabled={!bulkVINs.trim()} onClick={addBulkVINs}>加入</button></span></label></div>
|
||||
{deferredVehicleKeyword ? <div className="v2-vehicle-candidates">{candidates.isPending ? <p>正在搜索车辆…</p> : (candidates.data?.items ?? []).length === 0 ? <p>没有匹配车辆</p> : candidates.data?.items.map((vehicle) => <button type="button" className={assigned.has(vehicle.vin) ? 'is-selected' : ''} key={`${vehicle.vin}-${vehicle.protocol}`} onClick={() => toggleVIN(vehicle.vin)}><span><b>{vehicle.plate || '未登记车牌'}</b><small>{vehicle.vin}</small></span><i>{assigned.has(vehicle.vin) ? '已分配' : '选择'}</i></button>)}</div> : null}
|
||||
{draft.vehicleVins.length ? <div className="v2-assigned-vins">{draft.vehicleVins.map((vin) => <button type="button" key={vin} onClick={() => toggleVIN(vin)}>{vin}<span>×</span></button>)}</div> : <p className="v2-user-empty">尚未分配车辆,客户将无法看到任何车辆数据。</p>}
|
||||
{deferredVehicleKeyword ? <div className="v2-vehicle-candidates">{candidates.isPending ? <p>正在搜索车辆…</p> : candidateVehicles.length === 0 ? <p>没有匹配车辆</p> : candidateVehicles.map((vehicle) => <button type="button" className={assigned.has(vehicle.vin) ? 'is-selected' : ''} key={vehicle.vin} onClick={() => toggleVIN(vehicle.vin)}><span><b>{vehicle.plate || '未登记车牌'}</b><small>{vehicle.vin}</small></span><i>{assigned.has(vehicle.vin) ? '已分配' : '选择'}</i></button>)}</div> : null}
|
||||
{draft.vehicleVins.length ? <div className="v2-assigned-vins">{draft.vehicleVins.map((vin) => { const plate = vehicleLabels[vin]; return <button type="button" key={vin} aria-label={`移除 ${plate || vin}`} onClick={() => toggleVIN(vin)}><span><b>{plate || '未登记车牌'}</b><small>{vin}</small></span><i>×</i></button>; })}</div> : <p className="v2-user-empty">尚未分配车辆,客户将无法看到任何车辆数据。</p>}
|
||||
</section>
|
||||
{feedback ? <p className={`v2-user-feedback${save.isError ? ' is-error' : ''}`} role="status">{feedback}</p> : null}
|
||||
<footer><button type="submit" disabled={save.isPending}>{save.isPending ? '正在保存…' : creating ? '创建账号' : '保存权限'}</button></footer>
|
||||
|
||||
@@ -1423,7 +1423,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-vehicle-candidates > p { grid-column: 1/-1; margin: 16px; color: #8996a7; font-size: 10px; }
|
||||
.v2-vehicle-candidates > button { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; border: 0; border-radius: 6px; background: transparent; padding: 8px; text-align: left; cursor: pointer; }.v2-vehicle-candidates > button:hover { background: #f4f7fa; }.v2-vehicle-candidates > button.is-selected { background: #edf4ff; }
|
||||
.v2-vehicle-candidates b, .v2-vehicle-candidates small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-vehicle-candidates b { color: #34455c; font-size: 10px; }.v2-vehicle-candidates small { margin-top: 3px; color: #8a96a6; font-size: 8px; }.v2-vehicle-candidates i { color: var(--v2-blue); font-size: 9px; font-style: normal; }
|
||||
.v2-assigned-vins { display: flex; max-height: 118px; flex-wrap: wrap; gap: 6px; overflow: auto; margin-top: 10px; }.v2-assigned-vins button { height: 26px; border: 1px solid #dbe4ef; border-radius: 6px; background: #f7f9fc; padding: 0 7px; color: #52637a; cursor: pointer; font: 9px ui-monospace, SFMono-Regular, Menlo, monospace; }.v2-assigned-vins button span { margin-left: 6px; color: #99a4b3; }
|
||||
.v2-assigned-vins { display: grid; max-height: 150px; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 6px; overflow: auto; margin-top: 10px; }.v2-assigned-vins button { display: flex; min-width: 0; min-height: 42px; align-items: center; justify-content: space-between; gap: 8px; border: 1px solid #dbe4ef; border-radius: 7px; background: #f7f9fc; padding: 6px 8px; color: #52637a; text-align: left; cursor: pointer; }.v2-assigned-vins button > span { min-width: 0; }.v2-assigned-vins b,.v2-assigned-vins small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-assigned-vins b { color: #34455c; font-size: 10px; }.v2-assigned-vins small { margin-top: 3px; color: #8a96a6; font: 8px ui-monospace,SFMono-Regular,Menlo,monospace; }.v2-assigned-vins i { flex: 0 0 auto; color: #99a4b3; font-size: 13px; font-style: normal; }
|
||||
.v2-user-empty { margin: 14px 4px; color: #8b97a7; font-size: 10px; line-height: 1.6; }
|
||||
.v2-user-feedback { margin: 14px 0 0; color: #16805b; font-size: 11px; }.v2-user-feedback.is-error { color: var(--v2-red); }
|
||||
.v2-user-editor form > footer { display: flex; justify-content: flex-end; margin-top: 18px; }.v2-user-editor form > footer button:disabled { opacity: .5; }
|
||||
@@ -1443,7 +1443,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-user-admin-heading { align-items: flex-start; margin-bottom: 10px; }.v2-user-admin-heading h2 { font-size: 17px; }.v2-user-admin-heading p { display: none; }.v2-user-admin-heading > button { height: 34px; flex: 0 0 auto; padding: 0 10px; font-size: 10px; }
|
||||
.v2-user-admin-grid { display: flex; min-height: 0; overflow: visible; border: 0; box-shadow: none; flex-direction: column; gap: 8px; background: transparent; }
|
||||
.v2-user-list { display: flex; border: 1px solid var(--v2-border); border-radius: 10px; overflow-x: auto; padding: 6px; background: #fff; }.v2-user-list-summary { flex: 0 0 auto; padding: 8px; }.v2-user-list > button { min-width: 210px; }
|
||||
.v2-user-editor { border: 1px solid var(--v2-border); border-radius: 10px; overflow: hidden; background: #fff; }.v2-user-editor form { padding: 16px 14px 22px; }.v2-user-fields, .v2-menu-permissions, .v2-vehicle-permission-tools, .v2-vehicle-candidates { grid-template-columns: 1fr; }
|
||||
.v2-user-editor { border: 1px solid var(--v2-border); border-radius: 10px; overflow: hidden; background: #fff; }.v2-user-editor form { padding: 16px 14px 22px; }.v2-user-fields, .v2-menu-permissions, .v2-vehicle-permission-tools, .v2-vehicle-candidates, .v2-assigned-vins { grid-template-columns: 1fr; }
|
||||
.v2-password-mobile { display: grid !important; }
|
||||
.v2-topbar-actions .v2-current-user { display: none; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user