release: prepare RealeseV2.3

This commit is contained in:
hectorzhao
2026-08-06 10:48:36 +08:00
parent 57b58f1c40
commit 8ad8e61793
37 changed files with 997 additions and 64 deletions
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertTriangle, CheckCircle2, Clock3, Eye, RefreshCw, RotateCcw, Search } from 'lucide-react';
import { adminApi, type AdminChannel, type EnterpriseApplication, type GatewaySubmitException } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tabs, Tag, Textarea, type TableColumn } from '@/components/ui';
import { ReceiptAnomalyPanel } from './gateway-exceptions/ReceiptAnomalyPanel';
const statusLabel: Record<string, string> = {
pending: '待处理',
@@ -45,7 +46,7 @@ function ExceptionDetailModal({ record, onClose, onRequestRequeue }: {
open
onClose={onClose}
size="xl"
title={<div className="template-modal-title"><h2>Gateway提交异常详情</h2><p>{record.messageId ?? record.streamMessageId}</p></div>}
title={<div className="template-modal-title"><h2></h2><p>{record.messageId ?? record.streamMessageId}</p></div>}
footer={(
<div className="modal-footer-actions">
<Button onClick={onClose} variant="ghost"></Button>
@@ -139,7 +140,7 @@ function RequeueModal({ record, submitting, onClose, onSubmit }: {
);
}
export function AdminGatewaySubmitExceptionsPage() {
function GatewaySubmitExceptionPanel() {
const [items, setItems] = useState<GatewaySubmitException[]>([]);
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
const [channels, setChannels] = useState<AdminChannel[]>([]);
@@ -175,7 +176,7 @@ export function AdminGatewaySubmitExceptionsPage() {
.catch((failure: Error) => {
setItems([]);
setTotal(0);
setError(failure.message || 'Gateway提交异常加载失败');
setError(failure.message || '提交异常加载失败');
})
.finally(() => setLoading(false));
}, [applicationId, channelId, keyword, page, status]);
@@ -215,9 +216,9 @@ export function AdminGatewaySubmitExceptionsPage() {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<section className="page-stack admin-sms-task-page report-record-page gateway-exception-page">
<div className="page-stack admin-sms-task-page report-record-page gateway-exception-page">
<div className="page-heading">
<div><Breadcrumb items={['运营概览', 'Gateway提交异常']} /><h1>Gateway提交异常</h1><p className="page-inline-hint">Gateway连续失败且尚未取得明确上游结果的提交命令</p></div>
<div><h2></h2><p className="page-inline-hint"> Gateway </p></div>
<Button icon={<RefreshCw size={16} />} onClick={loadData} variant="secondary"></Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
@@ -239,11 +240,30 @@ export function AdminGatewaySubmitExceptionsPage() {
<div><h2></h2><p className="page-inline-hint"> Gateway </p></div>
<Tag tone="warning">{total} </Tag>
</div>
<Table columns={columns} data={items} emptyText={loading ? '加载中...' : '暂无Gateway提交异常'} pagination={false} rowKey="id" />
<Table columns={columns} data={items} emptyText={loading ? '加载中...' : '暂无提交异常'} pagination={false} rowKey="id" />
<Pagination total={total} page={page} totalPages={totalPages} onPageChange={setPage} previousDisabled={page <= 1} nextDisabled={page >= totalPages} onPrevious={() => setPage((current) => Math.max(1, current - 1))} onNext={() => setPage((current) => Math.min(totalPages, current + 1))} />
</div>
{detail ? <ExceptionDetailModal record={detail} onClose={() => setDetail(null)} onRequestRequeue={() => setRequeueRecord(detail)} /> : null}
{requeueRecord ? <RequeueModal record={requeueRecord} submitting={submitting} onClose={() => setRequeueRecord(null)} onSubmit={submitRequeue} /> : null}
</div>
);
}
export function AdminGatewaySubmitExceptionsPage() {
const [activeTab, setActiveTab] = useState('submit');
return (
<section className="page-stack gateway-exception-page">
<div className="page-heading">
<div><Breadcrumb items={['运营概览', '网关异常']} /><h1></h1><p className="page-inline-hint"></p></div>
</div>
<Tabs
items={[
{ label: '提交异常', value: 'submit', content: <GatewaySubmitExceptionPanel /> },
{ label: '回执异常', value: 'receipt', content: <ReceiptAnomalyPanel /> },
]}
onChange={setActiveTab}
value={activeTab}
/>
</section>
);
}
+13 -1
View File
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { Button, Input, Modal, Select } from '@/components/ui';
import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
import { carrierLabelMap, cmppVersionOptions, regionOptions } from './channelModel';
import type { Carrier, ChannelModalState, SmsChannel } from './channelTypes';
import type { Carrier, ChannelModalState, LongMessageReceiptMode, SmsChannel } from './channelTypes';
export function ChannelFormModal({
modal,
@@ -33,6 +33,7 @@ export function ChannelFormModal({
const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16));
const [heartbeatIntervalSeconds, setHeartbeatIntervalSeconds] = useState(String(channel?.heartbeatIntervalSeconds ?? 30));
const [heartbeatMissThreshold, setHeartbeatMissThreshold] = useState(String(channel?.heartbeatMissThreshold ?? 3));
const [longMessageReceiptMode, setLongMessageReceiptMode] = useState<LongMessageReceiptMode>(channel?.longMessageReceiptMode ?? 'per_segment');
function submit() {
if (!isValidMoneyInput(unitPrice)) {
@@ -67,6 +68,7 @@ export function ChannelFormModal({
heartbeatIntervalSeconds: Number(heartbeatIntervalSeconds) || 30,
heartbeatMissThreshold: Number(heartbeatMissThreshold) || 3,
extensionDigits: Number(extensionDigits),
longMessageReceiptMode,
rateLimitPerSecond: Number(flowLimit),
passwordCipher: password || undefined,
});
@@ -136,6 +138,16 @@ export function ChannelFormModal({
<Input label="* 提交窗口" onChange={(event) => setWindowSize(event.target.value)} placeholder="16" value={windowSize} />
<Input hint="平台主动向供应商发送 ACTIVE_TEST 的间隔" label="* 心跳间隔" min="1" onChange={(event) => setHeartbeatIntervalSeconds(event.target.value)} suffix="秒" type="number" value={heartbeatIntervalSeconds} />
<Input hint="连续未收到心跳响应达到该次数后重连" label="* 心跳失败阈值" min="1" onChange={(event) => setHeartbeatMissThreshold(event.target.value)} suffix="次" type="number" value={heartbeatMissThreshold} />
<Select
label="* 长短信成功回执口径"
onChange={(event) => setLongMessageReceiptMode(event.target.value as LongMessageReceiptMode)}
options={[
{ label: '逐分片回执(默认)', value: 'per_segment' },
{ label: '整条级回执(一条成功代表全部成功)', value: 'message_level' },
]}
value={longMessageReceiptMode}
/>
<p className="page-inline-hint"></p>
</div>
</section>
</div>
+6 -1
View File
@@ -121,6 +121,7 @@ export function mapApiChannel(
heartbeatIntervalSeconds: Number(channel.config?.heartbeatIntervalSeconds ?? 30),
heartbeatMissThreshold: Number(channel.config?.heartbeatMissThreshold ?? 3),
extensionDigits: Number(channel.config?.extensionDigits ?? 0),
longMessageReceiptMode: channel.config?.longMessageReceiptMode === 'message_level' ? 'message_level' : 'per_segment',
rateLimitPerSecond: channel.rateLimitPerSecond,
};
}
@@ -148,6 +149,10 @@ export function buildChannelPayload(channel: SmsChannel, passwordCipher?: string
windowSize: channel.windowSize,
heartbeatIntervalSeconds: channel.heartbeatIntervalSeconds,
heartbeatMissThreshold: channel.heartbeatMissThreshold,
config: { extensionDigits: channel.extensionDigits, serviceId: channel.businessCode },
config: {
extensionDigits: channel.extensionDigits,
serviceId: channel.businessCode,
longMessageReceiptMode: channel.longMessageReceiptMode,
},
};
}
+2
View File
@@ -2,6 +2,7 @@ import type { ChannelConnectionLogResponse } from '@/api/adminApi';
export type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all';
export type ChannelStatus = 'normal' | 'stopped' | 'connecting' | 'failed';
export type LongMessageReceiptMode = 'per_segment' | 'message_level';
export type SmsChannel = {
id: string;
@@ -31,6 +32,7 @@ export type SmsChannel = {
heartbeatIntervalSeconds: number;
heartbeatMissThreshold: number;
extensionDigits: number;
longMessageReceiptMode: LongMessageReceiptMode;
rateLimitPerSecond: number;
passwordCipher?: string;
};
@@ -0,0 +1,158 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertTriangle, CheckCircle2, Clock3, Eye, RefreshCw, Search } from 'lucide-react';
import { adminApi, type AdminChannel, type EnterpriseApplication, type ReceiptAnomaly } from '@/api/adminApi';
import { Button, Input, Modal, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
const anomalyTypeLabel: Record<string, string> = {
aggregate_success_then_failure: '整条成功后又收到失败',
};
const statusLabel: Record<string, string> = {
pending: '待处理',
resolved: '已处理',
ignored: '已忽略',
};
const statusTone: Record<string, 'neutral' | 'success' | 'warning' | 'danger'> = {
pending: 'danger',
resolved: 'success',
ignored: 'neutral',
};
function formatTime(value?: string | null) {
if (!value) return '-';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false });
}
function maskPhone(value?: string | null) {
if (!value) return '-';
return value.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2');
}
function ReceiptAnomalyDetailModal({ record, onClose }: { record: ReceiptAnomaly; onClose: () => void }) {
return (
<Modal
footer={<Button onClick={onClose} variant="ghost"></Button>}
onClose={onClose}
open
size="xl"
title={<div className="template-modal-title"><h2></h2><p>{record.messageRecord?.messageId ?? record.anomalyKey}</p></div>}
>
<div className="page-stack report-record-detail gateway-exception-detail">
<div className="detail-grid">
<div><span></span><strong>{anomalyTypeLabel[record.anomalyType] ?? record.anomalyType}</strong></div>
<div><span></span><strong>{statusLabel[record.status] ?? record.status}</strong></div>
<div><span></span><strong>{record.tenant?.name ?? '-'}</strong></div>
<div><span></span><strong>{record.application?.name ?? '-'}</strong></div>
<div><span></span><strong>{record.channel?.name ?? '-'}</strong></div>
<div><span></span><strong>{maskPhone(record.messageRecord?.phoneNumber)}</strong></div>
<div><span>MessageId</span><strong>{record.messageRecord?.messageId ?? '-'}</strong></div>
<div><span>SubmitId</span><strong>{record.submitRecord?.submitId ?? '-'}</strong></div>
<div><span></span><strong>{record.previousStatus ?? '-'}</strong></div>
<div><span></span><strong>{record.incomingStatus ?? '-'}</strong></div>
<div><span></span><strong>{record.rawStatus ?? '-'}</strong></div>
<div><span></span><strong>{record.errorCode ?? '-'}</strong></div>
<div><span></span><strong>{formatTime(record.firstOccurredAt)}</strong></div>
<div><span></span><strong>{formatTime(record.lastOccurredAt)}</strong></div>
<div><span></span><strong>{record.occurrenceCount}</strong></div>
<div><span> Msg_Id</span><strong>{record.receiptRecord?.gatewayMessageId ?? '-'}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{record.anomalyKey}</strong></div>
<div className="detail-grid__wide"><span></span><strong></strong></div>
</div>
<div className="gateway-exception-command">
<div className="section-heading"><h3></h3><p className="page-inline-hint"> MessageId Msg_Id </p></div>
<pre>{JSON.stringify(record.detail ?? {}, null, 2)}</pre>
</div>
</div>
</Modal>
);
}
export function ReceiptAnomalyPanel() {
const [items, setItems] = useState<ReceiptAnomaly[]>([]);
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
const [channels, setChannels] = useState<AdminChannel[]>([]);
const [summary, setSummary] = useState({ pending: 0, resolved: 0, ignored: 0, oldestPendingAt: null as string | null });
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('all');
const [anomalyType, setAnomalyType] = useState('all');
const [applicationId, setApplicationId] = useState('all');
const [channelId, setChannelId] = useState('all');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [detail, setDetail] = useState<ReceiptAnomaly | null>(null);
const pageSize = 10;
useEffect(() => {
Promise.all([adminApi.listEnterpriseApplications(), adminApi.listChannels()])
.then(([appItems, channelItems]) => {
setApplications(appItems);
setChannels(channelItems.filter((item) => item.status !== 'deleted'));
})
.catch((failure: Error) => setError(failure.message || '筛选条件加载失败'));
}, []);
const loadData = useCallback(() => {
setLoading(true);
adminApi.listReceiptAnomalies({ keyword, status, anomalyType, applicationId, channelId, page, pageSize })
.then((response) => {
setItems(response.items);
setTotal(response.total);
setSummary({ ...response.summary, oldestPendingAt: response.summary.oldestPendingAt ?? null });
setError('');
})
.catch((failure: Error) => {
setItems([]);
setTotal(0);
setError(failure.message || '回执异常加载失败');
})
.finally(() => setLoading(false));
}, [anomalyType, applicationId, channelId, keyword, page, status]);
useEffect(() => { loadData(); }, [loadData]);
const columns = useMemo<Array<TableColumn<ReceiptAnomaly>>>(() => [
{ key: 'lastOccurredAt', title: '最近发生', width: '170px', render: (record) => formatTime(record.lastOccurredAt) },
{ key: 'messageId', title: '消息编号', width: '180px', render: (record) => <strong className="admin-task-id">{record.messageRecord?.messageId ?? '-'}</strong> },
{ key: 'tenant', title: '企业 / 应用', width: '190px', render: (record) => <div><strong>{record.tenant?.name ?? '-'}</strong><small className="table-cell-note">{record.application?.name ?? '-'}</small></div> },
{ key: 'channel', title: '通道', width: '170px', render: (record) => <div>{record.channel?.name ?? '-'}<small className="table-cell-note">{record.channel?.code ?? '-'}</small></div> },
{ key: 'type', title: '异常类型', render: (record) => <div><strong>{anomalyTypeLabel[record.anomalyType] ?? record.anomalyType}</strong><small className="table-cell-note">{record.previousStatus ?? '-'} {record.incomingStatus ?? '-'}</small></div> },
{ key: 'count', title: '次数', width: '70px', align: 'center', render: (record) => record.occurrenceCount },
{ key: 'status', title: '状态', width: '100px', render: (record) => <Tag tone={statusTone[record.status] ?? 'neutral'}>{statusLabel[record.status] ?? record.status}</Tag> },
{ key: 'actions', title: '操作', width: '90px', align: 'right', render: (record) => <Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button> },
], []);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<div className="page-stack admin-sms-task-page report-record-page">
<div className="page-heading">
<div><h2></h2><p className="page-inline-hint"> CMPP </p></div>
<Button icon={<RefreshCw size={16} />} onClick={loadData} variant="secondary"></Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="dashboard-grid">
<div className="surface mini-status-card"><AlertTriangle size={22} /><div><span></span><strong>{summary.pending}</strong><small></small></div></div>
<div className="surface mini-status-card"><Clock3 size={22} /><div><span></span><strong className="gateway-exception-time">{formatTime(summary.oldestPendingAt)}</strong><small></small></div></div>
<div className="surface mini-status-card"><CheckCircle2 size={22} /><div><span></span><strong>{summary.resolved}</strong><small></small></div></div>
<div className="surface mini-status-card"><RefreshCw size={22} /><div><span></span><strong>{total}</strong><small></small></div></div>
</div>
<div className="surface admin-task-filter">
<Input label="消息编号 / 状态码" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} placeholder="MessageId、SubmitId、原始状态" value={keyword} />
<Select label="状态" options={[{ label: '全部状态', value: 'all' }, { label: '待处理', value: 'pending' }, { label: '已处理', value: 'resolved' }, { label: '已忽略', value: 'ignored' }]} value={status} onChange={(event) => { setStatus(event.target.value); setPage(1); }} />
<Select label="异常类型" options={[{ label: '全部类型', value: 'all' }, { label: '整条成功后又收到失败', value: 'aggregate_success_then_failure' }]} value={anomalyType} onChange={(event) => { setAnomalyType(event.target.value); setPage(1); }} />
<Select label="应用" options={[{ label: '全部应用', value: 'all' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]} value={applicationId} onChange={(event) => { setApplicationId(event.target.value); setPage(1); }} />
<Select label="通道" options={[{ label: '全部通道', value: 'all' }, ...channels.map((item) => ({ label: item.name, value: item.id }))]} value={channelId} onChange={(event) => { setChannelId(event.target.value); setPage(1); }} />
<div className="admin-task-filter__actions"><Button icon={<Search size={16} />} onClick={loadData}></Button></div>
</div>
<div className="surface admin-task-table-card report-task-table-card">
<div className="section-heading gateway-exception-list-heading"><div><h2></h2><p className="page-inline-hint"></p></div><Tag tone="warning">{total} </Tag></div>
<Table columns={columns} data={items} emptyText={loading ? '加载中...' : '暂无回执异常'} pagination={false} rowKey="id" />
<Pagination total={total} page={page} totalPages={totalPages} onPageChange={setPage} previousDisabled={page <= 1} nextDisabled={page >= totalPages} onPrevious={() => setPage((current) => Math.max(1, current - 1))} onNext={() => setPage((current) => Math.min(totalPages, current + 1))} />
</div>
{detail ? <ReceiptAnomalyDetailModal record={detail} onClose={() => setDetail(null)} /> : null}
</div>
);
}