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
+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>
);
}