feat: add phone frequency controls and modularize codebase

This commit is contained in:
hectorzhao
2026-07-31 22:25:23 +08:00
parent 0af671b4ed
commit ca4f591a13
216 changed files with 41579 additions and 23694 deletions
+37 -622
View File
@@ -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>
);
+104 -447
View File
@@ -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,
};
}
+2 -2
View File
@@ -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>
+29 -675
View File
@@ -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> PNGJPGJPEGPDF 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> PNGJPG 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 (
+291 -4
View File
@@ -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>245</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>
);
}
+64 -509
View File
@@ -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
+69 -563
View File
@@ -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>
);
+2 -2
View File
@@ -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>
);
}
+101
View File
@@ -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>
);
}
+133
View File
@@ -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>
);
}
+153
View File
@@ -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 },
};
}
+51
View File
@@ -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> PNGJPG 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> PNGJPGJPEGPDF 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[];
};