Initial CMPP frontend prototype
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
import { useState } from 'react';
|
||||
import { ClipboardCopy, FileText } from 'lucide-react';
|
||||
import { Button, Modal, Tag } from '@/components/ui';
|
||||
|
||||
type LinkStatus = 'connected' | 'disconnected' | 'inactive';
|
||||
|
||||
type SmsApplication = {
|
||||
id: string;
|
||||
name: string;
|
||||
appid: string;
|
||||
todaySuccess: number;
|
||||
deliveryRate?: number;
|
||||
price: string;
|
||||
score?: string;
|
||||
status: LinkStatus;
|
||||
params: Array<{ label: string; value: string; highlight?: boolean }>;
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<LinkStatus, string> = {
|
||||
connected: '已连接',
|
||||
disconnected: '已断',
|
||||
inactive: '未开通',
|
||||
};
|
||||
|
||||
const statusToneMap: Record<LinkStatus, 'success' | 'danger' | 'info'> = {
|
||||
connected: 'success',
|
||||
disconnected: 'danger',
|
||||
inactive: 'info',
|
||||
};
|
||||
|
||||
const applications: SmsApplication[] = [
|
||||
{
|
||||
id: 'app-1',
|
||||
name: '营销推广平台',
|
||||
appid: 'AK_2024010912345678',
|
||||
todaySuccess: 1500,
|
||||
deliveryRate: 95,
|
||||
price: '0.050 元',
|
||||
score: '100分',
|
||||
status: 'connected',
|
||||
params: [
|
||||
{ label: 'ID', value: '113009756' },
|
||||
{ label: '企业名', value: '启瑞物业三网' },
|
||||
{ label: '开通时间', value: '2023-12-13' },
|
||||
{ label: '企业代码', value: 'qrhyyd' },
|
||||
{ label: '账号', value: 'qrhyyd' },
|
||||
{ label: '密码', value: 'm6yZvZKn', highlight: true },
|
||||
{ label: '网关IP', value: '121.40.172.212' },
|
||||
{ label: '网关端口', value: '7890' },
|
||||
{ label: '接入号', value: '106999999' },
|
||||
{ label: '绑定IP', value: '61.129.57.48' },
|
||||
{ label: '连接数', value: '1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'app-2',
|
||||
name: '客服系统',
|
||||
appid: 'AK_2024010987654321',
|
||||
todaySuccess: 800,
|
||||
deliveryRate: 90,
|
||||
price: '0.060 元',
|
||||
score: '80分',
|
||||
status: 'disconnected',
|
||||
params: [
|
||||
{ label: 'ID', value: '113009812' },
|
||||
{ label: '企业名', value: '客服系统三网' },
|
||||
{ label: '开通时间', value: '2024-01-09' },
|
||||
{ label: '企业代码', value: 'kfxt' },
|
||||
{ label: '账号', value: 'kfxt' },
|
||||
{ label: '密码', value: 'r8xKvP2m', highlight: true },
|
||||
{ label: '网关IP', value: '121.40.172.213' },
|
||||
{ label: '网关端口', value: '7890' },
|
||||
{ label: '接入号', value: '106988888' },
|
||||
{ label: '绑定IP', value: '61.129.57.49' },
|
||||
{ label: '连接数', value: '1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'app-3',
|
||||
name: '验证码服务',
|
||||
appid: 'AK_2024010811223344',
|
||||
todaySuccess: 0,
|
||||
price: '0.040 元',
|
||||
status: 'inactive',
|
||||
params: [
|
||||
{ label: 'ID', value: '-' },
|
||||
{ label: '企业名', value: '验证码服务' },
|
||||
{ label: '开通时间', value: '-' },
|
||||
{ label: '企业代码', value: '-' },
|
||||
{ label: '账号', value: '-' },
|
||||
{ label: '密码', value: '-' },
|
||||
{ label: '网关IP', value: '-' },
|
||||
{ label: '网关端口', value: '-' },
|
||||
{ label: '接入号', value: '-' },
|
||||
{ label: '绑定IP', value: '-' },
|
||||
{ label: '连接数', value: '-' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function ClientApplicationsPage() {
|
||||
const [selectedApp, setSelectedApp] = useState<SmsApplication | null>(null);
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon">
|
||||
<FileText size={22} />
|
||||
</span>
|
||||
<h1>短信应用列表</h1>
|
||||
<span className="muted">共 {applications.length} 个应用</span>
|
||||
</div>
|
||||
|
||||
<div className="sms-app-grid">
|
||||
{applications.map((application) => (
|
||||
<article className="sms-app-card" key={application.id}>
|
||||
<h2>{application.name}</h2>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>appid</dt>
|
||||
<dd>{application.appid}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>今日发送成功</dt>
|
||||
<dd>{application.todaySuccess.toLocaleString('zh-CN')} 条</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>到达率</dt>
|
||||
<dd>{application.deliveryRate ? `${application.deliveryRate}%` : '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>单价</dt>
|
||||
<dd>{application.price}{application.score ? <span>({application.score})</span> : null}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>CMPP链接状态</dt>
|
||||
<dd><Tag tone={statusToneMap[application.status]}>{statusLabelMap[application.status]}</Tag></dd>
|
||||
</div>
|
||||
</dl>
|
||||
<Button onClick={() => setSelectedApp(application)} variant="ghost">查看对接参数</Button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
footer={<Button icon={<ClipboardCopy size={16} />}>复制参数</Button>}
|
||||
onClose={() => setSelectedApp(null)}
|
||||
open={Boolean(selectedApp)}
|
||||
size="xl"
|
||||
title="接口参数"
|
||||
>
|
||||
{selectedApp ? (
|
||||
<div className="sms-app-param-table">
|
||||
{selectedApp.params.map((item) => (
|
||||
<div key={item.label}>
|
||||
<span>{item.label}</span>
|
||||
<strong className={item.highlight ? 'text-blue' : undefined}>{item.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { Clock3, Eye, FileText, Search, StopCircle, TrendingUp } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
DateRangeInput,
|
||||
DetailInfoGrid,
|
||||
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';
|
||||
|
||||
const statusToneMap: Record<BatchTaskStatus, 'success' | 'info' | 'danger'> = {
|
||||
completed: 'success',
|
||||
sending: 'info',
|
||||
terminated: 'danger',
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<BatchTaskStatus, string> = {
|
||||
completed: '已完成',
|
||||
sending: '发送中',
|
||||
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 {
|
||||
signature: match?.[1],
|
||||
content: match?.[2] ?? content,
|
||||
};
|
||||
}
|
||||
|
||||
function getProgress(task: BatchTask) {
|
||||
return Math.round((task.sentCount / task.totalCount) * 100);
|
||||
}
|
||||
|
||||
function getBillingCount(task: BatchTask) {
|
||||
return getDeliveredCount(task);
|
||||
}
|
||||
|
||||
function getDeliveredCount(task: BatchTask) {
|
||||
if (task.status === 'completed') {
|
||||
return Math.round(task.totalCount * 0.9833);
|
||||
}
|
||||
|
||||
return task.sentCount;
|
||||
}
|
||||
|
||||
export function ClientBatchTasksPage() {
|
||||
const [tasks, setTasks] = useState(() => clientService.getBatchTasks());
|
||||
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);
|
||||
|
||||
const applicationOptions = useMemo(() => {
|
||||
const names = Array.from(new Set(tasks.map((item) => item.applicationName)));
|
||||
return [
|
||||
{ label: '全部应用', value: 'all' },
|
||||
...names.map((name) => ({ label: name, value: name })),
|
||||
];
|
||||
}, [tasks]);
|
||||
|
||||
const filteredTasks = tasks.filter((item) => {
|
||||
const matchesKeyword = !keyword || item.id.includes(keyword);
|
||||
const matchesApplication = application === 'all' || item.applicationName === application;
|
||||
const submittedDate = item.submittedAt.slice(0, 10);
|
||||
const matchesStartDate = !submittedDateRange.start || submittedDate >= submittedDateRange.start;
|
||||
const matchesEndDate = !submittedDateRange.end || submittedDate <= submittedDateRange.end;
|
||||
return matchesKeyword && matchesApplication && matchesStartDate && matchesEndDate;
|
||||
});
|
||||
|
||||
function terminateTask(id: string) {
|
||||
setTasks(clientService.terminateBatchTask(id));
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<BatchTask>> = [
|
||||
{
|
||||
key: 'id',
|
||||
title: '任务编号',
|
||||
width: '140px',
|
||||
render: (record) => (
|
||||
<div className="batch-task-id">
|
||||
<strong>{record.id}</strong>
|
||||
<Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'applicationName', title: '应用名称', width: '110px', render: (record) => record.applicationName },
|
||||
{ key: 'submittedAt', title: '提交时间', width: '130px', render: (record) => record.submittedAt },
|
||||
{ key: 'phoneCount', title: '发送号码数', width: '95px', render: (record) => record.phoneCount.toLocaleString('zh-CN') },
|
||||
{ key: 'wordCount', title: '单号码字数', width: '86px', render: (record) => <strong>{record.wordCount} 字</strong> },
|
||||
{
|
||||
key: 'sendType',
|
||||
title: '发送时间',
|
||||
width: '130px',
|
||||
render: (record) => (
|
||||
<div className="batch-send-time">
|
||||
<span>
|
||||
<Clock3 size={14} />
|
||||
{record.sendType === 'immediate' ? '立即发送' : '定时发送'}
|
||||
</span>
|
||||
{record.scheduledAt ? <small>{record.scheduledAt}</small> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'progress',
|
||||
title: '发送进度',
|
||||
width: '180px',
|
||||
render: (record) => {
|
||||
const percent = getProgress(record);
|
||||
return (
|
||||
<div className="batch-progress">
|
||||
<div>
|
||||
<span>{record.sentCount.toLocaleString('zh-CN')} / {record.totalCount.toLocaleString('zh-CN')}</span>
|
||||
<strong>{percent}%</strong>
|
||||
</div>
|
||||
<div className="batch-progress__track">
|
||||
<span className={`batch-progress__bar batch-progress__bar--${record.status}`} style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
width: '120px',
|
||||
render: (record) => (
|
||||
<div className="batch-actions">
|
||||
<Button icon={<Eye size={14} />} onClick={() => setSelectedTask(record)} size="sm" variant="ghost">详情</Button>
|
||||
<Button
|
||||
disabled={record.status !== 'sending'}
|
||||
icon={<StopCircle size={14} />}
|
||||
onClick={() => terminateTask(record.id)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
终止
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon">
|
||||
<FileText size={22} />
|
||||
</span>
|
||||
<h1>查看批量任务</h1>
|
||||
</div>
|
||||
|
||||
<QueryPanel
|
||||
title="查询条件"
|
||||
summary={<>共找到 <strong>{filteredTasks.length}</strong> 条任务记录</>}
|
||||
>
|
||||
<Input
|
||||
label="任务编号"
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="输入任务编号搜索"
|
||||
prefix={<Search size={16} />}
|
||||
value={keyword}
|
||||
/>
|
||||
<Select label="选择应用" onChange={(event) => setApplication(event.target.value)} options={applicationOptions} value={application} />
|
||||
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
|
||||
</QueryPanel>
|
||||
|
||||
<div className="surface batch-table-card">
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table batch-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column.key} style={{ width: column.width, textAlign: column.align ?? 'left' }}>{column.title}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredTasks.map((record, index) => {
|
||||
const { signature, content } = splitSignature(record.templateContent);
|
||||
|
||||
return (
|
||||
<Fragment key={record.id}>
|
||||
<tr
|
||||
className={['batch-main-row', hoveredTaskId === record.id ? 'batch-row--hovered' : ''].filter(Boolean).join(' ')}
|
||||
onMouseEnter={() => setHoveredTaskId(record.id)}
|
||||
onMouseLeave={() => setHoveredTaskId(null)}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} style={{ textAlign: column.align ?? 'left' }}>{column.render(record, index)}</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr
|
||||
className={['batch-template-row', hoveredTaskId === record.id ? 'batch-row--hovered' : ''].filter(Boolean).join(' ')}
|
||||
onMouseEnter={() => setHoveredTaskId(record.id)}
|
||||
onMouseLeave={() => setHoveredTaskId(null)}
|
||||
>
|
||||
<td colSpan={columns.length}>
|
||||
<InlineTextPreview label="模板内容" leading={signature ? <strong>【{signature}】</strong> : null}>
|
||||
{content}
|
||||
</InlineTextPreview>
|
||||
</td>
|
||||
</tr>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination total={filteredTasks.length} />
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
footer={<Button onClick={() => setSelectedTask(null)}>关闭</Button>}
|
||||
onClose={() => setSelectedTask(null)}
|
||||
open={Boolean(selectedTask)}
|
||||
size="xl"
|
||||
title={<DetailTitle title="任务详情" subtitle={selectedTask?.id} />}
|
||||
>
|
||||
{selectedTask ? (
|
||||
<div className="task-detail">
|
||||
<DetailSection title="基本信息" extra={<Tag tone={statusToneMap[selectedTask.status]}>{statusLabelMap[selectedTask.status]}</Tag>}>
|
||||
<DetailInfoGrid
|
||||
items={[
|
||||
{ label: '任务编号', value: selectedTask.id },
|
||||
{ label: '应用名称', value: selectedTask.applicationName },
|
||||
{ label: '提交时间', value: selectedTask.submittedAt },
|
||||
{
|
||||
label: '发送方式',
|
||||
value: (
|
||||
<span className="task-send-type">
|
||||
<Clock3 size={16} />
|
||||
{selectedTask.sendType === 'immediate' ? '立即发送' : '定时发送'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ label: '模板字数', value: `${selectedTask.wordCount} 字`, tone: 'primary' },
|
||||
{ label: '单个号码计费条数', value: `${Math.max(1, Math.ceil(selectedTask.wordCount / 70))} 条`, tone: 'primary' },
|
||||
{ label: '任务总号码数', value: `${selectedTask.totalCount.toLocaleString('zh-CN')} 个`, tone: 'primary' },
|
||||
{ label: '总计费条数', value: `${getBillingCount(selectedTask).toLocaleString('zh-CN')} 条`, tone: 'primary' },
|
||||
{ label: '模板内容', value: selectedTask.templateContent, full: true },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="发送统计">
|
||||
<DetailProgressStats
|
||||
label="发送进度"
|
||||
meta={`${selectedTask.sentCount.toLocaleString('zh-CN')} / ${selectedTask.totalCount.toLocaleString('zh-CN')} 已处理`}
|
||||
percent={getProgress(selectedTask)}
|
||||
status={selectedTask.status}
|
||||
stats={[
|
||||
{ label: '提交总数量', value: selectedTask.totalCount.toLocaleString('zh-CN') },
|
||||
{ label: '提交成功数量', value: selectedTask.totalCount.toLocaleString('zh-CN') },
|
||||
{ label: '发送成功数量', value: getDeliveredCount(selectedTask).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>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { CreditCard } from 'lucide-react';
|
||||
import { Button, Tag } from '@/components/ui';
|
||||
import { clientService } from '@/mock';
|
||||
|
||||
export function ClientBillingPage() {
|
||||
const plans = clientService.getBillingPlans();
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<p className="eyebrow">账户</p>
|
||||
<h1>充值套餐</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface">
|
||||
<div className="plan-grid">
|
||||
{plans.map((plan) => (
|
||||
<article className={['plan-card', plan.highlight ? 'plan-card--highlight' : ''].filter(Boolean).join(' ')} key={plan.id}>
|
||||
<div className="section-heading">
|
||||
<h2>{plan.name}</h2>
|
||||
{plan.highlight ? <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')} 立即充值
|
||||
</Button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
Building2,
|
||||
Check,
|
||||
ChevronRight,
|
||||
CreditCard,
|
||||
Landmark,
|
||||
ShieldCheck,
|
||||
Upload,
|
||||
UserCheck,
|
||||
} from 'lucide-react';
|
||||
import { Button, Input, Select, Textarea } from '@/components/ui';
|
||||
|
||||
type AuthStep = 'overview' | 'profile' | 'method' | 'recharge' | 'face' | 'faceScan' | 'success' | 'failed';
|
||||
type AuthMethod = 'face' | 'recharge';
|
||||
|
||||
const companyInfo = {
|
||||
name: '上海闪联九玖信息通信技术有限公司',
|
||||
code: 'XXXXXXXXXX',
|
||||
legalPerson: '张三',
|
||||
certifiedAt: '2022年07月09日 13:12:01',
|
||||
address: '上海XXX区XX路XX号',
|
||||
};
|
||||
|
||||
function UploadPanel() {
|
||||
return (
|
||||
<div className="enterprise-upload">
|
||||
<Upload size={38} />
|
||||
<strong>点击上传</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EnterpriseStepper({ current }: { current: number }) {
|
||||
const steps = ['填写资料', '选择认证方式', '校验认证信息', '认证完成'];
|
||||
|
||||
return (
|
||||
<div className="enterprise-stepper">
|
||||
{steps.map((label, index) => {
|
||||
const step = index + 1;
|
||||
const complete = step < current;
|
||||
const active = step === current;
|
||||
|
||||
return (
|
||||
<div className="enterprise-stepper__item" key={label}>
|
||||
<span className={['enterprise-stepper__dot', complete ? 'is-complete' : '', active ? 'is-active' : ''].filter(Boolean).join(' ')}>
|
||||
{complete ? <Check size={20} /> : step}
|
||||
</span>
|
||||
<strong className={active || complete ? 'is-active' : ''}>{label}</strong>
|
||||
{index < steps.length - 1 ? <ChevronRight className="enterprise-stepper__arrow" size={22} /> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthHeader({ onCertified }: { onCertified: () => void }) {
|
||||
return (
|
||||
<div className="system-page-toolbar">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon"><ShieldCheck size={22} /></span>
|
||||
<h1>企业认证</h1>
|
||||
</div>
|
||||
<Button onClick={onCertified} variant="secondary">设为已认证(测试)</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientEnterpriseAuthPage() {
|
||||
const [step, setStep] = useState<AuthStep>('overview');
|
||||
const [method, setMethod] = useState<AuthMethod>('face');
|
||||
|
||||
const currentStep = step === 'profile' ? 1 : step === 'method' ? 2 : step === 'recharge' || step === 'face' || step === 'faceScan' ? 3 : step === 'success' || step === 'failed' ? 4 : 1;
|
||||
|
||||
if (step === 'overview') {
|
||||
return (
|
||||
<section className="page-stack enterprise-page">
|
||||
<AuthHeader onCertified={() => setStep('success')} />
|
||||
|
||||
<div className="surface enterprise-status-card">
|
||||
<strong>您还未进行企业认证</strong>
|
||||
<button type="button" onClick={() => setStep('profile')}>企业认证 ></button>
|
||||
</div>
|
||||
|
||||
<div className="surface enterprise-info-card">
|
||||
<dl>
|
||||
<div><dt>企业名称:</dt><dd>未知</dd></div>
|
||||
<div><dt>认证时间:</dt><dd>未知</dd></div>
|
||||
<div><dt>统一社会信用代码:</dt><dd>未知</dd></div>
|
||||
<div><dt>通讯地址:</dt><dd>未知</dd></div>
|
||||
<div><dt>法定代表人:</dt><dd>未知</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack enterprise-page">
|
||||
<h1 className="enterprise-page-title">企业认证</h1>
|
||||
|
||||
<div className="surface enterprise-flow-card">
|
||||
<EnterpriseStepper current={currentStep} />
|
||||
|
||||
{step === 'profile' ? (
|
||||
<div className="enterprise-form-panel">
|
||||
<label className="enterprise-required">营业执照</label>
|
||||
<UploadPanel />
|
||||
<p className="enterprise-help">请上传电子版营业执照,JPG或PNG格式,大小不超过5M</p>
|
||||
|
||||
<Input label="* 企业名称" placeholder="请填写企业全称" hint="请严格按照营业执照上的企业名称进行填写" />
|
||||
<Input label="* 统一社会信用代码/其他组织机构代码" placeholder="请填写统一社会信用代码(若无请填写其他组织机构代码)" />
|
||||
|
||||
<div className="enterprise-address-selects">
|
||||
<span>* 通讯地址</span>
|
||||
<div>
|
||||
<Select
|
||||
options={[
|
||||
{ label: '请选择省/直辖市', value: '' },
|
||||
{ label: '上海市', value: 'shanghai' },
|
||||
{ label: '北京市', value: 'beijing' },
|
||||
]}
|
||||
defaultValue=""
|
||||
/>
|
||||
<Select
|
||||
options={[
|
||||
{ label: '请选择', value: '' },
|
||||
{ label: '浦东新区', value: 'pudong' },
|
||||
{ label: '徐汇区', value: 'xuhui' },
|
||||
]}
|
||||
defaultValue=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Textarea placeholder="请填写详细的通讯地址,可与证件上的地址不一致" rows={5} />
|
||||
|
||||
<p className="enterprise-form-note">为方便沟通企业进展情况,需补充联系人(法人或员工都可)信息</p>
|
||||
<Input label="* 企业联系人姓名" placeholder="请填写企业联系人姓名" />
|
||||
<Input label="* 企业联系人身份证号" placeholder="请填写企业联系人身份证号" />
|
||||
<Input label="* 企业联系人手机号" placeholder="请填写企业联系人手机号" />
|
||||
<Input label="企业联系人邮箱" placeholder="请填写企业联系人邮箱" />
|
||||
|
||||
<div className="enterprise-actions">
|
||||
<Button onClick={() => setStep('overview')} variant="secondary">取消</Button>
|
||||
<Button onClick={() => setStep('method')}>下一步</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'method' ? (
|
||||
<div className="enterprise-method-panel">
|
||||
<button
|
||||
className={['enterprise-method-card', method === 'face' ? 'is-selected' : ''].filter(Boolean).join(' ')}
|
||||
onClick={() => setMethod('face')}
|
||||
type="button"
|
||||
>
|
||||
<UserCheck size={56} />
|
||||
<div>
|
||||
<strong>企业法人人脸识别认证 <span>即时完成</span></strong>
|
||||
<p>填写法人姓名与身份证号码</p>
|
||||
<p>企业法人亲自进行校验</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={['enterprise-method-card', method === 'recharge' ? 'is-selected' : ''].filter(Boolean).join(' ')}
|
||||
onClick={() => setMethod('recharge')}
|
||||
type="button"
|
||||
>
|
||||
<Landmark size={56} />
|
||||
<div>
|
||||
<strong>聆界平台充值认证 <span>1个工作日完成</span></strong>
|
||||
<p>使用企业对公账户向聆界平台进行验证充值小于1元</p>
|
||||
<p>认证成功后,验证金将自动打入企业的聆界平台账户中,可随时使用</p>
|
||||
<p>若打款错误或失败,验证金将自动退回</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="enterprise-actions">
|
||||
<Button onClick={() => setStep('profile')} variant="secondary">返回修改企业信息</Button>
|
||||
<Button onClick={() => setStep(method === 'face' ? 'face' : 'recharge')}>下一步</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'recharge' ? (
|
||||
<div className="enterprise-verify-panel">
|
||||
<p><span>认证方式</span><strong>聆界平台充值认证</strong></p>
|
||||
<p className="enterprise-verify-copy">
|
||||
请使用 <em>企业银行对公账户</em> 向聆界平台账户打款 <em>(小于1元)</em> 至迅联天下平台账户,认证成功后,验证金将自动打入企业的平台账户中。
|
||||
</p>
|
||||
|
||||
<h2>付款方信息</h2>
|
||||
<p><span>付款企业</span><strong>XXXXXXX公司</strong></p>
|
||||
<small>请使用与企业营业执照名称一致的对公账户进行转账,以便系统进行识别,若填报错误企业信息,转账将无效暨审核不通过</small>
|
||||
|
||||
<div className="enterprise-info-alert">
|
||||
<AlertCircle size={20} />
|
||||
<span>为避免充值失败的情况,请在点击 <strong>确认并充值</strong> 后,根据页面提示的金额进行转账,验证、充值金额有效期:<em>安全充值有效期为7个工作日</em>,验证次数为2次。</span>
|
||||
</div>
|
||||
|
||||
<div className="enterprise-actions enterprise-actions--center">
|
||||
<Button onClick={() => setStep('success')}>确认并充值</Button>
|
||||
<Button onClick={() => setStep('method')} variant="secondary">返回选择认证方式</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'face' ? (
|
||||
<div className="enterprise-face-panel">
|
||||
<p><span>认证方式</span><strong>企业法人人脸识别认证</strong></p>
|
||||
<h2>企业法人基本信息</h2>
|
||||
<Input label="* 企业法人姓名" placeholder="请填写企业法人姓名" />
|
||||
<Input label="* 企业法人身份证号" placeholder="请填写企业法人身份证号" />
|
||||
|
||||
<div className="enterprise-actions">
|
||||
<Button onClick={() => setStep('method')} variant="secondary">返回选择认证方式</Button>
|
||||
<Button onClick={() => setStep('faceScan')}>完成填写</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'faceScan' ? (
|
||||
<div className="enterprise-face-panel">
|
||||
<p><span>认证方式</span><strong>企业法人人脸识别认证</strong></p>
|
||||
<h2>企业法人基本信息</h2>
|
||||
<dl className="enterprise-legal-summary">
|
||||
<div><dt>企业法人姓名</dt><dd>张三</dd></div>
|
||||
<div><dt>企业法人身份证号</dt><dd>162xxxxxxxxxxxxx</dd></div>
|
||||
</dl>
|
||||
|
||||
<div className="enterprise-qr-section">
|
||||
<h2>扫码认证</h2>
|
||||
<p>为了验证您的身份,请使用支付宝扫码进行人脸识别,剩余有效时间:<em>59分57秒</em></p>
|
||||
<div className="enterprise-qr">二维码</div>
|
||||
<span>完成扫描操作后,实名将立即成功</span>
|
||||
<div className="enterprise-actions enterprise-actions--center">
|
||||
<Button onClick={() => setStep('face')} variant="secondary">重新填写法人信息</Button>
|
||||
<Button onClick={() => setStep('success')} className="enterprise-success-test">测试:认证成功</Button>
|
||||
<Button onClick={() => setStep('failed')} variant="danger">测试:认证失败</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'success' ? (
|
||||
<div className="enterprise-result enterprise-result--success">
|
||||
<span><Check size={70} /></span>
|
||||
<h2>恭喜您,认证成功!</h2>
|
||||
<dl>
|
||||
<div><dt>企业名称:</dt><dd>{companyInfo.name}</dd></div>
|
||||
<div><dt>统一社会信用代码:</dt><dd>{companyInfo.code}</dd></div>
|
||||
<div><dt>法定代表人:</dt><dd>{companyInfo.legalPerson}</dd></div>
|
||||
<div><dt>认证时间:</dt><dd>{companyInfo.certifiedAt}</dd></div>
|
||||
<div><dt>通讯地址:</dt><dd>{companyInfo.address}</dd></div>
|
||||
</dl>
|
||||
<Button onClick={() => setStep('overview')} variant="secondary">关闭页面</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'failed' ? (
|
||||
<div className="enterprise-result enterprise-result--failed">
|
||||
<span>!</span>
|
||||
<h2>很遗憾,认证失败!</h2>
|
||||
<p>相同方式认证时限倒计时:</p>
|
||||
<strong>2天23小时59分</strong>
|
||||
<button type="button" onClick={() => setStep('method')}>选其他认证方式 <ChevronRight size={18} /></button>
|
||||
<div className="enterprise-actions enterprise-actions--center">
|
||||
<Button onClick={() => setStep('method')} variant="secondary">选择其他认证方式</Button>
|
||||
<Button onClick={() => setStep('overview')} variant="secondary">关闭页面</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
BadgeCheck,
|
||||
BellRing,
|
||||
FileText,
|
||||
PenLine,
|
||||
Plus,
|
||||
ReceiptText,
|
||||
Send,
|
||||
WalletCards,
|
||||
} from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button, Chart, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { channelShare, hourlySendTrend } from '@/mock/chartData';
|
||||
import { clientService, type RecentMessage, type TemplateStatus } from '@/mock';
|
||||
import { createLineOption, createPieOption } from '@/theme/chartOptions';
|
||||
|
||||
const statusLabelMap: Record<RecentMessage['status'], string> = {
|
||||
success: '发送完成',
|
||||
warning: '排队中',
|
||||
info: '发送中',
|
||||
danger: '发送失败',
|
||||
};
|
||||
|
||||
const templateStatusLabelMap: Record<TemplateStatus, string> = {
|
||||
draft: '草稿',
|
||||
pending: '待审核',
|
||||
approved: '已通过',
|
||||
rejected: '已驳回',
|
||||
};
|
||||
|
||||
const columns: Array<TableColumn<RecentMessage>> = [
|
||||
{ key: 'id', title: '批次编号', render: (record) => record.id },
|
||||
{ key: 'scene', title: '发送场景', render: (record) => record.scene },
|
||||
{ key: 'count', title: '发送量', render: (record) => `${record.count.toLocaleString('zh-CN')} 条` },
|
||||
{ key: 'channel', title: '通道', render: (record) => record.channel },
|
||||
{ key: 'createdAt', title: '创建时间', render: (record) => record.createdAt },
|
||||
{ key: 'status', title: '状态', render: (record) => <Tag tone={record.status}>{statusLabelMap[record.status]}</Tag> },
|
||||
];
|
||||
|
||||
export function ClientHome() {
|
||||
const navigate = useNavigate();
|
||||
const overview = clientService.getOverview();
|
||||
const recentMessages = clientService.getRecentMessages();
|
||||
const templates = clientService.getTemplates();
|
||||
const signatures = clientService.getSignatures();
|
||||
const invoices = clientService.getInvoices();
|
||||
|
||||
const approvedTemplates = templates.filter((item) => item.status === 'approved').length;
|
||||
const approvedSignatures = signatures.filter((item) => item.status === 'approved').length;
|
||||
const pendingTemplates = templates.filter((item) => item.status === 'pending').length;
|
||||
const pendingSignatures = signatures.filter((item) => item.status === 'pending').length;
|
||||
const latestInvoice = invoices[0];
|
||||
const quotaTotal = overview.availableMessages + overview.todaySent;
|
||||
const quotaPercent = Math.round((overview.availableMessages / quotaTotal) * 100);
|
||||
|
||||
const sendTrendOption = useMemo(
|
||||
() => createLineOption({
|
||||
labels: hourlySendTrend.map((item) => item.time),
|
||||
series: [
|
||||
{ name: '提交量', data: hourlySendTrend.map((item) => item.sent) },
|
||||
{ name: '成功量', data: hourlySendTrend.map((item) => item.success) },
|
||||
],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const channelShareOption = useMemo(() => createPieOption({ data: channelShare }), []);
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="overview-hero">
|
||||
<div>
|
||||
<p className="eyebrow">客户端概览</p>
|
||||
<h1>短信服务工作台</h1>
|
||||
<p className="muted">查看账户余量、审核进度、发送趋势和常用业务入口。</p>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<Button icon={<Plus size={16} />} onClick={() => navigate('/client/templates')} variant="ghost">
|
||||
新建模板
|
||||
</Button>
|
||||
<Button icon={<Send size={16} />} onClick={() => navigate('/client/send')}>发送短信</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid">
|
||||
<div className="surface metric-card metric-card--featured">
|
||||
<span>账户可用短信</span>
|
||||
<strong>{overview.availableMessages.toLocaleString('zh-CN')}</strong>
|
||||
<small>今日已发送 {overview.todaySent.toLocaleString('zh-CN')} 条</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>发送成功率</span>
|
||||
<strong>{overview.successRate}%</strong>
|
||||
<small>近 24 小时实时统计</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>待审核事项</span>
|
||||
<strong>{pendingTemplates + pendingSignatures}</strong>
|
||||
<small>模板 {pendingTemplates},签名 {pendingSignatures}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overview-grid">
|
||||
<div className="surface section-stack">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>快捷操作</h2>
|
||||
<p className="muted">覆盖概览页常用入口。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="quick-grid">
|
||||
<button className="quick-action" onClick={() => navigate('/client/send')} type="button">
|
||||
<Send size={20} />
|
||||
<span>短信发送</span>
|
||||
<small>创建发送批次</small>
|
||||
</button>
|
||||
<button className="quick-action" onClick={() => navigate('/client/templates')} type="button">
|
||||
<FileText size={20} />
|
||||
<span>模板管理</span>
|
||||
<small>{approvedTemplates} 个可用模板</small>
|
||||
</button>
|
||||
<button className="quick-action" onClick={() => navigate('/client/signatures')} type="button">
|
||||
<PenLine size={20} />
|
||||
<span>签名管理</span>
|
||||
<small>{approvedSignatures} 个可用签名</small>
|
||||
</button>
|
||||
<button className="quick-action" onClick={() => navigate('/client/billing')} type="button">
|
||||
<WalletCards size={20} />
|
||||
<span>账户充值</span>
|
||||
<small>查看套餐余量</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface section-stack">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>账户状态</h2>
|
||||
<p className="muted">企业认证与资源用量。</p>
|
||||
</div>
|
||||
<Tag tone="success">已认证</Tag>
|
||||
</div>
|
||||
<div className="summary-list">
|
||||
<div>
|
||||
<span>企业主体</span>
|
||||
<strong>上海云舟科技有限公司</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>默认签名</span>
|
||||
<strong>【云舟科技】</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>最近充值</span>
|
||||
<strong>{latestInvoice.title}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="progress-heading">
|
||||
<span>短信余量</span>
|
||||
<strong>{quotaPercent}%</strong>
|
||||
</div>
|
||||
<div className="progress-track">
|
||||
<span style={{ width: `${quotaPercent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overview-grid overview-grid--three">
|
||||
<div className="surface mini-status-card">
|
||||
<BadgeCheck size={22} />
|
||||
<div>
|
||||
<span>模板状态</span>
|
||||
<strong>{approvedTemplates} 已通过</strong>
|
||||
<small>{templates.map((item) => templateStatusLabelMap[item.status]).join(' / ')}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface mini-status-card">
|
||||
<PenLine size={22} />
|
||||
<div>
|
||||
<span>签名状态</span>
|
||||
<strong>{approvedSignatures} 已通过</strong>
|
||||
<small>待审核 {pendingSignatures},需处理 {signatures.filter((item) => item.status === 'rejected').length}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface mini-status-card">
|
||||
<BellRing size={22} />
|
||||
<div>
|
||||
<span>服务提醒</span>
|
||||
<strong>通道运行正常</strong>
|
||||
<small>备用通道有排队批次,请关注发送详情。</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chart-grid">
|
||||
<div className="surface chart-card">
|
||||
<h2>今日发送趋势</h2>
|
||||
<p className="muted">按 3 小时聚合提交量和成功量。</p>
|
||||
<Chart height={300} option={sendTrendOption} />
|
||||
</div>
|
||||
<div className="surface chart-card">
|
||||
<h2>通道占比</h2>
|
||||
<p className="muted">按今日提交量估算。</p>
|
||||
<Chart height={300} option={channelShareOption} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface section-stack">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>最近发送批次</h2>
|
||||
<p className="muted">跟踪发送状态、通道和创建时间。</p>
|
||||
</div>
|
||||
<Button icon={<ReceiptText size={16} />} onClick={() => navigate('/client/send-detail')} variant="ghost">
|
||||
查看全部
|
||||
</Button>
|
||||
</div>
|
||||
<Table columns={columns} data={recentMessages} rowKey="id" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { clientService, type Invoice } from '@/mock';
|
||||
|
||||
const statusToneMap: Record<Invoice['status'], 'success' | 'info' | 'danger'> = {
|
||||
paid: 'success',
|
||||
pending: 'info',
|
||||
failed: 'danger',
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<Invoice['status'], string> = {
|
||||
paid: '已支付',
|
||||
pending: '处理中',
|
||||
failed: '支付失败',
|
||||
};
|
||||
|
||||
const columns: Array<TableColumn<Invoice>> = [
|
||||
{ key: 'id', title: '流水号', render: (record) => record.id },
|
||||
{ key: 'title', title: '项目', render: (record) => record.title },
|
||||
{ key: 'messages', title: '短信条数', render: (record) => `${record.messages.toLocaleString('zh-CN')} 条` },
|
||||
{ key: 'amount', title: '金额', render: (record) => `¥${record.amount.toLocaleString('zh-CN')}` },
|
||||
{ key: 'createdAt', title: '创建时间', render: (record) => record.createdAt },
|
||||
{ key: 'status', title: '状态', render: (record) => <Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag> },
|
||||
];
|
||||
|
||||
export function ClientInvoicesPage() {
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<p className="eyebrow">账户</p>
|
||||
<h1>账单流水</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface">
|
||||
<Table columns={columns} data={clientService.getInvoices()} rowKey="id" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Clock3, Eye, FileImage, Search, TrendingUp, ZoomIn } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
DateRangeInput,
|
||||
DetailInfoGrid,
|
||||
DetailProgressStats,
|
||||
DetailSection,
|
||||
DetailTitle,
|
||||
getRateTone,
|
||||
Input,
|
||||
Modal,
|
||||
ProgressBar,
|
||||
QueryPanel,
|
||||
RateCard,
|
||||
RateOverview,
|
||||
Select,
|
||||
Tag,
|
||||
type DateRangeValue,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
|
||||
type MmsTaskStatus = 'completed' | 'sending';
|
||||
type SendType = 'immediate' | 'scheduled';
|
||||
|
||||
type MmsTask = {
|
||||
id: string;
|
||||
status: MmsTaskStatus;
|
||||
applicationName: string;
|
||||
submittedAt: string;
|
||||
title: string;
|
||||
content: string;
|
||||
image: string;
|
||||
phoneCount: number;
|
||||
sentCount: number;
|
||||
totalCount: number;
|
||||
sendType: SendType;
|
||||
scheduledAt?: string;
|
||||
};
|
||||
|
||||
const statusToneMap: Record<MmsTaskStatus, 'success' | 'info'> = {
|
||||
completed: 'success',
|
||||
sending: 'info',
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<MmsTaskStatus, string> = {
|
||||
completed: '已完成',
|
||||
sending: '发送中',
|
||||
};
|
||||
|
||||
const tasksSeed: MmsTask[] = [
|
||||
{
|
||||
id: 'MMSTASK20260317001',
|
||||
status: 'completed',
|
||||
applicationName: '营销活动彩信',
|
||||
submittedAt: '2026-03-17 10:30:15',
|
||||
title: '新春佳节,福气满满',
|
||||
content: '【优品商城】尊敬的客户,新春佳节来临之际,优品商城全体员工祝您新春快乐、万事如意!点击查看精美贺卡和新春优惠活动详情。',
|
||||
image: 'https://images.unsplash.com/photo-1519671482749-fd09be7ccebf?auto=format&fit=crop&w=500&q=80',
|
||||
phoneCount: 2000,
|
||||
sentCount: 2000,
|
||||
totalCount: 2000,
|
||||
sendType: 'immediate',
|
||||
},
|
||||
{
|
||||
id: 'MMSTASK20260317002',
|
||||
status: 'sending',
|
||||
applicationName: '营销活动彩信',
|
||||
submittedAt: '2026-03-17 11:15:30',
|
||||
title: '重磅新品震撼来袭',
|
||||
content: '【优品商城】优品商城倾力推出全新智能手表,高颜值高性能!限时特惠价299元,前100名购买送蓝牙耳机一份。',
|
||||
image: 'https://images.unsplash.com/photo-1434494878577-86c23bcb06b9?auto=format&fit=crop&w=500&q=80',
|
||||
phoneCount: 1500,
|
||||
sentCount: 850,
|
||||
totalCount: 1500,
|
||||
sendType: 'scheduled',
|
||||
scheduledAt: '2026-03-18 09:00:00',
|
||||
},
|
||||
{
|
||||
id: 'MMSTASK20260317003',
|
||||
status: 'sending',
|
||||
applicationName: '会员服务彩信',
|
||||
submittedAt: '2026-03-17 14:20:45',
|
||||
title: '会员专属优惠来了',
|
||||
content: '【优品商城】尊敬的黄金会员,您享受一波50%的特价惊喜购!本月专享活动仅限开放,精选热门产品5折主打优惠。',
|
||||
image: 'https://images.unsplash.com/photo-1567427017947-545c5f8d16ad?auto=format&fit=crop&w=500&q=80',
|
||||
phoneCount: 3000,
|
||||
sentCount: 2100,
|
||||
totalCount: 3000,
|
||||
sendType: 'immediate',
|
||||
},
|
||||
{
|
||||
id: 'MMSTASK20260316001',
|
||||
status: 'completed',
|
||||
applicationName: '节日祝福彩信',
|
||||
submittedAt: '2026-03-16 16:45:00',
|
||||
title: '中秋团圆,月满人圆',
|
||||
content: '【优品商城】月圆中秋,情满人间。优品商城全体员工祝您中秋快乐,阖家团圆!精选月饼礼盒8折优惠。',
|
||||
image: 'https://images.unsplash.com/photo-1600861194942-f883de0dfe96?auto=format&fit=crop&w=500&q=80',
|
||||
phoneCount: 1200,
|
||||
sentCount: 1200,
|
||||
totalCount: 1200,
|
||||
sendType: 'scheduled',
|
||||
scheduledAt: '2026-03-17 08:00:00',
|
||||
},
|
||||
{
|
||||
id: 'MMSTASK20260316002',
|
||||
status: 'sending',
|
||||
applicationName: '营销活动彩信',
|
||||
submittedAt: '2026-03-16 18:10:20',
|
||||
title: '周年庆典,感恩回馈',
|
||||
content: '【优品商城】优品商城5周年,感恩有你一路相伴。全场满减、买一送一,参与互动赢取千元购物卡。',
|
||||
image: 'https://images.unsplash.com/photo-1464349095431-e9a21285b5f3?auto=format&fit=crop&w=500&q=80',
|
||||
phoneCount: 800,
|
||||
sentCount: 450,
|
||||
totalCount: 800,
|
||||
sendType: 'immediate',
|
||||
},
|
||||
];
|
||||
|
||||
const carrierStats = [
|
||||
{ name: '中国移动', success: 983, total: 1000, rate: 98.3 },
|
||||
{ name: '中国联通', success: 590, total: 600, rate: 98.33 },
|
||||
{ name: '中国电信', success: 392, total: 400, rate: 98 },
|
||||
];
|
||||
|
||||
const cityStats = [
|
||||
{ city: '北京', total: 400, success: 393 },
|
||||
{ city: '上海', total: 360, success: 354 },
|
||||
{ city: '深圳', total: 320, success: 315 },
|
||||
{ city: '广州', total: 280, success: 275 },
|
||||
{ city: '杭州', total: 240, success: 236 },
|
||||
{ city: '成都', total: 200, success: 196 },
|
||||
{ city: '武汉', total: 200, success: 196 },
|
||||
];
|
||||
|
||||
function getProgress(task: MmsTask) {
|
||||
return Math.round((task.sentCount / task.totalCount) * 100);
|
||||
}
|
||||
|
||||
function getDeliveredCount(task: MmsTask) {
|
||||
if (task.status === 'completed') {
|
||||
return Math.round(task.totalCount * 0.9825);
|
||||
}
|
||||
return task.sentCount;
|
||||
}
|
||||
|
||||
export function ClientMmsBatchTasksPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [application, setApplication] = useState('all');
|
||||
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
|
||||
const [selectedTask, setSelectedTask] = useState<MmsTask | null>(null);
|
||||
const [previewTask, setPreviewTask] = useState<MmsTask | null>(null);
|
||||
|
||||
const applicationOptions = useMemo(() => {
|
||||
const names = Array.from(new Set(tasksSeed.map((item) => item.applicationName)));
|
||||
return [{ label: '全部应用', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
|
||||
}, []);
|
||||
|
||||
const filteredTasks = tasksSeed.filter((item) => {
|
||||
const matchesKeyword = !keyword || item.id.includes(keyword);
|
||||
const matchesApplication = application === 'all' || item.applicationName === application;
|
||||
const submittedDate = item.submittedAt.slice(0, 10);
|
||||
const matchesStartDate = !submittedDateRange.start || submittedDate >= submittedDateRange.start;
|
||||
const matchesEndDate = !submittedDateRange.end || submittedDate <= submittedDateRange.end;
|
||||
return matchesKeyword && matchesApplication && matchesStartDate && matchesEndDate;
|
||||
});
|
||||
|
||||
const columns: Array<TableColumn<MmsTask>> = [
|
||||
{
|
||||
key: 'id',
|
||||
title: '任务编号',
|
||||
width: '150px',
|
||||
render: (record) => (
|
||||
<div className="batch-task-id">
|
||||
<strong>{record.id}</strong>
|
||||
<Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'applicationName', title: '应用名称', width: '110px', render: (record) => record.applicationName },
|
||||
{ key: 'submittedAt', title: '提交时间', width: '130px', render: (record) => record.submittedAt },
|
||||
{
|
||||
key: 'content',
|
||||
title: '彩信内容',
|
||||
width: '410px',
|
||||
render: (record) => (
|
||||
<div className="mms-task-content">
|
||||
<img alt={record.title} src={record.image} />
|
||||
<div>
|
||||
<strong>{record.title}</strong>
|
||||
<p>{record.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'phoneCount', title: '发送号码数', width: '96px', render: (record) => <strong>{record.phoneCount.toLocaleString('zh-CN')}</strong> },
|
||||
{
|
||||
key: 'sendType',
|
||||
title: '发送时间',
|
||||
width: '125px',
|
||||
render: (record) => (
|
||||
<div className="batch-send-time">
|
||||
<span><Clock3 size={14} />{record.sendType === 'immediate' ? '立即发送' : '定时发送'}</span>
|
||||
{record.scheduledAt ? <small>{record.scheduledAt}</small> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'progress',
|
||||
title: '发送进度',
|
||||
width: '170px',
|
||||
render: (record) => {
|
||||
const percent = getProgress(record);
|
||||
return (
|
||||
<div className="batch-progress">
|
||||
<div>
|
||||
<span>{record.sentCount.toLocaleString('zh-CN')} / {record.totalCount.toLocaleString('zh-CN')}</span>
|
||||
<strong>{percent}%</strong>
|
||||
</div>
|
||||
<div className="batch-progress__track">
|
||||
<span className={`batch-progress__bar batch-progress__bar--${record.status}`} style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
width: '150px',
|
||||
render: (record) => (
|
||||
<div className="batch-actions mms-task-actions">
|
||||
<Button icon={<Eye size={14} />} onClick={() => setSelectedTask(record)} size="sm" variant="ghost">详情</Button>
|
||||
<Button icon={<ZoomIn size={14} />} onClick={() => setPreviewTask(record)} size="sm" variant="ghost">预览</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon"><FileImage size={22} /></span>
|
||||
<h1>查看批量任务</h1>
|
||||
</div>
|
||||
|
||||
<QueryPanel title="查询条件" summary={<>共找到 <strong>{filteredTasks.length}</strong> 条任务记录</>}>
|
||||
<Input label="任务编号" onChange={(event) => setKeyword(event.target.value)} placeholder="输入任务编号搜索" prefix={<Search size={16} />} value={keyword} />
|
||||
<Select label="选择应用" onChange={(event) => setApplication(event.target.value)} options={applicationOptions} value={application} />
|
||||
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
|
||||
</QueryPanel>
|
||||
|
||||
<div className="surface batch-table-card">
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table batch-table mms-task-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column.key} style={{ width: column.width, textAlign: column.align ?? 'left' }}>{column.title}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredTasks.map((record, index) => (
|
||||
<tr key={record.id}>
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} style={{ textAlign: column.align ?? 'left' }}>{column.render(record, index)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal footer={<Button onClick={() => setSelectedTask(null)}>关闭</Button>} onClose={() => setSelectedTask(null)} open={Boolean(selectedTask)} size="xl" title={<DetailTitle title="任务详情" subtitle={selectedTask?.id} />}>
|
||||
{selectedTask ? (
|
||||
<div className="task-detail">
|
||||
<DetailSection title="基本信息" extra={<Tag tone={statusToneMap[selectedTask.status]}>{statusLabelMap[selectedTask.status]}</Tag>}>
|
||||
<DetailInfoGrid
|
||||
items={[
|
||||
{ label: '任务编号', value: selectedTask.id },
|
||||
{ label: '应用名称', value: selectedTask.applicationName },
|
||||
{ label: '提交时间', value: selectedTask.submittedAt },
|
||||
{ label: '发送方式', value: <span className="task-send-type"><Clock3 size={16} />{selectedTask.sendType === 'immediate' ? '立即发送' : '定时发送'}</span> },
|
||||
{ label: '任务总号码数', value: `${selectedTask.totalCount.toLocaleString('zh-CN')} 个`, tone: 'primary' },
|
||||
{
|
||||
label: '彩信模板内容',
|
||||
value: (
|
||||
<div className="mms-detail-template">
|
||||
<img alt={selectedTask.title} src={selectedTask.image} />
|
||||
<div><strong>{selectedTask.title}</strong><p>{selectedTask.content}</p></div>
|
||||
</div>
|
||||
),
|
||||
full: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="发送统计">
|
||||
<DetailProgressStats
|
||||
label="任务进度"
|
||||
meta={`${selectedTask.sentCount.toLocaleString('zh-CN')} / ${selectedTask.totalCount.toLocaleString('zh-CN')} 已处理`}
|
||||
percent={getProgress(selectedTask)}
|
||||
status={selectedTask.status}
|
||||
stats={[
|
||||
{ label: '提交总数量', value: selectedTask.totalCount.toLocaleString('zh-CN') },
|
||||
{ label: '提交成功数量', value: selectedTask.totalCount.toLocaleString('zh-CN') },
|
||||
{ label: '发送成功数量', value: getDeliveredCount(selectedTask).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>
|
||||
|
||||
<Modal footer={<Button onClick={() => setPreviewTask(null)}>关闭</Button>} onClose={() => setPreviewTask(null)} open={Boolean(previewTask)} title={<div className="template-modal-title"><h2>彩信预览</h2><p>{previewTask?.id}</p></div>}>
|
||||
{previewTask ? (
|
||||
<div className="mms-preview">
|
||||
<img alt={previewTask.title} src={previewTask.image} />
|
||||
<h3>{previewTask.title}</h3>
|
||||
<p>{previewTask.content}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Eye, FileImage, Search, Smartphone } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
DateRangeInput,
|
||||
Input,
|
||||
Modal,
|
||||
QueryPanel,
|
||||
Select,
|
||||
Tag,
|
||||
type DateRangeValue,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
|
||||
type MmsSendStatus = 'success' | 'unknown' | 'failed';
|
||||
|
||||
type MmsSendRecord = {
|
||||
id: string;
|
||||
applicationName: string;
|
||||
sentAt: string;
|
||||
title: string;
|
||||
content: string;
|
||||
image: string;
|
||||
phone: string;
|
||||
carrier: '中国移动' | '中国联通' | '中国电信';
|
||||
region: string;
|
||||
status: MmsSendStatus;
|
||||
receipt: 'DELIVRD' | 'UNKNOWN' | 'UNDELIV';
|
||||
receiptAt?: string;
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<MmsSendStatus, string> = {
|
||||
success: '成功',
|
||||
unknown: '未知',
|
||||
failed: '失败',
|
||||
};
|
||||
|
||||
const statusToneMap: Record<MmsSendStatus, 'success' | 'info' | 'danger'> = {
|
||||
success: 'success',
|
||||
unknown: 'info',
|
||||
failed: 'danger',
|
||||
};
|
||||
|
||||
const mmsSendRows: MmsSendRecord[] = [
|
||||
{
|
||||
id: 'MMSD20260317001',
|
||||
applicationName: '营销活动彩信',
|
||||
sentAt: '2026-03-17 10:30:15',
|
||||
title: '新春佳节,福气满满',
|
||||
content: '【优品商城】尊敬的客户,新春佳节来临之际,优品商城全体员工祝您新春快乐、万事如意!点击查看精美贺卡和新春优惠活动详情。',
|
||||
image: 'https://images.unsplash.com/photo-1519671482749-fd09be7ccebf?auto=format&fit=crop&w=500&q=80',
|
||||
phone: '13800138000',
|
||||
carrier: '中国移动',
|
||||
region: '北京市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-17 10:30:20',
|
||||
},
|
||||
{
|
||||
id: 'MMSD20260317002',
|
||||
applicationName: '营销活动彩信',
|
||||
sentAt: '2026-03-17 10:32:25',
|
||||
title: '重磅新品震撼来袭',
|
||||
content: '【优品商城】优品商城倾力推出全新智能手表,高颜值高性能!限时特惠价299元,前100名购买送蓝牙耳机一份。',
|
||||
image: 'https://images.unsplash.com/photo-1434494878577-86c23bcb06b9?auto=format&fit=crop&w=500&q=80',
|
||||
phone: '13900139000',
|
||||
carrier: '中国联通',
|
||||
region: '上海市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-17 10:32:30',
|
||||
},
|
||||
{
|
||||
id: 'MMSD20260317003',
|
||||
applicationName: '会员服务彩信',
|
||||
sentAt: '2026-03-17 10:35:40',
|
||||
title: '会员专属优惠来了',
|
||||
content: '【优品商城】尊敬的黄金会员,您享受一波50%的特价惊喜购!本月专享活动仅限开放,精选热门产品5折主打优惠,立即购买,先到先得!',
|
||||
image: 'https://images.unsplash.com/photo-1567427017947-545c5f8d16ad?auto=format&fit=crop&w=500&q=80',
|
||||
phone: '13700137000',
|
||||
carrier: '中国电信',
|
||||
region: '深圳市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-17 10:35:46',
|
||||
},
|
||||
{
|
||||
id: 'MMSD20260317004',
|
||||
applicationName: '节日祝福彩信',
|
||||
sentAt: '2026-03-17 10:38:10',
|
||||
title: '中秋团圆,月满人圆',
|
||||
content: '【优品商城】月圆中秋,情满人间。优品商城全体员工祝您中秋快乐,阖家团圆!精选月饼礼盒8折优惠,送礼佳品,立即选购!',
|
||||
image: 'https://images.unsplash.com/photo-1600861194942-f883de0dfe96?auto=format&fit=crop&w=500&q=80',
|
||||
phone: '13600136000',
|
||||
carrier: '中国移动',
|
||||
region: '广州市',
|
||||
status: 'unknown',
|
||||
receipt: 'UNKNOWN',
|
||||
},
|
||||
{
|
||||
id: 'MMSD20260317005',
|
||||
applicationName: '营销活动彩信',
|
||||
sentAt: '2026-03-17 10:41:58',
|
||||
title: '周年庆典,感恩回馈',
|
||||
content: '【优品商城】优品商城5周年,感恩有你一路相伴。全场满减、买一送一,参与互动赢取千元购物卡。',
|
||||
image: 'https://images.unsplash.com/photo-1464349095431-e9a21285b5f3?auto=format&fit=crop&w=500&q=80',
|
||||
phone: '13500135000',
|
||||
carrier: '中国联通',
|
||||
region: '杭州市',
|
||||
status: 'failed',
|
||||
receipt: 'UNDELIV',
|
||||
},
|
||||
];
|
||||
|
||||
function getDate(value: string) {
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
export function ClientMmsSendDetailPage() {
|
||||
const [applicationName, setApplicationName] = useState('all');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [contentKeyword, setContentKeyword] = useState('');
|
||||
const [phoneKeyword, setPhoneKeyword] = useState('');
|
||||
const [previewRecord, setPreviewRecord] = useState<MmsSendRecord | null>(null);
|
||||
|
||||
const applicationOptions = useMemo(() => {
|
||||
const applications = Array.from(new Set(mmsSendRows.map((item) => item.applicationName)));
|
||||
return [{ label: '全部应用', value: 'all' }, ...applications.map((item) => ({ label: item, value: item }))];
|
||||
}, []);
|
||||
|
||||
const filteredRows = mmsSendRows.filter((item) => {
|
||||
const sentDate = getDate(item.sentAt);
|
||||
const matchesApplication = applicationName === 'all' || item.applicationName === applicationName;
|
||||
const matchesStatus = status === 'all' || item.status === status;
|
||||
const matchesStartDate = !dateRange.start || sentDate >= dateRange.start;
|
||||
const matchesEndDate = !dateRange.end || sentDate <= dateRange.end;
|
||||
const matchesContent = !contentKeyword || item.title.includes(contentKeyword) || item.content.includes(contentKeyword);
|
||||
const matchesPhone = !phoneKeyword || item.phone.includes(phoneKeyword);
|
||||
return matchesApplication && matchesStatus && matchesStartDate && matchesEndDate && matchesContent && matchesPhone;
|
||||
});
|
||||
|
||||
const columns: Array<TableColumn<MmsSendRecord>> = [
|
||||
{ key: 'applicationName', title: '应用名称', width: '92px', render: (record) => <strong className="send-detail-app-name">{record.applicationName}</strong> },
|
||||
{ key: 'sentAt', title: '发送时间', width: '120px', render: (record) => <span className="send-detail-time">{record.sentAt.slice(0, 10)}<small>{record.sentAt.slice(11)}</small></span> },
|
||||
{
|
||||
key: 'content',
|
||||
title: '彩信内容',
|
||||
width: '450px',
|
||||
render: (record) => (
|
||||
<div className="mms-detail-row-content">
|
||||
<img alt={record.title} src={record.image} />
|
||||
<div>
|
||||
<strong>{record.title}</strong>
|
||||
<p>{record.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'phone', title: '手机号码', width: '128px', render: (record) => <strong>{record.phone}</strong> },
|
||||
{ key: 'carrier', title: '所属运营商', width: '86px', render: (record) => <span className="send-detail-carrier">{record.carrier}</span> },
|
||||
{ key: 'region', title: '号码归属地', width: '80px', render: (record) => <span className="send-detail-region">{record.region.slice(0, 2)}<small>{record.region.slice(2)}</small></span> },
|
||||
{ key: 'status', title: '发送状态', width: '88px', align: 'center', render: (record) => <Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag> },
|
||||
{ key: 'receipt', title: '彩信回执', width: '92px', align: 'center', render: (record) => <strong className={record.receipt === 'DELIVRD' ? 'send-detail-receipt-code' : 'muted'}>{record.receipt}</strong> },
|
||||
{
|
||||
key: 'receiptAt',
|
||||
title: '回执时间',
|
||||
width: '118px',
|
||||
render: (record) => record.receiptAt ? <span className="send-detail-time">{record.receiptAt.slice(0, 10)}<small>{record.receiptAt.slice(11)}</small></span> : <span className="muted">-</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '92px',
|
||||
align: 'center',
|
||||
render: (record) => <Button icon={<Eye size={14} />} onClick={() => setPreviewRecord(record)} size="sm" variant="ghost">预览</Button>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon"><FileImage size={22} /></span>
|
||||
<h1>彩信发送详情</h1>
|
||||
</div>
|
||||
|
||||
<QueryPanel title="查询条件" summary={<>共找到 <strong>{filteredRows.length}</strong> 条发送记录</>}>
|
||||
<Select label="应用名称" onChange={(event) => setApplicationName(event.target.value)} options={applicationOptions} value={applicationName} />
|
||||
<DateRangeInput label="发送时间" onChange={setDateRange} value={dateRange} />
|
||||
<Select
|
||||
label="发送状态"
|
||||
onChange={(event) => setStatus(event.target.value)}
|
||||
options={[
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '成功', value: 'success' },
|
||||
{ label: '未知', value: 'unknown' },
|
||||
{ label: '失败', value: 'failed' },
|
||||
]}
|
||||
value={status}
|
||||
/>
|
||||
<Input label="彩信内容" onChange={(event) => setContentKeyword(event.target.value)} placeholder="输入关键词搜索" prefix={<Search size={16} />} value={contentKeyword} />
|
||||
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} placeholder="输入手机号搜索" prefix={<Smartphone size={16} />} value={phoneKeyword} />
|
||||
</QueryPanel>
|
||||
|
||||
<div className="surface send-detail-table-card">
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table send-detail-table mms-send-detail-table">
|
||||
<thead>
|
||||
<tr>{columns.map((column) => <th key={column.key} style={{ width: column.width, textAlign: column.align ?? 'left' }}>{column.title}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRows.length === 0 ? (
|
||||
<tr><td className="ui-table__empty" colSpan={columns.length}>暂无发送记录</td></tr>
|
||||
) : filteredRows.map((record, index) => (
|
||||
<tr key={record.id}>
|
||||
{columns.map((column) => <td key={column.key} style={{ textAlign: column.align ?? 'left' }}>{column.render(record, index)}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal footer={<Button onClick={() => setPreviewRecord(null)}>关闭</Button>} onClose={() => setPreviewRecord(null)} open={Boolean(previewRecord)} title={<div className="template-modal-title"><h2>彩信预览</h2><p>{previewRecord?.phone}</p></div>}>
|
||||
{previewRecord ? (
|
||||
<div className="mms-preview">
|
||||
<img alt={previewRecord.title} src={previewRecord.image} />
|
||||
<h3>{previewRecord.title}</h3>
|
||||
<p>{previewRecord.content}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Download, FileImage, FileText, ImageIcon, Plus, Send, Trash2, Upload } from 'lucide-react';
|
||||
import { Button, DateTimeInput, Input, Select, Tag } from '@/components/ui';
|
||||
|
||||
type SendMode = 'now' | 'scheduled';
|
||||
type ReceiverMode = 'manual' | 'import';
|
||||
|
||||
type Recipient = {
|
||||
id: string;
|
||||
phone: string;
|
||||
};
|
||||
|
||||
const mmsApplications = [
|
||||
{ label: '营销活动彩信', value: 'marketing' },
|
||||
{ label: '会员运营彩信', value: 'member' },
|
||||
{ label: '客户关怀彩信', value: 'care' },
|
||||
];
|
||||
|
||||
const mmsSignatures = [
|
||||
{ label: '【活动推广】', value: 'promo' },
|
||||
{ label: '【优品发布】', value: 'product' },
|
||||
{ label: '【周年庆典】', value: 'anniversary' },
|
||||
];
|
||||
|
||||
const mmsTemplates = [
|
||||
{
|
||||
label: '春节祝福',
|
||||
value: 'spring',
|
||||
title: '新春佳节,福气满满',
|
||||
content: '尊敬的客户,新春佳节来临之际,优品商城全体员工祝您新春快乐、万事如意!',
|
||||
},
|
||||
{
|
||||
label: '新品发布',
|
||||
value: 'product',
|
||||
title: '重磅新品震撼来袭',
|
||||
content: '优品商城倾力推出全新智能手表款高颜值,性能强!限时特惠价299元。',
|
||||
},
|
||||
{
|
||||
label: '节日问候',
|
||||
value: 'festival',
|
||||
title: '中秋团圆,月满人圆',
|
||||
content: '月圆中秋,情满人间。优品商城全体员工祝您中秋快乐,阖家团圆!',
|
||||
},
|
||||
];
|
||||
|
||||
export function ClientMmsSendPage() {
|
||||
const [taskName, setTaskName] = useState('');
|
||||
const [applicationId, setApplicationId] = useState('');
|
||||
const [signatureId, setSignatureId] = useState('');
|
||||
const [templateId, setTemplateId] = 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 [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const selectedSignature = useMemo(
|
||||
() => mmsSignatures.find((item) => item.value === signatureId),
|
||||
[signatureId],
|
||||
);
|
||||
const selectedTemplate = useMemo(
|
||||
() => mmsTemplates.find((item) => item.value === templateId),
|
||||
[templateId],
|
||||
);
|
||||
const validRecipients = recipients.filter((item) => item.phone.trim());
|
||||
const previewTitle = selectedTemplate?.title ?? '请选择签名和模板';
|
||||
const previewText = selectedSignature && selectedTemplate
|
||||
? `${selectedSignature.label}${selectedTemplate.content}`
|
||||
: '请选择签名和模板';
|
||||
const wordCount = previewText.length;
|
||||
const canSubmit = Boolean(taskName && applicationId && signatureId && templateId && (receiverMode === 'import' || validRecipients.length > 0) && (sendMode === 'now' || scheduledAt));
|
||||
|
||||
function updateRecipient(id: string, phone: string) {
|
||||
setRecipients((items) => items.map((item) => (item.id === id ? { ...item, phone } : item)));
|
||||
}
|
||||
|
||||
function addRecipient() {
|
||||
setRecipients((items) => [...items, { id: Date.now().toString(), phone: '' }]);
|
||||
}
|
||||
|
||||
function removeRecipient(id: string) {
|
||||
setRecipients((items) => (items.length === 1 ? items : items.filter((item) => item.id !== id)));
|
||||
}
|
||||
|
||||
function submitTask() {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitted(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="sms-send-page mms-send-page">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon">
|
||||
<FileImage size={22} />
|
||||
</span>
|
||||
<h1>发送彩信</h1>
|
||||
{submitted ? <Tag tone="success">发送任务已提交</Tag> : null}
|
||||
</div>
|
||||
|
||||
<div className="sms-send-layout">
|
||||
<div className="sms-send-main">
|
||||
<section className="send-card">
|
||||
<div className="send-card__title">
|
||||
<span>1</span>
|
||||
<h2>基本信息</h2>
|
||||
</div>
|
||||
<Input
|
||||
label="任务名称"
|
||||
onChange={(event) => setTaskName(event.target.value)}
|
||||
placeholder="请输入任务名称,便于后续查找和管理"
|
||||
value={taskName}
|
||||
/>
|
||||
<div className="send-form-row">
|
||||
<Select
|
||||
label="彩信应用"
|
||||
onChange={(event) => setApplicationId(event.target.value)}
|
||||
options={[{ label: '选择应用', value: '' }, ...mmsApplications]}
|
||||
value={applicationId}
|
||||
/>
|
||||
<Select
|
||||
label="彩信签名"
|
||||
onChange={(event) => setSignatureId(event.target.value)}
|
||||
options={[{ label: '选择签名', value: '' }, ...mmsSignatures]}
|
||||
value={signatureId}
|
||||
/>
|
||||
<Select
|
||||
label="彩信模板"
|
||||
onChange={(event) => setTemplateId(event.target.value)}
|
||||
options={[{ label: '选择模板', value: '' }, ...mmsTemplates.map(({ label, value }) => ({ label, value }))]}
|
||||
value={templateId}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="send-card">
|
||||
<div className="send-card__title">
|
||||
<span>2</span>
|
||||
<h2>发送时间</h2>
|
||||
</div>
|
||||
<div className="radio-row">
|
||||
<label>
|
||||
<input checked={sendMode === 'now'} onChange={() => setSendMode('now')} type="radio" />
|
||||
<span>立即发送</span>
|
||||
</label>
|
||||
<label>
|
||||
<input checked={sendMode === 'scheduled'} onChange={() => setSendMode('scheduled')} type="radio" />
|
||||
<span>定时发送</span>
|
||||
</label>
|
||||
{sendMode === 'scheduled' ? (
|
||||
<DateTimeInput onChange={setScheduledAt} value={scheduledAt} />
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="send-card">
|
||||
<div className="send-card__title">
|
||||
<span>3</span>
|
||||
<h2>发送对象</h2>
|
||||
</div>
|
||||
<div className="receiver-tabs">
|
||||
<button
|
||||
className={receiverMode === 'manual' ? 'active' : ''}
|
||||
onClick={() => setReceiverMode('manual')}
|
||||
type="button"
|
||||
>
|
||||
手动输入
|
||||
</button>
|
||||
<button
|
||||
className={receiverMode === 'import' ? 'active' : ''}
|
||||
onClick={() => setReceiverMode('import')}
|
||||
type="button"
|
||||
>
|
||||
导入表格
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{receiverMode === 'manual' ? (
|
||||
<>
|
||||
<div className="mms-send-tip">💡 彩信模板不支持信号,只需输入接收人手机号即可</div>
|
||||
<div className="receiver-table">
|
||||
<div className="receiver-table__head">
|
||||
<span>序号</span>
|
||||
<span>手机号码</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{recipients.map((item, index) => (
|
||||
<div className="receiver-table__row" key={item.id}>
|
||||
<span>{index + 1}</span>
|
||||
<input
|
||||
inputMode="tel"
|
||||
onChange={(event) => updateRecipient(item.id, event.target.value)}
|
||||
placeholder="请输入手机号"
|
||||
value={item.phone}
|
||||
/>
|
||||
<button
|
||||
aria-label="删除接收人"
|
||||
disabled={recipients.length === 1}
|
||||
onClick={() => removeRecipient(item.id)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="add-recipient" onClick={addRecipient} type="button">
|
||||
<Plus size={16} />
|
||||
征集接收人
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="mms-import-mode">
|
||||
<div className="mms-import-rules">
|
||||
<div>
|
||||
<strong>📋 表格文件格式要求</strong>
|
||||
<p>第一列:手机号码</p>
|
||||
<p>支持 .xlsx 和 .csv 格式</p>
|
||||
<p>每行一个手机号</p>
|
||||
<p>彩信模板不支持指标,需填写指标列</p>
|
||||
</div>
|
||||
<Button icon={<Download size={16} />}>下载模板</Button>
|
||||
</div>
|
||||
<div className="import-panel mms-import-panel">
|
||||
<div className="import-panel__icon">
|
||||
<Upload size={28} />
|
||||
</div>
|
||||
<strong>点击上传或拖拽文件到此处</strong>
|
||||
<span>支持 .xlsx、.csv 格式</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="send-submit-row">
|
||||
<Button disabled={!canSubmit} icon={<Send size={18} />} onClick={submitTask}>
|
||||
提交发送任务
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="sms-preview-card mms-preview-card">
|
||||
<div className="preview-title">
|
||||
<FileImage size={19} />
|
||||
<h2>彩信预览</h2>
|
||||
</div>
|
||||
<div className="mms-message-preview">
|
||||
<div>
|
||||
<strong>{previewTitle}</strong>
|
||||
<p>{previewText}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="preview-stats">
|
||||
<div>
|
||||
<span>字数统计</span>
|
||||
<strong>{wordCount}字</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>单价</span>
|
||||
<strong>¥0.30/人</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="preview-note">💡 彩信按0.30元/条,支持图片、视频、音频等多媒体内容</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Edit3, FilePenLine, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
|
||||
import { Button, Input, Modal, Select, Table, Tag } from '@/components/ui';
|
||||
import type { TableColumn } from '@/components/ui/Table';
|
||||
|
||||
type ReportStatus = 'approved' | 'pending' | 'rejected' | 'waiting';
|
||||
|
||||
type MmsSignature = {
|
||||
id: string;
|
||||
name: string;
|
||||
application: string;
|
||||
mobile: ReportStatus;
|
||||
unicom: ReportStatus;
|
||||
telecom: ReportStatus;
|
||||
editable: boolean;
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<ReportStatus, string> = {
|
||||
approved: '已通过',
|
||||
pending: '审核中',
|
||||
rejected: '已驳回',
|
||||
waiting: '待报备',
|
||||
};
|
||||
|
||||
const statusToneMap: Record<ReportStatus, 'success' | 'info' | 'danger' | 'neutral'> = {
|
||||
approved: 'success',
|
||||
pending: 'info',
|
||||
rejected: 'danger',
|
||||
waiting: 'neutral',
|
||||
};
|
||||
|
||||
const initialSignatures: MmsSignature[] = [
|
||||
{
|
||||
id: 'mms-sig-1',
|
||||
name: '【科技公司】',
|
||||
application: '营销推广平台',
|
||||
mobile: 'approved',
|
||||
unicom: 'approved',
|
||||
telecom: 'approved',
|
||||
editable: false,
|
||||
},
|
||||
{
|
||||
id: 'mms-sig-2',
|
||||
name: '【客户服务】',
|
||||
application: '客户服务系统',
|
||||
mobile: 'approved',
|
||||
unicom: 'pending',
|
||||
telecom: 'approved',
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
id: 'mms-sig-3',
|
||||
name: '【验证码】',
|
||||
application: '安全验证平台',
|
||||
mobile: 'waiting',
|
||||
unicom: 'waiting',
|
||||
telecom: 'waiting',
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
id: 'mms-sig-4',
|
||||
name: '【促销活动】',
|
||||
application: '电商平台',
|
||||
mobile: 'rejected',
|
||||
unicom: 'approved',
|
||||
telecom: 'pending',
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
id: 'mms-sig-5',
|
||||
name: '【会员中心】',
|
||||
application: '会员管理系统',
|
||||
mobile: 'pending',
|
||||
unicom: 'pending',
|
||||
telecom: 'pending',
|
||||
editable: true,
|
||||
},
|
||||
];
|
||||
|
||||
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 CarrierStatusTag({ status }: { status: ReportStatus }) {
|
||||
return <Tag tone={statusToneMap[status]}>{statusLabelMap[status]}</Tag>;
|
||||
}
|
||||
|
||||
function MmsSignatureForm({ signature }: { signature?: MmsSignature }) {
|
||||
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
|
||||
defaultValue={signature ? 'company' : ''}
|
||||
label="* 签名依据"
|
||||
options={[
|
||||
{ label: '请选择签名依据', value: '' },
|
||||
{ label: '企事业单位证明', value: 'company' },
|
||||
{ label: '商标注册证', value: 'trademark' },
|
||||
{ label: '授权委托书', value: 'authorization' },
|
||||
]}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientMmsSignatureReportPage() {
|
||||
const [signatures, setSignatures] = useState(initialSignatures);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [modalState, setModalState] = useState<{ mode: 'add' | 'edit'; signature?: MmsSignature } | 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));
|
||||
}
|
||||
|
||||
const columns = useMemo<Array<TableColumn<MmsSignature>>>(() => [
|
||||
{
|
||||
key: 'name',
|
||||
title: '签名',
|
||||
render: (record) => <strong className="mms-signature-name">{record.name}</strong>,
|
||||
},
|
||||
{
|
||||
key: 'application',
|
||||
title: '所属应用',
|
||||
render: (record) => <span className="muted">{record.application}</span>,
|
||||
},
|
||||
{
|
||||
key: 'mobile',
|
||||
title: '移动',
|
||||
render: (record) => <CarrierStatusTag status={record.mobile} />,
|
||||
},
|
||||
{
|
||||
key: 'unicom',
|
||||
title: '联通',
|
||||
render: (record) => <CarrierStatusTag status={record.unicom} />,
|
||||
},
|
||||
{
|
||||
key: 'telecom',
|
||||
title: '电信',
|
||||
render: (record) => <CarrierStatusTag status={record.telecom} />,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '220px',
|
||||
render: (record) => (
|
||||
<div className="mms-signature-actions">
|
||||
<Button
|
||||
disabled={!record.editable}
|
||||
icon={<Edit3 size={16} />}
|
||||
onClick={() => setModalState({ mode: 'edit', signature: record })}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
className="mms-signature-delete"
|
||||
icon={<Trash2 size={16} />}
|
||||
onClick={() => deleteSignature(record.id)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], []);
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="signature-page-header">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon">
|
||||
<FilePenLine size={22} />
|
||||
</span>
|
||||
<h1>签名报备</h1>
|
||||
</div>
|
||||
<Button icon={<Plus size={17} />} onClick={() => setModalState({ mode: 'add' })}>添加签名</Button>
|
||||
</div>
|
||||
|
||||
<div className="signature-search-row">
|
||||
<Input
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索签名名称或应用"
|
||||
prefix={<Search size={17} />}
|
||||
value={keyword}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mms-signature-table-card">
|
||||
<Table columns={columns} data={filteredSignatures} rowKey="id" />
|
||||
</div>
|
||||
|
||||
<div className="mms-pagination">
|
||||
<button disabled type="button"><</button>
|
||||
<button className="active" type="button">1</button>
|
||||
<button type="button">2</button>
|
||||
<button type="button">></button>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setModalState(null)}>取消</Button>
|
||||
<Button onClick={() => setModalState(null)}>确认</Button>
|
||||
</>
|
||||
}
|
||||
onClose={() => setModalState(null)}
|
||||
open={Boolean(modalState)}
|
||||
size="xl"
|
||||
title={<div className="signature-modal-title"><h2>{modalState?.mode === 'edit' ? '编辑签名' : '添加签名'}</h2><p>{modalState?.mode === 'edit' ? '修改彩信签名的相关信息' : '新增彩信签名的相关信息'}</p></div>}
|
||||
>
|
||||
<MmsSignatureForm signature={modalState?.signature} />
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Eye, FileText, ImageIcon, Music, Plus, Search, Trash2, Video } from 'lucide-react';
|
||||
import { Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
|
||||
type CarrierStatus = 'approved' | 'pending' | 'rejected';
|
||||
type TemplateAccent = 'green' | 'blue' | 'gray';
|
||||
type FrameType = 'text' | 'image' | 'video' | 'audio';
|
||||
|
||||
type MmsFrame = {
|
||||
id: string;
|
||||
type: FrameType;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
type MmsTemplate = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
application: string;
|
||||
signature: string;
|
||||
title: string;
|
||||
image: string;
|
||||
content: string;
|
||||
mobile: CarrierStatus;
|
||||
unicom: CarrierStatus;
|
||||
telecom: CarrierStatus;
|
||||
updatedAt: string;
|
||||
accent: TemplateAccent;
|
||||
frames: MmsFrame[];
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<CarrierStatus, string> = {
|
||||
approved: '已通过',
|
||||
pending: '审核中',
|
||||
rejected: '已驳回',
|
||||
};
|
||||
|
||||
const statusToneMap: Record<CarrierStatus, 'success' | 'info' | 'danger'> = {
|
||||
approved: 'success',
|
||||
pending: 'info',
|
||||
rejected: 'danger',
|
||||
};
|
||||
|
||||
const frameTypeOptions = [
|
||||
{ label: '文字', value: 'text' },
|
||||
{ label: '图片', value: 'image' },
|
||||
{ label: '视频', value: 'video' },
|
||||
{ label: '音频', value: 'audio' },
|
||||
];
|
||||
|
||||
const frameIconMap: Record<FrameType, typeof FileText> = {
|
||||
text: FileText,
|
||||
image: ImageIcon,
|
||||
video: Video,
|
||||
audio: Music,
|
||||
};
|
||||
|
||||
const frameFormatMap: Record<FrameType, string> = {
|
||||
text: '',
|
||||
image: '支持格式:jpg, jpeg, png, gif',
|
||||
video: '支持格式:mp4, mpg, 3gp, 3gpp',
|
||||
audio: '支持格式:mp3, mpeg3',
|
||||
};
|
||||
|
||||
const initialTemplates: MmsTemplate[] = [
|
||||
{
|
||||
id: 'mms-tpl-1',
|
||||
name: '春节祝福',
|
||||
code: 'MMS_1a2b3c4d5e6f',
|
||||
application: '营销活动彩信',
|
||||
signature: '【活动推广】',
|
||||
title: '新春佳节,福气满满',
|
||||
image: 'https://images.unsplash.com/photo-1519671482749-fd09be7ccebf?auto=format&fit=crop&w=900&q=80',
|
||||
content: '【活动推广】尊敬的客户,新春佳节来临之际,优品商城全体员工祝您新春快乐、万事如意!点击查看精美贺卡和新春优惠活动详情。',
|
||||
mobile: 'approved',
|
||||
unicom: 'approved',
|
||||
telecom: 'approved',
|
||||
updatedAt: '2028-01-15 10:30:00',
|
||||
accent: 'green',
|
||||
frames: [
|
||||
{ id: 'frame-1', type: 'text', text: '请输入文字内容' },
|
||||
{ id: 'frame-2', type: 'image' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mms-tpl-2',
|
||||
name: '新品发布',
|
||||
code: 'MMS_2b3c4d5e6f7a',
|
||||
application: '营销活动彩信',
|
||||
signature: '【优品发布】',
|
||||
title: '重磅新品震撼来袭',
|
||||
image: 'https://images.unsplash.com/photo-1434494878577-86c23bcb06b9?auto=format&fit=crop&w=900&q=80',
|
||||
content: '【优品发布】优品商城倾力推出全新智能手表款高颜值,性能强!限时特惠价299元,前100名购买送蓝牙耳机一份。图片展示高端产品,点击立即抢购!',
|
||||
mobile: 'approved',
|
||||
unicom: 'approved',
|
||||
telecom: 'pending',
|
||||
updatedAt: '2028-01-16 14:20:00',
|
||||
accent: 'blue',
|
||||
frames: [
|
||||
{ id: 'frame-3', type: 'text', text: '新品发布文案' },
|
||||
{ id: 'frame-4', type: 'image' },
|
||||
{ id: 'frame-5', type: 'video' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mms-tpl-3',
|
||||
name: '会员专享',
|
||||
code: 'MMS_3c4d5e6f7a8b',
|
||||
application: '会员运营彩信',
|
||||
signature: '【节日特惠】',
|
||||
title: '会员专属优惠来了',
|
||||
image: 'https://images.unsplash.com/photo-1567427017947-545c5f8d16ad?auto=format&fit=crop&w=900&q=80',
|
||||
content: '【节日特惠】尊享的黄金会员,您享受一波50%的特价惊喜购!本月专享活动仅开放,精选热门产品5折主打优惠,立即购买,先到先得!',
|
||||
mobile: 'pending',
|
||||
unicom: 'pending',
|
||||
telecom: 'pending',
|
||||
updatedAt: '2028-01-14 09:15:00',
|
||||
accent: 'gray',
|
||||
frames: [
|
||||
{ id: 'frame-6', type: 'image' },
|
||||
{ id: 'frame-7', type: 'text', text: '会员专享优惠说明' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mms-tpl-4',
|
||||
name: '促销活动',
|
||||
code: 'MMS_4d5e6f7a8b9c',
|
||||
application: '营销活动彩信',
|
||||
signature: '【限时特惠】',
|
||||
title: '限时抢购,低至3折',
|
||||
image: 'https://images.unsplash.com/photo-1607083206968-13611e3d76db?auto=format&fit=crop&w=900&q=80',
|
||||
content: '【限时特惠】优品商城年中大促火热进行中!全场3折起,满299减50,满599减120。精选商品限时抢购,数量有限,先到先得!',
|
||||
mobile: 'approved',
|
||||
unicom: 'approved',
|
||||
telecom: 'approved',
|
||||
updatedAt: '2028-01-13 16:45:00',
|
||||
accent: 'green',
|
||||
frames: [
|
||||
{ id: 'frame-8', type: 'text', text: '促销活动介绍' },
|
||||
{ id: 'frame-9', type: 'image' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mms-tpl-5',
|
||||
name: '节日问候',
|
||||
code: 'MMS_5e6f7a8b9c0d',
|
||||
application: '客户关怀彩信',
|
||||
signature: '【节日祝福】',
|
||||
title: '中秋团圆,月满人圆',
|
||||
image: 'https://images.unsplash.com/photo-1600861194942-f883de0dfe96?auto=format&fit=crop&w=900&q=80',
|
||||
content: '【节日祝福】月圆中秋,情满人间。优品商城全体员工祝您中秋快乐,阖家团圆!精选月饼礼盒8折优惠,送礼佳品,立即选购!',
|
||||
mobile: 'approved',
|
||||
unicom: 'pending',
|
||||
telecom: 'approved',
|
||||
updatedAt: '2028-01-12 11:00:00',
|
||||
accent: 'blue',
|
||||
frames: [
|
||||
{ id: 'frame-10', type: 'image' },
|
||||
{ id: 'frame-11', type: 'audio' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mms-tpl-6',
|
||||
name: '品牌活动',
|
||||
code: 'MMS_6f7a8b9c0d1e',
|
||||
application: '品牌运营彩信',
|
||||
signature: '【周年庆典】',
|
||||
title: '周年庆典,感恩回馈',
|
||||
image: 'https://images.unsplash.com/photo-1464349095431-e9a21285b5f3?auto=format&fit=crop&w=900&q=80',
|
||||
content: '【周年庆典】优品商城5周年庆典,感恩回馈!全场满减,买一送一,更有神秘大奖等你来拿。参与互动赢取千元购物卡,机会难得!',
|
||||
mobile: 'approved',
|
||||
unicom: 'approved',
|
||||
telecom: 'approved',
|
||||
updatedAt: '2028-01-11 15:30:00',
|
||||
accent: 'green',
|
||||
frames: [
|
||||
{ id: 'frame-12', type: 'video' },
|
||||
{ id: 'frame-13', type: 'text', text: '品牌周年庆介绍' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function FrameEditor({
|
||||
frame,
|
||||
index,
|
||||
onRemove,
|
||||
onTypeChange,
|
||||
}: {
|
||||
frame: MmsFrame;
|
||||
index: number;
|
||||
onRemove: () => void;
|
||||
onTypeChange: (type: FrameType) => void;
|
||||
}) {
|
||||
const Icon = frameIconMap[frame.type];
|
||||
|
||||
return (
|
||||
<div className="mms-frame">
|
||||
<div className="mms-frame__top">
|
||||
<strong>第 {index + 1} 帧</strong>
|
||||
<Select
|
||||
className="mms-frame-type"
|
||||
onChange={(event) => onTypeChange(event.target.value as FrameType)}
|
||||
options={frameTypeOptions}
|
||||
value={frame.type}
|
||||
/>
|
||||
<button aria-label="删除帧" onClick={onRemove} type="button">
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{frame.type === 'text' ? (
|
||||
<Textarea placeholder="请输入文字内容" defaultValue={frame.text} />
|
||||
) : (
|
||||
<div className="mms-file-drop">
|
||||
<Icon size={22} />
|
||||
<div>
|
||||
<strong>选择文件</strong>
|
||||
<span>未选择任何文件</span>
|
||||
</div>
|
||||
<small>{frameFormatMap[frame.type]}</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MmsTemplateModal({
|
||||
mode,
|
||||
template,
|
||||
onClose,
|
||||
}: {
|
||||
mode: 'create' | 'edit';
|
||||
template?: MmsTemplate;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [frames, setFrames] = useState<MmsFrame[]>(template?.frames ?? [
|
||||
{ id: 'new-frame-1', type: 'text' },
|
||||
{ id: 'new-frame-2', type: 'text' },
|
||||
]);
|
||||
|
||||
const totalSize = useMemo(() => {
|
||||
const textSize = frames.filter((frame) => frame.type === 'text').length * 0.2;
|
||||
const mediaSize = frames.filter((frame) => frame.type !== 'text').length * 180;
|
||||
return Math.min(2000, textSize + mediaSize).toFixed(1);
|
||||
}, [frames]);
|
||||
|
||||
function addFrame() {
|
||||
if (frames.length >= 9) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFrames((items) => [...items, { id: `new-frame-${Date.now()}`, type: 'text' }]);
|
||||
}
|
||||
|
||||
function removeFrame(id: string) {
|
||||
setFrames((items) => items.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
function changeFrameType(id: string, type: FrameType) {
|
||||
setFrames((items) => items.map((item) => (item.id === id ? { ...item, type } : item)));
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setPreviewOpen(true)}>预览</Button>
|
||||
<Button variant="ghost" onClick={onClose}>取消</Button>
|
||||
<Button className="mms-save-button" onClick={onClose}>确认</Button>
|
||||
</>
|
||||
}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={
|
||||
<div className="mms-template-modal-title">
|
||||
<h2>{mode === 'edit' ? '编辑彩信模板' : '创建彩信模板'}</h2>
|
||||
<p>彩信通过视频短信渠道发送,最多支持9帧,每帧可以是文字、图片、视频或者音频,内容总大小不超过2000KB。提交后需三大运营商审核,审核时间1-3个工作日。</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="mms-template-form">
|
||||
<div className="mms-template-form-grid">
|
||||
<Input label="彩信模板名称 *" defaultValue={template?.name ?? ''} placeholder="春节祝福" />
|
||||
<Select
|
||||
defaultValue={template?.application ?? '营销活动彩信'}
|
||||
label="彩信应用 *"
|
||||
options={[
|
||||
{ label: '营销活动彩信', value: '营销活动彩信' },
|
||||
{ label: '会员运营彩信', value: '会员运营彩信' },
|
||||
{ label: '客户关怀彩信', value: '客户关怀彩信' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
defaultValue={template?.signature ?? '【活动推广】'}
|
||||
label="签名 *"
|
||||
options={[
|
||||
{ label: '【活动推广】', value: '【活动推广】' },
|
||||
{ label: '【优品发布】', value: '【优品发布】' },
|
||||
{ label: '【周年庆典】', value: '【周年庆典】' },
|
||||
]}
|
||||
/>
|
||||
<Input label="彩信标题 *" defaultValue={template?.title ?? ''} placeholder="新春佳节,福气满满" />
|
||||
|
||||
<div className="mms-frame-header">
|
||||
<div>
|
||||
<strong>彩信内容 *</strong>
|
||||
<span>({frames.length}/9 帧,已使用 {totalSize}KB/2000KB)</span>
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />} onClick={addFrame} variant="ghost">添加帧</Button>
|
||||
</div>
|
||||
|
||||
<div className="mms-frame-list">
|
||||
{frames.map((frame, index) => (
|
||||
<FrameEditor
|
||||
frame={frame}
|
||||
index={index}
|
||||
key={frame.id}
|
||||
onRemove={() => removeFrame(frame.id)}
|
||||
onTypeChange={(type) => changeFrameType(frame.id, type)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
footer={<Button onClick={() => setPreviewOpen(false)}>关闭</Button>}
|
||||
onClose={() => setPreviewOpen(false)}
|
||||
open={previewOpen}
|
||||
size="md"
|
||||
title={<div className="template-modal-title"><h2>当前模板预览</h2><p>{template?.name ?? '新建彩信模板'}</p></div>}
|
||||
>
|
||||
<div className="mms-preview">
|
||||
{template?.image ? <img alt={template.name} src={template.image} /> : null}
|
||||
<h3>{template?.title ?? '新春佳节,福气满满'}</h3>
|
||||
<p>{template?.content ?? '这里展示当前彩信模板的文字、图片、视频或音频帧内容。保存前可先核对标题、签名和各帧顺序。'}</p>
|
||||
<div className="mms-preview-frames">
|
||||
{frames.map((frame, index) => {
|
||||
const Icon = frameIconMap[frame.type];
|
||||
return (
|
||||
<span key={frame.id}>
|
||||
<Icon size={15} />
|
||||
第 {index + 1} 帧 · {frameTypeOptions.find((option) => option.value === frame.type)?.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientMmsTemplatesPage() {
|
||||
const [templates, setTemplates] = useState(initialTemplates);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [modalState, setModalState] = useState<{ mode: 'create' | 'edit'; template?: MmsTemplate } | null>(null);
|
||||
const [previewTemplate, setPreviewTemplate] = useState<MmsTemplate | null>(null);
|
||||
|
||||
const filteredTemplates = templates.filter((item) => (
|
||||
item.name.includes(keyword) || item.application.includes(keyword) || item.title.includes(keyword)
|
||||
));
|
||||
|
||||
function deleteTemplate(id: string) {
|
||||
setTemplates((items) => items.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="mms-template-header">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon">
|
||||
<FileText size={22} />
|
||||
</span>
|
||||
<div>
|
||||
<h1>彩信模板列表</h1>
|
||||
<p>共 {templates.length} 个模板</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mms-template-toolbar">
|
||||
<Input
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索模板名称或应用"
|
||||
prefix={<Search size={17} />}
|
||||
value={keyword}
|
||||
/>
|
||||
<Button icon={<Plus size={17} />} onClick={() => setModalState({ mode: 'create' })}>创建彩信模板</Button>
|
||||
</div>
|
||||
|
||||
<div className="mms-template-grid">
|
||||
{filteredTemplates.map((template) => (
|
||||
<article className={`mms-template-card mms-template-card--${template.accent}`} key={template.id}>
|
||||
<span className="mms-template-app-tag">{template.application}</span>
|
||||
<div className="mms-template-card__body">
|
||||
<div className="mms-template-meta">
|
||||
<h2>{template.name}</h2>
|
||||
<p className="mms-template-code">{template.code}</p>
|
||||
<h3>{template.title}</h3>
|
||||
</div>
|
||||
<img alt={template.name} src={template.image} />
|
||||
<p className="mms-template-content">{template.content}</p>
|
||||
<div className="mms-template-status">
|
||||
<span>三网审核状态</span>
|
||||
<div>
|
||||
<section>
|
||||
<small>移动:</small>
|
||||
<Tag tone={statusToneMap[template.mobile]}>{statusLabelMap[template.mobile]}</Tag>
|
||||
</section>
|
||||
<section>
|
||||
<small>联通:</small>
|
||||
<Tag tone={statusToneMap[template.unicom]}>{statusLabelMap[template.unicom]}</Tag>
|
||||
</section>
|
||||
<section>
|
||||
<small>电信:</small>
|
||||
<Tag tone={statusToneMap[template.telecom]}>{statusLabelMap[template.telecom]}</Tag>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="mms-template-card__footer">
|
||||
<span>{template.updatedAt}</span>
|
||||
<div>
|
||||
<button onClick={() => setPreviewTemplate(template)} type="button"><Eye size={17} />预览</button>
|
||||
<button onClick={() => setModalState({ mode: 'edit', template })} type="button">编辑</button>
|
||||
<button onClick={() => deleteTemplate(template.id)} type="button"><Trash2 size={16} />删除</button>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mms-pagination">
|
||||
<button disabled type="button"><</button>
|
||||
<button className="active" type="button">1</button>
|
||||
<button type="button">2</button>
|
||||
<button type="button">></button>
|
||||
</div>
|
||||
|
||||
{modalState ? (
|
||||
<MmsTemplateModal
|
||||
mode={modalState.mode}
|
||||
onClose={() => setModalState(null)}
|
||||
template={modalState.template}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
footer={<Button onClick={() => setPreviewTemplate(null)}>关闭</Button>}
|
||||
onClose={() => setPreviewTemplate(null)}
|
||||
open={Boolean(previewTemplate)}
|
||||
size="md"
|
||||
title={<div className="template-modal-title"><h2>彩信预览</h2><p>{previewTemplate?.name}</p></div>}
|
||||
>
|
||||
{previewTemplate ? (
|
||||
<div className="mms-preview">
|
||||
<img alt={previewTemplate.name} src={previewTemplate.image} />
|
||||
<h3>{previewTemplate.title}</h3>
|
||||
<p>{previewTemplate.content}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Eye, FileImage, Search, Smartphone } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
DateRangeInput,
|
||||
DetailInfoGrid,
|
||||
DetailSection,
|
||||
Input,
|
||||
Modal,
|
||||
QueryPanel,
|
||||
Table,
|
||||
type DateRangeValue,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
|
||||
type MmsUplinkMessage = {
|
||||
id: string;
|
||||
phone: string;
|
||||
receivedAt: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
type MatchedMmsRecord = {
|
||||
id: string;
|
||||
sentAt: string;
|
||||
applicationName: string;
|
||||
title: string;
|
||||
image: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const uplinkMessages: MmsUplinkMessage[] = [
|
||||
{ id: 'MMSMO20260316001', phone: '13500000888', receivedAt: '2026-03-16 10:27:10', content: 'R' },
|
||||
{ id: 'MMSMO20260316002', phone: '13800138000', receivedAt: '2026-03-16 14:20:35', content: 'TD' },
|
||||
{ id: 'MMSMO20260316003', phone: '13900139000', receivedAt: '2026-03-16 09:15:42', content: '查询活动' },
|
||||
{ id: 'MMSMO20260316004', phone: '13700137000', receivedAt: '2026-03-16 10:05:18', content: 'R' },
|
||||
{ id: 'MMSMO20260316005', phone: '13600136000', receivedAt: '2026-03-16 11:30:25', content: '退订' },
|
||||
{ id: 'MMSMO20260316006', phone: '13400134000', receivedAt: '2026-03-16 13:45:10', content: '1' },
|
||||
];
|
||||
|
||||
const matchedMmsRecords: MatchedMmsRecord[] = [
|
||||
{
|
||||
id: 'MMSD20260314001',
|
||||
sentAt: '2026-03-14 12:25:28',
|
||||
applicationName: '营销活动彩信',
|
||||
title: '新春佳节,福气满满',
|
||||
image: 'https://images.unsplash.com/photo-1519671482749-fd09be7ccebf?auto=format&fit=crop&w=900&q=80',
|
||||
content: '【活动推广】尊敬的客户,新春佳节来临之际,优品商城全体员工祝您新春快乐、万事如意!点击查看精美贺卡和新春优惠活动详情。拒收请回复R',
|
||||
},
|
||||
{
|
||||
id: 'MMSD20260315002',
|
||||
sentAt: '2026-03-15 09:18:42',
|
||||
applicationName: '会员服务彩信',
|
||||
title: '会员专属优惠来了',
|
||||
image: 'https://images.unsplash.com/photo-1567427017947-545c5f8d16ad?auto=format&fit=crop&w=900&q=80',
|
||||
content: '【优品商城】尊敬的黄金会员,您享受一波50%的特价惊喜购!本月专享活动仅限开放,精选热门产品5折主打优惠。拒收请回复R',
|
||||
},
|
||||
];
|
||||
|
||||
function getDate(value: string) {
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
export function ClientMmsUplinkMessagesPage() {
|
||||
const [phoneKeyword, setPhoneKeyword] = useState('');
|
||||
const [contentKeyword, setContentKeyword] = useState('');
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [selectedMessage, setSelectedMessage] = useState<MmsUplinkMessage | null>(null);
|
||||
|
||||
const filteredMessages = uplinkMessages.filter((item) => {
|
||||
const receivedDate = getDate(item.receivedAt);
|
||||
const matchesPhone = !phoneKeyword || item.phone.includes(phoneKeyword);
|
||||
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
|
||||
const matchesStartDate = !dateRange.start || receivedDate >= dateRange.start;
|
||||
const matchesEndDate = !dateRange.end || receivedDate <= dateRange.end;
|
||||
return matchesPhone && matchesContent && matchesStartDate && matchesEndDate;
|
||||
});
|
||||
|
||||
const columns = useMemo<Array<TableColumn<MmsUplinkMessage>>>(() => [
|
||||
{ key: 'phone', title: '手机号码', width: '180px', render: (record) => <strong>{record.phone}</strong> },
|
||||
{ key: 'receivedAt', title: '上行时间', width: '220px', render: (record) => <span className="muted">{record.receivedAt}</span> },
|
||||
{ key: 'content', title: '上行内容', render: (record) => <span className="uplink-content">{record.content}</span> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '160px',
|
||||
align: 'center',
|
||||
render: (record) => (
|
||||
<Button icon={<Eye size={15} />} onClick={() => setSelectedMessage(record)} size="sm" variant="ghost">
|
||||
查看详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
], []);
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon"><FileImage size={22} /></span>
|
||||
<h1>查看上行彩信</h1>
|
||||
</div>
|
||||
|
||||
<QueryPanel title="查询条件" summary={<>共找到 <strong>{filteredMessages.length}</strong> 条上行记录</>}>
|
||||
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} placeholder="输入手机号搜索" prefix={<Smartphone size={16} />} value={phoneKeyword} />
|
||||
<DateRangeInput label="上行时间" onChange={setDateRange} value={dateRange} />
|
||||
<Input label="上行内容" onChange={(event) => setContentKeyword(event.target.value)} placeholder="输入关键词搜索" prefix={<Search size={16} />} value={contentKeyword} />
|
||||
</QueryPanel>
|
||||
|
||||
<div className="surface uplink-table-card">
|
||||
<Table columns={columns} data={filteredMessages} emptyText="暂无上行彩信记录" rowKey="id" />
|
||||
<div className="mms-uplink-pagination">
|
||||
<span>显示 {filteredMessages.length} 条记录</span>
|
||||
<div>
|
||||
<Button disabled size="sm" variant="secondary">上一页</Button>
|
||||
<Button disabled size="sm" variant="secondary">下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
footer={<Button variant="ghost" onClick={() => setSelectedMessage(null)}>关闭</Button>}
|
||||
onClose={() => setSelectedMessage(null)}
|
||||
open={Boolean(selectedMessage)}
|
||||
size="xl"
|
||||
title="上行彩信详情"
|
||||
>
|
||||
{selectedMessage ? (
|
||||
<div className="uplink-detail mms-uplink-detail">
|
||||
<DetailSection title="上行信息">
|
||||
<DetailInfoGrid
|
||||
items={[
|
||||
{ label: '手机号码', value: selectedMessage.phone },
|
||||
{ label: '上行时间', value: selectedMessage.receivedAt },
|
||||
{ label: '上行内容', value: selectedMessage.content, full: true },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="匹配发送记录">
|
||||
<p className="uplink-detail-hint">搜索到上行彩信前7天内的下发成功记录</p>
|
||||
<div className="uplink-match-list">
|
||||
{matchedMmsRecords.map((record) => (
|
||||
<article className="uplink-match-card mms-uplink-match-card" key={record.id}>
|
||||
<div className="uplink-match-grid">
|
||||
<div>
|
||||
<span>发送时间</span>
|
||||
<strong>{record.sentAt}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发送应用</span>
|
||||
<strong>{record.applicationName}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="uplink-match-content">
|
||||
<span>彩信标题</span>
|
||||
<strong>{record.title}</strong>
|
||||
</div>
|
||||
<div className="mms-uplink-image">
|
||||
<span>彩信图片</span>
|
||||
<img alt={record.title} src={record.image} />
|
||||
</div>
|
||||
<div className="uplink-match-content">
|
||||
<span>下发内容</span>
|
||||
<p>{record.content}</p>
|
||||
</div>
|
||||
<div className="uplink-match-actions">
|
||||
<Button size="sm" variant="ghost">添加到应用黑名单</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</DetailSection>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { FileText, Search, Smartphone } from 'lucide-react';
|
||||
import {
|
||||
DateRangeInput,
|
||||
Input,
|
||||
QueryPanel,
|
||||
Select,
|
||||
Tag,
|
||||
type DateRangeValue,
|
||||
} from '@/components/ui';
|
||||
|
||||
type SendStatus = 'success' | 'unknown' | 'failed';
|
||||
|
||||
type SmsSendDetail = {
|
||||
id: string;
|
||||
applicationName: string;
|
||||
sentAt: string;
|
||||
content: string;
|
||||
wordCount: number;
|
||||
billingCount: number;
|
||||
phone: string;
|
||||
carrier: '中国移动' | '中国联通' | '中国电信';
|
||||
region: string;
|
||||
status: SendStatus;
|
||||
receipt: 'DELIVRD' | 'UNKNOWN' | 'UNDELIV';
|
||||
receiptAt?: string;
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<SendStatus, string> = {
|
||||
success: '成功',
|
||||
unknown: '未知',
|
||||
failed: '失败',
|
||||
};
|
||||
|
||||
const statusToneMap: Record<SendStatus, 'success' | 'info' | 'danger'> = {
|
||||
success: 'success',
|
||||
unknown: 'info',
|
||||
failed: 'danger',
|
||||
};
|
||||
|
||||
const sendDetailRows: SmsSendDetail[] = [
|
||||
{
|
||||
id: 'SMSD20260316001',
|
||||
applicationName: '营销推广平台',
|
||||
sentAt: '2026-03-16 10:30:15',
|
||||
content: '【启瑞物业】尊敬的业主,您本月物业费500元,请及时缴纳。感谢您的配合!',
|
||||
wordCount: 45,
|
||||
billingCount: 1,
|
||||
phone: '13800138000',
|
||||
carrier: '中国移动',
|
||||
region: '北京市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-16 10:30:18',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316002',
|
||||
applicationName: '客服系统',
|
||||
sentAt: '2026-03-16 10:32:20',
|
||||
content: '【客服中心】尊敬的张先生,您已成功预约上门维修服务,时间:2026-03-18 14:00。',
|
||||
wordCount: 48,
|
||||
billingCount: 1,
|
||||
phone: '13900139000',
|
||||
carrier: '中国联通',
|
||||
region: '上海市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-16 10:32:25',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316003',
|
||||
applicationName: '验证码服务',
|
||||
sentAt: '2026-03-16 10:35:40',
|
||||
content: '【验证码】您的验证码是123456,5分钟内有效,请勿泄露给他人。',
|
||||
wordCount: 34,
|
||||
billingCount: 1,
|
||||
phone: '13700137000',
|
||||
carrier: '中国电信',
|
||||
region: '深圳市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-16 10:35:43',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316004',
|
||||
applicationName: '营销推广平台',
|
||||
sentAt: '2026-03-16 10:38:10',
|
||||
content: '【启瑞物业】您好!春季业主大会将于2026-03-20在小区会议室举行,欢迎参加。',
|
||||
wordCount: 46,
|
||||
billingCount: 1,
|
||||
phone: '13600136000',
|
||||
carrier: '中国移动',
|
||||
region: '广州市',
|
||||
status: 'unknown',
|
||||
receipt: 'UNKNOWN',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316005',
|
||||
applicationName: '验证码服务',
|
||||
sentAt: '2026-03-16 10:40:30',
|
||||
content: '【验证码】您的验证码是654321,5分钟内有效,请勿泄露给他人。',
|
||||
wordCount: 34,
|
||||
billingCount: 1,
|
||||
phone: '13500135000',
|
||||
carrier: '中国联通',
|
||||
region: '杭州市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-16 10:40:33',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316006',
|
||||
applicationName: '订单通知系统',
|
||||
sentAt: '2026-03-16 10:45:12',
|
||||
content: '【订单通知】您的订单已发货,请留意物流信息。',
|
||||
wordCount: 28,
|
||||
billingCount: 1,
|
||||
phone: '18800188000',
|
||||
carrier: '中国电信',
|
||||
region: '成都市',
|
||||
status: 'failed',
|
||||
receipt: 'UNDELIV',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316007',
|
||||
applicationName: '营销推广平台',
|
||||
sentAt: '2026-03-16 10:48:01',
|
||||
content: '【启瑞物业】尊敬的客户,值此佳节之际,祝您节日快乐,阖家幸福。',
|
||||
wordCount: 39,
|
||||
billingCount: 1,
|
||||
phone: '15900159000',
|
||||
carrier: '中国移动',
|
||||
region: '武汉市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-16 10:48:06',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316008',
|
||||
applicationName: '客服系统',
|
||||
sentAt: '2026-03-16 10:51:36',
|
||||
content: '【客服中心】您的服务工单已受理,工作人员将在24小时内联系您。',
|
||||
wordCount: 36,
|
||||
billingCount: 1,
|
||||
phone: '15000150000',
|
||||
carrier: '中国联通',
|
||||
region: '南京市',
|
||||
status: 'unknown',
|
||||
receipt: 'UNKNOWN',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316009',
|
||||
applicationName: '订单通知系统',
|
||||
sentAt: '2026-03-16 10:55:44',
|
||||
content: '【订单通知】您的退款申请已提交,预计1-3个工作日内到账。',
|
||||
wordCount: 35,
|
||||
billingCount: 1,
|
||||
phone: '18900189000',
|
||||
carrier: '中国电信',
|
||||
region: '西安市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-16 10:55:49',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316010',
|
||||
applicationName: '验证码服务',
|
||||
sentAt: '2026-03-16 10:59:18',
|
||||
content: '【验证码】您的登录验证码为908172,请在5分钟内完成验证。',
|
||||
wordCount: 33,
|
||||
billingCount: 1,
|
||||
phone: '13200132000',
|
||||
carrier: '中国移动',
|
||||
region: '重庆市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-16 10:59:21',
|
||||
},
|
||||
];
|
||||
|
||||
function getDate(value: string) {
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
export function ClientSendDetailPage() {
|
||||
const [applicationName, setApplicationName] = useState('all');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [contentKeyword, setContentKeyword] = useState('');
|
||||
const [phoneKeyword, setPhoneKeyword] = useState('');
|
||||
|
||||
const applicationOptions = useMemo(() => {
|
||||
const applications = Array.from(new Set(sendDetailRows.map((item) => item.applicationName)));
|
||||
return [
|
||||
{ label: '全部应用', value: 'all' },
|
||||
...applications.map((item) => ({ label: item, value: item })),
|
||||
];
|
||||
}, []);
|
||||
|
||||
const filteredRows = sendDetailRows.filter((item) => {
|
||||
const matchesApplication = applicationName === 'all' || item.applicationName === applicationName;
|
||||
const matchesStatus = status === 'all' || item.status === status;
|
||||
const sentDate = getDate(item.sentAt);
|
||||
const matchesStartDate = !dateRange.start || sentDate >= dateRange.start;
|
||||
const matchesEndDate = !dateRange.end || sentDate <= dateRange.end;
|
||||
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
|
||||
const matchesPhone = !phoneKeyword || item.phone.includes(phoneKeyword);
|
||||
return matchesApplication && matchesStatus && matchesStartDate && matchesEndDate && matchesContent && matchesPhone;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon">
|
||||
<FileText size={22} />
|
||||
</span>
|
||||
<h1>短信发送详情</h1>
|
||||
</div>
|
||||
|
||||
<QueryPanel
|
||||
title="查询条件"
|
||||
summary={<>共找到 <strong>{filteredRows.length}</strong> 条发送记录</>}
|
||||
>
|
||||
<Select label="应用名称" onChange={(event) => setApplicationName(event.target.value)} options={applicationOptions} value={applicationName} />
|
||||
<DateRangeInput label="发送时间" onChange={setDateRange} value={dateRange} />
|
||||
<Select
|
||||
label="发送状态"
|
||||
onChange={(event) => setStatus(event.target.value)}
|
||||
options={[
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '成功', value: 'success' },
|
||||
{ label: '未知', value: 'unknown' },
|
||||
{ label: '失败', value: 'failed' },
|
||||
]}
|
||||
value={status}
|
||||
/>
|
||||
<Input
|
||||
label="短信内容"
|
||||
onChange={(event) => setContentKeyword(event.target.value)}
|
||||
placeholder="输入关键词搜索"
|
||||
prefix={<Search size={16} />}
|
||||
value={contentKeyword}
|
||||
/>
|
||||
<Input
|
||||
label="手机号码"
|
||||
onChange={(event) => setPhoneKeyword(event.target.value)}
|
||||
placeholder="输入手机号搜索"
|
||||
prefix={<Smartphone size={16} />}
|
||||
value={phoneKeyword}
|
||||
/>
|
||||
</QueryPanel>
|
||||
|
||||
<div className="surface send-detail-table-card">
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table send-detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '112px' }}>应用名称</th>
|
||||
<th style={{ width: '128px' }}>发送时间</th>
|
||||
<th style={{ width: '90px', textAlign: 'center' }}>字符数/条数</th>
|
||||
<th style={{ width: '130px' }}>手机号码</th>
|
||||
<th style={{ width: '90px' }}>所属运营商</th>
|
||||
<th style={{ width: '90px' }}>号码归属地</th>
|
||||
<th style={{ width: '96px', textAlign: 'center' }}>发送状态</th>
|
||||
<th style={{ width: '95px', textAlign: 'center' }}>短信回执</th>
|
||||
<th style={{ width: '128px' }}>回执时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRows.length === 0 ? (
|
||||
<tr>
|
||||
<td className="ui-table__empty" colSpan={9}>暂无发送记录</td>
|
||||
</tr>
|
||||
) : filteredRows.map((record) => (
|
||||
<Fragment key={record.id}>
|
||||
<tr className="send-detail-main-row">
|
||||
<td><strong className="send-detail-app-name">{record.applicationName}</strong></td>
|
||||
<td>
|
||||
<span className="send-detail-time">
|
||||
{record.sentAt.slice(0, 10)}
|
||||
<small>{record.sentAt.slice(11)}</small>
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<span className="send-detail-count">
|
||||
<strong>{record.wordCount}字</strong>
|
||||
<small>{record.billingCount}条</small>
|
||||
</span>
|
||||
</td>
|
||||
<td><strong>{record.phone}</strong></td>
|
||||
<td><strong className="send-detail-carrier">{record.carrier}</strong></td>
|
||||
<td>
|
||||
<span className="send-detail-region">
|
||||
{record.region.slice(0, 2)}
|
||||
<small>{record.region.slice(2)}</small>
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}><Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag></td>
|
||||
<td style={{ textAlign: 'center' }}><strong className="send-detail-receipt-code">{record.receipt}</strong></td>
|
||||
<td>
|
||||
{record.receiptAt ? (
|
||||
<span className="send-detail-time">
|
||||
{record.receiptAt.slice(0, 10)}
|
||||
<small>{record.receiptAt.slice(11)}</small>
|
||||
</span>
|
||||
) : <span className="muted">-</span>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="send-detail-content-row">
|
||||
<td colSpan={9}>
|
||||
<div className="send-detail-content-block">
|
||||
<span>短信内容</span>
|
||||
<p>{record.content}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Check, FileText, Plus, Search, Send, Trash2 } from 'lucide-react';
|
||||
import { Button, DateTimeInput, Input, Modal, Select, Tag } from '@/components/ui';
|
||||
import { clientService, type RecentMessage } from '@/mock';
|
||||
|
||||
type Recipient = {
|
||||
id: string;
|
||||
phone: string;
|
||||
};
|
||||
|
||||
type SendMode = 'now' | 'scheduled';
|
||||
type ReceiverMode = 'manual' | 'import';
|
||||
|
||||
const smsApplications = [
|
||||
{ label: '会员营销平台', value: 'member' },
|
||||
{ label: '订单通知系统', value: 'order' },
|
||||
{ label: '登录认证服务', value: 'auth' },
|
||||
];
|
||||
|
||||
export function ClientSendPage() {
|
||||
const templates = clientService.getTemplates();
|
||||
const signatures = clientService.getSignatures();
|
||||
const approvedTemplates = templates.filter((item) => item.status === 'approved');
|
||||
const approvedSignatures = signatures.filter((item) => item.status === 'approved');
|
||||
|
||||
const [taskName, setTaskName] = useState('');
|
||||
const [applicationId, setApplicationId] = useState('');
|
||||
const [signatureId, setSignatureId] = useState('');
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
|
||||
const [templateKeyword, setTemplateKeyword] = useState('');
|
||||
const [sendMode, setSendMode] = useState<SendMode>('now');
|
||||
const [scheduledAt, setScheduledAt] = useState('');
|
||||
const [receiverMode, setReceiverMode] = useState<ReceiverMode>('manual');
|
||||
const [recipients, setRecipients] = useState<Recipient[]>([{ id: '1', phone: '' }]);
|
||||
const [submittedRecord, setSubmittedRecord] = useState<RecentMessage | null>(null);
|
||||
|
||||
const selectedSignature = useMemo(
|
||||
() => approvedSignatures.find((item) => item.id === signatureId),
|
||||
[approvedSignatures, signatureId],
|
||||
);
|
||||
const selectedTemplate = useMemo(
|
||||
() => approvedTemplates.find((item) => item.id === templateId),
|
||||
[approvedTemplates, templateId],
|
||||
);
|
||||
const filteredTemplates = approvedTemplates.filter((item) => (
|
||||
item.name.includes(templateKeyword) || item.content.includes(templateKeyword)
|
||||
));
|
||||
const validRecipients = recipients.filter((item) => item.phone.trim());
|
||||
const previewText = selectedSignature && selectedTemplate
|
||||
? `【${selectedSignature.name}】${selectedTemplate.content}`
|
||||
: '请选择签名和模板';
|
||||
const wordCount = previewText.length;
|
||||
const smsParts = Math.max(1, Math.ceil(wordCount / 70));
|
||||
const estimatedCount = validRecipients.length * smsParts;
|
||||
const canSubmit = Boolean(taskName && applicationId && signatureId && templateId && validRecipients.length > 0 && (sendMode === 'now' || scheduledAt));
|
||||
|
||||
function updateRecipient(id: string, phone: string) {
|
||||
setRecipients((items) => items.map((item) => (item.id === id ? { ...item, phone } : item)));
|
||||
}
|
||||
|
||||
function addRecipient() {
|
||||
setRecipients((items) => [...items, { id: Date.now().toString(), phone: '' }]);
|
||||
}
|
||||
|
||||
function removeRecipient(id: string) {
|
||||
setRecipients((items) => (items.length === 1 ? items : items.filter((item) => item.id !== id)));
|
||||
}
|
||||
|
||||
function chooseTemplate(id: string) {
|
||||
setTemplateId(id);
|
||||
setTemplatePickerOpen(false);
|
||||
}
|
||||
|
||||
function submitTask() {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRecord: RecentMessage = {
|
||||
id: `MSG-${Date.now().toString().slice(-6)}`,
|
||||
scene: selectedTemplate?.name ?? taskName,
|
||||
count: estimatedCount,
|
||||
channel: '华东主通道',
|
||||
status: sendMode === 'now' ? 'info' : 'warning',
|
||||
createdAt: new Date().toLocaleString('zh-CN', { hour12: false }),
|
||||
};
|
||||
|
||||
clientService.addRecentMessage(nextRecord);
|
||||
setSubmittedRecord(nextRecord);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="sms-send-page">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon">
|
||||
<Send size={22} />
|
||||
</span>
|
||||
<h1>发送短信</h1>
|
||||
{submittedRecord ? <Tag tone="success">已提交任务 {submittedRecord.id}</Tag> : null}
|
||||
</div>
|
||||
|
||||
<div className="sms-send-layout">
|
||||
<div className="sms-send-main">
|
||||
<section className="send-card">
|
||||
<div className="send-card__title">
|
||||
<span>1</span>
|
||||
<h2>基本信息</h2>
|
||||
</div>
|
||||
<Input
|
||||
label="任务名称"
|
||||
onChange={(event) => setTaskName(event.target.value)}
|
||||
placeholder="请输入任务名称,便于后续查找和管理"
|
||||
value={taskName}
|
||||
/>
|
||||
<div className="send-form-row">
|
||||
<Select
|
||||
label="短信应用"
|
||||
onChange={(event) => setApplicationId(event.target.value)}
|
||||
options={[{ label: '选择应用', value: '' }, ...smsApplications]}
|
||||
value={applicationId}
|
||||
/>
|
||||
<Select
|
||||
label="短信签名"
|
||||
onChange={(event) => setSignatureId(event.target.value)}
|
||||
options={[
|
||||
{ label: '选择签名', value: '' },
|
||||
...approvedSignatures.map((item) => ({ label: item.name, value: item.id })),
|
||||
]}
|
||||
value={signatureId}
|
||||
/>
|
||||
<label className="ui-field">
|
||||
<span className="ui-field__label">短信模板</span>
|
||||
<button className="template-trigger" onClick={() => setTemplatePickerOpen(true)} type="button">
|
||||
<span className={selectedTemplate ? '' : 'template-trigger__placeholder'}>
|
||||
{selectedTemplate?.name ?? '选择模板'}
|
||||
</span>
|
||||
<FileText size={17} />
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="send-card">
|
||||
<div className="send-card__title">
|
||||
<span>2</span>
|
||||
<h2>发送时间</h2>
|
||||
</div>
|
||||
<div className="radio-row">
|
||||
<label>
|
||||
<input checked={sendMode === 'now'} onChange={() => setSendMode('now')} type="radio" />
|
||||
<span>立即发送</span>
|
||||
</label>
|
||||
<label>
|
||||
<input checked={sendMode === 'scheduled'} onChange={() => setSendMode('scheduled')} type="radio" />
|
||||
<span>定时发送</span>
|
||||
</label>
|
||||
{sendMode === 'scheduled' ? (
|
||||
<DateTimeInput onChange={setScheduledAt} value={scheduledAt} />
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="send-card">
|
||||
<div className="send-card__title">
|
||||
<span>3</span>
|
||||
<h2>发送对象</h2>
|
||||
</div>
|
||||
<div className="receiver-tabs">
|
||||
<button
|
||||
className={receiverMode === 'manual' ? 'active' : ''}
|
||||
onClick={() => setReceiverMode('manual')}
|
||||
type="button"
|
||||
>
|
||||
手动输入
|
||||
</button>
|
||||
<button
|
||||
className={receiverMode === 'import' ? 'active' : ''}
|
||||
onClick={() => setReceiverMode('import')}
|
||||
type="button"
|
||||
>
|
||||
导入表格
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{receiverMode === 'manual' ? (
|
||||
<>
|
||||
<div className="receiver-table">
|
||||
<div className="receiver-table__head">
|
||||
<span>序号</span>
|
||||
<span>手机号码</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{recipients.map((item, index) => (
|
||||
<div className="receiver-table__row" key={item.id}>
|
||||
<span>{index + 1}</span>
|
||||
<input
|
||||
inputMode="tel"
|
||||
onChange={(event) => updateRecipient(item.id, event.target.value)}
|
||||
placeholder="请输入手机号"
|
||||
value={item.phone}
|
||||
/>
|
||||
<button
|
||||
aria-label="删除接收人"
|
||||
disabled={recipients.length === 1}
|
||||
onClick={() => removeRecipient(item.id)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="add-recipient" onClick={addRecipient} type="button">
|
||||
<Plus size={16} />
|
||||
添加接收人
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="import-panel">
|
||||
<div className="import-panel__icon">
|
||||
<FileText size={26} />
|
||||
</div>
|
||||
<strong>导入表格</strong>
|
||||
<span>支持 .xlsx / .csv 文件,当前原型仅展示上传入口。</span>
|
||||
<Button variant="ghost">选择文件</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="send-tip">已添加 {validRecipients.length} 个接收号码</p>
|
||||
</section>
|
||||
|
||||
<div className="send-submit-row">
|
||||
<Button disabled={!canSubmit} icon={<Send size={18} />} onClick={submitTask}>
|
||||
提交发送任务
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="sms-preview-card">
|
||||
<div className="preview-title">
|
||||
<FileText size={19} />
|
||||
<h2>短信预览</h2>
|
||||
</div>
|
||||
<div className="phone-preview">
|
||||
<div>{previewText}</div>
|
||||
</div>
|
||||
<div className="preview-stats">
|
||||
<div>
|
||||
<span>字数统计</span>
|
||||
<strong>{wordCount} 字</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>预计条数</span>
|
||||
<strong>{estimatedCount || 1} 条/人</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>单价</span>
|
||||
<strong>¥0.05 / 人</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="preview-note">短信按 70 字/条计费,超出部分按 67 字/条计算</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
footer={<Button variant="ghost" onClick={() => setTemplatePickerOpen(false)}>关闭</Button>}
|
||||
onClose={() => setTemplatePickerOpen(false)}
|
||||
open={templatePickerOpen}
|
||||
title="选择短信模板"
|
||||
>
|
||||
<div className="template-picker">
|
||||
<Input
|
||||
prefix={<Search size={16} />}
|
||||
onChange={(event) => setTemplateKeyword(event.target.value)}
|
||||
placeholder="搜索模板名称或内容"
|
||||
value={templateKeyword}
|
||||
/>
|
||||
<div className="template-picker__list">
|
||||
{filteredTemplates.map((template) => (
|
||||
<button
|
||||
className={template.id === templateId ? 'active' : ''}
|
||||
key={template.id}
|
||||
onClick={() => chooseTemplate(template.id)}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
<strong>{template.name}</strong>
|
||||
<small>{template.content}</small>
|
||||
</span>
|
||||
{template.id === templateId ? <Check size={18} /> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Save } from 'lucide-react';
|
||||
import { Button, Input, Select } from '@/components/ui';
|
||||
|
||||
export function ClientSettingsPage() {
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<p className="eyebrow">账户</p>
|
||||
<h1>账号设置</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface content-grid">
|
||||
<div className="form-grid">
|
||||
<Input label="企业名称" defaultValue="上海云舟科技有限公司" />
|
||||
<div className="form-grid form-grid--two">
|
||||
<Input label="联系人" defaultValue="赵先生" />
|
||||
<Input label="联系电话" defaultValue="13800000000" />
|
||||
</div>
|
||||
<Select
|
||||
label="默认发送通道"
|
||||
defaultValue="east"
|
||||
options={[
|
||||
{ label: '华东主通道', value: 'east' },
|
||||
{ label: '华南通道', value: 'south' },
|
||||
{ label: '备用通道', value: 'backup' },
|
||||
]}
|
||||
/>
|
||||
<Button icon={<Save size={16} />}>保存设置</Button>
|
||||
</div>
|
||||
<aside className="soft-panel">
|
||||
<h3>安全提示</h3>
|
||||
<p className="muted">当前为纯前端原型,账号设置仅用于展示交互形态,不会提交到后端。</p>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Edit3, FilePenLine, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
|
||||
import { Button, Input, Modal, Select, Tag } from '@/components/ui';
|
||||
|
||||
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'> = {
|
||||
approved: 'success',
|
||||
pending: 'info',
|
||||
rejected: 'danger',
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientSignaturesPage() {
|
||||
const [signatures, setSignatures] = useState(initialSignatures);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [expandedId, setExpandedId] = useState('sig-1');
|
||||
const [signatureModal, setSignatureModal] = useState<{ mode: 'add' | 'edit'; signature?: SignatureItem } | null>(null);
|
||||
const [editingDrainage, setEditingDrainage] = useState<{ signature: SignatureItem; drainage?: DrainageInfo } | 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 deleteDrainage(signatureId: string, drainageId: string) {
|
||||
setSignatures((items) => items.map((item) => (
|
||||
item.id === signatureId ? { ...item, drainage: item.drainage.filter((drainage) => drainage.id !== drainageId) } : item
|
||||
)));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="signature-page-header">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon">
|
||||
<FilePenLine size={22} />
|
||||
</span>
|
||||
<h1>签名与引流信息</h1>
|
||||
</div>
|
||||
<Button icon={<Plus size={17} />} onClick={() => setSignatureModal({ mode: 'add' })}>添加签名</Button>
|
||||
</div>
|
||||
|
||||
<div className="signature-search-row">
|
||||
<Input
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索签名名称或用途"
|
||||
prefix={<Search size={17} />}
|
||||
value={keyword}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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="ghost">删除</Button>
|
||||
</div>
|
||||
</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 })} type="button">编辑</button>
|
||||
<button onClick={() => deleteDrainage(signature.id, item.id)} type="button">删除</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>
|
||||
|
||||
<Modal
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setSignatureModal(null)}>取消</Button>
|
||||
<Button onClick={() => setSignatureModal(null)}>确认</Button>
|
||||
</>
|
||||
}
|
||||
onClose={() => setSignatureModal(null)}
|
||||
open={Boolean(signatureModal)}
|
||||
size="xl"
|
||||
title={<div className="signature-modal-title"><h2>{signatureModal?.mode === 'edit' ? '编辑签名' : '添加签名'}</h2><p>修改短信签名的相关信息</p></div>}
|
||||
>
|
||||
<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}
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { CalendarDays, Download, FileText, Search } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Table,
|
||||
Tag,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
|
||||
type LogLevel = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
type SystemLog = {
|
||||
id: string;
|
||||
time: string;
|
||||
level: LogLevel;
|
||||
module: string;
|
||||
operator: string;
|
||||
action: string;
|
||||
detail: string;
|
||||
ip: string;
|
||||
};
|
||||
|
||||
const levelLabelMap: Record<LogLevel, string> = {
|
||||
info: '信息',
|
||||
success: '成功',
|
||||
warning: '警告',
|
||||
error: '错误',
|
||||
};
|
||||
|
||||
const levelToneMap: Record<LogLevel, 'info' | 'success' | 'warning' | 'danger'> = {
|
||||
info: 'info',
|
||||
success: 'success',
|
||||
warning: 'warning',
|
||||
error: 'danger',
|
||||
};
|
||||
|
||||
const logsSeed: SystemLog[] = [
|
||||
{ id: 'LOG001', time: '2026-03-17 14:35:22', level: 'info', module: '用户管理', operator: '张三', action: '创建用户', detail: '创建用户账号:李四(lisi@example.com)', ip: '192.168.1.100' },
|
||||
{ id: 'LOG002', time: '2026-03-17 14:20:15', level: 'success', module: '短信服务', operator: '李四', action: '发送短信', detail: '批量发送短信至500个号码,发送成功', ip: '192.168.1.101' },
|
||||
{ id: 'LOG003', time: '2026-03-17 13:45:33', level: 'warning', module: '彩信服务', operator: '王五', action: '模板审核', detail: '彩信模板“春节祝福”审核未通过,原因:内容包含敏感词', ip: '192.168.1.102' },
|
||||
{ id: 'LOG004', time: '2026-03-17 12:10:08', level: 'error', module: '系统管理', operator: '赵六', action: '登录失败', detail: '用户登录失败,错误:密码错误(连续3次)', ip: '192.168.1.103' },
|
||||
{ id: 'LOG005', time: '2026-03-17 11:30:45', level: 'info', module: '用户管理', operator: '张三', action: '修改权限', detail: '修改用户“孙七”的角色:普通用户 → 管理员', ip: '192.168.1.100' },
|
||||
{ id: 'LOG006', time: '2026-03-17 10:15:20', level: 'success', module: '短信服务', operator: '李四', action: '签名审核', detail: '短信签名“优品商城”审核通过', ip: '192.168.1.101' },
|
||||
{ id: 'LOG007', time: '2026-03-17 09:50:12', level: 'info', module: '彩信服务', operator: '王五', action: '创建模板', detail: '创建彩信模板“新品发布”(模板ID:MMS_1a2b3c4d)', ip: '192.168.1.102' },
|
||||
{ id: 'LOG008', time: '2026-03-17 09:05:33', level: 'error', module: '短信服务', operator: '李四', action: '发送失败', detail: '短信发送失败,错误:余额不足', ip: '192.168.1.101' },
|
||||
{ id: 'LOG009', time: '2026-03-17 08:40:18', level: 'warning', module: '系统管理', operator: 'system', action: '系统告警', detail: '系统磁盘使用率超过80%,当前使用率:85%', ip: '127.0.0.1' },
|
||||
];
|
||||
|
||||
export function ClientSystemLogsPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [level, setLevel] = useState('all');
|
||||
const [module, setModule] = useState('all');
|
||||
const [range, setRange] = useState('today');
|
||||
|
||||
const moduleOptions = useMemo(() => {
|
||||
const modules = Array.from(new Set(logsSeed.map((item) => item.module)));
|
||||
return [{ label: '全部模块', value: 'all' }, ...modules.map((item) => ({ label: item, value: item }))];
|
||||
}, []);
|
||||
|
||||
const filteredLogs = logsSeed.filter((item) => {
|
||||
const target = `${item.operator} ${item.action} ${item.detail}`;
|
||||
const matchesKeyword = !keyword || target.toLowerCase().includes(keyword.toLowerCase());
|
||||
const matchesLevel = level === 'all' || item.level === level;
|
||||
const matchesModule = module === 'all' || item.module === module;
|
||||
return matchesKeyword && matchesLevel && matchesModule;
|
||||
});
|
||||
|
||||
const columns = useMemo<Array<TableColumn<SystemLog>>>(() => [
|
||||
{ key: 'time', title: '时间', width: '190px', render: (record) => <span className="muted">{record.time}</span> },
|
||||
{ key: 'level', title: '级别', width: '110px', render: (record) => <Tag tone={levelToneMap[record.level]}>{levelLabelMap[record.level]}</Tag> },
|
||||
{ key: 'module', title: '模块', width: '150px', render: (record) => <strong>{record.module}</strong> },
|
||||
{ key: 'operator', title: '操作人', width: '130px', render: (record) => <strong>{record.operator}</strong> },
|
||||
{ key: 'action', title: '操作', width: '160px', render: (record) => <strong>{record.action}</strong> },
|
||||
{ key: 'detail', title: '详情', render: (record) => <span className="system-log-detail">{record.detail}</span> },
|
||||
{ key: 'ip', title: 'IP地址', width: '150px', render: (record) => <span className="muted">{record.ip}</span> },
|
||||
], []);
|
||||
|
||||
return (
|
||||
<section className="page-stack system-page">
|
||||
<div className="system-page-toolbar">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon"><FileText size={22} /></span>
|
||||
<h1>系统日志</h1>
|
||||
</div>
|
||||
<Button icon={<Download size={17} />} variant="secondary">导出日志</Button>
|
||||
</div>
|
||||
|
||||
<div className="system-log-filters">
|
||||
<Input
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索操作人、操作或详情"
|
||||
prefix={<Search size={16} />}
|
||||
value={keyword}
|
||||
/>
|
||||
<Select
|
||||
onChange={(event) => setLevel(event.target.value)}
|
||||
options={[
|
||||
{ label: '全部级别', value: 'all' },
|
||||
{ label: '信息', value: 'info' },
|
||||
{ label: '成功', value: 'success' },
|
||||
{ label: '警告', value: 'warning' },
|
||||
{ label: '错误', value: 'error' },
|
||||
]}
|
||||
value={level}
|
||||
/>
|
||||
<Select onChange={(event) => setModule(event.target.value)} options={moduleOptions} value={module} />
|
||||
</div>
|
||||
|
||||
<div className="system-log-range">
|
||||
<span><CalendarDays size={18} /> 时间范围:</span>
|
||||
{[
|
||||
{ label: '今天', value: 'today' },
|
||||
{ label: '近7天', value: '7d' },
|
||||
{ label: '近30天', value: '30d' },
|
||||
{ label: '全部', value: 'all' },
|
||||
].map((item) => (
|
||||
<Button
|
||||
key={item.value}
|
||||
onClick={() => setRange(item.value)}
|
||||
size="sm"
|
||||
variant={range === item.value ? 'primary' : 'secondary'}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="surface system-table-card">
|
||||
<Table columns={columns} data={filteredLogs} emptyText="暂无系统日志" rowKey="id" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { MessageSquare, Plus, Search, Info } from 'lucide-react';
|
||||
import { Button, Input, Modal, Select, Textarea } from '@/components/ui';
|
||||
|
||||
type TemplateAccent = 'green' | 'blue' | 'red';
|
||||
|
||||
type SmsTemplateCard = {
|
||||
id: string;
|
||||
name: string;
|
||||
application: string;
|
||||
hash: string;
|
||||
content: string;
|
||||
variables: string[];
|
||||
updatedAt: string;
|
||||
accent: TemplateAccent;
|
||||
};
|
||||
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientTemplatesPage() {
|
||||
const [templates, setTemplates] = useState(initialTemplates);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [modalState, setModalState] = useState<{ mode: 'add' | 'edit'; template?: SmsTemplateCard } | null>(null);
|
||||
|
||||
const filteredTemplates = templates.filter((item) => (
|
||||
item.name.includes(keyword) || item.application.includes(keyword)
|
||||
));
|
||||
|
||||
function deleteTemplate(id: string) {
|
||||
setTemplates((items) => items.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="template-page-header">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon">
|
||||
<MessageSquare size={22} />
|
||||
</span>
|
||||
<h1>短信模板列表</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="template-toolbar">
|
||||
<Input
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索模板名称或应用..."
|
||||
prefix={<Search size={17} />}
|
||||
value={keyword}
|
||||
/>
|
||||
<Button icon={<Plus size={17} />} onClick={() => setModalState({ mode: 'add' })}>添加短信模板</Button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{modalState ? (
|
||||
<TemplateModal
|
||||
mode={modalState.mode}
|
||||
onClose={() => setModalState(null)}
|
||||
template={modalState.template}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Eye, MessageSquareReply, Search, Smartphone } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
DateRangeInput,
|
||||
DetailInfoGrid,
|
||||
DetailSection,
|
||||
Input,
|
||||
Modal,
|
||||
QueryPanel,
|
||||
Table,
|
||||
type DateRangeValue,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
|
||||
type UplinkMessage = {
|
||||
id: string;
|
||||
phone: string;
|
||||
receivedAt: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
type MatchedSendRecord = {
|
||||
id: string;
|
||||
sentAt: string;
|
||||
applicationName: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const uplinkMessages: UplinkMessage[] = [
|
||||
{ id: 'MO20260112001', phone: '13500000888', receivedAt: '2026-01-12 10:27:10', content: 'R' },
|
||||
{ id: 'MO20260315001', phone: '13800138000', receivedAt: '2026-03-15 14:20:35', content: 'TD' },
|
||||
{ id: 'MO20260316001', phone: '13900139000', receivedAt: '2026-03-16 09:15:42', content: '查询余额' },
|
||||
{ id: 'MO20260316002', phone: '13700137000', receivedAt: '2026-03-16 10:05:18', content: 'R' },
|
||||
{ id: 'MO20260316003', phone: '13600136000', receivedAt: '2026-03-16 11:30:25', content: '退订' },
|
||||
{ id: 'MO20260316004', phone: '13400134000', receivedAt: '2026-03-16 13:45:10', content: '1' },
|
||||
{ id: 'MO20260316005', phone: '13300133000', receivedAt: '2026-03-16 14:20:55', content: '取消预约' },
|
||||
{ id: 'MO20260316006', phone: '13200132000', receivedAt: '2026-03-16 15:10:30', content: 'R' },
|
||||
];
|
||||
|
||||
const matchedSendRecords: MatchedSendRecord[] = [
|
||||
{
|
||||
id: 'MT20260119001',
|
||||
sentAt: '2026-01-19 12:25:28',
|
||||
applicationName: 'XXX催收',
|
||||
content: '【XXX科技】如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 拒收请回复R',
|
||||
},
|
||||
{
|
||||
id: 'MT20260119002',
|
||||
sentAt: '2026-01-19 12:25:28',
|
||||
applicationName: 'XXX催收',
|
||||
content: '【XXX科技】如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行',
|
||||
},
|
||||
];
|
||||
|
||||
function getDate(value: string) {
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
export function ClientUplinkMessagesPage() {
|
||||
const [phoneKeyword, setPhoneKeyword] = useState('');
|
||||
const [contentKeyword, setContentKeyword] = useState('');
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [selectedMessage, setSelectedMessage] = useState<UplinkMessage | null>(null);
|
||||
|
||||
const filteredMessages = uplinkMessages.filter((item) => {
|
||||
const receivedDate = getDate(item.receivedAt);
|
||||
const matchesPhone = !phoneKeyword || item.phone.includes(phoneKeyword);
|
||||
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
|
||||
const matchesStartDate = !dateRange.start || receivedDate >= dateRange.start;
|
||||
const matchesEndDate = !dateRange.end || receivedDate <= dateRange.end;
|
||||
return matchesPhone && matchesContent && matchesStartDate && matchesEndDate;
|
||||
});
|
||||
|
||||
const columns = useMemo<Array<TableColumn<UplinkMessage>>>(() => [
|
||||
{ key: 'phone', title: '手机号码', width: '180px', render: (record) => <strong>{record.phone}</strong> },
|
||||
{ key: 'receivedAt', title: '上行时间', width: '220px', render: (record) => <span className="muted">{record.receivedAt}</span> },
|
||||
{ key: 'content', title: '上行内容', render: (record) => <span className="uplink-content">{record.content}</span> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '160px',
|
||||
align: 'center',
|
||||
render: (record) => (
|
||||
<Button icon={<Eye size={15} />} onClick={() => setSelectedMessage(record)} size="sm" variant="ghost">
|
||||
查看详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
], []);
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon">
|
||||
<MessageSquareReply size={22} />
|
||||
</span>
|
||||
<h1>查看上行短信</h1>
|
||||
</div>
|
||||
|
||||
<QueryPanel title="查询条件" summary={<>共找到 <strong>{filteredMessages.length}</strong> 条上行记录</>}>
|
||||
<Input
|
||||
label="手机号码"
|
||||
onChange={(event) => setPhoneKeyword(event.target.value)}
|
||||
placeholder="输入手机号搜索"
|
||||
prefix={<Smartphone size={16} />}
|
||||
value={phoneKeyword}
|
||||
/>
|
||||
<DateRangeInput label="上行时间" onChange={setDateRange} value={dateRange} />
|
||||
<Input
|
||||
label="上行内容"
|
||||
onChange={(event) => setContentKeyword(event.target.value)}
|
||||
placeholder="输入关键词搜索"
|
||||
prefix={<Search size={16} />}
|
||||
value={contentKeyword}
|
||||
/>
|
||||
</QueryPanel>
|
||||
|
||||
<div className="surface uplink-table-card">
|
||||
<Table columns={columns} data={filteredMessages} emptyText="暂无上行记录" rowKey="id" />
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
footer={<Button variant="ghost" onClick={() => setSelectedMessage(null)}>关闭</Button>}
|
||||
onClose={() => setSelectedMessage(null)}
|
||||
open={Boolean(selectedMessage)}
|
||||
size="xl"
|
||||
title="上行短信详情"
|
||||
>
|
||||
{selectedMessage ? (
|
||||
<div className="uplink-detail">
|
||||
<DetailSection title="上行信息">
|
||||
<DetailInfoGrid
|
||||
items={[
|
||||
{ label: '手机号码', value: selectedMessage.phone },
|
||||
{ label: '上行时间', value: selectedMessage.receivedAt },
|
||||
{ label: '上行内容', value: selectedMessage.content, full: true },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="匹配发送记录">
|
||||
<p className="uplink-detail-hint">搜索到上行短信前7天内的下发成功记录</p>
|
||||
<div className="uplink-match-list">
|
||||
{matchedSendRecords.map((record) => (
|
||||
<article className="uplink-match-card" key={record.id}>
|
||||
<div className="uplink-match-grid">
|
||||
<div>
|
||||
<span>发送时间</span>
|
||||
<strong>{record.sentAt}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发送应用</span>
|
||||
<strong>{record.applicationName}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="uplink-match-content">
|
||||
<span>下发内容</span>
|
||||
<p>{record.content}</p>
|
||||
</div>
|
||||
<div className="uplink-match-actions">
|
||||
<Button size="sm" variant="ghost">添加到应用黑名单</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</DetailSection>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Edit3, Plus, Search, Trash2, Users } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
Pagination,
|
||||
Select,
|
||||
Table,
|
||||
Tag,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
|
||||
type UserRole = 'admin' | 'user';
|
||||
type UserStatus = 'active' | 'disabled';
|
||||
|
||||
type ClientUser = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
role: UserRole;
|
||||
status: UserStatus;
|
||||
lastLoginAt: string;
|
||||
};
|
||||
|
||||
const roleLabelMap: Record<UserRole, string> = {
|
||||
admin: '管理员',
|
||||
user: '普通用户',
|
||||
};
|
||||
|
||||
const usersSeed: ClientUser[] = [
|
||||
{ id: 'USER001', name: '张三', email: 'zhangsan@example.com', phone: '13800138000', role: 'admin', status: 'active', lastLoginAt: '2026-03-17 09:15:00' },
|
||||
{ id: 'USER002', name: '李四', email: 'lisi@example.com', phone: '13800138001', role: 'user', status: 'active', lastLoginAt: '2026-03-16 16:45:00' },
|
||||
{ id: 'USER003', name: '王五', email: 'wangwu@example.com', phone: '13800138002', role: 'user', status: 'active', lastLoginAt: '2026-03-15 11:30:00' },
|
||||
{ id: 'USER004', name: '赵六', email: 'zhaoliu@example.com', phone: '13800138003', role: 'user', status: 'disabled', lastLoginAt: '2026-02-20 14:00:00' },
|
||||
{ id: 'USER005', name: '孙七', email: 'sunqi@example.com', phone: '13800138004', role: 'user', status: 'active', lastLoginAt: '2026-03-17 08:00:00' },
|
||||
];
|
||||
|
||||
const emptyUser: ClientUser = {
|
||||
id: 'NEW',
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
lastLoginAt: '-',
|
||||
};
|
||||
|
||||
export function ClientUsersPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [editingUser, setEditingUser] = useState<ClientUser | null>(null);
|
||||
const [draft, setDraft] = useState<ClientUser>(emptyUser);
|
||||
|
||||
const filteredUsers = usersSeed.filter((item) => {
|
||||
const target = `${item.name} ${item.email} ${item.phone}`;
|
||||
return !keyword || target.toLowerCase().includes(keyword.toLowerCase());
|
||||
});
|
||||
|
||||
function openEditor(user?: ClientUser) {
|
||||
const nextUser = user ?? emptyUser;
|
||||
setEditingUser(nextUser);
|
||||
setDraft(nextUser);
|
||||
}
|
||||
|
||||
const columns = useMemo<Array<TableColumn<ClientUser>>>(() => [
|
||||
{ key: 'name', title: '用户名', width: '120px', render: (record) => <strong className="text-strong">{record.name}</strong> },
|
||||
{ key: 'email', title: '邮箱', render: (record) => <span className="muted">{record.email}</span> },
|
||||
{ key: 'phone', title: '手机号', width: '180px', render: (record) => <span className="muted">{record.phone}</span> },
|
||||
{
|
||||
key: 'role',
|
||||
title: '角色',
|
||||
width: '150px',
|
||||
render: (record) => <Tag tone={record.role === 'admin' ? 'info' : 'success'}>{roleLabelMap[record.role]}</Tag>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: '状态',
|
||||
width: '130px',
|
||||
render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '正常' : '禁用'}</Tag>,
|
||||
},
|
||||
{ key: 'lastLoginAt', title: '最后登录时间', width: '210px', render: (record) => <span className="muted">{record.lastLoginAt}</span> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '170px',
|
||||
render: (record) => (
|
||||
<div className="inline-actions">
|
||||
<Button icon={<Edit3 size={15} />} onClick={() => openEditor(record)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button icon={<Trash2 size={15} />} size="sm" variant="danger">删除</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], []);
|
||||
|
||||
return (
|
||||
<section className="page-stack system-page">
|
||||
<div className="system-page-toolbar">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon"><Users size={22} /></span>
|
||||
<h1>用户管理</h1>
|
||||
</div>
|
||||
<Button icon={<Plus size={18} />} onClick={() => openEditor()}>添加用户</Button>
|
||||
</div>
|
||||
|
||||
<div className="system-filter-row">
|
||||
<Input
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索用户名、邮箱或手机号"
|
||||
prefix={<Search size={16} />}
|
||||
value={keyword}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="surface system-table-card">
|
||||
<Table columns={columns} data={filteredUsers} emptyText="暂无用户" rowKey="id" />
|
||||
<Pagination total={filteredUsers.length} page={1} />
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setEditingUser(null)} variant="secondary">取消</Button>
|
||||
<Button onClick={() => setEditingUser(null)}>保存</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setEditingUser(null)}
|
||||
open={Boolean(editingUser)}
|
||||
size="xl"
|
||||
title={editingUser?.id === 'NEW' ? '添加用户' : '编辑用户'}
|
||||
>
|
||||
<div className="system-user-form">
|
||||
<Input label="用户名 *" onChange={(event) => setDraft({ ...draft, name: event.target.value })} placeholder="请输入用户名" value={draft.name} />
|
||||
<Input label="邮箱 *" onChange={(event) => setDraft({ ...draft, email: event.target.value })} placeholder="请输入邮箱" value={draft.email} />
|
||||
<Input label="手机号 *" onChange={(event) => setDraft({ ...draft, phone: event.target.value })} placeholder="请输入手机号" value={draft.phone} />
|
||||
<Select
|
||||
label="角色 *"
|
||||
onChange={(event) => setDraft({ ...draft, role: event.target.value as UserRole })}
|
||||
options={[
|
||||
{ label: '管理员', value: 'admin' },
|
||||
{ label: '普通用户', value: 'user' },
|
||||
]}
|
||||
value={draft.role}
|
||||
/>
|
||||
<Select
|
||||
label="状态 *"
|
||||
onChange={(event) => setDraft({ ...draft, status: event.target.value as UserStatus })}
|
||||
options={[
|
||||
{ label: '正常', value: 'active' },
|
||||
{ label: '禁用', value: 'disabled' },
|
||||
]}
|
||||
value={draft.status}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user