531 lines
20 KiB
TypeScript
531 lines
20 KiB
TypeScript
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<ClientSmsApplication[]>([]);
|
||
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
|
||
const [signatures, setSignatures] = useState<ClientSmsSignatureView[]>([]);
|
||
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<SendMode>('now');
|
||
const [scheduledAt, setScheduledAt] = useState('');
|
||
const [receiverMode, setReceiverMode] = useState<ReceiverMode>('manual');
|
||
const [recipients, setRecipients] = useState<Recipient[]>([{ id: '1', phone: '' }]);
|
||
const [importContent, setImportContent] = useState('');
|
||
const [importFileName, setImportFileName] = useState('');
|
||
const [importFileUrl, setImportFileUrl] = useState('');
|
||
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()])
|
||
.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 (
|
||
<section className="sms-send-page">
|
||
<div className="sms-send-title">
|
||
<span className="sms-send-title__icon">
|
||
<Send size={22} />
|
||
</span>
|
||
<h1>发送短信</h1>
|
||
{submittedRecord ? <Tag tone="success">已提交发送批次 {submittedRecord.taskNo}</Tag> : null}
|
||
</div>
|
||
{error ? <p className="form-error">{error}</p> : null}
|
||
|
||
<div className="sms-send-layout">
|
||
<div className="sms-send-main">
|
||
<section className="send-card">
|
||
<div className="send-card__title">
|
||
<span>1</span>
|
||
<h2>基本信息</h2>
|
||
</div>
|
||
<Input
|
||
label="任务名称"
|
||
onChange={(event) => setTaskName(event.target.value)}
|
||
placeholder="请输入任务名称,便于后续查找和管理"
|
||
value={taskName}
|
||
/>
|
||
<div className="send-form-row">
|
||
<Select
|
||
label="短信应用"
|
||
onChange={(event) => setApplicationId(event.target.value)}
|
||
options={[{ label: '选择应用', value: '' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]}
|
||
value={applicationId}
|
||
/>
|
||
<Select
|
||
label="短信签名"
|
||
onChange={(event) => setSignatureId(event.target.value)}
|
||
options={[
|
||
{ label: '选择签名', value: '' },
|
||
...signatures.map((item) => ({ label: item.name, value: item.id })),
|
||
]}
|
||
value={signatureId}
|
||
/>
|
||
<label className="ui-field">
|
||
<span className="ui-field__label">短信模板</span>
|
||
<button className="template-trigger" onClick={() => setTemplatePickerOpen(true)} type="button">
|
||
<span className={selectedTemplate ? '' : 'template-trigger__placeholder'}>
|
||
{selectedTemplate?.name ?? '选择模板'}
|
||
</span>
|
||
<FileText size={17} />
|
||
</button>
|
||
</label>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="send-card">
|
||
<div className="send-card__title">
|
||
<span>2</span>
|
||
<h2>发送时间</h2>
|
||
</div>
|
||
<div className="radio-row">
|
||
<label>
|
||
<input checked={sendMode === 'now'} onChange={() => setSendMode('now')} type="radio" />
|
||
<span>立即发送</span>
|
||
</label>
|
||
<label>
|
||
<input checked={sendMode === 'scheduled'} onChange={() => setSendMode('scheduled')} type="radio" />
|
||
<span>定时发送</span>
|
||
</label>
|
||
{sendMode === 'scheduled' ? (
|
||
<DateTimeInput onChange={setScheduledAt} value={scheduledAt} />
|
||
) : null}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="send-card">
|
||
<div className="send-card__title">
|
||
<span>3</span>
|
||
<h2>发送对象</h2>
|
||
</div>
|
||
<div className="receiver-tabs">
|
||
<button
|
||
className={receiverMode === 'manual' ? 'active' : ''}
|
||
onClick={() => setReceiverMode('manual')}
|
||
type="button"
|
||
>
|
||
手动输入
|
||
</button>
|
||
<button
|
||
className={receiverMode === 'import' ? 'active' : ''}
|
||
onClick={() => setReceiverMode('import')}
|
||
type="button"
|
||
>
|
||
导入表格
|
||
</button>
|
||
</div>
|
||
|
||
{receiverMode === 'manual' ? (
|
||
<>
|
||
<div className="receiver-table">
|
||
<div className="receiver-table__head">
|
||
<span>序号</span>
|
||
<span>手机号码</span>
|
||
<span>操作</span>
|
||
</div>
|
||
{recipients.map((item, index) => (
|
||
<div className="receiver-table__row" key={item.id}>
|
||
<span>{index + 1}</span>
|
||
<input
|
||
inputMode="tel"
|
||
onChange={(event) => updateRecipient(item.id, event.target.value)}
|
||
placeholder="请输入手机号"
|
||
value={item.phone}
|
||
/>
|
||
<button
|
||
aria-label="删除接收人"
|
||
disabled={recipients.length === 1}
|
||
onClick={() => removeRecipient(item.id)}
|
||
type="button"
|
||
>
|
||
<Trash2 size={16} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<button className="add-recipient" onClick={addRecipient} type="button">
|
||
<Plus size={16} />
|
||
添加接收人
|
||
</button>
|
||
</>
|
||
) : (
|
||
<div className="import-panel">
|
||
<div className="import-panel__icon">
|
||
<FileText size={26} />
|
||
</div>
|
||
<strong>导入表格</strong>
|
||
<span>支持 CSV / TSV / TXT 文本文件。第一列为手机号,后续列名可对应模板变量。</span>
|
||
<input
|
||
accept=".csv,.tsv,.txt,text/csv,text/plain"
|
||
id="sms-import-file"
|
||
onChange={(event) => {
|
||
const file = event.target.files?.[0];
|
||
if (file) {
|
||
void previewImportFile(file);
|
||
}
|
||
event.currentTarget.value = '';
|
||
}}
|
||
style={{ display: 'none' }}
|
||
type="file"
|
||
/>
|
||
<Button disabled={importLoading || (!templateId && !allowsDirectSend)} onClick={() => document.getElementById('sms-import-file')?.click()} variant="ghost">
|
||
{importLoading ? '解析中...' : '选择文件'}
|
||
</Button>
|
||
{importFileName ? (
|
||
<span>
|
||
文件:{importFileName}
|
||
{importFileUrl ? (
|
||
<a className="file-action-link" download={importFileName} href={importFileUrl}>
|
||
<Download size={14} />
|
||
下载
|
||
</a>
|
||
) : null}
|
||
</span>
|
||
) : null}
|
||
{importPreview ? (
|
||
<div className="detail-grid">
|
||
<div><span>总行数</span><strong>{importPreview.totalRows}</strong></div>
|
||
<div><span>有效号码</span><strong>{importPreview.validCount}</strong></div>
|
||
<div><span>错误行</span><strong>{importPreview.errorCount}</strong></div>
|
||
<div><span>变量校验</span><strong>{requiredVariables.length ? requiredVariables.join(', ') : '无必填变量'}</strong></div>
|
||
{importPreview.errors.length ? (
|
||
<div className="detail-grid__wide">
|
||
<span>错误明细</span>
|
||
<strong>{importPreview.errors.slice(0, 5).map((item) => `第${item.rowNumber}行${item.phoneNumber ? ` ${item.phoneNumber}` : ''}:${item.reason}`).join(';')}</strong>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
|
||
<p className="send-tip">已添加 {receiverCount} 个接收号码</p>
|
||
</section>
|
||
|
||
<div className="send-submit-row">
|
||
<Button disabled={!canSubmit || submitting} icon={<Send size={18} />} onClick={submitTask}>
|
||
{submitting ? '提交中...' : '提交发送任务'}
|
||
</Button>
|
||
{!canSubmit ? <span className="send-submit-hint">{validationMessage}</span> : null}
|
||
</div>
|
||
</div>
|
||
|
||
<aside className="sms-preview-card">
|
||
<div className="preview-title">
|
||
<FileText size={19} />
|
||
<h2>短信预览</h2>
|
||
</div>
|
||
<div className="phone-preview">
|
||
<div>{previewText}</div>
|
||
</div>
|
||
<Textarea
|
||
label="模板内容"
|
||
onChange={(event) => setMessageContent(event.target.value)}
|
||
placeholder="选择模板后可在这里编辑模板内容和变量"
|
||
rows={7}
|
||
value={messageContent}
|
||
/>
|
||
<div className="preview-stats">
|
||
<div>
|
||
<span>字数统计</span>
|
||
<strong>{wordCount} 字</strong>
|
||
</div>
|
||
<div>
|
||
<span>预计条数</span>
|
||
<strong>{estimatedCount} 条</strong>
|
||
</div>
|
||
<div>
|
||
<span>单价</span>
|
||
<strong><MoneyText>¥{formatCents(selectedApplication?.customerUnitPrice)} / 条</MoneyText></strong>
|
||
</div>
|
||
</div>
|
||
<div className="preview-note">短信按 70 字/条计费,超出部分按 67 字/条计算</div>
|
||
</aside>
|
||
</div>
|
||
|
||
<Modal
|
||
footer={<Button variant="ghost" onClick={() => setTemplatePickerOpen(false)}>关闭</Button>}
|
||
onClose={() => setTemplatePickerOpen(false)}
|
||
open={templatePickerOpen}
|
||
title="选择短信模板"
|
||
>
|
||
<div className="template-picker">
|
||
<Input
|
||
prefix={<Search size={16} />}
|
||
onChange={(event) => setTemplateKeyword(event.target.value)}
|
||
placeholder="搜索模板名称或内容"
|
||
value={templateKeyword}
|
||
/>
|
||
<div className="template-picker__list">
|
||
{filteredTemplates.map((template) => (
|
||
<button
|
||
className={template.id === templateId ? 'active' : ''}
|
||
key={template.id}
|
||
onClick={() => chooseTemplate(template.id)}
|
||
type="button"
|
||
>
|
||
<span>
|
||
<strong>{template.name}</strong>
|
||
<small>{template.content}</small>
|
||
</span>
|
||
{template.id === templateId ? <Check size={18} /> : null}
|
||
</button>
|
||
))}
|
||
</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>
|
||
);
|
||
}
|