From 75682b813421a0bfd181fc93d030126f59ffd85d Mon Sep 17 00:00:00 2001 From: chick Date: Sun, 19 Jul 2026 14:05:06 +0800 Subject: [PATCH] feat(web): make instance detail the operations workbench --- apps/web/src/app-shell.integration.test.tsx | 11 +- apps/web/src/fleet/fleet-page.test.tsx | 45 +++ apps/web/src/fleet/fleet-page.tsx | 17 +- .../src/instances/instance-detail.test.tsx | 12 +- apps/web/src/instances/instance-detail.tsx | 6 +- .../src/instances/messages-module.test.tsx | 170 +++++++++- apps/web/src/instances/messages-module.tsx | 314 +++++++++++++----- apps/web/src/styles.css | 228 +++++++++++-- 8 files changed, 663 insertions(+), 140 deletions(-) create mode 100644 apps/web/src/fleet/fleet-page.test.tsx diff --git a/apps/web/src/app-shell.integration.test.tsx b/apps/web/src/app-shell.integration.test.tsx index b56da51..bd10d89 100644 --- a/apps/web/src/app-shell.integration.test.tsx +++ b/apps/web/src/app-shell.integration.test.tsx @@ -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( , ); - 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', diff --git a/apps/web/src/fleet/fleet-page.test.tsx b/apps/web/src/fleet/fleet-page.test.tsx new file mode 100644 index 0000000..f66c6dd --- /dev/null +++ b/apps/web/src/fleet/fleet-page.test.tsx @@ -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(); + + 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(); + }); +}); diff --git a/apps/web/src/fleet/fleet-page.tsx b/apps/web/src/fleet/fleet-page.tsx index 8b8773b..41aba78 100644 --- a/apps/web/src/fleet/fleet-page.tsx +++ b/apps/web/src/fleet/fleet-page.tsx @@ -458,10 +458,14 @@ export function FleetPage({ key={row.id} >
-
+

{row.displayName}

{row.id} -
+ {STATUS_LABELS[row.statusKind]} @@ -520,14 +524,7 @@ export function FleetPage({

暂无短信

)} -
- - 查看短信 - +
{ 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' } }); diff --git a/apps/web/src/instances/instance-detail.tsx b/apps/web/src/instances/instance-detail.tsx index fdf3630..7d16280 100644 --- a/apps/web/src/instances/instance-detail.tsx +++ b/apps/web/src/instances/instance-detail.tsx @@ -28,10 +28,10 @@ export interface InstanceDetailProps { } export const INSTANCE_MODULE_LABELS: Readonly> = { - overview: '设备信息', + overview: '实例仪表盘', cellular: '蜂窝网络', 'device-network': '设备网络', - messages: '短信', + messages: '短信管理', calls: '通话', esim: 'eSIM', notifications: '通知', @@ -39,7 +39,7 @@ export const INSTANCE_MODULE_LABELS: Readonly> = 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, string>> = { degraded: '此模块可用,但功能受限。', diff --git a/apps/web/src/instances/messages-module.test.tsx b/apps/web/src/instances/messages-module.test.tsx index e55632f..12f9d48 100644 --- a/apps/web/src/instances/messages-module.test.tsx +++ b/apps/web/src/instances/messages-module.test.tsx @@ -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(); + + 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(); - 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(); + 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(); + 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(); + 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(); + 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((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(); + 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(); - 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(); diff --git a/apps/web/src/instances/messages-module.tsx b/apps/web/src/instances/messages-module.tsx index 22b6b36..270c8a8 100644 --- a/apps/web/src/instances/messages-module.tsx +++ b/apps/web/src/instances/messages-module.tsx @@ -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(); + 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(); const [loadError, setLoadError] = useState(false); const [retry, setRetry] = useState(0); - const [phoneNumber, setPhoneNumber] = useState(''); + const [selectedPhone, setSelectedPhone] = useState(); + const [newConversationOpen, setNewConversationOpen] = useState(false); + const [newPhoneNumber, setNewPhoneNumber] = useState(''); const [content, setContent] = useState(''); const [pendingSend, setPendingSend] = useState(); 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 { 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 ( -
-
-

发送短信

-

发送会立即提交到当前设备,不会自动重试。请确认号码和内容。

-
{ - event.preventDefault(); - setPendingSend({ phoneNumber: phoneNumber.trim(), content: content.trim() }); - }} - > - -