feat(web): prioritize SMS operations workspace
This commit is contained in:
@@ -7,6 +7,7 @@ import { AppShell, type InstanceContext } from './app-shell.js';
|
|||||||
import type { AuditDataSource } from './audit/audit-page.js';
|
import type { AuditDataSource } from './audit/audit-page.js';
|
||||||
import type { EventStreamClient } from './events/event-stream-client.js';
|
import type { EventStreamClient } from './events/event-stream-client.js';
|
||||||
import { type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js';
|
import { type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js';
|
||||||
|
import type { FleetMessagesDataSource } from './fleet/fleet-messages-api-data-source.js';
|
||||||
import type { InstanceDataSource } from './instances/instance-api-data-source.js';
|
import type { InstanceDataSource } from './instances/instance-api-data-source.js';
|
||||||
import type { JobsDataSource } from './jobs/jobs-page.js';
|
import type { JobsDataSource } from './jobs/jobs-page.js';
|
||||||
|
|
||||||
@@ -116,10 +117,27 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
delete: remove,
|
delete: remove,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fleetMessagesDataSource: FleetMessagesDataSource = {
|
||||||
|
load: vi.fn(async (instanceId) =>
|
||||||
|
instanceId === 'bravo'
|
||||||
|
? {
|
||||||
|
latest: {
|
||||||
|
id: 'sms-1',
|
||||||
|
direction: 'incoming',
|
||||||
|
phoneNumber: '13900139000',
|
||||||
|
content: '这是一条用于聚合页展示的最新短信内容',
|
||||||
|
timestamp: '2026-07-19T08:30:00.000Z',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<AppShell
|
<AppShell
|
||||||
pathname="/fleet"
|
pathname="/fleet"
|
||||||
fleetDataSource={source(async () => snapshot)}
|
fleetDataSource={source(async () => snapshot)}
|
||||||
|
fleetMessagesDataSource={fleetMessagesDataSource}
|
||||||
instanceDataSource={instanceDataSource}
|
instanceDataSource={instanceDataSource}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -139,8 +157,13 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
expect(within(card).getByText('63.2%')).toBeTruthy();
|
expect(within(card).getByText('63.2%')).toBeTruthy();
|
||||||
expect(within(card).getByText('46.7 °C')).toBeTruthy();
|
expect(within(card).getByText('46.7 °C')).toBeTruthy();
|
||||||
expect(within(card).getByText('13800138000')).toBeTruthy();
|
expect(within(card).getByText('13800138000')).toBeTruthy();
|
||||||
expect(within(card).getByRole('link', { name: '管理 Bravo' }).getAttribute('href')).toBe(
|
expect(await within(card).findByText('收到')).toBeTruthy();
|
||||||
'/instances/bravo/overview',
|
expect(within(card).getByText('13900139000')).toBeTruthy();
|
||||||
|
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(
|
expect(within(card).getByRole('link', { name: '编辑 Bravo' }).getAttribute('href')).toBe(
|
||||||
'/settings/instances/bravo',
|
'/settings/instances/bravo',
|
||||||
@@ -154,6 +177,20 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
expect(within(card).getByRole('status').textContent).toContain('删除请求已提交');
|
expect(within(card).getByRole('status').textContent).toContain('删除请求已提交');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps message read failures separate from instance reachability and reports each once', async () => {
|
||||||
|
const messages: FleetMessagesDataSource = { load: vi.fn().mockRejectedValue(new Error('no')) };
|
||||||
|
render(
|
||||||
|
<AppShell
|
||||||
|
pathname="/fleet"
|
||||||
|
fleetDataSource={source(async () => snapshot)}
|
||||||
|
fleetMessagesDataSource={messages}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const bravo = await screen.findByRole('article', { name: 'Bravo 实例概览' });
|
||||||
|
expect(await within(bravo).findAllByText('短信暂不可用')).toHaveLength(1);
|
||||||
|
expect(within(bravo).getByText('需要认证')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps Jobs and Audit routes compatible without advertising them in global navigation', async () => {
|
it('keeps Jobs and Audit routes compatible without advertising them in global navigation', async () => {
|
||||||
const jobsDataSource: JobsDataSource = { load: vi.fn().mockResolvedValue(emptyPage) };
|
const jobsDataSource: JobsDataSource = { load: vi.fn().mockResolvedValue(emptyPage) };
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
@@ -204,7 +241,7 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
|
|
||||||
expect(screen.getByRole('status').textContent).toContain('正在加载实例');
|
expect(screen.getByRole('status').textContent).toContain('正在加载实例');
|
||||||
expect(await screen.findByText('Bravo')).toBeTruthy();
|
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(
|
expect(screen.getByRole('link', { name: '编辑实例' }).getAttribute('href')).toBe(
|
||||||
'/settings/instances/bravo',
|
'/settings/instances/bravo',
|
||||||
);
|
);
|
||||||
@@ -315,7 +352,7 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<AppShell pathname="/instances/owner/messages" instance={instance} />,
|
<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.getByText('Owner modem')).toBeTruthy();
|
||||||
expect(screen.getByRole('link', { name: '打开源站' }).getAttribute('href')).toBe(
|
expect(screen.getByRole('link', { name: '打开源站' }).getAttribute('href')).toBe(
|
||||||
'https://owner.example',
|
'https://owner.example',
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { createEventStreamClient, type EventStreamClient } from './events/event-
|
|||||||
|
|
||||||
import { FleetPage, type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js';
|
import { FleetPage, type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js';
|
||||||
import { createFleetApiDataSource } from './fleet/fleet-api-data-source.js';
|
import { createFleetApiDataSource } from './fleet/fleet-api-data-source.js';
|
||||||
|
import {
|
||||||
|
createFleetMessagesApiDataSource,
|
||||||
|
type FleetMessagesDataSource,
|
||||||
|
} from './fleet/fleet-messages-api-data-source.js';
|
||||||
import { AutomationModule, type AutomationDataSource } from './instances/automation-module.js';
|
import { AutomationModule, type AutomationDataSource } from './instances/automation-module.js';
|
||||||
import { CallsModule, type CallsDataSource } from './instances/calls-module.js';
|
import { CallsModule, type CallsDataSource } from './instances/calls-module.js';
|
||||||
import { CellularModule, type CellularDataSource } from './instances/cellular-module.js';
|
import { CellularModule, type CellularDataSource } from './instances/cellular-module.js';
|
||||||
@@ -79,6 +83,7 @@ export interface AppShellProps {
|
|||||||
version?: string;
|
version?: string;
|
||||||
instance?: InstanceContext;
|
instance?: InstanceContext;
|
||||||
fleetDataSource?: FleetDataSource;
|
fleetDataSource?: FleetDataSource;
|
||||||
|
fleetMessagesDataSource?: FleetMessagesDataSource;
|
||||||
fleetData?: FleetSnapshot;
|
fleetData?: FleetSnapshot;
|
||||||
instanceDataSource?: InstanceDataSource;
|
instanceDataSource?: InstanceDataSource;
|
||||||
capabilities?: InstanceCapabilityMap;
|
capabilities?: InstanceCapabilityMap;
|
||||||
@@ -156,6 +161,7 @@ function section(route: ResolvedRoute): GlobalSection | undefined {
|
|||||||
function Page({
|
function Page({
|
||||||
route,
|
route,
|
||||||
fleetDataSource,
|
fleetDataSource,
|
||||||
|
fleetMessagesDataSource,
|
||||||
fleetData,
|
fleetData,
|
||||||
instance,
|
instance,
|
||||||
instanceDataSource,
|
instanceDataSource,
|
||||||
@@ -177,6 +183,7 @@ function Page({
|
|||||||
}: {
|
}: {
|
||||||
route: ResolvedRoute;
|
route: ResolvedRoute;
|
||||||
fleetDataSource: FleetDataSource | undefined;
|
fleetDataSource: FleetDataSource | undefined;
|
||||||
|
fleetMessagesDataSource: FleetMessagesDataSource | undefined;
|
||||||
fleetData: FleetSnapshot | undefined;
|
fleetData: FleetSnapshot | undefined;
|
||||||
instance: InstanceContext | undefined;
|
instance: InstanceContext | undefined;
|
||||||
instanceDataSource: InstanceDataSource | undefined;
|
instanceDataSource: InstanceDataSource | undefined;
|
||||||
@@ -200,6 +207,7 @@ function Page({
|
|||||||
return (
|
return (
|
||||||
<FleetPage
|
<FleetPage
|
||||||
{...(fleetDataSource ? { dataSource: fleetDataSource } : {})}
|
{...(fleetDataSource ? { dataSource: fleetDataSource } : {})}
|
||||||
|
{...(fleetMessagesDataSource ? { messagesDataSource: fleetMessagesDataSource } : {})}
|
||||||
{...(instanceDataSource ? { instanceDataSource } : {})}
|
{...(instanceDataSource ? { instanceDataSource } : {})}
|
||||||
{...(fleetData ? { initialData: fleetData } : {})}
|
{...(fleetData ? { initialData: fleetData } : {})}
|
||||||
refreshSignal={fleetRefreshSignal}
|
refreshSignal={fleetRefreshSignal}
|
||||||
@@ -391,6 +399,7 @@ export function AppShell({
|
|||||||
version = 'dev',
|
version = 'dev',
|
||||||
instance,
|
instance,
|
||||||
fleetDataSource,
|
fleetDataSource,
|
||||||
|
fleetMessagesDataSource,
|
||||||
fleetData,
|
fleetData,
|
||||||
instanceDataSource,
|
instanceDataSource,
|
||||||
capabilities,
|
capabilities,
|
||||||
@@ -410,6 +419,7 @@ export function AppShell({
|
|||||||
}: AppShellProps) {
|
}: AppShellProps) {
|
||||||
const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []);
|
const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []);
|
||||||
const defaultFleetDataSource = useMemo(() => createFleetApiDataSource(), []);
|
const defaultFleetDataSource = useMemo(() => createFleetApiDataSource(), []);
|
||||||
|
const defaultFleetMessagesDataSource = useMemo(() => createFleetMessagesApiDataSource(), []);
|
||||||
const defaultInstanceDataSource = useMemo(() => createInstanceApiDataSource(), []);
|
const defaultInstanceDataSource = useMemo(() => createInstanceApiDataSource(), []);
|
||||||
const defaultMessagesDataSource = useMemo(() => createMessagesApiDataSource(), []);
|
const defaultMessagesDataSource = useMemo(() => createMessagesApiDataSource(), []);
|
||||||
const resolvedJobsDataSource = useMemo(
|
const resolvedJobsDataSource = useMemo(
|
||||||
@@ -512,6 +522,7 @@ export function AppShell({
|
|||||||
<Page
|
<Page
|
||||||
route={route}
|
route={route}
|
||||||
fleetDataSource={fleetDataSource ?? defaultFleetDataSource}
|
fleetDataSource={fleetDataSource ?? defaultFleetDataSource}
|
||||||
|
fleetMessagesDataSource={fleetMessagesDataSource ?? defaultFleetMessagesDataSource}
|
||||||
fleetData={fleetData}
|
fleetData={fleetData}
|
||||||
instance={routeInstance}
|
instance={routeInstance}
|
||||||
instanceDataSource={resolvedInstanceDataSource}
|
instanceDataSource={resolvedInstanceDataSource}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createFleetMessagesApiDataSource,
|
||||||
|
loadFleetMessageSummaries,
|
||||||
|
type FleetMessagesDataSource,
|
||||||
|
} from './fleet-messages-api-data-source.js';
|
||||||
|
|
||||||
|
describe('Fleet messages API data source', () => {
|
||||||
|
it('loads only the newest message through the same-origin read endpoint', async () => {
|
||||||
|
const fetcher = vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 'sms-1',
|
||||||
|
direction: 'incoming',
|
||||||
|
phoneNumber: '13800138000',
|
||||||
|
content: '聚合页最新短信',
|
||||||
|
timestamp: '2026-07-19T08:30:00.000Z',
|
||||||
|
status: 'received',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createFleetMessagesApiDataSource(fetcher as typeof fetch).load('alpha'),
|
||||||
|
).resolves.toEqual({
|
||||||
|
latest: {
|
||||||
|
id: 'sms-1',
|
||||||
|
direction: 'incoming',
|
||||||
|
phoneNumber: '13800138000',
|
||||||
|
content: '聚合页最新短信',
|
||||||
|
timestamp: '2026-07-19T08:30:00.000Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetcher).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/instances/alpha/messages?limit=1&offset=0',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty summary and rejects malformed responses', async () => {
|
||||||
|
const emptyFetcher = vi.fn(async () => new Response(JSON.stringify({ messages: [] })));
|
||||||
|
await expect(
|
||||||
|
createFleetMessagesApiDataSource(emptyFetcher as typeof fetch).load('alpha'),
|
||||||
|
).resolves.toEqual({});
|
||||||
|
|
||||||
|
const malformedFetcher = vi.fn(async () => new Response(JSON.stringify({ data: [] })));
|
||||||
|
await expect(
|
||||||
|
createFleetMessagesApiDataSource(malformedFetcher as typeof fetch).load('alpha'),
|
||||||
|
).rejects.toThrow('Fleet messages response is invalid.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds fleet fan-out and stops scheduling new owners after abort', async () => {
|
||||||
|
let active = 0;
|
||||||
|
let peak = 0;
|
||||||
|
const releases: (() => void)[] = [];
|
||||||
|
const load = vi.fn<FleetMessagesDataSource['load']>(async (instanceId, signal) => {
|
||||||
|
active += 1;
|
||||||
|
peak = Math.max(peak, active);
|
||||||
|
await new Promise<void>((resolve) => releases.push(resolve));
|
||||||
|
active -= 1;
|
||||||
|
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
||||||
|
return {
|
||||||
|
latest: {
|
||||||
|
id: instanceId,
|
||||||
|
direction: 'incoming',
|
||||||
|
phoneNumber: '10086',
|
||||||
|
content: instanceId,
|
||||||
|
timestamp: '2026-07-19T08:30:00.000Z',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const controller = new AbortController();
|
||||||
|
const pending = loadFleetMessageSummaries(
|
||||||
|
{ load },
|
||||||
|
['one', 'two', 'three', 'four', 'five', 'six'],
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(load).toHaveBeenCalledTimes(4));
|
||||||
|
expect(peak).toBe(4);
|
||||||
|
controller.abort();
|
||||||
|
for (const release of releases) release();
|
||||||
|
await expect(pending).resolves.toEqual(new Map());
|
||||||
|
expect(load).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
export interface FleetMessageSummary {
|
||||||
|
readonly id: string;
|
||||||
|
readonly direction: string;
|
||||||
|
readonly phoneNumber: string;
|
||||||
|
readonly content: string;
|
||||||
|
readonly timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetMessagesSnapshot {
|
||||||
|
readonly latest?: FleetMessageSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetMessagesDataSource {
|
||||||
|
load(instanceId: string, signal?: AbortSignal): Promise<FleetMessagesSnapshot>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FleetMessageLoadState = Readonly<{
|
||||||
|
latest?: FleetMessageSummary;
|
||||||
|
unavailable?: true;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const FLEET_MESSAGE_CONCURRENCY = 4;
|
||||||
|
export async function loadFleetMessageSummaries(
|
||||||
|
dataSource: FleetMessagesDataSource,
|
||||||
|
instanceIds: readonly string[],
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<ReadonlyMap<string, FleetMessageLoadState>> {
|
||||||
|
const results = new Map<string, FleetMessageLoadState>();
|
||||||
|
let cursor = 0;
|
||||||
|
async function worker(): Promise<void> {
|
||||||
|
while (!signal.aborted) {
|
||||||
|
const index = cursor++;
|
||||||
|
const instanceId = instanceIds[index];
|
||||||
|
if (instanceId === undefined) return;
|
||||||
|
try {
|
||||||
|
const result = await dataSource.load(instanceId, signal);
|
||||||
|
if (signal.aborted) return;
|
||||||
|
results.set(instanceId, result.latest ? { latest: result.latest } : {});
|
||||||
|
} catch {
|
||||||
|
if (signal.aborted) return;
|
||||||
|
results.set(instanceId, { unavailable: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: Math.min(FLEET_MESSAGE_CONCURRENCY, instanceIds.length) }, () => worker()),
|
||||||
|
);
|
||||||
|
return signal.aborted ? new Map() : results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> | undefined {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMessage(value: unknown): FleetMessageSummary | undefined {
|
||||||
|
const item = record(value);
|
||||||
|
if (
|
||||||
|
typeof item?.id !== 'string' ||
|
||||||
|
typeof item.direction !== 'string' ||
|
||||||
|
typeof item.phoneNumber !== 'string' ||
|
||||||
|
typeof item.content !== 'string' ||
|
||||||
|
typeof item.timestamp !== 'string'
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
direction: item.direction,
|
||||||
|
phoneNumber: item.phoneNumber,
|
||||||
|
content: item.content,
|
||||||
|
timestamp: item.timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFleetMessagesApiDataSource(
|
||||||
|
fetcher: typeof fetch = fetch,
|
||||||
|
): FleetMessagesDataSource {
|
||||||
|
return {
|
||||||
|
async load(instanceId, signal): Promise<FleetMessagesSnapshot> {
|
||||||
|
const response = await fetcher(
|
||||||
|
`/api/v1/instances/${encodeURIComponent(instanceId)}/messages?limit=1&offset=0`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
...(signal ? { signal } : {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error(`Fleet messages request failed (${response.status}).`);
|
||||||
|
const root = record(await response.json());
|
||||||
|
if (!Array.isArray(root?.messages)) throw new Error('Fleet messages response is invalid.');
|
||||||
|
if (root.messages.length === 0) return {};
|
||||||
|
const latest = parseMessage(root.messages[0]);
|
||||||
|
if (!latest) throw new Error('Fleet messages response is invalid.');
|
||||||
|
return { latest };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,11 @@ import {
|
|||||||
type SortDirection,
|
type SortDirection,
|
||||||
} from './fleet-table-view-model.js';
|
} from './fleet-table-view-model.js';
|
||||||
import type { InstanceDataSource } from '../instances/instance-crud.js';
|
import type { InstanceDataSource } from '../instances/instance-crud.js';
|
||||||
|
import type {
|
||||||
|
FleetMessageLoadState,
|
||||||
|
FleetMessagesDataSource,
|
||||||
|
} from './fleet-messages-api-data-source.js';
|
||||||
|
import { loadFleetMessageSummaries } from './fleet-messages-api-data-source.js';
|
||||||
|
|
||||||
export interface FleetSnapshot {
|
export interface FleetSnapshot {
|
||||||
readonly instances: readonly FleetInstance[];
|
readonly instances: readonly FleetInstance[];
|
||||||
@@ -20,10 +25,12 @@ export interface FleetDataSource {
|
|||||||
}
|
}
|
||||||
export interface FleetPageProps {
|
export interface FleetPageProps {
|
||||||
readonly dataSource?: FleetDataSource;
|
readonly dataSource?: FleetDataSource;
|
||||||
|
readonly messagesDataSource?: FleetMessagesDataSource;
|
||||||
readonly instanceDataSource?: InstanceDataSource;
|
readonly instanceDataSource?: InstanceDataSource;
|
||||||
readonly initialData?: FleetSnapshot;
|
readonly initialData?: FleetSnapshot;
|
||||||
readonly refreshSignal?: number;
|
readonly refreshSignal?: number;
|
||||||
}
|
}
|
||||||
|
type FleetMessageState = FleetMessageLoadState;
|
||||||
|
|
||||||
const EMPTY_SNAPSHOT: FleetSnapshot = { instances: [], statuses: new Map() };
|
const EMPTY_SNAPSHOT: FleetSnapshot = { instances: [], statuses: new Map() };
|
||||||
const STATUS_LABELS: Readonly<Record<string, string>> = {
|
const STATUS_LABELS: Readonly<Record<string, string>> = {
|
||||||
@@ -69,6 +76,18 @@ const percent = (value: number | undefined): string =>
|
|||||||
value === undefined ? '暂未获取' : `${value.toFixed(1)}%`;
|
value === undefined ? '暂未获取' : `${value.toFixed(1)}%`;
|
||||||
const temperature = (value: number | undefined): string =>
|
const temperature = (value: number | undefined): string =>
|
||||||
value === undefined ? '暂未获取' : `${value.toFixed(1)} °C`;
|
value === undefined ? '暂未获取' : `${value.toFixed(1)} °C`;
|
||||||
|
const messageDirection = (value: string): string =>
|
||||||
|
value === 'incoming' || value === 'received'
|
||||||
|
? '收到'
|
||||||
|
: value === 'outgoing' || value === 'sent'
|
||||||
|
? '发送'
|
||||||
|
: '未知方向';
|
||||||
|
const messageExcerpt = (value: string): string =>
|
||||||
|
value.length > 36 ? `${value.slice(0, 36)}…` : value;
|
||||||
|
const messageTime = (value: string): string => {
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN');
|
||||||
|
};
|
||||||
|
|
||||||
export function canonicalHttpOrigin(value: string): string | null {
|
export function canonicalHttpOrigin(value: string): string | null {
|
||||||
try {
|
try {
|
||||||
@@ -84,6 +103,7 @@ export function canonicalHttpOrigin(value: string): string | null {
|
|||||||
|
|
||||||
export function FleetPage({
|
export function FleetPage({
|
||||||
dataSource,
|
dataSource,
|
||||||
|
messagesDataSource,
|
||||||
instanceDataSource,
|
instanceDataSource,
|
||||||
initialData,
|
initialData,
|
||||||
refreshSignal = 0,
|
refreshSignal = 0,
|
||||||
@@ -114,6 +134,10 @@ export function FleetPage({
|
|||||||
const [deleteStatus, setDeleteStatus] =
|
const [deleteStatus, setDeleteStatus] =
|
||||||
useState<Readonly<{ id: string; kind: 'success' | 'error'; message: string }>>();
|
useState<Readonly<{ id: string; kind: 'success' | 'error'; message: string }>>();
|
||||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||||
|
const messagesOwner = useRef(0);
|
||||||
|
const [messageStates, setMessageStates] = useState<ReadonlyMap<string, FleetMessageState>>(
|
||||||
|
new Map(),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialData && refreshSignal === 0) {
|
if (initialData && refreshSignal === 0) {
|
||||||
@@ -141,6 +165,23 @@ export function FleetPage({
|
|||||||
};
|
};
|
||||||
}, [attempt, dataSource, initialData, refreshSignal]);
|
}, [attempt, dataSource, initialData, refreshSignal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const request = ++messagesOwner.current;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const instances = snapshot?.instances;
|
||||||
|
setMessageStates(new Map<string, FleetMessageState>());
|
||||||
|
if (!messagesDataSource || !instances) return () => controller.abort();
|
||||||
|
void loadFleetMessageSummaries(
|
||||||
|
messagesDataSource,
|
||||||
|
instances.map((instance) => instance.id),
|
||||||
|
controller.signal,
|
||||||
|
).then((entries) => {
|
||||||
|
if (request === messagesOwner.current && !controller.signal.aborted)
|
||||||
|
setMessageStates(entries);
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [messagesDataSource, snapshot]);
|
||||||
|
|
||||||
const model = useMemo(
|
const model = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildFleetTableViewModel(snapshot?.instances ?? [], snapshot?.statuses ?? new Map(), {
|
buildFleetTableViewModel(snapshot?.instances ?? [], snapshot?.statuses ?? new Map(), {
|
||||||
@@ -445,21 +486,47 @@ export function FleetPage({
|
|||||||
<dd>{temperature(row.status?.summary?.resources?.maxTemperatureCelsius)}</dd>
|
<dd>{temperature(row.status?.summary?.resources?.maxTemperatureCelsius)}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<div className="capability-tags" aria-label="能力">
|
{row.capabilities.length > 0 ? (
|
||||||
{row.capabilities.length > 0 ? (
|
<div className="capability-tags" aria-label="能力">
|
||||||
row.capabilities.map((item) => (
|
{row.capabilities.map((item) => (
|
||||||
<span key={item}>{capabilityLabel(item)}</span>
|
<span key={item}>{capabilityLabel(item)}</span>
|
||||||
))
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<section className="fleet-card-sms" aria-label="最近短信">
|
||||||
|
<h3>最近短信</h3>
|
||||||
|
{messageStates.get(row.id)?.unavailable ? (
|
||||||
|
<p className="fleet-card-sms-empty">短信暂不可用</p>
|
||||||
|
) : messageStates.get(row.id)?.latest ? (
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>方向</dt>
|
||||||
|
<dd>{messageDirection(messageStates.get(row.id)!.latest!.direction)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>号码</dt>
|
||||||
|
<dd>{messageStates.get(row.id)!.latest!.phoneNumber}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>内容</dt>
|
||||||
|
<dd>{messageExcerpt(messageStates.get(row.id)!.latest!.content)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>时间</dt>
|
||||||
|
<dd>{messageTime(messageStates.get(row.id)!.latest!.timestamp)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
) : (
|
) : (
|
||||||
<span>能力未知</span>
|
<p className="fleet-card-sms-empty">暂无短信</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</section>
|
||||||
<div className="fleet-card-actions">
|
<div className="fleet-card-actions">
|
||||||
<a
|
<a
|
||||||
href={`/instances/${encodeURIComponent(row.id)}/overview`}
|
className="primary-action"
|
||||||
aria-label={`管理 ${row.displayName}`}
|
href={`/instances/${encodeURIComponent(row.id)}/messages`}
|
||||||
|
aria-label={`查看 ${row.displayName} 的短信`}
|
||||||
>
|
>
|
||||||
管理
|
查看短信
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={`/settings/instances/${encodeURIComponent(row.id)}`}
|
href={`/settings/instances/${encodeURIComponent(row.id)}`}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// @vitest-environment jsdom
|
// @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 { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import type { InstanceContext, InstanceModule } from '../app-shell.js';
|
import type { InstanceContext, InstanceModule } from '../app-shell.js';
|
||||||
@@ -22,13 +22,17 @@ const owner: InstanceContext = {
|
|||||||
|
|
||||||
const capabilities: InstanceCapabilityMap = {
|
const capabilities: InstanceCapabilityMap = {
|
||||||
overview: { state: 'supported' },
|
overview: { state: 'supported' },
|
||||||
messages: { state: 'degraded', explanation: '消息历史记录为只读。' },
|
messages: { state: 'degraded', explanation: '短信历史记录为只读。' },
|
||||||
calls: { state: 'unsupported', explanation: '此调制解调器不支持语音功能。' },
|
calls: { state: 'unsupported', explanation: '此调制解调器不支持语音功能。' },
|
||||||
esim: { state: 'unknown', explanation: '能力探测结果未包含 eSIM。' },
|
esim: { state: 'unknown', explanation: '能力探测结果未包含 eSIM。' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function instanceNavigation() {
|
||||||
|
return within(screen.getByRole('navigation', { name: '实例模块' }));
|
||||||
|
}
|
||||||
|
|
||||||
describe('InstanceDetail', () => {
|
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(
|
render(
|
||||||
<InstanceDetail
|
<InstanceDetail
|
||||||
instanceId="owner"
|
instanceId="owner"
|
||||||
@@ -38,16 +42,71 @@ describe('InstanceDetail', () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByRole('link', { name: '概览' }).getAttribute('href')).toBe(
|
const navigation = instanceNavigation();
|
||||||
'/instances/owner/overview',
|
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(
|
expect(screen.getByRole('heading', { name: '短信' })).toBeTruthy();
|
||||||
'/instances/owner/messages',
|
expect(screen.queryByRole('heading', { name: '消息' })).toBeNull();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<InstanceDetail
|
||||||
|
instanceId="owner"
|
||||||
|
module="overview"
|
||||||
|
instance={owner}
|
||||||
|
capabilities={capabilities}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.queryByText('消息历史记录为只读。')).toBeNull();
|
expect(screen.getByRole('heading', { name: '设备信息' })).toBeTruthy();
|
||||||
expect(screen.getByText('此调制解调器不支持语音功能。')).toBeTruthy();
|
expect(screen.queryByRole('heading', { name: '概览' })).toBeNull();
|
||||||
expect(screen.getByText('能力探测结果未包含 eSIM。')).toBeTruthy();
|
});
|
||||||
expect(screen.getAllByText('能力状态未知。').length).toBeGreaterThan(0);
|
|
||||||
|
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', () => {
|
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);
|
expect(pending.get('owner')?.signal.aborted).toBe(true);
|
||||||
|
|
||||||
pending.get('second')?.resolve({ overview: { state: 'supported' } });
|
pending.get('second')?.resolve({ overview: { state: 'supported' } });
|
||||||
expect(await screen.findByRole('link', { name: '概览' })).toBeTruthy();
|
expect(await instanceNavigation().findByRole('link', { name: '设备信息' })).toBeTruthy();
|
||||||
pending
|
pending
|
||||||
.get('owner')
|
.get('owner')
|
||||||
?.resolve({ overview: { state: 'unsupported', explanation: 'Stale owner result' } });
|
?.resolve({ overview: { state: 'unsupported', explanation: 'Stale owner result' } });
|
||||||
@@ -111,17 +170,24 @@ describe('InstanceDetail', () => {
|
|||||||
it.each<[InstanceModule, string]>([
|
it.each<[InstanceModule, string]>([
|
||||||
['cellular', '蜂窝网络'],
|
['cellular', '蜂窝网络'],
|
||||||
['device-network', '设备网络'],
|
['device-network', '设备网络'],
|
||||||
|
['calls', '通话'],
|
||||||
|
['esim', 'eSIM'],
|
||||||
|
['notifications', '通知'],
|
||||||
['automation', '自动化'],
|
['automation', '自动化'],
|
||||||
['ota', 'OTA'],
|
['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(
|
render(
|
||||||
<InstanceDetail
|
<InstanceDetail
|
||||||
instanceId="owner"
|
instanceId="owner"
|
||||||
module={module}
|
module={module}
|
||||||
instance={owner}
|
instance={owner}
|
||||||
capabilities={{ [module]: { state: 'supported' } }}
|
capabilities={{ [module]: { state: 'supported' } }}
|
||||||
|
moduleContent={<p>旧深链接内容:{label}</p>}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByRole('heading', { name: label })).toBeTruthy();
|
expect(screen.getByRole('heading', { name: label })).toBeTruthy();
|
||||||
|
expect(screen.getByText(`旧深链接内容:${label}`)).toBeTruthy();
|
||||||
|
expect(instanceNavigation().queryByRole('link', { name: label })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ export interface InstanceDetailProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const INSTANCE_MODULE_LABELS: Readonly<Record<InstanceModule, string>> = {
|
export const INSTANCE_MODULE_LABELS: Readonly<Record<InstanceModule, string>> = {
|
||||||
overview: '概览',
|
overview: '设备信息',
|
||||||
cellular: '蜂窝网络',
|
cellular: '蜂窝网络',
|
||||||
'device-network': '设备网络',
|
'device-network': '设备网络',
|
||||||
messages: '消息',
|
messages: '短信',
|
||||||
calls: '通话',
|
calls: '通话',
|
||||||
esim: 'eSIM',
|
esim: 'eSIM',
|
||||||
notifications: '通知',
|
notifications: '通知',
|
||||||
@@ -39,7 +39,7 @@ export const INSTANCE_MODULE_LABELS: Readonly<Record<InstanceModule, string>> =
|
|||||||
ota: 'OTA',
|
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>> = {
|
const DEFAULT_EXPLANATIONS: Readonly<Record<Exclude<CapabilityState, 'supported'>, string>> = {
|
||||||
degraded: '此模块可用,但功能受限。',
|
degraded: '此模块可用,但功能受限。',
|
||||||
@@ -47,20 +47,18 @@ const DEFAULT_EXPLANATIONS: Readonly<Record<Exclude<CapabilityState, 'supported'
|
|||||||
unknown: '能力状态未知。',
|
unknown: '能力状态未知。',
|
||||||
};
|
};
|
||||||
|
|
||||||
function capabilityFor(map: InstanceCapabilityMap, module: InstanceModule): InstanceCapability {
|
function canRender(capability: InstanceCapability | undefined): boolean {
|
||||||
return map[module] ?? { state: 'unknown' };
|
if (!capability) return true;
|
||||||
}
|
|
||||||
|
|
||||||
function canRender(capability: InstanceCapability): boolean {
|
|
||||||
return capability.state === 'supported' || capability.state === 'degraded';
|
return capability.state === 'supported' || capability.state === 'degraded';
|
||||||
}
|
}
|
||||||
|
|
||||||
const CORE_MODULES = new Set<InstanceModule>(['overview', 'messages']);
|
const CORE_MODULES = new Set<InstanceModule>(['overview', 'messages']);
|
||||||
function canOpen(module: InstanceModule, capability: InstanceCapability): boolean {
|
function canOpen(module: InstanceModule, capability: InstanceCapability | undefined): boolean {
|
||||||
return CORE_MODULES.has(module) || canRender(capability);
|
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;
|
if (capability.state === 'supported') return null;
|
||||||
return capability.explanation?.trim() || DEFAULT_EXPLANATIONS[capability.state];
|
return capability.explanation?.trim() || DEFAULT_EXPLANATIONS[capability.state];
|
||||||
}
|
}
|
||||||
@@ -124,19 +122,21 @@ export function InstanceDetail({
|
|||||||
|
|
||||||
const map = capabilities ?? loadedCapabilities ?? {};
|
const map = capabilities ?? loadedCapabilities ?? {};
|
||||||
const origin = canonicalHttpOrigin(instance.origin);
|
const origin = canonicalHttpOrigin(instance.origin);
|
||||||
const activeCapability = capabilityFor(map, module);
|
const activeCapability = map[module];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="instance-detail">
|
<section className="instance-detail">
|
||||||
<aside className="instance-context" aria-label="当前实例">
|
<aside className="instance-context" aria-label="当前实例">
|
||||||
<strong>{instance.name}</strong>
|
<strong>{instance.name}</strong>
|
||||||
<code>{instance.id}</code>
|
<code>{instance.id}</code>
|
||||||
<dl>
|
{instance.status !== 'unknown' ? (
|
||||||
<div>
|
<dl>
|
||||||
<dt>状态</dt>
|
<div>
|
||||||
<dd>{displayStatus(instance.status)}</dd>
|
<dt>状态</dt>
|
||||||
</div>
|
<dd>{displayStatus(instance.status)}</dd>
|
||||||
</dl>
|
</div>
|
||||||
|
</dl>
|
||||||
|
) : null}
|
||||||
{origin ? (
|
{origin ? (
|
||||||
<a href={origin} target="_blank" rel="noopener noreferrer">
|
<a href={origin} target="_blank" rel="noopener noreferrer">
|
||||||
打开源站
|
打开源站
|
||||||
@@ -145,24 +145,15 @@ export function InstanceDetail({
|
|||||||
<a href={`/settings/instances/${encodeURIComponent(instanceId)}`}>编辑实例</a>
|
<a href={`/settings/instances/${encodeURIComponent(instanceId)}`}>编辑实例</a>
|
||||||
<nav aria-label="实例模块">
|
<nav aria-label="实例模块">
|
||||||
<ul>
|
<ul>
|
||||||
{MODULES.map((item) => {
|
{PRIMARY_MODULES.map((item) => {
|
||||||
const capability = capabilityFor(map, item);
|
|
||||||
const reason = explanation(capability);
|
|
||||||
return (
|
return (
|
||||||
<li key={item} data-capability-state={capability.state}>
|
<li key={item}>
|
||||||
{canOpen(item, capability) ? (
|
<a
|
||||||
<a
|
href={`/instances/${encodeURIComponent(instanceId)}/${item}`}
|
||||||
href={`/instances/${encodeURIComponent(instanceId)}/${item}`}
|
aria-current={module === item ? 'page' : undefined}
|
||||||
aria-current={module === item ? 'page' : undefined}
|
>
|
||||||
>
|
{INSTANCE_MODULE_LABELS[item]}
|
||||||
{INSTANCE_MODULE_LABELS[item]}
|
</a>
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span>{INSTANCE_MODULE_LABELS[item]}</span>
|
|
||||||
<small>{reason}</small>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -175,14 +166,14 @@ export function InstanceDetail({
|
|||||||
{loadError ? <p role="alert">能力不可用:{loadError}</p> : null}
|
{loadError ? <p role="alert">能力不可用:{loadError}</p> : null}
|
||||||
{!loading && canOpen(module, activeCapability) ? (
|
{!loading && canOpen(module, activeCapability) ? (
|
||||||
<>
|
<>
|
||||||
{activeCapability.state === 'degraded' ? (
|
{activeCapability?.state === 'degraded' ? (
|
||||||
<p data-capability-state="degraded">{explanation(activeCapability)}</p>
|
<p data-capability-state="degraded">{explanation(activeCapability)}</p>
|
||||||
) : null}
|
) : null}
|
||||||
{moduleContent ?? <p>查看{INSTANCE_MODULE_LABELS[module]}数据和可用操作。</p>}
|
{moduleContent ?? <p>查看{INSTANCE_MODULE_LABELS[module]}数据和可用操作。</p>}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{!loading && !canOpen(module, activeCapability) ? (
|
{!loading && !canOpen(module, activeCapability) ? (
|
||||||
<p data-capability-state={activeCapability.state}>{explanation(activeCapability)}</p>
|
<p data-capability-state={activeCapability?.state}>{explanation(activeCapability)}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// @vitest-environment jsdom
|
// @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 userEvent from '@testing-library/user-event';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ describe('MessagesModule', () => {
|
|||||||
expect(document.body.textContent).not.toContain('secret-pdu');
|
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 user = userEvent.setup();
|
||||||
const send = vi.fn().mockResolvedValue(undefined);
|
const send = vi.fn().mockResolvedValue(undefined);
|
||||||
const dataSource: MessagesDataSource = {
|
const dataSource: MessagesDataSource = {
|
||||||
@@ -51,6 +51,11 @@ describe('MessagesModule', () => {
|
|||||||
await user.type(screen.getByRole('textbox', { name: '手机号' }), '10086');
|
await user.type(screen.getByRole('textbox', { name: '手机号' }), '10086');
|
||||||
await user.type(screen.getByRole('textbox', { name: '短信内容' }), 'CXLL');
|
await user.type(screen.getByRole('textbox', { name: '短信内容' }), 'CXLL');
|
||||||
await user.click(screen.getByRole('button', { name: '发送短信' }));
|
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).toHaveBeenCalledTimes(1);
|
||||||
expect(send).toHaveBeenCalledWith('alpha', { phoneNumber: '10086', content: 'CXLL' });
|
expect(send).toHaveBeenCalledWith('alpha', { phoneNumber: '10086', content: 'CXLL' });
|
||||||
expect((await screen.findByRole('status')).textContent).toContain('短信已提交发送');
|
expect((await screen.findByRole('status')).textContent).toContain('短信已提交发送');
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
|
|||||||
const [retry, setRetry] = useState(0);
|
const [retry, setRetry] = useState(0);
|
||||||
const [phoneNumber, setPhoneNumber] = useState('');
|
const [phoneNumber, setPhoneNumber] = useState('');
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
|
const [pendingSend, setPendingSend] = useState<SendMessageInput>();
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [sendState, setSendState] = useState<'success' | 'error'>();
|
const [sendState, setSendState] = useState<'success' | 'error'>();
|
||||||
|
|
||||||
@@ -61,13 +62,14 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [dataSource, instance.id, refreshSignal, retry]);
|
}, [dataSource, instance.id, refreshSignal, retry]);
|
||||||
|
|
||||||
async function send(): Promise<void> {
|
async function send(input: SendMessageInput): Promise<void> {
|
||||||
if (!dataSource || sending) return;
|
if (!dataSource || sending) return;
|
||||||
setSending(true);
|
setSending(true);
|
||||||
setSendState(undefined);
|
setSendState(undefined);
|
||||||
try {
|
try {
|
||||||
await dataSource.send(instance.id, { phoneNumber, content });
|
await dataSource.send(instance.id, input);
|
||||||
setContent('');
|
setContent('');
|
||||||
|
setPendingSend(undefined);
|
||||||
setSendState('success');
|
setSendState('success');
|
||||||
setRetry((value) => value + 1);
|
setRetry((value) => value + 1);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -92,7 +94,7 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
|
|||||||
<form
|
<form
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void send();
|
setPendingSend({ phoneNumber: phoneNumber.trim(), content: content.trim() });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label>
|
<label>
|
||||||
@@ -122,6 +124,22 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
|
|||||||
</form>
|
</form>
|
||||||
{sendState === 'success' ? <p role="status">短信已提交发送。</p> : null}
|
{sendState === 'success' ? <p role="status">短信已提交发送。</p> : null}
|
||||||
{sendState === 'error' ? <p role="alert">短信发送失败,请核对设备状态后重试。</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>
|
||||||
|
|
||||||
<section className="messages-card" aria-labelledby="sms-list-title">
|
<section className="messages-card" aria-labelledby="sms-list-title">
|
||||||
|
|||||||
@@ -315,6 +315,42 @@ small {
|
|||||||
padding: 0.35rem 0;
|
padding: 0.35rem 0;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
.fleet-card-sms {
|
||||||
|
margin: 0.8rem 0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #d8e8d4;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #f5faf1;
|
||||||
|
}
|
||||||
|
.fleet-card-sms h3 {
|
||||||
|
margin: 0 0 0.55rem;
|
||||||
|
color: var(--brown);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.fleet-card-sms dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.35rem 0.75rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.fleet-card-sms dl div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.fleet-card-sms dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
.fleet-card-sms dd {
|
||||||
|
margin: 0.1rem 0 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.fleet-card-sms dl div:nth-child(3) {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.fleet-card-sms-empty {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
.capability-tags {
|
.capability-tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -586,6 +622,12 @@ dd {
|
|||||||
.instance-module-detail {
|
.instance-module-detail {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
.messages-module {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(16rem, 0.85fr) minmax(22rem, 1.4fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
.messages-module,
|
.messages-module,
|
||||||
.messages-card,
|
.messages-card,
|
||||||
.sms-list,
|
.sms-list,
|
||||||
@@ -594,12 +636,33 @@ dd {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
.messages-card h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
.messages-card form,
|
.messages-card form,
|
||||||
.messages-card label {
|
.messages-card label {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
.messages-card form {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.messages-card textarea {
|
||||||
|
min-height: 8rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.send-confirmation {
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
padding: 0.8rem;
|
||||||
|
border: 1px solid #dca66d;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #fff5dc;
|
||||||
|
}
|
||||||
|
.send-confirmation h3,
|
||||||
|
.send-confirmation p {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
.messages-card input,
|
.messages-card input,
|
||||||
.messages-card textarea {
|
.messages-card textarea {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -704,6 +767,9 @@ main ul[aria-label='已配置实例'] {
|
|||||||
.instance-context {
|
.instance-context {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
.messages-module {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
.instance-context ul {
|
.instance-context ul {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -797,6 +863,12 @@ main ul[aria-label='已配置实例'] {
|
|||||||
.fleet-card {
|
.fleet-card {
|
||||||
padding: 0.8rem;
|
padding: 0.8rem;
|
||||||
}
|
}
|
||||||
|
.fleet-card-sms dl {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.fleet-card-sms dl div:nth-child(3) {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
|
|||||||
Reference in New Issue
Block a user