fix: harden real backend admin workflows and ui

This commit is contained in:
hectorzhao
2026-07-03 19:29:56 +08:00
parent dd09d91c1e
commit 8cca361441
71 changed files with 5111 additions and 4439 deletions
+89 -15
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Check, FileText, Plus, Search, Send, Trash2 } from 'lucide-react';
import { Button, DateTimeInput, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type SmsBatchTask } from '@/api/adminApi';
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type ImportPreviewResponse, type SmsBatchTask } from '@/api/adminApi';
type Recipient = {
id: string;
@@ -27,6 +27,10 @@ export function ClientSendPage() {
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 [importPreview, setImportPreview] = useState<ImportPreviewResponse | null>(null);
const [importLoading, setImportLoading] = useState(false);
const [submittedRecord, setSubmittedRecord] = useState<SmsBatchTask | null>(null);
useEffect(() => {
@@ -52,13 +56,16 @@ export function ClientSendPage() {
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
? `${selectedSignature.name}${messageContent}`
: messageContent;
const wordCount = previewText.length;
const smsParts = wordCount > 0 ? Math.max(1, Math.ceil(wordCount / 70)) : 0;
const estimatedCount = validRecipients.length * smsParts;
const canSubmit = Boolean(taskName && applicationId && signatureId && templateId && validRecipients.length > 0 && (sendMode === 'now' || scheduledAt));
const estimatedCount = receiverCount * smsParts;
const requiredVariables = selectedTemplate?.variables?.map((item) => item.name) ?? [];
const canSubmit = Boolean(taskName && applicationId && signatureId && templateId && receiverCount > 0 && (sendMode === 'now' || scheduledAt));
function updateRecipient(id: string, phone: string) {
setRecipients((items) => items.map((item) => (item.id === id ? { ...item, phone } : item)));
@@ -84,15 +91,28 @@ export function ClientSendPage() {
return;
}
clientApi.createBatchTask({
applicationId,
templateId,
content: previewText,
category: selectedTemplate?.category ?? taskName,
phones: validRecipients.map((item) => item.phone.trim()),
sendMode: sendMode === 'now' ? 'immediate' : 'scheduled',
scheduledAt: sendMode === 'scheduled' ? scheduledAt : undefined,
})
const submitRequest = receiverMode === 'manual'
? clientApi.createBatchTask({
applicationId,
templateId,
content: previewText,
category: selectedTemplate?.category ?? taskName,
phones: validRecipients.map((item) => item.phone.trim()),
sendMode: sendMode === 'now' ? 'immediate' : 'scheduled',
scheduledAt: sendMode === 'scheduled' ? scheduledAt : undefined,
})
: clientApi.confirmImport({
applicationId,
templateId,
content: previewText,
category: selectedTemplate?.category ?? taskName,
importContent,
requiredVariables,
sendMode: sendMode === 'now' ? 'immediate' : 'scheduled',
scheduledAt: sendMode === 'scheduled' ? scheduledAt : undefined,
});
submitRequest
.then((task) => {
setSubmittedRecord(task);
setError('');
@@ -100,6 +120,30 @@ export function ClientSendPage() {
.catch((reason: Error) => setError(reason.message || '发送任务提交失败'));
}
async function previewImportFile(file: File) {
setImportLoading(true);
setError('');
try {
const content = await file.text();
const preview = await clientApi.previewImport({
content,
fileName: file.name,
delimiter: file.name.endsWith('.tsv') ? '\t' : ',',
requiredVariables,
});
setImportContent(content);
setImportFileName(file.name);
setImportPreview(preview);
} catch (reason) {
setImportContent('');
setImportFileName('');
setImportPreview(null);
setError(reason instanceof Error ? reason.message : '导入预览失败');
} finally {
setImportLoading(false);
}
}
return (
<section className="sms-send-page">
<div className="sms-send-title">
@@ -233,12 +277,42 @@ export function ClientSendPage() {
<FileText size={26} />
</div>
<strong></strong>
<span> .xlsx / .csv </span>
<Button variant="ghost"></Button>
<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} onClick={() => document.getElementById('sms-import-file')?.click()} variant="ghost">
{importLoading ? '解析中...' : '选择文件'}
</Button>
{importFileName ? <span>{importFileName}</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"> {validRecipients.length} </p>
<p className="send-tip"> {receiverCount} </p>
</section>
<div className="send-submit-row">