400 lines
16 KiB
TypeScript
400 lines
16 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Search, Smartphone, UserX } from 'lucide-react';
|
|
import { adminApi, type SmsMessageRecord, type SmsUplinkMatchCandidate, type SmsUplinkMessage } from '@/api/adminApi';
|
|
import {
|
|
Breadcrumb,
|
|
Button,
|
|
DateRangeInput,
|
|
Input,
|
|
Modal,
|
|
Pagination,
|
|
Table,
|
|
type DateRangeValue,
|
|
type TableColumn,
|
|
} from '@/components/ui';
|
|
|
|
function getDate(value?: string | null) {
|
|
return value ? value.slice(0, 10) : '';
|
|
}
|
|
|
|
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({
|
|
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={<>
|
|
{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"
|
|
title="上行短信详情"
|
|
>
|
|
<div className="admin-uplink-detail">
|
|
<section className="admin-uplink-info-card">
|
|
<h3>上行信息</h3>
|
|
<div className="admin-uplink-info-grid">
|
|
<div>
|
|
<span>手机号码</span>
|
|
<strong>{message.phoneNumber || '-'}</strong>
|
|
</div>
|
|
<div>
|
|
<span>上行时间</span>
|
|
<strong>{getTime(message.receivedAt)}</strong>
|
|
</div>
|
|
<div>
|
|
<span>上行企业</span>
|
|
<strong>{message.tenant?.name ?? message.tenantId ?? '-'}</strong>
|
|
</div>
|
|
<div>
|
|
<span>上行通道</span>
|
|
<strong>{message.channel?.name ?? message.channelId ?? '-'}</strong>
|
|
</div>
|
|
<div>
|
|
<span>上行接入号</span>
|
|
<strong>{message.destId || '-'}</strong>
|
|
</div>
|
|
<div>
|
|
<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>
|
|
</div>
|
|
</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>
|
|
{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 && 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}>
|
|
<div className="admin-uplink-match-grid">
|
|
<div>
|
|
<span>发送时间</span>
|
|
<strong>{getTime(record.queuedAt)}</strong>
|
|
</div>
|
|
<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>{record.channel?.srcId ?? '-'}</strong>
|
|
</div>
|
|
</div>
|
|
<div className="admin-uplink-match-content">
|
|
<span>下发内容</span>
|
|
<p>{record.content}</p>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</section>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
export function AdminSmsUplinkRecordsPage() {
|
|
const [messages, setMessages] = useState<SmsUplinkMessage[]>([]);
|
|
const [matchedRecords, setMatchedRecords] = useState<SmsMessageRecord[]>([]);
|
|
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
|
const [phoneKeyword, setPhoneKeyword] = useState('');
|
|
const [contentKeyword, setContentKeyword] = useState('');
|
|
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('');
|
|
const [blacklisting, setBlacklisting] = useState(false);
|
|
const [blacklistFeedback, setBlacklistFeedback] = useState('');
|
|
const [total, setTotal] = useState(0);
|
|
const [page, setPage] = useState(1);
|
|
const pageSize = 10;
|
|
|
|
function loadData(targetPage = page, filters = { phoneKeyword, contentKeyword, dateRange }) {
|
|
setLoading(true);
|
|
adminApi.listAdminUplinkMessagesPage({
|
|
phoneNumber: filters.phoneKeyword.trim() || undefined,
|
|
keyword: filters.contentKeyword.trim() || undefined,
|
|
startTime: filters.dateRange.start ? `${filters.dateRange.start}T00:00:00+08:00` : undefined,
|
|
endTime: filters.dateRange.end ? `${filters.dateRange.end}T23:59:59.999+08:00` : undefined,
|
|
page: targetPage,
|
|
pageSize,
|
|
})
|
|
.then((result) => {
|
|
setMessages(result.items);
|
|
setTotal(result.total);
|
|
setError('');
|
|
})
|
|
.catch((reason: Error) => setError(reason.message || '短信上行记录加载失败'))
|
|
.finally(() => setLoading(false));
|
|
}
|
|
|
|
function openDetail(message: SmsUplinkMessage) {
|
|
setSelectedMessage(message);
|
|
setMatchedRecords(message.messageRecord ? [message.messageRecord] : []);
|
|
setDetailError('');
|
|
setClaimError('');
|
|
setBlacklistFeedback('');
|
|
|
|
if (message.messageRecord || !message.messageId) {
|
|
return;
|
|
}
|
|
|
|
setMatching(true);
|
|
adminApi.listOperationMessages({ tenantId: message.tenantId ?? undefined, messageId: message.messageId, page: 1, pageSize: 10 })
|
|
.then((result) => setMatchedRecords(result.items))
|
|
.catch((reason: Error) => setDetailError(reason.message || '匹配发送记录加载失败'))
|
|
.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]);
|
|
|
|
function resetFilters() {
|
|
setDateRange({});
|
|
setPhoneKeyword('');
|
|
setContentKeyword('');
|
|
setPage(1);
|
|
loadData(1, { phoneKeyword: '', contentKeyword: '', dateRange: {} });
|
|
}
|
|
|
|
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));
|
|
setMatchedRecords(updated.messageRecord ? [updated.messageRecord] : []);
|
|
loadData(page);
|
|
})
|
|
.catch((reason: Error) => setClaimError(reason.message || '上行认领失败'))
|
|
.finally(() => setClaimingId(''));
|
|
}
|
|
|
|
const columns: Array<TableColumn<SmsUplinkMessage>> = [
|
|
{
|
|
key: 'select',
|
|
title: '',
|
|
width: '48px',
|
|
align: 'center',
|
|
render: () => <input aria-label="选择上行记录" className="admin-uplink-checkbox" type="checkbox" />,
|
|
},
|
|
{ key: 'phoneNumber', title: '手机号码', width: '120px', render: (record) => <strong>{record.phoneNumber}</strong> },
|
|
{ key: 'receivedAt', title: '上行时间', width: '156px', render: (record) => <strong>{getTime(record.receivedAt)}</strong> },
|
|
{ key: 'content', title: '上行内容', width: '260px', render: (record) => <span className="uplink-content" title={record.content}>{record.content}</span> },
|
|
{ key: 'channel', title: '上行通道', width: '160px', render: (record) => <strong>{record.channel?.name ?? record.channelId}</strong> },
|
|
{ key: 'accessNo', title: '上行接入号', width: '120px', render: (record) => <strong>{record.destId}</strong> },
|
|
{ key: 'matchStatus', title: '匹配状态', width: '100px', render: (record) => <strong>{matchStatusText(record.matchStatus)}</strong> },
|
|
{
|
|
key: 'actions',
|
|
title: '操作',
|
|
width: '88px',
|
|
align: 'center',
|
|
render: (record) => (
|
|
<button className="admin-uplink-detail-link" onClick={() => openDetail(record)} type="button">查看详情</button>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<section className="page-stack admin-uplink-page">
|
|
<div className="page-heading">
|
|
<div>
|
|
<Breadcrumb items={['数据详单', '短信上行记录']} />
|
|
<h1>短信上行记录</h1>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="surface admin-uplink-filter">
|
|
<DateRangeInput label="上行时间" onChange={setDateRange} value={dateRange} />
|
|
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} prefix={<Smartphone size={16} />} value={phoneKeyword} />
|
|
<Input label="上行内容" onChange={(event) => setContentKeyword(event.target.value)} value={contentKeyword} />
|
|
<div className="admin-uplink-filter__actions">
|
|
<Button icon={<Search size={16} />} onClick={() => { setPage(1); loadData(1); }}>查询</Button>
|
|
<Button onClick={resetFilters} variant="ghost">重置</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{error ? <p className="form-error">{error}</p> : null}
|
|
|
|
<div className="surface admin-uplink-table-card">
|
|
<Table columns={columns} data={loading ? [] : messages} emptyText={loading ? '正在加载真实上行短信记录...' : '暂无上行短信记录'} pagination={false} rowKey="id" />
|
|
<Pagination page={page} totalPages={Math.max(1, Math.ceil(total / pageSize))} total={total} onPageChange={setPage} onPrevious={() => setPage((value) => Math.max(1, value - 1))} onNext={() => setPage((value) => Math.min(Math.max(1, Math.ceil(total / pageSize)), value + 1))} previousDisabled={page <= 1} nextDisabled={page >= Math.max(1, Math.ceil(total / pageSize))} />
|
|
</div>
|
|
|
|
{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)}
|
|
/>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|