feat(web): rebuild the console around the fused control plane

Render the Hub feature set in the existing Animal Island styling: fleet
messages, notification centre, log centre, organisation panel, per-instance
device module panels and the settings backup, connection and maintenance
surfaces.
This commit is contained in:
chick
2026-09-05 18:53:04 +08:00
parent f9186bd851
commit a75badeb58
77 changed files with 20578 additions and 435 deletions
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { isSensitiveKey, maskTail, protectValue } from './sensitive-fields.js';
describe('sensitive field masking', () => {
it('recognises the same field under every naming style', () => {
expect(isSensitiveKey('iccid')).toBe(true);
expect(isSensitiveKey('ICCID')).toBe(true);
expect(isSensitiveKey('phone_number')).toBe(true);
expect(isSensitiveKey('phoneNumber')).toBe(true);
expect(isSensitiveKey('phone-number')).toBe(true);
expect(isSensitiveKey('slot')).toBe(false);
expect(isSensitiveKey('carrier')).toBe(false);
});
it('keeps the last four digits of a subscriber identifier', () => {
expect(maskTail('89860112345678901234')).toBe('•••••••• 1234');
expect(maskTail('490154203237518')).toBe('•••••••• 7518');
expect(maskTail('123')).toBe('••••');
});
it('masks only sensitive values and only while hidden', () => {
expect(protectValue('iccid', '89860112345678901234', false)).toBe('•••••••• 1234');
expect(protectValue('iccid', '89860112345678901234', true)).toBe('89860112345678901234');
expect(protectValue('status', 'ready', false)).toBe('ready');
});
it('uses the phone mask for subscriber numbers', () => {
expect(protectValue('phone_number', '13800001234', false)).toBe('138 •••• 1234');
expect(protectValue('msisdn', '13800001234', false)).toBe('138 •••• 1234');
expect(protectValue('phoneNumber', '13800001234', true)).toBe('13800001234');
});
});
+42
View File
@@ -0,0 +1,42 @@
import { maskPhoneNumber } from '../fleet/phone-privacy.js';
/**
* Subscriber identifiers the console keeps out of screenshots and shoulder-surfing range by
* default. The list is shared so a card, a table cell and a fleet row never disagree about what
* counts as sensitive.
*/
export const SENSITIVE_KEYS: ReadonlySet<string> = new Set([
'iccid',
'imei',
'imsi',
'meid',
'phone_number',
'msisdn',
'phonenumber',
]);
const MASK = '••••';
/** `phone-number`, `phoneNumber` and `phone_number` all name the same field. */
function normalize(key: string): string {
return key.toLocaleLowerCase().replace(/[-\s]/gu, '_');
}
const PHONE_KEYS: ReadonlySet<string> = new Set(['phone_number', 'msisdn', 'phonenumber']);
export function isSensitiveKey(key: string): boolean {
return SENSITIVE_KEYS.has(normalize(key));
}
/** Keeps the last four digits, which is usually all an operator needs to pick the right card. */
export function maskTail(value: string): string {
const digits = value.replace(/\D/gu, '');
if (digits.length <= 4) return MASK;
return `${'•'.repeat(Math.min(8, Math.max(4, digits.length - 4)))} ${digits.slice(-4)}`;
}
export function protectValue(key: string, value: string, revealed: boolean): string {
if (revealed || !isSensitiveKey(key)) return value;
if (PHONE_KEYS.has(normalize(key))) return maskPhoneNumber(value);
return maskTail(value);
}
@@ -0,0 +1,97 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it } from 'vitest';
import {
SensitiveRevealProvider,
SensitiveRevealToggle,
useSensitiveReveal,
} from './sensitive-reveal.js';
afterEach(() => {
cleanup();
localStorage.clear();
});
function Probe() {
const { revealed, toggle } = useSensitiveReveal();
return (
<>
<span data-testid="state">{revealed ? 'revealed' : 'masked'}</span>
<button type="button" onClick={toggle}>
切换
</button>
</>
);
}
describe('SensitiveRevealProvider', () => {
it('masks by default and shares one switch across the tree', async () => {
render(
<SensitiveRevealProvider>
<Probe />
<Probe />
</SensitiveRevealProvider>,
);
const states = screen.getAllByTestId('state');
expect(states[0]!.textContent).toBe('masked');
expect(states[1]!.textContent).toBe('masked');
await userEvent.click(screen.getAllByRole('button', { name: '切换' })[0]!);
expect(screen.getAllByTestId('state')[0]!.textContent).toBe('revealed');
expect(screen.getAllByTestId('state')[1]!.textContent).toBe('revealed');
});
it('exposes one labelled switch in the topbar', async () => {
render(
<SensitiveRevealProvider>
<SensitiveRevealToggle />
<Probe />
</SensitiveRevealProvider>,
);
const toggle = screen.getByRole('button', { name: '显示敏感标识' });
expect(toggle.getAttribute('aria-pressed')).toBe('false');
await userEvent.click(toggle);
expect(screen.getByTestId('state').textContent).toBe('revealed');
expect(screen.getByRole('button', { name: '隐藏敏感标识' }).getAttribute('aria-pressed')).toBe(
'true',
);
});
it('remembers the choice for the next page load', async () => {
const view = render(
<SensitiveRevealProvider>
<Probe />
</SensitiveRevealProvider>,
);
await userEvent.click(screen.getByRole('button', { name: '切换' }));
expect(localStorage.getItem('multi-simadmin.reveal-sensitive')).toBe('true');
view.unmount();
render(
<SensitiveRevealProvider>
<Probe />
</SensitiveRevealProvider>,
);
expect(screen.getByTestId('state').textContent).toBe('revealed');
});
it('starts masked again once the stored choice is turned off', async () => {
localStorage.setItem('multi-simadmin.reveal-sensitive', 'false');
render(
<SensitiveRevealProvider>
<Probe />
</SensitiveRevealProvider>,
);
expect(screen.getByTestId('state').textContent).toBe('masked');
await userEvent.click(screen.getByRole('button', { name: '切换' }));
expect(localStorage.getItem('multi-simadmin.reveal-sensitive')).toBe('true');
});
it('leaves consumers harmless without a provider', () => {
render(<Probe />);
expect(screen.getByTestId('state').textContent).toBe('masked');
});
});
+72
View File
@@ -0,0 +1,72 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { Button } from 'animal-island-ui';
import { Icon } from '../ui/icon.js';
const STORAGE_KEY = 'multi-simadmin.reveal-sensitive';
export interface SensitiveRevealState {
/** One switch for the whole console: masked by default, revealed only on purpose. */
readonly revealed: boolean;
readonly toggle: () => void;
}
const SensitiveRevealContext = createContext<SensitiveRevealState>({
revealed: false,
toggle: () => undefined,
});
function readStoredPreference(): boolean {
if (typeof localStorage === 'undefined') return false;
try {
return localStorage.getItem(STORAGE_KEY) === 'true';
} catch {
return false;
}
}
function writeStoredPreference(revealed: boolean): void {
if (typeof localStorage === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, String(revealed));
} catch {
// A blocked storage still keeps the switch working for this page view.
}
}
export function SensitiveRevealProvider({ children }: { readonly children: ReactNode }) {
const [revealed, setRevealed] = useState(readStoredPreference);
const toggle = useCallback(() => setRevealed((value) => !value), []);
useEffect(() => {
writeStoredPreference(revealed);
}, [revealed]);
const value = useMemo<SensitiveRevealState>(() => ({ revealed, toggle }), [revealed, toggle]);
return (
<SensitiveRevealContext.Provider value={value}>{children}</SensitiveRevealContext.Provider>
);
}
export function useSensitiveReveal(): SensitiveRevealState {
return useContext(SensitiveRevealContext);
}
export function SensitiveRevealToggle() {
const { revealed, toggle } = useSensitiveReveal();
return (
<Button
htmlType="button"
className="sensitive-reveal-toggle"
size="small"
ghost
aria-pressed={revealed}
title={revealed ? '恢复隐藏 ICCID、IMEI 与手机号' : '显示 ICCID、IMEI 与手机号'}
icon={<Icon name={revealed ? 'eye-off' : 'eye'} aria-hidden="true" />}
onClick={toggle}
>
{revealed ? '隐藏敏感标识' : '显示敏感标识'}
</Button>
);
}