fix(web): bound stalled mutations
This commit is contained in:
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
});
|
||||
|
||||
@@ -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', '新告警车牌');
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user