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>
);