Files
lisglosips/apps/web/src/pages/VendorGatewaysPage.jsx
T

400 lines
21 KiB
React
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 { useState } from 'react';
import { Button, Checkbox, Field, Input, Select } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
import { vendors, gateways } from '../fixtures/devFixtures.js';
import { explainApiError } from '../api.js';
export function VendorGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRows: setApiGatewayRows, vendorRows: apiVendorRows, apiLoading, apiError, refreshApi, can = () => true, onUpdateGateway, onToggleGatewayStatus, onDeleteGateway }) {
const [localGatewayRows, setLocalGatewayRows] = useState(gateways);
const gatewayRows = Array.isArray(apiGatewayRows) ? apiGatewayRows : localGatewayRows;
const setGatewayRows = setApiGatewayRows || setLocalGatewayRows;
const vendorOptions = apiVendorRows?.length ? apiVendorRows : vendors;
const [actionError, setActionError] = useState('');
const [editingGateway, setEditingGateway] = useState(null);
const [gatewayConfirm, setGatewayConfirm] = useState(null);
const provinceOptions = ['北京', '上海', '广东', '浙江', '江苏', '新疆', '西藏', '港澳台', '海外'];
const codecOptions = ['PCMA', 'PCMU', 'G729', 'G722', 'OPUS'];
const canManage = can('vendor_gateways.manage');
const splitList = (value) => (value && !['无', '-'].includes(value) ? value.split(/[,,、]/).map((item) => item.trim()).filter(Boolean) : []);
const splitTimeRanges = (value) => {
const ranges = String(value || '')
.split(/[,,、\n]/)
.map((item) => {
const [start = '', end = ''] = item.split('-').map((part) => part.trim());
return { start, end };
})
.filter((item) => item.start || item.end);
return ranges.length ? ranges : [{ start: '', end: '' }];
};
const splitRate = (value) => String(value || '').replace(/[^\d]/g, '');
const formatMinuteRate = (cycle, rate) => {
const billingCycle = Number(cycle);
const cycleRate = Number(rate);
if (!billingCycle || !Number.isFinite(billingCycle) || !Number.isFinite(cycleRate)) return '-';
return ${((cycleRate * 60) / billingCycle).toFixed(4)}/分钟`;
};
const rewritePoolRows = (value) => {
const rows = Array.isArray(value) ? value.map((item) => ({ caller: item.caller || '', weight: String(item.weight || 1) })) : [];
return rows.length ? rows : [{ caller: '', weight: '1' }];
};
const emptyGatewayForm = {
vendor: vendorOptions[0]?.name || '',
name: '',
authMode: 'IP',
ipAddress: '',
sipAccount: '',
sipPassword: '',
concurrencyLimit: '',
billingCycle: '60',
cycleRate: '',
requestRate: '',
blockedProvinces: [],
forbiddenPeriods: [{ start: '', end: '' }],
codecs: [],
landingCalleePrefix: '',
callerRewritePool: [{ caller: '', weight: '1' }],
};
const [gatewayForm, setGatewayForm] = useState(emptyGatewayForm);
const openEditGateway = (gateway) => {
setEditingGateway(gateway);
const rateValue = splitRate(gateway.requestRate);
setGatewayForm({
vendor: gateway.vendor,
name: gateway.name,
authMode: gateway.authMode,
ipAddress: gateway.ipAddress || '',
sipAccount: gateway.sipAccount || '',
sipPassword: gateway.sipPassword || '',
concurrencyLimit: String(gateway.concurrencyLimit),
billingCycle: String(gateway.billingCycle || 60),
cycleRate: String(gateway.cycleRate ?? ''),
requestRate: rateValue,
blockedProvinces: splitList(gateway.blockedProvinces),
forbiddenPeriods: splitTimeRanges(gateway.callTimeLimit),
codecs: splitList(gateway.codecs),
landingCalleePrefix: gateway.landingCalleePrefix || '',
callerRewritePool: rewritePoolRows(gateway.callerRewritePool),
});
};
const closeEditGateway = () => {
setEditingGateway(null);
setGatewayForm(emptyGatewayForm);
};
const submitGateway = async (event) => {
event.preventDefault();
if (!editingGateway || !gatewayForm.name.trim() || !gatewayForm.concurrencyLimit || !gatewayForm.billingCycle || !gatewayForm.cycleRate || !gatewayForm.requestRate) return;
const billingCycle = Number(gatewayForm.billingCycle);
const cycleRate = Number(gatewayForm.cycleRate);
const cpsLimit = Number(gatewayForm.requestRate);
if (!Number.isFinite(billingCycle) || billingCycle <= 0 || billingCycle > 60 || !Number.isFinite(cycleRate) || cycleRate < 0) return;
if (!Number.isInteger(cpsLimit) || cpsLimit < 1 || cpsLimit > 10000) return;
const ipAddress = gatewayForm.ipAddress.trim();
const sipAccount = gatewayForm.sipAccount.trim();
const sipPassword = gatewayForm.sipPassword.trim();
const authValid = gatewayForm.authMode === 'IP' ? ipAddress : ipAddress && sipAccount;
if (!authValid) return;
const vendorId = vendorOptions.find((vendor) => vendor.name === gatewayForm.vendor)?.id || editingGateway.vendorId;
const callerRewritePool = gatewayForm.callerRewritePool
.map((item) => ({ caller: item.caller.trim(), weight: Number(item.weight || 1), status: 'ENABLED' }))
.filter((item) => item.caller);
const body = {
vendorId,
name: gatewayForm.name.trim(),
authMode: gatewayForm.authMode === 'SIP注册' ? 'SIP_DIGEST' : gatewayForm.authMode,
host: ipAddress,
port: editingGateway.port || 5060,
transport: editingGateway.transport || 'udp',
sipUsername: sipAccount || undefined,
cpsLimit,
concurrencyLimit: Number(gatewayForm.concurrencyLimit),
billingCycleSec: billingCycle,
cycleRate: String(cycleRate),
landingCalleePrefix: gatewayForm.landingCalleePrefix.trim() || null,
callerRewritePool,
forbiddenPeriods: gatewayForm.forbiddenPeriods
.filter((period) => period.start || period.end)
.map((period) => ({ weekdayMask: 127, startTime: `${period.start || '00:00'}:00`, endTime: `${period.end || '23:59'}:00` })),
codecs: gatewayForm.codecs.map((codec, index) => ({ codec, priority: index + 1 })),
prefixRules: [],
};
if (sipPassword) {
body.sipPassword = sipPassword;
}
setActionError('');
try {
if (onUpdateGateway) {
await onUpdateGateway(editingGateway.id, body);
} else {
setGatewayRows((rows) => rows.map((gateway) => (
gateway.id === editingGateway.id
? {
...gateway,
vendor: gatewayForm.vendor,
vendorId,
name: body.name,
authMode: gatewayForm.authMode,
ipAddress,
sipAccount,
sipPassword: sipPassword ? '******' : gateway.sipPassword,
concurrencyLimit: body.concurrencyLimit,
billingCycle,
cycleRate,
requestRate: cpsLimit,
blockedProvinces: gatewayForm.blockedProvinces.length ? gatewayForm.blockedProvinces.join('、') : '无',
callTimeLimit: gatewayForm.forbiddenPeriods
.filter((period) => period.start || period.end)
.map((period) => `${period.start || '00:00'}-${period.end || '23:59'}`)
.join('、') || '无',
codecs: gatewayForm.codecs.length ? gatewayForm.codecs.join(', ') : '-',
landingCalleePrefix: body.landingCalleePrefix || '',
callerRewritePool,
calleePrefixTransform: '-',
callerPrefixTransform: '-',
}
: gateway
)));
}
closeEditGateway();
} catch (error) {
setActionError(explainApiError(error));
}
};
const toggleArrayValue = (field, value) => {
setGatewayForm((form) => ({
...form,
[field]: form[field].includes(value) ? form[field].filter((item) => item !== value) : [...form[field], value],
}));
};
const updateCallerRewrite = (index, key, value) => {
setGatewayForm((form) => ({
...form,
callerRewritePool: form.callerRewritePool.map((rule, ruleIndex) => (ruleIndex === index ? { ...rule, [key]: value } : rule)),
}));
};
const addCallerRewrite = () => {
setGatewayForm((form) => ({ ...form, callerRewritePool: [...form.callerRewritePool, { caller: '', weight: '1' }] }));
};
const removeCallerRewrite = (index) => {
setGatewayForm((form) => ({
...form,
callerRewritePool: form.callerRewritePool.length > 1 ? form.callerRewritePool.filter((_, ruleIndex) => ruleIndex !== index) : [{ caller: '', weight: '1' }],
}));
};
const updateForbiddenPeriod = (index, key, value) => {
setGatewayForm((form) => ({
...form,
forbiddenPeriods: form.forbiddenPeriods.map((period, periodIndex) => (
periodIndex === index ? { ...period, [key]: value } : period
)),
}));
};
const addForbiddenPeriod = () => {
setGatewayForm((form) => ({ ...form, forbiddenPeriods: [...form.forbiddenPeriods, { start: '', end: '' }] }));
};
const removeForbiddenPeriod = (index) => {
setGatewayForm((form) => ({
...form,
forbiddenPeriods: form.forbiddenPeriods.length > 1 ? form.forbiddenPeriods.filter((_, periodIndex) => periodIndex !== index) : [{ start: '', end: '' }],
}));
};
const toggleVendorGatewayStatus = async (gateway) => {
setActionError('');
try {
if (onToggleGatewayStatus) {
await onToggleGatewayStatus(gateway);
return;
}
setGatewayRows((rows) => rows.map((item) => (
item.id === gateway.id ? { ...item, status: item.status === '启用' ? '禁用' : '启用' } : item
)));
} catch (error) {
setActionError(explainApiError(error));
}
};
const deleteVendorGateway = async (gateway) => {
setActionError('');
try {
if (onDeleteGateway) {
await onDeleteGateway(gateway.id);
return;
}
setGatewayRows((rows) => rows.filter((item) => item.id !== gateway.id));
} catch (error) {
setActionError(explainApiError(error));
}
};
return (
<>
<PageTitle title="落地网关管理" desc="管理供应商落地网关认证、并发、价格和号码限制策略。" actions={canManage ? <Button icon={<Icon type="plus" />}>新增落地网关</Button> : null} />
<ApiNotice loading={apiLoading} error={apiError || actionError} onRetry={refreshApi} />
<Toolbar>
<Field label="供应商"><Select defaultValue="all"><option value="all">全部供应商</option>{vendorOptions.map((vendor) => <option key={vendor.id}>{vendor.name}</option>)}</Select></Field>
<Field label="落地网关名称"><Input placeholder="搜索网关名称" /></Field>
<Button icon={<Icon type="search" />}>查询</Button>
</Toolbar>
<section className="content-grid">
<Panel title="落地网关列表" className="wide-panel">
<SimpleTable rows={gatewayRows} columns={[
{ key: 'vendor', label: '供应商名称', width: '156px', className: 'table-cell-compact' },
{ key: 'name', label: '落地网关名称', width: '168px', className: 'table-cell-compact' },
{ key: 'authMode', label: '认证方式' },
{ key: 'authTarget', label: 'IP/账号', render: (row) => (row.authMode === 'IP' ? row.ipAddress : row.sipAccount) },
{ key: 'requestRate', label: 'CPS' },
{ key: 'concurrencyLimit', label: '并发上限' },
{ key: 'minuteRate', label: '价格', render: (row) => formatMinuteRate(row.billingCycle, row.cycleRate) },
{ key: 'landingCalleePrefix', label: '落地被叫前缀', render: (row) => row.landingCalleePrefix || '-' },
{ key: 'callerRewriteCount', label: '指定主叫数', render: (row) => (row.callerRewritePool || []).length },
{ key: 'status', label: '状态', status: true },
{
key: 'actions',
label: '操作',
render: (row) => (
<div className="table-actions">
{canManage ? <Button size="sm" variant="outline" onClick={() => openEditGateway(row)}>编辑</Button> : null}
{canManage ? (
<Button size="sm" variant={row.status === '启用' ? 'ghost' : 'secondary'} onClick={() => setGatewayConfirm({ type: 'toggle', row })}>
{row.status === '启用' ? '禁用' : '启用'}
</Button>
) : null}
{canManage ? <Button size="sm" variant="danger" onClick={() => setGatewayConfirm({ type: 'delete', row })}>删除</Button> : '-'}
</div>
),
},
]} />
</Panel>
</section>
{editingGateway ? (
<Modal title="编辑落地网关" onClose={closeEditGateway} size="lg">
<form className="modal-form" onSubmit={submitGateway}>
<div className="match-grid">
<Field label={<span>供应商名称 <span className="required-star">*</span></span>}>
<Select value={gatewayForm.vendor} onChange={(event) => setGatewayForm({ ...gatewayForm, vendor: event.target.value })} required>
{vendorOptions.map((vendor) => <option key={vendor.id}>{vendor.name}</option>)}
</Select>
</Field>
<Field label={<span>落地网关名称 <span className="required-star">*</span></span>}>
<Input value={gatewayForm.name} onChange={(event) => setGatewayForm({ ...gatewayForm, name: event.target.value })} placeholder="请输入落地网关名称" required />
</Field>
<Field label={<span>认证方式 <span className="required-star">*</span></span>}>
<Select value={gatewayForm.authMode} onChange={(event) => setGatewayForm({ ...gatewayForm, authMode: event.target.value, ipAddress: '', sipAccount: '', sipPassword: '' })} required>
<option>IP</option>
<option>SIP注册</option>
</Select>
</Field>
<Field label={<span>落地主机/IP <span className="required-star">*</span></span>}>
<Input value={gatewayForm.ipAddress} onChange={(event) => setGatewayForm({ ...gatewayForm, ipAddress: event.target.value })} placeholder="例如 203.0.113.18 或 sip.carrier.local" required />
</Field>
{gatewayForm.authMode !== 'IP' ? (
<>
<Field label={<span>SIP账号 <span className="required-star">*</span></span>}>
<Input value={gatewayForm.sipAccount} onChange={(event) => setGatewayForm({ ...gatewayForm, sipAccount: event.target.value })} placeholder="请输入 SIP 账号" required />
</Field>
<Field label="SIP密码(留空不修改)">
<Input type="password" value={gatewayForm.sipPassword} onChange={(event) => setGatewayForm({ ...gatewayForm, sipPassword: event.target.value })} placeholder="至少 12 位" />
</Field>
</>
) : null}
<Field label={<span>并发上限 <span className="required-star">*</span></span>}>
<Input type="number" min="1" value={gatewayForm.concurrencyLimit} onChange={(event) => setGatewayForm({ ...gatewayForm, concurrencyLimit: event.target.value })} placeholder="例如 800" required />
</Field>
<div className="rate-config-card">
<div className="rate-config-head">
<strong>费率配置</strong>
<span>{formatMinuteRate(gatewayForm.billingCycle, gatewayForm.cycleRate)}</span>
</div>
<div className="rate-config-fields">
<Field label={<span>计费周期 <span className="required-star">*</span></span>}>
<Input type="number" min="1" max="60" value={gatewayForm.billingCycle} onChange={(event) => setGatewayForm({ ...gatewayForm, billingCycle: event.target.value })} placeholder="最大 60 秒" required />
</Field>
<Field label={<span>周期费率 <span className="required-star">*</span></span>}>
<Input type="number" min="0" step="0.0001" value={gatewayForm.cycleRate} onChange={(event) => setGatewayForm({ ...gatewayForm, cycleRate: event.target.value })} placeholder="例如 0.02" required />
</Field>
</div>
</div>
<Field label={<span>CPS <span className="required-star">*</span></span>}>
<Input type="number" min="1" max="10000" step="1" value={gatewayForm.requestRate} onChange={(event) => setGatewayForm({ ...gatewayForm, requestRate: event.target.value })} placeholder="1-10000" required />
</Field>
<div className="config-card">
<strong>屏蔽省份</strong>
<div className="option-grid">
{provinceOptions.map((province) => (
<Checkbox key={province} label={province} checked={gatewayForm.blockedProvinces.includes(province)} onChange={() => toggleArrayValue('blockedProvinces', province)} />
))}
</div>
</div>
<div className="config-card">
<div className="config-card-head">
<strong>禁呼时段</strong>
<Button type="button" size="sm" variant="outline" onClick={addForbiddenPeriod}>添加时段</Button>
</div>
<div className="rule-list">
{gatewayForm.forbiddenPeriods.map((period, index) => (
<div className="time-range" key={`forbidden-${index}`}>
<Input type="time" value={period.start} onChange={(event) => updateForbiddenPeriod(index, 'start', event.target.value)} />
<span></span>
<Input type="time" value={period.end} onChange={(event) => updateForbiddenPeriod(index, 'end', event.target.value)} />
<Button type="button" size="sm" variant="danger" onClick={() => removeForbiddenPeriod(index)}>删除</Button>
</div>
))}
</div>
</div>
<div className="config-card">
<strong>语音编码限制</strong>
<div className="option-grid">
{codecOptions.map((codec) => (
<Checkbox key={codec} label={codec} checked={gatewayForm.codecs.includes(codec)} onChange={() => toggleArrayValue('codecs', codec)} />
))}
</div>
</div>
<div className="config-card config-card-wide">
<strong>落地要求被叫前缀</strong>
<Input value={gatewayForm.landingCalleePrefix} onChange={(event) => setGatewayForm({ ...gatewayForm, landingCalleePrefix: event.target.value })} placeholder="可为空,例如 86 或 ABC" />
</div>
<div className="config-card config-card-wide">
<div className="config-card-head">
<strong>落地要求指定主叫</strong>
<Button type="button" size="sm" variant="outline" onClick={addCallerRewrite}>添加号码</Button>
</div>
<div className="rule-list">
{gatewayForm.callerRewritePool.map((rule, index) => (
<div className="prefix-rule" key={`caller-${index}`}>
<Input value={rule.caller} onChange={(event) => updateCallerRewrite(index, 'caller', event.target.value)} placeholder="指定主叫,如 02160010001" />
<span>权重</span>
<Input type="number" min="1" value={rule.weight} onChange={(event) => updateCallerRewrite(index, 'weight', event.target.value)} />
<Button type="button" size="sm" variant="danger" onClick={() => removeCallerRewrite(index)}>删除</Button>
</div>
))}
</div>
</div>
</div>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={closeEditGateway}>取消</Button>
<Button type="submit">保存修改</Button>
</div>
</form>
</Modal>
) : null}
{gatewayConfirm ? (
<ConfirmDialog
title={gatewayConfirm.type === 'delete' ? '删除落地网关确认' : `${gatewayConfirm.row.status === '启用' ? '禁用' : '启用'}落地网关确认`}
confirmLabel={gatewayConfirm.type === 'delete' ? '确认删除' : `确认${gatewayConfirm.row.status === '启用' ? '禁用' : '启用'}`}
confirmVariant={gatewayConfirm.type === 'delete' ? 'danger' : 'primary'}
onCancel={() => setGatewayConfirm(null)}
onConfirm={() => {
const { type, row } = gatewayConfirm;
setGatewayConfirm(null);
return type === 'delete' ? void deleteVendorGateway(row) : void toggleVendorGatewayStatus(row);
}}
>
{gatewayConfirm.type === 'delete' ? (
<p>确认删除落地网关{gatewayConfirm.row.name}删除后该网关将不再参与落地</p>
) : (
<p>确认{gatewayConfirm.row.status === '启用' ? '禁用' : '启用'}落地网关{gatewayConfirm.row.name}</p>
)}
</ConfirmDialog>
) : null}
</>
);
}