324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
import { Fragment, startTransition, useEffect, useMemo, useState } from 'react';
|
|
import { FileText, Search, Smartphone } from 'lucide-react';
|
|
import { clientApi, type SmsMessageRecord } from '@/api/adminApi';
|
|
import {
|
|
DateRangeInput,
|
|
CarrierTag,
|
|
Input,
|
|
Pagination,
|
|
QueryPanel,
|
|
QueryButtons,
|
|
Select,
|
|
Tag,
|
|
type DateRangeValue,
|
|
} from '@/components/ui';
|
|
import { formatDateTime, recentBeijingDateRange } from '@/utils/dateTime';
|
|
|
|
const statusLabelMap: Record<string, string> = {
|
|
delivered: '成功',
|
|
queued: '排队中',
|
|
submitted: '已提交',
|
|
accepted: '已受理',
|
|
unknown: '未知',
|
|
failed: '失败',
|
|
rejected: '失败',
|
|
timeout: '超时',
|
|
pending_review: '待审核',
|
|
scheduled: '等待定时发送',
|
|
submit_queued: '等待提交',
|
|
submit_failed: '提交失败',
|
|
processing: '处理中',
|
|
sending: '发送中',
|
|
canceled: '已取消',
|
|
cancelled: '已取消',
|
|
};
|
|
|
|
const statusToneMap: Record<string, 'success' | 'info' | 'danger' | 'neutral'> = {
|
|
delivered: 'success',
|
|
queued: 'info',
|
|
submitted: 'info',
|
|
accepted: 'info',
|
|
unknown: 'neutral',
|
|
failed: 'danger',
|
|
rejected: 'danger',
|
|
timeout: 'danger',
|
|
};
|
|
|
|
function getReceipt(record: SmsMessageRecord) {
|
|
const latest = record.receiptRecords?.[0] as
|
|
{ rawStatus?: string; receiptStatus?: string; deliveredAt?: string } | undefined;
|
|
return {
|
|
status: receiptStatusLabel(record.receiptStatus ?? latest?.rawStatus ?? latest?.receiptStatus),
|
|
time: record.deliveredAt ?? latest?.deliveredAt,
|
|
};
|
|
}
|
|
|
|
function receiptStatusLabel(status?: string | null) {
|
|
if (!status) return '暂无回执';
|
|
const normalized = status.trim().toUpperCase();
|
|
return (
|
|
{
|
|
DELIVERED: '送达成功',
|
|
FAILED: '送达失败',
|
|
TIMEOUT: '回执超时',
|
|
DELIVRD: '送达成功',
|
|
ACCEPTD: '已受理',
|
|
UNDELIV: '未送达',
|
|
REJECTD: '已拒绝',
|
|
EXPIRED: '已过期',
|
|
DELETED: '已删除',
|
|
UNKNOWN: '状态未知',
|
|
}[normalized] ??
|
|
statusLabelMap[status.toLowerCase()] ??
|
|
'状态未知'
|
|
);
|
|
}
|
|
|
|
export function ClientSendDetailPage() {
|
|
const [records, setRecords] = useState<SmsMessageRecord[]>([]);
|
|
const [applicationId, setApplicationId] = useState('all');
|
|
const [status, setStatus] = useState('all');
|
|
const [dateRange, setDateRange] = useState<DateRangeValue>(() => recentBeijingDateRange(7));
|
|
const [contentKeyword, setContentKeyword] = useState('');
|
|
const [phoneKeyword, setPhoneKeyword] = useState('');
|
|
const [applied, setApplied] = useState(() => ({ applicationId, status, dateRange, contentKeyword, phoneKeyword }));
|
|
const [page, setPage] = useState(1);
|
|
const [total, setTotal] = useState(0);
|
|
const [applications, setApplications] = useState<Array<{ id: string; name: string }>>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState('');
|
|
|
|
function loadData(targetPage = page) {
|
|
setLoading(true);
|
|
clientApi
|
|
.listMessages({
|
|
applicationId: applied.applicationId === 'all' ? undefined : applied.applicationId,
|
|
phoneNumber: applied.phoneKeyword.trim() || undefined,
|
|
status: applied.status === 'all' ? undefined : applied.status,
|
|
contentKeyword: applied.contentKeyword.trim() || undefined,
|
|
queuedAtFrom: applied.dateRange.start || undefined,
|
|
queuedAtTo: applied.dateRange.end || undefined,
|
|
page: targetPage,
|
|
pageSize: 10,
|
|
})
|
|
.then((result) => {
|
|
setRecords(result.items);
|
|
setTotal(result.total);
|
|
setError('');
|
|
})
|
|
.catch((reason: Error) => setError(reason.message || '短信发送详情加载失败'))
|
|
.finally(() => setLoading(false));
|
|
}
|
|
|
|
useEffect(() => {
|
|
startTransition(() => loadData(page));
|
|
}, [applied, page]);
|
|
|
|
useEffect(() => {
|
|
clientApi
|
|
.listApplicationOptions()
|
|
.then((items) => setApplications(items.map((item) => ({ id: item.id, name: item.name }))))
|
|
.catch((reason: Error) => setError(reason.message || '应用列表加载失败'));
|
|
}, []);
|
|
|
|
const applicationOptions = useMemo(() => {
|
|
return [{ label: '全部应用', value: 'all' }, ...applications.map((item) => ({ label: item.name, value: item.id }))];
|
|
}, [applications]);
|
|
|
|
const filteredRows = records;
|
|
const pageSize = 10;
|
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
const currentPage = Math.min(page, totalPages);
|
|
const visibleRows = filteredRows;
|
|
|
|
function query() {
|
|
setPage(1);
|
|
setApplied({ applicationId, status, dateRange, contentKeyword, phoneKeyword });
|
|
}
|
|
|
|
function reset() {
|
|
const defaults = {
|
|
applicationId: 'all',
|
|
status: 'all',
|
|
dateRange: recentBeijingDateRange(7),
|
|
contentKeyword: '',
|
|
phoneKeyword: '',
|
|
};
|
|
setApplicationId(defaults.applicationId);
|
|
setStatus(defaults.status);
|
|
setDateRange(defaults.dateRange);
|
|
setContentKeyword('');
|
|
setPhoneKeyword('');
|
|
setPage(1);
|
|
setApplied(defaults);
|
|
}
|
|
|
|
return (
|
|
<section className="page-stack">
|
|
<div className="sms-send-title">
|
|
<span className="sms-send-title__icon">
|
|
<FileText size={22} />
|
|
</span>
|
|
<h1>短信发送详情</h1>
|
|
</div>
|
|
|
|
<QueryPanel
|
|
title="查询条件"
|
|
summary={
|
|
<>
|
|
共找到 <strong>{total}</strong> 条发送记录
|
|
</>
|
|
}
|
|
>
|
|
<Select
|
|
label="应用名称"
|
|
onChange={(event) => setApplicationId(event.target.value)}
|
|
options={applicationOptions}
|
|
value={applicationId}
|
|
/>
|
|
<DateRangeInput label="发送时间" onChange={setDateRange} value={dateRange} />
|
|
<Select
|
|
label="发送状态"
|
|
onChange={(event) => setStatus(event.target.value)}
|
|
options={[
|
|
{ label: '全部', value: 'all' },
|
|
{ label: '成功', value: 'delivered' },
|
|
{ label: '未知', value: 'unknown' },
|
|
{ label: '失败', value: 'failed' },
|
|
]}
|
|
value={status}
|
|
/>
|
|
<Input
|
|
label="短信内容"
|
|
onChange={(event) => setContentKeyword(event.target.value)}
|
|
placeholder="输入关键词搜索"
|
|
prefix={<Search size={16} />}
|
|
value={contentKeyword}
|
|
/>
|
|
<Input
|
|
label="手机号码"
|
|
onChange={(event) => setPhoneKeyword(event.target.value)}
|
|
placeholder="输入手机号搜索"
|
|
prefix={<Smartphone size={16} />}
|
|
value={phoneKeyword}
|
|
/>
|
|
<QueryButtons onQuery={query} onReset={reset} />
|
|
</QueryPanel>
|
|
|
|
{error ? <p className="form-error">{error}</p> : null}
|
|
|
|
<div className="surface send-detail-table-card">
|
|
<div className="ui-table-wrap">
|
|
<table className="ui-table send-detail-table">
|
|
<thead>
|
|
<tr>
|
|
<th style={{ width: '150px' }}>应用名称</th>
|
|
<th style={{ width: '128px' }}>发送时间</th>
|
|
<th style={{ width: '120px', textAlign: 'center' }}>字符数/条数</th>
|
|
<th style={{ width: '130px' }}>手机号码</th>
|
|
<th style={{ width: '120px' }}>所属运营商</th>
|
|
<th style={{ width: '120px' }}>发送地区</th>
|
|
<th style={{ width: '120px', textAlign: 'center' }}>发送状态</th>
|
|
<th style={{ width: '120px', textAlign: 'center' }}>短信回执</th>
|
|
<th style={{ width: '128px' }}>回执时间</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr>
|
|
<td className="ui-table__empty" colSpan={9}>
|
|
正在加载真实发送记录...
|
|
</td>
|
|
</tr>
|
|
) : filteredRows.length === 0 ? (
|
|
<tr>
|
|
<td className="ui-table__empty" colSpan={9}>
|
|
暂无发送记录
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
visibleRows.map((record) => {
|
|
const receipt = getReceipt(record);
|
|
const region = record.province ?? '-';
|
|
return (
|
|
<Fragment key={record.id}>
|
|
<tr className="send-detail-main-row">
|
|
<td>
|
|
<strong className="send-detail-app-name">
|
|
{record.application?.name ?? record.applicationId ?? '-'}
|
|
</strong>
|
|
</td>
|
|
<td>
|
|
<span className="send-detail-time">
|
|
{formatDateTime(record.queuedAt).slice(0, 10)}
|
|
<small>{formatDateTime(record.queuedAt).slice(11, 19)}</small>
|
|
</span>
|
|
</td>
|
|
<td style={{ textAlign: 'center' }}>
|
|
<span className="send-detail-count">
|
|
<strong>{[...record.content].length}字</strong>
|
|
<small>{record.billingUnits}条</small>
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<strong>{record.phoneNumber}</strong>
|
|
</td>
|
|
<td>{record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</td>
|
|
<td>
|
|
<span className="send-detail-region">{region}</span>
|
|
</td>
|
|
<td style={{ textAlign: 'center' }}>
|
|
<Tag tone={statusToneMap[record.status] ?? 'info'}>
|
|
{statusLabelMap[record.status] ?? '状态未知'}
|
|
</Tag>
|
|
</td>
|
|
<td style={{ textAlign: 'center' }}>
|
|
<strong className="send-detail-receipt-code">{receipt.status}</strong>
|
|
</td>
|
|
<td>
|
|
{receipt.time ? (
|
|
<span className="send-detail-time">
|
|
{formatDateTime(receipt.time).slice(0, 10)}
|
|
<small>{formatDateTime(receipt.time).slice(11, 19)}</small>
|
|
</span>
|
|
) : (
|
|
<span className="muted">-</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
<tr className="send-detail-content-row">
|
|
<td colSpan={9}>
|
|
<div className="send-detail-content-block">
|
|
<span>短信内容</span>
|
|
<p>{record.content}</p>
|
|
{record.originalContent != null ? (
|
|
<>
|
|
<span>原始短信内容</span>
|
|
<p>{record.originalContent}</p>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</Fragment>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<Pagination
|
|
nextDisabled={currentPage >= totalPages}
|
|
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
|
|
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
|
|
page={currentPage}
|
|
totalPages={totalPages}
|
|
onPageChange={setPage}
|
|
previousDisabled={currentPage <= 1}
|
|
total={total}
|
|
/>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|