feat: densify sms records and improve uplink matching

This commit is contained in:
hectorzhao
2026-08-27 10:15:08 +08:00
parent 898471423f
commit a280b4bb22
25 changed files with 859 additions and 154 deletions
+1
View File
@@ -225,6 +225,7 @@ export type SmsUplinkMessage = {
applicationId?: string | null;
messageRecordId?: string | null;
messageId?: string | null;
gatewayMessageId?: string | null;
sequenceId?: number | null;
phoneNumber: string;
destId: string;
+61 -9
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Search, Smartphone } from 'lucide-react';
import { Search, Smartphone, UserX } from 'lucide-react';
import { adminApi, type SmsMessageRecord, type SmsUplinkMatchCandidate, type SmsUplinkMessage } from '@/api/adminApi';
import {
Breadcrumb,
@@ -40,28 +40,41 @@ function candidateStatusText(status?: string | null) {
}
function UplinkDetailModal({
blacklistFeedback,
blacklisting,
claimError,
claimingId,
detailError,
matchedRecords,
matching,
message,
onAddBlacklist,
onClaim,
onClose,
}: {
blacklistFeedback: string;
blacklisting: boolean;
claimError: string;
claimingId: string;
detailError: string;
matchedRecords: SmsMessageRecord[];
matching: boolean;
message: SmsUplinkMessage;
onAddBlacklist: () => void;
onClaim: (candidate: SmsUplinkMatchCandidate) => void;
onClose: () => void;
}) {
const candidates = message.matchCandidates ?? [];
return (
<Modal
footer={<Button onClick={onClose} variant="ghost"></Button>}
footer={<>
{message.tenantId && message.applicationId ? (
<Button disabled={blacklisting} icon={<UserX size={15} />} onClick={onAddBlacklist}>
{blacklisting ? '加入中...' : '加入应用黑名单'}
</Button>
) : null}
<Button onClick={onClose} variant="ghost"></Button>
</>}
onClose={onClose}
open
size="xl"
@@ -92,13 +105,21 @@ function UplinkDetailModal({
<strong>{message.destId || '-'}</strong>
</div>
<div>
<span>ID</span>
<strong>{message.messageId || '-'}</strong>
<span>ID</span>
<strong>{message.gatewayMessageId || '-'}</strong>
</div>
<div>
<span>ID</span>
<strong>{message.messageRecord?.messageId ?? message.messageId ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{matchStatusText(message.matchStatus)}</strong>
</div>
<div className="admin-uplink-info-grid__full">
<span></span>
<strong>{message.matchReason || '-'}</strong>
</div>
<div className="admin-uplink-info-grid__full">
<span></span>
<strong>{message.content || '-'}</strong>
@@ -160,11 +181,13 @@ function UplinkDetailModal({
<section className="admin-uplink-match-section">
<h3></h3>
{blacklistFeedback ? <p className={blacklistFeedback.startsWith('已') ? 'form-success' : 'form-error'}>{blacklistFeedback}</p> : null}
{matching ? <p>...</p> : null}
{detailError ? <p className="form-error">{detailError}</p> : null}
{!matching && !message.messageId ? <div className="admin-uplink-empty-match">ID</div> : null}
{!matching && message.messageId && matchedRecords.length === 0 && !detailError ? (
<div className="admin-uplink-empty-match"></div>
{!matching && matchedRecords.length === 0 && !detailError ? (
<div className="admin-uplink-empty-match">
{message.matchStatus === 'ambiguous' ? '存在多个候选,请先认领正确应用' : '暂无匹配发送记录'}
</div>
) : null}
{matchedRecords.map((record) => (
<article className="admin-uplink-match-card" key={record.id}>
@@ -211,6 +234,8 @@ export function AdminSmsUplinkRecordsPage() {
const [error, setError] = useState('');
const [detailError, setDetailError] = useState('');
const [claimError, setClaimError] = useState('');
const [blacklisting, setBlacklisting] = useState(false);
const [blacklistFeedback, setBlacklistFeedback] = useState('');
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const pageSize = 10;
@@ -236,11 +261,12 @@ export function AdminSmsUplinkRecordsPage() {
function openDetail(message: SmsUplinkMessage) {
setSelectedMessage(message);
setMatchedRecords([]);
setMatchedRecords(message.messageRecord ? [message.messageRecord] : []);
setDetailError('');
setClaimError('');
setBlacklistFeedback('');
if (!message.messageId) {
if (message.messageRecord || !message.messageId) {
return;
}
@@ -251,6 +277,28 @@ export function AdminSmsUplinkRecordsPage() {
.finally(() => setMatching(false));
}
function handleAddBlacklist() {
if (!selectedMessage?.tenantId || !selectedMessage.applicationId) {
setBlacklistFeedback('请先匹配或认领企业应用');
return;
}
if (!window.confirm(`确认将 ${selectedMessage.phoneNumber} 加入当前应用黑名单?`)) {
return;
}
setBlacklisting(true);
setBlacklistFeedback('');
adminApi.createEnterpriseBlacklist({
tenantId: selectedMessage.tenantId,
applicationId: selectedMessage.applicationId,
phoneNumber: selectedMessage.phoneNumber,
reason: '上行短信人工加入',
status: 'active',
})
.then(() => setBlacklistFeedback('已加入当前应用黑名单'))
.catch((reason: Error) => setBlacklistFeedback(reason.message || '加入应用黑名单失败'))
.finally(() => setBlacklisting(false));
}
useEffect(() => {
loadData(page);
}, [page]);
@@ -273,6 +321,7 @@ export function AdminSmsUplinkRecordsPage() {
.then((updated) => {
setMessages((items) => items.map((item) => (item.id === updated.id ? { ...item, ...updated } : item)));
setSelectedMessage((current) => (current && current.id === updated.id ? { ...current, ...updated } : current));
setMatchedRecords(updated.messageRecord ? [updated.messageRecord] : []);
loadData(page);
})
.catch((reason: Error) => setClaimError(reason.message || '上行认领失败'))
@@ -332,12 +381,15 @@ export function AdminSmsUplinkRecordsPage() {
{selectedMessage ? (
<UplinkDetailModal
blacklistFeedback={blacklistFeedback}
blacklisting={blacklisting}
claimError={claimError}
claimingId={claimingId}
detailError={detailError}
matchedRecords={matchedRecords}
matching={matching}
message={selectedMessage}
onAddBlacklist={handleAddBlacklist}
onClaim={handleClaim}
onClose={() => setSelectedMessage(null)}
/>
@@ -5,14 +5,29 @@
.admin-sms-record-filter {
align-items: end;
display: grid;
gap: var(--space-5);
grid-template-columns: repeat(4, minmax(180px, 1fr));
gap: var(--space-4);
grid-template-columns: repeat(12, minmax(0, 1fr));
}
.admin-sms-record-filter__field {
grid-column: span 2;
min-width: 0;
}
.admin-sms-record-filter__field.is-date,
.admin-sms-record-filter__field.is-content {
grid-column: span 3;
}
.admin-sms-record-filter__field.is-phone {
grid-column: span 2;
}
.admin-sms-record-filter__actions {
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(2, minmax(120px, 1fr));
grid-column: span 3;
grid-template-columns: repeat(2, minmax(88px, 1fr));
}
.admin-sms-record-table-card {
@@ -25,8 +40,8 @@
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: flex-end;
min-height: 76px;
padding: var(--space-4) var(--space-6);
min-height: 56px;
padding: var(--space-2) var(--space-4);
}
.admin-sms-record-table-card .ui-table-wrap {
@@ -35,52 +50,64 @@
}
.admin-sms-record-list {
overflow-x: auto;
padding: 0 var(--space-4) var(--space-3);
}
.admin-sms-record-list__header,
.admin-sms-record-card {
align-items: center;
display: grid;
gap: var(--space-2);
padding: var(--space-3);
gap: var(--space-3);
grid-template-columns: 96px 96px minmax(360px, 1fr) 96px 112px 36px;
min-width: 960px;
}
.admin-sms-record-list__header {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
height: 38px;
padding: 0 var(--space-3);
}
.admin-sms-record-list__header span:last-child {
text-align: center;
}
.admin-sms-record-group-title {
background: var(--color-bg-subtle);
border-bottom: 1px solid var(--color-border);
border-top: 1px solid var(--color-border);
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
margin: 0 calc(var(--space-4) * -1);
padding: var(--space-2) var(--space-7);
}
.admin-sms-record-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
display: grid;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
min-height: 72px;
padding: var(--space-2) var(--space-3);
}
.admin-sms-record-card:hover {
border-color: color-mix(in srgb, var(--color-selected) 35%, var(--color-border));
box-shadow: var(--shadow-sm);
}
.admin-sms-record-card > header {
align-items: center;
display: grid;
gap: var(--space-4);
grid-template-columns: minmax(180px, 1fr) auto auto;
}
.admin-sms-record-card > header time {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
background: color-mix(in srgb, var(--color-selected) 4%, var(--color-surface));
}
.admin-sms-record-card .admin-sms-record-content {
background: var(--color-bg-subtle);
border-radius: var(--radius-md);
display: -webkit-box;
line-height: 1.55;
line-height: 1.45;
max-width: none;
min-width: 0;
overflow: hidden;
padding: var(--space-2) var(--space-3);
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
padding: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-sms-record-card .admin-sms-record-content.is-drainage {
background: color-mix(in srgb, #f59e0b 13%, var(--color-surface));
border: 1px solid color-mix(in srgb, #f59e0b 34%, var(--color-border));
color: #92400e;
}
.admin-sms-record-content mark {
@@ -95,7 +122,7 @@
display: inline-flex;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
margin-left: var(--space-3);
margin-left: var(--space-2);
padding: 1px var(--space-2);
vertical-align: middle;
}
@@ -111,32 +138,43 @@
color: var(--color-text-muted);
}
.admin-sms-record-card__meta {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.admin-sms-record-card__meta > div {
.admin-sms-record-main {
display: grid;
gap: var(--space-1);
min-width: 0;
}
.admin-sms-record-card__meta span,
.admin-sms-record-card__meta small {
.admin-sms-record-context {
align-items: center;
color: var(--color-text-muted);
display: flex;
font-size: var(--font-size-xs);
gap: var(--space-2) var(--space-4);
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
.admin-sms-record-context span {
overflow: hidden;
text-overflow: ellipsis;
}
.admin-sms-record-time,
.admin-sms-record-billing {
display: grid;
font-size: var(--font-size-xs);
gap: 2px;
}
.admin-sms-record-time span,
.admin-sms-record-billing span {
color: var(--color-text-muted);
}
.admin-sms-record-card__meta strong {
.admin-sms-record-time strong,
.admin-sms-record-billing strong {
color: var(--color-text-strong);
overflow-wrap: anywhere;
}
.admin-sms-record-card > footer {
border-top: 1px solid var(--color-border);
display: flex;
justify-content: flex-end;
padding-top: var(--space-2);
}
.admin-sms-record-table {
@@ -221,11 +259,21 @@
}
.admin-sms-record-detail-link {
background: transparent;
border: 0;
align-items: center;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-selected);
font-weight: var(--font-weight-semibold);
display: inline-flex;
height: 32px;
justify-content: center;
padding: 0;
width: 32px;
}
.admin-sms-record-detail-link:hover {
background: var(--color-selected-soft);
border-color: color-mix(in srgb, var(--color-selected) 35%, var(--color-border));
}
.admin-sms-send-detail {
@@ -392,8 +440,6 @@
}
@media (max-width: 900px) {
.admin-sms-record-card > header,
.admin-sms-record-card__meta,
.admin-sms-detail-overview,
.admin-sms-detail-status-grid,
.admin-sms-route-list dl,
@@ -402,8 +448,11 @@
grid-template-columns: 1fr;
}
.admin-sms-record-card > header time {
justify-self: start;
.admin-sms-record-filter__field,
.admin-sms-record-filter__field.is-date,
.admin-sms-record-filter__field.is-content,
.admin-sms-record-filter__actions {
grid-column: span 6;
}
}
@@ -463,4 +512,11 @@
.admin-sms-route-list dl {
grid-template-columns: 1fr;
}
.admin-sms-record-filter__field,
.admin-sms-record-filter__field.is-date,
.admin-sms-record-filter__field.is-content,
.admin-sms-record-filter__actions {
grid-column: auto;
}
}
@@ -85,15 +85,15 @@ export function SmsRecordFilter({
}: SmsRecordFilterProps) {
return (
<div className="surface admin-sms-record-filter">
<Select label="企业" onChange={(event) => onEnterpriseChange(event.target.value)} options={enterpriseOptions} value={enterprise} />
<Select label="应用" onChange={(event) => onApplicationChange(event.target.value)} options={applicationOptions} value={application} />
<DateRangeInput label="提交日期" onChange={onDateRangeChange} value={dateRange} />
<Input label="手机号码" onChange={(event) => onPhoneKeywordChange(event.target.value)} prefix={<Smartphone size={16} />} value={phoneKeyword} />
<Select label="运营商" onChange={(event) => onCarrierChange(event.target.value)} options={carrierOptions} value={carrier} />
<Input label="短信内容" onChange={(event) => onContentKeywordChange(event.target.value)} value={contentKeyword} />
<Select label="通道" onChange={(event) => onChannelChange(event.target.value)} options={channelOptions} searchable searchPlaceholder="输入通道名称搜索" value={channel} />
<Select label="发送状态" onChange={(event) => onStatusChange(event.target.value)} options={statusOptions} value={status} />
<Select label="是否含引流信息" onChange={(event) => onHasDrainageChange(event.target.value)} options={drainageOptions} value={hasDrainage} />
<div className="admin-sms-record-filter__field is-enterprise"><Select label="企业" onChange={(event) => onEnterpriseChange(event.target.value)} options={enterpriseOptions} value={enterprise} /></div>
<div className="admin-sms-record-filter__field is-application"><Select label="应用" onChange={(event) => onApplicationChange(event.target.value)} options={applicationOptions} value={application} /></div>
<div className="admin-sms-record-filter__field is-date"><DateRangeInput label="提交日期" onChange={onDateRangeChange} value={dateRange} /></div>
<div className="admin-sms-record-filter__field is-phone"><Input label="手机号码" onChange={(event) => onPhoneKeywordChange(event.target.value)} prefix={<Smartphone size={16} />} value={phoneKeyword} /></div>
<div className="admin-sms-record-filter__field is-carrier"><Select label="运营商" onChange={(event) => onCarrierChange(event.target.value)} options={carrierOptions} value={carrier} /></div>
<div className="admin-sms-record-filter__field is-content"><Input label="短信内容" onChange={(event) => onContentKeywordChange(event.target.value)} value={contentKeyword} /></div>
<div className="admin-sms-record-filter__field is-channel"><Select label="通道" onChange={(event) => onChannelChange(event.target.value)} options={channelOptions} searchable searchPlaceholder="输入通道名称搜索" value={channel} /></div>
<div className="admin-sms-record-filter__field is-status"><Select label="发送状态" onChange={(event) => onStatusChange(event.target.value)} options={statusOptions} value={status} /></div>
<div className="admin-sms-record-filter__field is-drainage"><Select label="是否含引流信息" onChange={(event) => onHasDrainageChange(event.target.value)} options={drainageOptions} value={hasDrainage} /></div>
<div className="admin-sms-record-filter__actions">
<Button icon={<Search size={16} />} onClick={onQuery}></Button>
<Button onClick={onReset} variant="ghost"></Button>
+46 -25
View File
@@ -1,4 +1,4 @@
import { Download } from 'lucide-react';
import { ChevronRight, Download } from 'lucide-react';
import type { ReactNode } from 'react';
import type { SmsMessageRecord } from '@/api/adminApi';
import { Button, CarrierTag, MoneyText, Pagination } from '@/components/ui';
@@ -8,7 +8,6 @@ import {
getDate,
getRecordStatus,
getStatusLabel,
getTime,
statusDotClassMap,
} from './smsRecordModel';
@@ -60,36 +59,58 @@ export function SmsRecordList({
onOpenDetail,
onPageChange,
}: SmsRecordListProps) {
let previousDate = '';
return (
<div className="surface admin-sms-record-table-card">
<div className="admin-sms-record-toolbar">
<Button icon={<Download size={16} />} onClick={onExport} variant="ghost">CSV</Button>
</div>
<div className="admin-sms-record-list">
{loading ? <div className="ui-table__empty">...</div> : records.length === 0 ? <div className="ui-table__empty"></div> : records.map((record) => (
<article className="admin-sms-record-card" key={record.id}>
<header>
<div className="admin-sms-record-sender">
<strong>{record.tenant?.name ?? record.tenantId ?? '运营端通道测试'}</strong>
<span>{record.application?.name ?? record.applicationId ?? '-'}</span>
</div>
<StatusLine record={record} />
<time>{getDate(record.queuedAt)} {getClock(record.queuedAt)}</time>
</header>
<p className={`admin-sms-record-content${record.hasDrainageContent ? ' is-drainage' : ''}`}>
<DrainageContent record={record} />
<span className={`admin-sms-record-drainage-badge is-${record.hasDrainageContent === true ? 'yes' : record.hasDrainageContent === false ? 'no' : 'unknown'}`}>
{record.hasDrainageContent === true ? '含引流' : record.hasDrainageContent === false ? '不含引流' : '未检测'}
</span>
</p>
<div className="admin-sms-record-card__meta">
<div><span></span><strong>{record.phoneNumber}</strong><small>{record.province ?? '-'} {record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</small></div>
<div><span></span><strong>{record.billingUnits} / <MoneyText>¥{formatCents(record.amountCents)}</MoneyText></strong><small>{record.content.length} </small></div>
<div><span></span><strong>{record.channel?.name ?? record.channelId ?? '-'}</strong><small> {getTime(record.deliveredAt)}</small></div>
{loading ? <div className="ui-table__empty">...</div> : records.length === 0 ? <div className="ui-table__empty"></div> : (
<>
<div aria-hidden="true" className="admin-sms-record-list__header">
<span></span><span></span><span></span><span></span><span></span><span></span>
</div>
<footer><button className="admin-sms-record-detail-link" onClick={() => onOpenDetail(record)} type="button"></button></footer>
</article>
))}
{records.map((record) => {
const submitDate = getDate(record.queuedAt);
const showDateGroup = submitDate !== previousDate;
previousDate = submitDate;
return (
<div className="admin-sms-record-group" key={record.id}>
{showDateGroup ? <div className="admin-sms-record-group-title">{submitDate}</div> : null}
<article className="admin-sms-record-card">
<StatusLine record={record} />
<time className="admin-sms-record-time" dateTime={record.queuedAt}>
<span>{submitDate}</span><strong>{getClock(record.queuedAt)}</strong>
</time>
<div className="admin-sms-record-main">
<p className={`admin-sms-record-content${record.hasDrainageContent ? ' is-drainage' : ''}`}>
<DrainageContent record={record} />
<span className={`admin-sms-record-drainage-badge is-${record.hasDrainageContent === true ? 'yes' : record.hasDrainageContent === false ? 'no' : 'unknown'}`}>
{record.hasDrainageContent === true ? '含引流' : record.hasDrainageContent === false ? '不含引流' : '未检测'}
</span>
</p>
<div className="admin-sms-record-context">
<span>{record.tenant?.name ?? record.tenantId ?? '运营端通道测试'} / {record.application?.name ?? record.applicationId ?? '-'}</span>
<span>{record.phoneNumber} · {record.province ?? '-'} {record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</span>
<span>{record.channel?.name ?? record.channelId ?? '-'}</span>
</div>
</div>
<time className="admin-sms-record-time" dateTime={record.deliveredAt ?? undefined}>
{record.deliveredAt ? <><span>{getDate(record.deliveredAt)}</span><strong>{getClock(record.deliveredAt)}</strong></> : <span></span>}
</time>
<div className="admin-sms-record-billing">
<MoneyText>¥{formatCents(record.amountCents)}</MoneyText>
<span>{record.billingUnits} · {record.content.length} </span>
</div>
<button aria-label="查看发送详情" className="admin-sms-record-detail-link" onClick={() => onOpenDetail(record)} title="查看发送详情" type="button"><ChevronRight size={18} /></button>
</article>
</div>
);
})}
</>
)}
</div>
<Pagination
nextDisabled={currentPage >= totalPages}
+10 -6
View File
@@ -52,10 +52,10 @@ export function ClientUplinkMessagesPage() {
function openDetail(message: SmsUplinkMessage) {
setSelectedMessage(message);
setMatchedRecords([]);
setMatchedRecords(message.messageRecord ? [message.messageRecord] : []);
setDetailError('');
if (!message.messageId) {
if (message.messageRecord || !message.messageId) {
return;
}
@@ -142,7 +142,10 @@ export function ClientUplinkMessagesPage() {
{ label: '手机号码', value: selectedMessage.phoneNumber },
{ label: '上行时间', value: getTime(selectedMessage.receivedAt) },
{ label: '接入号码', value: selectedMessage.destId },
{ label: '网关消息ID', value: selectedMessage.messageId || '-' },
{ label: '上行网关消息ID', value: selectedMessage.gatewayMessageId || '-' },
{ label: '关联平台消息ID', value: selectedMessage.messageRecord?.messageId ?? selectedMessage.messageId ?? '-' },
{ label: '匹配状态', value: selectedMessage.matchStatus || '-' },
{ label: '匹配说明', value: selectedMessage.matchReason || '-', full: true },
{ label: '上行内容', value: selectedMessage.content, full: true },
]}
/>
@@ -151,9 +154,10 @@ export function ClientUplinkMessagesPage() {
<DetailSection title="匹配发送记录">
{matching ? <p className="uplink-detail-hint">...</p> : null}
{detailError ? <p className="form-error">{detailError}</p> : null}
{!matching && !selectedMessage.messageId ? <p className="uplink-detail-hint">ID</p> : null}
{!matching && selectedMessage.messageId && matchedRecords.length === 0 && !detailError ? (
<p className="uplink-detail-hint"></p>
{!matching && matchedRecords.length === 0 && !detailError ? (
<p className="uplink-detail-hint">
{selectedMessage.matchStatus === 'ambiguous' ? '该上行存在多个候选,请联系运营人员认领。' : '未匹配到真实下发记录。'}
</p>
) : null}
{matchedRecords.length > 0 ? (
<div className="uplink-match-list">