fix: correct operational statistics and form interactions
This commit is contained in:
@@ -7,6 +7,24 @@ import type {
|
||||
} from '../types';
|
||||
|
||||
export const adminInfrastructureMonitoringApi = {
|
||||
getInfrastructureAlertHistory: (from?: string, to?: string, page = 1) =>
|
||||
request<{
|
||||
items: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
severity: string;
|
||||
service: string;
|
||||
instance: string;
|
||||
startedAt: string;
|
||||
firstObservedAt: string;
|
||||
lastObservedAt: string;
|
||||
}>;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}>(withQuery('/admin/infrastructure-monitoring/alert-history', { from, to, page })),
|
||||
getInfrastructureMonitoringOverview: (range: InfrastructureMonitoringRange) =>
|
||||
request<InfrastructureMonitoringOverview>(withQuery('/admin/infrastructure-monitoring/overview', { range })),
|
||||
getInfrastructureMonitoringNotificationSummary: (signal?: AbortSignal) =>
|
||||
|
||||
@@ -10,6 +10,23 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-analytics-page .signature-retirement-heatmap__heading {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-analytics-page .analytics-activity-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-analytics-page .analytics-activity-filters .ui-field {
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.admin-analytics-page .page-heading {
|
||||
align-items: stretch;
|
||||
|
||||
@@ -8,6 +8,47 @@ const { api } = vi.hoisted(() => ({
|
||||
vi.mock('@/api/adminApi', () => ({ adminApi: api }));
|
||||
|
||||
describe('independent analytics tabs', () => {
|
||||
it('combines separate activity search fields with AND and preserves them across tabs', async () => {
|
||||
api.getSignatureRetirementHeatmap.mockResolvedValue({
|
||||
items: [],
|
||||
dimensions: [
|
||||
{
|
||||
dimensionType: 'channel',
|
||||
signatureId: 'a',
|
||||
signatureName: '签名甲',
|
||||
tenantName: '企业甲',
|
||||
applicationName: '应用甲',
|
||||
channelName: '通道甲',
|
||||
channelId: 'c',
|
||||
carrier: 'mobile',
|
||||
approvedAt: '2026-08-01',
|
||||
},
|
||||
{
|
||||
dimensionType: 'channel',
|
||||
signatureId: 'b',
|
||||
signatureName: '签名乙',
|
||||
tenantName: '企业甲',
|
||||
applicationName: '应用乙',
|
||||
channelName: '通道乙',
|
||||
channelId: 'd',
|
||||
carrier: 'unicom',
|
||||
approvedAt: '2026-08-01',
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<AdminAnalyticsPage />);
|
||||
fireEvent.click(screen.getByRole('tab', { name: '通道签名活跃度' }));
|
||||
const panel = screen.getByRole('region', { name: '通道签名活跃度' });
|
||||
await within(panel).findByText('签名甲');
|
||||
fireEvent.change(within(panel).getByLabelText('企业'), { target: { value: '企业甲' } });
|
||||
fireEvent.change(within(panel).getByLabelText('企业应用'), { target: { value: '应用甲' } });
|
||||
await waitFor(() => expect(within(panel).queryByText('签名乙')).not.toBeInTheDocument());
|
||||
fireEvent.change(within(panel).getByLabelText('通道'), { target: { value: '通道乙' } });
|
||||
await waitFor(() => expect(within(panel).queryByText('签名甲')).not.toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('tab', { name: '企业签名活跃度' }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: '通道签名活跃度' }));
|
||||
expect(within(panel).getByLabelText('企业应用')).toHaveValue('应用甲');
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
api.getSignatureQuality.mockImplementation(async (query) => ({ ...query, total: 0, items: [] }));
|
||||
|
||||
@@ -382,8 +382,8 @@ function RetirementHeatmap({
|
||||
title: string;
|
||||
}) {
|
||||
const [pageState, setPageState] = useState({ key: '', page: 1 });
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const deferredKeyword = useDeferredValue(keyword.trim().toLocaleLowerCase('zh-CN'));
|
||||
const [filters, setFilters] = useState({ tenantName: '', applicationName: '', signatureName: '', channelName: '' });
|
||||
const deferredFilters = useDeferredValue(filters);
|
||||
const visible = items.filter((item) => item.dimensionType === dimensionType);
|
||||
const dates = previousDateKeys(date, 30);
|
||||
const cellMap = new Map(
|
||||
@@ -394,12 +394,14 @@ function RetirementHeatmap({
|
||||
);
|
||||
const rows = dimensions
|
||||
.filter((item) => item.dimensionType === dimensionType)
|
||||
.filter(
|
||||
(item) =>
|
||||
!deferredKeyword ||
|
||||
[item.channelName, item.tenantName, item.applicationName, item.signatureName].some((value) =>
|
||||
value?.toLocaleLowerCase('zh-CN').includes(deferredKeyword),
|
||||
),
|
||||
.filter((item) =>
|
||||
Object.entries(deferredFilters).every(
|
||||
([key, value]) =>
|
||||
!value.trim() ||
|
||||
(item[key as keyof typeof deferredFilters] ?? '')
|
||||
.toLocaleLowerCase('zh-CN')
|
||||
.includes(value.trim().toLocaleLowerCase('zh-CN')),
|
||||
),
|
||||
)
|
||||
.map((item) => ({
|
||||
key: `${item.signatureId}:${item.channelId ?? ''}:${item.carrier}`,
|
||||
@@ -419,7 +421,7 @@ function RetirementHeatmap({
|
||||
}))
|
||||
.sort((left, right) => right.total - left.total || left.signatureName.localeCompare(right.signatureName, 'zh-CN'));
|
||||
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
|
||||
const paginationKey = JSON.stringify([date, deferredKeyword, dimensionType, dimensions.length, pageSize]);
|
||||
const paginationKey = JSON.stringify([date, deferredFilters, dimensionType, dimensions.length, pageSize]);
|
||||
const page = pageState.key === paginationKey ? pageState.page : 1;
|
||||
const setPage = (value: number) => setPageState({ key: paginationKey, page: value });
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
@@ -432,13 +434,23 @@ function RetirementHeatmap({
|
||||
<h2>{title}</h2>
|
||||
<p className="muted">数字为真实受理业务短信数;零提交为灰色,非零按现有六档成功率色阶展示。</p>
|
||||
</div>
|
||||
<div className="signature-retirement-heatmap__actions">
|
||||
<Input
|
||||
aria-label={`${title}搜索通道、企业、企业应用或签名`}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder={dimensionType === 'channel' ? '搜索通道、企业、应用或签名' : '搜索企业、应用或签名'}
|
||||
value={keyword}
|
||||
/>
|
||||
<div className="analytics-activity-filters">
|
||||
{(
|
||||
[
|
||||
['tenantName', '企业'],
|
||||
['applicationName', '企业应用'],
|
||||
['signatureName', '签名'],
|
||||
...(dimensionType === 'channel' ? [['channelName', '通道']] : []),
|
||||
] as Array<[keyof typeof filters, string]>
|
||||
).map(([key, label]) => (
|
||||
<Input
|
||||
key={key}
|
||||
label={label}
|
||||
placeholder={`搜索${label}`}
|
||||
value={filters[key]}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, [key]: event.target.value }))}
|
||||
/>
|
||||
))}
|
||||
<Tag tone="info">T-1 至 T-30</Tag>
|
||||
</div>
|
||||
</div>
|
||||
@@ -500,7 +512,7 @@ function RetirementHeatmap({
|
||||
</>
|
||||
) : (
|
||||
<p className="empty-state">
|
||||
{deferredKeyword
|
||||
{Object.values(deferredFilters).some((value) => value.trim())
|
||||
? '没有匹配企业、企业应用或签名的热力图维度。'
|
||||
: '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}
|
||||
</p>
|
||||
|
||||
@@ -197,12 +197,15 @@ export function AdminSignatureRetirementPage() {
|
||||
{item.dailyGroupKey ? (
|
||||
<details>
|
||||
<summary>{item.detections?.length ?? 0} 项预警明细</summary>
|
||||
{item.content.split('\n').map((line, index) => (
|
||||
<p key={index}>{line}</p>
|
||||
))}
|
||||
{retirementDisplayContent(item.content, item.tenantName)
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line, index) => (
|
||||
<p key={index}>{line}</p>
|
||||
))}
|
||||
</details>
|
||||
) : (
|
||||
item.content
|
||||
retirementDisplayContent(item.content, item.tenantName)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -983,3 +986,17 @@ function differenceInDateKeys(from: string, to: string) {
|
||||
const toDate = new Date(`${to}T12:00:00+08:00`);
|
||||
return Math.round((toDate.getTime() - fromDate.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
function retirementDisplayContent(content: string, tenantName?: string | null) {
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const trimmed = line.trimStart();
|
||||
if (!tenantName || !trimmed.startsWith('请通知')) return line;
|
||||
const rest = trimmed.slice(3).trimStart();
|
||||
if (!rest.startsWith(tenantName)) return line;
|
||||
const afterName = rest.slice(tenantName.length).trimStart();
|
||||
return /^[::]/.test(afterName) ? afterName.slice(1).trimStart() : line;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AdminSmsApplicationFormPage } from './AdminSmsApplicationFormPage';
|
||||
|
||||
vi.mock('@/api/adminApi', () => ({
|
||||
adminApi: {
|
||||
listChannelGroups: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'group', name: '移动测试组', carrier: 'mobile', status: 'active' }]),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('application form feedback', () => {
|
||||
it('hides HTTP addresses with the protocol and retains input across toggles; invalid save uses a modal', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/enterprise/e/new']}>
|
||||
<Routes>
|
||||
<Route path="/enterprise/:enterpriseId/new" element={<AdminSmsApplicationFormPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '未开通' })).toBeInTheDocument());
|
||||
expect(screen.queryByLabelText(/^HTTP 回执地址/)).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '未开通' }));
|
||||
fireEvent.change(screen.getByLabelText(/^HTTP 回执地址/), { target: { value: 'https://example.test/receipt' } });
|
||||
expect(screen.getByLabelText(/^HTTP 上行地址/)).toBeInTheDocument();
|
||||
fireEvent.click(
|
||||
within(document.querySelector('.admin-app-protocol-section--http') as HTMLElement).getByRole('button', {
|
||||
name: '已开通',
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByLabelText(/^HTTP 回执地址/)).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '未开通' }));
|
||||
expect(screen.getByLabelText(/^HTTP 回执地址/)).toHaveValue('https://example.test/receipt');
|
||||
fireEvent.change(screen.getByLabelText(/^应用名称/), { target: { value: '测试应用' } });
|
||||
fireEvent.click(screen.getByLabelText('移动通道组'));
|
||||
fireEvent.click(screen.getByRole('option', { name: '移动测试组' }));
|
||||
fireEvent.change(screen.getByLabelText(/^应用扩展码/), { target: { value: 'invalid' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建应用' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '短信应用保存失败' });
|
||||
expect(within(dialog).getByRole('alert')).toHaveTextContent('应用扩展码只能填写数字');
|
||||
fireEvent.mouseDown(document.querySelector('.ui-modal__mask')!);
|
||||
expect(dialog).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Globe2, Info, RadioTower, RefreshCw } from 'lucide-react';
|
||||
import { adminApi, type ChannelGroup, type DictionaryItem, type EnterpriseApplication, type HttpApiConfig } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, CarrierTag, Input, Select, Tag } from '@/components/ui';
|
||||
import {
|
||||
adminApi,
|
||||
type ChannelGroup,
|
||||
type DictionaryItem,
|
||||
type EnterpriseApplication,
|
||||
type HttpApiConfig,
|
||||
} from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, CarrierTag, Input, Modal, Select, Tag } from '@/components/ui';
|
||||
import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
|
||||
import { createRandomHex } from '@/utils/randomId';
|
||||
|
||||
@@ -47,12 +53,27 @@ export function AdminSmsApplicationFormPage() {
|
||||
const [downstreamUplinkRetryEnabled, setDownstreamUplinkRetryEnabled] = useState(true);
|
||||
const [ipAddress, setIpAddress] = useState('');
|
||||
const [httpConfig, setHttpConfig] = useState<HttpApiConfig>({
|
||||
enabled: false, sendEnabled: true, messageQueryEnabled: true, receiptWebhookEnabled: true,
|
||||
uplinkWebhookEnabled: true, uplinkQueryEnabled: true, credentialSelfServiceEnabled: true,
|
||||
qpsLimit: 10, timestampToleranceSeconds: 300, maxCredentialCount: 2, uplinkRetentionDays: 90,
|
||||
maxQueryRangeDays: 31, maxPageSize: 100, receiptDeliveryMode: 'http', uplinkDeliveryMode: 'http',
|
||||
webhookRetryEnabled: true, webhookMaxAttempts: 7, webhookTimeoutSeconds: 10, requireHttps: true,
|
||||
allowClientManualRetry: true, allowClientTest: true,
|
||||
enabled: false,
|
||||
sendEnabled: true,
|
||||
messageQueryEnabled: true,
|
||||
receiptWebhookEnabled: true,
|
||||
uplinkWebhookEnabled: true,
|
||||
uplinkQueryEnabled: true,
|
||||
credentialSelfServiceEnabled: true,
|
||||
qpsLimit: 10,
|
||||
timestampToleranceSeconds: 300,
|
||||
maxCredentialCount: 2,
|
||||
uplinkRetentionDays: 90,
|
||||
maxQueryRangeDays: 31,
|
||||
maxPageSize: 100,
|
||||
receiptDeliveryMode: 'http',
|
||||
uplinkDeliveryMode: 'http',
|
||||
webhookRetryEnabled: true,
|
||||
webhookMaxAttempts: 7,
|
||||
webhookTimeoutSeconds: 10,
|
||||
requireHttps: true,
|
||||
allowClientManualRetry: true,
|
||||
allowClientTest: true,
|
||||
});
|
||||
const [httpIpAddress, setHttpIpAddress] = useState('');
|
||||
const [receiptWebhookUrl, setReceiptWebhookUrl] = useState('');
|
||||
@@ -62,6 +83,7 @@ export function AdminSmsApplicationFormPage() {
|
||||
const [unicomGroupId, setUnicomGroupId] = useState('');
|
||||
const [telecomGroupId, setTelecomGroupId] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [saveError, setSaveError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -92,7 +114,9 @@ export function AdminSmsApplicationFormPage() {
|
||||
}
|
||||
const [groupItems, application, routeRules] = await Promise.all([
|
||||
adminApi.listChannelGroups(),
|
||||
isEdit && appId ? adminApi.getEnterpriseApplication(appId) : Promise.resolve<EnterpriseApplication | null>(null),
|
||||
isEdit && appId
|
||||
? adminApi.getEnterpriseApplication(appId)
|
||||
: Promise.resolve<EnterpriseApplication | null>(null),
|
||||
isEdit ? adminApi.listChannelRouteRules() : Promise.resolve<DictionaryItem[]>([]),
|
||||
]);
|
||||
if (cancelled) {
|
||||
@@ -122,19 +146,20 @@ export function AdminSmsApplicationFormPage() {
|
||||
useEffect(() => {
|
||||
if (!appId) return;
|
||||
let cancelled = false;
|
||||
Promise.all([
|
||||
adminApi.getApplicationHttpApiConfig(appId),
|
||||
adminApi.listApplicationHttpWebhooks(appId),
|
||||
]).then(([result, webhooks]) => {
|
||||
if (cancelled) return;
|
||||
if (result.config) setHttpConfig(result.config);
|
||||
setHttpIpAddress(result.ipAllowlist.join('\n'));
|
||||
setReceiptWebhookUrl(webhooks.find((item) => item.eventType === 'receipt')?.url ?? '');
|
||||
setUplinkWebhookUrl(webhooks.find((item) => item.eventType === 'uplink')?.url ?? '');
|
||||
}).catch((failure: Error) => {
|
||||
if (!cancelled) setError(failure.message || 'HTTP接口配置加载失败');
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
Promise.all([adminApi.getApplicationHttpApiConfig(appId), adminApi.listApplicationHttpWebhooks(appId)])
|
||||
.then(([result, webhooks]) => {
|
||||
if (cancelled) return;
|
||||
if (result.config) setHttpConfig(result.config);
|
||||
setHttpIpAddress(result.ipAllowlist.join('\n'));
|
||||
setReceiptWebhookUrl(webhooks.find((item) => item.eventType === 'receipt')?.url ?? '');
|
||||
setUplinkWebhookUrl(webhooks.find((item) => item.eventType === 'uplink')?.url ?? '');
|
||||
})
|
||||
.catch((failure: Error) => {
|
||||
if (!cancelled) setError(failure.message || 'HTTP接口配置加载失败');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [appId]);
|
||||
|
||||
function goBack() {
|
||||
@@ -160,12 +185,9 @@ export function AdminSmsApplicationFormPage() {
|
||||
setDownstreamUplinkRetryEnabled(application.downstreamUplinkRetryEnabled !== false);
|
||||
setIpAddress(application.ipAllowlist?.map((item) => item.ipCidr).join('\n') ?? '');
|
||||
|
||||
const activeRules = routeRules.filter((rule) => (
|
||||
rule.applicationId === application.id
|
||||
&& rule.status !== 'deleted'
|
||||
&& !rule.province
|
||||
&& !rule.channelId
|
||||
));
|
||||
const activeRules = routeRules.filter(
|
||||
(rule) => rule.applicationId === application.id && rule.status !== 'deleted' && !rule.province && !rule.channelId,
|
||||
);
|
||||
setMobileGroupId(getRouteGroupId(activeRules, 'mobile'));
|
||||
setUnicomGroupId(getRouteGroupId(activeRules, 'unicom'));
|
||||
setTelecomGroupId(getRouteGroupId(activeRules, 'telecom'));
|
||||
@@ -173,7 +195,7 @@ export function AdminSmsApplicationFormPage() {
|
||||
|
||||
async function submit() {
|
||||
if (!enterpriseId) {
|
||||
setError('缺少企业 ID');
|
||||
setSaveError('缺少企业 ID');
|
||||
return;
|
||||
}
|
||||
const selectedGroups = [
|
||||
@@ -182,29 +204,29 @@ export function AdminSmsApplicationFormPage() {
|
||||
{ carrier: 'telecom' as Carrier, groupId: telecomGroupId },
|
||||
].filter((item) => item.groupId);
|
||||
if (selectedGroups.length === 0) {
|
||||
setError('请至少配置一个运营商通道组');
|
||||
setSaveError('请至少配置一个运营商通道组');
|
||||
return;
|
||||
}
|
||||
if (!isValidMoneyInput(customerUnitPrice)) {
|
||||
setError('客户单价必须是非负金额,且最多保留小数点后 4 位');
|
||||
setSaveError('客户单价必须是非负金额,且最多保留小数点后 4 位');
|
||||
return;
|
||||
}
|
||||
const normalizedExtension = applicationExtension.trim();
|
||||
const normalizedFillPrefix = accessNumberFillPrefix.trim();
|
||||
if (normalizedExtension && !/^\d+$/.test(normalizedExtension)) {
|
||||
setError('应用扩展码只能填写数字');
|
||||
setSaveError('应用扩展码只能填写数字');
|
||||
return;
|
||||
}
|
||||
if (accessNumberFillEnabled && !normalizedExtension) {
|
||||
setError('开启接入号填充时必须填写应用扩展码');
|
||||
setSaveError('开启接入号填充时必须填写应用扩展码');
|
||||
return;
|
||||
}
|
||||
if (accessNumberFillEnabled && !/^\d+$/.test(normalizedFillPrefix)) {
|
||||
setError('开启接入号填充时必须填写数字格式的填充前缀');
|
||||
setSaveError('开启接入号填充时必须填写数字格式的填充前缀');
|
||||
return;
|
||||
}
|
||||
if (`${accessNumberFillEnabled ? normalizedFillPrefix : ''}${normalizedExtension}`.length > 21) {
|
||||
setError('客户侧接入号不能超过 21 位');
|
||||
setSaveError('客户侧接入号不能超过 21 位');
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
@@ -228,11 +250,12 @@ export function AdminSmsApplicationFormPage() {
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
setSaveError('');
|
||||
try {
|
||||
const application = isEdit && appId
|
||||
? await adminApi.updateEnterpriseApplication(appId, payload)
|
||||
: await adminApi.createEnterpriseApplication({ tenantId: enterpriseId, ...payload });
|
||||
const application =
|
||||
isEdit && appId
|
||||
? await adminApi.updateEnterpriseApplication(appId, payload)
|
||||
: await adminApi.createEnterpriseApplication({ tenantId: enterpriseId, ...payload });
|
||||
await adminApi.replaceApplicationRouteRules(application.id, {
|
||||
routes: selectedGroups.map((item, index) => ({
|
||||
carrier: item.carrier,
|
||||
@@ -241,14 +264,17 @@ export function AdminSmsApplicationFormPage() {
|
||||
status: 'active',
|
||||
})),
|
||||
});
|
||||
await adminApi.updateApplicationHttpApiConfig(application.id, { ...httpConfig, ipAllowlist: parseIpAllowlist(httpIpAddress) });
|
||||
await adminApi.updateApplicationHttpApiConfig(application.id, {
|
||||
...httpConfig,
|
||||
ipAllowlist: parseIpAllowlist(httpIpAddress),
|
||||
});
|
||||
await Promise.all([
|
||||
adminApi.saveApplicationHttpWebhook(application.id, 'receipt', { url: receiptWebhookUrl.trim() }),
|
||||
adminApi.saveApplicationHttpWebhook(application.id, 'uplink', { url: uplinkWebhookUrl.trim() }),
|
||||
]);
|
||||
goBack();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '短信应用保存失败');
|
||||
setSaveError(failure instanceof Error ? failure.message : '短信应用保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -273,27 +299,65 @@ export function AdminSmsApplicationFormPage() {
|
||||
<Breadcrumb items={[isEdit ? '编辑短信应用' : '添加短信应用']} />
|
||||
<p>短信应用和三网通道组配置写入真实后台接口。</p>
|
||||
</div>
|
||||
<Button icon={<ArrowLeft size={16} />} onClick={goBack} variant="ghost">返回企业应用管理</Button>
|
||||
<Button icon={<ArrowLeft size={16} />} onClick={goBack} variant="ghost">
|
||||
返回企业应用管理
|
||||
</Button>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="surface admin-app-form-card">
|
||||
<section className="ui-detail-section">
|
||||
<div className="ui-detail-section__header"><h3>业务信息</h3><p>先填写应用基础信息,保存后将生成真实企业应用。</p></div>
|
||||
<div className="ui-detail-section__header">
|
||||
<h3>业务信息</h3>
|
||||
<p>先填写应用基础信息,保存后将生成真实企业应用。</p>
|
||||
</div>
|
||||
<div className="admin-app-form-grid">
|
||||
<Input label="应用名称" onChange={(event) => setAppName(event.target.value)} placeholder="请输入应用名称" required value={appName} />
|
||||
<Input label="应用场景" onChange={(event) => setScene(event.target.value)} placeholder="行业通知/营销推广/验证码" value={scene} />
|
||||
<Input label="日发送数量限制" onChange={(event) => setDailyLimit(event.target.value)} placeholder="100000" required value={dailyLimit} />
|
||||
<Input label="客户单价(元/条)" onChange={(event) => setCustomerUnitPrice(event.target.value)} placeholder="0.0300" required step="0.0001" type="number" value={customerUnitPrice} />
|
||||
<Input
|
||||
label="应用名称"
|
||||
onChange={(event) => setAppName(event.target.value)}
|
||||
placeholder="请输入应用名称"
|
||||
required
|
||||
value={appName}
|
||||
/>
|
||||
<Input
|
||||
label="应用场景"
|
||||
onChange={(event) => setScene(event.target.value)}
|
||||
placeholder="行业通知/营销推广/验证码"
|
||||
value={scene}
|
||||
/>
|
||||
<Input
|
||||
label="日发送数量限制"
|
||||
onChange={(event) => setDailyLimit(event.target.value)}
|
||||
placeholder="100000"
|
||||
required
|
||||
value={dailyLimit}
|
||||
/>
|
||||
<Input
|
||||
label="客户单价(元/条)"
|
||||
onChange={(event) => setCustomerUnitPrice(event.target.value)}
|
||||
placeholder="0.0300"
|
||||
required
|
||||
step="0.0001"
|
||||
type="number"
|
||||
value={customerUnitPrice}
|
||||
/>
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<span>发送队列</span>
|
||||
<div className="radio-row">
|
||||
<label>
|
||||
<input checked={queuePriority === 'priority'} onChange={() => setQueuePriority('priority')} type="radio" />
|
||||
<input
|
||||
checked={queuePriority === 'priority'}
|
||||
onChange={() => setQueuePriority('priority')}
|
||||
type="radio"
|
||||
/>
|
||||
优先队列(行业短信)
|
||||
</label>
|
||||
<label>
|
||||
<input checked={queuePriority === 'normal'} onChange={() => setQueuePriority('normal')} type="radio" />
|
||||
<input
|
||||
checked={queuePriority === 'normal'}
|
||||
onChange={() => setQueuePriority('normal')}
|
||||
type="radio"
|
||||
/>
|
||||
普通队列(会员营销)
|
||||
</label>
|
||||
</div>
|
||||
@@ -319,10 +383,19 @@ export function AdminSmsApplicationFormPage() {
|
||||
<section className="ui-detail-section admin-app-protocol-section admin-app-protocol-section--cmpp">
|
||||
<div className="ui-detail-section__header admin-app-protocol-header">
|
||||
<div className="admin-app-protocol-heading">
|
||||
<span className="admin-app-protocol-icon"><RadioTower size={19} /></span>
|
||||
<div><h3>CMPP 接入配置</h3><p>管理客户端长连接、账号、接入号与下游回执投递。</p></div>
|
||||
<span className="admin-app-protocol-icon">
|
||||
<RadioTower size={19} />
|
||||
</span>
|
||||
<div>
|
||||
<h3>CMPP 接入配置</h3>
|
||||
<p>管理客户端长连接、账号、接入号与下游回执投递。</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className={interfaceEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setInterfaceEnabled((current) => !current)} type="button">
|
||||
<button
|
||||
className={interfaceEnabled ? 'admin-switch is-on' : 'admin-switch'}
|
||||
onClick={() => setInterfaceEnabled((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<span />
|
||||
{interfaceEnabled ? '已开通' : '未开通'}
|
||||
</button>
|
||||
@@ -331,10 +404,30 @@ export function AdminSmsApplicationFormPage() {
|
||||
<div className="admin-app-form-grid admin-app-protocol-body">
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<span>CMPP 协议</span>
|
||||
<div className="radio-row"><label><input checked={interfaceType === 'cmpp20'} onChange={() => setInterfaceType('cmpp20')} type="radio" />CMPP2.0</label></div>
|
||||
<div className="radio-row">
|
||||
<label>
|
||||
<input
|
||||
checked={interfaceType === 'cmpp20'}
|
||||
onChange={() => setInterfaceType('cmpp20')}
|
||||
type="radio"
|
||||
/>
|
||||
CMPP2.0
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<Input label="CMPP 6位账号" onChange={(event) => setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} />
|
||||
<Input disabled hint="企业代码始终与 CMPP 6位账号一致;账号留空自动生成时,保存后自动生成相同企业代码。" label="企业代码" placeholder="跟随 CMPP 6位账号自动生成" value={cmppAccount} />
|
||||
<Input
|
||||
label="CMPP 6位账号"
|
||||
onChange={(event) => setCmppAccount(event.target.value)}
|
||||
placeholder="留空自动生成"
|
||||
value={cmppAccount}
|
||||
/>
|
||||
<Input
|
||||
disabled
|
||||
hint="企业代码始终与 CMPP 6位账号一致;账号留空自动生成时,保存后自动生成相同企业代码。"
|
||||
label="企业代码"
|
||||
placeholder="跟随 CMPP 6位账号自动生成"
|
||||
value={cmppAccount}
|
||||
/>
|
||||
<Input
|
||||
hint="真实扩展码会追加到上游通道基础接入号后,例如基础号 1069999999、扩展码 0001,最终发送号为 10699999990001。留空则继续使用通道基础号。"
|
||||
label="应用扩展码"
|
||||
@@ -344,93 +437,271 @@ export function AdminSmsApplicationFormPage() {
|
||||
/>
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<span>客户接入号填充</span>
|
||||
<button className={accessNumberFillEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setAccessNumberFillEnabled((current) => !current)} type="button"><span />{accessNumberFillEnabled ? '开启' : '关闭'}</button>
|
||||
<div className="admin-app-form-tip"><Info size={17} /><span>填充前缀只用于满足客户系统的接入号长度限制;平台校验客户 Src_Id 时去掉开头前缀,上游发送时只拼接真实应用扩展码。</span></div>
|
||||
<button
|
||||
className={accessNumberFillEnabled ? 'admin-switch is-on' : 'admin-switch'}
|
||||
onClick={() => setAccessNumberFillEnabled((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<span />
|
||||
{accessNumberFillEnabled ? '开启' : '关闭'}
|
||||
</button>
|
||||
<div className="admin-app-form-tip">
|
||||
<Info size={17} />
|
||||
<span>
|
||||
填充前缀只用于满足客户系统的接入号长度限制;平台校验客户 Src_Id
|
||||
时去掉开头前缀,上游发送时只拼接真实应用扩展码。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{accessNumberFillEnabled ? <Input hint="只能填写数字,且只允许作为客户 Src_Id 的开头前缀。" label="填充前缀" onChange={(event) => setAccessNumberFillPrefix(event.target.value)} placeholder="例如 00" required value={accessNumberFillPrefix} /> : null}
|
||||
<Input disabled hint="客户 CMPP SUBMIT 必须填写该完整值;填充前缀不会发送给上游。" label="客户侧接入号" placeholder="根据填充前缀和应用扩展码自动生成" value={clientSrcIdPreview} />
|
||||
{accessNumberFillEnabled ? (
|
||||
<Input
|
||||
hint="只能填写数字,且只允许作为客户 Src_Id 的开头前缀。"
|
||||
label="填充前缀"
|
||||
onChange={(event) => setAccessNumberFillPrefix(event.target.value)}
|
||||
placeholder="例如 00"
|
||||
required
|
||||
value={accessNumberFillPrefix}
|
||||
/>
|
||||
) : null}
|
||||
<Input
|
||||
disabled
|
||||
hint="客户 CMPP SUBMIT 必须填写该完整值;填充前缀不会发送给上游。"
|
||||
label="客户侧接入号"
|
||||
placeholder="根据填充前缀和应用扩展码自动生成"
|
||||
value={clientSrcIdPreview}
|
||||
/>
|
||||
<Input
|
||||
hint={isEdit ? '留空则不修改接口密码;填写 16 位字符后覆盖。' : '默认随机生成,可按需修改。'}
|
||||
label="CMPP 接口密码"
|
||||
onChange={(event) => setPasswordCipher(event.target.value)}
|
||||
placeholder="16 位接口密码"
|
||||
suffix={<button aria-label="随机生成接口密码" className="icon-button" onClick={() => setPasswordCipher(generateApplicationPassword())} type="button"><RefreshCw size={15} /></button>}
|
||||
suffix={
|
||||
<button
|
||||
aria-label="随机生成接口密码"
|
||||
className="icon-button"
|
||||
onClick={() => setPasswordCipher(generateApplicationPassword())}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw size={15} />
|
||||
</button>
|
||||
}
|
||||
value={passwordCipher}
|
||||
/>
|
||||
<Input label="客户最大连接数" onChange={(event) => setCmppMaxConnections(event.target.value)} placeholder="1" required value={cmppMaxConnections} />
|
||||
<Input label="CMPP IP 白名单" onChange={(event) => setIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可用逗号、空格或换行分隔" value={ipAddress} />
|
||||
<Input
|
||||
label="客户最大连接数"
|
||||
onChange={(event) => setCmppMaxConnections(event.target.value)}
|
||||
placeholder="1"
|
||||
required
|
||||
value={cmppMaxConnections}
|
||||
/>
|
||||
<Input
|
||||
label="CMPP IP 白名单"
|
||||
onChange={(event) => setIpAddress(event.target.value)}
|
||||
placeholder="多个 IP/CIDR 可用逗号、空格或换行分隔"
|
||||
value={ipAddress}
|
||||
/>
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<span>CMPP 下游投递策略</span>
|
||||
<div className="radio-row">
|
||||
<button className={downstreamReceiptRetryEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setDownstreamReceiptRetryEnabled((current) => !current)} type="button"><span />回执自动重试:{downstreamReceiptRetryEnabled ? '开启' : '关闭'}</button>
|
||||
<button className={downstreamUplinkRetryEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setDownstreamUplinkRetryEnabled((current) => !current)} type="button"><span />上行自动重试:{downstreamUplinkRetryEnabled ? '开启' : '关闭'}</button>
|
||||
<button
|
||||
className={downstreamReceiptRetryEnabled ? 'admin-switch is-on' : 'admin-switch'}
|
||||
onClick={() => setDownstreamReceiptRetryEnabled((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<span />
|
||||
回执自动重试:{downstreamReceiptRetryEnabled ? '开启' : '关闭'}
|
||||
</button>
|
||||
<button
|
||||
className={downstreamUplinkRetryEnabled ? 'admin-switch is-on' : 'admin-switch'}
|
||||
onClick={() => setDownstreamUplinkRetryEnabled((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<span />
|
||||
上行自动重试:{downstreamUplinkRetryEnabled ? '开启' : '关闭'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-app-form-tip">
|
||||
<Info size={17} />
|
||||
<span>
|
||||
首次投递始终保留;关闭后,已写出但未收到 CMPP_DELIVER_RESP
|
||||
的消息不会自动重发,仍可在下游投递记录中手工重投。
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-app-form-tip"><Info size={17} /><span>首次投递始终保留;关闭后,已写出但未收到 CMPP_DELIVER_RESP 的消息不会自动重发,仍可在下游投递记录中手工重投。</span></div>
|
||||
</div>
|
||||
</div>
|
||||
) : <div className="admin-app-protocol-empty">CMPP 接口未开通,账号、接入号和长连接参数已收起。</div>}
|
||||
) : (
|
||||
<div className="admin-app-protocol-empty">CMPP 接口未开通,账号、接入号和长连接参数已收起。</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="ui-detail-section admin-app-protocol-section admin-app-protocol-section--http">
|
||||
<div className="ui-detail-section__header admin-app-protocol-header">
|
||||
<div className="admin-app-protocol-heading">
|
||||
<span className="admin-app-protocol-icon"><Globe2 size={19} /></span>
|
||||
<div><h3>HTTP 接口配置</h3><p>管理接口能力、机器鉴权、查询限制与 Webhook 投递策略。</p></div>
|
||||
<span className="admin-app-protocol-icon">
|
||||
<Globe2 size={19} />
|
||||
</span>
|
||||
<div>
|
||||
<h3>HTTP 接口配置</h3>
|
||||
<p>管理接口能力、机器鉴权、查询限制与 Webhook 投递策略。</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className={httpConfig.enabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setHttpConfig((current) => current.enabled ? { ...current, enabled: false } : {
|
||||
...current,
|
||||
enabled: true,
|
||||
sendEnabled: true,
|
||||
messageQueryEnabled: true,
|
||||
receiptWebhookEnabled: true,
|
||||
uplinkWebhookEnabled: true,
|
||||
uplinkQueryEnabled: true,
|
||||
credentialSelfServiceEnabled: true,
|
||||
receiptDeliveryMode: 'http',
|
||||
uplinkDeliveryMode: 'http',
|
||||
})} type="button"><span />{httpConfig.enabled ? '已开通' : '未开通'}</button>
|
||||
<button
|
||||
className={httpConfig.enabled ? 'admin-switch is-on' : 'admin-switch'}
|
||||
onClick={() =>
|
||||
setHttpConfig((current) =>
|
||||
current.enabled
|
||||
? { ...current, enabled: false }
|
||||
: {
|
||||
...current,
|
||||
enabled: true,
|
||||
sendEnabled: true,
|
||||
messageQueryEnabled: true,
|
||||
receiptWebhookEnabled: true,
|
||||
uplinkWebhookEnabled: true,
|
||||
uplinkQueryEnabled: true,
|
||||
credentialSelfServiceEnabled: true,
|
||||
receiptDeliveryMode: 'http',
|
||||
uplinkDeliveryMode: 'http',
|
||||
},
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<span />
|
||||
{httpConfig.enabled ? '已开通' : '未开通'}
|
||||
</button>
|
||||
</div>
|
||||
{httpConfig.enabled ? (
|
||||
<div className="admin-app-form-grid admin-app-protocol-body">
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<span>HTTP 能力</span>
|
||||
<div className="radio-row">
|
||||
{httpCapabilityOptions.map(({ key, label }) => <label key={key}><input checked={Boolean(httpConfig[key])} onChange={() => setHttpConfig((current) => ({ ...current, [key]: !current[key] }))} type="checkbox" />{label}</label>)}
|
||||
{httpCapabilityOptions.map(({ key, label }) => (
|
||||
<label key={key}>
|
||||
<input
|
||||
checked={Boolean(httpConfig[key])}
|
||||
onChange={() => setHttpConfig((current) => ({ ...current, [key]: !current[key] }))}
|
||||
type="checkbox"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="admin-app-form-tip">
|
||||
<Info size={17} />
|
||||
<span>访问密钥由客户端“接口对接”页面按权限创建;HTTP 白名单与 CMPP 白名单完全独立。</span>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
label="HTTP IP 白名单"
|
||||
onChange={(event) => setHttpIpAddress(event.target.value)}
|
||||
placeholder="多个 IP/CIDR 可换行填写,留空表示不限制"
|
||||
value={httpIpAddress}
|
||||
/>
|
||||
<Input
|
||||
label="HTTP QPS"
|
||||
onChange={(event) =>
|
||||
setHttpConfig((current) => ({ ...current, qpsLimit: Number(event.target.value) || 1 }))
|
||||
}
|
||||
value={String(httpConfig.qpsLimit)}
|
||||
/>
|
||||
<Input
|
||||
label="签名时间容差(秒)"
|
||||
onChange={(event) =>
|
||||
setHttpConfig((current) => ({
|
||||
...current,
|
||||
timestampToleranceSeconds: Number(event.target.value) || 300,
|
||||
}))
|
||||
}
|
||||
value={String(httpConfig.timestampToleranceSeconds)}
|
||||
/>
|
||||
<Input
|
||||
label="最多有效凭据数"
|
||||
onChange={(event) =>
|
||||
setHttpConfig((current) => ({ ...current, maxCredentialCount: Number(event.target.value) || 2 }))
|
||||
}
|
||||
value={String(httpConfig.maxCredentialCount)}
|
||||
/>
|
||||
<Input
|
||||
label="Webhook 超时(秒)"
|
||||
onChange={(event) =>
|
||||
setHttpConfig((current) => ({ ...current, webhookTimeoutSeconds: Number(event.target.value) || 10 }))
|
||||
}
|
||||
value={String(httpConfig.webhookTimeoutSeconds)}
|
||||
/>
|
||||
<Input
|
||||
label="Webhook 最大尝试次数"
|
||||
onChange={(event) =>
|
||||
setHttpConfig((current) => ({ ...current, webhookMaxAttempts: Number(event.target.value) || 7 }))
|
||||
}
|
||||
value={String(httpConfig.webhookMaxAttempts)}
|
||||
/>
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<span>HTTP 安全与重试</span>
|
||||
<div className="radio-row">
|
||||
<label>
|
||||
<input
|
||||
checked={httpConfig.requireHttps}
|
||||
onChange={() => setHttpConfig((current) => ({ ...current, requireHttps: !current.requireHttps }))}
|
||||
type="checkbox"
|
||||
/>
|
||||
生产回调强制 HTTPS
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
checked={httpConfig.webhookRetryEnabled}
|
||||
onChange={() =>
|
||||
setHttpConfig((current) => ({ ...current, webhookRetryEnabled: !current.webhookRetryEnabled }))
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
Webhook 自动重试
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
checked={httpConfig.allowClientManualRetry}
|
||||
onChange={() =>
|
||||
setHttpConfig((current) => ({
|
||||
...current,
|
||||
allowClientManualRetry: !current.allowClientManualRetry,
|
||||
}))
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
允许客户端手工重投
|
||||
</label>
|
||||
</div>
|
||||
<div className="admin-app-form-tip"><Info size={17} /><span>访问密钥由客户端“接口对接”页面按权限创建;HTTP 白名单与 CMPP 白名单完全独立。</span></div>
|
||||
</div>
|
||||
<Input label="HTTP IP 白名单" onChange={(event) => setHttpIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可换行填写,留空表示不限制" value={httpIpAddress} />
|
||||
<Input label="HTTP QPS" onChange={(event) => setHttpConfig((current) => ({ ...current, qpsLimit: Number(event.target.value) || 1 }))} value={String(httpConfig.qpsLimit)} />
|
||||
<Input label="签名时间容差(秒)" onChange={(event) => setHttpConfig((current) => ({ ...current, timestampToleranceSeconds: Number(event.target.value) || 300 }))} value={String(httpConfig.timestampToleranceSeconds)} />
|
||||
<Input label="最多有效凭据数" onChange={(event) => setHttpConfig((current) => ({ ...current, maxCredentialCount: Number(event.target.value) || 2 }))} value={String(httpConfig.maxCredentialCount)} />
|
||||
<Input label="Webhook 超时(秒)" onChange={(event) => setHttpConfig((current) => ({ ...current, webhookTimeoutSeconds: Number(event.target.value) || 10 }))} value={String(httpConfig.webhookTimeoutSeconds)} />
|
||||
<Input label="Webhook 最大尝试次数" onChange={(event) => setHttpConfig((current) => ({ ...current, webhookMaxAttempts: Number(event.target.value) || 7 }))} value={String(httpConfig.webhookMaxAttempts)} />
|
||||
<div className="admin-app-form-row admin-app-form-row--wide"><span>HTTP 安全与重试</span><div className="radio-row">
|
||||
<label><input checked={httpConfig.requireHttps} onChange={() => setHttpConfig((current) => ({ ...current, requireHttps: !current.requireHttps }))} type="checkbox" />生产回调强制 HTTPS</label>
|
||||
<label><input checked={httpConfig.webhookRetryEnabled} onChange={() => setHttpConfig((current) => ({ ...current, webhookRetryEnabled: !current.webhookRetryEnabled }))} type="checkbox" />Webhook 自动重试</label>
|
||||
<label><input checked={httpConfig.allowClientManualRetry} onChange={() => setHttpConfig((current) => ({ ...current, allowClientManualRetry: !current.allowClientManualRetry }))} type="checkbox" />允许客户端手工重投</label>
|
||||
</div></div>
|
||||
</div>
|
||||
) : <div className="admin-app-protocol-empty">HTTP 接口未开通,接口能力和鉴权参数已收起。</div>}
|
||||
<div className="admin-app-form-grid admin-app-protocol-body">
|
||||
<Input
|
||||
hint="留空不推送;HTTP接口开通后按该地址推送状态回执。"
|
||||
label="HTTP 回执地址"
|
||||
onChange={(event) => setReceiptWebhookUrl(event.target.value)}
|
||||
placeholder="https://example.com/webhooks/sms/receipt"
|
||||
value={receiptWebhookUrl}
|
||||
/>
|
||||
<Input
|
||||
hint="留空不推送;HTTP接口开通后按该地址推送上行短信。"
|
||||
label="HTTP 上行地址"
|
||||
onChange={(event) => setUplinkWebhookUrl(event.target.value)}
|
||||
placeholder="https://example.com/webhooks/sms/uplink"
|
||||
value={uplinkWebhookUrl}
|
||||
/>
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<div className="admin-app-form-tip"><Info size={17} /><span>投递通道由接口开通状态自动决定:CMPP开通则走CMPP,HTTP开通且地址非空则走HTTP,两者都开通时双投;运营端无需另选投递方式。</span></div>
|
||||
) : (
|
||||
<div className="admin-app-protocol-empty">HTTP 接口未开通,接口能力和鉴权参数已收起。</div>
|
||||
)}
|
||||
{httpConfig.enabled ? (
|
||||
<div className="admin-app-form-grid admin-app-protocol-body">
|
||||
<Input
|
||||
hint="留空不推送;HTTP接口开通后按该地址推送状态回执。"
|
||||
label="HTTP 回执地址"
|
||||
onChange={(event) => setReceiptWebhookUrl(event.target.value)}
|
||||
placeholder="https://example.com/webhooks/sms/receipt"
|
||||
value={receiptWebhookUrl}
|
||||
/>
|
||||
<Input
|
||||
hint="留空不推送;HTTP接口开通后按该地址推送上行短信。"
|
||||
label="HTTP 上行地址"
|
||||
onChange={(event) => setUplinkWebhookUrl(event.target.value)}
|
||||
placeholder="https://example.com/webhooks/sms/uplink"
|
||||
value={uplinkWebhookUrl}
|
||||
/>
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<div className="admin-app-form-tip">
|
||||
<Info size={17} />
|
||||
<span>
|
||||
投递通道由接口开通状态自动决定:CMPP开通则走CMPP,HTTP开通且地址非空则走HTTP,两者都开通时双投;运营端无需另选投递方式。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="ui-detail-section">
|
||||
@@ -446,14 +717,23 @@ export function AdminSmsApplicationFormPage() {
|
||||
const available = groups.filter((group) => group.carrier === card.carrier);
|
||||
const meta = carrierMeta[card.carrier];
|
||||
return (
|
||||
<div className={['admin-app-route-card', card.groupId ? 'is-selected' : ''].filter(Boolean).join(' ')} key={card.carrier}>
|
||||
<div
|
||||
className={['admin-app-route-card', card.groupId ? 'is-selected' : ''].filter(Boolean).join(' ')}
|
||||
key={card.carrier}
|
||||
>
|
||||
<header>
|
||||
<span><RadioTower size={18} /></span>
|
||||
<span>
|
||||
<RadioTower size={18} />
|
||||
</span>
|
||||
<div>
|
||||
<strong><CarrierTag carrier={card.carrier} /> 通道组</strong>
|
||||
<strong>
|
||||
<CarrierTag carrier={card.carrier} /> 通道组
|
||||
</strong>
|
||||
<small>{meta.description}</small>
|
||||
</div>
|
||||
<Tag tone={card.groupId ? 'success' : available.length ? 'neutral' : 'warning'}>{card.groupId ? '已选择' : `${available.length} 个可选`}</Tag>
|
||||
<Tag tone={card.groupId ? 'success' : available.length ? 'neutral' : 'warning'}>
|
||||
{card.groupId ? '已选择' : `${available.length} 个可选`}
|
||||
</Tag>
|
||||
</header>
|
||||
<Select
|
||||
label={`${meta.label}通道组`}
|
||||
@@ -469,10 +749,27 @@ export function AdminSmsApplicationFormPage() {
|
||||
</section>
|
||||
|
||||
<div className="enterprise-form-footer">
|
||||
<Button disabled={!appName || selectedGroupCount === 0 || saving} onClick={() => { void submit(); }}>{saving ? '保存中...' : isEdit ? '保存应用' : '创建应用'}</Button>
|
||||
<Button onClick={goBack} variant="ghost">取消</Button>
|
||||
<Button
|
||||
disabled={!appName || selectedGroupCount === 0 || saving}
|
||||
onClick={() => {
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
{saving ? '保存中...' : isEdit ? '保存应用' : '创建应用'}
|
||||
</Button>
|
||||
<Button onClick={goBack} variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
open={Boolean(saveError)}
|
||||
title="短信应用保存失败"
|
||||
onClose={() => setSaveError('')}
|
||||
footer={<Button onClick={() => setSaveError('')}>关闭</Button>}
|
||||
>
|
||||
<p role="alert">{saveError}</p>
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,8 +86,8 @@ export function ChannelFormModal({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
closeOnBackdrop={modal.mode === 'edit'}
|
||||
closeOnEscape={modal.mode === 'edit'}
|
||||
closeOnBackdrop={false}
|
||||
closeOnEscape={false}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
@@ -212,31 +212,37 @@ export function MonitorAlerts() {
|
||||
const [data, setData] = useState<Page<Alert>>({ items: [], total: 0, page: 1, pageSize: 20 }),
|
||||
[page, setPage] = useState(1),
|
||||
[state, setState] = useState(''),
|
||||
[readStatus, setReadStatus] = useState(''),
|
||||
[error, setError] = useState('');
|
||||
const [detail, setDetail] = useState<Alert | null>(null),
|
||||
[busy, setBusy] = useState(false);
|
||||
const load = useCallback(
|
||||
() =>
|
||||
monitorApi
|
||||
.alerts(page, state)
|
||||
.then((r) => {
|
||||
setData(r);
|
||||
setError('');
|
||||
})
|
||||
.catch((e) => setError(e.message)),
|
||||
[page, state],
|
||||
);
|
||||
const requestSequence = useRef(0);
|
||||
const load = useCallback(async () => {
|
||||
const sequence = ++requestSequence.current;
|
||||
try {
|
||||
const result = await monitorApi.alerts(page, state, readStatus);
|
||||
if (sequence !== requestSequence.current) return;
|
||||
setData(result);
|
||||
setError('');
|
||||
} catch (failure) {
|
||||
if (sequence === requestSequence.current) setError(failure instanceof Error ? failure.message : '告警加载失败');
|
||||
}
|
||||
}, [page, state, readStatus]);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const timer = setInterval(() => {
|
||||
if (!document.hidden) void load();
|
||||
}, 30000);
|
||||
return () => clearInterval(timer);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
requestSequence.current += 1;
|
||||
};
|
||||
}, [load]);
|
||||
async function read(row: Alert) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await monitorApi.read(row.id);
|
||||
setDetail((current) => (current?.id === row.id ? { ...current, unread: false } : current));
|
||||
window.dispatchEvent(new Event('cmpp-monitor-alert-refresh'));
|
||||
await load();
|
||||
} catch (e) {
|
||||
@@ -312,6 +318,19 @@ export function MonitorAlerts() {
|
||||
];
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Select
|
||||
label="阅读状态"
|
||||
value={readStatus}
|
||||
options={[
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'unread', label: '未读' },
|
||||
{ value: 'read', label: '已读' },
|
||||
]}
|
||||
onChange={(event) => {
|
||||
setReadStatus(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label="告警状态"
|
||||
value={state}
|
||||
|
||||
@@ -158,8 +158,8 @@ export const monitorApi = {
|
||||
request<{ id: string; name: string }[]>(
|
||||
withQuery('/admin/sending-monitor/options', { kind, ...scope, keyword, page }),
|
||||
),
|
||||
alerts: (page: number, state: string, signal?: AbortSignal) =>
|
||||
request<Page<Alert>>(withQuery('/admin/sending-monitor/alerts', { page, state }), { signal }),
|
||||
alerts: (page: number, state: string, readStatus = '', signal?: AbortSignal) =>
|
||||
request<Page<Alert>>(withQuery('/admin/sending-monitor/alerts', { page, state, readStatus }), { signal }),
|
||||
read: (id: string) => request(`/admin/sending-monitor/alerts/${id}/read`, { method: 'POST' }),
|
||||
};
|
||||
|
||||
|
||||
@@ -18,12 +18,7 @@ type SendDetailModalProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function SendDetailModal({
|
||||
record,
|
||||
segmentAudits,
|
||||
segmentLoading,
|
||||
onClose,
|
||||
}: SendDetailModalProps) {
|
||||
export function SendDetailModal({ record, segmentAudits, segmentLoading, onClose }: SendDetailModalProps) {
|
||||
const routeRows = buildRouteRows(record, segmentAudits);
|
||||
const channelGroupNames = Array.from(new Set(routeRows.map((route) => route.channelGroup).filter(Boolean)));
|
||||
const orderedSegmentAudits = [...segmentAudits].sort((left, right) => {
|
||||
@@ -36,11 +31,20 @@ export function SendDetailModal({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={<Button onClick={onClose} variant="ghost">关闭</Button>}
|
||||
footer={
|
||||
<Button onClick={onClose} variant="ghost">
|
||||
关闭
|
||||
</Button>
|
||||
}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>发送详情</h2><p>{record.messageId}</p></div>}
|
||||
title={
|
||||
<div className="template-modal-title">
|
||||
<h2>发送详情</h2>
|
||||
<p>{record.messageId}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="admin-sms-send-detail">
|
||||
<div className="admin-sms-detail-overview">
|
||||
@@ -48,16 +52,54 @@ export function SendDetailModal({
|
||||
<span>最终状态</span>
|
||||
<Tag tone={statusToneMap[displayStatus] ?? 'info'}>{getRecordStatusLabel(record)}</Tag>
|
||||
</div>
|
||||
<div><span>提交状态</span><strong>{record.submitStatus ?? '-'}</strong></div>
|
||||
<div><span>回执状态</span><strong>{record.receiptStatus ?? '-'}</strong></div>
|
||||
<div><span>最终回执时间</span><strong>{getTime(record.deliveredAt)}</strong></div>
|
||||
<div><span>引流信息</span><Tag tone={record.hasDrainageContent === true ? 'warning' : 'neutral'}>{record.hasDrainageContent === true ? '含引流' : record.hasDrainageContent === false ? '不含引流' : '未检测'}</Tag></div>
|
||||
<div><span>提交时间</span><strong>{getTime(record.queuedAt)}</strong></div>
|
||||
<div><span>发送号码</span><strong>{record.phoneNumber || '-'}</strong></div>
|
||||
<div><span>号码归属</span><strong>{record.province ?? '-'} / {record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</strong></div>
|
||||
<div><span>通道组</span><strong>{channelGroupNames.join(' / ') || '-'}</strong></div>
|
||||
<div><span>收到的接入号</span><strong>{record.clientSrcId || '-'}</strong></div>
|
||||
<div><span>发送的接入号</span><strong>{sentAccessNumber || '-'}</strong></div>
|
||||
<div>
|
||||
<span>提交状态</span>
|
||||
<strong>{record.submitStatus ?? '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>回执状态</span>
|
||||
<strong>{record.receiptStatus ?? '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>最终回执时间</span>
|
||||
<strong>{getTime(record.deliveredAt)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>引流信息</span>
|
||||
<Tag tone={record.hasDrainageContent === true ? 'warning' : 'neutral'}>
|
||||
{record.hasDrainageContent === true
|
||||
? '含引流'
|
||||
: record.hasDrainageContent === false
|
||||
? '不含引流'
|
||||
: '未检测'}
|
||||
</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<span>提交时间</span>
|
||||
<strong>{getTime(record.queuedAt)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发送号码</span>
|
||||
<strong>{record.phoneNumber || '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>号码归属</span>
|
||||
<strong>
|
||||
{record.province ?? '-'} / {record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>通道组</span>
|
||||
<strong>{channelGroupNames.join(' / ') || '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>收到的接入号</span>
|
||||
<strong>{record.clientSrcId || '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发送的接入号</span>
|
||||
<strong>{sentAccessNumber || '-'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{receiptNotice ? (
|
||||
<div className="admin-sms-detail-notice" role="status">
|
||||
@@ -66,8 +108,12 @@ export function SendDetailModal({
|
||||
</div>
|
||||
) : null}
|
||||
<section>
|
||||
<h3><MessageSquare size={18} /> 短信内容</h3>
|
||||
<p className="admin-sms-detail-content"><DrainageContent record={record} /></p>
|
||||
<h3>
|
||||
<MessageSquare size={18} /> 短信内容
|
||||
</h3>
|
||||
<p className="admin-sms-detail-content">
|
||||
<DrainageContent record={record} />
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -78,12 +124,23 @@ export function SendDetailModal({
|
||||
<span>{index + 1}</span>
|
||||
<div>
|
||||
<strong>{route.channel}</strong>
|
||||
<p className="muted">通道组:{route.channelGroup ?? '-'}</p>
|
||||
<dl>
|
||||
<div><dt>发送时间</dt><dd>{getTime(route.sentAt)}</dd></div>
|
||||
<div><dt>回执时间</dt><dd>{getTime(route.receiptAt)}</dd></div>
|
||||
<div><dt>回执码</dt><dd>{route.receiptCode ?? '-'}</dd></div>
|
||||
<div><dt>提交状态</dt><dd>{route.submitStatus ?? '-'}</dd></div>
|
||||
<div>
|
||||
<dt>发送时间</dt>
|
||||
<dd>{getTime(route.sentAt)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>回执时间</dt>
|
||||
<dd>{getTime(route.receiptAt)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>回执码</dt>
|
||||
<dd>{route.receiptCode ?? '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>提交状态</dt>
|
||||
<dd>{route.submitStatus ?? '-'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</article>
|
||||
@@ -94,42 +151,104 @@ export function SendDetailModal({
|
||||
<section>
|
||||
<h3>状态信息</h3>
|
||||
<div className="admin-sms-detail-status-grid">
|
||||
<div><span>消息编号</span><strong>{record.messageId}</strong></div>
|
||||
<div><span>发送状态</span><strong>{getRecordStatusLabel(record)}</strong></div>
|
||||
<div><span>提交状态</span><strong>{record.submitStatus ?? '-'}</strong></div>
|
||||
<div><span>回执状态</span><strong>{record.receiptStatus ?? '-'}</strong></div>
|
||||
<div>
|
||||
<span>消息编号</span>
|
||||
<strong>{record.messageId}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发送状态</span>
|
||||
<strong>{getRecordStatusLabel(record)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>提交状态</span>
|
||||
<strong>{record.submitStatus ?? '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>回执状态</span>
|
||||
<strong>{record.receiptStatus ?? '-'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{['submit_failed', 'failed', 'rejected'].includes(displayStatus) ? (
|
||||
<div className="admin-sms-detail-failure" role="alert">
|
||||
<AlertTriangle size={20} />
|
||||
<div><span>失败原因</span><strong>{record.errorMessage ?? record.errorCode ?? '未返回明确失败原因'}</strong></div>
|
||||
<div>
|
||||
<span>失败原因</span>
|
||||
<strong>{record.errorMessage ?? record.errorCode ?? '未返回明确失败原因'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>分片补偿审计</h3>
|
||||
{segmentLoading ? <div className="ui-table__empty">加载中...</div> : segmentAudits.length === 0 ? (
|
||||
{segmentLoading ? (
|
||||
<div className="ui-table__empty">加载中...</div>
|
||||
) : segmentAudits.length === 0 ? (
|
||||
<div className="ui-table__empty">暂无分片审计</div>
|
||||
) : (
|
||||
<div className="admin-sms-segment-list">
|
||||
{orderedSegmentAudits.map((segment) => (
|
||||
<article className="admin-sms-segment-card" key={segment.id}>
|
||||
<header>
|
||||
<strong>分片 {segment.segmentIndex}/{segment.segmentTotal}</strong>
|
||||
<strong>
|
||||
分片 {segment.segmentIndex}/{segment.segmentTotal}
|
||||
</strong>
|
||||
<div>
|
||||
<Tag tone={segment.submitStatus === 'accepted' ? 'success' : segment.submitStatus === 'queued' ? 'info' : 'danger'}>{segment.submitStatus}</Tag>
|
||||
{segment.receiptStatus ? <Tag tone={segment.receiptStatus === 'delivered' ? 'success' : segment.receiptStatus === 'unknown' ? 'neutral' : 'danger'}>{segment.receiptStatus}</Tag> : null}
|
||||
<Tag
|
||||
tone={
|
||||
segment.submitStatus === 'accepted'
|
||||
? 'success'
|
||||
: segment.submitStatus === 'queued'
|
||||
? 'info'
|
||||
: 'danger'
|
||||
}
|
||||
>
|
||||
{segment.submitStatus}
|
||||
</Tag>
|
||||
{segment.receiptStatus ? (
|
||||
<Tag
|
||||
tone={
|
||||
segment.receiptStatus === 'delivered'
|
||||
? 'success'
|
||||
: segment.receiptStatus === 'unknown'
|
||||
? 'neutral'
|
||||
: 'danger'
|
||||
}
|
||||
>
|
||||
{segment.receiptStatus}
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
<dl>
|
||||
<div><dt>通道</dt><dd>{segment.channel?.name ?? segment.channelId ?? '-'}</dd></div>
|
||||
<div><dt>Sequence</dt><dd>{segment.sequenceId ?? '-'}</dd></div>
|
||||
<div><dt>提交 ID</dt><dd>{segment.submitId}</dd></div>
|
||||
<div><dt>网关 MsgId</dt><dd>{segment.gatewayMessageId ?? '-'}</dd></div>
|
||||
<div><dt>补偿方式</dt><dd>{segment.compensationType ?? '-'}</dd></div>
|
||||
<div><dt>审计时间</dt><dd>{getTime(segment.createdAt)}</dd></div>
|
||||
<div><dt>错误信息</dt><dd>{segment.errorMessage ?? segment.errorCode ?? '-'}</dd></div>
|
||||
<div>
|
||||
<dt>通道</dt>
|
||||
<dd>{segment.channel?.name ?? segment.channelId ?? '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Sequence</dt>
|
||||
<dd>{segment.sequenceId ?? '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>提交 ID</dt>
|
||||
<dd>{segment.submitId}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>网关 MsgId</dt>
|
||||
<dd>{segment.gatewayMessageId ?? '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>补偿方式</dt>
|
||||
<dd>{segment.compensationType ?? '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>审计时间</dt>
|
||||
<dd>{getTime(segment.createdAt)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>错误信息</dt>
|
||||
<dd>{segment.errorMessage ?? segment.errorCode ?? '-'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
))}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { Breadcrumb, Button, Input, Modal, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { Chart } from '@/components/ui/Chart';
|
||||
import './AdminSystemMonitoringPage.css';
|
||||
import { AlertHistory } from './AlertHistory';
|
||||
|
||||
const RANGE_OPTIONS: Array<{ value: InfrastructureMonitoringRange; label: string }> = [
|
||||
{ value: '1h', label: '近1小时' },
|
||||
@@ -62,23 +63,52 @@ function formatRate(value: number | null) {
|
||||
}
|
||||
|
||||
export function DiskMetricCards({ disks }: { disks: InfrastructureMonitoringOverview['disks'] }) {
|
||||
if (!disks.length) return <article className="surface system-monitoring-metric"><HardDrive size={19} /><div><span>磁盘</span><strong>暂无数据</strong></div></article>;
|
||||
return <>{disks.map((disk) => {
|
||||
const aliases = (disk.mountpoints ?? [disk.mountpoint]).filter((path) => path !== disk.mountpoint);
|
||||
return <article className="surface system-monitoring-metric" key={disk.id}>
|
||||
<div className="system-monitoring-metric__icon is-amber"><HardDrive size={19} /></div>
|
||||
<div>
|
||||
<span>{disk.mountpoint === '/' ? '系统盘' : '磁盘'} {disk.mountpoint}</span>
|
||||
<strong>{formatPercent(disk.usagePercent)}</strong>
|
||||
<small title={`${disk.device} · ${disk.filesystem} · ${disk.instance}`}>{disk.device} · {disk.filesystem}</small>
|
||||
<small>{formatBytes(disk.availableBytes)} 可用 / {formatBytes(disk.totalBytes)}</small>
|
||||
{aliases.length > 0 ? <details className="system-monitoring-metric__mounts">
|
||||
<summary>其他挂载点({aliases.length})</summary>
|
||||
<ul>{aliases.map((path) => <li key={path}>{path}</li>)}</ul>
|
||||
</details> : null}
|
||||
</div>
|
||||
</article>;
|
||||
})}</>;
|
||||
if (!disks.length)
|
||||
return (
|
||||
<article className="surface system-monitoring-metric">
|
||||
<HardDrive size={19} />
|
||||
<div>
|
||||
<span>磁盘</span>
|
||||
<strong>暂无数据</strong>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{disks.map((disk) => {
|
||||
const aliases = (disk.mountpoints ?? [disk.mountpoint]).filter((path) => path !== disk.mountpoint);
|
||||
return (
|
||||
<article className="surface system-monitoring-metric" key={disk.id}>
|
||||
<div className="system-monitoring-metric__icon is-amber">
|
||||
<HardDrive size={19} />
|
||||
</div>
|
||||
<div>
|
||||
<span>
|
||||
{disk.mountpoint === '/' ? '系统盘' : '磁盘'} {disk.mountpoint}
|
||||
</span>
|
||||
<strong>{formatPercent(disk.usagePercent)}</strong>
|
||||
<small title={`${disk.device} · ${disk.filesystem} · ${disk.instance}`}>
|
||||
{disk.device} · {disk.filesystem}
|
||||
</small>
|
||||
<small>
|
||||
{formatBytes(disk.availableBytes)} 可用 / {formatBytes(disk.totalBytes)}
|
||||
</small>
|
||||
{aliases.length > 0 ? (
|
||||
<details className="system-monitoring-metric__mounts">
|
||||
<summary>其他挂载点({aliases.length})</summary>
|
||||
<ul>
|
||||
{aliases.map((path) => (
|
||||
<li key={path}>{path}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function totalNetworkRate(receive: number | null | undefined, transmit: number | null | undefined) {
|
||||
@@ -105,7 +135,12 @@ function formatServiceMetric(value: number | null, unit: 'percent' | 'seconds' |
|
||||
function formatTime(value: string | null) {
|
||||
if (!value) return '暂无采样';
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
@@ -119,10 +154,14 @@ function formatDuration(startedAt: string) {
|
||||
}
|
||||
|
||||
function timeLabels(points: InfrastructureMetricPoint[], range: InfrastructureMonitoringRange) {
|
||||
return points.map((point) => new Intl.DateTimeFormat('zh-CN', range === '7d'
|
||||
? { month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false }
|
||||
: { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
.format(new Date(point.timestamp)));
|
||||
return points.map((point) =>
|
||||
new Intl.DateTimeFormat(
|
||||
'zh-CN',
|
||||
range === '7d'
|
||||
? { month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false }
|
||||
: { hour: '2-digit', minute: '2-digit', hour12: false },
|
||||
).format(new Date(point.timestamp)),
|
||||
);
|
||||
}
|
||||
|
||||
function makeTrendOption(params: {
|
||||
@@ -131,12 +170,17 @@ function makeTrendOption(params: {
|
||||
suffix: string;
|
||||
maximum?: number;
|
||||
}): EChartsOption {
|
||||
const timestamps = [...new Set(params.series.flatMap((series) => series.points.map((point) => point.timestamp)))].sort();
|
||||
const timestamps = [
|
||||
...new Set(params.series.flatMap((series) => series.points.map((point) => point.timestamp))),
|
||||
].sort();
|
||||
return {
|
||||
animationDuration: 280,
|
||||
color: params.series.map((item) => item.color),
|
||||
grid: { left: 8, right: 16, top: 34, bottom: 4, containLabel: true },
|
||||
legend: params.series.length > 1 ? { type: 'scroll', top: 0, left: 0, right: 0, textStyle: { color: '#6b7280', fontSize: 12 } } : undefined,
|
||||
legend:
|
||||
params.series.length > 1
|
||||
? { type: 'scroll', top: 0, left: 0, right: 0, textStyle: { color: '#6b7280', fontSize: 12 } }
|
||||
: undefined,
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
valueFormatter: (value) => `${Number(value).toFixed(1)}${params.suffix}`,
|
||||
@@ -144,26 +188,31 @@ function makeTrendOption(params: {
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: timeLabels(timestamps.map((timestamp) => ({ timestamp, value: 0 })), params.range),
|
||||
data: timeLabels(
|
||||
timestamps.map((timestamp) => ({ timestamp, value: 0 })),
|
||||
params.range,
|
||||
),
|
||||
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: '#9ca3af', hideOverlap: true, margin: 12 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value', min: 0, max: params.maximum,
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: params.maximum,
|
||||
axisLabel: { color: '#9ca3af', formatter: `{value}${params.suffix}` },
|
||||
splitLine: { lineStyle: { color: '#eef0f3' } },
|
||||
},
|
||||
series: params.series.map((item) => {
|
||||
const values = new Map(item.points.map((point) => [point.timestamp, point.value]));
|
||||
return {
|
||||
name: item.name,
|
||||
data: timestamps.map((timestamp) => values.get(timestamp) ?? null),
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 2.5 },
|
||||
areaStyle: { opacity: 0.07 },
|
||||
name: item.name,
|
||||
data: timestamps.map((timestamp) => values.get(timestamp) ?? null),
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 2.5 },
|
||||
areaStyle: { opacity: 0.07 },
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -175,23 +224,58 @@ function severityTag(severity: InfrastructureAlert['severity']) {
|
||||
return <Tag tone="info">提示</Tag>;
|
||||
}
|
||||
|
||||
function makeAlertColumns(onMarkRead: (alert: InfrastructureAlert) => void, readingFingerprint: string): Array<TableColumn<InfrastructureAlert>> { return [
|
||||
{ key: 'severity', title: '级别', width: '82px', render: (record) => severityTag(record.severity) },
|
||||
{
|
||||
key: 'alert', title: '告警', width: '280px', render: (record) => (
|
||||
<div className="system-monitoring-alert-copy"><strong>{record.name}</strong><span>{record.summary}</span></div>
|
||||
),
|
||||
},
|
||||
{ key: 'service', title: '服务 / 实例', width: '190px', render: (record) => record.service || record.instance || '主机资源' },
|
||||
{ key: 'value', title: '当前值 / 阈值', width: '150px', render: (record) => `${record.currentValue || '—'} / ${record.threshold || '—'}` },
|
||||
{ key: 'startedAt', title: '开始时间', width: '150px', render: (record) => formatTime(record.startedAt) },
|
||||
{ key: 'duration', title: '持续时间', width: '120px', render: (record) => formatDuration(record.startedAt) },
|
||||
{
|
||||
key: 'actions', title: '操作', width: '112px', render: (record) => 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>,
|
||||
},
|
||||
]; }
|
||||
function makeAlertColumns(
|
||||
onMarkRead: (alert: InfrastructureAlert) => void,
|
||||
readingFingerprint: string,
|
||||
): Array<TableColumn<InfrastructureAlert>> {
|
||||
return [
|
||||
{ key: 'severity', title: '级别', width: '82px', render: (record) => severityTag(record.severity) },
|
||||
{
|
||||
key: 'alert',
|
||||
title: '告警',
|
||||
width: '280px',
|
||||
render: (record) => (
|
||||
<div className="system-monitoring-alert-copy">
|
||||
<strong>{record.name}</strong>
|
||||
<span>{record.summary}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'service',
|
||||
title: '服务 / 实例',
|
||||
width: '190px',
|
||||
render: (record) => record.service || record.instance || '主机资源',
|
||||
},
|
||||
{
|
||||
key: 'value',
|
||||
title: '当前值 / 阈值',
|
||||
width: '150px',
|
||||
render: (record) => `${record.currentValue || '—'} / ${record.threshold || '—'}`,
|
||||
},
|
||||
{ key: 'startedAt', title: '开始时间', width: '150px', render: (record) => formatTime(record.startedAt) },
|
||||
{ key: 'duration', title: '持续时间', width: '120px', render: (record) => formatDuration(record.startedAt) },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '112px',
|
||||
render: (record) =>
|
||||
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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function AdminSystemMonitoringPage() {
|
||||
const [range, setRange] = useState<InfrastructureMonitoringRange>('24h');
|
||||
@@ -208,25 +292,28 @@ export function AdminSystemMonitoringPage() {
|
||||
const requestSequence = useRef(0);
|
||||
const pendingRequests = useRef(0);
|
||||
|
||||
const loadData = useCallback(async (supersede = false) => {
|
||||
if (!supersede && pendingRequests.current > 0) return;
|
||||
pendingRequests.current += 1;
|
||||
const sequence = ++requestSequence.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await adminApi.getInfrastructureMonitoringOverview(range);
|
||||
if (sequence !== requestSequence.current) return;
|
||||
setOverview(result);
|
||||
setError(result.available ? '' : result.error || '监控数据当前不可用');
|
||||
} catch (reason) {
|
||||
if (sequence !== requestSequence.current) return;
|
||||
setOverview(null);
|
||||
setError(reason instanceof Error ? reason.message : '监控数据加载失败');
|
||||
} finally {
|
||||
if (sequence === requestSequence.current) setLoading(false);
|
||||
pendingRequests.current -= 1;
|
||||
}
|
||||
}, [range]);
|
||||
const loadData = useCallback(
|
||||
async (supersede = false) => {
|
||||
if (!supersede && pendingRequests.current > 0) return;
|
||||
pendingRequests.current += 1;
|
||||
const sequence = ++requestSequence.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await adminApi.getInfrastructureMonitoringOverview(range);
|
||||
if (sequence !== requestSequence.current) return;
|
||||
setOverview(result);
|
||||
setError(result.available ? '' : result.error || '监控数据当前不可用');
|
||||
} catch (reason) {
|
||||
if (sequence !== requestSequence.current) return;
|
||||
setOverview(null);
|
||||
setError(reason instanceof Error ? reason.message : '监控数据加载失败');
|
||||
} finally {
|
||||
if (sequence === requestSequence.current) setLoading(false);
|
||||
pendingRequests.current -= 1;
|
||||
}
|
||||
},
|
||||
[range],
|
||||
);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
try {
|
||||
@@ -244,7 +331,10 @@ export function AdminSystemMonitoringPage() {
|
||||
setSavingSettings(true);
|
||||
setSettingsError('');
|
||||
try {
|
||||
const result = await adminApi.updateInfrastructureAlertThresholds({ configVersion: settings.configVersion, thresholds: draftThresholds });
|
||||
const result = await adminApi.updateInfrastructureAlertThresholds({
|
||||
configVersion: settings.configVersion,
|
||||
thresholds: draftThresholds,
|
||||
});
|
||||
setSettings(result);
|
||||
setDraftThresholds(result.thresholds);
|
||||
setShowSettings(false);
|
||||
@@ -262,12 +352,18 @@ export function AdminSystemMonitoringPage() {
|
||||
setReadError('');
|
||||
try {
|
||||
const result = await adminApi.markInfrastructureAlertRead(alert.fingerprint, alert.startedAt);
|
||||
setOverview((current) => current ? {
|
||||
...current,
|
||||
alerts: current.alerts.map((item) => item.fingerprint === result.fingerprint && Date.parse(item.startedAt) === Date.parse(result.activeAt)
|
||||
? { ...item, acknowledged: true, acknowledgedAt: result.acknowledgedAt }
|
||||
: item),
|
||||
} : current);
|
||||
setOverview((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
alerts: current.alerts.map((item) =>
|
||||
item.fingerprint === result.fingerprint && Date.parse(item.startedAt) === Date.parse(result.activeAt)
|
||||
? { ...item, acknowledged: true, acknowledgedAt: result.acknowledgedAt }
|
||||
: item,
|
||||
),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
window.dispatchEvent(new Event('cmpp-infrastructure-alert-count-refresh'));
|
||||
} catch (reason) {
|
||||
setReadError(reason instanceof Error ? reason.message : '活动告警标记已读失败');
|
||||
@@ -294,30 +390,63 @@ export function AdminSystemMonitoringPage() {
|
||||
}, [loadData, loadSettings]);
|
||||
|
||||
const status = STATUS_COPY[overview?.summary.overallStatus ?? 'unknown'];
|
||||
const cpuOption = useMemo(() => makeTrendOption({
|
||||
range, maximum: 100, suffix: '%', series: [{ name: 'CPU', points: overview?.trends.cpuUsagePercent ?? [], color: '#2563eb' }],
|
||||
}), [overview?.trends.cpuUsagePercent, range]);
|
||||
const memoryOption = useMemo(() => makeTrendOption({
|
||||
range, maximum: 100, suffix: '%', series: [{ name: '内存', points: overview?.trends.memoryUsagePercent ?? [], color: '#7c3aed' }],
|
||||
}), [overview?.trends.memoryUsagePercent, range]);
|
||||
const diskOption = useMemo(() => makeTrendOption({
|
||||
range, maximum: 100, suffix: '%', series: (overview?.disks ?? []).map((disk, index) => ({
|
||||
name: `${disk.mountpoint} · ${disk.device} · ${disk.instance}`,
|
||||
points: disk.trend,
|
||||
color: ['#d97706', '#2563eb', '#0f766e', '#7c3aed', '#dc2626', '#0891b2'][index % 6],
|
||||
})),
|
||||
}), [overview?.disks, range]);
|
||||
const networkOption = useMemo(() => makeTrendOption({
|
||||
range, suffix: ' B/s', series: [
|
||||
{ name: '接收', points: overview?.trends.networkReceiveBytesPerSecond ?? [], color: '#0f766e' },
|
||||
{ name: '发送', points: overview?.trends.networkTransmitBytesPerSecond ?? [], color: '#2563eb' },
|
||||
],
|
||||
}), [overview?.trends.networkReceiveBytesPerSecond, overview?.trends.networkTransmitBytesPerSecond, range]);
|
||||
const cpuOption = useMemo(
|
||||
() =>
|
||||
makeTrendOption({
|
||||
range,
|
||||
maximum: 100,
|
||||
suffix: '%',
|
||||
series: [{ name: 'CPU', points: overview?.trends.cpuUsagePercent ?? [], color: '#2563eb' }],
|
||||
}),
|
||||
[overview?.trends.cpuUsagePercent, range],
|
||||
);
|
||||
const memoryOption = useMemo(
|
||||
() =>
|
||||
makeTrendOption({
|
||||
range,
|
||||
maximum: 100,
|
||||
suffix: '%',
|
||||
series: [{ name: '内存', points: overview?.trends.memoryUsagePercent ?? [], color: '#7c3aed' }],
|
||||
}),
|
||||
[overview?.trends.memoryUsagePercent, range],
|
||||
);
|
||||
const diskOption = useMemo(
|
||||
() =>
|
||||
makeTrendOption({
|
||||
range,
|
||||
maximum: 100,
|
||||
suffix: '%',
|
||||
series: (overview?.disks ?? []).map((disk, index) => ({
|
||||
name: `${disk.mountpoint} · ${disk.device} · ${disk.instance}`,
|
||||
points: disk.trend,
|
||||
color: ['#d97706', '#2563eb', '#0f766e', '#7c3aed', '#dc2626', '#0891b2'][index % 6],
|
||||
})),
|
||||
}),
|
||||
[overview?.disks, range],
|
||||
);
|
||||
const networkOption = useMemo(
|
||||
() =>
|
||||
makeTrendOption({
|
||||
range,
|
||||
suffix: ' B/s',
|
||||
series: [
|
||||
{ name: '接收', points: overview?.trends.networkReceiveBytesPerSecond ?? [], color: '#0f766e' },
|
||||
{ name: '发送', points: overview?.trends.networkTransmitBytesPerSecond ?? [], color: '#2563eb' },
|
||||
],
|
||||
}),
|
||||
[overview?.trends.networkReceiveBytesPerSecond, overview?.trends.networkTransmitBytesPerSecond, range],
|
||||
);
|
||||
|
||||
const metrics = overview?.metrics;
|
||||
const serviceHealthy = overview?.summary.serviceHealthy ?? 0;
|
||||
const serviceTotal = overview?.summary.serviceTotal ?? 6;
|
||||
const alertColumns = useMemo(() => makeAlertColumns((alert) => { void markAlertRead(alert); }, readingFingerprint), [markAlertRead, readingFingerprint]);
|
||||
const alertColumns = useMemo(
|
||||
() =>
|
||||
makeAlertColumns((alert) => {
|
||||
void markAlertRead(alert);
|
||||
}, readingFingerprint),
|
||||
[markAlertRead, readingFingerprint],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-system-monitoring-page">
|
||||
@@ -338,10 +467,17 @@ export function AdminSystemMonitoringPage() {
|
||||
key={option.value}
|
||||
onClick={() => setRange(option.value)}
|
||||
type="button"
|
||||
>{option.label}</button>
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button disabled={loading} icon={<RefreshCw className={loading ? 'is-spinning' : ''} size={16} />} onClick={() => void loadData()} variant="ghost">
|
||||
<Button
|
||||
disabled={loading}
|
||||
icon={<RefreshCw className={loading ? 'is-spinning' : ''} size={16} />}
|
||||
onClick={() => void loadData()}
|
||||
variant="ghost"
|
||||
>
|
||||
{loading ? '刷新中' : '刷新'}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -350,7 +486,10 @@ export function AdminSystemMonitoringPage() {
|
||||
{error ? (
|
||||
<div className="system-monitoring-unavailable" role="alert">
|
||||
<ShieldAlert size={20} />
|
||||
<div><strong>监控数据不可用</strong><span>{error}。页面不会展示历史缓存值。</span></div>
|
||||
<div>
|
||||
<strong>监控数据不可用</strong>
|
||||
<span>{error}。页面不会展示历史缓存值。</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -363,78 +502,321 @@ export function AdminSystemMonitoringPage() {
|
||||
<strong>{status.label}</strong>
|
||||
<small>最新采样 {formatTime(overview?.lastSampleAt ?? null)}</small>
|
||||
</div>
|
||||
<div className="system-monitoring-health__fact"><span>核心服务</span><strong>{serviceHealthy}/{serviceTotal}</strong><small>正常运行</small></div>
|
||||
<div className="system-monitoring-health__fact"><span>活动告警</span><strong>{overview?.summary.activeAlerts ?? 0}</strong><small>{overview?.summary.criticalAlerts ?? 0} 严重 · {overview?.summary.warningAlerts ?? 0} 警告</small></div>
|
||||
<div className="system-monitoring-health__fact"><span>系统负载</span><strong>{metrics?.load1 === null || metrics?.load1 === undefined ? '—' : metrics.load1.toFixed(2)}</strong><small>最近1分钟</small></div>
|
||||
<div className="system-monitoring-health__fact"><span>持续运行</span><strong>{formatUptime(metrics?.uptimeSeconds ?? null)}</strong><small>主机启动后</small></div>
|
||||
<div className="system-monitoring-health__fact">
|
||||
<span>核心服务</span>
|
||||
<strong>
|
||||
{serviceHealthy}/{serviceTotal}
|
||||
</strong>
|
||||
<small>正常运行</small>
|
||||
</div>
|
||||
<div className="system-monitoring-health__fact">
|
||||
<span>活动告警</span>
|
||||
<strong>{overview?.summary.activeAlerts ?? 0}</strong>
|
||||
<small>
|
||||
{overview?.summary.criticalAlerts ?? 0} 严重 · {overview?.summary.warningAlerts ?? 0} 警告
|
||||
</small>
|
||||
</div>
|
||||
<div className="system-monitoring-health__fact">
|
||||
<span>系统负载</span>
|
||||
<strong>{metrics?.load1 === null || metrics?.load1 === undefined ? '—' : metrics.load1.toFixed(2)}</strong>
|
||||
<small>最近1分钟</small>
|
||||
</div>
|
||||
<div className="system-monitoring-health__fact">
|
||||
<span>持续运行</span>
|
||||
<strong>{formatUptime(metrics?.uptimeSeconds ?? null)}</strong>
|
||||
<small>主机启动后</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="system-monitoring-metrics">
|
||||
<article className="surface system-monitoring-metric"><div className="system-monitoring-metric__icon is-blue"><Cpu size={19} /></div><div><span>CPU 使用率</span><strong>{formatPercent(metrics?.cpuUsagePercent ?? null)}</strong><small>5分钟平均</small></div></article>
|
||||
<article className="surface system-monitoring-metric"><div className="system-monitoring-metric__icon is-violet"><MemoryStick size={19} /></div><div><span>内存使用率</span><strong>{formatPercent(metrics?.memoryUsagePercent ?? null)}</strong><small>{formatBytes(metrics?.memoryAvailableBytes ?? null)} 可用 / {formatBytes(metrics?.memoryTotalBytes ?? null)}</small></div></article>
|
||||
<article className="surface system-monitoring-metric">
|
||||
<div className="system-monitoring-metric__icon is-blue">
|
||||
<Cpu size={19} />
|
||||
</div>
|
||||
<div>
|
||||
<span>CPU 使用率</span>
|
||||
<strong>{formatPercent(metrics?.cpuUsagePercent ?? null)}</strong>
|
||||
<small>5分钟平均</small>
|
||||
</div>
|
||||
</article>
|
||||
<article className="surface system-monitoring-metric">
|
||||
<div className="system-monitoring-metric__icon is-violet">
|
||||
<MemoryStick size={19} />
|
||||
</div>
|
||||
<div>
|
||||
<span>内存使用率</span>
|
||||
<strong>{formatPercent(metrics?.memoryUsagePercent ?? null)}</strong>
|
||||
<small>
|
||||
{formatBytes(metrics?.memoryAvailableBytes ?? null)} 可用 /{' '}
|
||||
{formatBytes(metrics?.memoryTotalBytes ?? null)}
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
<DiskMetricCards disks={overview?.disks ?? []} />
|
||||
<article className="surface system-monitoring-metric"><div className="system-monitoring-metric__icon is-green"><Network size={19} /></div><div><span>网络吞吐</span><strong>{formatRate(totalNetworkRate(metrics?.networkReceiveBytesPerSecond, metrics?.networkTransmitBytesPerSecond))}</strong><small>接收 {formatRate(metrics?.networkReceiveBytesPerSecond ?? null)} · 发送 {formatRate(metrics?.networkTransmitBytesPerSecond ?? null)}</small></div></article>
|
||||
<article className="surface system-monitoring-metric">
|
||||
<div className="system-monitoring-metric__icon is-green">
|
||||
<Network size={19} />
|
||||
</div>
|
||||
<div>
|
||||
<span>网络吞吐</span>
|
||||
<strong>
|
||||
{formatRate(
|
||||
totalNetworkRate(metrics?.networkReceiveBytesPerSecond, metrics?.networkTransmitBytesPerSecond),
|
||||
)}
|
||||
</strong>
|
||||
<small>
|
||||
接收 {formatRate(metrics?.networkReceiveBytesPerSecond ?? null)} · 发送{' '}
|
||||
{formatRate(metrics?.networkTransmitBytesPerSecond ?? null)}
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="system-monitoring-main-grid">
|
||||
<div className="system-monitoring-chart-stack">
|
||||
<article className="surface system-monitoring-chart-card"><header><div><Cpu size={17} /><strong>CPU 趋势</strong></div><span>{formatPercent(metrics?.cpuUsagePercent ?? null)}</span></header>{overview?.trends.cpuUsagePercent.length ? <Chart height={230} option={cpuOption} /> : <EmptyChart />}</article>
|
||||
<article className="surface system-monitoring-chart-card"><header><div><MemoryStick size={17} /><strong>内存趋势</strong></div><span>{formatPercent(metrics?.memoryUsagePercent ?? null)}</span></header>{overview?.trends.memoryUsagePercent.length ? <Chart height={230} option={memoryOption} /> : <EmptyChart />}</article>
|
||||
<article className="surface system-monitoring-chart-card"><header><div><HardDrive size={17} /><strong>全部磁盘趋势</strong></div><span>{overview?.disks?.length ?? 0} 个文件系统</span></header>{overview?.disks?.some((disk) => disk.trend.length) ? <Chart height={230} option={diskOption} /> : <EmptyChart />}</article>
|
||||
<article className="surface system-monitoring-chart-card"><header><div><Activity size={17} /><strong>网络趋势</strong></div><span>{formatRate(totalNetworkRate(metrics?.networkReceiveBytesPerSecond, metrics?.networkTransmitBytesPerSecond))}</span></header>{overview?.trends.networkReceiveBytesPerSecond.length ? <Chart height={230} option={networkOption} /> : <EmptyChart />}</article>
|
||||
<article className="surface system-monitoring-chart-card">
|
||||
<header>
|
||||
<div>
|
||||
<Cpu size={17} />
|
||||
<strong>CPU 趋势</strong>
|
||||
</div>
|
||||
<span>{formatPercent(metrics?.cpuUsagePercent ?? null)}</span>
|
||||
</header>
|
||||
{overview?.trends.cpuUsagePercent.length ? <Chart height={230} option={cpuOption} /> : <EmptyChart />}
|
||||
</article>
|
||||
<article className="surface system-monitoring-chart-card">
|
||||
<header>
|
||||
<div>
|
||||
<MemoryStick size={17} />
|
||||
<strong>内存趋势</strong>
|
||||
</div>
|
||||
<span>{formatPercent(metrics?.memoryUsagePercent ?? null)}</span>
|
||||
</header>
|
||||
{overview?.trends.memoryUsagePercent.length ? <Chart height={230} option={memoryOption} /> : <EmptyChart />}
|
||||
</article>
|
||||
<article className="surface system-monitoring-chart-card">
|
||||
<header>
|
||||
<div>
|
||||
<HardDrive size={17} />
|
||||
<strong>全部磁盘趋势</strong>
|
||||
</div>
|
||||
<span>{overview?.disks?.length ?? 0} 个文件系统</span>
|
||||
</header>
|
||||
{overview?.disks?.some((disk) => disk.trend.length) ? (
|
||||
<Chart height={230} option={diskOption} />
|
||||
) : (
|
||||
<EmptyChart />
|
||||
)}
|
||||
</article>
|
||||
<article className="surface system-monitoring-chart-card">
|
||||
<header>
|
||||
<div>
|
||||
<Activity size={17} />
|
||||
<strong>网络趋势</strong>
|
||||
</div>
|
||||
<span>
|
||||
{formatRate(
|
||||
totalNetworkRate(metrics?.networkReceiveBytesPerSecond, metrics?.networkTransmitBytesPerSecond),
|
||||
)}
|
||||
</span>
|
||||
</header>
|
||||
{overview?.trends.networkReceiveBytesPerSecond.length ? (
|
||||
<Chart height={230} option={networkOption} />
|
||||
) : (
|
||||
<EmptyChart />
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<aside className="surface system-monitoring-services">
|
||||
<header><div><Server size={18} /><strong>核心服务</strong></div><Tag tone={serviceHealthy === serviceTotal && overview?.available ? 'success' : 'neutral'}>{serviceHealthy}/{serviceTotal} 正常</Tag></header>
|
||||
<header>
|
||||
<div>
|
||||
<Server size={18} />
|
||||
<strong>核心服务</strong>
|
||||
</div>
|
||||
<Tag tone={serviceHealthy === serviceTotal && overview?.available ? 'success' : 'neutral'}>
|
||||
{serviceHealthy}/{serviceTotal} 正常
|
||||
</Tag>
|
||||
</header>
|
||||
<div className="system-monitoring-service-list">
|
||||
{(overview?.services ?? []).map((service) => (
|
||||
<div className="system-monitoring-service" key={service.key}>
|
||||
<span className={`system-monitoring-service__dot is-${service.status}`} />
|
||||
<div><strong>{service.name}</strong><small>{service.unit}</small></div>
|
||||
<div>
|
||||
<strong>{service.name}</strong>
|
||||
<small>{service.unit}</small>
|
||||
</div>
|
||||
<span>{service.status === 'healthy' ? '正常' : service.status === 'unhealthy' ? '异常' : '未知'}</span>
|
||||
</div>
|
||||
))}
|
||||
{!overview?.services.length ? [
|
||||
['api', 'API服务'], ['gateway', 'Gateway服务'], ['postgresql', 'PostgreSQL'], ['redis', 'Redis'], ['minio', 'MinIO'], ['nginx', 'Nginx'],
|
||||
].map(([key, name]) => <div className="system-monitoring-service" key={key}><span className="system-monitoring-service__dot is-unknown" /><div><strong>{name}</strong><small>等待真实采集</small></div><span>未知</span></div>) : null}
|
||||
{!overview?.services.length
|
||||
? [
|
||||
['api', 'API服务'],
|
||||
['gateway', 'Gateway服务'],
|
||||
['postgresql', 'PostgreSQL'],
|
||||
['redis', 'Redis'],
|
||||
['minio', 'MinIO'],
|
||||
['nginx', 'Nginx'],
|
||||
].map(([key, name]) => (
|
||||
<div className="system-monitoring-service" key={key}>
|
||||
<span className="system-monitoring-service__dot is-unknown" />
|
||||
<div>
|
||||
<strong>{name}</strong>
|
||||
<small>等待真实采集</small>
|
||||
</div>
|
||||
<span>未知</span>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
<div className="system-monitoring-collector-note">
|
||||
<Database size={16} />
|
||||
<span>指标由 Prometheus 采集,业务数据库不写入高频时序数据。</span>
|
||||
</div>
|
||||
<div className="system-monitoring-collector-note"><Database size={16} /><span>指标由 Prometheus 采集,业务数据库不写入高频时序数据。</span></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section className="surface system-monitoring-service-metrics">
|
||||
<header>
|
||||
<div><Database size={18} /><strong>服务关键指标</strong></div>
|
||||
<div className="system-monitoring-service-actions"><span>固定低基数聚合,不含手机号、短信ID或SQL文本</span><Button icon={<Settings2 size={15} />} onClick={() => setShowSettings(true)} variant="ghost">告警阈值设置</Button></div>
|
||||
<div>
|
||||
<Database size={18} />
|
||||
<strong>服务关键指标</strong>
|
||||
</div>
|
||||
<div className="system-monitoring-service-actions">
|
||||
<span>固定低基数聚合,不含手机号、短信ID或SQL文本</span>
|
||||
<Button icon={<Settings2 size={15} />} onClick={() => setShowSettings(true)} variant="ghost">
|
||||
告警阈值设置
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="system-monitoring-service-metric-grid">
|
||||
{(overview?.serviceMetrics ?? []).map((group) => (
|
||||
<article key={group.key}>
|
||||
<div className="system-monitoring-service-metric-title"><strong>{group.name}</strong><Tag tone={group.available ? 'success' : 'neutral'}>{group.available ? '已采集' : '待采集'}</Tag></div>
|
||||
{group.metrics.length ? group.metrics.map((metric) => <div className="system-monitoring-service-metric-row" key={metric.key}><span>{metric.label}</span><strong>{formatServiceMetric(metric.value, metric.unit)}</strong></div>) : <div className="system-monitoring-service-metric-empty">已监控服务可用性,待原生容量指标接入</div>}
|
||||
<div className="system-monitoring-service-metric-title">
|
||||
<strong>{group.name}</strong>
|
||||
<Tag tone={group.available ? 'success' : 'neutral'}>{group.available ? '已采集' : '待采集'}</Tag>
|
||||
</div>
|
||||
{group.metrics.length ? (
|
||||
group.metrics.map((metric) => (
|
||||
<div className="system-monitoring-service-metric-row" key={metric.key}>
|
||||
<span>{metric.label}</span>
|
||||
<strong>{formatServiceMetric(metric.value, metric.unit)}</strong>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="system-monitoring-service-metric-empty">已监控服务可用性,待原生容量指标接入</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AlertHistory />
|
||||
<section className="surface system-monitoring-alerts" id="active-alerts">
|
||||
<header><div><AlertTriangle size={18} /><strong>活动告警</strong><Tag tone={overview?.summary.activeAlerts ? 'warning' : 'success'}>{overview?.summary.activeAlerts ?? 0}</Tag></div><span><Clock3 size={14} /> 刷新于 {formatTime(overview?.collectedAt ?? null)}</span></header>
|
||||
{readError ? <div className="system-monitoring-unavailable" role="alert"><AlertTriangle size={18} /><div><strong>标记已读失败</strong><span>{readError}</span></div></div> : null}
|
||||
<Table columns={alertColumns} data={overview?.alerts ?? []} emptyText={overview?.available ? '当前没有活动告警' : '监控不可用,无法读取活动告警'} pagination={false} rowKey="fingerprint" />
|
||||
<header>
|
||||
<div>
|
||||
<AlertTriangle size={18} />
|
||||
<strong>活动告警</strong>
|
||||
<Tag tone={overview?.summary.activeAlerts ? 'warning' : 'success'}>
|
||||
{overview?.summary.activeAlerts ?? 0}
|
||||
</Tag>
|
||||
</div>
|
||||
<span>
|
||||
<Clock3 size={14} /> 刷新于 {formatTime(overview?.collectedAt ?? null)}
|
||||
</span>
|
||||
</header>
|
||||
{readError ? (
|
||||
<div className="system-monitoring-unavailable" role="alert">
|
||||
<AlertTriangle size={18} />
|
||||
<div>
|
||||
<strong>标记已读失败</strong>
|
||||
<span>{readError}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<Table
|
||||
columns={alertColumns}
|
||||
data={overview?.alerts ?? []}
|
||||
emptyText={overview?.available ? '当前没有活动告警' : '监控不可用,无法读取活动告警'}
|
||||
pagination={false}
|
||||
rowKey="fingerprint"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Modal footer={<><Button onClick={() => setShowSettings(false)} variant="ghost">取消</Button><Button disabled={savingSettings || !settings} onClick={() => void saveSettings()}>{savingSettings ? '验证并应用中' : '保存并应用'}</Button></>} onClose={() => setShowSettings(false)} open={showSettings} title="Prometheus 告警阈值设置">
|
||||
<Modal
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setShowSettings(false)} variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={savingSettings || !settings} onClick={() => void saveSettings()}>
|
||||
{savingSettings ? '验证并应用中' : '保存并应用'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={() => setShowSettings(false)}
|
||||
open={showSettings}
|
||||
title="Prometheus 告警阈值设置"
|
||||
>
|
||||
<div className="system-monitoring-threshold-dialog">
|
||||
<div className="system-monitoring-threshold-note"><ShieldAlert size={17} /><span>仅允许修改固定指标的数值阈值。保存时先由 promtool 校验,再原子替换规则并热加载 Prometheus。</span></div>
|
||||
<div className="system-monitoring-threshold-note">
|
||||
<ShieldAlert size={17} />
|
||||
<span>仅允许修改固定指标的数值阈值。保存时先由 promtool 校验,再原子替换规则并热加载 Prometheus。</span>
|
||||
</div>
|
||||
{settings?.definitions.map((definition) => (
|
||||
<div className="system-monitoring-threshold-row" key={definition.key}>
|
||||
<div><strong>{definition.label}</strong><small>单位:{definition.unit}</small></div>
|
||||
<Input label="警告阈值" max={definition.max} min={definition.min} onChange={(event) => setDraftThresholds((current) => ({ ...current, [definition.key]: { ...current[definition.key], warning: Number(event.target.value) } }))} step={definition.step} type="number" value={draftThresholds[definition.key]?.warning ?? ''} />
|
||||
<Input label="严重阈值" max={definition.max} min={definition.min} onChange={(event) => setDraftThresholds((current) => ({ ...current, [definition.key]: { ...current[definition.key], critical: Number(event.target.value) } }))} step={definition.step} type="number" value={draftThresholds[definition.key]?.critical ?? ''} />
|
||||
<div>
|
||||
<strong>{definition.label}</strong>
|
||||
<small>单位:{definition.unit}</small>
|
||||
</div>
|
||||
<Input
|
||||
label="警告阈值"
|
||||
max={definition.max}
|
||||
min={definition.min}
|
||||
onChange={(event) =>
|
||||
setDraftThresholds((current) => ({
|
||||
...current,
|
||||
[definition.key]: { ...current[definition.key], warning: Number(event.target.value) },
|
||||
}))
|
||||
}
|
||||
step={definition.step}
|
||||
type="number"
|
||||
value={draftThresholds[definition.key]?.warning ?? ''}
|
||||
/>
|
||||
<Input
|
||||
label="严重阈值"
|
||||
max={definition.max}
|
||||
min={definition.min}
|
||||
onChange={(event) =>
|
||||
setDraftThresholds((current) => ({
|
||||
...current,
|
||||
[definition.key]: { ...current[definition.key], critical: Number(event.target.value) },
|
||||
}))
|
||||
}
|
||||
step={definition.step}
|
||||
type="number"
|
||||
value={draftThresholds[definition.key]?.critical ?? ''}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{settings?.applyStatus === 'failed' ? <div className="system-monitoring-unavailable"><AlertTriangle size={18} /><div><strong>上次应用失败</strong><span>{settings.lastError}</span></div></div> : null}
|
||||
{settingsError ? <div className="system-monitoring-unavailable"><AlertTriangle size={18} /><div><strong>阈值配置不可用</strong><span>{settingsError}</span></div></div> : null}
|
||||
{settings?.applyStatus === 'failed' ? (
|
||||
<div className="system-monitoring-unavailable">
|
||||
<AlertTriangle size={18} />
|
||||
<div>
|
||||
<strong>上次应用失败</strong>
|
||||
<span>{settings.lastError}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{settingsError ? (
|
||||
<div className="system-monitoring-unavailable">
|
||||
<AlertTriangle size={18} />
|
||||
<div>
|
||||
<strong>阈值配置不可用</strong>
|
||||
<span>{settingsError}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
@@ -442,5 +824,10 @@ export function AdminSystemMonitoringPage() {
|
||||
}
|
||||
|
||||
function EmptyChart() {
|
||||
return <div className="system-monitoring-chart-empty"><Activity size={22} /><span>暂无真实趋势指标</span></div>;
|
||||
return (
|
||||
<div className="system-monitoring-chart-empty">
|
||||
<Activity size={22} />
|
||||
<span>暂无真实趋势指标</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AlertHistory } from './AlertHistory';
|
||||
const { get } = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
vi.mock('@/api/adminApi', () => ({ adminApi: { getInfrastructureAlertHistory: get } }));
|
||||
|
||||
describe('alert history', () => {
|
||||
it('defaults to seven calendar days and sends the selected page; failure is not an empty success', async () => {
|
||||
get.mockResolvedValue({ items: [], total: 30, page: 1, pageSize: 25 });
|
||||
render(<AlertHistory />);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '下一页' })).toBeEnabled());
|
||||
const [from, to, page] = get.mock.calls[0];
|
||||
expect((Date.parse(to) - Date.parse(from)) / 86400_000).toBe(6);
|
||||
expect(page).toBe(1);
|
||||
get.mockRejectedValue(new Error('监控不可用'));
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('监控不可用'));
|
||||
expect(get).toHaveBeenLastCalledWith(from, to, 2);
|
||||
expect(screen.getByText('历史告警不可用')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { adminApi } from '@/api/adminApi';
|
||||
import { Button, DateRangeInput, Table, type TableColumn } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
type History = Awaited<ReturnType<typeof adminApi.getInfrastructureAlertHistory>>;
|
||||
const columns: TableColumn<History['items'][number]>[] = [
|
||||
{ key: 'name', title: '告警', width: '280px', render: (row) => row.name },
|
||||
{
|
||||
key: 'severity',
|
||||
title: '级别',
|
||||
width: '90px',
|
||||
render: (row) => ({ critical: '严重', warning: '警告', info: '提示' })[row.severity] || row.severity,
|
||||
},
|
||||
{ key: 'instance', title: '服务 / 实例', width: '220px', render: (row) => row.service || row.instance || '主机资源' },
|
||||
{ key: 'startedAt', title: '触发时间', width: '180px', render: (row) => formatDateTime(row.startedAt) },
|
||||
{
|
||||
key: 'lastObservedAt',
|
||||
title: '范围内最后采样',
|
||||
width: '180px',
|
||||
render: (row) => formatDateTime(row.lastObservedAt),
|
||||
},
|
||||
];
|
||||
|
||||
export function AlertHistory() {
|
||||
const [dates, setDates] = useState(() => {
|
||||
const key = (time: number) => new Date(time + 8 * 3600_000).toISOString().slice(0, 10);
|
||||
return { start: key(Date.now() - 6 * 86400_000), end: key(Date.now()) };
|
||||
});
|
||||
const [query, setQuery] = useState({ ...dates, page: 1, revision: 0 });
|
||||
const [response, setResponse] = useState<{ query: typeof query; data: History | null; error: string }>();
|
||||
const loading = response?.query !== query;
|
||||
const data = loading ? null : response?.data;
|
||||
const error = loading ? '' : response?.error;
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
adminApi
|
||||
.getInfrastructureAlertHistory(query.start, query.end, query.page)
|
||||
.then((result) => {
|
||||
if (current) setResponse({ query, data: result, error: '' });
|
||||
})
|
||||
.catch((reason) => {
|
||||
if (current)
|
||||
setResponse({ query, data: null, error: reason instanceof Error ? reason.message : '历史告警加载失败' });
|
||||
});
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
}, [query]);
|
||||
return (
|
||||
<section className="surface system-monitoring-alerts" aria-label="历史告警记录">
|
||||
<header>
|
||||
<strong>历史告警记录</strong>
|
||||
</header>
|
||||
<div className="filter-bar">
|
||||
<DateRangeInput
|
||||
label="告警日期"
|
||||
value={dates}
|
||||
onChange={(value) => setDates({ start: value.start || '', end: value.end || '' })}
|
||||
/>
|
||||
<Button disabled={loading} onClick={() => setQuery({ ...dates, page: 1, revision: query.revision + 1 })}>
|
||||
查询
|
||||
</Button>
|
||||
</div>
|
||||
<p className="muted">
|
||||
默认近7天,最多31天;读取 Prometheus
|
||||
保留的真实触发周期(含等待触发),最后采样不代表准确恢复时间。保留期外或采集缺失的历史无法补齐。
|
||||
</p>
|
||||
{error ? (
|
||||
<p role="alert" className="form-error">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<Table
|
||||
columns={columns}
|
||||
data={loading ? [] : (data?.items ?? [])}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
emptyText={loading ? '历史告警加载中…' : error ? '历史告警不可用' : '所选日期没有保留的告警记录'}
|
||||
/>
|
||||
<div className="table-footer">
|
||||
<span>
|
||||
共 {data?.total ?? 0} 条 · 第 {data?.page ?? query.page} 页
|
||||
</span>
|
||||
<Button disabled={loading || query.page <= 1} onClick={() => setQuery({ ...query, page: query.page - 1 })}>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
disabled={loading || !data || query.page * data.pageSize >= data.total}
|
||||
onClick={() => setQuery({ ...query, page: query.page + 1 })}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ describe('Modal close policy', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
it('preserves mask closing by default for existing consumers', () => {
|
||||
it('ignores backdrop, panel and Escape by default, including clean forms', () => {
|
||||
const close = vi.fn();
|
||||
render(
|
||||
<Modal open title="普通弹窗" onClose={close}>
|
||||
@@ -24,6 +24,32 @@ describe('Modal close policy', () => {
|
||||
</Modal>,
|
||||
);
|
||||
fireEvent.mouseDown(document.querySelector('.ui-modal__mask')!);
|
||||
fireEvent.mouseDown(screen.getByRole('dialog'));
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps the dirty guard for explicit close and allows canceling it', () => {
|
||||
const close = vi.fn();
|
||||
render(
|
||||
<Modal
|
||||
open
|
||||
dirty
|
||||
title="编辑"
|
||||
onClose={close}
|
||||
footer={({ requestClose }) => <button onClick={requestClose}>取消</button>}
|
||||
>
|
||||
未保存内容
|
||||
</Modal>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }));
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }));
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '放弃并关闭' }));
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,8 +97,8 @@ export function Modal({
|
||||
size = 'md',
|
||||
onClose,
|
||||
dirty = false,
|
||||
closeOnBackdrop = true,
|
||||
closeOnEscape = true,
|
||||
closeOnBackdrop = false,
|
||||
closeOnEscape = false,
|
||||
initialFocusRef,
|
||||
closeGuardTitle = '放弃未保存的修改?',
|
||||
closeGuardDescription = '当前内容尚未保存。放弃后无法恢复,请确认是否关闭。',
|
||||
@@ -139,9 +139,10 @@ export function Modal({
|
||||
lockDocument(layer);
|
||||
|
||||
const focusTarget = initialFocusRef?.current ?? focusableElements(panel)[0] ?? panel;
|
||||
requestAnimationFrame(() => focusTarget.focus());
|
||||
const focusFrame = requestAnimationFrame(() => focusTarget.focus());
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(focusFrame);
|
||||
const stackIndex = modalStack.lastIndexOf(panel);
|
||||
if (stackIndex >= 0) modalStack.splice(stackIndex, 1);
|
||||
unlockDocument();
|
||||
@@ -197,8 +198,12 @@ export function Modal({
|
||||
if (!showCloseGuard) return;
|
||||
const panel = panelRef.current;
|
||||
if (panel) panel.inert = true;
|
||||
requestAnimationFrame(() => focusableElements(guardRef.current ?? panelRef.current!)[0]?.focus());
|
||||
const focusFrame = requestAnimationFrame(() => {
|
||||
const root = guardRef.current ?? panelRef.current;
|
||||
if (root) focusableElements(root)[0]?.focus();
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(focusFrame);
|
||||
if (panel) panel.inert = false;
|
||||
const restoreTarget = guardRestoreFocusRef.current;
|
||||
queueMicrotask(() => {
|
||||
|
||||
Reference in New Issue
Block a user