178 lines
6.0 KiB
TypeScript
178 lines
6.0 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
|
|
import type { InstanceContext } from '../app-shell.js';
|
|
|
|
export interface SmsMessage {
|
|
readonly id: string;
|
|
readonly direction: string;
|
|
readonly phoneNumber: string;
|
|
readonly content: string;
|
|
readonly timestamp: string;
|
|
readonly status: string;
|
|
}
|
|
export interface MessagesSnapshot {
|
|
readonly messages: readonly SmsMessage[];
|
|
}
|
|
export interface SendMessageInput {
|
|
readonly phoneNumber: string;
|
|
readonly content: string;
|
|
}
|
|
export interface MessagesDataSource {
|
|
load(instanceId: string, signal: AbortSignal): Promise<MessagesSnapshot>;
|
|
send(instanceId: string, input: SendMessageInput): Promise<void>;
|
|
}
|
|
export interface MessagesModuleProps {
|
|
readonly instance: InstanceContext;
|
|
readonly dataSource?: MessagesDataSource;
|
|
readonly refreshSignal?: unknown;
|
|
}
|
|
|
|
const directionLabel = (value: string): string =>
|
|
value === 'incoming' || value === 'received'
|
|
? '收到'
|
|
: value === 'outgoing' || value === 'sent'
|
|
? '发送'
|
|
: '未知';
|
|
|
|
export function MessagesModule({ instance, dataSource, refreshSignal }: MessagesModuleProps) {
|
|
const owner = useRef(0);
|
|
const [messages, setMessages] = useState<readonly SmsMessage[]>();
|
|
const [loadError, setLoadError] = useState(false);
|
|
const [retry, setRetry] = useState(0);
|
|
const [phoneNumber, setPhoneNumber] = useState('');
|
|
const [content, setContent] = useState('');
|
|
const [pendingSend, setPendingSend] = useState<SendMessageInput>();
|
|
const [sending, setSending] = useState(false);
|
|
const [sendState, setSendState] = useState<'success' | 'error'>();
|
|
|
|
useEffect(() => {
|
|
const request = ++owner.current;
|
|
const controller = new AbortController();
|
|
setMessages(undefined);
|
|
setLoadError(false);
|
|
if (!dataSource) return () => controller.abort();
|
|
void dataSource.load(instance.id, controller.signal).then(
|
|
(snapshot) => {
|
|
if (request === owner.current && !controller.signal.aborted) setMessages(snapshot.messages);
|
|
},
|
|
() => {
|
|
if (request === owner.current && !controller.signal.aborted) setLoadError(true);
|
|
},
|
|
);
|
|
return () => controller.abort();
|
|
}, [dataSource, instance.id, refreshSignal, retry]);
|
|
|
|
async function send(input: SendMessageInput): Promise<void> {
|
|
if (!dataSource || sending) return;
|
|
setSending(true);
|
|
setSendState(undefined);
|
|
try {
|
|
await dataSource.send(instance.id, input);
|
|
setContent('');
|
|
setPendingSend(undefined);
|
|
setSendState('success');
|
|
setRetry((value) => value + 1);
|
|
} catch {
|
|
setSendState('error');
|
|
} finally {
|
|
setSending(false);
|
|
}
|
|
}
|
|
|
|
if (!dataSource)
|
|
return (
|
|
<div className="state-panel" role="status">
|
|
短信功能暂不可用。
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="messages-module">
|
|
<section className="messages-card" aria-labelledby="send-sms-title">
|
|
<h2 id="send-sms-title">发送短信</h2>
|
|
<p>发送会立即提交到当前设备,不会自动重试。请确认号码和内容。</p>
|
|
<form
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
setPendingSend({ phoneNumber: phoneNumber.trim(), content: content.trim() });
|
|
}}
|
|
>
|
|
<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>
|
|
</form>
|
|
{sendState === 'success' ? <p role="status">短信已提交发送。</p> : null}
|
|
{sendState === 'error' ? <p role="alert">短信发送失败,请核对设备状态后重试。</p> : null}
|
|
{pendingSend ? (
|
|
<section className="send-confirmation" role="region" aria-label="确认发送短信">
|
|
<h3>发送前确认</h3>
|
|
<p>号码:{pendingSend.phoneNumber}</p>
|
|
<p>内容:{pendingSend.content}</p>
|
|
<p>确认后会立即提交到当前设备。</p>
|
|
<div className="fleet-card-actions">
|
|
<button type="button" disabled={sending} onClick={() => void send(pendingSend)}>
|
|
{sending ? '正在发送…' : '确认并发送'}
|
|
</button>
|
|
<button type="button" disabled={sending} onClick={() => setPendingSend(undefined)}>
|
|
返回修改
|
|
</button>
|
|
</div>
|
|
</section>
|
|
) : 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>
|
|
);
|
|
}
|