feat(web): make instance detail the operations workbench

This commit is contained in:
chick
2026-07-19 14:05:06 +08:00
parent f7d69a0fa7
commit 75682b8134
8 changed files with 663 additions and 140 deletions
@@ -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' } });
+3 -3
View File
@@ -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: '此模块可用,但功能受限。',
+168 -2
View File
@@ -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();
+234 -80
View File
@@ -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>
);
}