fix: harden real backend admin workflows and ui
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import { FileText, Search, Smartphone } from 'lucide-react';
|
||||
import { clientApi, type SmsMessageRecord } from '@/api/adminApi';
|
||||
import {
|
||||
DateRangeInput,
|
||||
Input,
|
||||
@@ -9,203 +10,95 @@ import {
|
||||
type DateRangeValue,
|
||||
} from '@/components/ui';
|
||||
|
||||
type SendStatus = 'success' | 'unknown' | 'failed';
|
||||
|
||||
type SmsSendDetail = {
|
||||
id: string;
|
||||
applicationName: string;
|
||||
sentAt: string;
|
||||
content: string;
|
||||
wordCount: number;
|
||||
billingCount: number;
|
||||
phone: string;
|
||||
carrier: '中国移动' | '中国联通' | '中国电信';
|
||||
region: string;
|
||||
status: SendStatus;
|
||||
receipt: 'DELIVRD' | 'UNKNOWN' | 'UNDELIV';
|
||||
receiptAt?: string;
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<SendStatus, string> = {
|
||||
success: '成功',
|
||||
const statusLabelMap: Record<string, string> = {
|
||||
delivered: '成功',
|
||||
queued: '排队中',
|
||||
submitted: '已提交',
|
||||
accepted: '已受理',
|
||||
unknown: '未知',
|
||||
failed: '失败',
|
||||
rejected: '失败',
|
||||
timeout: '超时',
|
||||
};
|
||||
|
||||
const statusToneMap: Record<SendStatus, 'success' | 'info' | 'danger'> = {
|
||||
success: 'success',
|
||||
unknown: 'info',
|
||||
const statusToneMap: Record<string, 'success' | 'info' | 'danger' | 'neutral'> = {
|
||||
delivered: 'success',
|
||||
queued: 'info',
|
||||
submitted: 'info',
|
||||
accepted: 'info',
|
||||
unknown: 'neutral',
|
||||
failed: 'danger',
|
||||
rejected: 'danger',
|
||||
timeout: 'danger',
|
||||
};
|
||||
|
||||
const sendDetailRows: SmsSendDetail[] = [
|
||||
{
|
||||
id: 'SMSD20260316001',
|
||||
applicationName: '营销推广平台',
|
||||
sentAt: '2026-03-16 10:30:15',
|
||||
content: '【启瑞物业】尊敬的业主,您本月物业费500元,请及时缴纳。感谢您的配合!',
|
||||
wordCount: 45,
|
||||
billingCount: 1,
|
||||
phone: '13800138000',
|
||||
carrier: '中国移动',
|
||||
region: '北京市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-16 10:30:18',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316002',
|
||||
applicationName: '客服系统',
|
||||
sentAt: '2026-03-16 10:32:20',
|
||||
content: '【客服中心】尊敬的张先生,您已成功预约上门维修服务,时间:2026-03-18 14:00。',
|
||||
wordCount: 48,
|
||||
billingCount: 1,
|
||||
phone: '13900139000',
|
||||
carrier: '中国联通',
|
||||
region: '上海市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-16 10:32:25',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316003',
|
||||
applicationName: '验证码服务',
|
||||
sentAt: '2026-03-16 10:35:40',
|
||||
content: '【验证码】您的验证码是123456,5分钟内有效,请勿泄露给他人。',
|
||||
wordCount: 34,
|
||||
billingCount: 1,
|
||||
phone: '13700137000',
|
||||
carrier: '中国电信',
|
||||
region: '深圳市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-16 10:35:43',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316004',
|
||||
applicationName: '营销推广平台',
|
||||
sentAt: '2026-03-16 10:38:10',
|
||||
content: '【启瑞物业】您好!春季业主大会将于2026-03-20在小区会议室举行,欢迎参加。',
|
||||
wordCount: 46,
|
||||
billingCount: 1,
|
||||
phone: '13600136000',
|
||||
carrier: '中国移动',
|
||||
region: '广州市',
|
||||
status: 'unknown',
|
||||
receipt: 'UNKNOWN',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316005',
|
||||
applicationName: '验证码服务',
|
||||
sentAt: '2026-03-16 10:40:30',
|
||||
content: '【验证码】您的验证码是654321,5分钟内有效,请勿泄露给他人。',
|
||||
wordCount: 34,
|
||||
billingCount: 1,
|
||||
phone: '13500135000',
|
||||
carrier: '中国联通',
|
||||
region: '杭州市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-16 10:40:33',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316006',
|
||||
applicationName: '订单通知系统',
|
||||
sentAt: '2026-03-16 10:45:12',
|
||||
content: '【订单通知】您的订单已发货,请留意物流信息。',
|
||||
wordCount: 28,
|
||||
billingCount: 1,
|
||||
phone: '18800188000',
|
||||
carrier: '中国电信',
|
||||
region: '成都市',
|
||||
status: 'failed',
|
||||
receipt: 'UNDELIV',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316007',
|
||||
applicationName: '营销推广平台',
|
||||
sentAt: '2026-03-16 10:48:01',
|
||||
content: '【启瑞物业】尊敬的客户,值此佳节之际,祝您节日快乐,阖家幸福。',
|
||||
wordCount: 39,
|
||||
billingCount: 1,
|
||||
phone: '15900159000',
|
||||
carrier: '中国移动',
|
||||
region: '武汉市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-16 10:48:06',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316008',
|
||||
applicationName: '客服系统',
|
||||
sentAt: '2026-03-16 10:51:36',
|
||||
content: '【客服中心】您的服务工单已受理,工作人员将在24小时内联系您。',
|
||||
wordCount: 36,
|
||||
billingCount: 1,
|
||||
phone: '15000150000',
|
||||
carrier: '中国联通',
|
||||
region: '南京市',
|
||||
status: 'unknown',
|
||||
receipt: 'UNKNOWN',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316009',
|
||||
applicationName: '订单通知系统',
|
||||
sentAt: '2026-03-16 10:55:44',
|
||||
content: '【订单通知】您的退款申请已提交,预计1-3个工作日内到账。',
|
||||
wordCount: 35,
|
||||
billingCount: 1,
|
||||
phone: '18900189000',
|
||||
carrier: '中国电信',
|
||||
region: '西安市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-16 10:55:49',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316010',
|
||||
applicationName: '验证码服务',
|
||||
sentAt: '2026-03-16 10:59:18',
|
||||
content: '【验证码】您的登录验证码为908172,请在5分钟内完成验证。',
|
||||
wordCount: 33,
|
||||
billingCount: 1,
|
||||
phone: '13200132000',
|
||||
carrier: '中国移动',
|
||||
region: '重庆市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-16 10:59:21',
|
||||
},
|
||||
];
|
||||
const carrierLabelMap: Record<string, string> = {
|
||||
mobile: '中国移动',
|
||||
unicom: '中国联通',
|
||||
telecom: '中国电信',
|
||||
all: '三网',
|
||||
};
|
||||
|
||||
function getDate(value: string) {
|
||||
return value.slice(0, 10);
|
||||
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: latest?.rawStatus ?? latest?.receiptStatus ?? record.receiptStatus ?? '-',
|
||||
time: latest?.deliveredAt ?? record.deliveredAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function ClientSendDetailPage() {
|
||||
const [applicationName, setApplicationName] = useState('all');
|
||||
const [records, setRecords] = useState<SmsMessageRecord[]>([]);
|
||||
const [applicationId, setApplicationId] = useState('all');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [contentKeyword, setContentKeyword] = useState('');
|
||||
const [phoneKeyword, setPhoneKeyword] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function loadData() {
|
||||
setLoading(true);
|
||||
clientApi.listMessages({
|
||||
applicationId: applicationId === 'all' ? undefined : applicationId,
|
||||
phoneNumber: phoneKeyword || undefined,
|
||||
status: status === 'all' ? undefined : status,
|
||||
})
|
||||
.then((items) => {
|
||||
setRecords(items);
|
||||
setError('');
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '短信发送详情加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [applicationId, phoneKeyword, status]);
|
||||
|
||||
const applicationOptions = useMemo(() => {
|
||||
const applications = Array.from(new Set(sendDetailRows.map((item) => item.applicationName)));
|
||||
const applications = new Map<string, string>();
|
||||
records.forEach((item) => {
|
||||
if (item.applicationId) {
|
||||
applications.set(item.applicationId, item.application?.name ?? item.applicationId);
|
||||
}
|
||||
});
|
||||
return [
|
||||
{ label: '全部应用', value: 'all' },
|
||||
...applications.map((item) => ({ label: item, value: item })),
|
||||
...Array.from(applications.entries()).map(([value, label]) => ({ label, value })),
|
||||
];
|
||||
}, []);
|
||||
}, [records]);
|
||||
|
||||
const filteredRows = sendDetailRows.filter((item) => {
|
||||
const matchesApplication = applicationName === 'all' || item.applicationName === applicationName;
|
||||
const matchesStatus = status === 'all' || item.status === status;
|
||||
const sentDate = getDate(item.sentAt);
|
||||
const filteredRows = records.filter((item) => {
|
||||
const sentDate = getDate(item.queuedAt);
|
||||
const matchesStartDate = !dateRange.start || sentDate >= dateRange.start;
|
||||
const matchesEndDate = !dateRange.end || sentDate <= dateRange.end;
|
||||
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
|
||||
const matchesPhone = !phoneKeyword || item.phone.includes(phoneKeyword);
|
||||
return matchesApplication && matchesStatus && matchesStartDate && matchesEndDate && matchesContent && matchesPhone;
|
||||
return matchesStartDate && matchesEndDate && matchesContent;
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -221,14 +114,14 @@ export function ClientSendDetailPage() {
|
||||
title="查询条件"
|
||||
summary={<>共找到 <strong>{filteredRows.length}</strong> 条发送记录</>}
|
||||
>
|
||||
<Select label="应用名称" onChange={(event) => setApplicationName(event.target.value)} options={applicationOptions} value={applicationName} />
|
||||
<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: 'success' },
|
||||
{ label: '成功', value: 'delivered' },
|
||||
{ label: '未知', value: 'unknown' },
|
||||
{ label: '失败', value: 'failed' },
|
||||
]}
|
||||
@@ -250,72 +143,74 @@ export function ClientSendDetailPage() {
|
||||
/>
|
||||
</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: '112px' }}>应用名称</th>
|
||||
<th style={{ width: '150px' }}>应用名称</th>
|
||||
<th style={{ width: '128px' }}>发送时间</th>
|
||||
<th style={{ width: '90px', textAlign: 'center' }}>字符数/条数</th>
|
||||
<th style={{ width: '120px', textAlign: 'center' }}>字符数/条数</th>
|
||||
<th style={{ width: '130px' }}>手机号码</th>
|
||||
<th style={{ width: '90px' }}>所属运营商</th>
|
||||
<th style={{ width: '90px' }}>号码归属地</th>
|
||||
<th style={{ width: '96px', textAlign: 'center' }}>发送状态</th>
|
||||
<th style={{ width: '95px', textAlign: 'center' }}>短信回执</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>
|
||||
{filteredRows.length === 0 ? (
|
||||
<tr>
|
||||
<td className="ui-table__empty" colSpan={9}>暂无发送记录</td>
|
||||
</tr>
|
||||
) : filteredRows.map((record) => (
|
||||
<Fragment key={record.id}>
|
||||
<tr className="send-detail-main-row">
|
||||
<td><strong className="send-detail-app-name">{record.applicationName}</strong></td>
|
||||
<td>
|
||||
<span className="send-detail-time">
|
||||
{record.sentAt.slice(0, 10)}
|
||||
<small>{record.sentAt.slice(11)}</small>
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<span className="send-detail-count">
|
||||
<strong>{record.wordCount}字</strong>
|
||||
<small>{record.billingCount}条</small>
|
||||
</span>
|
||||
</td>
|
||||
<td><strong>{record.phone}</strong></td>
|
||||
<td><strong className="send-detail-carrier">{record.carrier}</strong></td>
|
||||
<td>
|
||||
<span className="send-detail-region">
|
||||
{record.region.slice(0, 2)}
|
||||
<small>{record.region.slice(2)}</small>
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}><Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag></td>
|
||||
<td style={{ textAlign: 'center' }}><strong className="send-detail-receipt-code">{record.receipt}</strong></td>
|
||||
<td>
|
||||
{record.receiptAt ? (
|
||||
{loading ? (
|
||||
<tr><td className="ui-table__empty" colSpan={9}>正在加载真实发送记录...</td></tr>
|
||||
) : filteredRows.length === 0 ? (
|
||||
<tr><td className="ui-table__empty" colSpan={9}>暂无发送记录</td></tr>
|
||||
) : filteredRows.map((record) => {
|
||||
const receipt = getReceipt(record);
|
||||
const carrier = record.channel?.carrier ? carrierLabelMap[record.channel.carrier] ?? record.channel.carrier : '-';
|
||||
const region = record.channel?.sendRegion ?? '-';
|
||||
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.receiptAt.slice(0, 10)}
|
||||
<small>{record.receiptAt.slice(11)}</small>
|
||||
{record.queuedAt.slice(0, 10)}
|
||||
<small>{record.queuedAt.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>
|
||||
))}
|
||||
</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><strong className="send-detail-carrier">{carrier}</strong></td>
|
||||
<td><span className="send-detail-region">{region}</span></td>
|
||||
<td style={{ textAlign: 'center' }}><Tag tone={statusToneMap[record.status] ?? 'info'}>{statusLabelMap[record.status] ?? 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>
|
||||
|
||||
Reference in New Issue
Block a user