Files
lislgosms/src/apps/admin/channel-groups/RouteConfigModal.tsx
T

246 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useId, useState } from 'react';
import { CheckCircle2, Info, Search } from 'lucide-react';
import type { AdminChannel } from '@/api/adminApi';
import { Button, CarrierTag, Input, Modal, Select, Tag } from '@/components/ui';
import { formatRateAmount, MONEY_UNITS_PER_YUAN } from '@/utils/currency';
import { normalizeRegion, type NationalRoute, type ProvinceRoute } from './model';
import './RouteConfigModal.css';
type Carrier = 'mobile' | 'unicom' | 'telecom';
export type RouteModalState = {
type: 'province' | 'national';
mode: 'create' | 'edit';
route?: ProvinceRoute | NationalRoute;
};
const carrierLabels: Record<Carrier, string> = { mobile: '移动', unicom: '联通', telecom: '电信' };
function isCarrierCompatible(channel: AdminChannel, carrier: Carrier) {
return channel.carriers?.length
? channel.carriers.includes(carrier)
: !channel.carrier || channel.carrier === 'all' || channel.carrier === carrier;
}
function connectionLabel(channel: AdminChannel) {
if (channel.status === 'deleted') return { text: '已删除', tone: 'neutral' as const };
if (channel.status !== 'active') return { text: '已停用', tone: 'neutral' as const };
const states = channel.connectionStates ?? [];
const connected = states.filter(
(state) => state.status === 'connected' && state.desiredConnections > 0 && state.currentConnections > 0,
);
if (connected.length) {
return {
text: `已连接 · ${connected.reduce((sum, state) => sum + state.currentConnections, 0)} 条`,
tone: 'success' as const,
};
}
if (!states.length) return { text: '暂无连接回写', tone: 'neutral' as const };
return {
text: states.some((state) => state.status === 'connecting') ? '连接中' : '未连接',
tone: 'warning' as const,
};
}
export function RouteConfigModal({
channels,
carrier,
modal,
occupiedChannelIds,
onClose,
onSubmit,
}: {
channels: AdminChannel[];
carrier: Carrier;
modal: RouteModalState;
occupiedChannelIds: string[];
onClose: () => void;
onSubmit: (route: ProvinceRoute | NationalRoute) => string | null;
}) {
const provinceRoute = modal.type === 'province' ? (modal.route as ProvinceRoute | undefined) : undefined;
const nationalRoute = modal.type === 'national' ? (modal.route as NationalRoute | undefined) : undefined;
const [province, setProvince] = useState(provinceRoute?.province ?? '');
const [channelId, setChannelId] = useState(modal.route?.channelId ?? '');
const [keyword, setKeyword] = useState('');
const [error, setError] = useState('');
const selectionName = useId();
const selected = channels.find((channel) => channel.id === channelId);
const dirty = province !== (provinceRoute?.province ?? '') || channelId !== (modal.route?.channelId ?? '');
const provinces = channels
.filter(
(channel) =>
channel.status !== 'deleted' &&
isCarrierCompatible(channel, carrier) &&
(channel.id === modal.route?.channelId || !occupiedChannelIds.includes(channel.id)),
)
.map((channel) => channel.sendRegion)
.filter((region): region is string => Boolean(region && normalizeRegion(region) !== '全国'));
if (provinceRoute?.province) provinces.push(provinceRoute.province);
const provinceOptions = [
{ label: '请选择省份', value: '' },
...Array.from(new Set(provinces))
.sort()
.map((region) => ({ label: region, value: region })),
];
function unavailableReason(channel: AdminChannel) {
if (channel.status === 'deleted') return '通道已删除';
if (!isCarrierCompatible(channel, carrier)) return `不支持${carrierLabels[carrier]}`;
if (channel.id !== modal.route?.channelId && occupiedChannelIds.includes(channel.id)) return '已在当前通道组中配置';
if (modal.type === 'province') {
if (!province) return '请先选择省份';
if (normalizeRegion(channel.sendRegion) !== normalizeRegion(province)) return '通道地区与所选省份不匹配';
}
return '';
}
const query = keyword.trim().toLocaleLowerCase();
const visibleChannels = channels.filter(
(channel) =>
!unavailableReason(channel) &&
`${channel.name} ${channel.code} ${channel.sendRegion ?? '全国'}`.toLocaleLowerCase().includes(query),
);
function submit() {
if (modal.type === 'province' && !province) {
setError('请选择省份');
return;
}
if (!selected) {
setError(channelId ? '原通道不存在,请重新选择' : '请选择通道');
return;
}
const reason = unavailableReason(selected);
if (reason) {
setError(reason);
return;
}
const route =
modal.type === 'province'
? { id: provinceRoute?.id ?? `p-${selectionName}`, province, channelId }
: { id: nationalRoute?.id ?? `n-${selectionName}`, priority: nationalRoute?.priority ?? 0, channelId };
setError(onSubmit(route) ?? '');
}
return (
<Modal
className="channel-route-editor"
dirty={dirty}
footer={({ requestClose }) => (
<>
<Button onClick={requestClose} variant="ghost">
取消
</Button>
<Button onClick={submit}>确认</Button>
</>
)}
onClose={onClose}
open
size="xl"
title={modal.mode === 'edit' ? '编辑通道' : '添加通道'}
>
<div className="channel-route-editor__content">
{modal.type === 'province' ? (
<Select
label="选择省份"
required
onChange={(event) => {
setProvince(event.target.value);
setChannelId('');
setError('');
}}
options={provinceOptions}
value={province}
/>
) : null}
<Input
label="搜索通道"
onChange={(event) => setKeyword(event.target.value)}
placeholder="输入通道名称、编号或地区"
prefix={<Search aria-hidden="true" size={16} />}
value={keyword}
/>
<p className="channel-route-editor__note">
<Info aria-hidden="true" size={16} />
<span>连接状态来自最近一次真实回写;暂时断连不影响配置,实际发送仍受可用性校验。</span>
</p>
<fieldset className="channel-route-editor__choices">
<legend>选择通道 · {visibleChannels.length} 个可选</legend>
{visibleChannels.map((channel) => {
const reason = unavailableReason(channel);
const connection = connectionLabel(channel);
return (
<label
className={`channel-route-editor__choice${channelId === channel.id ? ' is-selected' : ''}${reason ? ' is-disabled' : ''}`}
key={channel.id}
>
<input
aria-label={`选择通道 ${channel.name}${channel.code}`}
checked={channelId === channel.id}
disabled={Boolean(reason)}
name={selectionName}
onChange={() => {
setChannelId(channel.id);
setError('');
}}
type="radio"
value={channel.id}
/>
<span className="channel-route-editor__identity">
<strong>{channel.name}</strong>
<span className="channel-route-editor__code">{channel.code}</span>
<span className="channel-route-editor__carriers">
{(channel.carriers?.length ? channel.carriers : [channel.carrier ?? '未标记']).map((item) => (
<CarrierTag carrier={item} key={item} />
))}
</span>
</span>
<span className="channel-route-editor__facts">
<span>成本价格(元/条)</span>
<strong>
{channel.unitPrice == null ? '—' : formatRateAmount(channel.unitPrice / MONEY_UNITS_PER_YUAN)}
</strong>
</span>
<span className="channel-route-editor__facts">
<span>地区</span>
<strong>{channel.sendRegion || '全国'}</strong>
</span>
<span className="channel-route-editor__connection">
<Tag tone={connection.tone}>{connection.text}</Tag>
{reason ? <span className="channel-route-editor__reason">{reason}</span> : null}
</span>
</label>
);
})}
{visibleChannels.length === 0 ? (
<p className="channel-route-editor__empty">
{modal.type === 'province' && !province
? '请先选择省份'
: query
? '没有匹配的可选通道,请调整搜索条件'
: '暂无可添加的通道'}
</p>
) : null}
</fieldset>
{selected ? (
<p className="channel-route-editor__selected">
<CheckCircle2 aria-hidden="true" size={17} />
<span>
已选:{selected.name}{selected.code}
</span>
</p>
) : null}
{channelId && !selected ? (
<p className="channel-route-editor__error" role="alert">
原通道不存在,请重新选择
</p>
) : null}
{error ? (
<p className="channel-route-editor__error" role="alert">
{error}
</p>
) : null}
</div>
</Modal>
);
}