feat(web): protect phone data and polish release UI

This commit is contained in:
Codex
2026-07-30 20:16:50 +08:00
parent 6ec8e4f6a6
commit d60cff1dd8
19 changed files with 800 additions and 396 deletions
+324 -293
View File
@@ -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,297 +969,328 @@ export function FleetPage({
<>
<section className="fleet-card-section" aria-label="实例卡片">
<div className="fleet-card-grid">
{model.rows.map((row) => (
<Card
role="article"
className={`fleet-card${row.selected ? ' is-selected' : ''}`}
data-status={row.statusKind}
aria-label={`${row.displayName} 实例概览`}
key={row.id}
>
<div className="fleet-card-glow" aria-hidden="true" />
<header>
<div className="fleet-card-title-group">
<span className="fleet-node-avatar" aria-hidden="true">
{initials(row.displayName)}
</span>
<div>
<a
className="fleet-card-entry"
href={`/instances/${encodeURIComponent(row.id)}/overview`}
aria-label={`打开 ${row.displayName} 实例仪表盘`}
>
<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 ? (
{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' : ''}`}
data-status={row.statusKind}
aria-label={`${row.displayName} 实例概览`}
key={row.id}
>
<div className="fleet-card-glow" aria-hidden="true" />
<header>
<div className="fleet-card-title-group">
<span className="fleet-node-avatar" aria-hidden="true">
{initials(row.displayName)}
</span>
<div>
<a
className="fleet-card-origin fleet-card-origin-link"
href={origin}
target="_blank"
rel="noopener noreferrer"
aria-label={`打开 ${row.displayName} 节点入口`}
title={`在新窗口打开 ${origin}`}
className="fleet-card-entry"
href={`/instances/${encodeURIComponent(row.id)}/overview`}
aria-label={`打开 ${row.displayName} 实例仪表盘`}
>
<span>{origin}</span>
<Icon className="fleet-origin-arrow" name="arrow-up-right" />
<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>
) : (
<span
className="fleet-card-origin fleet-card-origin-invalid"
aria-label={`${row.displayName} 源地址无效`}
</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);
}}
>
源地址无效
</span>
);
})()}
<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
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>
{row.instance.description ? (
<p className="fleet-card-description">{row.instance.description}</p>
) : null}
{row.anomalies.length > 0 ? (
<dl className="fleet-card-hardware" role="group" aria-label="节点硬件信息">
<div className="fleet-card-phone">
<dt>
<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
className="fleet-card-anomalies"
aria-label={`${row.displayName} 异常`}
role={cardAction.error ? 'alert' : 'status'}
className={
cardAction.error ? 'card-operation-error' : 'card-operation-success'
}
>
{row.anomalies.join(', ')}
{cardAction.error ?? cardAction.message}
</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>
<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>
))}
</Card>
);
})}
</div>
</section>
<nav className="pagination" aria-label="实例分页">