feat: add phone frequency controls and modularize codebase
This commit is contained in:
@@ -1,488 +1,15 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CheckCircle2, Copy, Eye, ExternalLink, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type ChannelQualityStat, type ChannelTestResponse, type CmppConnectionState } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { formatCents, isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
|
||||
|
||||
type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all';
|
||||
type ChannelStatus = 'normal' | 'stopped' | 'connecting' | 'failed';
|
||||
|
||||
type SmsChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
carrier: Carrier;
|
||||
sendRegion: string;
|
||||
unitPrice: number;
|
||||
status: ChannelStatus;
|
||||
total: number;
|
||||
submitFailureRate: number;
|
||||
submitFailureCount: number;
|
||||
successRate: number;
|
||||
successCount: number;
|
||||
unknownRate: number;
|
||||
unknownCount: number;
|
||||
failureRate: number;
|
||||
failureCount: number;
|
||||
gatewayHost: string;
|
||||
gatewayPort: string;
|
||||
businessCode: string;
|
||||
corpCode: string;
|
||||
account: string;
|
||||
accessNo: string;
|
||||
cmppVersion: '2.0' | '3.0';
|
||||
desiredConnections: number;
|
||||
windowSize: number;
|
||||
heartbeatIntervalSeconds: number;
|
||||
heartbeatMissThreshold: number;
|
||||
extensionDigits: number;
|
||||
rateLimitPerSecond: number;
|
||||
passwordCipher?: string;
|
||||
};
|
||||
|
||||
type ChannelModalState = {
|
||||
mode: 'create' | 'edit';
|
||||
channel?: SmsChannel;
|
||||
};
|
||||
|
||||
type ChannelConfirmAction = {
|
||||
type: 'toggle' | 'copy';
|
||||
channel: SmsChannel;
|
||||
};
|
||||
|
||||
type ChannelLogState = {
|
||||
channel: SmsChannel;
|
||||
data?: ChannelConnectionLogResponse;
|
||||
};
|
||||
|
||||
const connectionStatusLabelMap: Record<string, string> = {
|
||||
connected: '已连接',
|
||||
connecting: '连接中',
|
||||
reconnecting: '重连中',
|
||||
disconnected: '已断开',
|
||||
failed: '连接失败',
|
||||
auth_failed: '鉴权失败',
|
||||
heartbeat_timeout: '心跳超时',
|
||||
};
|
||||
|
||||
function formatLogDetail(detail?: unknown) {
|
||||
if (!detail) return '无附加信息';
|
||||
if (typeof detail === 'string') return detail;
|
||||
return JSON.stringify(detail, null, 2);
|
||||
}
|
||||
|
||||
const carrierOptions = [
|
||||
{ label: '全部运营商', value: 'all' },
|
||||
{ label: '移动', value: 'mobile' },
|
||||
{ label: '联通', value: 'unicom' },
|
||||
{ label: '电信', value: 'telecom' },
|
||||
{ label: '三网', value: 'all' },
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '连接正常', value: 'normal' },
|
||||
{ label: '已停用', value: 'stopped' },
|
||||
{ label: '连接中', value: 'connecting' },
|
||||
{ label: '连接失败', value: 'failed' },
|
||||
];
|
||||
|
||||
const cmppVersionOptions = [
|
||||
{ label: 'CMPP 2.0', value: '2.0' },
|
||||
{ label: 'CMPP 3.0', value: '3.0' },
|
||||
];
|
||||
|
||||
const regionOptions = [
|
||||
{ label: '全国', value: '全国' },
|
||||
...'北京,天津,河北,山西,内蒙古,辽宁,吉林,黑龙江,上海,江苏,浙江,安徽,福建,江西,山东,河南,湖北,湖南,广东,广西,海南,重庆,四川,贵州,云南,西藏,陕西,甘肃,青海,宁夏,新疆,香港,澳门,台湾'.split(',').map((province) => ({ label: province, value: province })),
|
||||
];
|
||||
|
||||
const carrierLabelMap: Record<Carrier, string> = {
|
||||
mobile: '移动',
|
||||
unicom: '联通',
|
||||
telecom: '电信',
|
||||
all: '三网',
|
||||
};
|
||||
|
||||
const carrierToneMap: Record<Carrier, 'info' | 'danger' | 'success' | 'neutral'> = {
|
||||
mobile: 'info',
|
||||
unicom: 'danger',
|
||||
telecom: 'success',
|
||||
all: 'neutral',
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<ChannelStatus, string> = {
|
||||
normal: '连接正常',
|
||||
stopped: '已停用',
|
||||
connecting: '连接中',
|
||||
failed: '连接失败',
|
||||
};
|
||||
|
||||
const statusToneMap: Record<ChannelStatus, 'success' | 'neutral' | 'info' | 'danger'> = {
|
||||
normal: 'success',
|
||||
stopped: 'neutral',
|
||||
connecting: 'info',
|
||||
failed: 'danger',
|
||||
};
|
||||
|
||||
function resolveChannelStatus(channel: AdminChannel, connections: CmppConnectionState[] = []): ChannelStatus {
|
||||
if (channel.status !== 'active') {
|
||||
return 'stopped';
|
||||
}
|
||||
if (connections.some((connection) =>
|
||||
connection.status === 'connected'
|
||||
&& connection.currentConnections > 0
|
||||
&& connection.desiredConnections > 0,
|
||||
)) {
|
||||
return 'normal';
|
||||
}
|
||||
if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(connection.status) || connection.lastError)) {
|
||||
return 'failed';
|
||||
}
|
||||
return 'connecting';
|
||||
}
|
||||
|
||||
function mapApiChannel(
|
||||
channel: AdminChannel,
|
||||
connections: CmppConnectionState[] = channel.connectionStates ?? [],
|
||||
quality?: ChannelQualityStat,
|
||||
): SmsChannel {
|
||||
return {
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
carrier: channel.carrier === 'unicom' || channel.carrier === 'telecom' || channel.carrier === 'all' ? channel.carrier : 'mobile',
|
||||
sendRegion: channel.sendRegion ?? '全国',
|
||||
unitPrice: channel.unitPrice,
|
||||
status: resolveChannelStatus(channel, connections),
|
||||
total: quality?.total ?? 0,
|
||||
submitFailureRate: quality?.submitFailureRate ?? 0,
|
||||
submitFailureCount: quality?.submitFailureCount ?? 0,
|
||||
successRate: quality?.successRate ?? 0,
|
||||
successCount: quality?.successCount ?? 0,
|
||||
unknownRate: quality?.unknownRate ?? 0,
|
||||
unknownCount: quality?.unknownCount ?? 0,
|
||||
failureRate: quality?.failureRate ?? 0,
|
||||
failureCount: quality?.failureCount ?? 0,
|
||||
gatewayHost: channel.gatewayHost,
|
||||
gatewayPort: String(channel.gatewayPort),
|
||||
businessCode: String(channel.config?.serviceId ?? 'SMS'),
|
||||
corpCode: channel.enterpriseCode ?? channel.code,
|
||||
account: channel.account,
|
||||
accessNo: channel.srcId,
|
||||
cmppVersion: channel.cmppVersion === '3.0' ? '3.0' : '2.0',
|
||||
desiredConnections: Number(channel.config?.desiredConnections ?? 1),
|
||||
windowSize: Number(channel.config?.windowSize ?? 16),
|
||||
heartbeatIntervalSeconds: Number(channel.config?.heartbeatIntervalSeconds ?? 30),
|
||||
heartbeatMissThreshold: Number(channel.config?.heartbeatMissThreshold ?? 3),
|
||||
extensionDigits: Number(channel.config?.extensionDigits ?? 0),
|
||||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||
};
|
||||
}
|
||||
|
||||
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),
|
||||
protocol: 'CMPP',
|
||||
enterpriseCode: channel.corpCode,
|
||||
account: channel.account,
|
||||
passwordCipher: passwordCipher || undefined,
|
||||
srcId: channel.accessNo,
|
||||
cmppVersion: channel.cmppVersion,
|
||||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||
unitPrice: Math.round(channel.unitPrice),
|
||||
desiredConnections: channel.desiredConnections,
|
||||
windowSize: channel.windowSize,
|
||||
heartbeatIntervalSeconds: channel.heartbeatIntervalSeconds,
|
||||
heartbeatMissThreshold: channel.heartbeatMissThreshold,
|
||||
config: { extensionDigits: channel.extensionDigits, serviceId: channel.businessCode },
|
||||
};
|
||||
}
|
||||
|
||||
function RateBlock({ label, rate, count, tone = 'neutral' }: { label: string; rate: number; count: number; tone?: 'success' | 'warning' | 'danger' | 'neutral' }) {
|
||||
return (
|
||||
<div className={`sms-channel-rate sms-channel-rate--${tone}`}>
|
||||
<small>{label}</small>
|
||||
<strong>{rate}%</strong>
|
||||
<span>{count.toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelFormModal({
|
||||
modal,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
modal: ChannelModalState;
|
||||
onClose: () => void;
|
||||
onSubmit: (channel: SmsChannel) => void;
|
||||
}) {
|
||||
const channel = modal.channel;
|
||||
const [name, setName] = useState(channel?.name ?? '');
|
||||
const [carrier, setCarrier] = useState<Carrier>(channel?.carrier ?? 'mobile');
|
||||
const [unitPrice, setUnitPrice] = useState(channel ? moneyUnitsToYuan(channel.unitPrice).toFixed(4) : '0.0300');
|
||||
const [unitPriceError, setUnitPriceError] = useState('');
|
||||
const [region, setRegion] = useState(channel?.sendRegion ?? '全国');
|
||||
const [gatewayHost, setGatewayHost] = useState(channel?.gatewayHost ?? '');
|
||||
const [gatewayPort, setGatewayPort] = useState(channel?.gatewayPort ?? '7890');
|
||||
const [businessCode, setBusinessCode] = useState(channel?.businessCode ?? 'SMS');
|
||||
const [corpCode, setCorpCode] = useState(channel?.corpCode ?? '');
|
||||
const [account, setAccount] = useState(channel?.account ?? '');
|
||||
const [cmppVersion, setCmppVersion] = useState<'2.0' | '3.0'>(channel?.cmppVersion ?? '2.0');
|
||||
const [password, setPassword] = useState('');
|
||||
const [accessNo, setAccessNo] = useState(channel?.accessNo ?? '');
|
||||
const [extensionDigits, setExtensionDigits] = useState(String(channel?.extensionDigits ?? 0));
|
||||
const [flowLimit, setFlowLimit] = useState(String(channel?.rateLimitPerSecond ?? 100));
|
||||
const [desiredConnections, setDesiredConnections] = useState(String(channel?.desiredConnections ?? 1));
|
||||
const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16));
|
||||
const [heartbeatIntervalSeconds, setHeartbeatIntervalSeconds] = useState(String(channel?.heartbeatIntervalSeconds ?? 30));
|
||||
const [heartbeatMissThreshold, setHeartbeatMissThreshold] = useState(String(channel?.heartbeatMissThreshold ?? 3));
|
||||
|
||||
function submit() {
|
||||
if (!isValidMoneyInput(unitPrice)) {
|
||||
setUnitPriceError('单价必须是非负金额,且最多保留小数点后 4 位');
|
||||
return;
|
||||
}
|
||||
onSubmit({
|
||||
id: channel?.id ?? String(Math.floor(10000 + Math.random() * 80000)),
|
||||
name: name || '新建短信通道',
|
||||
carrier,
|
||||
sendRegion: region,
|
||||
unitPrice: yuanToMoneyUnits(unitPrice),
|
||||
status: channel?.status ?? 'connecting',
|
||||
total: channel?.total ?? 0,
|
||||
submitFailureRate: channel?.submitFailureRate ?? 0,
|
||||
submitFailureCount: channel?.submitFailureCount ?? 0,
|
||||
successRate: channel?.successRate ?? 0,
|
||||
successCount: channel?.successCount ?? 0,
|
||||
unknownRate: channel?.unknownRate ?? 0,
|
||||
unknownCount: channel?.unknownCount ?? 0,
|
||||
failureRate: channel?.failureRate ?? 0,
|
||||
failureCount: channel?.failureCount ?? 0,
|
||||
gatewayHost,
|
||||
gatewayPort,
|
||||
businessCode: businessCode.trim() || 'SMS',
|
||||
corpCode,
|
||||
account,
|
||||
accessNo,
|
||||
cmppVersion,
|
||||
desiredConnections: Number(desiredConnections) || 1,
|
||||
windowSize: Number(windowSize) || 16,
|
||||
heartbeatIntervalSeconds: Number(heartbeatIntervalSeconds) || 30,
|
||||
heartbeatMissThreshold: Number(heartbeatMissThreshold) || 3,
|
||||
extensionDigits: Number(extensionDigits),
|
||||
rateLimitPerSecond: Number(flowLimit),
|
||||
passwordCipher: password || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button onClick={submit}>确认</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>{modal.mode === 'edit' ? '编辑通道' : '创建通道'}</h2></div>}
|
||||
>
|
||||
<div className="sms-channel-form">
|
||||
<section>
|
||||
<h3>业务信息</h3>
|
||||
<div className="sms-channel-form-grid">
|
||||
<Input label="* 通道名称" onChange={(event) => setName(event.target.value)} placeholder="请输入通道名称" value={name} />
|
||||
<div className="sms-channel-radio-row">
|
||||
<span>* 运营商</span>
|
||||
{(['mobile', 'unicom', 'telecom', 'all'] as const).map((item) => (
|
||||
<label key={item}>
|
||||
<input checked={carrier === item} onChange={() => setCarrier(item)} type="radio" />
|
||||
{carrierLabelMap[item]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<Input error={unitPriceError} label="* 单价(元)" min="0" onChange={(event) => { setUnitPrice(event.target.value); setUnitPriceError(''); }} step="0.0001" type="number" value={unitPrice} />
|
||||
<Select label="* 发送地区" onChange={(event) => setRegion(event.target.value)} options={regionOptions} value={region} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>参数配置</h3>
|
||||
<div className="sms-channel-form-grid">
|
||||
<Input disabled label="* 协议选择" value="CMPP" />
|
||||
<div className="sms-channel-inline-field">
|
||||
<Input label="* 网关地址" onChange={(event) => setGatewayHost(event.target.value)} placeholder="请输入网关地址" value={gatewayHost} />
|
||||
<Input label="端口" onChange={(event) => setGatewayPort(event.target.value)} value={gatewayPort} />
|
||||
</div>
|
||||
<Input hint="对应 CMPP Service_Id,最多 10 个 ASCII 字符。" label="* 业务代码" maxLength={10} onChange={(event) => setBusinessCode(event.target.value.toUpperCase())} value={businessCode} />
|
||||
<Input label="* 企业代码" onChange={(event) => setCorpCode(event.target.value)} placeholder="请输入企业代码" value={corpCode} />
|
||||
<Input label="* 网关账号" onChange={(event) => setAccount(event.target.value)} placeholder="请输入网关账号" value={account} />
|
||||
<Select label="* CMPP版本" onChange={(event) => setCmppVersion(event.target.value as '2.0' | '3.0')} options={cmppVersionOptions} value={cmppVersion} />
|
||||
<Input
|
||||
hint={modal.mode === 'edit' ? '已配置的密码不会回显;留空保持不变,填写新密码才更新。' : undefined}
|
||||
label="网关密码"
|
||||
autoComplete="new-password"
|
||||
name="cmpp-gateway-password"
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder={modal.mode === 'edit' ? '留空不修改' : '请输入网关密码'}
|
||||
required={modal.mode === 'create'}
|
||||
type="password"
|
||||
value={password}
|
||||
/>
|
||||
<div className="sms-channel-inline-field">
|
||||
<Input autoComplete="off" label="* 接入号" name="cmpp-access-number" onChange={(event) => setAccessNo(event.target.value)} placeholder="请输入通道接入号" value={accessNo} />
|
||||
<Input label="扩展位数" max="20" min="0" onChange={(event) => setExtensionDigits(event.target.value)} type="number" value={extensionDigits} />
|
||||
</div>
|
||||
<Input label="* 通道流速" max="2000" min="1" onChange={(event) => setFlowLimit(event.target.value)} suffix="条/秒" type="number" value={flowLimit} />
|
||||
<Input label="* 期望连接数" onChange={(event) => setDesiredConnections(event.target.value)} placeholder="1" value={desiredConnections} />
|
||||
<Input label="* 提交窗口" onChange={(event) => setWindowSize(event.target.value)} placeholder="16" value={windowSize} />
|
||||
<Input hint="平台主动向供应商发送 ACTIVE_TEST 的间隔" label="* 心跳间隔" min="1" onChange={(event) => setHeartbeatIntervalSeconds(event.target.value)} suffix="秒" type="number" value={heartbeatIntervalSeconds} />
|
||||
<Input hint="连续未收到心跳响应达到该次数后重连" label="* 心跳失败阈值" min="1" onChange={(event) => setHeartbeatMissThreshold(event.target.value)} suffix="次" type="number" value={heartbeatMissThreshold} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function SmsTestModal({
|
||||
channel,
|
||||
onClose,
|
||||
onOpenRecords,
|
||||
}: {
|
||||
channel: SmsChannel;
|
||||
onClose: () => void;
|
||||
onOpenRecords: () => void;
|
||||
}) {
|
||||
const [phones, setPhones] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [accessNo, setAccessNo] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState<ChannelTestResponse | null>(null);
|
||||
const billingCount = Math.max(1, Math.ceil(content.length / 67));
|
||||
|
||||
async function submitTestSms() {
|
||||
if (!phones.trim()) {
|
||||
setError('请输入测试手机号');
|
||||
return;
|
||||
}
|
||||
if (!content.trim()) {
|
||||
setError('请输入测试短信内容');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
try {
|
||||
const response = await adminApi.testChannel(channel.id, {
|
||||
phones,
|
||||
content,
|
||||
accessNo: accessNo.trim() || undefined,
|
||||
});
|
||||
setResult(response);
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '测试短信发送失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
{result ? <Button icon={<ExternalLink size={16} />} onClick={onOpenRecords} variant="ghost">查看短信记录</Button> : null}
|
||||
<Button disabled={submitting || Boolean(result)} icon={<Send size={16} />} onClick={submitTestSms}>
|
||||
{submitting ? '发送中...' : result ? '已提交' : '发送测试'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={(
|
||||
<div className="sms-test-title">
|
||||
<span><Send size={30} /></span>
|
||||
<div>
|
||||
<h2>短信测试</h2>
|
||||
<p>向指定手机号发送测试短信</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="sms-test-modal">
|
||||
<div className="sms-test-channel">
|
||||
<span>测试通道</span>
|
||||
<strong>{channel.name}</strong>
|
||||
</div>
|
||||
<Textarea
|
||||
label="* 手机号码"
|
||||
onChange={(event) => setPhones(event.target.value)}
|
||||
placeholder="请输入手机号码,多个号码用逗号(,)隔开,最多允许10个号码"
|
||||
rows={3}
|
||||
value={phones}
|
||||
/>
|
||||
<p className="muted">支持多个号码,用中英文逗号分隔,最多10个</p>
|
||||
<Textarea
|
||||
label="* 短信内容"
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
placeholder="请输入短信内容"
|
||||
rows={4}
|
||||
value={content}
|
||||
/>
|
||||
<div className="sms-test-counter">
|
||||
<span>按67字/条计费</span>
|
||||
<strong>{content.length} 字符 <i /> 计费 {billingCount} 条</strong>
|
||||
</div>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
label="接入号(选填)"
|
||||
name="channel-test-access-number"
|
||||
onChange={(event) => setAccessNo(event.target.value)}
|
||||
placeholder="请输入接入号"
|
||||
value={accessNo}
|
||||
/>
|
||||
<div className="signature-alert sms-test-note">
|
||||
<Info size={18} />
|
||||
<span>如需测试接入号,可在通道接入号后上追加接入号进行测试</span>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{result ? (
|
||||
<div className="sms-test-result">
|
||||
<div className="sms-test-result__summary">
|
||||
<CheckCircle2 size={20} />
|
||||
<div>
|
||||
<strong>已写入真实发送队列</strong>
|
||||
<span>测试流水号:{result.testNo},共 {result.submitted} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sms-test-result__records">
|
||||
{result.messages.map((message) => (
|
||||
<div key={message.messageRecordId}>
|
||||
<span>{message.phoneNumber}</span>
|
||||
<code>{message.submitId}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
import { adminApi, type CmppConnectionState } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select } from '@/components/ui';
|
||||
import { ChannelFormModal } from './channels/ChannelFormModal';
|
||||
import { ChannelLogModal } from './channels/ChannelLogModal';
|
||||
import { ChannelTable } from './channels/ChannelTable';
|
||||
import { SmsTestModal } from './channels/SmsTestModal';
|
||||
import { buildChannelPayload, carrierOptions, mapApiChannel, mapUiStatusToApi, statusOptions } from './channels/channelModel';
|
||||
import type { ChannelConfirmAction, ChannelLogState, ChannelModalState, SmsChannel } from './channels/channelTypes';
|
||||
import './channels/AdminChannelsPage.css';
|
||||
|
||||
export function AdminChannelsPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -522,10 +49,8 @@ export function AdminChannelsPage() {
|
||||
loadChannels(page);
|
||||
}, [page]);
|
||||
|
||||
const filteredChannels = channels;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const visibleChannels = channels;
|
||||
|
||||
async function upsertChannel(nextChannel: SmsChannel) {
|
||||
try {
|
||||
@@ -572,29 +97,26 @@ export function AdminChannelsPage() {
|
||||
if (!confirmAction) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirmAction.type === 'toggle') {
|
||||
void toggleChannel(confirmAction.channel);
|
||||
}
|
||||
|
||||
if (confirmAction.type === 'copy') {
|
||||
void copyChannel(confirmAction.channel);
|
||||
}
|
||||
|
||||
setConfirmAction(null);
|
||||
}
|
||||
|
||||
const confirmTitle = confirmAction?.type === 'copy'
|
||||
? '确认复制通道'
|
||||
: confirmAction?.channel.status === 'stopped'
|
||||
? '确认启用通道'
|
||||
: '确认停用通道';
|
||||
? '确认复制通道'
|
||||
: confirmAction?.channel.status === 'stopped'
|
||||
? '确认启用通道'
|
||||
: '确认停用通道';
|
||||
|
||||
const confirmDescription = confirmAction?.type === 'copy'
|
||||
? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。'
|
||||
: confirmAction?.channel.status === 'stopped'
|
||||
? '启用后通道会进入连接中状态,后续可继续观察网关连接。'
|
||||
: '停用后该通道将不再承接新的发送任务。';
|
||||
? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。'
|
||||
: confirmAction?.channel.status === 'stopped'
|
||||
? '启用后通道会进入连接中状态,后续可继续观察网关连接。'
|
||||
: '停用后该通道将不再承接新的发送任务。';
|
||||
|
||||
return (
|
||||
<section className="page-stack sms-channel-page">
|
||||
@@ -616,72 +138,21 @@ export function AdminChannelsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface sms-channel-table">
|
||||
<div className="sms-channel-table__head">
|
||||
<span>通道信息</span>
|
||||
<span>运营商 / 成本</span>
|
||||
<span>状态</span>
|
||||
<span>今日提交</span>
|
||||
<span>今日提交 / 送达质量</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{visibleChannels.map((channel) => (
|
||||
<article className="sms-channel-table__row" key={channel.id}>
|
||||
<div className="sms-channel-identity">
|
||||
<strong>{channel.name}</strong>
|
||||
<span>通道 ID:{channel.id}</span>
|
||||
</div>
|
||||
<div className="sms-channel-carrier-price">
|
||||
<Tag tone={carrierToneMap[channel.carrier]}>{carrierLabelMap[channel.carrier]}</Tag>
|
||||
<strong>{formatCents(channel.unitPrice)} 元</strong>
|
||||
</div>
|
||||
<div className="sms-channel-status-cell">
|
||||
<Tag tone={statusToneMap[channel.status]}>{statusLabelMap[channel.status]}</Tag>
|
||||
<button onClick={() => void openLinkLogs(channel)} type="button">
|
||||
<FileText size={14} />连接日志
|
||||
</button>
|
||||
</div>
|
||||
<strong className="sms-channel-total">{channel.total.toLocaleString('zh-CN')}</strong>
|
||||
<div className="sms-channel-quality">
|
||||
<RateBlock count={channel.submitFailureCount} label="提交失败" rate={channel.submitFailureRate} tone={channel.submitFailureCount > 0 ? 'danger' : 'neutral'} />
|
||||
<RateBlock count={channel.successCount} label="送达成功" rate={channel.successRate} tone={channel.successRate >= 80 ? 'success' : 'warning'} />
|
||||
<RateBlock count={channel.unknownCount} label="回执未知" rate={channel.unknownRate} />
|
||||
<RateBlock count={channel.failureCount} label="送达失败" rate={channel.failureRate} tone={channel.failureRate >= 50 ? 'danger' : 'neutral'} />
|
||||
</div>
|
||||
<div className="sms-channel-actions">
|
||||
<button className="sms-channel-report-entry" onClick={() => navigate(`/admin/channels/${channel.id}/reports`)} type="button">
|
||||
<Eye size={15} />报备详情
|
||||
</button>
|
||||
<button onClick={() => setModal({ mode: 'edit', channel })} type="button"><Pencil size={15} />编辑</button>
|
||||
<button onClick={() => setConfirmAction({ type: 'copy', channel })} type="button"><Copy size={15} />复制通道</button>
|
||||
<button onClick={() => setTestChannel(channel)} type="button"><Send size={15} />发送测试</button>
|
||||
<button className={channel.status === 'stopped' ? 'is-success' : 'is-warning'} onClick={() => setConfirmAction({ type: 'toggle', channel })} type="button">
|
||||
<Power size={15} />{channel.status === 'stopped' ? '启用' : '停用'}
|
||||
</button>
|
||||
<DeleteRiskAction onCompleted={() => void loadChannels()} portal="admin" targetId={channel.id} targetType="channel" />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
|
||||
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
|
||||
page={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={total}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{modal ? (
|
||||
<ChannelFormModal
|
||||
modal={modal}
|
||||
onClose={() => setModal(null)}
|
||||
onSubmit={upsertChannel}
|
||||
/>
|
||||
) : null}
|
||||
<ChannelTable
|
||||
channels={channels}
|
||||
currentPage={currentPage}
|
||||
onConfirm={setConfirmAction}
|
||||
onDeleted={() => void loadChannels()}
|
||||
onEdit={setModal}
|
||||
onOpenLogs={(channel) => void openLinkLogs(channel)}
|
||||
onOpenReports={(channel) => navigate(`/admin/channels/${channel.id}/reports`)}
|
||||
onPageChange={setPage}
|
||||
onTest={setTestChannel}
|
||||
total={total}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
|
||||
{modal ? <ChannelFormModal modal={modal} onClose={() => setModal(null)} onSubmit={upsertChannel} /> : null}
|
||||
{testChannel ? (
|
||||
<SmsTestModal
|
||||
channel={testChannel}
|
||||
@@ -714,68 +185,12 @@ export function AdminChannelsPage() {
|
||||
) : null}
|
||||
|
||||
{logState ? (
|
||||
<Modal
|
||||
footer={<Button onClick={() => setLogState(null)} variant="ghost">关闭</Button>}
|
||||
<ChannelLogModal
|
||||
keyword={logKeyword}
|
||||
logState={logState}
|
||||
onClose={() => setLogState(null)}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>连接日志</h2><p>{logState.channel.name}</p></div>}
|
||||
>
|
||||
<div className="channel-log-modal">
|
||||
{logState.data ? (
|
||||
<div className="channel-connection-summary">
|
||||
{logState.data.connectionStates.map((connection) => (
|
||||
<article key={connection.id}>
|
||||
<div>
|
||||
<span>连接 ID</span>
|
||||
<strong>{connection.connectionId}</strong>
|
||||
</div>
|
||||
<Tag tone={connection.status === 'connected' ? 'success' : connection.lastError ? 'danger' : 'info'}>
|
||||
{connectionStatusLabelMap[connection.status] ?? connection.status}
|
||||
</Tag>
|
||||
<div>
|
||||
<span>当前 / 期望</span>
|
||||
<strong>{connection.currentConnections} / {connection.desiredConnections}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>最近心跳</span>
|
||||
<strong>{formatDateTime(connection.lastHeartbeatAt)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>自动重连</span>
|
||||
<strong>{connection.reconnectCount} 次 / {formatDateTime(connection.nextReconnectAt)}</strong>
|
||||
</div>
|
||||
{connection.lastError ? <p>{connection.lastError}</p> : null}
|
||||
</article>
|
||||
))}
|
||||
{logState.data.connectionStates.length === 0 ? <p className="muted">暂无连接状态回写</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<Input label="筛选日志" onChange={(event) => setLogKeyword(event.target.value)} placeholder="事件、资源或详情关键词" value={logKeyword} />
|
||||
<div className="channel-log-list">
|
||||
{(logState.data?.logs ?? []).filter((log) => {
|
||||
const keyword = logKeyword.trim().toLowerCase();
|
||||
return !keyword || `${log.event} ${log.resourceId ?? ''} ${formatLogDetail(log.detail)}`.toLowerCase().includes(keyword);
|
||||
}).map((log) => (
|
||||
<article className="channel-log-item" key={log.id}>
|
||||
<div>
|
||||
<strong>{log.event}</strong>
|
||||
<span>{formatDateTime(log.time)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>{log.resourceId}</span>
|
||||
<pre>{formatLogDetail(log.detail)}</pre>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{logState.data && logState.data.logs.filter((log) => {
|
||||
const keyword = logKeyword.trim().toLowerCase();
|
||||
return !keyword || `${log.event} ${log.resourceId ?? ''} ${formatLogDetail(log.detail)}`.toLowerCase().includes(keyword);
|
||||
}).length === 0 ? <p className="muted">未找到匹配的连接日志</p> : null}
|
||||
{!logState.data ? <p className="muted">正在加载连接日志...</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
onKeywordChange={setLogKeyword}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,318 +1,29 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Copy, Edit3, Plus, Search, Settings2, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type ApplicationCmppParams, type ApplicationDeactivationPreview, type CmppDownstreamConnection, type EnterpriseApplication, type HttpApiConfigResponse, type TenantOption } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { formatAmount, moneyUnitsToYuan } from '@/utils/currency';
|
||||
import { copyText } from '@/utils/clipboard';
|
||||
import { formatHttpApiParams } from '@/utils/interfaceParams';
|
||||
import type {
|
||||
ApplicationCmppParams,
|
||||
ApplicationDeactivationPreview,
|
||||
HttpApiConfigResponse,
|
||||
TenantOption,
|
||||
} from '@/api/adminApi';
|
||||
import { adminApi } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button } from '@/components/ui';
|
||||
import { AddApplicationModal, ConfirmModal, DeactivateApplicationModal } from './enterprise-applications/ApplicationLifecycleModals';
|
||||
import { CmppParamsModal, HttpParamsModal } from './enterprise-applications/ApplicationParamsModals';
|
||||
import { CmppConnectionModal } from './enterprise-applications/CmppConnectionModal';
|
||||
import { EnterpriseApplicationFilter } from './enterprise-applications/EnterpriseApplicationFilter';
|
||||
import { EnterpriseApplicationTable } from './enterprise-applications/EnterpriseApplicationTable';
|
||||
import { mapApplication } from './enterprise-applications/applicationModel';
|
||||
import type { ConfirmAction, SmsApp } from './enterprise-applications/applicationTypes';
|
||||
import './enterprise-applications/AdminEnterpriseApplicationsPage.css';
|
||||
|
||||
type SmsApp = {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
enterprise: string;
|
||||
appId: string;
|
||||
type ApplicationFilters = {
|
||||
enterpriseKeyword: string;
|
||||
applicationKeyword: string;
|
||||
status: string;
|
||||
enabled: boolean;
|
||||
deactivation?: ApplicationDeactivationPreview | null;
|
||||
sentToday: number;
|
||||
deliveryRate: number;
|
||||
unitPrice: number;
|
||||
cmppStatus: 'connected' | 'disconnected' | 'inactive';
|
||||
cmppConnections: CmppConnection[];
|
||||
cmppParams: CmppParams;
|
||||
httpEnabled: boolean;
|
||||
};
|
||||
|
||||
type CmppParams = {
|
||||
host: string;
|
||||
port: number;
|
||||
interfaceEnabled: boolean;
|
||||
interfaceType: string;
|
||||
enterpriseCode: string;
|
||||
account: string;
|
||||
password: string;
|
||||
accessNumber: string;
|
||||
maxConnections: number;
|
||||
heartbeatSeconds: number;
|
||||
windowSize: number;
|
||||
protocolVersion: string;
|
||||
};
|
||||
|
||||
type CmppConnection = {
|
||||
id: string;
|
||||
state: 'open' | 'closed' | 'reconnecting';
|
||||
bindType: 'transceiver' | 'submitter' | 'receiver';
|
||||
clientIp: string;
|
||||
sourceAddr: string;
|
||||
establishedAt: string;
|
||||
lastHeartbeatAt: string;
|
||||
lastSubmitAt: string;
|
||||
pendingWindow: number;
|
||||
};
|
||||
|
||||
function applicationStatusTag(app: SmsApp) {
|
||||
if (app.status === 'disabling') {
|
||||
const detail = app.deactivation;
|
||||
const title = [
|
||||
detail?.reason || '等待未完成回执清算',
|
||||
`等待供应商回执:${detail?.awaitingSupplierReceipt ?? 0}条`,
|
||||
`等待推送:${detail?.waitingToSend ?? 0}条`,
|
||||
`等待客户端确认:${detail?.awaitingClientAck ?? 0}条`,
|
||||
`可重试失败:${detail?.retryableFailures ?? 0}条`,
|
||||
`待推送上行:${detail?.pendingUplinks ?? 0}条`,
|
||||
`进入停用中:${formatDateTime(detail?.disablingAt)}`,
|
||||
`自动停用时间:${formatDateTime(detail?.autoDisableAt)}`,
|
||||
].join('\n');
|
||||
return <span aria-label={title} className="application-status-detail" tabIndex={0} title={title}><Tag tone="warning">停用中</Tag></span>;
|
||||
}
|
||||
return <Tag tone={app.status === 'active' ? 'success' : 'neutral'}>{app.status === 'active' ? '启用' : '停用'}</Tag>;
|
||||
}
|
||||
|
||||
function ConfirmModal({ message, danger, onCancel, onConfirm }: { message: string; danger?: boolean; onCancel: () => void; onConfirm: () => void }) {
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onCancel} variant="ghost">取消</Button>
|
||||
<Button onClick={onConfirm} variant={danger ? 'danger' : 'primary'}>确认</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onCancel}
|
||||
open
|
||||
title="操作确认"
|
||||
>
|
||||
<p className="admin-confirm-text">{message}</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function DeactivateApplicationModal({
|
||||
app,
|
||||
preview,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
app: SmsApp;
|
||||
preview: ApplicationDeactivationPreview;
|
||||
onCancel: () => void;
|
||||
onConfirm: (mode: 'wait' | 'force') => void;
|
||||
}) {
|
||||
const hasOutstanding = preview.totalOutstanding > 0;
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onCancel} variant="ghost">取消</Button>
|
||||
{hasOutstanding ? <Button onClick={() => onConfirm('force')} variant="danger">强制停用并断开连接</Button> : null}
|
||||
<Button onClick={() => onConfirm(hasOutstanding ? 'wait' : 'force')} variant={hasOutstanding ? 'warning' : 'primary'}>
|
||||
{hasOutstanding ? '等待回执后停用' : '确认停用'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onCancel}
|
||||
open
|
||||
title={`停用应用“${app.name}”`}
|
||||
>
|
||||
{hasOutstanding ? (
|
||||
<div className="section-stack">
|
||||
<p className="admin-confirm-text">该应用还有 {preview.totalOutstanding} 项回执或上行投递义务尚未清算。</p>
|
||||
<div className="cmpp-connection-summary">
|
||||
<div><span>等待供应商回执</span><strong>{preview.awaitingSupplierReceipt}</strong></div>
|
||||
<div><span>等待推送</span><strong>{preview.waitingToSend}</strong></div>
|
||||
<div><span>等待客户端确认</span><strong>{preview.awaitingClientAck}</strong></div>
|
||||
<div><span>可重试失败</span><strong>{preview.retryableFailures}</strong></div>
|
||||
<div><span>待推送上行</span><strong>{preview.pendingUplinks}</strong></div>
|
||||
<div><span>当前CMPP连接</span><strong>{preview.activeConnections}</strong></div>
|
||||
</div>
|
||||
<p className="form-hint">“等待回执后停用”会立即停止接收新短信,清算完成后自动停用;最长等待72小时。“强制停用”会立即断开全部连接并放弃剩余投递。</p>
|
||||
</div>
|
||||
) : <p className="admin-confirm-text">该应用没有待清算数据,将立即停用并断开全部 CMPP 客户端连接。</p>}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<Select
|
||||
disabled={loading || tenants.length === 0}
|
||||
hint={!loading && tenants.length === 0 ? '暂无可选择企业,请先创建真实企业。' : undefined}
|
||||
label="所属企业"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
options={[
|
||||
{ label: loading ? '企业加载中...' : '请选择真实企业', value: '' },
|
||||
...tenants.map((tenant) => ({ label: `${tenant.name}(${tenant.code})`, value: tenant.id })),
|
||||
]}
|
||||
value={selectedTenantId}
|
||||
/>
|
||||
<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' },
|
||||
reconnecting: { label: '重连中', tone: 'warning' },
|
||||
};
|
||||
|
||||
function formatCmppParams(app: SmsApp, params?: ApplicationCmppParams | null) {
|
||||
const cmppParams = params ?? app.cmppParams;
|
||||
const interfaceEnabled = params?.interfaceEnabled ?? app.cmppParams.interfaceEnabled;
|
||||
const interfaceType = params?.interfaceType ?? app.cmppParams.interfaceType;
|
||||
return [
|
||||
`应用名称: ${app.name}`,
|
||||
`企业名称: ${app.enterprise}`,
|
||||
`AppID: ${app.appId}`,
|
||||
`短信接口: ${interfaceEnabled ? '开通' : '关闭'}`,
|
||||
`接口类型: ${interfaceType === 'cmpp20' ? 'CMPP2.0' : 'HTTP接口'}`,
|
||||
`CMPP网关地址: ${'gatewayHost' in cmppParams ? cmppParams.gatewayHost : cmppParams.host}`,
|
||||
`CMPP网关端口: ${'gatewayPort' in cmppParams ? cmppParams.gatewayPort : cmppParams.port}`,
|
||||
`企业代码: ${cmppParams.enterpriseCode}`,
|
||||
`接口账号: ${cmppParams.account}`,
|
||||
`接口密码: ${'passwordCipher' in cmppParams ? cmppParams.passwordCipher : cmppParams.password}`,
|
||||
`接入号: ${'srcId' in cmppParams ? cmppParams.srcId : cmppParams.accessNumber}`,
|
||||
`最大连接数: ${cmppParams.maxConnections}`,
|
||||
`心跳间隔: ${cmppParams.heartbeatSeconds}秒`,
|
||||
`协议版本: ${cmppParams.protocolVersion}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: ApplicationCmppParams | null; onClose: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copyError, setCopyError] = useState('');
|
||||
const paramsText = formatCmppParams(app, params);
|
||||
|
||||
async function copyParams() {
|
||||
try {
|
||||
await copyText(paramsText);
|
||||
setCopyError('');
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
} catch (failure) {
|
||||
setCopyError(failure instanceof Error ? failure.message : '复制失败');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">关闭</Button>
|
||||
<Button icon={<Copy size={15} />} onClick={copyParams}>{copied ? '已复制' : '一键复制'}</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>CMPP连接参数</h2><p>{app.enterprise} / {app.name}</p></div>}
|
||||
>
|
||||
<div className="cmpp-param-detail">
|
||||
<pre className="cmpp-param-copy">{paramsText}</pre>
|
||||
{copyError ? <p className="form-error">{copyError}</p> : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function HttpParamsModal({ app, params, onClose }: { app: SmsApp; params: HttpApiConfigResponse; onClose: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copyError, setCopyError] = useState('');
|
||||
const paramsText = formatHttpApiParams(params, window.location.origin);
|
||||
|
||||
async function copyParams() {
|
||||
try {
|
||||
await copyText(paramsText);
|
||||
setCopyError('');
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
} catch (failure) {
|
||||
setCopyError(failure instanceof Error ? failure.message : '复制失败');
|
||||
}
|
||||
}
|
||||
|
||||
return <Modal footer={<><Button onClick={onClose} variant="ghost">关闭</Button><Button icon={<Copy size={15} />} onClick={() => void copyParams()}>{copied ? '已复制' : '一键复制'}</Button></>} onClose={onClose} open size="xl" title={<div className="template-modal-title"><h2>HTTP接口参数</h2><p>{app.enterprise} / {app.name}</p></div>}>
|
||||
<pre className="cmpp-param-copy">{paramsText}</pre>
|
||||
{copyError ? <p className="form-error">{copyError}</p> : null}
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
function CmppConnectionModal({
|
||||
app,
|
||||
onClose,
|
||||
}: {
|
||||
app: SmsApp;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const activeConnections = app.cmppConnections.filter((item) => item.state === 'open').length;
|
||||
const activeConnectionItems = app.cmppConnections.filter((item) => item.state === 'open');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={<Button onClick={onClose}>关闭</Button>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>CMPP连接详情</h2><p>{app.enterprise} / {app.name}</p></div>}
|
||||
>
|
||||
<div className="cmpp-connection-detail">
|
||||
<div className="cmpp-connection-summary">
|
||||
<div><span>当前连接数</span><strong>{activeConnections}</strong></div>
|
||||
<div><span>配置连接数</span><strong>{app.cmppParams.maxConnections}</strong></div>
|
||||
<div><span>AppID</span><strong>{app.appId}</strong></div>
|
||||
<div><span>连接状态</span><Tag tone={app.cmppStatus === 'connected' ? 'success' : app.cmppStatus === 'disconnected' ? 'danger' : 'neutral'}>{app.cmppStatus === 'connected' ? '在线' : app.cmppStatus === 'disconnected' ? '离线' : '未开通'}</Tag></div>
|
||||
</div>
|
||||
{activeConnectionItems.length ? <div className="cmpp-connection-list">{activeConnectionItems.map((record) => (
|
||||
<article className="cmpp-connection-card" key={record.id}>
|
||||
<div className="cmpp-connection-card__heading"><strong>{record.id}</strong><Tag tone={connectionStateMeta[record.state].tone}>{connectionStateMeta[record.state].label}</Tag></div>
|
||||
<div className="cmpp-connection-card__grid">
|
||||
<div><span>绑定类型</span><strong>{record.bindType}</strong></div>
|
||||
<div><span>客户端 IP</span><strong>{record.clientIp}</strong></div>
|
||||
<div><span>企业代码</span><strong>{record.sourceAddr}</strong></div>
|
||||
<div><span>窗口占用</span><strong>{record.pendingWindow}</strong></div>
|
||||
<div><span>连接建立时间</span><strong>{record.establishedAt}</strong></div>
|
||||
<div><span>上次心跳</span><strong>{record.lastHeartbeatAt}</strong></div>
|
||||
<div><span>上次提交</span><strong>{record.lastSubmitAt}</strong></div>
|
||||
</div>
|
||||
</article>
|
||||
))}</div> : <div className="ui-table__empty">当前暂无已连接的 CMPP 会话</div>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminEnterpriseApplicationsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [smsApps, setSmsApps] = useState<SmsApp[]>([]);
|
||||
@@ -335,22 +46,25 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [tenantsLoading, setTenantsLoading] = useState(false);
|
||||
const [selectedTenantId, setSelectedTenantId] = useState('');
|
||||
const [confirmAction, setConfirmAction] = useState<
|
||||
| { action: 'enable'; id: string; name: string }
|
||||
| { action: 'delete'; id: string; name: string }
|
||||
| null
|
||||
>(null);
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||
const [deactivateAction, setDeactivateAction] = useState<{ app: SmsApp; preview: ApplicationDeactivationPreview } | null>(null);
|
||||
|
||||
async function loadSmsApps(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, status: appliedStatus }, targetPage = page) {
|
||||
async function loadSmsApps(
|
||||
filters: ApplicationFilters = {
|
||||
enterpriseKeyword: appliedEnterpriseKeyword,
|
||||
applicationKeyword: appliedApplicationKeyword,
|
||||
status: appliedStatus,
|
||||
},
|
||||
targetPage = page,
|
||||
) {
|
||||
try {
|
||||
const result = await adminApi.listEnterpriseApplicationsPage({ ...filters, page: targetPage, pageSize });
|
||||
setSmsApps(result.items.map(mapApplication));
|
||||
setTotal(result.total);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
} catch (failure) {
|
||||
setSmsApps([]);
|
||||
setError(err instanceof Error ? err.message : '企业应用加载失败');
|
||||
setError(failure instanceof Error ? failure.message : '企业应用加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,8 +78,8 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
setTenantsLoading(true);
|
||||
try {
|
||||
setTenants((await adminApi.listTenants()).filter((tenant) => tenant.status !== 'deleted'));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '企业列表加载失败');
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '企业列表加载失败');
|
||||
} finally {
|
||||
setTenantsLoading(false);
|
||||
}
|
||||
@@ -410,13 +124,11 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
|
||||
async function confirmDelete(id: string) {
|
||||
await adminApi.changeApplicationStatus(id, 'deleted', '运营端删除应用');
|
||||
await loadSmsApps();
|
||||
await loadSmsApps();
|
||||
}
|
||||
|
||||
async function runConfirmedAction() {
|
||||
if (!confirmAction) {
|
||||
return;
|
||||
}
|
||||
if (!confirmAction) return;
|
||||
if (confirmAction.action === 'enable') {
|
||||
await confirmEnable(confirmAction.id);
|
||||
} else {
|
||||
@@ -445,51 +157,30 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const filteredSmsApps = smsApps;
|
||||
function queryApplications() {
|
||||
const filters = {
|
||||
enterpriseKeyword: enterpriseKeyword.trim(),
|
||||
applicationKeyword: applicationKeyword.trim(),
|
||||
status,
|
||||
};
|
||||
setAppliedEnterpriseKeyword(filters.enterpriseKeyword);
|
||||
setAppliedApplicationKeyword(filters.applicationKeyword);
|
||||
setAppliedStatus(filters.status);
|
||||
setPage(1);
|
||||
void loadSmsApps(filters, 1);
|
||||
}
|
||||
|
||||
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: 'sentToday', title: '今日发送', width: '120px', render: (record) => `${record.sentToday.toLocaleString('zh-CN')} 条` },
|
||||
{ key: 'deliveryRate', title: '到达率', width: '130px', render: (record) => `${record.deliveryRate}%` },
|
||||
{ key: 'unitPrice', title: '单价', width: '130px', render: (record) => `${formatAmount(record.unitPrice)} 元` },
|
||||
{
|
||||
key: 'cmppStatus',
|
||||
title: '客户连接状态',
|
||||
width: '250px',
|
||||
render: (record) => (
|
||||
<div className="cmpp-status-cell">
|
||||
<Tag tone={record.cmppStatus === 'connected' ? 'success' : record.cmppStatus === 'disconnected' ? 'danger' : 'neutral'}>
|
||||
{record.cmppStatus === 'connected' ? '已连接' : record.cmppStatus === 'disconnected' ? '已断开' : '未开通'}
|
||||
</Tag>
|
||||
<button disabled={!record.cmppParams.interfaceEnabled} onClick={() => setConnectionApp(record)} type="button">
|
||||
{record.cmppConnections.filter((item) => item.state === 'open').length}
|
||||
</button>
|
||||
<button className={`cmpp-status-cell__params ${record.cmppParams.interfaceEnabled ? 'is-enabled' : 'is-disabled'}`} disabled={!record.cmppParams.interfaceEnabled} onClick={() => { void openParams(record); }} type="button">
|
||||
<Settings2 size={13} />
|
||||
CMPP参数
|
||||
</button>
|
||||
<button className={`cmpp-status-cell__params ${record.httpEnabled ? 'is-enabled' : 'is-disabled'}`} disabled={!record.httpEnabled} onClick={() => { void openHttpParams(record); }} type="button">HTTP参数</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'enabled', title: '状态', width: '130px', render: (record) => applicationStatusTag(record) },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
width: '190px',
|
||||
render: (record) => (
|
||||
<div className="table-actions enterprise-app-actions">
|
||||
<Button icon={<Edit3 size={15} />} onClick={() => navigate(`/admin/customers/${record.tenantId}/sms-apps/${record.id}/edit`)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button onClick={() => { if (record.status === 'active') void openDeactivate(record); else setConfirmAction({ action: 'enable', id: record.id, name: record.name }); }} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>
|
||||
{record.status === 'active' ? '停用' : '启用'}
|
||||
</Button>
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ action: 'delete', id: record.id, name: record.name })} size="sm" variant="danger">删除</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], [navigate]);
|
||||
function resetApplicationFilters() {
|
||||
const filters = { enterpriseKeyword: '', applicationKeyword: '', status: 'all' };
|
||||
setEnterpriseKeyword('');
|
||||
setApplicationKeyword('');
|
||||
setStatus('all');
|
||||
setAppliedEnterpriseKeyword('');
|
||||
setAppliedApplicationKeyword('');
|
||||
setAppliedStatus('all');
|
||||
setPage(1);
|
||||
void loadSmsApps(filters, 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-customer-split-page">
|
||||
@@ -498,46 +189,35 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
<Breadcrumb items={['客户管理', '企业应用管理']} />
|
||||
<h1>企业应用管理</h1>
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />} onClick={() => { void openAddModal(); }}>添加应用</Button>
|
||||
<Button icon={<Plus size={16} />} onClick={() => void openAddModal()}>添加应用</Button>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-split-filter admin-application-filter">
|
||||
<Input
|
||||
label="企业名称"
|
||||
onChange={(event) => setEnterpriseKeyword(event.target.value)}
|
||||
placeholder="请输入企业名称"
|
||||
prefix={<Search size={16} />}
|
||||
value={enterpriseKeyword}
|
||||
/>
|
||||
<Input
|
||||
label="企业应用名称"
|
||||
onChange={(event) => setApplicationKeyword(event.target.value)}
|
||||
placeholder="请输入企业应用名称"
|
||||
prefix={<Search size={16} />}
|
||||
value={applicationKeyword}
|
||||
/>
|
||||
<Select
|
||||
label="状态"
|
||||
onChange={(event) => setStatus(event.target.value)}
|
||||
options={[{ label: '全部状态', value: 'all' }, { label: '启用', value: 'active' }, { label: '停用中', value: 'disabling' }, { label: '停用', value: 'disabled' }]}
|
||||
value={status}
|
||||
/>
|
||||
<div className="admin-split-filter__actions">
|
||||
<Button icon={<Search size={16} />} onClick={() => { const filters = { enterpriseKeyword: enterpriseKeyword.trim(), applicationKeyword: applicationKeyword.trim(), status }; setAppliedEnterpriseKeyword(filters.enterpriseKeyword); setAppliedApplicationKeyword(filters.applicationKeyword); setAppliedStatus(filters.status); setPage(1); void loadSmsApps(filters, 1); }}>查询</Button>
|
||||
<Button onClick={() => { const filters = { enterpriseKeyword: '', applicationKeyword: '', status: 'all' }; setEnterpriseKeyword(''); setApplicationKeyword(''); setStatus('all'); setAppliedEnterpriseKeyword(''); setAppliedApplicationKeyword(''); setAppliedStatus('all'); setPage(1); void loadSmsApps(filters, 1); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
<EnterpriseApplicationFilter
|
||||
applicationKeyword={applicationKeyword}
|
||||
enterpriseKeyword={enterpriseKeyword}
|
||||
onApplicationKeywordChange={setApplicationKeyword}
|
||||
onEnterpriseKeywordChange={setEnterpriseKeyword}
|
||||
onQuery={queryApplications}
|
||||
onReset={resetApplicationFilters}
|
||||
onStatusChange={setStatus}
|
||||
status={status}
|
||||
/>
|
||||
|
||||
{error ? <div className="surface ui-table__empty">{error}</div> : null}
|
||||
|
||||
<div className="surface section-stack">
|
||||
<Tabs
|
||||
items={[
|
||||
{ label: '短信应用', value: 'sms', content: <><Table columns={smsColumns} data={filteredSmsApps} pagination={false} rowKey="id" /><Pagination page={page} totalPages={Math.max(1, Math.ceil(total / pageSize))} total={total} onPageChange={setPage} onPrevious={() => setPage((value) => Math.max(1, value - 1))} onNext={() => setPage((value) => Math.min(Math.max(1, Math.ceil(total / pageSize)), value + 1))} previousDisabled={page <= 1} nextDisabled={page >= Math.max(1, Math.ceil(total / pageSize))} /></> },
|
||||
{ label: '彩信应用', value: 'mms', pending: true, content: <div className="ui-table__empty">彩信应用待后端能力确认,本页不展示演示数据。</div> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<EnterpriseApplicationTable
|
||||
apps={smsApps}
|
||||
onConfirmAction={setConfirmAction}
|
||||
onEdit={(app) => navigate(`/admin/customers/${app.tenantId}/sms-apps/${app.id}/edit`)}
|
||||
onOpenCmppParams={(app) => void openParams(app)}
|
||||
onOpenConnection={setConnectionApp}
|
||||
onOpenDeactivate={(app) => void openDeactivate(app)}
|
||||
onOpenHttpParams={(app) => void openHttpParams(app)}
|
||||
onPageChange={setPage}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
/>
|
||||
|
||||
{confirmAction ? (
|
||||
<ConfirmModal
|
||||
@@ -546,14 +226,14 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
? `确认删除应用“${confirmAction.name}”吗?`
|
||||
: `确认启用应用“${confirmAction.name}”吗?`}
|
||||
onCancel={() => setConfirmAction(null)}
|
||||
onConfirm={() => { void runConfirmedAction(); }}
|
||||
onConfirm={() => void runConfirmedAction()}
|
||||
/>
|
||||
) : null}
|
||||
{deactivateAction ? (
|
||||
<DeactivateApplicationModal
|
||||
app={deactivateAction.app}
|
||||
onCancel={() => setDeactivateAction(null)}
|
||||
onConfirm={(mode) => { void confirmDeactivate(mode); }}
|
||||
onConfirm={(mode) => void confirmDeactivate(mode)}
|
||||
preview={deactivateAction.preview}
|
||||
/>
|
||||
) : null}
|
||||
@@ -567,50 +247,27 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
tenants={tenants}
|
||||
/>
|
||||
) : null}
|
||||
{connectionApp ? (
|
||||
<CmppConnectionModal
|
||||
app={connectionApp}
|
||||
onClose={() => setConnectionApp(null)}
|
||||
{connectionApp ? <CmppConnectionModal app={connectionApp} onClose={() => setConnectionApp(null)} /> : null}
|
||||
{paramsApp ? (
|
||||
<CmppParamsModal
|
||||
app={paramsApp}
|
||||
onClose={() => {
|
||||
setParamsApp(null);
|
||||
setParamsDetail(null);
|
||||
}}
|
||||
params={paramsDetail}
|
||||
/>
|
||||
) : null}
|
||||
{httpParamsApp && httpParamsDetail ? (
|
||||
<HttpParamsModal
|
||||
app={httpParamsApp}
|
||||
onClose={() => {
|
||||
setHttpParamsApp(null);
|
||||
setHttpParamsDetail(null);
|
||||
}}
|
||||
params={httpParamsDetail}
|
||||
/>
|
||||
) : null}
|
||||
{paramsApp ? <CmppParamsModal app={paramsApp} params={paramsDetail} onClose={() => { setParamsApp(null); setParamsDetail(null); }} /> : null}
|
||||
{httpParamsApp && httpParamsDetail ? <HttpParamsModal app={httpParamsApp} params={httpParamsDetail} onClose={() => { setHttpParamsApp(null); setHttpParamsDetail(null); }} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
status: application.status,
|
||||
enabled: application.status === 'active',
|
||||
deactivation: application.deactivation,
|
||||
sentToday: application.sentToday ?? 0,
|
||||
deliveryRate: application.deliveryRate ?? 0,
|
||||
unitPrice: moneyUnitsToYuan(application.customerUnitPrice),
|
||||
cmppStatus: application.interfaceEnabled === false ? 'inactive' : application.cmppStatus === 'connected' ? 'connected' : application.cmppStatus === 'inactive' ? 'inactive' : 'disconnected',
|
||||
cmppParams: { host: '', port: 0, interfaceEnabled: application.interfaceEnabled !== false, interfaceType: application.interfaceType ?? 'cmpp20', enterpriseCode: application.cmppEnterpriseCode ?? application.tenant?.code ?? application.tenantId, account: application.cmppAccount ?? application.tenantId, password: '', accessNumber: '', maxConnections: application.cmppMaxConnections ?? 1, heartbeatSeconds: 30, windowSize: application.cmppWindowSize ?? 16, protocolVersion: 'CMPP2.0' },
|
||||
cmppConnections: connections,
|
||||
httpEnabled: Boolean(application.httpConfig?.enabled),
|
||||
};
|
||||
}
|
||||
|
||||
function mapConnection(connection: CmppDownstreamConnection): CmppConnection {
|
||||
const isOpen = connection.status === 'connected';
|
||||
return {
|
||||
id: connection.connectionId,
|
||||
state: isOpen ? 'open' : connection.status === 'reconnecting' ? 'reconnecting' : 'closed',
|
||||
bindType: 'transceiver',
|
||||
clientIp: String(connection.remoteIp ?? ''),
|
||||
sourceAddr: connection.enterpriseCode,
|
||||
establishedAt: formatDateTime(connection.connectedAt),
|
||||
lastHeartbeatAt: formatDateTime(connection.lastHeartbeatAt),
|
||||
lastSubmitAt: formatDateTime(connection.lastSubmitAt),
|
||||
pendingWindow: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ function mapCertification(record: EnterpriseCertification): EnterpriseAuditRecor
|
||||
|
||||
export function AdminEnterpriseAuditPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [status, setStatus] = useState('pending');
|
||||
const [records, setRecords] = useState<EnterpriseAuditRecord[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
const [detailRecord, setDetailRecord] = useState<EnterpriseAuditRecord | null>(null);
|
||||
@@ -146,7 +146,7 @@ export function AdminEnterpriseAuditPage() {
|
||||
<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>
|
||||
<Button onClick={() => { setKeyword(''); setStatus('pending'); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,598 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Edit3, FileSpreadsheet, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
|
||||
import { adminApi, type ApplicationReportField, type ClientSmsApplication, type ClientSmsSignature, type FileRef, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DeleteRiskAction, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
||||
import { displayFileName } from '@/utils/fileName';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { getSmsSignatureValidationError, hasForbiddenSmsSignatureCharacter, isCompleteSmsSignature, SMS_SIGNATURE_CHARACTER_ERROR } from '@/utils/smsSignature';
|
||||
import { FileSpreadsheet, Plus, Search } from 'lucide-react';
|
||||
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Tabs } from '@/components/ui';
|
||||
import { ReportMaterialImportModal } from './ReportMaterialImportModal';
|
||||
import { DrainageFormModal } from './enterprise-signatures/DrainageFormModal';
|
||||
import { EnterpriseSignaturesTable } from './enterprise-signatures/EnterpriseSignaturesTable';
|
||||
import { SignatureFormModal } from './enterprise-signatures/SignatureFormModal';
|
||||
import { ChannelReportStatusModal, ConfirmModal, DrainageReportModal, DrainageReportStatusModal, SignatureReportModal } from './enterprise-signatures/SignatureReportModals';
|
||||
import { buildDrainagePayload, readDrainagePayload } from './enterprise-signatures/signature.helpers';
|
||||
import type { DrainageInfo, SignatureFormState } from './enterprise-signatures/signature.types';
|
||||
|
||||
type CarrierStatus = 'approved' | 'pending' | 'rejected' | 'filing';
|
||||
|
||||
type DrainageInfo = {
|
||||
id: string;
|
||||
siteName: string;
|
||||
url: string;
|
||||
field1File?: UploadedFileRef | null;
|
||||
field2?: string;
|
||||
field3?: string;
|
||||
field4?: string;
|
||||
field5?: string;
|
||||
field6?: string;
|
||||
field7File?: UploadedFileRef | null;
|
||||
field8?: string;
|
||||
field9?: string;
|
||||
field10?: string;
|
||||
mobile: CarrierStatus;
|
||||
unicom: CarrierStatus;
|
||||
telecom: CarrierStatus;
|
||||
submittedAt: string;
|
||||
remark: string;
|
||||
reportValues: ReportValues;
|
||||
auditStatus?: string;
|
||||
rejectReason?: string | null;
|
||||
};
|
||||
|
||||
type UploadedFileRef = FileRef;
|
||||
type ReportValues = Record<string, string | UploadedFileRef | null>;
|
||||
|
||||
type SignatureFormState = {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
name: string;
|
||||
purpose: string;
|
||||
mobile: CarrierStatus;
|
||||
unicom: CarrierStatus;
|
||||
telecom: CarrierStatus;
|
||||
reportValues: ReportValues;
|
||||
};
|
||||
|
||||
type CarrierReportSummary = { status: string; approved: number; total: number };
|
||||
type SignatureCardTone = 'green' | 'blue' | 'amber' | 'red' | 'gray';
|
||||
|
||||
const carrierLabels: Record<string, string> = {
|
||||
mobile: '移动',
|
||||
unicom: '联通',
|
||||
telecom: '电信',
|
||||
all: '全网',
|
||||
};
|
||||
|
||||
function carrierLabel(carrier?: string | null) {
|
||||
if (!carrier) return '未标注运营商';
|
||||
return carrierLabels[carrier] ?? carrier;
|
||||
}
|
||||
|
||||
function CarrierReportTag({ summary }: { summary?: CarrierReportSummary }) {
|
||||
if (!summary || summary.status === 'not_applicable' || summary.total === 0) return <Tag tone="neutral">不适用</Tag>;
|
||||
let label = '未报备';
|
||||
let tone: 'success' | 'danger' | 'warning' | 'info' | 'neutral' = 'neutral';
|
||||
if (summary.status === 'approved') { label = '全部通过'; tone = 'success'; }
|
||||
else if (summary.status === 'failed' || summary.status === 'rejected') { label = '报备失败'; tone = 'danger'; }
|
||||
else if (summary.status === 'waiting_material') { label = '资料待补充'; tone = 'warning'; }
|
||||
else if (summary.approved > 0) { label = '部分通过'; tone = 'info'; }
|
||||
else if (summary.status === 'reporting' || summary.status === 'exporting') { label = '报备中'; tone = 'warning'; }
|
||||
return <span className="carrier-report-summary"><Tag tone={tone}>{label}</Tag><small>({summary.approved}/{summary.total})</small></span>;
|
||||
}
|
||||
|
||||
function signatureCardVisual(auditStatus: string, summaries?: Record<string, CarrierReportSummary>) {
|
||||
if (auditStatus === 'rejected') return { label: '签名审核已驳回', tone: 'red' as SignatureCardTone };
|
||||
if (auditStatus === 'pending') return { label: '签名待审核', tone: 'amber' as SignatureCardTone };
|
||||
if (auditStatus !== 'approved') return { label: '签名尚未提交审核', tone: 'gray' as SignatureCardTone };
|
||||
|
||||
const values = Object.values(summaries ?? {});
|
||||
const applicable = values.filter((summary) => summary.total > 0 && summary.status !== 'not_applicable');
|
||||
if (applicable.some((summary) => ['failed', 'rejected'].includes(summary.status))) return { label: '存在报备失败', tone: 'red' as SignatureCardTone };
|
||||
if (applicable.some((summary) => summary.approved > 0 && summary.approved < summary.total)) return { label: '部分通道报备通过', tone: 'blue' as SignatureCardTone };
|
||||
if (applicable.some((summary) => summary.status === 'waiting_material')) return { label: '报备资料待补充', tone: 'amber' as SignatureCardTone };
|
||||
if (applicable.some((summary) => ['reporting', 'exporting'].includes(summary.status))) return { label: '通道报备处理中', tone: 'amber' as SignatureCardTone };
|
||||
if (applicable.length > 0 && applicable.every((summary) => summary.status === 'approved')) return { label: '所有目标通道报备通过', tone: 'green' as SignatureCardTone };
|
||||
if (applicable.some((summary) => summary.approved > 0)) return { label: '部分运营商报备通过', tone: 'blue' as SignatureCardTone };
|
||||
return { label: applicable.length > 0 ? '目标通道尚未报备' : '没有适用的目标通道', tone: 'gray' as SignatureCardTone };
|
||||
}
|
||||
|
||||
function AuditStatusTag({ status }: { status: string }) {
|
||||
const meta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'danger' }> = {
|
||||
draft: { label: '草稿', tone: 'neutral' }, pending: { label: '待审核', tone: 'info' }, approved: { label: '已通过', tone: 'success' }, rejected: { label: '已驳回', tone: 'danger' },
|
||||
};
|
||||
const current = meta[status] ?? { label: status || '-', tone: 'neutral' as const };
|
||||
return <Tag tone={current.tone}>{current.label}</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 profile = typeof payload.signatureProfile === 'object' && payload.signatureProfile ? payload.signatureProfile 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),
|
||||
},
|
||||
signatureProfile: profile,
|
||||
signatureReportValues: normalizeReportValues(payload.signatureReportValues),
|
||||
links: links.map((item) => ({
|
||||
id: String(item.id ?? `drain-${Date.now()}`),
|
||||
siteName: String(item.siteName ?? ''),
|
||||
url: String(item.url ?? ''),
|
||||
field1File: normalizeUploadedFile(item.field1File),
|
||||
field2: String(item.field2 ?? ''),
|
||||
field3: String(item.field3 ?? ''),
|
||||
field4: String(item.field4 ?? ''),
|
||||
field5: String(item.field5 ?? ''),
|
||||
field6: String(item.field6 ?? ''),
|
||||
field7File: normalizeUploadedFile(item.field7File),
|
||||
field8: String(item.field8 ?? ''),
|
||||
field9: String(item.field9 ?? ''),
|
||||
field10: String(item.field10 ?? ''),
|
||||
mobile: normalizeCarrierStatus(item.mobile, 'filing'),
|
||||
unicom: normalizeCarrierStatus(item.unicom, 'filing'),
|
||||
telecom: normalizeCarrierStatus(item.telecom, 'filing'),
|
||||
submittedAt: String(item.submittedAt ?? ''),
|
||||
remark: String(item.remark ?? ''),
|
||||
reportValues: normalizeReportValues(item.reportValues),
|
||||
auditStatus: String(item.auditStatus ?? 'pending'),
|
||||
rejectReason: item.rejectReason ? String(item.rejectReason) : null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDrainagePayload(carrierStatus: { mobile: CarrierStatus; unicom: CarrierStatus; telecom: CarrierStatus }, links: DrainageInfo[], signatureProfile?: Record<string, unknown>, signatureReportValues?: ReportValues) {
|
||||
return { carrierStatus, links, signatureProfile, signatureReportValues };
|
||||
}
|
||||
|
||||
function normalizeReportValues(value: unknown): ReportValues {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [key, normalizeUploadedFile(item) ?? String(item ?? '')]));
|
||||
}
|
||||
|
||||
function hasMissingRequiredReportValue(fields: ApplicationReportField[], values: ReportValues) {
|
||||
return fields.some((field) => field.required && !values[field.code]);
|
||||
}
|
||||
|
||||
function normalizeUploadedFile(value: unknown): UploadedFileRef | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const item = value as Record<string, unknown>;
|
||||
const fileObjectId = String(item.fileObjectId ?? '');
|
||||
const fileName = String(item.fileName ?? '');
|
||||
const contentType = typeof item.contentType === 'string' ? item.contentType : undefined;
|
||||
return fileObjectId || fileName ? { contentType, fileObjectId, fileName } : null;
|
||||
}
|
||||
|
||||
function normalizeCarrierStatus(value: unknown, fallback: CarrierStatus = 'filing'): CarrierStatus {
|
||||
return value === 'approved' || value === 'pending' || value === 'rejected' || value === 'filing' ? value : fallback;
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
return formatDateTime(value);
|
||||
}
|
||||
|
||||
function SignatureUploadBox({
|
||||
compact = false,
|
||||
file,
|
||||
label,
|
||||
onUploaded,
|
||||
}: {
|
||||
compact?: boolean;
|
||||
file?: UploadedFileRef | null;
|
||||
label: string;
|
||||
onUploaded: (file: UploadedFileRef) => void;
|
||||
}) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function uploadFile(fileInput: File | undefined) {
|
||||
if (!fileInput) return;
|
||||
setUploading(true);
|
||||
setError('');
|
||||
try {
|
||||
const fileObject = await adminApi.uploadFileObject(fileInput, { purpose: 'signature_report_material', prefix: 'signature-report-materials' });
|
||||
onUploaded({ contentType: fileObject.contentType, fileObjectId: fileObject.id, fileName: fileObject.fileName });
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '文件上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<label className={compact ? 'signature-upload signature-upload--compact' : 'signature-upload'}>
|
||||
<span>{label}</span>
|
||||
<Upload size={compact ? 30 : 42} />
|
||||
<strong>{uploading ? '上传中...' : (file ? displayFileName(file.fileName) : '') || (compact ? '上传文件' : '点击上传 或拖拽文件到此处')}</strong>
|
||||
<FileActions file={file} />
|
||||
{!compact ? <small>支持 PNG、JPG、JPEG、PDF,文件大小不超过 10M</small> : null}
|
||||
{error ? <small className="form-error">{error}</small> : null}
|
||||
<input
|
||||
accept="image/png,image/jpeg,application/pdf"
|
||||
disabled={uploading}
|
||||
onChange={(event) => { void uploadFile(event.target.files?.[0]); }}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function DynamicReportFields({ fields, onChange, title, values }: { fields: ApplicationReportField[]; onChange: (code: string, value: string | UploadedFileRef | null) => void; title: string; values: ReportValues }) {
|
||||
const [explanationOpen, setExplanationOpen] = useState(false);
|
||||
if (fields.length === 0) return null;
|
||||
const channels = Array.from(new Map(fields.flatMap((field) => field.channels).map((channel) => [channel.id, channel])).values());
|
||||
const groups = Array.from(new Map(channels.map((channel) => [channel.groupId, channel.groupName])).entries());
|
||||
const requiredCount = fields.filter((field) => field.required).length;
|
||||
const commonCount = fields.filter((field) => (field.commonReportTypes?.length ?? 0) > 0).length;
|
||||
return (
|
||||
<section>
|
||||
<div className="report-requirement-heading">
|
||||
<h3>{title}</h3>
|
||||
<Button icon={<Info size={15} />} onClick={() => setExplanationOpen(true)} size="sm" variant="ghost">为什么需要这些资料?</Button>
|
||||
</div>
|
||||
<div className="signature-alert">
|
||||
<Info size={18} />
|
||||
<span>当前要求由 {commonCount} 项通用字段及 {groups.length} 个通道组、{channels.length} 个通道配置合并生成,共 {fields.length} 项,其中 {requiredCount} 项必填。保存时会固化本次要求快照。</span>
|
||||
</div>
|
||||
<div className="signature-form-grid">
|
||||
{fields.map((field) => {
|
||||
const channelHint = field.channels.map((channel) => channel.name).join('、');
|
||||
const requiredChannels = field.required ? field.channels.filter((channel) => channel.required).map((channel) => channel.name).join('、') : '';
|
||||
const isCommon = (field.commonReportTypes?.length ?? 0) > 0;
|
||||
const label = `${field.required ? '* ' : ''}${field.name}`;
|
||||
const hint = isCommon
|
||||
? `平台通用${field.required ? '必填' : '选填'}资料${channelHint ? `,适用于:${channelHint}` : ''}`
|
||||
: field.required
|
||||
? `由 ${requiredChannels} 要求,至少一个通道配置为必填`
|
||||
: `适用通道:${channelHint}`;
|
||||
return field.fieldType === 'file' || field.fieldType === 'image' ? (
|
||||
<div key={field.id}>
|
||||
<SignatureUploadBox compact file={typeof values[field.code] === 'object' ? values[field.code] as UploadedFileRef : null} label={label} onUploaded={(file) => onChange(field.code, file)} />
|
||||
<small className="report-field-source">{hint}</small>
|
||||
</div>
|
||||
) : (
|
||||
<div key={field.id}>
|
||||
<Input label={label} onChange={(event) => onChange(field.code, event.target.value)} placeholder={field.description ?? `请输入${field.name}`} required={field.required} value={typeof values[field.code] === 'string' ? values[field.code] as string : ''} />
|
||||
<small className="report-field-source">{hint}</small>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Modal footer={<Button onClick={() => setExplanationOpen(false)}>我知道了</Button>} onClose={() => setExplanationOpen(false)} open={explanationOpen} size="xl" title="这些资料从哪里来?">
|
||||
<div className="report-requirement-explanation">
|
||||
<p>资料要求由“报备字段库通用配置”和“企业应用 → 通道组 → 通道 → 通道报备字段”实时合并;相同字段只填写一次,但会按目标通道分别用于报备。</p>
|
||||
{commonCount > 0 ? (
|
||||
<section className="report-source-group">
|
||||
<h4>平台通用字段</h4>
|
||||
<ul>{fields.filter((field) => (field.commonReportTypes?.length ?? 0) > 0).map((field) => <li key={field.id}>{field.name} · {field.commonReportTypes?.includes('signature') ? '签名报备' : '引流信息报备'} · {field.required ? '必填' : '选填'}</li>)}</ul>
|
||||
</section>
|
||||
) : null}
|
||||
{groups.map(([groupId, groupName]) => (
|
||||
<section className="report-source-group" key={groupId}>
|
||||
<h4>通道组:{groupName}</h4>
|
||||
{channels.filter((channel) => channel.groupId === groupId).map((channel) => (
|
||||
<div className="report-source-channel" key={channel.id}>
|
||||
<strong>{channel.name}({channel.code})</strong>
|
||||
<ul>
|
||||
{fields.filter((field) => field.channels.some((source) => source.id === channel.id)).map((field) => (
|
||||
<li key={field.id}>{field.name} · {field.channels.find((source) => source.id === channel.id)?.reportType === 'both' ? '签名和引流共用' : field.channels.find((source) => source.id === channel.id)?.reportType === 'signature' ? '签名报备' : '引流信息报备'} · {field.channels.find((source) => source.id === channel.id)?.required ? '必填' : '选填'}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
reportValues: payload?.signatureReportValues ?? {},
|
||||
});
|
||||
const [reportFields, setReportFields] = useState<ApplicationReportField[]>([]);
|
||||
const [nameInputError, setNameInputError] = useState('');
|
||||
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted');
|
||||
|
||||
useEffect(() => {
|
||||
const request = form.applicationId
|
||||
? adminApi.listApplicationReportFields(form.applicationId, 'signature')
|
||||
: adminApi.listCommonApplicationReportFields('signature');
|
||||
request.then(setReportFields).catch(() => setReportFields([]));
|
||||
}, [form.applicationId]);
|
||||
|
||||
function update<Key extends keyof SignatureFormState>(key: Key, value: SignatureFormState[Key]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function updateReportValue(code: string, value: string | UploadedFileRef | null) {
|
||||
setForm((current) => ({ ...current, reportValues: { ...current.reportValues, [code]: value } }));
|
||||
}
|
||||
|
||||
const signatureNameValid = !nameInputError && isCompleteSmsSignature(form.name);
|
||||
const signatureNameError = nameInputError || (form.name ? getSmsSignatureValidationError(form.name) : undefined);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button disabled={!form.tenantId || !signatureNameValid || hasMissingRequiredReportValue(reportFields, form.reportValues)} onClick={() => onSubmit(form)}>保存</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={(
|
||||
<div className="signature-modal-title">
|
||||
<h2>{item ? '编辑签名' : '添加签名'}</h2>
|
||||
<p>{item ? '修改短信签名的相关信息' : '新增短信签名的相关信息'}</p>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="signature-form">
|
||||
<section>
|
||||
<h3>基本信息</h3>
|
||||
<div className="signature-alert">
|
||||
<Info size={18} />
|
||||
<span>签名名称必须包含完整中文黑括号,例如:【某某科技】。签名需履行报备,并遵照管理部门审核结果方可使用。</span>
|
||||
</div>
|
||||
<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
|
||||
error={signatureNameError}
|
||||
hint="新增和编辑时必须保留完整的【】,且不能包含空格或不可见字符"
|
||||
label="短信签名"
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
if (hasForbiddenSmsSignatureCharacter(value)) {
|
||||
setNameInputError(SMS_SIGNATURE_CHARACTER_ERROR);
|
||||
return;
|
||||
}
|
||||
setNameInputError('');
|
||||
update('name', value);
|
||||
}}
|
||||
placeholder="请输入完整签名,例如:【某某科技】"
|
||||
required
|
||||
value={form.name}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DynamicReportFields fields={reportFields} onChange={updateReportValue} title="签名报备资料(通用 + 通道)" values={form.reportValues} />
|
||||
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrainageFormModal({ applicationId, item, onClose, onSubmit }: { applicationId?: string | null; item?: DrainageInfo; onClose: () => void; onSubmit: (item: DrainageInfo) => void }) {
|
||||
const [reportFields, setReportFields] = useState<ApplicationReportField[]>([]);
|
||||
const [form, setForm] = useState<DrainageInfo>(() => item ?? {
|
||||
id: `drain-${Date.now()}`,
|
||||
siteName: '',
|
||||
url: '',
|
||||
field1File: null,
|
||||
field2: '',
|
||||
field3: '',
|
||||
field4: '',
|
||||
field5: '',
|
||||
field6: '',
|
||||
field7File: null,
|
||||
field8: '',
|
||||
field9: '',
|
||||
field10: '',
|
||||
mobile: 'filing',
|
||||
unicom: 'filing',
|
||||
telecom: 'filing',
|
||||
submittedAt: formatDateTime(new Date()),
|
||||
remark: '',
|
||||
reportValues: {},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const request = applicationId
|
||||
? adminApi.listApplicationReportFields(applicationId, 'drainage')
|
||||
: adminApi.listCommonApplicationReportFields('drainage');
|
||||
request.then(setReportFields).catch(() => setReportFields([]));
|
||||
}, [applicationId]);
|
||||
|
||||
function update<Key extends keyof DrainageInfo>(key: Key, value: DrainageInfo[Key]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function updateReportValue(code: string, value: string | UploadedFileRef | null) {
|
||||
setForm((current) => ({ ...current, reportValues: { ...current.reportValues, [code]: value } }));
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button disabled={!form.url || hasMissingRequiredReportValue(reportFields, form.reportValues)} onClick={() => onSubmit(form)}>保存</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={item ? '编辑引流信息' : '添加引流信息'}
|
||||
>
|
||||
<div className="signature-form drainage-edit-form">
|
||||
<section>
|
||||
<h3>基本信息</h3>
|
||||
<Input
|
||||
label="* 引流url或号码"
|
||||
onChange={(event) => update('url', event.target.value)}
|
||||
placeholder="请输入引流url或号码"
|
||||
required
|
||||
value={form.url}
|
||||
/>
|
||||
<div className="signature-alert drainage-form-note">
|
||||
<Info size={18} />
|
||||
<ol>
|
||||
<li>本页面中所填的信息需与短信内容应用所包含的网站或服务保持一致;</li>
|
||||
<li>图片仅支持 PNG、JPG 或 JPEG 格式的正版文件,且大小不超过 3M;</li>
|
||||
<li>文件格式支持 PDF 格式或者图片,且大小不超过 10M。</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="signature-form-grid">
|
||||
<Textarea className="signature-form-grid__wide" label="备注" onChange={(event) => update('remark', event.target.value)} rows={4} value={form.remark} />
|
||||
</div>
|
||||
<DynamicReportFields fields={reportFields} onChange={updateReportValue} title="引流信息报备资料(通用 + 通道)" values={form.reportValues} />
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function SignatureReportModal({ item, onClose }: { item: ClientSmsSignature; onClose: () => void }) {
|
||||
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><AuditStatusTag status={item.auditStatus} /></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><CarrierReportTag summary={item.carrierReportSummary?.mobile} /></span></button>
|
||||
<button className="admin-report-carrier--unicom active" type="button"><strong>联通</strong><span><CarrierReportTag summary={item.carrierReportSummary?.unicom} /></span></button>
|
||||
<button className="admin-report-carrier--telecom active" type="button"><strong>电信</strong><span><CarrierReportTag summary={item.carrierReportSummary?.telecom} /></span></button>
|
||||
</div>
|
||||
<div className="page-stack">{(item.reportTargets ?? []).map((target) => <div className="surface" key={target.channelId} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}({carrierLabel(target.channel.carrier)})</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const reportStatusOptions = [
|
||||
{ label: '未报备', value: 'pending' }, { label: '资料待补充', value: 'waiting_material' },
|
||||
{ label: '报备中', value: 'reporting' }, { label: '报备通过', value: 'approved' },
|
||||
{ label: '报备失败', value: 'failed' }, { label: '放弃报备', value: 'abandoned' },
|
||||
];
|
||||
|
||||
function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsSignature; onClose: () => void; onSaved: () => void }) {
|
||||
const targets = item.reportTargets ?? [];
|
||||
const [statuses, setStatuses] = useState<Record<string, string>>(() => Object.fromEntries(targets.map((target) => [target.channelId, target.status])));
|
||||
const [reason, setReason] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: item.id, channelId: target.channelId, status: statuses[target.channelId] ?? target.status })), reason, sourceEntry: 'enterprise_signature' });
|
||||
onSaved();
|
||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '报备状态保存失败'); } finally { setSaving(false); }
|
||||
}
|
||||
return <Modal footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="按通道修改签名报备状态">
|
||||
<div className="page-stack"><div className="signature-alert"><Info size={18} /><span>企业签名只展示汇总结果;这里修改的是每个具体通道的报备任务,保存后会同步通道详情、报备任务和企业签名三网状态。</span></div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{targets.length ? targets.map((target) => <div className="surface admin-report-target-row" key={target.channelId}><div><strong>{target.channel.name}</strong><div className="muted">{carrierLabel(target.channel.carrier)} · {target.channel.name}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state">该企业应用当前没有配置目标通道。</div>}
|
||||
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入运营商工单、确认依据或人工处理说明" rows={3} value={reason} />
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
function DrainageReportModal({ item, onClose, signature }: { item: DrainageInfo; onClose: () => void; signature: ClientSmsSignature }) {
|
||||
const summary = signature.drainageCarrierReportSummary?.[item.id];
|
||||
const targets = signature.drainageReportTargets?.[item.id] ?? [];
|
||||
return (
|
||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open size="xl" title="引流信息报备详情">
|
||||
<div className="detail-grid">
|
||||
<div><span>引流url或号码</span><strong>{item.url}</strong></div>
|
||||
<div><span>引流url或号码</span><strong>{item.url}</strong></div>
|
||||
<div><span>移动</span><CarrierReportTag summary={summary?.mobile} /></div>
|
||||
<div><span>联通</span><CarrierReportTag summary={summary?.unicom} /></div>
|
||||
<div><span>电信</span><CarrierReportTag summary={summary?.telecom} /></div>
|
||||
<div className="detail-grid__wide"><span>备注</span><strong>{item.remark || '-'}</strong></div>
|
||||
</div>
|
||||
<div className="page-stack" style={{ marginTop: 16 }}>{targets.map((target) => <div className="surface" key={target.channelId} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}({carrierLabel(target.channel.carrier)})</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrainageReportStatusModal({ item, onClose, onSaved, signature }: { item: DrainageInfo; onClose: () => void; onSaved: () => void; signature: ClientSmsSignature }) {
|
||||
const targets = signature.drainageReportTargets?.[item.id] ?? [];
|
||||
const [statuses, setStatuses] = useState<Record<string, string>>(() => Object.fromEntries(targets.map((target) => [target.channelId, target.status])));
|
||||
const [reason, setReason] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: signature.id, channelId: target.channelId, reportType: 'drainage', drainageItemId: item.id, status: statuses[target.channelId] ?? target.status })), reason, sourceEntry: 'enterprise_signature' });
|
||||
onSaved();
|
||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '引流报备状态保存失败'); } finally { setSaving(false); }
|
||||
}
|
||||
return <Modal footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="按通道修改引流信息报备状态">
|
||||
<div className="page-stack"><div className="signature-alert"><Info size={18} /><span>修改的是当前引流信息在具体通道上的真实报备任务,保存后会同步通道报备详情和报备任务页。</span></div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{targets.length ? targets.map((target) => <div className="surface admin-report-target-row" key={target.channelId}><div><strong>{target.channel.name}</strong><div className="muted">{carrierLabel(target.channel.carrier)} · {target.channel.name}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state">当前应用的目标通道没有配置引流信息报备字段。</div>}
|
||||
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} rows={3} value={reason} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
/** R4 page container: owns query state and coordinates focused presentation components. */
|
||||
export function AdminEnterpriseSignaturesPage() {
|
||||
const [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms');
|
||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||
@@ -704,89 +122,25 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
}
|
||||
|
||||
const smsSignatureContent = (
|
||||
<div className="signature-list admin-enterprise-signature-list">
|
||||
{visibleSignatures.map((signature) => {
|
||||
const payload = readDrainagePayload(signature);
|
||||
const visibleDrainageLinks = appliedDrainageKeyword
|
||||
? payload.links.filter((item) => `${item.siteName} ${item.url} ${item.remark}`.includes(appliedDrainageKeyword))
|
||||
: payload.links;
|
||||
const cardVisual = signatureCardVisual(signature.auditStatus, signature.carrierReportSummary);
|
||||
const expanded = expandedSignatureId === signature.id || Boolean(appliedDrainageKeyword);
|
||||
return (
|
||||
<article aria-label={`签名总体状态:${cardVisual.label}`} className={`signature-card signature-card--${cardVisual.tone}`} key={signature.id} title={`总体状态:${cardVisual.label}`}>
|
||||
<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><AuditStatusTag status={signature.auditStatus} /></div>
|
||||
<div><span>移动</span><CarrierReportTag summary={signature.carrierReportSummary?.mobile} /></div>
|
||||
<div><span>联通</span><CarrierReportTag summary={signature.carrierReportSummary?.unicom} /></div>
|
||||
<div><span>电信</span><CarrierReportTag summary={signature.carrierReportSummary?.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={() => setReportStatusTarget(signature)} size="sm" variant="ghost">报备状态</Button>
|
||||
<Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost">编辑</Button>
|
||||
<DeleteRiskAction onCompleted={() => void loadData()} portal="admin" targetId={signature.id} targetType="signature" />
|
||||
</div>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className="drainage-panel">
|
||||
<h2>引流信息列表</h2>
|
||||
{visibleDrainageLinks.length ? (
|
||||
<div className="drainage-table">
|
||||
<div className="drainage-table__head">
|
||||
<span>引流url或号码</span>
|
||||
<span>审核状态</span>
|
||||
<span>移动</span>
|
||||
<span>联通</span>
|
||||
<span>电信</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{visibleDrainageLinks.map((item) => {
|
||||
const summary = signature.drainageCarrierReportSummary?.[item.id];
|
||||
return (
|
||||
<div className="drainage-table__row" key={item.id}>
|
||||
<span className="drainage-table__url" title={item.url}>{item.url}</span>
|
||||
<AuditStatusTag status={item.auditStatus ?? 'pending'} />
|
||||
<CarrierReportTag summary={summary?.mobile} />
|
||||
<CarrierReportTag summary={summary?.unicom} />
|
||||
<CarrierReportTag summary={summary?.telecom} />
|
||||
<span className="drainage-row-actions">
|
||||
<Button onClick={() => setDrainageReport({ signature, item })} size="sm" variant="ghost">报备详情</Button>
|
||||
<Button disabled={item.auditStatus !== 'approved'} onClick={() => setDrainageStatusTarget({ signature, 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.url })} 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>
|
||||
);
|
||||
})}
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
|
||||
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
|
||||
page={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={total}
|
||||
/>
|
||||
{filteredSignatures.length === 0 ? <div className="ui-table__empty">暂无企业签名</div> : null}
|
||||
</div>
|
||||
<EnterpriseSignaturesTable
|
||||
appliedDrainageKeyword={appliedDrainageKeyword}
|
||||
currentPage={currentPage}
|
||||
expandedSignatureId={expandedSignatureId}
|
||||
filteredSignatures={filteredSignatures}
|
||||
loadData={loadData}
|
||||
setDeleteTarget={setDeleteTarget}
|
||||
setDrainageModal={setDrainageModal}
|
||||
setDrainageReport={setDrainageReport}
|
||||
setDrainageStatusTarget={setDrainageStatusTarget}
|
||||
setExpandedSignatureId={setExpandedSignatureId}
|
||||
setPage={setPage}
|
||||
setReportStatusTarget={setReportStatusTarget}
|
||||
setSignatureModal={setSignatureModal}
|
||||
setSignatureReport={setSignatureReport}
|
||||
total={total}
|
||||
totalPages={totalPages}
|
||||
visibleSignatures={visibleSignatures}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Pencil, Plus, RefreshCw } from 'lucide-react';
|
||||
import { Pencil, Plus, RefreshCw, Search, Trash2, Unlock } from 'lucide-react';
|
||||
import {
|
||||
adminApi,
|
||||
type EnterpriseApplication,
|
||||
type PhoneFrequencyHit,
|
||||
type PhoneFrequencyWhitelistItem,
|
||||
type RiskRuleItem,
|
||||
} from '@/api/adminApi';
|
||||
import {
|
||||
@@ -10,18 +12,27 @@ import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
Pagination,
|
||||
Select,
|
||||
Table,
|
||||
Tag,
|
||||
Textarea,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
const definitions: Array<{ code: RiskRuleItem['code']; label: string; unit: string }> = [
|
||||
{ code: 'MAX_PHONES_PER_TASK', label: '单任务最大号码数', unit: '个号码' },
|
||||
{ code: 'NON_WORKING_MARKETING_BULK', label: '非工作时间大批量营销发送', unit: '个号码' },
|
||||
{ code: 'TASK_CREATE_FREQUENCY', label: '10分钟客户端任务创建频控', unit: '个任务' },
|
||||
{ code: 'PHONE_FREQUENCY_24H', label: '单号码24小时发送频次', unit: '条业务短信' },
|
||||
{ code: 'PHONE_FREQUENCY_5M', label: '单号码5分钟发送频次', unit: '条业务短信' },
|
||||
];
|
||||
|
||||
function isPhoneFrequencyRule(code: RiskRuleItem['code']) {
|
||||
return code === 'PHONE_FREQUENCY_24H' || code === 'PHONE_FREQUENCY_5M';
|
||||
}
|
||||
|
||||
type EditorState = {
|
||||
id?: string;
|
||||
applicationId: string;
|
||||
@@ -34,6 +45,14 @@ type EditorState = {
|
||||
endTime: string;
|
||||
};
|
||||
|
||||
type WhitelistEditorState = {
|
||||
id?: string;
|
||||
phoneNumber: string;
|
||||
reason: string;
|
||||
remark: string;
|
||||
status: 'active' | 'inactive';
|
||||
};
|
||||
|
||||
function editorFromRule(rule?: RiskRuleItem): EditorState {
|
||||
return {
|
||||
id: rule?.id,
|
||||
@@ -55,6 +74,23 @@ export function AdminRiskRulesPage() {
|
||||
const [editor, setEditor] = useState<EditorState | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [frequencyHits, setFrequencyHits] = useState<PhoneFrequencyHit[]>([]);
|
||||
const [hitPhone, setHitPhone] = useState('');
|
||||
const [hitStatus, setHitStatus] = useState<'active' | 'expired' | 'released' | ''>('active');
|
||||
const [hitPage, setHitPage] = useState(1);
|
||||
const [hitTotal, setHitTotal] = useState(0);
|
||||
const [releaseHit, setReleaseHit] = useState<PhoneFrequencyHit | null>(null);
|
||||
const [releaseReason, setReleaseReason] = useState('');
|
||||
const [releasing, setReleasing] = useState(false);
|
||||
const [whitelist, setWhitelist] = useState<PhoneFrequencyWhitelistItem[]>([]);
|
||||
const [whitelistPhone, setWhitelistPhone] = useState('');
|
||||
const [whitelistStatus, setWhitelistStatus] = useState<'active' | 'inactive' | 'deleted' | ''>('');
|
||||
const [whitelistPage, setWhitelistPage] = useState(1);
|
||||
const [whitelistTotal, setWhitelistTotal] = useState(0);
|
||||
const [whitelistEditor, setWhitelistEditor] = useState<WhitelistEditorState | null>(null);
|
||||
const [whitelistSaving, setWhitelistSaving] = useState(false);
|
||||
const [deletingWhitelist, setDeletingWhitelist] = useState<PhoneFrequencyWhitelistItem | null>(null);
|
||||
const [deleteWhitelistReason, setDeleteWhitelistReason] = useState('');
|
||||
|
||||
function load() {
|
||||
Promise.all([
|
||||
@@ -69,6 +105,42 @@ export function AdminRiskRulesPage() {
|
||||
|
||||
useEffect(load, [applicationId]);
|
||||
|
||||
function loadFrequencyHits(page = hitPage) {
|
||||
adminApi.listPhoneFrequencyHits({
|
||||
applicationId: applicationId || undefined,
|
||||
phoneNumber: hitPhone.trim() || undefined,
|
||||
status: hitStatus || undefined,
|
||||
page,
|
||||
pageSize: 20,
|
||||
}).then((result) => {
|
||||
setFrequencyHits(result.items);
|
||||
setHitTotal(result.total);
|
||||
setHitPage(result.page);
|
||||
}).catch((failure: Error) => setError(failure.message || '号码频次触发记录加载失败'));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setHitPage(1);
|
||||
loadFrequencyHits(1);
|
||||
}, [applicationId]);
|
||||
|
||||
function loadWhitelist(page = whitelistPage) {
|
||||
adminApi.listPhoneFrequencyWhitelist({
|
||||
phoneNumber: whitelistPhone.trim() || undefined,
|
||||
status: whitelistStatus || undefined,
|
||||
page,
|
||||
pageSize: 20,
|
||||
}).then((result) => {
|
||||
setWhitelist(result.items);
|
||||
setWhitelistTotal(result.total);
|
||||
setWhitelistPage(result.page);
|
||||
}).catch((failure: Error) => setError(failure.message || '号码频控白名单加载失败'));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadWhitelist(1);
|
||||
}, []);
|
||||
|
||||
const existingCodes = useMemo(
|
||||
() => new Set(rules.filter((rule) => rule.applicationId === editor?.applicationId).map((rule) => rule.code)),
|
||||
[editor?.applicationId, rules],
|
||||
@@ -87,9 +159,10 @@ export function AdminRiskRulesPage() {
|
||||
}
|
||||
setSaving(true);
|
||||
setError('');
|
||||
const phoneFrequencyRule = isPhoneFrequencyRule(editor.code);
|
||||
const body = {
|
||||
thresholdValue,
|
||||
action: editor.action,
|
||||
action: phoneFrequencyRule ? 'block' as const : editor.action,
|
||||
status: editor.status,
|
||||
priority: Number(editor.priority) || 100,
|
||||
config: editor.code === 'NON_WORKING_MARKETING_BULK'
|
||||
@@ -115,6 +188,78 @@ export function AdminRiskRulesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRelease() {
|
||||
if (!releaseHit) return;
|
||||
if (!releaseReason.trim()) {
|
||||
setError('解除并清零时必须填写原因');
|
||||
return;
|
||||
}
|
||||
setReleasing(true);
|
||||
try {
|
||||
await adminApi.releasePhoneFrequencyHit(releaseHit.id, releaseReason.trim());
|
||||
setReleaseHit(null);
|
||||
setReleaseReason('');
|
||||
loadFrequencyHits();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '解除号码频控失败');
|
||||
} finally {
|
||||
setReleasing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWhitelist() {
|
||||
if (!whitelistEditor) return;
|
||||
if (!/^(\+?86)?1\d{10}$/.test(whitelistEditor.phoneNumber.replace(/[\s-]/g, ''))) {
|
||||
setError('请输入有效的中国大陆11位手机号码');
|
||||
return;
|
||||
}
|
||||
if (!whitelistEditor.reason.trim()) {
|
||||
setError('白名单用途说明不能为空');
|
||||
return;
|
||||
}
|
||||
setWhitelistSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const body = {
|
||||
phoneNumber: whitelistEditor.phoneNumber.trim(),
|
||||
reason: whitelistEditor.reason.trim(),
|
||||
remark: whitelistEditor.remark.trim(),
|
||||
status: whitelistEditor.status,
|
||||
};
|
||||
if (whitelistEditor.id) {
|
||||
await adminApi.updatePhoneFrequencyWhitelist(whitelistEditor.id, body);
|
||||
} else {
|
||||
await adminApi.createPhoneFrequencyWhitelist(body);
|
||||
}
|
||||
setWhitelistEditor(null);
|
||||
loadWhitelist(1);
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '号码频控白名单保存失败');
|
||||
} finally {
|
||||
setWhitelistSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteWhitelist() {
|
||||
if (!deletingWhitelist) return;
|
||||
if (!deleteWhitelistReason.trim()) {
|
||||
setError('删除白名单时必须填写原因');
|
||||
return;
|
||||
}
|
||||
setWhitelistSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await adminApi.deletePhoneFrequencyWhitelist(deletingWhitelist.id, deleteWhitelistReason.trim());
|
||||
setDeletingWhitelist(null);
|
||||
setDeleteWhitelistReason('');
|
||||
loadWhitelist(1);
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '号码频控白名单删除失败');
|
||||
} finally {
|
||||
setWhitelistSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<RiskRuleItem>> = [
|
||||
{ key: 'name', title: '规则名称', render: (rule) => <div><strong>{rule.name}</strong><small className="table-subline">{rule.description}</small></div> },
|
||||
{ key: 'scope', title: '适用范围', render: (rule) => rule.application ? <div><strong>{rule.application.name}</strong><small className="table-subline">{rule.application.tenant?.name ?? '-'}</small></div> : <Tag tone="info">全局默认</Tag> },
|
||||
@@ -131,6 +276,45 @@ export function AdminRiskRulesPage() {
|
||||
{ key: 'actions', title: '操作', width: '100px', align: 'right', render: (rule) => <Button icon={<Pencil size={15} />} onClick={() => setEditor(editorFromRule(rule))} size="sm" variant="ghost">编辑</Button> },
|
||||
];
|
||||
|
||||
const hitColumns: Array<TableColumn<PhoneFrequencyHit>> = [
|
||||
{ key: 'phone', title: '号码', width: '140px', render: (hit) => <strong>{hit.phoneNumber}</strong> },
|
||||
{ key: 'scope', title: '企业 / 应用', render: (hit) => <div><strong>{hit.tenant.name}</strong><small className="table-subline">{hit.application.name}</small></div> },
|
||||
{ key: 'rule', title: '命中规则', render: (hit) => <div><strong>{hit.ruleName}</strong><small className="table-subline">阈值 {hit.thresholdValue} 条,触发值 {hit.actualValue} 条</small></div> },
|
||||
{ key: 'window', title: '计数周期', width: '250px', render: (hit) => `${formatDateTime(hit.windowStartedAt)} 至 ${formatDateTime(hit.windowEndsAt)}` },
|
||||
{ key: 'status', title: '状态', width: '100px', render: (hit) => hit.releasedAt
|
||||
? <Tag tone="neutral">已解除</Tag>
|
||||
: new Date(hit.windowEndsAt).getTime() <= Date.now()
|
||||
? <Tag tone="warning">已到期</Tag>
|
||||
: <Tag tone="danger">拦截中</Tag> },
|
||||
{ key: 'createdAt', title: '触发时间', width: '170px', render: (hit) => formatDateTime(hit.createdAt) },
|
||||
{ key: 'actions', title: '操作', width: '110px', align: 'right', render: (hit) => hit.releasedAt
|
||||
? <span className="muted">已清零</span>
|
||||
: <Button icon={<Unlock size={15} />} onClick={() => { setReleaseHit(hit); setReleaseReason(''); }} size="sm" variant="ghost">解除</Button> },
|
||||
];
|
||||
|
||||
const whitelistColumns: Array<TableColumn<PhoneFrequencyWhitelistItem>> = [
|
||||
{ key: 'phone', title: '手机号码', width: '145px', render: (item) => <strong>{item.phoneNumber}</strong> },
|
||||
{ key: 'reason', title: '用途说明', render: (item) => <div><strong>{item.reason}</strong>{item.remark ? <small className="table-subline">{item.remark}</small> : null}</div> },
|
||||
{ key: 'status', title: '状态', width: '90px', render: (item) => <Tag tone={item.status === 'active' ? 'success' : item.status === 'deleted' ? 'danger' : 'neutral'}>{item.status === 'active' ? '启用' : item.status === 'deleted' ? '已删除' : '停用'}</Tag> },
|
||||
{ key: 'operator', title: '最后操作人', width: '150px', render: (item) => item.updatedBy.displayName || item.updatedBy.username },
|
||||
{ key: 'updatedAt', title: '更新时间', width: '170px', render: (item) => formatDateTime(item.updatedAt) },
|
||||
{ key: 'actions', title: '操作', width: '175px', align: 'right', render: (item) => item.status === 'deleted'
|
||||
? <span className="muted">历史记录</span>
|
||||
: <div className="table-actions">
|
||||
<Button icon={<Pencil size={15} />} onClick={() => setWhitelistEditor({
|
||||
id: item.id,
|
||||
phoneNumber: item.phoneNumber,
|
||||
reason: item.reason,
|
||||
remark: item.remark ?? '',
|
||||
status: item.status === 'active' ? 'active' : 'inactive',
|
||||
})} size="sm" variant="ghost">编辑</Button>
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => { setDeletingWhitelist(item); setDeleteWhitelistReason(''); }} size="sm" variant="ghost">删除</Button>
|
||||
</div> },
|
||||
];
|
||||
|
||||
const hitTotalPages = Math.max(1, Math.ceil(hitTotal / 20));
|
||||
const whitelistTotalPages = Math.max(1, Math.ceil(whitelistTotal / 20));
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="page-heading">
|
||||
@@ -156,6 +340,77 @@ export function AdminRiskRulesPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="surface"><Table columns={columns} data={rules} emptyText="暂无风控规则" rowKey="id" /></div>
|
||||
<div className="surface">
|
||||
<div className="section-heading">
|
||||
<div><h2>平台级号码频控白名单</h2><p>启用后,该号码在全平台所有企业应用下均不受24小时和5分钟号码频次限制;其他风控规则仍正常执行。</p></div>
|
||||
<div className="page-heading__actions">
|
||||
<Tag tone="info">{whitelistTotal} 条</Tag>
|
||||
<Button icon={<Plus size={16} />} onClick={() => setWhitelistEditor({ phoneNumber: '', reason: '', remark: '', status: 'active' })}>新增白名单</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sms-audit-filter">
|
||||
<Input label="手机号码" onChange={(event) => setWhitelistPhone(event.target.value)} placeholder="输入完整或部分号码" value={whitelistPhone} />
|
||||
<Select
|
||||
label="白名单状态"
|
||||
onChange={(event) => setWhitelistStatus(event.target.value as typeof whitelistStatus)}
|
||||
options={[
|
||||
{ label: '全部有效记录', value: '' },
|
||||
{ label: '启用', value: 'active' },
|
||||
{ label: '停用', value: 'inactive' },
|
||||
{ label: '已删除', value: 'deleted' },
|
||||
]}
|
||||
value={whitelistStatus}
|
||||
/>
|
||||
<div className="admin-task-filter__actions">
|
||||
<Button icon={<Search size={16} />} onClick={() => loadWhitelist(1)}>查询</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Table columns={whitelistColumns} data={whitelist} emptyText="暂无号码频控白名单" pagination={false} rowKey="id" />
|
||||
<Pagination
|
||||
nextDisabled={whitelistPage >= whitelistTotalPages}
|
||||
onNext={() => loadWhitelist(Math.min(whitelistTotalPages, whitelistPage + 1))}
|
||||
onPageChange={(page) => loadWhitelist(page)}
|
||||
onPrevious={() => loadWhitelist(Math.max(1, whitelistPage - 1))}
|
||||
page={whitelistPage}
|
||||
previousDisabled={whitelistPage <= 1}
|
||||
total={whitelistTotal}
|
||||
totalPages={whitelistTotalPages}
|
||||
/>
|
||||
</div>
|
||||
<div className="surface">
|
||||
<div className="section-heading">
|
||||
<div><h2>号码频次触发记录</h2><p>按企业应用和号码隔离计数;周期到期自动重新计数,人工解除会立即清零当前周期并保留审计记录。</p></div>
|
||||
<Tag tone="warning">{hitTotal} 条</Tag>
|
||||
</div>
|
||||
<div className="sms-audit-filter">
|
||||
<Input label="手机号码" onChange={(event) => setHitPhone(event.target.value)} placeholder="输入完整或部分号码" value={hitPhone} />
|
||||
<Select
|
||||
label="记录状态"
|
||||
onChange={(event) => setHitStatus(event.target.value as typeof hitStatus)}
|
||||
options={[
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '拦截中', value: 'active' },
|
||||
{ label: '周期已到期', value: 'expired' },
|
||||
{ label: '已人工解除', value: 'released' },
|
||||
]}
|
||||
value={hitStatus}
|
||||
/>
|
||||
<div className="admin-task-filter__actions">
|
||||
<Button icon={<Search size={16} />} onClick={() => { setHitPage(1); loadFrequencyHits(1); }}>查询</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Table columns={hitColumns} data={frequencyHits} emptyText="暂无号码频次触发记录" pagination={false} rowKey="id" />
|
||||
<Pagination
|
||||
nextDisabled={hitPage >= hitTotalPages}
|
||||
onNext={() => loadFrequencyHits(Math.min(hitTotalPages, hitPage + 1))}
|
||||
onPageChange={(page) => loadFrequencyHits(page)}
|
||||
onPrevious={() => loadFrequencyHits(Math.max(1, hitPage - 1))}
|
||||
page={hitPage}
|
||||
previousDisabled={hitPage <= 1}
|
||||
total={hitTotal}
|
||||
totalPages={hitTotalPages}
|
||||
/>
|
||||
</div>
|
||||
{editor ? <Modal
|
||||
footer={<><Button disabled={saving} onClick={() => setEditor(null)} variant="ghost">取消</Button><Button disabled={saving} onClick={() => void save()}>{saving ? '保存中…' : '保存'}</Button></>}
|
||||
onClose={() => setEditor(null)}
|
||||
@@ -180,8 +435,8 @@ export function AdminRiskRulesPage() {
|
||||
options={definitions.filter((item) => !existingCodes.has(item.code) || item.code === editor.code).map((item) => ({ label: item.label, value: item.code }))}
|
||||
value={editor.code}
|
||||
/> : <Input disabled label="规则" value={definitions.find((item) => item.code === editor.code)?.label ?? editor.code} />}
|
||||
<Input label={`阈值(${definitions.find((item) => item.code === editor.code)?.unit ?? ''})`} min="0" onChange={(event) => setEditor({ ...editor, thresholdValue: event.target.value })} type="number" value={editor.thresholdValue} />
|
||||
<Select label="处理动作" onChange={(event) => setEditor({ ...editor, action: event.target.value as RiskRuleItem['action'] })} options={[{ label: '直接拒绝', value: 'block' }, { label: '进入人工审核', value: 'manual_review' }]} value={editor.action} />
|
||||
<Input label={`阈值(${definitions.find((item) => item.code === editor.code)?.unit ?? ''})`} min={isPhoneFrequencyRule(editor.code) ? '1' : '0'} onChange={(event) => setEditor({ ...editor, thresholdValue: event.target.value })} type="number" value={editor.thresholdValue} />
|
||||
<Select disabled={isPhoneFrequencyRule(editor.code)} label="处理动作" onChange={(event) => setEditor({ ...editor, action: event.target.value as RiskRuleItem['action'] })} options={isPhoneFrequencyRule(editor.code) ? [{ label: '直接拒绝(首版固定)', value: 'block' }] : [{ label: '直接拒绝', value: 'block' }, { label: '进入人工审核', value: 'manual_review' }]} value={isPhoneFrequencyRule(editor.code) ? 'block' : editor.action} />
|
||||
<Select label="状态" onChange={(event) => setEditor({ ...editor, status: event.target.value as RiskRuleItem['status'] })} options={[{ label: '启用', value: 'active' }, { label: '停用', value: 'inactive' }]} value={editor.status} />
|
||||
<Input label="优先级" min="1" onChange={(event) => setEditor({ ...editor, priority: event.target.value })} type="number" value={editor.priority} />
|
||||
{editor.code === 'NON_WORKING_MARKETING_BULK' ? <>
|
||||
@@ -190,6 +445,38 @@ export function AdminRiskRulesPage() {
|
||||
</> : null}
|
||||
</div>
|
||||
</Modal> : null}
|
||||
{whitelistEditor ? <Modal
|
||||
footer={<><Button disabled={whitelistSaving} onClick={() => setWhitelistEditor(null)} variant="ghost">取消</Button><Button disabled={whitelistSaving} onClick={() => void saveWhitelist()}>{whitelistSaving ? '保存中…' : '保存'}</Button></>}
|
||||
onClose={() => setWhitelistEditor(null)}
|
||||
open
|
||||
title={whitelistEditor.id ? '编辑号码频控白名单' : '新增号码频控白名单'}
|
||||
>
|
||||
<div className="form-grid">
|
||||
<Input label="手机号码" onChange={(event) => setWhitelistEditor({ ...whitelistEditor, phoneNumber: event.target.value })} placeholder="中国大陆11位手机号码" value={whitelistEditor.phoneNumber} />
|
||||
<Select label="状态" onChange={(event) => setWhitelistEditor({ ...whitelistEditor, status: event.target.value as WhitelistEditorState['status'] })} options={[{ label: '启用', value: 'active' }, { label: '停用', value: 'inactive' }]} value={whitelistEditor.status} />
|
||||
</div>
|
||||
<Textarea label="用途说明" maxLength={200} onChange={(event) => setWhitelistEditor({ ...whitelistEditor, reason: event.target.value })} placeholder="必填,说明该号码为何需要豁免频控" rows={3} value={whitelistEditor.reason} />
|
||||
<Textarea label="备注" maxLength={500} onChange={(event) => setWhitelistEditor({ ...whitelistEditor, remark: event.target.value })} placeholder="选填" rows={3} value={whitelistEditor.remark} />
|
||||
<p className="muted">新增启用、启停切换或修改号码时,会清零相关号码在所有企业应用下的当前频控计数,并解除尚未到期的频控命中。</p>
|
||||
</Modal> : null}
|
||||
{deletingWhitelist ? <Modal
|
||||
footer={<><Button disabled={whitelistSaving} onClick={() => setDeletingWhitelist(null)} variant="ghost">取消</Button><Button disabled={whitelistSaving} onClick={() => void confirmDeleteWhitelist()}>{whitelistSaving ? '处理中…' : '删除并清零'}</Button></>}
|
||||
onClose={() => setDeletingWhitelist(null)}
|
||||
open
|
||||
title="删除号码频控白名单"
|
||||
>
|
||||
<p>删除号码 <strong>{deletingWhitelist.phoneNumber}</strong> 的平台级频控豁免,并清零该号码在所有企业应用下的当前频控计数。历史记录和审计日志会保留。</p>
|
||||
<Textarea label="删除原因" maxLength={500} onChange={(event) => setDeleteWhitelistReason(event.target.value)} placeholder="请填写删除原因" rows={4} value={deleteWhitelistReason} />
|
||||
</Modal> : null}
|
||||
{releaseHit ? <Modal
|
||||
footer={<><Button disabled={releasing} onClick={() => setReleaseHit(null)} variant="ghost">取消</Button><Button disabled={releasing} onClick={() => void confirmRelease()}>{releasing ? '处理中…' : '解除并清零'}</Button></>}
|
||||
onClose={() => setReleaseHit(null)}
|
||||
open
|
||||
title="解除号码频控"
|
||||
>
|
||||
<p>将解除号码 <strong>{releaseHit.phoneNumber}</strong> 在应用“{releaseHit.application.name}”下的当前拦截,并将该规则当前周期计数清零。历史触发记录仍会保留。</p>
|
||||
<Textarea label="解除原因" onChange={(event) => setReleaseReason(event.target.value)} placeholder="请填写人工解除原因" rows={4} value={releaseReason} />
|
||||
</Modal> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,411 +1,16 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, Download, Info, MessageSquare, Search, Smartphone } from 'lucide-react';
|
||||
import { adminApi, type SmsMessageRecord, type SmsMessageSegmentAudit, type SmsReceiptRecord, type SmsSubmitRecord } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
|
||||
import { formatCents } from '@/utils/currency';
|
||||
import { adminApi, type SmsMessageRecord, type SmsMessageSegmentAudit } from '@/api/adminApi';
|
||||
import { Breadcrumb, type DateRangeValue } from '@/components/ui';
|
||||
import { SendDetailModal } from './sms-records/SendDetailModal';
|
||||
import { SmsRecordFilter } from './sms-records/SmsRecordFilter';
|
||||
import { SmsRecordList } from './sms-records/SmsRecordList';
|
||||
import { defaultSmsRecordDateRange } from './sms-records/smsRecordModel';
|
||||
import type { ApplicationOption, MessageFilters, TenantOption } from './sms-records/smsRecordTypes';
|
||||
import './sms-records/AdminSmsRecordsPage.css';
|
||||
|
||||
const statusLabelMap: Record<string, string> = {
|
||||
delivered: '发送成功',
|
||||
queued: '排队中',
|
||||
submitted: '已提交',
|
||||
submit_failed: '提交失败',
|
||||
unknown: '未知',
|
||||
failed: '送达失败',
|
||||
rejected: '已拒绝',
|
||||
};
|
||||
|
||||
const statusToneMap: Record<string, 'success' | 'neutral' | 'danger' | 'info'> = {
|
||||
delivered: 'success',
|
||||
queued: 'info',
|
||||
submitted: 'info',
|
||||
submit_failed: 'danger',
|
||||
unknown: 'neutral',
|
||||
failed: 'danger',
|
||||
rejected: 'danger',
|
||||
};
|
||||
|
||||
const statusDotClassMap: Record<string, string> = {
|
||||
delivered: 'is-success',
|
||||
queued: 'is-unknown',
|
||||
submitted: 'is-unknown',
|
||||
submit_failed: 'is-failed',
|
||||
unknown: 'is-unknown',
|
||||
failed: 'is-failed',
|
||||
rejected: 'is-failed',
|
||||
};
|
||||
|
||||
const carrierLabelMap: Record<string, string> = {
|
||||
mobile: '中国移动',
|
||||
unicom: '中国联通',
|
||||
telecom: '中国电信',
|
||||
all: '三网',
|
||||
};
|
||||
|
||||
type RouteRow = {
|
||||
id: string;
|
||||
channel: string;
|
||||
channelGroup?: string | null;
|
||||
sentAt?: string | null;
|
||||
receiptAt?: string | null;
|
||||
receiptCode?: string | null;
|
||||
submitStatus?: string | null;
|
||||
};
|
||||
|
||||
function formatLocalDateTime(value?: string | null) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const parts = new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).formatToParts(date);
|
||||
const partMap = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
||||
return `${partMap.year}-${partMap.month}-${partMap.day} ${partMap.hour}:${partMap.minute}:${partMap.second}`;
|
||||
}
|
||||
|
||||
function getDate(value?: string | null) {
|
||||
return formatLocalDateTime(value)?.slice(0, 10) ?? '';
|
||||
}
|
||||
|
||||
function getTime(value?: string | null) {
|
||||
return formatLocalDateTime(value) ?? '-';
|
||||
}
|
||||
|
||||
function getClock(value?: string | null) {
|
||||
return formatLocalDateTime(value)?.slice(11, 19) ?? '-';
|
||||
}
|
||||
|
||||
function getStatusLabel(status?: string | null) {
|
||||
return status ? (statusLabelMap[status] ?? status) : '-';
|
||||
}
|
||||
|
||||
function isSubmitFailure(record: SmsMessageRecord) {
|
||||
return record.status === 'submit_failed' || ['rejected', 'timeout'].includes(record.submitStatus ?? '');
|
||||
}
|
||||
|
||||
function getRecordStatus(record: SmsMessageRecord) {
|
||||
return isSubmitFailure(record) ? 'submit_failed' : record.status;
|
||||
}
|
||||
|
||||
function getRecordStatusLabel(record: SmsMessageRecord) {
|
||||
return getStatusLabel(getRecordStatus(record));
|
||||
}
|
||||
|
||||
function getReceiptNotice(record: SmsMessageRecord) {
|
||||
const hasPlatformFailureReceipt = (record.receiptRecords ?? []).some((receipt) =>
|
||||
receipt.gatewayMessageId.startsWith('PLATFORM:') && receipt.rawStatus === 'REJECTD',
|
||||
);
|
||||
if (hasPlatformFailureReceipt) {
|
||||
const deliveries = (record.downstreamDeliveries ?? []).filter((item) => item.deliveryType === 'receipt');
|
||||
if (deliveries.some((item) => item.status === 'delivered')) {
|
||||
return '平台已生成失败回执并通知企业';
|
||||
}
|
||||
const deliveryStatuses = Array.from(new Set(deliveries.map((item) => item.status)));
|
||||
return `平台已生成失败回执,企业通知状态:${deliveryStatuses.join('、') || '待投递'}`;
|
||||
}
|
||||
if (!record.tenantId && !record.applicationId && record.messageId.startsWith('MSG-TEST-')) {
|
||||
return '运营端通道测试,无需生成客户回执';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCarrierLabel(carrier?: string | null) {
|
||||
return carrier ? (carrierLabelMap[carrier] ?? carrier) : '-';
|
||||
}
|
||||
|
||||
function buildRouteRows(record: SmsMessageRecord, segmentAudits: SmsMessageSegmentAudit[]): RouteRow[] {
|
||||
if (segmentAudits.length > 0) {
|
||||
const submitById = new Map((record.submitRecords ?? []).map((submit) => [submit.submitId, submit]));
|
||||
const attempts = new Map<string, SmsMessageSegmentAudit[]>();
|
||||
segmentAudits.forEach((segment) => {
|
||||
const current = attempts.get(segment.submitId) ?? [];
|
||||
current.push(segment);
|
||||
attempts.set(segment.submitId, current);
|
||||
});
|
||||
return Array.from(attempts.entries())
|
||||
.map(([submitId, segments]) => {
|
||||
const ordered = [...segments].sort((left, right) => left.segmentIndex - right.segmentIndex);
|
||||
const sentTimes = ordered.map((segment) => segment.submittedAt).filter(Boolean) as string[];
|
||||
const receiptTimes = ordered.map((segment) => segment.deliveredAt).filter(Boolean) as string[];
|
||||
const receiptCodes = Array.from(new Set(ordered.map((segment) => segment.rawStatus).filter(Boolean)));
|
||||
const submitStatuses = Array.from(new Set(ordered.map((segment) => segment.submitStatus).filter(Boolean)));
|
||||
return {
|
||||
id: submitId,
|
||||
attempt: Math.min(...ordered.map((segment) => segment.attempt)),
|
||||
channel: ordered.find((segment) => segment.channel?.name)?.channel?.name
|
||||
?? ordered.find((segment) => segment.channelId)?.channelId
|
||||
?? '-',
|
||||
channelGroup: submitById.get(submitId)?.channelGroupName ?? submitById.get(submitId)?.channelGroup?.name,
|
||||
sentAt: sentTimes.sort()[0],
|
||||
receiptAt: receiptTimes.sort()[receiptTimes.length - 1],
|
||||
receiptCode: receiptCodes.join(' / ') || undefined,
|
||||
submitStatus: submitStatuses.join(' / ') || undefined,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.attempt - right.attempt);
|
||||
}
|
||||
const receipts = record.receiptRecords ?? [];
|
||||
const receiptByGatewayId = new Map<string, SmsReceiptRecord>();
|
||||
receipts.forEach((receipt) => {
|
||||
if (receipt.gatewayMessageId) {
|
||||
receiptByGatewayId.set(receipt.gatewayMessageId, receipt);
|
||||
}
|
||||
});
|
||||
const submitRows = (record.submitRecords ?? []).map((submit, index) => {
|
||||
const receipt = submit.gatewayMessageId ? receiptByGatewayId.get(submit.gatewayMessageId) : undefined;
|
||||
return {
|
||||
id: submit.id || String(index + 1),
|
||||
channel: submit.channel?.name ?? record.channel?.name ?? submit.channelId ?? '-',
|
||||
channelGroup: submit.channelGroupName ?? submit.channelGroup?.name,
|
||||
sentAt: submit.submittedAt ?? submit.createdAt,
|
||||
receiptAt: receipt?.deliveredAt,
|
||||
receiptCode: receipt?.rawStatus,
|
||||
submitStatus: submit.submitStatus,
|
||||
};
|
||||
});
|
||||
if (submitRows.length > 0) {
|
||||
return submitRows;
|
||||
}
|
||||
return [{
|
||||
id: record.id,
|
||||
channel: record.channel?.name ?? record.channelId ?? '-',
|
||||
sentAt: record.submittedAt ?? record.queuedAt,
|
||||
receiptAt: record.deliveredAt,
|
||||
receiptCode: receipts[0]?.rawStatus,
|
||||
submitStatus: record.submitStatus,
|
||||
}];
|
||||
}
|
||||
|
||||
function StatusLine({ record }: { record: SmsMessageRecord }) {
|
||||
const status = getRecordStatus(record);
|
||||
return (
|
||||
<span className="admin-sms-record-status">
|
||||
<i className={statusDotClassMap[status] ?? 'is-unknown'} />
|
||||
{getStatusLabel(status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function csvCell(value: unknown) {
|
||||
const text = String(value ?? '');
|
||||
return `"${text.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function downloadCsv(records: SmsMessageRecord[]) {
|
||||
const rows = [
|
||||
['消息编号', '企业', '应用', '提交时间', '手机号', '地区', '运营商', '计费条数', '金额', '通道', '状态', '回执时间', '短信内容'],
|
||||
...records.map((record) => [
|
||||
record.messageId,
|
||||
record.tenant?.name ?? record.tenantId,
|
||||
record.application?.name ?? record.applicationId ?? '',
|
||||
getTime(record.queuedAt),
|
||||
record.phoneNumber,
|
||||
record.province ?? '',
|
||||
getCarrierLabel(record.carrier),
|
||||
record.billingUnits,
|
||||
formatCents(record.amountCents),
|
||||
record.channel?.name ?? record.channelId ?? '',
|
||||
getRecordStatusLabel(record),
|
||||
getTime(record.deliveredAt),
|
||||
record.content,
|
||||
]),
|
||||
];
|
||||
const blob = new Blob([`\uFEFF${rows.map((row) => row.map(csvCell).join(',')).join('\n')}`], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `sms-records-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function SendDetailModal({
|
||||
record,
|
||||
segmentAudits,
|
||||
segmentLoading,
|
||||
onClose,
|
||||
}: {
|
||||
record: SmsMessageRecord;
|
||||
segmentAudits: SmsMessageSegmentAudit[];
|
||||
segmentLoading: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const routeRows = buildRouteRows(record, segmentAudits);
|
||||
const channelGroupNames = Array.from(new Set(routeRows.map((route) => route.channelGroup).filter(Boolean)));
|
||||
const orderedSegmentAudits = [...segmentAudits].sort((left, right) => {
|
||||
const timeDiff = new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime();
|
||||
return timeDiff || left.segmentIndex - right.segmentIndex || left.id.localeCompare(right.id);
|
||||
});
|
||||
const sentAccessNumber = `${record.channel?.srcId ?? ''}${record.applicationExtension ?? ''}`;
|
||||
const displayStatus = getRecordStatus(record);
|
||||
const receiptNotice = getReceiptNotice(record);
|
||||
return (
|
||||
<Modal
|
||||
footer={<Button onClick={onClose} variant="ghost">关闭</Button>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>发送详情</h2><p>{record.messageId}</p></div>}
|
||||
>
|
||||
<div className="admin-sms-send-detail">
|
||||
<div className="admin-sms-detail-overview">
|
||||
<div>
|
||||
<span>最终状态</span>
|
||||
<Tag tone={statusToneMap[displayStatus] ?? 'info'}>{getRecordStatusLabel(record)}</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<span>提交状态</span>
|
||||
<strong>{record.submitStatus ?? '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>回执状态</span>
|
||||
<strong>{record.receiptStatus ?? '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>提交时间</span>
|
||||
<strong>{getTime(record.queuedAt)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发送号码</span>
|
||||
<strong>{record.phoneNumber || '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>号码归属</span>
|
||||
<strong>{record.province ?? '-'} / {getCarrierLabel(record.carrier)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>通道组</span>
|
||||
<strong>{channelGroupNames.join(' / ') || '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>收到的接入号</span>
|
||||
<strong>{record.clientSrcId || '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发送的接入号</span>
|
||||
<strong>{sentAccessNumber || '-'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{receiptNotice ? (
|
||||
<div className="admin-sms-detail-notice" role="status">
|
||||
<Info size={20} />
|
||||
<strong>{receiptNotice}</strong>
|
||||
</div>
|
||||
) : null}
|
||||
<section>
|
||||
<h3><MessageSquare size={18} /> 短信内容</h3>
|
||||
<p className="admin-sms-detail-content">{record.content}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>通道发送与回执</h3>
|
||||
<div className="admin-sms-route-list">
|
||||
{routeRows.map((route, index) => (
|
||||
<article key={route.id}>
|
||||
<span>{index + 1}</span>
|
||||
<div>
|
||||
<strong>{route.channel}</strong>
|
||||
<p className="muted">通道组:{route.channelGroup ?? '-'}</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>发送时间</dt>
|
||||
<dd>{getTime(route.sentAt)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>回执时间</dt>
|
||||
<dd>{getTime(route.receiptAt)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>回执码</dt>
|
||||
<dd>{route.receiptCode ?? '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>提交状态</dt>
|
||||
<dd>{route.submitStatus ?? '-'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>状态信息</h3>
|
||||
<div className="admin-sms-detail-status-grid">
|
||||
<div><span>消息编号</span><strong>{record.messageId}</strong></div>
|
||||
<div><span>发送状态</span><strong>{getRecordStatusLabel(record)}</strong></div>
|
||||
<div><span>提交状态</span><strong>{record.submitStatus ?? '-'}</strong></div>
|
||||
<div><span>回执状态</span><strong>{record.receiptStatus ?? '-'}</strong></div>
|
||||
</div>
|
||||
{['submit_failed', 'failed', 'rejected'].includes(displayStatus) ? (
|
||||
<div className="admin-sms-detail-failure" role="alert">
|
||||
<AlertTriangle size={20} />
|
||||
<div><span>失败原因</span><strong>{record.errorMessage ?? record.errorCode ?? '未返回明确失败原因'}</strong></div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>分片补偿审计</h3>
|
||||
{segmentLoading ? <div className="ui-table__empty">加载中...</div> : segmentAudits.length === 0 ? (
|
||||
<div className="ui-table__empty">暂无分片审计</div>
|
||||
) : (
|
||||
<div className="admin-sms-segment-list">
|
||||
{orderedSegmentAudits.map((segment) => (
|
||||
<article className="admin-sms-segment-card" key={segment.id}>
|
||||
<header>
|
||||
<strong>分片 {segment.segmentIndex}/{segment.segmentTotal}</strong>
|
||||
<div>
|
||||
<Tag tone={segment.submitStatus === 'accepted' ? 'success' : segment.submitStatus === 'queued' ? 'info' : 'danger'}>{segment.submitStatus}</Tag>
|
||||
{segment.receiptStatus ? <Tag tone={segment.receiptStatus === 'delivered' ? 'success' : segment.receiptStatus === 'unknown' ? 'neutral' : 'danger'}>{segment.receiptStatus}</Tag> : null}
|
||||
</div>
|
||||
</header>
|
||||
<dl>
|
||||
<div><dt>通道</dt><dd>{segment.channel?.name ?? segment.channelId ?? '-'}</dd></div>
|
||||
<div><dt>Sequence</dt><dd>{segment.sequenceId ?? '-'}</dd></div>
|
||||
<div><dt>提交 ID</dt><dd>{segment.submitId}</dd></div>
|
||||
<div><dt>网关 MsgId</dt><dd>{segment.gatewayMessageId ?? '-'}</dd></div>
|
||||
<div><dt>补偿方式</dt><dd>{segment.compensationType ?? '-'}</dd></div>
|
||||
<div><dt>审计时间</dt><dd>{getTime(segment.createdAt)}</dd></div>
|
||||
<div><dt>错误信息</dt><dd>{segment.errorMessage ?? segment.errorCode ?? '-'}</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function dateKey(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function defaultSmsRecordDateRange(): DateRangeValue {
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
return { start: dateKey(yesterday), end: dateKey(today) };
|
||||
}
|
||||
const pageSize = 25;
|
||||
|
||||
export function AdminSmsRecordsPage() {
|
||||
const pageSize = 25;
|
||||
const [records, setRecords] = useState<SmsMessageRecord[]>([]);
|
||||
const [enterprise, setEnterprise] = useState('all');
|
||||
const [application, setApplication] = useState('all');
|
||||
@@ -422,20 +27,8 @@ export function AdminSmsRecordsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filterTenants, setFilterTenants] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const [filterApplications, setFilterApplications] = useState<Array<{ id: string; tenantId: string; name: string }>>([]);
|
||||
|
||||
type MessageFilters = {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
phoneNumber?: string;
|
||||
contentKeyword?: string;
|
||||
channelKeyword?: string;
|
||||
carrier?: string;
|
||||
queuedAtFrom?: string;
|
||||
queuedAtTo?: string;
|
||||
status?: string;
|
||||
};
|
||||
const [filterTenants, setFilterTenants] = useState<TenantOption[]>([]);
|
||||
const [filterApplications, setFilterApplications] = useState<ApplicationOption[]>([]);
|
||||
|
||||
function currentFilters(): MessageFilters {
|
||||
return {
|
||||
@@ -471,8 +64,12 @@ export function AdminSmsRecordsPage() {
|
||||
useEffect(() => {
|
||||
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplicationOptions()])
|
||||
.then(([tenants, applications]) => {
|
||||
setFilterTenants(tenants.filter((item) => item.status !== 'deleted').map((item) => ({ id: item.id, name: item.name })));
|
||||
setFilterApplications(applications.filter((item) => item.status !== 'deleted').map((item) => ({ id: item.id, tenantId: item.tenantId, name: item.name })));
|
||||
setFilterTenants(tenants
|
||||
.filter((item) => item.status !== 'deleted')
|
||||
.map((item) => ({ id: item.id, name: item.name })));
|
||||
setFilterApplications(applications
|
||||
.filter((item) => item.status !== 'deleted')
|
||||
.map((item) => ({ id: item.id, tenantId: item.tenantId, name: item.name })));
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '短信记录筛选项加载失败'));
|
||||
}, []);
|
||||
@@ -492,20 +89,20 @@ export function AdminSmsRecordsPage() {
|
||||
.finally(() => setSegmentLoading(false));
|
||||
}, [selectedRecord]);
|
||||
|
||||
const enterpriseOptions = useMemo(() => {
|
||||
return [{ label: '全部企业', value: 'all' }, ...filterTenants.map((item) => ({ label: item.name, value: item.id }))];
|
||||
}, [filterTenants]);
|
||||
const enterpriseOptions = useMemo(
|
||||
() => [{ label: '全部企业', value: 'all' }, ...filterTenants.map((item) => ({ label: item.name, value: item.id }))],
|
||||
[filterTenants],
|
||||
);
|
||||
|
||||
const applicationOptions = useMemo(() => {
|
||||
return [{ label: '全部应用', value: 'all' }, ...filterApplications
|
||||
const applicationOptions = useMemo(
|
||||
() => [{ label: '全部应用', value: 'all' }, ...filterApplications
|
||||
.filter((item) => enterprise === 'all' || item.tenantId === enterprise)
|
||||
.map((item) => ({ label: item.name, value: item.id }))];
|
||||
}, [enterprise, filterApplications]);
|
||||
.map((item) => ({ label: item.name, value: item.id }))],
|
||||
[enterprise, filterApplications],
|
||||
);
|
||||
|
||||
const filteredRows = records;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const visibleRows = filteredRows;
|
||||
|
||||
function resetFilters() {
|
||||
const defaultDateRange = defaultSmsRecordDateRange();
|
||||
@@ -545,87 +142,45 @@ export function AdminSmsRecordsPage() {
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<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} />
|
||||
<Select
|
||||
label="运营商"
|
||||
onChange={(event) => setCarrier(event.target.value)}
|
||||
options={[
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '移动', value: 'mobile' },
|
||||
{ label: '联通', value: 'unicom' },
|
||||
{ label: '电信', value: 'telecom' },
|
||||
{ label: '未识别', value: 'unknown' },
|
||||
]}
|
||||
value={carrier}
|
||||
/>
|
||||
<Input label="短信内容" onChange={(event) => setContentKeyword(event.target.value)} value={contentKeyword} />
|
||||
<Input label="通道名称" onChange={(event) => setChannelKeyword(event.target.value)} value={channelKeyword} />
|
||||
<Select
|
||||
label="发送状态"
|
||||
onChange={(event) => setStatus(event.target.value)}
|
||||
options={[
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '发送成功', value: 'delivered' },
|
||||
{ label: '未知', value: 'unknown' },
|
||||
{ label: '提交失败', value: 'submit_failed' },
|
||||
{ label: '送达失败', value: 'failed' },
|
||||
]}
|
||||
value={status}
|
||||
/>
|
||||
<div className="admin-sms-record-filter__actions">
|
||||
<Button icon={<Search size={16} />} onClick={() => { if (page !== 1) setPage(1); else loadData(currentFilters(), 1); }}>查询</Button>
|
||||
<Button onClick={resetFilters} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
<SmsRecordFilter
|
||||
application={application}
|
||||
applicationOptions={applicationOptions}
|
||||
carrier={carrier}
|
||||
channelKeyword={channelKeyword}
|
||||
contentKeyword={contentKeyword}
|
||||
dateRange={dateRange}
|
||||
enterprise={enterprise}
|
||||
enterpriseOptions={enterpriseOptions}
|
||||
phoneKeyword={phoneKeyword}
|
||||
status={status}
|
||||
onApplicationChange={setApplication}
|
||||
onCarrierChange={setCarrier}
|
||||
onChannelKeywordChange={setChannelKeyword}
|
||||
onContentKeywordChange={setContentKeyword}
|
||||
onDateRangeChange={setDateRange}
|
||||
onEnterpriseChange={(value) => {
|
||||
setEnterprise(value);
|
||||
setApplication('all');
|
||||
}}
|
||||
onPhoneKeywordChange={setPhoneKeyword}
|
||||
onQuery={() => {
|
||||
if (page !== 1) setPage(1);
|
||||
else loadData(currentFilters(), 1);
|
||||
}}
|
||||
onReset={resetFilters}
|
||||
onStatusChange={setStatus}
|
||||
/>
|
||||
|
||||
<div className="surface admin-sms-record-table-card">
|
||||
<div className="admin-sms-record-toolbar">
|
||||
<Button icon={<Download size={16} />} onClick={() => void exportRecords()} variant="ghost">导出CSV</Button>
|
||||
</div>
|
||||
<div className="admin-sms-record-list">
|
||||
{loading ? <div className="ui-table__empty">正在加载真实短信记录...</div> : filteredRows.length === 0 ? <div className="ui-table__empty">暂无短信记录</div> : visibleRows.map((record) => (
|
||||
<article className="admin-sms-record-card" key={record.id}>
|
||||
<header>
|
||||
<div className="admin-sms-record-sender">
|
||||
<strong>{record.tenant?.name ?? record.tenantId ?? '运营端通道测试'}</strong>
|
||||
<span>{record.application?.name ?? record.applicationId ?? '-'}</span>
|
||||
</div>
|
||||
<StatusLine record={record} />
|
||||
<time>{getDate(record.queuedAt)} {getClock(record.queuedAt)}</time>
|
||||
</header>
|
||||
<p className="admin-sms-record-content">{record.content}</p>
|
||||
<div className="admin-sms-record-card__meta">
|
||||
<div><span>接收号码</span><strong>{record.phoneNumber}</strong><small>{record.province ?? '-'} · {getCarrierLabel(record.carrier)}</small></div>
|
||||
<div><span>计费</span><strong>{record.billingUnits} 条 / ¥{formatCents(record.amountCents)}</strong><small>{record.content.length} 字</small></div>
|
||||
<div><span>发送通道</span><strong>{record.channel?.name ?? record.channelId ?? '-'}</strong><small>回执 {getTime(record.deliveredAt)}</small></div>
|
||||
</div>
|
||||
<footer><button className="admin-sms-record-detail-link" onClick={() => setSelectedRecord(record)} type="button">查看发送详情</button></footer>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => setPage((current) => Math.min(totalPages, current + 1))}
|
||||
onPageChange={setPage}
|
||||
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
|
||||
page={currentPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={total}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
</div>
|
||||
<SmsRecordList
|
||||
currentPage={currentPage}
|
||||
loading={loading}
|
||||
records={records}
|
||||
total={total}
|
||||
totalPages={totalPages}
|
||||
onExport={() => void exportRecords()}
|
||||
onOpenDetail={setSelectedRecord}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
|
||||
{selectedRecord ? (
|
||||
<SendDetailModal
|
||||
|
||||
@@ -1,369 +1,16 @@
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import { BarChart3, CalendarClock, Eye, MapPin, Search, Send, Smartphone, StopCircle, TrendingUp } from 'lucide-react';
|
||||
import { adminApi, type BatchTaskMessagePage, type SmsBatchTask, type SmsMessageRecord } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import {
|
||||
Breadcrumb,
|
||||
Button,
|
||||
DateRangeInput,
|
||||
InlineTextPreview,
|
||||
Input,
|
||||
Modal,
|
||||
Pagination,
|
||||
Select,
|
||||
Table,
|
||||
Tag,
|
||||
type DateRangeValue,
|
||||
} from '@/components/ui';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { adminApi } from '@/api/adminApi';
|
||||
import { Breadcrumb, type DateRangeValue } from '@/components/ui';
|
||||
import { SmsTaskFilter } from './sms-task-progress/SmsTaskFilter';
|
||||
import { SmsTaskTable } from './sms-task-progress/SmsTaskTable';
|
||||
import { TaskDetailModal } from './sms-task-progress/TaskDetailModal';
|
||||
import { TaskPhoneListModal } from './sms-task-progress/TaskPhoneListModal';
|
||||
import { TerminateTaskModal } from './sms-task-progress/TerminateTaskModal';
|
||||
import { mapTask } from './sms-task-progress/taskModel';
|
||||
import type { SmsTask } from './sms-task-progress/taskTypes';
|
||||
import './sms-task-progress/AdminSmsTaskProgressPage.css';
|
||||
|
||||
type TaskStatus = 'sending' | 'completed' | 'terminated' | 'failed';
|
||||
type SendType = 'immediate' | 'scheduled';
|
||||
|
||||
type CarrierStat = {
|
||||
name: string;
|
||||
total: number;
|
||||
success: number;
|
||||
tone: 'mobile' | 'unicom' | 'telecom';
|
||||
};
|
||||
|
||||
type RegionStat = {
|
||||
region: string;
|
||||
total: number;
|
||||
success: number;
|
||||
};
|
||||
|
||||
type SmsTask = {
|
||||
id: string;
|
||||
backendId: string;
|
||||
enterprise: string;
|
||||
application: string;
|
||||
submittedAt: string;
|
||||
templateContent: string;
|
||||
phoneCount: number;
|
||||
wordCount: number;
|
||||
billingCount: number;
|
||||
sendType: SendType;
|
||||
scheduledAt?: string | null;
|
||||
submittedCount: number;
|
||||
submittedSuccess: number;
|
||||
sentCount: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
status: TaskStatus;
|
||||
rawStatus: string;
|
||||
carriers: CarrierStat[];
|
||||
regions: RegionStat[];
|
||||
};
|
||||
|
||||
const statusLabels: Record<TaskStatus, string> = {
|
||||
sending: '发送中',
|
||||
completed: '已完成',
|
||||
terminated: '已终止',
|
||||
failed: '失败',
|
||||
};
|
||||
|
||||
const statusTones: Record<TaskStatus, 'info' | 'success' | 'neutral' | 'danger'> = {
|
||||
sending: 'info',
|
||||
completed: 'success',
|
||||
terminated: 'neutral',
|
||||
failed: 'danger',
|
||||
};
|
||||
|
||||
const sendTypeLabels: Record<SendType, string> = {
|
||||
immediate: '立即发送',
|
||||
scheduled: '定时发送',
|
||||
};
|
||||
|
||||
const 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 formatDateTime(value);
|
||||
}
|
||||
|
||||
function messageStatusLabel(status: string) {
|
||||
return {
|
||||
pending_review: '待人工审核',
|
||||
queued: '已入队',
|
||||
scheduled: '等待定时发送',
|
||||
submitted: '供应商已受理',
|
||||
delivered: '送达成功',
|
||||
submit_failed: '提交失败',
|
||||
failed: '回执失败',
|
||||
rejected: '已拒绝',
|
||||
timeout: '超时',
|
||||
canceled: '已取消',
|
||||
}[status] ?? status;
|
||||
}
|
||||
|
||||
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.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.province ?? '未识别省份';
|
||||
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 buildCarrierStatsFromAggregates(stats: SmsBatchTask['messageStats']): CarrierStat[] {
|
||||
const totals = new Map<string, CarrierStat>();
|
||||
(stats ?? []).forEach((item) => {
|
||||
const carrier = item.carrier ?? 'unknown';
|
||||
const meta = carrierLabels[carrier] ?? { label: carrier || '未识别', tone: 'mobile' as const };
|
||||
const current = totals.get(carrier) ?? { name: meta.label, total: 0, success: 0, tone: meta.tone };
|
||||
current.total += item._count._all;
|
||||
if (item.status === 'delivered') current.success += item._count._all;
|
||||
totals.set(carrier, current);
|
||||
});
|
||||
return Array.from(totals.values());
|
||||
}
|
||||
|
||||
function buildRegionStatsFromAggregates(stats: SmsBatchTask['messageStats']): RegionStat[] {
|
||||
const totals = new Map<string, RegionStat>();
|
||||
(stats ?? []).forEach((item) => {
|
||||
const region = item.province ?? '未识别省份';
|
||||
const current = totals.get(region) ?? { region, total: 0, success: 0 };
|
||||
current.total += item._count._all;
|
||||
if (item.status === 'delivered') current.success += item._count._all;
|
||||
totals.set(region, current);
|
||||
});
|
||||
return Array.from(totals.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);
|
||||
// submittedTotal already includes unknown and timeout records, so never add them again.
|
||||
const processedCount = Math.max(submittedCount, successCount + failedCount + (task.unknownTotal ?? 0));
|
||||
const billingCount = (task.messageStats ?? []).reduce((sum, item) => sum + (item._sum.billingUnits ?? 0), 0)
|
||||
|| 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.min(task.phoneTotal, processedCount),
|
||||
successCount,
|
||||
failedCount,
|
||||
status: normalizeTaskStatus(task.status),
|
||||
rawStatus: task.status,
|
||||
carriers: task.messageStats ? buildCarrierStatsFromAggregates(task.messageStats) : buildCarrierStats(messages),
|
||||
regions: task.messageStats ? buildRegionStatsFromAggregates(task.messageStats) : buildRegionStats(messages),
|
||||
};
|
||||
}
|
||||
|
||||
function getProgress(task: SmsTask) {
|
||||
return task.phoneCount > 0 ? Math.min(100, Math.round((task.sentCount / task.phoneCount) * 100)) : 0;
|
||||
}
|
||||
|
||||
function getSuccessRate(task: SmsTask) {
|
||||
return task.submittedCount > 0 ? (task.successCount / task.submittedCount) * 100 : 0;
|
||||
}
|
||||
|
||||
function getRegionRate(region: RegionStat) {
|
||||
return region.total > 0 ? (region.success / region.total) * 100 : 0;
|
||||
}
|
||||
|
||||
function splitSignature(content: string) {
|
||||
const match = content.match(/^【(.+?)】(.+)$/);
|
||||
return {
|
||||
signature: match?.[1],
|
||||
content: match?.[2] ?? content,
|
||||
};
|
||||
}
|
||||
|
||||
function TaskDetailTitle({ task }: { task: SmsTask }) {
|
||||
return (
|
||||
<div className="admin-task-detail-title">
|
||||
<h2>发送批次详情</h2>
|
||||
<p>
|
||||
<span>{task.id}</span>
|
||||
<Tag tone={statusTones[task.status]}>{statusLabels[task.status]}</Tag>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, tone }: { label: string; value: string; tone?: 'success' | 'primary' }) {
|
||||
return (
|
||||
<div className={['admin-task-metric', tone ? `admin-task-metric--${tone}` : ''].filter(Boolean).join(' ')}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: () => void }) {
|
||||
const progress = getProgress(task);
|
||||
const successRate = getSuccessRate(task);
|
||||
const perPhoneBillingUnits = Math.max(1, Math.ceil(task.wordCount / 67));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={<Button onClick={onClose}>关闭</Button>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<TaskDetailTitle task={task} />}
|
||||
>
|
||||
<div className="admin-task-detail">
|
||||
<div className="admin-task-metrics">
|
||||
<MetricCard label="提交总数" value={formatNumber(task.submittedCount)} />
|
||||
<MetricCard label="提交成功" tone="success" value={formatNumber(task.submittedSuccess)} />
|
||||
<MetricCard label="发送成功" tone="primary" value={formatNumber(task.successCount)} />
|
||||
<MetricCard label="计费条数" tone="primary" value={formatNumber(task.billingCount)} />
|
||||
<MetricCard label="成功率" tone="primary" value={`${successRate.toFixed(2)}%`} />
|
||||
</div>
|
||||
|
||||
<div className="admin-task-detail-grid">
|
||||
<section className="admin-task-card">
|
||||
<h3><Send size={18} />发送批次信息</h3>
|
||||
<dl className="admin-task-info-list">
|
||||
<div>
|
||||
<dt>企业/应用</dt>
|
||||
<dd><strong>{task.enterprise}</strong><span>{task.application}</span></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>提交时间</dt>
|
||||
<dd>{formatTime(task.submittedAt)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>发送方式</dt>
|
||||
<dd><Tag tone={task.sendType === 'immediate' ? 'info' : 'warning'}>{sendTypeLabels[task.sendType]}</Tag></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="admin-task-card">
|
||||
<h3><TrendingUp size={18} />发送进度</h3>
|
||||
<div className="admin-task-progress-card">
|
||||
<div>
|
||||
<span>已处理 {formatNumber(task.sentCount)} / 总计 {formatNumber(task.phoneCount)}</span>
|
||||
<strong>{progress}%</strong>
|
||||
</div>
|
||||
<div className="batch-progress__track">
|
||||
<span className={`batch-progress__bar batch-progress__bar--${task.status === 'failed' ? 'terminated' : task.status}`} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="admin-task-progress-split">
|
||||
<span>已提交<strong>{formatNumber(task.submittedSuccess)}</strong></span>
|
||||
<span>已成功<strong>{formatNumber(task.successCount)}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-task-card">
|
||||
<h3><BarChart3 size={18} />模板信息</h3>
|
||||
<div className="admin-task-template-block">
|
||||
<span>短信模板内容</span>
|
||||
<p className="admin-task-template">{task.templateContent}</p>
|
||||
</div>
|
||||
<dl className="admin-task-template-meta">
|
||||
<div><dt>字符数/计费条数</dt><dd>{task.wordCount} 字符 <b>·</b> {perPhoneBillingUnits} 条/号码</dd></div>
|
||||
<div><dt>发送号码数</dt><dd>{formatNumber(task.phoneCount)} 个</dd></div>
|
||||
</dl>
|
||||
<div className="admin-task-billing-note">
|
||||
<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>
|
||||
{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>
|
||||
<Table
|
||||
columns={[
|
||||
{ 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: RegionStat) => <Tag tone={getRegionRate(record) >= 95 ? 'success' : 'info'}>{getRegionRate(record).toFixed(1)}%</Tag>,
|
||||
},
|
||||
]}
|
||||
data={task.regions}
|
||||
emptyText="暂无已识别省份记录"
|
||||
rowKey={(record) => record.region}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
const pageSize = 10;
|
||||
|
||||
export function AdminSmsTaskProgressPage() {
|
||||
const [tasks, setTasks] = useState<SmsTask[]>([]);
|
||||
@@ -375,17 +22,12 @@ export function AdminSmsTaskProgressPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedTask, setSelectedTask] = useState<SmsTask | null>(null);
|
||||
const [phoneTarget, setPhoneTarget] = useState<SmsTask | null>(null);
|
||||
const [phoneKeyword, setPhoneKeyword] = useState('');
|
||||
const [phonePage, setPhonePage] = useState(1);
|
||||
const [phonePageSize, setPhonePageSize] = useState(20);
|
||||
const [phoneData, setPhoneData] = useState<BatchTaskMessagePage>({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||
const [terminateTarget, setTerminateTarget] = useState<SmsTask | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [total, setTotal] = useState(0);
|
||||
const [filterTenants, setFilterTenants] = useState<string[]>([]);
|
||||
const [filterApplications, setFilterApplications] = useState<Array<{ tenantName: string; name: string }>>([]);
|
||||
const pageSize = 10;
|
||||
|
||||
function loadTasks(targetPage = page) {
|
||||
setLoading(true);
|
||||
@@ -416,35 +58,27 @@ export function AdminSmsTaskProgressPage() {
|
||||
.then(([tenants, applications]) => {
|
||||
const tenantNameById = new Map(tenants.map((item) => [item.id, item.name]));
|
||||
setFilterTenants(tenants.filter((item) => item.status !== 'deleted').map((item) => item.name));
|
||||
setFilterApplications(applications.filter((item) => item.status !== 'deleted').map((item) => ({ tenantName: tenantNameById.get(item.tenantId) ?? '', name: item.name })));
|
||||
setFilterApplications(applications
|
||||
.filter((item) => item.status !== 'deleted')
|
||||
.map((item) => ({ tenantName: tenantNameById.get(item.tenantId) ?? '', name: item.name })));
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '任务筛选项加载失败'));
|
||||
}, []);
|
||||
|
||||
function loadPhones(target = phoneTarget, page = phonePage, pageSize = phonePageSize) {
|
||||
if (!target) return;
|
||||
adminApi.listAdminBatchTaskMessages(target.backendId, { phone: phoneKeyword || undefined, page, pageSize })
|
||||
.then(setPhoneData)
|
||||
.catch((failure: Error) => setError(failure.message || '发送批次号码列表加载失败'));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (phoneTarget) loadPhones(phoneTarget, phonePage, phonePageSize);
|
||||
}, [phoneTarget, phonePage, phonePageSize]);
|
||||
|
||||
const enterpriseOptions = useMemo(() => {
|
||||
return [{ label: '全部企业', value: 'all' }, ...filterTenants.map((name) => ({ label: name, value: name }))];
|
||||
}, [filterTenants]);
|
||||
const enterpriseOptions = useMemo(
|
||||
() => [{ label: '全部企业', value: 'all' }, ...filterTenants.map((name) => ({ label: name, value: name }))],
|
||||
[filterTenants],
|
||||
);
|
||||
|
||||
const applicationOptions = useMemo(() => {
|
||||
const names = Array.from(new Set(filterApplications.filter((item) => enterprise === 'all' || item.tenantName === enterprise).map((item) => item.name)));
|
||||
const names = Array.from(new Set(filterApplications
|
||||
.filter((item) => enterprise === 'all' || item.tenantName === enterprise)
|
||||
.map((item) => item.name)));
|
||||
return [{ label: '全部应用', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
|
||||
}, [enterprise, filterApplications]);
|
||||
|
||||
const filteredTasks = tasks;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const visibleTasks = filteredTasks;
|
||||
|
||||
function resetFilters() {
|
||||
setKeyword('');
|
||||
@@ -453,8 +87,8 @@ export function AdminSmsTaskProgressPage() {
|
||||
setSubmittedDateRange({});
|
||||
}
|
||||
|
||||
function terminateTask(taskId: string) {
|
||||
adminApi.terminateAdminBatchTask(taskId)
|
||||
function terminateTask(task: SmsTask) {
|
||||
adminApi.terminateAdminBatchTask(task.backendId)
|
||||
.then(() => {
|
||||
setTerminateTarget(null);
|
||||
setSelectedTask(null);
|
||||
@@ -472,185 +106,57 @@ export function AdminSmsTaskProgressPage() {
|
||||
</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} />} onClick={() => { if (page !== 1) setPage(1); else loadTasks(1); }}>查询</Button>
|
||||
<Button onClick={resetFilters} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
<SmsTaskFilter
|
||||
application={application}
|
||||
applicationOptions={applicationOptions}
|
||||
enterprise={enterprise}
|
||||
enterpriseOptions={enterpriseOptions}
|
||||
keyword={keyword}
|
||||
submittedDateRange={submittedDateRange}
|
||||
onApplicationChange={setApplication}
|
||||
onEnterpriseChange={(value) => {
|
||||
setEnterprise(value);
|
||||
setApplication('all');
|
||||
}}
|
||||
onKeywordChange={setKeyword}
|
||||
onQuery={() => {
|
||||
if (page !== 1) setPage(1);
|
||||
else loadTasks(1);
|
||||
}}
|
||||
onReset={resetFilters}
|
||||
onSubmittedDateRangeChange={setSubmittedDateRange}
|
||||
/>
|
||||
|
||||
{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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '170px' }}>发送批次号</th>
|
||||
<th style={{ width: '180px' }}>企业/应用</th>
|
||||
<th style={{ width: '136px' }}>提交时间</th>
|
||||
<th style={{ width: '130px' }}>号码数/字符数</th>
|
||||
<th style={{ width: '150px' }}>发送方式</th>
|
||||
<th style={{ width: '190px' }}>进度</th>
|
||||
<th style={{ width: '130px' }}>状态</th>
|
||||
<th style={{ textAlign: 'right', width: '170px' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td className="ui-table__empty" colSpan={8}>正在加载真实短信任务...</td></tr>
|
||||
) : filteredTasks.length === 0 ? (
|
||||
<tr><td className="ui-table__empty" colSpan={8}>暂无短信任务</td></tr>
|
||||
) : visibleTasks.map((record) => {
|
||||
const progress = getProgress(record);
|
||||
const { signature, content } = splitSignature(record.templateContent);
|
||||
const rowClass = hoveredTaskId === record.id ? 'batch-row--hovered' : '';
|
||||
|
||||
return (
|
||||
<Fragment key={record.backendId}>
|
||||
<tr
|
||||
className={['batch-main-row', rowClass].filter(Boolean).join(' ')}
|
||||
onMouseEnter={() => setHoveredTaskId(record.id)}
|
||||
onMouseLeave={() => setHoveredTaskId(null)}
|
||||
>
|
||||
<td><strong className="admin-task-id">{record.id}</strong></td>
|
||||
<td>
|
||||
<div className="admin-task-enterprise">
|
||||
<strong>{record.enterprise}</strong>
|
||||
<span>{record.application}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><span>{formatTime(record.submittedAt)}</span></td>
|
||||
<td>
|
||||
<div className="admin-task-counts">
|
||||
<button className="table-link" onClick={() => { setPhoneTarget(record); setPhoneKeyword(''); setPhonePage(1); }} type="button">{formatNumber(record.phoneCount)} · 查看列表</button>
|
||||
<span>{record.wordCount}字</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-task-send-type">
|
||||
<Tag tone={record.sendType === 'immediate' ? 'info' : 'warning'}>
|
||||
{record.sendType === 'scheduled' ? <CalendarClock size={13} /> : null}
|
||||
{sendTypeLabels[record.sendType]}
|
||||
</Tag>
|
||||
{record.scheduledAt ? <span>{formatTime(record.scheduledAt)}</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="batch-progress admin-task-list-progress">
|
||||
<div>
|
||||
<span>{formatNumber(record.sentCount)}/{formatNumber(record.phoneCount)}</span>
|
||||
<strong>{progress}%</strong>
|
||||
</div>
|
||||
<div className="batch-progress__track">
|
||||
<span className={`batch-progress__bar batch-progress__bar--${record.status === 'failed' ? 'terminated' : record.status}`} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><Tag tone={statusTones[record.status]}>{statusLabels[record.status]}</Tag></td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div className="admin-task-actions">
|
||||
<Button icon={<Eye size={15} />} onClick={() => setSelectedTask(record)} size="sm" variant="ghost">详情</Button>
|
||||
<Button
|
||||
disabled={record.status !== 'sending'}
|
||||
icon={<StopCircle size={15} />}
|
||||
onClick={() => setTerminateTarget(record)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
终止
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
className={['batch-template-row', 'admin-task-template-row', rowClass].filter(Boolean).join(' ')}
|
||||
onMouseEnter={() => setHoveredTaskId(record.id)}
|
||||
onMouseLeave={() => setHoveredTaskId(null)}
|
||||
>
|
||||
<td colSpan={8}>
|
||||
<InlineTextPreview label="模板内容" leading={signature ? <strong>【{signature}】</strong> : null}>
|
||||
{content}
|
||||
</InlineTextPreview>
|
||||
</td>
|
||||
</tr>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
|
||||
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
|
||||
page={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={total}
|
||||
/>
|
||||
</div>
|
||||
<SmsTaskTable
|
||||
currentPage={currentPage}
|
||||
hoveredTaskId={hoveredTaskId}
|
||||
loading={loading}
|
||||
tasks={tasks}
|
||||
total={total}
|
||||
totalPages={totalPages}
|
||||
onHoverTask={setHoveredTaskId}
|
||||
onOpenDetail={setSelectedTask}
|
||||
onOpenPhones={setPhoneTarget}
|
||||
onPageChange={setPage}
|
||||
onTerminate={setTerminateTarget}
|
||||
/>
|
||||
|
||||
{selectedTask ? <TaskDetailModal onClose={() => setSelectedTask(null)} task={selectedTask} /> : null}
|
||||
{phoneTarget ? <Modal footer={<Button onClick={() => setPhoneTarget(null)}>关闭</Button>} onClose={() => setPhoneTarget(null)} open size="xl" title={`号码列表 · 发送批次号 ${phoneTarget.id}`}>
|
||||
<div className="page-stack">
|
||||
<div className="audit-filter-grid">
|
||||
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} placeholder="输入完整或部分号码" value={phoneKeyword} />
|
||||
<Select label="每页条数" onChange={(event) => { setPhonePageSize(Number(event.target.value)); setPhonePage(1); }} options={[{ label: '10条/页', value: '10' }, { label: '20条/页', value: '20' }, { label: '50条/页', value: '50' }]} value={String(phonePageSize)} />
|
||||
<div className="audit-filter-actions"><Button icon={<Search size={16} />} onClick={() => { setPhonePage(1); loadPhones(phoneTarget, 1, phonePageSize); }}>查询</Button></div>
|
||||
</div>
|
||||
<Table
|
||||
columns={[
|
||||
{ key: 'phoneNumber', title: '手机号码', render: (item) => <strong>{item.phoneNumber}</strong> },
|
||||
{ key: 'province', title: '号码归属地', render: (item) => item.province || '-' },
|
||||
{ key: 'carrier', title: '运营商', render: (item) => carrierLabels[item.carrier ?? '']?.label ?? item.carrier ?? '-' },
|
||||
{ key: 'status', title: '短信记录状态', render: (item) => <Tag tone={item.status === 'delivered' ? 'success' : ['failed', 'submit_failed', 'rejected', 'timeout'].includes(item.status) ? 'danger' : 'info'}>{messageStatusLabel(item.status)}</Tag> },
|
||||
]}
|
||||
data={phoneData.items}
|
||||
emptyText="暂无号码记录"
|
||||
rowKey="id"
|
||||
/>
|
||||
<Pagination
|
||||
nextDisabled={phonePage * phonePageSize >= phoneData.total}
|
||||
onNext={() => setPhonePage((current) => current + 1)}
|
||||
onPageChange={setPhonePage}
|
||||
onPrevious={() => setPhonePage((current) => Math.max(1, current - 1))}
|
||||
page={phonePage}
|
||||
previousDisabled={phonePage <= 1}
|
||||
total={phoneData.total}
|
||||
totalPages={Math.max(1, Math.ceil(phoneData.total / phonePageSize))}
|
||||
/>
|
||||
</div>
|
||||
</Modal> : null}
|
||||
{phoneTarget ? (
|
||||
<TaskPhoneListModal
|
||||
onClose={() => setPhoneTarget(null)}
|
||||
onError={setError}
|
||||
task={phoneTarget}
|
||||
/>
|
||||
) : null}
|
||||
{terminateTarget ? (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setTerminateTarget(null)} variant="ghost">取消</Button>
|
||||
<Button onClick={() => terminateTask(terminateTarget.backendId)} variant="danger">确认终止</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setTerminateTarget(null)}
|
||||
open
|
||||
title="确认终止短信任务"
|
||||
>
|
||||
<div className="admin-confirm-text">
|
||||
确认终止任务 <strong>{terminateTarget.id}</strong> 吗?终止后将停止继续提交未发送号码,已提交部分仍以运营商回执为准。
|
||||
</div>
|
||||
</Modal>
|
||||
<TerminateTaskModal
|
||||
onCancel={() => setTerminateTarget(null)}
|
||||
onConfirm={() => terminateTask(terminateTarget)}
|
||||
task={terminateTarget}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ const statusOptions = [
|
||||
export function AdminTemplateAuditPage() {
|
||||
const [audits, setAudits] = useState<SmsTemplateAudit[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [status, setStatus] = useState('pending');
|
||||
const [detail, setDetail] = useState<SmsTemplateAudit>();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -88,7 +88,7 @@ export function AdminTemplateAuditPage() {
|
||||
<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>
|
||||
<Button onClick={() => { setKeyword(''); setStatus('pending'); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
.sms-channel-page .page-heading {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sms-channel-filter {
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.sms-channel-filter-grid {
|
||||
align-items: end;
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) minmax(220px, 1fr) auto;
|
||||
}
|
||||
|
||||
.sms-channel-table {
|
||||
overflow-x: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sms-channel-table__head,
|
||||
.sms-channel-table__row {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: minmax(150px, 1.2fr) 88px 86px 96px minmax(280px, 1.6fr) 190px;
|
||||
min-width: 940px;
|
||||
}
|
||||
|
||||
.sms-channel-table__head {
|
||||
background: var(--color-surface-subtle);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
}
|
||||
|
||||
.sms-channel-table__row {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
min-height: 126px;
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.sms-channel-table__row:nth-child(odd) {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.sms-channel-total {
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
|
||||
.sms-channel-identity {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sms-channel-identity strong {
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sms-channel-identity span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.sms-channel-carrier-price {
|
||||
align-items: flex-start;
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.sms-channel-status-cell {
|
||||
align-items: flex-start;
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.sms-channel-status-cell button {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--color-selected);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sms-channel-status-cell button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.sms-channel-quality {
|
||||
background: var(--color-surface-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.sms-channel-rate {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sms-channel-rate small,
|
||||
.sms-channel-rate strong {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.sms-channel-rate--success strong {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.sms-channel-rate--warning strong {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.sms-channel-rate--danger strong {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.sms-channel-rate span {
|
||||
color: var(--color-text-strong);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sms-channel-actions {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.sms-channel-actions button {
|
||||
align-items: center;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-selected);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
gap: var(--space-1);
|
||||
justify-content: center;
|
||||
min-height: 34px;
|
||||
padding: var(--space-1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sms-channel-actions button:hover {
|
||||
background: var(--color-accent-soft);
|
||||
border-color: var(--color-selected);
|
||||
}
|
||||
|
||||
.sms-channel-actions button.is-warning {
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.sms-channel-actions button.is-success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.sms-channel-actions button.is-danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.channel-log-modal,
|
||||
.channel-log-list,
|
||||
.channel-connection-summary {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.channel-connection-summary article {
|
||||
align-items: center;
|
||||
background: var(--color-surface-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: minmax(130px, 1fr) auto minmax(110px, auto) minmax(180px, 1.2fr);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.channel-connection-summary span {
|
||||
color: var(--color-text-muted);
|
||||
display: block;
|
||||
font-size: var(--font-size-sm);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.channel-connection-summary strong {
|
||||
color: var(--color-text-strong);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.channel-connection-summary p {
|
||||
color: var(--color-danger);
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.channel-log-item {
|
||||
align-items: start;
|
||||
background: var(--color-surface-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: 150px minmax(0, 1fr);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.channel-log-item strong {
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.channel-log-item span {
|
||||
color: var(--color-text-muted);
|
||||
display: block;
|
||||
font-size: var(--font-size-sm);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.channel-log-item pre {
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.65;
|
||||
margin: var(--space-1) 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.sms-channel-pagination {
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
justify-content: flex-end;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
position: sticky;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.sms-channel-pagination > label {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.sms-channel-form {
|
||||
display: grid;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.sms-channel-form section {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
padding-bottom: var(--space-7);
|
||||
}
|
||||
|
||||
.sms-channel-form section:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.sms-channel-form h3 {
|
||||
border-left: 5px solid var(--color-selected);
|
||||
padding-left: var(--space-3);
|
||||
}
|
||||
|
||||
.sms-channel-form-grid {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
grid-template-columns: minmax(170px, 180px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sms-channel-form-grid > .ui-field,
|
||||
.sms-channel-radio-row,
|
||||
.sms-channel-inline-field {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.sms-channel-form-grid .ui-field__label,
|
||||
.sms-channel-radio-row > span {
|
||||
color: var(--color-text-strong);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.sms-channel-radio-row {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--space-8);
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.sms-channel-radio-row label {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.sms-channel-inline-field {
|
||||
align-items: end;
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: minmax(0, 1fr) 160px;
|
||||
}
|
||||
|
||||
.sms-test-title {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.sms-test-title span {
|
||||
align-items: center;
|
||||
background: var(--color-selected-soft);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-selected);
|
||||
display: inline-flex;
|
||||
height: 58px;
|
||||
justify-content: center;
|
||||
width: 58px;
|
||||
}
|
||||
|
||||
.sms-test-title h2,
|
||||
.sms-test-title p {
|
||||
color: var(--color-text-strong);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sms-test-title p {
|
||||
margin-top: var(--space-1);
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
.ui-modal__title:has(.sms-test-title) {
|
||||
margin: calc(-1 * var(--space-6)) calc(-1 * var(--space-7));
|
||||
padding: var(--space-6) var(--space-7);
|
||||
width: calc(100% + (var(--space-7) * 2));
|
||||
}
|
||||
|
||||
.ui-modal__header:has(.sms-test-title) {
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.ui-modal__header:has(.sms-test-title) .ui-button {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.sms-test-modal {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.sms-test-channel {
|
||||
align-items: center;
|
||||
background: var(--color-surface-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.sms-test-channel span {
|
||||
border-right: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
padding-right: var(--space-4);
|
||||
}
|
||||
|
||||
.sms-test-channel strong {
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
|
||||
.sms-test-counter {
|
||||
align-items: center;
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sms-test-counter strong {
|
||||
color: var(--color-text-strong);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.sms-test-counter i {
|
||||
border-left: 1px solid var(--color-border);
|
||||
display: inline-block;
|
||||
height: 18px;
|
||||
margin: 0 var(--space-3);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.sms-test-note {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.sms-test-result {
|
||||
background: color-mix(in srgb, var(--color-success) 8%, var(--color-surface));
|
||||
border: 1px solid color-mix(in srgb, var(--color-success) 35%, var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.sms-test-result__summary {
|
||||
align-items: center;
|
||||
color: var(--color-success);
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.sms-test-result__summary strong,
|
||||
.sms-test-result__summary span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sms-test-result__summary span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.sms-test-result__records {
|
||||
border-top: 1px solid color-mix(in srgb, var(--color-success) 25%, var(--color-border));
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
.sms-test-result__records div {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-size: var(--font-size-sm);
|
||||
gap: var(--space-3);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sms-test-result__records code {
|
||||
color: var(--color-text-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.channel-connection-summary article {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Input, Modal, Select } from '@/components/ui';
|
||||
import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
|
||||
import { carrierLabelMap, cmppVersionOptions, regionOptions } from './channelModel';
|
||||
import type { Carrier, ChannelModalState, SmsChannel } from './channelTypes';
|
||||
|
||||
export function ChannelFormModal({
|
||||
modal,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
modal: ChannelModalState;
|
||||
onClose: () => void;
|
||||
onSubmit: (channel: SmsChannel) => void;
|
||||
}) {
|
||||
const channel = modal.channel;
|
||||
const [name, setName] = useState(channel?.name ?? '');
|
||||
const [carrier, setCarrier] = useState<Carrier>(channel?.carrier ?? 'mobile');
|
||||
const [unitPrice, setUnitPrice] = useState(channel ? moneyUnitsToYuan(channel.unitPrice).toFixed(4) : '0.0300');
|
||||
const [unitPriceError, setUnitPriceError] = useState('');
|
||||
const [region, setRegion] = useState(channel?.sendRegion ?? '全国');
|
||||
const [gatewayHost, setGatewayHost] = useState(channel?.gatewayHost ?? '');
|
||||
const [gatewayPort, setGatewayPort] = useState(channel?.gatewayPort ?? '7890');
|
||||
const [businessCode, setBusinessCode] = useState(channel?.businessCode ?? 'SMS');
|
||||
const [corpCode, setCorpCode] = useState(channel?.corpCode ?? '');
|
||||
const [account, setAccount] = useState(channel?.account ?? '');
|
||||
const [cmppVersion, setCmppVersion] = useState<'2.0' | '3.0'>(channel?.cmppVersion ?? '2.0');
|
||||
const [password, setPassword] = useState('');
|
||||
const [accessNo, setAccessNo] = useState(channel?.accessNo ?? '');
|
||||
const [extensionDigits, setExtensionDigits] = useState(String(channel?.extensionDigits ?? 0));
|
||||
const [flowLimit, setFlowLimit] = useState(String(channel?.rateLimitPerSecond ?? 100));
|
||||
const [desiredConnections, setDesiredConnections] = useState(String(channel?.desiredConnections ?? 1));
|
||||
const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16));
|
||||
const [heartbeatIntervalSeconds, setHeartbeatIntervalSeconds] = useState(String(channel?.heartbeatIntervalSeconds ?? 30));
|
||||
const [heartbeatMissThreshold, setHeartbeatMissThreshold] = useState(String(channel?.heartbeatMissThreshold ?? 3));
|
||||
|
||||
function submit() {
|
||||
if (!isValidMoneyInput(unitPrice)) {
|
||||
setUnitPriceError('单价必须是非负金额,且最多保留小数点后 4 位');
|
||||
return;
|
||||
}
|
||||
onSubmit({
|
||||
id: channel?.id ?? String(Math.floor(10000 + Math.random() * 80000)),
|
||||
name: name || '新建短信通道',
|
||||
carrier,
|
||||
sendRegion: region,
|
||||
unitPrice: yuanToMoneyUnits(unitPrice),
|
||||
status: channel?.status ?? 'connecting',
|
||||
total: channel?.total ?? 0,
|
||||
submitFailureRate: channel?.submitFailureRate ?? 0,
|
||||
submitFailureCount: channel?.submitFailureCount ?? 0,
|
||||
successRate: channel?.successRate ?? 0,
|
||||
successCount: channel?.successCount ?? 0,
|
||||
unknownRate: channel?.unknownRate ?? 0,
|
||||
unknownCount: channel?.unknownCount ?? 0,
|
||||
failureRate: channel?.failureRate ?? 0,
|
||||
failureCount: channel?.failureCount ?? 0,
|
||||
gatewayHost,
|
||||
gatewayPort,
|
||||
businessCode: businessCode.trim() || 'SMS',
|
||||
corpCode,
|
||||
account,
|
||||
accessNo,
|
||||
cmppVersion,
|
||||
desiredConnections: Number(desiredConnections) || 1,
|
||||
windowSize: Number(windowSize) || 16,
|
||||
heartbeatIntervalSeconds: Number(heartbeatIntervalSeconds) || 30,
|
||||
heartbeatMissThreshold: Number(heartbeatMissThreshold) || 3,
|
||||
extensionDigits: Number(extensionDigits),
|
||||
rateLimitPerSecond: Number(flowLimit),
|
||||
passwordCipher: password || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button onClick={submit}>确认</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>{modal.mode === 'edit' ? '编辑通道' : '创建通道'}</h2></div>}
|
||||
>
|
||||
<div className="sms-channel-form">
|
||||
<section>
|
||||
<h3>业务信息</h3>
|
||||
<div className="sms-channel-form-grid">
|
||||
<Input label="* 通道名称" onChange={(event) => setName(event.target.value)} placeholder="请输入通道名称" value={name} />
|
||||
<div className="sms-channel-radio-row">
|
||||
<span>* 运营商</span>
|
||||
{(['mobile', 'unicom', 'telecom', 'all'] as const).map((item) => (
|
||||
<label key={item}>
|
||||
<input checked={carrier === item} onChange={() => setCarrier(item)} type="radio" />
|
||||
{carrierLabelMap[item]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<Input error={unitPriceError} label="* 单价(元)" min="0" onChange={(event) => { setUnitPrice(event.target.value); setUnitPriceError(''); }} step="0.0001" type="number" value={unitPrice} />
|
||||
<Select label="* 发送地区" onChange={(event) => setRegion(event.target.value)} options={regionOptions} value={region} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>参数配置</h3>
|
||||
<div className="sms-channel-form-grid">
|
||||
<Input disabled label="* 协议选择" value="CMPP" />
|
||||
<div className="sms-channel-inline-field">
|
||||
<Input label="* 网关地址" onChange={(event) => setGatewayHost(event.target.value)} placeholder="请输入网关地址" value={gatewayHost} />
|
||||
<Input label="端口" onChange={(event) => setGatewayPort(event.target.value)} value={gatewayPort} />
|
||||
</div>
|
||||
<Input hint="对应 CMPP Service_Id,最多 10 个 ASCII 字符。" label="* 业务代码" maxLength={10} onChange={(event) => setBusinessCode(event.target.value.toUpperCase())} value={businessCode} />
|
||||
<Input label="* 企业代码" onChange={(event) => setCorpCode(event.target.value)} placeholder="请输入企业代码" value={corpCode} />
|
||||
<Input label="* 网关账号" onChange={(event) => setAccount(event.target.value)} placeholder="请输入网关账号" value={account} />
|
||||
<Select label="* CMPP版本" onChange={(event) => setCmppVersion(event.target.value as '2.0' | '3.0')} options={cmppVersionOptions} value={cmppVersion} />
|
||||
<Input
|
||||
hint={modal.mode === 'edit' ? '已配置的密码不会回显;留空保持不变,填写新密码才更新。' : undefined}
|
||||
label="网关密码"
|
||||
autoComplete="new-password"
|
||||
name="cmpp-gateway-password"
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder={modal.mode === 'edit' ? '留空不修改' : '请输入网关密码'}
|
||||
required={modal.mode === 'create'}
|
||||
type="password"
|
||||
value={password}
|
||||
/>
|
||||
<div className="sms-channel-inline-field">
|
||||
<Input autoComplete="off" label="* 接入号" name="cmpp-access-number" onChange={(event) => setAccessNo(event.target.value)} placeholder="请输入通道接入号" value={accessNo} />
|
||||
<Input label="扩展位数" max="20" min="0" onChange={(event) => setExtensionDigits(event.target.value)} type="number" value={extensionDigits} />
|
||||
</div>
|
||||
<Input label="* 通道流速" max="2000" min="1" onChange={(event) => setFlowLimit(event.target.value)} suffix="条/秒" type="number" value={flowLimit} />
|
||||
<Input label="* 期望连接数" onChange={(event) => setDesiredConnections(event.target.value)} placeholder="1" value={desiredConnections} />
|
||||
<Input label="* 提交窗口" onChange={(event) => setWindowSize(event.target.value)} placeholder="16" value={windowSize} />
|
||||
<Input hint="平台主动向供应商发送 ACTIVE_TEST 的间隔" label="* 心跳间隔" min="1" onChange={(event) => setHeartbeatIntervalSeconds(event.target.value)} suffix="秒" type="number" value={heartbeatIntervalSeconds} />
|
||||
<Input hint="连续未收到心跳响应达到该次数后重连" label="* 心跳失败阈值" min="1" onChange={(event) => setHeartbeatMissThreshold(event.target.value)} suffix="次" type="number" value={heartbeatMissThreshold} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Button, Input, Modal, Tag } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { connectionStatusLabelMap, formatLogDetail } from './channelModel';
|
||||
import type { ChannelLogState } from './channelTypes';
|
||||
|
||||
export function ChannelLogModal({
|
||||
logState,
|
||||
keyword,
|
||||
onKeywordChange,
|
||||
onClose,
|
||||
}: {
|
||||
logState: ChannelLogState;
|
||||
keyword: string;
|
||||
onKeywordChange: (keyword: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const normalizedKeyword = keyword.trim().toLowerCase();
|
||||
const filteredLogs = (logState.data?.logs ?? []).filter((log) =>
|
||||
!normalizedKeyword
|
||||
|| `${log.event} ${log.resourceId ?? ''} ${formatLogDetail(log.detail)}`.toLowerCase().includes(normalizedKeyword),
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={<Button onClick={onClose} variant="ghost">关闭</Button>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>连接日志</h2><p>{logState.channel.name}</p></div>}
|
||||
>
|
||||
<div className="channel-log-modal">
|
||||
{logState.data ? (
|
||||
<div className="channel-connection-summary">
|
||||
{logState.data.connectionStates.map((connection) => (
|
||||
<article key={connection.id}>
|
||||
<div>
|
||||
<span>连接 ID</span>
|
||||
<strong>{connection.connectionId}</strong>
|
||||
</div>
|
||||
<Tag tone={connection.status === 'connected' ? 'success' : connection.lastError ? 'danger' : 'info'}>
|
||||
{connectionStatusLabelMap[connection.status] ?? connection.status}
|
||||
</Tag>
|
||||
<div>
|
||||
<span>当前 / 期望</span>
|
||||
<strong>{connection.currentConnections} / {connection.desiredConnections}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>最近心跳</span>
|
||||
<strong>{formatDateTime(connection.lastHeartbeatAt)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>自动重连</span>
|
||||
<strong>{connection.reconnectCount} 次 / {formatDateTime(connection.nextReconnectAt)}</strong>
|
||||
</div>
|
||||
{connection.lastError ? <p>{connection.lastError}</p> : null}
|
||||
</article>
|
||||
))}
|
||||
{logState.data.connectionStates.length === 0 ? <p className="muted">暂无连接状态回写</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<Input label="筛选日志" onChange={(event) => onKeywordChange(event.target.value)} placeholder="事件、资源或详情关键词" value={keyword} />
|
||||
<div className="channel-log-list">
|
||||
{filteredLogs.map((log) => (
|
||||
<article className="channel-log-item" key={log.id}>
|
||||
<div>
|
||||
<strong>{log.event}</strong>
|
||||
<span>{formatDateTime(log.time)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>{log.resourceId}</span>
|
||||
<pre>{formatLogDetail(log.detail)}</pre>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{logState.data && filteredLogs.length === 0 ? <p className="muted">未找到匹配的连接日志</p> : null}
|
||||
{!logState.data ? <p className="muted">正在加载连接日志...</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Copy, Eye, FileText, Pencil, Power, Send } from 'lucide-react';
|
||||
import { DeleteRiskAction, Pagination, Tag } from '@/components/ui';
|
||||
import { formatCents } from '@/utils/currency';
|
||||
import { carrierLabelMap, carrierToneMap, statusLabelMap, statusToneMap } from './channelModel';
|
||||
import type { ChannelConfirmAction, ChannelModalState, SmsChannel } from './channelTypes';
|
||||
|
||||
function RateBlock({ label, rate, count, tone = 'neutral' }: { label: string; rate: number; count: number; tone?: 'success' | 'warning' | 'danger' | 'neutral' }) {
|
||||
return (
|
||||
<div className={`sms-channel-rate sms-channel-rate--${tone}`}>
|
||||
<small>{label}</small>
|
||||
<strong>{rate}%</strong>
|
||||
<span>{count.toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelTable({
|
||||
channels,
|
||||
currentPage,
|
||||
total,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
onOpenLogs,
|
||||
onOpenReports,
|
||||
onEdit,
|
||||
onConfirm,
|
||||
onTest,
|
||||
onDeleted,
|
||||
}: {
|
||||
channels: SmsChannel[];
|
||||
currentPage: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number | ((value: number) => number)) => void;
|
||||
onOpenLogs: (channel: SmsChannel) => void;
|
||||
onOpenReports: (channel: SmsChannel) => void;
|
||||
onEdit: (modal: ChannelModalState) => void;
|
||||
onConfirm: (action: ChannelConfirmAction) => void;
|
||||
onTest: (channel: SmsChannel) => void;
|
||||
onDeleted: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="surface sms-channel-table">
|
||||
<div className="sms-channel-table__head">
|
||||
<span>通道信息</span>
|
||||
<span>运营商 / 成本</span>
|
||||
<span>状态</span>
|
||||
<span>今日提交</span>
|
||||
<span>今日提交 / 送达质量</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{channels.map((channel) => (
|
||||
<article className="sms-channel-table__row" key={channel.id}>
|
||||
<div className="sms-channel-identity">
|
||||
<strong>{channel.name}</strong>
|
||||
<span>通道 ID:{channel.id}</span>
|
||||
</div>
|
||||
<div className="sms-channel-carrier-price">
|
||||
<Tag tone={carrierToneMap[channel.carrier]}>{carrierLabelMap[channel.carrier]}</Tag>
|
||||
<strong>{formatCents(channel.unitPrice)} 元</strong>
|
||||
</div>
|
||||
<div className="sms-channel-status-cell">
|
||||
<Tag tone={statusToneMap[channel.status]}>{statusLabelMap[channel.status]}</Tag>
|
||||
<button onClick={() => onOpenLogs(channel)} type="button">
|
||||
<FileText size={14} />连接日志
|
||||
</button>
|
||||
</div>
|
||||
<strong className="sms-channel-total">{channel.total.toLocaleString('zh-CN')}</strong>
|
||||
<div className="sms-channel-quality">
|
||||
<RateBlock count={channel.submitFailureCount} label="提交失败" rate={channel.submitFailureRate} tone={channel.submitFailureCount > 0 ? 'danger' : 'neutral'} />
|
||||
<RateBlock count={channel.successCount} label="送达成功" rate={channel.successRate} tone={channel.successRate >= 80 ? 'success' : 'warning'} />
|
||||
<RateBlock count={channel.unknownCount} label="回执未知" rate={channel.unknownRate} />
|
||||
<RateBlock count={channel.failureCount} label="送达失败" rate={channel.failureRate} tone={channel.failureRate >= 50 ? 'danger' : 'neutral'} />
|
||||
</div>
|
||||
<div className="sms-channel-actions">
|
||||
<button className="sms-channel-report-entry" onClick={() => onOpenReports(channel)} type="button">
|
||||
<Eye size={15} />报备详情
|
||||
</button>
|
||||
<button onClick={() => onEdit({ mode: 'edit', channel })} type="button"><Pencil size={15} />编辑</button>
|
||||
<button onClick={() => onConfirm({ type: 'copy', channel })} type="button"><Copy size={15} />复制通道</button>
|
||||
<button onClick={() => onTest(channel)} type="button"><Send size={15} />发送测试</button>
|
||||
<button className={channel.status === 'stopped' ? 'is-success' : 'is-warning'} onClick={() => onConfirm({ type: 'toggle', channel })} type="button">
|
||||
<Power size={15} />{channel.status === 'stopped' ? '启用' : '停用'}
|
||||
</button>
|
||||
<DeleteRiskAction onCompleted={onDeleted} portal="admin" targetId={channel.id} targetType="channel" />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => onPageChange((value) => Math.min(totalPages, value + 1))}
|
||||
onPrevious={() => onPageChange((value) => Math.max(1, value - 1))}
|
||||
page={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={onPageChange}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={total}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react';
|
||||
import { CheckCircle2, ExternalLink, Info, Send } from 'lucide-react';
|
||||
import { adminApi, type ChannelTestResponse } from '@/api/adminApi';
|
||||
import { Button, Input, Modal, Textarea } from '@/components/ui';
|
||||
import type { SmsChannel } from './channelTypes';
|
||||
|
||||
export function SmsTestModal({
|
||||
channel,
|
||||
onClose,
|
||||
onOpenRecords,
|
||||
}: {
|
||||
channel: SmsChannel;
|
||||
onClose: () => void;
|
||||
onOpenRecords: () => void;
|
||||
}) {
|
||||
const [phones, setPhones] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [accessNo, setAccessNo] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState<ChannelTestResponse | null>(null);
|
||||
const billingCount = Math.max(1, Math.ceil(content.length / 67));
|
||||
|
||||
async function submitTestSms() {
|
||||
if (!phones.trim()) {
|
||||
setError('请输入测试手机号');
|
||||
return;
|
||||
}
|
||||
if (!content.trim()) {
|
||||
setError('请输入测试短信内容');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
try {
|
||||
const response = await adminApi.testChannel(channel.id, {
|
||||
phones,
|
||||
content,
|
||||
accessNo: accessNo.trim() || undefined,
|
||||
});
|
||||
setResult(response);
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '测试短信发送失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
{result ? <Button icon={<ExternalLink size={16} />} onClick={onOpenRecords} variant="ghost">查看短信记录</Button> : null}
|
||||
<Button disabled={submitting || Boolean(result)} icon={<Send size={16} />} onClick={submitTestSms}>
|
||||
{submitting ? '发送中...' : result ? '已提交' : '发送测试'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={(
|
||||
<div className="sms-test-title">
|
||||
<span><Send size={30} /></span>
|
||||
<div>
|
||||
<h2>短信测试</h2>
|
||||
<p>向指定手机号发送测试短信</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="sms-test-modal">
|
||||
<div className="sms-test-channel">
|
||||
<span>测试通道</span>
|
||||
<strong>{channel.name}</strong>
|
||||
</div>
|
||||
<Textarea
|
||||
label="* 手机号码"
|
||||
onChange={(event) => setPhones(event.target.value)}
|
||||
placeholder="请输入手机号码,多个号码用逗号(,)隔开,最多允许10个号码"
|
||||
rows={3}
|
||||
value={phones}
|
||||
/>
|
||||
<p className="muted">支持多个号码,用中英文逗号分隔,最多10个</p>
|
||||
<Textarea
|
||||
label="* 短信内容"
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
placeholder="请输入短信内容"
|
||||
rows={4}
|
||||
value={content}
|
||||
/>
|
||||
<div className="sms-test-counter">
|
||||
<span>按67字/条计费</span>
|
||||
<strong>{content.length} 字符 <i /> 计费 {billingCount} 条</strong>
|
||||
</div>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
label="接入号(选填)"
|
||||
name="channel-test-access-number"
|
||||
onChange={(event) => setAccessNo(event.target.value)}
|
||||
placeholder="请输入接入号"
|
||||
value={accessNo}
|
||||
/>
|
||||
<div className="signature-alert sms-test-note">
|
||||
<Info size={18} />
|
||||
<span>如需测试接入号,可在通道接入号后上追加接入号进行测试</span>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{result ? (
|
||||
<div className="sms-test-result">
|
||||
<div className="sms-test-result__summary">
|
||||
<CheckCircle2 size={20} />
|
||||
<div>
|
||||
<strong>已写入真实发送队列</strong>
|
||||
<span>测试流水号:{result.testNo},共 {result.submitted} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sms-test-result__records">
|
||||
{result.messages.map((message) => (
|
||||
<div key={message.messageRecordId}>
|
||||
<span>{message.phoneNumber}</span>
|
||||
<code>{message.submitId}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { AdminChannel, ChannelQualityStat, CmppConnectionState } from '@/api/adminApi';
|
||||
import type { Carrier, ChannelStatus, SmsChannel } from './channelTypes';
|
||||
|
||||
export const connectionStatusLabelMap: Record<string, string> = {
|
||||
connected: '已连接',
|
||||
connecting: '连接中',
|
||||
reconnecting: '重连中',
|
||||
disconnected: '已断开',
|
||||
failed: '连接失败',
|
||||
auth_failed: '鉴权失败',
|
||||
heartbeat_timeout: '心跳超时',
|
||||
};
|
||||
|
||||
export const carrierOptions = [
|
||||
{ label: '全部运营商', value: 'all' },
|
||||
{ label: '移动', value: 'mobile' },
|
||||
{ label: '联通', value: 'unicom' },
|
||||
{ label: '电信', value: 'telecom' },
|
||||
{ label: '三网', value: 'all' },
|
||||
];
|
||||
|
||||
export const statusOptions = [
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '连接正常', value: 'normal' },
|
||||
{ label: '已停用', value: 'stopped' },
|
||||
{ label: '连接中', value: 'connecting' },
|
||||
{ label: '连接失败', value: 'failed' },
|
||||
];
|
||||
|
||||
export const cmppVersionOptions = [
|
||||
{ label: 'CMPP 2.0', value: '2.0' },
|
||||
{ label: 'CMPP 3.0', value: '3.0' },
|
||||
];
|
||||
|
||||
export const regionOptions = [
|
||||
{ label: '全国', value: '全国' },
|
||||
...'北京,天津,河北,山西,内蒙古,辽宁,吉林,黑龙江,上海,江苏,浙江,安徽,福建,江西,山东,河南,湖北,湖南,广东,广西,海南,重庆,四川,贵州,云南,西藏,陕西,甘肃,青海,宁夏,新疆,香港,澳门,台湾'.split(',').map((province) => ({ label: province, value: province })),
|
||||
];
|
||||
|
||||
export const carrierLabelMap: Record<Carrier, string> = {
|
||||
mobile: '移动',
|
||||
unicom: '联通',
|
||||
telecom: '电信',
|
||||
all: '三网',
|
||||
};
|
||||
|
||||
export const carrierToneMap: Record<Carrier, 'info' | 'danger' | 'success' | 'neutral'> = {
|
||||
mobile: 'info',
|
||||
unicom: 'danger',
|
||||
telecom: 'success',
|
||||
all: 'neutral',
|
||||
};
|
||||
|
||||
export const statusLabelMap: Record<ChannelStatus, string> = {
|
||||
normal: '连接正常',
|
||||
stopped: '已停用',
|
||||
connecting: '连接中',
|
||||
failed: '连接失败',
|
||||
};
|
||||
|
||||
export const statusToneMap: Record<ChannelStatus, 'success' | 'neutral' | 'info' | 'danger'> = {
|
||||
normal: 'success',
|
||||
stopped: 'neutral',
|
||||
connecting: 'info',
|
||||
failed: 'danger',
|
||||
};
|
||||
|
||||
export function formatLogDetail(detail?: unknown) {
|
||||
if (!detail) return '无附加信息';
|
||||
if (typeof detail === 'string') return detail;
|
||||
return JSON.stringify(detail, null, 2);
|
||||
}
|
||||
|
||||
export function resolveChannelStatus(channel: AdminChannel, connections: CmppConnectionState[] = []): ChannelStatus {
|
||||
if (channel.status !== 'active') {
|
||||
return 'stopped';
|
||||
}
|
||||
if (connections.some((connection) =>
|
||||
connection.status === 'connected'
|
||||
&& connection.currentConnections > 0
|
||||
&& connection.desiredConnections > 0,
|
||||
)) {
|
||||
return 'normal';
|
||||
}
|
||||
if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(connection.status) || connection.lastError)) {
|
||||
return 'failed';
|
||||
}
|
||||
return 'connecting';
|
||||
}
|
||||
|
||||
export function mapApiChannel(
|
||||
channel: AdminChannel,
|
||||
connections: CmppConnectionState[] = channel.connectionStates ?? [],
|
||||
quality?: ChannelQualityStat,
|
||||
): SmsChannel {
|
||||
return {
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
carrier: channel.carrier === 'unicom' || channel.carrier === 'telecom' || channel.carrier === 'all' ? channel.carrier : 'mobile',
|
||||
sendRegion: channel.sendRegion ?? '全国',
|
||||
unitPrice: channel.unitPrice,
|
||||
status: resolveChannelStatus(channel, connections),
|
||||
total: quality?.total ?? 0,
|
||||
submitFailureRate: quality?.submitFailureRate ?? 0,
|
||||
submitFailureCount: quality?.submitFailureCount ?? 0,
|
||||
successRate: quality?.successRate ?? 0,
|
||||
successCount: quality?.successCount ?? 0,
|
||||
unknownRate: quality?.unknownRate ?? 0,
|
||||
unknownCount: quality?.unknownCount ?? 0,
|
||||
failureRate: quality?.failureRate ?? 0,
|
||||
failureCount: quality?.failureCount ?? 0,
|
||||
gatewayHost: channel.gatewayHost,
|
||||
gatewayPort: String(channel.gatewayPort),
|
||||
businessCode: String(channel.config?.serviceId ?? 'SMS'),
|
||||
corpCode: channel.enterpriseCode ?? channel.code,
|
||||
account: channel.account,
|
||||
accessNo: channel.srcId,
|
||||
cmppVersion: channel.cmppVersion === '3.0' ? '3.0' : '2.0',
|
||||
desiredConnections: Number(channel.config?.desiredConnections ?? 1),
|
||||
windowSize: Number(channel.config?.windowSize ?? 16),
|
||||
heartbeatIntervalSeconds: Number(channel.config?.heartbeatIntervalSeconds ?? 30),
|
||||
heartbeatMissThreshold: Number(channel.config?.heartbeatMissThreshold ?? 3),
|
||||
extensionDigits: Number(channel.config?.extensionDigits ?? 0),
|
||||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapUiStatusToApi(channel: SmsChannel) {
|
||||
return channel.status === 'stopped' ? 'active' : 'disabled';
|
||||
}
|
||||
|
||||
export function buildChannelPayload(channel: SmsChannel, passwordCipher?: string) {
|
||||
return {
|
||||
name: channel.name,
|
||||
carrier: channel.carrier,
|
||||
sendRegion: channel.sendRegion,
|
||||
gatewayHost: channel.gatewayHost,
|
||||
gatewayPort: Number(channel.gatewayPort),
|
||||
protocol: 'CMPP',
|
||||
enterpriseCode: channel.corpCode,
|
||||
account: channel.account,
|
||||
passwordCipher: passwordCipher || undefined,
|
||||
srcId: channel.accessNo,
|
||||
cmppVersion: channel.cmppVersion,
|
||||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||
unitPrice: Math.round(channel.unitPrice),
|
||||
desiredConnections: channel.desiredConnections,
|
||||
windowSize: channel.windowSize,
|
||||
heartbeatIntervalSeconds: channel.heartbeatIntervalSeconds,
|
||||
heartbeatMissThreshold: channel.heartbeatMissThreshold,
|
||||
config: { extensionDigits: channel.extensionDigits, serviceId: channel.businessCode },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ChannelConnectionLogResponse } from '@/api/adminApi';
|
||||
|
||||
export type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all';
|
||||
export type ChannelStatus = 'normal' | 'stopped' | 'connecting' | 'failed';
|
||||
|
||||
export type SmsChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
carrier: Carrier;
|
||||
sendRegion: string;
|
||||
unitPrice: number;
|
||||
status: ChannelStatus;
|
||||
total: number;
|
||||
submitFailureRate: number;
|
||||
submitFailureCount: number;
|
||||
successRate: number;
|
||||
successCount: number;
|
||||
unknownRate: number;
|
||||
unknownCount: number;
|
||||
failureRate: number;
|
||||
failureCount: number;
|
||||
gatewayHost: string;
|
||||
gatewayPort: string;
|
||||
businessCode: string;
|
||||
corpCode: string;
|
||||
account: string;
|
||||
accessNo: string;
|
||||
cmppVersion: '2.0' | '3.0';
|
||||
desiredConnections: number;
|
||||
windowSize: number;
|
||||
heartbeatIntervalSeconds: number;
|
||||
heartbeatMissThreshold: number;
|
||||
extensionDigits: number;
|
||||
rateLimitPerSecond: number;
|
||||
passwordCipher?: string;
|
||||
};
|
||||
|
||||
export type ChannelModalState = {
|
||||
mode: 'create' | 'edit';
|
||||
channel?: SmsChannel;
|
||||
};
|
||||
|
||||
export type ChannelConfirmAction = {
|
||||
type: 'toggle' | 'copy';
|
||||
channel: SmsChannel;
|
||||
};
|
||||
|
||||
export type ChannelLogState = {
|
||||
channel: SmsChannel;
|
||||
data?: ChannelConnectionLogResponse;
|
||||
};
|
||||
@@ -0,0 +1,222 @@
|
||||
.admin-application-filter {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.cmpp-status-cell {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cmpp-status-cell button {
|
||||
align-items: center;
|
||||
background: var(--color-selected-soft);
|
||||
border: 1px solid rgba(37, 99, 235, 0.22);
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--color-selected);
|
||||
display: inline-flex;
|
||||
font-weight: var(--font-weight-bold);
|
||||
height: 28px;
|
||||
justify-content: center;
|
||||
min-width: 34px;
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
|
||||
.cmpp-status-cell button:hover {
|
||||
background: var(--color-selected);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
.cmpp-status-cell button:disabled,
|
||||
.cmpp-status-cell button.is-disabled {
|
||||
background: var(--color-surface-muted);
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.cmpp-status-cell button.is-enabled {
|
||||
background: var(--color-success-soft);
|
||||
border-color: color-mix(in srgb, var(--color-success) 30%, var(--color-border));
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.cmpp-status-cell__params {
|
||||
gap: 4px;
|
||||
min-width: 58px !important;
|
||||
}
|
||||
|
||||
.enterprise-app-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, max-content);
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.cmpp-connection-detail {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.cmpp-connection-summary {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.cmpp-connection-summary > div {
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
min-height: 82px;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.cmpp-connection-summary span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.cmpp-connection-summary strong {
|
||||
color: var(--color-text-strong);
|
||||
font-size: var(--font-size-lg);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cmpp-connection-list {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.cmpp-connection-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.cmpp-connection-card__heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cmpp-connection-card__heading > strong {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cmpp-connection-card__grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.cmpp-connection-card__grid > div {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cmpp-connection-card__grid span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.cmpp-connection-card__grid strong {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cmpp-param-detail {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.cmpp-param-grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.cmpp-param-grid > div {
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
min-height: 78px;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.cmpp-param-grid span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.cmpp-param-grid strong {
|
||||
color: var(--color-text-strong);
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.cmpp-param-copy {
|
||||
background: #0f172a;
|
||||
border-radius: var(--radius-md);
|
||||
color: #e5e7eb;
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: var(--space-5);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.app-create-modal {
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.app-create-modal__hint {
|
||||
background: var(--color-selected-soft);
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.app-create-modal__hint strong {
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.app-create-modal__hint span {
|
||||
color: var(--color-text-muted);
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.admin-application-filter {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.cmpp-connection-summary,
|
||||
.cmpp-connection-card__grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.cmpp-connection-summary,
|
||||
.cmpp-connection-card__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { ApplicationDeactivationPreview, TenantOption } from '@/api/adminApi';
|
||||
import { Button, Modal, Select } from '@/components/ui';
|
||||
import type { SmsApp } from './applicationTypes';
|
||||
|
||||
export function ConfirmModal({
|
||||
message,
|
||||
danger,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
message: string;
|
||||
danger?: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onCancel} variant="ghost">取消</Button>
|
||||
<Button onClick={onConfirm} variant={danger ? 'danger' : 'primary'}>确认</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onCancel}
|
||||
open
|
||||
title="操作确认"
|
||||
>
|
||||
<p className="admin-confirm-text">{message}</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeactivateApplicationModal({
|
||||
app,
|
||||
preview,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
app: SmsApp;
|
||||
preview: ApplicationDeactivationPreview;
|
||||
onCancel: () => void;
|
||||
onConfirm: (mode: 'wait' | 'force') => void;
|
||||
}) {
|
||||
const hasOutstanding = preview.totalOutstanding > 0;
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onCancel} variant="ghost">取消</Button>
|
||||
{hasOutstanding ? <Button onClick={() => onConfirm('force')} variant="danger">强制停用并断开连接</Button> : null}
|
||||
<Button onClick={() => onConfirm(hasOutstanding ? 'wait' : 'force')} variant={hasOutstanding ? 'warning' : 'primary'}>
|
||||
{hasOutstanding ? '等待回执后停用' : '确认停用'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onCancel}
|
||||
open
|
||||
title={`停用应用“${app.name}”`}
|
||||
>
|
||||
{hasOutstanding ? (
|
||||
<div className="section-stack">
|
||||
<p className="admin-confirm-text">该应用还有 {preview.totalOutstanding} 项回执或上行投递义务尚未清算。</p>
|
||||
<div className="cmpp-connection-summary">
|
||||
<div><span>等待供应商回执</span><strong>{preview.awaitingSupplierReceipt}</strong></div>
|
||||
<div><span>等待推送</span><strong>{preview.waitingToSend}</strong></div>
|
||||
<div><span>等待客户端确认</span><strong>{preview.awaitingClientAck}</strong></div>
|
||||
<div><span>可重试失败</span><strong>{preview.retryableFailures}</strong></div>
|
||||
<div><span>待推送上行</span><strong>{preview.pendingUplinks}</strong></div>
|
||||
<div><span>当前CMPP连接</span><strong>{preview.activeConnections}</strong></div>
|
||||
</div>
|
||||
<p className="form-hint">“等待回执后停用”会立即停止接收新短信,清算完成后自动停用;最长等待72小时。“强制停用”会立即断开全部连接并放弃剩余投递。</p>
|
||||
</div>
|
||||
) : <p className="admin-confirm-text">该应用没有待清算数据,将立即停用并断开全部 CMPP 客户端连接。</p>}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export 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">
|
||||
<Select
|
||||
disabled={loading || tenants.length === 0}
|
||||
hint={!loading && tenants.length === 0 ? '暂无可选择企业,请先创建真实企业。' : undefined}
|
||||
label="所属企业"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
options={[
|
||||
{ label: loading ? '企业加载中...' : '请选择真实企业', value: '' },
|
||||
...tenants.map((tenant) => ({ label: `${tenant.name}(${tenant.code})`, value: tenant.id })),
|
||||
]}
|
||||
value={selectedTenantId}
|
||||
/>
|
||||
<div className="app-create-modal__hint">
|
||||
<strong>{selectedTenantId ? tenants.find((tenant) => tenant.id === selectedTenantId)?.name : '请选择要开通短信应用的企业'}</strong>
|
||||
<span>下一步会进入应用参数、客户单价、IP 白名单和运营商通道组配置。</span>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react';
|
||||
import { Copy } from 'lucide-react';
|
||||
import type { ApplicationCmppParams, HttpApiConfigResponse } from '@/api/adminApi';
|
||||
import { Button, Modal } from '@/components/ui';
|
||||
import { copyText } from '@/utils/clipboard';
|
||||
import { formatHttpApiParams } from '@/utils/interfaceParams';
|
||||
import { formatCmppParams } from './applicationModel';
|
||||
import type { SmsApp } from './applicationTypes';
|
||||
|
||||
export function CmppParamsModal({
|
||||
app,
|
||||
params,
|
||||
onClose,
|
||||
}: {
|
||||
app: SmsApp;
|
||||
params?: ApplicationCmppParams | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copyError, setCopyError] = useState('');
|
||||
const paramsText = formatCmppParams(app, params);
|
||||
|
||||
async function copyParams() {
|
||||
try {
|
||||
await copyText(paramsText);
|
||||
setCopyError('');
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
} catch (failure) {
|
||||
setCopyError(failure instanceof Error ? failure.message : '复制失败');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">关闭</Button>
|
||||
<Button icon={<Copy size={15} />} onClick={copyParams}>{copied ? '已复制' : '一键复制'}</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>CMPP连接参数</h2><p>{app.enterprise} / {app.name}</p></div>}
|
||||
>
|
||||
<div className="cmpp-param-detail">
|
||||
<pre className="cmpp-param-copy">{paramsText}</pre>
|
||||
{copyError ? <p className="form-error">{copyError}</p> : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function HttpParamsModal({
|
||||
app,
|
||||
params,
|
||||
onClose,
|
||||
}: {
|
||||
app: SmsApp;
|
||||
params: HttpApiConfigResponse;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copyError, setCopyError] = useState('');
|
||||
const paramsText = formatHttpApiParams(params, window.location.origin);
|
||||
|
||||
async function copyParams() {
|
||||
try {
|
||||
await copyText(paramsText);
|
||||
setCopyError('');
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
} catch (failure) {
|
||||
setCopyError(failure instanceof Error ? failure.message : '复制失败');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">关闭</Button>
|
||||
<Button icon={<Copy size={15} />} onClick={() => void copyParams()}>{copied ? '已复制' : '一键复制'}</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>HTTP接口参数</h2><p>{app.enterprise} / {app.name}</p></div>}
|
||||
>
|
||||
<pre className="cmpp-param-copy">{paramsText}</pre>
|
||||
{copyError ? <p className="form-error">{copyError}</p> : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Button, Modal, Tag } from '@/components/ui';
|
||||
import { connectionStateMeta } from './applicationModel';
|
||||
import type { SmsApp } from './applicationTypes';
|
||||
|
||||
export function CmppConnectionModal({ app, onClose }: { app: SmsApp; onClose: () => void }) {
|
||||
const activeConnectionItems = app.cmppConnections.filter((item) => item.state === 'open');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={<Button onClick={onClose}>关闭</Button>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>CMPP连接详情</h2><p>{app.enterprise} / {app.name}</p></div>}
|
||||
>
|
||||
<div className="cmpp-connection-detail">
|
||||
<div className="cmpp-connection-summary">
|
||||
<div><span>当前连接数</span><strong>{activeConnectionItems.length}</strong></div>
|
||||
<div><span>配置连接数</span><strong>{app.cmppParams.maxConnections}</strong></div>
|
||||
<div><span>AppID</span><strong>{app.appId}</strong></div>
|
||||
<div>
|
||||
<span>连接状态</span>
|
||||
<Tag tone={app.cmppStatus === 'connected' ? 'success' : app.cmppStatus === 'disconnected' ? 'danger' : 'neutral'}>
|
||||
{app.cmppStatus === 'connected' ? '在线' : app.cmppStatus === 'disconnected' ? '离线' : '未开通'}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
{activeConnectionItems.length ? (
|
||||
<div className="cmpp-connection-list">
|
||||
{activeConnectionItems.map((record) => (
|
||||
<article className="cmpp-connection-card" key={record.id}>
|
||||
<div className="cmpp-connection-card__heading">
|
||||
<strong>{record.id}</strong>
|
||||
<Tag tone={connectionStateMeta[record.state].tone}>{connectionStateMeta[record.state].label}</Tag>
|
||||
</div>
|
||||
<div className="cmpp-connection-card__grid">
|
||||
<div><span>绑定类型</span><strong>{record.bindType}</strong></div>
|
||||
<div><span>客户端 IP</span><strong>{record.clientIp}</strong></div>
|
||||
<div><span>企业代码</span><strong>{record.sourceAddr}</strong></div>
|
||||
<div><span>窗口占用</span><strong>{record.pendingWindow}</strong></div>
|
||||
<div><span>连接建立时间</span><strong>{record.establishedAt}</strong></div>
|
||||
<div><span>上次心跳</span><strong>{record.lastHeartbeatAt}</strong></div>
|
||||
<div><span>上次提交</span><strong>{record.lastSubmitAt}</strong></div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : <div className="ui-table__empty">当前暂无已连接的 CMPP 会话</div>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Search } from 'lucide-react';
|
||||
import { Button, Input, Select } from '@/components/ui';
|
||||
|
||||
type EnterpriseApplicationFilterProps = {
|
||||
applicationKeyword: string;
|
||||
enterpriseKeyword: string;
|
||||
status: string;
|
||||
onApplicationKeywordChange: (value: string) => void;
|
||||
onEnterpriseKeywordChange: (value: string) => void;
|
||||
onQuery: () => void;
|
||||
onReset: () => void;
|
||||
onStatusChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function EnterpriseApplicationFilter({
|
||||
applicationKeyword,
|
||||
enterpriseKeyword,
|
||||
status,
|
||||
onApplicationKeywordChange,
|
||||
onEnterpriseKeywordChange,
|
||||
onQuery,
|
||||
onReset,
|
||||
onStatusChange,
|
||||
}: EnterpriseApplicationFilterProps) {
|
||||
return (
|
||||
<div className="surface admin-split-filter admin-application-filter">
|
||||
<Input
|
||||
label="企业名称"
|
||||
onChange={(event) => onEnterpriseKeywordChange(event.target.value)}
|
||||
placeholder="请输入企业名称"
|
||||
prefix={<Search size={16} />}
|
||||
value={enterpriseKeyword}
|
||||
/>
|
||||
<Input
|
||||
label="企业应用名称"
|
||||
onChange={(event) => onApplicationKeywordChange(event.target.value)}
|
||||
placeholder="请输入企业应用名称"
|
||||
prefix={<Search size={16} />}
|
||||
value={applicationKeyword}
|
||||
/>
|
||||
<Select
|
||||
label="状态"
|
||||
onChange={(event) => onStatusChange(event.target.value)}
|
||||
options={[
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '启用', value: 'active' },
|
||||
{ label: '停用中', value: 'disabling' },
|
||||
{ label: '停用', value: 'disabled' },
|
||||
]}
|
||||
value={status}
|
||||
/>
|
||||
<div className="admin-split-filter__actions">
|
||||
<Button icon={<Search size={16} />} onClick={onQuery}>查询</Button>
|
||||
<Button onClick={onReset} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Edit3, Settings2, Trash2 } from 'lucide-react';
|
||||
import { Button, Pagination, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
|
||||
import { formatAmount } from '@/utils/currency';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import type { ConfirmAction, SmsApp } from './applicationTypes';
|
||||
|
||||
function applicationStatusTag(app: SmsApp) {
|
||||
if (app.status === 'disabling') {
|
||||
const detail = app.deactivation;
|
||||
const title = [
|
||||
detail?.reason || '等待未完成回执清算',
|
||||
`等待供应商回执:${detail?.awaitingSupplierReceipt ?? 0}条`,
|
||||
`等待推送:${detail?.waitingToSend ?? 0}条`,
|
||||
`等待客户端确认:${detail?.awaitingClientAck ?? 0}条`,
|
||||
`可重试失败:${detail?.retryableFailures ?? 0}条`,
|
||||
`待推送上行:${detail?.pendingUplinks ?? 0}条`,
|
||||
`进入停用中:${formatDateTime(detail?.disablingAt)}`,
|
||||
`自动停用时间:${formatDateTime(detail?.autoDisableAt)}`,
|
||||
].join('\n');
|
||||
return <span aria-label={title} className="application-status-detail" tabIndex={0} title={title}><Tag tone="warning">停用中</Tag></span>;
|
||||
}
|
||||
return <Tag tone={app.status === 'active' ? 'success' : 'neutral'}>{app.status === 'active' ? '启用' : '停用'}</Tag>;
|
||||
}
|
||||
|
||||
type EnterpriseApplicationTableProps = {
|
||||
apps: SmsApp[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
onConfirmAction: (action: ConfirmAction) => void;
|
||||
onEdit: (app: SmsApp) => void;
|
||||
onOpenCmppParams: (app: SmsApp) => void;
|
||||
onOpenConnection: (app: SmsApp) => void;
|
||||
onOpenDeactivate: (app: SmsApp) => void;
|
||||
onOpenHttpParams: (app: SmsApp) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
};
|
||||
|
||||
export function EnterpriseApplicationTable({
|
||||
apps,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
onConfirmAction,
|
||||
onEdit,
|
||||
onOpenCmppParams,
|
||||
onOpenConnection,
|
||||
onOpenDeactivate,
|
||||
onOpenHttpParams,
|
||||
onPageChange,
|
||||
}: EnterpriseApplicationTableProps) {
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const columns = 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: 'sentToday', title: '今日发送', width: '120px', render: (record) => `${record.sentToday.toLocaleString('zh-CN')} 条` },
|
||||
{ key: 'deliveryRate', title: '到达率', width: '130px', render: (record) => `${record.deliveryRate}%` },
|
||||
{ key: 'unitPrice', title: '单价', width: '130px', render: (record) => `${formatAmount(record.unitPrice)} 元` },
|
||||
{
|
||||
key: 'cmppStatus',
|
||||
title: '客户连接状态',
|
||||
width: '250px',
|
||||
render: (record) => (
|
||||
<div className="cmpp-status-cell">
|
||||
<Tag tone={record.cmppStatus === 'connected' ? 'success' : record.cmppStatus === 'disconnected' ? 'danger' : 'neutral'}>
|
||||
{record.cmppStatus === 'connected' ? '已连接' : record.cmppStatus === 'disconnected' ? '已断开' : '未开通'}
|
||||
</Tag>
|
||||
<button disabled={!record.cmppParams.interfaceEnabled} onClick={() => onOpenConnection(record)} type="button">
|
||||
{record.cmppConnections.filter((item) => item.state === 'open').length}
|
||||
</button>
|
||||
<button
|
||||
className={`cmpp-status-cell__params ${record.cmppParams.interfaceEnabled ? 'is-enabled' : 'is-disabled'}`}
|
||||
disabled={!record.cmppParams.interfaceEnabled}
|
||||
onClick={() => onOpenCmppParams(record)}
|
||||
type="button"
|
||||
>
|
||||
<Settings2 size={13} />
|
||||
CMPP参数
|
||||
</button>
|
||||
<button
|
||||
className={`cmpp-status-cell__params ${record.httpEnabled ? 'is-enabled' : 'is-disabled'}`}
|
||||
disabled={!record.httpEnabled}
|
||||
onClick={() => onOpenHttpParams(record)}
|
||||
type="button"
|
||||
>
|
||||
HTTP参数
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'enabled', title: '状态', width: '130px', render: applicationStatusTag },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
width: '190px',
|
||||
render: (record) => (
|
||||
<div className="table-actions enterprise-app-actions">
|
||||
<Button icon={<Edit3 size={15} />} onClick={() => onEdit(record)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (record.status === 'active') onOpenDeactivate(record);
|
||||
else onConfirmAction({ action: 'enable', id: record.id, name: record.name });
|
||||
}}
|
||||
size="sm"
|
||||
variant={record.status === 'active' ? 'warning' : 'success'}
|
||||
>
|
||||
{record.status === 'active' ? '停用' : '启用'}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Trash2 size={15} />}
|
||||
onClick={() => onConfirmAction({ action: 'delete', id: record.id, name: record.name })}
|
||||
size="sm"
|
||||
variant="danger"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], [
|
||||
onConfirmAction,
|
||||
onEdit,
|
||||
onOpenCmppParams,
|
||||
onOpenConnection,
|
||||
onOpenDeactivate,
|
||||
onOpenHttpParams,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="surface section-stack">
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
label: '短信应用',
|
||||
value: 'sms',
|
||||
content: (
|
||||
<>
|
||||
<Table columns={columns} data={apps} pagination={false} rowKey="id" />
|
||||
<Pagination
|
||||
nextDisabled={page >= totalPages}
|
||||
onNext={() => onPageChange(Math.min(totalPages, page + 1))}
|
||||
onPageChange={onPageChange}
|
||||
onPrevious={() => onPageChange(Math.max(1, page - 1))}
|
||||
page={page}
|
||||
previousDisabled={page <= 1}
|
||||
total={total}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '彩信应用',
|
||||
value: 'mms',
|
||||
pending: true,
|
||||
content: <div className="ui-table__empty">彩信应用待后端能力确认,本页不展示演示数据。</div>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
ApplicationCmppParams,
|
||||
CmppDownstreamConnection,
|
||||
EnterpriseApplication,
|
||||
} from '@/api/adminApi';
|
||||
import { moneyUnitsToYuan } from '@/utils/currency';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import type { CmppConnection, SmsApp } from './applicationTypes';
|
||||
|
||||
export const connectionStateMeta: Record<CmppConnection['state'], { label: string; tone: 'success' | 'warning' | 'neutral' }> = {
|
||||
open: { label: '已连接', tone: 'success' },
|
||||
closed: { label: '已断开', tone: 'neutral' },
|
||||
reconnecting: { label: '重连中', tone: 'warning' },
|
||||
};
|
||||
|
||||
export function formatCmppParams(app: SmsApp, params?: ApplicationCmppParams | null) {
|
||||
const cmppParams = params ?? app.cmppParams;
|
||||
const interfaceEnabled = params?.interfaceEnabled ?? app.cmppParams.interfaceEnabled;
|
||||
const interfaceType = params?.interfaceType ?? app.cmppParams.interfaceType;
|
||||
return [
|
||||
`应用名称: ${app.name}`,
|
||||
`企业名称: ${app.enterprise}`,
|
||||
`AppID: ${app.appId}`,
|
||||
`短信接口: ${interfaceEnabled ? '开通' : '关闭'}`,
|
||||
`接口类型: ${interfaceType === 'cmpp20' ? 'CMPP2.0' : 'HTTP接口'}`,
|
||||
`CMPP网关地址: ${'gatewayHost' in cmppParams ? cmppParams.gatewayHost : cmppParams.host}`,
|
||||
`CMPP网关端口: ${'gatewayPort' in cmppParams ? cmppParams.gatewayPort : cmppParams.port}`,
|
||||
`企业代码: ${cmppParams.enterpriseCode}`,
|
||||
`接口账号: ${cmppParams.account}`,
|
||||
`接口密码: ${'passwordCipher' in cmppParams ? cmppParams.passwordCipher : cmppParams.password}`,
|
||||
`接入号: ${'srcId' in cmppParams ? cmppParams.srcId : cmppParams.accessNumber}`,
|
||||
`最大连接数: ${cmppParams.maxConnections}`,
|
||||
`心跳间隔: ${cmppParams.heartbeatSeconds}秒`,
|
||||
`协议版本: ${cmppParams.protocolVersion}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function mapConnection(connection: CmppDownstreamConnection): CmppConnection {
|
||||
const isOpen = connection.status === 'connected';
|
||||
return {
|
||||
id: connection.connectionId,
|
||||
state: isOpen ? 'open' : connection.status === 'reconnecting' ? 'reconnecting' : 'closed',
|
||||
bindType: 'transceiver',
|
||||
clientIp: String(connection.remoteIp ?? ''),
|
||||
sourceAddr: connection.enterpriseCode,
|
||||
establishedAt: formatDateTime(connection.connectedAt),
|
||||
lastHeartbeatAt: formatDateTime(connection.lastHeartbeatAt),
|
||||
lastSubmitAt: formatDateTime(connection.lastSubmitAt),
|
||||
pendingWindow: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export 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,
|
||||
status: application.status,
|
||||
enabled: application.status === 'active',
|
||||
deactivation: application.deactivation,
|
||||
sentToday: application.sentToday ?? 0,
|
||||
deliveryRate: application.deliveryRate ?? 0,
|
||||
unitPrice: moneyUnitsToYuan(application.customerUnitPrice),
|
||||
cmppStatus: application.interfaceEnabled === false ? 'inactive' : application.cmppStatus === 'connected' ? 'connected' : application.cmppStatus === 'inactive' ? 'inactive' : 'disconnected',
|
||||
cmppParams: {
|
||||
host: '',
|
||||
port: 0,
|
||||
interfaceEnabled: application.interfaceEnabled !== false,
|
||||
interfaceType: application.interfaceType ?? 'cmpp20',
|
||||
enterpriseCode: application.cmppEnterpriseCode ?? application.tenant?.code ?? application.tenantId,
|
||||
account: application.cmppAccount ?? application.tenantId,
|
||||
password: '',
|
||||
accessNumber: '',
|
||||
maxConnections: application.cmppMaxConnections ?? 1,
|
||||
heartbeatSeconds: 30,
|
||||
windowSize: application.cmppWindowSize ?? 16,
|
||||
protocolVersion: 'CMPP2.0',
|
||||
},
|
||||
cmppConnections: connections,
|
||||
httpEnabled: Boolean(application.httpConfig?.enabled),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ApplicationDeactivationPreview } from '@/api/adminApi';
|
||||
|
||||
export type SmsApp = {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
enterprise: string;
|
||||
appId: string;
|
||||
status: string;
|
||||
enabled: boolean;
|
||||
deactivation?: ApplicationDeactivationPreview | null;
|
||||
sentToday: number;
|
||||
deliveryRate: number;
|
||||
unitPrice: number;
|
||||
cmppStatus: 'connected' | 'disconnected' | 'inactive';
|
||||
cmppConnections: CmppConnection[];
|
||||
cmppParams: CmppParams;
|
||||
httpEnabled: boolean;
|
||||
};
|
||||
|
||||
export type CmppParams = {
|
||||
host: string;
|
||||
port: number;
|
||||
interfaceEnabled: boolean;
|
||||
interfaceType: string;
|
||||
enterpriseCode: string;
|
||||
account: string;
|
||||
password: string;
|
||||
accessNumber: string;
|
||||
maxConnections: number;
|
||||
heartbeatSeconds: number;
|
||||
windowSize: number;
|
||||
protocolVersion: string;
|
||||
};
|
||||
|
||||
export type CmppConnection = {
|
||||
id: string;
|
||||
state: 'open' | 'closed' | 'reconnecting';
|
||||
bindType: 'transceiver' | 'submitter' | 'receiver';
|
||||
clientIp: string;
|
||||
sourceAddr: string;
|
||||
establishedAt: string;
|
||||
lastHeartbeatAt: string;
|
||||
lastSubmitAt: string;
|
||||
pendingWindow: number;
|
||||
};
|
||||
|
||||
export type ConfirmAction =
|
||||
| { action: 'enable'; id: string; name: string }
|
||||
| { action: 'delete'; id: string; name: string };
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Info } from 'lucide-react';
|
||||
import { adminApi, type ApplicationReportField } from '@/api/adminApi';
|
||||
import { Button, Input, Modal, Textarea } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { DynamicReportFields } from './SignatureMaterialFields';
|
||||
import { hasMissingRequiredReportValue } from './signature.helpers';
|
||||
import type { DrainageInfo, UploadedFileRef } from './signature.types';
|
||||
|
||||
export function DrainageFormModal({ applicationId, item, onClose, onSubmit }: { applicationId?: string | null; item?: DrainageInfo; onClose: () => void; onSubmit: (item: DrainageInfo) => void }) {
|
||||
const [reportFields, setReportFields] = useState<ApplicationReportField[]>([]);
|
||||
const [form, setForm] = useState<DrainageInfo>(() => item ?? {
|
||||
id: `drain-${Date.now()}`,
|
||||
siteName: '',
|
||||
url: '',
|
||||
field1File: null,
|
||||
field2: '',
|
||||
field3: '',
|
||||
field4: '',
|
||||
field5: '',
|
||||
field6: '',
|
||||
field7File: null,
|
||||
field8: '',
|
||||
field9: '',
|
||||
field10: '',
|
||||
mobile: 'filing',
|
||||
unicom: 'filing',
|
||||
telecom: 'filing',
|
||||
submittedAt: formatDateTime(new Date()),
|
||||
remark: '',
|
||||
reportValues: {},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const request = applicationId
|
||||
? adminApi.listApplicationReportFields(applicationId, 'drainage')
|
||||
: adminApi.listCommonApplicationReportFields('drainage');
|
||||
request.then(setReportFields).catch(() => setReportFields([]));
|
||||
}, [applicationId]);
|
||||
|
||||
function update<Key extends keyof DrainageInfo>(key: Key, value: DrainageInfo[Key]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function updateReportValue(code: string, value: string | UploadedFileRef | null) {
|
||||
setForm((current) => ({ ...current, reportValues: { ...current.reportValues, [code]: value } }));
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button disabled={!form.url || hasMissingRequiredReportValue(reportFields, form.reportValues)} onClick={() => onSubmit(form)}>保存</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={item ? '编辑引流信息' : '添加引流信息'}
|
||||
>
|
||||
<div className="signature-form drainage-edit-form">
|
||||
<section>
|
||||
<h3>基本信息</h3>
|
||||
<Input
|
||||
label="* 引流url或号码"
|
||||
onChange={(event) => update('url', event.target.value)}
|
||||
placeholder="请输入引流url或号码"
|
||||
required
|
||||
value={form.url}
|
||||
/>
|
||||
<div className="signature-alert drainage-form-note">
|
||||
<Info size={18} />
|
||||
<ol>
|
||||
<li>本页面中所填的信息需与短信内容应用所包含的网站或服务保持一致;</li>
|
||||
<li>图片仅支持 PNG、JPG 或 JPEG 格式的正版文件,且大小不超过 3M;</li>
|
||||
<li>文件格式支持 PDF 格式或者图片,且大小不超过 10M。</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="signature-form-grid">
|
||||
<Textarea className="signature-form-grid__wide" label="备注" onChange={(event) => update('remark', event.target.value)} rows={4} value={form.remark} />
|
||||
</div>
|
||||
<DynamicReportFields fields={reportFields} onChange={updateReportValue} title="引流信息报备资料(通用 + 通道)" values={form.reportValues} />
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { ChevronDown, ChevronRight, Edit3, FileText, Plus } from 'lucide-react';
|
||||
import type { ClientSmsSignature } from '@/api/adminApi';
|
||||
import { Button, DeleteRiskAction, Pagination } from '@/components/ui';
|
||||
import { AuditStatusTag, CarrierReportTag, readDrainagePayload, signatureCardVisual } from './signature.helpers';
|
||||
import type { DrainageInfo } from './signature.types';
|
||||
|
||||
type EnterpriseSignaturesTableProps = {
|
||||
appliedDrainageKeyword: string;
|
||||
currentPage: number;
|
||||
expandedSignatureId: string;
|
||||
filteredSignatures: ClientSmsSignature[];
|
||||
loadData: () => Promise<void>;
|
||||
setDeleteTarget: Dispatch<SetStateAction<{ kind: 'drainage'; signatureId: string; id: string; name: string } | null>>;
|
||||
setDrainageModal: Dispatch<SetStateAction<{ signatureId: string; item?: DrainageInfo } | null>>;
|
||||
setDrainageReport: Dispatch<SetStateAction<{ signature: ClientSmsSignature; item: DrainageInfo } | null>>;
|
||||
setDrainageStatusTarget: Dispatch<SetStateAction<{ signature: ClientSmsSignature; item: DrainageInfo } | null>>;
|
||||
setExpandedSignatureId: Dispatch<SetStateAction<string>>;
|
||||
setPage: Dispatch<SetStateAction<number>>;
|
||||
setReportStatusTarget: Dispatch<SetStateAction<ClientSmsSignature | null>>;
|
||||
setSignatureModal: Dispatch<SetStateAction<ClientSmsSignature | 'new' | null>>;
|
||||
setSignatureReport: Dispatch<SetStateAction<ClientSmsSignature | null>>;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
visibleSignatures: ClientSmsSignature[];
|
||||
};
|
||||
|
||||
export function EnterpriseSignaturesTable({
|
||||
appliedDrainageKeyword,
|
||||
currentPage,
|
||||
expandedSignatureId,
|
||||
filteredSignatures,
|
||||
loadData,
|
||||
setDeleteTarget,
|
||||
setDrainageModal,
|
||||
setDrainageReport,
|
||||
setDrainageStatusTarget,
|
||||
setExpandedSignatureId,
|
||||
setPage,
|
||||
setReportStatusTarget,
|
||||
setSignatureModal,
|
||||
setSignatureReport,
|
||||
total,
|
||||
totalPages,
|
||||
visibleSignatures,
|
||||
}: EnterpriseSignaturesTableProps) {
|
||||
return (
|
||||
<div className="signature-list admin-enterprise-signature-list">
|
||||
{visibleSignatures.map((signature) => {
|
||||
const payload = readDrainagePayload(signature);
|
||||
const visibleDrainageLinks = appliedDrainageKeyword
|
||||
? payload.links.filter((item) => `${item.siteName} ${item.url} ${item.remark}`.includes(appliedDrainageKeyword))
|
||||
: payload.links;
|
||||
const cardVisual = signatureCardVisual(signature.auditStatus, signature.carrierReportSummary);
|
||||
const expanded = expandedSignatureId === signature.id || Boolean(appliedDrainageKeyword);
|
||||
return (
|
||||
<article aria-label={`签名总体状态:${cardVisual.label}`} className={`signature-card signature-card--${cardVisual.tone}`} key={signature.id} title={`总体状态:${cardVisual.label}`}>
|
||||
<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><AuditStatusTag status={signature.auditStatus} /></div>
|
||||
<div><span>移动</span><CarrierReportTag summary={signature.carrierReportSummary?.mobile} /></div>
|
||||
<div><span>联通</span><CarrierReportTag summary={signature.carrierReportSummary?.unicom} /></div>
|
||||
<div><span>电信</span><CarrierReportTag summary={signature.carrierReportSummary?.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={() => setReportStatusTarget(signature)} size="sm" variant="ghost">报备状态</Button>
|
||||
<Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost">编辑</Button>
|
||||
<DeleteRiskAction onCompleted={() => void loadData()} portal="admin" targetId={signature.id} targetType="signature" />
|
||||
</div>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className="drainage-panel">
|
||||
<h2>引流信息列表</h2>
|
||||
{visibleDrainageLinks.length ? (
|
||||
<div className="drainage-table">
|
||||
<div className="drainage-table__head">
|
||||
<span>引流url或号码</span>
|
||||
<span>审核状态</span>
|
||||
<span>移动</span>
|
||||
<span>联通</span>
|
||||
<span>电信</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{visibleDrainageLinks.map((item) => {
|
||||
const summary = signature.drainageCarrierReportSummary?.[item.id];
|
||||
return (
|
||||
<div className="drainage-table__row" key={item.id}>
|
||||
<span className="drainage-table__url" title={item.url}>{item.url}</span>
|
||||
<AuditStatusTag status={item.auditStatus ?? 'pending'} />
|
||||
<CarrierReportTag summary={summary?.mobile} />
|
||||
<CarrierReportTag summary={summary?.unicom} />
|
||||
<CarrierReportTag summary={summary?.telecom} />
|
||||
<span className="drainage-row-actions">
|
||||
<Button onClick={() => setDrainageReport({ signature, item })} size="sm" variant="ghost">报备详情</Button>
|
||||
<Button disabled={item.auditStatus !== 'approved'} onClick={() => setDrainageStatusTarget({ signature, 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.url })} 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>
|
||||
);
|
||||
})}
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
|
||||
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
|
||||
page={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={total}
|
||||
/>
|
||||
{filteredSignatures.length === 0 ? <div className="ui-table__empty">暂无企业签名</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Info } from 'lucide-react';
|
||||
import { adminApi, type ApplicationReportField, type ClientSmsApplication, type ClientSmsSignature, type TenantOption } from '@/api/adminApi';
|
||||
import { Button, Input, Modal, Select } from '@/components/ui';
|
||||
import { getSmsSignatureValidationError, hasForbiddenSmsSignatureCharacter, isCompleteSmsSignature, SMS_SIGNATURE_CHARACTER_ERROR } from '@/utils/smsSignature';
|
||||
import { DynamicReportFields } from './SignatureMaterialFields';
|
||||
import { hasMissingRequiredReportValue, readDrainagePayload } from './signature.helpers';
|
||||
import type { SignatureFormState, UploadedFileRef } from './signature.types';
|
||||
|
||||
export 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',
|
||||
reportValues: payload?.signatureReportValues ?? {},
|
||||
});
|
||||
const [reportFields, setReportFields] = useState<ApplicationReportField[]>([]);
|
||||
const [nameInputError, setNameInputError] = useState('');
|
||||
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted');
|
||||
|
||||
useEffect(() => {
|
||||
const request = form.applicationId
|
||||
? adminApi.listApplicationReportFields(form.applicationId, 'signature')
|
||||
: adminApi.listCommonApplicationReportFields('signature');
|
||||
request.then(setReportFields).catch(() => setReportFields([]));
|
||||
}, [form.applicationId]);
|
||||
|
||||
function update<Key extends keyof SignatureFormState>(key: Key, value: SignatureFormState[Key]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function updateReportValue(code: string, value: string | UploadedFileRef | null) {
|
||||
setForm((current) => ({ ...current, reportValues: { ...current.reportValues, [code]: value } }));
|
||||
}
|
||||
|
||||
const signatureNameValid = !nameInputError && isCompleteSmsSignature(form.name);
|
||||
const signatureNameError = nameInputError || (form.name ? getSmsSignatureValidationError(form.name) : undefined);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button disabled={!form.tenantId || !signatureNameValid || hasMissingRequiredReportValue(reportFields, form.reportValues)} onClick={() => onSubmit(form)}>保存</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={(
|
||||
<div className="signature-modal-title">
|
||||
<h2>{item ? '编辑签名' : '添加签名'}</h2>
|
||||
<p>{item ? '修改短信签名的相关信息' : '新增短信签名的相关信息'}</p>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="signature-form">
|
||||
<section>
|
||||
<h3>基本信息</h3>
|
||||
<div className="signature-alert">
|
||||
<Info size={18} />
|
||||
<span>签名名称必须包含完整中文黑括号,例如:【某某科技】。签名需履行报备,并遵照管理部门审核结果方可使用。</span>
|
||||
</div>
|
||||
<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
|
||||
error={signatureNameError}
|
||||
hint="新增和编辑时必须保留完整的【】,且不能包含空格或不可见字符"
|
||||
label="短信签名"
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
if (hasForbiddenSmsSignatureCharacter(value)) {
|
||||
setNameInputError(SMS_SIGNATURE_CHARACTER_ERROR);
|
||||
return;
|
||||
}
|
||||
setNameInputError('');
|
||||
update('name', value);
|
||||
}}
|
||||
placeholder="请输入完整签名,例如:【某某科技】"
|
||||
required
|
||||
value={form.name}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DynamicReportFields fields={reportFields} onChange={updateReportValue} title="签名报备资料(通用 + 通道)" values={form.reportValues} />
|
||||
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState } from 'react';
|
||||
import { Info, Upload } from 'lucide-react';
|
||||
import { adminApi, type ApplicationReportField } from '@/api/adminApi';
|
||||
import { Button, FileActions, Input, Modal } from '@/components/ui';
|
||||
import { displayFileName } from '@/utils/fileName';
|
||||
import type { ReportValues, UploadedFileRef } from './signature.types';
|
||||
|
||||
export function SignatureUploadBox({
|
||||
compact = false,
|
||||
file,
|
||||
label,
|
||||
onUploaded,
|
||||
}: {
|
||||
compact?: boolean;
|
||||
file?: UploadedFileRef | null;
|
||||
label: string;
|
||||
onUploaded: (file: UploadedFileRef) => void;
|
||||
}) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function uploadFile(fileInput: File | undefined) {
|
||||
if (!fileInput) return;
|
||||
setUploading(true);
|
||||
setError('');
|
||||
try {
|
||||
const fileObject = await adminApi.uploadFileObject(fileInput, { purpose: 'signature_report_material', prefix: 'signature-report-materials' });
|
||||
onUploaded({ contentType: fileObject.contentType, fileObjectId: fileObject.id, fileName: fileObject.fileName });
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '文件上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<label className={compact ? 'signature-upload signature-upload--compact' : 'signature-upload'}>
|
||||
<span>{label}</span>
|
||||
<Upload size={compact ? 30 : 42} />
|
||||
<strong>{uploading ? '上传中...' : (file ? displayFileName(file.fileName) : '') || (compact ? '上传文件' : '点击上传 或拖拽文件到此处')}</strong>
|
||||
<FileActions file={file} />
|
||||
{!compact ? <small>支持 PNG、JPG、JPEG、PDF,文件大小不超过 10M</small> : null}
|
||||
{error ? <small className="form-error">{error}</small> : null}
|
||||
<input
|
||||
accept="image/png,image/jpeg,application/pdf"
|
||||
disabled={uploading}
|
||||
onChange={(event) => { void uploadFile(event.target.files?.[0]); }}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function DynamicReportFields({ fields, onChange, title, values }: { fields: ApplicationReportField[]; onChange: (code: string, value: string | UploadedFileRef | null) => void; title: string; values: ReportValues }) {
|
||||
const [explanationOpen, setExplanationOpen] = useState(false);
|
||||
if (fields.length === 0) return null;
|
||||
const channels = Array.from(new Map(fields.flatMap((field) => field.channels).map((channel) => [channel.id, channel])).values());
|
||||
const groups = Array.from(new Map(channels.map((channel) => [channel.groupId, channel.groupName])).entries());
|
||||
const requiredCount = fields.filter((field) => field.required).length;
|
||||
const commonCount = fields.filter((field) => (field.commonReportTypes?.length ?? 0) > 0).length;
|
||||
return (
|
||||
<section>
|
||||
<div className="report-requirement-heading">
|
||||
<h3>{title}</h3>
|
||||
<Button icon={<Info size={15} />} onClick={() => setExplanationOpen(true)} size="sm" variant="ghost">为什么需要这些资料?</Button>
|
||||
</div>
|
||||
<div className="signature-alert">
|
||||
<Info size={18} />
|
||||
<span>当前要求由 {commonCount} 项通用字段及 {groups.length} 个通道组、{channels.length} 个通道配置合并生成,共 {fields.length} 项,其中 {requiredCount} 项必填。保存时会固化本次要求快照。</span>
|
||||
</div>
|
||||
<div className="signature-form-grid">
|
||||
{fields.map((field) => {
|
||||
const channelHint = field.channels.map((channel) => channel.name).join('、');
|
||||
const requiredChannels = field.required ? field.channels.filter((channel) => channel.required).map((channel) => channel.name).join('、') : '';
|
||||
const isCommon = (field.commonReportTypes?.length ?? 0) > 0;
|
||||
const label = `${field.required ? '* ' : ''}${field.name}`;
|
||||
const hint = isCommon
|
||||
? `平台通用${field.required ? '必填' : '选填'}资料${channelHint ? `,适用于:${channelHint}` : ''}`
|
||||
: field.required
|
||||
? `由 ${requiredChannels} 要求,至少一个通道配置为必填`
|
||||
: `适用通道:${channelHint}`;
|
||||
return field.fieldType === 'file' || field.fieldType === 'image' ? (
|
||||
<div key={field.id}>
|
||||
<SignatureUploadBox compact file={typeof values[field.code] === 'object' ? values[field.code] as UploadedFileRef : null} label={label} onUploaded={(file) => onChange(field.code, file)} />
|
||||
<small className="report-field-source">{hint}</small>
|
||||
</div>
|
||||
) : (
|
||||
<div key={field.id}>
|
||||
<Input label={label} onChange={(event) => onChange(field.code, event.target.value)} placeholder={field.description ?? `请输入${field.name}`} required={field.required} value={typeof values[field.code] === 'string' ? values[field.code] as string : ''} />
|
||||
<small className="report-field-source">{hint}</small>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Modal footer={<Button onClick={() => setExplanationOpen(false)}>我知道了</Button>} onClose={() => setExplanationOpen(false)} open={explanationOpen} size="xl" title="这些资料从哪里来?">
|
||||
<div className="report-requirement-explanation">
|
||||
<p>资料要求由“报备字段库通用配置”和“企业应用 → 通道组 → 通道 → 通道报备字段”实时合并;相同字段只填写一次,但会按目标通道分别用于报备。</p>
|
||||
{commonCount > 0 ? (
|
||||
<section className="report-source-group">
|
||||
<h4>平台通用字段</h4>
|
||||
<ul>{fields.filter((field) => (field.commonReportTypes?.length ?? 0) > 0).map((field) => <li key={field.id}>{field.name} · {field.commonReportTypes?.includes('signature') ? '签名报备' : '引流信息报备'} · {field.required ? '必填' : '选填'}</li>)}</ul>
|
||||
</section>
|
||||
) : null}
|
||||
{groups.map(([groupId, groupName]) => (
|
||||
<section className="report-source-group" key={groupId}>
|
||||
<h4>通道组:{groupName}</h4>
|
||||
{channels.filter((channel) => channel.groupId === groupId).map((channel) => (
|
||||
<div className="report-source-channel" key={channel.id}>
|
||||
<strong>{channel.name}({channel.code})</strong>
|
||||
<ul>
|
||||
{fields.filter((field) => field.channels.some((source) => source.id === channel.id)).map((field) => (
|
||||
<li key={field.id}>{field.name} · {field.channels.find((source) => source.id === channel.id)?.reportType === 'both' ? '签名和引流共用' : field.channels.find((source) => source.id === channel.id)?.reportType === 'signature' ? '签名报备' : '引流信息报备'} · {field.channels.find((source) => source.id === channel.id)?.required ? '必填' : '选填'}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from 'react';
|
||||
import { Info } from 'lucide-react';
|
||||
import { adminApi, type ClientSmsSignature } from '@/api/adminApi';
|
||||
import { Button, Modal, Select, Textarea } from '@/components/ui';
|
||||
import { AuditStatusTag, carrierLabel, CarrierReportTag, formatDate } from './signature.helpers';
|
||||
import type { DrainageInfo } from './signature.types';
|
||||
|
||||
const reportStatusOptions = [
|
||||
{ label: '未报备', value: 'pending' }, { label: '资料待补充', value: 'waiting_material' },
|
||||
{ label: '报备中', value: 'reporting' }, { label: '报备通过', value: 'approved' },
|
||||
{ label: '报备失败', value: 'failed' }, { label: '放弃报备', value: 'abandoned' },
|
||||
];
|
||||
|
||||
export function SignatureReportModal({ item, onClose }: { item: ClientSmsSignature; onClose: () => void }) {
|
||||
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><AuditStatusTag status={item.auditStatus} /></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><CarrierReportTag summary={item.carrierReportSummary?.mobile} /></span></button>
|
||||
<button className="admin-report-carrier--unicom active" type="button"><strong>联通</strong><span><CarrierReportTag summary={item.carrierReportSummary?.unicom} /></span></button>
|
||||
<button className="admin-report-carrier--telecom active" type="button"><strong>电信</strong><span><CarrierReportTag summary={item.carrierReportSummary?.telecom} /></span></button>
|
||||
</div>
|
||||
<div className="page-stack">{(item.reportTargets ?? []).map((target) => <div className="surface" key={target.channelId} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}({carrierLabel(target.channel.carrier)})</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsSignature; onClose: () => void; onSaved: () => void }) {
|
||||
const targets = item.reportTargets ?? [];
|
||||
const [statuses, setStatuses] = useState<Record<string, string>>(() => Object.fromEntries(targets.map((target) => [target.channelId, target.status])));
|
||||
const [reason, setReason] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: item.id, channelId: target.channelId, status: statuses[target.channelId] ?? target.status })), reason, sourceEntry: 'enterprise_signature' });
|
||||
onSaved();
|
||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '报备状态保存失败'); } finally { setSaving(false); }
|
||||
}
|
||||
return <Modal footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="按通道修改签名报备状态">
|
||||
<div className="page-stack"><div className="signature-alert"><Info size={18} /><span>企业签名只展示汇总结果;这里修改的是每个具体通道的报备任务,保存后会同步通道详情、报备任务和企业签名三网状态。</span></div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{targets.length ? targets.map((target) => <div className="surface admin-report-target-row" key={target.channelId}><div><strong>{target.channel.name}</strong><div className="muted">{carrierLabel(target.channel.carrier)} · {target.channel.name}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state">该企业应用当前没有配置目标通道。</div>}
|
||||
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入运营商工单、确认依据或人工处理说明" rows={3} value={reason} />
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
export function DrainageReportModal({ item, onClose, signature }: { item: DrainageInfo; onClose: () => void; signature: ClientSmsSignature }) {
|
||||
const summary = signature.drainageCarrierReportSummary?.[item.id];
|
||||
const targets = signature.drainageReportTargets?.[item.id] ?? [];
|
||||
return (
|
||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open size="xl" title="引流信息报备详情">
|
||||
<div className="detail-grid">
|
||||
<div><span>引流url或号码</span><strong>{item.url}</strong></div>
|
||||
<div><span>引流url或号码</span><strong>{item.url}</strong></div>
|
||||
<div><span>移动</span><CarrierReportTag summary={summary?.mobile} /></div>
|
||||
<div><span>联通</span><CarrierReportTag summary={summary?.unicom} /></div>
|
||||
<div><span>电信</span><CarrierReportTag summary={summary?.telecom} /></div>
|
||||
<div className="detail-grid__wide"><span>备注</span><strong>{item.remark || '-'}</strong></div>
|
||||
</div>
|
||||
<div className="page-stack" style={{ marginTop: 16 }}>{targets.map((target) => <div className="surface" key={target.channelId} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}({carrierLabel(target.channel.carrier)})</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrainageReportStatusModal({ item, onClose, onSaved, signature }: { item: DrainageInfo; onClose: () => void; onSaved: () => void; signature: ClientSmsSignature }) {
|
||||
const targets = signature.drainageReportTargets?.[item.id] ?? [];
|
||||
const [statuses, setStatuses] = useState<Record<string, string>>(() => Object.fromEntries(targets.map((target) => [target.channelId, target.status])));
|
||||
const [reason, setReason] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: signature.id, channelId: target.channelId, reportType: 'drainage', drainageItemId: item.id, status: statuses[target.channelId] ?? target.status })), reason, sourceEntry: 'enterprise_signature' });
|
||||
onSaved();
|
||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '引流报备状态保存失败'); } finally { setSaving(false); }
|
||||
}
|
||||
return <Modal footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="按通道修改引流信息报备状态">
|
||||
<div className="page-stack"><div className="signature-alert"><Info size={18} /><span>修改的是当前引流信息在具体通道上的真实报备任务,保存后会同步通道报备详情和报备任务页。</span></div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{targets.length ? targets.map((target) => <div className="surface admin-report-target-row" key={target.channelId}><div><strong>{target.channel.name}</strong><div className="muted">{carrierLabel(target.channel.carrier)} · {target.channel.name}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state">当前应用的目标通道没有配置引流信息报备字段。</div>}
|
||||
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} rows={3} value={reason} />
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { ApplicationReportField, ClientSmsSignature } from '@/api/adminApi';
|
||||
import { Tag } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import type { CarrierReportSummary, CarrierStatus, DrainageInfo, ReportValues, SignatureCardTone, UploadedFileRef } from './signature.types';
|
||||
|
||||
const carrierLabels: Record<string, string> = {
|
||||
mobile: '移动',
|
||||
unicom: '联通',
|
||||
telecom: '电信',
|
||||
all: '全网',
|
||||
};
|
||||
|
||||
export function carrierLabel(carrier?: string | null) {
|
||||
if (!carrier) return '未标注运营商';
|
||||
return carrierLabels[carrier] ?? carrier;
|
||||
}
|
||||
|
||||
export function CarrierReportTag({ summary }: { summary?: CarrierReportSummary }) {
|
||||
if (!summary || summary.status === 'not_applicable' || summary.total === 0) return <Tag tone="neutral">不适用</Tag>;
|
||||
let label = '未报备';
|
||||
let tone: 'success' | 'danger' | 'warning' | 'info' | 'neutral' = 'neutral';
|
||||
if (summary.status === 'approved') { label = '全部通过'; tone = 'success'; }
|
||||
else if (summary.status === 'failed' || summary.status === 'rejected') { label = '报备失败'; tone = 'danger'; }
|
||||
else if (summary.status === 'waiting_material') { label = '资料待补充'; tone = 'warning'; }
|
||||
else if (summary.approved > 0) { label = '部分通过'; tone = 'info'; }
|
||||
else if (summary.status === 'reporting' || summary.status === 'exporting') { label = '报备中'; tone = 'warning'; }
|
||||
return <span className="carrier-report-summary"><Tag tone={tone}>{label}</Tag><small>({summary.approved}/{summary.total})</small></span>;
|
||||
}
|
||||
|
||||
export function signatureCardVisual(auditStatus: string, summaries?: Record<string, CarrierReportSummary>) {
|
||||
if (auditStatus === 'rejected') return { label: '签名审核已驳回', tone: 'red' as SignatureCardTone };
|
||||
if (auditStatus === 'pending') return { label: '签名待审核', tone: 'amber' as SignatureCardTone };
|
||||
if (auditStatus !== 'approved') return { label: '签名尚未提交审核', tone: 'gray' as SignatureCardTone };
|
||||
|
||||
const values = Object.values(summaries ?? {});
|
||||
const applicable = values.filter((summary) => summary.total > 0 && summary.status !== 'not_applicable');
|
||||
if (applicable.some((summary) => ['failed', 'rejected'].includes(summary.status))) return { label: '存在报备失败', tone: 'red' as SignatureCardTone };
|
||||
if (applicable.some((summary) => summary.approved > 0 && summary.approved < summary.total)) return { label: '部分通道报备通过', tone: 'blue' as SignatureCardTone };
|
||||
if (applicable.some((summary) => summary.status === 'waiting_material')) return { label: '报备资料待补充', tone: 'amber' as SignatureCardTone };
|
||||
if (applicable.some((summary) => ['reporting', 'exporting'].includes(summary.status))) return { label: '通道报备处理中', tone: 'amber' as SignatureCardTone };
|
||||
if (applicable.length > 0 && applicable.every((summary) => summary.status === 'approved')) return { label: '所有目标通道报备通过', tone: 'green' as SignatureCardTone };
|
||||
if (applicable.some((summary) => summary.approved > 0)) return { label: '部分运营商报备通过', tone: 'blue' as SignatureCardTone };
|
||||
return { label: applicable.length > 0 ? '目标通道尚未报备' : '没有适用的目标通道', tone: 'gray' as SignatureCardTone };
|
||||
}
|
||||
|
||||
export function AuditStatusTag({ status }: { status: string }) {
|
||||
const meta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'danger' }> = {
|
||||
draft: { label: '草稿', tone: 'neutral' }, pending: { label: '待审核', tone: 'info' }, approved: { label: '已通过', tone: 'success' }, rejected: { label: '已驳回', tone: 'danger' },
|
||||
};
|
||||
const current = meta[status] ?? { label: status || '-', tone: 'neutral' as const };
|
||||
return <Tag tone={current.tone}>{current.label}</Tag>;
|
||||
}
|
||||
|
||||
export 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 profile = typeof payload.signatureProfile === 'object' && payload.signatureProfile ? payload.signatureProfile 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),
|
||||
},
|
||||
signatureProfile: profile,
|
||||
signatureReportValues: normalizeReportValues(payload.signatureReportValues),
|
||||
links: links.map((item) => ({
|
||||
id: String(item.id ?? `drain-${Date.now()}`),
|
||||
siteName: String(item.siteName ?? ''),
|
||||
url: String(item.url ?? ''),
|
||||
field1File: normalizeUploadedFile(item.field1File),
|
||||
field2: String(item.field2 ?? ''),
|
||||
field3: String(item.field3 ?? ''),
|
||||
field4: String(item.field4 ?? ''),
|
||||
field5: String(item.field5 ?? ''),
|
||||
field6: String(item.field6 ?? ''),
|
||||
field7File: normalizeUploadedFile(item.field7File),
|
||||
field8: String(item.field8 ?? ''),
|
||||
field9: String(item.field9 ?? ''),
|
||||
field10: String(item.field10 ?? ''),
|
||||
mobile: normalizeCarrierStatus(item.mobile, 'filing'),
|
||||
unicom: normalizeCarrierStatus(item.unicom, 'filing'),
|
||||
telecom: normalizeCarrierStatus(item.telecom, 'filing'),
|
||||
submittedAt: String(item.submittedAt ?? ''),
|
||||
remark: String(item.remark ?? ''),
|
||||
reportValues: normalizeReportValues(item.reportValues),
|
||||
auditStatus: String(item.auditStatus ?? 'pending'),
|
||||
rejectReason: item.rejectReason ? String(item.rejectReason) : null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDrainagePayload(carrierStatus: { mobile: CarrierStatus; unicom: CarrierStatus; telecom: CarrierStatus }, links: DrainageInfo[], signatureProfile?: Record<string, unknown>, signatureReportValues?: ReportValues) {
|
||||
return { carrierStatus, links, signatureProfile, signatureReportValues };
|
||||
}
|
||||
|
||||
export function normalizeReportValues(value: unknown): ReportValues {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [key, normalizeUploadedFile(item) ?? String(item ?? '')]));
|
||||
}
|
||||
|
||||
export function hasMissingRequiredReportValue(fields: ApplicationReportField[], values: ReportValues) {
|
||||
return fields.some((field) => field.required && !values[field.code]);
|
||||
}
|
||||
|
||||
export function normalizeUploadedFile(value: unknown): UploadedFileRef | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const item = value as Record<string, unknown>;
|
||||
const fileObjectId = String(item.fileObjectId ?? '');
|
||||
const fileName = String(item.fileName ?? '');
|
||||
const contentType = typeof item.contentType === 'string' ? item.contentType : undefined;
|
||||
return fileObjectId || fileName ? { contentType, fileObjectId, fileName } : null;
|
||||
}
|
||||
|
||||
export function normalizeCarrierStatus(value: unknown, fallback: CarrierStatus = 'filing'): CarrierStatus {
|
||||
return value === 'approved' || value === 'pending' || value === 'rejected' || value === 'filing' ? value : fallback;
|
||||
}
|
||||
|
||||
export function formatDate(value?: string) {
|
||||
return formatDateTime(value);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { FileRef } from '@/api/adminApi';
|
||||
|
||||
export type CarrierStatus = 'approved' | 'pending' | 'rejected' | 'filing';
|
||||
|
||||
export type DrainageInfo = {
|
||||
id: string;
|
||||
siteName: string;
|
||||
url: string;
|
||||
field1File?: UploadedFileRef | null;
|
||||
field2?: string;
|
||||
field3?: string;
|
||||
field4?: string;
|
||||
field5?: string;
|
||||
field6?: string;
|
||||
field7File?: UploadedFileRef | null;
|
||||
field8?: string;
|
||||
field9?: string;
|
||||
field10?: string;
|
||||
mobile: CarrierStatus;
|
||||
unicom: CarrierStatus;
|
||||
telecom: CarrierStatus;
|
||||
submittedAt: string;
|
||||
remark: string;
|
||||
reportValues: ReportValues;
|
||||
auditStatus?: string;
|
||||
rejectReason?: string | null;
|
||||
};
|
||||
|
||||
export type UploadedFileRef = FileRef;
|
||||
|
||||
export type ReportValues = Record<string, string | UploadedFileRef | null>;
|
||||
|
||||
export type SignatureFormState = {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
name: string;
|
||||
purpose: string;
|
||||
mobile: CarrierStatus;
|
||||
unicom: CarrierStatus;
|
||||
telecom: CarrierStatus;
|
||||
reportValues: ReportValues;
|
||||
};
|
||||
|
||||
export type CarrierReportSummary = { status: string; approved: number; total: number };
|
||||
|
||||
export type SignatureCardTone = 'green' | 'blue' | 'amber' | 'red' | 'gray';
|
||||
@@ -0,0 +1,433 @@
|
||||
.admin-sms-records-page .page-heading {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.admin-sms-record-filter {
|
||||
align-items: end;
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
grid-template-columns: repeat(4, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.admin-sms-record-filter__actions {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||
}
|
||||
|
||||
.admin-sms-record-table-card {
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.admin-sms-record-toolbar {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
min-height: 76px;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
}
|
||||
|
||||
.admin-sms-record-table-card .ui-table-wrap {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.admin-sms-record-list {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.admin-sms-record-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
|
||||
.admin-sms-record-card:hover {
|
||||
border-color: color-mix(in srgb, var(--color-selected) 35%, var(--color-border));
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.admin-sms-record-card > header {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: minmax(180px, 1fr) auto auto;
|
||||
}
|
||||
|
||||
.admin-sms-record-card > header time {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.admin-sms-record-card .admin-sms-record-content {
|
||||
background: var(--color-bg-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
display: -webkit-box;
|
||||
line-height: 1.55;
|
||||
max-width: none;
|
||||
overflow: hidden;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.admin-sms-record-card__meta {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-sms-record-card__meta > div {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.admin-sms-record-card__meta span,
|
||||
.admin-sms-record-card__meta small {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-sms-record-card__meta strong {
|
||||
color: var(--color-text-strong);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-sms-record-card > footer {
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.admin-sms-record-table {
|
||||
min-width: 1180px;
|
||||
}
|
||||
|
||||
.admin-sms-record-table th {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-strong);
|
||||
height: 58px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin-sms-record-table td {
|
||||
height: 120px;
|
||||
padding: var(--space-5) var(--space-6);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.admin-sms-record-sender,
|
||||
.admin-sms-record-phone,
|
||||
.admin-sms-record-channel {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.admin-sms-record-sender strong,
|
||||
.admin-sms-record-phone strong,
|
||||
.admin-sms-record-channel strong {
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.admin-sms-record-sender span,
|
||||
.admin-sms-record-phone span,
|
||||
.admin-sms-record-channel span,
|
||||
.admin-sms-record-sender small,
|
||||
.admin-sms-record-phone small {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-sms-record-content {
|
||||
color: var(--color-text-strong);
|
||||
line-height: 1.75;
|
||||
margin: 0;
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
.admin-sms-record-status {
|
||||
align-items: center;
|
||||
color: var(--color-text-muted);
|
||||
display: inline-flex;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.admin-sms-record-status i {
|
||||
border-radius: var(--radius-full);
|
||||
display: inline-flex;
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.admin-sms-record-status i.is-success {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.admin-sms-record-status i.is-unknown {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.admin-sms-record-status i.is-failed {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.admin-sms-record-status:has(.is-success) {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.admin-sms-record-status:has(.is-failed) {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.admin-sms-record-detail-link {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--color-selected);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.admin-sms-send-detail {
|
||||
display: grid;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.admin-sms-detail-overview {
|
||||
background: var(--color-surface-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
padding: var(--space-4) var(--space-5);
|
||||
}
|
||||
|
||||
.admin-sms-detail-overview div {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.admin-sms-detail-overview span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.admin-sms-detail-overview strong {
|
||||
color: var(--color-text-strong);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-sms-detail-notice {
|
||||
align-items: center;
|
||||
background: var(--color-selected-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--color-selected) 28%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-selected);
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.admin-sms-detail-notice strong {
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.admin-sms-send-detail h3 {
|
||||
color: var(--color-text-strong);
|
||||
font-size: var(--font-size-md);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.admin-sms-detail-content {
|
||||
background: var(--color-bg-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-strong);
|
||||
line-height: 1.75;
|
||||
margin: 0;
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.admin-sms-detail-status-grid {
|
||||
background: var(--color-bg-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-4) var(--space-6);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.admin-sms-detail-status-grid div {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.admin-sms-detail-status-grid span {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.admin-sms-detail-status-grid strong {
|
||||
color: var(--color-text-strong);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-sms-detail-failure {
|
||||
align-items: flex-start;
|
||||
background: var(--color-danger-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--color-danger) 28%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-danger);
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.admin-sms-detail-failure div {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.admin-sms-detail-failure span {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.admin-sms-detail-failure strong {
|
||||
color: var(--color-danger);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-sms-segment-list {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-sms-segment-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.admin-sms-segment-card header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.admin-sms-segment-card header > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.admin-sms-segment-card dl {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-sms-segment-card dl > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-sms-segment-card dt {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.admin-sms-segment-card dd {
|
||||
color: var(--color-text-strong);
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.admin-sms-record-card > header,
|
||||
.admin-sms-record-card__meta,
|
||||
.admin-sms-detail-overview,
|
||||
.admin-sms-detail-status-grid,
|
||||
.admin-sms-route-list dl,
|
||||
.admin-sms-segment-list,
|
||||
.admin-sms-segment-card dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-sms-record-card > header time {
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-sms-route-list {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.admin-sms-route-list article {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
padding: var(--space-5) 0;
|
||||
}
|
||||
|
||||
.admin-sms-route-list article + article {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.admin-sms-route-list article > span {
|
||||
align-items: center;
|
||||
background: var(--color-surface-muted);
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--color-text-muted);
|
||||
display: inline-flex;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
height: 36px;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.admin-sms-route-list strong {
|
||||
color: var(--color-text-strong);
|
||||
display: block;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.admin-sms-route-list dl {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-sms-route-list dt {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.admin-sms-route-list dd {
|
||||
color: var(--color-text-strong);
|
||||
margin: var(--space-2) 0 0;
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.admin-sms-record-filter,
|
||||
.admin-sms-detail-overview,
|
||||
.admin-sms-route-list dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { AlertTriangle, Info, MessageSquare } from 'lucide-react';
|
||||
import type { SmsMessageRecord, SmsMessageSegmentAudit } from '@/api/adminApi';
|
||||
import { Button, Modal, Tag } from '@/components/ui';
|
||||
import {
|
||||
buildRouteRows,
|
||||
getCarrierLabel,
|
||||
getReceiptNotice,
|
||||
getRecordStatus,
|
||||
getRecordStatusLabel,
|
||||
getTime,
|
||||
statusToneMap,
|
||||
} from './smsRecordModel';
|
||||
|
||||
type SendDetailModalProps = {
|
||||
record: SmsMessageRecord;
|
||||
segmentAudits: SmsMessageSegmentAudit[];
|
||||
segmentLoading: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function SendDetailModal({
|
||||
record,
|
||||
segmentAudits,
|
||||
segmentLoading,
|
||||
onClose,
|
||||
}: SendDetailModalProps) {
|
||||
const routeRows = buildRouteRows(record, segmentAudits);
|
||||
const channelGroupNames = Array.from(new Set(routeRows.map((route) => route.channelGroup).filter(Boolean)));
|
||||
const orderedSegmentAudits = [...segmentAudits].sort((left, right) => {
|
||||
const timeDiff = new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime();
|
||||
return timeDiff || left.segmentIndex - right.segmentIndex || left.id.localeCompare(right.id);
|
||||
});
|
||||
const sentAccessNumber = `${record.channel?.srcId ?? ''}${record.applicationExtension ?? ''}`;
|
||||
const displayStatus = getRecordStatus(record);
|
||||
const receiptNotice = getReceiptNotice(record);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={<Button onClick={onClose} variant="ghost">关闭</Button>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>发送详情</h2><p>{record.messageId}</p></div>}
|
||||
>
|
||||
<div className="admin-sms-send-detail">
|
||||
<div className="admin-sms-detail-overview">
|
||||
<div>
|
||||
<span>最终状态</span>
|
||||
<Tag tone={statusToneMap[displayStatus] ?? 'info'}>{getRecordStatusLabel(record)}</Tag>
|
||||
</div>
|
||||
<div><span>提交状态</span><strong>{record.submitStatus ?? '-'}</strong></div>
|
||||
<div><span>回执状态</span><strong>{record.receiptStatus ?? '-'}</strong></div>
|
||||
<div><span>提交时间</span><strong>{getTime(record.queuedAt)}</strong></div>
|
||||
<div><span>发送号码</span><strong>{record.phoneNumber || '-'}</strong></div>
|
||||
<div><span>号码归属</span><strong>{record.province ?? '-'} / {getCarrierLabel(record.carrier)}</strong></div>
|
||||
<div><span>通道组</span><strong>{channelGroupNames.join(' / ') || '-'}</strong></div>
|
||||
<div><span>收到的接入号</span><strong>{record.clientSrcId || '-'}</strong></div>
|
||||
<div><span>发送的接入号</span><strong>{sentAccessNumber || '-'}</strong></div>
|
||||
</div>
|
||||
{receiptNotice ? (
|
||||
<div className="admin-sms-detail-notice" role="status">
|
||||
<Info size={20} />
|
||||
<strong>{receiptNotice}</strong>
|
||||
</div>
|
||||
) : null}
|
||||
<section>
|
||||
<h3><MessageSquare size={18} /> 短信内容</h3>
|
||||
<p className="admin-sms-detail-content">{record.content}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>通道发送与回执</h3>
|
||||
<div className="admin-sms-route-list">
|
||||
{routeRows.map((route, index) => (
|
||||
<article key={route.id}>
|
||||
<span>{index + 1}</span>
|
||||
<div>
|
||||
<strong>{route.channel}</strong>
|
||||
<p className="muted">通道组:{route.channelGroup ?? '-'}</p>
|
||||
<dl>
|
||||
<div><dt>发送时间</dt><dd>{getTime(route.sentAt)}</dd></div>
|
||||
<div><dt>回执时间</dt><dd>{getTime(route.receiptAt)}</dd></div>
|
||||
<div><dt>回执码</dt><dd>{route.receiptCode ?? '-'}</dd></div>
|
||||
<div><dt>提交状态</dt><dd>{route.submitStatus ?? '-'}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>状态信息</h3>
|
||||
<div className="admin-sms-detail-status-grid">
|
||||
<div><span>消息编号</span><strong>{record.messageId}</strong></div>
|
||||
<div><span>发送状态</span><strong>{getRecordStatusLabel(record)}</strong></div>
|
||||
<div><span>提交状态</span><strong>{record.submitStatus ?? '-'}</strong></div>
|
||||
<div><span>回执状态</span><strong>{record.receiptStatus ?? '-'}</strong></div>
|
||||
</div>
|
||||
{['submit_failed', 'failed', 'rejected'].includes(displayStatus) ? (
|
||||
<div className="admin-sms-detail-failure" role="alert">
|
||||
<AlertTriangle size={20} />
|
||||
<div><span>失败原因</span><strong>{record.errorMessage ?? record.errorCode ?? '未返回明确失败原因'}</strong></div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>分片补偿审计</h3>
|
||||
{segmentLoading ? <div className="ui-table__empty">加载中...</div> : segmentAudits.length === 0 ? (
|
||||
<div className="ui-table__empty">暂无分片审计</div>
|
||||
) : (
|
||||
<div className="admin-sms-segment-list">
|
||||
{orderedSegmentAudits.map((segment) => (
|
||||
<article className="admin-sms-segment-card" key={segment.id}>
|
||||
<header>
|
||||
<strong>分片 {segment.segmentIndex}/{segment.segmentTotal}</strong>
|
||||
<div>
|
||||
<Tag tone={segment.submitStatus === 'accepted' ? 'success' : segment.submitStatus === 'queued' ? 'info' : 'danger'}>{segment.submitStatus}</Tag>
|
||||
{segment.receiptStatus ? <Tag tone={segment.receiptStatus === 'delivered' ? 'success' : segment.receiptStatus === 'unknown' ? 'neutral' : 'danger'}>{segment.receiptStatus}</Tag> : null}
|
||||
</div>
|
||||
</header>
|
||||
<dl>
|
||||
<div><dt>通道</dt><dd>{segment.channel?.name ?? segment.channelId ?? '-'}</dd></div>
|
||||
<div><dt>Sequence</dt><dd>{segment.sequenceId ?? '-'}</dd></div>
|
||||
<div><dt>提交 ID</dt><dd>{segment.submitId}</dd></div>
|
||||
<div><dt>网关 MsgId</dt><dd>{segment.gatewayMessageId ?? '-'}</dd></div>
|
||||
<div><dt>补偿方式</dt><dd>{segment.compensationType ?? '-'}</dd></div>
|
||||
<div><dt>审计时间</dt><dd>{getTime(segment.createdAt)}</dd></div>
|
||||
<div><dt>错误信息</dt><dd>{segment.errorMessage ?? segment.errorCode ?? '-'}</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Search, Smartphone } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
DateRangeInput,
|
||||
Input,
|
||||
Select,
|
||||
type DateRangeValue,
|
||||
} from '@/components/ui';
|
||||
|
||||
type SelectOption = { label: string; value: string };
|
||||
|
||||
type SmsRecordFilterProps = {
|
||||
application: string;
|
||||
applicationOptions: SelectOption[];
|
||||
carrier: string;
|
||||
channelKeyword: string;
|
||||
contentKeyword: string;
|
||||
dateRange: DateRangeValue;
|
||||
enterprise: string;
|
||||
enterpriseOptions: SelectOption[];
|
||||
phoneKeyword: string;
|
||||
status: string;
|
||||
onApplicationChange: (value: string) => void;
|
||||
onCarrierChange: (value: string) => void;
|
||||
onChannelKeywordChange: (value: string) => void;
|
||||
onContentKeywordChange: (value: string) => void;
|
||||
onDateRangeChange: (value: DateRangeValue) => void;
|
||||
onEnterpriseChange: (value: string) => void;
|
||||
onPhoneKeywordChange: (value: string) => void;
|
||||
onQuery: () => void;
|
||||
onReset: () => void;
|
||||
onStatusChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const carrierOptions = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '移动', value: 'mobile' },
|
||||
{ label: '联通', value: 'unicom' },
|
||||
{ label: '电信', value: 'telecom' },
|
||||
{ label: '未识别', value: 'unknown' },
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '发送成功', value: 'delivered' },
|
||||
{ label: '未知', value: 'unknown' },
|
||||
{ label: '提交失败', value: 'submit_failed' },
|
||||
{ label: '送达失败', value: 'failed' },
|
||||
];
|
||||
|
||||
export function SmsRecordFilter({
|
||||
application,
|
||||
applicationOptions,
|
||||
carrier,
|
||||
channelKeyword,
|
||||
contentKeyword,
|
||||
dateRange,
|
||||
enterprise,
|
||||
enterpriseOptions,
|
||||
phoneKeyword,
|
||||
status,
|
||||
onApplicationChange,
|
||||
onCarrierChange,
|
||||
onChannelKeywordChange,
|
||||
onContentKeywordChange,
|
||||
onDateRangeChange,
|
||||
onEnterpriseChange,
|
||||
onPhoneKeywordChange,
|
||||
onQuery,
|
||||
onReset,
|
||||
onStatusChange,
|
||||
}: SmsRecordFilterProps) {
|
||||
return (
|
||||
<div className="surface admin-sms-record-filter">
|
||||
<Select label="企业" onChange={(event) => onEnterpriseChange(event.target.value)} options={enterpriseOptions} value={enterprise} />
|
||||
<Select label="应用" onChange={(event) => onApplicationChange(event.target.value)} options={applicationOptions} value={application} />
|
||||
<DateRangeInput label="提交日期" onChange={onDateRangeChange} value={dateRange} />
|
||||
<Input label="手机号码" onChange={(event) => onPhoneKeywordChange(event.target.value)} prefix={<Smartphone size={16} />} value={phoneKeyword} />
|
||||
<Select label="运营商" onChange={(event) => onCarrierChange(event.target.value)} options={carrierOptions} value={carrier} />
|
||||
<Input label="短信内容" onChange={(event) => onContentKeywordChange(event.target.value)} value={contentKeyword} />
|
||||
<Input label="通道名称" onChange={(event) => onChannelKeywordChange(event.target.value)} value={channelKeyword} />
|
||||
<Select label="发送状态" onChange={(event) => onStatusChange(event.target.value)} options={statusOptions} value={status} />
|
||||
<div className="admin-sms-record-filter__actions">
|
||||
<Button icon={<Search size={16} />} onClick={onQuery}>查询</Button>
|
||||
<Button onClick={onReset} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Download } from 'lucide-react';
|
||||
import type { SmsMessageRecord } from '@/api/adminApi';
|
||||
import { Button, Pagination } from '@/components/ui';
|
||||
import { formatCents } from '@/utils/currency';
|
||||
import {
|
||||
getCarrierLabel,
|
||||
getClock,
|
||||
getDate,
|
||||
getRecordStatus,
|
||||
getStatusLabel,
|
||||
getTime,
|
||||
statusDotClassMap,
|
||||
} from './smsRecordModel';
|
||||
|
||||
function StatusLine({ record }: { record: SmsMessageRecord }) {
|
||||
const status = getRecordStatus(record);
|
||||
return (
|
||||
<span className="admin-sms-record-status">
|
||||
<i className={statusDotClassMap[status] ?? 'is-unknown'} />
|
||||
{getStatusLabel(status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type SmsRecordListProps = {
|
||||
currentPage: number;
|
||||
loading: boolean;
|
||||
records: SmsMessageRecord[];
|
||||
total: number;
|
||||
totalPages: number;
|
||||
onExport: () => void;
|
||||
onOpenDetail: (record: SmsMessageRecord) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
};
|
||||
|
||||
export function SmsRecordList({
|
||||
currentPage,
|
||||
loading,
|
||||
records,
|
||||
total,
|
||||
totalPages,
|
||||
onExport,
|
||||
onOpenDetail,
|
||||
onPageChange,
|
||||
}: SmsRecordListProps) {
|
||||
return (
|
||||
<div className="surface admin-sms-record-table-card">
|
||||
<div className="admin-sms-record-toolbar">
|
||||
<Button icon={<Download size={16} />} onClick={onExport} variant="ghost">导出CSV</Button>
|
||||
</div>
|
||||
<div className="admin-sms-record-list">
|
||||
{loading ? <div className="ui-table__empty">正在加载真实短信记录...</div> : records.length === 0 ? <div className="ui-table__empty">暂无短信记录</div> : records.map((record) => (
|
||||
<article className="admin-sms-record-card" key={record.id}>
|
||||
<header>
|
||||
<div className="admin-sms-record-sender">
|
||||
<strong>{record.tenant?.name ?? record.tenantId ?? '运营端通道测试'}</strong>
|
||||
<span>{record.application?.name ?? record.applicationId ?? '-'}</span>
|
||||
</div>
|
||||
<StatusLine record={record} />
|
||||
<time>{getDate(record.queuedAt)} {getClock(record.queuedAt)}</time>
|
||||
</header>
|
||||
<p className="admin-sms-record-content">{record.content}</p>
|
||||
<div className="admin-sms-record-card__meta">
|
||||
<div><span>接收号码</span><strong>{record.phoneNumber}</strong><small>{record.province ?? '-'} · {getCarrierLabel(record.carrier)}</small></div>
|
||||
<div><span>计费</span><strong>{record.billingUnits} 条 / ¥{formatCents(record.amountCents)}</strong><small>{record.content.length} 字</small></div>
|
||||
<div><span>发送通道</span><strong>{record.channel?.name ?? record.channelId ?? '-'}</strong><small>回执 {getTime(record.deliveredAt)}</small></div>
|
||||
</div>
|
||||
<footer><button className="admin-sms-record-detail-link" onClick={() => onOpenDetail(record)} type="button">查看发送详情</button></footer>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
onPageChange={onPageChange}
|
||||
onPrevious={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
page={currentPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={total}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import type {
|
||||
SmsMessageRecord,
|
||||
SmsMessageSegmentAudit,
|
||||
SmsReceiptRecord,
|
||||
} from '@/api/adminApi';
|
||||
import { formatCents } from '@/utils/currency';
|
||||
import type { DateRangeValue } from '@/components/ui';
|
||||
import type { RouteRow } from './smsRecordTypes';
|
||||
|
||||
export const statusLabelMap: Record<string, string> = {
|
||||
delivered: '发送成功',
|
||||
queued: '排队中',
|
||||
submitted: '已提交',
|
||||
submit_failed: '提交失败',
|
||||
unknown: '未知',
|
||||
failed: '送达失败',
|
||||
rejected: '已拒绝',
|
||||
};
|
||||
|
||||
export const statusToneMap: Record<string, 'success' | 'neutral' | 'danger' | 'info'> = {
|
||||
delivered: 'success',
|
||||
queued: 'info',
|
||||
submitted: 'info',
|
||||
submit_failed: 'danger',
|
||||
unknown: 'neutral',
|
||||
failed: 'danger',
|
||||
rejected: 'danger',
|
||||
};
|
||||
|
||||
export const statusDotClassMap: Record<string, string> = {
|
||||
delivered: 'is-success',
|
||||
queued: 'is-unknown',
|
||||
submitted: 'is-unknown',
|
||||
submit_failed: 'is-failed',
|
||||
unknown: 'is-unknown',
|
||||
failed: 'is-failed',
|
||||
rejected: 'is-failed',
|
||||
};
|
||||
|
||||
const carrierLabelMap: Record<string, string> = {
|
||||
mobile: '中国移动',
|
||||
unicom: '中国联通',
|
||||
telecom: '中国电信',
|
||||
all: '三网',
|
||||
};
|
||||
|
||||
function formatLocalDateTime(value?: string | null) {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
const parts = new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).formatToParts(date);
|
||||
const partMap = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
||||
return `${partMap.year}-${partMap.month}-${partMap.day} ${partMap.hour}:${partMap.minute}:${partMap.second}`;
|
||||
}
|
||||
|
||||
export function getDate(value?: string | null) {
|
||||
return formatLocalDateTime(value)?.slice(0, 10) ?? '';
|
||||
}
|
||||
|
||||
export function getTime(value?: string | null) {
|
||||
return formatLocalDateTime(value) ?? '-';
|
||||
}
|
||||
|
||||
export function getClock(value?: string | null) {
|
||||
return formatLocalDateTime(value)?.slice(11, 19) ?? '-';
|
||||
}
|
||||
|
||||
export function getStatusLabel(status?: string | null) {
|
||||
return status ? (statusLabelMap[status] ?? status) : '-';
|
||||
}
|
||||
|
||||
function isSubmitFailure(record: SmsMessageRecord) {
|
||||
return record.status === 'submit_failed' || ['rejected', 'timeout'].includes(record.submitStatus ?? '');
|
||||
}
|
||||
|
||||
export function getRecordStatus(record: SmsMessageRecord) {
|
||||
return isSubmitFailure(record) ? 'submit_failed' : record.status;
|
||||
}
|
||||
|
||||
export function getRecordStatusLabel(record: SmsMessageRecord) {
|
||||
return getStatusLabel(getRecordStatus(record));
|
||||
}
|
||||
|
||||
export function getReceiptNotice(record: SmsMessageRecord) {
|
||||
const hasPlatformFailureReceipt = (record.receiptRecords ?? []).some((receipt) =>
|
||||
receipt.gatewayMessageId.startsWith('PLATFORM:') && receipt.rawStatus === 'REJECTD',
|
||||
);
|
||||
if (hasPlatformFailureReceipt) {
|
||||
const deliveries = (record.downstreamDeliveries ?? []).filter((item) => item.deliveryType === 'receipt');
|
||||
if (deliveries.some((item) => item.status === 'delivered')) {
|
||||
return '平台已生成失败回执并通知企业';
|
||||
}
|
||||
const deliveryStatuses = Array.from(new Set(deliveries.map((item) => item.status)));
|
||||
return `平台已生成失败回执,企业通知状态:${deliveryStatuses.join('、') || '待投递'}`;
|
||||
}
|
||||
if (!record.tenantId && !record.applicationId && record.messageId.startsWith('MSG-TEST-')) {
|
||||
return '运营端通道测试,无需生成客户回执';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getCarrierLabel(carrier?: string | null) {
|
||||
return carrier ? (carrierLabelMap[carrier] ?? carrier) : '-';
|
||||
}
|
||||
|
||||
export function buildRouteRows(record: SmsMessageRecord, segmentAudits: SmsMessageSegmentAudit[]): RouteRow[] {
|
||||
if (segmentAudits.length > 0) {
|
||||
const submitById = new Map((record.submitRecords ?? []).map((submit) => [submit.submitId, submit]));
|
||||
const attempts = new Map<string, SmsMessageSegmentAudit[]>();
|
||||
segmentAudits.forEach((segment) => {
|
||||
const current = attempts.get(segment.submitId) ?? [];
|
||||
current.push(segment);
|
||||
attempts.set(segment.submitId, current);
|
||||
});
|
||||
return Array.from(attempts.entries())
|
||||
.map(([submitId, segments]) => {
|
||||
const ordered = [...segments].sort((left, right) => left.segmentIndex - right.segmentIndex);
|
||||
const sentTimes = ordered.map((segment) => segment.submittedAt).filter(Boolean) as string[];
|
||||
const receiptTimes = ordered.map((segment) => segment.deliveredAt).filter(Boolean) as string[];
|
||||
const receiptCodes = Array.from(new Set(ordered.map((segment) => segment.rawStatus).filter(Boolean)));
|
||||
const submitStatuses = Array.from(new Set(ordered.map((segment) => segment.submitStatus).filter(Boolean)));
|
||||
return {
|
||||
id: submitId,
|
||||
attempt: Math.min(...ordered.map((segment) => segment.attempt)),
|
||||
channel: ordered.find((segment) => segment.channel?.name)?.channel?.name
|
||||
?? ordered.find((segment) => segment.channelId)?.channelId
|
||||
?? '-',
|
||||
channelGroup: submitById.get(submitId)?.channelGroupName ?? submitById.get(submitId)?.channelGroup?.name,
|
||||
sentAt: sentTimes.sort()[0],
|
||||
receiptAt: receiptTimes.sort()[receiptTimes.length - 1],
|
||||
receiptCode: receiptCodes.join(' / ') || undefined,
|
||||
submitStatus: submitStatuses.join(' / ') || undefined,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => (left.attempt ?? 0) - (right.attempt ?? 0));
|
||||
}
|
||||
const receipts = record.receiptRecords ?? [];
|
||||
const receiptByGatewayId = new Map<string, SmsReceiptRecord>();
|
||||
receipts.forEach((receipt) => {
|
||||
if (receipt.gatewayMessageId) receiptByGatewayId.set(receipt.gatewayMessageId, receipt);
|
||||
});
|
||||
const submitRows = (record.submitRecords ?? []).map((submit, index) => {
|
||||
const receipt = submit.gatewayMessageId ? receiptByGatewayId.get(submit.gatewayMessageId) : undefined;
|
||||
return {
|
||||
id: submit.id || String(index + 1),
|
||||
channel: submit.channel?.name ?? record.channel?.name ?? submit.channelId ?? '-',
|
||||
channelGroup: submit.channelGroupName ?? submit.channelGroup?.name,
|
||||
sentAt: submit.submittedAt ?? submit.createdAt,
|
||||
receiptAt: receipt?.deliveredAt,
|
||||
receiptCode: receipt?.rawStatus,
|
||||
submitStatus: submit.submitStatus,
|
||||
};
|
||||
});
|
||||
if (submitRows.length > 0) return submitRows;
|
||||
return [{
|
||||
id: record.id,
|
||||
channel: record.channel?.name ?? record.channelId ?? '-',
|
||||
sentAt: record.submittedAt ?? record.queuedAt,
|
||||
receiptAt: record.deliveredAt,
|
||||
receiptCode: receipts[0]?.rawStatus,
|
||||
submitStatus: record.submitStatus,
|
||||
}];
|
||||
}
|
||||
|
||||
function csvCell(value: unknown) {
|
||||
const text = String(value ?? '');
|
||||
return `"${text.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
export function downloadCsv(records: SmsMessageRecord[]) {
|
||||
const rows = [
|
||||
['消息编号', '企业', '应用', '提交时间', '手机号', '地区', '运营商', '计费条数', '金额', '通道', '状态', '回执时间', '短信内容'],
|
||||
...records.map((record) => [
|
||||
record.messageId,
|
||||
record.tenant?.name ?? record.tenantId,
|
||||
record.application?.name ?? record.applicationId ?? '',
|
||||
getTime(record.queuedAt),
|
||||
record.phoneNumber,
|
||||
record.province ?? '',
|
||||
getCarrierLabel(record.carrier),
|
||||
record.billingUnits,
|
||||
formatCents(record.amountCents),
|
||||
record.channel?.name ?? record.channelId ?? '',
|
||||
getRecordStatusLabel(record),
|
||||
getTime(record.deliveredAt),
|
||||
record.content,
|
||||
]),
|
||||
];
|
||||
const blob = new Blob([`\uFEFF${rows.map((row) => row.map(csvCell).join(',')).join('\n')}`], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `sms-records-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function dateKey(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function defaultSmsRecordDateRange(): DateRangeValue {
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
return { start: dateKey(yesterday), end: dateKey(today) };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export type RouteRow = {
|
||||
id: string;
|
||||
attempt?: number;
|
||||
channel: string;
|
||||
channelGroup?: string | null;
|
||||
sentAt?: string | null;
|
||||
receiptAt?: string | null;
|
||||
receiptCode?: string | null;
|
||||
submitStatus?: string | null;
|
||||
};
|
||||
|
||||
export type MessageFilters = {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
phoneNumber?: string;
|
||||
contentKeyword?: string;
|
||||
channelKeyword?: string;
|
||||
carrier?: string;
|
||||
queuedAtFrom?: string;
|
||||
queuedAtTo?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export type TenantOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type ApplicationOption = {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
};
|
||||
@@ -0,0 +1,277 @@
|
||||
.admin-sms-task-page .page-heading {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.admin-task-template-row .ui-inline-text-preview {
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
.admin-task-template-row .ui-inline-text-preview p,
|
||||
.admin-task-template-row .ui-inline-text-preview p strong {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.admin-task-detail-title {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.admin-task-detail-title h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.admin-task-detail-title p {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.admin-task-detail-title p > span {
|
||||
color: var(--color-text-strong);
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.admin-task-detail {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.admin-task-metrics {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-task-metric {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
min-height: 104px;
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.admin-task-metric span {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.admin-task-metric strong {
|
||||
color: var(--color-text-strong);
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.admin-task-metric--success strong {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.admin-task-metric--primary {
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.08), rgba(22, 163, 74, 0.08)), var(--color-surface);
|
||||
border-color: rgba(37, 99, 235, 0.24);
|
||||
}
|
||||
|
||||
.admin-task-metric--primary strong {
|
||||
color: var(--color-selected);
|
||||
}
|
||||
|
||||
.admin-task-detail-grid {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.admin-task-info-list,
|
||||
.admin-task-template-meta {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-task-info-list div,
|
||||
.admin-task-template-meta div {
|
||||
align-items: start;
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.admin-task-info-list dt,
|
||||
.admin-task-template-meta dt {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.admin-task-info-list dd,
|
||||
.admin-task-template-meta dd {
|
||||
color: var(--color-text-strong);
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
justify-items: end;
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.admin-task-info-list dd span {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-task-progress-card {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.admin-task-progress-card > div:first-child {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.admin-task-progress-card > div:first-child span {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-task-progress-card > div:first-child strong {
|
||||
color: var(--color-selected);
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.admin-task-progress-split {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-task-progress-split span {
|
||||
background: var(--color-surface-muted);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-muted);
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
justify-items: center;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.admin-task-progress-split strong {
|
||||
color: var(--color-text-strong);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.admin-task-template-block {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.admin-task-template-block > span {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.admin-task-template {
|
||||
background: var(--color-bg-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-strong);
|
||||
line-height: 1.75;
|
||||
margin: 0;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.admin-task-template-meta b {
|
||||
color: var(--color-selected);
|
||||
margin: 0 var(--space-1);
|
||||
}
|
||||
|
||||
.admin-task-billing-note {
|
||||
align-items: center;
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
min-height: 58px;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
}
|
||||
|
||||
.admin-task-card--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.admin-carrier-grid {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-carrier-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.admin-carrier-card--mobile {
|
||||
background: #eef4ff;
|
||||
border-color: #a7c4ff;
|
||||
}
|
||||
|
||||
.admin-carrier-card--unicom {
|
||||
background: #eaf8ee;
|
||||
border-color: #96dda7;
|
||||
}
|
||||
|
||||
.admin-carrier-card--telecom {
|
||||
background: #fff3e6;
|
||||
border-color: #fdba74;
|
||||
}
|
||||
|
||||
.admin-carrier-card strong {
|
||||
color: var(--color-text-strong);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.admin-carrier-card p {
|
||||
align-items: center;
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-carrier-card b {
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.admin-carrier-card div {
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.32);
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-4);
|
||||
}
|
||||
|
||||
.admin-carrier-card em {
|
||||
color: var(--color-selected);
|
||||
font-size: 30px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-bold);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.admin-carrier-card--unicom em,
|
||||
.admin-success-text {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.admin-carrier-card--telecom em {
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.admin-task-detail-grid,
|
||||
.admin-task-metrics,
|
||||
.admin-carrier-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Search } from 'lucide-react';
|
||||
import { Button, DateRangeInput, Input, Select, type DateRangeValue } from '@/components/ui';
|
||||
|
||||
type SmsTaskFilterProps = {
|
||||
application: string;
|
||||
applicationOptions: Array<{ label: string; value: string }>;
|
||||
enterprise: string;
|
||||
enterpriseOptions: Array<{ label: string; value: string }>;
|
||||
keyword: string;
|
||||
submittedDateRange: DateRangeValue;
|
||||
onApplicationChange: (value: string) => void;
|
||||
onEnterpriseChange: (value: string) => void;
|
||||
onKeywordChange: (value: string) => void;
|
||||
onQuery: () => void;
|
||||
onReset: () => void;
|
||||
onSubmittedDateRangeChange: (value: DateRangeValue) => void;
|
||||
};
|
||||
|
||||
export function SmsTaskFilter({
|
||||
application,
|
||||
applicationOptions,
|
||||
enterprise,
|
||||
enterpriseOptions,
|
||||
keyword,
|
||||
submittedDateRange,
|
||||
onApplicationChange,
|
||||
onEnterpriseChange,
|
||||
onKeywordChange,
|
||||
onQuery,
|
||||
onReset,
|
||||
onSubmittedDateRangeChange,
|
||||
}: SmsTaskFilterProps) {
|
||||
return (
|
||||
<div className="surface admin-task-filter">
|
||||
<Input label="发送批次号" onChange={(event) => onKeywordChange(event.target.value)} placeholder="请输入发送批次号" value={keyword} />
|
||||
<Select
|
||||
label="选择企业"
|
||||
onChange={(event) => onEnterpriseChange(event.target.value)}
|
||||
options={enterpriseOptions}
|
||||
value={enterprise}
|
||||
/>
|
||||
<Select label="选择应用" onChange={(event) => onApplicationChange(event.target.value)} options={applicationOptions} value={application} />
|
||||
<DateRangeInput label="提交时间" onChange={onSubmittedDateRangeChange} value={submittedDateRange} />
|
||||
<div className="admin-task-filter__actions">
|
||||
<Button icon={<Search size={16} />} onClick={onQuery}>查询</Button>
|
||||
<Button onClick={onReset} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Fragment } from 'react';
|
||||
import { CalendarClock, Eye, StopCircle } from 'lucide-react';
|
||||
import { Button, InlineTextPreview, Pagination, Tag } from '@/components/ui';
|
||||
import {
|
||||
formatNumber,
|
||||
formatTime,
|
||||
getProgress,
|
||||
sendTypeLabels,
|
||||
splitSignature,
|
||||
statusLabels,
|
||||
statusTones,
|
||||
} from './taskModel';
|
||||
import type { SmsTask } from './taskTypes';
|
||||
|
||||
type SmsTaskTableProps = {
|
||||
currentPage: number;
|
||||
hoveredTaskId: string | null;
|
||||
loading: boolean;
|
||||
tasks: SmsTask[];
|
||||
total: number;
|
||||
totalPages: number;
|
||||
onHoverTask: (taskId: string | null) => void;
|
||||
onOpenDetail: (task: SmsTask) => void;
|
||||
onOpenPhones: (task: SmsTask) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onTerminate: (task: SmsTask) => void;
|
||||
};
|
||||
|
||||
export function SmsTaskTable({
|
||||
currentPage,
|
||||
hoveredTaskId,
|
||||
loading,
|
||||
tasks,
|
||||
total,
|
||||
totalPages,
|
||||
onHoverTask,
|
||||
onOpenDetail,
|
||||
onOpenPhones,
|
||||
onPageChange,
|
||||
onTerminate,
|
||||
}: SmsTaskTableProps) {
|
||||
return (
|
||||
<div className="surface admin-task-table-card">
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table batch-table admin-task-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '170px' }}>发送批次号</th>
|
||||
<th style={{ width: '180px' }}>企业/应用</th>
|
||||
<th style={{ width: '136px' }}>提交时间</th>
|
||||
<th style={{ width: '130px' }}>号码数/字符数</th>
|
||||
<th style={{ width: '150px' }}>发送方式</th>
|
||||
<th style={{ width: '190px' }}>进度</th>
|
||||
<th style={{ width: '130px' }}>状态</th>
|
||||
<th style={{ textAlign: 'right', width: '170px' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td className="ui-table__empty" colSpan={8}>正在加载真实短信任务...</td></tr>
|
||||
) : tasks.length === 0 ? (
|
||||
<tr><td className="ui-table__empty" colSpan={8}>暂无短信任务</td></tr>
|
||||
) : tasks.map((record) => {
|
||||
const progress = getProgress(record);
|
||||
const { signature, content } = splitSignature(record.templateContent);
|
||||
const rowClass = hoveredTaskId === record.id ? 'batch-row--hovered' : '';
|
||||
|
||||
return (
|
||||
<Fragment key={record.backendId}>
|
||||
<tr
|
||||
className={['batch-main-row', rowClass].filter(Boolean).join(' ')}
|
||||
onMouseEnter={() => onHoverTask(record.id)}
|
||||
onMouseLeave={() => onHoverTask(null)}
|
||||
>
|
||||
<td><strong className="admin-task-id">{record.id}</strong></td>
|
||||
<td>
|
||||
<div className="admin-task-enterprise">
|
||||
<strong>{record.enterprise}</strong>
|
||||
<span>{record.application}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><span>{formatTime(record.submittedAt)}</span></td>
|
||||
<td>
|
||||
<div className="admin-task-counts">
|
||||
<button className="table-link" onClick={() => onOpenPhones(record)} type="button">{formatNumber(record.phoneCount)} · 查看列表</button>
|
||||
<span>{record.wordCount}字</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-task-send-type">
|
||||
<Tag tone={record.sendType === 'immediate' ? 'info' : 'warning'}>
|
||||
{record.sendType === 'scheduled' ? <CalendarClock size={13} /> : null}
|
||||
{sendTypeLabels[record.sendType]}
|
||||
</Tag>
|
||||
{record.scheduledAt ? <span>{formatTime(record.scheduledAt)}</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="batch-progress admin-task-list-progress">
|
||||
<div>
|
||||
<span>{formatNumber(record.sentCount)}/{formatNumber(record.phoneCount)}</span>
|
||||
<strong>{progress}%</strong>
|
||||
</div>
|
||||
<div className="batch-progress__track">
|
||||
<span className={`batch-progress__bar batch-progress__bar--${record.status === 'failed' ? 'terminated' : record.status}`} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><Tag tone={statusTones[record.status]}>{statusLabels[record.status]}</Tag></td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div className="admin-task-actions">
|
||||
<Button icon={<Eye size={15} />} onClick={() => onOpenDetail(record)} size="sm" variant="ghost">详情</Button>
|
||||
<Button
|
||||
disabled={record.status !== 'sending'}
|
||||
icon={<StopCircle size={15} />}
|
||||
onClick={() => onTerminate(record)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
终止
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
className={['batch-template-row', 'admin-task-template-row', rowClass].filter(Boolean).join(' ')}
|
||||
onMouseEnter={() => onHoverTask(record.id)}
|
||||
onMouseLeave={() => onHoverTask(null)}
|
||||
>
|
||||
<td colSpan={8}>
|
||||
<InlineTextPreview label="模板内容" leading={signature ? <strong>【{signature}】</strong> : null}>
|
||||
{content}
|
||||
</InlineTextPreview>
|
||||
</td>
|
||||
</tr>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
onPrevious={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
page={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={onPageChange}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={total}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { BarChart3, MapPin, Send, Smartphone, TrendingUp } from 'lucide-react';
|
||||
import { Button, Modal, Table, Tag } from '@/components/ui';
|
||||
import {
|
||||
formatNumber,
|
||||
formatTime,
|
||||
getProgress,
|
||||
getRegionRate,
|
||||
getSuccessRate,
|
||||
sendTypeLabels,
|
||||
statusLabels,
|
||||
statusTones,
|
||||
} from './taskModel';
|
||||
import type { RegionStat, SmsTask } from './taskTypes';
|
||||
|
||||
function TaskDetailTitle({ task }: { task: SmsTask }) {
|
||||
return (
|
||||
<div className="admin-task-detail-title">
|
||||
<h2>发送批次详情</h2>
|
||||
<p>
|
||||
<span>{task.id}</span>
|
||||
<Tag tone={statusTones[task.status]}>{statusLabels[task.status]}</Tag>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, tone }: { label: string; value: string; tone?: 'success' | 'primary' }) {
|
||||
return (
|
||||
<div className={['admin-task-metric', tone ? `admin-task-metric--${tone}` : ''].filter(Boolean).join(' ')}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export 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
|
||||
footer={<Button onClick={onClose}>关闭</Button>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<TaskDetailTitle task={task} />}
|
||||
>
|
||||
<div className="admin-task-detail">
|
||||
<div className="admin-task-metrics">
|
||||
<MetricCard label="提交总数" value={formatNumber(task.submittedCount)} />
|
||||
<MetricCard label="提交成功" tone="success" value={formatNumber(task.submittedSuccess)} />
|
||||
<MetricCard label="发送成功" tone="primary" value={formatNumber(task.successCount)} />
|
||||
<MetricCard label="计费条数" tone="primary" value={formatNumber(task.billingCount)} />
|
||||
<MetricCard label="成功率" tone="primary" value={`${successRate.toFixed(2)}%`} />
|
||||
</div>
|
||||
|
||||
<div className="admin-task-detail-grid">
|
||||
<section className="admin-task-card">
|
||||
<h3><Send size={18} />发送批次信息</h3>
|
||||
<dl className="admin-task-info-list">
|
||||
<div>
|
||||
<dt>企业/应用</dt>
|
||||
<dd><strong>{task.enterprise}</strong><span>{task.application}</span></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>提交时间</dt>
|
||||
<dd>{formatTime(task.submittedAt)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>发送方式</dt>
|
||||
<dd><Tag tone={task.sendType === 'immediate' ? 'info' : 'warning'}>{sendTypeLabels[task.sendType]}</Tag></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="admin-task-card">
|
||||
<h3><TrendingUp size={18} />发送进度</h3>
|
||||
<div className="admin-task-progress-card">
|
||||
<div>
|
||||
<span>已处理 {formatNumber(task.sentCount)} / 总计 {formatNumber(task.phoneCount)}</span>
|
||||
<strong>{progress}%</strong>
|
||||
</div>
|
||||
<div className="batch-progress__track">
|
||||
<span className={`batch-progress__bar batch-progress__bar--${task.status === 'failed' ? 'terminated' : task.status}`} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="admin-task-progress-split">
|
||||
<span>已提交<strong>{formatNumber(task.submittedSuccess)}</strong></span>
|
||||
<span>已成功<strong>{formatNumber(task.successCount)}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-task-card">
|
||||
<h3><BarChart3 size={18} />模板信息</h3>
|
||||
<div className="admin-task-template-block">
|
||||
<span>短信模板内容</span>
|
||||
<p className="admin-task-template">{task.templateContent}</p>
|
||||
</div>
|
||||
<dl className="admin-task-template-meta">
|
||||
<div><dt>字符数/计费条数</dt><dd>{task.wordCount} 字符 <b>·</b> {perPhoneBillingUnits} 条/号码</dd></div>
|
||||
<div><dt>发送号码数</dt><dd>{formatNumber(task.phoneCount)} 个</dd></div>
|
||||
</dl>
|
||||
<div className="admin-task-billing-note">
|
||||
<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>
|
||||
{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>
|
||||
<Table
|
||||
columns={[
|
||||
{ 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: RegionStat) => <Tag tone={getRegionRate(record) >= 95 ? 'success' : 'info'}>{getRegionRate(record).toFixed(1)}%</Tag>,
|
||||
},
|
||||
]}
|
||||
data={task.regions}
|
||||
emptyText="暂无已识别省份记录"
|
||||
rowKey={(record) => record.region}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { adminApi, type BatchTaskMessagePage } from '@/api/adminApi';
|
||||
import { Button, Input, Modal, Pagination, Select, Table, Tag } from '@/components/ui';
|
||||
import { carrierLabels, messageStatusLabel } from './taskModel';
|
||||
import type { SmsTask } from './taskTypes';
|
||||
|
||||
type TaskPhoneListModalProps = {
|
||||
task: SmsTask;
|
||||
onClose: () => void;
|
||||
onError: (message: string) => void;
|
||||
};
|
||||
|
||||
const emptyPage: BatchTaskMessagePage = { items: [], total: 0, page: 1, pageSize: 20 };
|
||||
|
||||
export function TaskPhoneListModal({ task, onClose, onError }: TaskPhoneListModalProps) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [data, setData] = useState<BatchTaskMessagePage>(emptyPage);
|
||||
|
||||
function loadPhones(targetPage = page, targetPageSize = pageSize) {
|
||||
adminApi.listAdminBatchTaskMessages(task.backendId, {
|
||||
phone: keyword || undefined,
|
||||
page: targetPage,
|
||||
pageSize: targetPageSize,
|
||||
})
|
||||
.then(setData)
|
||||
.catch((failure: Error) => onError(failure.message || '发送批次号码列表加载失败'));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadPhones(page, pageSize);
|
||||
}, [task.backendId, page, pageSize]);
|
||||
|
||||
return (
|
||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open size="xl" title={`号码列表 · 发送批次号 ${task.id}`}>
|
||||
<div className="page-stack">
|
||||
<div className="audit-filter-grid">
|
||||
<Input label="手机号码" onChange={(event) => setKeyword(event.target.value)} placeholder="输入完整或部分号码" value={keyword} />
|
||||
<Select
|
||||
label="每页条数"
|
||||
onChange={(event) => {
|
||||
setPageSize(Number(event.target.value));
|
||||
setPage(1);
|
||||
}}
|
||||
options={[{ label: '10条/页', value: '10' }, { label: '20条/页', value: '20' }, { label: '50条/页', value: '50' }]}
|
||||
value={String(pageSize)}
|
||||
/>
|
||||
<div className="audit-filter-actions">
|
||||
<Button icon={<Search size={16} />} onClick={() => { setPage(1); loadPhones(1, pageSize); }}>查询</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Table
|
||||
columns={[
|
||||
{ key: 'phoneNumber', title: '手机号码', render: (item) => <strong>{item.phoneNumber}</strong> },
|
||||
{ key: 'province', title: '号码归属地', render: (item) => item.province || '-' },
|
||||
{ key: 'carrier', title: '运营商', render: (item) => carrierLabels[item.carrier ?? '']?.label ?? item.carrier ?? '-' },
|
||||
{ key: 'status', title: '短信记录状态', render: (item) => <Tag tone={item.status === 'delivered' ? 'success' : ['failed', 'submit_failed', 'rejected', 'timeout'].includes(item.status) ? 'danger' : 'info'}>{messageStatusLabel(item.status)}</Tag> },
|
||||
]}
|
||||
data={data.items}
|
||||
emptyText="暂无号码记录"
|
||||
rowKey="id"
|
||||
/>
|
||||
<Pagination
|
||||
nextDisabled={page * pageSize >= data.total}
|
||||
onNext={() => setPage((current) => current + 1)}
|
||||
onPageChange={setPage}
|
||||
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
|
||||
page={page}
|
||||
previousDisabled={page <= 1}
|
||||
total={data.total}
|
||||
totalPages={Math.max(1, Math.ceil(data.total / pageSize))}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Button, Modal } from '@/components/ui';
|
||||
import type { SmsTask } from './taskTypes';
|
||||
|
||||
type TerminateTaskModalProps = {
|
||||
task: SmsTask;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function TerminateTaskModal({ task, onCancel, onConfirm }: TerminateTaskModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onCancel} variant="ghost">取消</Button>
|
||||
<Button onClick={onConfirm} variant="danger">确认终止</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onCancel}
|
||||
open
|
||||
title="确认终止短信任务"
|
||||
>
|
||||
<div className="admin-confirm-text">
|
||||
确认终止任务 <strong>{task.id}</strong> 吗?终止后将停止继续提交未发送号码,已提交部分仍以运营商回执为准。
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { SmsBatchTask, SmsMessageRecord } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import type { CarrierStat, RegionStat, SmsTask, TaskStatus } from './taskTypes';
|
||||
|
||||
export const statusLabels: Record<TaskStatus, string> = {
|
||||
sending: '发送中',
|
||||
completed: '已完成',
|
||||
terminated: '已终止',
|
||||
failed: '失败',
|
||||
};
|
||||
|
||||
export const statusTones: Record<TaskStatus, 'info' | 'success' | 'neutral' | 'danger'> = {
|
||||
sending: 'info',
|
||||
completed: 'success',
|
||||
terminated: 'neutral',
|
||||
failed: 'danger',
|
||||
};
|
||||
|
||||
export const sendTypeLabels = {
|
||||
immediate: '立即发送',
|
||||
scheduled: '定时发送',
|
||||
} as const;
|
||||
|
||||
export 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' },
|
||||
};
|
||||
|
||||
export function formatNumber(value: number) {
|
||||
return value.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
export function formatTime(value?: string | null) {
|
||||
return formatDateTime(value);
|
||||
}
|
||||
|
||||
export function messageStatusLabel(status: string) {
|
||||
return {
|
||||
pending_review: '待人工审核',
|
||||
queued: '已入队',
|
||||
scheduled: '等待定时发送',
|
||||
submitted: '供应商已受理',
|
||||
delivered: '送达成功',
|
||||
submit_failed: '提交失败',
|
||||
failed: '回执失败',
|
||||
rejected: '已拒绝',
|
||||
timeout: '超时',
|
||||
canceled: '已取消',
|
||||
}[status] ?? status;
|
||||
}
|
||||
|
||||
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.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.province ?? '未识别省份';
|
||||
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 buildCarrierStatsFromAggregates(stats: SmsBatchTask['messageStats']): CarrierStat[] {
|
||||
const totals = new Map<string, CarrierStat>();
|
||||
(stats ?? []).forEach((item) => {
|
||||
const carrier = item.carrier ?? 'unknown';
|
||||
const meta = carrierLabels[carrier] ?? { label: carrier || '未识别', tone: 'mobile' as const };
|
||||
const current = totals.get(carrier) ?? { name: meta.label, total: 0, success: 0, tone: meta.tone };
|
||||
current.total += item._count._all;
|
||||
if (item.status === 'delivered') current.success += item._count._all;
|
||||
totals.set(carrier, current);
|
||||
});
|
||||
return Array.from(totals.values());
|
||||
}
|
||||
|
||||
function buildRegionStatsFromAggregates(stats: SmsBatchTask['messageStats']): RegionStat[] {
|
||||
const totals = new Map<string, RegionStat>();
|
||||
(stats ?? []).forEach((item) => {
|
||||
const region = item.province ?? '未识别省份';
|
||||
const current = totals.get(region) ?? { region, total: 0, success: 0 };
|
||||
current.total += item._count._all;
|
||||
if (item.status === 'delivered') current.success += item._count._all;
|
||||
totals.set(region, current);
|
||||
});
|
||||
return Array.from(totals.values()).sort((a, b) => b.total - a.total);
|
||||
}
|
||||
|
||||
export 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);
|
||||
// submittedTotal already includes unknown and timeout records, so never add them again.
|
||||
const processedCount = Math.max(submittedCount, successCount + failedCount + (task.unknownTotal ?? 0));
|
||||
const billingCount = (task.messageStats ?? []).reduce((sum, item) => sum + (item._sum.billingUnits ?? 0), 0)
|
||||
|| 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.min(task.phoneTotal, processedCount),
|
||||
successCount,
|
||||
failedCount,
|
||||
status: normalizeTaskStatus(task.status),
|
||||
rawStatus: task.status,
|
||||
carriers: task.messageStats ? buildCarrierStatsFromAggregates(task.messageStats) : buildCarrierStats(messages),
|
||||
regions: task.messageStats ? buildRegionStatsFromAggregates(task.messageStats) : buildRegionStats(messages),
|
||||
};
|
||||
}
|
||||
|
||||
export function getProgress(task: SmsTask) {
|
||||
return task.phoneCount > 0 ? Math.min(100, Math.round((task.sentCount / task.phoneCount) * 100)) : 0;
|
||||
}
|
||||
|
||||
export function getSuccessRate(task: SmsTask) {
|
||||
return task.submittedCount > 0 ? (task.successCount / task.submittedCount) * 100 : 0;
|
||||
}
|
||||
|
||||
export function getRegionRate(region: RegionStat) {
|
||||
return region.total > 0 ? (region.success / region.total) * 100 : 0;
|
||||
}
|
||||
|
||||
export function splitSignature(content: string) {
|
||||
const match = content.match(/^【(.+?)】(.+)$/);
|
||||
return {
|
||||
signature: match?.[1],
|
||||
content: match?.[2] ?? content,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export type TaskStatus = 'sending' | 'completed' | 'terminated' | 'failed';
|
||||
export type SendType = 'immediate' | 'scheduled';
|
||||
|
||||
export type CarrierStat = {
|
||||
name: string;
|
||||
total: number;
|
||||
success: number;
|
||||
tone: 'mobile' | 'unicom' | 'telecom';
|
||||
};
|
||||
|
||||
export type RegionStat = {
|
||||
region: string;
|
||||
total: number;
|
||||
success: number;
|
||||
};
|
||||
|
||||
export type SmsTask = {
|
||||
id: string;
|
||||
backendId: string;
|
||||
enterprise: string;
|
||||
application: string;
|
||||
submittedAt: string;
|
||||
templateContent: string;
|
||||
phoneCount: number;
|
||||
wordCount: number;
|
||||
billingCount: number;
|
||||
sendType: SendType;
|
||||
scheduledAt?: string | null;
|
||||
submittedCount: number;
|
||||
submittedSuccess: number;
|
||||
sentCount: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
status: TaskStatus;
|
||||
rawStatus: string;
|
||||
carriers: CarrierStat[];
|
||||
regions: RegionStat[];
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
import { Clock3, Eye, FileText, Search, StopCircle } from 'lucide-react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
DateRangeInput,
|
||||
@@ -96,12 +97,13 @@ function mapTask(task: SmsBatchTask): BatchTask {
|
||||
}
|
||||
|
||||
export function ClientBatchTasksPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [tasks, setTasks] = useState<BatchTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [total, setTotal] = useState(0);
|
||||
const [applications, setApplications] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [keyword, setKeyword] = useState(() => searchParams.get('taskNo') ?? '');
|
||||
const [application, setApplication] = useState('all');
|
||||
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
|
||||
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
BadgeCheck,
|
||||
BellRing,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
PenLine,
|
||||
Plus,
|
||||
@@ -66,10 +66,10 @@ export function ClientHome() {
|
||||
|
||||
const sendTrendOption = useMemo(
|
||||
() => createLineOption({
|
||||
labels: ['今日'],
|
||||
labels: dashboard?.hourlySendTrend.map((item) => item.label) ?? [],
|
||||
series: [
|
||||
{ name: '提交量', data: [dashboard?.today.sent ?? 0] },
|
||||
{ name: '成功量', data: [dashboard?.today.delivered ?? 0] },
|
||||
{ name: '提交量', data: dashboard?.hourlySendTrend.map((item) => item.submittedCount) ?? [] },
|
||||
{ name: '成功量', data: dashboard?.hourlySendTrend.map((item) => item.successCount) ?? [] },
|
||||
],
|
||||
}),
|
||||
[dashboard],
|
||||
@@ -134,12 +134,12 @@ export function ClientHome() {
|
||||
<button className="quick-action" onClick={() => navigate('/client/templates')} type="button">
|
||||
<FileText size={20} />
|
||||
<span>模板管理</span>
|
||||
<small>进入真实模板列表</small>
|
||||
<small>进入模板列表</small>
|
||||
</button>
|
||||
<button className="quick-action" onClick={() => navigate('/client/signatures')} type="button">
|
||||
<PenLine size={20} />
|
||||
<span>签名管理</span>
|
||||
<small>进入真实签名列表</small>
|
||||
<small>进入签名列表</small>
|
||||
</button>
|
||||
<button className="quick-action" onClick={() => navigate('/client/billing')} type="button">
|
||||
<WalletCards size={20} />
|
||||
@@ -155,16 +155,18 @@ export function ClientHome() {
|
||||
<h2>账户状态</h2>
|
||||
<p className="muted">企业认证与资源用量。</p>
|
||||
</div>
|
||||
<Tag tone={account?.status === 'active' ? 'success' : 'warning'}>{account?.status ?? '未知'}</Tag>
|
||||
<Tag tone={dashboard?.clientOverview?.certificationStatus === 'certified' ? 'success' : 'warning'}>
|
||||
{dashboard?.clientOverview?.certificationStatus === 'certified' ? '已认证' : '未认证'}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="summary-list">
|
||||
<div>
|
||||
<span>企业主体</span>
|
||||
<strong>{account?.tenant?.name ?? account?.tenantId ?? '当前租户'}</strong>
|
||||
<strong>{dashboard?.clientOverview?.enterpriseName ?? account?.tenant?.name ?? '企业信息未完善'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>默认签名</span>
|
||||
<strong>由发送资源 API 管理</strong>
|
||||
<span>签名数量</span>
|
||||
<strong>{dashboard?.clientOverview?.signatureCount ?? 0} 个</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>最近充值</span>
|
||||
@@ -184,36 +186,36 @@ export function ClientHome() {
|
||||
</div>
|
||||
|
||||
<div className="overview-grid overview-grid--three">
|
||||
<div className="surface mini-status-card">
|
||||
<button className="surface mini-status-card mini-status-card--action" onClick={() => navigate('/client/templates')} type="button">
|
||||
<BadgeCheck size={22} />
|
||||
<div>
|
||||
<span>模板状态</span>
|
||||
<strong>{dashboard?.pendingAuditCount ?? 0} 待处理</strong>
|
||||
<strong>{dashboard?.pendingAudits.templates ?? 0} 个待审核</strong>
|
||||
<small>点击进入模板明细</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface mini-status-card">
|
||||
</button>
|
||||
<button className="surface mini-status-card mini-status-card--action" onClick={() => navigate('/client/signatures')} type="button">
|
||||
<PenLine size={22} />
|
||||
<div>
|
||||
<span>签名状态</span>
|
||||
<strong>真实 API</strong>
|
||||
<strong>{dashboard?.pendingAudits.signatures ?? 0} 个待审核</strong>
|
||||
<small>点击进入签名明细</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface mini-status-card">
|
||||
<BellRing size={22} />
|
||||
</button>
|
||||
<button className="surface mini-status-card mini-status-card--action" onClick={() => navigate('/client/batch-tasks')} type="button">
|
||||
<ClipboardList size={22} />
|
||||
<div>
|
||||
<span>服务提醒</span>
|
||||
<strong>通道运行正常</strong>
|
||||
<small>备用通道有排队批次,请关注发送详情。</small>
|
||||
<span>批量任务</span>
|
||||
<strong>{dashboard?.clientOverview?.pendingBatchTaskCount ?? 0} 个待审核</strong>
|
||||
<small>点击进入批量任务菜单</small>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="chart-grid">
|
||||
<div className="surface chart-card">
|
||||
<h2>今日发送趋势</h2>
|
||||
<p className="muted">按 3 小时聚合提交量和成功量。</p>
|
||||
<p className="muted">按北京时间逐小时展示提交量和成功量。</p>
|
||||
<Chart height={300} option={sendTrendOption} />
|
||||
</div>
|
||||
<div className="surface chart-card">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Download, FileText, Plus, Search, Send, Trash2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button, DateTimeInput, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { clientApi, type ClientSmsApplication, type ClientSmsSignatureView, type ClientSmsTemplate, type ImportPreviewResponse, type SmsBatchTask } from '@/api/adminApi';
|
||||
import { formatCents } from '@/utils/currency';
|
||||
@@ -14,6 +15,7 @@ type SendMode = 'now' | 'scheduled';
|
||||
type ReceiverMode = 'manual' | 'import';
|
||||
|
||||
export function ClientSendPage() {
|
||||
const navigate = useNavigate();
|
||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
|
||||
const [signatures, setSignatures] = useState<ClientSmsSignatureView[]>([]);
|
||||
@@ -35,6 +37,8 @@ export function ClientSendPage() {
|
||||
const [importPreview, setImportPreview] = useState<ImportPreviewResponse | null>(null);
|
||||
const [importLoading, setImportLoading] = useState(false);
|
||||
const [submittedRecord, setSubmittedRecord] = useState<SmsBatchTask | null>(null);
|
||||
const [submitError, setSubmitError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([clientApi.listApplications(), clientApi.listTemplates({ status: 'approved' }), clientApi.listSignatures()])
|
||||
@@ -74,8 +78,8 @@ export function ClientSendPage() {
|
||||
const previewText = selectedSignature && messageContent
|
||||
? replaceLeadingSmsSignature(messageContent, selectedSignature.name)
|
||||
: messageContent;
|
||||
const wordCount = previewText.length;
|
||||
const smsParts = wordCount > 0 ? Math.max(1, Math.ceil(wordCount / 70)) : 0;
|
||||
const wordCount = [...previewText].length;
|
||||
const smsParts = wordCount === 0 ? 0 : wordCount <= 70 ? 1 : Math.ceil(wordCount / 67);
|
||||
const estimatedCount = receiverCount * smsParts;
|
||||
const requiredVariables = selectedTemplate?.variables?.map((item) => item.name) ?? [];
|
||||
const canSubmit = Boolean(taskName && applicationId && signatureId && templateId && receiverCount > 0 && (sendMode === 'now' || scheduledAt));
|
||||
@@ -100,10 +104,15 @@ export function ClientSendPage() {
|
||||
}
|
||||
|
||||
function submitTask() {
|
||||
if (!canSubmit) {
|
||||
if (!canSubmit || submitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setSubmitError('');
|
||||
const normalizedScheduledAt = sendMode === 'scheduled' && scheduledAt
|
||||
? `${scheduledAt}:00+08:00`
|
||||
: undefined;
|
||||
const submitRequest = receiverMode === 'manual'
|
||||
? clientApi.createBatchTask({
|
||||
applicationId,
|
||||
@@ -112,7 +121,7 @@ export function ClientSendPage() {
|
||||
category: selectedTemplate?.category ?? taskName,
|
||||
phones: validRecipients.map((item) => item.phone.trim()),
|
||||
sendMode: sendMode === 'now' ? 'immediate' : 'scheduled',
|
||||
scheduledAt: sendMode === 'scheduled' ? scheduledAt : undefined,
|
||||
scheduledAt: normalizedScheduledAt,
|
||||
})
|
||||
: clientApi.confirmImport({
|
||||
applicationId,
|
||||
@@ -122,7 +131,7 @@ export function ClientSendPage() {
|
||||
importContent,
|
||||
requiredVariables,
|
||||
sendMode: sendMode === 'now' ? 'immediate' : 'scheduled',
|
||||
scheduledAt: sendMode === 'scheduled' ? scheduledAt : undefined,
|
||||
scheduledAt: normalizedScheduledAt,
|
||||
});
|
||||
|
||||
submitRequest
|
||||
@@ -130,7 +139,33 @@ export function ClientSendPage() {
|
||||
setSubmittedRecord(task);
|
||||
setError('');
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '发送任务提交失败'));
|
||||
.catch((reason: Error) => setSubmitError(reason.message || '发送任务提交失败'))
|
||||
.finally(() => setSubmitting(false));
|
||||
}
|
||||
|
||||
function resetSendForm() {
|
||||
setTaskName('');
|
||||
setApplicationId('');
|
||||
setSignatureId('');
|
||||
setTemplateId('');
|
||||
setTemplateKeyword('');
|
||||
setMessageContent('');
|
||||
setSendMode('now');
|
||||
setScheduledAt('');
|
||||
setReceiverMode('manual');
|
||||
setRecipients([{ id: Date.now().toString(), phone: '' }]);
|
||||
setImportContent('');
|
||||
setImportFileName('');
|
||||
setImportFileUrl((current) => {
|
||||
if (current) {
|
||||
URL.revokeObjectURL(current);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
setImportPreview(null);
|
||||
setSubmittedRecord(null);
|
||||
setSubmitError('');
|
||||
setError('');
|
||||
}
|
||||
|
||||
async function previewImportFile(file: File) {
|
||||
@@ -352,8 +387,8 @@ export function ClientSendPage() {
|
||||
</section>
|
||||
|
||||
<div className="send-submit-row">
|
||||
<Button disabled={!canSubmit} icon={<Send size={18} />} onClick={submitTask}>
|
||||
提交发送任务
|
||||
<Button disabled={!canSubmit || submitting} icon={<Send size={18} />} onClick={submitTask}>
|
||||
{submitting ? '提交中...' : '提交发送任务'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -384,7 +419,7 @@ export function ClientSendPage() {
|
||||
</div>
|
||||
<div>
|
||||
<span>单价</span>
|
||||
<strong>¥{formatCents(selectedApplication?.customerUnitPrice)} / 人</strong>
|
||||
<strong>¥{formatCents(selectedApplication?.customerUnitPrice)} / 条</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="preview-note">短信按 70 字/条计费,超出部分按 67 字/条计算</div>
|
||||
@@ -422,6 +457,38 @@ export function ClientSendPage() {
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{submitError ? (
|
||||
<Modal
|
||||
footer={<Button onClick={() => setSubmitError('')}>我知道了</Button>}
|
||||
onClose={() => setSubmitError('')}
|
||||
open
|
||||
title="提交发送任务失败"
|
||||
>
|
||||
<p className="form-error">{submitError}</p>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
{submittedRecord ? (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={resetSendForm} variant="ghost">继续发送短信</Button>
|
||||
<Button onClick={() => navigate(`/client/batch-tasks?taskNo=${encodeURIComponent(submittedRecord.taskNo)}`)}>
|
||||
查看任务进度
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setSubmittedRecord(null)}
|
||||
open
|
||||
title="发送任务提交成功"
|
||||
>
|
||||
<div className="detail-grid">
|
||||
<div><span>任务编号</span><strong>{submittedRecord.taskNo}</strong></div>
|
||||
<div><span>发送号码数</span><strong>{submittedRecord.phoneTotal.toLocaleString('zh-CN')} 个</strong></div>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -269,6 +269,7 @@ export function ClientSignaturesPage() {
|
||||
const [drainageModal, setDrainageModal] = useState<{ signature: ClientSmsSignatureView; item?: ClientDrainageInfo }>();
|
||||
const [deleting, setDeleting] = useState<{ type: 'signature' | 'drainage'; id: string; name: string }>();
|
||||
const [page, setPage] = useState(1);
|
||||
const [refreshVersion, setRefreshVersion] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@@ -298,7 +299,7 @@ export function ClientSignaturesPage() {
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => loadData(page), 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [applicationFilter, keyword, page, statusFilter]);
|
||||
}, [applicationFilter, keyword, page, refreshVersion, statusFilter]);
|
||||
|
||||
const filteredItems = workspace.items;
|
||||
const totalPages = Math.max(1, Math.ceil(workspace.total / pageSize));
|
||||
@@ -324,7 +325,13 @@ export function ClientSignaturesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const resetFilters = () => { setKeyword(''); setApplicationFilter(''); setStatusFilter(''); setPage(1); };
|
||||
const resetFilters = () => {
|
||||
setKeyword('');
|
||||
setApplicationFilter('');
|
||||
setStatusFilter('');
|
||||
setPage(1);
|
||||
setRefreshVersion((version) => version + 1);
|
||||
};
|
||||
return <section className="page-stack client-signature-page">
|
||||
<header className="client-signature-heading">
|
||||
<div className="client-signature-title">
|
||||
|
||||
Reference in New Issue
Block a user