Compare commits

...
3 Commits
21 changed files with 1245 additions and 396 deletions
+3 -6
View File
@@ -4,12 +4,9 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" /> <meta name="color-scheme" content="light" />
<meta name="theme-color" content="#142137" /> <meta name="theme-color" content="#f3f0e7" />
<link <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
rel="icon" <title>SimAdmin Control · 多节点控制台</title>
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>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+6
View File
@@ -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

+17
View File
@@ -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');
});
});
+11 -2
View File
@@ -110,6 +110,9 @@ describe('React AppShell and Fleet vertical slice', () => {
).toBeTruthy(); ).toBeTruthy();
expect(screen.getByLabelText('控制台版本 0.1.0')).toBeTruthy(); expect(screen.getByLabelText('控制台版本 0.1.0')).toBeTruthy();
expect(screen.getByText('v0.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( expect(
consoleError.mock.calls.some((call) => consoleError.mock.calls.some((call) =>
call.some((argument) => String(argument).includes('unique "key" prop')), 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('18.4%')).toBeTruthy();
expect(within(card).getByText('63.2%')).toBeTruthy(); expect(within(card).getByText('63.2%')).toBeTruthy();
expect(within(card).getByText('46.7 °C')).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(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).queryByText(/这是一条用于聚合页展示/)).toBeNull();
expect(within(card).getByText(/2026/)).toBeTruthy(); expect(within(card).getByText(/2026/)).toBeTruthy();
expect(within(card).queryByText('能力未知')).toBeNull(); 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();
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' })); await user.click(within(card).getByRole('button', { name: '实例操作 Bravo' }));
expect(within(card).getByRole('menuitem', { name: '重启服务 Bravo' })).toBeTruthy(); expect(within(card).getByRole('menuitem', { name: '重启服务 Bravo' })).toBeTruthy();
+3 -4
View File
@@ -659,7 +659,6 @@ export function AppShell({
</span> </span>
<span className="product-copy"> <span className="product-copy">
<strong>SimAdmin Control</strong> <strong>SimAdmin Control</strong>
<small></small>
</span> </span>
</a> </a>
<nav className="global-navigation" aria-label="全局导航"> <nav className="global-navigation" aria-label="全局导航">
@@ -748,9 +747,9 @@ export function AppShell({
</div> </div>
<footer className="app-footer" aria-label="项目与组件库信息"> <footer className="app-footer" aria-label="项目与组件库信息">
<p> <p>
SimAdmin · {' '} UI: <a href="https://github.com/guokaigdg/animal-island-ui">animal-island-ui</a>
<a href="https://github.com/guokaigdg/animal-island-ui">animal-island-ui</a> <span aria-hidden="true"> · </span>
CC BY-NC 4.0使 <a href="https://creativecommons.org/licenses/by-nc/4.0/">CC BY-NC 4.0</a>
</p> </p>
</footer> </footer>
</div> </div>
@@ -70,13 +70,10 @@ export function ConsoleAuthSettings({
return ( return (
<section className="settings-page" aria-labelledby="password-protection-title"> <section className="settings-page" aria-labelledby="password-protection-title">
<header className="page-heading"> <header className="page-heading">
<p className="eyebrow">SYSTEM SETTINGS</p>
<h1 id="password-protection-title"> <h1 id="password-protection-title">
<Title color="app-green"></Title> <Title color="app-green"></Title>
</h1> </h1>
<p></p>
<p> HTTP使 HTTPS</p> <p> HTTP使 HTTPS</p>
<p> SimAdmin 访</p>
</header> </header>
{error && !status ? <p role="alert">{error}</p> : null} {error && !status ? <p role="alert">{error}</p> : null}
{status ? ( {status ? (
+6 -1
View File
@@ -31,6 +31,8 @@ describe('aggregate-console authentication UI', () => {
); );
expect(await screen.findByRole('heading', { name: '请输入访问密码' })).toBeTruthy(); expect(await screen.findByRole('heading', { name: '请输入访问密码' })).toBeTruthy();
expect(screen.queryByText('MULTI SIMADMIN')).toBeNull();
expect(screen.queryByText(//u)).toBeNull();
expect(screen.queryByText('secret fleet')).toBeNull(); expect(screen.queryByText('secret fleet')).toBeNull();
await user.type(screen.getByLabelText('访问密码'), 'StrongPass!9'); await user.type(screen.getByLabelText('访问密码'), 'StrongPass!9');
await user.click(screen.getByRole('button', { name: '进入管理台' })); await user.click(screen.getByRole('button', { name: '进入管理台' }));
@@ -79,8 +81,11 @@ describe('aggregate-console authentication UI', () => {
render(<settings.ConsoleAuthSettings dataSource={dataSource} />); render(<settings.ConsoleAuthSettings dataSource={dataSource} />);
expect(await screen.findByRole('heading', { name: '密码保护' })).toBeTruthy(); expect(await screen.findByRole('heading', { name: '密码保护' })).toBeTruthy();
expect(screen.queryByText('SYSTEM SETTINGS')).toBeNull();
expect(screen.queryByText(/首次密码仅允许从运行主机本机设置/)).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: /启用密码保护/ }); const toggle = screen.getByRole('checkbox', { name: /启用密码保护/ });
expect((toggle as HTMLInputElement).checked).toBe(false); expect((toggle as HTMLInputElement).checked).toBe(false);
await user.click(toggle); await user.click(toggle);
-2
View File
@@ -125,9 +125,7 @@ export function ConsoleAuthGate({ children, dataSource }: ConsoleAuthGateProps)
return ( return (
<main className="auth-screen"> <main className="auth-screen">
<section className="auth-card" aria-labelledby="console-login-title"> <section className="auth-card" aria-labelledby="console-login-title">
<p className="eyebrow">MULTI SIMADMIN</p>
<h1 id="console-login-title">访</h1> <h1 id="console-login-title">访</h1>
<p></p>
<form onSubmit={(event) => void submit(event)}> <form onSubmit={(event) => void submit(event)}>
<label htmlFor="console-password">访</label> <label htmlFor="console-password">访</label>
<input <input
@@ -47,6 +47,8 @@ describe('AutomationPage', () => {
expect(within(tabs).getByRole('tab', { name: '执行记录' })).not.toBeNull(); expect(within(tabs).getByRole('tab', { name: '执行记录' })).not.toBeNull();
expect(within(tabs).getByRole('tab', { name: '操作审计' })).not.toBeNull(); expect(within(tabs).getByRole('tab', { name: '操作审计' })).not.toBeNull();
expect(await screen.findByText('暂无计划任务')).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 () => { 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: '创建任务' })); await user.click(screen.getByRole('button', { name: '创建任务' }));
const dialog = screen.getByRole('dialog', { name: '创建任务' }); const dialog = screen.getByRole('dialog', { name: '创建任务' });
expect(dialog).not.toBeNull(); expect(dialog).not.toBeNull();
expect(within(dialog).queryByText('新建自动化')).toBeNull();
expect(within(dialog).getByRole('button', { name: '关闭任务编辑器' })).not.toBeNull(); expect(within(dialog).getByRole('button', { name: '关闭任务编辑器' })).not.toBeNull();
await user.selectOptions(within(dialog).getByLabelText('目标方式'), 'tags'); await user.selectOptions(within(dialog).getByLabelText('目标方式'), 'tags');
expect(within(dialog).getByLabelText('标签匹配')).not.toBeNull(); expect(within(dialog).getByLabelText('标签匹配')).not.toBeNull();
@@ -343,7 +343,6 @@ export function AutomationPage({
<header className="workbench-heading"> <header className="workbench-heading">
<div> <div>
<h1 id="automation-title"></h1> <h1 id="automation-title"></h1>
<p>UTC+8</p>
</div> </div>
{tab === 'schedules' ? ( {tab === 'schedules' ? (
<button type="button" className="primary-action" onClick={createTask}> <button type="button" className="primary-action" onClick={createTask}>
@@ -377,7 +376,6 @@ export function AutomationPage({
{!loading && tasks.length === 0 ? ( {!loading && tasks.length === 0 ? (
<div className="automation-empty"> <div className="automation-empty">
<strong></strong> <strong></strong>
<span></span>
</div> </div>
) : null} ) : null}
{tasks.length > 0 ? ( {tasks.length > 0 ? (
@@ -512,7 +510,6 @@ export function AutomationPage({
(runs.length === 0 ? ( (runs.length === 0 ? (
<div className="automation-empty"> <div className="automation-empty">
<strong></strong> <strong></strong>
<span></span>
</div> </div>
) : ( ) : (
<div className="schedule-table-wrap"> <div className="schedule-table-wrap">
@@ -547,7 +544,6 @@ export function AutomationPage({
{recordsContent ?? ( {recordsContent ?? (
<div className="automation-empty"> <div className="automation-empty">
<strong></strong> <strong></strong>
<span></span>
</div> </div>
)} )}
</div> </div>
@@ -571,7 +567,6 @@ export function AutomationPage({
> >
<header> <header>
<div> <div>
<span className="drawer-eyebrow">{editingTask ? '任务设置' : '新建自动化'}</span>
<h2 id="schedule-editor-title">{editingTask ? '编辑任务' : '创建任务'}</h2> <h2 id="schedule-editor-title">{editingTask ? '编辑任务' : '创建任务'}</h2>
</div> </div>
<button <button
+104 -4
View File
@@ -23,7 +23,12 @@ const snapshot: FleetSnapshot = {
summary: { summary: {
version: '1.1.6', version: '1.1.6',
freshness: 'fresh', 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); afterEach(cleanup);
describe('FleetPage card navigation', () => { 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', () => { it('makes the whole instance card open its dashboard while ops stay separate and config stays out of the card', () => {
render(<FleetPage initialData={snapshot} />); render(<FleetPage initialData={snapshot} />);
@@ -47,6 +141,11 @@ describe('FleetPage card navigation', () => {
expect(screen.getByRole('region', { name: '实例状态摘要' }).textContent).toMatch( expect(screen.getByRole('region', { name: '实例状态摘要' }).textContent).toMatch(
/\s*1.*线\s*1.*\s*0/s, /\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(screen.queryByRole('region', { name: '节点资源健康' })).toBeNull();
expect(within(card).getByText('SimAdmin 1.1.6')).toBeTruthy(); expect(within(card).getByText('SimAdmin 1.1.6')).toBeTruthy();
expect( expect(
@@ -147,11 +246,12 @@ describe('FleetPage search and filter toolbar', () => {
const search = screen.getByRole('search', { name: '实例搜索与筛选' }); const search = screen.getByRole('search', { name: '实例搜索与筛选' });
expect(within(search).getByPlaceholderText('搜索名称、ID、标签、源地址…')).toBeTruthy(); 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: /离线/ })); fireEvent.click(within(search).getByRole('button', { name: /离线/ }));
expect(screen.queryByRole('article', { name: 'Alpha modem 实例概览' })).toBeNull(); 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(); expect(within(search).getByRole('button', { name: /移除筛选 状态:离线/ })).toBeTruthy();
fireEvent.change(within(search).getByPlaceholderText('搜索名称、ID、标签、源地址…'), { fireEvent.change(within(search).getByPlaceholderText('搜索名称、ID、标签、源地址…'), {
@@ -161,7 +261,7 @@ describe('FleetPage search and filter toolbar', () => {
fireEvent.click(within(search).getByRole('button', { name: '清除全部筛选' })); fireEvent.click(within(search).getByRole('button', { name: '清除全部筛选' }));
expect(screen.getByRole('article', { name: 'Alpha modem 实例概览' })).toBeTruthy(); 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', () => { it('filters the matrix from the tag group row without moving search out of the sidebar', () => {
+324 -293
View File
@@ -16,6 +16,7 @@ import type {
FleetMessagesDataSource, FleetMessagesDataSource,
} from './fleet-messages-api-data-source.js'; } from './fleet-messages-api-data-source.js';
import { loadFleetMessageSummaries } 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 { createOperationClient, type OperationClient } from '../operations/operation-client.js';
import { safeUiError } from '../ui/locale.js'; import { safeUiError } from '../ui/locale.js';
import { Icon, type IconName } from '../ui/icon.js'; import { Icon, type IconName } from '../ui/icon.js';
@@ -171,6 +172,7 @@ export function FleetPage({
direction: 'asc', direction: 'asc',
}); });
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set());
const [revealedPhoneIds, setRevealedPhoneIds] = useState<ReadonlySet<string>>(new Set());
const [selectionMode, setSelectionMode] = useState(false); const [selectionMode, setSelectionMode] = useState(false);
const [filtersCollapsed, setFiltersCollapsed] = useState(false); const [filtersCollapsed, setFiltersCollapsed] = useState(false);
const [batchBusy, setBatchBusy] = 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 }> { function selectedTargets(): Array<{ instanceId: string; revision: number }> {
const instances = snapshot?.instances ?? []; const instances = snapshot?.instances ?? [];
return model.selectedIds.flatMap((id) => { return model.selectedIds.flatMap((id) => {
@@ -637,7 +648,6 @@ export function FleetPage({
<div className="fleet-heading"> <div className="fleet-heading">
<div> <div>
<h1 id="fleet-title"></h1> <h1 id="fleet-title"></h1>
<p></p>
</div> </div>
<div className="fleet-actions"> <div className="fleet-actions">
<a className="primary-action" href="/instances/new"> <a className="primary-action" href="/instances/new">
@@ -668,7 +678,6 @@ export function FleetPage({
> >
<div className="fleet-sidebar-header"> <div className="fleet-sidebar-header">
<div> <div>
<p className="fleet-sidebar-eyebrow">NODE DIRECTORY</p>
<strong> <strong>
<Icon name="server" /> <Icon name="server" />
@@ -701,7 +710,6 @@ export function FleetPage({
</span> </span>
<strong>{fleetSummary.total}</strong> <strong>{fleetSummary.total}</strong>
<small></small>
</div> </div>
<div data-tone="online"> <div data-tone="online">
<span> <span>
@@ -709,7 +717,6 @@ export function FleetPage({
线 线
</span> </span>
<strong>{fleetSummary.online}</strong> <strong>{fleetSummary.online}</strong>
<small></small>
</div> </div>
<div data-tone="attention"> <div data-tone="attention">
<span> <span>
@@ -717,7 +724,6 @@ export function FleetPage({
</span> </span>
<strong>{fleetSummary.attention}</strong> <strong>{fleetSummary.attention}</strong>
<small>线</small>
</div> </div>
</section> </section>
) : null} ) : null}
@@ -796,9 +802,6 @@ export function FleetPage({
</button> </button>
))} ))}
</div> </div>
<p className="fleet-sidebar-result-count" aria-live="polite">
<strong>{model.filteredCount}</strong> / {model.totalCount}
</p>
{activeFilterChips.length > 0 ? ( {activeFilterChips.length > 0 ? (
<div className="fleet-active-filters" aria-label="当前筛选条件"> <div className="fleet-active-filters" aria-label="当前筛选条件">
{activeFilterChips.map((chip) => ( {activeFilterChips.map((chip) => (
@@ -885,9 +888,6 @@ export function FleetPage({
</div> </div>
<div className="fleet-results-header"> <div className="fleet-results-header">
<div> <div>
<p className="fleet-results-eyebrow">
<Icon name="grid" /> LIVE NODE MATRIX
</p>
<p className="fleet-result-count" aria-live="polite"> <p className="fleet-result-count" aria-live="polite">
<strong>{model.filteredCount}</strong> / {model.totalCount} <strong>{model.filteredCount}</strong> / {model.totalCount}
{model.pageCount > 1 ? ` · 第 ${model.page}/${model.pageCount}` : null} {model.pageCount > 1 ? ` · 第 ${model.page}/${model.pageCount}` : null}
@@ -969,297 +969,328 @@ export function FleetPage({
<> <>
<section className="fleet-card-section" aria-label="实例卡片"> <section className="fleet-card-section" aria-label="实例卡片">
<div className="fleet-card-grid"> <div className="fleet-card-grid">
{model.rows.map((row) => ( {model.rows.map((row) => {
<Card const phoneNumbers = row.status?.summary?.resources?.phoneNumbers ?? [];
role="article" const latestMessage = messageStates.get(row.id)?.latest;
className={`fleet-card${row.selected ? ' is-selected' : ''}`} const phoneNumbersRevealed = revealedPhoneIds.has(row.id);
data-status={row.statusKind} const hasPrivatePhone = phoneNumbers.length > 0 || Boolean(latestMessage);
aria-label={`${row.displayName} 实例概览`} return (
key={row.id} <Card
> role="article"
<div className="fleet-card-glow" aria-hidden="true" /> className={`fleet-card${row.selected ? ' is-selected' : ''}`}
<header> data-status={row.statusKind}
<div className="fleet-card-title-group"> aria-label={`${row.displayName} 实例概览`}
<span className="fleet-node-avatar" aria-hidden="true"> key={row.id}
{initials(row.displayName)} >
</span> <div className="fleet-card-glow" aria-hidden="true" />
<div> <header>
<a <div className="fleet-card-title-group">
className="fleet-card-entry" <span className="fleet-node-avatar" aria-hidden="true">
href={`/instances/${encodeURIComponent(row.id)}/overview`} {initials(row.displayName)}
aria-label={`打开 ${row.displayName} 实例仪表盘`} </span>
> <div>
<h2>{row.displayName}</h2>
<span className="fleet-card-version">
{row.version ? `SimAdmin ${row.version}` : '版本未知'}
</span>
<span className="fleet-card-entry-label">
<Icon name="chevron-right" />
</span>
</a>
</div>
</div>
<div className="fleet-card-header-meta">
<Tag
className={`status status-${row.statusKind}`}
color={STATUS_COLORS[row.statusKind]}
variant="soft"
size="small"
>
<span className="status-dot" aria-hidden="true" />
{STATUS_LABELS[row.statusKind]}
</Tag>
{selectionMode ? (
<label className="fleet-card-select touch-target">
<input
type="checkbox"
aria-label={`选择 ${row.displayName}`}
checked={row.selected}
onChange={(event) => toggleOne(row.id, event.currentTarget.checked)}
/>
</label>
) : null}
<div className="fleet-card-menu">
<button
ref={(element) => {
if (element) cardMenuTriggerRefs.current.set(row.id, element);
else cardMenuTriggerRefs.current.delete(row.id);
}}
type="button"
className="fleet-card-menu-trigger"
aria-label={`实例操作 ${row.displayName}`}
aria-haspopup="menu"
aria-expanded={cardMenuId === row.id}
aria-controls={`fleet-card-menu-${row.id}`}
title="实例操作"
onClick={() => {
cardMenuInitialFocus.current = 'first';
setCardMenuId((current) =>
current === row.id ? undefined : row.id,
);
}}
onKeyDown={(event) => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
event.preventDefault();
cardMenuInitialFocus.current =
event.key === 'ArrowUp' ? 'last' : 'first';
setCardMenuId(row.id);
}}
>
<Icon name="more" />
</button>
{cardMenuId === row.id ? (
<div
ref={cardMenuPanelRef}
id={`fleet-card-menu-${row.id}`}
className="fleet-card-menu-panel"
role="menu"
aria-label={`${row.displayName} 操作`}
onKeyDown={(event) => moveCardMenuFocus(event, row.id)}
>
<button
type="button"
role="menuitem"
tabIndex={-1}
aria-label={`重启服务 ${row.displayName}`}
disabled={cardAction?.id === row.id && cardAction.busy}
onClick={() => {
closeCardMenu(row.id, true);
void runCardAction(row.id, 'service-restart');
}}
>
<Icon name="restart" />
<span></span>
</button>
<button
type="button"
role="menuitem"
tabIndex={-1}
className="fleet-card-menu-danger"
aria-label={`系统重启 ${row.displayName}`}
disabled={cardAction?.id === row.id && cardAction.busy}
onClick={() => {
closeCardMenu(row.id, true);
void runCardAction(row.id, 'system-reboot');
}}
>
<Icon name="alert" />
<span></span>
</button>
</div>
) : null}
</div>
</div>
</header>
<div className="fleet-card-metadata">
<div className="fleet-card-origin-row">
<Icon name="globe" />
{(() => {
const origin = canonicalHttpOrigin(row.instance.url);
return origin ? (
<a <a
className="fleet-card-origin fleet-card-origin-link" className="fleet-card-entry"
href={origin} href={`/instances/${encodeURIComponent(row.id)}/overview`}
target="_blank" aria-label={`打开 ${row.displayName} 实例仪表盘`}
rel="noopener noreferrer"
aria-label={`打开 ${row.displayName} 节点入口`}
title={`在新窗口打开 ${origin}`}
> >
<span>{origin}</span> <h2>{row.displayName}</h2>
<Icon className="fleet-origin-arrow" name="arrow-up-right" /> <span className="fleet-card-version">
{row.version ? `SimAdmin ${row.version}` : '版本未知'}
</span>
<span className="fleet-card-entry-label">
<Icon name="chevron-right" />
</span>
</a> </a>
) : ( </div>
<span </div>
className="fleet-card-origin fleet-card-origin-invalid" <div className="fleet-card-header-meta">
aria-label={`${row.displayName} 源地址无效`} <Tag
className={`status status-${row.statusKind}`}
color={STATUS_COLORS[row.statusKind]}
variant="soft"
size="small"
>
<span className="status-dot" aria-hidden="true" />
{STATUS_LABELS[row.statusKind]}
</Tag>
{selectionMode ? (
<label className="fleet-card-select touch-target">
<input
type="checkbox"
aria-label={`选择 ${row.displayName}`}
checked={row.selected}
onChange={(event) =>
toggleOne(row.id, event.currentTarget.checked)
}
/>
</label>
) : null}
<div className="fleet-card-menu">
<button
ref={(element) => {
if (element) cardMenuTriggerRefs.current.set(row.id, element);
else cardMenuTriggerRefs.current.delete(row.id);
}}
type="button"
className="fleet-card-menu-trigger"
aria-label={`实例操作 ${row.displayName}`}
aria-haspopup="menu"
aria-expanded={cardMenuId === row.id}
aria-controls={`fleet-card-menu-${row.id}`}
title="实例操作"
onClick={() => {
cardMenuInitialFocus.current = 'first';
setCardMenuId((current) =>
current === row.id ? undefined : row.id,
);
}}
onKeyDown={(event) => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
event.preventDefault();
cardMenuInitialFocus.current =
event.key === 'ArrowUp' ? 'last' : 'first';
setCardMenuId(row.id);
}}
> >
<Icon name="more" />
</span> </button>
); {cardMenuId === row.id ? (
})()} <div
ref={cardMenuPanelRef}
id={`fleet-card-menu-${row.id}`}
className="fleet-card-menu-panel"
role="menu"
aria-label={`${row.displayName} 操作`}
onKeyDown={(event) => moveCardMenuFocus(event, row.id)}
>
<button
type="button"
role="menuitem"
tabIndex={-1}
aria-label={`重启服务 ${row.displayName}`}
disabled={cardAction?.id === row.id && cardAction.busy}
onClick={() => {
closeCardMenu(row.id, true);
void runCardAction(row.id, 'service-restart');
}}
>
<Icon name="restart" />
<span></span>
</button>
<button
type="button"
role="menuitem"
tabIndex={-1}
className="fleet-card-menu-danger"
aria-label={`系统重启 ${row.displayName}`}
disabled={cardAction?.id === row.id && cardAction.busy}
onClick={() => {
closeCardMenu(row.id, true);
void runCardAction(row.id, 'system-reboot');
}}
>
<Icon name="alert" />
<span></span>
</button>
</div>
) : null}
</div>
</div>
</header>
<div className="fleet-card-metadata">
<div className="fleet-card-origin-row">
<Icon name="globe" />
{(() => {
const origin = canonicalHttpOrigin(row.instance.url);
return origin ? (
<a
className="fleet-card-origin fleet-card-origin-link"
href={origin}
target="_blank"
rel="noopener noreferrer"
aria-label={`打开 ${row.displayName} 节点入口`}
title={`在新窗口打开 ${origin}`}
>
<span>{origin}</span>
<Icon className="fleet-origin-arrow" name="arrow-up-right" />
</a>
) : (
<span
className="fleet-card-origin fleet-card-origin-invalid"
aria-label={`${row.displayName} 源地址无效`}
>
</span>
);
})()}
</div>
{row.instance.description ? (
<p className="fleet-card-description">{row.instance.description}</p>
) : null}
{row.anomalies.length > 0 ? (
<p
className="fleet-card-anomalies"
aria-label={`${row.displayName} 异常`}
>
{row.anomalies.join(', ')}
</p>
) : null}
{row.tags.length > 0 ? (
<div className="fleet-instance-tags" aria-label="实例标签">
{row.tags.map((item) => (
<Tag key={item} size="small" color="app-yellow" variant="soft">
<Icon name="tag" />
{item}
</Tag>
))}
</div>
) : null}
{row.capabilities.length > 0 ? (
<div className="capability-tags" aria-label="能力">
{row.capabilities.map((item) => (
<Tag key={item} size="small" color="app-teal" variant="soft">
<Icon name={CAPABILITY_ICONS[item] ?? 'grid'} />
{capabilityLabel(item)}
</Tag>
))}
</div>
) : null}
</div> </div>
{row.instance.description ? ( <dl className="fleet-card-hardware" role="group" aria-label="节点硬件信息">
<p className="fleet-card-description">{row.instance.description}</p> <div className="fleet-card-phone">
) : null} <dt>
{row.anomalies.length > 0 ? ( <Icon name="phone" />
</dt>
<dd>
<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>
<dt>
<Icon name="temperature" />
</dt>
<dd>
{temperature(row.status?.summary?.resources?.maxTemperatureCelsius)}
</dd>
</div>
</dl>
<section className="fleet-card-telemetry" aria-label="资源遥测">
<div className="fleet-resource-row">
<span className="fleet-resource-label">
<Icon name="cpu" />
CPU
</span>
{row.status?.summary?.resources?.cpuPercent === undefined ? (
<span className="fleet-resource-unavailable">--</span>
) : (
<div className="fleet-resource-meter">
<Progress
percent={row.status.summary.resources.cpuPercent}
size="small"
showInfo={false}
duration={0}
aria-label="CPU 使用率"
/>
<strong>{percent(row.status.summary.resources.cpuPercent)}</strong>
</div>
)}
</div>
<div className="fleet-resource-row">
<span className="fleet-resource-label">
<Icon name="memory" />
</span>
{row.status?.summary?.resources?.memoryPercent === undefined ? (
<span className="fleet-resource-unavailable">--</span>
) : (
<div className="fleet-resource-meter">
<Progress
percent={row.status.summary.resources.memoryPercent}
size="small"
showInfo={false}
duration={0}
aria-label="内存使用率"
/>
<strong>
{percent(row.status.summary.resources.memoryPercent)}
</strong>
</div>
)}
</div>
</section>
<section className="fleet-card-footer" aria-label="短信状态">
{messageStates.get(row.id)?.unavailable ? (
<>
<span className="fleet-card-footer-primary">
<Icon name="message" />
</span>
<span className="fleet-card-footer-time">--</span>
</>
) : messageStates.get(row.id)?.latest ? (
<>
<span className="fleet-card-footer-primary">
<Icon name="message" />
<span>
{messageDirection(messageStates.get(row.id)!.latest!.direction)}
</span>
<span aria-hidden="true">·</span>
<span>
{formatPhoneNumbers(
[messageStates.get(row.id)!.latest!.phoneNumber],
phoneNumbersRevealed,
)}
</span>
</span>
<time
className="fleet-card-footer-time"
dateTime={messageStates.get(row.id)!.latest!.timestamp}
>
{messageTime(messageStates.get(row.id)!.latest!.timestamp)}
</time>
</>
) : (
<>
<span className="fleet-card-footer-primary">
<Icon name="message" />
</span>
<span className="fleet-card-footer-time"></span>
</>
)}
</section>
{cardAction?.id === row.id ? (
<p <p
className="fleet-card-anomalies" role={cardAction.error ? 'alert' : 'status'}
aria-label={`${row.displayName} 异常`} className={
cardAction.error ? 'card-operation-error' : 'card-operation-success'
}
> >
{row.anomalies.join(', ')} {cardAction.error ?? cardAction.message}
</p> </p>
) : null} ) : null}
{row.tags.length > 0 ? ( </Card>
<div className="fleet-instance-tags" aria-label="实例标签"> );
{row.tags.map((item) => ( })}
<Tag key={item} size="small" color="app-yellow" variant="soft">
<Icon name="tag" />
{item}
</Tag>
))}
</div>
) : null}
{row.capabilities.length > 0 ? (
<div className="capability-tags" aria-label="能力">
{row.capabilities.map((item) => (
<Tag key={item} size="small" color="app-teal" variant="soft">
<Icon name={CAPABILITY_ICONS[item] ?? 'grid'} />
{capabilityLabel(item)}
</Tag>
))}
</div>
) : null}
</div>
<dl className="fleet-card-hardware" role="group" aria-label="节点硬件信息">
<div>
<dt>
<Icon name="phone" />
</dt>
<dd>
{row.status?.summary?.resources?.phoneNumbers?.join('、') || '暂未获取'}
</dd>
</div>
<div>
<dt>
<Icon name="temperature" />
</dt>
<dd>
{temperature(row.status?.summary?.resources?.maxTemperatureCelsius)}
</dd>
</div>
</dl>
<section className="fleet-card-telemetry" aria-label="资源遥测">
<div className="fleet-resource-row">
<span className="fleet-resource-label">
<Icon name="cpu" />
CPU
</span>
{row.status?.summary?.resources?.cpuPercent === undefined ? (
<span className="fleet-resource-unavailable">--</span>
) : (
<div className="fleet-resource-meter">
<Progress
percent={row.status.summary.resources.cpuPercent}
size="small"
showInfo={false}
duration={0}
aria-label="CPU 使用率"
/>
<strong>{percent(row.status.summary.resources.cpuPercent)}</strong>
</div>
)}
</div>
<div className="fleet-resource-row">
<span className="fleet-resource-label">
<Icon name="memory" />
</span>
{row.status?.summary?.resources?.memoryPercent === undefined ? (
<span className="fleet-resource-unavailable">--</span>
) : (
<div className="fleet-resource-meter">
<Progress
percent={row.status.summary.resources.memoryPercent}
size="small"
showInfo={false}
duration={0}
aria-label="内存使用率"
/>
<strong>{percent(row.status.summary.resources.memoryPercent)}</strong>
</div>
)}
</div>
</section>
<section className="fleet-card-footer" aria-label="短信状态">
{messageStates.get(row.id)?.unavailable ? (
<>
<span className="fleet-card-footer-primary">
<Icon name="message" />
</span>
<span className="fleet-card-footer-time">--</span>
</>
) : messageStates.get(row.id)?.latest ? (
<>
<span className="fleet-card-footer-primary">
<Icon name="message" />
<span>
{messageDirection(messageStates.get(row.id)!.latest!.direction)}
</span>
<span aria-hidden="true">·</span>
<span>{messageStates.get(row.id)!.latest!.phoneNumber}</span>
</span>
<time
className="fleet-card-footer-time"
dateTime={messageStates.get(row.id)!.latest!.timestamp}
>
{messageTime(messageStates.get(row.id)!.latest!.timestamp)}
</time>
</>
) : (
<>
<span className="fleet-card-footer-primary">
<Icon name="message" />
</span>
<span className="fleet-card-footer-time"></span>
</>
)}
</section>
{cardAction?.id === row.id ? (
<p
role={cardAction.error ? 'alert' : 'status'}
className={
cardAction.error ? 'card-operation-error' : 'card-operation-success'
}
>
{cardAction.error ?? cardAction.message}
</p>
) : null}
</Card>
))}
</div> </div>
</section> </section>
<nav className="pagination" aria-label="实例分页"> <nav className="pagination" aria-label="实例分页">
+26
View File
@@ -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('••••');
});
});
+77
View File
@@ -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', () => { it('is explicitly unavailable without an injected fleet source', () => {
render(<InstanceSettingsPage />); render(<InstanceSettingsPage />);
expect(screen.getByRole('heading', { name: '实例' })).toBeTruthy(); expect(screen.getByRole('heading', { name: '实例' })).toBeTruthy();
expect(screen.queryByText('配置此工作区可用的 SimAdmin 实例。')).toBeNull();
expect(screen.getByRole('status', { name: '实例不可用' }).textContent).toMatch( expect(screen.getByRole('status', { name: '实例不可用' }).textContent).toMatch(
/未提供实例总览数据源/i, /未提供实例总览数据源/i,
); );
@@ -203,7 +203,6 @@ export function InstanceSettingsPage({
<h1 id="settings-instances-title"> <h1 id="settings-instances-title">
<Title color="app-green"></Title> <Title color="app-green"></Title>
</h1> </h1>
<p> SimAdmin </p>
</div> </div>
<a href="/instances/new"></a> <a href="/instances/new"></a>
</header> </header>
+97 -60
View File
@@ -253,11 +253,6 @@ code {
color: #fff; color: #fff;
font-size: 1rem; font-size: 1rem;
} }
.product-copy small {
color: #91a3b9;
font-size: 0.68rem;
letter-spacing: 0.03em;
}
.topbar-actions { .topbar-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -384,16 +379,13 @@ main > section > header,
margin-bottom: 1rem; margin-bottom: 1rem;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
.fleet-heading p,
main > section > header p, main > section > header p,
.page-heading p { .page-heading p {
margin: 0.2rem 0 0; margin: 0.2rem 0 0;
color: var(--muted); color: var(--muted);
} }
.page-kicker, .page-kicker,
.eyebrow, .eyebrow {
.fleet-sidebar-eyebrow,
.fleet-results-eyebrow {
margin: 0 0 0.18rem !important; margin: 0 0 0.18rem !important;
color: var(--primary) !important; color: var(--primary) !important;
font-size: 0.66rem; font-size: 0.66rem;
@@ -625,17 +617,6 @@ main > section > header p,
color: #fff; color: #fff;
background: var(--primary); 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 { .fleet-active-filters {
display: flex; display: flex;
gap: 0.35rem; gap: 0.35rem;
@@ -1722,10 +1703,6 @@ main ul[aria-label='已配置实例'] > li {
.auth-card input { .auth-card input {
width: 100%; width: 100%;
} }
.auth-card .eyebrow {
color: #8eabff !important;
}
@media (max-width: 70rem) { @media (max-width: 70rem) {
.fleet-workspace, .fleet-workspace,
.fleet-workspace.is-sidebar-collapsed { .fleet-workspace.is-sidebar-collapsed {
@@ -1775,7 +1752,6 @@ main ul[aria-label='已配置实例'] > li {
gap: 0.55rem; gap: 0.55rem;
padding: 0.55rem 0.75rem; padding: 0.55rem 0.75rem;
} }
.product-copy small,
.connection-status { .connection-status {
display: none; display: none;
} }
@@ -1901,11 +1877,6 @@ body,
letter-spacing: 0; letter-spacing: 0;
} }
.product-copy small {
color: var(--workbench-muted);
font-size: 0.68rem;
}
.global-navigation { .global-navigation {
align-self: stretch; align-self: stretch;
gap: 0.15rem; gap: 0.15rem;
@@ -2079,10 +2050,6 @@ main {
border-bottom: 1px solid var(--workbench-border); border-bottom: 1px solid var(--workbench-border);
} }
.fleet-results-eyebrow {
display: none;
}
.fleet-card-grid { .fleet-card-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -2409,12 +2376,6 @@ main {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
font-size: 0.82rem; font-size: 0.82rem;
} }
.drawer-eyebrow {
color: var(--workbench-mint);
font-size: 0.68rem;
font-weight: 800;
text-transform: uppercase;
}
.icon-button { .icon-button {
width: 2.75rem; width: 2.75rem;
height: 2.75rem; height: 2.75rem;
@@ -2579,9 +2540,6 @@ main {
flex-wrap: wrap; flex-wrap: wrap;
padding: 0.45rem 0.65rem; padding: 0.45rem 0.65rem;
} }
.product-copy small {
display: none;
}
.topbar-actions { .topbar-actions {
margin-left: auto; margin-left: auto;
} }
@@ -2838,10 +2796,6 @@ h1 {
font-size: 1.04rem; font-size: 1.04rem;
} }
.product-copy small {
color: #75664f;
}
.global-navigation { .global-navigation {
gap: 0.3rem; gap: 0.3rem;
padding: 0.32rem; padding: 0.32rem;
@@ -2961,9 +2915,7 @@ main > section > header h1,
} }
.page-kicker, .page-kicker,
.eyebrow, .eyebrow {
.fleet-sidebar-eyebrow,
.fleet-results-eyebrow {
color: #138f85 !important; color: #138f85 !important;
} }
@@ -3115,10 +3067,6 @@ main > section > header h1,
border-bottom: 2px dashed #cabfa9; border-bottom: 2px dashed #cabfa9;
} }
.fleet-results-eyebrow {
color: #725d42 !important;
}
.fleet-card-grid { .fleet-card-grid {
grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr)); grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr));
gap: 1rem; gap: 1rem;
@@ -3764,10 +3712,6 @@ main ul[aria-label='已配置实例'] > li > div[class*='animal-'] {
color: #725d42; color: #725d42;
} }
.auth-card .eyebrow {
color: #138f85 !important;
}
.app-footer { .app-footer {
width: 100%; width: 100%;
margin-top: 1rem; margin-top: 1rem;
@@ -3894,7 +3838,6 @@ svg {
} }
.fleet-sidebar-header strong, .fleet-sidebar-header strong,
.fleet-status-summary span, .fleet-status-summary span,
.fleet-results-eyebrow,
.fleet-card-kicker, .fleet-card-kicker,
.fleet-card-metrics dt, .fleet-card-metrics dt,
.fleet-card-sms h3, .fleet-card-sms h3,
@@ -3906,7 +3849,6 @@ svg {
} }
.fleet-sidebar-header strong svg, .fleet-sidebar-header strong svg,
.fleet-status-summary span svg, .fleet-status-summary span svg,
.fleet-results-eyebrow svg,
.fleet-card-kicker svg, .fleet-card-kicker svg,
.fleet-card-metrics dt svg, .fleet-card-metrics dt svg,
.fleet-card-sms h3 svg, .fleet-card-sms h3 svg,
@@ -4901,3 +4843,98 @@ svg {
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; 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;
}
}
+12
View File
@@ -13,6 +13,8 @@ export type IconName =
| 'globe' | 'globe'
| 'grid' | 'grid'
| 'history' | 'history'
| 'eye'
| 'eye-off'
| 'jobs' | 'jobs'
| 'memory' | 'memory'
| 'message' | 'message'
@@ -42,6 +44,16 @@ const paths: Readonly<Record<IconName, readonly string[]>> = {
'chevron-right': ['m9 18 6-6-6-6'], 'chevron-right': ['m9 18 6-6-6-6'],
cpu: ['M9 9h6v6H9z', 'M4 9h2M4 15h2M18 9h2M18 15h2M9 4v2M15 4v2M9 18v2M15 18v2', 'M6 6h12v12H6z'], cpu: ['M9 9h6v6H9z', 'M4 9h2M4 15h2M18 9h2M18 15h2M9 4v2M15 4v2M9 18v2M15 18v2', 'M6 6h12v12H6z'],
filter: ['M4 5h16l-6 7v5l-4 2v-7Z'], 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: [ globe: [
'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z', 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z',
'M3 12h18', 'M3 12h18',
@@ -0,0 +1,340 @@
# Phone Privacy And Release Polish Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make node phone data private by default, align the phone field, unify browser branding, and remove the approved redundant release copy across the application.
**Architecture:** Add one pure phone-display formatter beside the Fleet feature, then keep per-instance disclosure state inside `FleetPage`. Reuse the existing icon component and card DOM rather than introducing a new component system. Treat favicon and release-copy changes as static branding/content changes with source-level and browser-level regression coverage.
**Tech Stack:** React 19, TypeScript, Vitest, Testing Library, CSS, Vite, SVG, Chrome DevTools Protocol E2E.
## Global Constraints
- Both the card's own phone number and latest-SMS phone number are masked by default.
- One per-card eye button reveals or hides both phone locations for only that instance.
- Disclosure state is memory-only and resets after a page reload.
- Masked international example: `+852 •••• 0100`; masked local example: `138 •••• 5678`.
- The eye button has a stable 40-by-40-pixel hit target and cannot move the temperature field.
- The favicon uses the warm yellow and brown header-brand language.
- The release is non-commercial; `animal-island-ui` attribution and `CC BY-NC 4.0` remain visible and linked.
- Do not change APIs, card column counts, navigation, the left Fleet sidebar position, instance-detail phone displays, or SMS conversation displays.
---
### Task 1: Pure Phone Masking
**Files:**
- Create: `apps/web/src/fleet/phone-privacy.ts`
- Create: `apps/web/src/fleet/phone-privacy.test.ts`
**Interfaces:**
- Produces: `maskPhoneNumber(phoneNumber: string): string`.
- Produces: `formatPhoneNumbers(phoneNumbers: readonly string[], revealed: boolean): string`.
- [ ] **Step 1: Write failing formatter tests**
```ts
expect(maskPhoneNumber('+852 5550 0100')).toBe('+852 •••• 0100');
expect(maskPhoneNumber('13812345678')).toBe('138 •••• 5678');
expect(formatPhoneNumbers(['+852 5550 0100', '13812345678'], false)).toBe(
'+852 •••• 0100、138 •••• 5678',
);
expect(formatPhoneNumbers(['+852 5550 0100'], true)).toBe('+852 5550 0100');
```
- [ ] **Step 2: Verify the tests fail because the module does not exist**
Run: `corepack pnpm --filter @multi-simadmin/web exec vitest run src/fleet/phone-privacy.test.ts`
Expected: FAIL because `phone-privacy.ts` or its exports are missing.
- [ ] **Step 3: Implement the minimal pure formatter**
```ts
const MASK = '••••';
export function maskPhoneNumber(phoneNumber: string): string {
const trimmed = phoneNumber.trim();
const digits = trimmed.replace(/\D/gu, '');
if (digits.length <= 4) return MASK;
const suffix = digits.slice(-4);
if (trimmed.startsWith('+')) {
const groupedPrefix = /^\+\d{1,3}(?=[\s-])/u.exec(trimmed)?.[0];
const prefix = groupedPrefix ?? `+${digits.slice(0, 3)}`;
return `${prefix} ${MASK} ${suffix}`;
}
return `${digits.slice(0, 3)} ${MASK} ${suffix}`;
}
export function formatPhoneNumbers(
phoneNumbers: readonly string[],
revealed: boolean,
): string {
return phoneNumbers.map((number) => (revealed ? number : maskPhoneNumber(number))).join('、');
}
```
- [ ] **Step 4: Run the formatter tests and verify they pass**
Run: `corepack pnpm --filter @multi-simadmin/web exec vitest run src/fleet/phone-privacy.test.ts`
Expected: PASS.
- [ ] **Step 5: Commit the formatter**
Run: `git add apps/web/src/fleet/phone-privacy.ts apps/web/src/fleet/phone-privacy.test.ts && git commit -m "feat(web): add phone privacy formatter"`
### Task 2: Per-Card Disclosure And Stable Phone Layout
**Files:**
- Modify: `apps/web/src/fleet/fleet-page.test.tsx`
- Modify: `apps/web/src/fleet/fleet-page.tsx`
- Modify: `apps/web/src/ui/icon.tsx`
- Modify: `apps/web/src/styles.css`
**Interfaces:**
- Consumes: `formatPhoneNumbers(phoneNumbers, revealed)` from Task 1.
- Produces: per-instance `revealedPhoneIds: Set<string>` state in `FleetPage`.
- Produces: `IconName` values `eye` and `eye-off` through the existing icon component.
- [ ] **Step 1: Write failing card behavior tests**
```tsx
expect(within(firstCard).getByText('+852 •••• 0100')).toBeTruthy();
expect(within(firstCard).queryByText('+852 5550 0100')).toBeNull();
expect(within(firstCard).getByText('+852 •••• 0199')).toBeTruthy();
await user.click(within(firstCard).getByRole('button', { name: /.*/u }));
expect(within(firstCard).getByText('+852 5550 0100')).toBeTruthy();
expect(within(firstCard).getByText('+852 5550 0199')).toBeTruthy();
expect(within(secondCard).queryByText('+852 5550 0200')).toBeNull();
await user.click(within(firstCard).getByRole('button', { name: /.*/u }));
expect(within(firstCard).queryByText('+852 5550 0100')).toBeNull();
```
Also assert that a card without either phone source has no disclosure button.
- [ ] **Step 2: Run the Fleet test and verify privacy assertions fail**
Run: `corepack pnpm --filter @multi-simadmin/web exec vitest run src/fleet/fleet-page.test.tsx`
Expected: FAIL because raw numbers are still rendered and no disclosure button exists.
- [ ] **Step 3: Add eye icons and per-instance disclosure state**
```tsx
const [revealedPhoneIds, setRevealedPhoneIds] = useState<ReadonlySet<string>>(
() => new Set(),
);
function togglePhoneVisibility(instanceId: string): void {
setRevealedPhoneIds((current) => {
const next = new Set(current);
if (next.has(instanceId)) next.delete(instanceId);
else next.add(instanceId);
return next;
});
}
```
Render one `.fleet-card-phone-toggle` button in the hardware phone field when either phone source exists. Give it dynamic `aria-label`, `aria-pressed`, `title`, and `eye`/`eye-off` icon. Use the same `revealed` boolean for the card hardware value and latest-SMS phone text.
- [ ] **Step 4: Stabilize the phone and temperature grid**
```css
.fleet-card-hardware {
grid-template-columns: minmax(0, 1.35fr) minmax(5rem, 0.65fr);
}
.fleet-card-phone-value {
display: grid;
grid-template-columns: minmax(0, 1fr) 2.5rem;
align-items: center;
gap: 0.35rem;
}
.fleet-card-phone-toggle {
inline-size: 2.5rem;
block-size: 2.5rem;
}
```
Keep text within `min-width: 0`, use tabular numerals, and prevent the button from shrinking.
- [ ] **Step 5: Run Fleet behavior tests and verify they pass**
Run: `corepack pnpm --filter @multi-simadmin/web exec vitest run src/fleet/phone-privacy.test.ts src/fleet/fleet-page.test.tsx`
Expected: PASS.
- [ ] **Step 6: Commit card privacy behavior**
Run: `git add apps/web/src/fleet/fleet-page.tsx apps/web/src/fleet/fleet-page.test.tsx apps/web/src/ui/icon.tsx apps/web/src/styles.css && git commit -m "feat(web): protect fleet phone numbers"`
### Task 3: Browser Tab Branding
**Files:**
- Create: `apps/web/public/favicon.svg`
- Create: `apps/web/src/app-branding.test.ts`
- Modify: `apps/web/index.html`
- Modify: `scripts/real-browser-e2e.mjs`
**Interfaces:**
- Produces: `/favicon.svg` with a 64-by-64 warm signal badge.
- Produces: title `SimAdmin Control · 多节点控制台` and theme color `#f3f0e7`.
- [ ] **Step 1: Write failing branding source and browser assertions**
```ts
expect(indexHtml).toContain('href="/favicon.svg"');
expect(indexHtml).toContain('content="#f3f0e7"');
expect(indexHtml).toContain('<title>SimAdmin Control · 多节点控制台</title>');
expect(faviconSvg).toContain('#f6c95f');
expect(faviconSvg).toContain('#725d42');
```
Add E2E assertions for `document.title`, the favicon link pathname, and the theme-color value.
- [ ] **Step 2: Run branding tests and verify they fail**
Run: `corepack pnpm --filter @multi-simadmin/web exec vitest run src/app-branding.test.ts`
Expected: FAIL on the old inline blue favicon, dark theme color, and corrupted title.
- [ ] **Step 3: Add the static warm favicon and update `index.html`**
Create a compact SVG with a warm yellow rounded square, brown border, and three ascending rounded bars. Replace the data-URI favicon, theme color, and title with the exact values above.
- [ ] **Step 4: Run branding tests and verify they pass**
Run: `corepack pnpm --filter @multi-simadmin/web exec vitest run src/app-branding.test.ts`
Expected: PASS.
- [ ] **Step 5: Commit browser branding**
Run: `git add apps/web/index.html apps/web/public/favicon.svg apps/web/src/app-branding.test.ts scripts/real-browser-e2e.mjs && git commit -m "feat(web): align browser branding"`
### Task 4: Release Copy Cleanup
**Files:**
- Modify: `apps/web/src/app-shell.integration.test.tsx`
- Modify: `apps/web/src/app-shell.tsx`
- Modify: `apps/web/src/fleet/fleet-page.test.tsx`
- Modify: `apps/web/src/fleet/fleet-page.tsx`
- Modify: `apps/web/src/automation/automation-page.test.tsx`
- Modify: `apps/web/src/automation/automation-page.tsx`
- Modify: `apps/web/src/settings/instance-settings-page.test.tsx`
- Modify: `apps/web/src/settings/instance-settings-page.tsx`
- Modify: `apps/web/src/auth/console-auth.test.tsx`
- Modify: `apps/web/src/auth/console-auth.tsx`
- Modify: `apps/web/src/auth/console-auth-settings.tsx`
- Modify: `apps/web/src/styles.css`
**Interfaces:**
- Consumes: the exact removal and retention lists in the design specification.
- Produces: a shorter linked footer attribution that still names `animal-island-ui` and `CC BY-NC 4.0`.
- [ ] **Step 1: Add failing content assertions**
Assert that the approved redundant strings are absent and that required copy remains:
```tsx
expect(screen.queryByText('NODE DIRECTORY')).toBeNull();
expect(screen.queryByText(/LIVE NODE MATRIX/u)).toBeNull();
expect(screen.queryByText(/UTC\+8/u)).toBeNull();
expect(screen.queryByText('SYSTEM SETTINGS')).toBeNull();
expect(screen.queryByText('MULTI SIMADMIN')).toBeNull();
expect(screen.getByText(/HTTP/u)).toBeTruthy();
expect(screen.getByRole('link', { name: 'animal-island-ui' })).toBeTruthy();
expect(screen.getByRole('link', { name: 'CC BY-NC 4.0' })).toBeTruthy();
```
- [ ] **Step 2: Run the affected component tests and verify they fail**
Run: `corepack pnpm --filter @multi-simadmin/web exec vitest run src/app-shell.integration.test.tsx src/fleet/fleet-page.test.tsx src/automation/automation-page.test.tsx src/settings/instance-settings-page.test.tsx src/auth/console-auth.test.tsx`
Expected: FAIL while the redundant strings remain or the compact footer is absent.
- [ ] **Step 3: Remove only the approved visible copy**
Delete the approved header descriptions, English eyebrow labels, duplicate Fleet counts and status explanations, Automation empty-state descriptions and drawer eyebrow, Settings descriptions, and login eyebrow/description. Keep loading, error, risk, security, timezone-near-Cron, SMS preservation, and empty-state titles.
Replace the footer with two explicit links:
```tsx
<p>
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>
```
- [ ] **Step 4: Remove styles made unused by this change**
Remove selectors only when their final DOM owner was deleted, including the obsolete brand subtitle and removed eyebrow-specific declarations. Do not refactor unrelated style cascades.
- [ ] **Step 5: Run affected tests and verify they pass**
Run: `corepack pnpm --filter @multi-simadmin/web exec vitest run src/app-shell.integration.test.tsx src/fleet/fleet-page.test.tsx src/automation/automation-page.test.tsx src/settings/instance-settings-page.test.tsx src/auth/console-auth.test.tsx`
Expected: PASS.
- [ ] **Step 6: Commit release copy cleanup**
Run: `git add apps/web/src/app-shell.tsx apps/web/src/app-shell.integration.test.tsx apps/web/src/fleet/fleet-page.tsx apps/web/src/fleet/fleet-page.test.tsx apps/web/src/automation/automation-page.tsx apps/web/src/automation/automation-page.test.tsx apps/web/src/settings/instance-settings-page.tsx apps/web/src/settings/instance-settings-page.test.tsx apps/web/src/auth/console-auth.tsx apps/web/src/auth/console-auth.test.tsx apps/web/src/auth/console-auth-settings.tsx apps/web/src/styles.css && git commit -m "refactor(web): trim release copy"`
### Task 5: Responsive Browser Verification And Release Gates
**Files:**
- Modify: `scripts/real-browser-e2e.mjs`
**Interfaces:**
- Consumes: `.fleet-card-phone-value`, `.fleet-card-phone-toggle`, page title, favicon, and the existing five viewport fixtures.
- Produces: regression assertions and screenshots for 390, 768, 1024, 1440, and 1920 pixels.
- [ ] **Step 1: Extend E2E geometry and privacy assertions**
For every viewport, assert that raw phone fixtures are absent before disclosure, the masked value is present, the toggle is 40 by 40 pixels, and the phone value/toggle/temperature rectangles remain inside the hardware panel without overlap. At 1440 pixels, click the first card toggle and assert both raw numbers appear only in that card.
- [ ] **Step 2: Run Chrome E2E and correct only failures caused by this feature**
Run: `$env:E2E_SCREENSHOT_DIR='C:\Users\86135\Downloads\multi-simadmin\artifacts\phone-privacy-release'; corepack pnpm run test:e2e:browser`
Expected: PASS across all five viewports.
- [ ] **Step 3: Rebuild the production web bundle after E2E cleanup**
Run: `corepack pnpm --filter @multi-simadmin/web build`
Expected: Vite build succeeds and recreates `apps/web/dist`.
- [ ] **Step 4: Run changed-scope and static quality gates**
Run:
```powershell
corepack pnpm --filter @multi-simadmin/web test
corepack pnpm run typecheck
corepack pnpm run lint
corepack pnpm run format:check
git diff --check
```
Expected: Web tests, typecheck, lint, formatting, and diff checks pass.
- [ ] **Step 5: Verify the running local service**
Run:
```powershell
Invoke-WebRequest -UseBasicParsing http://127.0.0.1:8789/
Invoke-WebRequest -UseBasicParsing http://127.0.0.1:8789/api/v1/instances
```
Expected: both requests return HTTP 200.
- [ ] **Step 6: Commit E2E coverage after all gates pass**
Run: `git add scripts/real-browser-e2e.mjs docs/superpowers/plans/2026-07-30-phone-privacy-release-polish.md && git commit -m "test(web): verify phone privacy release polish"`
@@ -0,0 +1,105 @@
# 手机号隐私与正式发布精简设计
日期:2026-07-30
## 目标
在不改变节点卡片核心信息架构的前提下,解决手机号区域对齐不规整的问题,为本机手机号和最新短信对方号码增加默认脱敏与单卡显隐控制,并统一浏览器标签页品牌。面向非商业公开发布,移除全站重复、内部化和解释性过强的可见文案,同时保留操作、状态、安全、错误、无障碍和许可信息。
## 手机号隐私
### 脱敏范围
- 节点卡片硬件信息区的本机手机号。
- 同一卡片底部最新短信中的对方号码。
- 原始号码不得在默认可见文本、标题属性或无障碍名称中泄露。
### 掩码格式
- 带国际前缀的号码保留加号开头的首个原始分组和末四位,例如 `+852 •••• 0100`;无分隔符时保留 `+` 与其后三位。
- 不带国际前缀的号码保留前三位和末四位,例如 `138 •••• 5678`
- 中间部分统一显示四个圆点,避免不同号码长度造成布局跳动。
- 多个本机号码分别脱敏后使用现有分隔方式展示。
- 没有号码时继续显示“暂未获取”,且不显示显隐按钮。
### 显隐交互
- 每张卡片只有一个眼睛图标按钮,固定在本机手机号区域右侧。
- 默认状态为隐藏;点击后同时显示该卡片的本机手机号和最新短信号码,再次点击恢复脱敏。
- 状态按实例 ID 隔离,切换一张卡片不得影响其他卡片。
- 筛选、排序和分页期间保留当前页面会话中的显隐状态;刷新页面后恢复默认隐藏。
- 按钮使用眼睛与闭眼图标、悬浮提示、动态 `aria-label``aria-pressed`,不使用额外文字按钮。
## 手机号布局
- 硬件信息区继续保留“手机号”和“最高温度”两个字段。
- 网格调整为手机号较宽、温度较窄的稳定比例,手机号值与 40 像素显隐按钮使用固定的 `minmax(0, 1fr) auto` 子网格。
- 号码文本不得推动按钮、温度字段或卡片宽度;窄屏允许号码在自身区域内安全收缩,不产生横向滚动。
- 最新短信区域不增加第二个按钮,显示状态完全跟随卡片的唯一显隐按钮。
## 标签页品牌
- 新增独立的 `favicon.svg`,使用顶部品牌一致的暖黄色圆角底、棕色三段信号柱和暖色描边。
- `index.html` 删除现有蓝色内嵌 favicon,改为引用静态资源。
- 浏览器主题色改为工作台暖米白色。
- 标签页标题统一为 `SimAdmin Control · 多节点控制台`
## 全站文案精简
### 全局
- 删除品牌名下方的副标题。
- 底部署名缩短为 `UI: animal-island-ui · CC BY-NC 4.0`,保留项目链接与许可链接。
- 保留非商业使用所需的署名,不删除许可信息。
### 节点页
- 删除页头说明句。
- 删除 `NODE DIRECTORY``LIVE NODE MATRIX`
- 删除侧栏重复的“显示 x/x”,结果区保留唯一的数量与分页信息。
- 删除实例总数、在线和需处理数字下方的三句重复解释。
- 保留节点总览、筛选、状态、操作、批量风险提示、加载、错误和空状态。
### 自动化页
- 删除页头说明句。
- 删除抽屉中与主标题重复的“任务设置”和“新建自动化”眉题。
- 计划任务、执行记录和操作审计的空状态只保留简短状态标题。
- 保留 Cron 附近的北京时间说明、短信保留规则、高频风险提示和最终确认。
### 设置页
- 删除实例设置页说明句。
- 删除密码保护页的 `SYSTEM SETTINGS` 和重复介绍。
- 保留 HTTP/HTTPS 安全警告、密码配置状态、保存结果、加载与错误提示。
### 登录页
- 删除 `MULTI SIMADMIN` 和重复的登录说明。
- 保留登录标题、密码输入、提交按钮、加载与错误状态。
## 数据与状态边界
- 不修改 API、资源响应或短信数据结构。
- 显隐状态仅存在于前端内存中,不写入本地存储、URL、服务端或审计日志。
- 掩码函数只负责显示转换,不改变原始数据对象。
- favicon 和文案调整不改变路由、权限或操作流程。
## 测试与验收
- 单元测试验证默认状态只显示掩码,不出现原始本机号码或最新短信号码。
- 单元测试验证单卡按钮同时显示两处号码、再次点击恢复隐藏,并且不影响其他卡片。
- 单元测试验证国际前缀、本地号码、多个号码和无号码状态。
- 集成或浏览器测试验证眼睛按钮的无障碍名称、按下状态、40 像素稳定尺寸和响应式边界。
- 浏览器测试验证 favicon、主题色和标签页标题。
- 内容测试验证确认删除的冗余文案不再渲染,同时安全提示、错误状态和许可署名仍存在。
- 在 390、768、1024、1440 和 1920 像素宽度检查卡片无横向溢出、按钮位置稳定、号码与温度不重叠。
## 非目标
- 不增加全局号码显隐开关。
- 不在号码之间提供独立显隐按钮。
- 不持久化号码显隐偏好。
- 不修改实例详情页或短信会话页的号码展示。
- 不移除功能标签、安全提示、无障碍文本或许可署名。
- 不改变现有颜色系统、卡片列数、左侧总览位置或核心导航。
+110 -15
View File
@@ -102,8 +102,20 @@ async function builtAssetHandler(request, response) {
phoneNumbers: [`+852 5550 10${Math.max(0, fixtureIndex)}`], phoneNumbers: [`+852 5550 10${Math.max(0, fixtureIndex)}`],
}; };
} }
if (FLEET_FIXTURE && /^\/api\/v1\/instances\/[^/]+\/messages$/u.test(pathname)) if (FLEET_FIXTURE && /^\/api\/v1\/instances\/[^/]+\/messages$/u.test(pathname)) {
body = { messages: [] }; 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 ( if (
pathname === '/api/v1/jobs' || pathname === '/api/v1/jobs' ||
pathname === '/api/v1/audit' || pathname === '/api/v1/audit' ||
@@ -134,7 +146,10 @@ async function builtAssetHandler(request, response) {
response.end(JSON.stringify({ error: 'Not Found', statusCode: 404 })); response.end(JSON.stringify({ error: 'Not Found', statusCode: 404 }));
return; 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 { try {
const asset = await readFile(join(DIST, relative)); const asset = await readFile(join(DIST, relative));
response.statusCode = 200; response.statusCode = 200;
@@ -144,6 +159,7 @@ async function builtAssetHandler(request, response) {
'.html': 'text/html; charset=utf-8', '.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8', '.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml; charset=utf-8',
}[extname(relative)] ?? 'application/octet-stream', }[extname(relative)] ?? 'application/octet-stream',
); );
response.end(asset); response.end(asset);
@@ -396,9 +412,15 @@ async function fleetLayout(cdp) {
const firstResource = document.querySelector('.fleet-resource-row'); const firstResource = document.querySelector('.fleet-resource-row');
const telemetry = document.querySelector('.fleet-card-telemetry'); const telemetry = document.querySelector('.fleet-card-telemetry');
const footer = document.querySelector('.fleet-card-footer'); 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 menuTrigger = document.querySelector('.fleet-card-menu-trigger');
const menuPanel = document.querySelector('.fleet-card-menu-panel'); const menuPanel = document.querySelector('.fleet-card-menu-panel');
const checkbox = document.querySelector('.fleet-card-select'); 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) => { const bounds = (element) => {
if (!element) return null; if (!element) return null;
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
@@ -406,12 +428,14 @@ async function fleetLayout(cdp) {
}; };
const hardwareEntries = [...document.querySelectorAll('.fleet-card-hardware > div')].map((item) => { const hardwareEntries = [...document.querySelectorAll('.fleet-card-hardware > div')].map((item) => {
const label = item.querySelector('dt'); const label = item.querySelector('dt');
const value = item.querySelector('dd'); const value = item.querySelector('.fleet-card-phone-value') ?? item.querySelector('dd');
return { return {
labelHeight: label?.getBoundingClientRect().height ?? 0, labelHeight: label?.getBoundingClientRect().height ?? 0,
labelFits: label ? label.scrollWidth <= label.clientWidth : false, labelFits: label ? label.scrollWidth <= label.clientWidth : false,
valueHeight: value?.getBoundingClientRect().height ?? 0, valueHeight: value?.getBoundingClientRect().height ?? 0,
valueFits: value ? value.scrollWidth <= value.clientWidth : false, 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) => { const navigation = [...document.querySelectorAll('nav[aria-label="全局导航"] a')].map((item) => {
@@ -438,9 +462,16 @@ async function fleetLayout(cdp) {
firstResource: bounds(firstResource), firstResource: bounds(firstResource),
telemetry: bounds(telemetry), telemetry: bounds(telemetry),
footer: bounds(footer), footer: bounds(footer),
footerNumber: bounds(footerNumber),
footerNumberFits: footerNumber ? footerNumber.scrollWidth <= footerNumber.clientWidth : false,
menuTrigger: bounds(menuTrigger), menuTrigger: bounds(menuTrigger),
menuPanel: bounds(menuPanel), menuPanel: bounds(menuPanel),
checkbox: bounds(checkbox), checkbox: bounds(checkbox),
hardware: bounds(hardware),
phoneField: bounds(phoneField),
phoneValue: bounds(phoneValue),
phoneToggle: bounds(phoneToggle),
temperatureField: bounds(temperatureField),
hardwareEntries, hardwareEntries,
navigation, navigation,
}; };
@@ -575,6 +606,31 @@ try {
`document.querySelector('.fleet-card-version')?.textContent.trim() === 'SimAdmin 2.4.0'`, `document.querySelector('.fleet-card-version')?.textContent.trim() === 'SimAdmin 2.4.0'`,
`Fleet SimAdmin version did not synchronize at ${viewport.width}px`, `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); const layout = await fleetLayout(cdp);
assert.equal( assert.equal(
layout.scrollWidth <= layout.viewport, 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.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.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.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( assert.equal(
layout.workspaceDisplay, layout.workspaceDisplay,
'grid', 'grid',
@@ -604,7 +664,13 @@ try {
layout.firstResource && layout.firstResource &&
layout.telemetry && layout.telemetry &&
layout.footer && 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`, `${viewport.width}px Fleet information hierarchy must render`,
); );
for (const [name, bounds] of [ for (const [name, bounds] of [
@@ -651,16 +717,30 @@ try {
`${viewport.width}px card action trigger is too small: ${JSON.stringify(layout.menuTrigger)}`, `${viewport.width}px card action trigger is too small: ${JSON.stringify(layout.menuTrigger)}`,
); );
assert.equal( assert.equal(
layout.hardwareEntries.length === FLEET_FIXTURE_ITEMS.length * 2 && layout.footerNumberFits,
layout.hardwareEntries.every(
(entry) =>
entry.labelFits &&
entry.valueFits &&
entry.labelHeight <= 20 &&
entry.valueHeight <= 20,
),
true, 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') { if (viewport.sidebarMode === 'left') {
assert.equal( assert.equal(
@@ -705,6 +785,21 @@ try {
await captureScreenshot(cdp, 'warm-fleet-390-cards.png'); await captureScreenshot(cdp, 'warm-fleet-390-cards.png');
} }
if (viewport.width === 1440) { 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 evaluate(cdp, `document.querySelector('.fleet-card-menu-trigger').click()`);
await eventually( await eventually(
cdp, cdp,