feat: add phone frequency controls and modularize codebase
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
import { Clock3, Eye, FileText, Search, StopCircle } from 'lucide-react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
DateRangeInput,
|
||||
@@ -96,12 +97,13 @@ function mapTask(task: SmsBatchTask): BatchTask {
|
||||
}
|
||||
|
||||
export function ClientBatchTasksPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [tasks, setTasks] = useState<BatchTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [total, setTotal] = useState(0);
|
||||
const [applications, setApplications] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [keyword, setKeyword] = useState(() => searchParams.get('taskNo') ?? '');
|
||||
const [application, setApplication] = useState('all');
|
||||
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
|
||||
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
BadgeCheck,
|
||||
BellRing,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
PenLine,
|
||||
Plus,
|
||||
@@ -66,10 +66,10 @@ export function ClientHome() {
|
||||
|
||||
const sendTrendOption = useMemo(
|
||||
() => createLineOption({
|
||||
labels: ['今日'],
|
||||
labels: dashboard?.hourlySendTrend.map((item) => item.label) ?? [],
|
||||
series: [
|
||||
{ name: '提交量', data: [dashboard?.today.sent ?? 0] },
|
||||
{ name: '成功量', data: [dashboard?.today.delivered ?? 0] },
|
||||
{ name: '提交量', data: dashboard?.hourlySendTrend.map((item) => item.submittedCount) ?? [] },
|
||||
{ name: '成功量', data: dashboard?.hourlySendTrend.map((item) => item.successCount) ?? [] },
|
||||
],
|
||||
}),
|
||||
[dashboard],
|
||||
@@ -134,12 +134,12 @@ export function ClientHome() {
|
||||
<button className="quick-action" onClick={() => navigate('/client/templates')} type="button">
|
||||
<FileText size={20} />
|
||||
<span>模板管理</span>
|
||||
<small>进入真实模板列表</small>
|
||||
<small>进入模板列表</small>
|
||||
</button>
|
||||
<button className="quick-action" onClick={() => navigate('/client/signatures')} type="button">
|
||||
<PenLine size={20} />
|
||||
<span>签名管理</span>
|
||||
<small>进入真实签名列表</small>
|
||||
<small>进入签名列表</small>
|
||||
</button>
|
||||
<button className="quick-action" onClick={() => navigate('/client/billing')} type="button">
|
||||
<WalletCards size={20} />
|
||||
@@ -155,16 +155,18 @@ export function ClientHome() {
|
||||
<h2>账户状态</h2>
|
||||
<p className="muted">企业认证与资源用量。</p>
|
||||
</div>
|
||||
<Tag tone={account?.status === 'active' ? 'success' : 'warning'}>{account?.status ?? '未知'}</Tag>
|
||||
<Tag tone={dashboard?.clientOverview?.certificationStatus === 'certified' ? 'success' : 'warning'}>
|
||||
{dashboard?.clientOverview?.certificationStatus === 'certified' ? '已认证' : '未认证'}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="summary-list">
|
||||
<div>
|
||||
<span>企业主体</span>
|
||||
<strong>{account?.tenant?.name ?? account?.tenantId ?? '当前租户'}</strong>
|
||||
<strong>{dashboard?.clientOverview?.enterpriseName ?? account?.tenant?.name ?? '企业信息未完善'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>默认签名</span>
|
||||
<strong>由发送资源 API 管理</strong>
|
||||
<span>签名数量</span>
|
||||
<strong>{dashboard?.clientOverview?.signatureCount ?? 0} 个</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>最近充值</span>
|
||||
@@ -184,36 +186,36 @@ export function ClientHome() {
|
||||
</div>
|
||||
|
||||
<div className="overview-grid overview-grid--three">
|
||||
<div className="surface mini-status-card">
|
||||
<button className="surface mini-status-card mini-status-card--action" onClick={() => navigate('/client/templates')} type="button">
|
||||
<BadgeCheck size={22} />
|
||||
<div>
|
||||
<span>模板状态</span>
|
||||
<strong>{dashboard?.pendingAuditCount ?? 0} 待处理</strong>
|
||||
<strong>{dashboard?.pendingAudits.templates ?? 0} 个待审核</strong>
|
||||
<small>点击进入模板明细</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface mini-status-card">
|
||||
</button>
|
||||
<button className="surface mini-status-card mini-status-card--action" onClick={() => navigate('/client/signatures')} type="button">
|
||||
<PenLine size={22} />
|
||||
<div>
|
||||
<span>签名状态</span>
|
||||
<strong>真实 API</strong>
|
||||
<strong>{dashboard?.pendingAudits.signatures ?? 0} 个待审核</strong>
|
||||
<small>点击进入签名明细</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface mini-status-card">
|
||||
<BellRing size={22} />
|
||||
</button>
|
||||
<button className="surface mini-status-card mini-status-card--action" onClick={() => navigate('/client/batch-tasks')} type="button">
|
||||
<ClipboardList size={22} />
|
||||
<div>
|
||||
<span>服务提醒</span>
|
||||
<strong>通道运行正常</strong>
|
||||
<small>备用通道有排队批次,请关注发送详情。</small>
|
||||
<span>批量任务</span>
|
||||
<strong>{dashboard?.clientOverview?.pendingBatchTaskCount ?? 0} 个待审核</strong>
|
||||
<small>点击进入批量任务菜单</small>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="chart-grid">
|
||||
<div className="surface chart-card">
|
||||
<h2>今日发送趋势</h2>
|
||||
<p className="muted">按 3 小时聚合提交量和成功量。</p>
|
||||
<p className="muted">按北京时间逐小时展示提交量和成功量。</p>
|
||||
<Chart height={300} option={sendTrendOption} />
|
||||
</div>
|
||||
<div className="surface chart-card">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Download, FileText, Plus, Search, Send, Trash2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button, DateTimeInput, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { clientApi, type ClientSmsApplication, type ClientSmsSignatureView, type ClientSmsTemplate, type ImportPreviewResponse, type SmsBatchTask } from '@/api/adminApi';
|
||||
import { formatCents } from '@/utils/currency';
|
||||
@@ -14,6 +15,7 @@ type SendMode = 'now' | 'scheduled';
|
||||
type ReceiverMode = 'manual' | 'import';
|
||||
|
||||
export function ClientSendPage() {
|
||||
const navigate = useNavigate();
|
||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
|
||||
const [signatures, setSignatures] = useState<ClientSmsSignatureView[]>([]);
|
||||
@@ -35,6 +37,8 @@ export function ClientSendPage() {
|
||||
const [importPreview, setImportPreview] = useState<ImportPreviewResponse | null>(null);
|
||||
const [importLoading, setImportLoading] = useState(false);
|
||||
const [submittedRecord, setSubmittedRecord] = useState<SmsBatchTask | null>(null);
|
||||
const [submitError, setSubmitError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([clientApi.listApplications(), clientApi.listTemplates({ status: 'approved' }), clientApi.listSignatures()])
|
||||
@@ -74,8 +78,8 @@ export function ClientSendPage() {
|
||||
const previewText = selectedSignature && messageContent
|
||||
? replaceLeadingSmsSignature(messageContent, selectedSignature.name)
|
||||
: messageContent;
|
||||
const wordCount = previewText.length;
|
||||
const smsParts = wordCount > 0 ? Math.max(1, Math.ceil(wordCount / 70)) : 0;
|
||||
const wordCount = [...previewText].length;
|
||||
const smsParts = wordCount === 0 ? 0 : wordCount <= 70 ? 1 : Math.ceil(wordCount / 67);
|
||||
const estimatedCount = receiverCount * smsParts;
|
||||
const requiredVariables = selectedTemplate?.variables?.map((item) => item.name) ?? [];
|
||||
const canSubmit = Boolean(taskName && applicationId && signatureId && templateId && receiverCount > 0 && (sendMode === 'now' || scheduledAt));
|
||||
@@ -100,10 +104,15 @@ export function ClientSendPage() {
|
||||
}
|
||||
|
||||
function submitTask() {
|
||||
if (!canSubmit) {
|
||||
if (!canSubmit || submitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setSubmitError('');
|
||||
const normalizedScheduledAt = sendMode === 'scheduled' && scheduledAt
|
||||
? `${scheduledAt}:00+08:00`
|
||||
: undefined;
|
||||
const submitRequest = receiverMode === 'manual'
|
||||
? clientApi.createBatchTask({
|
||||
applicationId,
|
||||
@@ -112,7 +121,7 @@ export function ClientSendPage() {
|
||||
category: selectedTemplate?.category ?? taskName,
|
||||
phones: validRecipients.map((item) => item.phone.trim()),
|
||||
sendMode: sendMode === 'now' ? 'immediate' : 'scheduled',
|
||||
scheduledAt: sendMode === 'scheduled' ? scheduledAt : undefined,
|
||||
scheduledAt: normalizedScheduledAt,
|
||||
})
|
||||
: clientApi.confirmImport({
|
||||
applicationId,
|
||||
@@ -122,7 +131,7 @@ export function ClientSendPage() {
|
||||
importContent,
|
||||
requiredVariables,
|
||||
sendMode: sendMode === 'now' ? 'immediate' : 'scheduled',
|
||||
scheduledAt: sendMode === 'scheduled' ? scheduledAt : undefined,
|
||||
scheduledAt: normalizedScheduledAt,
|
||||
});
|
||||
|
||||
submitRequest
|
||||
@@ -130,7 +139,33 @@ export function ClientSendPage() {
|
||||
setSubmittedRecord(task);
|
||||
setError('');
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '发送任务提交失败'));
|
||||
.catch((reason: Error) => setSubmitError(reason.message || '发送任务提交失败'))
|
||||
.finally(() => setSubmitting(false));
|
||||
}
|
||||
|
||||
function resetSendForm() {
|
||||
setTaskName('');
|
||||
setApplicationId('');
|
||||
setSignatureId('');
|
||||
setTemplateId('');
|
||||
setTemplateKeyword('');
|
||||
setMessageContent('');
|
||||
setSendMode('now');
|
||||
setScheduledAt('');
|
||||
setReceiverMode('manual');
|
||||
setRecipients([{ id: Date.now().toString(), phone: '' }]);
|
||||
setImportContent('');
|
||||
setImportFileName('');
|
||||
setImportFileUrl((current) => {
|
||||
if (current) {
|
||||
URL.revokeObjectURL(current);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
setImportPreview(null);
|
||||
setSubmittedRecord(null);
|
||||
setSubmitError('');
|
||||
setError('');
|
||||
}
|
||||
|
||||
async function previewImportFile(file: File) {
|
||||
@@ -352,8 +387,8 @@ export function ClientSendPage() {
|
||||
</section>
|
||||
|
||||
<div className="send-submit-row">
|
||||
<Button disabled={!canSubmit} icon={<Send size={18} />} onClick={submitTask}>
|
||||
提交发送任务
|
||||
<Button disabled={!canSubmit || submitting} icon={<Send size={18} />} onClick={submitTask}>
|
||||
{submitting ? '提交中...' : '提交发送任务'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -384,7 +419,7 @@ export function ClientSendPage() {
|
||||
</div>
|
||||
<div>
|
||||
<span>单价</span>
|
||||
<strong>¥{formatCents(selectedApplication?.customerUnitPrice)} / 人</strong>
|
||||
<strong>¥{formatCents(selectedApplication?.customerUnitPrice)} / 条</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="preview-note">短信按 70 字/条计费,超出部分按 67 字/条计算</div>
|
||||
@@ -422,6 +457,38 @@ export function ClientSendPage() {
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{submitError ? (
|
||||
<Modal
|
||||
footer={<Button onClick={() => setSubmitError('')}>我知道了</Button>}
|
||||
onClose={() => setSubmitError('')}
|
||||
open
|
||||
title="提交发送任务失败"
|
||||
>
|
||||
<p className="form-error">{submitError}</p>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
{submittedRecord ? (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={resetSendForm} variant="ghost">继续发送短信</Button>
|
||||
<Button onClick={() => navigate(`/client/batch-tasks?taskNo=${encodeURIComponent(submittedRecord.taskNo)}`)}>
|
||||
查看任务进度
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setSubmittedRecord(null)}
|
||||
open
|
||||
title="发送任务提交成功"
|
||||
>
|
||||
<div className="detail-grid">
|
||||
<div><span>任务编号</span><strong>{submittedRecord.taskNo}</strong></div>
|
||||
<div><span>发送号码数</span><strong>{submittedRecord.phoneTotal.toLocaleString('zh-CN')} 个</strong></div>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -269,6 +269,7 @@ export function ClientSignaturesPage() {
|
||||
const [drainageModal, setDrainageModal] = useState<{ signature: ClientSmsSignatureView; item?: ClientDrainageInfo }>();
|
||||
const [deleting, setDeleting] = useState<{ type: 'signature' | 'drainage'; id: string; name: string }>();
|
||||
const [page, setPage] = useState(1);
|
||||
const [refreshVersion, setRefreshVersion] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@@ -298,7 +299,7 @@ export function ClientSignaturesPage() {
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => loadData(page), 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [applicationFilter, keyword, page, statusFilter]);
|
||||
}, [applicationFilter, keyword, page, refreshVersion, statusFilter]);
|
||||
|
||||
const filteredItems = workspace.items;
|
||||
const totalPages = Math.max(1, Math.ceil(workspace.total / pageSize));
|
||||
@@ -324,7 +325,13 @@ export function ClientSignaturesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const resetFilters = () => { setKeyword(''); setApplicationFilter(''); setStatusFilter(''); setPage(1); };
|
||||
const resetFilters = () => {
|
||||
setKeyword('');
|
||||
setApplicationFilter('');
|
||||
setStatusFilter('');
|
||||
setPage(1);
|
||||
setRefreshVersion((version) => version + 1);
|
||||
};
|
||||
return <section className="page-stack client-signature-page">
|
||||
<header className="client-signature-heading">
|
||||
<div className="client-signature-title">
|
||||
|
||||
Reference in New Issue
Block a user