fix: connect remaining sms pages to real backend
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { Clock3, Eye, FileText, Search, StopCircle, TrendingUp } from 'lucide-react';
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import { Clock3, Eye, FileText, Search, StopCircle } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
DateRangeInput,
|
||||
@@ -7,21 +7,36 @@ import {
|
||||
DetailProgressStats,
|
||||
DetailSection,
|
||||
DetailTitle,
|
||||
getRateTone,
|
||||
InlineTextPreview,
|
||||
Input,
|
||||
Modal,
|
||||
Pagination,
|
||||
ProgressBar,
|
||||
QueryPanel,
|
||||
RateCard,
|
||||
RateOverview,
|
||||
Select,
|
||||
Tag,
|
||||
type DateRangeValue,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
import { clientService, type BatchTask, type BatchTaskStatus } from '@/mock';
|
||||
import { clientApi, type SmsBatchTask } from '@/api/adminApi';
|
||||
|
||||
type BatchTaskStatus = 'completed' | 'sending' | 'terminated';
|
||||
|
||||
type BatchTask = {
|
||||
id: string;
|
||||
backendId: string;
|
||||
applicationName: string;
|
||||
submittedAt: string;
|
||||
phoneCount: number;
|
||||
wordCount: number;
|
||||
sendType: 'immediate' | 'scheduled';
|
||||
scheduledAt?: string | null;
|
||||
sentCount: number;
|
||||
deliveredCount: number;
|
||||
failedCount: number;
|
||||
totalCount: number;
|
||||
templateContent: string;
|
||||
status: BatchTaskStatus;
|
||||
};
|
||||
|
||||
const statusToneMap: Record<BatchTaskStatus, 'success' | 'info' | 'danger'> = {
|
||||
completed: 'success',
|
||||
@@ -35,22 +50,6 @@ const statusLabelMap: Record<BatchTaskStatus, string> = {
|
||||
terminated: '已终止',
|
||||
};
|
||||
|
||||
const carrierStats = [
|
||||
{ name: '中国移动', success: 738, total: 750, rate: 98.4 },
|
||||
{ name: '中国联通', success: 443, total: 450, rate: 98.44 },
|
||||
{ name: '中国电信', success: 294, total: 300, rate: 98 },
|
||||
];
|
||||
|
||||
const cityStats = [
|
||||
{ city: '北京', total: 300, success: 295 },
|
||||
{ city: '上海', total: 280, success: 276 },
|
||||
{ city: '深圳', total: 250, success: 246 },
|
||||
{ city: '广州', total: 220, success: 215 },
|
||||
{ city: '杭州', total: 200, success: 197 },
|
||||
{ city: '成都', total: 150, success: 148 },
|
||||
{ city: '武汉', total: 100, success: 98 },
|
||||
];
|
||||
|
||||
function splitSignature(content: string) {
|
||||
const match = content.match(/^【(.+?)】(.+)$/);
|
||||
return {
|
||||
@@ -60,7 +59,7 @@ function splitSignature(content: string) {
|
||||
}
|
||||
|
||||
function getProgress(task: BatchTask) {
|
||||
return Math.round((task.sentCount / task.totalCount) * 100);
|
||||
return task.totalCount > 0 ? Math.round((task.sentCount / task.totalCount) * 100) : 0;
|
||||
}
|
||||
|
||||
function getBillingCount(task: BatchTask) {
|
||||
@@ -68,21 +67,59 @@ function getBillingCount(task: BatchTask) {
|
||||
}
|
||||
|
||||
function getDeliveredCount(task: BatchTask) {
|
||||
if (task.status === 'completed') {
|
||||
return Math.round(task.totalCount * 0.9833);
|
||||
}
|
||||
return task.deliveredCount;
|
||||
}
|
||||
|
||||
return task.sentCount;
|
||||
function normalizeTaskStatus(status: string): BatchTaskStatus {
|
||||
if (['completed', 'done'].includes(status)) return 'completed';
|
||||
if (['cancelled', 'terminated', 'rejected', 'failed'].includes(status)) return 'terminated';
|
||||
return 'sending';
|
||||
}
|
||||
|
||||
function mapTask(task: SmsBatchTask): BatchTask {
|
||||
return {
|
||||
id: task.taskNo || task.id,
|
||||
backendId: task.id,
|
||||
applicationName: task.application?.name ?? task.applicationId ?? '未绑定应用',
|
||||
submittedAt: task.createdAt,
|
||||
phoneCount: task.phoneTotal,
|
||||
wordCount: [...task.content].length,
|
||||
sendType: task.scheduledAt ? 'scheduled' : 'immediate',
|
||||
scheduledAt: task.scheduledAt,
|
||||
sentCount: task.progressSent,
|
||||
deliveredCount: task.progressDelivered,
|
||||
failedCount: task.progressFailed,
|
||||
totalCount: task.progressTotal || task.phoneTotal,
|
||||
templateContent: task.content,
|
||||
status: normalizeTaskStatus(task.status),
|
||||
};
|
||||
}
|
||||
|
||||
export function ClientBatchTasksPage() {
|
||||
const [tasks, setTasks] = useState(() => clientService.getBatchTasks());
|
||||
const [tasks, setTasks] = useState<BatchTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [application, setApplication] = useState('all');
|
||||
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
|
||||
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
|
||||
const [selectedTask, setSelectedTask] = useState<BatchTask | null>(null);
|
||||
|
||||
function loadTasks() {
|
||||
setLoading(true);
|
||||
clientApi.listBatchTasks()
|
||||
.then((items) => {
|
||||
setTasks(items.map(mapTask));
|
||||
setError('');
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '批量任务加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks();
|
||||
}, []);
|
||||
|
||||
const applicationOptions = useMemo(() => {
|
||||
const names = Array.from(new Set(tasks.map((item) => item.applicationName)));
|
||||
return [
|
||||
@@ -101,7 +138,11 @@ export function ClientBatchTasksPage() {
|
||||
});
|
||||
|
||||
function terminateTask(id: string) {
|
||||
setTasks(clientService.terminateBatchTask(id));
|
||||
const source = tasks.find((item) => item.id === id);
|
||||
if (!source) return;
|
||||
clientApi.cancelBatchTask(source.backendId)
|
||||
.then(loadTasks)
|
||||
.catch((reason: Error) => setError(reason.message || '任务终止失败'));
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<BatchTask>> = [
|
||||
@@ -200,6 +241,8 @@ export function ClientBatchTasksPage() {
|
||||
</QueryPanel>
|
||||
|
||||
<div className="surface batch-table-card">
|
||||
{loading ? <p className="muted">正在加载批量任务...</p> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table batch-table">
|
||||
<thead>
|
||||
@@ -287,66 +330,10 @@ export function ClientBatchTasksPage() {
|
||||
{ label: '提交总数量', value: selectedTask.totalCount.toLocaleString('zh-CN') },
|
||||
{ label: '提交成功数量', value: selectedTask.totalCount.toLocaleString('zh-CN') },
|
||||
{ label: '发送成功数量', value: getDeliveredCount(selectedTask).toLocaleString('zh-CN') },
|
||||
{ label: '发送失败数量', value: selectedTask.failedCount.toLocaleString('zh-CN') },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={<><TrendingUp size={20} /> 成功率分析</>}>
|
||||
{(() => {
|
||||
const overallRate = (getDeliveredCount(selectedTask) / selectedTask.totalCount) * 100;
|
||||
return (
|
||||
<RateOverview
|
||||
label="总体成功率"
|
||||
metrics={[
|
||||
{ label: '成功总数', value: getDeliveredCount(selectedTask).toLocaleString('zh-CN') },
|
||||
{ label: '总计', value: selectedTask.totalCount.toLocaleString('zh-CN') },
|
||||
]}
|
||||
rate={overallRate}
|
||||
tone={getRateTone(overallRate)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
<h4>运营商成功率</h4>
|
||||
<div className="carrier-rate-grid">
|
||||
{carrierStats.map((item) => (
|
||||
<RateCard
|
||||
key={item.name}
|
||||
meta={<><span>成功: {item.success}</span><span>总计: {item.total}</span></>}
|
||||
rate={item.rate}
|
||||
title={item.name}
|
||||
tone={getRateTone(item.rate)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<h4>各城市成功率</h4>
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table city-rate-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>城市</th>
|
||||
<th>总发送数</th>
|
||||
<th>成功数</th>
|
||||
<th>成功率</th>
|
||||
<th>进度</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cityStats.map((item) => {
|
||||
const rate = (item.success / item.total) * 100;
|
||||
return (
|
||||
<tr key={item.city}>
|
||||
<td><strong>{item.city}</strong></td>
|
||||
<td>{item.total}</td>
|
||||
<td><span className={`rate-text ui-rate-tone-${getRateTone(rate)}`}>{item.success}</span></td>
|
||||
<td><span className={`rate-text ui-rate-tone-${getRateTone(rate)}`}>{rate.toFixed(2)}%</span></td>
|
||||
<td><ProgressBar percent={rate} tone={getRateTone(rate)} /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</DetailSection>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CreditCard } from 'lucide-react';
|
||||
import { Button, Tag } from '@/components/ui';
|
||||
import { clientService } from '@/mock';
|
||||
import { clientApi, type BillingPlan } from '@/api/adminApi';
|
||||
|
||||
export function ClientBillingPage() {
|
||||
const plans = clientService.getBillingPlans();
|
||||
const [plans, setPlans] = useState<BillingPlan[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
clientApi.listPlans()
|
||||
.then((items) => {
|
||||
setPlans(items.filter((item) => item.status !== 'disabled' && item.status !== 'deleted'));
|
||||
setError('');
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '充值套餐加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
function createOrder(plan: BillingPlan) {
|
||||
clientApi.createOrder({ planId: plan.id, amountCents: plan.amountCents, smsUnits: plan.smsUnits, payMethod: 'manual' })
|
||||
.catch((reason: Error) => setError(reason.message || '充值订单创建失败'));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
@@ -14,21 +33,24 @@ export function ClientBillingPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface">
|
||||
{loading ? <p className="muted">正在加载充值套餐...</p> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="plan-grid">
|
||||
{plans.map((plan) => (
|
||||
<article className={['plan-card', plan.highlight ? 'plan-card--highlight' : ''].filter(Boolean).join(' ')} key={plan.id}>
|
||||
<article className={['plan-card', plan.smsUnits >= 100000 ? 'plan-card--highlight' : ''].filter(Boolean).join(' ')} key={plan.id}>
|
||||
<div className="section-heading">
|
||||
<h2>{plan.name}</h2>
|
||||
{plan.highlight ? <Tag tone="accent">推荐</Tag> : null}
|
||||
{plan.smsUnits >= 100000 ? <Tag tone="accent">推荐</Tag> : null}
|
||||
</div>
|
||||
<strong>{plan.messages.toLocaleString('zh-CN')} 条</strong>
|
||||
<p className="muted">适合阶段性短信发送和活动通知。</p>
|
||||
<Button icon={<CreditCard size={16} />} variant={plan.highlight ? 'primary' : 'ghost'}>
|
||||
¥{plan.price.toLocaleString('zh-CN')} 立即充值
|
||||
<strong>{plan.smsUnits.toLocaleString('zh-CN')} 条</strong>
|
||||
<p className="muted">{plan.description ?? '适合阶段性短信发送和活动通知。'}</p>
|
||||
<Button icon={<CreditCard size={16} />} onClick={() => createOrder(plan)} variant={plan.smsUnits >= 100000 ? 'primary' : 'ghost'}>
|
||||
¥{(plan.amountCents / 100).toLocaleString('zh-CN')} 立即充值
|
||||
</Button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{!loading && !error && plans.length === 0 ? <p className="muted">暂无可用充值套餐。</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { clientService, type Invoice } from '@/mock';
|
||||
import { clientApi, type AccountTransaction, type RechargeOrder } from '@/api/adminApi';
|
||||
|
||||
type Invoice = {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: number;
|
||||
amount: number;
|
||||
createdAt: string;
|
||||
status: 'paid' | 'pending' | 'failed';
|
||||
};
|
||||
|
||||
const statusToneMap: Record<Invoice['status'], 'success' | 'info' | 'danger'> = {
|
||||
paid: 'success',
|
||||
@@ -23,6 +33,42 @@ const columns: Array<TableColumn<Invoice>> = [
|
||||
];
|
||||
|
||||
export function ClientInvoicesPage() {
|
||||
const [orders, setOrders] = useState<RechargeOrder[]>([]);
|
||||
const [transactions, setTransactions] = useState<AccountTransaction[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
Promise.all([clientApi.listOrders(), clientApi.listTransactions()])
|
||||
.then(([orderItems, transactionItems]) => {
|
||||
setOrders(orderItems);
|
||||
setTransactions(transactionItems);
|
||||
setError('');
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '账单流水加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const rows = useMemo<Invoice[]>(() => [
|
||||
...orders.map((item) => ({
|
||||
id: item.orderNo,
|
||||
title: item.payMethod === 'manual_topup' ? '人工充值' : '充值订单',
|
||||
messages: item.smsUnits,
|
||||
amount: item.amountCents / 100,
|
||||
createdAt: item.createdAt,
|
||||
status: item.status === 'paid' ? 'paid' as const : item.status === 'failed' ? 'failed' as const : 'pending' as const,
|
||||
})),
|
||||
...transactions.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.remark ?? item.transactionType,
|
||||
messages: item.smsUnits,
|
||||
amount: item.amountCents / 100,
|
||||
createdAt: item.createdAt,
|
||||
status: 'paid' as const,
|
||||
})),
|
||||
].sort((left, right) => right.createdAt.localeCompare(left.createdAt)), [orders, transactions]);
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="page-heading">
|
||||
@@ -32,7 +78,9 @@ export function ClientInvoicesPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface">
|
||||
<Table columns={columns} data={clientService.getInvoices()} rowKey="id" />
|
||||
{loading ? <p className="muted">正在加载账单流水...</p> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<Table columns={columns} data={rows} emptyText="暂无账单流水" rowKey="id" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, 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';
|
||||
import { Button, DateTimeInput, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type SmsBatchTask } from '@/api/adminApi';
|
||||
|
||||
type Recipient = {
|
||||
id: string;
|
||||
@@ -11,47 +11,52 @@ type Recipient = {
|
||||
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 [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
|
||||
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
|
||||
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 [submittedRecord, setSubmittedRecord] = useState<RecentMessage | null>(null);
|
||||
const [submittedRecord, setSubmittedRecord] = useState<SmsBatchTask | null>(null);
|
||||
|
||||
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 || '短信发送基础数据加载失败'));
|
||||
}, []);
|
||||
|
||||
const selectedSignature = useMemo(
|
||||
() => approvedSignatures.find((item) => item.id === signatureId),
|
||||
[approvedSignatures, signatureId],
|
||||
() => signatures.find((item) => item.id === signatureId),
|
||||
[signatures, signatureId],
|
||||
);
|
||||
const selectedTemplate = useMemo(
|
||||
() => approvedTemplates.find((item) => item.id === templateId),
|
||||
[approvedTemplates, templateId],
|
||||
() => templates.find((item) => item.id === templateId),
|
||||
[templates, templateId],
|
||||
);
|
||||
const filteredTemplates = approvedTemplates.filter((item) => (
|
||||
const filteredTemplates = templates.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 previewText = selectedSignature && messageContent
|
||||
? `【${selectedSignature.name}】${messageContent}`
|
||||
: messageContent;
|
||||
const wordCount = previewText.length;
|
||||
const smsParts = Math.max(1, Math.ceil(wordCount / 70));
|
||||
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));
|
||||
|
||||
@@ -68,7 +73,9 @@ export function ClientSendPage() {
|
||||
}
|
||||
|
||||
function chooseTemplate(id: string) {
|
||||
const template = templates.find((item) => item.id === id);
|
||||
setTemplateId(id);
|
||||
setMessageContent(template?.content ?? '');
|
||||
setTemplatePickerOpen(false);
|
||||
}
|
||||
|
||||
@@ -77,17 +84,20 @@ export function ClientSendPage() {
|
||||
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);
|
||||
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,
|
||||
})
|
||||
.then((task) => {
|
||||
setSubmittedRecord(task);
|
||||
setError('');
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '发送任务提交失败'));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -97,8 +107,9 @@ export function ClientSendPage() {
|
||||
<Send size={22} />
|
||||
</span>
|
||||
<h1>发送短信</h1>
|
||||
{submittedRecord ? <Tag tone="success">已提交任务 {submittedRecord.id}</Tag> : null}
|
||||
{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">
|
||||
@@ -117,7 +128,7 @@ export function ClientSendPage() {
|
||||
<Select
|
||||
label="短信应用"
|
||||
onChange={(event) => setApplicationId(event.target.value)}
|
||||
options={[{ label: '选择应用', value: '' }, ...smsApplications]}
|
||||
options={[{ label: '选择应用', value: '' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]}
|
||||
value={applicationId}
|
||||
/>
|
||||
<Select
|
||||
@@ -125,7 +136,7 @@ export function ClientSendPage() {
|
||||
onChange={(event) => setSignatureId(event.target.value)}
|
||||
options={[
|
||||
{ label: '选择签名', value: '' },
|
||||
...approvedSignatures.map((item) => ({ label: item.name, value: item.id })),
|
||||
...signatures.map((item) => ({ label: item.name, value: item.id })),
|
||||
]}
|
||||
value={signatureId}
|
||||
/>
|
||||
@@ -245,6 +256,13 @@ export function ClientSendPage() {
|
||||
<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>
|
||||
@@ -252,7 +270,7 @@ export function ClientSendPage() {
|
||||
</div>
|
||||
<div>
|
||||
<span>预计条数</span>
|
||||
<strong>{estimatedCount || 1} 条/人</strong>
|
||||
<strong>{estimatedCount} 条</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>单价</span>
|
||||
|
||||
@@ -1,175 +1,88 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Edit3, FilePenLine, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FilePenLine, Plus, Search, Trash2, Upload } from 'lucide-react';
|
||||
import { Button, Input, Modal, Select, Tag } from '@/components/ui';
|
||||
import { clientApi, type ClientSmsApplication, type ClientSmsSignature } from '@/api/adminApi';
|
||||
|
||||
type CarrierStatus = 'approved' | 'pending' | 'rejected';
|
||||
|
||||
type DrainageInfo = {
|
||||
id: string;
|
||||
siteName: string;
|
||||
content: string;
|
||||
mobile: CarrierStatus;
|
||||
unicom: CarrierStatus;
|
||||
telecom: CarrierStatus;
|
||||
submittedAt: string;
|
||||
};
|
||||
|
||||
type SignatureItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
application: string;
|
||||
accent: 'green' | 'blue' | 'red' | 'gray';
|
||||
mobile: CarrierStatus;
|
||||
unicom: CarrierStatus;
|
||||
telecom: CarrierStatus;
|
||||
drainage: DrainageInfo[];
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<CarrierStatus, string> = {
|
||||
approved: '已通过',
|
||||
pending: '审核中',
|
||||
rejected: '已驳回',
|
||||
};
|
||||
|
||||
const statusToneMap: Record<CarrierStatus, 'success' | 'info' | 'danger'> = {
|
||||
const statusTone: Record<string, 'success' | 'info' | 'danger' | 'warning'> = {
|
||||
approved: 'success',
|
||||
pending: 'info',
|
||||
rejected: 'danger',
|
||||
draft: 'warning',
|
||||
};
|
||||
|
||||
const initialSignatures: SignatureItem[] = [
|
||||
{
|
||||
id: 'sig-1',
|
||||
name: '【科技公司】',
|
||||
application: '营销推广',
|
||||
accent: 'green',
|
||||
mobile: 'approved',
|
||||
unicom: 'approved',
|
||||
telecom: 'approved',
|
||||
drainage: [
|
||||
{ id: 'drain-1', siteName: '官方网站', content: 'https://www.example.com', mobile: 'approved', unicom: 'approved', telecom: 'approved', submittedAt: '2024-01-08 11:00:00' },
|
||||
{ id: 'drain-2', siteName: '促销活动页', content: 'https://promo.example.com', mobile: 'approved', unicom: 'pending', telecom: 'pending', submittedAt: '2024-01-09 10:30:00' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sig-2',
|
||||
name: '【客户服务】',
|
||||
application: '通知服务',
|
||||
accent: 'blue',
|
||||
mobile: 'approved',
|
||||
unicom: 'pending',
|
||||
telecom: 'approved',
|
||||
drainage: [
|
||||
{ id: 'drain-3', siteName: '客服入口', content: 'https://service.example.com', mobile: 'approved', unicom: 'pending', telecom: 'approved', submittedAt: '2024-01-10 09:12:00' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sig-3',
|
||||
name: '【验证码】',
|
||||
application: '验证码',
|
||||
accent: 'green',
|
||||
mobile: 'approved',
|
||||
unicom: 'approved',
|
||||
telecom: 'approved',
|
||||
drainage: [],
|
||||
},
|
||||
{
|
||||
id: 'sig-4',
|
||||
name: '【促销活动】',
|
||||
application: '百<>会员推广',
|
||||
accent: 'red',
|
||||
mobile: 'rejected',
|
||||
unicom: 'approved',
|
||||
telecom: 'pending',
|
||||
drainage: [
|
||||
{ id: 'drain-4', siteName: '会员活动页', content: 'https://vip.example.com', mobile: 'rejected', unicom: 'approved', telecom: 'pending', submittedAt: '2024-01-11 13:42:00' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sig-5',
|
||||
name: '【会员中心】',
|
||||
application: '会员服务',
|
||||
accent: 'gray',
|
||||
mobile: 'pending',
|
||||
unicom: 'pending',
|
||||
telecom: 'pending',
|
||||
drainage: [],
|
||||
},
|
||||
];
|
||||
|
||||
function UploadBox({ label, compact = false }: { label?: string; compact?: boolean }) {
|
||||
return (
|
||||
<div className={compact ? 'signature-upload signature-upload--compact' : 'signature-upload'}>
|
||||
{label ? <span>{label}</span> : null}
|
||||
<Upload size={compact ? 30 : 42} />
|
||||
<strong>{compact ? '上传文件' : '点击上传 或拖拽文件到此处'}</strong>
|
||||
{!compact ? <small>支持 PNG、JPG、JPEG 格式,大小不超过 3M</small> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SignatureForm({ signature }: { signature?: SignatureItem }) {
|
||||
return (
|
||||
<div className="signature-form">
|
||||
<section>
|
||||
<h3>基本信息</h3>
|
||||
<div className="signature-alert">
|
||||
<Info size={18} />
|
||||
<span>签名需履行报备,并遵照管理部门审核结果方可使用。请用PNG、JPG或JPEG格式的正版文件,且大小不超过3M。</span>
|
||||
</div>
|
||||
<div className="signature-form-grid">
|
||||
<Select label="* 签名依据" options={[{ label: '请选择签名依据', value: '' }, { label: '企事业单位证明', value: 'company' }]} defaultValue={signature ? 'company' : ''} />
|
||||
<Input label="* 短信签名" defaultValue={signature?.name ?? ''} placeholder="请输入短信签名,如【XXXX公司】" />
|
||||
</div>
|
||||
<UploadBox label="* 资质凭证" />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>公司信息</h3>
|
||||
<div className="signature-form-grid">
|
||||
<Input label="* 公司名称" defaultValue={signature?.application ?? ''} placeholder="请输入公司名称" />
|
||||
<Input label="* 统一社会信用代码" placeholder="请输入统一社会信用代码" />
|
||||
<Input label="* 法人姓名" placeholder="请输入法人姓名" />
|
||||
<Input label="法人身份证号" placeholder="请输入法人身份证号" />
|
||||
<UploadBox compact label="法人身份证照片-人像面" />
|
||||
<UploadBox compact label="法人身份证照片-国徽面" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>责任人信息</h3>
|
||||
<div className="signature-form-grid">
|
||||
<Input label="* 责任人姓名" placeholder="请输入责任人姓名" />
|
||||
<Input label="* 责任人手机号" placeholder="请输入责任人手机号" />
|
||||
<Input label="* 责任人身份证号" placeholder="请输入责任人身份证号" />
|
||||
<Input label="责任人邮箱" placeholder="请输入责任人邮箱" />
|
||||
<UploadBox compact label="责任人身份证照片-人像面" />
|
||||
<UploadBox compact label="责任人身份证照片-国徽面" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const statusLabel: Record<string, string> = {
|
||||
approved: '已通过',
|
||||
pending: '审核中',
|
||||
rejected: '已驳回',
|
||||
draft: '草稿',
|
||||
disabled: '已禁用',
|
||||
};
|
||||
|
||||
export function ClientSignaturesPage() {
|
||||
const [signatures, setSignatures] = useState(initialSignatures);
|
||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [expandedId, setExpandedId] = useState('');
|
||||
const [signatureModal, setSignatureModal] = useState<{ mode: 'add' | 'edit'; signature?: SignatureItem } | null>(null);
|
||||
const [editingDrainage, setEditingDrainage] = useState<{ signature: SignatureItem; drainage?: DrainageInfo } | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [applicationId, setApplicationId] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [purpose, setPurpose] = useState('');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
const filteredSignatures = signatures.filter((item) => (
|
||||
item.name.includes(keyword) || item.application.includes(keyword)
|
||||
));
|
||||
|
||||
function deleteSignature(id: string) {
|
||||
setSignatures((items) => items.filter((item) => item.id !== id));
|
||||
function loadData() {
|
||||
setLoading(true);
|
||||
Promise.all([clientApi.listApplications(), clientApi.listSignatures()])
|
||||
.then(([applicationItems, signatureItems]) => {
|
||||
setApplications(applicationItems.filter((item) => item.status === 'active'));
|
||||
setSignatures(signatureItems.filter((item) => item.auditStatus !== 'disabled' && item.auditStatus !== 'deleted'));
|
||||
setError('');
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '签名数据加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
function deleteDrainage(signatureId: string, drainageId: string) {
|
||||
setSignatures((items) => items.map((item) => (
|
||||
item.id === signatureId ? { ...item, drainage: item.drainage.filter((drainage) => drainage.id !== drainageId) } : item
|
||||
)));
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const filteredSignatures = useMemo(() => signatures.filter((item) => (
|
||||
!keyword || [item.name, item.purpose, item.applicationId].join(' ').includes(keyword)
|
||||
)), [keyword, signatures]);
|
||||
|
||||
async function createSignature() {
|
||||
try {
|
||||
const signature = await clientApi.createSignature({ applicationId: applicationId || undefined, name, purpose });
|
||||
if (file) {
|
||||
const fileObject = await clientApi.createFileObject({
|
||||
objectKey: `signature-materials/${signature.id}/${Date.now()}-${file.name}`,
|
||||
fileName: file.name,
|
||||
contentType: file.type || 'application/octet-stream',
|
||||
sizeBytes: file.size,
|
||||
purpose: 'signature_material',
|
||||
});
|
||||
await clientApi.createSignatureMaterial(signature.id, {
|
||||
fileObjectId: fileObject.id,
|
||||
materialType: file.type.startsWith('image/') ? 'image' : 'file',
|
||||
title: file.name,
|
||||
});
|
||||
}
|
||||
await clientApi.submitSignature(signature.id);
|
||||
setModalOpen(false);
|
||||
setApplicationId('');
|
||||
setName('');
|
||||
setPurpose('');
|
||||
setFile(null);
|
||||
loadData();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '签名提交失败');
|
||||
}
|
||||
}
|
||||
|
||||
function disableSignature(id: string) {
|
||||
clientApi.changeSignatureStatus(id, 'disabled')
|
||||
.then(loadData)
|
||||
.catch((reason: Error) => setError(reason.message || '签名禁用失败'));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -179,154 +92,83 @@ export function ClientSignaturesPage() {
|
||||
<span className="sms-send-title__icon">
|
||||
<FilePenLine size={22} />
|
||||
</span>
|
||||
<h1>签名与引流信息</h1>
|
||||
<h1>签名与报备材料</h1>
|
||||
</div>
|
||||
<Button icon={<Plus size={17} />} onClick={() => setSignatureModal({ mode: 'add' })}>添加签名</Button>
|
||||
<Button icon={<Plus size={17} />} onClick={() => setModalOpen(true)}>添加签名</Button>
|
||||
</div>
|
||||
|
||||
<div className="signature-search-row">
|
||||
<Input
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索签名名称或用途"
|
||||
placeholder="搜索签名名称、用途或应用"
|
||||
prefix={<Search size={17} />}
|
||||
value={keyword}
|
||||
/>
|
||||
</div>
|
||||
{loading ? <p className="muted">正在加载签名...</p> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="signature-list">
|
||||
{filteredSignatures.map((signature) => {
|
||||
const expanded = expandedId === signature.id;
|
||||
return (
|
||||
<article className={`signature-card signature-card--${signature.accent}`} key={signature.id}>
|
||||
<div className="signature-summary">
|
||||
<button aria-label="展开签名" onClick={() => setExpandedId(expanded ? '' : signature.id)} type="button">
|
||||
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||
</button>
|
||||
<div>
|
||||
<span>签名名称</span>
|
||||
<strong>{signature.name}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>应用</span>
|
||||
<strong>{signature.application}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>移动</span>
|
||||
<Tag tone={statusToneMap[signature.mobile]}>{statusLabelMap[signature.mobile]}</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<span>联通</span>
|
||||
<Tag tone={statusToneMap[signature.unicom]}>{statusLabelMap[signature.unicom]}</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<span>电信</span>
|
||||
<Tag tone={statusToneMap[signature.telecom]}>{statusLabelMap[signature.telecom]}</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<span>引流信息</span>
|
||||
<strong>{signature.drainage.length} 条</strong>
|
||||
</div>
|
||||
<div className="signature-actions">
|
||||
<Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal({ mode: 'edit', signature })} size="sm" variant="ghost">编辑</Button>
|
||||
<Button icon={<Trash2 size={16} />} onClick={() => deleteSignature(signature.id)} size="sm" variant="danger">删除</Button>
|
||||
</div>
|
||||
{filteredSignatures.map((signature) => (
|
||||
<article className="signature-card signature-card--green" key={signature.id}>
|
||||
<div className="signature-summary">
|
||||
<div>
|
||||
<span>签名名称</span>
|
||||
<strong>{signature.name}</strong>
|
||||
</div>
|
||||
|
||||
{expanded ? (
|
||||
<div className="drainage-panel">
|
||||
<h2>引流信息列表</h2>
|
||||
<div className="drainage-table">
|
||||
<div className="drainage-table__head">
|
||||
<span>站名称</span>
|
||||
<span>引内容</span>
|
||||
<span>移动</span>
|
||||
<span>联通</span>
|
||||
<span>电信</span>
|
||||
<span>提交时间</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{signature.drainage.map((item) => (
|
||||
<div className="drainage-table__row" key={item.id}>
|
||||
<strong>{item.siteName}</strong>
|
||||
<a href={item.content}>{item.content}</a>
|
||||
<Tag tone={statusToneMap[item.mobile]}>{statusLabelMap[item.mobile]}</Tag>
|
||||
<Tag tone={statusToneMap[item.unicom]}>{statusLabelMap[item.unicom]}</Tag>
|
||||
<Tag tone={statusToneMap[item.telecom]}>{statusLabelMap[item.telecom]}</Tag>
|
||||
<span className="muted">{item.submittedAt}</span>
|
||||
<span className="drainage-row-actions">
|
||||
<Button onClick={() => setEditingDrainage({ signature, drainage: item })} size="sm" variant="ghost">编辑</Button>
|
||||
<Button onClick={() => deleteDrainage(signature.id, item.id)} size="sm" variant="danger">删除</Button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="drainage-panel__footer">
|
||||
<Button icon={<Plus size={16} />} onClick={() => setEditingDrainage({ signature })} size="sm" variant="ghost">
|
||||
添加引流信息
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
<div>
|
||||
<span>用途</span>
|
||||
<strong>{signature.purpose ?? '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>审核状态</span>
|
||||
<Tag tone={statusTone[signature.auditStatus] ?? 'info'}>{statusLabel[signature.auditStatus] ?? signature.auditStatus}</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<span>材料</span>
|
||||
<strong>{signature.materials?.length ?? 0} 份</strong>
|
||||
</div>
|
||||
<div className="signature-actions">
|
||||
<Button icon={<Trash2 size={16} />} onClick={() => disableSignature(signature.id)} size="sm" variant="danger">删除</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{!loading && !error && filteredSignatures.length === 0 ? <p className="muted">暂无签名记录。</p> : null}
|
||||
|
||||
<Modal
|
||||
footer={
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setSignatureModal(null)}>取消</Button>
|
||||
<Button onClick={() => setSignatureModal(null)}>确认</Button>
|
||||
<Button onClick={() => setModalOpen(false)} variant="ghost">取消</Button>
|
||||
<Button disabled={!name} onClick={createSignature}>提交审核</Button>
|
||||
</>
|
||||
}
|
||||
onClose={() => setSignatureModal(null)}
|
||||
open={Boolean(signatureModal)}
|
||||
)}
|
||||
onClose={() => setModalOpen(false)}
|
||||
open={modalOpen}
|
||||
size="xl"
|
||||
title={<div className="signature-modal-title"><h2>{signatureModal?.mode === 'edit' ? '编辑签名' : '添加签名'}</h2><p>修改短信签名的相关信息</p></div>}
|
||||
title="添加签名"
|
||||
>
|
||||
<SignatureForm signature={signatureModal?.signature} />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setEditingDrainage(null)}>取消</Button>
|
||||
<Button onClick={() => setEditingDrainage(null)}>确认</Button>
|
||||
</>
|
||||
}
|
||||
onClose={() => setEditingDrainage(null)}
|
||||
open={Boolean(editingDrainage)}
|
||||
size="xl"
|
||||
title={<div className="signature-modal-title"><h2>编辑引流信息</h2><p>所属签名:{editingDrainage?.signature.name}</p></div>}
|
||||
>
|
||||
{editingDrainage ? (
|
||||
<div className="signature-form">
|
||||
<section>
|
||||
<h3>基本信息</h3>
|
||||
<Input label="* 引流信息:" defaultValue={editingDrainage.drainage?.content ?? 'https://www.example.com'} />
|
||||
<div className="signature-alert">
|
||||
<Info size={18} />
|
||||
<span>1 本页面中所填的信息需与您使用的包含的网站或服务保持一致;2 图片仅支持PNG、JPG或JPEG格式的正版文件,且大小不超过3M;3 文件格式支持pdf格式或者图片,且大小不超过10M。</span>
|
||||
</div>
|
||||
<div className="signature-form-grid">
|
||||
<UploadBox compact label="* 字段名称1:" />
|
||||
<Input label="* 字段名称2:" defaultValue={editingDrainage.drainage?.siteName ?? '官方网站'} />
|
||||
<Input label="* 字段名称3:" placeholder="请输入公司名称" />
|
||||
<Input label="* 字段名称4:" placeholder="请输入统一社会信用代码" />
|
||||
<Input label="* 字段名称5:" placeholder="请输入法人姓名" />
|
||||
<Input label="* 字段名称6:" placeholder="请输入法人身份证号" />
|
||||
<div className="signature-file-line">
|
||||
<span>字段名称7:</span>
|
||||
<Button size="sm">选择文件</Button>
|
||||
<small>未选择文件</small>
|
||||
</div>
|
||||
<Input label="* 字段名称8:" placeholder="请输入责任人身份证号" />
|
||||
<Input label="* 字段名称9:" placeholder="请输入责任人姓名" />
|
||||
<Input label="* 字段名称10:" placeholder="请输入责任人手机号" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="signature-form">
|
||||
<Select
|
||||
label="短信应用"
|
||||
onChange={(event) => setApplicationId(event.target.value)}
|
||||
options={[{ label: '不绑定应用', value: '' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]}
|
||||
value={applicationId}
|
||||
/>
|
||||
<Input label="短信签名" onChange={(event) => setName(event.target.value)} placeholder="请输入短信签名,如【某某科技】" value={name} />
|
||||
<Input label="用途" onChange={(event) => setPurpose(event.target.value)} placeholder="请输入签名用途" value={purpose} />
|
||||
<label className="signature-upload">
|
||||
<Upload size={36} />
|
||||
<strong>{file ? file.name : '上传资质图片或文件'}</strong>
|
||||
<small>支持图片、PDF、Word 等真实材料文件</small>
|
||||
<input
|
||||
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,207 +1,76 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { MessageSquare, Plus, Search, Info } from 'lucide-react';
|
||||
import { Button, Input, Modal, Select, Textarea } from '@/components/ui';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { clientApi, type ClientSmsApplication, type ClientSmsTemplate } from '@/api/adminApi';
|
||||
|
||||
type TemplateAccent = 'green' | 'blue' | 'red';
|
||||
|
||||
type SmsTemplateCard = {
|
||||
id: string;
|
||||
name: string;
|
||||
application: string;
|
||||
hash: string;
|
||||
content: string;
|
||||
variables: string[];
|
||||
updatedAt: string;
|
||||
accent: TemplateAccent;
|
||||
const statusTone: Record<string, 'success' | 'info' | 'danger' | 'warning'> = {
|
||||
approved: 'success',
|
||||
pending: 'info',
|
||||
rejected: 'danger',
|
||||
draft: 'warning',
|
||||
};
|
||||
|
||||
const recommendedVariables = [
|
||||
['验证码', 'code'],
|
||||
['手机号', 'phone'],
|
||||
['姓名', 'name'],
|
||||
['日期', 'date'],
|
||||
['金额', 'amount'],
|
||||
['时间', 'time'],
|
||||
['余额', 'balance'],
|
||||
['地址', 'address'],
|
||||
['天数', 'days'],
|
||||
['快递单号', 'trackingNumber'],
|
||||
['案件号', 'caseNumber'],
|
||||
['课程名称', 'courseName'],
|
||||
['链接', 'link'],
|
||||
['站点', 'station'],
|
||||
];
|
||||
|
||||
const initialTemplates: SmsTemplateCard[] = [
|
||||
{
|
||||
id: 'tpl-1',
|
||||
name: '营销领券',
|
||||
application: '营销推广平台',
|
||||
hash: '1c37f4da7c4a4a63',
|
||||
content: '尊敬的${time}客户!您于${time}在有效期${expiryTime},基础${party}有优惠元。',
|
||||
variables: ['time', 'time', 'expiryTime', 'party'],
|
||||
updatedAt: '2026-01-04 17:45:36',
|
||||
accent: 'green',
|
||||
},
|
||||
{
|
||||
id: 'tpl-2',
|
||||
name: '南通申诉受理',
|
||||
application: '客户服务系统',
|
||||
hash: '8e8a32c9d7b14c6a',
|
||||
content: '尊敬的${caseNumber}客户!您的${responder}已受理,当事人:${responder},当联总台/本人在任你定义您的档案表返。${url}。',
|
||||
variables: ['caseNumber', 'responder', 'responder', 'url'],
|
||||
updatedAt: '2026-01-04 17:46:30',
|
||||
accent: 'blue',
|
||||
},
|
||||
{
|
||||
id: 'tpl-3',
|
||||
name: '商城通知',
|
||||
application: '营销推广平台',
|
||||
hash: '9f8b2d3e5a7c4f2',
|
||||
content: '亲爱的${username},您的订单已发货,预计${days}个工作日送达。物流单号:${trackingNumber},可通过官网查询物流信息。',
|
||||
variables: ['username', 'days', 'trackingNumber'],
|
||||
updatedAt: '2026-01-03 14:20:16',
|
||||
accent: 'green',
|
||||
},
|
||||
{
|
||||
id: 'tpl-4',
|
||||
name: '支付通知',
|
||||
application: '客户服务系统',
|
||||
hash: '7d6c43e21f9a5d8',
|
||||
content: '尊敬的客户,您的账户已收到${date}的款项${amount}元,账户余额${balance}元。如有疑问请联系客服${phone}。',
|
||||
variables: ['date', 'amount', 'balance', 'phone'],
|
||||
updatedAt: '2026-01-04 08:30:22',
|
||||
accent: 'green',
|
||||
},
|
||||
{
|
||||
id: 'tpl-5',
|
||||
name: '课程提醒',
|
||||
application: '营销推广平台',
|
||||
hash: '3a5b678d9ef42aa',
|
||||
content: '${name}同学您好,您预约的${courseName}课程将于${time}开始,请提前进入直播间,课程链接:${link}',
|
||||
variables: ['name', 'courseName', 'time', 'link'],
|
||||
updatedAt: '2026-01-03 16:55:40',
|
||||
accent: 'blue',
|
||||
},
|
||||
{
|
||||
id: 'tpl-6',
|
||||
name: '派件通知',
|
||||
application: '客户服务系统',
|
||||
hash: '6e4f23ad4c7d8e1',
|
||||
content: '${name}您的快递已到达${station},快递员${courier}正在派件中:${address}。',
|
||||
variables: ['name', 'station', 'courier', 'address'],
|
||||
updatedAt: '2026-01-04 11:20:18',
|
||||
accent: 'red',
|
||||
},
|
||||
];
|
||||
const statusLabel: Record<string, string> = {
|
||||
approved: '已通过',
|
||||
pending: '审核中',
|
||||
rejected: '已驳回',
|
||||
draft: '草稿',
|
||||
disabled: '已禁用',
|
||||
};
|
||||
|
||||
function extractVariables(content: string) {
|
||||
return Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1]);
|
||||
}
|
||||
|
||||
function TemplateModal({
|
||||
mode,
|
||||
template,
|
||||
onClose,
|
||||
}: {
|
||||
mode: 'add' | 'edit';
|
||||
template?: SmsTemplateCard;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [content, setContent] = useState(template?.content ?? '');
|
||||
const [variablesOpen, setVariablesOpen] = useState(false);
|
||||
const variables = extractVariables(content);
|
||||
const wordCount = content.length;
|
||||
const billingCount = Math.max(1, Math.ceil(wordCount / 70));
|
||||
|
||||
function insertVariable(name: string) {
|
||||
setContent((current) => `${current}\${${name}}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>取消</Button>
|
||||
<Button onClick={onClose}>确认</Button>
|
||||
</>
|
||||
}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>{mode === 'edit' ? '编辑模板' : '添加模板'}</h2><p>请填写模板信息</p></div>}
|
||||
>
|
||||
<div className="template-form">
|
||||
<Select
|
||||
label="* 应用:"
|
||||
defaultValue={template?.application ?? ''}
|
||||
options={[
|
||||
{ label: '请选择应用', value: '' },
|
||||
{ label: '营销推广平台', value: '营销推广平台' },
|
||||
{ label: '客户服务系统', value: '客户服务系统' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="* 签名:"
|
||||
options={[
|
||||
{ label: '请选择签名', value: '' },
|
||||
{ label: '【科技公司】', value: '科技公司' },
|
||||
{ label: '【客户服务】', value: '客户服务' },
|
||||
]}
|
||||
/>
|
||||
<Textarea
|
||||
label="* 模板内容:"
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
placeholder="请输入模板内容"
|
||||
value={content}
|
||||
/>
|
||||
<div className="template-form-meta">
|
||||
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
|
||||
+ {variablesOpen ? '收起变量面板' : '插入变量'}
|
||||
</button>
|
||||
<span>{wordCount} 字符(不含变量),计费 {billingCount} 条</span>
|
||||
</div>
|
||||
{variablesOpen ? (
|
||||
<div className="template-variable-panel">
|
||||
<h3>推荐变量</h3>
|
||||
<div className="template-variable-buttons">
|
||||
{recommendedVariables.map(([label, value]) => (
|
||||
<button key={value} onClick={() => insertVariable(value)} type="button">{label} ({value})</button>
|
||||
))}
|
||||
</div>
|
||||
<h3>自定义变量</h3>
|
||||
<div className="template-custom-variable">
|
||||
<Input placeholder="英文字符或数字" />
|
||||
<Button onClick={() => insertVariable('custom')}>插入</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="template-info-tip">
|
||||
<Info size={18} />
|
||||
<span>短信字数=签名+模板内容+变量内容,普通短信 70 字符计费 1 条,长短信 67 字符计算为 1 条短信(包含标点符号和空格)</span>
|
||||
</div>
|
||||
{variables.length ? (
|
||||
<div className="template-current-vars">
|
||||
<span>已识别变量:</span>
|
||||
{variables.map((item, index) => <strong key={`${item}-${index}`}>${`{${item}}`}</strong>)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
return Array.from(new Set(Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1])));
|
||||
}
|
||||
|
||||
export function ClientTemplatesPage() {
|
||||
const [templates, setTemplates] = useState(initialTemplates);
|
||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [modalState, setModalState] = useState<{ mode: 'add' | 'edit'; template?: SmsTemplateCard } | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [applicationId, setApplicationId] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
|
||||
const filteredTemplates = templates.filter((item) => (
|
||||
item.name.includes(keyword) || item.application.includes(keyword)
|
||||
));
|
||||
function loadData() {
|
||||
setLoading(true);
|
||||
Promise.all([clientApi.listApplications(), clientApi.listTemplates()])
|
||||
.then(([applicationItems, templateItems]) => {
|
||||
setApplications(applicationItems.filter((item) => item.status === 'active'));
|
||||
setTemplates(templateItems.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled'));
|
||||
setError('');
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '短信模板加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
function deleteTemplate(id: string) {
|
||||
setTemplates((items) => items.filter((item) => item.id !== id));
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const filteredTemplates = useMemo(() => templates.filter((item) => (
|
||||
!keyword || [item.name, item.content, item.application?.name].join(' ').includes(keyword)
|
||||
)), [keyword, templates]);
|
||||
|
||||
function createTemplate() {
|
||||
const variables = extractVariables(content).map((variable) => ({ name: variable, required: true }));
|
||||
clientApi.createTemplate({ applicationId, name, content, variables })
|
||||
.then((created) => clientApi.submitTemplate(created.id))
|
||||
.then(() => {
|
||||
setModalOpen(false);
|
||||
setApplicationId('');
|
||||
setName('');
|
||||
setContent('');
|
||||
loadData();
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '模板提交失败'));
|
||||
}
|
||||
|
||||
function disableTemplate(id: string) {
|
||||
clientApi.changeTemplateStatus(id, 'disabled')
|
||||
.then(loadData)
|
||||
.catch((reason: Error) => setError(reason.message || '模板禁用失败'));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -218,42 +87,66 @@ export function ClientTemplatesPage() {
|
||||
<div className="template-toolbar">
|
||||
<Input
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索模板名称或应用..."
|
||||
placeholder="搜索模板名称、应用或内容"
|
||||
prefix={<Search size={17} />}
|
||||
value={keyword}
|
||||
/>
|
||||
<Button icon={<Plus size={17} />} onClick={() => setModalState({ mode: 'add' })}>添加短信模板</Button>
|
||||
<Button icon={<Plus size={17} />} onClick={() => setModalOpen(true)}>添加短信模板</Button>
|
||||
</div>
|
||||
{loading ? <p className="muted">正在加载短信模板...</p> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="template-card-grid">
|
||||
{filteredTemplates.map((template) => (
|
||||
<article className={`template-card template-card--${template.accent}`} key={template.id}>
|
||||
<h2>{template.name}</h2>
|
||||
<p className="muted">{template.application}</p>
|
||||
<p className="template-hash">{template.hash}</p>
|
||||
<p className="template-content">{template.content}</p>
|
||||
<div className="template-vars">
|
||||
<span>变量:</span>
|
||||
{template.variables.map((item, index) => <strong key={`${item}-${index}`}>${`{${item}}`}</strong>)}
|
||||
</div>
|
||||
<div className="template-card-footer">
|
||||
<span>{template.updatedAt}</span>
|
||||
<div>
|
||||
<button onClick={() => setModalState({ mode: 'edit', template })} type="button">编辑</button>
|
||||
<button onClick={() => deleteTemplate(template.id)} type="button">删除</button>
|
||||
{filteredTemplates.map((template) => {
|
||||
const variables = template.variables?.map((item) => item.name) ?? extractVariables(template.content);
|
||||
return (
|
||||
<article className="template-card template-card--green" key={template.id}>
|
||||
<h2>{template.name}</h2>
|
||||
<p className="muted">{template.application?.name ?? template.applicationId}</p>
|
||||
<Tag tone={statusTone[template.auditStatus] ?? 'info'}>{statusLabel[template.auditStatus] ?? template.auditStatus}</Tag>
|
||||
<p className="template-content">{template.content}</p>
|
||||
<div className="template-vars">
|
||||
<span>变量:</span>
|
||||
{variables.length > 0 ? variables.map((item) => <strong key={item}>${`{${item}}`}</strong>) : <span className="muted">无变量</span>}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
<div className="template-card-footer">
|
||||
<span>{template.updatedAt}</span>
|
||||
<div>
|
||||
<button onClick={() => disableTemplate(template.id)} type="button">
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!loading && !error && filteredTemplates.length === 0 ? <p className="muted">暂无短信模板。</p> : null}
|
||||
|
||||
{modalState ? (
|
||||
<TemplateModal
|
||||
mode={modalState.mode}
|
||||
onClose={() => setModalState(null)}
|
||||
template={modalState.template}
|
||||
/>
|
||||
) : null}
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setModalOpen(false)} variant="ghost">取消</Button>
|
||||
<Button disabled={!applicationId || !name || !content} onClick={createTemplate}>提交审核</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setModalOpen(false)}
|
||||
open={modalOpen}
|
||||
size="xl"
|
||||
title="添加短信模板"
|
||||
>
|
||||
<div className="template-form">
|
||||
<Select
|
||||
label="短信应用"
|
||||
onChange={(event) => setApplicationId(event.target.value)}
|
||||
options={[{ label: '请选择应用', value: '' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]}
|
||||
value={applicationId}
|
||||
/>
|
||||
<Input label="模板名称" onChange={(event) => setName(event.target.value)} placeholder="请输入模板名称" value={name} />
|
||||
<Textarea label="模板内容" onChange={(event) => setContent(event.target.value)} placeholder="变量格式:${code}" rows={5} value={content} />
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user