feat(sms): add real instance message workflow
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user