feat: complete phase2 baseline cdr quality rbac
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
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) => {
|
||||
return String(value || '').trim();
|
||||
};
|
||||
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) return;
|
||||
const billingCycle = Number(gatewayForm.billingCycle);
|
||||
const cycleRate = Number(gatewayForm.cycleRate);
|
||||
if (!Number.isFinite(billingCycle) || billingCycle <= 0 || billingCycle > 60 || !Number.isFinite(cycleRate) || cycleRate < 0) 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: Number(String(gatewayForm.requestRate).match(/\d+/)?.[0] || 0),
|
||||
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: gatewayForm.requestRate.trim() || '-',
|
||||
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: '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>
|
||||
<div className="config-card">
|
||||
<strong>请求速率</strong>
|
||||
<Input value={gatewayForm.requestRate} onChange={(event) => setGatewayForm({ ...gatewayForm, requestRate: event.target.value })} placeholder="例如 120 CPS" />
|
||||
</div>
|
||||
<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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user