Initial CMPP frontend prototype
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Check, FileText, Plus, Search, Send, Trash2 } from 'lucide-react';
|
||||
import { Button, DateTimeInput, Input, Modal, Select, Tag } from '@/components/ui';
|
||||
import { clientService, type RecentMessage } from '@/mock';
|
||||
|
||||
type Recipient = {
|
||||
id: string;
|
||||
phone: string;
|
||||
};
|
||||
|
||||
type SendMode = 'now' | 'scheduled';
|
||||
type ReceiverMode = 'manual' | 'import';
|
||||
|
||||
const smsApplications = [
|
||||
{ label: '会员营销平台', value: 'member' },
|
||||
{ label: '订单通知系统', value: 'order' },
|
||||
{ label: '登录认证服务', value: 'auth' },
|
||||
];
|
||||
|
||||
export function ClientSendPage() {
|
||||
const templates = clientService.getTemplates();
|
||||
const signatures = clientService.getSignatures();
|
||||
const approvedTemplates = templates.filter((item) => item.status === 'approved');
|
||||
const approvedSignatures = signatures.filter((item) => item.status === 'approved');
|
||||
|
||||
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 [sendMode, setSendMode] = useState<SendMode>('now');
|
||||
const [scheduledAt, setScheduledAt] = useState('');
|
||||
const [receiverMode, setReceiverMode] = useState<ReceiverMode>('manual');
|
||||
const [recipients, setRecipients] = useState<Recipient[]>([{ id: '1', phone: '' }]);
|
||||
const [submittedRecord, setSubmittedRecord] = useState<RecentMessage | null>(null);
|
||||
|
||||
const selectedSignature = useMemo(
|
||||
() => approvedSignatures.find((item) => item.id === signatureId),
|
||||
[approvedSignatures, signatureId],
|
||||
);
|
||||
const selectedTemplate = useMemo(
|
||||
() => approvedTemplates.find((item) => item.id === templateId),
|
||||
[approvedTemplates, templateId],
|
||||
);
|
||||
const filteredTemplates = approvedTemplates.filter((item) => (
|
||||
item.name.includes(templateKeyword) || item.content.includes(templateKeyword)
|
||||
));
|
||||
const validRecipients = recipients.filter((item) => item.phone.trim());
|
||||
const previewText = selectedSignature && selectedTemplate
|
||||
? `【${selectedSignature.name}】${selectedTemplate.content}`
|
||||
: '请选择签名和模板';
|
||||
const wordCount = previewText.length;
|
||||
const smsParts = Math.max(1, Math.ceil(wordCount / 70));
|
||||
const estimatedCount = validRecipients.length * smsParts;
|
||||
const canSubmit = Boolean(taskName && applicationId && signatureId && templateId && validRecipients.length > 0 && (sendMode === 'now' || scheduledAt));
|
||||
|
||||
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) {
|
||||
setTemplateId(id);
|
||||
setTemplatePickerOpen(false);
|
||||
}
|
||||
|
||||
function submitTask() {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRecord: RecentMessage = {
|
||||
id: `MSG-${Date.now().toString().slice(-6)}`,
|
||||
scene: selectedTemplate?.name ?? taskName,
|
||||
count: estimatedCount,
|
||||
channel: '华东主通道',
|
||||
status: sendMode === 'now' ? 'info' : 'warning',
|
||||
createdAt: new Date().toLocaleString('zh-CN', { hour12: false }),
|
||||
};
|
||||
|
||||
clientService.addRecentMessage(nextRecord);
|
||||
setSubmittedRecord(nextRecord);
|
||||
}
|
||||
|
||||
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.id}</Tag> : null}
|
||||
</div>
|
||||
|
||||
<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: '' }, ...smsApplications]}
|
||||
value={applicationId}
|
||||
/>
|
||||
<Select
|
||||
label="短信签名"
|
||||
onChange={(event) => setSignatureId(event.target.value)}
|
||||
options={[
|
||||
{ label: '选择签名', value: '' },
|
||||
...approvedSignatures.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>支持 .xlsx / .csv 文件,当前原型仅展示上传入口。</span>
|
||||
<Button variant="ghost">选择文件</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="send-tip">已添加 {validRecipients.length} 个接收号码</p>
|
||||
</section>
|
||||
|
||||
<div className="send-submit-row">
|
||||
<Button disabled={!canSubmit} icon={<Send size={18} />} onClick={submitTask}>
|
||||
提交发送任务
|
||||
</Button>
|
||||
</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>
|
||||
<div className="preview-stats">
|
||||
<div>
|
||||
<span>字数统计</span>
|
||||
<strong>{wordCount} 字</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>预计条数</span>
|
||||
<strong>{estimatedCount || 1} 条/人</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>单价</span>
|
||||
<strong>¥0.05 / 人</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>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user