fix: harden real backend admin workflows and ui

This commit is contained in:
hectorzhao
2026-07-03 19:29:56 +08:00
parent dd09d91c1e
commit 8cca361441
71 changed files with 5111 additions and 4439 deletions
+167 -48
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react';
import { Info, Plus, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Info, Plus } from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelGroup } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag } from '@/components/ui';
import type { TableColumn } from '@/components/ui';
@@ -9,13 +10,13 @@ type ChannelStatus = 'normal' | 'stopped';
type ProvinceRoute = {
id: string;
province: string;
channel: string;
channelId: string;
status: ChannelStatus;
};
type NationalRoute = {
id: string;
priority: number;
channel: string;
channelId: string;
status: ChannelStatus;
};
type RouteModalState = {
@@ -33,14 +34,6 @@ const provinceOptions = [
{ 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' },
@@ -66,26 +59,31 @@ const statusTones: Record<ChannelStatus, 'success' | 'neutral'> = {
stopped: 'neutral',
};
const defaultProvinceRoutes: ProvinceRoute[] = [
{ id: 'p-shandong', province: '山东', channel: '行北-移动-山东有限公司-上海XXXXXXX-22j', status: 'normal' },
{ id: 'p-henan', province: '河南', channel: '行北-移动-河南有限公司-上海XXXX-22j', status: 'stopped' },
];
function normalizeRegion(region?: string | null) {
return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim();
}
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 isCarrierCompatible(channelCarrier: string | null | undefined, carrier: Carrier) {
return !channelCarrier || channelCarrier === 'all' || channelCarrier === carrier;
}
function getChannelStatus(channel?: AdminChannel): ChannelStatus {
return channel?.status === 'active' ? 'normal' : 'stopped';
}
function StatusTag({ status }: { status: ChannelStatus }) {
return <Tag tone={statusTones[status]}>{statusLabels[status]}</Tag>;
}
function RouteConfigModal({
channels,
carrier,
modal,
onClose,
onSubmit,
}: {
channels: AdminChannel[];
carrier: Carrier;
modal: RouteModalState;
onClose: () => void;
onSubmit: (route: ProvinceRoute | NationalRoute) => void;
@@ -94,24 +92,43 @@ function RouteConfigModal({
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 ?? '');
const [channelId, setChannelId] = useState(modal.route?.channelId ?? '');
const selectableChannels = channels.filter((channel) => {
if (!isCarrierCompatible(channel.carrier, carrier)) return false;
if (modal.type === 'province' && province) {
return normalizeRegion(channel.sendRegion) === normalizeRegion(province);
}
return true;
});
const channelOptions = [
{ label: '请选择', value: '' },
...selectableChannels.map((channel) => ({
label: `${channel.name} / ${channel.carrier ?? '未标记'} / ${channel.sendRegion ?? '全国'}`,
value: channel.id,
})),
];
function submit() {
if (!channelId) return;
const channel = channels.find((item) => item.id === channelId);
if (modal.type === 'province') {
if (!province) return;
onSubmit({
id: provinceRoute?.id ?? `p-${Date.now()}`,
province: province || '山东',
channel: channel || channelOptions[1].value,
status: provinceRoute?.status ?? 'normal',
province,
channelId,
status: getChannelStatus(channel),
});
return;
}
if (!priority) return;
onSubmit({
id: nationalRoute?.id ?? `n-${Date.now()}`,
priority: Number(priority || 1),
channel: channel || channelOptions[1].value,
status: nationalRoute?.status ?? 'normal',
priority: Number(priority),
channelId,
status: getChannelStatus(channel),
});
}
@@ -129,7 +146,7 @@ function RouteConfigModal({
>
<div className="channel-route-modal">
{modal.type === 'province' ? (
<Select label="* 选择省份" onChange={(event) => setProvince(event.target.value)} options={provinceOptions} value={province} />
<Select label="* 选择省份" onChange={(event) => { setProvince(event.target.value); setChannelId(''); }} options={provinceOptions} value={province} />
) : (
<>
<Select label="* 优先级" onChange={(event) => setPriority(event.target.value)} options={priorityOptions} value={priority} />
@@ -139,7 +156,7 @@ function RouteConfigModal({
</div>
</>
)}
<Select label="* 选择通道" onChange={(event) => setChannel(event.target.value)} options={channelOptions} value={channel} />
<Select label="* 选择通道" onChange={(event) => setChannelId(event.target.value)} options={channelOptions} value={channelId} />
</div>
</Modal>
);
@@ -149,16 +166,65 @@ export function AdminChannelGroupFormPage() {
const navigate = useNavigate();
const { groupId } = useParams();
const editing = Boolean(groupId && groupId !== 'new');
const [groupName, setGroupName] = useState(editing ? '学医移动专用组' : '');
const [channels, setChannels] = useState<AdminChannel[]>([]);
const [groupName, setGroupName] = useState('');
const [carrier, setCarrier] = useState<Carrier>('mobile');
const [retryEnabled, setRetryEnabled] = useState(false);
const [provinceRoutes, setProvinceRoutes] = useState(defaultProvinceRoutes);
const [nationalRoutes, setNationalRoutes] = useState(defaultNationalRoutes);
const [retryEnabled, setRetryEnabled] = useState(true);
const [provinceRoutes, setProvinceRoutes] = useState<ProvinceRoute[]>([]);
const [nationalRoutes, setNationalRoutes] = useState<NationalRoute[]>([]);
const [modal, setModal] = useState<RouteModalState | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const channelById = useMemo(() => new Map(channels.map((channel) => [channel.id, channel])), [channels]);
function applyGroup(group: ChannelGroup) {
setGroupName(group.name);
setCarrier(group.carrier);
setRetryEnabled(group.retryEnabled ?? true);
setProvinceRoutes((group.items ?? [])
.filter((item) => item.province)
.map((item) => ({
id: item.id,
province: item.province ?? '',
channelId: item.channelId,
status: getChannelStatus(item.channel),
})));
setNationalRoutes((group.items ?? [])
.filter((item) => !item.province)
.map((item) => ({
id: item.id,
priority: item.priority,
channelId: item.channelId,
status: getChannelStatus(item.channel),
}))
.sort((a, b) => a.priority - b.priority));
}
function loadData() {
setLoading(true);
Promise.all([adminApi.listChannels(), adminApi.listChannelGroups()])
.then(([channelItems, groups]) => {
setChannels(channelItems.filter((channel) => channel.status !== 'deleted'));
if (editing && groupId) {
const group = groups.find((item) => item.id === groupId);
if (!group) throw new Error('通道组不存在或已被删除');
applyGroup(group);
}
setError('');
})
.catch((reason: Error) => setError(reason.message || '通道组表单加载失败'))
.finally(() => setLoading(false));
}
useEffect(() => {
loadData();
}, [groupId]);
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: 'channel', title: '通道', render: (record) => channelById.get(record.channelId)?.name ?? record.channelId },
{ key: 'status', title: '通道状态', width: '160px', render: (record) => <StatusTag status={record.status} /> },
{
key: 'actions',
@@ -171,11 +237,11 @@ export function AdminChannelGroupFormPage() {
</div>
),
},
], []);
], [channelById]);
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: 'channel', title: '通道', render: (record) => channelById.get(record.channelId)?.name ?? record.channelId },
{ key: 'status', title: '通道状态', width: '160px', render: (record) => <StatusTag status={record.status} /> },
{
key: 'actions',
@@ -188,26 +254,76 @@ export function AdminChannelGroupFormPage() {
</div>
),
},
], []);
], [channelById]);
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];
const withoutSameProvince = current.filter((item) => item.id === nextRoute.id || item.province !== nextRoute.province);
const exists = withoutSameProvince.some((item) => item.id === nextRoute.id);
return exists ? withoutSameProvince.map((item) => (item.id === nextRoute.id ? nextRoute : item)) : [...withoutSameProvince, 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];
const withoutSamePriority = current.filter((item) => item.id === nextRoute.id || item.priority !== nextRoute.priority);
const exists = withoutSamePriority.some((item) => item.id === nextRoute.id);
const next = exists ? withoutSamePriority.map((item) => (item.id === nextRoute.id ? nextRoute : item)) : [...withoutSamePriority, nextRoute];
return [...next].sort((a, b) => a.priority - b.priority);
});
}
setModal(null);
}
function buildItems() {
return [
...provinceRoutes.map((route) => ({
channelId: route.channelId,
carrier,
province: route.province,
priority: 100,
})),
...nationalRoutes.map((route) => ({
channelId: route.channelId,
carrier,
priority: route.priority,
})),
];
}
function saveGroup() {
if (!groupName.trim()) {
setError('请输入通道组名称');
return;
}
const payload = {
name: groupName.trim(),
carrier,
status: 'active',
retryEnabled,
retryTimeLimitHours: 72,
items: buildItems(),
};
setSaving(true);
setError('');
const request = editing && groupId
? adminApi.updateChannelGroup(groupId, payload)
: adminApi.createChannelGroup({
code: `CG-${Date.now()}`,
name: payload.name,
carrier,
status: 'active',
retryEnabled,
retryTimeLimitHours: 72,
}).then((group) => adminApi.updateChannelGroup(group.id, payload));
request
.then(() => navigate('/admin/channel-groups'))
.catch((reason: Error) => setError(reason.message || '通道组保存失败'))
.finally(() => setSaving(false));
}
return (
<div className="page-stack channel-group-form-page">
<div className="page-heading">
@@ -216,6 +332,9 @@ export function AdminChannelGroupFormPage() {
</div>
</div>
{loading ? <p className="muted">...</p> : null}
{error ? <p className="form-error">{error}</p> : null}
<section className="surface channel-group-form-section">
<h2></h2>
<div className="channel-group-base-form">
@@ -240,26 +359,26 @@ export function AdminChannelGroupFormPage() {
<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">
<Table columns={provinceColumns} data={provinceRoutes} emptyText="暂无省网通道" rowKey="id" />
<Button disabled={loading} 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">
<Table columns={nationalColumns} data={nationalRoutes} emptyText="暂无全国通道" rowKey="id" />
<Button disabled={loading} 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 disabled={saving || loading} onClick={saveGroup}>{saving ? '保存中...' : '确认'}</Button>
<Button onClick={() => navigate('/admin/channel-groups')} variant="ghost"></Button>
</div>
{modal ? <RouteConfigModal modal={modal} onClose={() => setModal(null)} onSubmit={saveRoute} /> : null}
{modal ? <RouteConfigModal carrier={carrier} channels={channels} modal={modal} onClose={() => setModal(null)} onSubmit={saveRoute} /> : null}
</div>
);
}
+30 -21
View File
@@ -26,6 +26,7 @@ type SmsChannel = {
corpCode: string;
account: string;
accessNo: string;
passwordCipher?: string;
};
type ChannelModalState = {
@@ -139,6 +140,22 @@ function mapUiStatusToApi(channel: SmsChannel) {
return channel.status === 'stopped' ? 'active' : 'disabled';
}
function buildChannelPayload(channel: SmsChannel, passwordCipher?: string) {
return {
name: channel.name,
carrier: channel.carrier,
sendRegion: channel.sendRegion,
gatewayHost: channel.gatewayHost,
gatewayPort: Number(channel.gatewayPort),
enterpriseCode: channel.corpCode,
account: channel.account,
passwordCipher: passwordCipher || undefined,
srcId: channel.accessNo,
rateLimitPerSecond: 100,
unitPrice: Math.round(channel.unitPrice),
};
}
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}`}>
@@ -193,6 +210,7 @@ function ChannelFormModal({
corpCode,
account,
accessNo,
passwordCipher: password || undefined,
});
}
@@ -355,31 +373,22 @@ export function AdminChannelsPage() {
);
async function upsertChannel(nextChannel: SmsChannel) {
if (modal?.mode === 'edit') {
setError('短信通道编辑接口待补,当前不做本地模拟保存');
return;
}
try {
const created = await adminApi.createChannel({
code: `CH-${Date.now()}`,
name: nextChannel.name,
carrier: nextChannel.carrier,
sendRegion: nextChannel.sendRegion,
gatewayHost: nextChannel.gatewayHost,
gatewayPort: Number(nextChannel.gatewayPort),
enterpriseCode: nextChannel.corpCode,
account: nextChannel.account,
passwordCipher: 'secret',
srcId: nextChannel.accessNo,
rateLimitPerSecond: 100,
unitPrice: Math.round(nextChannel.unitPrice),
status: 'active',
});
setChannels((items) => [mapApiChannel(created), ...items]);
if (modal?.mode === 'edit' && modal.channel) {
const updated = await adminApi.updateChannel(modal.channel.id, buildChannelPayload(nextChannel, nextChannel.passwordCipher));
setChannels((items) => items.map((item) => (item.id === updated.id ? mapApiChannel(updated) : item)));
} else {
const created = await adminApi.createChannel({
code: `CH-${Date.now()}`,
...buildChannelPayload(nextChannel, nextChannel.passwordCipher || 'secret'),
status: 'active',
});
setChannels((items) => [mapApiChannel(created), ...items]);
}
setModal(null);
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '通道创建失败');
setError(failure instanceof Error ? failure.message : '通道保存失败');
}
}
@@ -70,6 +70,19 @@ export function AdminCustomerDetailPage() {
<div className="surface mini-status-card"><FileText size={22} /><div><span></span><strong>¥{((account?.balanceCents ?? 0) / 100).toLocaleString('zh-CN')}</strong><small></small></div></div>
</div>
<div className="surface section-stack">
<div className="section-heading"><div><h2></h2><p className="muted"></p></div></div>
<div className="ui-detail-info-grid">
<div className="ui-detail-info-grid__item"><span></span><strong>{tenant?.enterpriseProfile?.creditCode || '-'}</strong></div>
<div className="ui-detail-info-grid__item"><span>/</span><strong>{[tenant?.enterpriseProfile?.province, tenant?.enterpriseProfile?.city].filter(Boolean).join(' / ') || '-'}</strong></div>
<div className="ui-detail-info-grid__item ui-detail-info-grid__item--full"><span></span><strong>{tenant?.enterpriseProfile?.address || '-'}</strong></div>
<div className="ui-detail-info-grid__item"><span></span><strong>{tenant?.enterpriseProfile?.contactName || '-'}</strong></div>
<div className="ui-detail-info-grid__item"><span></span><strong>{tenant?.enterpriseProfile?.contactPhone || '-'}</strong></div>
<div className="ui-detail-info-grid__item"><span></span><strong>{tenant?.enterpriseProfile?.contactIdCard || '-'}</strong></div>
<div className="ui-detail-info-grid__item"><span></span><strong>{tenant?.enterpriseProfile?.contactEmail || '-'}</strong></div>
</div>
</div>
<div className="surface section-stack">
<div className="section-heading"><div><h2></h2><p className="muted"></p></div><Tag tone="info">{applications.length} </Tag></div>
<Table columns={appColumns} data={applications} emptyText="暂无短信应用" rowKey="id" />
+204 -23
View File
@@ -1,36 +1,153 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { adminApi } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Select } from '@/components/ui';
import { ImagePlus } from 'lucide-react';
import { adminApi, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Select, Textarea } from '@/components/ui';
type EnterpriseForm = {
name: string;
code: string;
status: string;
creditCode: string;
province: string;
city: string;
address: string;
contactName: string;
contactIdCard: string;
contactPhone: string;
contactEmail: string;
photoFileObjectId: string;
photoFileName: string;
};
type EnterpriseFormErrors = Partial<Record<keyof EnterpriseForm, string>>;
const provinceOptions = [
{ label: '请选择省/直辖市', value: '' },
...'北京,上海,广东,山东,河南,江苏,浙江,四川,重庆,湖北,湖南,陕西'.split(',').map((item) => ({ label: item, value: item })),
];
const cityOptionsByProvince: Record<string, Array<{ label: string; value: string }>> = {
: [{ label: '北京市', value: '北京市' }],
: [{ label: '上海市', value: '上海市' }],
广: ['广州市', '深圳市', '东莞市'].map((item) => ({ label: item, value: item })),
: ['济南市', '青岛市', '烟台市'].map((item) => ({ label: item, value: item })),
: ['郑州市', '洛阳市', '开封市'].map((item) => ({ label: item, value: item })),
: ['南京市', '苏州市', '无锡市'].map((item) => ({ label: item, value: item })),
: ['杭州市', '宁波市', '温州市'].map((item) => ({ label: item, value: item })),
: ['成都市', '绵阳市', '德阳市'].map((item) => ({ label: item, value: item })),
: [{ label: '重庆市', value: '重庆市' }],
: ['武汉市', '宜昌市', '襄阳市'].map((item) => ({ label: item, value: item })),
: ['长沙市', '株洲市', '湘潭市'].map((item) => ({ label: item, value: item })),
西: ['西安市', '咸阳市', '宝鸡市'].map((item) => ({ label: item, value: item })),
};
const emptyForm: EnterpriseForm = {
name: '',
code: '',
status: 'active',
creditCode: '',
province: '',
city: '',
address: '',
contactName: '',
contactIdCard: '',
contactPhone: '',
contactEmail: '',
photoFileObjectId: '',
photoFileName: '',
};
function formFromTenant(tenant: TenantOption): EnterpriseForm {
const profile = tenant.enterpriseProfile;
return {
name: tenant.name,
code: tenant.code,
status: tenant.status,
creditCode: profile?.creditCode ?? '',
province: profile?.province ?? '',
city: profile?.city ?? '',
address: profile?.address ?? '',
contactName: profile?.contactName ?? '',
contactIdCard: profile?.contactIdCard ?? '',
contactPhone: profile?.contactPhone ?? '',
contactEmail: profile?.contactEmail ?? '',
photoFileObjectId: profile?.photoFileObjectId ?? '',
photoFileName: profile?.photoFileObjectId ? '已上传企业照片' : '',
};
}
export function AdminCustomerFormPage() {
const navigate = useNavigate();
const { enterpriseId } = useParams();
const isEdit = Boolean(enterpriseId);
const [name, setName] = useState('');
const [code, setCode] = useState('');
const [status, setStatus] = useState('active');
const [form, setForm] = useState<EnterpriseForm>(emptyForm);
const [errors, setErrors] = useState<EnterpriseFormErrors>({});
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const [uploadingPhoto, setUploadingPhoto] = useState(false);
useEffect(() => {
if (!enterpriseId) return;
if (!enterpriseId) {
setForm(emptyForm);
return;
}
adminApi.getTenant(enterpriseId)
.then((tenant) => {
setName(tenant.name);
setCode(tenant.code);
setStatus(tenant.status);
setForm(formFromTenant(tenant));
setError('');
})
.catch((failure: Error) => setError(failure.message || '企业信息加载失败'));
}, [enterpriseId]);
const cityOptions = useMemo(() => [
{ label: '请选择市/区', value: '' },
...(cityOptionsByProvince[form.province] ?? []),
], [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.code.trim()) nextErrors.code = '请填写企业编码';
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() {
const action = isEdit && enterpriseId
? adminApi.updateTenant(enterpriseId, { name, code, status })
: adminApi.createTenant({ name, code, status });
action
if (!validateForm()) return;
setSaving(true);
const { photoFileName, ...payload } = form;
const request = isEdit && enterpriseId
? adminApi.updateTenant(enterpriseId, payload)
: adminApi.createTenant(payload);
request
.then(() => navigate('/admin/customers'))
.catch((failure: Error) => setError(failure.message || '企业保存失败'));
.catch((failure: Error) => setError(failure.message || '企业保存失败'))
.finally(() => setSaving(false));
}
function uploadEnterprisePhoto(file: File | undefined) {
if (!file) return;
setUploadingPhoto(true);
adminApi.uploadFileObject(file, { purpose: 'enterprise_photo', prefix: 'enterprise-photos' })
.then((fileObject) => {
setForm((current) => ({ ...current, photoFileObjectId: fileObject.id, photoFileName: fileObject.fileName }));
setError('');
})
.catch((failure: Error) => setError(failure.message || '企业照片上传失败'))
.finally(() => setUploadingPhoto(false));
}
return (
@@ -38,7 +155,7 @@ export function AdminCustomerFormPage() {
<div className="page-heading">
<div>
<Breadcrumb items={[isEdit ? '编辑企业' : '创建企业']} />
<p></p>
<p></p>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
@@ -48,23 +165,87 @@ export function AdminCustomerFormPage() {
<div className="ui-detail-section__header">
<div>
<h3></h3>
<p></p>
<p></p>
</div>
</div>
<div className="form-grid form-grid--two">
<Input label="企业名称" onChange={(event) => setName(event.target.value)} placeholder="请填写企业全称" required value={name} />
<Input label="企业编码" onChange={(event) => setCode(event.target.value)} placeholder="请填写唯一企业编码" required value={code} />
<div className="enterprise-upload-panel">
<span></span>
<label className="enterprise-upload-button">
<ImagePlus size={28} />
{uploadingPhoto ? '上传中...' : form.photoFileName || '上传企业照片'}
<input
accept="image/png,image/jpeg,image/webp"
disabled={uploadingPhoto}
onChange={(event) => uploadEnterprisePhoto(event.target.files?.[0])}
style={{ display: 'none' }}
type="file"
/>
</label>
<p>{form.photoFileObjectId ? `文件对象:${form.photoFileObjectId}` : '支持 JPG、PNG、WebP,上传后随企业档案保存。'}</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.code} label="企业编码" onChange={(event) => updateForm('code', event.target.value)} placeholder="请填写唯一企业编码" required value={form.code} />
</div>
<Input
error={errors.creditCode}
hint="修改此项将同步更新该企业档案。"
label="统一社会信用代码"
onChange={(event) => updateForm('creditCode', event.target.value)}
placeholder="请填写统一社会信用代码或纳税识别号"
required
value={form.creditCode}
/>
<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>
<Select
label="企业状态"
onChange={(event) => setStatus(event.target.value)}
onChange={(event) => updateForm('status', event.target.value)}
options={[{ label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]}
value={status}
value={form.status}
/>
</section>
<div className="enterprise-form-footer">
<Button disabled={!name || !code} onClick={submitForm}>{isEdit ? '保存企业' : '创建企业'}</Button>
<Button disabled={saving} onClick={submitForm}>{saving ? '保存中...' : isEdit ? '保存企业' : '创建企业'}</Button>
<Button onClick={() => navigate('/admin/customers')} variant="ghost"></Button>
</div>
</div>
+10 -6
View File
@@ -58,16 +58,20 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
const totalBalance = records.reduce((sum, record) => sum + (record.account?.balanceCents ?? 0), 0);
const columns: Array<TableColumn<CustomerRow>> = [
{ key: 'id', title: '企业ID', width: '220px', render: (record) => record.id },
{ key: 'name', title: '企业名称', render: (record) => <strong>{record.name}</strong> },
{ key: 'code', title: '企业编码', render: (record) => record.code },
{ key: 'balance', title: '现金余额', align: 'right', render: (record) => `¥${((record.account?.balanceCents ?? 0) / 100).toLocaleString('zh-CN')}` },
{ key: 'smsUnits', title: '短信余量', align: 'right', render: (record) => `${(record.account?.smsUnits ?? 0).toLocaleString('zh-CN')}` },
{ key: 'status', title: '企业状态', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'warning'}>{record.status === 'active' ? '正常' : '已禁用'}</Tag> },
{ key: 'id', title: '企业ID', width: '240px', render: (record) => <span className="table-mono-id">{record.id}</span> },
{ key: 'name', title: '企业名称', width: '260px', render: (record) => <strong className="table-strong-text">{record.name}</strong> },
{ key: 'code', title: '企业编码', width: '180px', render: (record) => <span className="table-mono-id">{record.code}</span> },
{ key: 'creditCode', title: '统一社会信用代码', width: '220px', render: (record) => record.enterpriseProfile?.creditCode || '-' },
{ key: 'contact', title: '联系人', width: '160px', render: (record) => record.enterpriseProfile?.contactName || '-' },
{ key: 'phone', title: '联系电话', width: '150px', render: (record) => record.enterpriseProfile?.contactPhone || '-' },
{ key: 'balance', title: '现金余额', width: '150px', align: 'right', render: (record) => `¥${((record.account?.balanceCents ?? 0) / 100).toLocaleString('zh-CN')}` },
{ key: 'smsUnits', title: '短信余量', width: '150px', align: 'right', render: (record) => `${(record.account?.smsUnits ?? 0).toLocaleString('zh-CN')}` },
{ key: 'status', title: '企业状态', width: '130px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'warning'}>{record.status === 'active' ? '正常' : '已禁用'}</Tag> },
{
key: 'actions',
title: '操作',
align: 'right',
width: '280px',
render: (record) => (
<div className="table-actions">
<Button onClick={() => navigate(`${basePath}/${record.id}`)} size="sm" variant="ghost"></Button>
@@ -2,10 +2,11 @@ import { useEffect, useMemo, useState } from 'react';
import { Copy, Edit3, Plus, Search, Settings2, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Breadcrumb, Button, Input, Modal, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
import { adminApi, type ApplicationCmppParams, type CmppConnectionState, type EnterpriseApplication } from '@/api/adminApi';
import { adminApi, type ApplicationCmppParams, type CmppConnectionState, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
type SmsApp = {
id: string;
tenantId: string;
name: string;
enterprise: string;
appId: string;
@@ -43,18 +44,6 @@ type CmppConnection = {
pendingWindow: number;
};
type MmsApp = Omit<SmsApp, 'cmppStatus' | 'cmppConnections' | 'cmppParams'> & {
pointPrice: number;
};
type AppKind = 'sms' | 'mms';
const initialMmsApps: MmsApp[] = [
{ id: 'mms-app-1', name: '营销活动彩信', enterprise: '上海XXXXX科技有限公司', appId: 'MMS_2024020112345678', enabled: true, sentToday: 320, deliveryRate: 92, unitPrice: 0.15, pointPrice: 50 },
{ id: 'mms-app-2', name: '节日祝福彩信', enterprise: '重庆进载数智', appId: 'MMS_2024020187654321', enabled: true, sentToday: 180, deliveryRate: 88, unitPrice: 0.12, pointPrice: 30 },
{ id: 'mms-app-3', name: '会员权益彩信', enterprise: '四川骠骑企业管理', appId: 'MMS_2024020199001122', enabled: false, sentToday: 0, deliveryRate: 0, unitPrice: 0.18, pointPrice: 60 },
];
function enabledTag(enabled: boolean) {
return <Tag tone={enabled ? 'success' : 'neutral'}>{enabled ? '启用' : '停用'}</Tag>;
}
@@ -77,6 +66,52 @@ function ConfirmModal({ message, danger, onCancel, onConfirm }: { message: strin
);
}
function AddApplicationModal({
tenants,
loading,
selectedTenantId,
onChange,
onCancel,
onConfirm,
}: {
tenants: TenantOption[];
loading: boolean;
selectedTenantId: string;
onChange: (tenantId: string) => void;
onCancel: () => void;
onConfirm: () => void;
}) {
return (
<Modal
footer={(
<>
<Button onClick={onCancel} variant="ghost"></Button>
<Button disabled={!selectedTenantId || loading} onClick={onConfirm}></Button>
</>
)}
onClose={onCancel}
open
title={<div className="template-modal-title"><h2></h2><p></p></div>}
>
<div className="form-grid app-create-modal">
<label className="field">
<span></span>
<select disabled={loading} onChange={(event) => onChange(event.target.value)} value={selectedTenantId}>
<option value="">{loading ? '企业加载中...' : '请选择真实企业'}</option>
{tenants.map((tenant) => (
<option key={tenant.id} value={tenant.id}>{tenant.name}{tenant.code}</option>
))}
</select>
</label>
<div className="app-create-modal__hint">
<strong>{selectedTenantId ? tenants.find((tenant) => tenant.id === selectedTenantId)?.name : '请选择要开通短信应用的企业'}</strong>
<span>IP </span>
</div>
</div>
</Modal>
);
}
const connectionStateMeta: Record<CmppConnection['state'], { label: string; tone: 'success' | 'warning' | 'neutral' }> = {
open: { label: '已连接', tone: 'success' },
closed: { label: '已断开', tone: 'neutral' },
@@ -177,19 +212,19 @@ function CmppConnectionModal({
<Table
columns={[
{ key: 'id', title: '连接ID', width: '150px', render: (record: CmppConnection) => <strong>{record.id}</strong> },
{ key: 'state', title: '状态', width: '100px', render: (record: CmppConnection) => <Tag tone={connectionStateMeta[record.state].tone}>{connectionStateMeta[record.state].label}</Tag> },
{ key: 'state', title: '状态', width: '130px', render: (record: CmppConnection) => <Tag tone={connectionStateMeta[record.state].tone}>{connectionStateMeta[record.state].label}</Tag> },
{ key: 'bindType', title: '绑定类型', width: '120px', render: (record: CmppConnection) => record.bindType },
{ key: 'clientIp', title: '客户端IP', width: '170px', render: (record: CmppConnection) => record.clientIp },
{ key: 'sourceAddr', title: '企业代码', width: '120px', render: (record: CmppConnection) => record.sourceAddr },
{ key: 'establishedAt', title: '连接建立时间', width: '180px', render: (record: CmppConnection) => record.establishedAt },
{ key: 'lastHeartbeatAt', title: '上次心跳', width: '180px', render: (record: CmppConnection) => record.lastHeartbeatAt },
{ key: 'lastSubmitAt', title: '上次提交', width: '180px', render: (record: CmppConnection) => record.lastSubmitAt },
{ key: 'pendingWindow', title: '窗口占用', align: 'right', width: '100px', render: (record: CmppConnection) => record.pendingWindow },
{ key: 'pendingWindow', title: '窗口占用', align: 'right', width: '120px', render: (record: CmppConnection) => record.pendingWindow },
{
key: 'actions',
title: '操作',
align: 'right',
width: '100px',
width: '120px',
render: (record: CmppConnection) => (
<Button icon={<Trash2 size={14} />} onClick={() => onDeleteConnection(record.id)} size="sm" variant="danger"></Button>
),
@@ -207,15 +242,18 @@ function CmppConnectionModal({
export function AdminEnterpriseApplicationsPage() {
const navigate = useNavigate();
const [smsApps, setSmsApps] = useState<SmsApp[]>([]);
const [mmsApps, setMmsApps] = useState(initialMmsApps);
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [connectionApp, setConnectionApp] = useState<SmsApp | null>(null);
const [paramsApp, setParamsApp] = useState<SmsApp | null>(null);
const [paramsDetail, setParamsDetail] = useState<ApplicationCmppParams | null>(null);
const [error, setError] = useState('');
const [addModalOpen, setAddModalOpen] = useState(false);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [tenantsLoading, setTenantsLoading] = useState(false);
const [selectedTenantId, setSelectedTenantId] = useState('');
const [confirmAction, setConfirmAction] = useState<
| { action: 'toggle'; kind: AppKind; id: string; name: string; enabled: boolean }
| { action: 'delete'; kind: AppKind; id: string; name: string }
| { action: 'toggle'; id: string; name: string; enabled: boolean }
| { action: 'delete'; id: string; name: string }
| null
>(null);
@@ -234,36 +272,47 @@ export function AdminEnterpriseApplicationsPage() {
void loadSmsApps();
}, [enterpriseKeyword]);
async function confirmToggle(kind: AppKind, id: string) {
if (kind === 'sms') {
const app = smsApps.find((item) => item.id === id);
if (app) {
await adminApi.changeApplicationStatus(id, app.enabled ? 'disabled' : 'active', '运营端企业应用管理');
await loadSmsApps();
async function openAddModal() {
setAddModalOpen(true);
if (tenants.length === 0) {
setTenantsLoading(true);
try {
setTenants((await adminApi.listTenants()).filter((tenant) => tenant.status !== 'deleted'));
} catch (err) {
setError(err instanceof Error ? err.message : '企业列表加载失败');
} finally {
setTenantsLoading(false);
}
return;
}
setMmsApps((current) => current.map((item) => item.id === id ? { ...item, enabled: !item.enabled } : item));
}
async function confirmDelete(kind: AppKind, id: string) {
if (kind === 'sms') {
await adminApi.changeApplicationStatus(id, 'deleted', '运营端删除应用');
await loadSmsApps();
} else {
setMmsApps((current) => current.filter((item) => item.id !== id));
function confirmAddApplication() {
if (selectedTenantId) {
navigate(`/admin/customers/${selectedTenantId}/sms-apps/new`);
}
}
async function confirmToggle(id: string) {
const app = smsApps.find((item) => item.id === id);
if (app) {
await adminApi.changeApplicationStatus(id, app.enabled ? 'disabled' : 'active', '运营端企业应用管理');
await loadSmsApps();
}
}
async function confirmDelete(id: string) {
await adminApi.changeApplicationStatus(id, 'deleted', '运营端删除应用');
await loadSmsApps();
}
async function runConfirmedAction() {
if (!confirmAction) {
return;
}
if (confirmAction.action === 'toggle') {
await confirmToggle(confirmAction.kind, confirmAction.id);
await confirmToggle(confirmAction.id);
} else {
await confirmDelete(confirmAction.kind, confirmAction.id);
await confirmDelete(confirmAction.id);
}
setConfirmAction(null);
}
@@ -286,18 +335,13 @@ export function AdminEnterpriseApplicationsPage() {
[enterpriseKeyword, smsApps],
);
const filteredMmsApps = useMemo(
() => mmsApps.filter((item) => !enterpriseKeyword || item.enterprise.includes(enterpriseKeyword)),
[enterpriseKeyword, mmsApps],
);
const smsColumns = useMemo<Array<TableColumn<SmsApp>>>(() => [
{ key: 'name', title: '应用名称', width: '180px', render: (record) => <strong>{record.name}</strong> },
{ key: 'enterprise', title: '企业名称', width: '220px', render: (record) => record.enterprise },
{ key: 'appId', title: 'AppID', width: '220px', render: (record) => record.appId },
{ key: 'sentToday', title: '今日发送', width: '120px', render: (record) => `${record.sentToday.toLocaleString('zh-CN')}` },
{ key: 'deliveryRate', title: '到达率', width: '110px', render: (record) => `${record.deliveryRate}%` },
{ key: 'unitPrice', title: '单价', width: '100px', render: (record) => `${record.unitPrice.toFixed(3)}` },
{ key: 'deliveryRate', title: '到达率', width: '130px', render: (record) => `${record.deliveryRate}%` },
{ key: 'unitPrice', title: '单价', width: '130px', render: (record) => `${record.unitPrice.toFixed(3)}` },
{
key: 'cmppStatus',
title: 'CMPP状态',
@@ -317,7 +361,7 @@ export function AdminEnterpriseApplicationsPage() {
</div>
),
},
{ key: 'enabled', title: '状态', width: '100px', render: (record) => enabledTag(record.enabled) },
{ key: 'enabled', title: '状态', width: '130px', render: (record) => enabledTag(record.enabled) },
{
key: 'actions',
title: '操作',
@@ -325,37 +369,11 @@ export function AdminEnterpriseApplicationsPage() {
width: '190px',
render: (record) => (
<div className="table-actions">
<Button icon={<Edit3 size={15} />} onClick={() => navigate(`/admin/customers/2763/sms-apps/${record.id}/edit`)} size="sm" variant="ghost"></Button>
<Button onClick={() => setConfirmAction({ action: 'toggle', kind: 'sms', id: record.id, name: record.name, enabled: record.enabled })} size="sm" variant="secondary">
<Button icon={<Edit3 size={15} />} onClick={() => navigate(`/admin/customers/${record.tenantId}/sms-apps/${record.id}/edit`)} size="sm" variant="ghost"></Button>
<Button onClick={() => setConfirmAction({ action: 'toggle', id: record.id, name: record.name, enabled: record.enabled })} size="sm" variant="secondary">
{record.enabled ? '停用' : '启用'}
</Button>
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ action: 'delete', kind: 'sms', id: record.id, name: record.name })} size="sm" variant="danger"></Button>
</div>
),
},
], [navigate]);
const mmsColumns = useMemo<Array<TableColumn<MmsApp>>>(() => [
{ key: 'name', title: '应用名称', width: '180px', render: (record) => <strong>{record.name}</strong> },
{ key: 'enterprise', title: '企业名称', width: '220px', render: (record) => record.enterprise },
{ key: 'appId', title: 'AppID', width: '220px', render: (record) => record.appId },
{ key: 'sentToday', title: '今日发送', width: '120px', render: (record) => `${record.sentToday.toLocaleString('zh-CN')}` },
{ key: 'deliveryRate', title: '到达率', width: '110px', render: (record) => `${record.deliveryRate}%` },
{ key: 'unitPrice', title: '单价', width: '100px', render: (record) => `${record.unitPrice.toFixed(3)}` },
{ key: 'pointPrice', title: '点数', width: '100px', render: (record) => `${record.pointPrice}` },
{ key: 'enabled', title: '状态', width: '100px', render: (record) => enabledTag(record.enabled) },
{
key: 'actions',
title: '操作',
align: 'right',
width: '190px',
render: (record) => (
<div className="table-actions">
<Button icon={<Edit3 size={15} />} onClick={() => navigate(`/admin/customers/2763/mms-apps/${record.id}/edit`)} size="sm" variant="ghost"></Button>
<Button onClick={() => setConfirmAction({ action: 'toggle', kind: 'mms', id: record.id, name: record.name, enabled: record.enabled })} size="sm" variant="secondary">
{record.enabled ? '停用' : '启用'}
</Button>
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ action: 'delete', kind: 'mms', id: record.id, name: record.name })} size="sm" variant="danger"></Button>
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ action: 'delete', id: record.id, name: record.name })} size="sm" variant="danger"></Button>
</div>
),
},
@@ -368,7 +386,7 @@ export function AdminEnterpriseApplicationsPage() {
<Breadcrumb items={['客户管理', '企业应用管理']} />
<h1></h1>
</div>
<Button icon={<Plus size={16} />} onClick={() => navigate('/admin/customers/2763/sms-apps/new')}></Button>
<Button icon={<Plus size={16} />} onClick={() => { void openAddModal(); }}></Button>
</div>
<div className="surface admin-split-filter">
@@ -388,7 +406,7 @@ export function AdminEnterpriseApplicationsPage() {
<Tabs
items={[
{ label: '短信应用', value: 'sms', content: <Table columns={smsColumns} data={filteredSmsApps} rowKey="id" /> },
{ label: '彩信应用', value: 'mms', pending: true, content: <Table columns={mmsColumns} data={filteredMmsApps} rowKey="id" /> },
{ label: '彩信应用', value: 'mms', pending: true, content: <div className="ui-table__empty"></div> },
]}
/>
</div>
@@ -403,6 +421,16 @@ export function AdminEnterpriseApplicationsPage() {
onConfirm={() => { void runConfirmedAction(); }}
/>
) : null}
{addModalOpen ? (
<AddApplicationModal
loading={tenantsLoading}
onCancel={() => setAddModalOpen(false)}
onChange={setSelectedTenantId}
onConfirm={confirmAddApplication}
selectedTenantId={selectedTenantId}
tenants={tenants}
/>
) : null}
{connectionApp ? (
<CmppConnectionModal
app={connectionApp}
@@ -419,13 +447,14 @@ function mapApplication(application: EnterpriseApplication): SmsApp {
const connections = (application.cmppConnections ?? []).map(mapConnection);
return {
id: application.id,
tenantId: application.tenantId,
name: application.name,
enterprise: application.tenant?.name ?? application.tenantId,
appId: application.id,
enabled: application.status === 'active',
sentToday: application.sentToday ?? 0,
deliveryRate: application.deliveryRate ?? 0,
unitPrice: 0,
unitPrice: (application.customerUnitPrice ?? 0) / 100,
cmppStatus: application.cmppStatus === 'connected' ? 'connected' : application.cmppStatus === 'inactive' ? 'inactive' : 'disconnected',
cmppParams: { host: '', port: 0, enterpriseCode: application.tenant?.code ?? application.tenantId, account: application.tenant?.code ?? application.tenantId, password: '', accessNumber: '', maxConnections: 0, heartbeatSeconds: 30, windowSize: 16, protocolVersion: 'CMPP 3.0' },
cmppConnections: connections,
@@ -44,11 +44,11 @@ export function AdminEnterpriseBlacklistPage() {
{ key: 'phone', title: '手机号码', width: '150px', render: (record) => <strong>{record.phoneNumber}</strong> },
{ key: 'createdAt', title: '入库时间', width: '170px', render: (record) => record.createdAt ?? '-' },
{ key: 'reason', title: '入库原因', render: (record) => record.reason ?? '-' },
{ key: 'status', title: '状态', width: '110px', render: (record) => record.status ?? '-' },
{ key: 'status', title: '状态', width: '130px', render: (record) => record.status ?? '-' },
{
key: 'actions',
title: '操作',
width: '110px',
width: '130px',
align: 'right',
render: (record) => (
<Button icon={<Trash2 size={15} />} onClick={() => adminApi.deleteEnterpriseBlacklist(record.id).then(loadData).catch((failure: Error) => setError(failure.message))} size="sm" variant="danger">
+495 -31
View File
@@ -1,53 +1,517 @@
import { useEffect, useMemo, useState } from 'react';
import { Search } from 'lucide-react';
import { adminApi, type ClientSmsSignature } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Table, Tag, type TableColumn } from '@/components/ui';
import { ChevronDown, ChevronRight, Edit3, FileText, Plus, Search, Trash2 } from 'lucide-react';
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Tabs, Tag, Textarea } from '@/components/ui';
type CarrierStatus = 'approved' | 'pending' | 'rejected' | 'filing';
type DrainageInfo = {
id: string;
siteName: string;
url: string;
mobile: CarrierStatus;
unicom: CarrierStatus;
telecom: CarrierStatus;
submittedAt: string;
remark: string;
};
type SignatureFormState = {
tenantId: string;
applicationId: string;
name: string;
purpose: string;
mobile: CarrierStatus;
unicom: CarrierStatus;
telecom: CarrierStatus;
};
const statusLabelMap: Record<CarrierStatus, string> = {
approved: '已通过',
pending: '审核中',
rejected: '已驳回',
filing: '待报备',
};
const statusToneMap: Record<CarrierStatus, 'success' | 'info' | 'danger' | 'neutral'> = {
approved: 'success',
pending: 'info',
rejected: 'danger',
filing: 'neutral',
};
const statusOptions = [
{ label: '已通过', value: 'approved' },
{ label: '审核中', value: 'pending' },
{ label: '已驳回', value: 'rejected' },
{ label: '待报备', value: 'filing' },
];
function StatusTag({ status }: { status: CarrierStatus }) {
return <Tag tone={statusToneMap[status]}>{statusLabelMap[status]}</Tag>;
}
function readDrainagePayload(signature: ClientSmsSignature) {
const payload = signature.drainageInfo && typeof signature.drainageInfo === 'object' ? signature.drainageInfo : {};
const carrierStatus = typeof payload.carrierStatus === 'object' && payload.carrierStatus ? payload.carrierStatus as Record<string, unknown> : {};
const links = Array.isArray(payload.links) ? payload.links as Array<Record<string, unknown>> : [];
const fallbackStatus = normalizeCarrierStatus(signature.auditStatus);
return {
carrierStatus: {
mobile: normalizeCarrierStatus(carrierStatus.mobile, fallbackStatus),
unicom: normalizeCarrierStatus(carrierStatus.unicom, fallbackStatus),
telecom: normalizeCarrierStatus(carrierStatus.telecom, fallbackStatus),
},
links: links.map((item) => ({
id: String(item.id ?? `drain-${Date.now()}`),
siteName: String(item.siteName ?? ''),
url: String(item.url ?? ''),
mobile: normalizeCarrierStatus(item.mobile, 'filing'),
unicom: normalizeCarrierStatus(item.unicom, 'filing'),
telecom: normalizeCarrierStatus(item.telecom, 'filing'),
submittedAt: String(item.submittedAt ?? ''),
remark: String(item.remark ?? ''),
})),
};
}
function buildDrainagePayload(carrierStatus: { mobile: CarrierStatus; unicom: CarrierStatus; telecom: CarrierStatus }, links: DrainageInfo[]) {
return { carrierStatus, links };
}
function normalizeCarrierStatus(value: unknown, fallback: CarrierStatus = 'filing'): CarrierStatus {
return value === 'approved' || value === 'pending' || value === 'rejected' || value === 'filing' ? value : fallback;
}
function toAuditStatus(status: CarrierStatus) {
return status === 'filing' ? 'pending' : status;
}
function formatDate(value?: string) {
return value ? new Date(value).toLocaleString('zh-CN') : '-';
}
function SignatureFormModal({
applications,
item,
onClose,
onSubmit,
tenants,
}: {
applications: ClientSmsApplication[];
item?: ClientSmsSignature;
onClose: () => void;
onSubmit: (state: SignatureFormState) => void;
tenants: TenantOption[];
}) {
const payload = item ? readDrainagePayload(item) : null;
const [form, setForm] = useState<SignatureFormState>({
tenantId: item?.tenantId ?? '',
applicationId: item?.applicationId ?? '',
name: item?.name ?? '',
purpose: item?.purpose ?? '',
mobile: payload?.carrierStatus.mobile ?? 'filing',
unicom: payload?.carrierStatus.unicom ?? 'filing',
telecom: payload?.carrierStatus.telecom ?? 'filing',
});
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted');
function update<Key extends keyof SignatureFormState>(key: Key, value: SignatureFormState[Key]) {
setForm((current) => ({ ...current, [key]: value }));
}
return (
<Modal
footer={(
<>
<Button onClick={onClose} variant="ghost"></Button>
<Button disabled={!form.tenantId || !form.name} onClick={() => onSubmit(form)}></Button>
</>
)}
onClose={onClose}
open
size="xl"
title={item ? '编辑短信签名' : '添加短信签名'}
>
<div className="signature-form">
<section>
<h3></h3>
<div className="signature-form-grid">
<Select
disabled={Boolean(item)}
label="所属企业"
onChange={(event) => update('tenantId', event.target.value)}
options={[
{ label: '请选择企业', value: '' },
...tenants.map((tenant) => ({ label: `${tenant.name}${tenant.code}`, value: tenant.id })),
]}
required
value={form.tenantId}
/>
<Select
label="所属应用"
onChange={(event) => update('applicationId', event.target.value)}
options={[
{ label: '不绑定应用', value: '' },
...tenantApplications.map((application) => ({ label: application.name, value: application.id })),
]}
value={form.applicationId}
/>
<Input label="短信签名" onChange={(event) => update('name', event.target.value)} placeholder="例如【某某科技】" required value={form.name} />
<Input label="签名用途" onChange={(event) => update('purpose', event.target.value)} placeholder="行业通知/营销推广/验证码" value={form.purpose} />
</div>
</section>
<section>
<h3></h3>
<div className="signature-form-grid">
<Select label="移动状态" onChange={(event) => update('mobile', event.target.value as CarrierStatus)} options={statusOptions} value={form.mobile} />
<Select label="联通状态" onChange={(event) => update('unicom', event.target.value as CarrierStatus)} options={statusOptions} value={form.unicom} />
<Select label="电信状态" onChange={(event) => update('telecom', event.target.value as CarrierStatus)} options={statusOptions} value={form.telecom} />
</div>
</section>
</div>
</Modal>
);
}
function DrainageFormModal({ item, onClose, onSubmit }: { item?: DrainageInfo; onClose: () => void; onSubmit: (item: DrainageInfo) => void }) {
const [form, setForm] = useState<DrainageInfo>(() => item ?? {
id: `drain-${Date.now()}`,
siteName: '',
url: '',
mobile: 'filing',
unicom: 'filing',
telecom: 'filing',
submittedAt: new Date().toLocaleString('zh-CN'),
remark: '',
});
function update<Key extends keyof DrainageInfo>(key: Key, value: DrainageInfo[Key]) {
setForm((current) => ({ ...current, [key]: value }));
}
return (
<Modal
footer={(
<>
<Button onClick={onClose} variant="ghost"></Button>
<Button disabled={!form.siteName || !form.url} onClick={() => onSubmit(form)}></Button>
</>
)}
onClose={onClose}
open
size="xl"
title={item ? '编辑引流链接' : '添加引流链接'}
>
<div className="signature-form drainage-edit-form">
<section>
<h3></h3>
<div className="signature-form-grid">
<Input label="站名称" onChange={(event) => update('siteName', event.target.value)} placeholder="请输入站点名称" required value={form.siteName} />
<Input label="网站链接" onChange={(event) => update('url', event.target.value)} placeholder="https://example.com" required value={form.url} />
<Select label="移动状态" onChange={(event) => update('mobile', event.target.value as CarrierStatus)} options={statusOptions} value={form.mobile} />
<Select label="联通状态" onChange={(event) => update('unicom', event.target.value as CarrierStatus)} options={statusOptions} value={form.unicom} />
<Select label="电信状态" onChange={(event) => update('telecom', event.target.value as CarrierStatus)} options={statusOptions} value={form.telecom} />
<Input label="提交时间" onChange={(event) => update('submittedAt', event.target.value)} value={form.submittedAt} />
<Textarea className="signature-form-grid__wide" label="备注" onChange={(event) => update('remark', event.target.value)} rows={4} value={form.remark} />
</div>
</section>
</div>
</Modal>
);
}
function SignatureReportModal({ item, onClose }: { item: ClientSmsSignature; onClose: () => void }) {
const payload = readDrainagePayload(item);
return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open title="签名报备详情">
<div className="admin-report-detail">
<div className="detail-grid">
<div><span></span><strong>{item.tenant?.name ?? item.tenantId}</strong></div>
<div><span></span><strong>{item.application?.name ?? '-'}</strong></div>
<div><span></span><strong>{item.name}</strong></div>
<div><span></span><strong>{formatDate(item.updatedAt)}</strong></div>
</div>
<div className="admin-report-tabs">
<button className="admin-report-carrier--mobile active" type="button"><strong></strong><span><StatusTag status={payload.carrierStatus.mobile} /></span></button>
<button className="admin-report-carrier--unicom active" type="button"><strong></strong><span><StatusTag status={payload.carrierStatus.unicom} /></span></button>
<button className="admin-report-carrier--telecom active" type="button"><strong></strong><span><StatusTag status={payload.carrierStatus.telecom} /></span></button>
</div>
</div>
</Modal>
);
}
function DrainageReportModal({ item, onClose }: { item: DrainageInfo; onClose: () => void }) {
return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open title="引流信息报备详情">
<div className="detail-grid">
<div><span></span><strong>{item.siteName}</strong></div>
<div><span></span><strong>{item.url}</strong></div>
<div><span></span><StatusTag status={item.mobile} /></div>
<div><span></span><StatusTag status={item.unicom} /></div>
<div><span></span><StatusTag status={item.telecom} /></div>
<div><span></span><strong>{item.submittedAt}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{item.remark || '-'}</strong></div>
</div>
</Modal>
);
}
function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
return (
<Modal
footer={(
<>
<Button onClick={onCancel} variant="ghost"></Button>
<Button onClick={onConfirm} variant="danger"></Button>
</>
)}
onClose={onCancel}
open
title="删除确认"
>
<p className="admin-confirm-text">{message}</p>
</Modal>
);
}
export function AdminEnterpriseSignaturesPage() {
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [keyword, setKeyword] = useState('');
const [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms');
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [deleteTarget, setDeleteTarget] = useState<{ kind: 'signature'; id: string; name: string } | { kind: 'drainage'; signatureId: string; id: string; name: string } | null>(null);
const [drainageModal, setDrainageModal] = useState<{ signatureId: string; item?: DrainageInfo } | null>(null);
const [drainageReport, setDrainageReport] = useState<DrainageInfo | null>(null);
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [error, setError] = useState('');
const [expandedSignatureId, setExpandedSignatureId] = useState('');
const [signatureKeyword, setSignatureKeyword] = useState('');
const [signatureModal, setSignatureModal] = useState<ClientSmsSignature | 'new' | null>(null);
const [signatureReport, setSignatureReport] = useState<ClientSmsSignature | null>(null);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [tenants, setTenants] = useState<TenantOption[]>([]);
function loadData() {
adminApi.listEnterpriseSignatures({ keyword })
.then((items) => {
setSignatures(items);
setError('');
})
.catch((failure: Error) => setError(failure.message || '企业签名加载失败'));
async function loadData() {
try {
const [signatureItems, tenantItems, applicationItems] = await Promise.all([
adminApi.listEnterpriseSignatures({ keyword: [enterpriseKeyword, signatureKeyword].filter(Boolean).join(' ') }),
adminApi.listTenants(),
adminApi.listEnterpriseApplications(),
]);
setSignatures(signatureItems);
setTenants(tenantItems.filter((tenant) => tenant.status !== 'deleted'));
setApplications(applicationItems);
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '企业签名加载失败');
}
}
useEffect(() => {
loadData();
void loadData();
}, []);
const filtered = useMemo(() => signatures.filter((item) => !keyword || [item.name, item.purpose, item.auditStatus].join(' ').includes(keyword)), [keyword, signatures]);
const filteredSignatures = useMemo(() => signatures.filter((item) => {
const enterprise = item.tenant?.name ?? item.tenantId;
const application = item.application?.name ?? '';
return (!enterpriseKeyword || enterprise.includes(enterpriseKeyword))
&& (!signatureKeyword || item.name.includes(signatureKeyword) || application.includes(signatureKeyword) || (item.purpose ?? '').includes(signatureKeyword));
}), [enterpriseKeyword, signatureKeyword, signatures]);
const columns: Array<TableColumn<ClientSmsSignature>> = [
{ key: 'name', title: '签名名称', render: (record) => <strong>{record.name}</strong> },
{ key: 'tenant', title: '企业', render: (record) => record.tenantId },
{ key: 'purpose', title: '用途', render: (record) => record.purpose ?? '-' },
{ key: 'materials', title: '材料', render: (record) => `${record.materials?.length ?? 0}` },
{ key: 'status', title: '审核状态', render: (record) => <Tag tone={record.auditStatus === 'approved' ? 'success' : record.auditStatus === 'rejected' ? 'danger' : 'info'}>{record.auditStatus}</Tag> },
{ key: 'updatedAt', title: '更新时间', render: (record) => record.updatedAt },
];
async function saveSignature(state: SignatureFormState) {
const existing = signatureModal && signatureModal !== 'new' ? signatureModal : null;
const existingPayload = existing ? readDrainagePayload(existing) : { links: [] };
const drainageInfo = buildDrainagePayload({
mobile: state.mobile,
unicom: state.unicom,
telecom: state.telecom,
}, existingPayload.links);
try {
if (existing) {
await adminApi.updateEnterpriseSignature(existing.id, {
applicationId: state.applicationId || null,
auditStatus: toAuditStatus(state.mobile),
drainageInfo,
name: state.name,
purpose: state.purpose,
});
} else {
await adminApi.createEnterpriseSignature({
applicationId: state.applicationId || undefined,
drainageInfo,
name: state.name,
purpose: state.purpose,
tenantId: state.tenantId,
});
}
setSignatureModal(null);
await loadData();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '企业签名保存失败');
}
}
async function saveDrainage(signatureId: string, item: DrainageInfo) {
const signature = signatures.find((current) => current.id === signatureId);
if (!signature) {
return;
}
const payload = readDrainagePayload(signature);
const links = payload.links.some((current) => current.id === item.id)
? payload.links.map((current) => current.id === item.id ? item : current)
: [item, ...payload.links];
await adminApi.updateEnterpriseSignature(signatureId, {
drainageInfo: buildDrainagePayload(payload.carrierStatus, links),
});
setDrainageModal(null);
setExpandedSignatureId(signatureId);
await loadData();
}
async function confirmDelete() {
if (!deleteTarget) {
return;
}
if (deleteTarget.kind === 'signature') {
await adminApi.changeEnterpriseSignatureStatus(deleteTarget.id, 'deleted', '运营端删除签名');
} else {
const signature = signatures.find((item) => item.id === deleteTarget.signatureId);
if (signature) {
const payload = readDrainagePayload(signature);
await adminApi.updateEnterpriseSignature(signature.id, {
drainageInfo: buildDrainagePayload(payload.carrierStatus, payload.links.filter((item) => item.id !== deleteTarget.id)),
});
}
}
setDeleteTarget(null);
await loadData();
}
const smsSignatureContent = (
<div className="signature-list admin-enterprise-signature-list">
{filteredSignatures.map((signature) => {
const payload = readDrainagePayload(signature);
const expanded = expandedSignatureId === signature.id;
return (
<article className="signature-card signature-card--green" key={signature.id}>
<div className="signature-summary">
<button aria-label="展开签名" onClick={() => setExpandedSignatureId(expanded ? '' : signature.id)} type="button">
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
<div><span></span><strong>{signature.name}</strong></div>
<div><span></span><strong>{signature.tenant?.name ?? signature.tenantId}</strong></div>
<div><span></span><strong>{signature.application?.name ?? '-'}</strong></div>
<div><span></span><StatusTag status={payload.carrierStatus.mobile} /></div>
<div><span></span><StatusTag status={payload.carrierStatus.unicom} /></div>
<div><span></span><StatusTag status={payload.carrierStatus.telecom} /></div>
<div><span></span><strong>{payload.links.length} </strong></div>
<div className="signature-actions">
<Button icon={<FileText size={16} />} onClick={() => setSignatureReport(signature)} size="sm" variant="ghost"></Button>
<Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost"></Button>
<Button icon={<Trash2 size={16} />} onClick={() => setDeleteTarget({ kind: 'signature', id: signature.id, name: signature.name })} size="sm" variant="danger"></Button>
</div>
</div>
{expanded ? (
<div className="drainage-panel">
<h2></h2>
{payload.links.length ? (
<div className="drainage-table">
<div className="drainage-table__head">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{payload.links.map((item) => (
<div className="drainage-table__row" key={item.id}>
<strong>{item.siteName}</strong>
<a href={item.url} rel="noreferrer" target="_blank">{item.url}</a>
<StatusTag status={item.mobile} />
<StatusTag status={item.unicom} />
<StatusTag status={item.telecom} />
<span className="muted">{item.submittedAt}</span>
<span className="drainage-row-actions">
<Button onClick={() => setDrainageReport(item)} size="sm" variant="ghost"></Button>
<Button onClick={() => setDrainageModal({ signatureId: signature.id, item })} size="sm" variant="ghost"></Button>
<Button onClick={() => setDeleteTarget({ kind: 'drainage', signatureId: signature.id, id: item.id, name: item.siteName })} size="sm" variant="danger"></Button>
</span>
</div>
))}
</div>
) : (
<p className="muted"></p>
)}
<div className="drainage-panel__footer">
<Button icon={<Plus size={16} />} onClick={() => setDrainageModal({ signatureId: signature.id })} size="sm" variant="ghost"></Button>
</div>
</div>
) : null}
</article>
);
})}
{filteredSignatures.length === 0 ? <div className="ui-table__empty"></div> : null}
</div>
);
return (
<section className="page-stack">
<section className="page-stack admin-customer-split-page">
<div className="page-heading">
<div>
<Breadcrumb items={['企业配置', '企业签名']} />
<h1></h1>
<Breadcrumb items={['客户管理', '企业签名管理']} />
<h1></h1>
</div>
<Button icon={<Plus size={16} />} onClick={() => setSignatureModal(activeTab === 'sms' ? 'new' : null)}></Button>
</div>
<div className="surface admin-split-filter">
<Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} />
<Input label="签名/应用" onChange={(event) => setSignatureKeyword(event.target.value)} placeholder="请输入签名或应用名称" prefix={<Search size={16} />} value={signatureKeyword} />
<Button onClick={() => { setEnterpriseKeyword(''); setSignatureKeyword(''); void loadData(); }} variant="ghost"></Button>
<Button icon={<Search size={16} />} onClick={() => { void loadData(); }}></Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-security-filter">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索签名、用途或状态" prefix={<Search size={16} />} value={keyword} />
<Button icon={<Search size={16} />} onClick={loadData}></Button>
</div>
<div className="surface">
<Table columns={columns} data={filtered} emptyText="暂无企业签名" rowKey="id" />
<div className="surface section-stack">
<Tabs
onChange={(value) => setActiveTab(value as 'sms' | 'mms')}
value={activeTab}
items={[
{ label: '短信签名', value: 'sms', content: smsSignatureContent },
{ label: '彩信签名', pending: true, value: 'mms', content: <div className="ui-table__empty"></div> },
]}
/>
</div>
{signatureModal ? (
<SignatureFormModal
applications={applications}
item={signatureModal === 'new' ? undefined : signatureModal}
onClose={() => setSignatureModal(null)}
onSubmit={(state) => { void saveSignature(state); }}
tenants={tenants}
/>
) : null}
{signatureReport ? <SignatureReportModal item={signatureReport} onClose={() => setSignatureReport(null)} /> : null}
{drainageModal ? (
<DrainageFormModal
item={drainageModal.item}
onClose={() => setDrainageModal(null)}
onSubmit={(item) => { void saveDrainage(drainageModal.signatureId, item); }}
/>
) : null}
{drainageReport ? <DrainageReportModal item={drainageReport} onClose={() => setDrainageReport(null)} /> : null}
{deleteTarget ? (
<ConfirmModal
message={`确认删除“${deleteTarget.name}”吗?删除后会写入真实后台。`}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => { void confirmDelete(); }}
/>
) : null}
</section>
);
}
+366 -28
View File
@@ -1,53 +1,391 @@
import { useEffect, useMemo, useState } from 'react';
import { Search } from 'lucide-react';
import { adminApi, type ClientSmsTemplate } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Table, Tag, type TableColumn } from '@/components/ui';
import { Edit3, Eye, Plus, Search, Trash2 } from 'lucide-react';
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, Tag, Textarea, type TableColumn } from '@/components/ui';
type TemplateFormState = {
tenantId: string;
applicationId: string;
signatureId: string;
name: string;
content: string;
category: string;
variables: TemplateVariable[];
};
type TemplateVariable = {
name: string;
example?: string;
required?: boolean;
};
const recommendedVariables = [
['验证码', 'code'],
['手机号', 'phone'],
['姓名', 'name'],
['日期', 'date'],
['金额', 'amount'],
['时间', 'time'],
['余额', 'balance'],
['地址', 'address'],
['天数', 'days'],
['快递单号', 'trackingNumber'],
['案件号', 'caseNumber'],
['链接', 'link'],
['站点', 'station'],
];
function extractVariables(content: string): TemplateVariable[] {
const matches = content.match(/\$\{[a-zA-Z0-9_]+\}/g) ?? [];
return [...new Set(matches)].map((match) => ({ name: match.slice(2, -1), required: true }));
}
function formatDate(value?: string) {
return value ? new Date(value).toLocaleString('zh-CN') : '-';
}
function statusTone(status: string) {
if (status === 'approved') return 'success';
if (status === 'rejected') return 'danger';
if (status === 'deleted') return 'neutral';
return 'info';
}
function billingUnits(content: string) {
if (!content) {
return 1;
}
return content.length <= 70 ? 1 : Math.ceil(content.length / 67);
}
function TemplateFormModal({
applications,
item,
onClose,
onSubmit,
signatures,
tenants,
}: {
applications: ClientSmsApplication[];
item?: ClientSmsTemplate;
onClose: () => void;
onSubmit: (state: TemplateFormState) => void;
signatures: ClientSmsSignature[];
tenants: TenantOption[];
}) {
const [customVariable, setCustomVariable] = useState('');
const [variablesOpen, setVariablesOpen] = useState(false);
const [form, setForm] = useState<TemplateFormState>({
tenantId: item?.tenantId ?? '',
applicationId: item?.applicationId ?? '',
signatureId: item?.signatureId ?? '',
name: item?.name ?? '',
content: item?.content ?? '',
category: item?.category ?? '行业通知',
variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [],
});
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted');
const tenantSignatures = signatures.filter((signature) => signature.tenantId === form.tenantId && signature.auditStatus !== 'deleted');
const currentVariables = form.variables.length ? form.variables : extractVariables(form.content);
function update<Key extends keyof TemplateFormState>(key: Key, value: TemplateFormState[Key]) {
setForm((current) => ({ ...current, [key]: value }));
}
function setContent(content: string) {
setForm((current) => ({ ...current, content, variables: extractVariables(content) }));
}
function insertVariable(name: string) {
const normalized = name.trim();
if (!normalized) {
return;
}
setContent(`${form.content}\${${normalized}}`);
}
function updateVariableExample(name: string, example: string) {
const variables = currentVariables.map((variable) => variable.name === name ? { ...variable, example } : variable);
update('variables', variables);
}
return (
<Modal
footer={(
<>
<Button onClick={onClose} variant="ghost"></Button>
<Button disabled={!form.tenantId || !form.applicationId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables: currentVariables })}></Button>
</>
)}
onClose={onClose}
open
size="xl"
title={<div className="template-modal-title"><h2>{item ? '编辑短信模板' : '添加短信模板'}</h2><p></p></div>}
>
<div className="template-form">
<Select
disabled={Boolean(item)}
label="所属企业"
onChange={(event) => update('tenantId', event.target.value)}
options={[
{ label: '请选择企业', value: '' },
...tenants.map((tenant) => ({ label: `${tenant.name}${tenant.code}`, value: tenant.id })),
]}
required
value={form.tenantId}
/>
<Select
label="所属应用"
onChange={(event) => update('applicationId', event.target.value)}
options={[
{ label: '请选择应用', value: '' },
...tenantApplications.map((application) => ({ label: application.name, value: application.id })),
]}
required
value={form.applicationId}
/>
<Select
label="签名"
onChange={(event) => update('signatureId', event.target.value)}
options={[
{ label: '不绑定签名', value: '' },
...tenantSignatures.map((signature) => ({ label: signature.name, value: signature.id })),
]}
value={form.signatureId}
/>
<Input label="模板名称" onChange={(event) => update('name', event.target.value)} placeholder="请输入模板名称" required value={form.name} />
<Input label="模板分类" onChange={(event) => update('category', event.target.value)} placeholder="行业通知/营销推广/验证码" value={form.category} />
<Textarea
label="模板内容"
onChange={(event) => setContent(event.target.value)}
placeholder="例如:尊敬的${name},您的验证码为${code}。"
required
rows={8}
value={form.content}
/>
<div className="template-form-meta">
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
<Plus size={16} /> {variablesOpen ? '收起变量面板' : '插入变量'}
</button>
<span>{form.content.length} {billingUnits(form.content)} </span>
</div>
{variablesOpen ? (
<div className="template-variable-panel">
<h3></h3>
<div className="template-variable-buttons">
{recommendedVariables.map(([label, value]) => (
<button key={value} onClick={() => insertVariable(value)} type="button">
{label} ({value})
</button>
))}
</div>
<h3></h3>
<div className="template-custom-variable">
<Input onChange={(event) => setCustomVariable(event.target.value)} placeholder="英文字符或数字" value={customVariable} />
<Button onClick={() => { insertVariable(customVariable); setCustomVariable(''); }}></Button>
</div>
</div>
) : null}
<div className="template-variable-panel">
<h3></h3>
{currentVariables.length ? currentVariables.map((variable) => (
<Input
key={variable.name}
label={`\${${variable.name}}`}
onChange={(event) => updateVariableExample(variable.name, event.target.value)}
placeholder="请输入变量示例值"
value={variable.example ?? ''}
/>
)) : <p className="muted"></p>}
</div>
</div>
</Modal>
);
}
function TemplatePreviewModal({ item, onClose }: { item: ClientSmsTemplate; onClose: () => void }) {
return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open title="模板预览">
<div className="detail-grid">
<div><span></span><strong>{item.tenant?.name ?? item.tenantId}</strong></div>
<div><span></span><strong>{item.application?.name ?? item.applicationId}</strong></div>
<div><span></span><strong>{item.signature?.name ?? '-'}</strong></div>
<div><span></span><strong>{billingUnits(item.content)} </strong></div>
<div className="detail-grid__wide"><span></span><strong>{item.content}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{item.variables?.map((variable) => `\${${variable.name}}`).join('、') || '-'}</strong></div>
</div>
</Modal>
);
}
function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
return (
<Modal
footer={(
<>
<Button onClick={onCancel} variant="ghost"></Button>
<Button onClick={onConfirm} variant="danger"></Button>
</>
)}
onClose={onCancel}
open
title="删除确认"
>
<p className="admin-confirm-text">{message}</p>
</Modal>
);
}
export function AdminEnterpriseTemplatesPage() {
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
const [keyword, setKeyword] = useState('');
const [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms');
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [deleteTarget, setDeleteTarget] = useState<ClientSmsTemplate | null>(null);
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [error, setError] = useState('');
const [signatureItems, setSignatureItems] = useState<ClientSmsSignature[]>([]);
const [templateModal, setTemplateModal] = useState<ClientSmsTemplate | 'new' | null>(null);
const [templatePreview, setTemplatePreview] = useState<ClientSmsTemplate | null>(null);
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
const [templateKeyword, setTemplateKeyword] = useState('');
const [tenants, setTenants] = useState<TenantOption[]>([]);
function loadData() {
adminApi.listEnterpriseTemplates({ keyword })
.then((items) => {
setTemplates(items);
setError('');
})
.catch((failure: Error) => setError(failure.message || '企业模板加载失败'));
async function loadData() {
try {
const [templateItems, tenantItems, applicationItems, signatureList] = await Promise.all([
adminApi.listEnterpriseTemplates({ keyword: [enterpriseKeyword, templateKeyword].filter(Boolean).join(' ') }),
adminApi.listTenants(),
adminApi.listEnterpriseApplications(),
adminApi.listEnterpriseSignatures(),
]);
setTemplates(templateItems);
setTenants(tenantItems.filter((tenant) => tenant.status !== 'deleted'));
setApplications(applicationItems);
setSignatureItems(signatureList);
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '企业模板加载失败');
}
}
useEffect(() => {
loadData();
void loadData();
}, []);
const filtered = useMemo(() => templates.filter((item) => !keyword || [item.name, item.content, item.auditStatus, item.application?.name].join(' ').includes(keyword)), [keyword, templates]);
const filteredTemplates = useMemo(() => templates.filter((item) => {
const enterprise = item.tenant?.name ?? item.tenantId;
const application = item.application?.name ?? '';
return (!enterpriseKeyword || enterprise.includes(enterpriseKeyword))
&& (!templateKeyword || item.name.includes(templateKeyword) || item.content.includes(templateKeyword) || application.includes(templateKeyword));
}), [enterpriseKeyword, templateKeyword, templates]);
async function saveTemplate(state: TemplateFormState) {
const existing = templateModal && templateModal !== 'new' ? templateModal : null;
try {
if (existing) {
await adminApi.updateEnterpriseTemplate(existing.id, {
applicationId: state.applicationId,
category: state.category,
content: state.content,
name: state.name,
signatureId: state.signatureId || null,
variables: state.variables,
});
} else {
await adminApi.createEnterpriseTemplate({
applicationId: state.applicationId,
category: state.category,
content: state.content,
name: state.name,
signatureId: state.signatureId || undefined,
tenantId: state.tenantId,
variables: state.variables,
});
}
setTemplateModal(null);
await loadData();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '企业模板保存失败');
}
}
async function confirmDelete() {
if (!deleteTarget) {
return;
}
await adminApi.changeEnterpriseTemplateStatus(deleteTarget.id, 'deleted', '运营端删除模板');
setDeleteTarget(null);
await loadData();
}
const columns: Array<TableColumn<ClientSmsTemplate>> = [
{ key: 'name', title: '模板名称', render: (record) => <strong>{record.name}</strong> },
{ key: 'tenant', title: '企业', render: (record) => record.tenantId },
{ key: 'application', title: '应用', render: (record) => record.application?.name ?? record.applicationId },
{ key: 'content', title: '内容', render: (record) => <span className="table-long-text">{record.content}</span> },
{ key: 'status', title: '审核状态', render: (record) => <Tag tone={record.auditStatus === 'approved' ? 'success' : record.auditStatus === 'rejected' ? 'danger' : 'info'}>{record.auditStatus}</Tag> },
{ key: 'updatedAt', title: '更新时间', render: (record) => record.updatedAt },
{ key: 'name', title: '模板名称', width: '180px', render: (record) => <strong>{record.name}</strong> },
{ key: 'tenant', title: '企业', width: '240px', render: (record) => record.tenant?.name ?? record.tenantId },
{ key: 'application', title: '应用', width: '180px', render: (record) => record.application?.name ?? record.applicationId },
{ key: 'signature', title: '签名', width: '160px', render: (record) => record.signature?.name ?? '-' },
{ key: 'content', title: '模板内容', width: '420px', render: (record) => <span className="table-long-text table-long-text--sms-template">{record.content}</span> },
{ key: 'variables', title: '变量', width: '120px', render: (record) => `${record.variables?.length ?? 0}` },
{ key: 'status', title: '审核状态', width: '130px', render: (record) => <Tag tone={statusTone(record.auditStatus)}>{record.auditStatus}</Tag> },
{ key: 'updatedAt', title: '更新时间', width: '170px', render: (record) => formatDate(record.updatedAt) },
{
key: 'actions',
title: '操作',
align: 'right',
width: '220px',
render: (record) => (
<div className="table-actions">
<Button icon={<Eye size={15} />} onClick={() => setTemplatePreview(record)} size="sm" variant="ghost"></Button>
<Button icon={<Edit3 size={15} />} onClick={() => setTemplateModal(record)} size="sm" variant="ghost"></Button>
<Button icon={<Trash2 size={15} />} onClick={() => setDeleteTarget(record)} size="sm" variant="danger"></Button>
</div>
),
},
];
return (
<section className="page-stack">
<section className="page-stack admin-customer-split-page">
<div className="page-heading">
<div>
<Breadcrumb items={['企业配置', '企业模板']} />
<h1></h1>
<Breadcrumb items={['客户管理', '企业模板管理']} />
<h1></h1>
</div>
<Button icon={<Plus size={16} />} onClick={() => setTemplateModal(activeTab === 'sms' ? 'new' : null)}></Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-security-filter">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索模板、应用、内容或状态" prefix={<Search size={16} />} value={keyword} />
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<div className="surface admin-split-filter">
<Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} />
<Input label="模板/应用/内容" onChange={(event) => setTemplateKeyword(event.target.value)} placeholder="请输入模板、应用或内容" prefix={<Search size={16} />} value={templateKeyword} />
<Button onClick={() => { setEnterpriseKeyword(''); setTemplateKeyword(''); void loadData(); }} variant="ghost"></Button>
<Button icon={<Search size={16} />} onClick={() => { void loadData(); }}></Button>
</div>
<div className="surface">
<Table columns={columns} data={filtered} emptyText="暂无企业模板" rowKey="id" />
<div className="surface section-stack">
<Tabs
onChange={(value) => setActiveTab(value as 'sms' | 'mms')}
value={activeTab}
items={[
{ label: '短信模板', value: 'sms', content: <Table columns={columns} data={filteredTemplates} emptyText="暂无企业模板" rowKey="id" /> },
{ label: '彩信模板', pending: true, value: 'mms', content: <div className="ui-table__empty"></div> },
]}
/>
</div>
{templateModal ? (
<TemplateFormModal
applications={applications}
item={templateModal === 'new' ? undefined : templateModal}
onClose={() => setTemplateModal(null)}
onSubmit={(state) => { void saveTemplate(state); }}
signatures={signatureItems}
tenants={tenants}
/>
) : null}
{templatePreview ? <TemplatePreviewModal item={templatePreview} onClose={() => setTemplatePreview(null)} /> : null}
{deleteTarget ? (
<ConfirmModal
message={`确认删除模板“${deleteTarget.name}”吗?删除后会写入真实后台。`}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => { void confirmDelete(); }}
/>
) : null}
</section>
);
}
+1 -1
View File
@@ -42,7 +42,7 @@ export function AdminGlobalBlacklistPage() {
{
key: 'actions',
title: '操作',
width: '110px',
width: '130px',
align: 'right',
render: (record) => (
<Button icon={<Trash2 size={15} />} onClick={() => adminApi.deleteGlobalBlacklist(record.id).then(loadData).catch((failure: Error) => setError(failure.message))} size="sm" variant="danger">
@@ -1,157 +0,0 @@
import { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, RefreshCw } from 'lucide-react';
import { Breadcrumb, Button, Input, Select } from '@/components/ui';
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 enterpriseName = 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>
<Breadcrumb items={[isEdit ? '编辑彩信应用' : '添加彩信应用']} />
<p>{enterpriseName} </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
@@ -1,197 +0,0 @@
import { useMemo, useState } from 'react';
import { Ban, Pencil, Plus, Power, Search, Trash2 } from 'lucide-react';
import { Breadcrumb, 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><Breadcrumb items={['彩信通道管理']} /></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>
);
}
-332
View File
@@ -1,332 +0,0 @@
import { useMemo, useState } from 'react';
import { Download, Eye, Search, Smartphone } from 'lucide-react';
import {
Breadcrumb,
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>
<Breadcrumb items={['数据详单', '彩信记录']} />
<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>
);
}
-482
View File
@@ -1,482 +0,0 @@
import { useMemo, useState } from 'react';
import { BarChart3, CalendarClock, Eye, FileImage, ImageIcon, Search, TrendingUp } from 'lucide-react';
import {
Breadcrumb,
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>
<Breadcrumb items={['发送任务', '彩信任务进度']} />
<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>
);
}
+1 -1
View File
@@ -140,7 +140,7 @@ export function AdminRechargeRecordsPage() {
<th style={{ width: '130px' }}></th>
<th style={{ width: '140px' }}></th>
<th style={{ width: '120px' }}></th>
<th style={{ width: '110px' }}></th>
<th style={{ width: '140px' }}></th>
<th style={{ width: '300px' }}></th>
</tr>
</thead>
+26 -10
View File
@@ -13,12 +13,12 @@ const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'su
failed: { label: '有失败', tone: 'danger' },
};
function ReceiptImportModal({ onClose, onSubmit }: { onClose: () => void; onSubmit: (fileName: string, remark: string) => void }) {
const [fileName, setFileName] = useState('');
function ReceiptImportModal({ onClose, onSubmit }: { onClose: () => void; onSubmit: (file: File, remark: string) => void }) {
const [file, setFile] = useState<File | null>(null);
const [remark, setRemark] = useState('');
return (
<Modal
footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!fileName} onClick={() => onSubmit(fileName, remark)}></Button></>}
footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!file} onClick={() => file && onSubmit(file, remark)}></Button></>}
onClose={onClose}
open
size="xl"
@@ -27,9 +27,14 @@ function ReceiptImportModal({ onClose, onSubmit }: { onClose: () => void; onSubm
<div className="report-receipt-modal">
<label className="report-upload-drop">
<FileUp size={38} />
<strong>{fileName || '选择回执文件'}</strong>
<span> ExcelCSVPDF </span>
<input onChange={(event) => setFileName(event.target.files?.[0]?.name ?? '')} style={{ display: 'none' }} type="file" />
<strong>{file?.name || '选择回执文件'}</strong>
<span> CSVTSVTXT /</span>
<input
accept=".csv,.tsv,.txt,text/csv,text/plain"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
style={{ display: 'none' }}
type="file"
/>
</label>
<Textarea label="导入备注" onChange={(event) => setRemark(event.target.value)} placeholder="记录回执来源、运营商工单号或人工处理说明" rows={4} value={remark} />
</div>
@@ -87,20 +92,31 @@ export function AdminReportTasksPage() {
.catch((failure: Error) => setError(failure.message || '报备任务导出失败'));
}
function importReceipt(fileName: string, remark: string) {
function importReceipt(file: File, remark: string) {
if (!receiptTask) return;
adminApi.importReportReceipt(receiptTask.id, { fileName, reason: remark, statusAfter: 'partial' })
const delimiter = file.name.toLowerCase().endsWith('.tsv') ? '\t' : ',';
Promise.all([
adminApi.uploadFileObject(file, { purpose: 'report_receipt', prefix: `report-receipts/${receiptTask.id}` }),
file.text(),
])
.then(([fileObject, fileContent]) => adminApi.importReportReceipt(receiptTask.id, {
fileObjectId: fileObject.id,
fileName: file.name,
fileContent,
delimiter,
reason: remark,
}))
.then(() => {
setReceiptTask(null);
loadData();
})
.catch((failure: Error) => setError(failure.message));
.catch((failure: Error) => setError(failure.message || '报备回执导入失败'));
}
const columns: Array<TableColumn<ReportTask>> = [
{ key: 'id', title: '任务编号', width: '190px', render: (record) => <strong className="admin-task-id">{record.id}</strong> },
{ key: 'scope', title: '通道/签名', width: '280px', render: (record) => <div className="admin-task-enterprise"><strong>{record.channel?.name ?? record.channelId}</strong><span>{record.signature?.name ?? record.signatureId}</span></div> },
{ key: 'status', title: '状态', width: '110px', render: (record) => <Tag tone={(statusMeta[record.status] ?? { tone: 'info' as const }).tone}>{(statusMeta[record.status] ?? { label: record.status }).label}</Tag> },
{ key: 'status', title: '状态', width: '130px', render: (record) => <Tag tone={(statusMeta[record.status] ?? { tone: 'info' as const }).tone}>{(statusMeta[record.status] ?? { label: record.status }).label}</Tag> },
{ key: 'time', title: '创建时间', width: '190px', render: (record) => record.createdAt ?? '-' },
{
key: 'actions',
+1 -1
View File
@@ -63,7 +63,7 @@ export function AdminSensitiveWordsPage() {
{
key: 'actions',
title: '操作',
width: '110px',
width: '130px',
align: 'right',
render: (record) => (
<Button icon={<Trash2 size={15} />} onClick={() => adminApi.deleteSensitiveWord(record.id).then(loadData).catch((failure: Error) => setError(failure.message))} size="sm" variant="danger">
-36
View File
@@ -1,36 +0,0 @@
import { Save } from 'lucide-react';
import { Breadcrumb, Button, Input, Select } from '@/components/ui';
export function AdminSettingsPage() {
return (
<section className="page-stack">
<div className="page-heading">
<div>
<Breadcrumb items={['系统配置']} />
</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
@@ -1,177 +0,0 @@
import { useMemo, useState } from 'react';
import { Eye, Search } from 'lucide-react';
import { Breadcrumb, 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">
<Breadcrumb items={['审核中心', '彩信模板审核']} />
<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>
);
}
+145 -35
View File
@@ -1,8 +1,16 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import { adminApi, type ChannelGroup } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Select } from '@/components/ui';
import { ArrowLeft, RadioTower } from 'lucide-react';
import { adminApi, type ChannelGroup, type DictionaryItem, type EnterpriseApplication } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui';
type Carrier = 'mobile' | 'unicom' | 'telecom';
const carrierMeta: Record<Carrier, { label: string; description: string }> = {
mobile: { label: '移动', description: '移动号码只会进入移动通道组' },
unicom: { label: '联通', description: '联通号码只会进入联通通道组' },
telecom: { label: '电信', description: '电信号码只会进入电信通道组' },
};
export function AdminSmsApplicationFormPage() {
const navigate = useNavigate();
@@ -20,70 +28,128 @@ export function AdminSmsApplicationFormPage() {
const [unicomGroupId, setUnicomGroupId] = useState('');
const [telecomGroupId, setTelecomGroupId] = useState('');
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
adminApi.listChannelGroups()
.then((items) => setGroups(items.filter((item) => item.status !== 'disabled' && item.status !== 'deleted')))
.catch((failure: Error) => setError(failure.message || '通道组加载失败'));
}, []);
let cancelled = false;
async function loadForm() {
try {
const [groupItems, application, routeRules] = await Promise.all([
adminApi.listChannelGroups(),
isEdit && appId ? adminApi.getEnterpriseApplication(appId) : Promise.resolve<EnterpriseApplication | null>(null),
isEdit ? adminApi.listChannelRouteRules() : Promise.resolve<DictionaryItem[]>([]),
]);
if (cancelled) {
return;
}
setGroups(groupItems.filter((item) => item.status !== 'disabled' && item.status !== 'deleted'));
if (application) {
if (enterpriseId && application.tenantId !== enterpriseId) {
setError('应用不属于当前企业,已停止加载');
return;
}
hydrateApplication(application, routeRules);
}
} catch (failure) {
if (!cancelled) {
setError(failure instanceof Error ? failure.message : '短信应用加载失败');
}
}
}
void loadForm();
return () => {
cancelled = true;
};
}, [appId, enterpriseId, isEdit]);
function goBack() {
navigate('/admin/enterprise-applications');
}
function submit() {
function hydrateApplication(application: EnterpriseApplication, routeRules: DictionaryItem[]) {
setAppName(application.name);
setScene(application.scene ?? '');
setDailyLimit(application.dailyLimit ? String(application.dailyLimit) : '');
setCustomerUnitPrice(((application.customerUnitPrice ?? 0) / 100).toFixed(4));
setPhoneDailyLimit(application.maxPhonesPerTask ? String(application.maxPhonesPerTask) : '');
setMismatchPolicy(application.templateMismatchMode ?? 'reject');
setIpAddress(application.ipAllowlist?.map((item) => item.ipCidr).join('\n') ?? '');
const activeRules = routeRules.filter((rule) => (
rule.applicationId === application.id
&& rule.status !== 'deleted'
&& !rule.province
&& !rule.channelId
));
setMobileGroupId(getRouteGroupId(activeRules, 'mobile'));
setUnicomGroupId(getRouteGroupId(activeRules, 'unicom'));
setTelecomGroupId(getRouteGroupId(activeRules, 'telecom'));
}
async function submit() {
if (!enterpriseId) {
setError('缺少企业 ID');
return;
}
if (isEdit) {
setError('短信应用编辑接口待补,当前不做本地模拟保存');
return;
}
const selectedGroups = [
{ carrier: 'mobile', groupId: mobileGroupId },
{ carrier: 'unicom', groupId: unicomGroupId },
{ carrier: 'telecom', groupId: telecomGroupId },
{ carrier: 'mobile' as Carrier, groupId: mobileGroupId },
{ carrier: 'unicom' as Carrier, groupId: unicomGroupId },
{ carrier: 'telecom' as Carrier, groupId: telecomGroupId },
].filter((item) => item.groupId);
if (selectedGroups.length === 0) {
setError('请至少配置一个运营商通道组');
return;
}
adminApi.createEnterpriseApplication({
tenantId: enterpriseId,
const payload = {
name: appName,
scene,
dailyLimit: Number(dailyLimit) || undefined,
customerUnitPrice: Math.round(Number(customerUnitPrice || 0) * 100),
maxPhonesPerTask: Number(phoneDailyLimit) || undefined,
templateMismatchMode: mismatchPolicy,
ipAllowlist: ipAddress ? [ipAddress] : [],
})
.then(async (application) => {
await Promise.all(selectedGroups.map((item, index) => adminApi.createChannelRouteRule({
tenantId: enterpriseId,
applicationId: application.id,
groupId: item.groupId,
ipAllowlist: parseIpAllowlist(ipAddress),
};
setSaving(true);
setError('');
try {
const application = isEdit && appId
? await adminApi.updateEnterpriseApplication(appId, payload)
: await adminApi.createEnterpriseApplication({ tenantId: enterpriseId, ...payload });
await adminApi.replaceApplicationRouteRules(application.id, {
routes: selectedGroups.map((item, index) => ({
carrier: item.carrier,
groupId: item.groupId,
priority: (index + 1) * 10,
status: 'active',
})));
goBack();
})
.catch((failure: Error) => setError(failure.message || '短信应用保存失败'));
})),
});
goBack();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '短信应用保存失败');
} finally {
setSaving(false);
}
}
const groupOptionsByCarrier = (carrier: ChannelGroup['carrier']) => [
{ label: '不配置', value: '' },
...groups.filter((group) => group.carrier === carrier).map((group) => ({ label: group.name, value: group.id })),
];
const selectedGroupCount = [mobileGroupId, unicomGroupId, telecomGroupId].filter(Boolean).length;
const routeCards: Array<{ carrier: Carrier; groupId: string; onChange: (value: string) => void }> = [
{ carrier: 'mobile', groupId: mobileGroupId, onChange: setMobileGroupId },
{ carrier: 'unicom', groupId: unicomGroupId, onChange: setUnicomGroupId },
{ carrier: 'telecom', groupId: telecomGroupId, onChange: setTelecomGroupId },
];
return (
<section className="page-stack admin-app-form-page">
<div className="page-heading">
<div>
<Breadcrumb items={[isEdit ? '编辑短信应用' : '添加短信应用']} />
<p></p>
<p></p>
</div>
<Button icon={<ArrowLeft size={16} />} onClick={goBack} variant="ghost"></Button>
</div>
@@ -91,7 +157,7 @@ export function AdminSmsApplicationFormPage() {
<div className="surface admin-app-form-card">
<section className="ui-detail-section">
<div className="ui-detail-section__header"><h3></h3></div>
<div className="ui-detail-section__header"><h3></h3><p></p></div>
<div className="admin-app-form-grid">
<Input label="应用名称" onChange={(event) => setAppName(event.target.value)} placeholder="请输入应用名称" required value={appName} />
<Input label="应用场景" onChange={(event) => setScene(event.target.value)} placeholder="行业通知/营销推广/验证码" value={scene} />
@@ -109,18 +175,62 @@ export function AdminSmsApplicationFormPage() {
required
value={mismatchPolicy}
/>
<Input label="IP 白名单" onChange={(event) => setIpAddress(event.target.value)} placeholder="例如 192.168.1.100/32" value={ipAddress} />
<Select label="移动通道组" onChange={(event) => setMobileGroupId(event.target.value)} options={groupOptionsByCarrier('mobile')} value={mobileGroupId} />
<Select label="联通通道组" onChange={(event) => setUnicomGroupId(event.target.value)} options={groupOptionsByCarrier('unicom')} value={unicomGroupId} />
<Select label="电信通道组" onChange={(event) => setTelecomGroupId(event.target.value)} options={groupOptionsByCarrier('telecom')} value={telecomGroupId} />
<Input label="IP 白名单" onChange={(event) => setIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可用逗号、空格或换行分隔" value={ipAddress} />
</div>
</section>
<section className="ui-detail-section">
<div className="ui-detail-section__header">
<div>
<h3></h3>
<p></p>
</div>
<Tag tone={selectedGroupCount > 0 ? 'success' : 'warning'}>{selectedGroupCount}/3 </Tag>
</div>
<div className="admin-app-route-grid">
{routeCards.map((card) => {
const available = groups.filter((group) => group.carrier === card.carrier);
const meta = carrierMeta[card.carrier];
return (
<div className={['admin-app-route-card', card.groupId ? 'is-selected' : ''].filter(Boolean).join(' ')} key={card.carrier}>
<header>
<span><RadioTower size={18} /></span>
<div>
<strong>{meta.label}</strong>
<small>{meta.description}</small>
</div>
<Tag tone={card.groupId ? 'success' : available.length ? 'neutral' : 'warning'}>{card.groupId ? '已选择' : `${available.length} 个可选`}</Tag>
</header>
<Select
label={`${meta.label}通道组`}
onChange={(event) => card.onChange(event.target.value)}
options={groupOptionsByCarrier(card.carrier)}
value={card.groupId}
/>
{!available.length ? <p>{meta.label}</p> : null}
</div>
);
})}
</div>
</section>
<div className="enterprise-form-footer">
<Button disabled={!appName || isEdit} onClick={submit}>{isEdit ? '编辑待补接口' : '创建应用'}</Button>
<Button disabled={!appName || selectedGroupCount === 0 || saving} onClick={() => { void submit(); }}>{saving ? '保存中...' : isEdit ? '保存应用' : '创建应用'}</Button>
<Button onClick={goBack} variant="ghost"></Button>
</div>
</div>
</section>
);
}
function getRouteGroupId(routeRules: DictionaryItem[], carrier: Carrier) {
const rule = routeRules.find((item) => item.carrier === carrier);
return typeof rule?.groupId === 'string' ? rule.groupId : '';
}
function parseIpAllowlist(value: string) {
return value
.split(/[\s,]+/)
.map((item) => item.trim())
.filter(Boolean);
}
+2 -2
View File
@@ -70,13 +70,13 @@ export function AdminSmsAuditPage() {
const columns: Array<TableColumn<RiskReviewTask>> = [
{ key: 'taskNo', title: '任务编号', width: '180px', render: (record) => <strong>{record.taskNo}</strong> },
{ key: 'content', title: '短信内容', render: (record) => <span className="table-long-text">{record.content}</span> },
{ key: 'phoneTotal', title: '号码数', width: '110px', render: (record) => record.phoneTotal.toLocaleString('zh-CN') },
{ key: 'phoneTotal', title: '号码数', width: '130px', render: (record) => record.phoneTotal.toLocaleString('zh-CN') },
{ key: 'createdAt', title: '提交时间', width: '190px', render: (record) => record.createdAt },
{ key: 'reason', title: '审核原因', render: (record) => record.reviewReason ?? record.rejectReason ?? record.riskHits?.map((item) => item.reason).join('') ?? '-' },
{
key: 'status',
title: '状态',
width: '110px',
width: '130px',
render: (record) => <Tag tone={statusTone[record.status] ?? 'warning'}>{statusLabel[record.status] ?? record.status}</Tag>,
},
{
+2 -2
View File
@@ -60,8 +60,8 @@ export function AdminSmsRecordsPage() {
{ key: 'content', title: '短信内容', render: (record) => <span className="table-long-text">{record.content}</span> },
{ key: 'billing', title: '计费', width: '120px', render: (record) => `${record.billingUnits} 条 / ¥${(record.amountCents / 100).toFixed(2)}` },
{ key: 'queuedAt', title: '提交时间', width: '190px', render: (record) => record.queuedAt },
{ key: 'status', title: '状态', width: '110px', render: (record) => <Tag tone={statusToneMap[record.status] ?? 'info'}>{statusLabelMap[record.status] ?? record.status}</Tag> },
{ key: 'actions', title: '操作', width: '100px', align: 'right', render: (record) => <Button onClick={() => setSelectedRecord(record)} size="sm" variant="ghost"></Button> },
{ key: 'status', title: '状态', width: '130px', render: (record) => <Tag tone={statusToneMap[record.status] ?? 'info'}>{statusLabelMap[record.status] ?? record.status}</Tag> },
{ key: 'actions', title: '操作', width: '120px', align: 'right', render: (record) => <Button onClick={() => setSelectedRecord(record)} size="sm" variant="ghost"></Button> },
];
function resetFilters() {
+163 -219
View File
@@ -1,5 +1,6 @@
import { Fragment, useMemo, useState } from 'react';
import { Fragment, useEffect, useMemo, useState } from 'react';
import { BarChart3, CalendarClock, Eye, MapPin, Search, Send, Smartphone, StopCircle, TrendingUp } from 'lucide-react';
import { adminApi, type SmsBatchTask, type SmsMessageRecord } from '@/api/adminApi';
import {
Breadcrumb,
Button,
@@ -24,15 +25,15 @@ type CarrierStat = {
tone: 'mobile' | 'unicom' | 'telecom';
};
type CityStat = {
city: string;
province: string;
type RegionStat = {
region: string;
total: number;
success: number;
};
type SmsTask = {
id: string;
backendId: string;
enterprise: string;
application: string;
submittedAt: string;
@@ -41,14 +42,16 @@ type SmsTask = {
wordCount: number;
billingCount: number;
sendType: SendType;
scheduledAt?: string;
scheduledAt?: string | null;
submittedCount: number;
submittedSuccess: number;
sentCount: number;
successCount: number;
failedCount: number;
status: TaskStatus;
rawStatus: string;
carriers: CarrierStat[];
cities: CityStat[];
regions: RegionStat[];
};
const statusLabels: Record<TaskStatus, string> = {
@@ -70,188 +73,102 @@ const sendTypeLabels: Record<SendType, string> = {
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 },
],
},
];
const carrierLabels: Record<string, { label: string; tone: CarrierStat['tone'] }> = {
mobile: { label: '中国移动', tone: 'mobile' },
unicom: { label: '中国联通', tone: 'unicom' },
telecom: { label: '中国电信', tone: 'telecom' },
all: { label: '三网通道', tone: 'mobile' },
};
function formatNumber(value: number) {
return value.toLocaleString('zh-CN');
}
function formatTime(value?: string | null) {
return value ? `${value.slice(0, 10)} ${value.slice(11, 16)}` : '-';
}
function normalizeTaskStatus(status: string): TaskStatus {
if (['finished', 'completed', 'done'].includes(status)) return 'completed';
if (['canceled', 'cancelled', 'terminated'].includes(status)) return 'terminated';
if (['failed', 'rejected'].includes(status)) return 'failed';
return 'sending';
}
function countMessages(messages: SmsMessageRecord[] | undefined, statuses: string[]) {
return (messages ?? []).filter((message) => statuses.includes(message.status)).length;
}
function buildCarrierStats(messages: SmsMessageRecord[] | undefined): CarrierStat[] {
const stats = new Map<string, CarrierStat>();
(messages ?? []).forEach((message) => {
const carrier = message.channel?.carrier ?? 'unknown';
const meta = carrierLabels[carrier] ?? { label: carrier || '未知通道', tone: 'mobile' as const };
const current = stats.get(carrier) ?? { name: meta.label, total: 0, success: 0, tone: meta.tone };
current.total += 1;
if (message.status === 'delivered') current.success += 1;
stats.set(carrier, current);
});
return Array.from(stats.values());
}
function buildRegionStats(messages: SmsMessageRecord[] | undefined): RegionStat[] {
const stats = new Map<string, RegionStat>();
(messages ?? []).forEach((message) => {
const region = message.channel?.sendRegion ?? '未分配通道';
const current = stats.get(region) ?? { region, total: 0, success: 0 };
current.total += 1;
if (message.status === 'delivered') current.success += 1;
stats.set(region, current);
});
return Array.from(stats.values()).sort((a, b) => b.total - a.total);
}
function mapTask(task: SmsBatchTask): SmsTask {
const messages = task.messages ?? [];
const submittedStatuses = ['submitted', 'delivered', 'failed', 'unknown', 'timeout', 'submit_failed'];
const failedStatuses = ['failed', 'submit_failed', 'rejected', 'timeout'];
const submittedCount = task.submittedTotal ?? countMessages(messages, submittedStatuses);
const successCount = task.successTotal ?? countMessages(messages, ['delivered']);
const failedCount = task.failedTotal ?? countMessages(messages, failedStatuses);
const processedCount = submittedCount + (task.unknownTotal ?? 0) + (task.timeoutTotal ?? 0);
const billingCount = messages.reduce((sum, message) => sum + (message.billingUnits ?? 0), 0)
|| task.phoneTotal * (task.template?.billingUnits ?? Math.max(1, Math.ceil([...task.content].length / 67)));
return {
id: task.taskNo || task.id,
backendId: task.id,
enterprise: task.tenant?.name ?? task.tenantId,
application: task.application?.name ?? task.applicationId ?? '未绑定应用',
submittedAt: task.createdAt,
templateContent: task.content,
phoneCount: task.phoneTotal,
wordCount: [...task.content].length,
billingCount,
sendType: task.scheduledAt ? 'scheduled' : 'immediate',
scheduledAt: task.scheduledAt,
submittedCount,
submittedSuccess: submittedCount,
sentCount: Math.max(processedCount, successCount + failedCount),
successCount,
failedCount,
status: normalizeTaskStatus(task.status),
rawStatus: task.status,
carriers: buildCarrierStats(messages),
regions: buildRegionStats(messages),
};
}
function getProgress(task: SmsTask) {
return Math.round((task.sentCount / task.phoneCount) * 100);
return task.phoneCount > 0 ? Math.min(100, Math.round((task.sentCount / task.phoneCount) * 100)) : 0;
}
function getSuccessRate(task: SmsTask) {
return (task.successCount / task.submittedCount) * 100;
return task.submittedCount > 0 ? (task.successCount / task.submittedCount) * 100 : 0;
}
function getCityRate(city: CityStat) {
return (city.success / city.total) * 100;
function getRegionRate(region: RegionStat) {
return region.total > 0 ? (region.success / region.total) * 100 : 0;
}
function splitSignature(content: string) {
@@ -286,6 +203,7 @@ function MetricCard({ label, value, tone }: { label: string; value: string; tone
function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: () => void }) {
const progress = getProgress(task);
const successRate = getSuccessRate(task);
const perPhoneBillingUnits = Math.max(1, Math.ceil(task.wordCount / 67));
return (
<Modal
@@ -314,7 +232,7 @@ function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: () => void
</div>
<div>
<dt></dt>
<dd>{task.submittedAt}</dd>
<dd>{formatTime(task.submittedAt)}</dd>
</div>
<div>
<dt></dt>
@@ -327,7 +245,7 @@ function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: () => void
<h3><TrendingUp size={18} /></h3>
<div className="admin-task-progress-card">
<div>
<span> {formatNumber(task.sentCount)} / {formatNumber(task.phoneCount)}</span>
<span> {formatNumber(task.sentCount)} / {formatNumber(task.phoneCount)}</span>
<strong>{progress}%</strong>
</div>
<div className="batch-progress__track">
@@ -347,52 +265,56 @@ function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: () => void
<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>{task.wordCount} <b>·</b> {perPhoneBillingUnits} /</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>
<span> 67 1 {perPhoneBillingUnits} {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>
<h3><Smartphone size={18} /></h3>
{task.carriers.length === 0 ? (
<div className="admin-uplink-empty-match"></div>
) : (
<div className="admin-carrier-grid">
{task.carriers.map((carrier) => {
const rate = carrier.total > 0 ? (carrier.success / carrier.total) * 100 : 0;
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>
<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: 'region', title: '发送地区', render: (record: RegionStat) => <strong>{record.region}</strong> },
{ key: 'total', title: '总数', align: 'right', render: (record: RegionStat) => formatNumber(record.total) },
{ key: 'success', title: '成功', align: 'right', render: (record: RegionStat) => <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>,
render: (record: RegionStat) => <Tag tone={getRegionRate(record) >= 95 ? 'success' : 'info'}>{getRegionRate(record).toFixed(1)}%</Tag>,
},
]}
data={task.cities}
rowKey={(record) => record.city}
data={task.regions}
emptyText="暂无已分配通道记录"
rowKey={(record) => record.region}
/>
</section>
</div>
@@ -401,7 +323,7 @@ function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: () => void
}
export function AdminSmsTaskProgressPage() {
const [tasks, setTasks] = useState(taskData);
const [tasks, setTasks] = useState<SmsTask[]>([]);
const [keyword, setKeyword] = useState('');
const [enterprise, setEnterprise] = useState('all');
const [application, setApplication] = useState('all');
@@ -409,6 +331,23 @@ export function AdminSmsTaskProgressPage() {
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<SmsTask | null>(null);
const [terminateTarget, setTerminateTarget] = useState<SmsTask | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
function loadTasks() {
setLoading(true);
adminApi.listAdminBatchTasks()
.then((items) => {
setTasks(items.map(mapTask));
setError('');
})
.catch((reason: Error) => setError(reason.message || '短信任务进度加载失败'))
.finally(() => setLoading(false));
}
useEffect(() => {
loadTasks();
}, []);
const enterpriseOptions = useMemo(() => {
const names = Array.from(new Set(tasks.map((item) => item.enterprise)));
@@ -441,10 +380,13 @@ export function AdminSmsTaskProgressPage() {
}
function terminateTask(taskId: string) {
setTasks((current) => current.map((task) => (
task.id === taskId ? { ...task, status: 'terminated' } : task
)));
setTerminateTarget(null);
adminApi.terminateAdminBatchTask(taskId)
.then(() => {
setTerminateTarget(null);
setSelectedTask(null);
loadTasks();
})
.catch((reason: Error) => setError(reason.message || '任务终止失败'));
}
return (
@@ -470,11 +412,13 @@ export function AdminSmsTaskProgressPage() {
<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 icon={<Search size={16} />} onClick={loadTasks}></Button>
<Button onClick={resetFilters} variant="ghost"></Button>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-task-table-card">
<div className="ui-table-wrap">
<table className="ui-table batch-table admin-task-table">
@@ -486,22 +430,22 @@ export function AdminSmsTaskProgressPage() {
<th style={{ width: '130px' }}>/</th>
<th style={{ width: '150px' }}></th>
<th style={{ width: '190px' }}></th>
<th style={{ width: '100px' }}></th>
<th style={{ width: '130px' }}></th>
<th style={{ textAlign: 'right', width: '170px' }}></th>
</tr>
</thead>
<tbody>
{filteredTasks.length === 0 ? (
<tr>
<td className="ui-table__empty" colSpan={8}></td>
</tr>
{loading ? (
<tr><td className="ui-table__empty" colSpan={8}>...</td></tr>
) : 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}>
<Fragment key={record.backendId}>
<tr
className={['batch-main-row', rowClass].filter(Boolean).join(' ')}
onMouseEnter={() => setHoveredTaskId(record.id)}
@@ -583,7 +527,7 @@ export function AdminSmsTaskProgressPage() {
footer={(
<>
<Button onClick={() => setTerminateTarget(null)} variant="ghost"></Button>
<Button onClick={() => terminateTask(terminateTarget.id)} variant="danger"></Button>
<Button onClick={() => terminateTask(terminateTarget.backendId)} variant="danger"></Button>
</>
)}
onClose={() => setTerminateTarget(null)}
+107 -72
View File
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Search, Smartphone } from 'lucide-react';
import { adminApi, type SmsMessageRecord, type SmsUplinkMessage } from '@/api/adminApi';
import {
Breadcrumb,
Button,
@@ -12,50 +13,27 @@ import {
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 getDate(value?: string | null) {
return value ? value.slice(0, 10) : '';
}
function UplinkDetailModal({ message, onClose }: { message: UplinkMessage; onClose: () => void }) {
function getTime(value?: string | null) {
return value ? `${value.slice(0, 10)} ${value.slice(11, 19)}` : '-';
}
function UplinkDetailModal({
detailError,
matchedRecords,
matching,
message,
onClose,
}: {
detailError: string;
matchedRecords: SmsMessageRecord[];
matching: boolean;
message: SmsUplinkMessage;
onClose: () => void;
}) {
return (
<Modal
footer={<Button onClick={onClose} variant="ghost"></Button>}
@@ -70,19 +48,27 @@ function UplinkDetailModal({ message, onClose }: { message: UplinkMessage; onClo
<div className="admin-uplink-info-grid">
<div>
<span></span>
<strong>{message.phone || '-'}</strong>
<strong>{message.phoneNumber || '-'}</strong>
</div>
<div>
<span></span>
<strong>{message.receivedAt}</strong>
<strong>{getTime(message.receivedAt)}</strong>
</div>
<div>
<span></span>
<strong>{message.tenant?.name ?? message.tenantId ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{message.channel || '-'}</strong>
<strong>{message.channel?.name ?? message.channelId ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{message.accessNo || '-'}</strong>
<strong>{message.destId || '-'}</strong>
</div>
<div>
<span>ID</span>
<strong>{message.messageId || '-'}</strong>
</div>
<div className="admin-uplink-info-grid__full">
<span></span>
@@ -93,36 +79,38 @@ function UplinkDetailModal({ message, onClose }: { message: UplinkMessage; onClo
<section className="admin-uplink-match-section">
<h3></h3>
<p>7</p>
{message.matchedRecord ? (
<article className="admin-uplink-match-card">
{matching ? <p>...</p> : null}
{detailError ? <p className="form-error">{detailError}</p> : null}
{!matching && !message.messageId ? <div className="admin-uplink-empty-match">ID</div> : null}
{!matching && message.messageId && matchedRecords.length === 0 && !detailError ? (
<div className="admin-uplink-empty-match"></div>
) : null}
{matchedRecords.map((record) => (
<article className="admin-uplink-match-card" key={record.id}>
<div className="admin-uplink-match-grid">
<div>
<span></span>
<strong>{message.matchedRecord.sentAt}</strong>
<strong>{getTime(record.queuedAt)}</strong>
</div>
<div>
<span></span>
<strong>{message.matchedRecord.enterprise}</strong>
<strong>{record.tenant?.name ?? record.tenantId ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{message.matchedRecord.application}</strong>
<strong>{record.application?.name ?? record.applicationId ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{message.matchedRecord.accessNo}</strong>
<strong>{record.channel?.srcId ?? '-'}</strong>
</div>
</div>
<div className="admin-uplink-match-content">
<span></span>
<p>{message.matchedRecord.content}</p>
<p>{record.content}</p>
</div>
<button type="button"></button>
</article>
) : (
<div className="admin-uplink-empty-match"></div>
)}
))}
</section>
</div>
</Modal>
@@ -130,21 +118,58 @@ function UplinkDetailModal({ message, onClose }: { message: UplinkMessage; onClo
}
export function AdminSmsUplinkRecordsPage() {
const [messages, setMessages] = useState<SmsUplinkMessage[]>([]);
const [matchedRecords, setMatchedRecords] = useState<SmsMessageRecord[]>([]);
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [phoneKeyword, setPhoneKeyword] = useState('');
const [contentKeyword, setContentKeyword] = useState('');
const [selectedMessage, setSelectedMessage] = useState<UplinkMessage | null>(null);
const [selectedMessage, setSelectedMessage] = useState<SmsUplinkMessage | null>(null);
const [loading, setLoading] = useState(true);
const [matching, setMatching] = useState(false);
const [error, setError] = useState('');
const [detailError, setDetailError] = useState('');
function loadData() {
setLoading(true);
adminApi.listAdminUplinkMessages()
.then((items) => {
setMessages(items);
setError('');
})
.catch((reason: Error) => setError(reason.message || '短信上行记录加载失败'))
.finally(() => setLoading(false));
}
function openDetail(message: SmsUplinkMessage) {
setSelectedMessage(message);
setMatchedRecords([]);
setDetailError('');
if (!message.messageId) {
return;
}
setMatching(true);
adminApi.listOperationMessages({ tenantId: message.tenantId ?? undefined, messageId: message.messageId })
.then((items) => setMatchedRecords(items))
.catch((reason: Error) => setDetailError(reason.message || '匹配发送记录加载失败'))
.finally(() => setMatching(false));
}
useEffect(() => {
loadData();
}, []);
const filteredMessages = useMemo(
() => uplinkMessages.filter((item) => {
() => messages.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 matchesPhone = !phoneKeyword || item.phoneNumber.includes(phoneKeyword);
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
return matchesStartDate && matchesEndDate && matchesPhone && matchesContent;
}),
[contentKeyword, dateRange.end, dateRange.start, phoneKeyword],
[contentKeyword, dateRange.end, dateRange.start, messages, phoneKeyword],
);
function resetFilters() {
@@ -153,7 +178,7 @@ export function AdminSmsUplinkRecordsPage() {
setContentKeyword('');
}
const columns: Array<TableColumn<UplinkMessage>> = [
const columns: Array<TableColumn<SmsUplinkMessage>> = [
{
key: 'select',
title: '',
@@ -161,18 +186,18 @@ export function AdminSmsUplinkRecordsPage() {
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: 'phoneNumber', title: '手机号码', width: '170px', render: (record) => <strong>{record.phoneNumber}</strong> },
{ key: 'receivedAt', title: '上行时间', width: '220px', render: (record) => <strong>{getTime(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: 'channel', title: '上行通道', width: '260px', render: (record) => <strong>{record.channel?.name ?? record.channelId}</strong> },
{ key: 'accessNo', title: '上行接入号', width: '180px', render: (record) => <strong>{record.destId}</strong> },
{
key: 'actions',
title: '操作',
width: '140px',
align: 'center',
render: (record) => (
<button className="admin-uplink-detail-link" onClick={() => setSelectedMessage(record)} type="button"></button>
<button className="admin-uplink-detail-link" onClick={() => openDetail(record)} type="button"></button>
),
},
];
@@ -191,17 +216,27 @@ export function AdminSmsUplinkRecordsPage() {
<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 icon={<Search size={16} />} onClick={loadData}></Button>
<Button onClick={resetFilters} variant="ghost"></Button>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-uplink-table-card">
<Table columns={columns} data={filteredMessages} emptyText="暂无上行短信记录" rowKey="id" />
<Table columns={columns} data={loading ? [] : filteredMessages} emptyText={loading ? '正在加载真实上行短信记录...' : '暂无上行短信记录'} rowKey="id" />
<Pagination total={filteredMessages.length} />
</div>
{selectedMessage ? <UplinkDetailModal message={selectedMessage} onClose={() => setSelectedMessage(null)} /> : null}
{selectedMessage ? (
<UplinkDetailModal
detailError={detailError}
matchedRecords={matchedRecords}
matching={matching}
message={selectedMessage}
onClose={() => setSelectedMessage(null)}
/>
) : null}
</section>
);
}
+1 -1
View File
@@ -55,7 +55,7 @@ export function AdminSystemLogsPage() {
const columns = useMemo<Array<TableColumn<OperationLogItem>>>(() => [
{ key: 'time', title: '时间', width: '180px', render: (record) => <span className="muted">{new Date(record.time).toLocaleString('zh-CN')}</span> },
{ key: 'level', title: '级别', width: '100px', render: (record) => <Tag tone={levelToneMap[record.level]}>{levelLabelMap[record.level]}</Tag> },
{ key: 'level', title: '级别', width: '120px', render: (record) => <Tag tone={levelToneMap[record.level]}>{levelLabelMap[record.level]}</Tag> },
{ key: 'tenant', title: '企业', width: '190px', render: (record) => <strong>{record.tenant}</strong> },
{ key: 'module', title: '模块', width: '130px', render: (record) => record.module },
{ key: 'operator', title: '操作人', width: '120px', render: (record) => <strong>{record.operator}</strong> },
+1 -1
View File
@@ -146,7 +146,7 @@ export function AdminUsersPage() {
{ key: 'account', title: '邮箱/手机号', width: '230px', render: (record) => <span>{record.email ?? '-'}<br /><small className="muted">{record.phone ?? '-'}</small></span> },
{ key: 'role', title: '角色', width: '130px', render: (record) => roleLabel[record.roles[0]?.role.code] ?? record.roles[0]?.role.name ?? '-' },
{ key: 'tenant', title: '企业', width: '180px', render: (record) => record.tenant?.name ?? '-' },
{ key: 'status', title: '状态', width: '110px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '启用' : '禁用'}</Tag> },
{ key: 'status', title: '状态', width: '130px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '启用' : '禁用'}</Tag> },
{ key: 'lastLoginAt', title: '最近登录', width: '190px', render: (record) => record.lastLoginAt ? new Date(record.lastLoginAt).toLocaleString('zh-CN') : '-' },
{
key: 'actions',