feat(web): prioritize SMS operations workspace

This commit is contained in:
chick
2026-07-19 10:30:52 +08:00
parent 0112e3320a
commit f7d69a0fa7
10 changed files with 532 additions and 68 deletions
+79 -13
View File
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { InstanceContext, InstanceModule } from '../app-shell.js';
@@ -22,13 +22,17 @@ const owner: InstanceContext = {
const capabilities: InstanceCapabilityMap = {
overview: { state: 'supported' },
messages: { state: 'degraded', explanation: '消息历史记录为只读。' },
messages: { state: 'degraded', explanation: '短信历史记录为只读。' },
calls: { state: 'unsupported', explanation: '此调制解调器不支持语音功能。' },
esim: { state: 'unknown', explanation: '能力探测结果未包含 eSIM。' },
};
function instanceNavigation() {
return within(screen.getByRole('navigation', { name: '实例模块' }));
}
describe('InstanceDetail', () => {
it('keeps core overview and message modules actionable while explaining other capability states', () => {
it('converges primary navigation on messages and device information only', () => {
render(
<InstanceDetail
instanceId="owner"
@@ -38,16 +42,71 @@ describe('InstanceDetail', () => {
/>,
);
expect(screen.getByRole('link', { name: '概览' }).getAttribute('href')).toBe(
'/instances/owner/overview',
const navigation = instanceNavigation();
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'],
]);
expect(navigation.queryByText(/蜂窝网络|设备网络|通话|eSIM|通知|自动化|OTA/)).toBeNull();
expect(screen.getByRole('heading', { name: '设备信息' })).toBeTruthy();
expect(screen.queryByText('能力状态未知。')).toBeNull();
expect(screen.queryByText('此调制解调器不支持语音功能。')).toBeNull();
expect(screen.queryByText('能力探测结果未包含 eSIM。')).toBeNull();
});
it('uses the business titles for both primary pages', () => {
const { rerender } = render(
<InstanceDetail
instanceId="owner"
module="messages"
instance={owner}
capabilities={capabilities}
/>,
);
expect(screen.getByRole('link', { name: '消息' }).getAttribute('href')).toBe(
'/instances/owner/messages',
expect(screen.getByRole('heading', { name: '短信' })).toBeTruthy();
expect(screen.queryByRole('heading', { name: '消息' })).toBeNull();
rerender(
<InstanceDetail
instanceId="owner"
module="overview"
instance={owner}
capabilities={capabilities}
/>,
);
expect(screen.queryByText('消息历史记录为只读。')).toBeNull();
expect(screen.getByText('此调制解调器不支持语音功能。')).toBeTruthy();
expect(screen.getByText('能力探测结果未包含 eSIM。')).toBeTruthy();
expect(screen.getAllByText('能力状态未知。').length).toBeGreaterThan(0);
expect(screen.getByRole('heading', { name: '设备信息' })).toBeTruthy();
expect(screen.queryByRole('heading', { name: '概览' })).toBeNull();
});
it('does not invent an unknown capability placeholder when capability data is absent', () => {
render(
<InstanceDetail instanceId="owner" module="overview" instance={owner} capabilities={{}} />,
);
expect(instanceNavigation().getAllByRole('link')).toHaveLength(2);
expect(screen.queryByText('能力状态未知。')).toBeNull();
});
it('hides unknown instance status but keeps known status context', () => {
const unknownOwner: InstanceContext = { ...owner, status: 'unknown' };
const { rerender } = render(
<InstanceDetail
instanceId="owner"
module="overview"
instance={unknownOwner}
capabilities={{}}
/>,
);
expect(screen.queryByText('状态')).toBeNull();
expect(screen.queryByText('未知')).toBeNull();
rerender(
<InstanceDetail instanceId="owner" module="overview" instance={owner} capabilities={{}} />,
);
expect(screen.getByText('状态')).toBeTruthy();
expect(screen.getByText('在线')).toBeTruthy();
});
it('never renders or loads context when the direct-route owner does not match', () => {
@@ -100,7 +159,7 @@ describe('InstanceDetail', () => {
expect(pending.get('owner')?.signal.aborted).toBe(true);
pending.get('second')?.resolve({ overview: { state: 'supported' } });
expect(await screen.findByRole('link', { name: '概览' })).toBeTruthy();
expect(await instanceNavigation().findByRole('link', { name: '设备信息' })).toBeTruthy();
pending
.get('owner')
?.resolve({ overview: { state: 'unsupported', explanation: 'Stale owner result' } });
@@ -111,17 +170,24 @@ describe('InstanceDetail', () => {
it.each<[InstanceModule, string]>([
['cellular', '蜂窝网络'],
['device-network', '设备网络'],
['calls', '通话'],
['esim', 'eSIM'],
['notifications', '通知'],
['automation', '自动化'],
['ota', 'OTA'],
])('uses the existing module contract for %s', (module, label) => {
])('keeps the active legacy deep link and content compatible for %s', (module, label) => {
render(
<InstanceDetail
instanceId="owner"
module={module}
instance={owner}
capabilities={{ [module]: { state: 'supported' } }}
moduleContent={<p>旧深链接内容:{label}</p>}
/>,
);
expect(screen.getByRole('heading', { name: label })).toBeTruthy();
expect(screen.getByText(`旧深链接内容:${label}`)).toBeTruthy();
expect(instanceNavigation().queryByRole('link', { name: label })).toBeNull();
});
});
+28 -37
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 MODULES = Object.keys(INSTANCE_MODULE_LABELS) as readonly InstanceModule[];
const PRIMARY_MODULES = ['messages', 'overview'] as const satisfies readonly InstanceModule[];
const DEFAULT_EXPLANATIONS: Readonly<Record<Exclude<CapabilityState, 'supported'>, string>> = {
degraded: '此模块可用,但功能受限。',
@@ -47,20 +47,18 @@ const DEFAULT_EXPLANATIONS: Readonly<Record<Exclude<CapabilityState, 'supported'
unknown: '能力状态未知。',
};
function capabilityFor(map: InstanceCapabilityMap, module: InstanceModule): InstanceCapability {
return map[module] ?? { state: 'unknown' };
}
function canRender(capability: InstanceCapability): boolean {
function canRender(capability: InstanceCapability | undefined): boolean {
if (!capability) return true;
return capability.state === 'supported' || capability.state === 'degraded';
}
const CORE_MODULES = new Set<InstanceModule>(['overview', 'messages']);
function canOpen(module: InstanceModule, capability: InstanceCapability): boolean {
return CORE_MODULES.has(module) || canRender(capability);
function canOpen(module: InstanceModule, capability: InstanceCapability | undefined): boolean {
return capability ? canRender(capability) : CORE_MODULES.has(module);
}
function explanation(capability: InstanceCapability): string | null {
function explanation(capability: InstanceCapability | undefined): string | null {
if (!capability) return null;
if (capability.state === 'supported') return null;
return capability.explanation?.trim() || DEFAULT_EXPLANATIONS[capability.state];
}
@@ -124,19 +122,21 @@ export function InstanceDetail({
const map = capabilities ?? loadedCapabilities ?? {};
const origin = canonicalHttpOrigin(instance.origin);
const activeCapability = capabilityFor(map, module);
const activeCapability = map[module];
return (
<section className="instance-detail">
<aside className="instance-context" aria-label="当前实例">
<strong>{instance.name}</strong>
<code>{instance.id}</code>
<dl>
<div>
<dt>状态</dt>
<dd>{displayStatus(instance.status)}</dd>
</div>
</dl>
{instance.status !== 'unknown' ? (
<dl>
<div>
<dt>状态</dt>
<dd>{displayStatus(instance.status)}</dd>
</div>
</dl>
) : null}
{origin ? (
<a href={origin} target="_blank" rel="noopener noreferrer">
打开源站
@@ -145,24 +145,15 @@ export function InstanceDetail({
<a href={`/settings/instances/${encodeURIComponent(instanceId)}`}>编辑实例</a>
<nav aria-label="实例模块">
<ul>
{MODULES.map((item) => {
const capability = capabilityFor(map, item);
const reason = explanation(capability);
{PRIMARY_MODULES.map((item) => {
return (
<li key={item} data-capability-state={capability.state}>
{canOpen(item, capability) ? (
<a
href={`/instances/${encodeURIComponent(instanceId)}/${item}`}
aria-current={module === item ? 'page' : undefined}
>
{INSTANCE_MODULE_LABELS[item]}
</a>
) : (
<>
<span>{INSTANCE_MODULE_LABELS[item]}</span>
<small>{reason}</small>
</>
)}
<li key={item}>
<a
href={`/instances/${encodeURIComponent(instanceId)}/${item}`}
aria-current={module === item ? 'page' : undefined}
>
{INSTANCE_MODULE_LABELS[item]}
</a>
</li>
);
})}
@@ -175,14 +166,14 @@ export function InstanceDetail({
{loadError ? <p role="alert">能力不可用:{loadError}</p> : null}
{!loading && canOpen(module, activeCapability) ? (
<>
{activeCapability.state === 'degraded' ? (
{activeCapability?.state === 'degraded' ? (
<p data-capability-state="degraded">{explanation(activeCapability)}</p>
) : null}
{moduleContent ?? <p>查看{INSTANCE_MODULE_LABELS[module]}数据和可用操作。</p>}
</>
) : null}
{!loading && !canOpen(module, activeCapability) ? (
<p data-capability-state={activeCapability.state}>{explanation(activeCapability)}</p>
<p data-capability-state={activeCapability?.state}>{explanation(activeCapability)}</p>
) : null}
</div>
</section>
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react';
import { cleanup, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -40,7 +40,7 @@ describe('MessagesModule', () => {
expect(document.body.textContent).not.toContain('secret-pdu');
});
it('provides an intentional send form and submits exactly once', async () => {
it('requires an explicit review step before it submits exactly once', async () => {
const user = userEvent.setup();
const send = vi.fn().mockResolvedValue(undefined);
const dataSource: MessagesDataSource = {
@@ -51,6 +51,11 @@ describe('MessagesModule', () => {
await user.type(screen.getByRole('textbox', { name: '手机号' }), '10086');
await user.type(screen.getByRole('textbox', { name: '短信内容' }), 'CXLL');
await user.click(screen.getByRole('button', { name: '发送短信' }));
expect(send).not.toHaveBeenCalled();
const confirmation = screen.getByRole('region', { name: '确认发送短信' });
expect(confirmation.textContent).toContain('10086');
expect(confirmation.textContent).toContain('CXLL');
await user.click(within(confirmation).getByRole('button', { name: '确认并发送' }));
expect(send).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledWith('alpha', { phoneNumber: '10086', content: 'CXLL' });
expect((await screen.findByRole('status')).textContent).toContain('短信已提交发送');
+21 -3
View File
@@ -41,6 +41,7 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
const [retry, setRetry] = useState(0);
const [phoneNumber, setPhoneNumber] = useState('');
const [content, setContent] = useState('');
const [pendingSend, setPendingSend] = useState<SendMessageInput>();
const [sending, setSending] = useState(false);
const [sendState, setSendState] = useState<'success' | 'error'>();
@@ -61,13 +62,14 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
return () => controller.abort();
}, [dataSource, instance.id, refreshSignal, retry]);
async function send(): Promise<void> {
async function send(input: SendMessageInput): Promise<void> {
if (!dataSource || sending) return;
setSending(true);
setSendState(undefined);
try {
await dataSource.send(instance.id, { phoneNumber, content });
await dataSource.send(instance.id, input);
setContent('');
setPendingSend(undefined);
setSendState('success');
setRetry((value) => value + 1);
} catch {
@@ -92,7 +94,7 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
<form
onSubmit={(event) => {
event.preventDefault();
void send();
setPendingSend({ phoneNumber: phoneNumber.trim(), content: content.trim() });
}}
>
<label>
@@ -122,6 +124,22 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
</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">