Initial CMPP frontend prototype

This commit is contained in:
hectorzhao
2026-06-30 16:09:46 +08:00
commit 2f3c274a30
98 changed files with 25255 additions and 0 deletions
+123
View File
@@ -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>
);
}
+38
View File
@@ -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>
);
}
+157
View File
@@ -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>
);
}
+565
View File
@@ -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>
);
}
+477
View File
@@ -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
+212
View File
@@ -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> JPGPNG 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>
);
}
+191
View File
@@ -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>
);
}
+200
View File
@@ -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>
);
}
+115
View File
@@ -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>
);
}
+383
View File
@@ -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>
);
}
+197
View File
@@ -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>
);
}
+331
View File
@@ -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>
);
}
+481
View File
@@ -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>
);
}
+53
View File
@@ -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>
);
}
+152
View File
@@ -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>
);
}
+119
View File
@@ -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>
);
}
+37
View File
@@ -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>
);
}
+177
View File
@@ -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>
);
}
+400
View File
@@ -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>
);
}
+341
View File
@@ -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>
);
}
+581
View File
@@ -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>
);
}
+81
View File
@@ -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>
);
}
+180
View File
@@ -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>
);
}
+193
View File
@@ -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,
});
}
+74
View File
@@ -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>
),
},
];
}