fix: connect remaining sms pages to real backend

This commit is contained in:
hectorzhao
2026-07-02 19:30:04 +08:00
parent 06562e42dd
commit 131f344ac4
50 changed files with 2074 additions and 6400 deletions
+74 -87
View File
@@ -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>
+30 -8
View File
@@ -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>
);
+50 -2
View File
@@ -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>
);
+57 -39
View File
@@ -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>
+129 -287
View File
@@ -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> PNGJPGJPEG 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>使PNGJPG或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 PNGJPG或JPEG格式的正版文件3M3 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>PDFWord </small>
<input
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
style={{ display: 'none' }}
type="file"
/>
</label>
</div>
</Modal>
</section>
);
+113 -220
View File
@@ -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>
);
}