feat: harden CMPP delivery and platform workflows

This commit is contained in:
hectorzhao
2026-07-20 18:07:29 +08:00
parent 80fb5a8f53
commit f02c33cbb7
61 changed files with 1834 additions and 281 deletions
+2 -1
View File
@@ -119,6 +119,7 @@ export function AdminCustomerFormPage() {
const nextErrors: EnterpriseFormErrors = {};
if (!form.name.trim()) nextErrors.name = '请填写企业名称';
if (!form.creditCode.trim()) nextErrors.creditCode = '请填写统一社会信用代码';
else if (!/^[A-Za-z0-9]+$/.test(form.creditCode.trim())) nextErrors.creditCode = '统一社会信用代码只能包含英文字母和数字';
if (!form.contactName.trim()) nextErrors.contactName = '请填写联系人姓名';
if (!form.contactPhone.trim()) nextErrors.contactPhone = '请填写手机号';
const creditLimit = Number(form.creditLimit);
@@ -210,7 +211,7 @@ export function AdminCustomerFormPage() {
error={errors.creditCode}
hint="修改此项将同步更新该企业档案。"
label="统一社会信用代码"
onChange={(event) => updateForm('creditCode', event.target.value)}
onChange={(event) => updateForm('creditCode', event.target.value.replace(/[^A-Za-z0-9]/g, ''))}
placeholder="请填写统一社会信用代码或纳税识别号"
required
value={form.creditCode}
@@ -252,22 +252,20 @@ function CmppConnectionModal({
<div><span>AppID</span><strong>{app.appId}</strong></div>
<div><span></span><Tag tone={app.cmppStatus === 'connected' ? 'success' : app.cmppStatus === 'disconnected' ? 'danger' : 'neutral'}>{app.cmppStatus === 'connected' ? '在线' : app.cmppStatus === 'disconnected' ? '离线' : '未开通'}</Tag></div>
</div>
<Table
columns={[
{ key: 'id', title: '连接ID', width: '150px', render: (record: CmppConnection) => <strong>{record.id}</strong> },
{ key: 'state', title: '状态', width: '130px', render: (record: CmppConnection) => <Tag tone={connectionStateMeta[record.state].tone}>{connectionStateMeta[record.state].label}</Tag> },
{ key: 'bindType', title: '绑定类型', width: '120px', render: (record: CmppConnection) => record.bindType },
{ key: 'clientIp', title: '客户端IP', width: '170px', render: (record: CmppConnection) => record.clientIp },
{ key: 'sourceAddr', title: '企业代码', width: '120px', render: (record: CmppConnection) => record.sourceAddr },
{ key: 'establishedAt', title: '连接建立时间', width: '180px', render: (record: CmppConnection) => record.establishedAt },
{ key: 'lastHeartbeatAt', title: '上次心跳', width: '180px', render: (record: CmppConnection) => record.lastHeartbeatAt },
{ key: 'lastSubmitAt', title: '上次提交', width: '180px', render: (record: CmppConnection) => record.lastSubmitAt },
{ key: 'pendingWindow', title: '窗口占用', align: 'right', width: '120px', render: (record: CmppConnection) => record.pendingWindow },
]}
data={activeConnectionItems}
emptyText="当前暂无已连接的 CMPP 会话"
rowKey="id"
/>
{activeConnectionItems.length ? <div className="cmpp-connection-list">{activeConnectionItems.map((record) => (
<article className="cmpp-connection-card" key={record.id}>
<div className="cmpp-connection-card__heading"><strong>{record.id}</strong><Tag tone={connectionStateMeta[record.state].tone}>{connectionStateMeta[record.state].label}</Tag></div>
<div className="cmpp-connection-card__grid">
<div><span></span><strong>{record.bindType}</strong></div>
<div><span> IP</span><strong>{record.clientIp}</strong></div>
<div><span></span><strong>{record.sourceAddr}</strong></div>
<div><span></span><strong>{record.pendingWindow}</strong></div>
<div><span></span><strong>{record.establishedAt}</strong></div>
<div><span></span><strong>{record.lastHeartbeatAt}</strong></div>
<div><span></span><strong>{record.lastSubmitAt}</strong></div>
</div>
</article>
))}</div> : <div className="ui-table__empty"> CMPP </div>}
</div>
</Modal>
);
@@ -461,8 +459,8 @@ export function AdminEnterpriseApplicationsPage() {
value={status}
/>
<div className="admin-split-filter__actions">
<Button icon={<Search size={16} />} onClick={() => { setAppliedEnterpriseKeyword(enterpriseKeyword.trim()); setAppliedApplicationKeyword(applicationKeyword.trim()); setAppliedStatus(status); }}></Button>
<Button onClick={() => { setEnterpriseKeyword(''); setApplicationKeyword(''); setStatus('all'); setAppliedEnterpriseKeyword(''); setAppliedApplicationKeyword(''); setAppliedStatus('all'); }} variant="ghost"></Button>
<Button icon={<Search size={16} />} onClick={() => { const filters = { enterpriseKeyword: enterpriseKeyword.trim(), applicationKeyword: applicationKeyword.trim(), status }; setAppliedEnterpriseKeyword(filters.enterpriseKeyword); setAppliedApplicationKeyword(filters.applicationKeyword); setAppliedStatus(filters.status); void loadSmsApps(filters); }}></Button>
<Button onClick={() => { const filters = { enterpriseKeyword: '', applicationKeyword: '', status: 'all' }; setEnterpriseKeyword(''); setApplicationKeyword(''); setStatus('all'); setAppliedEnterpriseKeyword(''); setAppliedApplicationKeyword(''); setAppliedStatus('all'); void loadSmsApps(filters); }} variant="ghost"></Button>
</div>
</div>
@@ -509,7 +509,7 @@ function DrainageReportModal({ item, onClose, signature }: { item: DrainageInfo;
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title="引流信息报备详情">
<div className="detail-grid">
<div><span></span><strong>{item.siteName}</strong></div>
<div><span></span><strong>{item.url}</strong></div>
<div><span>url或号码</span><strong>{item.url}</strong></div>
<div><span></span><CarrierReportTag summary={summary?.mobile} /></div>
<div><span></span><CarrierReportTag summary={summary?.unicom} /></div>
<div><span></span><CarrierReportTag summary={summary?.telecom} /></div>
@@ -717,8 +717,7 @@ export function AdminEnterpriseSignaturesPage() {
{visibleDrainageLinks.length ? (
<div className="drainage-table">
<div className="drainage-table__head">
<span></span>
<span>URL</span>
<span>url或号码</span>
<span></span>
<span></span>
<span></span>
@@ -729,7 +728,6 @@ export function AdminEnterpriseSignaturesPage() {
const summary = signature.drainageCarrierReportSummary?.[item.id];
return (
<div className="drainage-table__row" key={item.id}>
<strong>{item.siteName}</strong>
<span className="drainage-table__url" title={item.url}>{item.url}</span>
<AuditStatusTag status={item.auditStatus ?? 'pending'} />
<CarrierReportTag summary={summary?.mobile} />
+29 -1
View File
@@ -24,6 +24,12 @@ const carrierTone: Record<string, 'success' | 'info' | 'warning' | 'neutral'> =
: 'warning',
};
const ruleCarrierMeta: Record<string, { label: string; tone: 'success' | 'info' | 'warning' | 'neutral' }> = {
mobile: { label: '中国移动', tone: 'success' },
unicom: { label: '中国联通', tone: 'info' },
telecom: { label: '中国电信', tone: 'warning' },
};
type PhoneSegmentSummaryProps = {
icon: ReactNode;
label: string;
@@ -69,6 +75,7 @@ export function AdminPhoneSegmentsPage() {
const [rulePage, setRulePage] = useState(1);
const [reloadKey, setReloadKey] = useState(0);
const [deleteTarget, setDeleteTarget] = useState<PhoneSegment | null>(null);
const [ruleDeleteTarget, setRuleDeleteTarget] = useState<CarrierRule | null>(null);
useEffect(() => {
let cancelled = false;
@@ -146,6 +153,16 @@ export function AdminPhoneSegmentsPage() {
.catch((failure: Error) => setError(failure.message || '手机号段删除失败'));
}
function deleteCarrierRule() {
if (!ruleDeleteTarget) return;
adminApi.deletePhoneCarrierRule(ruleDeleteTarget.id)
.then(() => {
setRuleDeleteTarget(null);
setReloadKey((current) => current + 1);
})
.catch((failure: Error) => setError(failure.message || '运营商区分规则删除失败'));
}
const columns = useMemo<Array<TableColumn<PhoneSegment>>>(() => [
{ key: 'segment', title: '手机号段(前7位)', width: '190px', render: (record) => <strong className="phone-segment-prefix">{record.prefix}</strong> },
{ key: 'carrier', title: '运营商', width: '130px', render: (record) => record.carrier ? <Tag tone={carrierTone[record.carrier] ?? 'neutral'}>{record.carrier}</Tag> : '-' },
@@ -156,10 +173,11 @@ export function AdminPhoneSegmentsPage() {
], []);
const ruleColumns = useMemo<Array<TableColumn<CarrierRule>>>(() => [
{ key: 'carrier', title: '运营商', width: '140px', render: (record) => record.carrier ?? '-' },
{ key: 'carrier', title: '运营商', width: '140px', render: (record) => { const meta = ruleCarrierMeta[record.carrier ?? '']; return meta ? <Tag tone={meta.tone}>{meta.label}</Tag> : record.carrier ?? '-'; } },
{ key: 'pattern', title: '号码前缀正则', render: (record) => <strong>{record.pattern}</strong> },
{ key: 'priority', title: '优先级', width: '120px', render: (record) => record.priority ?? 100 },
{ key: 'remark', title: '备注', render: (record) => record.remark ?? '-' },
{ key: 'actions', title: '操作', width: '90px', align: 'right', render: (record) => <Button className="phone-segment-delete" icon={<Trash2 size={14} />} onClick={() => setRuleDeleteTarget(record)} size="sm" variant="ghost"></Button> },
], []);
const queryPanel = (
@@ -304,6 +322,16 @@ export function AdminPhoneSegmentsPage() {
<p>{deleteTarget.prefix}使</p>
</Modal>
) : null}
{ruleDeleteTarget ? (
<Modal
footer={<><Button onClick={() => setRuleDeleteTarget(null)} variant="ghost"></Button><Button onClick={deleteCarrierRule} variant="danger"></Button></>}
onClose={() => setRuleDeleteTarget(null)}
open
title="删除运营商区分规则"
>
<p>{ruleDeleteTarget.pattern}使</p>
</Modal>
) : null}
</section>
);
}
+5 -5
View File
@@ -74,12 +74,12 @@ export function AdminProfitReportsPage() {
<div className="surface">
<div className="ui-table-wrap">
<table className="ui-table">
<thead><tr><th></th><th>{dimensionType === 'application' ? '企业 / 企业应用' : '通道'}</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<thead><tr><th></th><th>{dimensionType === 'application' ? '企业 / 企业应用' : '通道'}</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{error ? <tr><td className="ui-table__empty" colSpan={9}>{error}</td></tr>
: loading ? <tr><td className="ui-table__empty" colSpan={9}>...</td></tr>
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={9}></td></tr>
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>¥{formatCents(row.revenueCents)}</td><td>¥{formatCents(row.costCents)}</td><td style={{ color: row.profitCents < 0 ? 'var(--danger)' : undefined }}>¥{formatCents(row.profitCents)}</td><td>{(row.profitRateBps / 100).toFixed(2)}%</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
{error ? <tr><td className="ui-table__empty" colSpan={11}>{error}</td></tr>
: loading ? <tr><td className="ui-table__empty" colSpan={11}>...</td></tr>
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={11}></td></tr>
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>¥{formatCents(row.revenueCents)}</td><td>¥{formatCents(row.refundCents)}</td><td>¥{formatCents(row.costCents)}</td><td style={{ color: row.profitCents < 0 ? 'var(--danger)' : undefined }}>¥{formatCents(row.profitCents)}</td><td>{(row.profitRateBps / 100).toFixed(2)}%</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
</tbody>
</table>
</div>
+5 -5
View File
@@ -81,12 +81,12 @@ export function AdminQualityReportsPage() {
<div className="surface">
<div className="ui-table-wrap">
<table className="ui-table">
<thead><tr><th></th><th>{dimensionLabels[dimension]}</th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<thead><tr><th></th><th>{dimensionLabels[dimension]}</th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{error ? <tr><td className="ui-table__empty" colSpan={7}>{error}</td></tr>
: loading ? <tr><td className="ui-table__empty" colSpan={7}>...</td></tr>
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={7}></td></tr>
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{(row.successRateBps / 100).toFixed(2)}%</td><td>{formatDuration(row.avgArrivalMs)}</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
{error ? <tr><td className="ui-table__empty" colSpan={8}>{error}</td></tr>
: loading ? <tr><td className="ui-table__empty" colSpan={8}>...</td></tr>
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={8}></td></tr>
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>{(row.successRateBps / 100).toFixed(2)}%</td><td>{formatDuration(row.avgArrivalMs)}</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
</tbody>
</table>
</div>
@@ -80,12 +80,12 @@ export function AdminReconciliationReportsPage() {
<div className="surface">
<div className="ui-table-wrap">
<table className="ui-table">
<thead><tr><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<thead><tr><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{error ? <tr><td className="ui-table__empty" colSpan={6}>{error}</td></tr>
: loading ? <tr><td className="ui-table__empty" colSpan={6}>...</td></tr>
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={6}></td></tr>
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td>{row.tenantName}</td><td>{row.applicationName}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
{error ? <tr><td className="ui-table__empty" colSpan={7}>{error}</td></tr>
: loading ? <tr><td className="ui-table__empty" colSpan={7}>...</td></tr>
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={7}></td></tr>
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td>{row.tenantName}</td><td>{row.applicationName}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
</tbody>
</table>
</div>
+15 -4
View File
@@ -54,10 +54,10 @@ export function AdminSmsApplicationFormPage() {
const [downstreamUplinkRetryEnabled, setDownstreamUplinkRetryEnabled] = useState(true);
const [ipAddress, setIpAddress] = useState('');
const [httpConfig, setHttpConfig] = useState<HttpApiConfig>({
enabled: false, sendEnabled: false, messageQueryEnabled: false, receiptWebhookEnabled: false,
uplinkWebhookEnabled: false, uplinkQueryEnabled: false, credentialSelfServiceEnabled: false,
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: 'cmpp', uplinkDeliveryMode: 'cmpp',
maxQueryRangeDays: 31, maxPageSize: 100, receiptDeliveryMode: 'http', uplinkDeliveryMode: 'http',
webhookRetryEnabled: true, webhookMaxAttempts: 7, webhookTimeoutSeconds: 10, requireHttps: true,
allowClientManualRetry: true, allowClientTest: true,
});
@@ -354,7 +354,18 @@ export function AdminSmsApplicationFormPage() {
<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 }))} 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">
+32 -7
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
import { CalendarDays, Check, Search, X } from 'lucide-react';
import { CalendarDays, Check, Info, Search, X } from 'lucide-react';
import { adminApi, type RiskReviewTask } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
const statusLabel: Record<string, string> = {
pending_review: '待审核',
@@ -29,6 +30,11 @@ export function AdminSmsAuditPage() {
const [rejectTarget, setRejectTarget] = useState<RiskReviewTask | 'batch' | null>(null);
const [rejectReason, setRejectReason] = useState('');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [detailTarget, setDetailTarget] = useState<RiskReviewTask | null>(null);
function refreshAuditCount() {
window.dispatchEvent(new Event('cmpp-audit-count-refresh'));
}
function loadData() {
adminApi.listRiskReviewTasks({ status: status === 'all' ? undefined : status })
@@ -57,6 +63,7 @@ export function AdminSmsAuditPage() {
await adminApi.approveRiskReviewTask(record.id, '运营审核通过');
setApproveTarget(null);
loadData();
refreshAuditCount();
}
async function approveBatch() {
@@ -64,6 +71,7 @@ export function AdminSmsAuditPage() {
setApproveTarget(null);
setSelectedIds([]);
loadData();
refreshAuditCount();
}
async function rejectRecord() {
@@ -72,6 +80,7 @@ export function AdminSmsAuditPage() {
setRejectTarget(null);
setRejectReason('');
loadData();
refreshAuditCount();
}
async function rejectBatch() {
@@ -81,6 +90,7 @@ export function AdminSmsAuditPage() {
setRejectReason('');
setSelectedIds([]);
loadData();
refreshAuditCount();
}
const selectableIds = filteredRecords.filter((item) => item.status === 'pending_review').map((item) => item.id);
@@ -96,7 +106,7 @@ export function AdminSmsAuditPage() {
{ key: 'sourceType', title: '审核来源', width: '180px', render: (record) => <Tag tone={record.sourceType === 'cmpp_template_mismatch' ? 'warning' : 'info'}>{sourceLabel(record.sourceType)}</Tag> },
{ key: 'content', title: '短信内容', render: (record) => <span className="table-long-text">{record.content}</span> },
{ key: 'phoneTotal', title: '聚合号码数', width: '140px', render: (record) => (record._count?.messageRecords ?? record.phoneTotal).toLocaleString('zh-CN') },
{ key: 'createdAt', title: '提交时间', width: '190px', render: (record) => record.createdAt },
{ key: 'createdAt', title: '提交时间', width: '190px', render: (record) => formatDateTime(record.createdAt) },
{ key: 'reason', title: '审核原因', render: (record) => record.reviewReason ?? record.rejectReason ?? record.riskHits?.map((item) => item.reason).join('') ?? '-' },
{
key: 'status',
@@ -107,14 +117,17 @@ export function AdminSmsAuditPage() {
{
key: 'actions',
title: '操作',
width: '160px',
width: '230px',
align: 'right',
render: (record) => record.status === 'pending_review' ? (
render: (record) => (
<div className="audit-actions">
<Button icon={<Check size={15} />} onClick={() => setApproveTarget(record)} size="sm" variant="success"></Button>
<Button icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger"></Button>
<Button icon={<Info size={15} />} onClick={() => setDetailTarget(record)} size="sm" variant="ghost"></Button>
{record.status === 'pending_review' ? <>
<Button icon={<Check size={15} />} onClick={() => setApproveTarget(record)} size="sm" variant="success"></Button>
<Button icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger"></Button>
</> : null}
</div>
) : null,
),
},
];
@@ -168,6 +181,18 @@ export function AdminSmsAuditPage() {
<p>{approveTarget === 'batch' ? `确认通过已选择的 ${selectedIds.length} 条待审核任务?` : '确认通过该短信审核任务?'}</p>
</Modal>
{detailTarget ? <Modal footer={<Button onClick={() => setDetailTarget(null)}></Button>} onClose={() => setDetailTarget(null)} open title="审核任务更多信息">
<div className="detail-grid">
<div><span></span><strong>{detailTarget.taskNo}</strong></div>
<div><span></span><strong>{formatDateTime(detailTarget.createdAt)}</strong></div>
<div><span></span><strong>{detailTarget.reviewedBy?.displayName || detailTarget.reviewedBy?.username || '-'}</strong></div>
<div><span></span><strong>{formatDateTime(detailTarget.reviewedAt)}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{detailTarget.reviewReason || '-'}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{detailTarget.rejectReason || '-'}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{detailTarget.riskHits?.map((item) => item.reason).join('') || '-'}</strong></div>
</div>
</Modal> : null}
<Modal
footer={(
<>
+30 -4
View File
@@ -177,6 +177,7 @@ function SendDetailModal({
onClose: () => void;
}) {
const routeRows = buildRouteRows(record);
const sentAccessNumber = `${record.channel?.srcId ?? ''}${record.applicationExtension ?? ''}`;
return (
<Modal
footer={<Button onClick={onClose} variant="ghost"></Button>}
@@ -207,6 +208,14 @@ function SendDetailModal({
<span></span>
<strong>{record.province ?? '-'} / {getCarrierLabel(record.carrier)}</strong>
</div>
<div>
<span></span>
<strong>{record.clientSrcId || '-'}</strong>
</div>
<div>
<span></span>
<strong>{sentAccessNumber || '-'}</strong>
</div>
</div>
<section>
<h3><MessageSquare size={18} /> </h3>
@@ -292,8 +301,19 @@ export function AdminSmsRecordsPage() {
const [error, setError] = useState('');
const [page, setPage] = useState(1);
function loadData() {
adminApi.listOperationMessages({
type MessageFilters = {
tenantId?: string;
applicationId?: string;
phoneNumber?: string;
contentKeyword?: string;
channelKeyword?: string;
queuedAtFrom?: string;
queuedAtTo?: string;
status?: string;
};
function currentFilters(): MessageFilters {
return {
tenantId: enterprise === 'all' ? undefined : enterprise,
applicationId: application === 'all' ? undefined : application,
phoneNumber: phoneKeyword || undefined,
@@ -302,9 +322,14 @@ export function AdminSmsRecordsPage() {
queuedAtFrom: dateRange.start,
queuedAtTo: dateRange.end,
status: status === 'all' ? undefined : status,
})
};
}
function loadData(filters = currentFilters()) {
adminApi.listOperationMessages(filters)
.then((items) => {
setRecords(items);
setSelectedRecord((current) => current ? items.find((item) => item.id === current.id) ?? null : null);
setPage(1);
setError('');
})
@@ -373,6 +398,7 @@ export function AdminSmsRecordsPage() {
setContentKeyword('');
setChannelKeyword('');
setStatus('all');
loadData({});
}
return (
@@ -412,7 +438,7 @@ export function AdminSmsRecordsPage() {
value={status}
/>
<div className="admin-sms-record-filter__actions">
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button icon={<Search size={16} />} onClick={() => loadData()}></Button>
<Button onClick={resetFilters} variant="ghost"></Button>
</div>
</div>