feat(sms): add real instance message workflow

This commit is contained in:
chick
2026-07-19 02:12:57 +08:00
parent 38a26c2ce1
commit 0112e3320a
17 changed files with 781 additions and 450 deletions
+34 -2
View File
@@ -129,8 +129,12 @@ describe('React AppShell and Fleet vertical slice', () => {
'/instances/new',
);
const card = screen.getByRole('article', { name: 'Bravo 实例概览' });
expect(within(card).getByText('40 ms')).toBeTruthy();
expect(within(card).getByText('2.0')).toBeTruthy();
expect(within(card).queryByText('延迟')).toBeNull();
expect(within(card).queryByText('版本')).toBeNull();
expect(within(card).queryByText('新鲜度')).toBeNull();
expect(within(card).queryByText('40 ms')).toBeNull();
expect(within(card).queryByText('2.0')).toBeNull();
expect(within(card).queryByText('可能过期')).toBeNull();
expect(within(card).getByText('18.4%')).toBeTruthy();
expect(within(card).getByText('63.2%')).toBeTruthy();
expect(within(card).getByText('46.7 °C')).toBeTruthy();
@@ -150,6 +154,34 @@ describe('React AppShell and Fleet vertical slice', () => {
expect(within(card).getByRole('status').textContent).toContain('删除请求已提交');
});
it('keeps Jobs and Audit routes compatible without advertising them in global navigation', async () => {
const jobsDataSource: JobsDataSource = { load: vi.fn().mockResolvedValue(emptyPage) };
const { rerender } = render(
<AppShell
pathname="/jobs"
jobsDataSource={jobsDataSource}
eventStreamClient={quietEventStreamClient}
/>,
);
expect(await screen.findByText(/没有任务符合当前查询/i)).toBeTruthy();
const navigation = screen.getByRole('navigation', { name: '全局导航' });
expect(within(navigation).queryByRole('link', { name: '任务' })).toBeNull();
expect(within(navigation).queryByRole('link', { name: '审计' })).toBeNull();
expect(within(navigation).getByRole('link', { name: '实例总览' })).toBeTruthy();
expect(within(navigation).getByRole('link', { name: '设置' })).toBeTruthy();
const auditDataSource: AuditDataSource = { load: vi.fn().mockResolvedValue(emptyPage) };
rerender(
<AppShell
pathname="/audit"
auditDataSource={auditDataSource}
eventStreamClient={quietEventStreamClient}
/>,
);
expect(await screen.findByText(/没有审计事件符合当前查询/i)).toBeTruthy();
});
it('loads route-owned instance context so overview-card navigation opens detail management', async () => {
const instanceDataSource: InstanceDataSource = {
get: vi.fn().mockResolvedValue({
+3 -3
View File
@@ -22,6 +22,7 @@ import { createInstanceApiDataSource } from './instances/instance-api-data-sourc
import { JobsPage, type JobsDataSource } from './jobs/jobs-page.js';
import { createJobsApiDataSource } from './jobs/jobs-api-data-source.js';
import { MessagesModule, type MessagesDataSource } from './instances/messages-module.js';
import { createMessagesApiDataSource } from './instances/messages-api-data-source.js';
import {
NotificationsModule,
type NotificationsDataSource,
@@ -410,6 +411,7 @@ export function AppShell({
const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []);
const defaultFleetDataSource = useMemo(() => createFleetApiDataSource(), []);
const defaultInstanceDataSource = useMemo(() => createInstanceApiDataSource(), []);
const defaultMessagesDataSource = useMemo(() => createMessagesApiDataSource(), []);
const resolvedJobsDataSource = useMemo(
() => jobsDataSource ?? createJobsApiDataSource(),
[jobsDataSource],
@@ -488,8 +490,6 @@ export function AppShell({
{(
[
['fleet', '/fleet', '实例总览'],
['jobs', '/jobs', '任务'],
['audit', '/audit', '审计'],
['settings', '/settings/instances', '设置'],
] as const
).map(([key, href, label]) => (
@@ -520,7 +520,7 @@ export function AppShell({
overviewDataSource={overviewDataSource}
cellularDataSource={cellularDataSource}
deviceNetworkDataSource={deviceNetworkDataSource}
messagesDataSource={messagesDataSource}
messagesDataSource={messagesDataSource ?? defaultMessagesDataSource}
callsDataSource={callsDataSource}
esimDataSource={esimDataSource}
notificationsDataSource={notificationsDataSource}
-12
View File
@@ -444,18 +444,6 @@ export function FleetPage({
<dt></dt>
<dd>{temperature(row.status?.summary?.resources?.maxTemperatureCelsius)}</dd>
</div>
<div>
<dt></dt>
<dd>{row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}</dd>
</div>
<div>
<dt></dt>
<dd>{row.version ?? '—'}</dd>
</div>
<div>
<dt></dt>
<dd>{FRESHNESS_LABELS[row.freshness] ?? row.freshness}</dd>
</div>
</dl>
<div className="capability-tags" aria-label="能力">
{row.capabilities.length > 0 ? (
@@ -28,7 +28,7 @@ const capabilities: InstanceCapabilityMap = {
};
describe('InstanceDetail', () => {
it('makes only supported modules actionable and explains every other capability state', () => {
it('keeps core overview and message modules actionable while explaining other capability states', () => {
render(
<InstanceDetail
instanceId="owner"
@@ -41,8 +41,10 @@ describe('InstanceDetail', () => {
expect(screen.getByRole('link', { name: '概览' }).getAttribute('href')).toBe(
'/instances/owner/overview',
);
expect(screen.queryByRole('link', { name: '消息' })).toBeNull();
expect(screen.getByText('消息历史记录为只读。')).toBeTruthy();
expect(screen.getByRole('link', { name: '消息' }).getAttribute('href')).toBe(
'/instances/owner/messages',
);
expect(screen.queryByText('消息历史记录为只读。')).toBeNull();
expect(screen.getByText('此调制解调器不支持语音功能。')).toBeTruthy();
expect(screen.getByText('能力探测结果未包含 eSIM。')).toBeTruthy();
expect(screen.getAllByText('能力状态未知。').length).toBeGreaterThan(0);
+8 -11
View File
@@ -55,6 +55,11 @@ function canRender(capability: InstanceCapability): boolean {
return capability.state === 'supported' || capability.state === 'degraded';
}
const CORE_MODULES = new Set<InstanceModule>(['overview', 'messages']);
function canOpen(module: InstanceModule, capability: InstanceCapability): boolean {
return CORE_MODULES.has(module) || canRender(capability);
}
function explanation(capability: InstanceCapability): string | null {
if (capability.state === 'supported') return null;
return capability.explanation?.trim() || DEFAULT_EXPLANATIONS[capability.state];
@@ -131,14 +136,6 @@ export function InstanceDetail({
<dt></dt>
<dd>{displayStatus(instance.status)}</dd>
</div>
<div>
<dt></dt>
<dd>{displayStatus(instance.authentication)}</dd>
</div>
<div>
<dt></dt>
<dd>{displayStatus(instance.freshness)}</dd>
</div>
</dl>
{origin ? (
<a href={origin} target="_blank" rel="noopener noreferrer">
@@ -153,7 +150,7 @@ export function InstanceDetail({
const reason = explanation(capability);
return (
<li key={item} data-capability-state={capability.state}>
{capability.state === 'supported' ? (
{canOpen(item, capability) ? (
<a
href={`/instances/${encodeURIComponent(instanceId)}/${item}`}
aria-current={module === item ? 'page' : undefined}
@@ -176,7 +173,7 @@ export function InstanceDetail({
<h1>{INSTANCE_MODULE_LABELS[module]}</h1>
{loading ? <p role="status"></p> : null}
{loadError ? <p role="alert">{loadError}</p> : null}
{!loading && canRender(activeCapability) ? (
{!loading && canOpen(module, activeCapability) ? (
<>
{activeCapability.state === 'degraded' ? (
<p data-capability-state="degraded">{explanation(activeCapability)}</p>
@@ -184,7 +181,7 @@ export function InstanceDetail({
{moduleContent ?? <p>{INSTANCE_MODULE_LABELS[module]}</p>}
</>
) : null}
{!loading && !canRender(activeCapability) ? (
{!loading && !canOpen(module, activeCapability) ? (
<p data-capability-state={activeCapability.state}>{explanation(activeCapability)}</p>
) : null}
</div>
@@ -0,0 +1,59 @@
import type { MessagesDataSource, MessagesSnapshot, SendMessageInput } from './messages-module.js';
interface Options {
readonly fetcher?: typeof fetch;
}
function record(value: unknown): Record<string, unknown> | undefined {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
export function createMessagesApiDataSource(options: Options = {}): MessagesDataSource {
const fetcher = options.fetcher ?? fetch;
return {
async load(instanceId, signal): Promise<MessagesSnapshot> {
const response = await fetcher(
`/api/v1/instances/${encodeURIComponent(instanceId)}/messages?limit=50&offset=0`,
{ headers: { accept: 'application/json' }, signal },
);
if (!response.ok) throw new Error('message load failed');
const root = record(await response.json());
if (!Array.isArray(root?.messages)) throw new Error('message load failed');
return {
messages: root.messages.flatMap((item) => {
const value = record(item);
return typeof value?.id === 'string' &&
typeof value.phoneNumber === 'string' &&
typeof value.content === 'string' &&
typeof value.timestamp === 'string' &&
typeof value.status === 'string' &&
typeof value.direction === 'string'
? [
{
id: value.id,
phoneNumber: value.phoneNumber,
content: value.content,
timestamp: value.timestamp,
status: value.status,
direction: value.direction,
},
]
: [];
}),
};
},
async send(instanceId: string, input: SendMessageInput): Promise<void> {
const response = await fetcher(
`/api/v1/instances/${encodeURIComponent(instanceId)}/messages/send`,
{
method: 'POST',
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify(input),
},
);
if (!response.ok) throw new Error('message send failed');
},
};
}
+42 -199
View File
@@ -1,215 +1,58 @@
// @vitest-environment jsdom
import { cleanup, render, screen, within } from '@testing-library/react';
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { InstanceContext } from '../app-shell.js';
import {
MessagesModule,
type MessagesDataSource,
type MessagesSnapshot,
} from './messages-module.js';
import { MessagesModule, type MessagesDataSource } from './messages-module.js';
afterEach(cleanup);
const owner: InstanceContext = {
const instance: InstanceContext = {
id: 'alpha',
name: 'Alpha',
origin: 'https://alpha.example',
status: 'online',
authentication: 'authenticated',
freshness: 'fresh',
origin: 'http://192.168.1.2',
status: 'unknown',
authentication: 'unknown',
freshness: 'unknown',
};
afterEach(cleanup);
const snapshot: MessagesSnapshot = {
observedAt: '2026-07-17T12:00:00Z',
sms: {
total: 42,
inbound: 25,
outbound: 17,
unread: 3,
failed: 2,
queued: 1,
lastActivityAt: '2026-07-17T11:55:00Z',
// Deliberate excess fields: payloads and recipient data must never reach the DOM.
body: 'private message body',
content: 'private message content',
recipient: '+15550199',
recipientCredential: 'secret-recipient-token',
},
devices: [
{
deviceId: 'modem-1',
label: 'Primary modem',
state: 'online',
total: 30,
inbound: 18,
outbound: 12,
unread: 2,
failed: 1,
queued: 0,
lastActivityAt: '2026-07-17T11:54:00Z',
body: 'device body must not render',
phoneNumber: '+15550123',
password: 'device credential',
},
],
};
function deferredSource() {
let resolve!: (value: MessagesSnapshot) => void;
let reject!: (reason: unknown) => void;
const load = vi.fn<MessagesDataSource['load']>(
(_instanceId, _signal) =>
new Promise((done, fail) => {
void _instanceId;
void _signal;
resolve = done;
reject = fail;
describe('MessagesModule', () => {
it('shows the real message list including content and never exposes PDU', async () => {
const dataSource: MessagesDataSource = {
load: vi.fn().mockResolvedValue({
messages: [
{
id: '32',
direction: 'incoming',
phoneNumber: '10086',
content: '余额提醒',
timestamp: '2026-07-18 09:09:20',
status: 'received',
pdu: 'secret-pdu',
},
],
}),
);
return {
source: { load },
load,
resolve: (value: MessagesSnapshot) => resolve(value),
reject: (reason: unknown) => reject(reason),
};
}
describe('Messages isolated read module', () => {
it('loads only through the injected exact-owner source and renders aggregate SMS/device metadata', async () => {
const pending = deferredSource();
render(<MessagesModule instance={owner} dataSource={pending.source} />);
expect(screen.getByRole('status', { name: '消息加载状态' })).toBeTruthy();
expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
pending.resolve(snapshot);
const sms = await screen.findByRole('region', { name: '短信汇总' });
expect(within(sms).getByText('42')).toBeTruthy();
expect(within(sms).getByText('2026-07-17T11:55:00Z')).toBeTruthy();
const devices = screen.getByRole('region', { name: '设备消息汇总' });
expect(within(devices).getByText('modem-1')).toBeTruthy();
expect(within(devices).getByText('Primary modem')).toBeTruthy();
expect(within(devices).getByText('在线')).toBeTruthy();
expect(within(devices).queryByText('online')).toBeNull();
expect(screen.getByText(/观测时间:2026-07-17T12:00:00Z/)).toBeTruthy();
send: vi.fn(),
};
render(<MessagesModule instance={instance} dataSource={dataSource} />);
expect(await screen.findByText('余额提醒')).toBeTruthy();
expect(screen.getByText(/收到 · 10086/)).toBeTruthy();
expect(document.body.textContent).not.toContain('secret-pdu');
});
it('uses an explicit safe allowlist and exposes no bodies, content, recipients, credentials, or write actions', async () => {
render(<MessagesModule instance={owner} dataSource={{ load: async () => snapshot }} />);
expect(await screen.findByText('Primary modem')).toBeTruthy();
const text = document.body.textContent ?? '';
for (const secret of [
'private message body',
'private message content',
'+15550199',
'secret-recipient-token',
'device body must not render',
'+15550123',
'device credential',
]) {
expect(text).not.toContain(secret);
}
expect(screen.queryByText(/recipient|phone number|password/i)).toBeNull();
expect(screen.queryByRole('button')).toBeNull();
expect(screen.getByText(/仅显示汇总元数据/i)).toBeTruthy();
expect(screen.getByText(/此只读模块不支持发送、删除或修改消息/)).toBeTruthy();
});
it('does not invent an endpoint when no source is injected', () => {
render(<MessagesModule instance={owner} />);
expect(screen.getByRole('status', { name: '消息不可用' }).textContent).toMatch(
/没有可用的安全消息只读数据源.*未签订契约的生产端点/i,
);
});
it('requires the exact owner to remain authenticated and clears prior owner data', async () => {
const load = vi.fn<MessagesDataSource['load']>().mockResolvedValue(snapshot);
const { rerender } = render(<MessagesModule instance={owner} dataSource={{ load }} />);
expect(await screen.findByText('Primary modem')).toBeTruthy();
rerender(
<MessagesModule
instance={{ ...owner, id: 'bravo', name: 'Bravo', authentication: 'auth-required' }}
dataSource={{ load }}
/>,
);
expect(screen.getByRole('alert').textContent).toMatch(/需要先完成认证/i);
expect(screen.queryByText('Primary modem')).toBeNull();
expect(load).toHaveBeenCalledTimes(1);
});
it('uses a fixed safe error and retries without exposing rejection details', async () => {
it('provides an intentional send form and submits exactly once', async () => {
const user = userEvent.setup();
const load = vi
.fn<MessagesDataSource['load']>()
.mockRejectedValueOnce(new Error('secret URL and recipient credential'))
.mockResolvedValueOnce(snapshot);
render(<MessagesModule instance={owner} dataSource={{ load }} />);
const alert = await screen.findByRole('alert');
expect(alert.textContent).toContain('无法加载消息数据。');
expect(alert.textContent).not.toContain('secret URL');
await user.click(screen.getByRole('button', { name: '重试加载消息' }));
expect(await screen.findByText('Primary modem')).toBeTruthy();
expect(load).toHaveBeenCalledTimes(2);
});
it('retains only the same owner last-good snapshot during refresh and after refresh failure', async () => {
const user = userEvent.setup();
const load = vi
.fn<MessagesDataSource['load']>()
.mockResolvedValueOnce(snapshot)
.mockRejectedValueOnce(new Error('unsafe detail'))
.mockResolvedValueOnce({ ...snapshot, devices: [{ label: 'Replacement modem', total: 4 }] });
const source = { load };
const { rerender } = render(
<MessagesModule instance={owner} dataSource={source} refreshSignal={0} />,
);
expect(await screen.findByText('Primary modem')).toBeTruthy();
rerender(<MessagesModule instance={owner} dataSource={source} refreshSignal={1} />);
expect(screen.getByText('Primary modem')).toBeTruthy();
expect((await screen.findByRole('alert')).textContent).toMatch(/正在显示上次已知/i);
expect(screen.getByText('Primary modem')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '重试加载消息' }));
expect(await screen.findByText('Replacement modem')).toBeTruthy();
});
it('aborts replaced reads and fences late responses from another owner', async () => {
const alpha = deferredSource();
const bravo = deferredSource();
const load = vi.fn<MessagesDataSource['load']>((instanceId, signal) =>
instanceId === 'alpha'
? alpha.source.load(instanceId, signal)
: bravo.source.load(instanceId, signal),
);
const source = { load };
const { rerender } = render(<MessagesModule instance={owner} dataSource={source} />);
const alphaSignal = load.mock.calls[0]?.[1];
rerender(
<MessagesModule instance={{ ...owner, id: 'bravo', name: 'Bravo' }} dataSource={source} />,
);
expect(alphaSignal?.aborted).toBe(true);
expect(screen.queryByText('Primary modem')).toBeNull();
alpha.resolve(snapshot);
bravo.resolve({ ...snapshot, devices: [{ label: 'Bravo modem', total: 9 }] });
expect(await screen.findByText('Bravo modem')).toBeTruthy();
expect(screen.queryByText('Primary modem')).toBeNull();
});
it('marks owner-declared stale data while preserving aggregates', async () => {
render(
<MessagesModule
instance={{ ...owner, freshness: 'stale' }}
dataSource={{ load: async () => snapshot }}
/>,
);
expect((await screen.findByRole('status', { name: '消息数据新鲜度' })).textContent).toMatch(
/可能已过期/i,
);
expect(screen.getByText('Primary modem')).toBeTruthy();
const send = vi.fn().mockResolvedValue(undefined);
const dataSource: MessagesDataSource = {
load: vi.fn().mockResolvedValue({ messages: [] }),
send,
};
render(<MessagesModule instance={instance} dataSource={dataSource} />);
await user.type(screen.getByRole('textbox', { name: '手机号' }), '10086');
await user.type(screen.getByRole('textbox', { name: '短信内容' }), 'CXLL');
await user.click(screen.getByRole('button', { name: '发送短信' }));
expect(send).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledWith('alpha', { phoneNumber: '10086', content: 'CXLL' });
expect((await screen.findByRole('status')).textContent).toContain('短信已提交发送');
});
});
+122 -214
View File
@@ -1,251 +1,159 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useEffect, useRef, useState } from 'react';
import type { InstanceContext } from '../app-shell.js';
import { displayValue } from '../ui/locale.js';
/** Bounded primitives permitted at the Messages presentation boundary. */
export type MessageMetadataValue = string | number | boolean | null;
/** Aggregate SMS metadata only. Message bodies, content, recipients, and credentials are absent. */
export interface SmsAggregate {
readonly total?: number | null;
readonly inbound?: number | null;
readonly outbound?: number | null;
readonly unread?: number | null;
readonly failed?: number | null;
readonly queued?: number | null;
readonly lastActivityAt?: string | null;
export interface SmsMessage {
readonly id: string;
readonly direction: string;
readonly phoneNumber: string;
readonly content: string;
readonly timestamp: string;
readonly status: string;
}
/** Non-sensitive device identity/status and aggregate counts only. */
export interface DeviceMessageAggregate extends SmsAggregate {
readonly deviceId?: string | null;
readonly label?: string | null;
readonly state?: string | null;
}
export interface MessagesSnapshot {
readonly observedAt?: string;
readonly sms: SmsAggregate;
readonly devices: readonly DeviceMessageAggregate[];
readonly messages: readonly SmsMessage[];
}
export interface SendMessageInput {
readonly phoneNumber: string;
readonly content: string;
}
export interface MessagesDataSource {
/** Supplied by the authenticated owner; this isolated module defines no network endpoint. */
load(instanceId: string, signal: AbortSignal): Promise<MessagesSnapshot>;
send(instanceId: string, input: SendMessageInput): Promise<void>;
}
export interface MessagesModuleProps {
readonly instance: InstanceContext;
readonly dataSource?: MessagesDataSource;
/** Change this owner-provided value to request another read. */
readonly refreshSignal?: unknown;
}
type ReadState =
| { kind: 'idle'; ownerId: string }
| { kind: 'loading'; ownerId: string; snapshot?: MessagesSnapshot }
| { kind: 'ready'; ownerId: string; snapshot: MessagesSnapshot }
| { kind: 'error'; ownerId: string; snapshot?: MessagesSnapshot };
const SAFE_LOAD_ERROR = '无法加载消息数据。';
const AGGREGATE_FIELDS = [
['总计', 'total'],
['接收', 'inbound'],
['发送', 'outbound'],
['未读', 'unread'],
['失败', 'failed'],
['排队中', 'queued'],
['最近活动', 'lastActivityAt'],
] as const;
function displayRaw(value: MessageMetadataValue | undefined): string {
return value == null ? '不可用' : String(value);
}
function Fields({
values,
}: {
values: readonly (readonly [
label: string,
value: MessageMetadataValue | undefined,
localized?: boolean,
])[];
}) {
return (
<dl>
{values.map(([label, value, localized]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{localized ? displayValue(value) : displayRaw(value)}</dd>
</div>
))}
</dl>
);
}
function Section({ label, children }: { label: string; children: ReactNode }) {
return (
<section className="messages-card" aria-label={label}>
<h2>{label}</h2>
{children}
</section>
);
}
function aggregateFields(
aggregate: SmsAggregate,
): readonly (readonly [string, MessageMetadataValue | undefined])[] {
// This constant-key projection is the presentation allowlist. Never enumerate source objects.
return AGGREGATE_FIELDS.map(([label, key]) => [label, aggregate[key]] as const);
}
function SnapshotView({ snapshot }: { snapshot: MessagesSnapshot }) {
return (
<>
{snapshot.observedAt ? <p>{snapshot.observedAt}</p> : null}
<p></p>
<div className="messages-grid">
<Section label="短信汇总">
<Fields values={aggregateFields(snapshot.sms)} />
</Section>
<Section label="设备消息汇总">
{snapshot.devices.length ? (
snapshot.devices.map((device, index) => (
<article key={`${device.deviceId ?? device.label ?? 'device'}-${index}`}>
<Fields
values={[
['设备 ID', device.deviceId],
['标签', device.label],
['状态', device.state, true],
...aggregateFields(device),
]}
/>
</article>
))
) : (
<p></p>
)}
</Section>
</div>
<section className="state-panel" aria-label="消息操作">
<h2></h2>
<p></p>
</section>
</>
);
}
const directionLabel = (value: string): string =>
value === 'incoming' || value === 'received'
? '收到'
: value === 'outgoing' || value === 'sent'
? '发送'
: '未知';
export function MessagesModule({ instance, dataSource, refreshSignal }: MessagesModuleProps) {
const requestOwner = useRef(0);
const owner = useRef(0);
const [messages, setMessages] = useState<readonly SmsMessage[]>();
const [loadError, setLoadError] = useState(false);
const [retry, setRetry] = useState(0);
const [state, setState] = useState<ReadState>({ kind: 'idle', ownerId: instance.id });
const [phoneNumber, setPhoneNumber] = useState('');
const [content, setContent] = useState('');
const [sending, setSending] = useState(false);
const [sendState, setSendState] = useState<'success' | 'error'>();
useEffect(() => {
const request = ++requestOwner.current;
const ownerId = instance.id;
const request = ++owner.current;
const controller = new AbortController();
if (instance.authentication !== 'authenticated' || !dataSource) {
setState({ kind: 'idle', ownerId });
return () => controller.abort();
}
setState((current) => ({
kind: 'loading',
ownerId,
...(current.ownerId === ownerId &&
(current.kind === 'ready' || current.kind === 'error') &&
current.snapshot
? { snapshot: current.snapshot }
: {}),
}));
void dataSource.load(ownerId, controller.signal).then(
setMessages(undefined);
setLoadError(false);
if (!dataSource) return () => controller.abort();
void dataSource.load(instance.id, controller.signal).then(
(snapshot) => {
if (request === requestOwner.current && !controller.signal.aborted) {
setState({ kind: 'ready', ownerId, snapshot });
}
if (request === owner.current && !controller.signal.aborted) setMessages(snapshot.messages);
},
(_reason: unknown) => {
void _reason;
if (request === requestOwner.current && !controller.signal.aborted) {
setState((current) => ({
kind: 'error',
ownerId,
...(current.ownerId === ownerId && current.kind === 'loading' && current.snapshot
? { snapshot: current.snapshot }
: {}),
}));
}
() => {
if (request === owner.current && !controller.signal.aborted) setLoadError(true);
},
);
return () => controller.abort();
}, [dataSource, instance.authentication, instance.id, refreshSignal, retry]);
}, [dataSource, instance.id, refreshSignal, retry]);
if (instance.authentication !== 'authenticated') {
async function send(): Promise<void> {
if (!dataSource || sending) return;
setSending(true);
setSendState(undefined);
try {
await dataSource.send(instance.id, { phoneNumber, content });
setContent('');
setSendState('success');
setRetry((value) => value + 1);
} catch {
setSendState('error');
} finally {
setSending(false);
}
}
if (!dataSource)
return (
<div className="state-panel state-error" role="alert">
<div className="state-panel" role="status">
</div>
);
}
if (!dataSource) {
return (
<div className="state-panel" role="status" aria-label="消息不可用">
</div>
);
}
const retainedSnapshot =
state.ownerId === instance.id && (state.kind === 'loading' || state.kind === 'error')
? state.snapshot
: undefined;
if ((state.kind === 'idle' || state.kind === 'loading') && !retainedSnapshot) {
return (
<p role="status" aria-label="消息加载状态">
</p>
);
}
if (state.kind === 'error' && !retainedSnapshot) {
return (
<div className="state-panel state-error" role="alert">
<p>{SAFE_LOAD_ERROR}</p>
<button type="button" onClick={() => setRetry((value) => value + 1)}>
</button>
</div>
);
}
const currentSnapshot =
state.kind === 'ready' && state.ownerId === instance.id ? state.snapshot : retainedSnapshot;
if (!currentSnapshot) return null;
return (
<div className="messages-module">
{state.kind === 'loading' ? <p role="status"></p> : null}
{state.kind === 'error' ? (
<div className="state-panel state-error" role="alert">
<p></p>
<button type="button" onClick={() => setRetry((value) => value + 1)}>
<section className="messages-card" aria-labelledby="send-sms-title">
<h2 id="send-sms-title"></h2>
<p></p>
<form
onSubmit={(event) => {
event.preventDefault();
void send();
}}
>
<label>
<input
name="phoneNumber"
value={phoneNumber}
onChange={(event) => setPhoneNumber(event.target.value)}
minLength={3}
maxLength={32}
required
/>
</label>
<label>
<textarea
name="content"
value={content}
onChange={(event) => setContent(event.target.value)}
maxLength={1600}
required
/>
</label>
<button type="submit" disabled={sending || !phoneNumber.trim() || !content.trim()}>
{sending ? '正在发送…' : '发送短信'}
</button>
</div>
) : null}
{instance.freshness !== 'fresh' ? (
<p className="state-panel" role="status" aria-label="消息数据新鲜度">
使
</p>
) : null}
<SnapshotView snapshot={currentSnapshot} />
</form>
{sendState === 'success' ? <p role="status"></p> : null}
{sendState === 'error' ? <p role="alert"></p> : null}
</section>
<section className="messages-card" aria-labelledby="sms-list-title">
<h2 id="sms-list-title"></h2>
{loadError ? (
<div role="alert">
<p></p>
<button type="button" onClick={() => setRetry((value) => value + 1)}>
</button>
</div>
) : messages === undefined ? (
<p role="status"></p>
) : messages.length === 0 ? (
<p></p>
) : (
<ol className="sms-list">
{messages.map((message) => (
<li key={message.id} className="sms-message-card">
<header>
<strong>
{directionLabel(message.direction)} · {message.phoneNumber}
</strong>
<time>{message.timestamp}</time>
</header>
<p>{message.content}</p>
<small>{message.status}</small>
</li>
))}
</ol>
)}
</section>
</div>
);
}
+32 -3
View File
@@ -586,6 +586,30 @@ dd {
.instance-module-detail {
min-width: 0;
}
.messages-module,
.messages-card,
.sms-list,
.sms-message-card {
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
}
.messages-card form,
.messages-card label {
display: grid;
min-width: 0;
gap: 0.5rem;
}
.messages-card input,
.messages-card textarea {
box-sizing: border-box;
min-width: 0;
max-width: 100%;
width: 100%;
}
.sms-list {
padding-inline-start: 1.25rem;
}
.overview-grid,
.cellular-grid,
.device-network-grid,
@@ -681,11 +705,16 @@ main ul[aria-label='已配置实例'] {
margin-bottom: 1rem;
}
.instance-context ul {
display: flex;
overflow-x: auto;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
overflow: visible;
}
.instance-context li {
min-width: max-content;
min-width: 0;
}
.instance-context nav a,
.instance-context nav span {
overflow-wrap: anywhere;
}
.fleet-toolbar label,
.jobs-toolbar label {