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, MoneyText, 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'; import { replaceLeadingSmsSignature } from '@/utils/smsSignature'; type Recipient = { id: string; phone: string; }; type SendMode = 'now' | 'scheduled'; type ReceiverMode = 'manual' | 'import'; const supportedImportExtensions = new Set(['csv', 'tsv', 'txt']); const supportedImportMimeTypes = new Set([ 'text/csv', 'application/csv', 'application/vnd.ms-excel', 'text/tab-separated-values', 'text/tsv', 'text/plain', ]); function assertSupportedImportFile(file: File) { const extension = file.name.split('.').pop()?.toLowerCase() ?? ''; const mimeType = file.type.toLowerCase(); if (!supportedImportExtensions.has(extension) || (mimeType && !supportedImportMimeTypes.has(mimeType))) { throw new Error('仅支持 CSV、TSV 或 TXT 文本文件'); } } export function ClientSendPage() { const navigate = useNavigate(); const [applications, setApplications] = useState([]); const [templates, setTemplates] = useState([]); const [signatures, setSignatures] = useState([]); const [error, setError] = useState(''); const [taskName, setTaskName] = useState(''); const [applicationId, setApplicationId] = useState(''); const [signatureId, setSignatureId] = useState(''); const [templateId, setTemplateId] = useState(''); const [templatePickerOpen, setTemplatePickerOpen] = useState(false); const [templateKeyword, setTemplateKeyword] = useState(''); const [messageContent, setMessageContent] = useState(''); const [sendMode, setSendMode] = useState('now'); const [scheduledAt, setScheduledAt] = useState(''); const [receiverMode, setReceiverMode] = useState('manual'); const [recipients, setRecipients] = useState([{ id: '1', phone: '' }]); const [importContent, setImportContent] = useState(''); const [importFileName, setImportFileName] = useState(''); const [importFileUrl, setImportFileUrl] = useState(''); const [importPreview, setImportPreview] = useState(null); const [importLoading, setImportLoading] = useState(false); const [submittedRecord, setSubmittedRecord] = useState(null); const [submitError, setSubmitError] = useState(''); const [submitting, setSubmitting] = useState(false); useEffect(() => { Promise.all([clientApi.listApplications(), clientApi.listTemplates({ status: 'approved' }), clientApi.listSignatures()]) .then(([applicationItems, templateItems, signatureItems]) => { setApplications(applicationItems.filter((item) => item.status === 'active')); setTemplates(templateItems); setSignatures(signatureItems.filter((item) => item.auditStatus === 'approved')); setError(''); }) .catch((reason: Error) => setError(reason.message || '短信发送基础数据加载失败')); }, []); useEffect(() => () => { if (importFileUrl) { URL.revokeObjectURL(importFileUrl); } }, [importFileUrl]); const selectedSignature = useMemo( () => signatures.find((item) => item.id === signatureId), [signatures, signatureId], ); const selectedTemplate = useMemo( () => templates.find((item) => item.id === templateId), [templates, templateId], ); const selectedApplication = useMemo( () => applications.find((item) => item.id === applicationId), [applicationId, applications], ); const filteredTemplates = templates.filter((item) => ( item.name.includes(templateKeyword) || item.content.includes(templateKeyword) )); const validRecipients = recipients.filter((item) => item.phone.trim()); const importedValidCount = importPreview?.validCount ?? 0; const receiverCount = receiverMode === 'manual' ? validRecipients.length : importedValidCount; const previewText = selectedSignature && messageContent ? replaceLeadingSmsSignature(messageContent, selectedSignature.name) : messageContent; 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 allowsDirectSend = selectedApplication?.templateMismatchMode === 'direct_send'; const validationMessage = !taskName.trim() ? '请填写任务名称' : !applicationId ? '请选择短信应用' : !signatureId ? '请选择已审核通过的短信签名' : !templateId && !allowsDirectSend ? '当前应用必须选择已审核通过的短信模板' : !messageContent.trim() ? '请填写短信内容' : receiverCount < 1 ? receiverMode === 'manual' ? '请至少填写一个接收号码' : '请导入至少一个有效号码' : sendMode === 'scheduled' && !scheduledAt ? '请选择定时发送时间' : ''; const canSubmit = validationMessage === ''; function updateRecipient(id: string, phone: string) { setRecipients((items) => items.map((item) => (item.id === id ? { ...item, phone } : item))); } function addRecipient() { setRecipients((items) => [...items, { id: Date.now().toString(), phone: '' }]); } function removeRecipient(id: string) { setRecipients((items) => (items.length === 1 ? items : items.filter((item) => item.id !== id))); } function chooseTemplate(id: string) { const template = templates.find((item) => item.id === id); setTemplateId(id); setMessageContent(template?.content ?? ''); setTemplatePickerOpen(false); } function submitTask() { if (!canSubmit || submitting) { return; } setSubmitting(true); setSubmitError(''); const normalizedScheduledAt = sendMode === 'scheduled' && scheduledAt ? `${scheduledAt}:00+08:00` : undefined; const submitRequest = receiverMode === 'manual' ? clientApi.createBatchTask({ applicationId, templateId: templateId || undefined, content: previewText, category: selectedTemplate?.category ?? taskName, phones: validRecipients.map((item) => item.phone.trim()), sendMode: sendMode === 'now' ? 'immediate' : 'scheduled', scheduledAt: normalizedScheduledAt, }) : clientApi.confirmImport({ applicationId, templateId: templateId || undefined, content: previewText, category: selectedTemplate?.category ?? taskName, importContent, requiredVariables, sendMode: sendMode === 'now' ? 'immediate' : 'scheduled', scheduledAt: normalizedScheduledAt, }); submitRequest .then((task) => { setSubmittedRecord(task); setError(''); }) .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) { setImportLoading(true); setError(''); try { assertSupportedImportFile(file); const content = await file.text(); const preview = await clientApi.previewImport({ applicationId: applicationId || undefined, content, fileName: file.name, delimiter: file.name.endsWith('.tsv') ? '\t' : ',', requiredVariables, }); setImportContent(content); setImportFileName(file.name); setImportFileUrl((current) => { if (current) { URL.revokeObjectURL(current); } return URL.createObjectURL(file); }); setImportPreview(preview); } catch (reason) { setImportContent(''); setImportFileName(''); setImportFileUrl((current) => { if (current) { URL.revokeObjectURL(current); } return ''; }); setImportPreview(null); setError(reason instanceof Error ? reason.message : '导入预览失败'); } finally { setImportLoading(false); } } return (

发送短信

{submittedRecord ? 已提交发送批次 {submittedRecord.taskNo} : null}
{error ?

{error}

: null}
1

基本信息

setTaskName(event.target.value)} placeholder="请输入任务名称,便于后续查找和管理" value={taskName} />
setSignatureId(event.target.value)} options={[ { label: '选择签名', value: '' }, ...signatures.map((item) => ({ label: item.name, value: item.id })), ]} value={signatureId} />
2

发送时间

{sendMode === 'scheduled' ? ( ) : null}
3

发送对象

{receiverMode === 'manual' ? ( <>
序号 手机号码 操作
{recipients.map((item, index) => (
{index + 1} updateRecipient(item.id, event.target.value)} placeholder="请输入手机号" value={item.phone} />
))}
) : (
导入表格 支持 CSV / TSV / TXT 文本文件。第一列为手机号,后续列名可对应模板变量。 { const file = event.target.files?.[0]; if (file) { void previewImportFile(file); } event.currentTarget.value = ''; }} style={{ display: 'none' }} type="file" /> {importFileName ? ( 文件:{importFileName} {importFileUrl ? ( 下载 ) : null} ) : null} {importPreview ? (
总行数{importPreview.totalRows}
有效号码{importPreview.validCount}
错误行{importPreview.errorCount}
变量校验{requiredVariables.length ? requiredVariables.join(', ') : '无必填变量'}
{importPreview.errors.length ? (
错误明细 {importPreview.errors.slice(0, 5).map((item) => `第${item.rowNumber}行${item.phoneNumber ? ` ${item.phoneNumber}` : ''}:${item.reason}`).join(';')}
) : null}
) : null}
)}

已添加 {receiverCount} 个接收号码

{!canSubmit ? {validationMessage} : null}