fix(web): bound stalled mutations

This commit is contained in:
lingniu
2026-07-16 07:54:04 +08:00
parent be1ff4ff0d
commit 1a9b2ce962
5 changed files with 74 additions and 37 deletions

View File

@@ -2,6 +2,10 @@ import { afterEach, expect, test, vi } from 'vitest';
import { API_QUERY_TIMEOUT_MS, api } from './client';
import { getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, setAccessToken } from '../v2/auth/session';
function boundedRequest(init: Record<string, unknown> = {}) {
return { ...init, signal: expect.any(AbortSignal) };
}
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
@@ -77,11 +81,11 @@ test('a delayed 401 from an old token cannot terminate a newer login', async ()
test('durable alert APIs keep versioned actions, rules and notification reads explicit', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {}, traceId: 'trace-alert', timestamp: 1 }) } as Response);
await api.alertEventsV2({ status: 'unprocessed', limit: 20, offset: 0 });
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/alerts/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'unprocessed', limit: 20, offset: 0 }) });
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/alerts/events', boundedRequest({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'unprocessed', limit: 20, offset: 0 }) }));
await api.actOnAlertV2('alert 1', { version: 2, action: 'acknowledge', note: '已确认' });
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/alerts/events/alert%201/actions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version: 2, action: 'acknowledge', note: '已确认' }) });
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/alerts/events/alert%201/actions', boundedRequest({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version: 2, action: 'acknowledge', note: '已确认' }) }));
await api.readAlertNotificationsV2([7, 8]);
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/alerts/notifications/read', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: [7, 8] }) });
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/alerts/notifications/read', boundedRequest({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: [7, 8] }) }));
});
test('access APIs post one shared filter contract and version threshold updates', async () => {
@@ -91,35 +95,35 @@ test('access APIs post one shared filter contract and version threshold updates'
} as Response);
const query = { protocol: 'JT808', onlineState: 'offline', limit: 50, offset: 0 };
await api.accessVehicles(query);
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/access/vehicles', {
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/access/vehicles', boundedRequest({
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
});
}));
const unresolved = { protocol: 'JT808', limit: 20, offset: 0 };
await api.accessUnresolvedIdentities(unresolved);
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/access/unresolved-identities', {
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/access/unresolved-identities', boundedRequest({
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(unresolved)
});
}));
await api.updateAccessThresholds({ version: 3, defaultThresholdSec: 300, delayThresholdSec: 30, longOfflineSec: 1800, protocols: [{ protocol: 'JT808', thresholdSec: 60 }] });
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/access/thresholds', {
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/access/thresholds', boundedRequest({
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version: 3, defaultThresholdSec: 300, delayThresholdSec: 30, longOfflineSec: 1800, protocols: [{ protocol: 'JT808', thresholdSec: 60 }] })
});
}));
});
test('vehicle profile API uses encoded VIN and optimistic version updates', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {}, traceId: 'trace-profile', timestamp: 1 }) } as Response);
const input = { modelName: '氢燃料重卡', vehicleType: '重卡', companyName: '示范物流', operationStatus: 'active' as const, accessProvider: '车厂平台', firstAccessAt: '2026-07-01T08:30', runtimeSeconds: 3600, version: 2 };
await api.updateVehicleProfile('VIN 001', input);
expect(fetchMock).toHaveBeenCalledWith('/api/v2/vehicles/VIN%20001/profile', {
expect(fetchMock).toHaveBeenCalledWith('/api/v2/vehicles/VIN%20001/profile', boundedRequest({
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
});
}));
});
test('latest telemetry uses the encoded vehicle identity path', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: { categories: [], values: [] }, traceId: 'trace-telemetry', timestamp: 1 }) } as Response);
await api.latestTelemetry('粤A 001');
expect(fetchMock).toHaveBeenCalledWith('/api/v2/vehicles/%E7%B2%A4A%20001/telemetry/latest', undefined);
expect(fetchMock).toHaveBeenCalledWith('/api/v2/vehicles/%E7%B2%A4A%20001/telemetry/latest', boundedRequest());
});
test('vehicle profile sync posts an explicit dry-run and conflict policy contract', async () => {
@@ -129,9 +133,9 @@ test('vehicle profile sync posts an explicit dry-run and conflict policy contrac
items: [{ vin: 'VIN001', modelName: '车型一', vehicleType: '重卡', companyName: '示范物流', operationStatus: 'active' as const, accessProvider: '车厂平台', firstAccessAt: '', runtimeSeconds: null }]
};
await api.syncVehicleProfiles(input);
expect(fetchMock).toHaveBeenCalledWith('/api/v2/vehicle-profiles/sync', {
expect(fetchMock).toHaveBeenCalledWith('/api/v2/vehicle-profiles/sync', boundedRequest({
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
});
}));
});
test('trackPlayback preserves the bounded V2 query contract', async () => {
@@ -140,7 +144,7 @@ test('trackPlayback preserves the bounded V2 query contract', async () => {
json: async () => ({ data: { vin: 'VIN001', points: [], events: [], sources: [], summary: { pointCount: 0 } }, traceId: 'trace-test', timestamp: 1783094400000 })
} as Response);
await api.trackPlayback(new URLSearchParams({ keyword: '粤AG18312', maxPoints: '1200' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v2/tracks?keyword=%E7%B2%A4AG18312&maxPoints=1200', undefined);
expect(fetchMock).toHaveBeenCalledWith('/api/v2/tracks?keyword=%E7%B2%A4AG18312&maxPoints=1200', boundedRequest());
});
test('monitor viewport requests forward AbortSignal so obsolete pans can be cancelled', async () => {
@@ -221,6 +225,27 @@ test('turns a stalled route query into a bounded retryable timeout', async () =>
expect(controller.signal.aborted).toBe(false);
});
test('bounds a stalled mutation and aborts its transport without an upstream signal', async () => {
vi.useFakeTimers();
let transportSignal: AbortSignal | undefined;
vi.spyOn(globalThis, 'fetch').mockImplementation((_path, init) => {
transportSignal = init?.signal ?? undefined;
return new Promise<Response>((_resolve, reject) => {
transportSignal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true });
});
});
const timedOut = expect(api.actOnAlertV2('alert-1', { version: 1, action: 'acknowledge', note: '' })).rejects.toMatchObject({
name: 'ApiRequestTimeoutError',
message: expect.stringContaining('15 秒')
});
await vi.advanceTimersByTimeAsync(API_QUERY_TIMEOUT_MS);
await timedOut;
expect(transportSignal).toBeInstanceOf(AbortSignal);
expect(transportSignal?.aborted).toBe(true);
});
test('preserves navigation cancellation instead of reporting it as a timeout', async () => {
const controller = new AbortController();
let transportSignal: AbortSignal | undefined;
@@ -257,7 +282,7 @@ test('rawFramesQuery posts structured JSON instead of URL query strings', async
offset: 0
});
expect(fetchMock).toHaveBeenCalledWith('/api/history/raw-frames/query', {
expect(fetchMock).toHaveBeenCalledWith('/api/history/raw-frames/query', boundedRequest({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -268,7 +293,7 @@ test('rawFramesQuery posts structured JSON instead of URL query strings', async
limit: 20,
offset: 0
})
});
}));
});
test('vehicleResolve sends keyword to the identity resolution endpoint', async () => {
@@ -293,7 +318,7 @@ test('vehicleResolve sends keyword to the identity resolution endpoint', async (
const result = await api.vehicleResolve(new URLSearchParams({ keyword: '粤AG18312' }));
expect(fetchMock).toHaveBeenCalledWith('/api/vehicles/resolve?keyword=%E7%B2%A4AG18312', undefined);
expect(fetchMock).toHaveBeenCalledWith('/api/vehicles/resolve?keyword=%E7%B2%A4AG18312', boundedRequest());
expect(result.vin).toBe('LB9A32A24R0LS1426');
});
@@ -333,7 +358,7 @@ test('vehicleServiceOverview sends keyword to the lightweight overview endpoint'
const result = await api.vehicleServiceOverview(new URLSearchParams({ keyword: '粤AG18312' }));
expect(fetchMock).toHaveBeenCalledWith('/api/vehicle-service/overview?keyword=%E7%B2%A4AG18312', undefined);
expect(fetchMock).toHaveBeenCalledWith('/api/vehicle-service/overview?keyword=%E7%B2%A4AG18312', boundedRequest());
expect(result.coverageStatus).toBe('partial');
expect(result.serviceStatus?.status).toBe('degraded');
});
@@ -362,7 +387,7 @@ test('vehicleServiceOverviews posts keywords to the batch overview endpoint', as
offset: 0
});
expect(fetchMock).toHaveBeenCalledWith('/api/vehicle-service/overviews', {
expect(fetchMock).toHaveBeenCalledWith('/api/vehicle-service/overviews', boundedRequest({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -370,7 +395,7 @@ test('vehicleServiceOverviews posts keywords to the batch overview endpoint', as
limit: 200,
offset: 0
})
});
}));
expect(result.total).toBe(2);
});
@@ -399,7 +424,7 @@ test('vehicleServiceSummary reads the vehicle service summary endpoint', async (
const result = await api.vehicleServiceSummary();
expect(fetchMock).toHaveBeenCalledWith('/api/vehicle-service/summary', undefined);
expect(fetchMock).toHaveBeenCalledWith('/api/vehicle-service/summary', boundedRequest());
expect(result.totalVehicles).toBe(1033);
expect(result.multiSourceVehicles).toBe(181);
expect(result.serviceStatuses.find((item) => item.status === 'no_data')?.count).toBe(461);
@@ -426,7 +451,7 @@ test('reverseGeocode reads the server-side AMap reverse geocode endpoint', async
const result = await api.reverseGeocode(new URLSearchParams({ longitude: '113.2', latitude: '23.1' }));
expect(fetchMock).toHaveBeenCalledWith('/api/map/reverse-geocode?longitude=113.2&latitude=23.1', undefined);
expect(fetchMock).toHaveBeenCalledWith('/api/map/reverse-geocode?longitude=113.2&latitude=23.1', boundedRequest());
expect(result.formattedAddress).toBe('广东省广州市天河区测试路');
});
@@ -449,7 +474,7 @@ test('qualityNotificationPlan reads alert rules and priority issues from backend
const result = await api.qualityNotificationPlan(new URLSearchParams({ issueType: 'NO_SOURCE' }));
expect(fetchMock).toHaveBeenCalledWith('/api/quality/notification-plan?issueType=NO_SOURCE', undefined);
expect(fetchMock).toHaveBeenCalledWith('/api/quality/notification-plan?issueType=NO_SOURCE', boundedRequest());
expect(result.rules[0].level).toBe('P0');
expect(result.policies[0].escalationMinutes).toBe(30);
expect(result.policies[0].acceptanceCriteria).toContain('来源恢复');
@@ -483,7 +508,7 @@ test('alertEvents reads the vehicle alert event endpoint', async () => {
const result = await api.alertEvents(new URLSearchParams({ protocol: 'JT808', limit: '20' }));
expect(fetchMock).toHaveBeenCalledWith('/api/alert-events?protocol=JT808&limit=20', undefined);
expect(fetchMock).toHaveBeenCalledWith('/api/alert-events?protocol=JT808&limit=20', boundedRequest());
expect(result.items[0].issueType).toBe('LINK_GAP');
});
@@ -516,7 +541,7 @@ test('onlineVehicleStatuses reads paged online vehicle status rows', async () =>
const result = await api.onlineVehicleStatuses(new URLSearchParams({ keyword: '粤AG18312', limit: '10' }));
expect(fetchMock).toHaveBeenCalledWith('/api/statistics/online-vehicles?keyword=%E7%B2%A4AG18312&limit=10', undefined);
expect(fetchMock).toHaveBeenCalledWith('/api/statistics/online-vehicles?keyword=%E7%B2%A4AG18312&limit=10', boundedRequest());
expect(result.items[0].offlineDurationMinutes).toBe(18);
});
@@ -556,7 +581,7 @@ test('sourceReadiness reads ops source readiness plan', async () => {
const result = await api.sourceReadiness();
expect(fetchMock).toHaveBeenCalledWith('/api/ops/source-readiness', undefined);
expect(fetchMock).toHaveBeenCalledWith('/api/ops/source-readiness', boundedRequest());
expect(result.platformRelease).toBe('platform-source-test');
expect(result.sources[0].protocol).toBe('GB32960');
});

View File

@@ -89,12 +89,11 @@ export const API_QUERY_TIMEOUT_MS = 15_000;
function queryTimeout(init: RequestInit | undefined) {
const upstream = init?.signal;
if (!upstream) return undefined;
const controller = new AbortController();
let timedOut = false;
const abortFromUpstream = () => controller.abort(upstream.reason);
if (upstream.aborted) abortFromUpstream();
else upstream.addEventListener('abort', abortFromUpstream, { once: true });
const abortFromUpstream = () => controller.abort(upstream?.reason);
if (upstream?.aborted) abortFromUpstream();
else upstream?.addEventListener('abort', abortFromUpstream, { once: true });
const timer = window.setTimeout(() => {
timedOut = true;
controller.abort();
@@ -104,7 +103,7 @@ function queryTimeout(init: RequestInit | undefined) {
timedOut: () => timedOut,
cleanup: () => {
window.clearTimeout(timer);
upstream.removeEventListener('abort', abortFromUpstream);
upstream?.removeEventListener('abort', abortFromUpstream);
}
};
}

View File

@@ -41,6 +41,6 @@ test('renders date-range mileage summary, trend and daily detail list', async ()
expect((await screen.findAllByText('210.5 km')).length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('粤A12345')).toBeInTheDocument();
expect(screen.getAllByText('80.5').length).toBeGreaterThanOrEqual(1);
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateFrom=2026-07-01'), undefined));
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('keyword=%E7%B2%A4A12345'), undefined);
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateFrom=2026-07-01'), { signal: expect.any(AbortSignal) }));
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('keyword=%E7%B2%A4A12345'), { signal: expect.any(AbortSignal) });
});

View File

@@ -72,6 +72,18 @@ test('loads one full notification query on a direct notifications entry', async
expect(mocks.alertNotificationsV2.mock.calls[0][0].toString()).toBe('limit=100');
});
test('ends notification mutation feedback with a visible retryable error instead of a silent pending state', async () => {
mocks.alertNotificationsV2.mockResolvedValue({ items: [{ id: 7, eventId: 'event-7', title: '车辆告警', content: '测试告警', severity: 'major', read: false, createdAt: '2026-07-16T04:00:00Z' }], total: 1, limit: 100, offset: 0 });
mocks.readAlertNotificationsV2.mockRejectedValue(new Error('通知状态更新超时'));
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=notifications']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
fireEvent.click(await screen.findByRole('button', { name: '标为已读' }));
expect(await screen.findByText('通知状态更新超时')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '标为已读' })).toBeEnabled();
});
test('clears old alert rows and inspector evidence when the event scope changes', async () => {
const oldEvent = alertEvent('old-event', 'OLDVIN', '旧告警车牌');
const newEvent = alertEvent('new-event', 'NEWVIN', '新告警车牌');

View File

@@ -79,6 +79,7 @@ function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: Metri
useEffect(() => { if (!selectedID && rules[0]) { setSelectedID(rules[0].id); setDraft(ruleDraft(rules[0])); } }, [rules, selectedID]);
const save = useMutation({ mutationFn: api.saveAlertRuleV2, onSuccess: async (rule) => { setSelectedID(rule.id); setDraft(ruleDraft(rule)); await queryClient.invalidateQueries({ queryKey: ['alert-rules-v2'] }); } });
const toggle = useMutation({ mutationFn: (rule: AlertRule) => api.setAlertRuleEnabledV2(rule.id, { version: rule.version, enabled: !rule.enabled }), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['alert-rules-v2'] }); } });
const mutationError = save.error ?? toggle.error;
const operators = draft.valueType === 'boolean' ? BOOLEAN_OPERATORS : NUMERIC_OPERATORS;
const availableMetrics = metrics.filter((metric) => metric.alertable && metric.valueType === draft.valueType);
const catalogLabels = Object.fromEntries(metrics.map((metric) => [metric.key, metric.label]));
@@ -86,7 +87,7 @@ function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: Metri
return <div className="v2-alert-rules">
<section className="v2-alert-rule-list"><header><strong></strong><button onClick={() => { setSelectedID('__new__'); setDraft(emptyRule()); }}>+ </button></header>{rules.map((rule) => <button className={selectedID === rule.id ? 'is-selected' : ''} key={rule.id} onClick={() => { setSelectedID(rule.id); setDraft(ruleDraft(rule)); }}><i className={`is-${rule.severity}`} /><span><strong>{rule.name}</strong><small>{ruleCondition(rule, catalogLabels)} · v{rule.version}</small></span><em className={rule.enabled ? 'is-enabled' : ''}>{rule.enabled ? '已启用' : '已停用'}</em></button>)}</section>
<form className="v2-alert-rule-editor" onSubmit={(event) => { event.preventDefault(); save.mutate(draft); }}>
<header><div><strong>{draft.version ? '编辑规则' : '新建规则'}</strong><span></span></div>{draft.version ? <button type="button" onClick={() => { const current = rules.find((item) => item.id === draft.id); if (current) toggle.mutate(current); }}>{draft.enabled ? '停用规则' : '启用规则'}</button> : null}</header>
<header><div><strong>{draft.version ? '编辑规则' : '新建规则'}</strong><span></span></div>{draft.version ? <button type="button" disabled={toggle.isPending || save.isPending} onClick={() => { const current = rules.find((item) => item.id === draft.id); if (current) toggle.mutate(current); }}>{toggle.isPending ? '更新中' : draft.enabled ? '停用规则' : '启用规则'}</button> : null}</header>
<div className="v2-rule-form-grid">
<label><span></span><input required maxLength={80} value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} /></label>
<label><span></span><select value={draft.severity} onChange={(e) => setDraft({ ...draft, severity: e.target.value as AlertRuleInput['severity'] })}><option value="critical"></option><option value="major"></option><option value="minor"></option></select></label>
@@ -106,7 +107,7 @@ function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: Metri
<label className="is-wide"><span></span><input value={draft.scopeCompanies.join(',')} onChange={(e) => setList('scopeCompanies', e.target.value)} /></label>
<label className="is-wide"><span></span><textarea maxLength={500} value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} /></label>
</div>
<footer><div><b></b><span> / </span></div>{save.error ? <em>{save.error.message}</em> : null}<button className="v2-primary-button" disabled={save.isPending}>{save.isPending ? '保存中' : '保存规则'}</button></footer>
<footer><div><b></b><span> / </span></div>{mutationError ? <em>{mutationError.message}</em> : null}<button className="v2-primary-button" disabled={save.isPending || toggle.isPending}>{save.isPending ? '保存中' : '保存规则'}</button></footer>
</form>
</div>;
}
@@ -121,7 +122,7 @@ type NotificationsQuery = {
function NotificationsWorkspace({ editable, notifications }: { editable: boolean; notifications: NotificationsQuery }) {
const queryClient = useQueryClient();
const read = useMutation({ mutationFn: api.readAlertNotificationsV2, onSuccess: async () => { await Promise.all([queryClient.invalidateQueries({ queryKey: ['alert-notifications-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-summary-v2'] })]); } });
return <div className="v2-alert-notifications"><header><div><strong></strong><span></span></div>{editable ? <button disabled={!notifications.data?.items.some((item) => !item.read)} onClick={() => read.mutate(notifications.data?.items.filter((item) => !item.read).map((item) => item.id) ?? [])}></button> : <span className="v2-role-badge"></span>}</header>{notifications.isError ? <InlineError message={notifications.error?.message ?? '站内通知读取失败'} onRetry={() => notifications.refetch()} /> : null}<div>{notifications.data?.items.map((item) => <article className={item.read ? 'is-read' : ''} key={item.id}><i className={`is-${item.severity}`} /><div><strong>{item.title}</strong><p>{item.content}</p><span>{formatAlertTime(item.createdAt)} · {item.read ? '已读' : '未读'}</span></div>{editable && !item.read ? <button onClick={() => read.mutate([item.id])}></button> : null}</article>)}</div><footer><b></b><span>SMS / </span><span>Email / </span><span>WeCom / </span></footer></div>;
return <div className="v2-alert-notifications"><header><div><strong></strong><span></span></div>{editable ? <button disabled={read.isPending || !notifications.data?.items.some((item) => !item.read)} onClick={() => read.mutate(notifications.data?.items.filter((item) => !item.read).map((item) => item.id) ?? [])}>{read.isPending ? '正在更新' : '全部标为已读'}</button> : <span className="v2-role-badge"></span>}</header>{notifications.isError ? <InlineError message={notifications.error?.message ?? '站内通知读取失败'} onRetry={() => notifications.refetch()} /> : null}{read.isError ? <InlineError message={read.error instanceof Error ? read.error.message : '通知状态更新失败'} /> : null}<div>{notifications.data?.items.map((item) => <article className={item.read ? 'is-read' : ''} key={item.id}><i className={`is-${item.severity}`} /><div><strong>{item.title}</strong><p>{item.content}</p><span>{formatAlertTime(item.createdAt)} · {item.read ? '已读' : '未读'}</span></div>{editable && !item.read ? <button disabled={read.isPending} onClick={() => read.mutate([item.id])}>{read.isPending ? '更新中' : '标为已读'}</button> : null}</article>)}</div><footer><b></b><span>SMS / </span><span>Email / </span><span>WeCom / </span></footer></div>;
}
export default function AlertsPage() {