fix: coordinate SMS completion and improve operations diagnostics
CSS quality / css-quality (push) Has been cancelled
CSS quality / css-quality (push) Has been cancelled
This commit is contained in:
@@ -25,8 +25,10 @@ export const adminInfrastructureMonitoringApi = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}>(withQuery('/admin/infrastructure-monitoring/alert-history', { from, to, page })),
|
||||
getInfrastructureMonitoringOverview: (range: InfrastructureMonitoringRange) =>
|
||||
request<InfrastructureMonitoringOverview>(withQuery('/admin/infrastructure-monitoring/overview', { range })),
|
||||
getInfrastructureMonitoringOverview: (range: InfrastructureMonitoringRange, signal?: AbortSignal) =>
|
||||
request<InfrastructureMonitoringOverview>(withQuery('/admin/infrastructure-monitoring/overview', { range }), {
|
||||
signal,
|
||||
}),
|
||||
getInfrastructureMonitoringNotificationSummary: (signal?: AbortSignal) =>
|
||||
request<{ count: number; criticalCount: number }>('/admin/infrastructure-monitoring/notification-summary', {
|
||||
signal,
|
||||
@@ -38,6 +40,11 @@ export const adminInfrastructureMonitoringApi = {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
clearInfrastructureAlert: (fingerprint: string, activeAt: string) =>
|
||||
request<{ cleared: boolean }>(`/admin/infrastructure-monitoring/alerts/${fingerprint}/clear`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ activeAt }),
|
||||
}),
|
||||
markInfrastructureAlertRead: (fingerprint: string, activeAt: string) =>
|
||||
request<{ fingerprint: string; activeAt: string; acknowledged: true; acknowledgedAt: string }>(
|
||||
`/admin/infrastructure-monitoring/alerts/${fingerprint}/read`,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AdminSystemMonitoringPage } from './AdminSystemMonitoringPage';
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getInfrastructureMonitoringOverview: vi.fn(),
|
||||
getInfrastructureAlertThresholds: vi.fn(),
|
||||
clearInfrastructureAlert: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/api/adminApi', () => ({ adminApi: api }));
|
||||
vi.mock('@/components/ui/Chart', () => ({ Chart: () => <div>趋势图</div> }));
|
||||
vi.mock('./AlertHistory', () => ({ AlertHistory: () => null }));
|
||||
const sample = {
|
||||
available: true,
|
||||
range: '24h',
|
||||
collectedAt: '2026-09-16T00:00:00Z',
|
||||
lastSampleAt: '2026-09-16T00:00:00Z',
|
||||
summary: {
|
||||
overallStatus: 'healthy',
|
||||
serviceTotal: 0,
|
||||
serviceHealthy: 0,
|
||||
warningAlerts: 0,
|
||||
criticalAlerts: 0,
|
||||
activeAlerts: 0,
|
||||
},
|
||||
metrics: { cpuUsagePercent: 12.3 },
|
||||
trends: {
|
||||
cpuUsagePercent: [],
|
||||
memoryUsagePercent: [],
|
||||
diskUsagePercent: [],
|
||||
networkReceiveBytesPerSecond: [],
|
||||
networkTransmitBytesPerSecond: [],
|
||||
},
|
||||
services: [],
|
||||
serviceMetrics: [],
|
||||
disks: [],
|
||||
alerts: [],
|
||||
};
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
api.getInfrastructureMonitoringOverview.mockResolvedValue(sample);
|
||||
api.getInfrastructureAlertThresholds.mockResolvedValue({ thresholds: {}, definitions: [], configVersion: 1 });
|
||||
});
|
||||
describe('monitoring snapshots', () => {
|
||||
it('retains the last successful data with a visible stale warning after refresh fails, then recovers', async () => {
|
||||
render(<AdminSystemMonitoringPage />);
|
||||
await screen.findAllByText('12.3%');
|
||||
api.getInfrastructureMonitoringOverview.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||
await screen.findByText('监控数据更新失败');
|
||||
expect(screen.getAllByText('12.3%').length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('上次成功数据');
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('并非实时状态');
|
||||
api.getInfrastructureMonitoringOverview.mockResolvedValueOnce({ ...sample, metrics: { cpuUsagePercent: 45.6 } });
|
||||
fireEvent(window, new Event('online'));
|
||||
await screen.findAllByText('45.6%');
|
||||
expect(screen.queryByText('监控数据更新失败')).not.toBeInTheDocument();
|
||||
});
|
||||
it('does not label a different time range with the previous range snapshot and aborts superseded requests', async () => {
|
||||
render(<AdminSystemMonitoringPage />);
|
||||
await screen.findAllByText('12.3%');
|
||||
api.getInfrastructureMonitoringOverview.mockRejectedValueOnce(new TypeError('offline'));
|
||||
fireEvent.click(screen.getByRole('button', { name: '近1小时' }));
|
||||
await screen.findByText('监控数据更新失败');
|
||||
expect(screen.queryByText('12.3%')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('尚无成功采样');
|
||||
api.getInfrastructureMonitoringOverview.mockImplementationOnce(
|
||||
(_range, signal) =>
|
||||
new Promise((_resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))),
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||
const calls = api.getInfrastructureMonitoringOverview.mock.calls;
|
||||
const signal = calls[calls.length - 1]?.[1] as AbortSignal;
|
||||
fireEvent.click(screen.getByRole('button', { name: '近24小时' }));
|
||||
await waitFor(() => expect(signal.aborted).toBe(true));
|
||||
await screen.findAllByText('12.3%');
|
||||
});
|
||||
});
|
||||
@@ -227,6 +227,7 @@ function severityTag(severity: InfrastructureAlert['severity']) {
|
||||
function makeAlertColumns(
|
||||
onMarkRead: (alert: InfrastructureAlert) => void,
|
||||
readingFingerprint: string,
|
||||
onClear: (alert: InfrastructureAlert) => void,
|
||||
): Array<TableColumn<InfrastructureAlert>> {
|
||||
return [
|
||||
{ key: 'severity', title: '级别', width: '82px', render: (record) => severityTag(record.severity) },
|
||||
@@ -254,25 +255,42 @@ function makeAlertColumns(
|
||||
render: (record) => `${record.currentValue || '—'} / ${record.threshold || '—'}`,
|
||||
},
|
||||
{ key: 'startedAt', title: '开始时间', width: '150px', render: (record) => formatTime(record.startedAt) },
|
||||
{
|
||||
key: 'status',
|
||||
title: '状态',
|
||||
width: '100px',
|
||||
render: (record) => (record.status === 'resolved' ? '已恢复待清除' : '仍在触发'),
|
||||
},
|
||||
{ key: 'duration', title: '持续时间', width: '120px', render: (record) => formatDuration(record.startedAt) },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '112px',
|
||||
render: (record) =>
|
||||
record.acknowledged ? (
|
||||
<Tag tone="neutral">已读</Tag>
|
||||
) : (
|
||||
width: '180px',
|
||||
render: (record) => (
|
||||
<div className="table-actions">
|
||||
{record.acknowledged ? (
|
||||
<Tag tone="neutral">已读</Tag>
|
||||
) : (
|
||||
<Button
|
||||
disabled={readingFingerprint === record.fingerprint}
|
||||
icon={<CheckCircle2 size={14} />}
|
||||
onClick={() => onMarkRead(record)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
{readingFingerprint === record.fingerprint ? '处理中' : '标记已读'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={readingFingerprint === record.fingerprint}
|
||||
icon={<CheckCircle2 size={14} />}
|
||||
onClick={() => onMarkRead(record)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onClear(record)}
|
||||
>
|
||||
{readingFingerprint === record.fingerprint ? '处理中' : '标记已读'}
|
||||
清除
|
||||
</Button>
|
||||
),
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -291,25 +309,43 @@ export function AdminSystemMonitoringPage() {
|
||||
const [readError, setReadError] = useState('');
|
||||
const requestSequence = useRef(0);
|
||||
const pendingRequests = useRef(0);
|
||||
const activeRequest = useRef<AbortController | null>(null);
|
||||
const snapshots = useRef<Partial<Record<InfrastructureMonitoringRange, InfrastructureMonitoringOverview>>>({});
|
||||
|
||||
const loadData = useCallback(
|
||||
async (supersede = false) => {
|
||||
if (!supersede && pendingRequests.current > 0) return;
|
||||
if (supersede) activeRequest.current?.abort();
|
||||
const controller = new AbortController();
|
||||
activeRequest.current = controller;
|
||||
const timer = window.setTimeout(() => controller.abort(), 20_000);
|
||||
pendingRequests.current += 1;
|
||||
const sequence = ++requestSequence.current;
|
||||
setLoading(true);
|
||||
setOverview(snapshots.current[range] ?? null);
|
||||
try {
|
||||
const result = await adminApi.getInfrastructureMonitoringOverview(range);
|
||||
const result = await adminApi.getInfrastructureMonitoringOverview(range, controller.signal);
|
||||
if (sequence !== requestSequence.current) return;
|
||||
setOverview(result);
|
||||
if (result.available) {
|
||||
snapshots.current[range] = result;
|
||||
setOverview(result);
|
||||
} else {
|
||||
setOverview(snapshots.current[range] ?? result);
|
||||
}
|
||||
setError(result.available ? '' : result.error || '监控数据当前不可用');
|
||||
} catch (reason) {
|
||||
if (sequence !== requestSequence.current) return;
|
||||
setOverview(null);
|
||||
setError(reason instanceof Error ? reason.message : '监控数据加载失败');
|
||||
setOverview(snapshots.current[range] ?? null);
|
||||
setError(
|
||||
reason instanceof Error && reason.name === 'AbortError'
|
||||
? '刷新超时,请检查网络后重试'
|
||||
: '监控刷新失败,请检查网络或采集服务',
|
||||
);
|
||||
} finally {
|
||||
if (sequence === requestSequence.current) setLoading(false);
|
||||
pendingRequests.current -= 1;
|
||||
window.clearTimeout(timer);
|
||||
if (activeRequest.current === controller) activeRequest.current = null;
|
||||
}
|
||||
},
|
||||
[range],
|
||||
@@ -372,6 +408,43 @@ export function AdminSystemMonitoringPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearAlert = useCallback(
|
||||
async (alert: InfrastructureAlert) => {
|
||||
setReadingFingerprint(alert.fingerprint);
|
||||
setReadError('');
|
||||
try {
|
||||
await adminApi.clearInfrastructureAlert(alert.fingerprint, alert.startedAt);
|
||||
const remove = (snapshot: InfrastructureMonitoringOverview) => {
|
||||
const alerts = snapshot.alerts.filter(
|
||||
(item) => item.fingerprint !== alert.fingerprint || item.startedAt !== alert.startedAt,
|
||||
);
|
||||
return {
|
||||
...snapshot,
|
||||
alerts,
|
||||
summary: {
|
||||
...snapshot.summary,
|
||||
activeAlerts: alerts.length,
|
||||
criticalAlerts: alerts.filter((item) => item.severity === 'critical').length,
|
||||
warningAlerts: alerts.filter((item) => item.severity === 'warning').length,
|
||||
},
|
||||
};
|
||||
};
|
||||
for (const range of Object.keys(snapshots.current) as InfrastructureMonitoringRange[]) {
|
||||
const snapshot = snapshots.current[range];
|
||||
if (snapshot) snapshots.current[range] = remove(snapshot);
|
||||
}
|
||||
setOverview((snapshot) => (snapshot ? remove(snapshot) : snapshot));
|
||||
window.dispatchEvent(new Event('cmpp-infrastructure-alert-count-refresh'));
|
||||
await loadData(true);
|
||||
} catch (reason) {
|
||||
setReadError(reason instanceof Error ? reason.message : '清除告警失败');
|
||||
} finally {
|
||||
setReadingFingerprint('');
|
||||
}
|
||||
},
|
||||
[loadData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData(true);
|
||||
void loadSettings();
|
||||
@@ -382,14 +455,17 @@ export function AdminSystemMonitoringPage() {
|
||||
if (document.visibilityState === 'visible') void loadData();
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
window.addEventListener('online', handleVisibility);
|
||||
return () => {
|
||||
requestSequence.current += 1;
|
||||
activeRequest.current?.abort();
|
||||
window.clearInterval(intervalId);
|
||||
document.removeEventListener('visibilitychange', handleVisibility);
|
||||
window.removeEventListener('online', handleVisibility);
|
||||
};
|
||||
}, [loadData, loadSettings]);
|
||||
|
||||
const status = STATUS_COPY[overview?.summary.overallStatus ?? 'unknown'];
|
||||
const status = STATUS_COPY[error ? 'unknown' : (overview?.summary.overallStatus ?? 'unknown')];
|
||||
const cpuOption = useMemo(
|
||||
() =>
|
||||
makeTrendOption({
|
||||
@@ -442,10 +518,16 @@ export function AdminSystemMonitoringPage() {
|
||||
const serviceTotal = overview?.summary.serviceTotal ?? 6;
|
||||
const alertColumns = useMemo(
|
||||
() =>
|
||||
makeAlertColumns((alert) => {
|
||||
void markAlertRead(alert);
|
||||
}, readingFingerprint),
|
||||
[markAlertRead, readingFingerprint],
|
||||
makeAlertColumns(
|
||||
(alert) => {
|
||||
void markAlertRead(alert);
|
||||
},
|
||||
readingFingerprint,
|
||||
(alert) => {
|
||||
void clearAlert(alert);
|
||||
},
|
||||
),
|
||||
[markAlertRead, readingFingerprint, clearAlert],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -487,8 +569,13 @@ export function AdminSystemMonitoringPage() {
|
||||
<div className="system-monitoring-unavailable" role="alert">
|
||||
<ShieldAlert size={20} />
|
||||
<div>
|
||||
<strong>监控数据不可用</strong>
|
||||
<span>{error}。页面不会展示历史缓存值。</span>
|
||||
<strong>监控数据更新失败</strong>
|
||||
<span>
|
||||
{error}。
|
||||
{overview?.available
|
||||
? `当前展示上次成功数据,采样时间 ${formatTime(overview.lastSampleAt ?? overview.collectedAt)},并非实时状态。`
|
||||
: '尚无成功采样,请稍后重试。'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useRef, useState } from 'react';
|
||||
import { Breadcrumb, Button, Input, Select, Textarea } from '@/components/ui';
|
||||
import { calculateSignature, createNonce, type SignatureInput } from './signature';
|
||||
import './HttpSignaturePage.css';
|
||||
import { SmsRequestDebugger } from './SmsRequestDebugger';
|
||||
|
||||
function emptyInput(): SignatureInput {
|
||||
return {
|
||||
@@ -52,7 +53,9 @@ export function HttpSignaturePage() {
|
||||
return (
|
||||
<section className="page-stack http-signature-page">
|
||||
<Breadcrumb items={['接口工具', 'HTTP签名计算']} />
|
||||
<p className="muted">仅在当前浏览器计算,不上传或保存密钥。生成结果不会发送请求。</p>
|
||||
<p className="muted">
|
||||
上方工具仅在浏览器计算签名,不上传或保存密钥;需要实际发送时,请使用下方短信接口发送调试。
|
||||
</p>
|
||||
<form
|
||||
className="surface http-signature-page__form"
|
||||
autoComplete="off"
|
||||
@@ -154,6 +157,7 @@ export function HttpSignaturePage() {
|
||||
</div>
|
||||
) : null}
|
||||
<p role="status">{notice}</p>
|
||||
<SmsRequestDebugger />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SmsRequestDebugger } from './SmsRequestDebugger';
|
||||
import { calculateSignature } from './signature';
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
function fill() {
|
||||
render(<SmsRequestDebugger />);
|
||||
for (const [label, value] of [
|
||||
['AccessKey', 'qa-key'],
|
||||
['发送 AccessSecret', 'qa-secret'],
|
||||
['手机号(mobile)', '13800138000'],
|
||||
['短信正文(content)', '【测试】长短信中文内容'.repeat(20)],
|
||||
]) {
|
||||
fireEvent.change(screen.getByLabelText(label), { target: { value } });
|
||||
}
|
||||
}
|
||||
describe('SMS request debugger', () => {
|
||||
it('signs the exact UTF-8 body and displays real HTTP results without transmitting the secret', async () => {
|
||||
const fetcher = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response('{"code":"OK"}', { status: 202, headers: { 'X-Request-ID': 'qa-trace' } }));
|
||||
vi.stubGlobal('fetch', fetcher);
|
||||
fill();
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送短信' }));
|
||||
await waitFor(() =>
|
||||
expect((screen.getByLabelText('返回 HTTP 报文') as HTMLTextAreaElement).value).toContain('HTTP 202'),
|
||||
);
|
||||
const [path, options] = fetcher.mock.calls[0];
|
||||
const headers = options.headers;
|
||||
expect(headers['X-Signature']).toBe(
|
||||
calculateSignature({
|
||||
method: 'POST',
|
||||
path,
|
||||
body: options.body,
|
||||
secret: 'qa-secret',
|
||||
timestamp: headers['X-Timestamp'],
|
||||
nonce: headers['X-Nonce'],
|
||||
}).signature,
|
||||
);
|
||||
expect(JSON.stringify(options)).not.toContain('qa-secret');
|
||||
expect(options.credentials).toBe('omit');
|
||||
expect((screen.getByLabelText('返回 HTTP 报文') as HTMLTextAreaElement).value).toContain('qa-trace');
|
||||
});
|
||||
|
||||
it('preserves the idempotency key after network uncertainty, never auto retries, and changes it when body changes', async () => {
|
||||
const fetcher = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
vi.stubGlobal('fetch', fetcher);
|
||||
fill();
|
||||
const key = (screen.getByLabelText('业务幂等键(Idempotency-Key)') as HTMLInputElement).value;
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送短信' }));
|
||||
await screen.findByRole('alert');
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('发送结果未知');
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByLabelText('业务幂等键(Idempotency-Key)')).toHaveValue(key);
|
||||
fireEvent.change(screen.getByLabelText('短信正文(content)'), { target: { value: '修改正文' } });
|
||||
expect(screen.getByLabelText('业务幂等键(Idempotency-Key)')).not.toHaveValue(key);
|
||||
});
|
||||
|
||||
it('blocks duplicate clicks while a response is outstanding', async () => {
|
||||
const fetcher = vi.fn().mockImplementation(() => new Promise(() => {}));
|
||||
vi.stubGlobal('fetch', fetcher);
|
||||
fill();
|
||||
const send = screen.getByRole('button', { name: '发送短信' });
|
||||
fireEvent.click(send);
|
||||
fireEvent.click(send);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Input, Textarea } from '@/components/ui';
|
||||
import { calculateSignature, createNonce } from './signature';
|
||||
|
||||
const path = '/api/openapi/v1/sms/messages';
|
||||
|
||||
export function SmsRequestDebugger() {
|
||||
const [fields, setFields] = useState({
|
||||
appKey: '',
|
||||
secret: '',
|
||||
mobile: '',
|
||||
content: '',
|
||||
clientMessageId: '',
|
||||
idempotencyKey: createNonce(),
|
||||
});
|
||||
const [sending, setSending] = useState(false);
|
||||
const [requestText, setRequestText] = useState('');
|
||||
const [responseText, setResponseText] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const controller = useRef<AbortController | null>(null);
|
||||
useEffect(() => () => controller.current?.abort(), []);
|
||||
|
||||
function change(key: keyof typeof fields, value: string) {
|
||||
setFields((current) => ({
|
||||
...current,
|
||||
[key]: value,
|
||||
...(key === 'idempotencyKey' ? {} : { idempotencyKey: createNonce() }),
|
||||
}));
|
||||
setError('');
|
||||
}
|
||||
|
||||
async function send() {
|
||||
if (controller.current) return;
|
||||
if (!fields.appKey.trim() || !fields.secret || !/^1\d{10}$/.test(fields.mobile) || !fields.content.trim()) {
|
||||
setError('请填写 AccessKey、AccessSecret、11位手机号和短信正文');
|
||||
return;
|
||||
}
|
||||
if (!/^[A-Za-z0-9._:-]{8,128}$/.test(fields.idempotencyKey) || fields.clientMessageId.length > 128) {
|
||||
setError('请检查业务幂等键(8~128位)及客户消息编号(最多128字符)');
|
||||
return;
|
||||
}
|
||||
const body = JSON.stringify({
|
||||
mobile: fields.mobile,
|
||||
content: fields.content,
|
||||
...(fields.clientMessageId ? { clientMessageId: fields.clientMessageId } : {}),
|
||||
});
|
||||
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||
const nonce = createNonce();
|
||||
const { signature } = calculateSignature({ method: 'POST', path, timestamp, nonce, secret: fields.secret, body });
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-App-Key': fields.appKey.trim(),
|
||||
'X-Timestamp': timestamp,
|
||||
'X-Nonce': nonce,
|
||||
'X-Signature': signature,
|
||||
'Idempotency-Key': fields.idempotencyKey,
|
||||
};
|
||||
setRequestText(
|
||||
`POST ${path}\n${Object.entries(headers)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\n')}\n\n${body}`,
|
||||
);
|
||||
setResponseText('');
|
||||
setError('');
|
||||
setSending(true);
|
||||
const active = new AbortController();
|
||||
controller.current = active;
|
||||
const timer = window.setTimeout(() => active.abort(), 20_000);
|
||||
let responseHead = '';
|
||||
try {
|
||||
const response = await fetch(path, {
|
||||
method: 'POST',
|
||||
credentials: 'omit',
|
||||
redirect: 'error',
|
||||
headers,
|
||||
body,
|
||||
signal: active.signal,
|
||||
});
|
||||
responseHead = `HTTP ${response.status} ${response.statusText}\n${Array.from(response.headers.entries())
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\n')}\n\n`;
|
||||
const responseBody = await response.text();
|
||||
setResponseText(responseHead + responseBody);
|
||||
if (!response.ok) setError(`接口返回 ${response.status},请查看响应报文`);
|
||||
} catch (reason) {
|
||||
if (responseHead) setResponseText(responseHead + '[响应正文未完整读取]');
|
||||
setError(
|
||||
`${reason instanceof Error && reason.name === 'AbortError' ? '请求超时或已取消' : '网络请求失败'},发送结果未知。请先查询消息结果;如需重试,保持相同业务幂等键。`,
|
||||
);
|
||||
} finally {
|
||||
window.clearTimeout(timer);
|
||||
controller.current = null;
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="surface http-signature-page__form" aria-label="短信接口发送调试">
|
||||
<h2>短信接口发送调试</h2>
|
||||
<p className="muted">
|
||||
发送到当前平台,会真实创建短信并按应用规则计费。密钥只用于本页计算签名,不保存。以下展示应用层报文,浏览器自动添加的头部不在其中。
|
||||
</p>
|
||||
<div className="http-signature-page__fields">
|
||||
<Input
|
||||
label="AccessKey"
|
||||
autoComplete="off"
|
||||
disabled={sending}
|
||||
value={fields.appKey}
|
||||
onChange={(event) => change('appKey', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="发送 AccessSecret"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
disabled={sending}
|
||||
value={fields.secret}
|
||||
onChange={(event) => change('secret', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="手机号(mobile)"
|
||||
disabled={sending}
|
||||
value={fields.mobile}
|
||||
onChange={(event) => change('mobile', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="客户消息编号(clientMessageId,可选)"
|
||||
disabled={sending}
|
||||
value={fields.clientMessageId}
|
||||
onChange={(event) => change('clientMessageId', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="业务幂等键(Idempotency-Key)"
|
||||
disabled={sending}
|
||||
value={fields.idempotencyKey}
|
||||
onChange={(event) => change('idempotencyKey', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
label="短信正文(content)"
|
||||
rows={5}
|
||||
disabled={sending}
|
||||
value={fields.content}
|
||||
onChange={(event) => change('content', event.target.value)}
|
||||
/>
|
||||
<Button disabled={sending} onClick={() => void send()}>
|
||||
{sending ? '发送中…' : '发送短信'}
|
||||
</Button>
|
||||
{error ? (
|
||||
<p className="form-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{requestText ? <Textarea label="发送 HTTP 报文" rows={12} readOnly value={requestText} /> : null}
|
||||
{responseText ? <Textarea label="返回 HTTP 报文" rows={12} readOnly value={responseText} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -16,19 +16,12 @@ export function formatReceiptAmount(moneyUnits: number) {
|
||||
return formatCents(Math.abs(moneyUnits));
|
||||
}
|
||||
|
||||
export function RechargeReceiptDialog({
|
||||
open,
|
||||
record,
|
||||
tenant,
|
||||
onClose,
|
||||
}: RechargeReceiptDialogProps) {
|
||||
export function RechargeReceiptDialog({ open, record, tenant, onClose }: RechargeReceiptDialogProps) {
|
||||
if (!record) return null;
|
||||
|
||||
const isCorrection = record.amountCents < 0;
|
||||
const balanceAfter = record.balanceAfterCents;
|
||||
const balanceBefore = balanceAfter === null || balanceAfter === undefined
|
||||
? null
|
||||
: balanceAfter - record.amountCents;
|
||||
const balanceBefore = balanceAfter === null || balanceAfter === undefined ? null : balanceAfter - record.amountCents;
|
||||
const enterpriseName = record.tenant?.name ?? tenant?.name ?? record.tenantId;
|
||||
const enterpriseCode = record.tenant?.code ?? tenant?.code ?? '-';
|
||||
|
||||
@@ -40,13 +33,11 @@ export function RechargeReceiptDialog({
|
||||
open={open}
|
||||
title="账户充值回执"
|
||||
>
|
||||
<article className={['recharge-receipt', isCorrection ? 'recharge-receipt--correction' : ''].filter(Boolean).join(' ')}>
|
||||
<article
|
||||
className={['recharge-receipt', isCorrection ? 'recharge-receipt--correction' : ''].filter(Boolean).join(' ')}
|
||||
>
|
||||
<header className="recharge-receipt__header">
|
||||
<img
|
||||
alt="聆界短信平台"
|
||||
className="recharge-receipt__logo"
|
||||
src="/logo/logo1.png"
|
||||
/>
|
||||
<img alt="聆界短信平台" className="recharge-receipt__logo" src="/logo/logo1.png" />
|
||||
<span className={isCorrection ? 'is-correction' : 'is-posted'}>
|
||||
<CheckCircle2 aria-hidden="true" size={16} />
|
||||
{isCorrection ? '已冲正' : '已入账'}
|
||||
@@ -104,9 +95,7 @@ export function RechargeReceiptDialog({
|
||||
<p>{record.remark || '无'}</p>
|
||||
</section>
|
||||
|
||||
<footer className="recharge-receipt__note">
|
||||
本回执由系统根据真实入账记录自动生成
|
||||
</footer>
|
||||
<footer className="recharge-receipt__note">本回执由系统根据真实入账记录自动生成</footer>
|
||||
</article>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user