198 lines
7.7 KiB
TypeScript
198 lines
7.7 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { Plus, Search } from 'lucide-react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
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();
|
||
const [channels, setChannels] = useState<SmsChannel[]>([]);
|
||
const [error, setError] = useState('');
|
||
const [keyword, setKeyword] = useState('');
|
||
const [carrier, setCarrier] = useState('all');
|
||
const [status, setStatus] = useState('all');
|
||
const [modal, setModal] = useState<ChannelModalState | null>(null);
|
||
const [testChannel, setTestChannel] = useState<SmsChannel | null>(null);
|
||
const [confirmAction, setConfirmAction] = useState<ChannelConfirmAction | null>(null);
|
||
const [logState, setLogState] = useState<ChannelLogState | null>(null);
|
||
const [logKeyword, setLogKeyword] = useState('');
|
||
const [page, setPage] = useState(1);
|
||
const [total, setTotal] = useState(0);
|
||
const pageSize = 10;
|
||
|
||
function loadChannels(targetPage = page, filters = { keyword, carrier, status }) {
|
||
Promise.all([
|
||
adminApi.listChannelsPage({ keyword: filters.keyword.trim() || undefined, carrier: filters.carrier, status: filters.status, page: targetPage, pageSize }),
|
||
adminApi.getSendQuality(),
|
||
])
|
||
.then(async ([result, quality]) => {
|
||
const visibleChannels = result.items;
|
||
const connections = await Promise.all(visibleChannels.map((channel) =>
|
||
adminApi.listChannelConnections(channel.id).catch(() => [] as CmppConnectionState[]),
|
||
));
|
||
const qualityByChannel = new Map(quality.channels.map((item) => [item.channelId, item]));
|
||
setChannels(visibleChannels.map((item, index) => mapApiChannel(item, connections[index], qualityByChannel.get(item.id))));
|
||
setTotal(result.total);
|
||
setError('');
|
||
})
|
||
.catch((failure: Error) => setError(failure.message || '通道列表加载失败'));
|
||
}
|
||
|
||
useEffect(() => {
|
||
loadChannels(page);
|
||
}, [page]);
|
||
|
||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||
const currentPage = Math.min(page, totalPages);
|
||
|
||
async function upsertChannel(nextChannel: SmsChannel) {
|
||
try {
|
||
if (modal?.mode === 'edit' && modal.channel) {
|
||
await adminApi.updateChannel(modal.channel.id, buildChannelPayload(nextChannel, nextChannel.passwordCipher));
|
||
} else {
|
||
await adminApi.createChannel({
|
||
code: `CH-${Date.now()}`,
|
||
...buildChannelPayload(nextChannel, nextChannel.passwordCipher || 'secret'),
|
||
status: 'active',
|
||
});
|
||
}
|
||
loadChannels();
|
||
setModal(null);
|
||
setError('');
|
||
} catch (failure) {
|
||
setError(failure instanceof Error ? failure.message : '通道保存失败');
|
||
}
|
||
}
|
||
|
||
async function toggleChannel(channel: SmsChannel) {
|
||
await adminApi.changeChannelStatus(channel.id, mapUiStatusToApi(channel));
|
||
loadChannels();
|
||
}
|
||
|
||
async function copyChannel(channel: SmsChannel) {
|
||
await adminApi.copyChannel(channel.id);
|
||
loadChannels();
|
||
}
|
||
|
||
async function openLinkLogs(channel: SmsChannel) {
|
||
setLogState({ channel });
|
||
setLogKeyword('');
|
||
try {
|
||
const data = await adminApi.listChannelConnectionLogs(channel.id);
|
||
setLogState({ channel, data });
|
||
} catch (failure) {
|
||
setLogState(null);
|
||
setError(failure instanceof Error ? failure.message : '连接日志加载失败');
|
||
}
|
||
}
|
||
|
||
function submitConfirmAction() {
|
||
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'
|
||
? '确认启用通道'
|
||
: '确认停用通道';
|
||
|
||
const confirmDescription = confirmAction?.type === 'copy'
|
||
? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。'
|
||
: confirmAction?.channel.status === 'stopped'
|
||
? '启用后通道会进入连接中状态,后续可继续观察网关连接。'
|
||
: '停用后该通道将不再承接新的发送任务。';
|
||
|
||
return (
|
||
<section className="page-stack sms-channel-page">
|
||
<div className="page-heading">
|
||
<Breadcrumb items={['短信通道管理']} />
|
||
<Button icon={<Plus size={16} />} onClick={() => setModal({ mode: 'create' })}>添加通道</Button>
|
||
</div>
|
||
{error ? <p className="form-error">{error}</p> : null}
|
||
|
||
<div className="surface sms-channel-filter">
|
||
<div className="sms-channel-filter-grid">
|
||
<Input label="通道名称" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入通道名称" value={keyword} />
|
||
<Select label="运营商" onChange={(event) => setCarrier(event.target.value)} options={carrierOptions} value={carrier} />
|
||
<Select label="当前状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
|
||
<div className="audit-filter-actions">
|
||
<Button icon={<Search size={16} />} onClick={() => { setPage(1); void loadChannels(1); }}>查询</Button>
|
||
<Button onClick={() => { setKeyword(''); setCarrier('all'); setStatus('all'); setPage(1); void loadChannels(1, { keyword: '', carrier: 'all', status: 'all' }); }} variant="ghost">重置</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<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}
|
||
onClose={() => setTestChannel(null)}
|
||
onOpenRecords={() => {
|
||
setTestChannel(null);
|
||
navigate('/admin/sms-records');
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
{confirmAction ? (
|
||
<Modal
|
||
footer={(
|
||
<>
|
||
<Button onClick={() => setConfirmAction(null)} variant="ghost">取消</Button>
|
||
<Button onClick={submitConfirmAction}>确认</Button>
|
||
</>
|
||
)}
|
||
onClose={() => setConfirmAction(null)}
|
||
open
|
||
title={confirmTitle}
|
||
>
|
||
<div className="channel-confirm">
|
||
<strong>{confirmAction.channel.name}</strong>
|
||
<span>通道 ID:{confirmAction.channel.id}</span>
|
||
<p>{confirmDescription}</p>
|
||
</div>
|
||
</Modal>
|
||
) : null}
|
||
|
||
{logState ? (
|
||
<ChannelLogModal
|
||
keyword={logKeyword}
|
||
logState={logState}
|
||
onClose={() => setLogState(null)}
|
||
onKeywordChange={setLogKeyword}
|
||
/>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|