Files
lislgosms/src/apps/client/ClientSendDetailPage.tsx
T

254 lines
9.7 KiB
TypeScript

import { Fragment, 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,
Select,
Tag,
type DateRangeValue,
} from '@/components/ui';
import { 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 getDate(value?: string | null) {
return value ? value.slice(0, 10) : '';
}
function getReceipt(record: SmsMessageRecord) {
const latest = record.receiptRecords?.[0] as { rawStatus?: string; receiptStatus?: string; deliveredAt?: string } | undefined;
return {
status: receiptStatusLabel(latest?.rawStatus ?? latest?.receiptStatus ?? record.receiptStatus),
time: latest?.deliveredAt ?? record.deliveredAt,
};
}
function receiptStatusLabel(status?: string | null) {
if (!status) return '-';
const normalized = status.trim().toUpperCase();
return {
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 [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: applicationId === 'all' ? undefined : applicationId,
phoneNumber: phoneKeyword || undefined,
status: status === 'all' ? undefined : status,
contentKeyword: contentKeyword || undefined,
queuedAtFrom: dateRange.start || undefined,
queuedAtTo: 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(() => {
loadData(page);
}, [applicationId, contentKeyword, dateRange.end, dateRange.start, page, phoneKeyword, status]);
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;
useEffect(() => {
setPage(1);
}, [applicationId, contentKeyword, dateRange.end, dateRange.start, phoneKeyword, status]);
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}
/>
</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">
{record.queuedAt.slice(0, 10)}
<small>{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">
{receipt.time.slice(0, 10)}
<small>{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>
</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>
);
}