feat: complete phase2 baseline cdr quality rbac

This commit is contained in:
hectorzhao
2026-06-24 18:40:21 +08:00
parent 7057fd3c42
commit a86de6545f
63 changed files with 7853 additions and 3678 deletions
+399
View File
@@ -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}
</>
);
}