feat: complete cmpp gateway delivery recovery workflows
This commit is contained in:
@@ -26,6 +26,8 @@ type SmsChannel = {
|
||||
corpCode: string;
|
||||
account: string;
|
||||
accessNo: string;
|
||||
desiredConnections: number;
|
||||
windowSize: number;
|
||||
passwordCipher?: string;
|
||||
};
|
||||
|
||||
@@ -143,6 +145,8 @@ function mapApiChannel(channel: AdminChannel, connections: CmppConnectionState[]
|
||||
corpCode: channel.enterpriseCode ?? channel.code,
|
||||
account: channel.account,
|
||||
accessNo: channel.srcId,
|
||||
desiredConnections: Number(channel.config?.desiredConnections ?? 1),
|
||||
windowSize: Number(channel.config?.windowSize ?? 16),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -163,6 +167,8 @@ function buildChannelPayload(channel: SmsChannel, passwordCipher?: string) {
|
||||
srcId: channel.accessNo,
|
||||
rateLimitPerSecond: 100,
|
||||
unitPrice: Math.round(channel.unitPrice),
|
||||
desiredConnections: channel.desiredConnections,
|
||||
windowSize: channel.windowSize,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -199,6 +205,8 @@ function ChannelFormModal({
|
||||
const [accessNo, setAccessNo] = useState(channel?.accessNo ?? '');
|
||||
const [extensionDigits, setExtensionDigits] = useState('0');
|
||||
const [flowLimit, setFlowLimit] = useState('1-2000');
|
||||
const [desiredConnections, setDesiredConnections] = useState(String(channel?.desiredConnections ?? 1));
|
||||
const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16));
|
||||
|
||||
function submit() {
|
||||
onSubmit({
|
||||
@@ -220,6 +228,8 @@ function ChannelFormModal({
|
||||
corpCode,
|
||||
account,
|
||||
accessNo,
|
||||
desiredConnections: Number(desiredConnections) || 1,
|
||||
windowSize: Number(windowSize) || 16,
|
||||
passwordCipher: password || undefined,
|
||||
});
|
||||
}
|
||||
@@ -272,6 +282,8 @@ function ChannelFormModal({
|
||||
<Select label="拓展位数" onChange={(event) => setExtensionDigits(event.target.value)} options={extensionOptions} value={extensionDigits} />
|
||||
</div>
|
||||
<Input label="* 通道流速" onChange={(event) => setFlowLimit(event.target.value)} suffix="条/秒" value={flowLimit} />
|
||||
<Input label="* 期望连接数" onChange={(event) => setDesiredConnections(event.target.value)} placeholder="1" value={desiredConnections} />
|
||||
<Input label="* 提交窗口" onChange={(event) => setWindowSize(event.target.value)} placeholder="16" value={windowSize} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, BarChart3, CheckCircle2, Eye, RefreshCw, Search, TimerReset } from 'lucide-react';
|
||||
import { adminApi, type DownstreamDeliveryDashboard, type DownstreamDeliveryRecord, type EnterpriseApplication } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
|
||||
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
|
||||
pending: 'warning',
|
||||
delivered: 'success',
|
||||
failed: 'danger',
|
||||
};
|
||||
|
||||
const deliveryTypeLabel: Record<string, string> = {
|
||||
receipt: '状态回执',
|
||||
uplink: '上行短信',
|
||||
};
|
||||
|
||||
function DeliveryDetailModal({ record, onClose }: { record: DownstreamDeliveryRecord; onClose: () => void }) {
|
||||
const payloadText = useMemo(() => JSON.stringify(record.payload ?? {}, null, 2), [record.payload]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>下游投递详情</h2><p>{record.id}</p></div>}
|
||||
footer={<Button onClick={onClose}>关闭</Button>}
|
||||
>
|
||||
<div className="report-record-detail">
|
||||
<div className="detail-grid">
|
||||
<div><span>企业</span><strong>{record.tenant?.name ?? record.tenantId}</strong></div>
|
||||
<div><span>应用</span><strong>{record.application?.name ?? record.applicationId}</strong></div>
|
||||
<div><span>投递类型</span><strong>{deliveryTypeLabel[record.deliveryType] ?? record.deliveryType}</strong></div>
|
||||
<div><span>当前状态</span><strong>{record.status}</strong></div>
|
||||
<div><span>消息 ID</span><strong>{record.messageId ?? '-'}</strong></div>
|
||||
<div><span>重试次数</span><strong>{record.retryCount}</strong></div>
|
||||
<div><span>下次重试</span><strong>{record.nextRetryAt ?? '-'}</strong></div>
|
||||
<div><span>已投递时间</span><strong>{record.deliveredAt ?? '-'}</strong></div>
|
||||
<div className="detail-grid__wide"><span>最后错误</span><strong>{record.lastError ?? '-'}</strong></div>
|
||||
</div>
|
||||
<section className="report-history">
|
||||
<h3>Payload</h3>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0 }}>{payloadText}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminDownstreamDeliveriesPage() {
|
||||
const [records, setRecords] = useState<DownstreamDeliveryRecord[]>([]);
|
||||
const [dashboard, setDashboard] = useState<DownstreamDeliveryDashboard | null>(null);
|
||||
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [deliveryType, setDeliveryType] = useState('all');
|
||||
const [applicationId, setApplicationId] = useState('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [detail, setDetail] = useState<DownstreamDeliveryRecord | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
adminApi.getDownstreamDeliveryDashboard({
|
||||
applicationId,
|
||||
deliveryType,
|
||||
}),
|
||||
adminApi.listDownstreamDeliveries({
|
||||
keyword,
|
||||
status,
|
||||
deliveryType,
|
||||
applicationId,
|
||||
page,
|
||||
pageSize,
|
||||
}),
|
||||
adminApi.listEnterpriseApplications(),
|
||||
])
|
||||
.then(([dashboardResponse, response, apps]) => {
|
||||
setDashboard(dashboardResponse);
|
||||
setRecords(response.items);
|
||||
setTotal(response.total);
|
||||
setApplications(apps);
|
||||
setSelectedIds((current) => current.filter((id) => response.items.some((item) => item.id === id)));
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '下游投递记录加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [applicationId, deliveryType, keyword, page, pageSize, status]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const selectableIds = useMemo(
|
||||
() => records.filter((item) => item.status !== 'delivered').map((item) => item.id),
|
||||
[records],
|
||||
);
|
||||
const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.includes(id));
|
||||
const summary = dashboard?.summary;
|
||||
const typeBreakdown = dashboard?.typeBreakdown ?? [];
|
||||
const retryBuckets = dashboard?.retryBuckets ?? [];
|
||||
const topApplications = dashboard?.topApplications ?? [];
|
||||
|
||||
const columns: Array<TableColumn<DownstreamDeliveryRecord>> = [
|
||||
{
|
||||
key: 'select',
|
||||
title: '选择',
|
||||
width: '52px',
|
||||
align: 'center',
|
||||
render: (record) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={record.status === 'delivered'}
|
||||
checked={selectedIds.includes(record.id)}
|
||||
onChange={(event) => {
|
||||
setSelectedIds((current) =>
|
||||
event.target.checked
|
||||
? [...current, record.id]
|
||||
: current.filter((item) => item !== record.id),
|
||||
);
|
||||
}}
|
||||
aria-label={`选择${record.id}`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ key: 'createdAt', title: '投递时间', width: '180px', render: (record) => record.createdAt },
|
||||
{ key: 'tenant', title: '企业', width: '180px', render: (record) => record.tenant?.name ?? '-' },
|
||||
{ key: 'application', title: '应用', width: '180px', render: (record) => record.application?.name ?? '-' },
|
||||
{ key: 'type', title: '类型', width: '110px', render: (record) => deliveryTypeLabel[record.deliveryType] ?? record.deliveryType },
|
||||
{ key: 'messageId', title: '消息 ID', width: '180px', render: (record) => <strong className="admin-task-id">{record.messageId ?? '-'}</strong> },
|
||||
{ key: 'status', title: '状态', width: '110px', render: (record) => <Tag tone={statusTone[record.status] ?? 'info'}>{record.status}</Tag> },
|
||||
{ key: 'retry', title: '重试', width: '90px', align: 'center', render: (record) => record.retryCount },
|
||||
{ key: 'error', title: '最后错误', render: (record) => record.lastError ?? '-' },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '170px',
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost">详情</Button>
|
||||
<Button
|
||||
icon={<RefreshCw size={14} />}
|
||||
onClick={() => {
|
||||
adminApi.requeueDownstreamDelivery(record.id)
|
||||
.then(() => loadData())
|
||||
.catch((failure: Error) => setError(failure.message || '人工重投失败'));
|
||||
}}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
重投
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-sms-task-page report-record-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<Breadcrumb items={['数据详单', '下游投递记录']} />
|
||||
<h1>下游投递记录</h1>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="dashboard-grid">
|
||||
<div className="surface mini-status-card">
|
||||
<BarChart3 size={22} />
|
||||
<div>
|
||||
<span>投递总量</span>
|
||||
<strong>{summary?.total ?? 0}</strong>
|
||||
<small>当前筛选范围内的真实下游投递记录。</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface mini-status-card">
|
||||
<TimerReset size={22} />
|
||||
<div>
|
||||
<span>待投递</span>
|
||||
<strong>{summary?.pending ?? 0}</strong>
|
||||
<small>其中积压告警 {summary?.stalledPending ?? 0} 条。</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface mini-status-card">
|
||||
<CheckCircle2 size={22} />
|
||||
<div>
|
||||
<span>已投递</span>
|
||||
<strong>{summary?.delivered ?? 0}</strong>
|
||||
<small>已成功下发给客户端的记录。</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface mini-status-card">
|
||||
<AlertTriangle size={22} />
|
||||
<div>
|
||||
<span>告警</span>
|
||||
<strong>{summary?.alertCount ?? 0}</strong>
|
||||
<small>近期失败 {summary?.recentFailed ?? 0} 条。</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-task-filter">
|
||||
<Input label="消息ID / 账号 / 手机号 / 错误" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
|
||||
<Select
|
||||
label="状态"
|
||||
options={[
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '待投递', value: 'pending' },
|
||||
{ label: '已投递', value: 'delivered' },
|
||||
{ label: '最终失败', value: 'failed' },
|
||||
]}
|
||||
value={status}
|
||||
onChange={(event) => {
|
||||
setStatus(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label="类型"
|
||||
options={[
|
||||
{ label: '全部类型', value: 'all' },
|
||||
{ label: '状态回执', value: 'receipt' },
|
||||
{ label: '上行短信', value: 'uplink' },
|
||||
]}
|
||||
value={deliveryType}
|
||||
onChange={(event) => {
|
||||
setDeliveryType(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);
|
||||
}}
|
||||
/>
|
||||
<div className="admin-task-filter__actions">
|
||||
<Button icon={<Search size={16} />} onClick={loadData}>查询</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setKeyword('');
|
||||
setStatus('all');
|
||||
setDeliveryType('all');
|
||||
setApplicationId('all');
|
||||
setPage(1);
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overview-grid">
|
||||
<div className="surface">
|
||||
<div className="section-heading">
|
||||
<h2>类型分布</h2>
|
||||
</div>
|
||||
<div className="downstream-breakdown-table">
|
||||
<div className="downstream-breakdown-table__head">
|
||||
<span>类型</span>
|
||||
<span>总量</span>
|
||||
<span>待投递</span>
|
||||
<span>已投递</span>
|
||||
<span>最终失败</span>
|
||||
</div>
|
||||
{typeBreakdown.map((item) => (
|
||||
<div className="downstream-breakdown-table__row" key={item.deliveryType}>
|
||||
<strong>{deliveryTypeLabel[item.deliveryType] ?? item.deliveryType}</strong>
|
||||
<span>{item.total}</span>
|
||||
<span>{item.pending}</span>
|
||||
<span>{item.delivered}</span>
|
||||
<span>{item.failed}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface">
|
||||
<div className="section-heading">
|
||||
<h2>重试压力</h2>
|
||||
</div>
|
||||
<div className="downstream-bucket-list">
|
||||
{retryBuckets.map((item) => (
|
||||
<div className="downstream-bucket-item" key={item.label}>
|
||||
<span>{item.label}</span>
|
||||
<strong>{item.count}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface">
|
||||
<div className="section-heading">
|
||||
<h2>应用告警排行</h2>
|
||||
</div>
|
||||
<div className="downstream-breakdown-table">
|
||||
<div className="downstream-breakdown-table__head downstream-breakdown-table__head--apps">
|
||||
<span>应用</span>
|
||||
<span>待投递</span>
|
||||
<span>最终失败</span>
|
||||
<span>已投递</span>
|
||||
<span>告警合计</span>
|
||||
</div>
|
||||
{topApplications.length > 0 ? topApplications.map((item) => (
|
||||
<div className="downstream-breakdown-table__row downstream-breakdown-table__row--apps" key={item.applicationId}>
|
||||
<strong>{item.name}</strong>
|
||||
<span>{item.pending}</span>
|
||||
<span>{item.failed}</span>
|
||||
<span>{item.delivered}</span>
|
||||
<span>{item.alertCount}</span>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="downstream-breakdown-table__empty">暂无应用告警数据</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-task-table-card report-task-table-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
||||
<p style={{ margin: 0, color: 'var(--text-secondary)' }}>
|
||||
已选择 {selectedIds.length} 条,可对 `pending/failed` 记录执行批量重投
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
disabled={selectableIds.length === 0}
|
||||
onClick={() => setSelectedIds(allSelected ? [] : selectableIds)}
|
||||
variant="ghost"
|
||||
>
|
||||
{allSelected ? '取消全选当前页' : '全选当前页'}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<RefreshCw size={14} />}
|
||||
disabled={selectedIds.length === 0}
|
||||
onClick={() => {
|
||||
adminApi.batchRequeueDownstreamDeliveries(selectedIds)
|
||||
.then((result) => {
|
||||
setError(result.failedCount > 0 ? `批量重投完成,成功 ${result.successCount} 条,失败 ${result.failedCount} 条` : '');
|
||||
loadData();
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '批量重投失败'));
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
批量重投
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Table columns={columns} data={records} emptyText={loading ? '加载中...' : '暂无下游投递记录'} pagination={false} rowKey="id" />
|
||||
<Pagination
|
||||
total={total}
|
||||
page={page}
|
||||
previousDisabled={page <= 1}
|
||||
nextDisabled={page >= totalPages}
|
||||
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
|
||||
onNext={() => setPage((current) => Math.min(totalPages, current + 1))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{detail ? <DeliveryDetailModal record={detail} onClose={() => setDetail(null)} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, CheckCircle2, Download, Eye, RefreshCw, Search, TimerReset } from 'lucide-react';
|
||||
import { adminApi, type EnterpriseApplication, type GatewayDownstreamRecoveryStatus } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
|
||||
const recoveryStatusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
|
||||
running: 'info',
|
||||
success: 'success',
|
||||
failed: 'danger',
|
||||
waiting_connection: 'warning',
|
||||
partial: 'warning',
|
||||
};
|
||||
|
||||
const recoveryStatusLabel: Record<string, string> = {
|
||||
running: '恢复中',
|
||||
success: '恢复成功',
|
||||
failed: '恢复失败',
|
||||
waiting_connection: '等待连接',
|
||||
partial: '部分成功',
|
||||
};
|
||||
|
||||
const failureCategoryTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
|
||||
client_disconnected: 'warning',
|
||||
backoff: 'warning',
|
||||
lock_contended: 'info',
|
||||
flush_failed: 'danger',
|
||||
partial_delivery_failed: 'danger',
|
||||
lock_lost: 'danger',
|
||||
unknown: 'neutral',
|
||||
};
|
||||
|
||||
const failureCategoryLabel: Record<string, string> = {
|
||||
client_disconnected: '客户未连接',
|
||||
backoff: '退避等待',
|
||||
lock_contended: '恢复锁占用',
|
||||
flush_failed: '恢复执行失败',
|
||||
partial_delivery_failed: '部分投递失败',
|
||||
lock_lost: '恢复锁丢失',
|
||||
unknown: '未知原因',
|
||||
};
|
||||
|
||||
function RecoveryDetailModal({ record, onClose }: { record: GatewayDownstreamRecoveryStatus; onClose: () => void }) {
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>恢复状态详情</h2><p>{record.account}</p></div>}
|
||||
footer={<Button onClick={onClose}>关闭</Button>}
|
||||
>
|
||||
<div className="report-record-detail">
|
||||
<div className="admin-detail-metric-grid admin-detail-metric-grid--compact">
|
||||
<div className="surface mini-status-card">
|
||||
<RefreshCw size={20} />
|
||||
<div>
|
||||
<span>当前状态</span>
|
||||
<strong>{recoveryStatusLabel[record.state] ?? record.state}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface mini-status-card">
|
||||
<TimerReset size={20} />
|
||||
<div>
|
||||
<span>尝试次数</span>
|
||||
<strong>{record.attemptCount}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface mini-status-card">
|
||||
<CheckCircle2 size={20} />
|
||||
<div>
|
||||
<span>下次恢复</span>
|
||||
<strong>{record.nextRetryAt ?? '-'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-grid">
|
||||
<div><span>企业</span><strong>{record.tenant?.name ?? '-'}</strong></div>
|
||||
<div><span>应用</span><strong>{record.application?.name ?? '-'}</strong></div>
|
||||
<div><span>账号</span><strong>{record.account}</strong></div>
|
||||
<div><span>Gateway 实例</span><strong>{record.gatewayInstanceId ?? '-'}</strong></div>
|
||||
<div><span>锁持有实例</span><strong>{record.lockOwner ?? '-'}</strong></div>
|
||||
<div><span>锁过期时间</span><strong>{record.lockExpiresAt ?? '-'}</strong></div>
|
||||
<div><span>失败分类</span><strong>{record.failureCategory ? failureCategoryLabel[record.failureCategory] ?? record.failureCategory : '-'}</strong></div>
|
||||
<div><span>最后尝试</span><strong>{record.lastAttemptAt ?? '-'}</strong></div>
|
||||
<div><span>恢复成功</span><strong>{record.lastSuccessAt ?? '-'}</strong></div>
|
||||
<div><span>恢复失败</span><strong>{record.lastFailureAt ?? '-'}</strong></div>
|
||||
<div><span>创建时间</span><strong>{record.createdAt}</strong></div>
|
||||
<div><span>更新时间</span><strong>{record.updatedAt}</strong></div>
|
||||
<div className="detail-grid__wide"><span>最后错误</span><strong>{record.lastError ?? '-'}</strong></div>
|
||||
<div className="detail-grid__wide"><span>最后跳过原因</span><strong>{record.lastSkipReason ?? '-'}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminDownstreamRecoveryStatusesPage() {
|
||||
const [items, setItems] = useState<GatewayDownstreamRecoveryStatus[]>([]);
|
||||
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
|
||||
const [summary, setSummary] = useState<{ total: number; running: number; success: number; failed: number; waitingConnection: number; backoff: number; failureCategories: Array<{ category: string; count: number }> } | null>(null);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [state, setState] = useState('all');
|
||||
const [failureCategory, setFailureCategory] = useState('all');
|
||||
const [applicationId, setApplicationId] = useState('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [detail, setDetail] = useState<GatewayDownstreamRecoveryStatus | null>(null);
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
adminApi.listDownstreamRecoveryStatuses({ keyword, state, failureCategory, applicationId, page, pageSize }),
|
||||
adminApi.listEnterpriseApplications(),
|
||||
])
|
||||
.then(([response, apps]) => {
|
||||
setItems(response.items);
|
||||
setSummary(response.summary);
|
||||
setTotal(response.total);
|
||||
setApplications(apps);
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => {
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
setSummary(null);
|
||||
setError(failure.message || '恢复状态加载失败');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [applicationId, failureCategory, keyword, page, pageSize, state]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const columns = useMemo<Array<TableColumn<GatewayDownstreamRecoveryStatus>>>(() => [
|
||||
{ key: 'updatedAt', title: '更新时间', width: '180px', render: (record) => record.updatedAt },
|
||||
{ key: 'account', title: '账号', width: '120px', render: (record) => <strong className="admin-task-id">{record.account}</strong> },
|
||||
{ key: 'tenant', title: '企业', width: '180px', render: (record) => record.tenant?.name ?? '-' },
|
||||
{ key: 'application', title: '应用', width: '180px', render: (record) => record.application?.name ?? '-' },
|
||||
{ key: 'gateway', title: 'Gateway实例', width: '180px', render: (record) => <span className="muted">{record.gatewayInstanceId ?? '-'}</span> },
|
||||
{ key: 'lockOwner', title: '锁持有', width: '150px', render: (record) => <span className="muted">{record.lockOwner ?? '-'}</span> },
|
||||
{ key: 'state', title: '状态', width: '120px', render: (record) => <Tag tone={recoveryStatusTone[record.state] ?? 'info'}>{recoveryStatusLabel[record.state] ?? record.state}</Tag> },
|
||||
{ key: 'failureCategory', title: '失败分类', width: '140px', render: (record) => record.failureCategory ? <Tag tone={failureCategoryTone[record.failureCategory] ?? 'neutral'}>{failureCategoryLabel[record.failureCategory] ?? record.failureCategory}</Tag> : '-' },
|
||||
{ key: 'attemptCount', title: '尝试次数', width: '96px', align: 'center', render: (record) => record.attemptCount },
|
||||
{ key: 'nextRetryAt', title: '下次恢复', width: '180px', render: (record) => record.nextRetryAt ?? '-' },
|
||||
{ key: 'lastError', title: '最后错误/跳过原因', render: (record) => record.lastError ?? record.lastSkipReason ?? '-' },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '110px',
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<Button
|
||||
icon={<Eye size={14} />}
|
||||
onClick={() => {
|
||||
adminApi.getDownstreamRecoveryStatus(record.id)
|
||||
.then((data) => {
|
||||
setDetail(data);
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '恢复状态详情加载失败'));
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
], []);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const failureCategoryBreakdown = summary?.failureCategories ?? [];
|
||||
|
||||
function resetFilters() {
|
||||
setKeyword('');
|
||||
setState('all');
|
||||
setFailureCategory('all');
|
||||
setApplicationId('all');
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
async function exportCurrent() {
|
||||
setExporting(true);
|
||||
try {
|
||||
const blob = await adminApi.exportDownstreamRecoveryStatuses({ keyword, state, failureCategory, applicationId });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
||||
anchor.href = url;
|
||||
anchor.download = `recovery-statuses-${timestamp}.csv`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
URL.revokeObjectURL(url);
|
||||
setError('');
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '恢复状态导出失败');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-sms-task-page report-record-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<Breadcrumb items={['发送运维', '恢复状态管理']} />
|
||||
<h1>恢复状态管理</h1>
|
||||
</div>
|
||||
<Button icon={<Download size={16} />} onClick={exportCurrent} variant="secondary" disabled={exporting}>
|
||||
{exporting ? '导出中...' : '导出当前筛选'}
|
||||
</Button>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="dashboard-grid">
|
||||
<div className="surface mini-status-card">
|
||||
<RefreshCw size={22} />
|
||||
<div>
|
||||
<span>恢复总量</span>
|
||||
<strong>{summary?.total ?? 0}</strong>
|
||||
<small>来自真实 PostgreSQL 恢复状态表。</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface mini-status-card">
|
||||
<TimerReset size={22} />
|
||||
<div>
|
||||
<span>等待连接</span>
|
||||
<strong>{summary?.waitingConnection ?? 0}</strong>
|
||||
<small>客户尚未重连,暂不可恢复。</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface mini-status-card">
|
||||
<CheckCircle2 size={22} />
|
||||
<div>
|
||||
<span>恢复成功</span>
|
||||
<strong>{summary?.success ?? 0}</strong>
|
||||
<small>最近一次恢复已成功完成。</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface mini-status-card">
|
||||
<AlertTriangle size={22} />
|
||||
<div>
|
||||
<span>退避中</span>
|
||||
<strong>{summary?.backoff ?? 0}</strong>
|
||||
<small>当前处于退避窗口,稍后自动再试。</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface">
|
||||
<div className="section-heading">
|
||||
<h2>失败分类分布</h2>
|
||||
<p className="page-inline-hint">按当前筛选条件统计最近一次恢复失败的归因。</p>
|
||||
</div>
|
||||
<div className="downstream-bucket-list downstream-bucket-list--wrap">
|
||||
{failureCategoryBreakdown.length > 0 ? failureCategoryBreakdown.map((item) => (
|
||||
<div className="downstream-bucket-item" key={item.category}>
|
||||
<span>{failureCategoryLabel[item.category] ?? item.category}</span>
|
||||
<strong>{item.count}</strong>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="downstream-breakdown-table__empty">暂无失败分类数据</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-task-filter">
|
||||
<Input label="账号 / 企业 / 应用 / 错误" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
|
||||
<Select
|
||||
label="状态"
|
||||
options={[
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '恢复中', value: 'running' },
|
||||
{ label: '恢复成功', value: 'success' },
|
||||
{ label: '恢复失败', value: 'failed' },
|
||||
{ label: '等待连接', value: 'waiting_connection' },
|
||||
{ label: '部分成功', value: 'partial' },
|
||||
]}
|
||||
value={state}
|
||||
onChange={(event) => {
|
||||
setState(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label="失败分类"
|
||||
options={[
|
||||
{ label: '全部分类', value: 'all' },
|
||||
{ label: '客户未连接', value: 'client_disconnected' },
|
||||
{ label: '退避等待', value: 'backoff' },
|
||||
{ label: '恢复锁占用', value: 'lock_contended' },
|
||||
{ label: '恢复执行失败', value: 'flush_failed' },
|
||||
{ label: '部分投递失败', value: 'partial_delivery_failed' },
|
||||
{ label: '恢复锁丢失', value: 'lock_lost' },
|
||||
{ label: '未知原因', value: 'unknown' },
|
||||
]}
|
||||
value={failureCategory}
|
||||
onChange={(event) => {
|
||||
setFailureCategory(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);
|
||||
}}
|
||||
/>
|
||||
<div className="admin-task-filter__actions">
|
||||
<Button icon={<Search size={16} />} onClick={loadData}>查询</Button>
|
||||
<Button onClick={resetFilters} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-task-table-card report-task-table-card">
|
||||
<div className="section-heading">
|
||||
<h2>恢复状态列表</h2>
|
||||
<p className="page-inline-hint">支持筛选、详情查看与当前结果导出。</p>
|
||||
</div>
|
||||
<Table columns={columns} data={items} emptyText={loading ? '加载中...' : '暂无恢复状态'} pagination={false} rowKey="id" />
|
||||
<Pagination
|
||||
total={total}
|
||||
page={page}
|
||||
previousDisabled={page <= 1}
|
||||
nextDisabled={page >= totalPages}
|
||||
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
|
||||
onNext={() => setPage((current) => Math.min(totalPages, current + 1))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{detail ? <RecoveryDetailModal record={detail} onClose={() => setDetail(null)} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -84,6 +84,7 @@ export function AdminHome() {
|
||||
const averageSuccessRate = dashboard?.today.successRate ?? 0;
|
||||
const todaySpend = (dashboard?.today.spendCents ?? 0) / 100;
|
||||
const activeConnectionCount = dashboard?.gatewayConnections.reduce((sum, item) => sum + (item._sum.currentConnections ?? 0), 0) ?? 0;
|
||||
const downstreamAlertCount = dashboard?.downstreamDeliverySummary?.alertCount ?? 0;
|
||||
|
||||
const sendTrendOption = useMemo(
|
||||
() => createLineOption({
|
||||
@@ -253,6 +254,14 @@ export function AdminHome() {
|
||||
<small>在线连接数。</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mini-status-card">
|
||||
<RadioTower size={22} />
|
||||
<div>
|
||||
<span>下游投递告警</span>
|
||||
<strong>{downstreamAlertCount} 条</strong>
|
||||
<small>积压过久或近期失败。</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,9 @@ export function AdminSmsApplicationFormPage() {
|
||||
const [dailyLimit, setDailyLimit] = useState('100000');
|
||||
const [customerUnitPrice, setCustomerUnitPrice] = useState('0.0300');
|
||||
const [queuePriority, setQueuePriority] = useState<QueuePriority>('normal');
|
||||
const [cmppAccount, setCmppAccount] = useState('');
|
||||
const [cmppMaxConnections, setCmppMaxConnections] = useState('1');
|
||||
const [cmppWindowSize, setCmppWindowSize] = useState('16');
|
||||
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10');
|
||||
const [mismatchPolicy, setMismatchPolicy] = useState('manual_review');
|
||||
const [ipAddress, setIpAddress] = useState('');
|
||||
@@ -75,6 +78,9 @@ export function AdminSmsApplicationFormPage() {
|
||||
setDailyLimit(application.dailyLimit ? String(application.dailyLimit) : '');
|
||||
setCustomerUnitPrice(((application.customerUnitPrice ?? 0) / 100).toFixed(4));
|
||||
setQueuePriority(application.queuePriority === 'priority' ? 'priority' : 'normal');
|
||||
setCmppAccount(application.cmppAccount ?? '');
|
||||
setCmppMaxConnections(String(application.cmppMaxConnections ?? 1));
|
||||
setCmppWindowSize(String(application.cmppWindowSize ?? 16));
|
||||
setPhoneDailyLimit(application.maxPhonesPerTask ? String(application.maxPhonesPerTask) : '');
|
||||
setMismatchPolicy(application.templateMismatchMode ?? 'reject');
|
||||
setIpAddress(application.ipAllowlist?.map((item) => item.ipCidr).join('\n') ?? '');
|
||||
@@ -110,6 +116,9 @@ export function AdminSmsApplicationFormPage() {
|
||||
dailyLimit: Number(dailyLimit) || undefined,
|
||||
customerUnitPrice: Math.round(Number(customerUnitPrice || 0) * 100),
|
||||
queuePriority,
|
||||
cmppAccount: cmppAccount.trim() || undefined,
|
||||
cmppMaxConnections: Number(cmppMaxConnections) || 1,
|
||||
cmppWindowSize: Number(cmppWindowSize) || 16,
|
||||
maxPhonesPerTask: Number(phoneDailyLimit) || undefined,
|
||||
templateMismatchMode: mismatchPolicy,
|
||||
ipAllowlist: parseIpAllowlist(ipAddress),
|
||||
@@ -167,6 +176,9 @@ export function AdminSmsApplicationFormPage() {
|
||||
<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 value={customerUnitPrice} />
|
||||
<Input label="CMPP 6位账号" onChange={(event) => setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} />
|
||||
<Input label="客户最大连接数" onChange={(event) => setCmppMaxConnections(event.target.value)} placeholder="1" required value={cmppMaxConnections} />
|
||||
<Input label="客户提交窗口" onChange={(event) => setCmppWindowSize(event.target.value)} placeholder="16" required value={cmppWindowSize} />
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<span>发送队列</span>
|
||||
<div className="radio-row">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { MessageSquare, Search, Smartphone } from 'lucide-react';
|
||||
import { adminApi, type SmsMessageRecord } from '@/api/adminApi';
|
||||
import { adminApi, type SmsMessageRecord, type SmsMessageSegmentAudit } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Tag, type DateRangeValue, type TableColumn, Table } from '@/components/ui';
|
||||
|
||||
const statusLabelMap: Record<string, string> = {
|
||||
@@ -28,6 +28,8 @@ export function AdminSmsRecordsPage() {
|
||||
const [contentKeyword, setContentKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [selectedRecord, setSelectedRecord] = useState<SmsMessageRecord | null>(null);
|
||||
const [segmentAudits, setSegmentAudits] = useState<SmsMessageSegmentAudit[]>([]);
|
||||
const [segmentLoading, setSegmentLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function loadData() {
|
||||
@@ -43,6 +45,21 @@ export function AdminSmsRecordsPage() {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRecord) {
|
||||
setSegmentAudits([]);
|
||||
return;
|
||||
}
|
||||
setSegmentLoading(true);
|
||||
adminApi.listMessageSegmentAudits({ messageRecordId: selectedRecord.id })
|
||||
.then((items) => {
|
||||
setSegmentAudits(items);
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '分片审计加载失败'))
|
||||
.finally(() => setSegmentLoading(false));
|
||||
}, [selectedRecord]);
|
||||
|
||||
const filteredRows = useMemo(
|
||||
() => records.filter((item) => {
|
||||
const submittedDate = item.queuedAt.slice(0, 10);
|
||||
@@ -64,6 +81,18 @@ export function AdminSmsRecordsPage() {
|
||||
{ key: 'actions', title: '操作', width: '120px', align: 'right', render: (record) => <Button onClick={() => setSelectedRecord(record)} size="sm" variant="ghost">详情</Button> },
|
||||
];
|
||||
|
||||
const segmentColumns: Array<TableColumn<SmsMessageSegmentAudit>> = [
|
||||
{ key: 'segment', title: '分片', width: '90px', render: (record) => `${record.segmentIndex}/${record.segmentTotal}` },
|
||||
{ key: 'submitId', title: '提交ID', width: '190px', render: (record) => <strong className="admin-task-id">{record.submitId}</strong> },
|
||||
{ key: 'channel', title: '通道', width: '150px', render: (record) => record.channel?.name ?? record.channelId ?? '-' },
|
||||
{ key: 'sequenceId', title: 'Sequence', width: '110px', render: (record) => record.sequenceId ?? '-' },
|
||||
{ key: 'gatewayMessageId', title: 'MsgId', width: '180px', render: (record) => record.gatewayMessageId ?? '-' },
|
||||
{ key: 'submitStatus', title: '提交状态', width: '110px', render: (record) => <Tag tone={record.submitStatus === 'accepted' ? 'success' : record.submitStatus === 'queued' ? 'info' : 'danger'}>{record.submitStatus}</Tag> },
|
||||
{ key: 'receiptStatus', title: '回执状态', width: '110px', render: (record) => record.receiptStatus ? <Tag tone={record.receiptStatus === 'delivered' ? 'success' : record.receiptStatus === 'unknown' ? 'neutral' : 'danger'}>{record.receiptStatus}</Tag> : '-' },
|
||||
{ key: 'compensation', title: '补偿', width: '120px', render: (record) => record.compensationType ?? '-' },
|
||||
{ key: 'error', title: '错误', render: (record) => record.errorMessage ?? record.errorCode ?? '-' },
|
||||
];
|
||||
|
||||
function resetFilters() {
|
||||
setDateRange({});
|
||||
setPhoneKeyword('');
|
||||
@@ -125,6 +154,16 @@ export function AdminSmsRecordsPage() {
|
||||
<p>状态:{statusLabelMap[selectedRecord.status] ?? selectedRecord.status}</p>
|
||||
<p>失败原因:{selectedRecord.errorMessage ?? '-'}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>分片补偿审计</h3>
|
||||
<Table
|
||||
columns={segmentColumns}
|
||||
data={segmentAudits}
|
||||
emptyText={segmentLoading ? '加载中...' : '暂无分片审计'}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Search, Smartphone } from 'lucide-react';
|
||||
import { adminApi, type SmsMessageRecord, type SmsUplinkMessage } from '@/api/adminApi';
|
||||
import { adminApi, type SmsMessageRecord, type SmsUplinkMatchCandidate, type SmsUplinkMessage } from '@/api/adminApi';
|
||||
import {
|
||||
Breadcrumb,
|
||||
Button,
|
||||
@@ -20,19 +20,44 @@ function getTime(value?: string | null) {
|
||||
return value ? `${value.slice(0, 10)} ${value.slice(11, 19)}` : '-';
|
||||
}
|
||||
|
||||
function matchStatusText(status?: string | null) {
|
||||
const map: Record<string, string> = {
|
||||
matched: '已匹配',
|
||||
ambiguous: '待认领',
|
||||
unmatched: '未匹配',
|
||||
};
|
||||
return status ? (map[status] ?? status) : '-';
|
||||
}
|
||||
|
||||
function candidateStatusText(status?: string | null) {
|
||||
const map: Record<string, string> = {
|
||||
pending: '待认领',
|
||||
claimed: '已认领',
|
||||
rejected: '已排除',
|
||||
};
|
||||
return status ? (map[status] ?? status) : '-';
|
||||
}
|
||||
|
||||
function UplinkDetailModal({
|
||||
claimError,
|
||||
claimingId,
|
||||
detailError,
|
||||
matchedRecords,
|
||||
matching,
|
||||
message,
|
||||
onClaim,
|
||||
onClose,
|
||||
}: {
|
||||
claimError: string;
|
||||
claimingId: string;
|
||||
detailError: string;
|
||||
matchedRecords: SmsMessageRecord[];
|
||||
matching: boolean;
|
||||
message: SmsUplinkMessage;
|
||||
onClaim: (candidate: SmsUplinkMatchCandidate) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const candidates = message.matchCandidates ?? [];
|
||||
return (
|
||||
<Modal
|
||||
footer={<Button onClick={onClose} variant="ghost">关闭</Button>}
|
||||
@@ -69,6 +94,10 @@ function UplinkDetailModal({
|
||||
<span>网关消息ID</span>
|
||||
<strong>{message.messageId || '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>匹配状态</span>
|
||||
<strong>{matchStatusText(message.matchStatus)}</strong>
|
||||
</div>
|
||||
<div className="admin-uplink-info-grid__full">
|
||||
<span>上行内容</span>
|
||||
<strong>{message.content || '-'}</strong>
|
||||
@@ -76,6 +105,58 @@ function UplinkDetailModal({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-uplink-match-section">
|
||||
<h3>候选认领</h3>
|
||||
{claimError ? <p className="form-error">{claimError}</p> : null}
|
||||
{candidates.length === 0 ? <div className="admin-uplink-empty-match">暂无人工认领候选</div> : null}
|
||||
{candidates.map((candidate) => (
|
||||
<article className="admin-uplink-match-card" key={candidate.id}>
|
||||
<div className="admin-uplink-match-grid">
|
||||
<div>
|
||||
<span>候选企业</span>
|
||||
<strong>{candidate.tenant?.name ?? candidate.tenantId}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>候选应用</span>
|
||||
<strong>{candidate.application?.name ?? candidate.applicationId}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>候选来源</span>
|
||||
<strong>{candidate.matchSource === 'access_number' ? '接入号' : '手机号时间窗'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>置信度</span>
|
||||
<strong>{candidate.confidence}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>状态</span>
|
||||
<strong>{candidateStatusText(candidate.status)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>下发短信</span>
|
||||
<strong>{candidate.messageRecord?.messageId ?? '-'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{candidate.messageRecord ? (
|
||||
<div className="admin-uplink-match-content">
|
||||
<span>下发内容</span>
|
||||
<p>{candidate.messageRecord.content}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="admin-uplink-candidate-footer">
|
||||
<span>{candidate.reason ?? '-'}</span>
|
||||
<Button
|
||||
disabled={claimingId === candidate.id || candidate.status === 'claimed' || candidate.status === 'rejected'}
|
||||
onClick={() => onClaim(candidate)}
|
||||
size="sm"
|
||||
>
|
||||
{candidate.status === 'claimed' ? '已认领' : claimingId === candidate.id ? '认领中...' : '认领并推送'}
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="admin-uplink-match-section">
|
||||
<h3>匹配发送记录</h3>
|
||||
{matching ? <p>正在查询真实下发记录...</p> : null}
|
||||
@@ -125,8 +206,10 @@ export function AdminSmsUplinkRecordsPage() {
|
||||
const [selectedMessage, setSelectedMessage] = useState<SmsUplinkMessage | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [matching, setMatching] = useState(false);
|
||||
const [claimingId, setClaimingId] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [detailError, setDetailError] = useState('');
|
||||
const [claimError, setClaimError] = useState('');
|
||||
|
||||
function loadData() {
|
||||
setLoading(true);
|
||||
@@ -143,6 +226,7 @@ export function AdminSmsUplinkRecordsPage() {
|
||||
setSelectedMessage(message);
|
||||
setMatchedRecords([]);
|
||||
setDetailError('');
|
||||
setClaimError('');
|
||||
|
||||
if (!message.messageId) {
|
||||
return;
|
||||
@@ -177,6 +261,22 @@ export function AdminSmsUplinkRecordsPage() {
|
||||
setContentKeyword('');
|
||||
}
|
||||
|
||||
function handleClaim(candidate: SmsUplinkMatchCandidate) {
|
||||
if (!selectedMessage) {
|
||||
return;
|
||||
}
|
||||
setClaimingId(candidate.id);
|
||||
setClaimError('');
|
||||
adminApi.claimUplinkMatchCandidate(selectedMessage.id, { candidateId: candidate.id })
|
||||
.then((updated) => {
|
||||
setMessages((items) => items.map((item) => (item.id === updated.id ? { ...item, ...updated } : item)));
|
||||
setSelectedMessage((current) => (current && current.id === updated.id ? { ...current, ...updated } : current));
|
||||
loadData();
|
||||
})
|
||||
.catch((reason: Error) => setClaimError(reason.message || '上行认领失败'))
|
||||
.finally(() => setClaimingId(''));
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<SmsUplinkMessage>> = [
|
||||
{
|
||||
key: 'select',
|
||||
@@ -190,6 +290,7 @@ export function AdminSmsUplinkRecordsPage() {
|
||||
{ key: 'content', title: '上行内容', render: (record) => <span className="uplink-content">{record.content}</span> },
|
||||
{ key: 'channel', title: '上行通道', width: '260px', render: (record) => <strong>{record.channel?.name ?? record.channelId}</strong> },
|
||||
{ key: 'accessNo', title: '上行接入号', width: '180px', render: (record) => <strong>{record.destId}</strong> },
|
||||
{ key: 'matchStatus', title: '匹配状态', width: '140px', render: (record) => <strong>{matchStatusText(record.matchStatus)}</strong> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
@@ -228,10 +329,13 @@ export function AdminSmsUplinkRecordsPage() {
|
||||
|
||||
{selectedMessage ? (
|
||||
<UplinkDetailModal
|
||||
claimError={claimError}
|
||||
claimingId={claimingId}
|
||||
detailError={detailError}
|
||||
matchedRecords={matchedRecords}
|
||||
matching={matching}
|
||||
message={selectedMessage}
|
||||
onClaim={handleClaim}
|
||||
onClose={() => setSelectedMessage(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user