Initial CMPP frontend prototype
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
*.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,138 @@
|
||||
# CMPP 平台 UI 设计规范
|
||||
|
||||
## 设计原则
|
||||
|
||||
- 后台页面优先服务扫描、比对和操作,避免营销式大留白。
|
||||
- 信息密度保持克制:列表、表格、卡片要紧凑,但标题、正文、状态之间必须保留可辨识层级。
|
||||
- 页面以白色和浅灰为主,品牌黑用于导航和关键结构,蓝色只用于选中、主操作和链接。
|
||||
- 所有业务页面优先复用 `src/components/ui` 内的自研组件。
|
||||
|
||||
## 颜色
|
||||
|
||||
- 品牌主色:`#12121A`,用于侧边栏、强品牌识别区域。
|
||||
- 辅助金色:`#D9C3A0`,用于品牌点缀,不作为大面积背景。
|
||||
- 选中蓝色:`#2563EB`,用于当前菜单、主按钮、链接、聚焦态。
|
||||
- 背景色:页面背景 `#F6F7F9`,卡片和表单背景 `#FFFFFF`。
|
||||
- 边框色:默认 `#E5E7EB`,较强分隔 `#D1D5DB`。
|
||||
- 文本色:主文本 `#1F2937`,标题 `#111827`,辅助文本 `#6B7280`。
|
||||
- 状态色:成功 `#16A34A`,警告 `#D97706`,失败 `#DC2626`,信息 `#2563EB`。
|
||||
|
||||
## 字号与层级
|
||||
|
||||
- 页面标题:24px,700。只用于页面主标题、重要弹窗标题。
|
||||
- 区块标题:16px,600。用于表单分组、卡片区块、小节标题。
|
||||
- 卡片标题:16px,600。短信模板名称、彩信模板名称、应用名称等都使用这一档,不要放大到 20px 以上。
|
||||
- 正文文本:14px,400-500,行高 1.5。用于表格正文、卡片正文、普通说明。
|
||||
- 次要文本:13px,400-500,使用辅助色。用于 ID、时间、应用名、补充说明。
|
||||
- 辅助标签:12px,500-600。用于 Tag、字段提示、状态旁注。
|
||||
- 表格正文:14px,关键字段可 600。
|
||||
- 状态 Tag:12px,600,保持短胶囊,不拉满列宽。
|
||||
|
||||
## 文字规范
|
||||
|
||||
- 同一业务类型的名称必须使用同一档字号:短信模板名称、彩信模板名称、签名名称、应用名称统一用卡片标题 `16px / 600`。
|
||||
- 页面标题和卡片标题要明显区分:页面标题可 20-24px,卡片内标题不超过 16px,避免列表卡片视觉过重。
|
||||
- ID、时间、哈希、编码等机器信息统一使用 13px、辅助色,可使用等宽字体。
|
||||
- 大段短信/彩信内容统一使用 14px、辅助色、行高 1.5-1.6;卡片内最多展示 3 行,详情弹窗再展示全文。
|
||||
- 字重优先使用 400 / 500 / 600 / 700,不使用 800 以上。
|
||||
- 不使用负字距;后台界面文字不随视口宽度缩放。
|
||||
- 中文按钮文字保持 2-6 个字为宜,动词优先,例如「预览」「编辑」「删除」「确认」。
|
||||
|
||||
## 间距
|
||||
|
||||
- 页面区块间距:20-24px。
|
||||
- 工具栏和列表间距:16-20px。
|
||||
- 卡片内边距:20px。
|
||||
- 卡片内部常规间距:10-12px。
|
||||
- 卡片顶部「名称 / ID / 标题」建议节奏:
|
||||
- 名称到 ID:约 7-8px。
|
||||
- ID 到标题:约 7-8px。
|
||||
- 标题到媒体或正文:约 10-12px。
|
||||
- 正文到状态区域:约 8-12px;不要使用 `margin-top: auto` 把状态推到底部,除非卡片高度必须统一。
|
||||
|
||||
## 卡片
|
||||
|
||||
- 圆角统一不超过 8px。
|
||||
- 列表卡片使用 `1px` 边框,hover 可轻微阴影和边框变色。
|
||||
- 彩信/短信模板类卡片可用左侧 5px 状态色条,但不要再叠加过多装饰。
|
||||
- 业务标签放右上角时,要为标题保留右侧 padding,避免重叠。
|
||||
- 卡片底部操作条高度建议 48-52px,背景用浅灰 `#F4F6F8`。
|
||||
|
||||
## 表格
|
||||
|
||||
- 表头使用浅灰背景,字号 13-14px,字体 600。
|
||||
- 行高常规 64-86px,复杂两行信息可增加,但避免超过 110px。
|
||||
- 操作按钮采用文字或图标加文字,危险操作使用红色。
|
||||
|
||||
## 表单和弹窗
|
||||
|
||||
- 弹窗默认 `md`,复杂编辑器使用 `xl`。
|
||||
- 弹窗必须采用 Header / Body / Footer 三段布局,Footer 固定完整显示。
|
||||
- 表单字段在宽弹窗中可双列;编辑器内容、上传区、长文本保持单列。
|
||||
- Footer 按钮顺序:辅助操作、取消、确认。确认按钮在最右侧。
|
||||
- 弹窗内容高度超过视口时,只滚动 Body,不滚动 Header/Footer。
|
||||
|
||||
## 状态与反馈
|
||||
|
||||
- `已通过` 使用成功绿。
|
||||
- `审核中` 使用信息蓝。
|
||||
- `已驳回` 使用危险红。
|
||||
- `待报备 / 未开通 / 未知` 使用中性灰。
|
||||
- 同一行展示多个运营商状态时,使用「移动:Tag 联通:Tag 电信:Tag」的键值形式,便于横向扫描。
|
||||
|
||||
## 导航
|
||||
|
||||
### 左侧菜单
|
||||
|
||||
- 左侧菜单采用白底:背景使用 `#FFFFFF`,右侧使用 `1px #E5E7EB` 分隔线,不使用深色整栏背景。
|
||||
- 菜单宽度默认使用全局变量 `--sidebar-width: 248px`;折叠态宽度为 `84px`。
|
||||
- 侧边栏固定在视口内:高度 `100vh`,页面主内容滚动时菜单不跟随滚动。
|
||||
- 侧边栏内边距建议为上下 24px、左右 16px;菜单项之间保持紧凑,分组间距约 20px。
|
||||
- 品牌区左上固定展示,图标使用浅蓝底和蓝色图形,品牌文字使用强文本色,不再使用大面积品牌黑底。
|
||||
|
||||
### 菜单分组
|
||||
|
||||
- 分组标题使用 12px、600、辅助浅灰色,字距为 0,不使用全大写或过强颜色。
|
||||
- 分组标题左右内边距与菜单项保持对齐,建议 `0 12px`。
|
||||
- 分组标题只承担结构提示,不作为强视觉元素。
|
||||
|
||||
### 菜单项
|
||||
|
||||
- 菜单项高度由 `padding: 10px 12px` 控制,图标和文字间距约 12px。
|
||||
- 菜单项圆角使用 8px 或 `var(--radius-md)`,不要使用过大的胶囊形。
|
||||
- 默认文字使用主文本色,字重 500。
|
||||
- 菜单文字单行展示,超长时省略号,不换行,不撑开侧栏。
|
||||
- 图标使用线性图标,大小约 18-20px,颜色跟随文字状态。
|
||||
|
||||
### 选中与悬停
|
||||
|
||||
- 当前菜单和 hover 菜单统一使用浅蓝背景 `var(--color-selected-soft)`,文字和图标使用选中蓝 `#2563EB`。
|
||||
- 当前菜单左侧增加 `3px` 蓝色内嵌竖条:`box-shadow: inset 3px 0 0 var(--color-selected)`。
|
||||
- 选中态不使用投影,不使用大面积深蓝底,避免菜单视觉过重。
|
||||
- hover 只改变背景和文字颜色,不改变尺寸,避免菜单跳动。
|
||||
|
||||
### 菜单滚动条
|
||||
|
||||
- 分组较多时,只让菜单列表区域滚动,不让整个侧边栏或页面标题区域滚动。
|
||||
- 菜单滚动条默认隐藏:`scrollbar-color: transparent transparent`,WebKit thumb 透明。
|
||||
- 鼠标进入侧边栏时再显示滚动条,thumb 使用低透明蓝色:约 `rgba(37, 99, 235, 0.18)`。
|
||||
- 鼠标悬停在滚动区域时可略微加深到 `rgba(37, 99, 235, 0.22)`;拖拽/hover thumb 时可到 `0.34`。
|
||||
- 滚动条宽度建议 6px,靠近侧栏右边界显示;通过负 `margin-right` 和补偿 `padding-right` 让滚动条贴边但不遮挡菜单文字。
|
||||
|
||||
### 复用要求
|
||||
|
||||
- 客户端、运营端、系统管理类页面都应复用同一套左侧菜单规范。
|
||||
- 新增一级模块时,优先加入现有分组;只有业务域明显不同才新增分组。
|
||||
- 后续若抽离布局组件,应保留现有类名语义:`.sidebar`、`.side-nav`、`.side-nav-section`、`.side-nav-list`。
|
||||
|
||||
## 组件约定
|
||||
|
||||
- 按钮:使用 `Button`。
|
||||
- 输入框:使用 `Input` / `Textarea`。
|
||||
- 下拉:使用 `Select`。
|
||||
- 日期:使用 `DateRangeInput` / `DateTimeInput`。
|
||||
- 表格:使用 `Table`。
|
||||
- 状态:使用 `Tag`。
|
||||
- 弹窗:使用 `Modal`。
|
||||
|
||||
新增页面或改版时,先检查是否可以复用以上组件;只有现有组件无法表达业务交互时,才新增全局组件。
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CMPP 短信平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1126
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "cmpp-platform-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"echarts": "^6.1.0",
|
||||
"lucide-react": "^1.18.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"vite": "^8.0.16",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useMemo } from 'react';
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
import { Button, Chart, Tag } from '@/components/ui';
|
||||
import { auditTrend, channelShare, customerGrowth, hourlySendTrend } from '@/mock/chartData';
|
||||
import { adminService } from '@/mock';
|
||||
import { createBarOption, createLineOption, createPieOption } from '@/theme/chartOptions';
|
||||
|
||||
export function AdminAnalyticsPage() {
|
||||
const overview = adminService.getOverview();
|
||||
const customers = adminService.getCustomers();
|
||||
|
||||
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 auditTrendOption = useMemo(
|
||||
() => createBarOption({
|
||||
labels: auditTrend.map((item) => item.day),
|
||||
series: [
|
||||
{ name: '通过', data: auditTrend.map((item) => item.approved) },
|
||||
{ name: '驳回', data: auditTrend.map((item) => item.rejected) },
|
||||
{ name: '待审', data: auditTrend.map((item) => item.pending) },
|
||||
],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const customerGrowthOption = useMemo(
|
||||
() => createLineOption({
|
||||
labels: customerGrowth.map((item) => item.month),
|
||||
series: [
|
||||
{ name: '活跃客户', data: customerGrowth.map((item) => item.active) },
|
||||
{ name: '新增客户', data: customerGrowth.map((item) => item.new) },
|
||||
],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const channelShareOption = useMemo(() => createPieOption({ data: channelShare }), []);
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<p className="eyebrow">运营</p>
|
||||
<h1>数据统计</h1>
|
||||
</div>
|
||||
<Button icon={<BarChart3 size={16} />} variant="ghost">导出报表</Button>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid">
|
||||
<div className="surface metric-card">
|
||||
<span>提交量</span>
|
||||
<strong>{overview.todaySubmissions}</strong>
|
||||
<small>今日审核提交</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>活跃客户</span>
|
||||
<strong>{customers.filter((item) => item.status === 'active').length}</strong>
|
||||
<small>当前平台客户</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>通道健康度</span>
|
||||
<strong>{overview.channelHealth}%</strong>
|
||||
<small>近 24 小时</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chart-grid">
|
||||
<div className="surface chart-card">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>发送趋势</h2>
|
||||
<p className="muted">按 3 小时聚合短信提交与成功量。</p>
|
||||
</div>
|
||||
<Tag tone="info">今日</Tag>
|
||||
</div>
|
||||
<Chart height={320} option={sendTrendOption} />
|
||||
</div>
|
||||
<div className="surface chart-card">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>通道占比</h2>
|
||||
<p className="muted">按提交量估算。</p>
|
||||
</div>
|
||||
<Tag tone="accent">通道</Tag>
|
||||
</div>
|
||||
<Chart height={320} option={channelShareOption} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chart-grid">
|
||||
<div className="surface chart-card">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>审核趋势</h2>
|
||||
<p className="muted">近 7 天审核处理情况。</p>
|
||||
</div>
|
||||
<Tag tone="warning">审核</Tag>
|
||||
</div>
|
||||
<Chart height={320} option={auditTrendOption} />
|
||||
</div>
|
||||
<div className="surface chart-card">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>客户增长</h2>
|
||||
<p className="muted">平台客户增长与活跃趋势。</p>
|
||||
</div>
|
||||
<Tag tone="success">客户</Tag>
|
||||
</div>
|
||||
<Chart height={320} option={customerGrowthOption} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminService, type Customer } from '@/mock';
|
||||
|
||||
const columns: Array<TableColumn<Customer>> = [
|
||||
{ key: 'id', title: '客户编号', render: (record) => record.id },
|
||||
{ key: 'name', title: '客户名称', render: (record) => record.name },
|
||||
{ key: 'balance', title: '短信余额', render: (record) => `${record.balance.toLocaleString('zh-CN')} 条` },
|
||||
{
|
||||
key: 'amount',
|
||||
title: '预估账户价值',
|
||||
render: (record) => `¥${Math.round(record.balance * 0.06).toLocaleString('zh-CN')}`,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: '账户状态',
|
||||
render: (record) => (
|
||||
<Tag tone={record.status === 'active' ? 'success' : 'danger'}>
|
||||
{record.status === 'active' ? '正常' : '已停用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function AdminBillingPage() {
|
||||
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={adminService.getCustomers()} rowKey="id" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Info, Plus, Trash2 } from 'lucide-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Button, Input, Modal, Select, Table, Tag } from '@/components/ui';
|
||||
import type { TableColumn } from '@/components/ui';
|
||||
|
||||
type Carrier = 'mobile' | 'unicom' | 'telecom';
|
||||
type ChannelStatus = 'normal' | 'stopped';
|
||||
type ProvinceRoute = {
|
||||
id: string;
|
||||
province: string;
|
||||
channel: string;
|
||||
status: ChannelStatus;
|
||||
};
|
||||
type NationalRoute = {
|
||||
id: string;
|
||||
priority: number;
|
||||
channel: string;
|
||||
status: ChannelStatus;
|
||||
};
|
||||
type RouteModalState = {
|
||||
type: 'province' | 'national';
|
||||
mode: 'create' | 'edit';
|
||||
route?: ProvinceRoute | NationalRoute;
|
||||
};
|
||||
|
||||
const provinceOptions = [
|
||||
{ label: '请选择', value: '' },
|
||||
{ label: '山东', value: '山东' },
|
||||
{ label: '河南', value: '河南' },
|
||||
{ label: '北京', value: '北京' },
|
||||
{ label: '上海', value: '上海' },
|
||||
{ label: '广东', value: '广东' },
|
||||
];
|
||||
|
||||
const channelOptions = [
|
||||
{ label: '请选择', value: '' },
|
||||
{ label: '行北-移动-山东有限公司-上海XXXXXXX-22j', value: '行北-移动-山东有限公司-上海XXXXXXX-22j' },
|
||||
{ label: '行北-移动-河南有限公司-上海XXXX-22j', value: '行北-移动-河南有限公司-上海XXXX-22j' },
|
||||
{ label: '三网行北-黄峰-三网-编号3.3', value: '三网行北-黄峰-三网-编号3.3' },
|
||||
{ label: '移动映华北-上海富煌C60289-移动2.7', value: '移动映华北-上海富煌C60289-移动2.7' },
|
||||
];
|
||||
|
||||
const priorityOptions = [
|
||||
{ label: '请选择', value: '' },
|
||||
{ label: '1', value: '1' },
|
||||
{ label: '2', value: '2' },
|
||||
{ label: '3', value: '3' },
|
||||
{ label: '4', value: '4' },
|
||||
{ label: '5', value: '5' },
|
||||
];
|
||||
|
||||
const carrierLabels: Record<Carrier, string> = {
|
||||
mobile: '移动',
|
||||
unicom: '联通',
|
||||
telecom: '电信',
|
||||
};
|
||||
|
||||
const statusLabels: Record<ChannelStatus, string> = {
|
||||
normal: '链接正常',
|
||||
stopped: '通道停用',
|
||||
};
|
||||
|
||||
const statusTones: Record<ChannelStatus, 'success' | 'neutral'> = {
|
||||
normal: 'success',
|
||||
stopped: 'neutral',
|
||||
};
|
||||
|
||||
const defaultProvinceRoutes: ProvinceRoute[] = [
|
||||
{ id: 'p-shandong', province: '山东', channel: '行北-移动-山东有限公司-上海XXXXXXX-22j', status: 'normal' },
|
||||
{ id: 'p-henan', province: '河南', channel: '行北-移动-河南有限公司-上海XXXX-22j', status: 'stopped' },
|
||||
];
|
||||
|
||||
const defaultNationalRoutes: NationalRoute[] = [
|
||||
{ id: 'n-1', priority: 1, channel: '三网行北-黄峰-三网-编号3.3', status: 'normal' },
|
||||
{ id: 'n-2', priority: 2, channel: '三网行北-黄峰(循环号用)-三网-编号3.4', status: 'normal' },
|
||||
{ id: 'n-3', priority: 3, channel: '移动映华北-上海富煌C60289-移动2.7', status: 'stopped' },
|
||||
];
|
||||
|
||||
function StatusTag({ status }: { status: ChannelStatus }) {
|
||||
return <Tag tone={statusTones[status]}>{statusLabels[status]}</Tag>;
|
||||
}
|
||||
|
||||
function RouteConfigModal({
|
||||
modal,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
modal: RouteModalState;
|
||||
onClose: () => void;
|
||||
onSubmit: (route: ProvinceRoute | NationalRoute) => void;
|
||||
}) {
|
||||
const provinceRoute = modal.type === 'province' ? modal.route as ProvinceRoute | undefined : undefined;
|
||||
const nationalRoute = modal.type === 'national' ? modal.route as NationalRoute | undefined : undefined;
|
||||
const [province, setProvince] = useState(provinceRoute?.province ?? '');
|
||||
const [priority, setPriority] = useState(nationalRoute ? String(nationalRoute.priority) : '');
|
||||
const [channel, setChannel] = useState(modal.route?.channel ?? '');
|
||||
|
||||
function submit() {
|
||||
if (modal.type === 'province') {
|
||||
onSubmit({
|
||||
id: provinceRoute?.id ?? `p-${Date.now()}`,
|
||||
province: province || '山东',
|
||||
channel: channel || channelOptions[1].value,
|
||||
status: provinceRoute?.status ?? 'normal',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit({
|
||||
id: nationalRoute?.id ?? `n-${Date.now()}`,
|
||||
priority: Number(priority || 1),
|
||||
channel: channel || channelOptions[1].value,
|
||||
status: nationalRoute?.status ?? 'normal',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={submit}>确认</Button>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
title={modal.mode === 'edit' ? '编辑通道' : '添加通道'}
|
||||
>
|
||||
<div className="channel-route-modal">
|
||||
{modal.type === 'province' ? (
|
||||
<Select label="* 选择省份" onChange={(event) => setProvince(event.target.value)} options={provinceOptions} value={province} />
|
||||
) : (
|
||||
<>
|
||||
<Select label="* 优先级" onChange={(event) => setPriority(event.target.value)} options={priorityOptions} value={priority} />
|
||||
<div className="channel-route-modal__note">
|
||||
<Info size={17} />
|
||||
<span>失败补发会跳往下一优先级的通道,不会在同优先级的通道去补发</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Select label="* 选择通道" onChange={(event) => setChannel(event.target.value)} options={channelOptions} value={channel} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminChannelGroupFormPage() {
|
||||
const navigate = useNavigate();
|
||||
const { groupId } = useParams();
|
||||
const editing = Boolean(groupId && groupId !== 'new');
|
||||
const [groupName, setGroupName] = useState(editing ? '学医三网专用群' : '');
|
||||
const [carrier, setCarrier] = useState<Carrier>('mobile');
|
||||
const [retryEnabled, setRetryEnabled] = useState(false);
|
||||
const [provinceRoutes, setProvinceRoutes] = useState(defaultProvinceRoutes);
|
||||
const [nationalRoutes, setNationalRoutes] = useState(defaultNationalRoutes);
|
||||
const [modal, setModal] = useState<RouteModalState | null>(null);
|
||||
|
||||
const provinceColumns = useMemo<Array<TableColumn<ProvinceRoute>>>(() => [
|
||||
{ key: 'province', title: '省份', width: '120px', render: (record) => <strong>{record.province}</strong> },
|
||||
{ key: 'channel', title: '通道', render: (record) => record.channel },
|
||||
{ key: 'status', title: '通道状态', width: '160px', render: (record) => <StatusTag status={record.status} /> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '180px',
|
||||
render: (record) => (
|
||||
<div className="channel-group-row-actions">
|
||||
<button onClick={() => setModal({ type: 'province', mode: 'edit', route: record })} type="button">编辑</button>
|
||||
<button className="is-danger" onClick={() => setProvinceRoutes((current) => current.filter((item) => item.id !== record.id))} type="button">删除</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], []);
|
||||
|
||||
const nationalColumns = useMemo<Array<TableColumn<NationalRoute>>>(() => [
|
||||
{ key: 'priority', title: '优先级', width: '120px', render: (record) => <strong>{record.priority}</strong> },
|
||||
{ key: 'channel', title: '通道', render: (record) => record.channel },
|
||||
{ key: 'status', title: '通道状态', width: '160px', render: (record) => <StatusTag status={record.status} /> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '180px',
|
||||
render: (record) => (
|
||||
<div className="channel-group-row-actions">
|
||||
<button onClick={() => setModal({ type: 'national', mode: 'edit', route: record })} type="button">编辑</button>
|
||||
<button className="is-danger" onClick={() => setNationalRoutes((current) => current.filter((item) => item.id !== record.id))} type="button">删除</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], []);
|
||||
|
||||
function saveRoute(route: ProvinceRoute | NationalRoute) {
|
||||
if (modal?.type === 'province') {
|
||||
const nextRoute = route as ProvinceRoute;
|
||||
setProvinceRoutes((current) => {
|
||||
const exists = current.some((item) => item.id === nextRoute.id);
|
||||
return exists ? current.map((item) => (item.id === nextRoute.id ? nextRoute : item)) : [...current, nextRoute];
|
||||
});
|
||||
} else {
|
||||
const nextRoute = route as NationalRoute;
|
||||
setNationalRoutes((current) => {
|
||||
const exists = current.some((item) => item.id === nextRoute.id);
|
||||
const next = exists ? current.map((item) => (item.id === nextRoute.id ? nextRoute : item)) : [...current, nextRoute];
|
||||
return [...next].sort((a, b) => a.priority - b.priority);
|
||||
});
|
||||
}
|
||||
setModal(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-stack channel-group-form-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<div className="eyebrow">通道管理 / 通道组 / {editing ? '编辑通道组' : '添加通道组'}</div>
|
||||
<h1>{editing ? '编辑短信通道组' : '添加短信通道组'}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="surface channel-group-form-section">
|
||||
<h2>基础设置</h2>
|
||||
<div className="channel-group-base-form">
|
||||
<Input label="* 通道组名称" onChange={(event) => setGroupName(event.target.value)} placeholder="请输入通道组名称" value={groupName} />
|
||||
<div className="channel-group-radio-row">
|
||||
<span>* 运营商</span>
|
||||
{(Object.keys(carrierLabels) as Carrier[]).map((item) => (
|
||||
<label key={item}>
|
||||
<input checked={carrier === item} onChange={() => setCarrier(item)} type="radio" />
|
||||
{carrierLabels[item]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="channel-group-switch-row">
|
||||
<span>* 失败补发</span>
|
||||
<button aria-pressed={retryEnabled} className={retryEnabled ? 'is-on' : ''} onClick={() => setRetryEnabled((current) => !current)} type="button">
|
||||
<i />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="surface channel-group-form-section">
|
||||
<h2>省网分流配置</h2>
|
||||
<Table columns={provinceColumns} data={provinceRoutes} rowKey="id" />
|
||||
<Button icon={<Plus size={16} />} onClick={() => setModal({ type: 'province', mode: 'create' })} variant="ghost">
|
||||
添加通道
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section className="surface channel-group-form-section">
|
||||
<h2>全国通道配置</h2>
|
||||
<Table columns={nationalColumns} data={nationalRoutes} rowKey="id" />
|
||||
<Button icon={<Plus size={16} />} onClick={() => setModal({ type: 'national', mode: 'create' })} variant="ghost">
|
||||
添加通道
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<div className="channel-group-form-footer">
|
||||
<Button onClick={() => navigate('/admin/channel-groups')}>确认</Button>
|
||||
<Button onClick={() => navigate('/admin/channel-groups')} variant="ghost">返回</Button>
|
||||
</div>
|
||||
|
||||
{modal ? <RouteConfigModal modal={modal} onClose={() => setModal(null)} onSubmit={saveRoute} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Edit3, Layers3, Plus, Search, Trash2, UsersRound } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button, Input, Pagination } from '@/components/ui';
|
||||
|
||||
type ChannelGroup = {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
channels: string[];
|
||||
};
|
||||
|
||||
const initialGroups: ChannelGroup[] = [
|
||||
{
|
||||
id: 'medical-a',
|
||||
name: '学医三网专用群',
|
||||
count: 16,
|
||||
channels: [
|
||||
'三网行北-黄峰-三网-编号3.3',
|
||||
'三网行北-黄峰(循环号用)-三网-编号3.4',
|
||||
'移动映华北-上海富煌C60289-移动2.7',
|
||||
'三网行北-北京富惠互联-三网-编号3.5',
|
||||
'移动行北-上海悉斯4pp0131-移动2.8',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'medical-b',
|
||||
name: '学医三网专用群',
|
||||
count: 5,
|
||||
channels: [
|
||||
'三网行北-黄峰-三网-编号3.3',
|
||||
'三网行北-黄峰(循环号用)-三网-编号3.4',
|
||||
'移动映华北-上海富煌C60289-移动2.7',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'group-a',
|
||||
name: 'XXXX通道组',
|
||||
count: 3,
|
||||
channels: [
|
||||
'三网行北-黄峰-三网-编号3.3',
|
||||
'三网行北-黄峰(循环号用)-三网-编号3.4',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'group-b',
|
||||
name: 'XXXX通道组',
|
||||
count: 1,
|
||||
channels: ['三网行北-黄峰-三网-编号3.3'],
|
||||
},
|
||||
{
|
||||
id: 'test-a',
|
||||
name: '测试通道组A',
|
||||
count: 8,
|
||||
channels: [
|
||||
'三网行北-黄峰-三网-编号3.3',
|
||||
'移动映华北-上海富煌C60289-移动2.7',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'test-b',
|
||||
name: '测试通道组B',
|
||||
count: 12,
|
||||
channels: ['三网行北-黄峰-三网-编号3.3'],
|
||||
},
|
||||
{
|
||||
id: 'test-c',
|
||||
name: '测试通道组C',
|
||||
count: 6,
|
||||
channels: [
|
||||
'三网行北-黄峰-三网-编号3.3',
|
||||
'三网行北-黄峰(循环号用)-三网-编号3.4',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'test-d',
|
||||
name: '测试通道组D',
|
||||
count: 2,
|
||||
channels: ['三网行北-黄峰-三网-编号3.3'],
|
||||
},
|
||||
];
|
||||
|
||||
export function AdminChannelGroupsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [groupName, setGroupName] = useState('');
|
||||
const [channelKeyword, setChannelKeyword] = useState('');
|
||||
const [groups, setGroups] = useState(initialGroups);
|
||||
|
||||
const filteredGroups = useMemo(() => groups.filter((group) => {
|
||||
const nameMatched = group.name.includes(groupName.trim());
|
||||
const channelMatched = group.channels.some((channel) => channel.includes(channelKeyword.trim()));
|
||||
return nameMatched && channelMatched;
|
||||
}), [channelKeyword, groupName, groups]);
|
||||
|
||||
function resetFilters() {
|
||||
setGroupName('');
|
||||
setChannelKeyword('');
|
||||
}
|
||||
|
||||
function removeGroup(id: string) {
|
||||
setGroups((current) => current.filter((group) => group.id !== id));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-stack sms-channel-group-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<div className="eyebrow">通道管理 / 短信通道组管理</div>
|
||||
<h1>短信通道组管理</h1>
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />} onClick={() => navigate('/admin/channel-groups/new')}>
|
||||
添加通道组
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section className="surface channel-group-filter">
|
||||
<Input label="通道组名称" onChange={(event) => setGroupName(event.target.value)} placeholder="请输入通道组名称" value={groupName} />
|
||||
<Input label="包含通道" onChange={(event) => setChannelKeyword(event.target.value)} placeholder="请输入包含的通道" value={channelKeyword} />
|
||||
<div className="channel-group-filter__actions">
|
||||
<Button icon={<Search size={16} />}>查询</Button>
|
||||
<Button onClick={resetFilters} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="surface channel-group-list">
|
||||
<div className="channel-group-grid">
|
||||
{filteredGroups.map((group) => (
|
||||
<article className="channel-group-card" key={group.id}>
|
||||
<header>
|
||||
<div>
|
||||
<Layers3 size={18} />
|
||||
<strong>{group.name}</strong>
|
||||
</div>
|
||||
<span title="包含通道数"><UsersRound size={16} />{group.count}</span>
|
||||
</header>
|
||||
<div className="channel-group-card__body">
|
||||
{group.channels.slice(0, 5).map((channel) => (
|
||||
<p key={channel}>{channel}</p>
|
||||
))}
|
||||
{group.count > group.channels.length ? <small>还有 {group.count - group.channels.length} 个通道...</small> : null}
|
||||
</div>
|
||||
<footer>
|
||||
<Button icon={<Edit3 size={16} />} onClick={() => navigate(`/admin/channel-groups/${group.id}/edit`)} size="sm" variant="ghost">
|
||||
编辑
|
||||
</Button>
|
||||
<Button icon={<Trash2 size={16} />} onClick={() => removeGroup(group.id)} size="sm" variant="danger">
|
||||
删除
|
||||
</Button>
|
||||
</footer>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<Pagination nextDisabled={false} page={1} total={filteredGroups.length} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Eye,
|
||||
FileSliders,
|
||||
GripVertical,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Settings2,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Button, DateRangeInput, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
import type { DateRangeValue } from '@/components/ui';
|
||||
|
||||
type ReportStatus = 'success' | 'failed' | 'reporting' | 'unreported' | 'withdrawn' | 'abandoned';
|
||||
|
||||
type DeliveryStats = {
|
||||
successRate: number;
|
||||
successCount: number;
|
||||
unknownRate: number;
|
||||
unknownCount: number;
|
||||
failureRate: number;
|
||||
failureCount: number;
|
||||
};
|
||||
|
||||
type DrainageReport = {
|
||||
id: string;
|
||||
value: string;
|
||||
status: ReportStatus;
|
||||
submittedAt: string;
|
||||
reportedAt?: string;
|
||||
lastSentAt?: string;
|
||||
stats: DeliveryStats;
|
||||
remark?: string;
|
||||
};
|
||||
|
||||
type SignatureReport = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: ReportStatus;
|
||||
submittedAt: string;
|
||||
reportedAt?: string;
|
||||
lastSentAt?: string;
|
||||
stats: DeliveryStats;
|
||||
drainage: DrainageReport[];
|
||||
details: SignatureDetails;
|
||||
remark?: string;
|
||||
};
|
||||
|
||||
type SignatureDetails = {
|
||||
basis: string;
|
||||
companyName: string;
|
||||
creditCode: string;
|
||||
legalName: string;
|
||||
legalId: string;
|
||||
contactName: string;
|
||||
contactPhone: string;
|
||||
contactId: string;
|
||||
};
|
||||
|
||||
type ReportDetail =
|
||||
| { kind: 'signature'; report: SignatureReport }
|
||||
| { kind: 'drainage'; title: string; status: ReportStatus; submittedAt: string; reportedAt?: string; lastSentAt?: string };
|
||||
|
||||
type ReportField = {
|
||||
id: string;
|
||||
label: string;
|
||||
type: '文本' | '图片' | '文件';
|
||||
};
|
||||
|
||||
type SelectedReportField = ReportField & {
|
||||
required: boolean;
|
||||
mapping: string;
|
||||
};
|
||||
|
||||
const channelNames: Record<string, string> = {
|
||||
'88827': '行北-集市三甲医院-39',
|
||||
'77': '移动-行北-上海甲医院-38',
|
||||
'78': '联通-行政-杭州甲医院-37',
|
||||
'67': '联通-行政-上海甲医院-34',
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '报备成功', value: 'success' },
|
||||
{ label: '报备失败', value: 'failed' },
|
||||
{ label: '报备中', value: 'reporting' },
|
||||
{ label: '未报备', value: 'unreported' },
|
||||
{ label: '被清退', value: 'withdrawn' },
|
||||
{ label: '放弃报备', value: 'abandoned' },
|
||||
];
|
||||
|
||||
const statusMeta: Record<ReportStatus, { label: string; tone: 'success' | 'danger' | 'warning' | 'neutral' }> = {
|
||||
success: { label: '报备成功', tone: 'success' },
|
||||
failed: { label: '报备失败', tone: 'danger' },
|
||||
reporting: { label: '报备中', tone: 'warning' },
|
||||
unreported: { label: '未报备', tone: 'neutral' },
|
||||
withdrawn: { label: '被清退', tone: 'danger' },
|
||||
abandoned: { label: '放弃报备', tone: 'warning' },
|
||||
};
|
||||
|
||||
const statusChoices: Array<{ value: ReportStatus; label: string; className: string }> = [
|
||||
{ value: 'unreported', label: '未报备', className: 'is-neutral' },
|
||||
{ value: 'reporting', label: '报备中', className: 'is-info' },
|
||||
{ value: 'success', label: '报备成功', className: 'is-success' },
|
||||
{ value: 'failed', label: '报备失败', className: 'is-danger' },
|
||||
{ value: 'withdrawn', label: '被清退', className: 'is-danger' },
|
||||
{ value: 'abandoned', label: '放弃报备', className: 'is-warning' },
|
||||
];
|
||||
|
||||
const drainageFieldPool: ReportField[] = [
|
||||
{ id: 'businessScope', label: '营业范围', type: '文本' },
|
||||
{ id: 'legalName', label: '法人姓名', type: '文本' },
|
||||
{ id: 'legalPhone', label: '法人手机号', type: '文本' },
|
||||
{ id: 'legalIdImage', label: '法人身份证图片', type: '图片' },
|
||||
{ id: 'managerIdImage', label: '经办人身份证图片', type: '图片' },
|
||||
{ id: 'managerPhone', label: '经办人手机号', type: '文本' },
|
||||
{ id: 'managerName', label: '经办人姓名', type: '文本' },
|
||||
{ id: 'creditCode', label: '统一社会信用代码', type: '文本' },
|
||||
{ id: 'licenseImage', label: '营业执照图片', type: '图片' },
|
||||
{ id: 'brandName', label: '品牌名称', type: '文本' },
|
||||
{ id: 'drainageInfo', label: '引流信息', type: '文本' },
|
||||
{ id: 'companyAddress', label: '公司地址', type: '文本' },
|
||||
{ id: 'authorization', label: '授权证明', type: '文件' },
|
||||
];
|
||||
|
||||
const initialSelectedDrainageFields: SelectedReportField[] = [
|
||||
{ id: 'companyName', label: '公司名称', type: '文本', required: false, mapping: '' },
|
||||
{ id: 'legalId', label: '法人身份证号', type: '文本', required: false, mapping: '' },
|
||||
];
|
||||
|
||||
const emptyStats: DeliveryStats = {
|
||||
successRate: 0,
|
||||
successCount: 0,
|
||||
unknownRate: 0,
|
||||
unknownCount: 0,
|
||||
failureRate: 0,
|
||||
failureCount: 0,
|
||||
};
|
||||
|
||||
const initialReports: SignatureReport[] = [
|
||||
{
|
||||
id: 'sig-1',
|
||||
name: '中华长城签名1',
|
||||
status: 'failed',
|
||||
submittedAt: '2025-12-28 18:08:08',
|
||||
reportedAt: '2025-12-29 12:03:01',
|
||||
lastSentAt: '2025-12-30 08:13:21',
|
||||
stats: { successRate: 88.2, successCount: 1130200, unknownRate: 29.1, unknownCount: 372940, failureRate: 8, failureCount: 102480 },
|
||||
details: { basis: '企业自用签名', companyName: '示例科技有限公司', creditCode: '91110000XXXXXXXXXX', legalName: '张三', legalId: '110101199001011234', contactName: '李四', contactPhone: '13800138000', contactId: '110101199002021234' },
|
||||
drainage: [
|
||||
{ id: 'flow-1', value: '400-123-4567', status: 'success', submittedAt: '2025-12-28 18:08:08', reportedAt: '2025-12-28 18:08:08', lastSentAt: '2025-12-28 18:08:08', stats: { ...emptyStats, failureRate: 100, failureCount: 50 } },
|
||||
{ id: 'flow-2', value: 'www.example.com', status: 'success', submittedAt: '2025-12-28 18:08:08', reportedAt: '2025-12-28 18:08:08', lastSentAt: '2025-12-28 18:08:08', stats: { ...emptyStats, failureRate: 100, failureCount: 54 } },
|
||||
{ id: 'flow-3', value: 'service@example.com', status: 'success', submittedAt: '2025-12-28 18:08:08', reportedAt: '2025-12-28 18:08:08', lastSentAt: '2025-12-28 18:08:08', stats: { ...emptyStats, failureRate: 100, failureCount: 50 } },
|
||||
{ id: 'flow-4', value: '18912345678', status: 'unreported', submittedAt: '2025-12-28 18:08:08', stats: emptyStats },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sig-2',
|
||||
name: '医长长长长长长长长长长',
|
||||
status: 'success',
|
||||
submittedAt: '2025-12-28 18:08:08',
|
||||
reportedAt: '2025-12-29 12:03:01',
|
||||
stats: emptyStats,
|
||||
details: { basis: '企业自用签名', companyName: '上海医长信息科技有限公司', creditCode: '91310000XXXXXXXXXX', legalName: '王强', legalId: '310101198805061234', contactName: '赵敏', contactPhone: '13900139000', contactId: '310101199006081234' },
|
||||
drainage: [
|
||||
{ id: 'flow-5', value: '18912345678', status: 'unreported', submittedAt: '2025-12-28 18:08:08', stats: emptyStats },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sig-3',
|
||||
name: '国信委科技服务',
|
||||
status: 'reporting',
|
||||
submittedAt: '2025-12-28 18:08:08',
|
||||
reportedAt: '2025-12-29 12:03:01',
|
||||
lastSentAt: '2025-12-30 08:13:21',
|
||||
stats: { successRate: 78.2, successCount: 8804, unknownRate: 1.2, unknownCount: 2046, failureRate: 5.8, failureCount: 1916 },
|
||||
details: { basis: '企事业单位全称或简称', companyName: '国信委科技服务有限公司', creditCode: '91110108XXXXXXXXXX', legalName: '陈杰', legalId: '110108198812121234', contactName: '周宁', contactPhone: '13700137000', contactId: '110108199103151234' },
|
||||
drainage: [],
|
||||
},
|
||||
];
|
||||
|
||||
function DateTime({ value }: { value?: string }) {
|
||||
if (!value) return <span className="muted">-</span>;
|
||||
const [date, time] = value.split(' ');
|
||||
return <span className="channel-report-date"><span>{date}</span><span>{time}</span></span>;
|
||||
}
|
||||
|
||||
function Stats({ stats }: { stats: DeliveryStats }) {
|
||||
return (
|
||||
<div className="channel-report-stats">
|
||||
<span>成功 <strong className="is-success">{stats.successRate}%</strong><b>{stats.successCount.toLocaleString('zh-CN')}</b></span>
|
||||
<span>未知 <strong className="is-warning">{stats.unknownRate}%</strong><b>{stats.unknownCount.toLocaleString('zh-CN')}</b></span>
|
||||
<span>失败 <strong className="is-danger">{stats.failureRate}%</strong><b>{stats.failureCount.toLocaleString('zh-CN')}</b></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowActions({ onDelete, onStatus, onView }: { onDelete: () => void; onStatus: () => void; onView: () => void }) {
|
||||
return (
|
||||
<div className="channel-report-actions">
|
||||
<button onClick={onView} type="button"><Eye size={16} />查看详情</button>
|
||||
<button className="is-warning" onClick={onStatus} type="button"><Pencil size={16} />更改状态</button>
|
||||
<button className="is-danger" onClick={onDelete} type="button"><Trash2 size={16} />删除</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadonlyUpload({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="channel-signature-upload">
|
||||
<span>{label}</span>
|
||||
<div>已上传</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SignatureDetailModal({ report, onClose }: { report: SignatureReport; onClose: () => void }) {
|
||||
const details = report.details;
|
||||
return (
|
||||
<Modal
|
||||
footer={<Button onClick={onClose}>关闭</Button>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="channel-signature-title"><h2>查看签名详情</h2><p>查看短信签名的详细信息</p></div>}
|
||||
>
|
||||
<div className="channel-signature-detail">
|
||||
<section>
|
||||
<h3>基本信息</h3>
|
||||
<div className="channel-signature-grid">
|
||||
<Select disabled label="签名依据" options={[{ label: details.basis, value: details.basis }]} value={details.basis} />
|
||||
<Input label="短信签名" readOnly value={`【${report.name}】`} />
|
||||
<ReadonlyUpload label="资质凭证" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>公司信息</h3>
|
||||
<div className="channel-signature-grid">
|
||||
<Input label="公司名称" readOnly value={details.companyName} />
|
||||
<Input label="统一社会信用代码" readOnly value={details.creditCode} />
|
||||
<Input label="法人姓名" readOnly value={details.legalName} />
|
||||
<Input label="法人身份证号" readOnly value={details.legalId} />
|
||||
<ReadonlyUpload label="法人身份证照片-人像面" />
|
||||
<ReadonlyUpload label="法人身份证照片-国徽面" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>责任人信息</h3>
|
||||
<div className="channel-signature-grid">
|
||||
<Input label="责任人姓名" readOnly value={details.contactName} />
|
||||
<Input label="责任人手机号" readOnly value={details.contactPhone} />
|
||||
<Input className="channel-signature-grid__wide" label="责任人身份证号" readOnly value={details.contactId} />
|
||||
<ReadonlyUpload label="责任人身份证照片-人像面" />
|
||||
<ReadonlyUpload label="责任人身份证照片-国徽面" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrainageFieldConfigModal({
|
||||
fields,
|
||||
onChange,
|
||||
onClose,
|
||||
}: {
|
||||
fields: SelectedReportField[];
|
||||
onChange: (fields: SelectedReportField[]) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(fields);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const availableFields = useMemo(() => drainageFieldPool.filter((field) => (
|
||||
!draft.some((item) => item.id === field.id)
|
||||
&& (!searchText || field.label.includes(searchText))
|
||||
)), [draft, searchText]);
|
||||
|
||||
function addField(field: ReportField) {
|
||||
setDraft((items) => [...items, { ...field, required: false, mapping: '' }]);
|
||||
}
|
||||
|
||||
function updateField(id: string, patch: Partial<SelectedReportField>) {
|
||||
setDraft((items) => items.map((item) => item.id === id ? { ...item, ...patch } : item));
|
||||
}
|
||||
|
||||
function moveField(index: number, offset: number) {
|
||||
setDraft((items) => {
|
||||
const target = index + offset;
|
||||
if (target < 0 || target >= items.length) return items;
|
||||
const next = [...items];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button onClick={() => { onChange(draft); onClose(); }}>确认保存</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="channel-field-config-title"><h2>配置报备字段</h2><p>从字段池中选择字段,并配置是否必填</p></div>}
|
||||
>
|
||||
<div className="channel-field-config">
|
||||
<section className="channel-field-pool">
|
||||
<div className="channel-field-section-head">
|
||||
<h3>字段池</h3>
|
||||
<Tag tone="neutral">{availableFields.length} 个可选</Tag>
|
||||
</div>
|
||||
<Input onChange={(event) => setSearchText(event.target.value)} placeholder="搜索字段..." prefix={<Search size={16} />} value={searchText} />
|
||||
<div className="channel-field-pool-list">
|
||||
{availableFields.map((field) => (
|
||||
<button key={field.id} onClick={() => addField(field)} type="button">
|
||||
<span><strong>{field.label}</strong><Tag tone="neutral">{field.type}</Tag></span>
|
||||
<span>添加 <Plus size={15} /></span>
|
||||
</button>
|
||||
))}
|
||||
{availableFields.length === 0 ? <p>没有可添加的字段</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="channel-selected-fields">
|
||||
<div className="channel-field-section-head">
|
||||
<div><h3>已选择字段</h3><p>可调整顺序和设置必填项</p></div>
|
||||
<Tag tone="info">{draft.length} 个</Tag>
|
||||
</div>
|
||||
<div className="channel-selected-field-list">
|
||||
{draft.map((field, index) => (
|
||||
<article key={field.id}>
|
||||
<div className="channel-selected-field-head">
|
||||
<span className="channel-selected-field-index">{index + 1}</span>
|
||||
<GripVertical size={17} />
|
||||
<strong>{field.label}</strong>
|
||||
<Tag tone="neutral">{field.type}</Tag>
|
||||
<div className="channel-selected-field-order">
|
||||
<button disabled={index === 0} onClick={() => moveField(index, -1)} type="button"><ChevronUp size={16} /><span className="sr-only">上移</span></button>
|
||||
<button disabled={index === draft.length - 1} onClick={() => moveField(index, 1)} type="button"><ChevronDown size={16} /><span className="sr-only">下移</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="channel-selected-field-controls">
|
||||
<label><input checked={field.required} onChange={() => updateField(field.id, { required: true })} type="radio" />必填</label>
|
||||
<label><input checked={!field.required} onChange={() => updateField(field.id, { required: false })} type="radio" />非必填</label>
|
||||
<button aria-label={`删除${field.label}`} onClick={() => setDraft((items) => items.filter((item) => item.id !== field.id))} type="button"><Trash2 size={17} /></button>
|
||||
</div>
|
||||
<Input label="映射通道字段" onChange={(event) => updateField(field.id, { mapping: event.target.value })} placeholder="请输入映射字段名" value={field.mapping} />
|
||||
</article>
|
||||
))}
|
||||
{draft.length === 0 ? <div className="channel-report-empty">请从左侧添加报备字段</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminChannelReportPage() {
|
||||
const navigate = useNavigate();
|
||||
const { channelId = '88827' } = useParams();
|
||||
const [reports, setReports] = useState(initialReports);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(['sig-1']));
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
const [statusTarget, setStatusTarget] = useState<{ signatureId: string; drainageId?: string } | null>(null);
|
||||
const [nextStatus, setNextStatus] = useState<ReportStatus>('success');
|
||||
const [nextRemark, setNextRemark] = useState('');
|
||||
const [detail, setDetail] = useState<ReportDetail | null>(null);
|
||||
const [fieldConfigOpen, setFieldConfigOpen] = useState(false);
|
||||
const [drainageFields, setDrainageFields] = useState(initialSelectedDrainageFields);
|
||||
|
||||
const filteredReports = useMemo(() => reports.filter((report) => {
|
||||
const matchesKeyword = !keyword || report.name.includes(keyword) || report.drainage.some((item) => item.value.includes(keyword));
|
||||
const matchesStatus = status === 'all' || report.status === status;
|
||||
const date = report.submittedAt.slice(0, 10);
|
||||
const matchesStart = !dateRange.start || date >= dateRange.start;
|
||||
const matchesEnd = !dateRange.end || date <= dateRange.end;
|
||||
return matchesKeyword && matchesStatus && matchesStart && matchesEnd;
|
||||
}), [dateRange.end, dateRange.start, keyword, reports, status]);
|
||||
|
||||
function toggleExpanded(id: string) {
|
||||
setExpanded((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSelected(id: string) {
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function removeItem(signatureId: string, drainageId?: string) {
|
||||
setReports((items) => drainageId
|
||||
? items.map((item) => item.id === signatureId ? { ...item, drainage: item.drainage.filter((flow) => flow.id !== drainageId) } : item)
|
||||
: items.filter((item) => item.id !== signatureId));
|
||||
}
|
||||
|
||||
function applyStatus() {
|
||||
if (!statusTarget) return;
|
||||
setReports((items) => items.map((item) => {
|
||||
if (item.id !== statusTarget.signatureId) return item;
|
||||
if (!statusTarget.drainageId) return { ...item, status: nextStatus, remark: nextRemark };
|
||||
return { ...item, drainage: item.drainage.map((flow) => flow.id === statusTarget.drainageId ? { ...flow, status: nextStatus, remark: nextRemark } : flow) };
|
||||
}));
|
||||
setStatusTarget(null);
|
||||
setNextRemark('');
|
||||
}
|
||||
|
||||
function openStatus(signatureId: string, currentStatus: ReportStatus, drainageId?: string, remark = '') {
|
||||
setNextStatus(currentStatus);
|
||||
setNextRemark(remark);
|
||||
setStatusTarget({ signatureId, drainageId });
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack channel-report-page">
|
||||
<div className="surface channel-report-hero">
|
||||
<div className="breadcrumb-line"><span>短信通道管理</span><span>/</span><strong>通道报备详情</strong></div>
|
||||
<div className="channel-report-heading">
|
||||
<Button icon={<ChevronLeft size={16} />} onClick={() => navigate('/admin/channels')} variant="ghost">返回列表</Button>
|
||||
<h1>{channelNames[channelId] ?? `短信通道 ${channelId}`}</h1>
|
||||
<Button icon={<ChevronRight size={16} />} variant="ghost">下一个</Button>
|
||||
<div className="channel-report-config-actions">
|
||||
<Button icon={<Settings2 size={16} />} onClick={() => setFieldConfigOpen(true)} variant="ghost">个性化引流信息报备字段</Button>
|
||||
<Button icon={<FileSliders size={16} />} variant="ghost">个性化签名报备字段</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface channel-report-filter">
|
||||
<div className="channel-report-filter-grid">
|
||||
<Input label="签名或引流信息" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入签名、网址、电话或邮箱" value={keyword} />
|
||||
<Select label="报备状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
|
||||
<DateRangeInput label="提交报备时间" onChange={setDateRange} value={dateRange} />
|
||||
</div>
|
||||
<div className="channel-report-filter-footer">
|
||||
<div>
|
||||
<Button disabled={selectedIds.size === 0} icon={<Pencil size={16} />} onClick={() => setSelectedIds(new Set())} variant="ghost">批量更改状态</Button>
|
||||
<Button disabled={selectedIds.size === 0} icon={<Trash2 size={16} />} onClick={() => { setReports((items) => items.filter((item) => !selectedIds.has(item.id))); setSelectedIds(new Set()); }} variant="danger">删除</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button onClick={() => { setKeyword(''); setStatus('all'); setDateRange({}); }} variant="ghost">重置</Button>
|
||||
<Button icon={<Search size={16} />}>查询</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface channel-report-table">
|
||||
<div className="channel-report-table__head">
|
||||
<span />
|
||||
<span>签名与引流信息</span>
|
||||
<span>报备状态</span>
|
||||
<span>提交报备时间</span>
|
||||
<span>报备成功时间</span>
|
||||
<span>上次发送成功时间</span>
|
||||
<span>今日发送</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{filteredReports.map((report) => {
|
||||
const isExpanded = expanded.has(report.id);
|
||||
return (
|
||||
<div className="channel-report-group" key={report.id}>
|
||||
<div className="channel-report-row channel-report-row--signature">
|
||||
<input aria-label={`选择${report.name}`} checked={selectedIds.has(report.id)} onChange={() => toggleSelected(report.id)} type="checkbox" />
|
||||
<div className="channel-report-name">
|
||||
<button aria-label={isExpanded ? '收起引流信息' : '展开引流信息'} disabled={report.drainage.length === 0} onClick={() => toggleExpanded(report.id)} type="button">
|
||||
{isExpanded ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||
</button>
|
||||
<span><strong>【{report.name}】</strong><small>引流 <b>{report.drainage.length}</b></small></span>
|
||||
</div>
|
||||
<Tag tone={statusMeta[report.status].tone}>{statusMeta[report.status].label}</Tag>
|
||||
<DateTime value={report.submittedAt} />
|
||||
<DateTime value={report.reportedAt} />
|
||||
<DateTime value={report.lastSentAt} />
|
||||
<Stats stats={report.stats} />
|
||||
<RowActions onDelete={() => removeItem(report.id)} onStatus={() => openStatus(report.id, report.status, undefined, report.remark)} onView={() => setDetail({ kind: 'signature', report })} />
|
||||
</div>
|
||||
{isExpanded ? report.drainage.map((flow) => (
|
||||
<div className="channel-report-row channel-report-row--drainage" key={flow.id}>
|
||||
<input aria-label={`选择${flow.value}`} type="checkbox" />
|
||||
<div className="channel-report-name channel-report-name--flow"><i /> <strong>{flow.value}</strong></div>
|
||||
<Tag tone={statusMeta[flow.status].tone}>{statusMeta[flow.status].label}</Tag>
|
||||
<DateTime value={flow.submittedAt} />
|
||||
<DateTime value={flow.reportedAt} />
|
||||
<DateTime value={flow.lastSentAt} />
|
||||
<Stats stats={flow.stats} />
|
||||
<RowActions onDelete={() => removeItem(report.id, flow.id)} onStatus={() => openStatus(report.id, flow.status, flow.id, flow.remark)} onView={() => setDetail({ kind: 'drainage', title: flow.value, ...flow })} />
|
||||
</div>
|
||||
)) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{filteredReports.length === 0 ? <div className="channel-report-empty">暂无符合条件的报备记录</div> : null}
|
||||
</div>
|
||||
|
||||
{statusTarget ? (
|
||||
<Modal
|
||||
footer={<><Button onClick={() => setStatusTarget(null)} variant="ghost">取消</Button><Button onClick={applyStatus}>确认</Button></>}
|
||||
onClose={() => setStatusTarget(null)}
|
||||
open
|
||||
size="md"
|
||||
title={<div className="channel-status-title"><h2>更改报备状态</h2><p>选择新的报备状态并添加备注。</p></div>}
|
||||
>
|
||||
<div className="channel-status-form">
|
||||
<div className="channel-status-options">
|
||||
{statusChoices.map((choice) => (
|
||||
<button
|
||||
aria-pressed={nextStatus === choice.value}
|
||||
className={`${choice.className} ${nextStatus === choice.value ? 'is-selected' : ''}`}
|
||||
key={choice.value}
|
||||
onClick={() => setNextStatus(choice.value)}
|
||||
type="button"
|
||||
>
|
||||
{choice.label}
|
||||
{nextStatus === choice.value ? <span>✓</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Textarea label="备注" onChange={(event) => setNextRemark(event.target.value)} placeholder="备注内容" rows={5} value={nextRemark} />
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
{detail?.kind === 'signature' ? <SignatureDetailModal onClose={() => setDetail(null)} report={detail.report} /> : null}
|
||||
|
||||
{fieldConfigOpen ? (
|
||||
<DrainageFieldConfigModal
|
||||
fields={drainageFields}
|
||||
onChange={setDrainageFields}
|
||||
onClose={() => setFieldConfigOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{detail?.kind === 'drainage' ? (
|
||||
<Modal footer={<Button onClick={() => setDetail(null)} variant="ghost">关闭</Button>} onClose={() => setDetail(null)} open size="md" title="引流信息报备详情">
|
||||
<div className="channel-report-detail">
|
||||
<strong>{detail.title}</strong>
|
||||
<p><span>报备状态</span><Tag tone={statusMeta[detail.status].tone}>{statusMeta[detail.status].label}</Tag></p>
|
||||
<p><span>提交报备时间</span>{detail.submittedAt}</p>
|
||||
<p><span>报备成功时间</span>{detail.reportedAt ?? '-'}</p>
|
||||
<p><span>上次发送成功时间</span>{detail.lastSentAt ?? '-'}</p>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Eye, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
|
||||
type Carrier = 'mobile' | 'unicom' | 'telecom';
|
||||
type ChannelStatus = 'normal' | 'stopped' | 'connecting' | 'failed';
|
||||
|
||||
type SmsChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
carrier: Carrier;
|
||||
unitPrice: number;
|
||||
status: ChannelStatus;
|
||||
total: number;
|
||||
successRate: number;
|
||||
successCount: number;
|
||||
unknownRate: number;
|
||||
unknownCount: number;
|
||||
failureRate: number;
|
||||
failureCount: number;
|
||||
gatewayHost: string;
|
||||
gatewayPort: string;
|
||||
corpCode: string;
|
||||
account: string;
|
||||
accessNo: string;
|
||||
};
|
||||
|
||||
type ChannelModalState = {
|
||||
mode: 'create' | 'edit';
|
||||
channel?: SmsChannel;
|
||||
};
|
||||
|
||||
const carrierOptions = [
|
||||
{ label: '全部运营商', value: 'all' },
|
||||
{ label: '移动', value: 'mobile' },
|
||||
{ label: '联通', value: 'unicom' },
|
||||
{ label: '电信', value: 'telecom' },
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '链接正常', value: 'normal' },
|
||||
{ label: '已停用', value: 'stopped' },
|
||||
{ label: '链接中', value: 'connecting' },
|
||||
{ label: '链接失败', value: 'failed' },
|
||||
];
|
||||
|
||||
const protocolOptions = [
|
||||
{ label: 'CMPP', value: 'CMPP' },
|
||||
{ label: 'HTTP', value: 'HTTP' },
|
||||
{ label: 'SGIP', value: 'SGIP' },
|
||||
];
|
||||
|
||||
const regionOptions = [
|
||||
{ label: '全国', value: '全国' },
|
||||
{ label: '华东', value: '华东' },
|
||||
{ label: '华南', value: '华南' },
|
||||
{ label: '华北', value: '华北' },
|
||||
];
|
||||
|
||||
const extensionOptions = [
|
||||
{ label: '0', value: '0' },
|
||||
{ label: '2', value: '2' },
|
||||
{ label: '4', value: '4' },
|
||||
{ label: '6', value: '6' },
|
||||
];
|
||||
|
||||
const carrierLabelMap: Record<Carrier, string> = {
|
||||
mobile: '移动',
|
||||
unicom: '联通',
|
||||
telecom: '电信',
|
||||
};
|
||||
|
||||
const carrierToneMap: Record<Carrier, 'info' | 'danger' | 'success'> = {
|
||||
mobile: 'info',
|
||||
unicom: 'danger',
|
||||
telecom: 'success',
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<ChannelStatus, string> = {
|
||||
normal: '链接正常',
|
||||
stopped: '已停用',
|
||||
connecting: '链接中',
|
||||
failed: '链接失败',
|
||||
};
|
||||
|
||||
const statusToneMap: Record<ChannelStatus, 'success' | 'neutral' | 'info' | 'danger'> = {
|
||||
normal: 'success',
|
||||
stopped: 'neutral',
|
||||
connecting: 'info',
|
||||
failed: 'danger',
|
||||
};
|
||||
|
||||
const initialChannels: SmsChannel[] = [
|
||||
{
|
||||
id: '88827',
|
||||
name: '行北-集市三甲医院-39',
|
||||
carrier: 'mobile',
|
||||
unitPrice: 3.9,
|
||||
status: 'normal',
|
||||
total: 1587451,
|
||||
successRate: 88.2,
|
||||
successCount: 1281035,
|
||||
unknownRate: 29.1,
|
||||
unknownCount: 31648,
|
||||
failureRate: 8,
|
||||
failureCount: 189,
|
||||
gatewayHost: '10.10.39.8',
|
||||
gatewayPort: '7890',
|
||||
corpCode: 'CM88827',
|
||||
account: 'acct88827',
|
||||
accessNo: '10690088',
|
||||
},
|
||||
{
|
||||
id: '77',
|
||||
name: '移动-行北-上海甲医院-38',
|
||||
carrier: 'mobile',
|
||||
unitPrice: 3.8,
|
||||
status: 'stopped',
|
||||
total: 12867,
|
||||
successRate: 68.2,
|
||||
successCount: 9982,
|
||||
unknownRate: 20.5,
|
||||
unknownCount: 2671,
|
||||
failureRate: 16.2,
|
||||
failureCount: 189,
|
||||
gatewayHost: '10.10.38.8',
|
||||
gatewayPort: '7890',
|
||||
corpCode: 'CM00077',
|
||||
account: 'acct00077',
|
||||
accessNo: '10690077',
|
||||
},
|
||||
{
|
||||
id: '78',
|
||||
name: '联通-行政-杭州甲医院-37',
|
||||
carrier: 'unicom',
|
||||
unitPrice: 3.7,
|
||||
status: 'connecting',
|
||||
total: 8123,
|
||||
successRate: 78.2,
|
||||
successCount: 0,
|
||||
unknownRate: 1.2,
|
||||
unknownCount: 12,
|
||||
failureRate: 5.8,
|
||||
failureCount: 0,
|
||||
gatewayHost: '10.10.37.8',
|
||||
gatewayPort: '7890',
|
||||
corpCode: 'CU00078',
|
||||
account: 'acct00078',
|
||||
accessNo: '10690078',
|
||||
},
|
||||
{
|
||||
id: '67',
|
||||
name: '联通-行政-上海甲医院-34',
|
||||
carrier: 'telecom',
|
||||
unitPrice: 16.2,
|
||||
status: 'failed',
|
||||
total: 154,
|
||||
successRate: 0,
|
||||
successCount: 0,
|
||||
unknownRate: 0,
|
||||
unknownCount: 0,
|
||||
failureRate: 100,
|
||||
failureCount: 154,
|
||||
gatewayHost: '10.10.34.8',
|
||||
gatewayPort: '7890',
|
||||
corpCode: 'CT00067',
|
||||
account: 'acct00067',
|
||||
accessNo: '10690067',
|
||||
},
|
||||
];
|
||||
|
||||
function RateBlock({ label, rate, count, tone = 'neutral' }: { label: string; rate: number; count: number; tone?: 'success' | 'warning' | 'danger' | 'neutral' }) {
|
||||
return (
|
||||
<div className={`sms-channel-rate sms-channel-rate--${tone}`}>
|
||||
<small>{label}</small>
|
||||
<strong>{rate}%</strong>
|
||||
<span>{count.toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelFormModal({
|
||||
modal,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
modal: ChannelModalState;
|
||||
onClose: () => void;
|
||||
onSubmit: (channel: SmsChannel) => void;
|
||||
}) {
|
||||
const channel = modal.channel;
|
||||
const [name, setName] = useState(channel?.name ?? '');
|
||||
const [carrier, setCarrier] = useState<Carrier>(channel?.carrier ?? 'mobile');
|
||||
const [unitPrice, setUnitPrice] = useState(channel ? String(channel.unitPrice / 100) : '0.0300');
|
||||
const [region, setRegion] = useState('全国');
|
||||
const [protocol, setProtocol] = useState('CMPP');
|
||||
const [gatewayHost, setGatewayHost] = useState(channel?.gatewayHost ?? '');
|
||||
const [gatewayPort, setGatewayPort] = useState(channel?.gatewayPort ?? '7890');
|
||||
const [corpCode, setCorpCode] = useState(channel?.corpCode ?? '');
|
||||
const [account, setAccount] = useState(channel?.account ?? '');
|
||||
const [password, setPassword] = useState('');
|
||||
const [accessNo, setAccessNo] = useState(channel?.accessNo ?? '');
|
||||
const [extensionDigits, setExtensionDigits] = useState('0');
|
||||
const [flowLimit, setFlowLimit] = useState('1-2000');
|
||||
|
||||
function submit() {
|
||||
onSubmit({
|
||||
id: channel?.id ?? String(Math.floor(10000 + Math.random() * 80000)),
|
||||
name: name || '新建短信通道',
|
||||
carrier,
|
||||
unitPrice: Number(unitPrice || 0) * 100,
|
||||
status: channel?.status ?? 'connecting',
|
||||
total: channel?.total ?? 0,
|
||||
successRate: channel?.successRate ?? 0,
|
||||
successCount: channel?.successCount ?? 0,
|
||||
unknownRate: channel?.unknownRate ?? 0,
|
||||
unknownCount: channel?.unknownCount ?? 0,
|
||||
failureRate: channel?.failureRate ?? 0,
|
||||
failureCount: channel?.failureCount ?? 0,
|
||||
gatewayHost,
|
||||
gatewayPort,
|
||||
corpCode,
|
||||
account,
|
||||
accessNo,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button onClick={submit}>确认</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>{modal.mode === 'edit' ? '编辑通道' : '创建通道'}</h2></div>}
|
||||
>
|
||||
<div className="sms-channel-form">
|
||||
<section>
|
||||
<h3>业务信息</h3>
|
||||
<div className="sms-channel-form-grid">
|
||||
<Input label="* 通道名称" onChange={(event) => setName(event.target.value)} placeholder="请输入通道名称" value={name} />
|
||||
<div className="sms-channel-radio-row">
|
||||
<span>* 运营商</span>
|
||||
{(['mobile', 'unicom', 'telecom'] as const).map((item) => (
|
||||
<label key={item}>
|
||||
<input checked={carrier === item} onChange={() => setCarrier(item)} type="radio" />
|
||||
{carrierLabelMap[item]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<Input label="* 单价(元)" onChange={(event) => setUnitPrice(event.target.value)} value={unitPrice} />
|
||||
<Select label="* 发送地区" onChange={(event) => setRegion(event.target.value)} options={regionOptions} value={region} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>参数配置</h3>
|
||||
<div className="sms-channel-form-grid">
|
||||
<Select label="* 协议选择" onChange={(event) => setProtocol(event.target.value)} options={protocolOptions} value={protocol} />
|
||||
<div className="sms-channel-inline-field">
|
||||
<Input label="* 网关地址" onChange={(event) => setGatewayHost(event.target.value)} placeholder="请输入网关地址" value={gatewayHost} />
|
||||
<Input label="端口" onChange={(event) => setGatewayPort(event.target.value)} value={gatewayPort} />
|
||||
</div>
|
||||
<Input label="* 企业代码" onChange={(event) => setCorpCode(event.target.value)} placeholder="请输入企业代码" value={corpCode} />
|
||||
<Input label="* 网关账号" onChange={(event) => setAccount(event.target.value)} placeholder="请输入网关账号" value={account} />
|
||||
<Input label="* 网关密码" onChange={(event) => setPassword(event.target.value)} placeholder="请输入网关密码" type="password" value={password} />
|
||||
<div className="sms-channel-inline-field">
|
||||
<Input label="* 接入号" onChange={(event) => setAccessNo(event.target.value)} placeholder="请输入通道接入号" value={accessNo} />
|
||||
<Select label="拓展位数" onChange={(event) => setExtensionDigits(event.target.value)} options={extensionOptions} value={extensionDigits} />
|
||||
</div>
|
||||
<Input label="* 通道流速" onChange={(event) => setFlowLimit(event.target.value)} suffix="条/秒" value={flowLimit} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function SmsTestModal({
|
||||
channel,
|
||||
onClose,
|
||||
}: {
|
||||
channel: SmsChannel;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [phones, setPhones] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [accessNo, setAccessNo] = useState('');
|
||||
const billingCount = Math.max(1, Math.ceil(content.length / 67));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button icon={<Send size={16} />} onClick={onClose}>发送测试</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={(
|
||||
<div className="sms-test-title">
|
||||
<span><Send size={30} /></span>
|
||||
<div>
|
||||
<h2>短信测试</h2>
|
||||
<p>向指定手机号发送测试短信</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="sms-test-modal">
|
||||
<div className="sms-test-channel">
|
||||
<span>测试通道</span>
|
||||
<strong>{channel.name}</strong>
|
||||
</div>
|
||||
<Textarea
|
||||
label="* 手机号码"
|
||||
onChange={(event) => setPhones(event.target.value)}
|
||||
placeholder="请输入手机号码,多个号码用逗号(,)隔开,最多允许10个号码"
|
||||
rows={3}
|
||||
value={phones}
|
||||
/>
|
||||
<p className="muted">支持多个号码,用中英文逗号分隔,最多10个</p>
|
||||
<Textarea
|
||||
label="* 短信内容"
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
placeholder="请输入短信内容"
|
||||
rows={4}
|
||||
value={content}
|
||||
/>
|
||||
<div className="sms-test-counter">
|
||||
<span>按67字/条计费</span>
|
||||
<strong>{content.length} 字符 <i /> 计费 {billingCount} 条</strong>
|
||||
</div>
|
||||
<Input
|
||||
label="接入号(选填)"
|
||||
onChange={(event) => setAccessNo(event.target.value)}
|
||||
placeholder="请输入接入号"
|
||||
value={accessNo}
|
||||
/>
|
||||
<div className="signature-alert sms-test-note">
|
||||
<Info size={18} />
|
||||
<span>如需测试接入号,可在通道接入号后上追加接入号进行测试</span>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminChannelsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [channels, setChannels] = useState(initialChannels);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [carrier, setCarrier] = useState('all');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [modal, setModal] = useState<ChannelModalState | null>(null);
|
||||
const [testChannel, setTestChannel] = useState<SmsChannel | null>(null);
|
||||
|
||||
const filteredChannels = useMemo(
|
||||
() => channels.filter((channel) => {
|
||||
const matchesKeyword = !keyword || channel.name.includes(keyword);
|
||||
const matchesCarrier = carrier === 'all' || channel.carrier === carrier;
|
||||
const matchesStatus = status === 'all' || channel.status === status;
|
||||
return matchesKeyword && matchesCarrier && matchesStatus;
|
||||
}),
|
||||
[carrier, channels, keyword, status],
|
||||
);
|
||||
|
||||
function upsertChannel(nextChannel: SmsChannel) {
|
||||
setChannels((items) => {
|
||||
const exists = items.some((item) => item.id === nextChannel.id);
|
||||
return exists ? items.map((item) => (item.id === nextChannel.id ? nextChannel : item)) : [nextChannel, ...items];
|
||||
});
|
||||
setModal(null);
|
||||
}
|
||||
|
||||
function toggleChannel(id: string) {
|
||||
setChannels((items) => items.map((item) => (
|
||||
item.id === id ? { ...item, status: item.status === 'stopped' ? 'connecting' : 'stopped' } : item
|
||||
)));
|
||||
}
|
||||
|
||||
function deleteChannel(id: string) {
|
||||
setChannels((items) => items.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack sms-channel-page">
|
||||
<div className="page-heading">
|
||||
<div className="breadcrumb-line"><strong>短信通道管理</strong></div>
|
||||
<Button icon={<Plus size={16} />} onClick={() => setModal({ mode: 'create' })}>添加通道</Button>
|
||||
</div>
|
||||
|
||||
<div className="surface sms-channel-filter">
|
||||
<div className="sms-channel-filter-grid">
|
||||
<Input label="通道名称" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入通道名称" value={keyword} />
|
||||
<Select label="运营商" onChange={(event) => setCarrier(event.target.value)} options={carrierOptions} value={carrier} />
|
||||
<Select label="当前状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
|
||||
<div className="audit-filter-actions">
|
||||
<Button icon={<Search size={16} />}>查询</Button>
|
||||
<Button onClick={() => { setKeyword(''); setCarrier('all'); setStatus('all'); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface sms-channel-table">
|
||||
<div className="sms-channel-table__head">
|
||||
<span>通道信息</span>
|
||||
<span>运营商 / 成本</span>
|
||||
<span>状态</span>
|
||||
<span>今日总数</span>
|
||||
<span>今日发送质量</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{filteredChannels.map((channel) => (
|
||||
<article className="sms-channel-table__row" key={channel.id}>
|
||||
<div className="sms-channel-identity">
|
||||
<strong>{channel.name}</strong>
|
||||
<span>通道 ID:{channel.id}</span>
|
||||
</div>
|
||||
<div className="sms-channel-carrier-price">
|
||||
<Tag tone={carrierToneMap[channel.carrier]}>{carrierLabelMap[channel.carrier]}</Tag>
|
||||
<strong>{channel.unitPrice.toFixed(1)} 分</strong>
|
||||
</div>
|
||||
<Tag tone={statusToneMap[channel.status]}>{statusLabelMap[channel.status]}</Tag>
|
||||
<strong className="sms-channel-total">{channel.total.toLocaleString('zh-CN')}</strong>
|
||||
<div className="sms-channel-quality">
|
||||
<RateBlock count={channel.successCount} label="成功" rate={channel.successRate} tone={channel.successRate >= 80 ? 'success' : 'warning'} />
|
||||
<RateBlock count={channel.unknownCount} label="未知" rate={channel.unknownRate} />
|
||||
<RateBlock count={channel.failureCount} label="失败" rate={channel.failureRate} tone={channel.failureRate >= 50 ? 'danger' : 'neutral'} />
|
||||
</div>
|
||||
<div className="sms-channel-actions">
|
||||
<button className="sms-channel-report-entry" onClick={() => navigate(`/admin/channels/${channel.id}/reports`)} type="button">
|
||||
<Eye size={15} />通道报备详情
|
||||
</button>
|
||||
<button onClick={() => setModal({ mode: 'edit', channel })} type="button"><Pencil size={15} />编辑</button>
|
||||
<button onClick={() => setTestChannel(channel)} type="button"><Send size={15} />发送测试</button>
|
||||
<button className={channel.status === 'stopped' ? 'is-success' : 'is-warning'} onClick={() => toggleChannel(channel.id)} type="button">
|
||||
<Power size={15} />{channel.status === 'stopped' ? '启用' : '停用'}
|
||||
</button>
|
||||
<button className="is-danger" onClick={() => deleteChannel(channel.id)} type="button"><Trash2 size={15} />删除</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
<div className="sms-channel-pagination">
|
||||
<Select options={[{ label: '10 条/页', value: '10' }, { label: '20 条/页', value: '20' }]} value="10" />
|
||||
<Button disabled size="sm" variant="ghost">上一页</Button>
|
||||
<Button size="sm" variant="secondary">1</Button>
|
||||
<Button size="sm" variant="ghost">2</Button>
|
||||
<Button size="sm" variant="ghost">下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modal ? (
|
||||
<ChannelFormModal
|
||||
modal={modal}
|
||||
onClose={() => setModal(null)}
|
||||
onSubmit={upsertChannel}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{testChannel ? (
|
||||
<SmsTestModal
|
||||
channel={testChannel}
|
||||
onClose={() => setTestChannel(null)}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ImagePlus } from 'lucide-react';
|
||||
import { Button, Input, Select, Textarea } from '@/components/ui';
|
||||
import {
|
||||
cityOptionsByProvince,
|
||||
createEnterprise,
|
||||
getEnterpriseRecords,
|
||||
initialEnterpriseForm,
|
||||
provinceOptions,
|
||||
toEnterpriseForm,
|
||||
updateEnterprise,
|
||||
type EnterpriseForm,
|
||||
} from './adminEnterpriseMock';
|
||||
|
||||
type EnterpriseFormErrors = Partial<Record<keyof EnterpriseForm, string>>;
|
||||
|
||||
export function AdminCustomerFormPage() {
|
||||
const navigate = useNavigate();
|
||||
const { enterpriseId } = useParams();
|
||||
const records = useMemo(() => getEnterpriseRecords(), []);
|
||||
const editingRecord = useMemo(
|
||||
() => records.find((record) => record.id === enterpriseId),
|
||||
[enterpriseId, records],
|
||||
);
|
||||
const isEdit = Boolean(enterpriseId);
|
||||
const [form, setForm] = useState<EnterpriseForm>(() => (
|
||||
editingRecord ? toEnterpriseForm(editingRecord) : initialEnterpriseForm
|
||||
));
|
||||
const [errors, setErrors] = useState<EnterpriseFormErrors>({});
|
||||
|
||||
useEffect(() => {
|
||||
setForm(editingRecord ? toEnterpriseForm(editingRecord) : initialEnterpriseForm);
|
||||
setErrors({});
|
||||
}, [editingRecord, enterpriseId]);
|
||||
|
||||
const cityOptions = [
|
||||
{ label: '请选择市/区', value: '' },
|
||||
...(cityOptionsByProvince[form.province] ?? []),
|
||||
];
|
||||
|
||||
function updateForm<K extends keyof EnterpriseForm>(key: K, value: EnterpriseForm[K]) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
[key]: value,
|
||||
...(key === 'province' ? { city: '' } : {}),
|
||||
}));
|
||||
setErrors((current) => ({ ...current, [key]: undefined }));
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
const nextErrors: EnterpriseFormErrors = {};
|
||||
if (!form.name.trim()) {
|
||||
nextErrors.name = '请填写企业名称';
|
||||
}
|
||||
if (!form.creditCode.trim()) {
|
||||
nextErrors.creditCode = '请填写统一社会信用代码';
|
||||
}
|
||||
if (!form.contactName.trim()) {
|
||||
nextErrors.contactName = '请填写联系人姓名';
|
||||
}
|
||||
if (!form.contactPhone.trim()) {
|
||||
nextErrors.contactPhone = '请填写手机号';
|
||||
}
|
||||
setErrors(nextErrors);
|
||||
return Object.keys(nextErrors).length === 0;
|
||||
}
|
||||
|
||||
function submitForm() {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit) {
|
||||
updateEnterprise(form);
|
||||
} else {
|
||||
createEnterprise(form);
|
||||
}
|
||||
navigate('/admin/customers');
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack enterprise-form-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<p className="eyebrow">企业管理 / {isEdit ? '编辑企业' : '创建企业'}</p>
|
||||
<h1>企业信息</h1>
|
||||
<p>填写企业基本信息和联系人信息,用于运营端企业档案管理。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface enterprise-form-card">
|
||||
<section className="ui-detail-section">
|
||||
<div className="ui-detail-section__header">
|
||||
<div>
|
||||
<h3>基本信息</h3>
|
||||
<p>企业主体、证照识别和通讯地址。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="enterprise-upload-panel">
|
||||
<span>企业照片</span>
|
||||
<button type="button">
|
||||
<ImagePlus size={28} />
|
||||
点击上传
|
||||
</button>
|
||||
<p>上传企业识别图片,支持 JPG、PNG 格式,文件大小不超过 5MB。</p>
|
||||
</div>
|
||||
|
||||
<div className="form-grid form-grid--two">
|
||||
<Input
|
||||
error={errors.name}
|
||||
label="企业名称"
|
||||
onChange={(event) => updateForm('name', event.target.value)}
|
||||
placeholder="请填写企业全称"
|
||||
required
|
||||
value={form.name}
|
||||
/>
|
||||
<Input
|
||||
error={errors.creditCode}
|
||||
hint="修改此项将同步更新该企业在系统中的所有相关记录。"
|
||||
label="统一社会信用代码"
|
||||
onChange={(event) => updateForm('creditCode', event.target.value)}
|
||||
placeholder="请填写统一社会信用代码或纳税识别号"
|
||||
required
|
||||
value={form.creditCode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-grid form-grid--two">
|
||||
<Select
|
||||
label="省/直辖市"
|
||||
onChange={(event) => updateForm('province', event.target.value)}
|
||||
options={provinceOptions}
|
||||
value={form.province}
|
||||
/>
|
||||
<Select
|
||||
label="市/区"
|
||||
onChange={(event) => updateForm('city', event.target.value)}
|
||||
options={cityOptions}
|
||||
value={form.city}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
hint="通讯地址可以与营业执照上的地址不一致。"
|
||||
label="通讯地址"
|
||||
onChange={(event) => updateForm('address', event.target.value)}
|
||||
placeholder="请填写详细通讯地址"
|
||||
rows={4}
|
||||
value={form.address}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="ui-detail-section">
|
||||
<div className="ui-detail-section__header">
|
||||
<div>
|
||||
<h3>联系人信息</h3>
|
||||
<p>建议填写联系人(法人或财务主管)的真实信息。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="enterprise-info-tip">
|
||||
为方便给企业提供更好的服务,建议填写联系人(法人或财务主管)的真实信息。
|
||||
</div>
|
||||
|
||||
<div className="form-grid form-grid--two">
|
||||
<Input
|
||||
error={errors.contactName}
|
||||
label="联系人姓名"
|
||||
onChange={(event) => updateForm('contactName', event.target.value)}
|
||||
placeholder="请填写企业联系人姓名"
|
||||
required
|
||||
value={form.contactName}
|
||||
/>
|
||||
<Input
|
||||
label="身份证号"
|
||||
onChange={(event) => updateForm('contactIdCard', event.target.value)}
|
||||
placeholder="请填写企业联系人身份证号"
|
||||
value={form.contactIdCard}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-grid form-grid--two">
|
||||
<Input
|
||||
error={errors.contactPhone}
|
||||
label="手机号"
|
||||
onChange={(event) => updateForm('contactPhone', event.target.value)}
|
||||
placeholder="请填写企业联系人手机号"
|
||||
required
|
||||
value={form.contactPhone}
|
||||
/>
|
||||
<Input
|
||||
label="电子邮箱"
|
||||
onChange={(event) => updateForm('contactEmail', event.target.value)}
|
||||
placeholder="请填写企业联系人邮箱"
|
||||
type="email"
|
||||
value={form.contactEmail}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="enterprise-form-footer">
|
||||
<Button onClick={submitForm}>{isEdit ? '保存企业' : '创建企业'}</Button>
|
||||
<Button onClick={() => navigate('/admin/customers')} variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Building2, DollarSign, Plus, TrendingDown, TrendingUp } from 'lucide-react';
|
||||
import { Button, Input, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import {
|
||||
formatCurrency,
|
||||
getEnterpriseRecords,
|
||||
statusOptions,
|
||||
toggleEnterpriseStatus,
|
||||
type EnterpriseRecord,
|
||||
} from './adminEnterpriseMock';
|
||||
|
||||
export function AdminCustomersPage() {
|
||||
const navigate = useNavigate();
|
||||
const [records, setRecords] = useState<EnterpriseRecord[]>(() => getEnterpriseRecords());
|
||||
const [queryId, setQueryId] = useState('');
|
||||
const [queryName, setQueryName] = useState('');
|
||||
const [queryStatus, setQueryStatus] = useState('all');
|
||||
const [filters, setFilters] = useState({ id: '', name: '', status: 'all' });
|
||||
|
||||
const filteredRecords = useMemo(() => records.filter((record) => {
|
||||
const matchId = filters.id ? record.id.includes(filters.id) : true;
|
||||
const matchName = filters.name ? record.name.includes(filters.name) : true;
|
||||
const matchStatus = filters.status === 'all' ? true : record.status === filters.status;
|
||||
return matchId && matchName && matchStatus;
|
||||
}), [filters, records]);
|
||||
|
||||
const activeCount = records.filter((record) => record.status === 'active').length;
|
||||
const disabledCount = records.filter((record) => record.status === 'disabled').length;
|
||||
const todaySpend = records.reduce((sum, record) => sum + record.todaySpend, 0);
|
||||
|
||||
const columns: Array<TableColumn<EnterpriseRecord>> = [
|
||||
{ key: 'id', title: '企业ID', width: '90px', render: (record) => record.id },
|
||||
{ key: 'name', title: '企业名称', render: (record) => <strong>{record.name}</strong> },
|
||||
{
|
||||
key: 'balance',
|
||||
title: '当前余额',
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<span className={record.balance < 0 ? 'status-danger' : ''}>
|
||||
¥{formatCurrency(record.balance)}
|
||||
{record.balance < 0 ? <Tag tone="danger" className="enterprise-inline-tag">欠费</Tag> : null}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'overdraftLimit', title: '透支限额', align: 'right', render: (record) => `¥${formatCurrency(record.overdraftLimit)}` },
|
||||
{ key: 'todaySpend', title: '今日消费', align: 'right', render: (record) => `¥${formatCurrency(record.todaySpend)}` },
|
||||
{
|
||||
key: 'status',
|
||||
title: '企业状态',
|
||||
render: (record) => (
|
||||
<Tag tone={record.status === 'active' ? 'success' : 'warning'}>
|
||||
{record.status === 'active' ? '正常' : '已禁用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<div className="table-actions">
|
||||
<Button
|
||||
onClick={() => navigate(`/admin/customers/${record.id}`)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate(`/admin/customers/${record.id}/edit`)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setRecords(toggleEnterpriseStatus(record.id))}
|
||||
size="sm"
|
||||
variant={record.status === 'active' ? 'danger' : 'secondary'}
|
||||
>
|
||||
{record.status === 'active' ? '禁用' : '启用'}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<p className="eyebrow">管理</p>
|
||||
<h1>企业管理</h1>
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />} onClick={() => navigate('/admin/customers/new')}>
|
||||
添加企业
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid enterprise-summary-grid">
|
||||
<div className="surface mini-status-card">
|
||||
<Building2 size={22} />
|
||||
<div>
|
||||
<span>企业总数</span>
|
||||
<strong>{records.length}</strong>
|
||||
<small>当前系统企业档案数量。</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface mini-status-card">
|
||||
<TrendingUp size={22} />
|
||||
<div>
|
||||
<span>正常运营</span>
|
||||
<strong>{activeCount}</strong>
|
||||
<small>可正常提交发送任务。</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface mini-status-card">
|
||||
<TrendingDown size={22} />
|
||||
<div>
|
||||
<span>已禁用</span>
|
||||
<strong>{disabledCount}</strong>
|
||||
<small>已暂停发送能力。</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface mini-status-card">
|
||||
<DollarSign size={22} />
|
||||
<div>
|
||||
<span>今日总消费</span>
|
||||
<strong>¥{formatCurrency(todaySpend)}</strong>
|
||||
<small>列表内企业消费汇总。</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface ui-query-panel">
|
||||
<h2>查询条件</h2>
|
||||
<div className="ui-query-panel__grid enterprise-query-grid">
|
||||
<Input
|
||||
label="企业ID"
|
||||
onChange={(event) => setQueryId(event.target.value)}
|
||||
placeholder="请输入企业ID"
|
||||
value={queryId}
|
||||
/>
|
||||
<Input
|
||||
label="企业名称"
|
||||
onChange={(event) => setQueryName(event.target.value)}
|
||||
placeholder="请输入企业名称"
|
||||
value={queryName}
|
||||
/>
|
||||
<Select
|
||||
label="企业状态"
|
||||
onChange={(event) => setQueryStatus(event.target.value)}
|
||||
options={statusOptions}
|
||||
value={queryStatus}
|
||||
/>
|
||||
<div className="enterprise-query-actions">
|
||||
<Button
|
||||
onClick={() => setFilters({ id: queryId, name: queryName, status: queryStatus })}
|
||||
variant="secondary"
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setQueryId('');
|
||||
setQueryName('');
|
||||
setQueryStatus('all');
|
||||
setFilters({ id: '', name: '', status: 'all' });
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface section-stack">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>企业列表</h2>
|
||||
<p className="muted">纯前端 mock 数据,支持新增、编辑、启用和禁用。</p>
|
||||
</div>
|
||||
<Tag tone="info">{filteredRecords.length} 条</Tag>
|
||||
</div>
|
||||
<Table columns={columns} data={filteredRecords} rowKey="id" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Filter, Pencil, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Button, Input, Modal, Select, Table, Textarea, Tag, type TableColumn } from '@/components/ui';
|
||||
|
||||
type DrainageFieldType = '字符串' | '整数' | '文件' | '网址' | '电话' | '日期';
|
||||
|
||||
type DrainageField = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: DrainageFieldType;
|
||||
description: string;
|
||||
channels: number;
|
||||
channel: 'sms' | 'mms' | 'all';
|
||||
};
|
||||
|
||||
const typeOptions = [
|
||||
{ label: '全部类型', value: 'all' },
|
||||
{ label: '字符串', value: '字符串' },
|
||||
{ label: '整数', value: '整数' },
|
||||
{ label: '文件', value: '文件' },
|
||||
{ label: '网址', value: '网址' },
|
||||
{ label: '电话', value: '电话' },
|
||||
{ label: '日期', value: '日期' },
|
||||
];
|
||||
|
||||
const channelOptions = [
|
||||
{ label: '全部通道', value: 'all' },
|
||||
{ label: '短信通道', value: 'sms' },
|
||||
{ label: '彩信通道', value: 'mms' },
|
||||
];
|
||||
|
||||
const initialFields: DrainageField[] = [
|
||||
{ id: 'DRF20260630001', name: '应用ID', type: '字符串', description: '在应用集成中创建的短信应用 ID', channels: 1, channel: 'sms' },
|
||||
{ id: 'DRF20260630002', name: '应用密匙', type: '字符串', description: '应用密匙或数字签名', channels: 1, channel: 'sms' },
|
||||
{ id: 'DRF20260630003', name: '短信签名', type: '字符串', description: '短信签名,【】符号可省略', channels: 1, channel: 'sms' },
|
||||
{ id: 'DRF20260630004', name: '短信用途', type: '整数', description: '0-行业通知短信、1-营销推广短信', channels: 1, channel: 'sms' },
|
||||
{ id: 'DRF20260630005', name: '证明材料', type: '文件', description: '上传营业执照、授权书等证明材料', channels: 1, channel: 'all' },
|
||||
{ id: 'DRF20260630006', name: '引流链接', type: '网址', description: '短信内链接地址', channels: 2, channel: 'all' },
|
||||
{ id: 'DRF20260630007', name: '引流号码', type: '电话', description: '引流号码1', channels: 3, channel: 'sms' },
|
||||
{ id: 'DRF20260630008', name: '机主姓名', type: '字符串', description: '引流号码1机主姓名', channels: 3, channel: 'sms' },
|
||||
{ id: 'DRF20260630009', name: 'ICP备案号', type: '字符串', description: '域名ICP备案号', channels: 1, channel: 'all' },
|
||||
{ id: 'DRF20260630010', name: '拨测日期', type: '日期', description: '引流号码拨测日期', channels: 2, channel: 'sms' },
|
||||
];
|
||||
|
||||
function createFieldId() {
|
||||
return `DRF${Date.now()}`;
|
||||
}
|
||||
|
||||
type FieldFormModalProps = {
|
||||
item?: DrainageField;
|
||||
onClose: () => void;
|
||||
onSubmit: (item: DrainageField) => void;
|
||||
};
|
||||
|
||||
function FieldFormModal({ item, onClose, onSubmit }: FieldFormModalProps) {
|
||||
const [form, setForm] = useState<DrainageField>(() => item ?? {
|
||||
id: createFieldId(),
|
||||
name: '',
|
||||
type: '字符串',
|
||||
description: '',
|
||||
channels: 1,
|
||||
channel: 'sms',
|
||||
});
|
||||
|
||||
function updateField<Key extends keyof DrainageField>(key: Key, value: DrainageField[Key]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button onClick={() => onSubmit(form)}>保存</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
title={item ? '编辑报备字段' : '添加报备字段'}
|
||||
>
|
||||
<div className="admin-system-modal-form">
|
||||
<Input label="字段名称" onChange={(event) => updateField('name', event.target.value)} value={form.name} />
|
||||
<Select
|
||||
label="字段类型"
|
||||
onChange={(event) => updateField('type', event.target.value as DrainageFieldType)}
|
||||
options={typeOptions.filter((option) => option.value !== 'all')}
|
||||
value={form.type}
|
||||
/>
|
||||
<Select
|
||||
label="适用通道"
|
||||
onChange={(event) => updateField('channel', event.target.value as DrainageField['channel'])}
|
||||
options={channelOptions}
|
||||
value={form.channel}
|
||||
/>
|
||||
<Input label="使用通道数" min={1} onChange={(event) => updateField('channels', Number(event.target.value) || 1)} type="number" value={form.channels} />
|
||||
<Textarea
|
||||
className="admin-system-modal-form__wide"
|
||||
label="描述"
|
||||
onChange={(event) => updateField('description', event.target.value)}
|
||||
rows={4}
|
||||
value={form.description}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminDrainageFieldsPage() {
|
||||
const [fields, setFields] = useState(initialFields);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [channel, setChannel] = useState('all');
|
||||
const [type, setType] = useState('all');
|
||||
const [editingField, setEditingField] = useState<DrainageField | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const filteredFields = useMemo(
|
||||
() => fields.filter((field) => {
|
||||
const matchesKeyword = [field.name, field.type, field.description].some((value) => value.includes(keyword));
|
||||
const matchesChannel = channel === 'all' || field.channel === channel || field.channel === 'all';
|
||||
const matchesType = type === 'all' || field.type === type;
|
||||
return matchesKeyword && matchesChannel && matchesType;
|
||||
}),
|
||||
[channel, fields, keyword, type],
|
||||
);
|
||||
|
||||
function upsertField(nextField: DrainageField) {
|
||||
setFields((current) => {
|
||||
const exists = current.some((item) => item.id === nextField.id);
|
||||
if (exists) {
|
||||
return current.map((item) => (item.id === nextField.id ? nextField : item));
|
||||
}
|
||||
|
||||
return [nextField, ...current];
|
||||
});
|
||||
setEditingField(null);
|
||||
setCreating(false);
|
||||
}
|
||||
|
||||
const columns = useMemo<Array<TableColumn<DrainageField>>>(() => [
|
||||
{ key: 'name', title: '字段名称', width: '190px', render: (record) => <strong>{record.name}</strong> },
|
||||
{ key: 'type', title: '字段类型', width: '160px', render: (record) => <span className="admin-drainage-type">{record.type}</span> },
|
||||
{ key: 'description', title: '描述', render: (record) => record.description },
|
||||
{ key: 'channels', title: '使用通道数', width: '170px', render: (record) => <Tag>{record.channels} 个通道</Tag> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '140px',
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<div className="admin-drainage-actions">
|
||||
<Button icon={<Pencil size={17} />} iconOnly onClick={() => setEditingField(record)} variant="ghost">编辑</Button>
|
||||
<Button
|
||||
icon={<Trash2 size={17} />}
|
||||
iconOnly
|
||||
onClick={() => setFields((current) => current.filter((item) => item.id !== record.id))}
|
||||
variant="danger"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], []);
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-system-page admin-drainage-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<div className="breadcrumb-line">基础配置 / <strong>引流信息报备字段库</strong></div>
|
||||
<h1>引流信息报备字段库</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-drainage-toolbar">
|
||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索字段名称、代码或描述..." prefix={<Search size={16} />} value={keyword} />
|
||||
<Select
|
||||
onChange={(event) => setChannel(event.target.value)}
|
||||
options={channelOptions}
|
||||
value={channel}
|
||||
/>
|
||||
<Select onChange={(event) => setType(event.target.value)} options={typeOptions} value={type} />
|
||||
<Button icon={<Plus size={18} />} onClick={() => setCreating(true)}>添加字段</Button>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-system-table-card admin-drainage-table-card">
|
||||
<Table columns={columns} data={filteredFields} emptyText="暂无字段" rowKey="id" />
|
||||
<div className="admin-drainage-pagination">
|
||||
<span>10条/页</span>
|
||||
<Button icon={<Filter size={16} />} iconOnly variant="ghost">筛选</Button>
|
||||
<Button size="sm">1</Button>
|
||||
<span>...</span>
|
||||
<Button size="sm" variant="ghost">1</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{creating ? <FieldFormModal onClose={() => setCreating(false)} onSubmit={upsertField} /> : null}
|
||||
{editingField ? <FieldFormModal item={editingField} onClose={() => setEditingField(null)} onSubmit={upsertField} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Check, FileSearch, Search, X } from 'lucide-react';
|
||||
import { Button, Input, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
|
||||
type EnterpriseAuditStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
type EnterpriseAuditRecord = {
|
||||
id: string;
|
||||
companyName: string;
|
||||
creditCode: string;
|
||||
contactName: string;
|
||||
contactPhone: string;
|
||||
submittedAt: string;
|
||||
status: EnterpriseAuditStatus;
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '待审核', value: 'pending' },
|
||||
{ label: '已通过', value: 'approved' },
|
||||
{ label: '已拒绝', value: 'rejected' },
|
||||
];
|
||||
|
||||
const statusTextMap: Record<EnterpriseAuditStatus, string> = {
|
||||
pending: '待审核',
|
||||
approved: '已通过',
|
||||
rejected: '已拒绝',
|
||||
};
|
||||
|
||||
const statusToneMap: Record<EnterpriseAuditStatus, 'warning' | 'success' | 'danger'> = {
|
||||
pending: 'warning',
|
||||
approved: 'success',
|
||||
rejected: 'danger',
|
||||
};
|
||||
|
||||
const initialEnterpriseAudits: EnterpriseAuditRecord[] = [
|
||||
{ id: 'ENT-20260319-001', companyName: '北京星云科技有限公司', creditCode: '91110000X12345678A', contactName: '张伟', contactPhone: '13800138000', submittedAt: '2026-03-19 10:23:45', status: 'pending' },
|
||||
{ id: 'ENT-20260319-002', companyName: '上海蓝海科技有限公司', creditCode: '91310000X87654321B', contactName: '李娜', contactPhone: '13900139000', submittedAt: '2026-03-18 15:45:12', status: 'pending' },
|
||||
{ id: 'ENT-20260318-001', companyName: '广州飞跃文化传媒有限公司', creditCode: '91440100X11223344C', contactName: '王强', contactPhone: '13700137000', submittedAt: '2026-03-17 09:12:30', status: 'approved' },
|
||||
{ id: 'ENT-20260317-001', companyName: '深圳前海贸易有限公司', creditCode: '91440300X55667788D', contactName: '陈杰', contactPhone: '13600136000', submittedAt: '2026-03-16 11:30:22', status: 'rejected' },
|
||||
];
|
||||
|
||||
export function AdminEnterpriseAuditPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [records, setRecords] = useState(initialEnterpriseAudits);
|
||||
|
||||
const filteredRecords = useMemo(
|
||||
() => records.filter((record) => {
|
||||
const matchesKeyword = !keyword || `${record.companyName}${record.creditCode}`.includes(keyword);
|
||||
const matchesStatus = status === 'all' || record.status === status;
|
||||
return matchesKeyword && matchesStatus;
|
||||
}),
|
||||
[keyword, records, status],
|
||||
);
|
||||
|
||||
function updateStatus(id: string, nextStatus: EnterpriseAuditStatus) {
|
||||
setRecords((items) => items.map((item) => (item.id === id ? { ...item, status: nextStatus } : item)));
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<EnterpriseAuditRecord>> = [
|
||||
{ key: 'id', title: '申请单号', render: (record) => <span className="muted">{record.id}</span> },
|
||||
{ key: 'companyName', title: '企业名称', render: (record) => <strong>{record.companyName}</strong> },
|
||||
{ key: 'creditCode', title: '统一社会信用代码', render: (record) => record.creditCode },
|
||||
{ key: 'contactName', title: '联系人', render: (record) => record.contactName },
|
||||
{ key: 'contactPhone', title: '联系电话', render: (record) => record.contactPhone },
|
||||
{ key: 'submittedAt', title: '提交时间', render: (record) => record.submittedAt },
|
||||
{
|
||||
key: 'status',
|
||||
title: '状态',
|
||||
render: (record) => <Tag tone={statusToneMap[record.status]}>{statusTextMap[record.status]}</Tag>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<div className="audit-actions">
|
||||
{record.status === 'pending' ? (
|
||||
<>
|
||||
<button className="audit-link audit-link--success" onClick={() => updateStatus(record.id, 'approved')} type="button">通过</button>
|
||||
<button className="audit-link audit-link--danger" onClick={() => updateStatus(record.id, 'rejected')} type="button">拒绝</button>
|
||||
</>
|
||||
) : null}
|
||||
<button className="audit-link" type="button">详情</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-audit-page">
|
||||
<div className="breadcrumb-line"><span>审核中心</span><span>/</span><strong>企业认证审核</strong></div>
|
||||
<div className="surface audit-filter-card">
|
||||
<div className="audit-filter-grid audit-filter-grid--enterprise">
|
||||
<Input label="企业名称/信用代码" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入企业名称或统一社会信用代码" value={keyword} />
|
||||
<Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
|
||||
<div className="audit-filter-actions">
|
||||
<Button icon={<Search size={17} />}>查询</Button>
|
||||
<Button onClick={() => { setKeyword(''); setStatus('all'); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface audit-table-card">
|
||||
<Table columns={columns} data={filteredRecords} rowKey="id" />
|
||||
<div className="audit-pagination">
|
||||
<span>共 {filteredRecords.length} 条</span>
|
||||
<Button disabled icon={<FileSearch size={16} />} size="sm" variant="ghost">更多</Button>
|
||||
<Button disabled icon={<Check size={15} />} size="sm" variant="secondary">1</Button>
|
||||
<Button disabled icon={<X size={15} />} size="sm" variant="ghost">下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Button, Table, type TableColumn } from '@/components/ui';
|
||||
|
||||
type EnterpriseBlacklistItem = {
|
||||
id: string;
|
||||
enterprise: string;
|
||||
application: string;
|
||||
phone: string;
|
||||
createdAt: string;
|
||||
reason: string;
|
||||
expiredAt: string;
|
||||
};
|
||||
|
||||
const initialItems: EnterpriseBlacklistItem[] = [
|
||||
{ id: 'EBL20260630001', enterprise: '四川骠骑企业管理', application: '营销应用1', phone: '13675569095', createdAt: '2026-06-28 10:12:05', reason: '用户回复退订', expiredAt: '2026-12-28 23:59:59' },
|
||||
{ id: 'EBL20260630002', enterprise: '重庆进载数智', application: '通知应用', phone: '18607638087', createdAt: '2026-06-27 15:34:22', reason: '投诉拦截', expiredAt: '2026-09-27 23:59:59' },
|
||||
{ id: 'EBL20260630003', enterprise: '超感世纪三三网', application: '推广应用2', phone: '15250668026', createdAt: '2026-06-25 09:18:41', reason: '运营手动加入', expiredAt: '2026-08-25 23:59:59' },
|
||||
{ id: 'EBL20260630004', enterprise: '重庆香惠慧', application: '客服应用', phone: '18800000555', createdAt: '2026-06-24 18:01:10', reason: '敏感投诉号码', expiredAt: '2026-07-24 23:59:59' },
|
||||
];
|
||||
|
||||
export function AdminEnterpriseBlacklistPage() {
|
||||
const [items, setItems] = useState(initialItems);
|
||||
|
||||
const columns = useMemo<Array<TableColumn<EnterpriseBlacklistItem>>>(() => [
|
||||
{ key: 'enterprise', title: '企业名称', width: '180px', render: (record) => <strong>{record.enterprise}</strong> },
|
||||
{ key: 'application', title: '应用名称', width: '150px', render: (record) => record.application },
|
||||
{ key: 'phone', title: '手机号码', width: '150px', render: (record) => <strong>{record.phone}</strong> },
|
||||
{ key: 'createdAt', title: '入库时间', width: '170px', render: (record) => record.createdAt },
|
||||
{ key: 'reason', title: '入库原因', render: (record) => record.reason },
|
||||
{ key: 'expiredAt', title: '过期时间', width: '170px', render: (record) => record.expiredAt },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '110px',
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => setItems((current) => current.filter((item) => item.id !== record.id))} size="sm" variant="danger">
|
||||
删除
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
], []);
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-security-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<div className="breadcrumb-line">安全控制 / <strong>企业黑名单</strong></div>
|
||||
<h1>企业黑名单</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-security-table-card">
|
||||
<Table columns={columns} data={items} emptyText="暂无企业黑名单记录" rowKey="id" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Button, Table, type TableColumn } from '@/components/ui';
|
||||
|
||||
type GlobalBlacklistItem = {
|
||||
id: string;
|
||||
phone: string;
|
||||
createdAt: string;
|
||||
reason: string;
|
||||
expiredAt: string;
|
||||
};
|
||||
|
||||
const initialItems: GlobalBlacklistItem[] = [
|
||||
{ id: 'GBL20260630001', phone: '13500000888', createdAt: '2026-06-28 11:20:05', reason: '多企业投诉号码', expiredAt: '2026-12-28 23:59:59' },
|
||||
{ id: 'GBL20260630002', phone: '13755558888', createdAt: '2026-06-27 16:05:12', reason: '黑名单同步导入', expiredAt: '2026-09-27 23:59:59' },
|
||||
{ id: 'GBL20260630003', phone: '18800000555', createdAt: '2026-06-26 09:44:30', reason: '监管要求拦截', expiredAt: '2027-06-26 23:59:59' },
|
||||
{ id: 'GBL20260630004', phone: '15250668026', createdAt: '2026-06-25 14:12:18', reason: '高频退订', expiredAt: '2026-08-25 23:59:59' },
|
||||
];
|
||||
|
||||
export function AdminGlobalBlacklistPage() {
|
||||
const [items, setItems] = useState(initialItems);
|
||||
|
||||
const columns = useMemo<Array<TableColumn<GlobalBlacklistItem>>>(() => [
|
||||
{ key: 'phone', title: '手机号码', width: '180px', render: (record) => <strong>{record.phone}</strong> },
|
||||
{ key: 'createdAt', title: '入库时间', width: '190px', render: (record) => record.createdAt },
|
||||
{ key: 'reason', title: '入库原因', render: (record) => record.reason },
|
||||
{ key: 'expiredAt', title: '过期时间', width: '190px', render: (record) => record.expiredAt },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '110px',
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => setItems((current) => current.filter((item) => item.id !== record.id))} size="sm" variant="danger">
|
||||
删除
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
], []);
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-security-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<div className="breadcrumb-line">安全控制 / <strong>全局黑名单</strong></div>
|
||||
<h1>全局黑名单</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-security-table-card">
|
||||
<Table columns={columns} data={items} emptyText="暂无全局黑名单记录" rowKey="id" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
BarChart3,
|
||||
Clock3,
|
||||
DollarSign,
|
||||
FileCheck2,
|
||||
RadioTower,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Chart,
|
||||
Modal,
|
||||
Table,
|
||||
Tag,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
import { auditTrend, hourlySendTrend } from '@/mock/chartData';
|
||||
import { adminService, type AuditStatus } from '@/mock';
|
||||
import { createAuditColumns } from '@/apps/admin/auditColumns';
|
||||
import { createBarOption, createLineOption } from '@/theme/chartOptions';
|
||||
|
||||
type SignatureRank = {
|
||||
id: string;
|
||||
signature: string;
|
||||
customer: string;
|
||||
type: '不含引流' | '仅引流';
|
||||
successCount: number;
|
||||
successRate: number;
|
||||
averageSeconds: number;
|
||||
status: '正常' | '关注' | '异常';
|
||||
};
|
||||
|
||||
type EnterpriseSpendRank = {
|
||||
id: string;
|
||||
city: string;
|
||||
enterprise: string;
|
||||
contact: string;
|
||||
todaySpend: number;
|
||||
balanceStatus: '充足' | '紧张' | '欠费';
|
||||
availableBalance: number;
|
||||
};
|
||||
|
||||
const signatureRanks: SignatureRank[] = [
|
||||
{ id: 'SIG-001', signature: '[XX银行]', customer: '上海云舟科技', type: '不含引流', successCount: 1278, successRate: 77.8, averageSeconds: 3.7, status: '正常' },
|
||||
{ id: 'SIG-002', signature: '[XX科技有限公司]', customer: '杭州星澜商贸', type: '不含引流', successCount: 627, successRate: 65.3, averageSeconds: 2.5, status: '关注' },
|
||||
{ id: 'SIG-003', signature: '[XXAPP]', customer: '深圳北辰出行', type: '不含引流', successCount: 322, successRate: 97.2, averageSeconds: 115.2, status: '关注' },
|
||||
{ id: 'SIG-004', signature: '[XXXXX公司]', customer: '广州麦芒科技', type: '不含引流', successCount: 125, successRate: 33.2, averageSeconds: 13, status: '异常' },
|
||||
{ id: 'SIG-005', signature: '[XXXXX公司]', customer: '北京鸣川科技', type: '不含引流', successCount: 45, successRate: 0, averageSeconds: 0.3, status: '异常' },
|
||||
{ id: 'SIG-101', signature: '[XX银行]', customer: '上海云舟科技', type: '仅引流', successCount: 1278, successRate: 77.8, averageSeconds: 3.7, status: '正常' },
|
||||
{ id: 'SIG-102', signature: '[XX科技有限公司]', customer: '杭州星澜商贸', type: '仅引流', successCount: 527, successRate: 65.3, averageSeconds: 2.5, status: '关注' },
|
||||
{ id: 'SIG-103', signature: '[XXAPP]', customer: '深圳北辰出行', type: '仅引流', successCount: 322, successRate: 97.2, averageSeconds: 115.2, status: '关注' },
|
||||
{ id: 'SIG-104', signature: '[XXXXX公司]', customer: '广州麦芒科技', type: '仅引流', successCount: 125, successRate: 33.2, averageSeconds: 13, status: '异常' },
|
||||
{ id: 'SIG-105', signature: '[XXXXX公司]', customer: '北京鸣川科技', type: '仅引流', successCount: 45, successRate: 0, averageSeconds: 0.3, status: '异常' },
|
||||
];
|
||||
|
||||
const enterpriseSpendRanks: EnterpriseSpendRank[] = [
|
||||
{ id: 'ENT-001', city: '上海', enterprise: '上海XXXXX科技有限公司', contact: '赵先生', todaySpend: 1123.4, balanceStatus: '充足', availableBalance: 286420 },
|
||||
{ id: 'ENT-002', city: '上海', enterprise: '上海云舟科技有限公司', contact: '王女士', todaySpend: 256.3, balanceStatus: '充足', availableBalance: 94220 },
|
||||
{ id: 'ENT-003', city: '深圳', enterprise: '深圳XXXXX科技有限公司', contact: '陈先生', todaySpend: 97.25, balanceStatus: '紧张', availableBalance: 1200 },
|
||||
{ id: 'ENT-004', city: '北京', enterprise: '北京XXXXX科技有限公司', contact: '刘女士', todaySpend: 66.2, balanceStatus: '充足', availableBalance: 55200 },
|
||||
{ id: 'ENT-005', city: '杭州', enterprise: '杭州XXXXX科技有限公司', contact: '周先生', todaySpend: 12, balanceStatus: '欠费', availableBalance: 0 },
|
||||
];
|
||||
|
||||
const rankStatusTone = {
|
||||
正常: 'success',
|
||||
关注: 'warning',
|
||||
异常: 'danger',
|
||||
} as const;
|
||||
|
||||
const balanceTone = {
|
||||
充足: 'success',
|
||||
紧张: 'warning',
|
||||
欠费: 'danger',
|
||||
} as const;
|
||||
|
||||
function formatCurrency(value: number) {
|
||||
return value.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
function formatCount(value: number) {
|
||||
return value.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
export function AdminHome() {
|
||||
const navigate = useNavigate();
|
||||
const overview = adminService.getOverview();
|
||||
const [audits, setAudits] = useState(() => adminService.getAudits());
|
||||
const [selectedEnterprise, setSelectedEnterprise] = useState<EnterpriseSpendRank | null>(null);
|
||||
const channels = adminService.getChannels();
|
||||
|
||||
function updateAuditStatus(id: string, status: AuditStatus) {
|
||||
setAudits(adminService.updateAuditStatus(id, status));
|
||||
}
|
||||
|
||||
const auditColumns = useMemo(() => createAuditColumns(updateAuditStatus), []);
|
||||
const pendingAudits = audits.filter((item) => item.status === 'pending');
|
||||
|
||||
const noDiversionSignatureRanks = signatureRanks.filter((item) => item.type === '不含引流');
|
||||
const diversionSignatureRanks = signatureRanks.filter((item) => item.type === '仅引流');
|
||||
|
||||
const totalSend = signatureRanks.reduce((sum, item) => sum + item.successCount, 0);
|
||||
const averageSuccessRate = signatureRanks.reduce((sum, item) => sum + item.successRate, 0) / signatureRanks.length;
|
||||
const todaySpend = enterpriseSpendRanks.reduce((sum, item) => sum + item.todaySpend, 0);
|
||||
const activeSignatureCount = new Set(signatureRanks.map((item) => item.signature)).size;
|
||||
|
||||
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 auditTrendOption = useMemo(
|
||||
() => createBarOption({
|
||||
labels: auditTrend.map((item) => item.day),
|
||||
series: [
|
||||
{ name: '通过', data: auditTrend.map((item) => item.approved) },
|
||||
{ name: '驳回', data: auditTrend.map((item) => item.rejected) },
|
||||
{ name: '待审', data: auditTrend.map((item) => item.pending) },
|
||||
],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const signatureColumns: Array<TableColumn<SignatureRank>> = [
|
||||
{ key: 'rank', title: '排名', width: '72px', render: (_record, index) => index + 1 },
|
||||
{ key: 'signature', title: '签名', render: (record) => <strong>{record.signature}</strong> },
|
||||
{ key: 'successCount', title: '成功总数', align: 'right', render: (record) => formatCount(record.successCount) },
|
||||
{ key: 'successRate', title: '成功率', align: 'right', render: (record) => `${record.successRate}%` },
|
||||
{ key: 'averageSeconds', title: '平均时长(秒)', align: 'right', render: (record) => record.averageSeconds },
|
||||
];
|
||||
|
||||
const enterpriseColumns: Array<TableColumn<EnterpriseSpendRank>> = [
|
||||
{ key: 'rank', title: '排名', width: '72px', render: (_record, index) => index + 1 },
|
||||
{
|
||||
key: 'enterprise',
|
||||
title: '企业名称',
|
||||
render: (record) => (
|
||||
<div>
|
||||
<strong>{record.enterprise}</strong>
|
||||
<p className="text-caption">{record.city} · {record.contact}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'todaySpend', title: '今日消费(元)', align: 'right', render: (record) => `¥${formatCurrency(record.todaySpend)}` },
|
||||
{ key: 'availableBalance', title: '可用余额', align: 'right', render: (record) => formatCount(record.availableBalance) },
|
||||
{ key: 'balanceStatus', title: '余额状态', render: (record) => <Tag tone={balanceTone[record.balanceStatus]}>{record.balanceStatus}</Tag> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<Button onClick={() => setSelectedEnterprise(record)} size="sm" variant="ghost">
|
||||
查看详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const channelColumns: Array<TableColumn<ReturnType<typeof adminService.getChannels>[number]>> = [
|
||||
{ key: 'name', title: '通道名称', render: (record) => <strong>{record.name}</strong> },
|
||||
{ key: 'region', title: '区域', render: (record) => <span className="muted">{record.region}</span> },
|
||||
{ key: 'successRate', title: '成功率', align: 'right', render: (record) => `${record.successRate}%` },
|
||||
{ key: 'latencyMs', title: '平均延迟', align: 'right', render: (record) => `${record.latencyMs}ms` },
|
||||
{ key: 'enabled', title: '状态', render: (record) => <Tag tone={record.enabled ? 'success' : 'neutral'}>{record.enabled ? '运行中' : '已停用'}</Tag> },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-dashboard">
|
||||
<div className="overview-hero admin-dashboard-hero">
|
||||
<div>
|
||||
<p className="eyebrow">运营端</p>
|
||||
<h1>数据概览</h1>
|
||||
<p className="muted">按业务口径查看平台发送、签名、消费、审核和通道运行情况。</p>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<Button icon={<FileCheck2 size={16} />} onClick={() => navigate('/admin/templates')} variant="ghost">
|
||||
处理审核
|
||||
</Button>
|
||||
<Button icon={<RadioTower size={16} />} onClick={() => navigate('/admin/channels')}>
|
||||
查看通道
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid admin-metric-grid">
|
||||
<div className="surface metric-card">
|
||||
<span>今日发送总量</span>
|
||||
<strong>{(totalSend / 10000).toFixed(4)}万条</strong>
|
||||
<small>基于签名发送排行汇总</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>总体成功率</span>
|
||||
<strong>{averageSuccessRate.toFixed(1)}%</strong>
|
||||
<small>包含不含引流与仅引流口径</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>今日消费</span>
|
||||
<strong>¥{formatCurrency(todaySpend)}</strong>
|
||||
<small>企业消费排行汇总</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>活跃签名数量</span>
|
||||
<strong>{activeSignatureCount}</strong>
|
||||
<small>今日有成功发送记录的签名</small>
|
||||
</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">近 7 天模板、签名审核处理情况。</p>
|
||||
<Chart height={300} option={auditTrendOption} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overview-grid admin-signature-rank-grid">
|
||||
<div className="surface section-stack">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>今日省签名发送量排行 - 不含引流</h2>
|
||||
<p className="muted">字段来自截图结构:排名、签名、成功总数、成功率、平均时长和状态。</p>
|
||||
</div>
|
||||
<Tag tone="info">{noDiversionSignatureRanks.length} 条</Tag>
|
||||
</div>
|
||||
<Table columns={signatureColumns} data={noDiversionSignatureRanks} rowKey="id" />
|
||||
</div>
|
||||
|
||||
<div className="surface section-stack">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>今日省签名发送量排行 - 仅引流</h2>
|
||||
<p className="muted">单独展示引流口径下的签名发送效果。</p>
|
||||
</div>
|
||||
<Tag tone="info">{diversionSignatureRanks.length} 条</Tag>
|
||||
</div>
|
||||
<Table columns={signatureColumns} data={diversionSignatureRanks} rowKey="id" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface section-stack">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>今日企业消费排行</h2>
|
||||
<p className="muted">支持按余额状态筛选,并通过详情弹窗查看企业余额和联系人信息。</p>
|
||||
</div>
|
||||
<Button icon={<DollarSign size={16} />} size="sm" variant="ghost">
|
||||
导出排行
|
||||
</Button>
|
||||
</div>
|
||||
<Table columns={enterpriseColumns} data={enterpriseSpendRanks} rowKey="id" />
|
||||
</div>
|
||||
|
||||
<div className="overview-grid">
|
||||
<div className="surface section-stack">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>通道运行</h2>
|
||||
<p className="muted">核心通道成功率和延迟。</p>
|
||||
</div>
|
||||
<Button onClick={() => navigate('/admin/channels')} size="sm" variant="ghost">
|
||||
管理通道
|
||||
</Button>
|
||||
</div>
|
||||
<Table columns={channelColumns} data={channels} rowKey="id" />
|
||||
</div>
|
||||
|
||||
<div className="surface section-stack">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>运营状态</h2>
|
||||
<p className="muted">当日关键流程状态汇总。</p>
|
||||
</div>
|
||||
<BarChart3 size={20} className="status-info" />
|
||||
</div>
|
||||
<div className="overview-grid overview-grid--three">
|
||||
<div className="mini-status-card">
|
||||
<FileCheck2 size={22} />
|
||||
<div>
|
||||
<span>待审核</span>
|
||||
<strong>{pendingAudits.length} 条</strong>
|
||||
<small>模板、签名和企业认证。</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mini-status-card">
|
||||
<ShieldCheck size={22} />
|
||||
<div>
|
||||
<span>平均等待</span>
|
||||
<strong>{overview.averageWaitMinutes} 分钟</strong>
|
||||
<small>高风险内容优先处理。</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mini-status-card">
|
||||
<Users size={22} />
|
||||
<div>
|
||||
<span>平台健康度</span>
|
||||
<strong>{overview.channelHealth}%</strong>
|
||||
<small>通道服务整体稳定。</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface section-stack">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>待审核队列</h2>
|
||||
<p className="muted">展示当前仍需处理的模板和签名审核。</p>
|
||||
</div>
|
||||
<Button icon={<Clock3 size={16} />} onClick={() => navigate('/admin/templates')} variant="ghost">
|
||||
查看审核中心
|
||||
</Button>
|
||||
</div>
|
||||
<Table columns={auditColumns} data={pendingAudits} rowKey="id" emptyText="暂无待审核记录" />
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setSelectedEnterprise(null)} variant="ghost">关闭</Button>
|
||||
<Button onClick={() => navigate('/admin/billing')}>查看账单流水</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setSelectedEnterprise(null)}
|
||||
open={Boolean(selectedEnterprise)}
|
||||
title={(
|
||||
<div className="ui-detail-title">
|
||||
<h2>企业消费详情</h2>
|
||||
<p>{selectedEnterprise?.id}</p>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{selectedEnterprise ? (
|
||||
<div className="ui-detail-info-grid">
|
||||
<div className="ui-detail-info-grid__item">
|
||||
<span>企业名称</span>
|
||||
<strong>{selectedEnterprise.enterprise}</strong>
|
||||
</div>
|
||||
<div className="ui-detail-info-grid__item">
|
||||
<span>所在城市</span>
|
||||
<strong>{selectedEnterprise.city}</strong>
|
||||
</div>
|
||||
<div className="ui-detail-info-grid__item">
|
||||
<span>联系人</span>
|
||||
<strong>{selectedEnterprise.contact}</strong>
|
||||
</div>
|
||||
<div className="ui-detail-info-grid__item">
|
||||
<span>余额状态</span>
|
||||
<strong>
|
||||
<Tag tone={balanceTone[selectedEnterprise.balanceStatus]}>{selectedEnterprise.balanceStatus}</Tag>
|
||||
</strong>
|
||||
</div>
|
||||
<div className="ui-detail-info-grid__item">
|
||||
<span>今日消费</span>
|
||||
<strong>¥{formatCurrency(selectedEnterprise.todaySpend)}</strong>
|
||||
</div>
|
||||
<div className="ui-detail-info-grid__item">
|
||||
<span>可用余额</span>
|
||||
<strong>{formatCount(selectedEnterprise.availableBalance)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ArrowLeft, RefreshCw } from 'lucide-react';
|
||||
import { Button, Input, Select } from '@/components/ui';
|
||||
import { getEnterpriseRecords } from './adminEnterpriseMock';
|
||||
|
||||
const channelOptions = {
|
||||
mobile: [
|
||||
{ label: '移动通道A', value: 'mobile-a' },
|
||||
{ label: '移动通道B', value: 'mobile-b' },
|
||||
],
|
||||
unicom: [
|
||||
{ label: '联通通道B', value: 'unicom-b' },
|
||||
{ label: '联通通道C', value: 'unicom-c' },
|
||||
],
|
||||
telecom: [
|
||||
{ label: '电信通道C', value: 'telecom-c' },
|
||||
{ label: '电信通道D', value: 'telecom-d' },
|
||||
],
|
||||
};
|
||||
|
||||
function generateCode(prefix: string) {
|
||||
return `${prefix}${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
|
||||
}
|
||||
|
||||
export function AdminMmsApplicationFormPage() {
|
||||
const navigate = useNavigate();
|
||||
const { enterpriseId, appId } = useParams();
|
||||
const enterprise = useMemo(
|
||||
() => getEnterpriseRecords().find((item) => item.id === enterpriseId),
|
||||
[enterpriseId],
|
||||
);
|
||||
const isEdit = Boolean(appId);
|
||||
const [appName, setAppName] = useState(isEdit ? '示例彩信应用' : '');
|
||||
const [unitPrice, setUnitPrice] = useState(isEdit ? '0.0300' : '');
|
||||
const [mobileChannel, setMobileChannel] = useState('mobile-a');
|
||||
const [unicomChannel, setUnicomChannel] = useState('unicom-b');
|
||||
const [telecomChannel, setTelecomChannel] = useState('telecom-c');
|
||||
const [dailyLimit, setDailyLimit] = useState(isEdit ? '100000' : '');
|
||||
const [phoneDailyLimit, setPhoneDailyLimit] = useState(isEdit ? '10' : '');
|
||||
const [mmsEnabled, setMmsEnabled] = useState(true);
|
||||
const [ipAddress, setIpAddress] = useState(isEdit ? '192.168.1.100' : '');
|
||||
const [connectionCount, setConnectionCount] = useState(isEdit ? '2' : '');
|
||||
const [enterpriseCode, setEnterpriseCode] = useState(isEdit ? 'ABC123' : generateCode('ME'));
|
||||
const [interfaceAccount, setInterfaceAccount] = useState(isEdit ? 'ABC123' : generateCode('MA'));
|
||||
const [interfacePassword, setInterfacePassword] = useState(isEdit ? '************' : generateCode('MP'));
|
||||
const [accessNumber, setAccessNumber] = useState(isEdit ? '1069' : '');
|
||||
const [nameError, setNameError] = useState('');
|
||||
|
||||
function goBack() {
|
||||
navigate(`/admin/customers/${enterpriseId ?? ''}`);
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!appName.trim()) {
|
||||
setNameError('请填写应用名称');
|
||||
return;
|
||||
}
|
||||
goBack();
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-app-form-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<p className="eyebrow">企业管理 / {isEdit ? '编辑彩信应用' : '添加彩信应用'}</p>
|
||||
<h1>{isEdit ? '编辑彩信应用' : '添加彩信应用'}</h1>
|
||||
<p>{enterprise?.name ?? '当前企业'} 的彩信应用配置。</p>
|
||||
</div>
|
||||
<Button icon={<ArrowLeft size={16} />} onClick={goBack} variant="ghost">
|
||||
返回企业详情
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-app-form-card">
|
||||
<section className="ui-detail-section">
|
||||
<div className="ui-detail-section__header">
|
||||
<h3>业务信息</h3>
|
||||
</div>
|
||||
<div className="admin-app-form-grid">
|
||||
<Input
|
||||
error={nameError}
|
||||
label="应用名称"
|
||||
onChange={(event) => {
|
||||
setAppName(event.target.value);
|
||||
setNameError('');
|
||||
}}
|
||||
placeholder="请输入应用名称"
|
||||
required
|
||||
value={appName}
|
||||
/>
|
||||
<Input
|
||||
label="编ID(元)"
|
||||
onChange={(event) => setUnitPrice(event.target.value)}
|
||||
placeholder="0.0300"
|
||||
required
|
||||
suffix={<span className="admin-app-form-price-note">(3.0000)</span>}
|
||||
value={unitPrice}
|
||||
/>
|
||||
<Select label="发送通道-移动" onChange={(event) => setMobileChannel(event.target.value)} options={channelOptions.mobile} required value={mobileChannel} />
|
||||
<Select label="发送通道-联通" onChange={(event) => setUnicomChannel(event.target.value)} options={channelOptions.unicom} required value={unicomChannel} />
|
||||
<Select label="发送通道-电信" onChange={(event) => setTelecomChannel(event.target.value)} options={channelOptions.telecom} required value={telecomChannel} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ui-detail-section">
|
||||
<div className="ui-detail-section__header">
|
||||
<h3>安全策略</h3>
|
||||
</div>
|
||||
<div className="admin-app-form-grid">
|
||||
<Input label="日发送数量限制" onChange={(event) => setDailyLimit(event.target.value)} placeholder="100000" required value={dailyLimit} />
|
||||
<Input label="每号码日发送频次限制" onChange={(event) => setPhoneDailyLimit(event.target.value)} placeholder="10" required value={phoneDailyLimit} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ui-detail-section">
|
||||
<div className="ui-detail-section__header">
|
||||
<h3>接口配置</h3>
|
||||
</div>
|
||||
<div className="admin-app-form-grid">
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<span>彩信接口</span>
|
||||
<div className="radio-row">
|
||||
<label>
|
||||
<input checked={mmsEnabled} onChange={() => setMmsEnabled(true)} type="radio" />
|
||||
开通
|
||||
</label>
|
||||
<label>
|
||||
<input checked={!mmsEnabled} onChange={() => setMmsEnabled(false)} type="radio" />
|
||||
关闭
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<Input label="IP地址" onChange={(event) => setIpAddress(event.target.value)} placeholder="请输入 IP 地址" required value={ipAddress} />
|
||||
<Input label="连接数" onChange={(event) => setConnectionCount(event.target.value)} placeholder="请输入连接数" required value={connectionCount} />
|
||||
<Input
|
||||
label="企业代码"
|
||||
onChange={(event) => setEnterpriseCode(event.target.value)}
|
||||
required
|
||||
suffix={<Button icon={<RefreshCw size={14} />} onClick={() => setEnterpriseCode(generateCode('ME'))} size="sm" variant="ghost">生成</Button>}
|
||||
value={enterpriseCode}
|
||||
/>
|
||||
<Input label="接口账号" onChange={(event) => setInterfaceAccount(event.target.value)} required value={interfaceAccount} />
|
||||
<Input
|
||||
label="接口密码"
|
||||
onChange={(event) => setInterfacePassword(event.target.value)}
|
||||
required
|
||||
suffix={<Button icon={<RefreshCw size={14} />} onClick={() => setInterfacePassword(generateCode('MP'))} size="sm" variant="ghost">生成</Button>}
|
||||
value={interfacePassword}
|
||||
/>
|
||||
<Input label="接入号" onChange={(event) => setAccessNumber(event.target.value)} placeholder="请输入接入号" required value={accessNumber} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="enterprise-form-footer">
|
||||
<Button onClick={submit}>确认</Button>
|
||||
<Button onClick={goBack} variant="ghost">返回</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Ban, Pencil, Plus, Power, Search, Trash2 } from 'lucide-react';
|
||||
import { Button, Input, Modal, Pagination, Select, Table, Tag, Textarea } from '@/components/ui';
|
||||
import type { TableColumn } from '@/components/ui';
|
||||
|
||||
type Carrier = 'mobile' | 'unicom' | 'telecom';
|
||||
type MmsChannelStatus = 'active' | 'inactive';
|
||||
|
||||
type MmsChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
carrier: Carrier;
|
||||
endpoint: string;
|
||||
status: MmsChannelStatus;
|
||||
unitPrice: number;
|
||||
priority: number;
|
||||
dailyLimit: number;
|
||||
description: string;
|
||||
total: number;
|
||||
successRate: number;
|
||||
successCount: number;
|
||||
unknownRate: number;
|
||||
unknownCount: number;
|
||||
failureRate: number;
|
||||
failureCount: number;
|
||||
};
|
||||
|
||||
type MmsChannelModalState = {
|
||||
mode: 'create' | 'edit';
|
||||
channel?: MmsChannel;
|
||||
};
|
||||
|
||||
const carrierOptions = [
|
||||
{ label: '全部运营商', value: 'all' },
|
||||
{ label: '移动', value: 'mobile' },
|
||||
{ label: '联通', value: 'unicom' },
|
||||
{ label: '电信', value: 'telecom' },
|
||||
];
|
||||
|
||||
const formCarrierOptions = carrierOptions.slice(1);
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '正常', value: 'active' },
|
||||
{ label: '停用', value: 'inactive' },
|
||||
];
|
||||
|
||||
const formStatusOptions = statusOptions.slice(1);
|
||||
|
||||
const carrierMeta: Record<Carrier, { label: string; tone: 'info' | 'danger' | 'success' }> = {
|
||||
mobile: { label: '移动', tone: 'info' },
|
||||
unicom: { label: '联通', tone: 'danger' },
|
||||
telecom: { label: '电信', tone: 'success' },
|
||||
};
|
||||
|
||||
const initialChannels: MmsChannel[] = [
|
||||
{ id: 'mmschannel001', name: '移动彩信通道A', carrier: 'mobile', endpoint: 'https://mms-api.example.com/mobile/a', status: 'active', unitPrice: 0.18, priority: 1, dailyLimit: 10000, description: '移动主通道', total: 5240, successRate: 92.5, successCount: 4847, unknownRate: 3.2, unknownCount: 168, failureRate: 4.3, failureCount: 225 },
|
||||
{ id: 'mmschannel002', name: '联通彩信通道A', carrier: 'unicom', endpoint: 'https://mms-api.example.com/unicom/a', status: 'active', unitPrice: 0.16, priority: 2, dailyLimit: 8000, description: '联通主通道', total: 3820, successRate: 89.8, successCount: 3431, unknownRate: 5.1, unknownCount: 195, failureRate: 5.1, failureCount: 194 },
|
||||
{ id: 'mmschannel003', name: '电信彩信通道A', carrier: 'telecom', endpoint: 'https://mms-api.example.com/telecom/a', status: 'active', unitPrice: 0.2, priority: 1, dailyLimit: 12000, description: '电信主通道', total: 6580, successRate: 94.2, successCount: 6200, unknownRate: 2.8, unknownCount: 184, failureRate: 3, failureCount: 196 },
|
||||
{ id: 'mmschannel004', name: '移动彩信通道B', carrier: 'mobile', endpoint: 'https://mms-api.example.com/mobile/b', status: 'inactive', unitPrice: 0.19, priority: 3, dailyLimit: 5000, description: '移动备用通道', total: 0, successRate: 0, successCount: 0, unknownRate: 0, unknownCount: 0, failureRate: 0, failureCount: 0 },
|
||||
];
|
||||
|
||||
function Metric({ label, rate, count, tone }: { label: string; rate: number; count: number; tone: 'success' | 'warning' | 'danger' }) {
|
||||
return (
|
||||
<span className={`mms-channel-metric mms-channel-metric--${tone}`}>
|
||||
<small>{label}</small>
|
||||
<strong>{rate}%</strong>
|
||||
<b>{count.toLocaleString('zh-CN')}</b>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MmsChannelFormModal({
|
||||
modal,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
modal: MmsChannelModalState;
|
||||
onClose: () => void;
|
||||
onSubmit: (channel: MmsChannel) => void;
|
||||
}) {
|
||||
const channel = modal.channel;
|
||||
const [name, setName] = useState(channel?.name ?? '');
|
||||
const [carrier, setCarrier] = useState<Carrier>(channel?.carrier ?? 'mobile');
|
||||
const [endpoint, setEndpoint] = useState(channel?.endpoint ?? '');
|
||||
const [status, setStatus] = useState<MmsChannelStatus>(channel?.status ?? 'active');
|
||||
const [priority, setPriority] = useState(String(channel?.priority ?? 1));
|
||||
const [dailyLimit, setDailyLimit] = useState(String(channel?.dailyLimit ?? 10000));
|
||||
const [unitPrice, setUnitPrice] = useState(String(channel?.unitPrice ?? 0.18));
|
||||
const [description, setDescription] = useState(channel?.description ?? '');
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const nameError = submitted && !name.trim() ? '请输入通道名称' : '';
|
||||
const endpointError = submitted && !endpoint.trim() ? '请输入 API 端点' : '';
|
||||
|
||||
function submit() {
|
||||
setSubmitted(true);
|
||||
if (!name.trim() || !endpoint.trim()) return;
|
||||
onSubmit({
|
||||
id: channel?.id ?? `mmschannel${String(Date.now()).slice(-4)}`,
|
||||
name: name.trim(),
|
||||
carrier,
|
||||
endpoint: endpoint.trim(),
|
||||
status,
|
||||
unitPrice: Number(unitPrice || 0),
|
||||
priority: Number(priority || 1),
|
||||
dailyLimit: Number(dailyLimit || 0),
|
||||
description,
|
||||
total: channel?.total ?? 0,
|
||||
successRate: channel?.successRate ?? 0,
|
||||
successCount: channel?.successCount ?? 0,
|
||||
unknownRate: channel?.unknownRate ?? 0,
|
||||
unknownCount: channel?.unknownCount ?? 0,
|
||||
failureRate: channel?.failureRate ?? 0,
|
||||
failureCount: channel?.failureCount ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button onClick={submit}>保存</Button></>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="mms-channel-modal-title"><h2>{modal.mode === 'create' ? '添加彩信通道' : '编辑彩信通道'}</h2><p>{modal.mode === 'create' ? '添加新的彩信发送通道' : '修改彩信发送通道配置'}</p></div>}
|
||||
>
|
||||
<div className="mms-channel-form">
|
||||
<Input error={nameError} label="通道名称 *" onChange={(event) => setName(event.target.value)} placeholder="如:移动彩信通道A" value={name} />
|
||||
<Select label="运营商" onChange={(event) => setCarrier(event.target.value as Carrier)} options={formCarrierOptions} value={carrier} />
|
||||
<Input className="mms-channel-form__wide" error={endpointError} label="API 端点 *" onChange={(event) => setEndpoint(event.target.value)} placeholder="https://api.example.com/v1/mms" value={endpoint} />
|
||||
<Select label="状态" onChange={(event) => setStatus(event.target.value as MmsChannelStatus)} options={formStatusOptions} value={status} />
|
||||
<Input label="优先级" min="1" onChange={(event) => setPriority(event.target.value)} type="number" value={priority} />
|
||||
<Input label="日限额" min="0" onChange={(event) => setDailyLimit(event.target.value)} type="number" value={dailyLimit} />
|
||||
<Input label="成本单价(元)" min="0" onChange={(event) => setUnitPrice(event.target.value)} step="0.01" type="number" value={unitPrice} />
|
||||
<Textarea className="mms-channel-form__wide" label="描述" onChange={(event) => setDescription(event.target.value)} placeholder="请输入通道描述" rows={4} value={description} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminMmsChannelsPage() {
|
||||
const [channels, setChannels] = useState(initialChannels);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [carrier, setCarrier] = useState('all');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [modal, setModal] = useState<MmsChannelModalState | null>(null);
|
||||
|
||||
const filteredChannels = useMemo(() => channels.filter((channel) => (
|
||||
(!keyword || `${channel.id}${channel.name}${channel.endpoint}`.toLowerCase().includes(keyword.toLowerCase()))
|
||||
&& (carrier === 'all' || channel.carrier === carrier)
|
||||
&& (status === 'all' || channel.status === status)
|
||||
)), [carrier, channels, keyword, status]);
|
||||
|
||||
function upsertChannel(nextChannel: MmsChannel) {
|
||||
setChannels((items) => items.some((item) => item.id === nextChannel.id)
|
||||
? items.map((item) => item.id === nextChannel.id ? nextChannel : item)
|
||||
: [nextChannel, ...items]);
|
||||
setModal(null);
|
||||
}
|
||||
|
||||
function toggleChannel(id: string) {
|
||||
setChannels((items) => items.map((item) => item.id === id
|
||||
? { ...item, status: item.status === 'active' ? 'inactive' : 'active' }
|
||||
: item));
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<MmsChannel>> = [
|
||||
{ key: 'identity', title: '通道信息', width: '220px', render: (item) => <div className="mms-channel-identity"><strong>{item.name}</strong><span>{item.id}</span><small>{item.endpoint}</small></div> },
|
||||
{ key: 'carrier', title: '运营商 / 成本', width: '112px', render: (item) => <div className="mms-channel-carrier"><Tag tone={carrierMeta[item.carrier].tone}>{carrierMeta[item.carrier].label}</Tag><strong>¥{item.unitPrice.toFixed(2)}</strong></div> },
|
||||
{ key: 'status', title: '状态', width: '86px', render: (item) => <Tag tone={item.status === 'active' ? 'success' : 'neutral'}>{item.status === 'active' ? '正常' : '停用'}</Tag> },
|
||||
{ key: 'total', title: '今日总数', width: '90px', render: (item) => <strong>{item.total.toLocaleString('zh-CN')}</strong> },
|
||||
{ key: 'quality', title: '今日发送质量', width: '260px', render: (item) => <div className="mms-channel-quality"><Metric count={item.successCount} label="成功" rate={item.successRate} tone="success" /><Metric count={item.unknownCount} label="未知" rate={item.unknownRate} tone="warning" /><Metric count={item.failureCount} label="失败" rate={item.failureRate} tone="danger" /></div> },
|
||||
{ key: 'actions', title: '操作', align: 'right', width: '140px', render: (item) => <div className="mms-channel-actions"><Button aria-label={item.status === 'active' ? '停用' : '启用'} icon={item.status === 'active' ? <Ban size={16} /> : <Power size={16} />} iconOnly onClick={() => toggleChannel(item.id)} title={item.status === 'active' ? '停用' : '启用'} variant="ghost">{item.status === 'active' ? '停用' : '启用'}</Button><Button aria-label="编辑" icon={<Pencil size={16} />} iconOnly onClick={() => setModal({ mode: 'edit', channel: item })} title="编辑" variant="ghost">编辑</Button><Button aria-label="删除" icon={<Trash2 size={16} />} iconOnly onClick={() => setChannels((items) => items.filter((channel) => channel.id !== item.id))} title="删除" variant="danger">删除</Button></div> },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="page-stack mms-channel-page">
|
||||
<div className="page-heading">
|
||||
<div><div className="breadcrumb-line"><strong>彩信通道管理</strong></div></div>
|
||||
<Button icon={<Plus size={16} />} onClick={() => setModal({ mode: 'create' })}>添加通道</Button>
|
||||
</div>
|
||||
|
||||
<div className="surface mms-channel-filter">
|
||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索通道名称、ID 或 API 端点" prefix={<Search size={16} />} value={keyword} />
|
||||
<Select onChange={(event) => setCarrier(event.target.value)} options={carrierOptions} value={carrier} />
|
||||
<Select onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
|
||||
<Button onClick={() => { setKeyword(''); setCarrier('all'); setStatus('all'); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
|
||||
<div className="surface mms-channel-list">
|
||||
<Table columns={columns} data={filteredChannels} emptyText="暂无符合条件的彩信通道" rowKey="id" />
|
||||
<Pagination total={filteredChannels.length} />
|
||||
</div>
|
||||
|
||||
{modal ? <MmsChannelFormModal modal={modal} onClose={() => setModal(null)} onSubmit={upsertChannel} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Download, Eye, Search, Smartphone } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
DateRangeInput,
|
||||
Input,
|
||||
Modal,
|
||||
Pagination,
|
||||
Select,
|
||||
type DateRangeValue,
|
||||
} from '@/components/ui';
|
||||
|
||||
type SendStatus = 'success' | 'unknown' | 'failed';
|
||||
|
||||
type MmsRecord = {
|
||||
id: string;
|
||||
enterprise: string;
|
||||
application: string;
|
||||
submittedAt: string;
|
||||
templateName: string;
|
||||
templateId: string;
|
||||
frames: number;
|
||||
sizeKb: number;
|
||||
title: string;
|
||||
content: string;
|
||||
image: string;
|
||||
phone: string;
|
||||
carrier: string;
|
||||
region: string;
|
||||
channel: string;
|
||||
status: SendStatus;
|
||||
receiptAt?: string;
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<SendStatus, string> = {
|
||||
success: '发送成功',
|
||||
unknown: '未知',
|
||||
failed: '失败',
|
||||
};
|
||||
|
||||
const statusDotClassMap: Record<SendStatus, string> = {
|
||||
success: 'is-success',
|
||||
unknown: 'is-unknown',
|
||||
failed: 'is-failed',
|
||||
};
|
||||
|
||||
const recordsSeed: MmsRecord[] = [
|
||||
{
|
||||
id: 'MMSR202512310001',
|
||||
enterprise: '四川骠骑企业管理',
|
||||
application: '应用1',
|
||||
submittedAt: '2025-12-31 18:00:02',
|
||||
templateName: '春节促销活动模板',
|
||||
templateId: 'TPL001',
|
||||
frames: 3,
|
||||
sizeKb: 856,
|
||||
title: '春节促销活动模板',
|
||||
content: '【骠骑科技】春节促销活动开始啦!精选商品低至5折,多重优惠叠加,点击查看活动详情。',
|
||||
image: 'https://images.unsplash.com/photo-1519671482749-fd09be7ccebf?auto=format&fit=crop&w=500&q=80',
|
||||
phone: '13675569095',
|
||||
carrier: '中国移动',
|
||||
region: '成都',
|
||||
channel: '移动彩信通道A - mmschannel001',
|
||||
status: 'success',
|
||||
receiptAt: '2025-12-31 18:01:02',
|
||||
},
|
||||
{
|
||||
id: 'MMSR202512310002',
|
||||
enterprise: '重庆进载数智',
|
||||
application: '应用2',
|
||||
submittedAt: '2025-12-31 11:49:10',
|
||||
templateName: '产品发布会邀请函',
|
||||
templateId: 'TPL002',
|
||||
frames: 3,
|
||||
sizeKb: 1245,
|
||||
title: '产品发布会邀请函',
|
||||
content: '【进载数智】诚邀您参加新品发布会,现场将展示全新智能终端与行业解决方案。',
|
||||
image: 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=500&q=80',
|
||||
phone: '18607638087',
|
||||
carrier: '中国联通',
|
||||
region: '重庆',
|
||||
channel: '联通彩信通道A - mmschannel002',
|
||||
status: 'unknown',
|
||||
},
|
||||
{
|
||||
id: 'MMSR202512310003',
|
||||
enterprise: '行业',
|
||||
application: '应用3',
|
||||
submittedAt: '2025-12-31 11:49:08',
|
||||
templateName: '会员积分兑换通知',
|
||||
templateId: 'TPL003',
|
||||
frames: 2,
|
||||
sizeKb: 512,
|
||||
title: '会员积分兑换通知',
|
||||
content: '【会员中心】您的积分可兑换多款权益礼包,请及时查看并领取。',
|
||||
image: 'https://images.unsplash.com/photo-1567427017947-545c5f8d16ad?auto=format&fit=crop&w=500&q=80',
|
||||
phone: '15012345678',
|
||||
carrier: '未知',
|
||||
region: '未知',
|
||||
channel: '',
|
||||
status: 'unknown',
|
||||
},
|
||||
{
|
||||
id: 'MMSR202512310004',
|
||||
enterprise: '超感世纪互三网',
|
||||
application: '应用4',
|
||||
submittedAt: '2025-12-31 11:47:23',
|
||||
templateName: '理财产品推荐',
|
||||
templateId: 'TPL004',
|
||||
frames: 3,
|
||||
sizeKb: 980,
|
||||
title: '理财产品推荐',
|
||||
content: '【南京邮银】为您推荐全新理财产品,图文详情请查看彩信内容。',
|
||||
image: 'https://images.unsplash.com/photo-1607083206968-13611e3d76db?auto=format&fit=crop&w=500&q=80',
|
||||
phone: '15250668026',
|
||||
carrier: '中国电信',
|
||||
region: '南京',
|
||||
channel: '电信彩信通道A - mmschannel003',
|
||||
status: 'failed',
|
||||
receiptAt: '2025-12-31 11:48:23',
|
||||
},
|
||||
{
|
||||
id: 'MMSR202512310005',
|
||||
enterprise: '行业',
|
||||
application: '应用5',
|
||||
submittedAt: '2025-12-31 11:45:18',
|
||||
templateName: '招聘信息模板',
|
||||
templateId: 'TPL005',
|
||||
frames: 2,
|
||||
sizeKb: 640,
|
||||
title: '招聘信息模板',
|
||||
content: '【招聘中心】岗位热招中,欢迎投递简历,查看岗位详情和福利待遇。',
|
||||
image: 'https://images.unsplash.com/photo-1484480974693-6ca0a78fb36b?auto=format&fit=crop&w=500&q=80',
|
||||
phone: '13800138000',
|
||||
carrier: '中国移动',
|
||||
region: '上海',
|
||||
channel: '移动彩信通道A - mmschannel001',
|
||||
status: 'success',
|
||||
receiptAt: '2025-12-31 11:46:12',
|
||||
},
|
||||
];
|
||||
|
||||
function getDate(value: string) {
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
function StatusLine({ status }: { status: SendStatus }) {
|
||||
return (
|
||||
<span className="admin-sms-record-status">
|
||||
<i className={statusDotClassMap[status]} />
|
||||
{statusLabelMap[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewModal({ record, onClose }: { record: MmsRecord; onClose: () => void }) {
|
||||
return (
|
||||
<Modal
|
||||
footer={<Button onClick={onClose}>关闭</Button>}
|
||||
onClose={onClose}
|
||||
open
|
||||
title={<div className="template-modal-title"><h2>彩信预览</h2><p>{record.templateName}</p></div>}
|
||||
>
|
||||
<div className="mms-preview">
|
||||
<img alt={record.title} src={record.image} />
|
||||
<h3>{record.title}</h3>
|
||||
<p>{record.content}</p>
|
||||
<div className="mms-preview-frames">
|
||||
<span>{record.frames} 帧</span>
|
||||
<span>{record.sizeKb}KB</span>
|
||||
<span>{record.templateId}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminMmsRecordsPage() {
|
||||
const [enterprise, setEnterprise] = useState('all');
|
||||
const [application, setApplication] = useState('all');
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [phoneKeyword, setPhoneKeyword] = useState('');
|
||||
const [templateKeyword, setTemplateKeyword] = useState('');
|
||||
const [channelKeyword, setChannelKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [previewRecord, setPreviewRecord] = useState<MmsRecord | null>(null);
|
||||
|
||||
const enterpriseOptions = useMemo(() => {
|
||||
const names = Array.from(new Set(recordsSeed.map((item) => item.enterprise)));
|
||||
return [{ label: '全部企业', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
|
||||
}, []);
|
||||
|
||||
const applicationOptions = useMemo(() => {
|
||||
const names = Array.from(new Set(recordsSeed.filter((item) => enterprise === 'all' || item.enterprise === enterprise).map((item) => item.application)));
|
||||
return [{ label: '全部应用', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
|
||||
}, [enterprise]);
|
||||
|
||||
const filteredRows = useMemo(
|
||||
() => recordsSeed.filter((item) => {
|
||||
const submittedDate = getDate(item.submittedAt);
|
||||
const matchesEnterprise = enterprise === 'all' || item.enterprise === enterprise;
|
||||
const matchesApplication = application === 'all' || item.application === application;
|
||||
const matchesStartDate = !dateRange.start || submittedDate >= dateRange.start;
|
||||
const matchesEndDate = !dateRange.end || submittedDate <= dateRange.end;
|
||||
const matchesPhone = !phoneKeyword || item.phone.includes(phoneKeyword);
|
||||
const matchesTemplate = !templateKeyword || item.templateName.includes(templateKeyword) || item.templateId.includes(templateKeyword);
|
||||
const matchesChannel = !channelKeyword || item.channel.includes(channelKeyword);
|
||||
const matchesStatus = status === 'all' || item.status === status;
|
||||
return matchesEnterprise && matchesApplication && matchesStartDate && matchesEndDate && matchesPhone && matchesTemplate && matchesChannel && matchesStatus;
|
||||
}),
|
||||
[application, channelKeyword, dateRange.end, dateRange.start, enterprise, phoneKeyword, status, templateKeyword],
|
||||
);
|
||||
|
||||
function resetFilters() {
|
||||
setEnterprise('all');
|
||||
setApplication('all');
|
||||
setDateRange({});
|
||||
setPhoneKeyword('');
|
||||
setTemplateKeyword('');
|
||||
setChannelKeyword('');
|
||||
setStatus('all');
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-sms-records-page admin-mms-records-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<div className="breadcrumb-line">数据详单 / <strong>彩信记录</strong></div>
|
||||
<h1>彩信记录</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-sms-record-filter">
|
||||
<Select
|
||||
label="企业"
|
||||
onChange={(event) => {
|
||||
setEnterprise(event.target.value);
|
||||
setApplication('all');
|
||||
}}
|
||||
options={enterpriseOptions}
|
||||
value={enterprise}
|
||||
/>
|
||||
<Select label="应用" onChange={(event) => setApplication(event.target.value)} options={applicationOptions} value={application} />
|
||||
<DateRangeInput label="提交日期" onChange={setDateRange} value={dateRange} />
|
||||
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} prefix={<Smartphone size={16} />} value={phoneKeyword} />
|
||||
<Input label="彩信模板名称" onChange={(event) => setTemplateKeyword(event.target.value)} value={templateKeyword} />
|
||||
<Input label="通道名称" onChange={(event) => setChannelKeyword(event.target.value)} value={channelKeyword} />
|
||||
<Select
|
||||
label="发送状态"
|
||||
onChange={(event) => setStatus(event.target.value)}
|
||||
options={[
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '发送成功', value: 'success' },
|
||||
{ label: '未知', value: 'unknown' },
|
||||
{ label: '失败', value: 'failed' },
|
||||
]}
|
||||
value={status}
|
||||
/>
|
||||
<div className="admin-sms-record-filter__actions">
|
||||
<Button className="admin-mms-record-search-button" icon={<Search size={16} />}>查询</Button>
|
||||
<Button onClick={resetFilters} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-sms-record-table-card admin-mms-record-table-card">
|
||||
<div className="admin-sms-record-toolbar">
|
||||
<Button icon={<Download size={16} />} variant="ghost">导出CSV</Button>
|
||||
</div>
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table admin-sms-record-table admin-mms-record-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '190px' }}>发送者</th>
|
||||
<th style={{ width: '260px' }}>彩信模板名称</th>
|
||||
<th style={{ width: '180px' }}>手机号码</th>
|
||||
<th>通道与发送状态</th>
|
||||
<th style={{ textAlign: 'right', width: '160px' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRows.length === 0 ? (
|
||||
<tr>
|
||||
<td className="ui-table__empty" colSpan={5}>暂无彩信记录</td>
|
||||
</tr>
|
||||
) : filteredRows.map((record) => (
|
||||
<tr key={record.id}>
|
||||
<td>
|
||||
<div className="admin-sms-record-sender">
|
||||
<strong>{record.enterprise}</strong>
|
||||
<span>{record.application}</span>
|
||||
<small>{record.submittedAt.slice(0, 10)} {record.submittedAt.slice(11)}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-mms-template-cell">
|
||||
<strong>{record.templateName}</strong>
|
||||
<span>ID: {record.templateId}</span>
|
||||
<small>{record.frames} 帧 · {record.sizeKb}KB</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-sms-record-phone">
|
||||
<strong>{record.phone}</strong>
|
||||
<span>{record.region} {record.carrier}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-sms-record-channel">
|
||||
{record.channel ? <strong>{record.channel}</strong> : null}
|
||||
<StatusLine status={record.status} />
|
||||
{record.receiptAt ? <span>{record.receiptAt}</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<button className="admin-mms-preview-link" onClick={() => setPreviewRecord(record)} type="button">
|
||||
<Eye size={16} />
|
||||
模板预览
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination total={filteredRows.length} />
|
||||
</div>
|
||||
|
||||
{previewRecord ? <PreviewModal onClose={() => setPreviewRecord(null)} record={previewRecord} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { BarChart3, CalendarClock, Eye, FileImage, ImageIcon, Search, TrendingUp } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
DateRangeInput,
|
||||
DetailInfoGrid,
|
||||
DetailProgressStats,
|
||||
DetailSection,
|
||||
DetailTitle,
|
||||
getRateTone,
|
||||
Input,
|
||||
Modal,
|
||||
Pagination,
|
||||
ProgressBar,
|
||||
RateCard,
|
||||
RateOverview,
|
||||
Select,
|
||||
Table,
|
||||
Tag,
|
||||
type DateRangeValue,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
|
||||
type MmsTaskStatus = 'completed' | 'sending' | 'terminated' | 'failed';
|
||||
type SendType = 'immediate' | 'scheduled';
|
||||
|
||||
type CarrierStat = {
|
||||
name: string;
|
||||
success: number;
|
||||
total: number;
|
||||
rate: number;
|
||||
};
|
||||
|
||||
type CityStat = {
|
||||
city: string;
|
||||
total: number;
|
||||
success: number;
|
||||
};
|
||||
|
||||
type MmsTask = {
|
||||
id: string;
|
||||
enterprise: string;
|
||||
applicationName: string;
|
||||
submittedAt: string;
|
||||
title: string;
|
||||
content: string;
|
||||
image: string;
|
||||
attachment: string;
|
||||
phoneCount: number;
|
||||
sentCount: number;
|
||||
totalCount: number;
|
||||
sendType: SendType;
|
||||
scheduledAt?: string;
|
||||
status: MmsTaskStatus;
|
||||
carrierStats: CarrierStat[];
|
||||
cityStats: CityStat[];
|
||||
};
|
||||
|
||||
const statusToneMap: Record<MmsTaskStatus, 'success' | 'info' | 'neutral' | 'danger'> = {
|
||||
completed: 'success',
|
||||
sending: 'info',
|
||||
terminated: 'neutral',
|
||||
failed: 'danger',
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<MmsTaskStatus, string> = {
|
||||
completed: '已完成',
|
||||
sending: '发送中',
|
||||
terminated: '已终止',
|
||||
failed: '失败',
|
||||
};
|
||||
|
||||
const sendTypeLabels: Record<SendType, string> = {
|
||||
immediate: '立即发送',
|
||||
scheduled: '定时发送',
|
||||
};
|
||||
|
||||
const defaultCarrierStats: CarrierStat[] = [
|
||||
{ name: '中国移动', success: 1450, total: 1502, rate: 96.5 },
|
||||
{ name: '中国联通', success: 1138, total: 1200, rate: 94.8 },
|
||||
{ name: '中国电信', success: 762, total: 800, rate: 95.2 },
|
||||
];
|
||||
|
||||
const defaultCityStats: CityStat[] = [
|
||||
{ city: '成都', total: 1250, success: 1200 },
|
||||
{ city: '重庆', total: 1000, success: 950 },
|
||||
{ city: '绵阳', total: 600, success: 570 },
|
||||
{ city: '泸州', total: 500, success: 475 },
|
||||
{ city: '宜宾', total: 300, success: 285 },
|
||||
];
|
||||
|
||||
const taskSeed: MmsTask[] = [
|
||||
{
|
||||
id: 'MMS202603120001',
|
||||
enterprise: '四川骠骑企业管理',
|
||||
applicationName: '营销应用1',
|
||||
submittedAt: '2026-03-12 09:30:15',
|
||||
title: '春季新品发布会邀请函',
|
||||
content: '【骠骑科技】尊敬的客户,我们诚挚邀请您参加春季新品发布会,现场将展示多款创新产品,精彩活动等您参与!活动时间:3月20日下午2点,期待您的光临!',
|
||||
image: 'https://images.unsplash.com/photo-1519671482749-fd09be7ccebf?auto=format&fit=crop&w=500&q=80',
|
||||
attachment: '3张图片',
|
||||
phoneCount: 5000,
|
||||
sentCount: 3500,
|
||||
totalCount: 5000,
|
||||
sendType: 'immediate',
|
||||
status: 'sending',
|
||||
carrierStats: defaultCarrierStats,
|
||||
cityStats: defaultCityStats,
|
||||
},
|
||||
{
|
||||
id: 'MMS202603120002',
|
||||
enterprise: '重庆进载数智',
|
||||
applicationName: '通知应用',
|
||||
submittedAt: '2026-03-12 10:15:00',
|
||||
title: '会员升级通知',
|
||||
content: '【进载数智】尊敬的VIP会员,恭喜您的会员等级已升级至钻石级别!查看您的专属权益,享受更多优质服务。',
|
||||
image: 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=500&q=80',
|
||||
attachment: '2张图片',
|
||||
phoneCount: 3000,
|
||||
sentCount: 3000,
|
||||
totalCount: 3000,
|
||||
sendType: 'scheduled',
|
||||
scheduledAt: '2026-03-13 08:00:00',
|
||||
status: 'completed',
|
||||
carrierStats: defaultCarrierStats,
|
||||
cityStats: defaultCityStats,
|
||||
},
|
||||
{
|
||||
id: 'MMS202603120003',
|
||||
enterprise: '超感世纪三三网',
|
||||
applicationName: '推广应用2',
|
||||
submittedAt: '2026-03-12 11:20:00',
|
||||
title: '限时优惠活动',
|
||||
content: '【超感世纪】春季大促来袭!全场商品5折起,精选商品低至3折!更有满减优惠,买一送一活动等您参与。',
|
||||
image: 'https://images.unsplash.com/photo-1607083206968-13611e3d76db?auto=format&fit=crop&w=500&q=80',
|
||||
attachment: '4张图片',
|
||||
phoneCount: 8000,
|
||||
sentCount: 6000,
|
||||
totalCount: 8000,
|
||||
sendType: 'immediate',
|
||||
status: 'sending',
|
||||
carrierStats: defaultCarrierStats,
|
||||
cityStats: defaultCityStats,
|
||||
},
|
||||
{
|
||||
id: 'MMS202603120004',
|
||||
enterprise: '重庆香惠慧',
|
||||
applicationName: '客服应用',
|
||||
submittedAt: '2026-03-12 14:05:00',
|
||||
title: '产品使用指南',
|
||||
content: '【香惠慧】感谢您选择我们的产品!为了帮助您更好地了解和使用我们的服务,特为您准备了产品使用指南。',
|
||||
image: 'https://images.unsplash.com/photo-1484480974693-6ca0a78fb36b?auto=format&fit=crop&w=500&q=80',
|
||||
attachment: '1张图片',
|
||||
phoneCount: 2000,
|
||||
sentCount: 2000,
|
||||
totalCount: 2000,
|
||||
sendType: 'scheduled',
|
||||
scheduledAt: '2026-03-12 16:00:00',
|
||||
status: 'completed',
|
||||
carrierStats: defaultCarrierStats,
|
||||
cityStats: defaultCityStats,
|
||||
},
|
||||
{
|
||||
id: 'MMS202603120005',
|
||||
enterprise: '四川骠骑企业管理',
|
||||
applicationName: '活动推广',
|
||||
submittedAt: '2026-03-12 15:30:00',
|
||||
title: '周末特惠活动',
|
||||
content: '【骠骑科技】周末特惠活动开始啦!精美图片抢先看,超值优惠不容错过!点击查看活动详情。',
|
||||
image: 'https://images.unsplash.com/photo-1607082350899-7e105aa886ae?auto=format&fit=crop&w=500&q=80',
|
||||
attachment: '3张图片',
|
||||
phoneCount: 4000,
|
||||
sentCount: 1000,
|
||||
totalCount: 4000,
|
||||
sendType: 'immediate',
|
||||
status: 'terminated',
|
||||
carrierStats: defaultCarrierStats,
|
||||
cityStats: defaultCityStats,
|
||||
},
|
||||
{
|
||||
id: 'MMS202603110006',
|
||||
enterprise: '重庆进载数智',
|
||||
applicationName: '系统通知',
|
||||
submittedAt: '2026-03-11 17:45:00',
|
||||
title: '系统维护通知',
|
||||
content: '【进载数智】系统维护通知:我们将于今晚进行系统升级,预计耗时2小时。维护期间部分服务可能受影响。',
|
||||
image: 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?auto=format&fit=crop&w=500&q=80',
|
||||
attachment: '1张图片',
|
||||
phoneCount: 6000,
|
||||
sentCount: 4000,
|
||||
totalCount: 6000,
|
||||
sendType: 'scheduled',
|
||||
scheduledAt: '2026-03-11 20:00:00',
|
||||
status: 'failed',
|
||||
carrierStats: defaultCarrierStats,
|
||||
cityStats: defaultCityStats,
|
||||
},
|
||||
];
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return value.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
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.96);
|
||||
}
|
||||
|
||||
if (task.status === 'failed') {
|
||||
return Math.round(task.sentCount * 0.82);
|
||||
}
|
||||
|
||||
return Math.round(task.sentCount * 0.95);
|
||||
}
|
||||
|
||||
function MmsContent({ task }: { task: MmsTask }) {
|
||||
return (
|
||||
<div className="mms-task-content">
|
||||
<img alt={task.title} src={task.image} />
|
||||
<div>
|
||||
<strong>{task.title}</strong>
|
||||
<p>{task.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskDetailModal({ task, onClose }: { task: MmsTask; onClose: () => void }) {
|
||||
const progress = getProgress(task);
|
||||
const deliveredCount = getDeliveredCount(task);
|
||||
const overallRate = (deliveredCount / task.totalCount) * 100;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={<Button onClick={onClose}>关闭</Button>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<DetailTitle title="彩信任务详情" subtitle="查看任务的详细信息和进度。" />}
|
||||
>
|
||||
<div className="task-detail admin-mms-task-detail">
|
||||
<DetailSection title="基本信息" extra={<Tag tone={statusToneMap[task.status]}>{statusLabelMap[task.status]}</Tag>}>
|
||||
<DetailInfoGrid
|
||||
items={[
|
||||
{ label: '任务编号', value: task.id },
|
||||
{ label: '企业名称', value: task.enterprise },
|
||||
{ label: '应用名称', value: task.applicationName },
|
||||
{ label: '提交时间', value: task.submittedAt },
|
||||
{ label: '发送方式', value: sendTypeLabels[task.sendType] },
|
||||
{ label: '号码数', value: `${formatNumber(task.phoneCount)} 个`, tone: 'primary' },
|
||||
{
|
||||
label: '彩信内容',
|
||||
value: (
|
||||
<div className="mms-detail-template">
|
||||
<img alt={task.title} src={task.image} />
|
||||
<div><strong>{task.title}</strong><p>{task.content}</p><small>附件:{task.attachment}</small></div>
|
||||
</div>
|
||||
),
|
||||
full: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="发送进度">
|
||||
<DetailProgressStats
|
||||
label="任务进度"
|
||||
meta={`已发送 ${formatNumber(task.sentCount)} / 总计 ${formatNumber(task.totalCount)}`}
|
||||
percent={progress}
|
||||
status={task.status === 'failed' ? 'terminated' : task.status}
|
||||
stats={[
|
||||
{ label: '提交总数量', value: formatNumber(task.totalCount) },
|
||||
{ label: '已处理数量', value: formatNumber(task.sentCount) },
|
||||
{ label: '发送成功数量', value: formatNumber(deliveredCount) },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={<><TrendingUp size={20} /> 按运营商统计成功率</>}>
|
||||
<RateOverview
|
||||
label="总体成功率"
|
||||
metrics={[
|
||||
{ label: '成功总数', value: formatNumber(deliveredCount) },
|
||||
{ label: '总计', value: formatNumber(task.totalCount) },
|
||||
]}
|
||||
rate={overallRate}
|
||||
tone={getRateTone(overallRate)}
|
||||
/>
|
||||
<div className="carrier-rate-grid">
|
||||
{task.carrierStats.map((item) => (
|
||||
<RateCard
|
||||
key={item.name}
|
||||
meta={<><span>{formatNumber(item.success)}</span><span>/ {formatNumber(item.total)}</span></>}
|
||||
rate={item.rate}
|
||||
title={item.name}
|
||||
tone={getRateTone(item.rate)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<h4>按城市统计成功率</h4>
|
||||
<div className="admin-mms-city-list">
|
||||
{task.cityStats.map((item) => {
|
||||
const rate = (item.success / item.total) * 100;
|
||||
return (
|
||||
<div key={item.city}>
|
||||
<strong>{item.city}</strong>
|
||||
<span>{formatNumber(item.success)} / {formatNumber(item.total)}</span>
|
||||
<b>{rate.toFixed(1)}%</b>
|
||||
<ProgressBar percent={rate} tone={getRateTone(rate)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DetailSection>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewModal({ task, onClose }: { task: MmsTask; onClose: () => void }) {
|
||||
return (
|
||||
<Modal
|
||||
footer={<Button onClick={onClose}>关闭</Button>}
|
||||
onClose={onClose}
|
||||
open
|
||||
title={<div className="template-modal-title"><h2>彩信预览</h2><p>{task.id}</p></div>}
|
||||
>
|
||||
<div className="mms-preview">
|
||||
<img alt={task.title} src={task.image} />
|
||||
<h3>{task.title}</h3>
|
||||
<p>{task.content}</p>
|
||||
<div className="mms-preview-frames"><span>{task.attachment}</span><span>图文彩信</span></div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminMmsTaskProgressPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [enterprise, setEnterprise] = useState('all');
|
||||
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 enterpriseOptions = useMemo(() => {
|
||||
const names = Array.from(new Set(taskSeed.map((item) => item.enterprise)));
|
||||
return [{ label: '全部企业', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
|
||||
}, []);
|
||||
|
||||
const applicationOptions = useMemo(() => {
|
||||
const names = Array.from(new Set(taskSeed.filter((item) => enterprise === 'all' || item.enterprise === enterprise).map((item) => item.applicationName)));
|
||||
return [{ label: '全部应用', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
|
||||
}, [enterprise]);
|
||||
|
||||
const filteredTasks = useMemo(
|
||||
() => taskSeed.filter((item) => {
|
||||
const submittedDate = item.submittedAt.slice(0, 10);
|
||||
const matchesKeyword = !keyword || item.id.includes(keyword);
|
||||
const matchesEnterprise = enterprise === 'all' || item.enterprise === enterprise;
|
||||
const matchesApplication = application === 'all' || item.applicationName === application;
|
||||
const matchesStartDate = !submittedDateRange.start || submittedDate >= submittedDateRange.start;
|
||||
const matchesEndDate = !submittedDateRange.end || submittedDate <= submittedDateRange.end;
|
||||
return matchesKeyword && matchesEnterprise && matchesApplication && matchesStartDate && matchesEndDate;
|
||||
}),
|
||||
[application, enterprise, keyword, submittedDateRange.end, submittedDateRange.start],
|
||||
);
|
||||
|
||||
function resetFilters() {
|
||||
setKeyword('');
|
||||
setEnterprise('all');
|
||||
setApplication('all');
|
||||
setSubmittedDateRange({});
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<MmsTask>> = [
|
||||
{ key: 'id', title: '任务编号', width: '150px', render: (record) => <strong className="admin-task-id">{record.id}</strong> },
|
||||
{
|
||||
key: 'enterprise',
|
||||
title: '企业/应用',
|
||||
width: '180px',
|
||||
render: (record) => (
|
||||
<div className="admin-task-enterprise">
|
||||
<strong>{record.enterprise}</strong>
|
||||
<span>{record.applicationName}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'submittedAt', title: '提交时间', width: '116px', render: (record) => <span>{record.submittedAt.slice(0, 10)}<br />{record.submittedAt.slice(11, 16)}</span> },
|
||||
{ key: 'content', title: '模板内容', width: '420px', render: (record) => <MmsContent task={record} /> },
|
||||
{ key: 'phoneCount', title: '号码数', align: 'right', width: '90px', render: (record) => <strong>{formatNumber(record.phoneCount)}</strong> },
|
||||
{
|
||||
key: 'sendType',
|
||||
title: '发送方式',
|
||||
width: '150px',
|
||||
render: (record) => (
|
||||
<div className="admin-task-send-type">
|
||||
<Tag tone={record.sendType === 'immediate' ? 'info' : 'warning'}>
|
||||
{record.sendType === 'scheduled' ? <CalendarClock size={13} /> : null}
|
||||
{sendTypeLabels[record.sendType]}
|
||||
</Tag>
|
||||
{record.scheduledAt ? <span>{record.scheduledAt.slice(0, 10)}<br />{record.scheduledAt.slice(11, 16)}</span> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'progress',
|
||||
title: '进度',
|
||||
width: '180px',
|
||||
render: (record) => {
|
||||
const progress = getProgress(record);
|
||||
return (
|
||||
<div className="batch-progress admin-task-list-progress">
|
||||
<div>
|
||||
<span>{formatNumber(record.sentCount)}/{formatNumber(record.totalCount)}</span>
|
||||
<strong>{progress}%</strong>
|
||||
</div>
|
||||
<div className="batch-progress__track">
|
||||
<span className={`batch-progress__bar batch-progress__bar--${record.status === 'failed' ? 'terminated' : record.status}`} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: 'status', title: '状态', width: '100px', render: (record) => <Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
width: '160px',
|
||||
render: (record) => (
|
||||
<div className="batch-actions mms-task-actions">
|
||||
<Button icon={<Eye size={14} />} onClick={() => setSelectedTask(record)} size="sm" variant="ghost">任务详情</Button>
|
||||
<Button icon={<ImageIcon size={14} />} onClick={() => setPreviewTask(record)} size="sm" variant="ghost">彩信预览</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-mms-task-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<div className="breadcrumb-line">发送任务 / <strong>彩信任务进度</strong></div>
|
||||
<h1>彩信任务进度</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-task-filter">
|
||||
<Input label="任务编号" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入任务编号" value={keyword} />
|
||||
<Select
|
||||
label="选择企业"
|
||||
onChange={(event) => {
|
||||
setEnterprise(event.target.value);
|
||||
setApplication('all');
|
||||
}}
|
||||
options={enterpriseOptions}
|
||||
value={enterprise}
|
||||
/>
|
||||
<Select label="选择应用" onChange={(event) => setApplication(event.target.value)} options={applicationOptions} value={application} />
|
||||
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
|
||||
<div className="admin-task-filter__actions">
|
||||
<Button icon={<Search size={16} />}>查询</Button>
|
||||
<Button onClick={resetFilters} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-task-table-card admin-mms-task-table-card">
|
||||
<Table columns={columns} data={filteredTasks} rowKey="id" />
|
||||
<Pagination total={filteredTasks.length} />
|
||||
</div>
|
||||
|
||||
{selectedTask ? <TaskDetailModal onClose={() => setSelectedTask(null)} task={selectedTask} /> : null}
|
||||
{previewTask ? <PreviewModal onClose={() => setPreviewTask(null)} task={previewTask} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Activity } from 'lucide-react';
|
||||
import { Button, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminService, type Channel } from '@/mock';
|
||||
|
||||
const columns: Array<TableColumn<Channel>> = [
|
||||
{ key: 'id', title: '通道编号', render: (record) => record.id },
|
||||
{ key: 'name', title: '通道名称', render: (record) => record.name },
|
||||
{ key: 'region', title: '区域', render: (record) => record.region },
|
||||
{ key: 'successRate', title: '成功率', render: (record) => `${record.successRate}%` },
|
||||
{ key: 'latencyMs', title: '平均延迟', render: (record) => `${record.latencyMs} ms` },
|
||||
{
|
||||
key: 'enabled',
|
||||
title: '状态',
|
||||
render: (record) => <Tag tone={record.enabled ? 'success' : 'danger'}>{record.enabled ? '运行中' : '已停用'}</Tag>,
|
||||
},
|
||||
];
|
||||
|
||||
export function AdminMonitorPage() {
|
||||
const channels = adminService.getChannels();
|
||||
const enabledChannels = channels.filter((item) => item.enabled).length;
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<p className="eyebrow">运营</p>
|
||||
<h1>发送监控</h1>
|
||||
</div>
|
||||
<Button icon={<Activity size={16} />} variant="ghost">实时刷新</Button>
|
||||
</div>
|
||||
<div className="dashboard-grid">
|
||||
<div className="surface metric-card">
|
||||
<span>运行通道</span>
|
||||
<strong>{enabledChannels}</strong>
|
||||
<small>共 {channels.length} 条通道</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>平均成功率</span>
|
||||
<strong>98.5%</strong>
|
||||
<small>近 1 小时</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>平均延迟</span>
|
||||
<strong>167 ms</strong>
|
||||
<small>全通道加权</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface">
|
||||
<Table columns={columns} data={channels} rowKey="id" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Button, Input, Modal, Select, Table, type TableColumn } from '@/components/ui';
|
||||
|
||||
type PhoneSegment = {
|
||||
id: string;
|
||||
segment: string;
|
||||
carrier: string;
|
||||
province: string;
|
||||
city: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
const initialSegments: PhoneSegment[] = [
|
||||
{ id: 'SEG20260630001', segment: '1367556', carrier: '中国移动', province: '四川省', city: '成都市', createdAt: '2026-06-11 09:30:12', updatedAt: '2026-06-28 15:40:00' },
|
||||
{ id: 'SEG20260630002', segment: '1860763', carrier: '中国联通', province: '重庆市', city: '重庆市', createdAt: '2026-06-12 10:18:44', updatedAt: '2026-06-27 11:22:13' },
|
||||
{ id: 'SEG20260630003', segment: '1525066', carrier: '中国电信', province: '江苏省', city: '南京市', createdAt: '2026-06-15 14:22:31', updatedAt: '2026-06-26 17:05:39' },
|
||||
{ id: 'SEG20260630004', segment: '1501234', carrier: '中国移动', province: '广东省', city: '深圳市', createdAt: '2026-06-18 16:10:25', updatedAt: '2026-06-24 09:15:26' },
|
||||
];
|
||||
|
||||
function createSegmentId() {
|
||||
return `SEG${Date.now()}`;
|
||||
}
|
||||
|
||||
type SegmentFormModalProps = {
|
||||
item?: PhoneSegment;
|
||||
onClose: () => void;
|
||||
onSubmit: (item: PhoneSegment) => void;
|
||||
};
|
||||
|
||||
function SegmentFormModal({ item, onClose, onSubmit }: SegmentFormModalProps) {
|
||||
const [form, setForm] = useState<PhoneSegment>(() => item ?? {
|
||||
id: createSegmentId(),
|
||||
segment: '',
|
||||
carrier: '中国移动',
|
||||
province: '',
|
||||
city: '',
|
||||
createdAt: '2026-06-30 10:00:00',
|
||||
updatedAt: '2026-06-30 10:00:00',
|
||||
});
|
||||
|
||||
function updateField<Key extends keyof PhoneSegment>(key: Key, value: PhoneSegment[Key]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button onClick={() => onSubmit({ ...form, updatedAt: '2026-06-30 10:00:00' })}>保存</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
title={item ? '编辑手机号段' : '新增手机号段'}
|
||||
>
|
||||
<div className="admin-system-modal-form">
|
||||
<Input label="手机号段" maxLength={7} onChange={(event) => updateField('segment', event.target.value)} placeholder="手机号码前7位" value={form.segment} />
|
||||
<Select
|
||||
label="运营商"
|
||||
onChange={(event) => updateField('carrier', event.target.value)}
|
||||
options={[
|
||||
{ label: '中国移动', value: '中国移动' },
|
||||
{ label: '中国联通', value: '中国联通' },
|
||||
{ label: '中国电信', value: '中国电信' },
|
||||
]}
|
||||
value={form.carrier}
|
||||
/>
|
||||
<Input label="省份" onChange={(event) => updateField('province', event.target.value)} value={form.province} />
|
||||
<Input label="城市" onChange={(event) => updateField('city', event.target.value)} value={form.city} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminPhoneSegmentsPage() {
|
||||
const [segments, setSegments] = useState(initialSegments);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [editingSegment, setEditingSegment] = useState<PhoneSegment | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const filteredSegments = useMemo(
|
||||
() => segments.filter((segment) => [segment.segment, segment.carrier, segment.province, segment.city].some((value) => value.includes(keyword))),
|
||||
[keyword, segments],
|
||||
);
|
||||
|
||||
function upsertSegment(nextSegment: PhoneSegment) {
|
||||
setSegments((current) => {
|
||||
const exists = current.some((item) => item.id === nextSegment.id);
|
||||
if (exists) {
|
||||
return current.map((item) => (item.id === nextSegment.id ? nextSegment : item));
|
||||
}
|
||||
|
||||
return [nextSegment, ...current];
|
||||
});
|
||||
setEditingSegment(null);
|
||||
setCreating(false);
|
||||
}
|
||||
|
||||
const columns = useMemo<Array<TableColumn<PhoneSegment>>>(() => [
|
||||
{ key: 'segment', title: '手机号段(手机号码前7位)', width: '230px', render: (record) => <strong>{record.segment}</strong> },
|
||||
{ key: 'carrier', title: '运营商', width: '150px', render: (record) => record.carrier },
|
||||
{ key: 'province', title: '省份', width: '140px', render: (record) => record.province },
|
||||
{ key: 'city', title: '城市', width: '140px', render: (record) => record.city },
|
||||
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => record.createdAt },
|
||||
{ key: 'updatedAt', title: '更新时间', width: '190px', render: (record) => record.updatedAt },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '150px',
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<div className="admin-system-actions">
|
||||
<Button onClick={() => setEditingSegment(record)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button
|
||||
icon={<Trash2 size={15} />}
|
||||
onClick={() => setSegments((current) => current.filter((item) => item.id !== record.id))}
|
||||
size="sm"
|
||||
variant="danger"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], []);
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-system-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<div className="breadcrumb-line">系统管理 / <strong>手机号段库</strong></div>
|
||||
<h1>手机号段库</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-system-toolbar">
|
||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索手机号段、运营商、省份或城市" prefix={<Search size={16} />} value={keyword} />
|
||||
<Button icon={<Plus size={16} />} onClick={() => setCreating(true)}>新增号段</Button>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-system-table-card">
|
||||
<Table columns={columns} data={filteredSegments} emptyText="暂无手机号段" rowKey="id" />
|
||||
</div>
|
||||
|
||||
{creating ? <SegmentFormModal onClose={() => setCreating(false)} onSubmit={upsertSegment} /> : null}
|
||||
{editingSegment ? <SegmentFormModal item={editingSegment} onClose={() => setEditingSegment(null)} onSubmit={upsertSegment} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Search } from 'lucide-react';
|
||||
import { Button, DateRangeInput, Input, Select, type DateRangeValue } from '@/components/ui';
|
||||
|
||||
type RechargeRecord = {
|
||||
id: string;
|
||||
enterprise: string;
|
||||
rechargedAt: string;
|
||||
amount?: number;
|
||||
balance?: number;
|
||||
operator?: string;
|
||||
};
|
||||
|
||||
const rechargeRecords: RechargeRecord[] = [
|
||||
{ id: 'RCG202601120001', enterprise: 'XXXX科技有限公司', rechargedAt: '2026-01-12 19:27:19', amount: 1000, balance: 1000, operator: '李XXX' },
|
||||
{ id: 'RCG202601120002', enterprise: 'XXX公司名字', rechargedAt: '2026-01-12 19:27:19', amount: 500, balance: 5896.25, operator: '张三' },
|
||||
{ id: 'RCG202601120003', enterprise: 'XXX公司名字XXX公司名字', rechargedAt: '2026-01-12 19:27:19', amount: 192.29, balance: 0, operator: '张三' },
|
||||
{ id: 'RCG202601120004', enterprise: '', rechargedAt: '2026-01-12 19:27:19', amount: 2617.09, balance: 0, operator: '李四' },
|
||||
{ id: 'RCG202601120005', enterprise: '', rechargedAt: '2026-01-12 19:27:19', amount: 122, balance: 0, operator: '' },
|
||||
{ id: 'RCG202601120006', enterprise: '', rechargedAt: '2026-01-12 19:27:19' },
|
||||
{ id: 'RCG202601120007', enterprise: '', rechargedAt: '2026-01-12 19:27:19' },
|
||||
{ id: 'RCG202601120008', enterprise: '', rechargedAt: '2026-01-12 19:27:19' },
|
||||
];
|
||||
|
||||
function getDate(value: string) {
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
function formatAmount(value?: number) {
|
||||
if (value === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return value.toLocaleString('zh-CN', {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: Number.isInteger(value) ? 0 : 2,
|
||||
});
|
||||
}
|
||||
|
||||
export function AdminRechargeRecordsPage() {
|
||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
|
||||
const filteredRows = useMemo(
|
||||
() => rechargeRecords.filter((item) => {
|
||||
const rechargeDate = getDate(item.rechargedAt);
|
||||
const matchesEnterprise = !enterpriseKeyword || item.enterprise.includes(enterpriseKeyword);
|
||||
const matchesStartDate = !dateRange.start || rechargeDate >= dateRange.start;
|
||||
const matchesEndDate = !dateRange.end || rechargeDate <= dateRange.end;
|
||||
return matchesEnterprise && matchesStartDate && matchesEndDate;
|
||||
}),
|
||||
[dateRange.end, dateRange.start, enterpriseKeyword],
|
||||
);
|
||||
|
||||
function resetFilters() {
|
||||
setEnterpriseKeyword('');
|
||||
setDateRange({});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-recharge-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<div className="breadcrumb-line">数据详单 / <strong>充值记录</strong></div>
|
||||
<h1>充值记录</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-recharge-filter">
|
||||
<Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} value={enterpriseKeyword} />
|
||||
<DateRangeInput label="充值日期" onChange={setDateRange} value={dateRange} />
|
||||
<div className="admin-recharge-filter__actions">
|
||||
<Button icon={<Search size={16} />}>查询</Button>
|
||||
<Button onClick={resetFilters} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-recharge-table-card">
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table admin-recharge-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>企业名称</th>
|
||||
<th>充值时间</th>
|
||||
<th>充值金额</th>
|
||||
<th>充值后余额</th>
|
||||
<th>操作人</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRows.map((record) => (
|
||||
<tr key={record.id}>
|
||||
<td><strong>{record.enterprise}</strong></td>
|
||||
<td>{record.rechargedAt}</td>
|
||||
<td>{formatAmount(record.amount)}</td>
|
||||
<td>{formatAmount(record.balance)}</td>
|
||||
<td>{record.operator}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="admin-recharge-pagination">
|
||||
<Select options={[{ label: '10条/页', value: '10' }, { label: '20条/页', value: '20' }]} value="10" />
|
||||
<div>
|
||||
<Button icon={<ChevronLeft size={16} />} iconOnly variant="ghost">上一页</Button>
|
||||
<Button size="sm" variant="ghost">24</Button>
|
||||
<Button size="sm">25</Button>
|
||||
<Button size="sm" variant="ghost">26</Button>
|
||||
<span>...</span>
|
||||
<Button size="sm" variant="ghost">63</Button>
|
||||
<Button icon={<ChevronRight size={16} />} iconOnly variant="ghost">下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Button, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
|
||||
type SensitiveLevel = 'low' | 'medium' | 'high';
|
||||
|
||||
type SensitiveWordItem = {
|
||||
id: string;
|
||||
word: string;
|
||||
category: string;
|
||||
level: SensitiveLevel;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
const levelLabelMap: Record<SensitiveLevel, string> = {
|
||||
low: '低',
|
||||
medium: '中',
|
||||
high: '高',
|
||||
};
|
||||
|
||||
const levelToneMap: Record<SensitiveLevel, 'success' | 'warning' | 'danger'> = {
|
||||
low: 'success',
|
||||
medium: 'warning',
|
||||
high: 'danger',
|
||||
};
|
||||
|
||||
const initialItems: SensitiveWordItem[] = [
|
||||
{ id: 'SW20260630001', word: '高息贷款', category: '金融营销', level: 'high', createdAt: '2026-06-20 09:12:18', updatedAt: '2026-06-28 16:24:10' },
|
||||
{ id: 'SW20260630002', word: '中奖链接', category: '欺诈风险', level: 'high', createdAt: '2026-06-19 13:40:22', updatedAt: '2026-06-26 11:09:45' },
|
||||
{ id: 'SW20260630003', word: '限时返利', category: '营销规范', level: 'medium', createdAt: '2026-06-18 10:30:00', updatedAt: '2026-06-24 15:18:32' },
|
||||
{ id: 'SW20260630004', word: '免费领取', category: '普通营销', level: 'low', createdAt: '2026-06-17 17:06:51', updatedAt: '2026-06-21 09:05:14' },
|
||||
];
|
||||
|
||||
export function AdminSensitiveWordsPage() {
|
||||
const [items, setItems] = useState(initialItems);
|
||||
|
||||
const columns = useMemo<Array<TableColumn<SensitiveWordItem>>>(() => [
|
||||
{ key: 'word', title: '敏感词', width: '180px', render: (record) => <strong>{record.word}</strong> },
|
||||
{ key: 'category', title: '分类', width: '160px', render: (record) => record.category },
|
||||
{ key: 'level', title: '级别', width: '120px', render: (record) => <Tag tone={levelToneMap[record.level]}>{levelLabelMap[record.level]}</Tag> },
|
||||
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => record.createdAt },
|
||||
{ key: 'updatedAt', title: '更新时间', width: '190px', render: (record) => record.updatedAt },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '110px',
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => setItems((current) => current.filter((item) => item.id !== record.id))} size="sm" variant="danger">
|
||||
删除
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
], []);
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-security-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<div className="breadcrumb-line">安全控制 / <strong>敏感词管理</strong></div>
|
||||
<h1>敏感词管理</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-security-table-card">
|
||||
<Table columns={columns} data={items} emptyText="暂无敏感词记录" rowKey="id" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Save } from 'lucide-react';
|
||||
import { Button, Input, Select } from '@/components/ui';
|
||||
|
||||
export function AdminSettingsPage() {
|
||||
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">
|
||||
<div className="form-grid form-grid--two">
|
||||
<Input label="审核超时提醒" defaultValue="30 分钟" />
|
||||
<Input label="单批发送上限" defaultValue="50000" />
|
||||
</div>
|
||||
<Select
|
||||
label="默认风控等级"
|
||||
defaultValue="medium"
|
||||
options={[
|
||||
{ label: '宽松', value: 'low' },
|
||||
{ label: '标准', value: 'medium' },
|
||||
{ label: '严格', value: 'high' },
|
||||
]}
|
||||
/>
|
||||
<Button icon={<Save size={16} />}>保存配置</Button>
|
||||
</div>
|
||||
<aside className="soft-panel">
|
||||
<h3>配置说明</h3>
|
||||
<p className="muted">当前配置只在前端展示,用于模拟运营端系统参数维护。</p>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Eye, Search } from 'lucide-react';
|
||||
import { Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
|
||||
type MmsAuditStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
type MmsTemplateAudit = {
|
||||
id: string;
|
||||
name: string;
|
||||
application: string;
|
||||
subject: string;
|
||||
sizeKb: number;
|
||||
enterprise: string;
|
||||
submittedAt: string;
|
||||
status: MmsAuditStatus;
|
||||
image: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '待审核', value: 'pending' },
|
||||
{ label: '已通过', value: 'approved' },
|
||||
{ label: '已拒绝', value: 'rejected' },
|
||||
];
|
||||
|
||||
const statusLabelMap: Record<MmsAuditStatus, string> = {
|
||||
pending: '待审核',
|
||||
approved: '已通过',
|
||||
rejected: '已拒绝',
|
||||
};
|
||||
|
||||
const statusToneMap: Record<MmsAuditStatus, 'warning' | 'success' | 'danger'> = {
|
||||
pending: 'warning',
|
||||
approved: 'success',
|
||||
rejected: 'danger',
|
||||
};
|
||||
|
||||
const initialMmsAudits: MmsTemplateAudit[] = [
|
||||
{
|
||||
id: 'MMS-TPL-20260319-001',
|
||||
name: '春季上新图文推广',
|
||||
application: '营销活动彩信',
|
||||
subject: '【星云科技】春季新品发布',
|
||||
sizeKb: 1450,
|
||||
enterprise: '北京星云科技有限公司',
|
||||
submittedAt: '2026-03-19 11:15:20',
|
||||
status: 'pending',
|
||||
image: 'https://images.unsplash.com/photo-1434494878577-86c23bcb06b9?auto=format&fit=crop&w=900&q=80',
|
||||
content: '春季新品重磅发布,限时优惠价299元起,前100名购买送精美礼品一份。',
|
||||
},
|
||||
{
|
||||
id: 'MMS-TPL-20260319-002',
|
||||
name: '端午节大促视频',
|
||||
application: '节日促销彩信',
|
||||
subject: '【蓝海科技】端午狂欢最高满减',
|
||||
sizeKb: 1850,
|
||||
enterprise: '上海蓝海科技有限公司',
|
||||
submittedAt: '2026-03-18 09:30:10',
|
||||
status: 'pending',
|
||||
image: 'https://images.unsplash.com/photo-1607083206968-13611e3d76db?auto=format&fit=crop&w=900&q=80',
|
||||
content: '端午节会员福利开启,精选商品满299减50,满599减120,数量有限。',
|
||||
},
|
||||
{
|
||||
id: 'MMS-TPL-20260318-001',
|
||||
name: '新用户欢迎礼包',
|
||||
application: '会员运营彩信',
|
||||
subject: '【飞跃传媒】新老用户专享福利',
|
||||
sizeKb: 850,
|
||||
enterprise: '广州飞跃文化传媒有限公司',
|
||||
submittedAt: '2026-03-17 14:20:05',
|
||||
status: 'approved',
|
||||
image: 'https://images.unsplash.com/photo-1567427017947-545c5f8d16ad?auto=format&fit=crop&w=900&q=80',
|
||||
content: '欢迎加入会员中心,新用户可领取专属礼包和本月优惠券。',
|
||||
},
|
||||
{
|
||||
id: 'MMS-TPL-20260317-001',
|
||||
name: '理财产品营销违规',
|
||||
application: '金融营销彩信',
|
||||
subject: '【理财通】高收益理财推荐',
|
||||
sizeKb: 1950,
|
||||
enterprise: '深圳前海贸易有限公司',
|
||||
submittedAt: '2026-03-16 16:45:30',
|
||||
status: 'rejected',
|
||||
image: 'https://images.unsplash.com/photo-1554224155-6726b3ff858f?auto=format&fit=crop&w=900&q=80',
|
||||
content: '精选高收益理财产品推荐,限时申购,活动名额有限。',
|
||||
},
|
||||
];
|
||||
|
||||
export function AdminSignatureAuditPage() {
|
||||
const [records, setRecords] = useState(initialMmsAudits);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [previewRecord, setPreviewRecord] = useState<MmsTemplateAudit | null>(null);
|
||||
|
||||
const filteredRecords = useMemo(
|
||||
() => records.filter((record) => {
|
||||
const matchesKeyword = !keyword || `${record.name}${record.enterprise}`.includes(keyword);
|
||||
const matchesStatus = status === 'all' || record.status === status;
|
||||
return matchesKeyword && matchesStatus;
|
||||
}),
|
||||
[keyword, records, status],
|
||||
);
|
||||
|
||||
function updateStatus(id: string, nextStatus: MmsAuditStatus) {
|
||||
setRecords((items) => items.map((item) => (item.id === id ? { ...item, status: nextStatus } : item)));
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<MmsTemplateAudit>> = [
|
||||
{ key: 'id', title: '模板编号', render: (record) => <span className="muted">{record.id}</span> },
|
||||
{ key: 'name', title: '模板名称', render: (record) => <strong>{record.name}</strong> },
|
||||
{ key: 'application', title: '彩信应用', render: (record) => record.application },
|
||||
{ key: 'subject', title: '彩信主题', render: (record) => record.subject },
|
||||
{ key: 'sizeKb', title: '大小(KB)', render: (record) => record.sizeKb },
|
||||
{ key: 'enterprise', title: '归属企业', render: (record) => record.enterprise },
|
||||
{ key: 'submittedAt', title: '提交时间', render: (record) => record.submittedAt },
|
||||
{ key: 'status', title: '状态', render: (record) => <Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<div className="audit-actions">
|
||||
<button className="audit-link" onClick={() => setPreviewRecord(record)} type="button"><Eye size={16} />预览</button>
|
||||
{record.status === 'pending' ? (
|
||||
<>
|
||||
<button className="audit-link audit-link--success" onClick={() => updateStatus(record.id, 'approved')} type="button">通过</button>
|
||||
<button className="audit-link audit-link--danger" onClick={() => updateStatus(record.id, 'rejected')} type="button">拒绝</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-audit-page">
|
||||
<div className="breadcrumb-line"><span>审核中心</span><span>/</span><strong>彩信模板审核</strong></div>
|
||||
|
||||
<div className="surface audit-filter-card">
|
||||
<div className="audit-filter-grid audit-filter-grid--enterprise">
|
||||
<Input label="模板名称/企业名称" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入模板名称或企业名称" value={keyword} />
|
||||
<Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
|
||||
<div className="audit-filter-actions">
|
||||
<Button icon={<Search size={17} />}>查询</Button>
|
||||
<Button onClick={() => { setKeyword(''); setStatus('all'); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface audit-table-card mms-audit-table">
|
||||
<Table columns={columns} data={filteredRecords} rowKey="id" />
|
||||
<div className="audit-pagination">
|
||||
<span>共 {filteredRecords.length} 条</span>
|
||||
<Button disabled size="sm" variant="ghost">上一页</Button>
|
||||
<Button size="sm" variant="secondary">1</Button>
|
||||
<Button disabled size="sm" variant="ghost">下一页</Button>
|
||||
</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?.name}</p></div>}
|
||||
>
|
||||
{previewRecord ? (
|
||||
<div className="mms-preview">
|
||||
<img alt={previewRecord.name} src={previewRecord.image} />
|
||||
<h3>{previewRecord.subject}</h3>
|
||||
<p>{previewRecord.content}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Info, RefreshCw } from 'lucide-react';
|
||||
import { Button, Input, Select } from '@/components/ui';
|
||||
import { getEnterpriseRecords } from './adminEnterpriseMock';
|
||||
|
||||
type QueueType = 'priority' | 'normal';
|
||||
type ReportRule = 'any' | 'one';
|
||||
type InterfaceType = 'cmpp' | 'http';
|
||||
|
||||
const channelGroupOptions = {
|
||||
mobile: [
|
||||
{ label: '移动通道组A', value: 'mobile-a' },
|
||||
{ label: '移动通道组B', value: 'mobile-b' },
|
||||
],
|
||||
unicom: [
|
||||
{ label: '联通通道组B', value: 'unicom-b' },
|
||||
{ label: '联通通道组C', value: 'unicom-c' },
|
||||
],
|
||||
telecom: [
|
||||
{ label: '电信通道组C', value: 'telecom-c' },
|
||||
{ label: '电信通道组D', value: 'telecom-d' },
|
||||
],
|
||||
};
|
||||
|
||||
const mismatchPolicyOptions = [
|
||||
{ label: '拒绝发送', value: 'reject' },
|
||||
{ label: '跳人工审核', value: 'manual-review' },
|
||||
{ label: '直接发送', value: 'direct-send' },
|
||||
];
|
||||
|
||||
function generateCode(prefix: string) {
|
||||
return `${prefix}${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
|
||||
}
|
||||
|
||||
export function AdminSmsApplicationFormPage() {
|
||||
const navigate = useNavigate();
|
||||
const { enterpriseId, appId } = useParams();
|
||||
const enterprise = useMemo(
|
||||
() => getEnterpriseRecords().find((item) => item.id === enterpriseId),
|
||||
[enterpriseId],
|
||||
);
|
||||
const isEdit = Boolean(appId);
|
||||
const [appName, setAppName] = useState(isEdit ? '示例应用' : '');
|
||||
const [unitPrice, setUnitPrice] = useState(isEdit ? '0.0300' : '');
|
||||
const [queueType, setQueueType] = useState<QueueType>('priority');
|
||||
const [mobileGroup, setMobileGroup] = useState('mobile-a');
|
||||
const [unicomGroup, setUnicomGroup] = useState('unicom-b');
|
||||
const [telecomGroup, setTelecomGroup] = useState('telecom-c');
|
||||
const [reportRule, setReportRule] = useState<ReportRule>('any');
|
||||
const [dailyLimit, setDailyLimit] = useState(isEdit ? '100000' : '');
|
||||
const [phoneDailyLimit, setPhoneDailyLimit] = useState(isEdit ? '10' : '');
|
||||
const [mismatchPolicy, setMismatchPolicy] = useState('manual-review');
|
||||
const [smsEnabled, setSmsEnabled] = useState(true);
|
||||
const [interfaceType, setInterfaceType] = useState<InterfaceType>('cmpp');
|
||||
const [ipAddress, setIpAddress] = useState(isEdit ? '192.168.1.100' : '');
|
||||
const [connectionCount, setConnectionCount] = useState(isEdit ? '2' : '');
|
||||
const [enterpriseCode, setEnterpriseCode] = useState(isEdit ? 'ABC123' : generateCode('EC'));
|
||||
const [interfaceAccount, setInterfaceAccount] = useState(isEdit ? 'ABC123' : generateCode('AC'));
|
||||
const [interfacePassword, setInterfacePassword] = useState(isEdit ? '************' : generateCode('PW'));
|
||||
const [accessNumber, setAccessNumber] = useState(isEdit ? '1069' : '');
|
||||
const [nameError, setNameError] = useState('');
|
||||
|
||||
function goBack() {
|
||||
navigate(`/admin/customers/${enterpriseId ?? ''}`);
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!appName.trim()) {
|
||||
setNameError('请填写应用名称');
|
||||
return;
|
||||
}
|
||||
goBack();
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-app-form-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<p className="eyebrow">企业管理 / {isEdit ? '编辑短信应用' : '添加短信应用'}</p>
|
||||
<h1>{isEdit ? '编辑短信应用' : '添加短信应用'}</h1>
|
||||
<p>{enterprise?.name ?? '当前企业'} 的短信应用配置。</p>
|
||||
</div>
|
||||
<Button icon={<ArrowLeft size={16} />} onClick={goBack} variant="ghost">
|
||||
返回企业详情
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-app-form-card">
|
||||
<section className="ui-detail-section">
|
||||
<div className="ui-detail-section__header">
|
||||
<h3>业务信息</h3>
|
||||
</div>
|
||||
<div className="admin-app-form-grid">
|
||||
<Input
|
||||
error={nameError}
|
||||
label="应用名称"
|
||||
onChange={(event) => {
|
||||
setAppName(event.target.value);
|
||||
setNameError('');
|
||||
}}
|
||||
placeholder="请输入应用名称"
|
||||
required
|
||||
value={appName}
|
||||
/>
|
||||
<Input
|
||||
label="编ID(元)"
|
||||
onChange={(event) => setUnitPrice(event.target.value)}
|
||||
placeholder="0.0300"
|
||||
required
|
||||
suffix={<span className="admin-app-form-price-note">(3.0000)</span>}
|
||||
value={unitPrice}
|
||||
/>
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<span>发送队列</span>
|
||||
<div className="radio-row">
|
||||
<label>
|
||||
<input checked={queueType === 'priority'} onChange={() => setQueueType('priority')} type="radio" />
|
||||
优先队列(行业短信)
|
||||
</label>
|
||||
<label>
|
||||
<input checked={queueType === 'normal'} onChange={() => setQueueType('normal')} type="radio" />
|
||||
普通队列(会员营销)
|
||||
</label>
|
||||
</div>
|
||||
<div className="admin-app-form-tip">
|
||||
<Info size={17} />
|
||||
<span>请严格区分快充队列(如验证码等效率高的短信)和正常队列(例如营销短信),否则会影响连接性能。</span>
|
||||
</div>
|
||||
</div>
|
||||
<Select label="发送通道组-移动" onChange={(event) => setMobileGroup(event.target.value)} options={channelGroupOptions.mobile} required value={mobileGroup} />
|
||||
<Select label="发送通道组-联通" onChange={(event) => setUnicomGroup(event.target.value)} options={channelGroupOptions.unicom} required value={unicomGroup} />
|
||||
<Select label="发送通道组-电信" onChange={(event) => setTelecomGroup(event.target.value)} options={channelGroupOptions.telecom} required value={telecomGroup} />
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<span>报备成功状态判断条件</span>
|
||||
<div className="radio-row">
|
||||
<label>
|
||||
<input checked={reportRule === 'any'} onChange={() => setReportRule('any')} type="radio" />
|
||||
通道组中至少有一个全网通道报备成功
|
||||
</label>
|
||||
<label>
|
||||
<input checked={reportRule === 'one'} onChange={() => setReportRule('one')} type="radio" />
|
||||
通道组中任一通道报备成功
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ui-detail-section">
|
||||
<div className="ui-detail-section__header">
|
||||
<h3>安全策略</h3>
|
||||
</div>
|
||||
<div className="admin-app-form-grid">
|
||||
<Input label="日发送数量限制" onChange={(event) => setDailyLimit(event.target.value)} placeholder="100000" required value={dailyLimit} />
|
||||
<Input label="每号码日发送频次限制" onChange={(event) => setPhoneDailyLimit(event.target.value)} placeholder="10" required value={phoneDailyLimit} />
|
||||
<Select
|
||||
label="不符合模板的短信"
|
||||
onChange={(event) => setMismatchPolicy(event.target.value)}
|
||||
options={mismatchPolicyOptions}
|
||||
required
|
||||
value={mismatchPolicy}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ui-detail-section">
|
||||
<div className="ui-detail-section__header">
|
||||
<h3>接口配置</h3>
|
||||
</div>
|
||||
<div className="admin-app-form-grid">
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<span>短信接口</span>
|
||||
<button className={smsEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setSmsEnabled((current) => !current)} type="button">
|
||||
<span />
|
||||
{smsEnabled ? '开通' : '关闭'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<span>接口类型</span>
|
||||
<div className="radio-row">
|
||||
<label>
|
||||
<input checked={interfaceType === 'cmpp'} onChange={() => setInterfaceType('cmpp')} type="radio" />
|
||||
CMPP接口
|
||||
</label>
|
||||
<label>
|
||||
<input checked={interfaceType === 'http'} onChange={() => setInterfaceType('http')} type="radio" />
|
||||
HTTP接口
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<Input label="IP地址" onChange={(event) => setIpAddress(event.target.value)} placeholder="请输入 IP 地址" required value={ipAddress} />
|
||||
<Input label="连接数" onChange={(event) => setConnectionCount(event.target.value)} placeholder="请输入连接数" required value={connectionCount} />
|
||||
<Input
|
||||
label="企业代码"
|
||||
onChange={(event) => setEnterpriseCode(event.target.value)}
|
||||
required
|
||||
suffix={<Button icon={<RefreshCw size={14} />} onClick={() => setEnterpriseCode(generateCode('EC'))} size="sm" variant="ghost">生成</Button>}
|
||||
value={enterpriseCode}
|
||||
/>
|
||||
<Input label="接口账号" onChange={(event) => setInterfaceAccount(event.target.value)} required value={interfaceAccount} />
|
||||
<Input
|
||||
label="接口密码"
|
||||
onChange={(event) => setInterfacePassword(event.target.value)}
|
||||
required
|
||||
suffix={<Button icon={<RefreshCw size={14} />} onClick={() => setInterfacePassword(generateCode('PW'))} size="sm" variant="ghost">生成</Button>}
|
||||
value={interfacePassword}
|
||||
/>
|
||||
<Input label="接入号" onChange={(event) => setAccessNumber(event.target.value)} placeholder="请输入接入号" required value={accessNumber} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="enterprise-form-footer">
|
||||
<Button onClick={submit}>确认</Button>
|
||||
<Button onClick={goBack} variant="ghost">返回</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { CalendarDays, Check, Search, X } from 'lucide-react';
|
||||
import { Button, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
|
||||
type SmsAuditStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
type SmsAuditRecord = {
|
||||
id: string;
|
||||
customer: string;
|
||||
industry: string;
|
||||
application: string;
|
||||
submittedAt: string;
|
||||
content: string;
|
||||
chars: number;
|
||||
billCount: number;
|
||||
phoneCount: number;
|
||||
reasons: string[];
|
||||
status: SmsAuditStatus;
|
||||
reviewedAt?: string;
|
||||
};
|
||||
|
||||
type PhoneRecord = {
|
||||
id: number;
|
||||
phone: string;
|
||||
location: string;
|
||||
carrier: string;
|
||||
};
|
||||
|
||||
const initialSmsAudits: SmsAuditRecord[] = [
|
||||
{
|
||||
id: 'SMS-20250106-001',
|
||||
customer: '广州XXXXX有限公司',
|
||||
industry: 'XXXXXXXX行业',
|
||||
application: '应用名称示例',
|
||||
submittedAt: '2025-01-06 09:02:28',
|
||||
content: '【深圳市北健科技有限公司】KH58户主,您录名的您好您好。1)童套充不要禁运控。2)禁止去速1200m左右的来水,收费收在2;3)恐怕接待,我接待您恐来子。单,现现确下里况要可图的注明技术想上,统统统他只有禁运信息;最可以请点仙应出他有记。',
|
||||
chars: 300,
|
||||
billCount: 1,
|
||||
phoneCount: 256,
|
||||
reasons: ['模板', '签名', '引流信息'],
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'SMS-20250105-001',
|
||||
customer: '上海XXXXX有限公司',
|
||||
industry: 'XXXXXXXX行业',
|
||||
application: '客户通知服务',
|
||||
submittedAt: '2025-01-05 08:02:28',
|
||||
content: '【某某公司】尊敬的客户您好,您好。感谢您选择光纤宽带!如遇问题请联系客服...',
|
||||
chars: 100,
|
||||
billCount: 1,
|
||||
phoneCount: 156,
|
||||
reasons: ['模板', '签名', '引流信息'],
|
||||
status: 'approved',
|
||||
reviewedAt: '2026-01-12 17:27:28',
|
||||
},
|
||||
{
|
||||
id: 'SMS-20250105-002',
|
||||
customer: '上海XXXXX有限公司',
|
||||
industry: 'XXXXXXXX行业',
|
||||
application: '会员通知',
|
||||
submittedAt: '2025-01-05 08:02:28',
|
||||
content: '【某某公司】尊敬的客户您好,您好。',
|
||||
chars: 50,
|
||||
billCount: 1,
|
||||
phoneCount: 2762,
|
||||
reasons: ['模板', '签名'],
|
||||
status: 'approved',
|
||||
reviewedAt: '2026-01-12 17:27:28',
|
||||
},
|
||||
{
|
||||
id: 'SMS-20250105-003',
|
||||
customer: '北京XXXXX有限公司',
|
||||
industry: 'XXXXXXXX营销',
|
||||
application: '营销推广平台',
|
||||
submittedAt: '2025-01-05 08:02:28',
|
||||
content: '【大童芝】签住链,应控计 移奇亭某间遇道进使用请您动幼时此从此出收在2026-01-06 17:00-17:30至取到交投买元画山点。',
|
||||
chars: 200,
|
||||
billCount: 1,
|
||||
phoneCount: 256,
|
||||
reasons: ['模板'],
|
||||
status: 'pending',
|
||||
},
|
||||
];
|
||||
|
||||
const rejectReasons = ['内容不发', '模板未提交', '签名未报备', '引流未报备完成', '内容包含敏感词', '号码格式错误', '缺少必要信息', '违反运营商规定', '签名与内容不符', '模板变量不匹配'];
|
||||
|
||||
const initialPhoneRows: PhoneRecord[] = [
|
||||
{ id: 1, phone: '188xxxx9999', location: '河南 信阳', carrier: '移动' },
|
||||
{ id: 2, phone: '133xxxx1111', location: '河南 信阳', carrier: '移动' },
|
||||
{ id: 3, phone: '134xxxx2222', location: '河南 信阳', carrier: '移动' },
|
||||
{ id: 4, phone: '156xxxxyyyy', location: '山东 青岛', carrier: '联通' },
|
||||
{ id: 5, phone: '178625xxxxx', location: '山东 青岛', carrier: '联通' },
|
||||
{ id: 6, phone: '190xxxxyyyy', location: '山东 青岛', carrier: '联通' },
|
||||
{ id: 7, phone: '190xxxxyyyy', location: '山东 青岛', carrier: '联通' },
|
||||
{ id: 8, phone: '190xxxxyyyy', location: '山东 青岛', carrier: '电信' },
|
||||
{ id: 9, phone: '177xxxx5555', location: '北京', carrier: '移动' },
|
||||
{ id: 10, phone: '189xxxx6666', location: '上海', carrier: '电信' },
|
||||
];
|
||||
|
||||
function SmsEditModal({
|
||||
record,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
record: SmsAuditRecord;
|
||||
onClose: () => void;
|
||||
onSubmit: (content: string) => void;
|
||||
}) {
|
||||
const [content, setContent] = useState(record.content);
|
||||
const billingCount = Math.max(1, Math.ceil(content.length / 67));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button onClick={() => onSubmit(content)}>确认修改</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>修改短信内容</h2><p>请修改短信内容后点击确认</p></div>}
|
||||
>
|
||||
<div className="sms-audit-edit">
|
||||
<div className="sms-audit-edit__meta">
|
||||
<span>企业名称:<strong>{record.customer}</strong></span>
|
||||
<span>所属应用:<strong>{record.application}</strong></span>
|
||||
<span>提交时间:<strong>{record.submittedAt}</strong></span>
|
||||
</div>
|
||||
<Textarea label="短信内容" onChange={(event) => setContent(event.target.value)} rows={9} value={content} />
|
||||
<div className="sms-audit-edit__count">
|
||||
<span>已输入 {content.length} 字</span>
|
||||
<strong>计费 {billingCount} 条</strong>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function SmsRejectModal({
|
||||
record,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
record: SmsAuditRecord;
|
||||
onClose: () => void;
|
||||
onSubmit: (reason: string) => void;
|
||||
}) {
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
function appendReason(nextReason: string) {
|
||||
setReason((current) => (current ? `${current};${nextReason}` : nextReason));
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => onSubmit(reason)} disabled={!reason.trim()}>确认</Button>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
title={<div className="template-modal-title"><h2>确认驳回</h2><p>请选择或输入驳回原因</p></div>}
|
||||
>
|
||||
<div className="sms-reject-modal">
|
||||
<Textarea label="* 驳回原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入驳回原因" rows={4} value={reason} />
|
||||
<div>
|
||||
<strong>常见原因:</strong>
|
||||
<div className="reject-reason-tags">
|
||||
{rejectReasons.map((item) => (
|
||||
<button key={item} onClick={() => appendReason(item)} type="button">{item}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button className="audit-collapse-link" type="button">收起</button>
|
||||
<p className="muted">当前处理:{record.customer}</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function PhoneListModal({
|
||||
record,
|
||||
onClose,
|
||||
}: {
|
||||
record: SmsAuditRecord;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const phoneRows = useMemo(
|
||||
() => initialPhoneRows.filter((item) => !keyword || item.phone.includes(keyword)),
|
||||
[keyword],
|
||||
);
|
||||
const columns: Array<TableColumn<PhoneRecord>> = [
|
||||
{ key: 'id', title: '序号', width: '120px', render: (item) => item.id },
|
||||
{ key: 'phone', title: '手机号码', render: (item) => <strong>{item.phone}</strong> },
|
||||
{ key: 'location', title: '号码归属地', render: (item) => item.location },
|
||||
{ key: 'carrier', title: '运营商', render: (item) => item.carrier },
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={<Button onClick={onClose} variant="ghost">关闭</Button>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>号码列表</h2><p>{record.customer},共 {record.phoneCount} 个号码</p></div>}
|
||||
>
|
||||
<div className="phone-list-modal">
|
||||
<Input
|
||||
autoFocus
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="手机号"
|
||||
prefix={<Search size={18} />}
|
||||
value={keyword}
|
||||
/>
|
||||
<div className="phone-list-toolbar">
|
||||
<Select
|
||||
options={[
|
||||
{ label: '10条/页', value: '10' },
|
||||
{ label: '20条/页', value: '20' },
|
||||
{ label: '50条/页', value: '50' },
|
||||
]}
|
||||
value="10"
|
||||
/>
|
||||
<div>
|
||||
<Button disabled size="sm" variant="ghost">上一页</Button>
|
||||
<Button size="sm" variant="ghost">24</Button>
|
||||
<Button size="sm" variant="secondary">25</Button>
|
||||
<Button size="sm" variant="ghost">26</Button>
|
||||
<span>...</span>
|
||||
<Button size="sm" variant="ghost">63</Button>
|
||||
<Button size="sm" variant="ghost">下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="phone-list-table">
|
||||
<Table columns={columns} data={phoneRows} rowKey="id" />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminSmsAuditPage() {
|
||||
const [records, setRecords] = useState(initialSmsAudits);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [company, setCompany] = useState('');
|
||||
const [application, setApplication] = useState('');
|
||||
const [date, setDate] = useState('');
|
||||
const [editRecord, setEditRecord] = useState<SmsAuditRecord | null>(null);
|
||||
const [rejectRecord, setRejectRecord] = useState<SmsAuditRecord | null>(null);
|
||||
const [phoneListRecord, setPhoneListRecord] = useState<SmsAuditRecord | null>(null);
|
||||
|
||||
const filteredRecords = useMemo(
|
||||
() => records.filter((record) => {
|
||||
const matchesCompany = !company || record.customer === company;
|
||||
const matchesApplication = !application || record.application === application;
|
||||
const matchesKeyword = !keyword || record.content.includes(keyword);
|
||||
const matchesDate = !date || record.submittedAt.startsWith(date);
|
||||
return matchesCompany && matchesApplication && matchesKeyword && matchesDate;
|
||||
}),
|
||||
[application, company, date, keyword, records],
|
||||
);
|
||||
|
||||
function updateStatus(id: string, status: SmsAuditStatus) {
|
||||
setRecords((items) => items.map((item) => (
|
||||
item.id === id ? { ...item, status, reviewedAt: status === 'pending' ? undefined : '2026-01-12 17:27:28' } : item
|
||||
)));
|
||||
}
|
||||
|
||||
function updateContent(content: string) {
|
||||
if (!editRecord) return;
|
||||
setRecords((items) => items.map((item) => (
|
||||
item.id === editRecord.id ? { ...item, content, chars: content.length, billCount: Math.max(1, Math.ceil(content.length / 67)) } : item
|
||||
)));
|
||||
setEditRecord(null);
|
||||
}
|
||||
|
||||
function rejectRecordWithReason() {
|
||||
if (!rejectRecord) return;
|
||||
updateStatus(rejectRecord.id, 'rejected');
|
||||
setRejectRecord(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-audit-page">
|
||||
<div className="breadcrumb-line"><span>审核中心</span><span>/</span><strong>短信审核</strong></div>
|
||||
<div className="surface sms-audit-filter">
|
||||
<h2>筛选条件</h2>
|
||||
<div className="audit-filter-grid audit-filter-grid--sms">
|
||||
<Select
|
||||
label="企业"
|
||||
onChange={(event) => setCompany(event.target.value)}
|
||||
options={[{ label: '请选择企业', value: '' }, ...Array.from(new Set(records.map((item) => item.customer))).map((item) => ({ label: item, value: item }))]}
|
||||
value={company}
|
||||
/>
|
||||
<Select
|
||||
disabled={!company}
|
||||
label="应用"
|
||||
onChange={(event) => setApplication(event.target.value)}
|
||||
options={[{ label: company ? '请选择应用' : '请先选择企业', value: '' }, ...Array.from(new Set(records.filter((item) => !company || item.customer === company).map((item) => item.application))).map((item) => ({ label: item, value: item }))]}
|
||||
value={application}
|
||||
/>
|
||||
<Input label="短信内容" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入短信内容关键词" value={keyword} />
|
||||
<Input label="提交日期" onChange={(event) => setDate(event.target.value)} placeholder="yyyy-mm-dd" prefix={<CalendarDays size={16} />} value={date} />
|
||||
<div className="audit-filter-actions">
|
||||
<Button icon={<Search size={17} />}>查询</Button>
|
||||
<Button onClick={() => { setCompany(''); setApplication(''); setKeyword(''); setDate(''); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sms-bulk-actions">
|
||||
<span>批量操作:</span>
|
||||
<Button icon={<Check size={16} />} onClick={() => setRecords((items) => items.map((item) => ({ ...item, status: 'approved', reviewedAt: '2026-01-12 17:27:28' })))} variant="secondary">批量通过</Button>
|
||||
<Button icon={<X size={16} />} onClick={() => setRejectRecord(filteredRecords[0] ?? null)} variant="danger">批量驳回</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sms-audit-toolbar">
|
||||
<span>10条/页</span>
|
||||
<div>
|
||||
<Button disabled size="sm" variant="ghost">上一页</Button>
|
||||
<Button size="sm" variant="secondary">1</Button>
|
||||
<Button size="sm" variant="ghost">2</Button>
|
||||
<Button size="sm" variant="ghost">63</Button>
|
||||
<Button size="sm" variant="ghost">下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface sms-audit-list">
|
||||
<div className="sms-audit-head">
|
||||
<span><input aria-label="全选" type="checkbox" /></span>
|
||||
<span>客户信息</span>
|
||||
<span>短信内容</span>
|
||||
<span>号码数量</span>
|
||||
<span>审核原因</span>
|
||||
<span>审核结果</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{filteredRecords.map((record) => (
|
||||
<article className="sms-audit-row" key={record.id}>
|
||||
<span><input aria-label={`选择 ${record.id}`} type="checkbox" /></span>
|
||||
<div className="sms-audit-customer">
|
||||
<strong>{record.customer}</strong>
|
||||
<span>{record.industry}</span>
|
||||
<small>{record.submittedAt} 提交</small>
|
||||
</div>
|
||||
<p className="sms-audit-content">{record.content}</p>
|
||||
<div className="sms-audit-count">
|
||||
<span>字符:<strong>{record.chars}</strong></span>
|
||||
<span>计费:<strong>{record.billCount}条</strong></span>
|
||||
<span>号码:<a>{record.phoneCount}</a></span>
|
||||
<button onClick={() => setPhoneListRecord(record)} type="button">查看列表</button>
|
||||
</div>
|
||||
<div className="sms-audit-reasons">
|
||||
{record.reasons.map((reason) => <Tag key={reason} tone={reason === '模板' ? 'success' : 'neutral'}>{reason}</Tag>)}
|
||||
</div>
|
||||
<div className="sms-audit-result">
|
||||
{record.status === 'pending' ? <span className="audit-dot audit-dot--warning">审核中</span> : null}
|
||||
{record.status === 'approved' ? <span className="audit-dot audit-dot--success">审核通过</span> : null}
|
||||
{record.status === 'rejected' ? <span className="audit-dot audit-dot--danger">已驳回</span> : null}
|
||||
{record.reviewedAt ? <small>{record.reviewedAt}</small> : null}
|
||||
</div>
|
||||
<div className="sms-audit-actions">
|
||||
<Button onClick={() => updateStatus(record.id, 'approved')} size="sm" variant="secondary">通过</Button>
|
||||
<Button onClick={() => setRejectRecord(record)} size="sm" variant="danger">驳回</Button>
|
||||
<Button onClick={() => setEditRecord(record)} size="sm" variant="ghost">修改</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{editRecord ? (
|
||||
<SmsEditModal
|
||||
onClose={() => setEditRecord(null)}
|
||||
onSubmit={updateContent}
|
||||
record={editRecord}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{rejectRecord ? (
|
||||
<SmsRejectModal
|
||||
onClose={() => setRejectRecord(null)}
|
||||
onSubmit={rejectRecordWithReason}
|
||||
record={rejectRecord}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{phoneListRecord ? (
|
||||
<PhoneListModal
|
||||
onClose={() => setPhoneListRecord(null)}
|
||||
record={phoneListRecord}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Download, MessageSquare, Search, Smartphone } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
DateRangeInput,
|
||||
Input,
|
||||
Modal,
|
||||
Pagination,
|
||||
Select,
|
||||
Tag,
|
||||
type DateRangeValue,
|
||||
} from '@/components/ui';
|
||||
|
||||
type SendStatus = 'success' | 'unknown' | 'failed';
|
||||
|
||||
type SendRoute = {
|
||||
id: string;
|
||||
channel: string;
|
||||
sentAt: string;
|
||||
receiptAt: string;
|
||||
receiptCode: string;
|
||||
};
|
||||
|
||||
type SmsRecord = {
|
||||
id: string;
|
||||
enterprise: string;
|
||||
application: string;
|
||||
submittedAt: string;
|
||||
content: string;
|
||||
phone: string;
|
||||
carrier: string;
|
||||
region: string;
|
||||
wordCount: number;
|
||||
billingCount: number;
|
||||
channel: string;
|
||||
status: SendStatus;
|
||||
receiptAt: string;
|
||||
routes: SendRoute[];
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<SendStatus, string> = {
|
||||
success: '发送成功',
|
||||
unknown: '未知',
|
||||
failed: '失败',
|
||||
};
|
||||
|
||||
const statusToneMap: Record<SendStatus, 'success' | 'neutral' | 'danger'> = {
|
||||
success: 'success',
|
||||
unknown: 'neutral',
|
||||
failed: 'danger',
|
||||
};
|
||||
|
||||
const statusDotClassMap: Record<SendStatus, string> = {
|
||||
success: 'is-success',
|
||||
unknown: 'is-unknown',
|
||||
failed: 'is-failed',
|
||||
};
|
||||
|
||||
const recordsSeed: SmsRecord[] = [
|
||||
{
|
||||
id: 'SMSR202601020001',
|
||||
enterprise: '四川骠骑企业管理',
|
||||
application: '应用1',
|
||||
submittedAt: '2025-12-31 18:00:02',
|
||||
content: '【大富翁】尊重的,您好!非常荣幸能邀请您到我们最新活动现场,请仔细阅读此短信,并将链接分享给朋友:https://x.wrtdalent.cn/aedrex',
|
||||
phone: '13675569095',
|
||||
carrier: '中国移动',
|
||||
region: '成都',
|
||||
wordCount: 100,
|
||||
billingCount: 1,
|
||||
channel: '三网西南堡垒卡 上海高流量 C77021',
|
||||
status: 'success',
|
||||
receiptAt: '2026-01-02 18:38:05',
|
||||
routes: [
|
||||
{ id: '1', channel: '三网行业-黄峰-三网-编号3.3', sentAt: '2026-01-02 18:38:02', receiptAt: '2026-01-02 18:38:05', receiptCode: '2' },
|
||||
{ id: '2', channel: '三网行业-黄峰(循环号用)-三网-编号3.4', sentAt: '2026-01-02 18:38:05', receiptAt: '2026-01-02 18:38:07', receiptCode: 'UDJDF' },
|
||||
{ id: '3', channel: '移动映华北-上海富煌C60289-移动2.7', sentAt: '2026-01-02 18:38:07', receiptAt: '2026-01-02 18:38:15', receiptCode: 'VKIJ' },
|
||||
{ id: '4', channel: '三网行业-北京富慧互联-三网-编号3.5', sentAt: '2026-01-02 18:38:15', receiptAt: '2026-01-02 18:38:23', receiptCode: 'DELIVRD' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'SMSR202601020002',
|
||||
enterprise: '重庆进载数智',
|
||||
application: '应用2',
|
||||
submittedAt: '2025-12-31 11:49:10',
|
||||
content: '【宜信易贷】限额提升:宜昌市用土工业 宜昌市营商会金 宜昌市营销线上信用 共同提出新行业更新工商登记变更名主动、您由是联机工具...',
|
||||
phone: '18607638087',
|
||||
carrier: '中国联通',
|
||||
region: '重庆',
|
||||
wordCount: 150,
|
||||
billingCount: 2,
|
||||
channel: '112383 三网合群 第部 三三 郭划3.5 CMPP2.0(32-27-0)',
|
||||
status: 'unknown',
|
||||
receiptAt: '2026-01-02 18:38:05',
|
||||
routes: [
|
||||
{ id: '1', channel: '112383 三网合群 第部 三三 郭划3.5', sentAt: '2026-01-02 18:37:58', receiptAt: '2026-01-02 18:38:05', receiptCode: 'UNKNOWN' },
|
||||
{ id: '2', channel: '三网行业-西南备用-编号2.1', sentAt: '2026-01-02 18:38:05', receiptAt: '2026-01-02 18:38:10', receiptCode: 'UNKNOWN' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'SMSR202601020003',
|
||||
enterprise: '行业',
|
||||
application: '应用3',
|
||||
submittedAt: '2025-12-31 11:49:08',
|
||||
content: '【瓷慧坤营销】联系联络:重点开拓士工业 宜昌市营商会金 宜昌市营销线上信用共同提出新行业更新工商登记变更名',
|
||||
phone: 'XXXX市 持动',
|
||||
carrier: '未知',
|
||||
region: '未知',
|
||||
wordCount: 80,
|
||||
billingCount: 1,
|
||||
channel: '未知',
|
||||
status: 'unknown',
|
||||
receiptAt: '2026-01-02 18:38:05',
|
||||
routes: [
|
||||
{ id: '1', channel: '未知通道', sentAt: '2026-01-02 18:38:01', receiptAt: '2026-01-02 18:38:05', receiptCode: 'UNKNOWN' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'SMSR202601020004',
|
||||
enterprise: '超感世纪互三网',
|
||||
application: '应用4',
|
||||
submittedAt: '2025-12-31 11:47:23',
|
||||
content: '【南京邮银】南京市权益和金融互实消市证就增幅有限公司 为您开通了一款全新、友联、诚信的创新模块:https://baWF.cn/s6/1WNnOaAy5i',
|
||||
phone: '15250668026',
|
||||
carrier: '中国电信',
|
||||
region: '南京',
|
||||
wordCount: 120,
|
||||
billingCount: 1,
|
||||
channel: '1069017 三网行业-北京商在互动-三网-第0.4-CMPP2.0(25-5-0)',
|
||||
status: 'failed',
|
||||
receiptAt: '2026-01-02 18:38:05',
|
||||
routes: [
|
||||
{ id: '1', channel: '1069017 三网行业-北京商在互动-三网-第0.4', sentAt: '2026-01-02 18:38:00', receiptAt: '2026-01-02 18:38:04', receiptCode: 'UNDELIV' },
|
||||
{ id: '2', channel: '北京备用-CMPP2.0', sentAt: '2026-01-02 18:38:05', receiptAt: '2026-01-02 18:38:09', receiptCode: 'FAILED' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function getDate(value: string) {
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
function StatusLine({ status }: { status: SendStatus }) {
|
||||
return (
|
||||
<span className="admin-sms-record-status">
|
||||
<i className={statusDotClassMap[status]} />
|
||||
{statusLabelMap[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SendDetailModal({ record, onClose }: { record: SmsRecord; onClose: () => void }) {
|
||||
return (
|
||||
<Modal
|
||||
footer={<Button onClick={onClose} variant="ghost">关闭</Button>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title="发送详情"
|
||||
>
|
||||
<div className="admin-sms-send-detail">
|
||||
<section>
|
||||
<h3>短信内容</h3>
|
||||
<p className="admin-sms-detail-content">{record.content}</p>
|
||||
</section>
|
||||
|
||||
<div className="admin-sms-route-list">
|
||||
{record.routes.map((route) => (
|
||||
<article key={route.id}>
|
||||
<span>{route.id}</span>
|
||||
<div>
|
||||
<strong>{route.channel}</strong>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>发送时间</dt>
|
||||
<dd>{route.sentAt}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>回执时间</dt>
|
||||
<dd>{route.receiptAt}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>回执码</dt>
|
||||
<dd>{route.receiptCode}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminSmsRecordsPage() {
|
||||
const [enterprise, setEnterprise] = useState('all');
|
||||
const [application, setApplication] = useState('all');
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [phoneKeyword, setPhoneKeyword] = useState('');
|
||||
const [contentKeyword, setContentKeyword] = useState('');
|
||||
const [channelKeyword, setChannelKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [selectedRecord, setSelectedRecord] = useState<SmsRecord | null>(null);
|
||||
|
||||
const enterpriseOptions = useMemo(() => {
|
||||
const names = Array.from(new Set(recordsSeed.map((item) => item.enterprise)));
|
||||
return [{ label: '全部企业', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
|
||||
}, []);
|
||||
|
||||
const applicationOptions = useMemo(() => {
|
||||
const names = Array.from(new Set(recordsSeed.filter((item) => enterprise === 'all' || item.enterprise === enterprise).map((item) => item.application)));
|
||||
return [{ label: '全部应用', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
|
||||
}, [enterprise]);
|
||||
|
||||
const filteredRows = useMemo(
|
||||
() => recordsSeed.filter((item) => {
|
||||
const submittedDate = getDate(item.submittedAt);
|
||||
const matchesEnterprise = enterprise === 'all' || item.enterprise === enterprise;
|
||||
const matchesApplication = application === 'all' || item.application === application;
|
||||
const matchesStartDate = !dateRange.start || submittedDate >= dateRange.start;
|
||||
const matchesEndDate = !dateRange.end || submittedDate <= dateRange.end;
|
||||
const matchesPhone = !phoneKeyword || item.phone.includes(phoneKeyword);
|
||||
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
|
||||
const matchesChannel = !channelKeyword || item.channel.includes(channelKeyword);
|
||||
const matchesStatus = status === 'all' || item.status === status;
|
||||
return matchesEnterprise && matchesApplication && matchesStartDate && matchesEndDate && matchesPhone && matchesContent && matchesChannel && matchesStatus;
|
||||
}),
|
||||
[application, channelKeyword, contentKeyword, dateRange.end, dateRange.start, enterprise, phoneKeyword, status],
|
||||
);
|
||||
|
||||
function resetFilters() {
|
||||
setEnterprise('all');
|
||||
setApplication('all');
|
||||
setDateRange({});
|
||||
setPhoneKeyword('');
|
||||
setContentKeyword('');
|
||||
setChannelKeyword('');
|
||||
setStatus('all');
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-sms-records-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<div className="breadcrumb-line">数据详单 / <strong>短信记录</strong></div>
|
||||
<h1>短信记录</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-sms-record-filter">
|
||||
<Select
|
||||
label="企业"
|
||||
onChange={(event) => {
|
||||
setEnterprise(event.target.value);
|
||||
setApplication('all');
|
||||
}}
|
||||
options={enterpriseOptions}
|
||||
value={enterprise}
|
||||
/>
|
||||
<Select label="应用" onChange={(event) => setApplication(event.target.value)} options={applicationOptions} value={application} />
|
||||
<DateRangeInput label="提交日期" onChange={setDateRange} value={dateRange} />
|
||||
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} prefix={<Smartphone size={16} />} value={phoneKeyword} />
|
||||
<Input label="短信内容" onChange={(event) => setContentKeyword(event.target.value)} value={contentKeyword} />
|
||||
<Input label="通道名称" onChange={(event) => setChannelKeyword(event.target.value)} value={channelKeyword} />
|
||||
<Select
|
||||
label="发送状态"
|
||||
onChange={(event) => setStatus(event.target.value)}
|
||||
options={[
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '发送成功', value: 'success' },
|
||||
{ label: '未知', value: 'unknown' },
|
||||
{ label: '失败', value: 'failed' },
|
||||
]}
|
||||
value={status}
|
||||
/>
|
||||
<div className="admin-sms-record-filter__actions">
|
||||
<Button icon={<Search size={16} />}>查询</Button>
|
||||
<Button onClick={resetFilters} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-sms-record-table-card">
|
||||
<div className="admin-sms-record-toolbar">
|
||||
<Button icon={<Download size={16} />} variant="ghost">导出CSV</Button>
|
||||
</div>
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table admin-sms-record-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '170px' }}>发送者</th>
|
||||
<th>短信内容</th>
|
||||
<th style={{ width: '170px' }}>手机号码</th>
|
||||
<th style={{ width: '300px' }}>通道与发送状态</th>
|
||||
<th style={{ textAlign: 'right', width: '120px' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRows.length === 0 ? (
|
||||
<tr>
|
||||
<td className="ui-table__empty" colSpan={5}>暂无短信记录</td>
|
||||
</tr>
|
||||
) : filteredRows.map((record) => (
|
||||
<tr key={record.id}>
|
||||
<td>
|
||||
<div className="admin-sms-record-sender">
|
||||
<strong>{record.enterprise}</strong>
|
||||
<span>{record.application}</span>
|
||||
<small>{record.submittedAt.slice(0, 10)}<br />{record.submittedAt.slice(11)}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td><p className="admin-sms-record-content">{record.content}</p></td>
|
||||
<td>
|
||||
<div className="admin-sms-record-phone">
|
||||
<strong>{record.phone}</strong>
|
||||
<span>{record.region} {record.carrier}</span>
|
||||
<small>{record.wordCount}字/{record.billingCount}条</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-sms-record-channel">
|
||||
<strong>{record.channel}</strong>
|
||||
<StatusLine status={record.status} />
|
||||
<span>{record.receiptAt}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<button className="admin-sms-record-detail-link" onClick={() => setSelectedRecord(record)} type="button">发送详情</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination total={filteredRows.length} />
|
||||
</div>
|
||||
|
||||
{selectedRecord ? <SendDetailModal onClose={() => setSelectedRecord(null)} record={selectedRecord} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { BarChart3, CalendarClock, Eye, MapPin, Search, Send, Smartphone, StopCircle, TrendingUp } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
DateRangeInput,
|
||||
InlineTextPreview,
|
||||
Input,
|
||||
Modal,
|
||||
Pagination,
|
||||
Select,
|
||||
Table,
|
||||
Tag,
|
||||
type DateRangeValue,
|
||||
} from '@/components/ui';
|
||||
|
||||
type TaskStatus = 'sending' | 'completed' | 'terminated' | 'failed';
|
||||
type SendType = 'immediate' | 'scheduled';
|
||||
|
||||
type CarrierStat = {
|
||||
name: string;
|
||||
total: number;
|
||||
success: number;
|
||||
tone: 'mobile' | 'unicom' | 'telecom';
|
||||
};
|
||||
|
||||
type CityStat = {
|
||||
city: string;
|
||||
province: string;
|
||||
total: number;
|
||||
success: number;
|
||||
};
|
||||
|
||||
type SmsTask = {
|
||||
id: string;
|
||||
enterprise: string;
|
||||
application: string;
|
||||
submittedAt: string;
|
||||
templateContent: string;
|
||||
phoneCount: number;
|
||||
wordCount: number;
|
||||
billingCount: number;
|
||||
sendType: SendType;
|
||||
scheduledAt?: string;
|
||||
submittedCount: number;
|
||||
submittedSuccess: number;
|
||||
sentCount: number;
|
||||
successCount: number;
|
||||
status: TaskStatus;
|
||||
carriers: CarrierStat[];
|
||||
cities: CityStat[];
|
||||
};
|
||||
|
||||
const statusLabels: Record<TaskStatus, string> = {
|
||||
sending: '发送中',
|
||||
completed: '已完成',
|
||||
terminated: '已终止',
|
||||
failed: '失败',
|
||||
};
|
||||
|
||||
const statusTones: Record<TaskStatus, 'info' | 'success' | 'neutral' | 'danger'> = {
|
||||
sending: 'info',
|
||||
completed: 'success',
|
||||
terminated: 'neutral',
|
||||
failed: 'danger',
|
||||
};
|
||||
|
||||
const sendTypeLabels: Record<SendType, string> = {
|
||||
immediate: '立即发送',
|
||||
scheduled: '定时发送',
|
||||
};
|
||||
|
||||
const taskData: SmsTask[] = [
|
||||
{
|
||||
id: 'TASK202603120001',
|
||||
enterprise: '四川骠骑企业管理',
|
||||
application: '营销应用1',
|
||||
submittedAt: '2026-03-12 09:30:15',
|
||||
templateContent: '【骠骑科技】尊敬的{name},您有一笔{amount}元的订单已确认,预计{date}送达。',
|
||||
phoneCount: 10000,
|
||||
wordCount: 68,
|
||||
billingCount: 14250,
|
||||
sendType: 'immediate',
|
||||
submittedCount: 10000,
|
||||
submittedSuccess: 7500,
|
||||
sentCount: 7500,
|
||||
successCount: 7125,
|
||||
status: 'sending',
|
||||
carriers: [
|
||||
{ name: '中国移动', total: 6000, success: 5760, tone: 'mobile' },
|
||||
{ name: '中国联通', total: 2500, success: 2350, tone: 'unicom' },
|
||||
{ name: '中国电信', total: 1500, success: 1425, tone: 'telecom' },
|
||||
],
|
||||
cities: [
|
||||
{ city: '成都市', province: '四川省', total: 1500, success: 1440 },
|
||||
{ city: '重庆市', province: '重庆市', total: 1200, success: 1140 },
|
||||
{ city: '北京市', province: '北京市', total: 1000, success: 970 },
|
||||
{ city: '上海市', province: '上海市', total: 1000, success: 960 },
|
||||
{ city: '深圳市', province: '广东省', total: 800, success: 760 },
|
||||
{ city: '广州市', province: '广东省', total: 700, success: 658 },
|
||||
{ city: '杭州市', province: '浙江省', total: 600, success: 576 },
|
||||
{ city: '南京市', province: '江苏省', total: 500, success: 475 },
|
||||
{ city: '武汉市', province: '湖北省', total: 500, success: 470 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'TASK202603120002',
|
||||
enterprise: '重庆进载数智',
|
||||
application: '通知应用',
|
||||
submittedAt: '2026-03-12 10:15:00',
|
||||
templateContent: '【进载数智】亲爱的用户,您的会员即将到期,请及时续费。',
|
||||
phoneCount: 5000,
|
||||
wordCount: 52,
|
||||
billingCount: 5000,
|
||||
sendType: 'scheduled',
|
||||
scheduledAt: '2026-03-13 08:00:00',
|
||||
submittedCount: 5000,
|
||||
submittedSuccess: 5000,
|
||||
sentCount: 5000,
|
||||
successCount: 4910,
|
||||
status: 'completed',
|
||||
carriers: [
|
||||
{ name: '中国移动', total: 3000, success: 2955, tone: 'mobile' },
|
||||
{ name: '中国联通', total: 1200, success: 1170, tone: 'unicom' },
|
||||
{ name: '中国电信', total: 800, success: 785, tone: 'telecom' },
|
||||
],
|
||||
cities: [
|
||||
{ city: '重庆市', province: '重庆市', total: 1200, success: 1180 },
|
||||
{ city: '成都市', province: '四川省', total: 900, success: 884 },
|
||||
{ city: '西安市', province: '陕西省', total: 700, success: 688 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'TASK202603120003',
|
||||
enterprise: '超感世纪三三网',
|
||||
application: '推广应用2',
|
||||
submittedAt: '2026-03-12 11:20:00',
|
||||
templateContent: '【超感世纪】新用户专享优惠,限时抢购中!点击链接查看详情:...',
|
||||
phoneCount: 20000,
|
||||
wordCount: 85,
|
||||
billingCount: 40000,
|
||||
sendType: 'immediate',
|
||||
submittedCount: 20000,
|
||||
submittedSuccess: 12000,
|
||||
sentCount: 12000,
|
||||
successCount: 11160,
|
||||
status: 'sending',
|
||||
carriers: [
|
||||
{ name: '中国移动', total: 12000, success: 11160, tone: 'mobile' },
|
||||
{ name: '中国联通', total: 5000, success: 4600, tone: 'unicom' },
|
||||
{ name: '中国电信', total: 3000, success: 2820, tone: 'telecom' },
|
||||
],
|
||||
cities: [
|
||||
{ city: '北京市', province: '北京市', total: 3000, success: 2820 },
|
||||
{ city: '上海市', province: '上海市', total: 2600, success: 2418 },
|
||||
{ city: '广州市', province: '广东省', total: 2300, success: 2139 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'TASK202603120004',
|
||||
enterprise: '重庆香惠慧',
|
||||
application: '客服应用',
|
||||
submittedAt: '2026-03-12 14:05:00',
|
||||
templateContent: '【香惠慧】您的验证码是{code},请在5分钟内完成验证。',
|
||||
phoneCount: 3000,
|
||||
wordCount: 45,
|
||||
billingCount: 3000,
|
||||
sendType: 'scheduled',
|
||||
scheduledAt: '2026-03-12 16:00:00',
|
||||
submittedCount: 3000,
|
||||
submittedSuccess: 3000,
|
||||
sentCount: 3000,
|
||||
successCount: 2962,
|
||||
status: 'completed',
|
||||
carriers: [
|
||||
{ name: '中国移动', total: 1700, success: 1682, tone: 'mobile' },
|
||||
{ name: '中国联通', total: 800, success: 790, tone: 'unicom' },
|
||||
{ name: '中国电信', total: 500, success: 490, tone: 'telecom' },
|
||||
],
|
||||
cities: [
|
||||
{ city: '重庆市', province: '重庆市', total: 1600, success: 1584 },
|
||||
{ city: '成都市', province: '四川省', total: 800, success: 786 },
|
||||
{ city: '贵阳市', province: '贵州省', total: 600, success: 592 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'TASK202603120005',
|
||||
enterprise: '四川骠骑企业管理',
|
||||
application: '活动推广',
|
||||
submittedAt: '2026-03-12 15:30:00',
|
||||
templateContent: '【骠骑科技】周末特惠活动开始啦!全场商品8折起,更多优惠请访问官网。',
|
||||
phoneCount: 8000,
|
||||
wordCount: 72,
|
||||
billingCount: 16000,
|
||||
sendType: 'immediate',
|
||||
submittedCount: 8000,
|
||||
submittedSuccess: 2000,
|
||||
sentCount: 2000,
|
||||
successCount: 1860,
|
||||
status: 'terminated',
|
||||
carriers: [
|
||||
{ name: '中国移动', total: 4800, success: 4464, tone: 'mobile' },
|
||||
{ name: '中国联通', total: 2000, success: 1840, tone: 'unicom' },
|
||||
{ name: '中国电信', total: 1200, success: 1116, tone: 'telecom' },
|
||||
],
|
||||
cities: [
|
||||
{ city: '成都市', province: '四川省', total: 1600, success: 1488 },
|
||||
{ city: '绵阳市', province: '四川省', total: 900, success: 837 },
|
||||
{ city: '德阳市', province: '四川省', total: 700, success: 651 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'TASK202603110006',
|
||||
enterprise: '重庆进载数智',
|
||||
application: '系统通知',
|
||||
submittedAt: '2026-03-11 17:45:00',
|
||||
templateContent: '【进载数智】系统维护通知:我们将于{date}进行系统升级,预计耗时2小时。',
|
||||
phoneCount: 15000,
|
||||
wordCount: 63,
|
||||
billingCount: 15000,
|
||||
sendType: 'scheduled',
|
||||
scheduledAt: '2026-03-11 20:00:00',
|
||||
submittedCount: 15000,
|
||||
submittedSuccess: 10000,
|
||||
sentCount: 10000,
|
||||
successCount: 8200,
|
||||
status: 'failed',
|
||||
carriers: [
|
||||
{ name: '中国移动', total: 9000, success: 7380, tone: 'mobile' },
|
||||
{ name: '中国联通', total: 3600, success: 2952, tone: 'unicom' },
|
||||
{ name: '中国电信', total: 2400, success: 1968, tone: 'telecom' },
|
||||
],
|
||||
cities: [
|
||||
{ city: '重庆市', province: '重庆市', total: 2600, success: 2132 },
|
||||
{ city: '成都市', province: '四川省', total: 1800, success: 1476 },
|
||||
{ city: '昆明市', province: '云南省', total: 1200, success: 984 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return value.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function getProgress(task: SmsTask) {
|
||||
return Math.round((task.sentCount / task.phoneCount) * 100);
|
||||
}
|
||||
|
||||
function getSuccessRate(task: SmsTask) {
|
||||
return (task.successCount / task.submittedCount) * 100;
|
||||
}
|
||||
|
||||
function getCityRate(city: CityStat) {
|
||||
return (city.success / city.total) * 100;
|
||||
}
|
||||
|
||||
function splitSignature(content: string) {
|
||||
const match = content.match(/^【(.+?)】(.+)$/);
|
||||
return {
|
||||
signature: match?.[1],
|
||||
content: match?.[2] ?? content,
|
||||
};
|
||||
}
|
||||
|
||||
function TaskDetailTitle({ task }: { task: SmsTask }) {
|
||||
return (
|
||||
<div className="admin-task-detail-title">
|
||||
<h2>任务详情</h2>
|
||||
<p>
|
||||
<span>{task.id}</span>
|
||||
<Tag tone={statusTones[task.status]}>{statusLabels[task.status]}</Tag>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, tone }: { label: string; value: string; tone?: 'success' | 'primary' }) {
|
||||
return (
|
||||
<div className={['admin-task-metric', tone ? `admin-task-metric--${tone}` : ''].filter(Boolean).join(' ')}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: () => void }) {
|
||||
const progress = getProgress(task);
|
||||
const successRate = getSuccessRate(task);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={<Button onClick={onClose}>关闭</Button>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<TaskDetailTitle task={task} />}
|
||||
>
|
||||
<div className="admin-task-detail">
|
||||
<div className="admin-task-metrics">
|
||||
<MetricCard label="提交总数" value={formatNumber(task.submittedCount)} />
|
||||
<MetricCard label="提交成功" tone="success" value={formatNumber(task.submittedSuccess)} />
|
||||
<MetricCard label="发送成功" tone="primary" value={formatNumber(task.successCount)} />
|
||||
<MetricCard label="计费条数" tone="primary" value={formatNumber(task.billingCount)} />
|
||||
<MetricCard label="成功率" tone="primary" value={`${successRate.toFixed(2)}%`} />
|
||||
</div>
|
||||
|
||||
<div className="admin-task-detail-grid">
|
||||
<section className="admin-task-card">
|
||||
<h3><Send size={18} />任务信息</h3>
|
||||
<dl className="admin-task-info-list">
|
||||
<div>
|
||||
<dt>企业/应用</dt>
|
||||
<dd><strong>{task.enterprise}</strong><span>{task.application}</span></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>提交时间</dt>
|
||||
<dd>{task.submittedAt}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>发送方式</dt>
|
||||
<dd><Tag tone={task.sendType === 'immediate' ? 'info' : 'warning'}>{sendTypeLabels[task.sendType]}</Tag></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="admin-task-card">
|
||||
<h3><TrendingUp size={18} />发送进度</h3>
|
||||
<div className="admin-task-progress-card">
|
||||
<div>
|
||||
<span>已发送 {formatNumber(task.sentCount)} / 总计 {formatNumber(task.phoneCount)}</span>
|
||||
<strong>{progress}%</strong>
|
||||
</div>
|
||||
<div className="batch-progress__track">
|
||||
<span className={`batch-progress__bar batch-progress__bar--${task.status === 'failed' ? 'terminated' : task.status}`} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="admin-task-progress-split">
|
||||
<span>已提交<strong>{formatNumber(task.submittedSuccess)}</strong></span>
|
||||
<span>已成功<strong>{formatNumber(task.successCount)}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-task-card">
|
||||
<h3><BarChart3 size={18} />模板信息</h3>
|
||||
<div className="admin-task-template-block">
|
||||
<span>短信模板内容</span>
|
||||
<p className="admin-task-template">{task.templateContent}</p>
|
||||
</div>
|
||||
<dl className="admin-task-template-meta">
|
||||
<div><dt>字符数/计费条数</dt><dd>{task.wordCount} 字符 <b>·</b> {Math.max(1, Math.ceil(task.wordCount / 67))} 条/号码</dd></div>
|
||||
<div><dt>发送号码数</dt><dd>{formatNumber(task.phoneCount)} 个</dd></div>
|
||||
</dl>
|
||||
<div className="admin-task-billing-note">
|
||||
<span>计费规则:每 67 字为 1 条短信。本次任务单号码 {Math.max(1, Math.ceil(task.wordCount / 67))} 条,预计总计费 {formatNumber(task.billingCount)} 条</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="admin-task-card admin-task-card--full">
|
||||
<h3><Smartphone size={18} />运营商分布</h3>
|
||||
<div className="admin-carrier-grid">
|
||||
{task.carriers.map((carrier) => {
|
||||
const rate = (carrier.success / carrier.total) * 100;
|
||||
return (
|
||||
<article className={`admin-carrier-card admin-carrier-card--${carrier.tone}`} key={carrier.name}>
|
||||
<strong>{carrier.name}</strong>
|
||||
<p><span>总数</span><b>{formatNumber(carrier.total)}</b></p>
|
||||
<p><span>成功</span><b>{formatNumber(carrier.success)}</b></p>
|
||||
<div>
|
||||
<em>{rate.toFixed(1)}%</em>
|
||||
<span>成功率</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-task-card admin-task-card--full">
|
||||
<h3><MapPin size={18} />城市分布</h3>
|
||||
<Table
|
||||
columns={[
|
||||
{ key: 'city', title: '城市', render: (record: CityStat) => <strong>{record.city}</strong> },
|
||||
{ key: 'province', title: '省份', render: (record: CityStat) => <span className="muted">{record.province}</span> },
|
||||
{ key: 'total', title: '总数', align: 'right', render: (record: CityStat) => formatNumber(record.total) },
|
||||
{ key: 'success', title: '成功', align: 'right', render: (record: CityStat) => <span className="admin-success-text">{formatNumber(record.success)}</span> },
|
||||
{
|
||||
key: 'rate',
|
||||
title: '成功率',
|
||||
align: 'right',
|
||||
render: (record: CityStat) => <Tag tone={getCityRate(record) >= 95 ? 'success' : 'info'}>{getCityRate(record).toFixed(1)}%</Tag>,
|
||||
},
|
||||
]}
|
||||
data={task.cities}
|
||||
rowKey={(record) => record.city}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminSmsTaskProgressPage() {
|
||||
const [tasks, setTasks] = useState(taskData);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [enterprise, setEnterprise] = useState('all');
|
||||
const [application, setApplication] = useState('all');
|
||||
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
|
||||
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
|
||||
const [selectedTask, setSelectedTask] = useState<SmsTask | null>(null);
|
||||
|
||||
const enterpriseOptions = useMemo(() => {
|
||||
const names = Array.from(new Set(tasks.map((item) => item.enterprise)));
|
||||
return [{ label: '全部企业', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
|
||||
}, [tasks]);
|
||||
|
||||
const applicationOptions = useMemo(() => {
|
||||
const names = Array.from(new Set(tasks.filter((item) => enterprise === 'all' || item.enterprise === enterprise).map((item) => item.application)));
|
||||
return [{ label: '全部应用', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
|
||||
}, [enterprise, tasks]);
|
||||
|
||||
const filteredTasks = useMemo(
|
||||
() => tasks.filter((item) => {
|
||||
const submittedDate = item.submittedAt.slice(0, 10);
|
||||
const matchesKeyword = !keyword || item.id.includes(keyword);
|
||||
const matchesEnterprise = enterprise === 'all' || item.enterprise === enterprise;
|
||||
const matchesApplication = application === 'all' || item.application === application;
|
||||
const matchesStartDate = !submittedDateRange.start || submittedDate >= submittedDateRange.start;
|
||||
const matchesEndDate = !submittedDateRange.end || submittedDate <= submittedDateRange.end;
|
||||
return matchesKeyword && matchesEnterprise && matchesApplication && matchesStartDate && matchesEndDate;
|
||||
}),
|
||||
[application, enterprise, keyword, submittedDateRange.end, submittedDateRange.start, tasks],
|
||||
);
|
||||
|
||||
function resetFilters() {
|
||||
setKeyword('');
|
||||
setEnterprise('all');
|
||||
setApplication('all');
|
||||
setSubmittedDateRange({});
|
||||
}
|
||||
|
||||
function terminateTask(taskId: string) {
|
||||
setTasks((current) => current.map((task) => (
|
||||
task.id === taskId ? { ...task, status: 'terminated' } : task
|
||||
)));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-sms-task-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<div className="breadcrumb-line">发送任务 / <strong>短信任务进度</strong></div>
|
||||
<h1>短信任务进度</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-task-filter">
|
||||
<Input label="任务编号" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入任务编号" value={keyword} />
|
||||
<Select
|
||||
label="选择企业"
|
||||
onChange={(event) => {
|
||||
setEnterprise(event.target.value);
|
||||
setApplication('all');
|
||||
}}
|
||||
options={enterpriseOptions}
|
||||
value={enterprise}
|
||||
/>
|
||||
<Select label="选择应用" onChange={(event) => setApplication(event.target.value)} options={applicationOptions} value={application} />
|
||||
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
|
||||
<div className="admin-task-filter__actions">
|
||||
<Button icon={<Search size={16} />}>查询</Button>
|
||||
<Button onClick={resetFilters} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-task-table-card">
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table batch-table admin-task-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '170px' }}>任务编号</th>
|
||||
<th style={{ width: '180px' }}>企业/应用</th>
|
||||
<th style={{ width: '136px' }}>提交时间</th>
|
||||
<th style={{ width: '130px' }}>号码数/字符数</th>
|
||||
<th style={{ width: '150px' }}>发送方式</th>
|
||||
<th style={{ width: '190px' }}>进度</th>
|
||||
<th style={{ width: '100px' }}>状态</th>
|
||||
<th style={{ textAlign: 'right', width: '120px' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredTasks.length === 0 ? (
|
||||
<tr>
|
||||
<td className="ui-table__empty" colSpan={8}>暂无数据</td>
|
||||
</tr>
|
||||
) : filteredTasks.map((record) => {
|
||||
const progress = getProgress(record);
|
||||
const { signature, content } = splitSignature(record.templateContent);
|
||||
const rowClass = hoveredTaskId === record.id ? 'batch-row--hovered' : '';
|
||||
|
||||
return (
|
||||
<Fragment key={record.id}>
|
||||
<tr
|
||||
className={['batch-main-row', rowClass].filter(Boolean).join(' ')}
|
||||
onMouseEnter={() => setHoveredTaskId(record.id)}
|
||||
onMouseLeave={() => setHoveredTaskId(null)}
|
||||
>
|
||||
<td><strong className="admin-task-id">{record.id}</strong></td>
|
||||
<td>
|
||||
<div className="admin-task-enterprise">
|
||||
<strong>{record.enterprise}</strong>
|
||||
<span>{record.application}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><span>{record.submittedAt.slice(0, 10)}<br />{record.submittedAt.slice(11, 16)}</span></td>
|
||||
<td>
|
||||
<div className="admin-task-counts">
|
||||
<strong>{formatNumber(record.phoneCount)}</strong>
|
||||
<span>{record.wordCount}字</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-task-send-type">
|
||||
<Tag tone={record.sendType === 'immediate' ? 'info' : 'warning'}>
|
||||
{record.sendType === 'scheduled' ? <CalendarClock size={13} /> : null}
|
||||
{sendTypeLabels[record.sendType]}
|
||||
</Tag>
|
||||
{record.scheduledAt ? <span>{record.scheduledAt.slice(0, 10)}<br />{record.scheduledAt.slice(11, 16)}</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="batch-progress admin-task-list-progress">
|
||||
<div>
|
||||
<span>{formatNumber(record.sentCount)}/{formatNumber(record.phoneCount)}</span>
|
||||
<strong>{progress}%</strong>
|
||||
</div>
|
||||
<div className="batch-progress__track">
|
||||
<span className={`batch-progress__bar batch-progress__bar--${record.status === 'failed' ? 'terminated' : record.status}`} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><Tag tone={statusTones[record.status]}>{statusLabels[record.status]}</Tag></td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div className="admin-task-actions">
|
||||
<Button icon={<Eye size={15} />} iconOnly onClick={() => setSelectedTask(record)} size="sm" variant="ghost">详情</Button>
|
||||
<Button
|
||||
disabled={record.status !== 'sending'}
|
||||
icon={<StopCircle size={15} />}
|
||||
iconOnly
|
||||
onClick={() => terminateTask(record.id)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
终止
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
className={['batch-template-row', 'admin-task-template-row', rowClass].filter(Boolean).join(' ')}
|
||||
onMouseEnter={() => setHoveredTaskId(record.id)}
|
||||
onMouseLeave={() => setHoveredTaskId(null)}
|
||||
>
|
||||
<td colSpan={8}>
|
||||
<InlineTextPreview label="模板内容" leading={signature ? <strong>【{signature}】</strong> : null}>
|
||||
{content}
|
||||
</InlineTextPreview>
|
||||
</td>
|
||||
</tr>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination total={filteredTasks.length} />
|
||||
</div>
|
||||
|
||||
{selectedTask ? <TaskDetailModal onClose={() => setSelectedTask(null)} task={selectedTask} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Search, Smartphone } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
DateRangeInput,
|
||||
Input,
|
||||
Modal,
|
||||
Pagination,
|
||||
Table,
|
||||
type DateRangeValue,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
|
||||
type UplinkMessage = {
|
||||
id: string;
|
||||
phone: string;
|
||||
receivedAt: string;
|
||||
content: string;
|
||||
channel: string;
|
||||
accessNo: string;
|
||||
matchedRecord?: MatchedSendRecord;
|
||||
};
|
||||
|
||||
type MatchedSendRecord = {
|
||||
id: string;
|
||||
sentAt: string;
|
||||
enterprise: string;
|
||||
application: string;
|
||||
accessNo: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const matchedRecord: MatchedSendRecord = {
|
||||
id: 'MT20260119001',
|
||||
sentAt: '2026-01-19 12:25:28',
|
||||
enterprise: '上海XXX有限公司',
|
||||
application: 'XXX催收',
|
||||
accessNo: '1069558812',
|
||||
content: '【XXX科技】如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 拒收请回复R',
|
||||
};
|
||||
|
||||
const uplinkMessages: UplinkMessage[] = [
|
||||
{ id: 'MO20260112001', phone: '13500000888', receivedAt: '2026-01-12 19:27:10', content: 'R', channel: '通道名称通道名称通道名称', accessNo: '106912246726', matchedRecord },
|
||||
{ id: 'MO20260112002', phone: '13755558888', receivedAt: '2026-01-12 19:27:19', content: 'R', channel: '通道名称通道名称通道名称', accessNo: '106912246712', matchedRecord },
|
||||
{ id: 'MO20260112003', phone: '18800000555', receivedAt: '2026-01-12 19:27:19', content: '到家了', channel: '通道名称通道名称通道名称', accessNo: '106912246732' },
|
||||
{ id: 'MO20260112004', phone: '', receivedAt: '2026-01-12 19:27:19', content: 'XX', channel: '通道名称通道名称通道名称', accessNo: '106912346745' },
|
||||
{ id: 'MO20260112005', phone: '', receivedAt: '2026-01-12 19:27:19', content: 'XX', channel: '', accessNo: '' },
|
||||
{ id: 'MO20260112006', phone: '', receivedAt: '2026-01-12 19:27:19', content: 'XX', channel: '', accessNo: '' },
|
||||
{ id: 'MO20260112007', phone: '', receivedAt: '2026-01-12 19:27:19', content: '', channel: '', accessNo: '' },
|
||||
{ id: 'MO20260112008', phone: '', receivedAt: '2026-01-12 19:27:19', content: '', channel: '', accessNo: '' },
|
||||
];
|
||||
|
||||
function getDate(value: string) {
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
function UplinkDetailModal({ message, onClose }: { message: UplinkMessage; onClose: () => void }) {
|
||||
return (
|
||||
<Modal
|
||||
footer={<Button onClick={onClose} variant="ghost">关闭</Button>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title="上行短信详情"
|
||||
>
|
||||
<div className="admin-uplink-detail">
|
||||
<section className="admin-uplink-info-card">
|
||||
<h3>上行信息</h3>
|
||||
<div className="admin-uplink-info-grid">
|
||||
<div>
|
||||
<span>手机号码</span>
|
||||
<strong>{message.phone || '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>上行时间</span>
|
||||
<strong>{message.receivedAt}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>上行通道</span>
|
||||
<strong>{message.channel || '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>上行接入号</span>
|
||||
<strong>{message.accessNo || '-'}</strong>
|
||||
</div>
|
||||
<div className="admin-uplink-info-grid__full">
|
||||
<span>上行内容</span>
|
||||
<strong>{message.content || '-'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-uplink-match-section">
|
||||
<h3>匹配发送记录</h3>
|
||||
<p>搜索到上行短信前7天内的下发成功记录</p>
|
||||
{message.matchedRecord ? (
|
||||
<article className="admin-uplink-match-card">
|
||||
<div className="admin-uplink-match-grid">
|
||||
<div>
|
||||
<span>发送时间</span>
|
||||
<strong>{message.matchedRecord.sentAt}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发送企业</span>
|
||||
<strong>{message.matchedRecord.enterprise}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发送应用</span>
|
||||
<strong>{message.matchedRecord.application}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>客户提交接入号</span>
|
||||
<strong>{message.matchedRecord.accessNo}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-uplink-match-content">
|
||||
<span>下发内容</span>
|
||||
<p>{message.matchedRecord.content}</p>
|
||||
</div>
|
||||
<button type="button">添加到应用黑名单</button>
|
||||
</article>
|
||||
) : (
|
||||
<div className="admin-uplink-empty-match">暂无匹配发送记录</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminSmsUplinkRecordsPage() {
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [phoneKeyword, setPhoneKeyword] = useState('');
|
||||
const [contentKeyword, setContentKeyword] = useState('');
|
||||
const [selectedMessage, setSelectedMessage] = useState<UplinkMessage | null>(null);
|
||||
|
||||
const filteredMessages = useMemo(
|
||||
() => uplinkMessages.filter((item) => {
|
||||
const receivedDate = getDate(item.receivedAt);
|
||||
const matchesStartDate = !dateRange.start || receivedDate >= dateRange.start;
|
||||
const matchesEndDate = !dateRange.end || receivedDate <= dateRange.end;
|
||||
const matchesPhone = !phoneKeyword || item.phone.includes(phoneKeyword);
|
||||
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
|
||||
return matchesStartDate && matchesEndDate && matchesPhone && matchesContent;
|
||||
}),
|
||||
[contentKeyword, dateRange.end, dateRange.start, phoneKeyword],
|
||||
);
|
||||
|
||||
function resetFilters() {
|
||||
setDateRange({});
|
||||
setPhoneKeyword('');
|
||||
setContentKeyword('');
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<UplinkMessage>> = [
|
||||
{
|
||||
key: 'select',
|
||||
title: '',
|
||||
width: '72px',
|
||||
align: 'center',
|
||||
render: () => <input aria-label="选择上行记录" className="admin-uplink-checkbox" type="checkbox" />,
|
||||
},
|
||||
{ key: 'phone', title: '手机号码', width: '170px', render: (record) => <strong>{record.phone}</strong> },
|
||||
{ key: 'receivedAt', title: '上行时间', width: '220px', render: (record) => <strong>{record.receivedAt}</strong> },
|
||||
{ key: 'content', title: '上行内容', render: (record) => <span className="uplink-content">{record.content}</span> },
|
||||
{ key: 'channel', title: '上行通道', width: '260px', render: (record) => <strong>{record.channel}</strong> },
|
||||
{ key: 'accessNo', title: '上行接入号', width: '180px', render: (record) => <strong>{record.accessNo}</strong> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '140px',
|
||||
align: 'center',
|
||||
render: (record) => (
|
||||
<button className="admin-uplink-detail-link" onClick={() => setSelectedMessage(record)} type="button">查看详情</button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-uplink-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<div className="breadcrumb-line">数据详单 / <strong>短信上行记录</strong></div>
|
||||
<h1>短信上行记录</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-uplink-filter">
|
||||
<DateRangeInput label="上行时间" onChange={setDateRange} value={dateRange} />
|
||||
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} prefix={<Smartphone size={16} />} value={phoneKeyword} />
|
||||
<Input label="上行内容" onChange={(event) => setContentKeyword(event.target.value)} value={contentKeyword} />
|
||||
<div className="admin-uplink-filter__actions">
|
||||
<Button icon={<Search size={16} />}>查询</Button>
|
||||
<Button onClick={resetFilters} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-uplink-table-card">
|
||||
<Table columns={columns} data={filteredMessages} emptyText="暂无上行短信记录" rowKey="id" />
|
||||
<Pagination total={filteredMessages.length} />
|
||||
</div>
|
||||
|
||||
{selectedMessage ? <UplinkDetailModal message={selectedMessage} onClose={() => setSelectedMessage(null)} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { Button, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminService } from '@/mock';
|
||||
import type { AuditItem, AuditStatus } from '@/mock';
|
||||
|
||||
const applicationMap: Record<string, string> = {
|
||||
'AUD-2401': '验证码服务',
|
||||
'AUD-2403': '营销推广平台',
|
||||
};
|
||||
|
||||
const auditStatusLabelMap: Record<AuditStatus, string> = {
|
||||
pending: '待审核',
|
||||
approved: '已通过',
|
||||
rejected: '已驳回',
|
||||
};
|
||||
|
||||
export function AdminTemplateAuditPage() {
|
||||
const [audits, setAudits] = useState(() => adminService.getAudits());
|
||||
const columns = useMemo<Array<TableColumn<AuditItem>>>(
|
||||
() => [
|
||||
{ key: 'id', title: '审核编号', render: (record) => record.id },
|
||||
{ key: 'customer', title: '客户', render: (record) => record.customer },
|
||||
{ key: 'application', title: '短信应用', render: (record) => applicationMap[record.id] ?? '客户通知服务' },
|
||||
{ key: 'content', title: '短信模板内容', render: (record) => record.content },
|
||||
{ key: 'submittedAt', title: '提交时间', render: (record) => record.submittedAt },
|
||||
{
|
||||
key: 'status',
|
||||
title: '状态',
|
||||
render: (record) => (
|
||||
<Tag tone={record.status === 'approved' ? 'success' : record.status === 'rejected' ? 'danger' : 'info'}>
|
||||
{auditStatusLabelMap[record.status]}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<div className="table-actions">
|
||||
<Button
|
||||
disabled={record.status !== 'pending'}
|
||||
icon={<Check size={15} />}
|
||||
onClick={() => setAudits(adminService.updateAuditStatus(record.id, 'approved'))}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
disabled={record.status !== 'pending'}
|
||||
icon={<X size={15} />}
|
||||
onClick={() => setAudits(adminService.updateAuditStatus(record.id, 'rejected'))}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
const templateAudits = audits.filter((item) => item.type === '模板');
|
||||
|
||||
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={templateAudits} rowKey="id" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import { Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
|
||||
type UserStatus = 'enabled' | 'disabled';
|
||||
|
||||
type AdminUser = {
|
||||
id: string;
|
||||
name: string;
|
||||
account: string;
|
||||
role: string;
|
||||
phone: string;
|
||||
status: UserStatus;
|
||||
createdAt: string;
|
||||
lastLogin: string;
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<UserStatus, string> = {
|
||||
enabled: '启用',
|
||||
disabled: '停用',
|
||||
};
|
||||
|
||||
const statusToneMap: Record<UserStatus, 'success' | 'neutral'> = {
|
||||
enabled: 'success',
|
||||
disabled: 'neutral',
|
||||
};
|
||||
|
||||
const initialUsers: AdminUser[] = [
|
||||
{ id: 'USR20260630001', name: '李明', account: 'liming', role: '平台管理员', phone: '13800001234', status: 'enabled', createdAt: '2026-06-01 09:20:10', lastLogin: '2026-06-30 09:15:22' },
|
||||
{ id: 'USR20260630002', name: '张青', account: 'zhangqing', role: '审核专员', phone: '13900005678', status: 'enabled', createdAt: '2026-06-03 14:05:36', lastLogin: '2026-06-29 18:22:11' },
|
||||
{ id: 'USR20260630003', name: '王珊', account: 'wangshan', role: '运营人员', phone: '13755558888', status: 'disabled', createdAt: '2026-06-08 11:12:48', lastLogin: '2026-06-21 10:08:09' },
|
||||
{ id: 'USR20260630004', name: '赵一', account: 'zhaoyi', role: '财务人员', phone: '13677779999', status: 'enabled', createdAt: '2026-06-12 16:30:00', lastLogin: '2026-06-30 08:40:18' },
|
||||
];
|
||||
|
||||
function createUserId() {
|
||||
return `USR${Date.now()}`;
|
||||
}
|
||||
|
||||
type UserFormModalProps = {
|
||||
item?: AdminUser;
|
||||
onClose: () => void;
|
||||
onSubmit: (item: AdminUser) => void;
|
||||
};
|
||||
|
||||
function UserFormModal({ item, onClose, onSubmit }: UserFormModalProps) {
|
||||
const [form, setForm] = useState<AdminUser>(() => item ?? {
|
||||
id: createUserId(),
|
||||
name: '',
|
||||
account: '',
|
||||
role: '运营人员',
|
||||
phone: '',
|
||||
status: 'enabled',
|
||||
createdAt: '2026-06-30 10:00:00',
|
||||
lastLogin: '-',
|
||||
});
|
||||
|
||||
function updateField<Key extends keyof AdminUser>(key: Key, value: AdminUser[Key]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
onSubmit(form);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button onClick={handleSubmit}>保存</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
title={item ? '编辑用户' : '新增用户'}
|
||||
>
|
||||
<div className="admin-system-modal-form">
|
||||
<Input label="用户姓名" onChange={(event) => updateField('name', event.target.value)} value={form.name} />
|
||||
<Input label="登录账号" onChange={(event) => updateField('account', event.target.value)} value={form.account} />
|
||||
<Select
|
||||
label="角色"
|
||||
onChange={(event) => updateField('role', event.target.value)}
|
||||
options={[
|
||||
{ label: '平台管理员', value: '平台管理员' },
|
||||
{ label: '审核专员', value: '审核专员' },
|
||||
{ label: '运营人员', value: '运营人员' },
|
||||
{ label: '财务人员', value: '财务人员' },
|
||||
]}
|
||||
value={form.role}
|
||||
/>
|
||||
<Input label="手机号码" onChange={(event) => updateField('phone', event.target.value)} value={form.phone} />
|
||||
<Select
|
||||
label="状态"
|
||||
onChange={(event) => updateField('status', event.target.value as UserStatus)}
|
||||
options={[
|
||||
{ label: '启用', value: 'enabled' },
|
||||
{ label: '停用', value: 'disabled' },
|
||||
]}
|
||||
value={form.status}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminUsersPage() {
|
||||
const [users, setUsers] = useState(initialUsers);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const filteredUsers = useMemo(
|
||||
() => users.filter((user) => [user.name, user.account, user.role, user.phone].some((value) => value.includes(keyword))),
|
||||
[keyword, users],
|
||||
);
|
||||
|
||||
function upsertUser(nextUser: AdminUser) {
|
||||
setUsers((current) => {
|
||||
const exists = current.some((item) => item.id === nextUser.id);
|
||||
if (exists) {
|
||||
return current.map((item) => (item.id === nextUser.id ? nextUser : item));
|
||||
}
|
||||
|
||||
return [nextUser, ...current];
|
||||
});
|
||||
setEditingUser(null);
|
||||
setCreating(false);
|
||||
}
|
||||
|
||||
const columns = useMemo<Array<TableColumn<AdminUser>>>(() => [
|
||||
{ key: 'name', title: '用户姓名', width: '150px', render: (record) => <strong>{record.name}</strong> },
|
||||
{ key: 'account', title: '登录账号', width: '160px', render: (record) => record.account },
|
||||
{ key: 'role', title: '角色', width: '150px', render: (record) => record.role },
|
||||
{ key: 'phone', title: '手机号码', width: '150px', render: (record) => record.phone },
|
||||
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag> },
|
||||
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => record.createdAt },
|
||||
{ key: 'lastLogin', title: '最近登录', width: '190px', render: (record) => record.lastLogin },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '170px',
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<div className="admin-system-actions">
|
||||
<Button onClick={() => setEditingUser(record)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button
|
||||
onClick={() => setUsers((current) => current.filter((item) => item.id !== record.id))}
|
||||
size="sm"
|
||||
variant="danger"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], []);
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-system-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<div className="breadcrumb-line">系统管理 / <strong>用户管理</strong></div>
|
||||
<h1>用户管理</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-system-toolbar">
|
||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索姓名、账号、角色或手机号" prefix={<Search size={16} />} value={keyword} />
|
||||
<Button icon={<Plus size={16} />} onClick={() => setCreating(true)}>新增用户</Button>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-system-table-card">
|
||||
<Table columns={columns} data={filteredUsers} emptyText="暂无用户" rowKey="id" />
|
||||
</div>
|
||||
|
||||
{creating ? <UserFormModal onClose={() => setCreating(false)} onSubmit={upsertUser} /> : null}
|
||||
{editingUser ? <UserFormModal item={editingUser} onClose={() => setEditingUser(null)} onSubmit={upsertUser} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { adminService } from '@/mock';
|
||||
import { readLocalData, writeLocalData } from '@/mock/storage';
|
||||
|
||||
export type EnterpriseStatus = 'active' | 'disabled';
|
||||
|
||||
export type EnterpriseRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
creditCode: string;
|
||||
province: string;
|
||||
city: string;
|
||||
address: string;
|
||||
contactName: string;
|
||||
contactIdCard: string;
|
||||
contactPhone: string;
|
||||
contactEmail: string;
|
||||
balance: number;
|
||||
overdraftLimit: number;
|
||||
todaySpend: number;
|
||||
status: EnterpriseStatus;
|
||||
};
|
||||
|
||||
export type EnterpriseForm = {
|
||||
id?: string;
|
||||
name: string;
|
||||
creditCode: string;
|
||||
province: string;
|
||||
city: string;
|
||||
address: string;
|
||||
contactName: string;
|
||||
contactIdCard: string;
|
||||
contactPhone: string;
|
||||
contactEmail: string;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'admin-enterprises';
|
||||
|
||||
export const statusOptions = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '正常', value: 'active' },
|
||||
{ label: '已禁用', value: 'disabled' },
|
||||
];
|
||||
|
||||
export const provinceOptions = [
|
||||
{ label: '请选择省/直辖市', value: '' },
|
||||
{ label: '上海', value: '上海' },
|
||||
{ label: '广东', value: '广东' },
|
||||
{ label: '北京', value: '北京' },
|
||||
{ label: '浙江', value: '浙江' },
|
||||
{ label: '四川', value: '四川' },
|
||||
];
|
||||
|
||||
export const cityOptionsByProvince: Record<string, Array<{ label: string; value: string }>> = {
|
||||
上海: [{ label: '上海市', value: '上海市' }],
|
||||
广东: [{ label: '深圳市', value: '深圳市' }, { label: '广州市', value: '广州市' }],
|
||||
北京: [{ label: '北京市', value: '北京市' }],
|
||||
浙江: [{ label: '杭州市', value: '杭州市' }],
|
||||
四川: [{ label: '成都市', value: '成都市' }],
|
||||
};
|
||||
|
||||
export const initialEnterpriseForm: EnterpriseForm = {
|
||||
name: '',
|
||||
creditCode: '',
|
||||
province: '',
|
||||
city: '',
|
||||
address: '',
|
||||
contactName: '',
|
||||
contactIdCard: '',
|
||||
contactPhone: '',
|
||||
contactEmail: '',
|
||||
};
|
||||
|
||||
function buildEnterpriseRecords(): EnterpriseRecord[] {
|
||||
const customers = adminService.getCustomers();
|
||||
const seed = [
|
||||
{ id: '2763', city: '上海市', province: '上海', spend: 1123.4, overdraft: 1000, code: '91310000MA1K2763X1' },
|
||||
{ id: '9213', city: '上海市', province: '上海', spend: 256.3, overdraft: 0, code: '91310000MA1K9213X2' },
|
||||
{ id: '2345', city: '深圳市', province: '广东', spend: 97.25, overdraft: 0, code: '91440300MA1K2345X3' },
|
||||
{ id: '3431', city: '北京市', province: '北京', spend: 66.2, overdraft: 0, code: '91110108MA1K3431X4' },
|
||||
{ id: '2313', city: '杭州市', province: '浙江', spend: 12, overdraft: 0, code: '91330100MA1K2313X5' },
|
||||
{ id: '5621', city: '广州市', province: '广东', spend: 2.51, overdraft: 0, code: '91440100MA1K5621X6' },
|
||||
{ id: '7834', city: '成都市', province: '四川', spend: 0, overdraft: 0, code: '91510100MA1K7834X7' },
|
||||
];
|
||||
|
||||
return seed.map((item, index) => {
|
||||
const customer = customers[index % customers.length];
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.id === '2763' ? '上海XXXXX科技有限公司' : customer.name.replace('云舟', 'XXXXX'),
|
||||
creditCode: item.code,
|
||||
province: item.province,
|
||||
city: item.city,
|
||||
address: `${item.city}示例路 ${index + 1} 号`,
|
||||
contactName: customer.contact,
|
||||
contactIdCard: `31010119900${index + 1}01001X`,
|
||||
contactPhone: `1380000${String(index + 1).padStart(4, '0')}`,
|
||||
contactEmail: `contact${index + 1}@example.com`,
|
||||
balance: index === 1 ? -256.3 : customer.balance / 100,
|
||||
overdraftLimit: item.overdraft,
|
||||
todaySpend: item.spend,
|
||||
status: index === 0 || index === 2 ? 'disabled' : 'active',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getEnterpriseRecords() {
|
||||
return readLocalData<EnterpriseRecord[]>(STORAGE_KEY, buildEnterpriseRecords());
|
||||
}
|
||||
|
||||
export function saveEnterpriseRecords(records: EnterpriseRecord[]) {
|
||||
writeLocalData(STORAGE_KEY, records);
|
||||
}
|
||||
|
||||
export function createEnterprise(form: EnterpriseForm) {
|
||||
const records = getEnterpriseRecords();
|
||||
const nextId = String(Math.max(...records.map((record) => Number(record.id)), 1000) + 1);
|
||||
const nextRecord: EnterpriseRecord = {
|
||||
id: nextId,
|
||||
name: form.name,
|
||||
creditCode: form.creditCode,
|
||||
province: form.province,
|
||||
city: form.city,
|
||||
address: form.address,
|
||||
contactName: form.contactName,
|
||||
contactIdCard: form.contactIdCard,
|
||||
contactPhone: form.contactPhone,
|
||||
contactEmail: form.contactEmail,
|
||||
balance: 0,
|
||||
overdraftLimit: 0,
|
||||
todaySpend: 0,
|
||||
status: 'active',
|
||||
};
|
||||
saveEnterpriseRecords([nextRecord, ...records]);
|
||||
return nextRecord;
|
||||
}
|
||||
|
||||
export function updateEnterprise(form: EnterpriseForm) {
|
||||
if (!form.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveEnterpriseRecords(getEnterpriseRecords().map((record) => (
|
||||
record.id === form.id
|
||||
? {
|
||||
...record,
|
||||
name: form.name,
|
||||
creditCode: form.creditCode,
|
||||
province: form.province,
|
||||
city: form.city,
|
||||
address: form.address,
|
||||
contactName: form.contactName,
|
||||
contactIdCard: form.contactIdCard,
|
||||
contactPhone: form.contactPhone,
|
||||
contactEmail: form.contactEmail,
|
||||
}
|
||||
: record
|
||||
)));
|
||||
}
|
||||
|
||||
export function toggleEnterpriseStatus(id: string) {
|
||||
const records = getEnterpriseRecords().map((record) => (
|
||||
record.id === id
|
||||
? {
|
||||
...record,
|
||||
status: (record.status === 'active' ? 'disabled' : 'active') as EnterpriseStatus,
|
||||
}
|
||||
: record
|
||||
));
|
||||
saveEnterpriseRecords(records);
|
||||
return records;
|
||||
}
|
||||
|
||||
export function toEnterpriseForm(record: EnterpriseRecord): EnterpriseForm {
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
creditCode: record.creditCode,
|
||||
province: record.province,
|
||||
city: record.city,
|
||||
address: record.address,
|
||||
contactName: record.contactName,
|
||||
contactIdCard: record.contactIdCard,
|
||||
contactPhone: record.contactPhone,
|
||||
contactEmail: record.contactEmail,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatCurrency(value: number) {
|
||||
return value.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { Button, Tag, type TableColumn } from '@/components/ui';
|
||||
import type { AuditItem, AuditStatus } from '@/mock';
|
||||
|
||||
const riskToneMap = {
|
||||
low: 'success',
|
||||
medium: 'warning',
|
||||
high: 'danger',
|
||||
} as const;
|
||||
|
||||
const riskLabelMap = {
|
||||
low: '低风险',
|
||||
medium: '中风险',
|
||||
high: '高风险',
|
||||
};
|
||||
|
||||
const auditStatusLabelMap: Record<AuditStatus, string> = {
|
||||
pending: '待审核',
|
||||
approved: '已通过',
|
||||
rejected: '已驳回',
|
||||
};
|
||||
|
||||
export function createAuditColumns(
|
||||
onUpdateStatus: (id: string, status: AuditStatus) => void,
|
||||
): Array<TableColumn<AuditItem>> {
|
||||
return [
|
||||
{ key: 'id', title: '审核编号', render: (record) => record.id },
|
||||
{ key: 'customer', title: '客户', render: (record) => record.customer },
|
||||
{ key: 'type', title: '类型', render: (record) => <Tag tone="accent">{record.type}</Tag> },
|
||||
{ key: 'content', title: '内容', render: (record) => record.content },
|
||||
{ key: 'submittedAt', title: '提交时间', render: (record) => record.submittedAt },
|
||||
{
|
||||
key: 'risk',
|
||||
title: '风险',
|
||||
render: (record) => <Tag tone={riskToneMap[record.risk]}>{riskLabelMap[record.risk]}</Tag>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: '状态',
|
||||
render: (record) => (
|
||||
<Tag tone={record.status === 'approved' ? 'success' : record.status === 'rejected' ? 'danger' : 'info'}>
|
||||
{auditStatusLabelMap[record.status]}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<div className="table-actions">
|
||||
<Button
|
||||
disabled={record.status !== 'pending'}
|
||||
icon={<Check size={15} />}
|
||||
onClick={() => onUpdateStatus(record.id, 'approved')}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
disabled={record.status !== 'pending'}
|
||||
icon={<X size={15} />}
|
||||
onClick={() => onUpdateStatus(record.id, 'rejected')}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
const pageTitleMap: Record<string, string> = {
|
||||
'/client/send': '短信发送',
|
||||
'/client/templates': '模板管理',
|
||||
'/client/signatures': '签名与引流信息',
|
||||
'/client/billing': '充值套餐',
|
||||
'/client/invoices': '账单流水',
|
||||
'/client/settings': '账号设置',
|
||||
'/admin/monitor': '发送监控',
|
||||
'/admin/analytics': '数据统计',
|
||||
'/admin/customers': '客户管理',
|
||||
'/admin/templates': '模板审核',
|
||||
'/admin/signatures': '签名审核',
|
||||
'/admin/channels': '通道管理',
|
||||
'/admin/billing': '账单流水',
|
||||
'/admin/settings': '系统配置',
|
||||
};
|
||||
|
||||
export function PagePlaceholder() {
|
||||
const location = useLocation();
|
||||
const title = pageTitleMap[location.pathname] ?? '页面建设中';
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<p className="eyebrow">模块</p>
|
||||
<h1>{title}</h1>
|
||||
</div>
|
||||
<p className="muted">后续步骤会在这里补齐表格、筛选、表单和交互流程。</p>
|
||||
</div>
|
||||
<div className="surface empty-state">
|
||||
<strong>{title}</strong>
|
||||
<span>页面骨架已接入,等待业务组件填充。</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
icon?: ReactNode;
|
||||
iconOnly?: boolean;
|
||||
};
|
||||
|
||||
export function Button({
|
||||
className = '',
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
icon,
|
||||
iconOnly = false,
|
||||
children,
|
||||
type = 'button',
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const classes = [
|
||||
'ui-button',
|
||||
`ui-button--${variant}`,
|
||||
`ui-button--${size}`,
|
||||
iconOnly ? 'ui-button--icon-only' : '',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<button className={classes} type={type} {...props}>
|
||||
{icon ? <span className="ui-button__icon">{icon}</span> : null}
|
||||
{iconOnly ? <span className="sr-only">{children}</span> : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
type ChartProps = {
|
||||
option: EChartsOption;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
export function Chart({ option, height = 280 }: ChartProps) {
|
||||
const chartRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const chart = echarts.init(chartRef.current);
|
||||
chart.setOption(option);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => chart.resize());
|
||||
resizeObserver.observe(chartRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
chart.dispose();
|
||||
};
|
||||
}, [option]);
|
||||
|
||||
return <div className="ui-chart" ref={chartRef} style={{ height }} />;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
||||
|
||||
export type DateRangeValue = {
|
||||
start?: string;
|
||||
end?: string;
|
||||
};
|
||||
|
||||
type DateRangeInputProps = {
|
||||
label?: string;
|
||||
value: DateRangeValue;
|
||||
onChange: (value: DateRangeValue) => void;
|
||||
};
|
||||
|
||||
const weekDays = ['一', '二', '三', '四', '五', '六', '日'];
|
||||
const presets = [
|
||||
{ label: '今天', days: 0 },
|
||||
{ label: '近7天', days: 6 },
|
||||
{ label: '近30天', days: 29 },
|
||||
];
|
||||
|
||||
function formatDate(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function parseDate(value?: string) {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [year, month, day] = value.split('-').map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
function getMonthLabel(date: Date) {
|
||||
return `${date.getFullYear()}年${date.getMonth() + 1}月`;
|
||||
}
|
||||
|
||||
function isSameDate(left: Date, right?: Date) {
|
||||
if (!right) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return formatDate(left) === formatDate(right);
|
||||
}
|
||||
|
||||
function getCalendarDays(viewDate: Date) {
|
||||
const year = viewDate.getFullYear();
|
||||
const month = viewDate.getMonth();
|
||||
const firstDate = new Date(year, month, 1);
|
||||
const firstWeekday = (firstDate.getDay() + 6) % 7;
|
||||
const startDate = new Date(year, month, 1 - firstWeekday);
|
||||
|
||||
return Array.from({ length: 42 }, (_, index) => {
|
||||
const date = new Date(startDate);
|
||||
date.setDate(startDate.getDate() + index);
|
||||
return {
|
||||
date,
|
||||
dateString: formatDate(date),
|
||||
inCurrentMonth: date.getMonth() === month,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function DateRangeInput({ label, value, onChange }: DateRangeInputProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [viewDate, setViewDate] = useState(() => parseDate(value.start) ?? new Date());
|
||||
const startDate = parseDate(value.start);
|
||||
const endDate = parseDate(value.end);
|
||||
|
||||
const calendarDays = useMemo(() => getCalendarDays(viewDate), [viewDate]);
|
||||
|
||||
const displayValue = useMemo(() => {
|
||||
if (value.start && value.end) {
|
||||
return `${value.start} 至 ${value.end}`;
|
||||
}
|
||||
|
||||
if (value.start) {
|
||||
return `${value.start} 至 ...`;
|
||||
}
|
||||
|
||||
return '选择提交时间区间';
|
||||
}, [value.end, value.start]);
|
||||
|
||||
function moveMonth(offset: number) {
|
||||
setViewDate((current) => new Date(current.getFullYear(), current.getMonth() + offset, 1));
|
||||
}
|
||||
|
||||
function applyPreset(days: number) {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setDate(end.getDate() - days);
|
||||
onChange({ start: formatDate(start), end: formatDate(end) });
|
||||
setViewDate(start);
|
||||
}
|
||||
|
||||
function selectDate(dateString: string) {
|
||||
if (!value.start || value.end) {
|
||||
onChange({ start: dateString, end: undefined });
|
||||
return;
|
||||
}
|
||||
|
||||
if (dateString < value.start) {
|
||||
onChange({ start: dateString, end: value.start });
|
||||
return;
|
||||
}
|
||||
|
||||
onChange({ start: value.start, end: dateString });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ui-field ui-date-range-field">
|
||||
{label ? <span className="ui-field__label">{label}</span> : null}
|
||||
<button
|
||||
className={['ui-date-range-trigger', open ? 'ui-date-range-trigger--open' : '', value.start || value.end ? 'ui-date-range-trigger--filled' : ''].filter(Boolean).join(' ')}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<CalendarDays size={16} />
|
||||
<span>{displayValue}</span>
|
||||
{value.start || value.end ? (
|
||||
<span
|
||||
aria-label="清空时间区间"
|
||||
className="ui-date-range__clear"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onChange({});
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onChange({});
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<X size={14} />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="ui-date-range-popover">
|
||||
<div className="ui-date-range-presets">
|
||||
{presets.map((item) => (
|
||||
<button key={item.label} onClick={() => applyPreset(item.days)} type="button">{item.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ui-calendar">
|
||||
<div className="ui-calendar__header">
|
||||
<button aria-label="上个月" onClick={() => moveMonth(-1)} type="button">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<strong>{getMonthLabel(viewDate)}</strong>
|
||||
<button aria-label="下个月" onClick={() => moveMonth(1)} type="button">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="ui-calendar__weekdays">
|
||||
{weekDays.map((day) => <span key={day}>{day}</span>)}
|
||||
</div>
|
||||
<div className="ui-calendar__grid">
|
||||
{calendarDays.map((item) => {
|
||||
const isStart = isSameDate(item.date, startDate);
|
||||
const isEnd = isSameDate(item.date, endDate);
|
||||
const isInRange = Boolean(startDate && endDate && item.date > startDate && item.date < endDate);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={[
|
||||
'ui-calendar__day',
|
||||
item.inCurrentMonth ? '' : 'ui-calendar__day--muted',
|
||||
isStart ? 'ui-calendar__day--start' : '',
|
||||
isEnd ? 'ui-calendar__day--end' : '',
|
||||
isInRange ? 'ui-calendar__day--range' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
key={item.dateString}
|
||||
onClick={() => selectDate(item.dateString)}
|
||||
type="button"
|
||||
>
|
||||
{item.date.getDate()}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ui-date-range-actions">
|
||||
<button onClick={() => onChange({})} type="button">清空</button>
|
||||
<button disabled={!value.start} onClick={() => setOpen(false)} type="button">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, Clock3, X } from 'lucide-react';
|
||||
|
||||
type DateTimeInputProps = {
|
||||
label?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
const weekDays = ['一', '二', '三', '四', '五', '六', '日'];
|
||||
|
||||
function formatDate(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function parseDate(value?: string) {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [year, month, day] = value.split('-').map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
function getMonthLabel(date: Date) {
|
||||
return `${date.getFullYear()}年${date.getMonth() + 1}月`;
|
||||
}
|
||||
|
||||
function getCalendarDays(viewDate: Date) {
|
||||
const year = viewDate.getFullYear();
|
||||
const month = viewDate.getMonth();
|
||||
const firstDate = new Date(year, month, 1);
|
||||
const firstWeekday = (firstDate.getDay() + 6) % 7;
|
||||
const startDate = new Date(year, month, 1 - firstWeekday);
|
||||
|
||||
return Array.from({ length: 42 }, (_, index) => {
|
||||
const date = new Date(startDate);
|
||||
date.setDate(startDate.getDate() + index);
|
||||
return {
|
||||
date,
|
||||
dateString: formatDate(date),
|
||||
inCurrentMonth: date.getMonth() === month,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function splitDateTime(value: string) {
|
||||
const [date = '', time = ''] = value.split('T');
|
||||
return { date, time };
|
||||
}
|
||||
|
||||
export function DateTimeInput({ label, value, onChange, placeholder = '选择定时发送时间' }: DateTimeInputProps) {
|
||||
const { date, time } = splitDateTime(value);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [viewDate, setViewDate] = useState(() => parseDate(date) ?? new Date());
|
||||
const calendarDays = useMemo(() => getCalendarDays(viewDate), [viewDate]);
|
||||
|
||||
function moveMonth(offset: number) {
|
||||
setViewDate((current) => new Date(current.getFullYear(), current.getMonth() + offset, 1));
|
||||
}
|
||||
|
||||
function updateDate(nextDate: string) {
|
||||
onChange(`${nextDate}T${time || '09:00'}`);
|
||||
}
|
||||
|
||||
function updateTime(nextTime: string) {
|
||||
onChange(`${date || formatDate(new Date())}T${nextTime}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ui-field ui-date-range-field">
|
||||
{label ? <span className="ui-field__label">{label}</span> : null}
|
||||
<button
|
||||
className={['ui-date-range-trigger', open ? 'ui-date-range-trigger--open' : '', value ? 'ui-date-range-trigger--filled' : ''].filter(Boolean).join(' ')}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<CalendarDays size={16} />
|
||||
<span>{value ? `${date} ${time}` : placeholder}</span>
|
||||
{value ? (
|
||||
<span
|
||||
aria-label="清空定时发送时间"
|
||||
className="ui-date-range__clear"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onChange('');
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<X size={14} />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="ui-date-range-popover ui-date-time-popover">
|
||||
<div className="ui-calendar">
|
||||
<div className="ui-calendar__header">
|
||||
<button aria-label="上个月" onClick={() => moveMonth(-1)} type="button">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<strong>{getMonthLabel(viewDate)}</strong>
|
||||
<button aria-label="下个月" onClick={() => moveMonth(1)} type="button">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="ui-calendar__weekdays">
|
||||
{weekDays.map((day) => <span key={day}>{day}</span>)}
|
||||
</div>
|
||||
<div className="ui-calendar__grid">
|
||||
{calendarDays.map((item) => (
|
||||
<button
|
||||
className={[
|
||||
'ui-calendar__day',
|
||||
item.inCurrentMonth ? '' : 'ui-calendar__day--muted',
|
||||
item.dateString === date ? 'ui-calendar__day--selected' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
key={item.dateString}
|
||||
onClick={() => updateDate(item.dateString)}
|
||||
type="button"
|
||||
>
|
||||
{item.date.getDate()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<label className="ui-time-picker">
|
||||
<span><Clock3 size={15} /> 发送时间</span>
|
||||
<input onChange={(event) => updateTime(event.target.value)} type="time" value={time || '09:00'} />
|
||||
</label>
|
||||
<div className="ui-date-range-actions">
|
||||
<button onClick={() => onChange('')} type="button">清空</button>
|
||||
<button disabled={!value} onClick={() => setOpen(false)} type="button">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type RateTone = 'low' | 'medium' | 'high';
|
||||
|
||||
type DetailTitleProps = {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
};
|
||||
|
||||
type DetailSectionProps = {
|
||||
title: ReactNode;
|
||||
extra?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export type DetailInfoItem = {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
tone?: 'default' | 'primary' | 'success' | 'danger';
|
||||
full?: boolean;
|
||||
};
|
||||
|
||||
type DetailInfoGridProps = {
|
||||
items: DetailInfoItem[];
|
||||
};
|
||||
|
||||
type DetailProgressStatsProps = {
|
||||
label: ReactNode;
|
||||
percent: number;
|
||||
meta?: ReactNode;
|
||||
status?: 'completed' | 'sending' | 'terminated';
|
||||
stats: Array<{
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
}>;
|
||||
};
|
||||
|
||||
type RateOverviewProps = {
|
||||
label: ReactNode;
|
||||
rate: number;
|
||||
tone: RateTone;
|
||||
metrics: Array<{
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
}>;
|
||||
};
|
||||
|
||||
type RateCardProps = {
|
||||
title: ReactNode;
|
||||
rate: number;
|
||||
tone: RateTone;
|
||||
meta?: ReactNode;
|
||||
};
|
||||
|
||||
type ProgressBarProps = {
|
||||
percent: number;
|
||||
tone?: RateTone;
|
||||
status?: 'completed' | 'sending' | 'terminated';
|
||||
};
|
||||
|
||||
export function getRateTone(rate: number): RateTone {
|
||||
if (rate < 50) {
|
||||
return 'low';
|
||||
}
|
||||
|
||||
if (rate <= 80) {
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
return 'high';
|
||||
}
|
||||
|
||||
export function DetailTitle({ title, subtitle }: DetailTitleProps) {
|
||||
return (
|
||||
<div className="ui-detail-title">
|
||||
<h2>{title}</h2>
|
||||
{subtitle ? <p>{subtitle}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailSection({ title, extra, children }: DetailSectionProps) {
|
||||
return (
|
||||
<section className="ui-detail-section">
|
||||
<div className="ui-detail-section__header">
|
||||
<h3>{title}</h3>
|
||||
{extra ? <div className="ui-detail-section__extra">{extra}</div> : null}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailInfoGrid({ items }: DetailInfoGridProps) {
|
||||
return (
|
||||
<div className="ui-detail-info-grid">
|
||||
{items.map((item, index) => (
|
||||
<div className={item.full ? 'ui-detail-info-grid__item ui-detail-info-grid__item--full' : 'ui-detail-info-grid__item'} key={index}>
|
||||
<span>{item.label}</span>
|
||||
<strong className={item.tone && item.tone !== 'default' ? `ui-detail-text--${item.tone}` : undefined}>{item.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailProgressStats({ label, percent, meta, status, stats }: DetailProgressStatsProps) {
|
||||
return (
|
||||
<div className="ui-detail-progress-stats">
|
||||
<div className="ui-detail-progress-card">
|
||||
<span>{label}</span>
|
||||
<strong>{percent}%</strong>
|
||||
<ProgressBar percent={percent} status={status} />
|
||||
{meta ? <small>{meta}</small> : null}
|
||||
</div>
|
||||
<div className="ui-detail-stat-list">
|
||||
{stats.map((item, index) => (
|
||||
<div key={index}>
|
||||
<span>{item.label}</span>
|
||||
<strong>{item.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RateOverview({ label, rate, tone, metrics }: RateOverviewProps) {
|
||||
return (
|
||||
<div className={`ui-rate-overview ui-rate-tone-${tone}`}>
|
||||
<span>{label}</span>
|
||||
<strong>{rate.toFixed(2)}%</strong>
|
||||
<div className="ui-rate-overview__metrics">
|
||||
{metrics.map((item, index) => (
|
||||
<div key={index}>
|
||||
<span>{item.label}</span>
|
||||
<b>{item.value}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RateCard({ title, rate, tone, meta }: RateCardProps) {
|
||||
return (
|
||||
<div className={`ui-rate-card ui-rate-tone-${tone}`}>
|
||||
<div className="ui-rate-card__header">
|
||||
<strong>{title}</strong>
|
||||
<span>{rate.toFixed(2)}%</span>
|
||||
</div>
|
||||
<ProgressBar percent={rate} tone={tone} />
|
||||
{meta ? <p>{meta}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProgressBar({ percent, tone, status }: ProgressBarProps) {
|
||||
const toneClass = tone ? `ui-rate-tone-${tone}` : '';
|
||||
const statusClass = status ? `ui-progress-bar--${status}` : '';
|
||||
|
||||
return (
|
||||
<div className={['ui-progress-bar', toneClass, statusClass].filter(Boolean).join(' ')}>
|
||||
<span style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { InputHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
type NativeInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'prefix'>;
|
||||
|
||||
type InputProps = NativeInputProps & {
|
||||
label?: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
prefix?: ReactNode;
|
||||
suffix?: ReactNode;
|
||||
};
|
||||
|
||||
export function Input({
|
||||
className = '',
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
prefix,
|
||||
suffix,
|
||||
id,
|
||||
...props
|
||||
}: InputProps) {
|
||||
const inputId = id ?? props.name;
|
||||
|
||||
return (
|
||||
<label className={['ui-field', className].filter(Boolean).join(' ')} htmlFor={inputId}>
|
||||
{label ? <span className="ui-field__label">{label}</span> : null}
|
||||
<span className={['ui-input', error ? 'ui-input--error' : ''].filter(Boolean).join(' ')}>
|
||||
{prefix ? <span className="ui-input__addon">{prefix}</span> : null}
|
||||
<input id={inputId} {...props} />
|
||||
{suffix ? <span className="ui-input__addon">{suffix}</span> : null}
|
||||
</span>
|
||||
{error ? <span className="ui-field__error">{error}</span> : null}
|
||||
{!error && hint ? <span className="ui-field__hint">{hint}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
type ModalProps = {
|
||||
open: boolean;
|
||||
title: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
size?: 'md' | 'xl';
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function Modal({ open, title, children, footer, size = 'md', onClose }: ModalProps) {
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="ui-modal" role="presentation">
|
||||
<button className="ui-modal__mask" type="button" aria-label="关闭弹窗" onClick={onClose} />
|
||||
<section aria-modal="true" className={['ui-modal__panel', `ui-modal__panel--${size}`].join(' ')} role="dialog">
|
||||
<header className="ui-modal__header">
|
||||
<div className="ui-modal__title">{title}</div>
|
||||
<Button icon={<X size={17} />} iconOnly variant="ghost" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</header>
|
||||
<div className="ui-modal__body">{children}</div>
|
||||
{footer ? <footer className="ui-modal__footer">{footer}</footer> : null}
|
||||
</section>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
type QueryPanelProps = {
|
||||
title: ReactNode;
|
||||
summary?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type PaginationProps = {
|
||||
total: number;
|
||||
page?: number;
|
||||
previousDisabled?: boolean;
|
||||
nextDisabled?: boolean;
|
||||
onPrevious?: () => void;
|
||||
onNext?: () => void;
|
||||
};
|
||||
|
||||
type InlineTextPreviewProps = {
|
||||
label: ReactNode;
|
||||
leading?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function QueryPanel({ title, summary, children }: QueryPanelProps) {
|
||||
return (
|
||||
<div className="surface ui-query-panel">
|
||||
<h2>{title}</h2>
|
||||
<div className="ui-query-panel__grid">{children}</div>
|
||||
{summary ? <p className="ui-query-panel__summary">{summary}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
total,
|
||||
page = 1,
|
||||
previousDisabled = true,
|
||||
nextDisabled = true,
|
||||
onPrevious,
|
||||
onNext,
|
||||
}: PaginationProps) {
|
||||
return (
|
||||
<div className="ui-pagination">
|
||||
<span>显示 {total} 条记录</span>
|
||||
<div>
|
||||
<Button disabled={previousDisabled} onClick={onPrevious} size="sm" variant="ghost">上一页</Button>
|
||||
<Button size="sm" variant="secondary">{page}</Button>
|
||||
<Button disabled={nextDisabled} onClick={onNext} size="sm" variant="ghost">下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InlineTextPreview({ label, leading, children }: InlineTextPreviewProps) {
|
||||
return (
|
||||
<div className="ui-inline-text-preview">
|
||||
<span>{label}</span>
|
||||
<p>{leading}{children}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { SelectHTMLAttributes } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
export type SelectOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type NativeSelectProps = Omit<
|
||||
SelectHTMLAttributes<HTMLSelectElement>,
|
||||
'children' | 'defaultValue' | 'multiple' | 'onChange' | 'size' | 'value'
|
||||
>;
|
||||
|
||||
type SelectProps = NativeSelectProps & {
|
||||
label?: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
options: SelectOption[];
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
onChange?: (event: { target: { value: string } }) => void;
|
||||
};
|
||||
|
||||
export function Select({
|
||||
className = '',
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
options,
|
||||
id,
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder,
|
||||
...props
|
||||
}: SelectProps) {
|
||||
const selectId = id ?? props.name;
|
||||
const rootRef = useRef<HTMLLabelElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [internalValue, setInternalValue] = useState(defaultValue ?? value ?? options[0]?.value ?? '');
|
||||
const selectedValue = value ?? internalValue;
|
||||
const selectedOption = useMemo(
|
||||
() => options.find((option) => option.value === selectedValue),
|
||||
[options, selectedValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
return () => document.removeEventListener('pointerdown', handlePointerDown);
|
||||
}, []);
|
||||
|
||||
function selectOption(nextValue: string) {
|
||||
setInternalValue(nextValue);
|
||||
onChange?.({ target: { value: nextValue } });
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<label
|
||||
className={['ui-field', className].filter(Boolean).join(' ')}
|
||||
htmlFor={selectId}
|
||||
ref={rootRef}
|
||||
>
|
||||
{label ? <span className="ui-field__label">{label}</span> : null}
|
||||
<span
|
||||
className={[
|
||||
'ui-select',
|
||||
open ? 'ui-select--open' : '',
|
||||
error ? 'ui-select--error' : '',
|
||||
disabled ? 'ui-select--disabled' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
<button
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
disabled={disabled}
|
||||
id={selectId}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<span className={selectedOption?.value ? '' : 'ui-select__placeholder'}>
|
||||
{selectedOption?.label ?? placeholder ?? '请选择'}
|
||||
</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="ui-select__dropdown" role="listbox">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
aria-selected={option.value === selectedValue}
|
||||
key={option.value}
|
||||
onClick={() => selectOption(option.value)}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</span>
|
||||
{error ? <span className="ui-field__error">{error}</span> : null}
|
||||
{!error && hint ? <span className="ui-field__hint">{hint}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type TableColumn<T> = {
|
||||
key: string;
|
||||
title: string;
|
||||
width?: string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
render: (record: T, index: number) => ReactNode;
|
||||
};
|
||||
|
||||
type TableProps<T> = {
|
||||
columns: Array<TableColumn<T>>;
|
||||
data: T[];
|
||||
rowKey: keyof T | ((record: T) => string);
|
||||
emptyText?: string;
|
||||
};
|
||||
|
||||
export function Table<T>({ columns, data, rowKey, emptyText = '暂无数据' }: TableProps<T>) {
|
||||
function getRowKey(record: T) {
|
||||
if (typeof rowKey === 'function') {
|
||||
return rowKey(record);
|
||||
}
|
||||
|
||||
return String(record[rowKey]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
style={{ width: column.width, textAlign: column.align ?? 'left' }}
|
||||
>
|
||||
{column.title}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td className="ui-table__empty" colSpan={columns.length}>
|
||||
{emptyText}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((record, index) => (
|
||||
<tr key={getRowKey(record)}>
|
||||
{columns.map((column) => (
|
||||
<td
|
||||
key={column.key}
|
||||
style={{ textAlign: column.align ?? 'left' }}
|
||||
>
|
||||
{column.render(record, index)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export type TabItem = {
|
||||
label: string;
|
||||
value: string;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
type TabsProps = {
|
||||
items: TabItem[];
|
||||
defaultValue?: string;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
export function Tabs({ items, defaultValue, value, onChange }: TabsProps) {
|
||||
const [internalValue, setInternalValue] = useState(defaultValue ?? items[0]?.value);
|
||||
const activeValue = value ?? internalValue;
|
||||
const activeItem = items.find((item) => item.value === activeValue) ?? items[0];
|
||||
|
||||
function handleChange(nextValue: string) {
|
||||
setInternalValue(nextValue);
|
||||
onChange?.(nextValue);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ui-tabs">
|
||||
<div className="ui-tabs__list" role="tablist">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
aria-selected={item.value === activeValue}
|
||||
className="ui-tabs__tab"
|
||||
key={item.value}
|
||||
onClick={() => handleChange(item.value)}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ui-tabs__panel" role="tabpanel">
|
||||
{activeItem?.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
type TagTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger' | 'accent';
|
||||
|
||||
type TagProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
tone?: TagTone;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function Tag({ className = '', tone = 'neutral', children, ...props }: TagProps) {
|
||||
return (
|
||||
<span className={['ui-tag', `ui-tag--${tone}`, className].filter(Boolean).join(' ')} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { TextareaHTMLAttributes } from 'react';
|
||||
|
||||
type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
||||
label?: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function Textarea({ className = '', label, hint, error, id, ...props }: TextareaProps) {
|
||||
const textareaId = id ?? props.name;
|
||||
|
||||
return (
|
||||
<label className={['ui-field', className].filter(Boolean).join(' ')} htmlFor={textareaId}>
|
||||
{label ? <span className="ui-field__label">{label}</span> : null}
|
||||
<textarea
|
||||
className={['ui-textarea', error ? 'ui-textarea--error' : ''].filter(Boolean).join(' ')}
|
||||
id={textareaId}
|
||||
{...props}
|
||||
/>
|
||||
{error ? <span className="ui-field__error">{error}</span> : null}
|
||||
{!error && hint ? <span className="ui-field__hint">{hint}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export { Button } from './Button';
|
||||
export { Chart } from './Chart';
|
||||
export { DateRangeInput } from './DateRangeInput';
|
||||
export { DateTimeInput } from './DateTimeInput';
|
||||
export { DetailInfoGrid, DetailProgressStats, DetailSection, DetailTitle, getRateTone, ProgressBar, RateCard, RateOverview } from './Detail';
|
||||
export { Input } from './Input';
|
||||
export { Modal } from './Modal';
|
||||
export { InlineTextPreview, Pagination, QueryPanel } from './PagePrimitives';
|
||||
export { Select } from './Select';
|
||||
export { Table } from './Table';
|
||||
export { Tabs } from './Tabs';
|
||||
export { Tag } from './Tag';
|
||||
export { Textarea } from './Textarea';
|
||||
export type { SelectOption } from './Select';
|
||||
export type { DetailInfoItem, RateTone } from './Detail';
|
||||
export type { DateRangeValue } from './DateRangeInput';
|
||||
export type { TableColumn } from './Table';
|
||||
export type { TabItem } from './Tabs';
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Building2,
|
||||
FileCheck2,
|
||||
Gauge,
|
||||
Hash,
|
||||
ImageIcon,
|
||||
Layers3,
|
||||
MessageSquare,
|
||||
Phone,
|
||||
TrendingUp,
|
||||
RadioTower,
|
||||
ReceiptText,
|
||||
Send,
|
||||
Settings,
|
||||
Shield,
|
||||
ShieldOff,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
UserX,
|
||||
} from 'lucide-react';
|
||||
import { AppShell } from '@/layouts/AppShell';
|
||||
|
||||
export function AdminLayout() {
|
||||
return (
|
||||
<AppShell
|
||||
title="CMPP 运营端"
|
||||
subtitle="平台运营管理中心"
|
||||
workspaceName="平台运营工作区"
|
||||
userName="运营"
|
||||
userRole="平台管理员"
|
||||
navSections={[
|
||||
{
|
||||
title: '运营概览',
|
||||
icon: Gauge,
|
||||
items: [
|
||||
{ label: '运营看板', to: '/admin', icon: Gauge },
|
||||
{ label: '发送监控', to: '/admin/monitor', icon: Activity },
|
||||
{ label: '数据统计', to: '/admin/analytics', icon: BarChart3 },
|
||||
{ label: '客户管理', to: '/admin/customers', icon: Building2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '审核中心',
|
||||
icon: FileCheck2,
|
||||
items: [
|
||||
{ label: '企业认证审核', to: '/admin/enterprise-audit', icon: ShieldCheck },
|
||||
{ label: '短信审核', to: '/admin/sms-audit', icon: MessageSquare },
|
||||
{ label: '短信模板审核', to: '/admin/templates', icon: FileCheck2 },
|
||||
{ label: '彩信模板审核', to: '/admin/signatures', icon: ImageIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '通道管理',
|
||||
icon: RadioTower,
|
||||
items: [
|
||||
{ label: '短信通道管理', to: '/admin/channels', icon: RadioTower },
|
||||
{ label: '彩信通道管理', to: '/admin/mms-channels', icon: ImageIcon },
|
||||
{ label: '短信通道组管理', to: '/admin/channel-groups', icon: Layers3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '发送任务',
|
||||
icon: Send,
|
||||
items: [
|
||||
{ label: '短信任务进度', to: '/admin/sms-task-progress', icon: TrendingUp },
|
||||
{ label: '彩信任务进度', to: '/admin/mms-task-progress', icon: BarChart3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '数据详单',
|
||||
icon: ReceiptText,
|
||||
items: [
|
||||
{ label: '短信记录', to: '/admin/sms-records', icon: MessageSquare },
|
||||
{ label: '彩信记录', to: '/admin/mms-records', icon: ImageIcon },
|
||||
{ label: '短信上行记录', to: '/admin/sms-uplink-records', icon: MessageSquare },
|
||||
{ label: '充值记录', to: '/admin/recharge-records', icon: ReceiptText },
|
||||
{ label: '账单流水', to: '/admin/billing', icon: ReceiptText },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '安全控制',
|
||||
icon: Shield,
|
||||
items: [
|
||||
{ label: '企业黑名单', to: '/admin/enterprise-blacklist', icon: UserX },
|
||||
{ label: '全局黑名单', to: '/admin/global-blacklist', icon: ShieldOff },
|
||||
{ label: '敏感词管理', to: '/admin/sensitive-words', icon: Shield },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '系统管理',
|
||||
icon: Settings,
|
||||
items: [
|
||||
{ label: '用户管理', to: '/admin/users', icon: Users },
|
||||
{ label: '手机号段库', to: '/admin/phone-segments', icon: Phone },
|
||||
{ label: '引流信息字段库', to: '/admin/drainage-fields', icon: Hash },
|
||||
{ label: '系统配置', to: '/admin/settings', icon: Settings },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Search,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
|
||||
export type ShellNavItem = {
|
||||
label: string;
|
||||
to: string;
|
||||
icon: ComponentType<{ size?: number; strokeWidth?: number }>;
|
||||
};
|
||||
|
||||
export type ShellNavSection = {
|
||||
title: string;
|
||||
icon?: ComponentType<{ size?: number; strokeWidth?: number }>;
|
||||
items: ShellNavItem[];
|
||||
};
|
||||
|
||||
type AppShellProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
workspaceName: string;
|
||||
userName: string;
|
||||
userRole: string;
|
||||
navSections: ShellNavSection[];
|
||||
};
|
||||
|
||||
export function AppShell({
|
||||
title,
|
||||
subtitle,
|
||||
workspaceName,
|
||||
userName,
|
||||
userRole,
|
||||
navSections,
|
||||
}: AppShellProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [closedSections, setClosedSections] = useState<Record<string, boolean>>({});
|
||||
const ToggleIcon = collapsed ? PanelLeftOpen : PanelLeftClose;
|
||||
|
||||
return (
|
||||
<div className={['app-shell', collapsed ? 'app-shell--collapsed' : ''].filter(Boolean).join(' ')}>
|
||||
<aside className="sidebar">
|
||||
<div className="brand-block">
|
||||
<div className="brand-icon">
|
||||
<Sparkles size={18} />
|
||||
</div>
|
||||
<div className="brand-copy">
|
||||
<div className="brand-mark">{title}</div>
|
||||
<div className="brand-subtitle">{subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="side-nav" aria-label="主导航">
|
||||
{navSections.map((section) => (
|
||||
<section className="side-nav-section" key={section.title}>
|
||||
{section.icon ? (
|
||||
<button
|
||||
aria-expanded={!closedSections[section.title]}
|
||||
className="side-nav-group-toggle"
|
||||
onClick={() => setClosedSections((current) => ({ ...current, [section.title]: !current[section.title] }))}
|
||||
type="button"
|
||||
>
|
||||
<section.icon size={18} strokeWidth={2.1} />
|
||||
<span className="side-nav-group-label">{section.title}</span>
|
||||
{closedSections[section.title] ? (
|
||||
<ChevronRight className="side-nav-group-chevron" size={16} />
|
||||
) : (
|
||||
<ChevronDown className="side-nav-group-chevron" size={16} />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<p>{section.title}</p>
|
||||
)}
|
||||
<div className={['side-nav-list', closedSections[section.title] ? 'side-nav-list--closed' : ''].join(' ')}>
|
||||
{section.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<NavLink key={item.to} to={item.to} end title={item.label}>
|
||||
<Icon size={17} strokeWidth={2.1} />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<span>当前空间</span>
|
||||
<strong>{workspaceName}</strong>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="main-area">
|
||||
<header className="topbar">
|
||||
<div className="topbar-left">
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
type="button"
|
||||
aria-label={collapsed ? '展开导航' : '收起导航'}
|
||||
>
|
||||
<ToggleIcon size={18} />
|
||||
</button>
|
||||
<label className="topbar-search">
|
||||
<Search size={17} />
|
||||
<input placeholder="搜索模板、客户、发送记录" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="topbar-actions">
|
||||
<button className="icon-button" type="button" aria-label="帮助中心">
|
||||
<CircleHelp size={18} />
|
||||
</button>
|
||||
<button className="icon-button has-dot" type="button" aria-label="通知">
|
||||
<Bell size={18} />
|
||||
</button>
|
||||
<button className="user-menu" type="button">
|
||||
<span className="user-avatar">{userName.slice(0, 1)}</span>
|
||||
<span>
|
||||
<strong>{userName}</strong>
|
||||
<small>{userRole}</small>
|
||||
</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="page-content">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
BadgeDollarSign,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
Home,
|
||||
ImageIcon,
|
||||
MessageSquareText,
|
||||
PenLine,
|
||||
ReceiptText,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { AppShell } from '@/layouts/AppShell';
|
||||
|
||||
export function ClientLayout() {
|
||||
return (
|
||||
<AppShell
|
||||
title="CMPP 客户端"
|
||||
subtitle="短信服务控制台"
|
||||
workspaceName="华东一区客户空间"
|
||||
userName="赵先生"
|
||||
userRole="企业管理员"
|
||||
navSections={[
|
||||
{
|
||||
title: '概览',
|
||||
items: [{ label: '工作台', to: '/client', icon: Home }],
|
||||
},
|
||||
{
|
||||
title: '短信业务',
|
||||
items: [
|
||||
{ label: '短信发送', to: '/client/send', icon: MessageSquareText },
|
||||
{ label: '查看批量任务', to: '/client/batch-tasks', icon: ClipboardList },
|
||||
{ label: '短信发送详情', to: '/client/send-detail', icon: ClipboardList },
|
||||
{ label: '查看上行短信', to: '/client/uplink-messages', icon: MessageSquareText },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '短信基础配置',
|
||||
items: [
|
||||
{ label: '短信应用', to: '/client/applications', icon: FileText },
|
||||
{ label: '模板管理', to: '/client/templates', icon: FileText },
|
||||
{ label: '签名与引流信息', to: '/client/signatures', icon: PenLine },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '彩信服务',
|
||||
items: [
|
||||
{ label: '签名报备', to: '/client/mms-signatures', icon: PenLine },
|
||||
{ label: '彩信模板管理', to: '/client/mms-templates', icon: ImageIcon },
|
||||
{ label: '发送彩信', to: '/client/mms-send', icon: MessageSquareText },
|
||||
{ label: '查看批量任务', to: '/client/mms-batch-tasks', icon: ClipboardList },
|
||||
{ label: '彩信发送详情', to: '/client/mms-send-detail', icon: ClipboardList },
|
||||
{ label: '查看上行彩信', to: '/client/mms-uplink-messages', icon: ImageIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '账户',
|
||||
items: [
|
||||
{ label: '充值套餐', to: '/client/billing', icon: BadgeDollarSign },
|
||||
{ label: '账单流水', to: '/client/invoices', icon: ReceiptText },
|
||||
{ label: '账号设置', to: '/client/settings', icon: Settings },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '系统管理',
|
||||
items: [
|
||||
{ label: '企业认证', to: '/client/enterprise-auth', icon: ShieldCheck },
|
||||
{ label: '用户管理', to: '/client/users', icon: Users },
|
||||
{ label: '系统日志', to: '/client/system-logs', icon: FileText },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { HashRouter } from 'react-router-dom';
|
||||
import { AppRoutes } from '@/routes/AppRoutes';
|
||||
import '@/styles/tokens.css';
|
||||
import '@/styles/global.css';
|
||||
import '@/styles/components.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<HashRouter>
|
||||
<AppRoutes />
|
||||
</HashRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { AdminOverview, AuditItem, Channel, Customer } from '@/mock/types';
|
||||
|
||||
export const adminOverview: AdminOverview = {
|
||||
todaySubmissions: 128,
|
||||
pendingAudits: 36,
|
||||
averageWaitMinutes: 12,
|
||||
channelHealth: 99.2,
|
||||
};
|
||||
|
||||
export const auditItems: AuditItem[] = [
|
||||
{
|
||||
id: 'AUD-2401',
|
||||
customer: '上海云舟科技',
|
||||
type: '模板',
|
||||
content: '验证码 ${code},5 分钟内有效。',
|
||||
risk: 'low',
|
||||
status: 'pending',
|
||||
submittedAt: '2026-06-16 09:12',
|
||||
},
|
||||
{
|
||||
id: 'AUD-2402',
|
||||
customer: '杭州星澜商贸',
|
||||
type: '签名',
|
||||
content: '星澜会员中心',
|
||||
risk: 'medium',
|
||||
status: 'pending',
|
||||
submittedAt: '2026-06-16 09:04',
|
||||
},
|
||||
{
|
||||
id: 'AUD-2403',
|
||||
customer: '深圳北辰出行',
|
||||
type: '模板',
|
||||
content: '您的优惠券已到账,点击链接查看。',
|
||||
risk: 'high',
|
||||
status: 'pending',
|
||||
submittedAt: '2026-06-16 08:46',
|
||||
},
|
||||
];
|
||||
|
||||
export const customers: Customer[] = [
|
||||
{ id: 'CUS-1001', name: '上海云舟科技', contact: '赵先生', balance: 286420, status: 'active' },
|
||||
{ id: 'CUS-1002', name: '杭州星澜商贸', contact: '王女士', balance: 94220, status: 'active' },
|
||||
{ id: 'CUS-1003', name: '深圳北辰出行', contact: '陈先生', balance: 0, status: 'suspended' },
|
||||
];
|
||||
|
||||
export const channels: Channel[] = [
|
||||
{ id: 'CH-01', name: '华东主通道', region: '华东', successRate: 99.1, latencyMs: 128, enabled: true },
|
||||
{ id: 'CH-02', name: '华南通道', region: '华南', successRate: 98.6, latencyMs: 164, enabled: true },
|
||||
{ id: 'CH-03', name: '备用通道', region: '全国', successRate: 97.8, latencyMs: 210, enabled: true },
|
||||
];
|
||||
@@ -0,0 +1,48 @@
|
||||
import { adminOverview, auditItems, channels, customers } from '@/mock/adminData';
|
||||
import { readLocalData, writeLocalData } from '@/mock/storage';
|
||||
import type { AuditItem, AuditStatus, Channel, Customer } from '@/mock/types';
|
||||
|
||||
const keys = {
|
||||
audits: 'admin:audits',
|
||||
customers: 'admin:customers',
|
||||
channels: 'admin:channels',
|
||||
};
|
||||
|
||||
export const adminService = {
|
||||
getOverview() {
|
||||
return adminOverview;
|
||||
},
|
||||
|
||||
getAudits(status?: AuditStatus) {
|
||||
const records = readLocalData<AuditItem[]>(keys.audits, auditItems);
|
||||
return status ? records.filter((item) => item.status === status) : records;
|
||||
},
|
||||
|
||||
updateAuditStatus(id: string, status: AuditStatus) {
|
||||
const records = readLocalData<AuditItem[]>(keys.audits, auditItems);
|
||||
const nextRecords = records.map((item) => (item.id === id ? { ...item, status } : item));
|
||||
writeLocalData(keys.audits, nextRecords);
|
||||
return nextRecords;
|
||||
},
|
||||
|
||||
getCustomers() {
|
||||
return readLocalData<Customer[]>(keys.customers, customers);
|
||||
},
|
||||
|
||||
getChannels() {
|
||||
return readLocalData<Channel[]>(keys.channels, channels);
|
||||
},
|
||||
|
||||
saveChannels(nextChannels: Channel[]) {
|
||||
writeLocalData(keys.channels, nextChannels);
|
||||
},
|
||||
|
||||
toggleChannel(id: string) {
|
||||
const records = readLocalData<Channel[]>(keys.channels, channels);
|
||||
const nextRecords = records.map((item) => (
|
||||
item.id === id ? { ...item, enabled: !item.enabled } : item
|
||||
));
|
||||
writeLocalData(keys.channels, nextRecords);
|
||||
return nextRecords;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
export const hourlySendTrend = [
|
||||
{ time: '00:00', sent: 8200, success: 8020 },
|
||||
{ time: '03:00', sent: 5400, success: 5280 },
|
||||
{ time: '06:00', sent: 9600, success: 9410 },
|
||||
{ time: '09:00', sent: 28600, success: 28110 },
|
||||
{ time: '12:00', sent: 21400, success: 21030 },
|
||||
{ time: '15:00', sent: 24600, success: 24180 },
|
||||
{ time: '18:00', sent: 31800, success: 31260 },
|
||||
{ time: '21:00', sent: 17600, success: 17270 },
|
||||
];
|
||||
|
||||
export const channelShare = [
|
||||
{ name: '华东主通道', value: 46 },
|
||||
{ name: '华南通道', value: 31 },
|
||||
{ name: '备用通道', value: 23 },
|
||||
];
|
||||
|
||||
export const auditTrend = [
|
||||
{ day: '06-10', pending: 18, approved: 86, rejected: 7 },
|
||||
{ day: '06-11', pending: 24, approved: 92, rejected: 9 },
|
||||
{ day: '06-12', pending: 22, approved: 88, rejected: 6 },
|
||||
{ day: '06-13', pending: 29, approved: 104, rejected: 12 },
|
||||
{ day: '06-14', pending: 31, approved: 116, rejected: 10 },
|
||||
{ day: '06-15', pending: 27, approved: 108, rejected: 8 },
|
||||
{ day: '06-16', pending: 36, approved: 128, rejected: 11 },
|
||||
];
|
||||
|
||||
export const customerGrowth = [
|
||||
{ month: '1月', active: 42, new: 8 },
|
||||
{ month: '2月', active: 51, new: 9 },
|
||||
{ month: '3月', active: 63, new: 12 },
|
||||
{ month: '4月', active: 74, new: 11 },
|
||||
{ month: '5月', active: 86, new: 12 },
|
||||
{ month: '6月', active: 101, new: 15 },
|
||||
];
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { BatchTask, BillingPlan, ClientOverview, Invoice, RecentMessage, Signature, SmsTemplate } from '@/mock/types';
|
||||
|
||||
export const clientOverview: ClientOverview = {
|
||||
availableMessages: 286420,
|
||||
todaySent: 74166,
|
||||
successRate: 98.7,
|
||||
pendingAudits: 12,
|
||||
};
|
||||
|
||||
export const recentMessages: RecentMessage[] = [
|
||||
{
|
||||
id: 'MSG-1024',
|
||||
scene: '登录验证码',
|
||||
count: 18240,
|
||||
channel: '华东主通道',
|
||||
status: 'success',
|
||||
createdAt: '2026-06-16 09:24',
|
||||
},
|
||||
{
|
||||
id: 'MSG-1023',
|
||||
scene: '营销活动通知',
|
||||
count: 46800,
|
||||
channel: '备用通道',
|
||||
status: 'warning',
|
||||
createdAt: '2026-06-16 08:40',
|
||||
},
|
||||
{
|
||||
id: 'MSG-1022',
|
||||
scene: '订单状态提醒',
|
||||
count: 9126,
|
||||
channel: '华南通道',
|
||||
status: 'info',
|
||||
createdAt: '2026-06-15 18:12',
|
||||
},
|
||||
];
|
||||
|
||||
export const smsTemplates: SmsTemplate[] = [
|
||||
{
|
||||
id: 'TPL-301',
|
||||
name: '登录验证码',
|
||||
type: '验证码',
|
||||
content: '您的验证码为 ${code},5 分钟内有效。',
|
||||
status: 'approved',
|
||||
updatedAt: '2026-06-15 13:20',
|
||||
},
|
||||
{
|
||||
id: 'TPL-302',
|
||||
name: '订单发货提醒',
|
||||
type: '通知短信',
|
||||
content: '您的订单 ${orderNo} 已发货,请留意物流信息。',
|
||||
status: 'pending',
|
||||
updatedAt: '2026-06-16 08:18',
|
||||
},
|
||||
{
|
||||
id: 'TPL-303',
|
||||
name: '会员权益通知',
|
||||
type: '营销短信',
|
||||
content: '会员专属权益已到账,登录小程序查看详情。',
|
||||
status: 'draft',
|
||||
updatedAt: '2026-06-14 17:05',
|
||||
},
|
||||
];
|
||||
|
||||
export const signatures: Signature[] = [
|
||||
{ id: 'SIG-101', name: '云舟科技', company: '上海云舟科技有限公司', status: 'approved' },
|
||||
{ id: 'SIG-102', name: '星澜会员中心', company: '杭州星澜商贸有限公司', status: 'pending' },
|
||||
{ id: 'SIG-103', name: '北辰出行', company: '深圳北辰出行服务有限公司', status: 'rejected' },
|
||||
];
|
||||
|
||||
export const billingPlans: BillingPlan[] = [
|
||||
{ id: 'PLAN-01', name: '基础包', messages: 10000, price: 680 },
|
||||
{ id: 'PLAN-02', name: '增长包', messages: 50000, price: 2980, highlight: true },
|
||||
{ id: 'PLAN-03', name: '企业包', messages: 200000, price: 10800 },
|
||||
];
|
||||
|
||||
export const invoices: Invoice[] = [
|
||||
{ id: 'INV-20260601', title: '增长包充值', amount: 2980, messages: 50000, status: 'paid', createdAt: '2026-06-01 10:12' },
|
||||
{ id: 'INV-20260518', title: '基础包充值', amount: 680, messages: 10000, status: 'paid', createdAt: '2026-05-18 14:26' },
|
||||
{ id: 'INV-20260502', title: '企业包充值', amount: 10800, messages: 200000, status: 'pending', createdAt: '2026-05-02 09:48' },
|
||||
];
|
||||
|
||||
export const batchTasks: BatchTask[] = [
|
||||
{
|
||||
id: 'TASK20260312001',
|
||||
applicationName: '营销推广平台',
|
||||
submittedAt: '2026-03-12 10:30:15',
|
||||
templateContent: '【启瑞物业】尊敬的业主,您本月物业费500元,请及时缴纳。感谢您的配合!',
|
||||
phoneCount: 1500,
|
||||
wordCount: 45,
|
||||
sendType: 'immediate',
|
||||
sentCount: 1500,
|
||||
totalCount: 1500,
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: 'TASK20260312002',
|
||||
applicationName: '客服系统',
|
||||
submittedAt: '2026-03-12 11:15:30',
|
||||
templateContent: '【客服中心】尊敬的张三,您已成功预约上门维修服务,时间:2026-03-15 14:00,地点:XX小区1号楼。',
|
||||
phoneCount: 300,
|
||||
wordCount: 58,
|
||||
sendType: 'scheduled',
|
||||
scheduledAt: '2026-03-13 09:00:00',
|
||||
sentCount: 158,
|
||||
totalCount: 300,
|
||||
status: 'sending',
|
||||
},
|
||||
{
|
||||
id: 'TASK20260312003',
|
||||
applicationName: '验证码服务',
|
||||
submittedAt: '2026-03-12 14:20:45',
|
||||
templateContent: '【验证码】您的验证码是123456,5分钟内有效,请勿泄露给他人。',
|
||||
phoneCount: 5000,
|
||||
wordCount: 34,
|
||||
sendType: 'immediate',
|
||||
sentCount: 3200,
|
||||
totalCount: 5000,
|
||||
status: 'sending',
|
||||
},
|
||||
{
|
||||
id: 'TASK20260311001',
|
||||
applicationName: '营销推广平台',
|
||||
submittedAt: '2026-03-11 16:45:00',
|
||||
templateContent: '【启瑞物业】您好!春季业主大会将于2026-03-20在小区会议室举行,欢迎参加。咨询电话:400-8888-8888',
|
||||
phoneCount: 2000,
|
||||
wordCount: 62,
|
||||
sendType: 'scheduled',
|
||||
scheduledAt: '2026-03-12 08:00:00',
|
||||
sentCount: 2000,
|
||||
totalCount: 2000,
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: 'TASK20260311002',
|
||||
applicationName: '营销推广平台',
|
||||
submittedAt: '2026-03-11 09:30:20',
|
||||
templateContent: '【启瑞物业】尊敬的客户,值此佳节之际,祝您节日快乐,阖家幸福!',
|
||||
phoneCount: 800,
|
||||
wordCount: 38,
|
||||
sendType: 'immediate',
|
||||
sentCount: 500,
|
||||
totalCount: 800,
|
||||
status: 'terminated',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,70 @@
|
||||
import { batchTasks, billingPlans, clientOverview, invoices, recentMessages, signatures, smsTemplates } from '@/mock/clientData';
|
||||
import { readLocalData, writeLocalData } from '@/mock/storage';
|
||||
import type { BatchTask, RecentMessage, Signature, SmsTemplate } from '@/mock/types';
|
||||
|
||||
const keys = {
|
||||
recentMessages: 'client:recent-messages',
|
||||
templates: 'client:templates',
|
||||
signatures: 'client:signatures',
|
||||
batchTasks: 'client:batch-tasks',
|
||||
};
|
||||
|
||||
export const clientService = {
|
||||
getOverview() {
|
||||
return clientOverview;
|
||||
},
|
||||
|
||||
getRecentMessages() {
|
||||
return readLocalData<RecentMessage[]>(keys.recentMessages, recentMessages);
|
||||
},
|
||||
|
||||
saveRecentMessages(nextMessages: RecentMessage[]) {
|
||||
writeLocalData(keys.recentMessages, nextMessages);
|
||||
},
|
||||
|
||||
addRecentMessage(message: RecentMessage) {
|
||||
const records = readLocalData<RecentMessage[]>(keys.recentMessages, recentMessages);
|
||||
const nextRecords = [message, ...records];
|
||||
writeLocalData(keys.recentMessages, nextRecords);
|
||||
return nextRecords;
|
||||
},
|
||||
|
||||
getTemplates() {
|
||||
return readLocalData<SmsTemplate[]>(keys.templates, smsTemplates);
|
||||
},
|
||||
|
||||
saveTemplates(nextTemplates: SmsTemplate[]) {
|
||||
writeLocalData(keys.templates, nextTemplates);
|
||||
},
|
||||
|
||||
getSignatures() {
|
||||
return readLocalData<Signature[]>(keys.signatures, signatures);
|
||||
},
|
||||
|
||||
saveSignatures(nextSignatures: Signature[]) {
|
||||
writeLocalData(keys.signatures, nextSignatures);
|
||||
},
|
||||
|
||||
getBillingPlans() {
|
||||
return billingPlans;
|
||||
},
|
||||
|
||||
getInvoices() {
|
||||
return invoices;
|
||||
},
|
||||
|
||||
getBatchTasks() {
|
||||
return readLocalData<BatchTask[]>(keys.batchTasks, batchTasks);
|
||||
},
|
||||
|
||||
terminateBatchTask(id: string) {
|
||||
const records = readLocalData<BatchTask[]>(keys.batchTasks, batchTasks);
|
||||
const nextRecords = records.map((item) => (
|
||||
item.id === id && item.status === 'sending'
|
||||
? { ...item, status: 'terminated' as const }
|
||||
: item
|
||||
));
|
||||
writeLocalData(keys.batchTasks, nextRecords);
|
||||
return nextRecords;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
export { adminService } from './adminService';
|
||||
export { clientService } from './clientService';
|
||||
export type {
|
||||
AdminOverview,
|
||||
AuditItem,
|
||||
AuditStatus,
|
||||
AuditType,
|
||||
BatchSendType,
|
||||
BatchTask,
|
||||
BatchTaskStatus,
|
||||
BillingPlan,
|
||||
Channel,
|
||||
ClientOverview,
|
||||
Customer,
|
||||
Invoice,
|
||||
MessageStatus,
|
||||
RecentMessage,
|
||||
RiskLevel,
|
||||
Signature,
|
||||
SmsTemplate,
|
||||
TemplateStatus,
|
||||
} from './types';
|
||||
@@ -0,0 +1,35 @@
|
||||
const STORAGE_PREFIX = 'cmpp-platform-v2';
|
||||
|
||||
export function readLocalData<T>(key: string, fallback: T): T {
|
||||
if (typeof window === 'undefined') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const rawValue = window.localStorage.getItem(`${STORAGE_PREFIX}:${key}`);
|
||||
|
||||
if (!rawValue) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(rawValue) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLocalData<T>(key: string, value: T) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(`${STORAGE_PREFIX}:${key}`, JSON.stringify(value));
|
||||
}
|
||||
|
||||
export function resetLocalData(key: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(`${STORAGE_PREFIX}:${key}`);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
export type MessageStatus = 'success' | 'warning' | 'info' | 'danger';
|
||||
|
||||
export type RecentMessage = {
|
||||
id: string;
|
||||
scene: string;
|
||||
count: number;
|
||||
channel: string;
|
||||
status: MessageStatus;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type ClientOverview = {
|
||||
availableMessages: number;
|
||||
todaySent: number;
|
||||
successRate: number;
|
||||
pendingAudits: number;
|
||||
};
|
||||
|
||||
export type TemplateStatus = 'draft' | 'pending' | 'approved' | 'rejected';
|
||||
|
||||
export type SmsTemplate = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: '验证码' | '通知短信' | '营销短信';
|
||||
content: string;
|
||||
status: TemplateStatus;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type Signature = {
|
||||
id: string;
|
||||
name: string;
|
||||
company: string;
|
||||
status: TemplateStatus;
|
||||
};
|
||||
|
||||
export type RiskLevel = 'low' | 'medium' | 'high';
|
||||
export type AuditType = '模板' | '签名';
|
||||
export type AuditStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
export type AuditItem = {
|
||||
id: string;
|
||||
customer: string;
|
||||
type: AuditType;
|
||||
content: string;
|
||||
risk: RiskLevel;
|
||||
status: AuditStatus;
|
||||
submittedAt: string;
|
||||
};
|
||||
|
||||
export type AdminOverview = {
|
||||
todaySubmissions: number;
|
||||
pendingAudits: number;
|
||||
averageWaitMinutes: number;
|
||||
channelHealth: number;
|
||||
};
|
||||
|
||||
export type Customer = {
|
||||
id: string;
|
||||
name: string;
|
||||
contact: string;
|
||||
balance: number;
|
||||
status: 'active' | 'suspended';
|
||||
};
|
||||
|
||||
export type Channel = {
|
||||
id: string;
|
||||
name: string;
|
||||
region: string;
|
||||
successRate: number;
|
||||
latencyMs: number;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type BillingPlan = {
|
||||
id: string;
|
||||
name: string;
|
||||
messages: number;
|
||||
price: number;
|
||||
highlight?: boolean;
|
||||
};
|
||||
|
||||
export type Invoice = {
|
||||
id: string;
|
||||
title: string;
|
||||
amount: number;
|
||||
messages: number;
|
||||
status: 'paid' | 'pending' | 'failed';
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type BatchTaskStatus = 'completed' | 'sending' | 'terminated';
|
||||
export type BatchSendType = 'immediate' | 'scheduled';
|
||||
|
||||
export type BatchTask = {
|
||||
id: string;
|
||||
applicationName: string;
|
||||
submittedAt: string;
|
||||
templateContent: string;
|
||||
phoneCount: number;
|
||||
wordCount: number;
|
||||
sendType: BatchSendType;
|
||||
scheduledAt?: string;
|
||||
sentCount: number;
|
||||
totalCount: number;
|
||||
status: BatchTaskStatus;
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { AdminAnalyticsPage } from '@/apps/admin/AdminAnalyticsPage';
|
||||
import { AdminBillingPage } from '@/apps/admin/AdminBillingPage';
|
||||
import { AdminChannelGroupFormPage } from '@/apps/admin/AdminChannelGroupFormPage';
|
||||
import { AdminChannelGroupsPage } from '@/apps/admin/AdminChannelGroupsPage';
|
||||
import { AdminChannelsPage } from '@/apps/admin/AdminChannelsPage';
|
||||
import { AdminChannelReportPage } from '@/apps/admin/AdminChannelReportPage';
|
||||
import { AdminCustomerDetailPage } from '@/apps/admin/AdminCustomerDetailPage';
|
||||
import { AdminCustomerFormPage } from '@/apps/admin/AdminCustomerFormPage';
|
||||
import { AdminCustomersPage } from '@/apps/admin/AdminCustomersPage';
|
||||
import { AdminDrainageFieldsPage } from '@/apps/admin/AdminDrainageFieldsPage';
|
||||
import { AdminEnterpriseBlacklistPage } from '@/apps/admin/AdminEnterpriseBlacklistPage';
|
||||
import { AdminGlobalBlacklistPage } from '@/apps/admin/AdminGlobalBlacklistPage';
|
||||
import { AdminHome } from '@/apps/admin/AdminHome';
|
||||
import { AdminMmsApplicationFormPage } from '@/apps/admin/AdminMmsApplicationFormPage';
|
||||
import { AdminMmsChannelsPage } from '@/apps/admin/AdminMmsChannelsPage';
|
||||
import { AdminMmsRecordsPage } from '@/apps/admin/AdminMmsRecordsPage';
|
||||
import { AdminMmsTaskProgressPage } from '@/apps/admin/AdminMmsTaskProgressPage';
|
||||
import { AdminMonitorPage } from '@/apps/admin/AdminMonitorPage';
|
||||
import { AdminPhoneSegmentsPage } from '@/apps/admin/AdminPhoneSegmentsPage';
|
||||
import { AdminRechargeRecordsPage } from '@/apps/admin/AdminRechargeRecordsPage';
|
||||
import { AdminSensitiveWordsPage } from '@/apps/admin/AdminSensitiveWordsPage';
|
||||
import { AdminSettingsPage } from '@/apps/admin/AdminSettingsPage';
|
||||
import { AdminSmsAuditPage } from '@/apps/admin/AdminSmsAuditPage';
|
||||
import { AdminSignatureAuditPage } from '@/apps/admin/AdminSignatureAuditPage';
|
||||
import { AdminSmsApplicationFormPage } from '@/apps/admin/AdminSmsApplicationFormPage';
|
||||
import { AdminSmsRecordsPage } from '@/apps/admin/AdminSmsRecordsPage';
|
||||
import { AdminSmsTaskProgressPage } from '@/apps/admin/AdminSmsTaskProgressPage';
|
||||
import { AdminSmsUplinkRecordsPage } from '@/apps/admin/AdminSmsUplinkRecordsPage';
|
||||
import { AdminTemplateAuditPage } from '@/apps/admin/AdminTemplateAuditPage';
|
||||
import { AdminUsersPage } from '@/apps/admin/AdminUsersPage';
|
||||
import { AdminEnterpriseAuditPage } from '@/apps/admin/AdminEnterpriseAuditPage';
|
||||
import { ClientApplicationsPage } from '@/apps/client/ClientApplicationsPage';
|
||||
import { ClientBatchTasksPage } from '@/apps/client/ClientBatchTasksPage';
|
||||
import { ClientBillingPage } from '@/apps/client/ClientBillingPage';
|
||||
import { ClientEnterpriseAuthPage } from '@/apps/client/ClientEnterpriseAuthPage';
|
||||
import { ClientHome } from '@/apps/client/ClientHome';
|
||||
import { ClientInvoicesPage } from '@/apps/client/ClientInvoicesPage';
|
||||
import { ClientMmsBatchTasksPage } from '@/apps/client/ClientMmsBatchTasksPage';
|
||||
import { ClientMmsSignatureReportPage } from '@/apps/client/ClientMmsSignatureReportPage';
|
||||
import { ClientMmsSendDetailPage } from '@/apps/client/ClientMmsSendDetailPage';
|
||||
import { ClientMmsSendPage } from '@/apps/client/ClientMmsSendPage';
|
||||
import { ClientMmsTemplatesPage } from '@/apps/client/ClientMmsTemplatesPage';
|
||||
import { ClientMmsUplinkMessagesPage } from '@/apps/client/ClientMmsUplinkMessagesPage';
|
||||
import { ClientSendDetailPage } from '@/apps/client/ClientSendDetailPage';
|
||||
import { ClientSendPage } from '@/apps/client/ClientSendPage';
|
||||
import { ClientSettingsPage } from '@/apps/client/ClientSettingsPage';
|
||||
import { ClientSignaturesPage } from '@/apps/client/ClientSignaturesPage';
|
||||
import { ClientSystemLogsPage } from '@/apps/client/ClientSystemLogsPage';
|
||||
import { ClientTemplatesPage } from '@/apps/client/ClientTemplatesPage';
|
||||
import { ClientUplinkMessagesPage } from '@/apps/client/ClientUplinkMessagesPage';
|
||||
import { ClientUsersPage } from '@/apps/client/ClientUsersPage';
|
||||
import { PagePlaceholder } from '@/components/PagePlaceholder';
|
||||
import { AdminLayout } from '@/layouts/AdminLayout';
|
||||
import { ClientLayout } from '@/layouts/ClientLayout';
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/client" replace />} />
|
||||
<Route path="/client" element={<ClientLayout />}>
|
||||
<Route index element={<ClientHome />} />
|
||||
<Route path="send" element={<ClientSendPage />} />
|
||||
<Route path="batch-tasks" element={<ClientBatchTasksPage />} />
|
||||
<Route path="send-detail" element={<ClientSendDetailPage />} />
|
||||
<Route path="uplink-messages" element={<ClientUplinkMessagesPage />} />
|
||||
<Route path="applications" element={<ClientApplicationsPage />} />
|
||||
<Route path="templates" element={<ClientTemplatesPage />} />
|
||||
<Route path="signatures" element={<ClientSignaturesPage />} />
|
||||
<Route path="mms-signatures" element={<ClientMmsSignatureReportPage />} />
|
||||
<Route path="mms-templates" element={<ClientMmsTemplatesPage />} />
|
||||
<Route path="mms-send" element={<ClientMmsSendPage />} />
|
||||
<Route path="mms-batch-tasks" element={<ClientMmsBatchTasksPage />} />
|
||||
<Route path="mms-send-detail" element={<ClientMmsSendDetailPage />} />
|
||||
<Route path="mms-uplink-messages" element={<ClientMmsUplinkMessagesPage />} />
|
||||
<Route path="billing" element={<ClientBillingPage />} />
|
||||
<Route path="invoices" element={<ClientInvoicesPage />} />
|
||||
<Route path="settings" element={<ClientSettingsPage />} />
|
||||
<Route path="enterprise-auth" element={<ClientEnterpriseAuthPage />} />
|
||||
<Route path="users" element={<ClientUsersPage />} />
|
||||
<Route path="system-logs" element={<ClientSystemLogsPage />} />
|
||||
<Route path="*" element={<PagePlaceholder />} />
|
||||
</Route>
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
<Route index element={<AdminHome />} />
|
||||
<Route path="monitor" element={<AdminMonitorPage />} />
|
||||
<Route path="analytics" element={<AdminAnalyticsPage />} />
|
||||
<Route path="customers" element={<AdminCustomersPage />} />
|
||||
<Route path="customers/new" element={<AdminCustomerFormPage />} />
|
||||
<Route path="customers/:enterpriseId" element={<AdminCustomerDetailPage />} />
|
||||
<Route path="customers/:enterpriseId/edit" element={<AdminCustomerFormPage />} />
|
||||
<Route path="customers/:enterpriseId/sms-apps/new" element={<AdminSmsApplicationFormPage />} />
|
||||
<Route path="customers/:enterpriseId/sms-apps/:appId/edit" element={<AdminSmsApplicationFormPage />} />
|
||||
<Route path="customers/:enterpriseId/mms-apps/new" element={<AdminMmsApplicationFormPage />} />
|
||||
<Route path="customers/:enterpriseId/mms-apps/:appId/edit" element={<AdminMmsApplicationFormPage />} />
|
||||
<Route path="templates" element={<AdminTemplateAuditPage />} />
|
||||
<Route path="signatures" element={<AdminSignatureAuditPage />} />
|
||||
<Route path="enterprise-audit" element={<AdminEnterpriseAuditPage />} />
|
||||
<Route path="sms-audit" element={<AdminSmsAuditPage />} />
|
||||
<Route path="sms-task-progress" element={<AdminSmsTaskProgressPage />} />
|
||||
<Route path="mms-task-progress" element={<AdminMmsTaskProgressPage />} />
|
||||
<Route path="sms-records" element={<AdminSmsRecordsPage />} />
|
||||
<Route path="mms-records" element={<AdminMmsRecordsPage />} />
|
||||
<Route path="sms-uplink-records" element={<AdminSmsUplinkRecordsPage />} />
|
||||
<Route path="recharge-records" element={<AdminRechargeRecordsPage />} />
|
||||
<Route path="channels" element={<AdminChannelsPage />} />
|
||||
<Route path="channels/:channelId/reports" element={<AdminChannelReportPage />} />
|
||||
<Route path="channel-groups" element={<AdminChannelGroupsPage />} />
|
||||
<Route path="channel-groups/new" element={<AdminChannelGroupFormPage />} />
|
||||
<Route path="channel-groups/:groupId/edit" element={<AdminChannelGroupFormPage />} />
|
||||
<Route path="mms-channels" element={<AdminMmsChannelsPage />} />
|
||||
<Route path="enterprise-blacklist" element={<AdminEnterpriseBlacklistPage />} />
|
||||
<Route path="global-blacklist" element={<AdminGlobalBlacklistPage />} />
|
||||
<Route path="sensitive-words" element={<AdminSensitiveWordsPage />} />
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="phone-segments" element={<AdminPhoneSegmentsPage />} />
|
||||
<Route path="drainage-fields" element={<AdminDrainageFieldsPage />} />
|
||||
<Route path="billing" element={<AdminBillingPage />} />
|
||||
<Route path="settings" element={<AdminSettingsPage />} />
|
||||
<Route path="*" element={<PagePlaceholder />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
/* Brand */
|
||||
--color-brand: #12121a;
|
||||
--color-brand-hover: #1c1c27;
|
||||
--color-brand-active: #08080d;
|
||||
--color-accent: #d9c3a0;
|
||||
--color-accent-soft: #f6efe4;
|
||||
--color-accent-strong: #b99763;
|
||||
--color-selected: #2563eb;
|
||||
--color-selected-hover: #1d4ed8;
|
||||
--color-selected-active: #1e40af;
|
||||
--color-selected-soft: #eff6ff;
|
||||
|
||||
/* Neutrals */
|
||||
--color-bg: #f6f7f9;
|
||||
--color-bg-subtle: #fbfbfc;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-elevated: #ffffff;
|
||||
--color-surface-muted: #f3f4f6;
|
||||
--color-border: #e5e7eb;
|
||||
--color-border-strong: #d1d5db;
|
||||
--color-text: #1f2937;
|
||||
--color-text-strong: #111827;
|
||||
--color-text-muted: #6b7280;
|
||||
--color-text-subtle: #9ca3af;
|
||||
--color-text-inverse: #ffffff;
|
||||
|
||||
/* Status */
|
||||
--color-success: #16a34a;
|
||||
--color-success-soft: #ecfdf3;
|
||||
--color-warning: #d97706;
|
||||
--color-warning-soft: #fffbeb;
|
||||
--color-danger: #dc2626;
|
||||
--color-danger-soft: #fef2f2;
|
||||
--color-info: #2563eb;
|
||||
--color-info-soft: #eff6ff;
|
||||
|
||||
/* Typography */
|
||||
--font-family: Inter, "PingFang SC", "Microsoft YaHei", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-size-xs: 12px;
|
||||
--font-size-sm: 13px;
|
||||
--font-size-md: 14px;
|
||||
--font-size-lg: 16px;
|
||||
--font-size-xl: 20px;
|
||||
--font-size-2xl: 24px;
|
||||
--line-height-tight: 1.25;
|
||||
--line-height-base: 1.5;
|
||||
--line-height-loose: 1.75;
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
/* Spacing */
|
||||
--space-0: 0;
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-7: 28px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
|
||||
/* Shape */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-full: 999px;
|
||||
|
||||
/* Effects */
|
||||
--shadow-xs: 0 1px 1px rgba(18, 18, 26, 0.04);
|
||||
--shadow-sm: 0 1px 2px rgba(18, 18, 26, 0.08);
|
||||
--shadow-md: 0 8px 24px rgba(18, 18, 26, 0.08);
|
||||
--shadow-lg: 0 18px 48px rgba(18, 18, 26, 0.12);
|
||||
--focus-ring: 0 0 0 3px rgba(37, 99, 235, 0.18);
|
||||
--transition-fast: 120ms ease;
|
||||
--transition-base: 180ms ease;
|
||||
|
||||
/* Layout */
|
||||
--sidebar-width: 248px;
|
||||
--topbar-height: 64px;
|
||||
--content-max-width: 1440px;
|
||||
|
||||
/* Component primitives */
|
||||
--control-height-sm: 32px;
|
||||
--control-height-md: 38px;
|
||||
--control-height-lg: 44px;
|
||||
--control-padding-x: 12px;
|
||||
--z-dropdown: 1000;
|
||||
--z-modal: 1100;
|
||||
--z-toast: 1200;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import { chartPalette, themeColors } from '@/theme/tokens';
|
||||
|
||||
const axisStyle = {
|
||||
axisLine: { lineStyle: { color: themeColors.border } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: themeColors.textMuted },
|
||||
splitLine: { lineStyle: { color: '#EEF0F3' } },
|
||||
};
|
||||
|
||||
export function createLineOption(params: {
|
||||
labels: string[];
|
||||
series: Array<{ name: string; data: number[] }>;
|
||||
}): EChartsOption {
|
||||
return {
|
||||
color: [...chartPalette],
|
||||
grid: { left: 12, right: 12, top: 36, bottom: 8, containLabel: true },
|
||||
legend: { top: 0, right: 0, textStyle: { color: themeColors.textMuted } },
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: params.labels, ...axisStyle },
|
||||
yAxis: { type: 'value', ...axisStyle },
|
||||
series: params.series.map((item) => ({
|
||||
name: item.name,
|
||||
data: item.data,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbolSize: 7,
|
||||
lineStyle: { width: 3 },
|
||||
areaStyle: { opacity: 0.08 },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function createBarOption(params: {
|
||||
labels: string[];
|
||||
series: Array<{ name: string; data: number[] }>;
|
||||
}): EChartsOption {
|
||||
return {
|
||||
color: [...chartPalette],
|
||||
grid: { left: 12, right: 12, top: 36, bottom: 8, containLabel: true },
|
||||
legend: { top: 0, right: 0, textStyle: { color: themeColors.textMuted } },
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: params.labels, ...axisStyle },
|
||||
yAxis: { type: 'value', ...axisStyle },
|
||||
series: params.series.map((item) => ({
|
||||
name: item.name,
|
||||
data: item.data,
|
||||
type: 'bar',
|
||||
barMaxWidth: 28,
|
||||
borderRadius: [6, 6, 0, 0],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPieOption(params: {
|
||||
data: Array<{ name: string; value: number }>;
|
||||
}): EChartsOption {
|
||||
return {
|
||||
color: [...chartPalette],
|
||||
legend: {
|
||||
bottom: 0,
|
||||
left: 'center',
|
||||
textStyle: { color: themeColors.textMuted },
|
||||
},
|
||||
tooltip: { trigger: 'item' },
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['48%', '72%'],
|
||||
center: ['50%', '44%'],
|
||||
avoidLabelOverlap: true,
|
||||
label: { color: themeColors.text },
|
||||
data: params.data,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export const themeColors = {
|
||||
brand: '#12121A',
|
||||
brandHover: '#1C1C27',
|
||||
accent: '#D9C3A0',
|
||||
accentSoft: '#F6EFE4',
|
||||
selected: '#2563EB',
|
||||
selectedSoft: '#EFF6FF',
|
||||
surface: '#FFFFFF',
|
||||
background: '#F6F7F9',
|
||||
border: '#E5E7EB',
|
||||
text: '#1F2937',
|
||||
textStrong: '#111827',
|
||||
textMuted: '#6B7280',
|
||||
success: '#16A34A',
|
||||
warning: '#D97706',
|
||||
danger: '#DC2626',
|
||||
info: '#2563EB',
|
||||
} as const;
|
||||
|
||||
export const chartPalette = [
|
||||
themeColors.selected,
|
||||
themeColors.accent,
|
||||
themeColors.success,
|
||||
themeColors.warning,
|
||||
'#64748B',
|
||||
'#0F766E',
|
||||
] as const;
|
||||
|
||||
export const statusTone = {
|
||||
success: {
|
||||
color: themeColors.success,
|
||||
background: '#ECFDF3',
|
||||
},
|
||||
warning: {
|
||||
color: themeColors.warning,
|
||||
background: '#FFFBEB',
|
||||
},
|
||||
danger: {
|
||||
color: themeColors.danger,
|
||||
background: '#FEF2F2',
|
||||
},
|
||||
info: {
|
||||
color: themeColors.info,
|
||||
background: themeColors.selectedSoft,
|
||||
},
|
||||
neutral: {
|
||||
color: themeColors.textMuted,
|
||||
background: '#F3F4F6',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type ThemeColorName = keyof typeof themeColors;
|
||||
export type StatusToneName = keyof typeof statusTone;
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@/apps/*": ["src/apps/*"],
|
||||
"@/components/*": ["src/components/*"],
|
||||
"@/layouts/*": ["src/layouts/*"],
|
||||
"@/mock/*": ["src/mock/*"],
|
||||
"@/routes/*": ["src/routes/*"],
|
||||
"@/store/*": ["src/store/*"],
|
||||
"@/styles/*": ["src/styles/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": []
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user