feat: add phone frequency controls and modularize codebase
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
.admin-sms-task-page .page-heading {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.admin-task-template-row .ui-inline-text-preview {
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
.admin-task-template-row .ui-inline-text-preview p,
|
||||
.admin-task-template-row .ui-inline-text-preview p strong {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.admin-task-detail-title {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.admin-task-detail-title h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.admin-task-detail-title p {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.admin-task-detail-title p > span {
|
||||
color: var(--color-text-strong);
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.admin-task-detail {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.admin-task-metrics {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-task-metric {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
min-height: 104px;
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.admin-task-metric span {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.admin-task-metric strong {
|
||||
color: var(--color-text-strong);
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.admin-task-metric--success strong {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.admin-task-metric--primary {
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.08), rgba(22, 163, 74, 0.08)), var(--color-surface);
|
||||
border-color: rgba(37, 99, 235, 0.24);
|
||||
}
|
||||
|
||||
.admin-task-metric--primary strong {
|
||||
color: var(--color-selected);
|
||||
}
|
||||
|
||||
.admin-task-detail-grid {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.admin-task-info-list,
|
||||
.admin-task-template-meta {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-task-info-list div,
|
||||
.admin-task-template-meta div {
|
||||
align-items: start;
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.admin-task-info-list dt,
|
||||
.admin-task-template-meta dt {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.admin-task-info-list dd,
|
||||
.admin-task-template-meta dd {
|
||||
color: var(--color-text-strong);
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
justify-items: end;
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.admin-task-info-list dd span {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-task-progress-card {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.admin-task-progress-card > div:first-child {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.admin-task-progress-card > div:first-child span {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-task-progress-card > div:first-child strong {
|
||||
color: var(--color-selected);
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.admin-task-progress-split {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-task-progress-split span {
|
||||
background: var(--color-surface-muted);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-muted);
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
justify-items: center;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.admin-task-progress-split strong {
|
||||
color: var(--color-text-strong);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.admin-task-template-block {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.admin-task-template-block > span {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.admin-task-template {
|
||||
background: var(--color-bg-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-strong);
|
||||
line-height: 1.75;
|
||||
margin: 0;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.admin-task-template-meta b {
|
||||
color: var(--color-selected);
|
||||
margin: 0 var(--space-1);
|
||||
}
|
||||
|
||||
.admin-task-billing-note {
|
||||
align-items: center;
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
min-height: 58px;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
}
|
||||
|
||||
.admin-task-card--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.admin-carrier-grid {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-carrier-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.admin-carrier-card--mobile {
|
||||
background: #eef4ff;
|
||||
border-color: #a7c4ff;
|
||||
}
|
||||
|
||||
.admin-carrier-card--unicom {
|
||||
background: #eaf8ee;
|
||||
border-color: #96dda7;
|
||||
}
|
||||
|
||||
.admin-carrier-card--telecom {
|
||||
background: #fff3e6;
|
||||
border-color: #fdba74;
|
||||
}
|
||||
|
||||
.admin-carrier-card strong {
|
||||
color: var(--color-text-strong);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.admin-carrier-card p {
|
||||
align-items: center;
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-carrier-card b {
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.admin-carrier-card div {
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.32);
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-4);
|
||||
}
|
||||
|
||||
.admin-carrier-card em {
|
||||
color: var(--color-selected);
|
||||
font-size: 30px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-bold);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.admin-carrier-card--unicom em,
|
||||
.admin-success-text {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.admin-carrier-card--telecom em {
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.admin-task-detail-grid,
|
||||
.admin-task-metrics,
|
||||
.admin-carrier-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Search } from 'lucide-react';
|
||||
import { Button, DateRangeInput, Input, Select, type DateRangeValue } from '@/components/ui';
|
||||
|
||||
type SmsTaskFilterProps = {
|
||||
application: string;
|
||||
applicationOptions: Array<{ label: string; value: string }>;
|
||||
enterprise: string;
|
||||
enterpriseOptions: Array<{ label: string; value: string }>;
|
||||
keyword: string;
|
||||
submittedDateRange: DateRangeValue;
|
||||
onApplicationChange: (value: string) => void;
|
||||
onEnterpriseChange: (value: string) => void;
|
||||
onKeywordChange: (value: string) => void;
|
||||
onQuery: () => void;
|
||||
onReset: () => void;
|
||||
onSubmittedDateRangeChange: (value: DateRangeValue) => void;
|
||||
};
|
||||
|
||||
export function SmsTaskFilter({
|
||||
application,
|
||||
applicationOptions,
|
||||
enterprise,
|
||||
enterpriseOptions,
|
||||
keyword,
|
||||
submittedDateRange,
|
||||
onApplicationChange,
|
||||
onEnterpriseChange,
|
||||
onKeywordChange,
|
||||
onQuery,
|
||||
onReset,
|
||||
onSubmittedDateRangeChange,
|
||||
}: SmsTaskFilterProps) {
|
||||
return (
|
||||
<div className="surface admin-task-filter">
|
||||
<Input label="发送批次号" onChange={(event) => onKeywordChange(event.target.value)} placeholder="请输入发送批次号" value={keyword} />
|
||||
<Select
|
||||
label="选择企业"
|
||||
onChange={(event) => onEnterpriseChange(event.target.value)}
|
||||
options={enterpriseOptions}
|
||||
value={enterprise}
|
||||
/>
|
||||
<Select label="选择应用" onChange={(event) => onApplicationChange(event.target.value)} options={applicationOptions} value={application} />
|
||||
<DateRangeInput label="提交时间" onChange={onSubmittedDateRangeChange} value={submittedDateRange} />
|
||||
<div className="admin-task-filter__actions">
|
||||
<Button icon={<Search size={16} />} onClick={onQuery}>查询</Button>
|
||||
<Button onClick={onReset} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Fragment } from 'react';
|
||||
import { CalendarClock, Eye, StopCircle } from 'lucide-react';
|
||||
import { Button, InlineTextPreview, Pagination, Tag } from '@/components/ui';
|
||||
import {
|
||||
formatNumber,
|
||||
formatTime,
|
||||
getProgress,
|
||||
sendTypeLabels,
|
||||
splitSignature,
|
||||
statusLabels,
|
||||
statusTones,
|
||||
} from './taskModel';
|
||||
import type { SmsTask } from './taskTypes';
|
||||
|
||||
type SmsTaskTableProps = {
|
||||
currentPage: number;
|
||||
hoveredTaskId: string | null;
|
||||
loading: boolean;
|
||||
tasks: SmsTask[];
|
||||
total: number;
|
||||
totalPages: number;
|
||||
onHoverTask: (taskId: string | null) => void;
|
||||
onOpenDetail: (task: SmsTask) => void;
|
||||
onOpenPhones: (task: SmsTask) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onTerminate: (task: SmsTask) => void;
|
||||
};
|
||||
|
||||
export function SmsTaskTable({
|
||||
currentPage,
|
||||
hoveredTaskId,
|
||||
loading,
|
||||
tasks,
|
||||
total,
|
||||
totalPages,
|
||||
onHoverTask,
|
||||
onOpenDetail,
|
||||
onOpenPhones,
|
||||
onPageChange,
|
||||
onTerminate,
|
||||
}: SmsTaskTableProps) {
|
||||
return (
|
||||
<div className="surface admin-task-table-card">
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table batch-table admin-task-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '170px' }}>发送批次号</th>
|
||||
<th style={{ width: '180px' }}>企业/应用</th>
|
||||
<th style={{ width: '136px' }}>提交时间</th>
|
||||
<th style={{ width: '130px' }}>号码数/字符数</th>
|
||||
<th style={{ width: '150px' }}>发送方式</th>
|
||||
<th style={{ width: '190px' }}>进度</th>
|
||||
<th style={{ width: '130px' }}>状态</th>
|
||||
<th style={{ textAlign: 'right', width: '170px' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td className="ui-table__empty" colSpan={8}>正在加载真实短信任务...</td></tr>
|
||||
) : tasks.length === 0 ? (
|
||||
<tr><td className="ui-table__empty" colSpan={8}>暂无短信任务</td></tr>
|
||||
) : tasks.map((record) => {
|
||||
const progress = getProgress(record);
|
||||
const { signature, content } = splitSignature(record.templateContent);
|
||||
const rowClass = hoveredTaskId === record.id ? 'batch-row--hovered' : '';
|
||||
|
||||
return (
|
||||
<Fragment key={record.backendId}>
|
||||
<tr
|
||||
className={['batch-main-row', rowClass].filter(Boolean).join(' ')}
|
||||
onMouseEnter={() => onHoverTask(record.id)}
|
||||
onMouseLeave={() => onHoverTask(null)}
|
||||
>
|
||||
<td><strong className="admin-task-id">{record.id}</strong></td>
|
||||
<td>
|
||||
<div className="admin-task-enterprise">
|
||||
<strong>{record.enterprise}</strong>
|
||||
<span>{record.application}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><span>{formatTime(record.submittedAt)}</span></td>
|
||||
<td>
|
||||
<div className="admin-task-counts">
|
||||
<button className="table-link" onClick={() => onOpenPhones(record)} type="button">{formatNumber(record.phoneCount)} · 查看列表</button>
|
||||
<span>{record.wordCount}字</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-task-send-type">
|
||||
<Tag tone={record.sendType === 'immediate' ? 'info' : 'warning'}>
|
||||
{record.sendType === 'scheduled' ? <CalendarClock size={13} /> : null}
|
||||
{sendTypeLabels[record.sendType]}
|
||||
</Tag>
|
||||
{record.scheduledAt ? <span>{formatTime(record.scheduledAt)}</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="batch-progress admin-task-list-progress">
|
||||
<div>
|
||||
<span>{formatNumber(record.sentCount)}/{formatNumber(record.phoneCount)}</span>
|
||||
<strong>{progress}%</strong>
|
||||
</div>
|
||||
<div className="batch-progress__track">
|
||||
<span className={`batch-progress__bar batch-progress__bar--${record.status === 'failed' ? 'terminated' : record.status}`} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><Tag tone={statusTones[record.status]}>{statusLabels[record.status]}</Tag></td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div className="admin-task-actions">
|
||||
<Button icon={<Eye size={15} />} onClick={() => onOpenDetail(record)} size="sm" variant="ghost">详情</Button>
|
||||
<Button
|
||||
disabled={record.status !== 'sending'}
|
||||
icon={<StopCircle size={15} />}
|
||||
onClick={() => onTerminate(record)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
终止
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
className={['batch-template-row', 'admin-task-template-row', rowClass].filter(Boolean).join(' ')}
|
||||
onMouseEnter={() => onHoverTask(record.id)}
|
||||
onMouseLeave={() => onHoverTask(null)}
|
||||
>
|
||||
<td colSpan={8}>
|
||||
<InlineTextPreview label="模板内容" leading={signature ? <strong>【{signature}】</strong> : null}>
|
||||
{content}
|
||||
</InlineTextPreview>
|
||||
</td>
|
||||
</tr>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
onPrevious={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
page={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={onPageChange}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={total}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { BarChart3, MapPin, Send, Smartphone, TrendingUp } from 'lucide-react';
|
||||
import { Button, Modal, Table, Tag } from '@/components/ui';
|
||||
import {
|
||||
formatNumber,
|
||||
formatTime,
|
||||
getProgress,
|
||||
getRegionRate,
|
||||
getSuccessRate,
|
||||
sendTypeLabels,
|
||||
statusLabels,
|
||||
statusTones,
|
||||
} from './taskModel';
|
||||
import type { RegionStat, SmsTask } from './taskTypes';
|
||||
|
||||
function TaskDetailTitle({ task }: { task: SmsTask }) {
|
||||
return (
|
||||
<div className="admin-task-detail-title">
|
||||
<h2>发送批次详情</h2>
|
||||
<p>
|
||||
<span>{task.id}</span>
|
||||
<Tag tone={statusTones[task.status]}>{statusLabels[task.status]}</Tag>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, tone }: { label: string; value: string; tone?: 'success' | 'primary' }) {
|
||||
return (
|
||||
<div className={['admin-task-metric', tone ? `admin-task-metric--${tone}` : ''].filter(Boolean).join(' ')}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: () => void }) {
|
||||
const progress = getProgress(task);
|
||||
const successRate = getSuccessRate(task);
|
||||
const perPhoneBillingUnits = Math.max(1, Math.ceil(task.wordCount / 67));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={<Button onClick={onClose}>关闭</Button>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<TaskDetailTitle task={task} />}
|
||||
>
|
||||
<div className="admin-task-detail">
|
||||
<div className="admin-task-metrics">
|
||||
<MetricCard label="提交总数" value={formatNumber(task.submittedCount)} />
|
||||
<MetricCard label="提交成功" tone="success" value={formatNumber(task.submittedSuccess)} />
|
||||
<MetricCard label="发送成功" tone="primary" value={formatNumber(task.successCount)} />
|
||||
<MetricCard label="计费条数" tone="primary" value={formatNumber(task.billingCount)} />
|
||||
<MetricCard label="成功率" tone="primary" value={`${successRate.toFixed(2)}%`} />
|
||||
</div>
|
||||
|
||||
<div className="admin-task-detail-grid">
|
||||
<section className="admin-task-card">
|
||||
<h3><Send size={18} />发送批次信息</h3>
|
||||
<dl className="admin-task-info-list">
|
||||
<div>
|
||||
<dt>企业/应用</dt>
|
||||
<dd><strong>{task.enterprise}</strong><span>{task.application}</span></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>提交时间</dt>
|
||||
<dd>{formatTime(task.submittedAt)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>发送方式</dt>
|
||||
<dd><Tag tone={task.sendType === 'immediate' ? 'info' : 'warning'}>{sendTypeLabels[task.sendType]}</Tag></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="admin-task-card">
|
||||
<h3><TrendingUp size={18} />发送进度</h3>
|
||||
<div className="admin-task-progress-card">
|
||||
<div>
|
||||
<span>已处理 {formatNumber(task.sentCount)} / 总计 {formatNumber(task.phoneCount)}</span>
|
||||
<strong>{progress}%</strong>
|
||||
</div>
|
||||
<div className="batch-progress__track">
|
||||
<span className={`batch-progress__bar batch-progress__bar--${task.status === 'failed' ? 'terminated' : task.status}`} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="admin-task-progress-split">
|
||||
<span>已提交<strong>{formatNumber(task.submittedSuccess)}</strong></span>
|
||||
<span>已成功<strong>{formatNumber(task.successCount)}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-task-card">
|
||||
<h3><BarChart3 size={18} />模板信息</h3>
|
||||
<div className="admin-task-template-block">
|
||||
<span>短信模板内容</span>
|
||||
<p className="admin-task-template">{task.templateContent}</p>
|
||||
</div>
|
||||
<dl className="admin-task-template-meta">
|
||||
<div><dt>字符数/计费条数</dt><dd>{task.wordCount} 字符 <b>·</b> {perPhoneBillingUnits} 条/号码</dd></div>
|
||||
<div><dt>发送号码数</dt><dd>{formatNumber(task.phoneCount)} 个</dd></div>
|
||||
</dl>
|
||||
<div className="admin-task-billing-note">
|
||||
<span>计费规则:每 67 字为 1 条短信。本次任务单号码 {perPhoneBillingUnits} 条,预计总计费 {formatNumber(task.billingCount)} 条</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="admin-task-card admin-task-card--full">
|
||||
<h3><Smartphone size={18} />号码运营商分布</h3>
|
||||
{task.carriers.length === 0 ? (
|
||||
<div className="admin-uplink-empty-match">暂无已识别运营商记录</div>
|
||||
) : (
|
||||
<div className="admin-carrier-grid">
|
||||
{task.carriers.map((carrier) => {
|
||||
const rate = carrier.total > 0 ? (carrier.success / carrier.total) * 100 : 0;
|
||||
return (
|
||||
<article className={`admin-carrier-card admin-carrier-card--${carrier.tone}`} key={carrier.name}>
|
||||
<strong>{carrier.name}</strong>
|
||||
<p><span>总数</span><b>{formatNumber(carrier.total)}</b></p>
|
||||
<p><span>成功</span><b>{formatNumber(carrier.success)}</b></p>
|
||||
<div>
|
||||
<em>{rate.toFixed(1)}%</em>
|
||||
<span>成功率</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="admin-task-card admin-task-card--full">
|
||||
<h3><MapPin size={18} />号码省份分布</h3>
|
||||
<Table
|
||||
columns={[
|
||||
{ key: 'region', title: '省份', render: (record: RegionStat) => <strong>{record.region}</strong> },
|
||||
{ key: 'total', title: '总数', align: 'right', render: (record: RegionStat) => formatNumber(record.total) },
|
||||
{ key: 'success', title: '成功', align: 'right', render: (record: RegionStat) => <span className="admin-success-text">{formatNumber(record.success)}</span> },
|
||||
{
|
||||
key: 'rate',
|
||||
title: '成功率',
|
||||
align: 'right',
|
||||
render: (record: RegionStat) => <Tag tone={getRegionRate(record) >= 95 ? 'success' : 'info'}>{getRegionRate(record).toFixed(1)}%</Tag>,
|
||||
},
|
||||
]}
|
||||
data={task.regions}
|
||||
emptyText="暂无已识别省份记录"
|
||||
rowKey={(record) => record.region}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { adminApi, type BatchTaskMessagePage } from '@/api/adminApi';
|
||||
import { Button, Input, Modal, Pagination, Select, Table, Tag } from '@/components/ui';
|
||||
import { carrierLabels, messageStatusLabel } from './taskModel';
|
||||
import type { SmsTask } from './taskTypes';
|
||||
|
||||
type TaskPhoneListModalProps = {
|
||||
task: SmsTask;
|
||||
onClose: () => void;
|
||||
onError: (message: string) => void;
|
||||
};
|
||||
|
||||
const emptyPage: BatchTaskMessagePage = { items: [], total: 0, page: 1, pageSize: 20 };
|
||||
|
||||
export function TaskPhoneListModal({ task, onClose, onError }: TaskPhoneListModalProps) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [data, setData] = useState<BatchTaskMessagePage>(emptyPage);
|
||||
|
||||
function loadPhones(targetPage = page, targetPageSize = pageSize) {
|
||||
adminApi.listAdminBatchTaskMessages(task.backendId, {
|
||||
phone: keyword || undefined,
|
||||
page: targetPage,
|
||||
pageSize: targetPageSize,
|
||||
})
|
||||
.then(setData)
|
||||
.catch((failure: Error) => onError(failure.message || '发送批次号码列表加载失败'));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadPhones(page, pageSize);
|
||||
}, [task.backendId, page, pageSize]);
|
||||
|
||||
return (
|
||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open size="xl" title={`号码列表 · 发送批次号 ${task.id}`}>
|
||||
<div className="page-stack">
|
||||
<div className="audit-filter-grid">
|
||||
<Input label="手机号码" onChange={(event) => setKeyword(event.target.value)} placeholder="输入完整或部分号码" value={keyword} />
|
||||
<Select
|
||||
label="每页条数"
|
||||
onChange={(event) => {
|
||||
setPageSize(Number(event.target.value));
|
||||
setPage(1);
|
||||
}}
|
||||
options={[{ label: '10条/页', value: '10' }, { label: '20条/页', value: '20' }, { label: '50条/页', value: '50' }]}
|
||||
value={String(pageSize)}
|
||||
/>
|
||||
<div className="audit-filter-actions">
|
||||
<Button icon={<Search size={16} />} onClick={() => { setPage(1); loadPhones(1, pageSize); }}>查询</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Table
|
||||
columns={[
|
||||
{ key: 'phoneNumber', title: '手机号码', render: (item) => <strong>{item.phoneNumber}</strong> },
|
||||
{ key: 'province', title: '号码归属地', render: (item) => item.province || '-' },
|
||||
{ key: 'carrier', title: '运营商', render: (item) => carrierLabels[item.carrier ?? '']?.label ?? item.carrier ?? '-' },
|
||||
{ key: 'status', title: '短信记录状态', render: (item) => <Tag tone={item.status === 'delivered' ? 'success' : ['failed', 'submit_failed', 'rejected', 'timeout'].includes(item.status) ? 'danger' : 'info'}>{messageStatusLabel(item.status)}</Tag> },
|
||||
]}
|
||||
data={data.items}
|
||||
emptyText="暂无号码记录"
|
||||
rowKey="id"
|
||||
/>
|
||||
<Pagination
|
||||
nextDisabled={page * pageSize >= data.total}
|
||||
onNext={() => setPage((current) => current + 1)}
|
||||
onPageChange={setPage}
|
||||
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
|
||||
page={page}
|
||||
previousDisabled={page <= 1}
|
||||
total={data.total}
|
||||
totalPages={Math.max(1, Math.ceil(data.total / pageSize))}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Button, Modal } from '@/components/ui';
|
||||
import type { SmsTask } from './taskTypes';
|
||||
|
||||
type TerminateTaskModalProps = {
|
||||
task: SmsTask;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function TerminateTaskModal({ task, onCancel, onConfirm }: TerminateTaskModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onCancel} variant="ghost">取消</Button>
|
||||
<Button onClick={onConfirm} variant="danger">确认终止</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onCancel}
|
||||
open
|
||||
title="确认终止短信任务"
|
||||
>
|
||||
<div className="admin-confirm-text">
|
||||
确认终止任务 <strong>{task.id}</strong> 吗?终止后将停止继续提交未发送号码,已提交部分仍以运营商回执为准。
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { SmsBatchTask, SmsMessageRecord } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import type { CarrierStat, RegionStat, SmsTask, TaskStatus } from './taskTypes';
|
||||
|
||||
export const statusLabels: Record<TaskStatus, string> = {
|
||||
sending: '发送中',
|
||||
completed: '已完成',
|
||||
terminated: '已终止',
|
||||
failed: '失败',
|
||||
};
|
||||
|
||||
export const statusTones: Record<TaskStatus, 'info' | 'success' | 'neutral' | 'danger'> = {
|
||||
sending: 'info',
|
||||
completed: 'success',
|
||||
terminated: 'neutral',
|
||||
failed: 'danger',
|
||||
};
|
||||
|
||||
export const sendTypeLabels = {
|
||||
immediate: '立即发送',
|
||||
scheduled: '定时发送',
|
||||
} as const;
|
||||
|
||||
export const carrierLabels: Record<string, { label: string; tone: CarrierStat['tone'] }> = {
|
||||
mobile: { label: '中国移动', tone: 'mobile' },
|
||||
unicom: { label: '中国联通', tone: 'unicom' },
|
||||
telecom: { label: '中国电信', tone: 'telecom' },
|
||||
all: { label: '三网通道', tone: 'mobile' },
|
||||
};
|
||||
|
||||
export function formatNumber(value: number) {
|
||||
return value.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
export function formatTime(value?: string | null) {
|
||||
return formatDateTime(value);
|
||||
}
|
||||
|
||||
export function messageStatusLabel(status: string) {
|
||||
return {
|
||||
pending_review: '待人工审核',
|
||||
queued: '已入队',
|
||||
scheduled: '等待定时发送',
|
||||
submitted: '供应商已受理',
|
||||
delivered: '送达成功',
|
||||
submit_failed: '提交失败',
|
||||
failed: '回执失败',
|
||||
rejected: '已拒绝',
|
||||
timeout: '超时',
|
||||
canceled: '已取消',
|
||||
}[status] ?? status;
|
||||
}
|
||||
|
||||
function normalizeTaskStatus(status: string): TaskStatus {
|
||||
if (['finished', 'completed', 'done'].includes(status)) return 'completed';
|
||||
if (['canceled', 'cancelled', 'terminated'].includes(status)) return 'terminated';
|
||||
if (['failed', 'rejected'].includes(status)) return 'failed';
|
||||
return 'sending';
|
||||
}
|
||||
|
||||
function countMessages(messages: SmsMessageRecord[] | undefined, statuses: string[]) {
|
||||
return (messages ?? []).filter((message) => statuses.includes(message.status)).length;
|
||||
}
|
||||
|
||||
function buildCarrierStats(messages: SmsMessageRecord[] | undefined): CarrierStat[] {
|
||||
const stats = new Map<string, CarrierStat>();
|
||||
(messages ?? []).forEach((message) => {
|
||||
const carrier = message.carrier ?? 'unknown';
|
||||
const meta = carrierLabels[carrier] ?? { label: carrier || '未知通道', tone: 'mobile' as const };
|
||||
const current = stats.get(carrier) ?? { name: meta.label, total: 0, success: 0, tone: meta.tone };
|
||||
current.total += 1;
|
||||
if (message.status === 'delivered') current.success += 1;
|
||||
stats.set(carrier, current);
|
||||
});
|
||||
return Array.from(stats.values());
|
||||
}
|
||||
|
||||
function buildRegionStats(messages: SmsMessageRecord[] | undefined): RegionStat[] {
|
||||
const stats = new Map<string, RegionStat>();
|
||||
(messages ?? []).forEach((message) => {
|
||||
const region = message.province ?? '未识别省份';
|
||||
const current = stats.get(region) ?? { region, total: 0, success: 0 };
|
||||
current.total += 1;
|
||||
if (message.status === 'delivered') current.success += 1;
|
||||
stats.set(region, current);
|
||||
});
|
||||
return Array.from(stats.values()).sort((a, b) => b.total - a.total);
|
||||
}
|
||||
|
||||
function buildCarrierStatsFromAggregates(stats: SmsBatchTask['messageStats']): CarrierStat[] {
|
||||
const totals = new Map<string, CarrierStat>();
|
||||
(stats ?? []).forEach((item) => {
|
||||
const carrier = item.carrier ?? 'unknown';
|
||||
const meta = carrierLabels[carrier] ?? { label: carrier || '未识别', tone: 'mobile' as const };
|
||||
const current = totals.get(carrier) ?? { name: meta.label, total: 0, success: 0, tone: meta.tone };
|
||||
current.total += item._count._all;
|
||||
if (item.status === 'delivered') current.success += item._count._all;
|
||||
totals.set(carrier, current);
|
||||
});
|
||||
return Array.from(totals.values());
|
||||
}
|
||||
|
||||
function buildRegionStatsFromAggregates(stats: SmsBatchTask['messageStats']): RegionStat[] {
|
||||
const totals = new Map<string, RegionStat>();
|
||||
(stats ?? []).forEach((item) => {
|
||||
const region = item.province ?? '未识别省份';
|
||||
const current = totals.get(region) ?? { region, total: 0, success: 0 };
|
||||
current.total += item._count._all;
|
||||
if (item.status === 'delivered') current.success += item._count._all;
|
||||
totals.set(region, current);
|
||||
});
|
||||
return Array.from(totals.values()).sort((a, b) => b.total - a.total);
|
||||
}
|
||||
|
||||
export function mapTask(task: SmsBatchTask): SmsTask {
|
||||
const messages = task.messages ?? [];
|
||||
const submittedStatuses = ['submitted', 'delivered', 'failed', 'unknown', 'timeout', 'submit_failed'];
|
||||
const failedStatuses = ['failed', 'submit_failed', 'rejected', 'timeout'];
|
||||
const submittedCount = task.submittedTotal ?? countMessages(messages, submittedStatuses);
|
||||
const successCount = task.successTotal ?? countMessages(messages, ['delivered']);
|
||||
const failedCount = task.failedTotal ?? countMessages(messages, failedStatuses);
|
||||
// submittedTotal already includes unknown and timeout records, so never add them again.
|
||||
const processedCount = Math.max(submittedCount, successCount + failedCount + (task.unknownTotal ?? 0));
|
||||
const billingCount = (task.messageStats ?? []).reduce((sum, item) => sum + (item._sum.billingUnits ?? 0), 0)
|
||||
|| messages.reduce((sum, message) => sum + (message.billingUnits ?? 0), 0)
|
||||
|| task.phoneTotal * (task.template?.billingUnits ?? Math.max(1, Math.ceil([...task.content].length / 67)));
|
||||
|
||||
return {
|
||||
id: task.taskNo || task.id,
|
||||
backendId: task.id,
|
||||
enterprise: task.tenant?.name ?? task.tenantId,
|
||||
application: task.application?.name ?? task.applicationId ?? '未绑定应用',
|
||||
submittedAt: task.createdAt,
|
||||
templateContent: task.content,
|
||||
phoneCount: task.phoneTotal,
|
||||
wordCount: [...task.content].length,
|
||||
billingCount,
|
||||
sendType: task.scheduledAt ? 'scheduled' : 'immediate',
|
||||
scheduledAt: task.scheduledAt,
|
||||
submittedCount,
|
||||
submittedSuccess: submittedCount,
|
||||
sentCount: Math.min(task.phoneTotal, processedCount),
|
||||
successCount,
|
||||
failedCount,
|
||||
status: normalizeTaskStatus(task.status),
|
||||
rawStatus: task.status,
|
||||
carriers: task.messageStats ? buildCarrierStatsFromAggregates(task.messageStats) : buildCarrierStats(messages),
|
||||
regions: task.messageStats ? buildRegionStatsFromAggregates(task.messageStats) : buildRegionStats(messages),
|
||||
};
|
||||
}
|
||||
|
||||
export function getProgress(task: SmsTask) {
|
||||
return task.phoneCount > 0 ? Math.min(100, Math.round((task.sentCount / task.phoneCount) * 100)) : 0;
|
||||
}
|
||||
|
||||
export function getSuccessRate(task: SmsTask) {
|
||||
return task.submittedCount > 0 ? (task.successCount / task.submittedCount) * 100 : 0;
|
||||
}
|
||||
|
||||
export function getRegionRate(region: RegionStat) {
|
||||
return region.total > 0 ? (region.success / region.total) * 100 : 0;
|
||||
}
|
||||
|
||||
export function splitSignature(content: string) {
|
||||
const match = content.match(/^【(.+?)】(.+)$/);
|
||||
return {
|
||||
signature: match?.[1],
|
||||
content: match?.[2] ?? content,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export type TaskStatus = 'sending' | 'completed' | 'terminated' | 'failed';
|
||||
export type SendType = 'immediate' | 'scheduled';
|
||||
|
||||
export type CarrierStat = {
|
||||
name: string;
|
||||
total: number;
|
||||
success: number;
|
||||
tone: 'mobile' | 'unicom' | 'telecom';
|
||||
};
|
||||
|
||||
export type RegionStat = {
|
||||
region: string;
|
||||
total: number;
|
||||
success: number;
|
||||
};
|
||||
|
||||
export type SmsTask = {
|
||||
id: string;
|
||||
backendId: string;
|
||||
enterprise: string;
|
||||
application: string;
|
||||
submittedAt: string;
|
||||
templateContent: string;
|
||||
phoneCount: number;
|
||||
wordCount: number;
|
||||
billingCount: number;
|
||||
sendType: SendType;
|
||||
scheduledAt?: string | null;
|
||||
submittedCount: number;
|
||||
submittedSuccess: number;
|
||||
sentCount: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
status: TaskStatus;
|
||||
rawStatus: string;
|
||||
carriers: CarrierStat[];
|
||||
regions: RegionStat[];
|
||||
};
|
||||
Reference in New Issue
Block a user