feat(web): protect phone data and polish release UI
This commit is contained in:
+3
-6
@@ -4,12 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="theme-color" content="#142137" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22><rect width=%2264%22 height=%2264%22 rx=%2216%22 fill=%22%23315bea%22/><path d=%22M18 42h6V31h-6zm11 0h6V22h-6zm11 0h6V13h-6z%22 fill=%22white%22/></svg>"
|
||||
/>
|
||||
<title>SimAdmin Nexus · 多节点控制台</title>
|
||||
<meta name="theme-color" content="#f3f0e7" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>SimAdmin Control · 多节点控制台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect x="2" y="2" width="60" height="60" rx="15" fill="#f6c95f" stroke="#725d42" stroke-width="3" />
|
||||
<rect x="15" y="35" width="8" height="13" rx="3" fill="#725d42" />
|
||||
<rect x="28" y="26" width="8" height="22" rx="3" fill="#725d42" />
|
||||
<rect x="41" y="16" width="8" height="32" rx="3" fill="#725d42" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 378 B |
@@ -0,0 +1,17 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('browser branding', () => {
|
||||
it('uses the SimAdmin Control title and warm static favicon', async () => {
|
||||
const indexHtml = await readFile(new URL('../index.html', import.meta.url), 'utf8');
|
||||
|
||||
expect(indexHtml).toContain('href="/favicon.svg"');
|
||||
expect(indexHtml).toContain('content="#f3f0e7"');
|
||||
expect(indexHtml).toContain('<title>SimAdmin Control · 多节点控制台</title>');
|
||||
|
||||
const faviconSvg = await readFile(new URL('../public/favicon.svg', import.meta.url), 'utf8');
|
||||
expect(faviconSvg).toContain('#f6c95f');
|
||||
expect(faviconSvg).toContain('#725d42');
|
||||
});
|
||||
});
|
||||
@@ -110,6 +110,9 @@ describe('React AppShell and Fleet vertical slice', () => {
|
||||
).toBeTruthy();
|
||||
expect(screen.getByLabelText('控制台版本 0.1.0')).toBeTruthy();
|
||||
expect(screen.getByText('v0.1.0')).toBeTruthy();
|
||||
expect(screen.queryByText('多节点蜂窝设备控制中心')).toBeNull();
|
||||
expect(screen.getByRole('link', { name: 'animal-island-ui' })).toBeTruthy();
|
||||
expect(screen.getByRole('link', { name: 'CC BY-NC 4.0' })).toBeTruthy();
|
||||
expect(
|
||||
consoleError.mock.calls.some((call) =>
|
||||
call.some((argument) => String(argument).includes('unique "key" prop')),
|
||||
@@ -157,9 +160,11 @@ describe('React AppShell and Fleet vertical slice', () => {
|
||||
expect(within(card).getByText('18.4%')).toBeTruthy();
|
||||
expect(within(card).getByText('63.2%')).toBeTruthy();
|
||||
expect(within(card).getByText('46.7 °C')).toBeTruthy();
|
||||
expect(within(card).getByText('13800138000')).toBeTruthy();
|
||||
expect(within(card).getByText('138 •••• 8000')).toBeTruthy();
|
||||
expect(await within(card).findByText('收到')).toBeTruthy();
|
||||
expect(within(card).getByText('13900139000')).toBeTruthy();
|
||||
expect(within(card).getByText('139 •••• 9000')).toBeTruthy();
|
||||
expect(within(card).queryByText('13800138000')).toBeNull();
|
||||
expect(within(card).queryByText('13900139000')).toBeNull();
|
||||
expect(within(card).queryByText(/这是一条用于聚合页展示/)).toBeNull();
|
||||
expect(within(card).getByText(/2026/)).toBeTruthy();
|
||||
expect(within(card).queryByText('能力未知')).toBeNull();
|
||||
@@ -174,6 +179,10 @@ describe('React AppShell and Fleet vertical slice', () => {
|
||||
expect(within(card).queryByRole('button', { name: '重启服务 Bravo' })).toBeNull();
|
||||
expect(within(card).queryByRole('button', { name: '系统重启 Bravo' })).toBeNull();
|
||||
|
||||
await user.click(within(card).getByRole('button', { name: '显示 Bravo 手机号' }));
|
||||
expect(within(card).getByText('13800138000')).toBeTruthy();
|
||||
expect(within(card).getByText('13900139000')).toBeTruthy();
|
||||
|
||||
await user.click(within(card).getByRole('button', { name: '实例操作 Bravo' }));
|
||||
|
||||
expect(within(card).getByRole('menuitem', { name: '重启服务 Bravo' })).toBeTruthy();
|
||||
|
||||
@@ -659,7 +659,6 @@ export function AppShell({
|
||||
</span>
|
||||
<span className="product-copy">
|
||||
<strong>SimAdmin Control</strong>
|
||||
<small>多节点蜂窝设备控制中心</small>
|
||||
</span>
|
||||
</a>
|
||||
<nav className="global-navigation" aria-label="全局导航">
|
||||
@@ -748,9 +747,9 @@ export function AppShell({
|
||||
</div>
|
||||
<footer className="app-footer" aria-label="项目与组件库信息">
|
||||
<p>
|
||||
SimAdmin 聚合控制台 · 界面组件来自{' '}
|
||||
<a href="https://github.com/guokaigdg/animal-island-ui">animal-island-ui</a>
|
||||
(CC BY-NC 4.0,仅限非商业使用)
|
||||
UI: <a href="https://github.com/guokaigdg/animal-island-ui">animal-island-ui</a>
|
||||
<span aria-hidden="true"> · </span>
|
||||
<a href="https://creativecommons.org/licenses/by-nc/4.0/">CC BY-NC 4.0</a>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -70,13 +70,10 @@ export function ConsoleAuthSettings({
|
||||
return (
|
||||
<section className="settings-page" aria-labelledby="password-protection-title">
|
||||
<header className="page-heading">
|
||||
<p className="eyebrow">SYSTEM SETTINGS</p>
|
||||
<h1 id="password-protection-title">
|
||||
<Title color="app-green">密码保护</Title>
|
||||
</h1>
|
||||
<p>可在当前管理台直接完成首次密码设置;请在可信网络中初始化并妥善保管密码。</p>
|
||||
<p>当前部署为 HTTP,仅适用于可信内网;公网使用必须在前置代理启用 HTTPS。</p>
|
||||
<p>参考单实例 SimAdmin 的访问方式,为整个聚合工作台增加统一登录保护。</p>
|
||||
</header>
|
||||
{error && !status ? <p role="alert">{error}</p> : null}
|
||||
{status ? (
|
||||
|
||||
@@ -31,6 +31,8 @@ describe('aggregate-console authentication UI', () => {
|
||||
);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '请输入访问密码' })).toBeTruthy();
|
||||
expect(screen.queryByText('MULTI SIMADMIN')).toBeNull();
|
||||
expect(screen.queryByText(/密码保护已启用。验证通过后/u)).toBeNull();
|
||||
expect(screen.queryByText('secret fleet')).toBeNull();
|
||||
await user.type(screen.getByLabelText('访问密码'), 'StrongPass!9');
|
||||
await user.click(screen.getByRole('button', { name: '进入管理台' }));
|
||||
@@ -79,8 +81,11 @@ describe('aggregate-console authentication UI', () => {
|
||||
render(<settings.ConsoleAuthSettings dataSource={dataSource} />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '密码保护' })).toBeTruthy();
|
||||
expect(screen.queryByText('SYSTEM SETTINGS')).toBeNull();
|
||||
expect(screen.queryByText(/首次密码仅允许从运行主机本机设置/)).toBeNull();
|
||||
expect(screen.getByText(/可在当前管理台直接完成首次密码设置/)).toBeTruthy();
|
||||
expect(screen.queryByText(/可在当前管理台直接完成首次密码设置/)).toBeNull();
|
||||
expect(screen.queryByText(/参考单实例 SimAdmin/u)).toBeNull();
|
||||
expect(screen.getByText(/当前部署为 HTTP/u)).toBeTruthy();
|
||||
const toggle = screen.getByRole('checkbox', { name: /启用密码保护/ });
|
||||
expect((toggle as HTMLInputElement).checked).toBe(false);
|
||||
await user.click(toggle);
|
||||
|
||||
@@ -125,9 +125,7 @@ export function ConsoleAuthGate({ children, dataSource }: ConsoleAuthGateProps)
|
||||
return (
|
||||
<main className="auth-screen">
|
||||
<section className="auth-card" aria-labelledby="console-login-title">
|
||||
<p className="eyebrow">MULTI SIMADMIN</p>
|
||||
<h1 id="console-login-title">请输入访问密码</h1>
|
||||
<p>密码保护已启用。验证通过后才能进入聚合工作台。</p>
|
||||
<form onSubmit={(event) => void submit(event)}>
|
||||
<label htmlFor="console-password">访问密码</label>
|
||||
<input
|
||||
|
||||
@@ -47,6 +47,8 @@ describe('AutomationPage', () => {
|
||||
expect(within(tabs).getByRole('tab', { name: '执行记录' })).not.toBeNull();
|
||||
expect(within(tabs).getByRole('tab', { name: '操作审计' })).not.toBeNull();
|
||||
expect(await screen.findByText('暂无计划任务')).not.toBeNull();
|
||||
expect(screen.queryByText(/按北京时间(UTC\+8)统一调度/u)).toBeNull();
|
||||
expect(screen.queryByText('创建定时重启、系统重启或短信发送任务。')).toBeNull();
|
||||
});
|
||||
|
||||
it('opens a progressive editor with dynamic tags and no timezone selector', async () => {
|
||||
@@ -55,6 +57,7 @@ describe('AutomationPage', () => {
|
||||
await user.click(screen.getByRole('button', { name: '创建任务' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '创建任务' });
|
||||
expect(dialog).not.toBeNull();
|
||||
expect(within(dialog).queryByText('新建自动化')).toBeNull();
|
||||
expect(within(dialog).getByRole('button', { name: '关闭任务编辑器' })).not.toBeNull();
|
||||
await user.selectOptions(within(dialog).getByLabelText('目标方式'), 'tags');
|
||||
expect(within(dialog).getByLabelText('标签匹配')).not.toBeNull();
|
||||
|
||||
@@ -343,7 +343,6 @@ export function AutomationPage({
|
||||
<header className="workbench-heading">
|
||||
<div>
|
||||
<h1 id="automation-title">自动化</h1>
|
||||
<p>按北京时间(UTC+8)统一调度多实例运维操作。</p>
|
||||
</div>
|
||||
{tab === 'schedules' ? (
|
||||
<button type="button" className="primary-action" onClick={createTask}>
|
||||
@@ -377,7 +376,6 @@ export function AutomationPage({
|
||||
{!loading && tasks.length === 0 ? (
|
||||
<div className="automation-empty">
|
||||
<strong>暂无计划任务</strong>
|
||||
<span>创建定时重启、系统重启或短信发送任务。</span>
|
||||
</div>
|
||||
) : null}
|
||||
{tasks.length > 0 ? (
|
||||
@@ -512,7 +510,6 @@ export function AutomationPage({
|
||||
(runs.length === 0 ? (
|
||||
<div className="automation-empty">
|
||||
<strong>暂无执行记录</strong>
|
||||
<span>任务执行后,结果会显示在这里。</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="schedule-table-wrap">
|
||||
@@ -547,7 +544,6 @@ export function AutomationPage({
|
||||
{recordsContent ?? (
|
||||
<div className="automation-empty">
|
||||
<strong>暂无操作审计</strong>
|
||||
<span>安全追踪与故障排查记录会显示在这里。</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -571,7 +567,6 @@ export function AutomationPage({
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<span className="drawer-eyebrow">{editingTask ? '任务设置' : '新建自动化'}</span>
|
||||
<h2 id="schedule-editor-title">{editingTask ? '编辑任务' : '创建任务'}</h2>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -23,7 +23,12 @@ const snapshot: FleetSnapshot = {
|
||||
summary: {
|
||||
version: '1.1.6',
|
||||
freshness: 'fresh',
|
||||
resources: { cpuPercent: 24, memoryPercent: 51, maxTemperatureCelsius: 42 },
|
||||
resources: {
|
||||
cpuPercent: 24,
|
||||
memoryPercent: 51,
|
||||
maxTemperatureCelsius: 42,
|
||||
phoneNumbers: ['+852 5550 0100'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -33,6 +38,95 @@ const snapshot: FleetSnapshot = {
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('FleetPage card navigation', () => {
|
||||
it('masks both phone locations and reveals them only for the selected card', async () => {
|
||||
const privacySnapshot: FleetSnapshot = {
|
||||
instances: [
|
||||
...snapshot.instances,
|
||||
{
|
||||
id: 'beta',
|
||||
name: 'Beta modem',
|
||||
url: 'http://192.168.1.3',
|
||||
tags: [],
|
||||
revision: 1,
|
||||
},
|
||||
],
|
||||
statuses: new Map([
|
||||
...snapshot.statuses,
|
||||
[
|
||||
'beta',
|
||||
{
|
||||
reachable: true,
|
||||
authenticated: true,
|
||||
summary: {
|
||||
version: '1.2.0',
|
||||
freshness: 'fresh' as const,
|
||||
resources: { phoneNumbers: ['13812345678'] },
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
const messagesDataSource = {
|
||||
load: vi.fn(async (instanceId: string) =>
|
||||
instanceId === 'alpha'
|
||||
? {
|
||||
latest: {
|
||||
id: 'sms-1',
|
||||
direction: 'incoming',
|
||||
phoneNumber: '13900139000',
|
||||
content: 'status',
|
||||
timestamp: '2026-07-19T08:30:00.000Z',
|
||||
},
|
||||
}
|
||||
: {},
|
||||
),
|
||||
};
|
||||
|
||||
render(<FleetPage initialData={privacySnapshot} messagesDataSource={messagesDataSource} />);
|
||||
|
||||
const alpha = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
||||
const beta = screen.getByRole('article', { name: 'Beta modem 实例概览' });
|
||||
expect(within(alpha).getByText('+852 •••• 0100')).toBeTruthy();
|
||||
expect(within(beta).getByText('138 •••• 5678')).toBeTruthy();
|
||||
expect(await within(alpha).findByText('139 •••• 9000')).toBeTruthy();
|
||||
expect(within(alpha).queryByText('+852 5550 0100')).toBeNull();
|
||||
expect(within(alpha).queryByText('13900139000')).toBeNull();
|
||||
|
||||
const reveal = within(alpha).getByRole('button', { name: '显示 Alpha modem 手机号' });
|
||||
expect(reveal.getAttribute('aria-pressed')).toBe('false');
|
||||
fireEvent.click(reveal);
|
||||
|
||||
expect(within(alpha).getByText('+852 5550 0100')).toBeTruthy();
|
||||
expect(within(alpha).getByText('13900139000')).toBeTruthy();
|
||||
expect(within(beta).getByText('138 •••• 5678')).toBeTruthy();
|
||||
expect(within(beta).queryByText('13812345678')).toBeNull();
|
||||
expect(within(alpha).getByRole('button', { name: '隐藏 Alpha modem 手机号' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not show a privacy toggle when neither phone location has a number', () => {
|
||||
const noNumbers: FleetSnapshot = {
|
||||
...snapshot,
|
||||
statuses: new Map([
|
||||
[
|
||||
'alpha',
|
||||
{
|
||||
...snapshot.statuses.get('alpha')!,
|
||||
summary: {
|
||||
...snapshot.statuses.get('alpha')!.summary,
|
||||
resources: { cpuPercent: 24, memoryPercent: 51 },
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
|
||||
render(<FleetPage initialData={noNumbers} />);
|
||||
|
||||
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
||||
expect(within(card).getByText('暂未获取')).toBeTruthy();
|
||||
expect(within(card).queryByRole('button', { name: /手机号/ })).toBeNull();
|
||||
});
|
||||
|
||||
it('makes the whole instance card open its dashboard while ops stay separate and config stays out of the card', () => {
|
||||
render(<FleetPage initialData={snapshot} />);
|
||||
|
||||
@@ -47,6 +141,11 @@ describe('FleetPage card navigation', () => {
|
||||
expect(screen.getByRole('region', { name: '实例状态摘要' }).textContent).toMatch(
|
||||
/实例总数\s*1.*在线\s*1.*需处理\s*0/s,
|
||||
);
|
||||
expect(screen.queryByText('NODE DIRECTORY')).toBeNull();
|
||||
expect(screen.queryByText(/LIVE NODE MATRIX/u)).toBeNull();
|
||||
expect(screen.queryByText('已纳入统一管理')).toBeNull();
|
||||
expect(screen.queryByText('连接与认证正常')).toBeNull();
|
||||
expect(screen.queryByText('离线、认证或未知')).toBeNull();
|
||||
expect(screen.queryByRole('region', { name: '节点资源健康' })).toBeNull();
|
||||
expect(within(card).getByText('SimAdmin 1.1.6')).toBeTruthy();
|
||||
expect(
|
||||
@@ -147,11 +246,12 @@ describe('FleetPage search and filter toolbar', () => {
|
||||
|
||||
const search = screen.getByRole('search', { name: '实例搜索与筛选' });
|
||||
expect(within(search).getByPlaceholderText('搜索名称、ID、标签、源地址…')).toBeTruthy();
|
||||
expect(within(search).getByText(/显示/).textContent).toMatch(/显示\s*1\s*\/\s*1/);
|
||||
expect(within(search).queryByText(/显示/)).toBeNull();
|
||||
expect(document.querySelector('.fleet-result-count')?.textContent).toMatch(/显示\s*1\s*\/\s*1/);
|
||||
|
||||
fireEvent.click(within(search).getByRole('button', { name: /离线/ }));
|
||||
expect(screen.queryByRole('article', { name: 'Alpha modem 实例概览' })).toBeNull();
|
||||
expect(within(search).getByText(/显示/).textContent).toMatch(/显示\s*0\s*\/\s*1/);
|
||||
expect(document.querySelector('.fleet-result-count')?.textContent).toMatch(/显示\s*0\s*\/\s*1/);
|
||||
expect(within(search).getByRole('button', { name: /移除筛选 状态:离线/ })).toBeTruthy();
|
||||
|
||||
fireEvent.change(within(search).getByPlaceholderText('搜索名称、ID、标签、源地址…'), {
|
||||
@@ -161,7 +261,7 @@ describe('FleetPage search and filter toolbar', () => {
|
||||
|
||||
fireEvent.click(within(search).getByRole('button', { name: '清除全部筛选' }));
|
||||
expect(screen.getByRole('article', { name: 'Alpha modem 实例概览' })).toBeTruthy();
|
||||
expect(within(search).getByText(/显示/).textContent).toMatch(/显示\s*1\s*\/\s*1/);
|
||||
expect(document.querySelector('.fleet-result-count')?.textContent).toMatch(/显示\s*1\s*\/\s*1/);
|
||||
});
|
||||
|
||||
it('filters the matrix from the tag group row without moving search out of the sidebar', () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
FleetMessagesDataSource,
|
||||
} from './fleet-messages-api-data-source.js';
|
||||
import { loadFleetMessageSummaries } from './fleet-messages-api-data-source.js';
|
||||
import { formatPhoneNumbers } from './phone-privacy.js';
|
||||
import { createOperationClient, type OperationClient } from '../operations/operation-client.js';
|
||||
import { safeUiError } from '../ui/locale.js';
|
||||
import { Icon, type IconName } from '../ui/icon.js';
|
||||
@@ -171,6 +172,7 @@ export function FleetPage({
|
||||
direction: 'asc',
|
||||
});
|
||||
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set());
|
||||
const [revealedPhoneIds, setRevealedPhoneIds] = useState<ReadonlySet<string>>(new Set());
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [filtersCollapsed, setFiltersCollapsed] = useState(false);
|
||||
const [batchBusy, setBatchBusy] = useState(false);
|
||||
@@ -458,6 +460,15 @@ export function FleetPage({
|
||||
});
|
||||
}
|
||||
|
||||
function togglePhonePrivacy(id: string): void {
|
||||
setRevealedPhoneIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function selectedTargets(): Array<{ instanceId: string; revision: number }> {
|
||||
const instances = snapshot?.instances ?? [];
|
||||
return model.selectedIds.flatMap((id) => {
|
||||
@@ -637,7 +648,6 @@ export function FleetPage({
|
||||
<div className="fleet-heading">
|
||||
<div>
|
||||
<h1 id="fleet-title">节点</h1>
|
||||
<p>统一观察蜂窝设备健康度、连接状态与关键资源,快速进入节点执行运维。</p>
|
||||
</div>
|
||||
<div className="fleet-actions">
|
||||
<a className="primary-action" href="/instances/new">
|
||||
@@ -668,7 +678,6 @@ export function FleetPage({
|
||||
>
|
||||
<div className="fleet-sidebar-header">
|
||||
<div>
|
||||
<p className="fleet-sidebar-eyebrow">NODE DIRECTORY</p>
|
||||
<strong>
|
||||
<Icon name="server" />
|
||||
节点总览
|
||||
@@ -701,7 +710,6 @@ export function FleetPage({
|
||||
实例总数
|
||||
</span>
|
||||
<strong>{fleetSummary.total}</strong>
|
||||
<small>已纳入统一管理</small>
|
||||
</div>
|
||||
<div data-tone="online">
|
||||
<span>
|
||||
@@ -709,7 +717,6 @@ export function FleetPage({
|
||||
在线
|
||||
</span>
|
||||
<strong>{fleetSummary.online}</strong>
|
||||
<small>连接与认证正常</small>
|
||||
</div>
|
||||
<div data-tone="attention">
|
||||
<span>
|
||||
@@ -717,7 +724,6 @@ export function FleetPage({
|
||||
需处理
|
||||
</span>
|
||||
<strong>{fleetSummary.attention}</strong>
|
||||
<small>离线、认证或未知</small>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
@@ -796,9 +802,6 @@ export function FleetPage({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="fleet-sidebar-result-count" aria-live="polite">
|
||||
显示 <strong>{model.filteredCount}</strong> / {model.totalCount}
|
||||
</p>
|
||||
{activeFilterChips.length > 0 ? (
|
||||
<div className="fleet-active-filters" aria-label="当前筛选条件">
|
||||
{activeFilterChips.map((chip) => (
|
||||
@@ -885,9 +888,6 @@ export function FleetPage({
|
||||
</div>
|
||||
<div className="fleet-results-header">
|
||||
<div>
|
||||
<p className="fleet-results-eyebrow">
|
||||
<Icon name="grid" /> LIVE NODE MATRIX
|
||||
</p>
|
||||
<p className="fleet-result-count" aria-live="polite">
|
||||
显示 <strong>{model.filteredCount}</strong> / {model.totalCount}
|
||||
{model.pageCount > 1 ? ` · 第 ${model.page}/${model.pageCount} 页` : null}
|
||||
@@ -969,7 +969,12 @@ export function FleetPage({
|
||||
<>
|
||||
<section className="fleet-card-section" aria-label="实例卡片">
|
||||
<div className="fleet-card-grid">
|
||||
{model.rows.map((row) => (
|
||||
{model.rows.map((row) => {
|
||||
const phoneNumbers = row.status?.summary?.resources?.phoneNumbers ?? [];
|
||||
const latestMessage = messageStates.get(row.id)?.latest;
|
||||
const phoneNumbersRevealed = revealedPhoneIds.has(row.id);
|
||||
const hasPrivatePhone = phoneNumbers.length > 0 || Boolean(latestMessage);
|
||||
return (
|
||||
<Card
|
||||
role="article"
|
||||
className={`fleet-card${row.selected ? ' is-selected' : ''}`}
|
||||
@@ -1015,7 +1020,9 @@ export function FleetPage({
|
||||
type="checkbox"
|
||||
aria-label={`选择 ${row.displayName}`}
|
||||
checked={row.selected}
|
||||
onChange={(event) => toggleOne(row.id, event.currentTarget.checked)}
|
||||
onChange={(event) =>
|
||||
toggleOne(row.id, event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
@@ -1151,13 +1158,29 @@ export function FleetPage({
|
||||
) : null}
|
||||
</div>
|
||||
<dl className="fleet-card-hardware" role="group" aria-label="节点硬件信息">
|
||||
<div>
|
||||
<div className="fleet-card-phone">
|
||||
<dt>
|
||||
<Icon name="phone" />
|
||||
手机号
|
||||
</dt>
|
||||
<dd>
|
||||
{row.status?.summary?.resources?.phoneNumbers?.join('、') || '暂未获取'}
|
||||
<span className="fleet-card-phone-value">
|
||||
{phoneNumbers.length > 0
|
||||
? formatPhoneNumbers(phoneNumbers, phoneNumbersRevealed)
|
||||
: '暂未获取'}
|
||||
</span>
|
||||
{hasPrivatePhone ? (
|
||||
<button
|
||||
type="button"
|
||||
className="fleet-phone-privacy-toggle"
|
||||
aria-label={`${phoneNumbersRevealed ? '隐藏' : '显示'} ${row.displayName} 手机号`}
|
||||
aria-pressed={phoneNumbersRevealed}
|
||||
title={phoneNumbersRevealed ? '隐藏手机号' : '显示手机号'}
|
||||
onClick={() => togglePhonePrivacy(row.id)}
|
||||
>
|
||||
<Icon name={phoneNumbersRevealed ? 'eye-off' : 'eye'} />
|
||||
</button>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
@@ -1207,7 +1230,9 @@ export function FleetPage({
|
||||
duration={0}
|
||||
aria-label="内存使用率"
|
||||
/>
|
||||
<strong>{percent(row.status.summary.resources.memoryPercent)}</strong>
|
||||
<strong>
|
||||
{percent(row.status.summary.resources.memoryPercent)}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1229,7 +1254,12 @@ export function FleetPage({
|
||||
{messageDirection(messageStates.get(row.id)!.latest!.direction)}
|
||||
</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{messageStates.get(row.id)!.latest!.phoneNumber}</span>
|
||||
<span>
|
||||
{formatPhoneNumbers(
|
||||
[messageStates.get(row.id)!.latest!.phoneNumber],
|
||||
phoneNumbersRevealed,
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<time
|
||||
className="fleet-card-footer-time"
|
||||
@@ -1259,7 +1289,8 @@ export function FleetPage({
|
||||
</p>
|
||||
) : null}
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
<nav className="pagination" aria-label="实例分页">
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatPhoneNumbers, maskPhoneNumber } from './phone-privacy.js';
|
||||
|
||||
describe('phone privacy formatting', () => {
|
||||
it('preserves an international prefix and the last four digits', () => {
|
||||
expect(maskPhoneNumber('+852 5550 0100')).toBe('+852 •••• 0100');
|
||||
expect(maskPhoneNumber('+852-5550-0100')).toBe('+852 •••• 0100');
|
||||
expect(maskPhoneNumber('+85255500100')).toBe('+852 •••• 0100');
|
||||
});
|
||||
|
||||
it('preserves the first three and last four digits for local numbers', () => {
|
||||
expect(maskPhoneNumber('13812345678')).toBe('138 •••• 5678');
|
||||
});
|
||||
|
||||
it('formats multiple numbers without changing revealed values', () => {
|
||||
const numbers = ['+852 5550 0100', '13812345678'];
|
||||
|
||||
expect(formatPhoneNumbers(numbers, false)).toBe('+852 •••• 0100、138 •••• 5678');
|
||||
expect(formatPhoneNumbers(numbers, true)).toBe('+852 5550 0100、13812345678');
|
||||
});
|
||||
|
||||
it('fully masks values that are too short to expose safely', () => {
|
||||
expect(maskPhoneNumber('1234')).toBe('••••');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
const MASK = '••••';
|
||||
const TWO_DIGIT_CALLING_CODES = new Set([
|
||||
'20',
|
||||
'27',
|
||||
'30',
|
||||
'31',
|
||||
'32',
|
||||
'33',
|
||||
'34',
|
||||
'36',
|
||||
'39',
|
||||
'40',
|
||||
'41',
|
||||
'43',
|
||||
'44',
|
||||
'45',
|
||||
'46',
|
||||
'47',
|
||||
'48',
|
||||
'49',
|
||||
'51',
|
||||
'52',
|
||||
'53',
|
||||
'54',
|
||||
'55',
|
||||
'56',
|
||||
'57',
|
||||
'58',
|
||||
'60',
|
||||
'61',
|
||||
'62',
|
||||
'63',
|
||||
'64',
|
||||
'65',
|
||||
'66',
|
||||
'81',
|
||||
'82',
|
||||
'84',
|
||||
'86',
|
||||
'90',
|
||||
'91',
|
||||
'92',
|
||||
'93',
|
||||
'94',
|
||||
'95',
|
||||
'98',
|
||||
]);
|
||||
|
||||
function internationalPrefix(value: string, digits: string): string {
|
||||
const separated = value.match(/^\+\s*(\d{1,3})(?:[\s-]+)/u)?.[1];
|
||||
if (separated) return separated;
|
||||
|
||||
const prefixLength = ['1', '7'].includes(digits[0] ?? '')
|
||||
? 1
|
||||
: TWO_DIGIT_CALLING_CODES.has(digits.slice(0, 2))
|
||||
? 2
|
||||
: 3;
|
||||
return digits.slice(0, prefixLength);
|
||||
}
|
||||
|
||||
export function maskPhoneNumber(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
const digits = trimmed.replace(/\D/gu, '');
|
||||
if (digits.length <= 4) return MASK;
|
||||
|
||||
const suffix = digits.slice(-4);
|
||||
if (trimmed.startsWith('+')) {
|
||||
const prefix = internationalPrefix(trimmed, digits);
|
||||
return `+${prefix} ${MASK} ${suffix}`;
|
||||
}
|
||||
|
||||
return `${digits.slice(0, 3)} ${MASK} ${suffix}`;
|
||||
}
|
||||
|
||||
export function formatPhoneNumbers(values: readonly string[], revealed: boolean): string {
|
||||
return values.map((value) => (revealed ? value : maskPhoneNumber(value))).join('、');
|
||||
}
|
||||
@@ -80,6 +80,7 @@ describe('Settings instances page', () => {
|
||||
it('is explicitly unavailable without an injected fleet source', () => {
|
||||
render(<InstanceSettingsPage />);
|
||||
expect(screen.getByRole('heading', { name: '实例' })).toBeTruthy();
|
||||
expect(screen.queryByText('配置此工作区可用的 SimAdmin 实例。')).toBeNull();
|
||||
expect(screen.getByRole('status', { name: '实例不可用' }).textContent).toMatch(
|
||||
/未提供实例总览数据源/i,
|
||||
);
|
||||
|
||||
@@ -203,7 +203,6 @@ export function InstanceSettingsPage({
|
||||
<h1 id="settings-instances-title">
|
||||
<Title color="app-green">实例</Title>
|
||||
</h1>
|
||||
<p>配置此工作区可用的 SimAdmin 实例。</p>
|
||||
</div>
|
||||
<a href="/instances/new">添加实例</a>
|
||||
</header>
|
||||
|
||||
+97
-60
@@ -253,11 +253,6 @@ code {
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.product-copy small {
|
||||
color: #91a3b9;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -384,16 +379,13 @@ main > section > header,
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.fleet-heading p,
|
||||
main > section > header p,
|
||||
.page-heading p {
|
||||
margin: 0.2rem 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
.page-kicker,
|
||||
.eyebrow,
|
||||
.fleet-sidebar-eyebrow,
|
||||
.fleet-results-eyebrow {
|
||||
.eyebrow {
|
||||
margin: 0 0 0.18rem !important;
|
||||
color: var(--primary) !important;
|
||||
font-size: 0.66rem;
|
||||
@@ -625,17 +617,6 @@ main > section > header p,
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
}
|
||||
.fleet-sidebar-result-count {
|
||||
margin: 0;
|
||||
padding: 0.45rem 0.6rem;
|
||||
color: var(--muted);
|
||||
border-radius: 8px;
|
||||
background: #f4f6f8;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
.fleet-sidebar-result-count strong {
|
||||
color: var(--ink);
|
||||
}
|
||||
.fleet-active-filters {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
@@ -1722,10 +1703,6 @@ main ul[aria-label='已配置实例'] > li {
|
||||
.auth-card input {
|
||||
width: 100%;
|
||||
}
|
||||
.auth-card .eyebrow {
|
||||
color: #8eabff !important;
|
||||
}
|
||||
|
||||
@media (max-width: 70rem) {
|
||||
.fleet-workspace,
|
||||
.fleet-workspace.is-sidebar-collapsed {
|
||||
@@ -1775,7 +1752,6 @@ main ul[aria-label='已配置实例'] > li {
|
||||
gap: 0.55rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
}
|
||||
.product-copy small,
|
||||
.connection-status {
|
||||
display: none;
|
||||
}
|
||||
@@ -1901,11 +1877,6 @@ body,
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.product-copy small {
|
||||
color: var(--workbench-muted);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.global-navigation {
|
||||
align-self: stretch;
|
||||
gap: 0.15rem;
|
||||
@@ -2079,10 +2050,6 @@ main {
|
||||
border-bottom: 1px solid var(--workbench-border);
|
||||
}
|
||||
|
||||
.fleet-results-eyebrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fleet-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -2409,12 +2376,6 @@ main {
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.drawer-eyebrow {
|
||||
color: var(--workbench-mint);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.icon-button {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
@@ -2579,9 +2540,6 @@ main {
|
||||
flex-wrap: wrap;
|
||||
padding: 0.45rem 0.65rem;
|
||||
}
|
||||
.product-copy small {
|
||||
display: none;
|
||||
}
|
||||
.topbar-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -2838,10 +2796,6 @@ h1 {
|
||||
font-size: 1.04rem;
|
||||
}
|
||||
|
||||
.product-copy small {
|
||||
color: #75664f;
|
||||
}
|
||||
|
||||
.global-navigation {
|
||||
gap: 0.3rem;
|
||||
padding: 0.32rem;
|
||||
@@ -2961,9 +2915,7 @@ main > section > header h1,
|
||||
}
|
||||
|
||||
.page-kicker,
|
||||
.eyebrow,
|
||||
.fleet-sidebar-eyebrow,
|
||||
.fleet-results-eyebrow {
|
||||
.eyebrow {
|
||||
color: #138f85 !important;
|
||||
}
|
||||
|
||||
@@ -3115,10 +3067,6 @@ main > section > header h1,
|
||||
border-bottom: 2px dashed #cabfa9;
|
||||
}
|
||||
|
||||
.fleet-results-eyebrow {
|
||||
color: #725d42 !important;
|
||||
}
|
||||
|
||||
.fleet-card-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr));
|
||||
gap: 1rem;
|
||||
@@ -3764,10 +3712,6 @@ main ul[aria-label='已配置实例'] > li > div[class*='animal-'] {
|
||||
color: #725d42;
|
||||
}
|
||||
|
||||
.auth-card .eyebrow {
|
||||
color: #138f85 !important;
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
@@ -3894,7 +3838,6 @@ svg {
|
||||
}
|
||||
.fleet-sidebar-header strong,
|
||||
.fleet-status-summary span,
|
||||
.fleet-results-eyebrow,
|
||||
.fleet-card-kicker,
|
||||
.fleet-card-metrics dt,
|
||||
.fleet-card-sms h3,
|
||||
@@ -3906,7 +3849,6 @@ svg {
|
||||
}
|
||||
.fleet-sidebar-header strong svg,
|
||||
.fleet-status-summary span svg,
|
||||
.fleet-results-eyebrow svg,
|
||||
.fleet-card-kicker svg,
|
||||
.fleet-card-metrics dt svg,
|
||||
.fleet-card-sms h3 svg,
|
||||
@@ -4901,3 +4843,98 @@ svg {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
/* Phone privacy controls share one stable row across all fleet card widths. */
|
||||
.fleet-card-hardware {
|
||||
grid-template-columns: minmax(0, 1.55fr) minmax(5.25rem, 0.65fr);
|
||||
gap: 0.35rem;
|
||||
padding: 0.72rem 0.45rem;
|
||||
}
|
||||
|
||||
.fleet-card-hardware dt,
|
||||
.fleet-card-hardware dd {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fleet-card-hardware .fleet-card-phone {
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.fleet-card-hardware .fleet-card-phone dd {
|
||||
display: flex;
|
||||
min-height: 2.5rem;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.fleet-card-phone-value {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
font-size: 0.7rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fleet-phone-privacy-toggle,
|
||||
.fleet-phone-privacy-toggle:hover:not(:disabled),
|
||||
.fleet-phone-privacy-toggle:active:not(:disabled) {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
min-height: 2.5rem;
|
||||
flex: 0 0 2.5rem;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 1px solid var(--workbench-border);
|
||||
border-radius: 8px;
|
||||
color: var(--workbench-muted);
|
||||
background: #fffef9;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.fleet-phone-privacy-toggle::before {
|
||||
position: absolute;
|
||||
inset: -0.125rem;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.fleet-phone-privacy-toggle:hover:not(:disabled) {
|
||||
color: var(--workbench-text);
|
||||
border-color: var(--workbench-border-strong);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.fleet-phone-privacy-toggle[aria-pressed='true'] {
|
||||
color: var(--workbench-mint);
|
||||
border-color: rgba(17, 119, 110, 0.36);
|
||||
background: #f1faf6;
|
||||
}
|
||||
|
||||
.fleet-phone-privacy-toggle:focus-visible {
|
||||
outline: 2px solid var(--workbench-mint);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.fleet-phone-privacy-toggle svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.fleet-card-footer-primary > span:last-child {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (min-width: 80rem) {
|
||||
.fleet-card-footer {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.fleet-card-footer-time {
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export type IconName =
|
||||
| 'globe'
|
||||
| 'grid'
|
||||
| 'history'
|
||||
| 'eye'
|
||||
| 'eye-off'
|
||||
| 'jobs'
|
||||
| 'memory'
|
||||
| 'message'
|
||||
@@ -42,6 +44,16 @@ const paths: Readonly<Record<IconName, readonly string[]>> = {
|
||||
'chevron-right': ['m9 18 6-6-6-6'],
|
||||
cpu: ['M9 9h6v6H9z', 'M4 9h2M4 15h2M18 9h2M18 15h2M9 4v2M15 4v2M9 18v2M15 18v2', 'M6 6h12v12H6z'],
|
||||
filter: ['M4 5h16l-6 7v5l-4 2v-7Z'],
|
||||
eye: [
|
||||
'M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6S2.5 12 2.5 12Z',
|
||||
'M12 9a3 3 0 1 1 0 6 3 3 0 0 1 0-6Z',
|
||||
],
|
||||
'eye-off': [
|
||||
'M3 3l18 18',
|
||||
'M10.6 6.2A10.7 10.7 0 0 1 12 6c6 0 9.5 6 9.5 6a15 15 0 0 1-2.1 2.8',
|
||||
'M6.1 6.1C3.7 7.8 2.5 12 2.5 12s3.5 6 9.5 6a10.7 10.7 0 0 0 3-.4',
|
||||
'M9.9 9.9a3 3 0 0 0 4.2 4.2',
|
||||
],
|
||||
globe: [
|
||||
'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z',
|
||||
'M3 12h18',
|
||||
|
||||
+110
-15
@@ -102,8 +102,20 @@ async function builtAssetHandler(request, response) {
|
||||
phoneNumbers: [`+852 5550 10${Math.max(0, fixtureIndex)}`],
|
||||
};
|
||||
}
|
||||
if (FLEET_FIXTURE && /^\/api\/v1\/instances\/[^/]+\/messages$/u.test(pathname))
|
||||
body = { messages: [] };
|
||||
if (FLEET_FIXTURE && /^\/api\/v1\/instances\/[^/]+\/messages$/u.test(pathname)) {
|
||||
const fixtureIndex = FLEET_FIXTURE_ITEMS.findIndex((item) => pathname.includes(item.id));
|
||||
body = {
|
||||
messages: [
|
||||
{
|
||||
id: `sms-${Math.max(0, fixtureIndex)}`,
|
||||
direction: 'incoming',
|
||||
phoneNumber: `1390013900${Math.max(0, fixtureIndex)}`,
|
||||
content: 'status',
|
||||
timestamp: '2026-07-30T08:30:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (
|
||||
pathname === '/api/v1/jobs' ||
|
||||
pathname === '/api/v1/audit' ||
|
||||
@@ -134,7 +146,10 @@ async function builtAssetHandler(request, response) {
|
||||
response.end(JSON.stringify({ error: 'Not Found', statusCode: 404 }));
|
||||
return;
|
||||
}
|
||||
const relative = pathname.startsWith('/assets/') ? pathname.slice(1) : 'index.html';
|
||||
const relative =
|
||||
pathname.startsWith('/assets/') || pathname === '/favicon.svg'
|
||||
? pathname.slice(1)
|
||||
: 'index.html';
|
||||
try {
|
||||
const asset = await readFile(join(DIST, relative));
|
||||
response.statusCode = 200;
|
||||
@@ -144,6 +159,7 @@ async function builtAssetHandler(request, response) {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.svg': 'image/svg+xml; charset=utf-8',
|
||||
}[extname(relative)] ?? 'application/octet-stream',
|
||||
);
|
||||
response.end(asset);
|
||||
@@ -396,9 +412,15 @@ async function fleetLayout(cdp) {
|
||||
const firstResource = document.querySelector('.fleet-resource-row');
|
||||
const telemetry = document.querySelector('.fleet-card-telemetry');
|
||||
const footer = document.querySelector('.fleet-card-footer');
|
||||
const footerNumber = document.querySelector('.fleet-card-footer-primary > span:last-child');
|
||||
const menuTrigger = document.querySelector('.fleet-card-menu-trigger');
|
||||
const menuPanel = document.querySelector('.fleet-card-menu-panel');
|
||||
const checkbox = document.querySelector('.fleet-card-select');
|
||||
const hardware = document.querySelector('.fleet-card-hardware');
|
||||
const phoneField = document.querySelector('.fleet-card-phone');
|
||||
const phoneValue = document.querySelector('.fleet-card-phone-value');
|
||||
const phoneToggle = document.querySelector('.fleet-phone-privacy-toggle');
|
||||
const temperatureField = document.querySelector('.fleet-card-hardware > div:not(.fleet-card-phone)');
|
||||
const bounds = (element) => {
|
||||
if (!element) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
@@ -406,12 +428,14 @@ async function fleetLayout(cdp) {
|
||||
};
|
||||
const hardwareEntries = [...document.querySelectorAll('.fleet-card-hardware > div')].map((item) => {
|
||||
const label = item.querySelector('dt');
|
||||
const value = item.querySelector('dd');
|
||||
const value = item.querySelector('.fleet-card-phone-value') ?? item.querySelector('dd');
|
||||
return {
|
||||
labelHeight: label?.getBoundingClientRect().height ?? 0,
|
||||
labelFits: label ? label.scrollWidth <= label.clientWidth : false,
|
||||
valueHeight: value?.getBoundingClientRect().height ?? 0,
|
||||
valueFits: value ? value.scrollWidth <= value.clientWidth : false,
|
||||
valueClientWidth: value?.clientWidth ?? 0,
|
||||
valueScrollWidth: value?.scrollWidth ?? 0,
|
||||
};
|
||||
});
|
||||
const navigation = [...document.querySelectorAll('nav[aria-label="全局导航"] a')].map((item) => {
|
||||
@@ -438,9 +462,16 @@ async function fleetLayout(cdp) {
|
||||
firstResource: bounds(firstResource),
|
||||
telemetry: bounds(telemetry),
|
||||
footer: bounds(footer),
|
||||
footerNumber: bounds(footerNumber),
|
||||
footerNumberFits: footerNumber ? footerNumber.scrollWidth <= footerNumber.clientWidth : false,
|
||||
menuTrigger: bounds(menuTrigger),
|
||||
menuPanel: bounds(menuPanel),
|
||||
checkbox: bounds(checkbox),
|
||||
hardware: bounds(hardware),
|
||||
phoneField: bounds(phoneField),
|
||||
phoneValue: bounds(phoneValue),
|
||||
phoneToggle: bounds(phoneToggle),
|
||||
temperatureField: bounds(temperatureField),
|
||||
hardwareEntries,
|
||||
navigation,
|
||||
};
|
||||
@@ -575,6 +606,31 @@ try {
|
||||
`document.querySelector('.fleet-card-version')?.textContent.trim() === 'SimAdmin 2.4.0'`,
|
||||
`Fleet SimAdmin version did not synchronize at ${viewport.width}px`,
|
||||
);
|
||||
await eventually(
|
||||
cdp,
|
||||
`document.querySelector('.fleet-card-footer-primary')?.textContent.includes('139 •••• 9000')`,
|
||||
`Fleet SMS phone did not load privately at ${viewport.width}px`,
|
||||
);
|
||||
if (viewport.width === 390) {
|
||||
const branding = await evaluate(
|
||||
cdp,
|
||||
`(() => ({ title: document.title, favicon: new URL(document.querySelector('link[rel="icon"]').href).pathname, theme: document.querySelector('meta[name="theme-color"]').content }))()`,
|
||||
);
|
||||
assert.deepEqual(branding, {
|
||||
title: 'SimAdmin Control · 多节点控制台',
|
||||
favicon: '/favicon.svg',
|
||||
theme: '#f3f0e7',
|
||||
});
|
||||
}
|
||||
const privacyText = await evaluate(
|
||||
cdp,
|
||||
`(() => ({ text: document.body.textContent, toggleCount: document.querySelectorAll('.fleet-phone-privacy-toggle').length }))()`,
|
||||
);
|
||||
assert.equal(privacyText.toggleCount, FLEET_FIXTURE_ITEMS.length);
|
||||
assert.match(privacyText.text, /\+852 •••• 0100/u);
|
||||
assert.match(privacyText.text, /139 •••• 9000/u);
|
||||
assert.doesNotMatch(privacyText.text, /\+852 5550 100/u);
|
||||
assert.doesNotMatch(privacyText.text, /13900139000/u);
|
||||
const layout = await fleetLayout(cdp);
|
||||
assert.equal(
|
||||
layout.scrollWidth <= layout.viewport,
|
||||
@@ -585,7 +641,11 @@ try {
|
||||
assert.equal(layout.groupDisplay, 'flex', `${viewport.width}px tag groups must use flex`);
|
||||
assert.equal(layout.resourceDisplay, 'grid', `${viewport.width}px resource rows must use grid`);
|
||||
assert.equal(layout.telemetryDisplay, 'grid', `${viewport.width}px telemetry must use grid`);
|
||||
assert.equal(layout.footerDisplay, 'flex', `${viewport.width}px SMS footer must use flex`);
|
||||
assert.equal(
|
||||
layout.footerDisplay,
|
||||
viewport.width >= 1280 ? 'grid' : 'flex',
|
||||
`${viewport.width}px SMS footer layout`,
|
||||
);
|
||||
assert.equal(
|
||||
layout.workspaceDisplay,
|
||||
'grid',
|
||||
@@ -604,7 +664,13 @@ try {
|
||||
layout.firstResource &&
|
||||
layout.telemetry &&
|
||||
layout.footer &&
|
||||
layout.menuTrigger,
|
||||
layout.footerNumber &&
|
||||
layout.menuTrigger &&
|
||||
layout.hardware &&
|
||||
layout.phoneField &&
|
||||
layout.phoneValue &&
|
||||
layout.phoneToggle &&
|
||||
layout.temperatureField,
|
||||
`${viewport.width}px Fleet information hierarchy must render`,
|
||||
);
|
||||
for (const [name, bounds] of [
|
||||
@@ -651,16 +717,30 @@ try {
|
||||
`${viewport.width}px card action trigger is too small: ${JSON.stringify(layout.menuTrigger)}`,
|
||||
);
|
||||
assert.equal(
|
||||
layout.hardwareEntries.length === FLEET_FIXTURE_ITEMS.length * 2 &&
|
||||
layout.hardwareEntries.every(
|
||||
(entry) =>
|
||||
entry.labelFits &&
|
||||
entry.valueFits &&
|
||||
entry.labelHeight <= 20 &&
|
||||
entry.valueHeight <= 20,
|
||||
),
|
||||
layout.footerNumberFits,
|
||||
true,
|
||||
`${viewport.width}px hardware facts wrap or clip: ${JSON.stringify(layout.hardwareEntries)}`,
|
||||
`${viewport.width}px latest SMS phone must remain fully visible`,
|
||||
);
|
||||
assert.equal(
|
||||
Math.abs(layout.phoneToggle.width - 40) <= 0.5 &&
|
||||
Math.abs(layout.phoneToggle.height - 40) <= 0.5,
|
||||
true,
|
||||
`${viewport.width}px privacy toggle must remain 40px: ${JSON.stringify(layout.phoneToggle)}`,
|
||||
);
|
||||
assert.equal(
|
||||
layout.phoneField.left >= layout.hardware.left - 0.5 &&
|
||||
layout.temperatureField.right <= layout.hardware.right + 0.5 &&
|
||||
layout.phoneValue.right <= layout.phoneToggle.left + 0.5 &&
|
||||
layout.phoneToggle.right <= layout.phoneField.right + 0.5 &&
|
||||
layout.phoneField.right < layout.temperatureField.left,
|
||||
true,
|
||||
`${viewport.width}px phone and temperature geometry overlaps: ${JSON.stringify(layout)}`,
|
||||
);
|
||||
assert.equal(
|
||||
layout.hardwareEntries.length === FLEET_FIXTURE_ITEMS.length * 2 &&
|
||||
layout.hardwareEntries.every((entry) => entry.labelFits && entry.valueFits),
|
||||
true,
|
||||
`${viewport.width}px hardware facts overflow: ${JSON.stringify(layout.hardwareEntries)}`,
|
||||
);
|
||||
if (viewport.sidebarMode === 'left') {
|
||||
assert.equal(
|
||||
@@ -705,6 +785,21 @@ try {
|
||||
await captureScreenshot(cdp, 'warm-fleet-390-cards.png');
|
||||
}
|
||||
if (viewport.width === 1440) {
|
||||
await evaluate(cdp, `document.querySelector('.fleet-phone-privacy-toggle').click()`);
|
||||
await eventually(
|
||||
cdp,
|
||||
`document.querySelector('.fleet-card')?.textContent.includes('+852 5550 100') && document.querySelector('.fleet-card')?.textContent.includes('13900139000')`,
|
||||
'Fleet phone disclosure did not reveal both phone locations',
|
||||
);
|
||||
const disclosedText = await evaluate(
|
||||
cdp,
|
||||
`[...document.querySelectorAll('.fleet-card')].map((card) => card.textContent)`,
|
||||
);
|
||||
assert.match(disclosedText[0], /\+852 5550 100/u);
|
||||
assert.match(disclosedText[0], /13900139000/u);
|
||||
assert.doesNotMatch(disclosedText[1], /\+852 5550/u);
|
||||
assert.doesNotMatch(disclosedText[1], /1390013900/u);
|
||||
await evaluate(cdp, `document.querySelector('.fleet-phone-privacy-toggle').click()`);
|
||||
await evaluate(cdp, `document.querySelector('.fleet-card-menu-trigger').click()`);
|
||||
await eventually(
|
||||
cdp,
|
||||
|
||||
Reference in New Issue
Block a user