Files
lislgosms/src/apps/client/ClientSendPage.tsx
T

531 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}