Files
lislgosms/src/apps/admin/AdminSmsUplinkRecordsPage.tsx
T

345 lines
13 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { Search, Smartphone } from 'lucide-react';
import { adminApi, type SmsMessageRecord, type SmsUplinkMatchCandidate, type SmsUplinkMessage } from '@/api/adminApi';
import {
Breadcrumb,
Button,
DateRangeInput,
Input,
Modal,
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({
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>}
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.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>
</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>
{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>
) : 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('');
function loadData() {
setLoading(true);
adminApi.listAdminUplinkMessages()
.then((items) => {
setMessages(items);
setError('');
})
.catch((reason: Error) => setError(reason.message || '短信上行记录加载失败'))
.finally(() => setLoading(false));
}
function openDetail(message: SmsUplinkMessage) {
setSelectedMessage(message);
setMatchedRecords([]);
setDetailError('');
setClaimError('');
if (!message.messageId) {
return;
}
setMatching(true);
adminApi.listOperationMessages({ tenantId: message.tenantId ?? undefined, messageId: message.messageId })
.then((items) => setMatchedRecords(items))
.catch((reason: Error) => setDetailError(reason.message || '匹配发送记录加载失败'))
.finally(() => setMatching(false));
}
useEffect(() => {
loadData();
}, []);
const filteredMessages = useMemo(
() => messages.filter((item) => {
const receivedDate = getDate(item.receivedAt);
const matchesStartDate = !dateRange.start || receivedDate >= dateRange.start;
const matchesEndDate = !dateRange.end || receivedDate <= dateRange.end;
const matchesPhone = !phoneKeyword || item.phoneNumber.includes(phoneKeyword);
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
return matchesStartDate && matchesEndDate && matchesPhone && matchesContent;
}),
[contentKeyword, dateRange.end, dateRange.start, messages, phoneKeyword],
);
function resetFilters() {
setDateRange({});
setPhoneKeyword('');
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',
title: '',
width: '72px',
align: 'center',
render: () => <input aria-label="选择上行记录" className="admin-uplink-checkbox" type="checkbox" />,
},
{ key: 'phoneNumber', title: '手机号码', width: '170px', render: (record) => <strong>{record.phoneNumber}</strong> },
{ key: 'receivedAt', title: '上行时间', width: '220px', render: (record) => <strong>{getTime(record.receivedAt)}</strong> },
{ key: 'content', title: '上行内容', width: '360px', render: (record) => <span className="uplink-content" title={record.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: '操作',
width: '140px',
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={loadData}>查询</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 ? [] : filteredMessages} emptyText={loading ? '正在加载真实上行短信记录...' : '暂无上行短信记录'} rowKey="id" />
</div>
{selectedMessage ? (
<UplinkDetailModal
claimError={claimError}
claimingId={claimingId}
detailError={detailError}
matchedRecords={matchedRecords}
matching={matching}
message={selectedMessage}
onClaim={handleClaim}
onClose={() => setSelectedMessage(null)}
/>
) : null}
</section>
);
}