feat(web): localize and redesign operations console
This commit is contained in:
@@ -25,23 +25,43 @@ export interface FleetPageProps {
|
||||
|
||||
const EMPTY_SNAPSHOT: FleetSnapshot = { instances: [], statuses: new Map() };
|
||||
const STATUS_LABELS: Readonly<Record<string, string>> = {
|
||||
online: 'Online',
|
||||
auth: 'Authentication required',
|
||||
offline: 'Offline',
|
||||
unknown: 'Unknown',
|
||||
online: '在线',
|
||||
auth: '需要认证',
|
||||
offline: '离线',
|
||||
unknown: '未知',
|
||||
};
|
||||
const COLUMN_LABELS: Readonly<Record<FleetSortColumn | 'origin', string>> = {
|
||||
name: 'Name',
|
||||
status: 'Status',
|
||||
latency: 'Latency',
|
||||
version: 'Version',
|
||||
capabilities: 'Capabilities',
|
||||
tags: 'Tags',
|
||||
freshness: 'Freshness',
|
||||
anomalies: 'Anomalies',
|
||||
origin: 'Origin',
|
||||
name: '名称',
|
||||
status: '状态',
|
||||
latency: '延迟',
|
||||
version: '版本',
|
||||
capabilities: '能力',
|
||||
tags: '标签',
|
||||
freshness: '数据新鲜度',
|
||||
anomalies: '异常',
|
||||
origin: '源地址',
|
||||
};
|
||||
const ALL_COLUMNS = Object.keys(COLUMN_LABELS) as readonly (FleetSortColumn | 'origin')[];
|
||||
const CAPABILITY_LABELS: Readonly<Record<string, string>> = {
|
||||
overview: '概览',
|
||||
cellular: '蜂窝网络',
|
||||
'device-network': '设备网络',
|
||||
messages: '消息',
|
||||
calls: '通话',
|
||||
esim: 'eSIM',
|
||||
notifications: '通知',
|
||||
automation: '自动化',
|
||||
ota: 'OTA',
|
||||
};
|
||||
const FRESHNESS_LABELS: Readonly<Record<string, string>> = {
|
||||
fresh: '最新',
|
||||
stale: '可能过期',
|
||||
unknown: '未知',
|
||||
};
|
||||
|
||||
function capabilityLabel(value: string): string {
|
||||
return CAPABILITY_LABELS[value] ?? value;
|
||||
}
|
||||
|
||||
export function canonicalHttpOrigin(value: string): string | null {
|
||||
try {
|
||||
@@ -93,9 +113,8 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
|
||||
(data) => {
|
||||
if (active) setSnapshot(data);
|
||||
},
|
||||
(reason: unknown) => {
|
||||
if (active)
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to load instances.');
|
||||
() => {
|
||||
if (active) setError('实例加载失败。');
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
@@ -171,6 +190,7 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
|
||||
setValue: (value: string) => void,
|
||||
choices: readonly string[],
|
||||
allLabel: string,
|
||||
displayChoice: (choice: string) => string = (choice) => choice,
|
||||
) => (
|
||||
<label>
|
||||
<span>{label}</span>
|
||||
@@ -181,7 +201,7 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
|
||||
<option value="">{allLabel}</option>
|
||||
{choices.map((choice) => (
|
||||
<option key={choice} value={choice}>
|
||||
{choice}
|
||||
{displayChoice(choice)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -192,92 +212,92 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
|
||||
<section className="fleet-panel" aria-labelledby="fleet-title">
|
||||
<div className="fleet-heading">
|
||||
<div>
|
||||
<h1 id="fleet-title">Fleet</h1>
|
||||
<p>Find, compare, and manage SimAdmin instances.</p>
|
||||
<h1 id="fleet-title">实例总览</h1>
|
||||
<p>查找、对比并管理所有 SimAdmin 实例。</p>
|
||||
</div>
|
||||
<div className="fleet-actions">
|
||||
<span className="selection-summary" aria-live="polite">
|
||||
{model.selectedIds.length} selected
|
||||
已选择 {model.selectedIds.length} 项
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={model.selectedIds.length === 0}
|
||||
onClick={() => setBatchOpen((open) => !open)}
|
||||
>
|
||||
Batch actions
|
||||
批量操作
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{batchOpen ? (
|
||||
<div className="batch-entry" role="region" aria-label="Batch action entry">
|
||||
Choose an action for {model.selectedIds.length} selected{' '}
|
||||
{model.selectedIds.length === 1 ? 'instance' : 'instances'}.
|
||||
<div className="batch-entry" role="region" aria-label="批量操作入口">
|
||||
请为已选择的 {model.selectedIds.length} 项选择操作。
|
||||
</div>
|
||||
) : null}
|
||||
<div className="fleet-toolbar">
|
||||
<label>
|
||||
<span>Search instances</span>
|
||||
<span>搜索实例</span>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => resetPage(() => setQuery(event.currentTarget.value))}
|
||||
placeholder="Name, ID, tag, origin…"
|
||||
placeholder="名称、ID、标签、源地址…"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Status</span>
|
||||
<span>状态</span>
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(event) =>
|
||||
resetPage(() => setFilter(event.currentTarget.value as FleetFilter))
|
||||
}
|
||||
>
|
||||
<option value="all">All statuses</option>
|
||||
<option value="online">Online</option>
|
||||
<option value="auth">Authentication required</option>
|
||||
<option value="offline">Offline</option>
|
||||
<option value="unknown">Unknown</option>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="online">在线</option>
|
||||
<option value="auth">需要认证</option>
|
||||
<option value="offline">离线</option>
|
||||
<option value="unknown">未知</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Authentication</span>
|
||||
<span>认证</span>
|
||||
<select
|
||||
value={auth}
|
||||
onChange={(event) =>
|
||||
resetPage(() => setAuth(event.currentTarget.value as FleetAuthFilter))
|
||||
}
|
||||
>
|
||||
<option value="all">All authentication</option>
|
||||
<option value="authenticated">Authenticated</option>
|
||||
<option value="required">Authentication required</option>
|
||||
<option value="unknown">Unknown</option>
|
||||
<option value="all">全部认证状态</option>
|
||||
<option value="authenticated">已认证</option>
|
||||
<option value="required">需要认证</option>
|
||||
<option value="unknown">未知</option>
|
||||
</select>
|
||||
</label>
|
||||
{selectFilter(
|
||||
'Capability',
|
||||
'能力',
|
||||
capability,
|
||||
setCapability,
|
||||
model.facets.capabilities,
|
||||
'All capabilities',
|
||||
'全部能力',
|
||||
capabilityLabel,
|
||||
)}
|
||||
{selectFilter('Version', version, setVersion, model.facets.versions, 'All versions')}
|
||||
{selectFilter('Tag', tag, setTag, model.facets.tags, 'All tags')}
|
||||
{selectFilter('版本', version, setVersion, model.facets.versions, '全部版本')}
|
||||
{selectFilter('标签', tag, setTag, model.facets.tags, '全部标签')}
|
||||
<div className="column-picker">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={columnsOpen}
|
||||
onClick={() => setColumnsOpen((open) => !open)}
|
||||
>
|
||||
Choose columns
|
||||
选择列
|
||||
</button>
|
||||
{columnsOpen ? (
|
||||
<fieldset>
|
||||
<legend>Visible columns</legend>
|
||||
<legend>显示的列</legend>
|
||||
{ALL_COLUMNS.map((column) => (
|
||||
<label key={column}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Show ${column} column`}
|
||||
aria-label={`显示${COLUMN_LABELS[column]}列`}
|
||||
checked={shownColumns.has(column)}
|
||||
onChange={() => toggleColumn(column)}
|
||||
/>
|
||||
@@ -290,42 +310,42 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
|
||||
</div>
|
||||
|
||||
{!snapshot && !error ? (
|
||||
<p role="status" aria-label="Fleet loading status">
|
||||
Loading instances…
|
||||
<p role="status" aria-label="实例加载状态">
|
||||
正在加载实例…
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Could not load instances: {error}</p>
|
||||
<p>无法加载实例。{error}</p>
|
||||
<button type="button" onClick={() => setAttempt((value) => value + 1)}>
|
||||
Retry loading instances
|
||||
重试加载实例
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{snapshot && model.emptyReason ? (
|
||||
<div className="state-panel">
|
||||
{model.emptyReason === 'config' ? <p>No instances are configured.</p> : null}
|
||||
{model.emptyReason === 'search' ? (
|
||||
<p>No instances match your search and filters.</p>
|
||||
) : null}
|
||||
{model.emptyReason === 'filter' ? <p>No instances match the active filters.</p> : null}
|
||||
{model.emptyReason === 'config' ? <p>尚未配置实例。</p> : null}
|
||||
{model.emptyReason === 'search' ? <p>没有实例符合搜索与筛选条件。</p> : null}
|
||||
{model.emptyReason === 'filter' ? <p>没有实例符合当前筛选条件。</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{snapshot && model.rows.length > 0 ? (
|
||||
<>
|
||||
<div className="table-scroll" tabIndex={0}>
|
||||
<table className="dense-table">
|
||||
<caption>Fleet instances</caption>
|
||||
<caption>实例列表</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="select-column">
|
||||
<input
|
||||
ref={selectAllRef}
|
||||
type="checkbox"
|
||||
aria-label="Select all visible instances"
|
||||
checked={model.visibleSelection.checked}
|
||||
onChange={(event) => toggleVisible(event.currentTarget.checked)}
|
||||
/>
|
||||
<label className="touch-target">
|
||||
<input
|
||||
ref={selectAllRef}
|
||||
type="checkbox"
|
||||
aria-label="选择当前页全部实例"
|
||||
checked={model.visibleSelection.checked}
|
||||
onChange={(event) => toggleVisible(event.currentTarget.checked)}
|
||||
/>
|
||||
</label>
|
||||
</th>
|
||||
{ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => (
|
||||
<th
|
||||
@@ -345,7 +365,7 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeSort(column)}
|
||||
aria-label={`Sort by ${column}, currently ${sort.column === column ? sort.direction : 'not sorted'}`}
|
||||
aria-label={`按${COLUMN_LABELS[column]}排序`}
|
||||
>
|
||||
{COLUMN_LABELS[column]}
|
||||
</button>
|
||||
@@ -375,9 +395,11 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
|
||||
),
|
||||
latency: <td>{row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}</td>,
|
||||
version: <td>{row.version ?? '—'}</td>,
|
||||
capabilities: <td>{row.capabilities.join(', ') || '—'}</td>,
|
||||
capabilities: (
|
||||
<td>{row.capabilities.map(capabilityLabel).join(', ') || '—'}</td>
|
||||
),
|
||||
tags: <td>{row.tags.join(', ') || '—'}</td>,
|
||||
freshness: <td>{row.freshness}</td>,
|
||||
freshness: <td>{FRESHNESS_LABELS[row.freshness] ?? row.freshness}</td>,
|
||||
anomalies: <td>{row.anomalies.join(', ') || '—'}</td>,
|
||||
origin: (
|
||||
<td>
|
||||
@@ -386,12 +408,12 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
|
||||
href={origin}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Open ${row.displayName} origin`}
|
||||
aria-label={`打开 ${row.displayName} 的源站`}
|
||||
>
|
||||
{origin}
|
||||
</a>
|
||||
) : (
|
||||
<span>Invalid origin</span>
|
||||
<span>源地址无效</span>
|
||||
)}
|
||||
</td>
|
||||
),
|
||||
@@ -399,12 +421,14 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
|
||||
return (
|
||||
<tr key={row.id} aria-selected={row.selected}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Select ${row.displayName}`}
|
||||
checked={row.selected}
|
||||
onChange={(event) => toggleOne(row.id, event.currentTarget.checked)}
|
||||
/>
|
||||
<label className="touch-target">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`选择 ${row.displayName}`}
|
||||
checked={row.selected}
|
||||
onChange={(event) => toggleOne(row.id, event.currentTarget.checked)}
|
||||
/>
|
||||
</label>
|
||||
</td>
|
||||
{ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => (
|
||||
<Fragment key={column}>{cells[column]}</Fragment>
|
||||
@@ -415,25 +439,25 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<nav className="pagination" aria-label="Fleet pagination">
|
||||
<nav className="pagination" aria-label="实例分页">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous page"
|
||||
aria-label="上一页"
|
||||
disabled={model.page === 1}
|
||||
onClick={() => setPage((value) => value - 1)}
|
||||
>
|
||||
Previous
|
||||
上一页
|
||||
</button>
|
||||
<span>
|
||||
Page {model.page} of {model.pageCount}
|
||||
第 {model.page} 页,共 {model.pageCount} 页
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next page"
|
||||
aria-label="下一页"
|
||||
disabled={model.page === model.pageCount}
|
||||
onClick={() => setPage((value) => value + 1)}
|
||||
>
|
||||
Next
|
||||
下一页
|
||||
</button>
|
||||
</nav>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user