feat(web): make instance detail the operations workbench
This commit is contained in:
@@ -162,9 +162,10 @@ describe('React AppShell and Fleet vertical slice', () => {
|
||||
expect(within(card).getByText(/这是一条用于聚合页展示/)).toBeTruthy();
|
||||
expect(within(card).getByText(/2026/)).toBeTruthy();
|
||||
expect(within(card).queryByText('能力未知')).toBeNull();
|
||||
expect(within(card).getByRole('link', { name: '查看 Bravo 的短信' }).getAttribute('href')).toBe(
|
||||
'/instances/bravo/messages',
|
||||
);
|
||||
expect(
|
||||
within(card).getByRole('link', { name: '打开 Bravo 实例仪表盘' }).getAttribute('href'),
|
||||
).toBe('/instances/bravo/overview');
|
||||
expect(within(card).queryByRole('link', { name: /查看.*短信/ })).toBeNull();
|
||||
expect(within(card).getByRole('link', { name: '编辑 Bravo' }).getAttribute('href')).toBe(
|
||||
'/settings/instances/bravo',
|
||||
);
|
||||
@@ -241,7 +242,7 @@ describe('React AppShell and Fleet vertical slice', () => {
|
||||
|
||||
expect(screen.getByRole('status').textContent).toContain('正在加载实例');
|
||||
expect(await screen.findByText('Bravo')).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: '设备信息' })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: '实例仪表盘' })).toBeTruthy();
|
||||
expect(screen.getByRole('link', { name: '编辑实例' }).getAttribute('href')).toBe(
|
||||
'/settings/instances/bravo',
|
||||
);
|
||||
@@ -352,7 +353,7 @@ describe('React AppShell and Fleet vertical slice', () => {
|
||||
const { rerender } = render(
|
||||
<AppShell pathname="/instances/owner/messages" instance={instance} />,
|
||||
);
|
||||
expect(screen.getByRole('heading', { name: '短信' })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: '短信管理' })).toBeTruthy();
|
||||
expect(screen.getByText('Owner modem')).toBeTruthy();
|
||||
expect(screen.getByRole('link', { name: '打开源站' }).getAttribute('href')).toBe(
|
||||
'https://owner.example',
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { FleetPage, type FleetSnapshot } from './fleet-page.js';
|
||||
|
||||
const snapshot: FleetSnapshot = {
|
||||
instances: [
|
||||
{
|
||||
id: 'alpha',
|
||||
name: 'Alpha modem',
|
||||
url: 'http://192.168.1.2',
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
statuses: new Map([
|
||||
[
|
||||
'alpha',
|
||||
{
|
||||
instanceId: 'alpha',
|
||||
connectivity: 'online',
|
||||
authentication: 'authenticated',
|
||||
freshness: 'fresh',
|
||||
capabilities: ['overview', 'messages'],
|
||||
anomalies: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('FleetPage card navigation', () => {
|
||||
it('makes the whole instance card open its dashboard while admin controls stay separately interactive', () => {
|
||||
render(<FleetPage initialData={snapshot} />);
|
||||
|
||||
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
||||
const entry = within(card).getByRole('link', { name: '打开 Alpha modem 实例仪表盘' });
|
||||
expect(entry.getAttribute('href')).toBe('/instances/alpha/overview');
|
||||
expect(entry.classList.contains('fleet-card-entry')).toBe(true);
|
||||
expect(within(card).getByRole('link', { name: '编辑 Alpha modem' })).toBeTruthy();
|
||||
expect(within(card).getByRole('button', { name: '删除 Alpha modem' })).toBeTruthy();
|
||||
expect(within(card).queryByRole('link', { name: /查看.*短信/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -458,10 +458,14 @@ export function FleetPage({
|
||||
key={row.id}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<a
|
||||
className="fleet-card-entry"
|
||||
href={`/instances/${encodeURIComponent(row.id)}/overview`}
|
||||
aria-label={`打开 ${row.displayName} 实例仪表盘`}
|
||||
>
|
||||
<h2>{row.displayName}</h2>
|
||||
<code>{row.id}</code>
|
||||
</div>
|
||||
</a>
|
||||
<span className={`status status-${row.statusKind}`}>
|
||||
{STATUS_LABELS[row.statusKind]}
|
||||
</span>
|
||||
@@ -520,14 +524,7 @@ export function FleetPage({
|
||||
<p className="fleet-card-sms-empty">暂无短信</p>
|
||||
)}
|
||||
</section>
|
||||
<div className="fleet-card-actions">
|
||||
<a
|
||||
className="primary-action"
|
||||
href={`/instances/${encodeURIComponent(row.id)}/messages`}
|
||||
aria-label={`查看 ${row.displayName} 的短信`}
|
||||
>
|
||||
查看短信
|
||||
</a>
|
||||
<div className="fleet-card-actions fleet-card-admin-actions">
|
||||
<a
|
||||
href={`/settings/instances/${encodeURIComponent(row.id)}`}
|
||||
aria-label={`编辑 ${row.displayName}`}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+194
-34
@@ -284,6 +284,7 @@ small {
|
||||
gap: 1rem;
|
||||
}
|
||||
.fleet-card {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
@@ -588,6 +589,43 @@ tbody th small {
|
||||
justify-content: flex-end;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.fleet-card-entry {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.fleet-card-entry::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.fleet-card-entry:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.fleet-card-entry:focus-visible::after {
|
||||
outline: 3px solid var(--warning);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.fleet-card-entry:hover h2,
|
||||
.fleet-card-entry:focus-visible h2 {
|
||||
color: var(--accent-strong);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
.fleet-card-entry code {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.fleet-card-admin-actions {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
justify-content: flex-end;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px solid #e9dfca;
|
||||
}
|
||||
.instance-detail {
|
||||
display: grid;
|
||||
grid-template-columns: 13rem minmax(0, 1fr);
|
||||
@@ -622,57 +660,171 @@ dd {
|
||||
.instance-module-detail {
|
||||
min-width: 0;
|
||||
}
|
||||
.messages-module {
|
||||
.messages-workbench {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(16rem, 0.85fr) minmax(22rem, 1.4fr);
|
||||
align-items: start;
|
||||
gap: 0.8rem;
|
||||
grid-template-columns: minmax(16rem, 21rem) minmax(24rem, 1fr);
|
||||
min-height: 36rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e2d6bf;
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.messages-module,
|
||||
.messages-card,
|
||||
.sms-list,
|
||||
.sms-message-card {
|
||||
.conversation-panel {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
border-right: 1px solid #e2d6bf;
|
||||
background: #fffdf5;
|
||||
}
|
||||
.messages-card h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.messages-card form,
|
||||
.messages-card label {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.messages-card form {
|
||||
.conversation-panel-header,
|
||||
.conversation-detail-header,
|
||||
.conversation-entry-title,
|
||||
.composer-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.messages-card textarea {
|
||||
min-height: 8rem;
|
||||
.conversation-panel-header,
|
||||
.conversation-detail-header {
|
||||
min-height: 4.5rem;
|
||||
padding: 0.8rem 1rem;
|
||||
border-bottom: 1px solid #e2d6bf;
|
||||
}
|
||||
.conversation-panel-header h2,
|
||||
.conversation-detail-header h2,
|
||||
.conversation-panel-header span,
|
||||
.conversation-detail-header small {
|
||||
margin: 0;
|
||||
}
|
||||
.conversation-panel-header span,
|
||||
.conversation-detail-header span,
|
||||
.conversation-entry small,
|
||||
.conversation-entry time,
|
||||
.composer-footer small {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.conversation-list,
|
||||
.message-thread {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.conversation-entry {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #eee4d2;
|
||||
border-radius: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
text-align: left;
|
||||
}
|
||||
.conversation-entry > span:not(.conversation-entry-title) {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.conversation-entry:hover,
|
||||
.conversation-entry[aria-pressed='true'] {
|
||||
background: #f2f8e9;
|
||||
}
|
||||
.conversation-state,
|
||||
.conversation-empty-state {
|
||||
padding: 1.2rem;
|
||||
}
|
||||
.conversation-detail {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fffaf0;
|
||||
}
|
||||
.conversation-empty-state {
|
||||
margin: auto;
|
||||
max-width: 24rem;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
.message-thread {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
max-height: 32rem;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.message-bubble {
|
||||
max-width: min(78%, 36rem);
|
||||
padding: 0.75rem 0.9rem;
|
||||
border: 1px solid #e2d6bf;
|
||||
border-radius: 14px;
|
||||
background: #fffdf5;
|
||||
}
|
||||
.message-bubble header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.message-bubble p {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.message-发送 {
|
||||
align-self: flex-end;
|
||||
background: #eaf4dd;
|
||||
}
|
||||
.message-composer {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
padding: 0.8rem 1rem;
|
||||
border-top: 1px solid #e2d6bf;
|
||||
background: #fffdf5;
|
||||
}
|
||||
.message-composer label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.message-composer textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 5rem;
|
||||
resize: vertical;
|
||||
}
|
||||
.send-confirmation {
|
||||
margin-top: 0.8rem;
|
||||
margin: 0 1rem 1rem;
|
||||
padding: 0.8rem;
|
||||
border: 1px solid #dca66d;
|
||||
border-radius: 14px;
|
||||
background: #fff5dc;
|
||||
}
|
||||
.send-confirmation h3,
|
||||
.send-confirmation p {
|
||||
margin-top: 0;
|
||||
.dialog-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background: rgba(72, 52, 33, 0.36);
|
||||
}
|
||||
.messages-card input,
|
||||
.messages-card textarea {
|
||||
.new-conversation-dialog {
|
||||
width: min(100%, 26rem);
|
||||
padding: 1.2rem;
|
||||
border: 1px solid #e2d6bf;
|
||||
border-radius: var(--radius);
|
||||
background: #fffdf5;
|
||||
box-shadow: 0 20px 60px rgba(72, 52, 33, 0.24);
|
||||
}
|
||||
.new-conversation-dialog label {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.new-conversation-dialog input {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.sms-list {
|
||||
padding-inline-start: 1.25rem;
|
||||
}
|
||||
.overview-grid,
|
||||
.cellular-grid,
|
||||
.device-network-grid,
|
||||
@@ -767,8 +919,16 @@ main ul[aria-label='已配置实例'] {
|
||||
.instance-context {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.messages-module {
|
||||
.messages-workbench {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
}
|
||||
.conversation-panel {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #e2d6bf;
|
||||
}
|
||||
.message-thread {
|
||||
max-height: none;
|
||||
}
|
||||
.instance-context ul {
|
||||
display: grid;
|
||||
|
||||
Reference in New Issue
Block a user