feat(web): make instance detail the operations workbench
This commit is contained in:
@@ -46,11 +46,11 @@ describe('InstanceDetail', () => {
|
||||
const links = navigation.getAllByRole('link');
|
||||
expect(links).toHaveLength(2);
|
||||
expect(links.map((link) => [link.textContent, link.getAttribute('href')])).toEqual([
|
||||
['短信', '/instances/owner/messages'],
|
||||
['设备信息', '/instances/owner/overview'],
|
||||
['实例仪表盘', '/instances/owner/overview'],
|
||||
['短信管理', '/instances/owner/messages'],
|
||||
]);
|
||||
expect(navigation.queryByText(/蜂窝网络|设备网络|通话|eSIM|通知|自动化|OTA/)).toBeNull();
|
||||
expect(screen.getByRole('heading', { name: '设备信息' })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: '实例仪表盘' })).toBeTruthy();
|
||||
expect(screen.queryByText('能力状态未知。')).toBeNull();
|
||||
expect(screen.queryByText('此调制解调器不支持语音功能。')).toBeNull();
|
||||
expect(screen.queryByText('能力探测结果未包含 eSIM。')).toBeNull();
|
||||
@@ -65,7 +65,7 @@ describe('InstanceDetail', () => {
|
||||
capabilities={capabilities}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('heading', { name: '短信' })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: '短信管理' })).toBeTruthy();
|
||||
expect(screen.queryByRole('heading', { name: '消息' })).toBeNull();
|
||||
|
||||
rerender(
|
||||
@@ -76,7 +76,7 @@ describe('InstanceDetail', () => {
|
||||
capabilities={capabilities}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('heading', { name: '设备信息' })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: '实例仪表盘' })).toBeTruthy();
|
||||
expect(screen.queryByRole('heading', { name: '概览' })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -159,7 +159,7 @@ describe('InstanceDetail', () => {
|
||||
expect(pending.get('owner')?.signal.aborted).toBe(true);
|
||||
|
||||
pending.get('second')?.resolve({ overview: { state: 'supported' } });
|
||||
expect(await instanceNavigation().findByRole('link', { name: '设备信息' })).toBeTruthy();
|
||||
expect(await instanceNavigation().findByRole('link', { name: '实例仪表盘' })).toBeTruthy();
|
||||
pending
|
||||
.get('owner')
|
||||
?.resolve({ overview: { state: 'unsupported', explanation: 'Stale owner result' } });
|
||||
|
||||
@@ -28,10 +28,10 @@ export interface InstanceDetailProps {
|
||||
}
|
||||
|
||||
export const INSTANCE_MODULE_LABELS: Readonly<Record<InstanceModule, string>> = {
|
||||
overview: '设备信息',
|
||||
overview: '实例仪表盘',
|
||||
cellular: '蜂窝网络',
|
||||
'device-network': '设备网络',
|
||||
messages: '短信',
|
||||
messages: '短信管理',
|
||||
calls: '通话',
|
||||
esim: 'eSIM',
|
||||
notifications: '通知',
|
||||
@@ -39,7 +39,7 @@ export const INSTANCE_MODULE_LABELS: Readonly<Record<InstanceModule, string>> =
|
||||
ota: 'OTA',
|
||||
};
|
||||
|
||||
const PRIMARY_MODULES = ['messages', 'overview'] as const satisfies readonly InstanceModule[];
|
||||
const PRIMARY_MODULES = ['overview', 'messages'] as const satisfies readonly InstanceModule[];
|
||||
|
||||
const DEFAULT_EXPLANATIONS: Readonly<Record<Exclude<CapabilityState, 'supported'>, string>> = {
|
||||
degraded: '此模块可用,但功能受限。',
|
||||
|
||||
@@ -17,7 +17,39 @@ const instance: InstanceContext = {
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('MessagesModule', () => {
|
||||
it('organizes messages as conversations and starts a new conversation before composing', async () => {
|
||||
const user = userEvent.setup();
|
||||
const dataSource: MessagesDataSource = {
|
||||
load: vi.fn().mockResolvedValue({
|
||||
messages: [
|
||||
{
|
||||
id: '32',
|
||||
direction: 'incoming',
|
||||
phoneNumber: '10086',
|
||||
content: '余额提醒',
|
||||
timestamp: '2026-07-18 09:09:20',
|
||||
status: 'received',
|
||||
},
|
||||
],
|
||||
}),
|
||||
send: vi.fn(),
|
||||
};
|
||||
render(<MessagesModule instance={instance} dataSource={dataSource} />);
|
||||
|
||||
const conversations = await screen.findByRole('region', { name: '短信对话' });
|
||||
expect(within(conversations).getByRole('button', { name: /10086/ })).toBeTruthy();
|
||||
expect(screen.getByRole('region', { name: '当前短信会话' }).textContent).toContain(
|
||||
'选择一个对话',
|
||||
);
|
||||
|
||||
await user.click(within(conversations).getByRole('button', { name: '新建对话' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '新建对话' });
|
||||
expect(within(dialog).getByRole('textbox', { name: '手机号' })).toBeTruthy();
|
||||
expect(screen.queryByRole('textbox', { name: '短信内容' })).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the real message list including content and never exposes PDU', async () => {
|
||||
const user = userEvent.setup();
|
||||
const dataSource: MessagesDataSource = {
|
||||
load: vi.fn().mockResolvedValue({
|
||||
messages: [
|
||||
@@ -35,11 +67,142 @@ describe('MessagesModule', () => {
|
||||
send: vi.fn(),
|
||||
};
|
||||
render(<MessagesModule instance={instance} dataSource={dataSource} />);
|
||||
expect(await screen.findByText('余额提醒')).toBeTruthy();
|
||||
await user.click(await screen.findByRole('button', { name: /10086/ }));
|
||||
expect((await screen.findAllByText('余额提醒')).length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByText(/收到 · 10086/)).toBeTruthy();
|
||||
expect(document.body.textContent).not.toContain('secret-pdu');
|
||||
});
|
||||
|
||||
it('clears recipient-bound drafts when the conversation changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const dataSource: MessagesDataSource = {
|
||||
load: vi.fn().mockResolvedValue({
|
||||
messages: [
|
||||
{
|
||||
id: 'one',
|
||||
direction: 'incoming',
|
||||
phoneNumber: '10086',
|
||||
content: 'A',
|
||||
timestamp: '2026-07-18 09:09:20',
|
||||
status: 'received',
|
||||
},
|
||||
{
|
||||
id: 'two',
|
||||
direction: 'incoming',
|
||||
phoneNumber: '10010',
|
||||
content: 'B',
|
||||
timestamp: '2026-07-18 09:10:20',
|
||||
status: 'received',
|
||||
},
|
||||
],
|
||||
}),
|
||||
send: vi.fn(),
|
||||
};
|
||||
render(<MessagesModule instance={instance} dataSource={dataSource} />);
|
||||
await user.click(await screen.findByRole('button', { name: /10086/ }));
|
||||
await user.type(screen.getByRole('textbox', { name: '短信内容' }), 'draft for 10086');
|
||||
await user.click(screen.getByRole('button', { name: /10010/ }));
|
||||
expect((screen.getByRole('textbox', { name: '短信内容' }) as HTMLTextAreaElement).value).toBe(
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
it('discards a pending confirmation when the instance owner changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const send = vi.fn().mockResolvedValue(undefined);
|
||||
const dataSource: MessagesDataSource = {
|
||||
load: vi.fn().mockResolvedValue({ messages: [] }),
|
||||
send,
|
||||
};
|
||||
const second = { ...instance, id: 'beta', name: 'Beta' };
|
||||
const { rerender } = render(<MessagesModule instance={instance} dataSource={dataSource} />);
|
||||
await user.click(await screen.findByRole('button', { name: '新建对话' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '新建对话' });
|
||||
await user.type(within(dialog).getByRole('textbox', { name: '手机号' }), '10086');
|
||||
await user.click(within(dialog).getByRole('button', { name: '开始对话' }));
|
||||
await user.type(screen.getByRole('textbox', { name: '短信内容' }), 'owner-bound draft');
|
||||
await user.click(screen.getByRole('button', { name: '发送短信' }));
|
||||
expect(screen.getByRole('region', { name: '确认发送短信' })).toBeTruthy();
|
||||
|
||||
rerender(<MessagesModule instance={second} dataSource={dataSource} />);
|
||||
expect(screen.queryByRole('region', { name: '确认发送短信' })).toBeNull();
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops an old confirmation when starting another recipient conversation', async () => {
|
||||
const user = userEvent.setup();
|
||||
const dataSource: MessagesDataSource = {
|
||||
load: vi.fn().mockResolvedValue({
|
||||
messages: [
|
||||
{
|
||||
id: 'one',
|
||||
direction: 'incoming',
|
||||
phoneNumber: '10086',
|
||||
content: 'A',
|
||||
timestamp: '2026-07-18 09:09:20',
|
||||
status: 'received',
|
||||
},
|
||||
],
|
||||
}),
|
||||
send: vi.fn(),
|
||||
};
|
||||
render(<MessagesModule instance={instance} dataSource={dataSource} />);
|
||||
await user.click(await screen.findByRole('button', { name: /10086/ }));
|
||||
await user.type(screen.getByRole('textbox', { name: '短信内容' }), 'old confirmation');
|
||||
await user.click(screen.getByRole('button', { name: '发送短信' }));
|
||||
expect(screen.getByRole('region', { name: '确认发送短信' })).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '新建对话' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '新建对话' });
|
||||
await user.type(within(dialog).getByRole('textbox', { name: '手机号' }), '10010');
|
||||
await user.click(within(dialog).getByRole('button', { name: '开始对话' }));
|
||||
expect(screen.queryByRole('region', { name: '确认发送短信' })).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves a new recipient draft when an earlier send completes late', async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveSend: (() => void) | undefined;
|
||||
const send = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveSend = resolve;
|
||||
}),
|
||||
);
|
||||
const dataSource: MessagesDataSource = {
|
||||
load: vi.fn().mockResolvedValue({
|
||||
messages: [
|
||||
{
|
||||
id: 'one',
|
||||
direction: 'incoming',
|
||||
phoneNumber: '10086',
|
||||
content: 'A',
|
||||
timestamp: '2026-07-18 09:09:20',
|
||||
status: 'received',
|
||||
},
|
||||
{
|
||||
id: 'two',
|
||||
direction: 'incoming',
|
||||
phoneNumber: '10010',
|
||||
content: 'B',
|
||||
timestamp: '2026-07-18 09:10:20',
|
||||
status: 'received',
|
||||
},
|
||||
],
|
||||
}),
|
||||
send,
|
||||
};
|
||||
render(<MessagesModule instance={instance} dataSource={dataSource} />);
|
||||
await user.click(await screen.findByRole('button', { name: /10086/ }));
|
||||
await user.type(screen.getByRole('textbox', { name: '短信内容' }), 'send to 10086');
|
||||
await user.click(screen.getByRole('button', { name: '发送短信' }));
|
||||
await user.click(screen.getByRole('button', { name: '确认并发送' }));
|
||||
await user.click(screen.getByRole('button', { name: /10010/ }));
|
||||
await user.type(screen.getByRole('textbox', { name: '短信内容' }), 'draft for 10010');
|
||||
|
||||
resolveSend?.();
|
||||
expect(await screen.findByDisplayValue('draft for 10010')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('requires an explicit review step before it submits exactly once', async () => {
|
||||
const user = userEvent.setup();
|
||||
const send = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -48,7 +211,10 @@ describe('MessagesModule', () => {
|
||||
send,
|
||||
};
|
||||
render(<MessagesModule instance={instance} dataSource={dataSource} />);
|
||||
await user.type(screen.getByRole('textbox', { name: '手机号' }), '10086');
|
||||
await user.click(await screen.findByRole('button', { name: '新建对话' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '新建对话' });
|
||||
await user.type(within(dialog).getByRole('textbox', { name: '手机号' }), '10086');
|
||||
await user.click(within(dialog).getByRole('button', { name: '开始对话' }));
|
||||
await user.type(screen.getByRole('textbox', { name: '短信内容' }), 'CXLL');
|
||||
await user.click(screen.getByRole('button', { name: '发送短信' }));
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { InstanceContext } from '../app-shell.js';
|
||||
|
||||
@@ -34,12 +34,39 @@ const directionLabel = (value: string): string =>
|
||||
? '发送'
|
||||
: '未知';
|
||||
|
||||
interface Conversation {
|
||||
readonly phoneNumber: string;
|
||||
readonly messages: readonly SmsMessage[];
|
||||
readonly latest: SmsMessage;
|
||||
}
|
||||
|
||||
function conversationsFor(messages: readonly SmsMessage[]): readonly Conversation[] {
|
||||
const groups = new Map<string, SmsMessage[]>();
|
||||
for (const message of messages) {
|
||||
const group = groups.get(message.phoneNumber) ?? [];
|
||||
group.push(message);
|
||||
groups.set(message.phoneNumber, group);
|
||||
}
|
||||
return [...groups.entries()]
|
||||
.map(([phoneNumber, group]) => ({
|
||||
phoneNumber,
|
||||
messages: group,
|
||||
latest: group[group.length - 1]!,
|
||||
}))
|
||||
.sort((left, right) => right.latest.timestamp.localeCompare(left.latest.timestamp));
|
||||
}
|
||||
|
||||
export function MessagesModule({ instance, dataSource, refreshSignal }: MessagesModuleProps) {
|
||||
const owner = useRef(0);
|
||||
const lifecycle = useRef(0);
|
||||
const conversationLifecycle = useRef(0);
|
||||
const instanceOwner = useRef(instance.id);
|
||||
const [messages, setMessages] = useState<readonly SmsMessage[]>();
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [retry, setRetry] = useState(0);
|
||||
const [phoneNumber, setPhoneNumber] = useState('');
|
||||
const [selectedPhone, setSelectedPhone] = useState<string>();
|
||||
const [newConversationOpen, setNewConversationOpen] = useState(false);
|
||||
const [newPhoneNumber, setNewPhoneNumber] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [pendingSend, setPendingSend] = useState<SendMessageInput>();
|
||||
const [sending, setSending] = useState(false);
|
||||
@@ -47,9 +74,22 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
|
||||
|
||||
useEffect(() => {
|
||||
const request = ++owner.current;
|
||||
const ownerChanged = instanceOwner.current !== instance.id;
|
||||
if (ownerChanged) lifecycle.current += 1;
|
||||
instanceOwner.current = instance.id;
|
||||
const controller = new AbortController();
|
||||
setMessages(undefined);
|
||||
setLoadError(false);
|
||||
if (ownerChanged) {
|
||||
conversationLifecycle.current += 1;
|
||||
setSelectedPhone(undefined);
|
||||
setNewConversationOpen(false);
|
||||
setNewPhoneNumber('');
|
||||
setContent('');
|
||||
setPendingSend(undefined);
|
||||
setSending(false);
|
||||
setSendState(undefined);
|
||||
}
|
||||
if (!dataSource) return () => controller.abort();
|
||||
void dataSource.load(instance.id, controller.signal).then(
|
||||
(snapshot) => {
|
||||
@@ -62,20 +102,37 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
|
||||
return () => controller.abort();
|
||||
}, [dataSource, instance.id, refreshSignal, retry]);
|
||||
|
||||
const conversations = useMemo(() => conversationsFor(messages ?? []), [messages]);
|
||||
const activeConversation = conversations.find((item) => item.phoneNumber === selectedPhone);
|
||||
|
||||
async function send(input: SendMessageInput): Promise<void> {
|
||||
if (!dataSource || sending) return;
|
||||
const sendOwner = instance.id;
|
||||
const sendLifecycle = lifecycle.current;
|
||||
const sendConversationLifecycle = conversationLifecycle.current;
|
||||
if (instanceOwner.current !== sendOwner) return;
|
||||
setSending(true);
|
||||
setSendState(undefined);
|
||||
try {
|
||||
await dataSource.send(instance.id, input);
|
||||
setContent('');
|
||||
setPendingSend(undefined);
|
||||
setSendState('success');
|
||||
await dataSource.send(sendOwner, input);
|
||||
if (instanceOwner.current !== sendOwner || lifecycle.current !== sendLifecycle) return;
|
||||
const sameConversation = conversationLifecycle.current === sendConversationLifecycle;
|
||||
if (sameConversation) {
|
||||
setContent((current) => (current === input.content ? '' : current));
|
||||
setPendingSend(undefined);
|
||||
setSendState('success');
|
||||
}
|
||||
setRetry((value) => value + 1);
|
||||
} catch {
|
||||
setSendState('error');
|
||||
if (
|
||||
instanceOwner.current === sendOwner &&
|
||||
lifecycle.current === sendLifecycle &&
|
||||
conversationLifecycle.current === sendConversationLifecycle
|
||||
)
|
||||
setSendState('error');
|
||||
} finally {
|
||||
setSending(false);
|
||||
if (instanceOwner.current === sendOwner && lifecycle.current === sendLifecycle)
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,91 +144,188 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
|
||||
);
|
||||
|
||||
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 ? '正在发送…' : '发送短信'}
|
||||
<div className="messages-workbench">
|
||||
<section className="conversation-panel" role="region" aria-label="短信对话">
|
||||
<header className="conversation-panel-header">
|
||||
<div>
|
||||
<h2>对话</h2>
|
||||
<span>{conversations.length} 个对话</span>
|
||||
</div>
|
||||
<button type="button" aria-label="新建对话" onClick={() => setNewConversationOpen(true)}>
|
||||
新建对话
|
||||
</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>
|
||||
</header>
|
||||
{loadError ? (
|
||||
<div role="alert">
|
||||
<p>无法加载短信列表。</p>
|
||||
<div role="alert" className="conversation-state">
|
||||
<p>无法加载短信对话。</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
重试加载
|
||||
</button>
|
||||
</div>
|
||||
) : messages === undefined ? (
|
||||
<p role="status">正在加载短信…</p>
|
||||
) : messages.length === 0 ? (
|
||||
<p>暂无短信。</p>
|
||||
<p role="status" className="conversation-state">
|
||||
正在加载短信…
|
||||
</p>
|
||||
) : conversations.length === 0 ? (
|
||||
<p className="conversation-state">暂无对话。可以新建对话开始发送短信。</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>
|
||||
<ol className="conversation-list">
|
||||
{conversations.map((conversation) => (
|
||||
<li key={conversation.phoneNumber}>
|
||||
<button
|
||||
type="button"
|
||||
className="conversation-entry"
|
||||
aria-pressed={selectedPhone === conversation.phoneNumber}
|
||||
aria-label={`${conversation.phoneNumber},${conversation.messages.length} 条短信`}
|
||||
onClick={() => {
|
||||
conversationLifecycle.current += 1;
|
||||
setSelectedPhone(conversation.phoneNumber);
|
||||
setContent('');
|
||||
setPendingSend(undefined);
|
||||
setSendState(undefined);
|
||||
}}
|
||||
>
|
||||
<span className="conversation-entry-title">
|
||||
<strong>{conversation.phoneNumber}</strong>
|
||||
<time>{conversation.latest.timestamp}</time>
|
||||
</span>
|
||||
<span>{conversation.latest.content}</span>
|
||||
<small>{conversation.messages.length} 条短信</small>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="conversation-detail" role="region" aria-label="当前短信会话">
|
||||
{!selectedPhone ? (
|
||||
<div className="conversation-empty-state">
|
||||
<h2>选择一个对话</h2>
|
||||
<p>从左侧选择已有对话,或新建对话开始发送短信。</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<header className="conversation-detail-header">
|
||||
<div>
|
||||
<small>当前收件人</small>
|
||||
<h2>{selectedPhone}</h2>
|
||||
</div>
|
||||
<span>{instance.name}</span>
|
||||
</header>
|
||||
<ol className="message-thread" aria-label={`${selectedPhone} 的短信记录`}>
|
||||
{activeConversation?.messages.map((message) => (
|
||||
<li
|
||||
key={message.id}
|
||||
className={`message-bubble message-${directionLabel(message.direction)}`}
|
||||
>
|
||||
<header>
|
||||
<strong>
|
||||
{directionLabel(message.direction)} · {message.phoneNumber}
|
||||
</strong>
|
||||
<time>{message.timestamp}</time>
|
||||
</header>
|
||||
<p>{message.content}</p>
|
||||
<small>状态:{message.status}</small>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<form
|
||||
className="message-composer"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setPendingSend({ phoneNumber: selectedPhone, content: content.trim() });
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
短信内容
|
||||
<textarea
|
||||
name="content"
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
maxLength={1600}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<div className="composer-footer">
|
||||
<small>{content.length} / 1600 字符</small>
|
||||
<button type="submit" disabled={sending || !content.trim()}>
|
||||
{sending ? '正在发送…' : '发送短信'}
|
||||
</button>
|
||||
</div>
|
||||
</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>实例:{instance.name}</p>
|
||||
<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>
|
||||
|
||||
{newConversationOpen ? (
|
||||
<div className="dialog-backdrop">
|
||||
<section
|
||||
className="new-conversation-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="new-conversation-title"
|
||||
>
|
||||
<h2 id="new-conversation-title">新建对话</h2>
|
||||
<p>选择当前实例要联系的手机号。进入会话后再填写短信内容。</p>
|
||||
<label>
|
||||
手机号
|
||||
<input
|
||||
value={newPhoneNumber}
|
||||
onChange={(event) => setNewPhoneNumber(event.target.value)}
|
||||
minLength={3}
|
||||
maxLength={32}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<div className="fleet-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!newPhoneNumber.trim()}
|
||||
onClick={() => {
|
||||
conversationLifecycle.current += 1;
|
||||
setSelectedPhone(newPhoneNumber.trim());
|
||||
setNewConversationOpen(false);
|
||||
setNewPhoneNumber('');
|
||||
setContent('');
|
||||
setPendingSend(undefined);
|
||||
setSendState(undefined);
|
||||
}}
|
||||
>
|
||||
开始对话
|
||||
</button>
|
||||
<button type="button" onClick={() => setNewConversationOpen(false)}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user