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
+123
View File
@@ -0,0 +1,123 @@
import { useEffect, useState } from 'react';
import { Badge, Button } from '../components/ui.jsx';
import { Icon, PageTitle, Panel, ApiNotice, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
import { formatDateTime, formatDurationText } from '../utils/formatters.js';
import { metrics } from '../fixtures/devFixtures.js';
export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsError, refreshActiveCalls, can = () => true, onHangupActiveCall }) {
const [busyId, setBusyId] = useState('');
const [hangupTarget, setHangupTarget] = useState(null);
const [autoRefresh, setAutoRefresh] = useState(true);
const canManage = can('active_calls.manage');
const rows = activeCalls.map((call) => ({
...call,
callerText: call.caller || '-',
calleeText: call.callee || '-',
callerIpText: call.callerIp || '-',
landingIpText: call.landingIp || '-',
stateText: call.state || '未知',
startedText: formatDateTime(call.startedAt),
durationText: call.durationSec === null || call.durationSec === undefined ? '-' : formatDurationText(call.durationSec),
}));
const hangup = async (call) => {
setBusyId(call.id);
try {
await onHangupActiveCall(call.id);
} finally {
setBusyId('');
setHangupTarget(null);
}
};
useEffect(() => {
if (!autoRefresh) {
return undefined;
}
const timer = window.setInterval(() => {
if (!activeCallsLoading) {
void refreshActiveCalls();
}
}, 5000);
return () => window.clearInterval(timer);
}, [autoRefresh, activeCallsLoading, refreshActiveCalls]);
return (
<>
<PageTitle
title="当前通话"
desc="查看 OpenSIPS 当前已跟踪的实时呼叫,并对异常通话执行强制挂断。"
actions={(
<div className="table-actions">
<Button variant={autoRefresh ? 'secondary' : 'outline'} onClick={() => setAutoRefresh((value) => !value)}>
{autoRefresh ? '自动刷新中' : '开启自动刷新'}
</Button>
<Button icon={<Icon type="reload" />} onClick={refreshActiveCalls} disabled={activeCallsLoading}>刷新通话</Button>
</div>
)}
/>
<ApiNotice loading={activeCallsLoading} error={activeCallsError} onRetry={refreshActiveCalls} />
<section className="metric-grid active-call-metrics">
<div className="metric-card">
<span>当前通话数</span>
<strong>{rows.length}</strong>
<em className="metric-neutral">OpenSIPS MI</em>
</div>
<div className="metric-card">
<span>最长通话</span>
<strong>{rows.length ? rows[0].durationText : '00:00'}</strong>
<em className="metric-neutral">按持续时长排序</em>
</div>
<div className="metric-card">
<span>控制面</span>
<strong>{activeCallsError ? '异常' : '就绪'}</strong>
<em className={activeCallsError ? 'metric-warn' : 'metric-neutral'}>MI 受控访问</em>
</div>
</section>
<Panel title="实时呼叫列表" aside={<Badge tone={rows.length ? 'success' : 'neutral'}>{rows.length} </Badge>} className="wide-panel">
<SimpleTable rows={rows} columns={[
{ key: 'callId', label: 'Call-ID' },
{ key: 'callerText', label: '主叫' },
{ key: 'calleeText', label: '被叫' },
{ key: 'callerIpText', label: '呼叫方 IP' },
{ key: 'landingIpText', label: '落地 IP' },
{ key: 'stateText', label: '状态', status: true },
{ key: 'durationText', label: '持续时长' },
{ key: 'startedText', label: '开始时间' },
{
key: 'action',
label: '操作',
render: (row) => canManage ? (
<Button
size="sm"
variant="danger"
icon={<Icon type="phoneOff" />}
disabled={busyId === row.id}
onClick={(event) => {
event.stopPropagation();
setHangupTarget(row);
}}
>
{busyId === row.id ? '挂断中' : '强制挂断'}
</Button>
) : '-'
},
]} />
</Panel>
{hangupTarget ? (
<ConfirmDialog
title="强制挂断确认"
confirmLabel="强制挂断"
confirmVariant="danger"
busy={busyId === hangupTarget.id}
onCancel={() => setHangupTarget(null)}
onConfirm={() => void hangup(hangupTarget)}
>
<p>确认强制挂断当前通话</p>
<p className="muted-text">{hangupTarget.callId || hangupTarget.id}</p>
</ConfirmDialog>
) : null}
</>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { Alert, Button, Progress } from '../components/ui.jsx';
import { Icon, PageTitle, Panel, SimpleTable, KeyValue } from '../components/layout.jsx';
import { rates } from '../fixtures/devFixtures.js';
export function BillingPage() {
return (
<>
<PageTitle title="费率与计费" desc="客户费率、供应商费率、按业务映射匹配和 Billing Worker 计费链路。" actions={<Button icon={<Icon type="plus" />}>新增费率</Button>} />
<section className="content-grid">
<Panel title="客户/供应商费率" className="wide-panel">
<SimpleTable rows={rates} columns={[
{ key: 'type', label: '类型' },
{ key: 'owner', label: '客户/供应商' },
{ key: 'business', label: '业务名称' },
{ key: 'gateway', label: '网关' },
{ key: 'caller', label: '主叫号码/号段' },
{ key: 'region', label: '国家/地区' },
{ key: 'prefix', label: '号码前缀' },
{ key: 'price', label: '单价' },
{ key: 'cycle', label: '计费周期' },
{ key: 'first', label: '首周期' },
{ key: 'start', label: '生效时间' },
{ key: 'end', label: '失效时间' },
]} />
</Panel>
<Panel title="计费匹配维度">
<div className="flow">
{['客户', '客户网关', '来源 IP', '被叫前缀', '主叫号码', '业务类型', '供应商网关'].map((step) => <span key={step}>{step}</span>)}
</div>
<Alert title="计费说明">客户侧按客户网关 + 来源 IP + 被叫前缀匹配业务与费率供应商侧按供应商网关 + 被叫前缀或主叫号码匹配成本费率</Alert>
</Panel>
<Panel title="Billing Worker 流程">
<div className="flow">
{['读取未计费 CDR', '识别客户业务', '匹配客户费率', '匹配供应商业务', '匹配供应商费率', '计算费用/成本/毛利', '写入已计费话单', '更新余额/授信'].map((step) => <span key={step}>{step}</span>)}
</div>
<div className="formula">计费秒数 = ceil(实际通话秒数 / 计费周期) * 计费周期</div>
</Panel>
<Panel title="Worker 队列">
<KeyValue label="未计费 CDR" value="1,284 条" />
<Progress value={64} />
<KeyValue label="最近计费时间" value="2026-06-15 10:31:52" />
<KeyValue label="失败重试" value="12 条" />
</Panel>
<Panel title="预付费余额实时控制">
<div className="balance-control">
<KeyValue label="呼叫前检查" value="客户状态、余额、授信、外呼时段" />
<KeyValue label="通话中占用" value="按最大可通话时长冻结余额" />
<KeyValue label="计费后更新" value="扣减余额或更新授信占用" />
<Progress value={72} />
</div>
</Panel>
</section>
</>
);
}
+271
View File
@@ -0,0 +1,271 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Field, Input, Select, Textarea } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
import { enStatus } from '../utils/formatters.js';
import { api, explainApiError } from '../api.js';
export function BusinessPrefixesPage({ can = () => true }) {
const [rows, setRows] = useState([]);
const [filters, setFilters] = useState({ keyword: '', status: 'all' });
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const [editingPrefix, setEditingPrefix] = useState(undefined);
const [prefixForm, setPrefixForm] = useState(emptyBusinessPrefixForm);
const [statusTarget, setStatusTarget] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [submitting, setSubmitting] = useState(false);
const canManage = can('customer_gateways.manage');
const loadPrefixes = async () => {
setLoading(true);
setError('');
try {
const payload = await api.businessPrefixes(filters);
setRows((Array.isArray(payload) ? payload : []).map(normalizeBusinessPrefix));
} catch (loadError) {
setError(explainApiError(loadError));
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadPrefixes();
}, []);
const openCreate = () => {
setEditingPrefix(null);
setPrefixForm(emptyBusinessPrefixForm);
setError('');
setMessage('');
};
const openEdit = (row) => {
setEditingPrefix(row);
setPrefixForm({
prefix: row.prefix,
name: row.name,
description: row.description === '-' ? '' : row.description,
priority: row.priority,
status: enStatus(row.status),
});
setError('');
setMessage('');
};
const closeForm = () => {
setEditingPrefix(undefined);
setPrefixForm(emptyBusinessPrefixForm);
setSubmitting(false);
};
const submitPrefix = async (event) => {
event.preventDefault();
setSubmitting(true);
setError('');
try {
const body = {
prefix: prefixForm.prefix.trim(),
name: prefixForm.name.trim(),
description: prefixForm.description.trim() || null,
priority: Number(prefixForm.priority),
status: prefixForm.status,
};
if (editingPrefix) {
await api.updateBusinessPrefix(editingPrefix.id, body);
setMessage(`业务前缀「${body.prefix}」已更新。`);
} else {
await api.createBusinessPrefix(body);
setMessage(`业务前缀「${body.prefix}」已创建。`);
}
closeForm();
await loadPrefixes();
} catch (submitError) {
setError(explainApiError(submitError));
} finally {
setSubmitting(false);
}
};
const toggleStatus = async (row) => {
setSubmitting(true);
setError('');
try {
if (row.status === '启用') {
await api.disableBusinessPrefix(row.id);
} else {
await api.enableBusinessPrefix(row.id);
}
setStatusTarget(null);
await loadPrefixes();
} catch (toggleError) {
setError(explainApiError(toggleError));
} finally {
setSubmitting(false);
}
};
const deletePrefix = async (row) => {
setSubmitting(true);
setError('');
try {
await api.deleteBusinessPrefix(row.id);
setDeleteTarget(null);
await loadPrefixes();
} catch (deleteError) {
setError(explainApiError(deleteError));
} finally {
setSubmitting(false);
}
};
const formOpen = editingPrefix !== undefined;
return (
<>
<PageTitle
title="业务前缀管理"
desc="维护客户呼入被叫号码前置的业务标识,后续客户网关按 IP、主叫规则和业务前缀识别归属。"
actions={canManage ? <Button icon={<Icon type="plus" />} onClick={openCreate}>新增业务前缀</Button> : null}
/>
<ApiNotice loading={loading} error={error} onRetry={loadPrefixes} />
{message ? <Alert title="操作完成" tone="success">{message}</Alert> : null}
<Panel
title="业务前缀"
className="wide-panel"
aside={<Button size="sm" variant="outline" icon={<Icon type="reload" />} onClick={loadPrefixes}>刷新</Button>}
>
<Toolbar>
<Field label="关键字">
<Input value={filters.keyword} onChange={(event) => setFilters({ ...filters, keyword: event.target.value })} placeholder="前缀或名称" />
</Field>
<Field label="状态">
<Select value={filters.status} onChange={(event) => setFilters({ ...filters, status: event.target.value })}>
<option value="all">全部</option>
<option value="ENABLED">启用</option>
<option value="DISABLED">停用</option>
</Select>
</Field>
<Button icon={<Icon type="search" />} onClick={loadPrefixes}>查询</Button>
</Toolbar>
<SimpleTable rows={rows} columns={[
{ key: 'prefix', label: '业务前缀', width: '120px' },
{ key: 'name', label: '名称', width: '160px' },
{ key: 'priority', label: '优先级', width: '90px' },
{ key: 'gatewayCount', label: '使用客户网关数', width: '140px' },
{ key: 'status', label: '状态', width: '90px', status: true },
{ key: 'description', label: '备注' },
{ key: 'createdAt', label: '创建日期', width: '120px' },
{
key: 'actions',
label: '操作',
width: '220px',
render: (row) => (
<div className="table-actions">
{canManage ? <Button size="sm" variant="outline" onClick={() => openEdit(row)}>编辑</Button> : null}
{canManage ? <Button size="sm" variant={row.status === '启用' ? 'ghost' : 'secondary'} onClick={() => setStatusTarget(row)}>{row.status === '启用' ? '禁用' : '启用'}</Button> : null}
{canManage ? <Button size="sm" variant="danger" onClick={() => setDeleteTarget(row)}>删除</Button> : '-'}
</div>
),
},
]} />
</Panel>
{formOpen ? (
<Modal title={editingPrefix ? '编辑业务前缀' : '新增业务前缀'} onClose={submitting ? () => {} : closeForm} size="sm">
<form className="modal-form" onSubmit={submitPrefix}>
<Field label={<span>业务前缀 <span className="required-star">*</span></span>}>
<Input value={prefixForm.prefix} onChange={(event) => setPrefixForm({ ...prefixForm, prefix: event.target.value })} placeholder="如 671" required />
</Field>
<Field label={<span>名称 <span className="required-star">*</span></span>}>
<Input value={prefixForm.name} onChange={(event) => setPrefixForm({ ...prefixForm, name: event.target.value })} placeholder="如 国内移动业务" required />
</Field>
<Field label="优先级">
<Input type="number" min="1" max="9999" value={prefixForm.priority} onChange={(event) => setPrefixForm({ ...prefixForm, priority: event.target.value })} />
</Field>
<Field label="状态">
<Select value={prefixForm.status} onChange={(event) => setPrefixForm({ ...prefixForm, status: event.target.value })}>
<option value="ENABLED">启用</option>
<option value="DISABLED">停用</option>
</Select>
</Field>
<Field label="备注">
<Textarea rows={3} value={prefixForm.description} onChange={(event) => setPrefixForm({ ...prefixForm, description: event.target.value })} />
</Field>
<div className="modal-actions">
<Button type="button" variant="outline" disabled={submitting} onClick={closeForm}>取消</Button>
<Button type="submit" disabled={submitting}>{submitting ? '保存中' : '保存'}</Button>
</div>
</form>
</Modal>
) : null}
{statusTarget ? (
<ConfirmDialog
title={`${statusTarget.status === '启用' ? '禁用' : '启用'}业务前缀确认`}
confirmLabel={`确认${statusTarget.status === '启用' ? '禁用' : '启用'}`}
busy={submitting}
onCancel={() => setStatusTarget(null)}
onConfirm={() => void toggleStatus(statusTarget)}
>
<p>确认{statusTarget.status === '启用' ? '禁用' : '启用'}业务前缀{statusTarget.prefix}</p>
</ConfirmDialog>
) : null}
{deleteTarget ? (
<ConfirmDialog
title="删除业务前缀确认"
confirmLabel="确认删除"
confirmVariant="danger"
busy={submitting}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => void deletePrefix(deleteTarget)}
>
<p>确认删除业务前缀{deleteTarget.prefix}删除后不能再被客户网关选择</p>
</ConfirmDialog>
) : null}
</>
);
}
const numberLibraryTabs = [
{ value: 'cities', label: '地级市字典' },
{ value: 'phoneSegments', label: '手机号码库' },
{ value: 'areaCodes', label: '城市区号' },
{ value: 'carrierPrefixRules', label: '运营商号码段规则' },
];
const numberLibraryImportExamples = {
cities: [
{ code: '340100', provinceCode: '340000', provinceName: '安徽省', cityName: '合肥市', cityLevel: 'PREFECTURE' },
],
phoneSegments: [
{ segment7: '1380013', provinceName: '北京市', cityCode: '110100', cityName: '北京市', carrier: 'MOBILE' },
],
areaCodes: [
{ areaCode: '0551', provinceName: '安徽省', cityCode: '340100', cityName: '合肥市' },
],
carrierPrefixRules: [
{ prefix: '138', carrier: 'MOBILE', priority: 100 },
],
};
const emptyNumberLibraryRows = {
cities: [],
phoneSegments: [],
areaCodes: [],
carrierPrefixRules: [],
};
const emptyNumberLibraryTotals = {
cities: 0,
phoneSegments: 0,
areaCodes: 0,
carrierPrefixRules: 0,
};
function normalizeNumberLibraryList(payload, mapItem) {
const items = Array.isArray(payload?.items) ? payload.items : [];
return {
rows: items.map(mapItem),
total: payload?.total ?? items.length,
};
}
+392
View File
@@ -0,0 +1,392 @@
import { useEffect, useState } from 'react';
import { Alert, Badge, Button, Field, Input, Select } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Drawer, SimpleTable, KeyValue } from '../components/layout.jsx';
import { formatCurrency, formatDateTime, formatDurationText, zhStatus, carrierLabel } from '../utils/formatters.js';
import { api, explainApiError } from '../api.js';
export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can = () => true }) {
const [detailCdr, setDetailCdr] = useState(null);
const [cdrRows, setCdrRows] = useState([]);
const [cdrMeta, setCdrMeta] = useState({ total: 0, take: 50, skip: 0, hasMore: false });
const [cdrLoading, setCdrLoading] = useState(false);
const [cdrError, setCdrError] = useState('');
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState('');
const [playbackLoading, setPlaybackLoading] = useState(false);
const [playbackError, setPlaybackError] = useState('');
const [playbackUrl, setPlaybackUrl] = useState('');
const [signalOpen, setSignalOpen] = useState(false);
const canPlayRecordings = can('recordings.play');
const [filters, setFilters] = useState({
caller: '',
callee: '',
customerGatewayId: 'all',
vendorGatewayId: 'all',
cityCode: '',
carrier: 'all',
startedFrom: '',
startedTo: '',
take: '50',
skip: 0,
});
const money = (value) => (value === null || value === undefined ? '¥0.000000' : formatCurrency(value, 6));
const ratingStatusLabel = (value) => ({
UNRATED: '未计费',
RATED: '已计费',
SKIPPED: '跳过计费',
FAILED: '计费失败',
}[value] || value || '-');
const normalizeCdr = (cdr) => {
const location = cdr.calleeCityName && cdr.calleeCityName !== 'UNKNOWN'
? `${cdr.calleeProvinceName || '-'} / ${cdr.calleeCityName}`
: '-';
const vendorGatewayHost = cdr.vendorGateway?.host || cdr.vendorGatewayHost;
const vendorGatewayPort = cdr.vendorGateway?.port || cdr.vendorGatewayPort;
const customerGatewayName = cdr.customerGateway?.name || cdr.customerGatewayName || cdr.customerGatewayId || '-';
const vendorGatewayName = cdr.vendorGateway?.name || cdr.vendorGatewayName || cdr.vendorGatewayId || '-';
const recording = cdr.recording || null;
const rated = cdr.rated || null;
return {
...cdr,
id: cdr.id,
customerName: cdr.customer?.name || cdr.customerName || cdr.customerId || '-',
vendorName: cdr.vendor?.name || cdr.vendorName || cdr.vendorId || '-',
lineGroupName: cdr.lineGroup?.name || cdr.lineGroupName || cdr.lineGroupId || '-',
customerGatewayName,
callIp: cdr.sourceIp || '-',
vendorGatewayName,
lineIp: vendorGatewayHost ? `${vendorGatewayHost}:${vendorGatewayPort || 5060}` : '-',
rawCalleeText: cdr.rawCallee || '-',
businessPrefixText: cdr.businessPrefix && cdr.businessPrefix !== 'none' ? cdr.businessPrefix : '-',
landingCallerText: cdr.landingCaller || '-',
landingCalleeText: cdr.landingCallee || '-',
callTime: formatDateTime(cdr.startedAt),
connectedTime: formatDateTime(cdr.answeredAt),
endTime: formatDateTime(cdr.endedAt),
durationText: formatDurationText(cdr.durationSec),
customerFee: money(cdr.customerFee),
costFee: money(cdr.vendorCost),
grossProfit: money(cdr.grossProfit),
customerRateText: rated?.customerRate ? JSON.stringify(rated.customerRate) : '-',
vendorRateText: rated?.vendorRate ? JSON.stringify(rated.vendorRate) : '-',
billSecText: cdr.billSec === null || cdr.billSec === undefined ? '-' : `${cdr.billSec}s`,
ratedAtText: formatDateTime(rated?.ratedAt),
location,
operatorText: carrierLabel(cdr.calleeOperator),
numberTypeText: cdr.calleeNumberType || '-',
hangupReason: cdr.hangupReason || '-',
sipCodeText: String(cdr.sipCode),
ratingStatusText: ratingStatusLabel(cdr.ratingStatus),
recordingId: cdr.recordingId || recording?.id || null,
recordingStatus: cdr.recordingStatus || recording?.status || null,
recordingText: cdr.hasRecording || recording ? zhStatus(cdr.recordingStatus || recording?.status || 'READY') : '无录音',
recordingKeyText: recording?.storageKey || cdr.recordingKey || '-',
recordingSizeText: recording?.bytes ? `${(Number(recording.bytes) / 1024 / 1024).toFixed(2)} MB` : '-',
traceText: JSON.stringify({
callId: cdr.callId,
eventId: cdr.eventId,
sourceIp: cdr.sourceIp,
customerGatewayId: cdr.customerGatewayId,
vendorGatewayId: cdr.vendorGatewayId,
configVersion: cdr.configVersion,
payload: cdr.payload || null,
}, null, 2),
};
};
const queryParams = (nextFilters = filters) => ({
caller: nextFilters.caller.trim(),
callee: nextFilters.callee.trim(),
customerGatewayId: nextFilters.customerGatewayId,
vendorGatewayId: nextFilters.vendorGatewayId,
cityCode: nextFilters.cityCode.trim(),
carrier: nextFilters.carrier,
startedFrom: nextFilters.startedFrom ? new Date(nextFilters.startedFrom).toISOString() : '',
startedTo: nextFilters.startedTo ? new Date(nextFilters.startedTo).toISOString() : '',
take: nextFilters.take,
skip: String(nextFilters.skip),
});
const loadCdrs = async (nextFilters = filters) => {
setCdrLoading(true);
setCdrError('');
try {
const response = await api.cdrs(queryParams(nextFilters));
setCdrRows((response.items || []).map(normalizeCdr));
setCdrMeta(response.meta || { total: response.total ?? 0, take: Number(nextFilters.take), skip: nextFilters.skip, hasMore: false });
} catch (error) {
setCdrError(explainApiError(error));
} finally {
setCdrLoading(false);
}
};
useEffect(() => {
void loadCdrs();
}, []);
useEffect(() => () => {
if (playbackUrl) {
URL.revokeObjectURL(playbackUrl);
}
}, [playbackUrl]);
const updateFilter = (key, value) => setFilters((current) => ({ ...current, [key]: value }));
const searchCdrs = () => {
const nextFilters = { ...filters, skip: 0 };
setFilters(nextFilters);
void loadCdrs(nextFilters);
};
const resetFilters = () => {
const nextFilters = { caller: '', callee: '', customerGatewayId: 'all', vendorGatewayId: 'all', cityCode: '', carrier: 'all', startedFrom: '', startedTo: '', take: filters.take, skip: 0 };
setFilters(nextFilters);
void loadCdrs(nextFilters);
};
const changePage = (direction) => {
const take = Number(filters.take) || 50;
const nextSkip = Math.max(0, filters.skip + direction * take);
const nextFilters = { ...filters, skip: nextSkip };
setFilters(nextFilters);
void loadCdrs(nextFilters);
};
const openCdrDetail = async (row) => {
setDetailCdr(row);
setDetailError('');
setSignalOpen(false);
setPlaybackError('');
setPlaybackLoading(false);
if (playbackUrl) {
URL.revokeObjectURL(playbackUrl);
setPlaybackUrl('');
}
setDetailLoading(true);
try {
const detail = await api.cdrDetail(row.id);
setDetailCdr(normalizeCdr(detail));
} catch (error) {
setDetailError(explainApiError(error));
} finally {
setDetailLoading(false);
}
};
const closeCdrDetail = () => {
setDetailCdr(null);
setDetailError('');
setSignalOpen(false);
setPlaybackError('');
setPlaybackLoading(false);
if (playbackUrl) {
URL.revokeObjectURL(playbackUrl);
setPlaybackUrl('');
}
};
const loadRecordingPlayback = async () => {
if (!detailCdr?.recordingId || playbackLoading) return;
setPlaybackLoading(true);
setPlaybackError('');
try {
const blob = await api.recordingPlayback(detailCdr.recordingId);
if (playbackUrl) {
URL.revokeObjectURL(playbackUrl);
}
setPlaybackUrl(URL.createObjectURL(blob));
} catch (error) {
setPlaybackError(explainApiError(error));
} finally {
setPlaybackLoading(false);
}
};
const pageStart = cdrMeta.total ? cdrMeta.skip + 1 : 0;
const pageEnd = Math.min(cdrMeta.total, cdrMeta.skip + cdrRows.length);
return (
<>
<PageTitle
title="话单中心"
desc="查询客户网关到落地网关的通话记录、费用、录音与信令详情。"
actions={<Button variant="secondary" icon={<Icon type="export" />} disabled>导出 CSV</Button>}
/>
<ApiNotice loading={cdrLoading} error={cdrError} onRetry={() => void loadCdrs()} />
<Toolbar>
<Field label="主叫号码"><Input value={filters.caller} onChange={(event) => updateFilter('caller', event.target.value)} placeholder="输入主叫号码" /></Field>
<Field label="被叫号码"><Input value={filters.callee} onChange={(event) => updateFilter('callee', event.target.value)} placeholder="输入被叫号码" /></Field>
<Field label="客户网关">
<Select value={filters.customerGatewayId} onChange={(event) => updateFilter('customerGatewayId', event.target.value)}>
<option value="all">全部客户网关</option>
{customerGatewayRows.map((gateway) => <option key={gateway.id} value={gateway.id}>{gateway.customer} / {gateway.name}</option>)}
</Select>
</Field>
<Field label="落地网关">
<Select value={filters.vendorGatewayId} onChange={(event) => updateFilter('vendorGatewayId', event.target.value)}>
<option value="all">全部落地网关</option>
{vendorGatewayRows.map((gateway) => <option key={gateway.id} value={gateway.id}>{gateway.vendor} / {gateway.name}</option>)}
</Select>
</Field>
<Field label="地级市代码"><Input value={filters.cityCode} onChange={(event) => updateFilter('cityCode', event.target.value)} placeholder="如 340100" /></Field>
<Field label="运营商">
<Select value={filters.carrier} onChange={(event) => updateFilter('carrier', event.target.value)}>
<option value="all">全部</option>
<option value="MOBILE">移动</option>
<option value="UNICOM">联通</option>
<option value="TELECOM">电信</option>
<option value="BROADCAST">广电</option>
<option value="MVNO">虚拟运营商</option>
<option value="UNKNOWN">未知</option>
</Select>
</Field>
<Field label="开始时间"><Input type="datetime-local" value={filters.startedFrom} onChange={(event) => updateFilter('startedFrom', event.target.value)} /></Field>
<Field label="结束时间"><Input type="datetime-local" value={filters.startedTo} onChange={(event) => updateFilter('startedTo', event.target.value)} /></Field>
<Field label="每页">
<Select value={filters.take} onChange={(event) => {
const nextFilters = { ...filters, take: event.target.value, skip: 0 };
setFilters(nextFilters);
void loadCdrs(nextFilters);
}}>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
</Select>
</Field>
<Button icon={<Icon type="search" />} disabled={cdrLoading} onClick={searchCdrs}>查询</Button>
<Button variant="outline" disabled={cdrLoading} onClick={resetFilters}>重置</Button>
</Toolbar>
<Panel title="话单列表" className="wide-panel" aside={<Badge tone="info">{cdrMeta.total} </Badge>}>
<SimpleTable rows={cdrRows} columns={[
{ key: 'caller', label: '主叫号码' },
{ key: 'callee', label: '被叫号码' },
{ key: 'location', label: '地级市' },
{ key: 'operatorText', label: '运营商' },
{ key: 'customerGatewayName', label: '客户网关名称' },
{ key: 'callIp', label: '呼叫IP地址' },
{ key: 'vendorGatewayName', label: '落地网关名称' },
{ key: 'lineIp', label: '线路IP地址' },
{ key: 'callTime', label: '呼叫时间' },
{ key: 'durationText', label: '通话时长' },
{ key: 'ratingStatusText', label: '计费状态', status: true },
{ key: 'recordingText', label: '录音', status: true },
{
key: 'actions',
label: '操作',
render: (row) => (
<div className="table-actions">
<Button size="sm" variant="outline" onClick={() => void openCdrDetail(row)}>详情</Button>
</div>
),
},
]} />
<div className="drawer-toolbar">
<div>
<strong>{pageStart}-{pageEnd}</strong>
<span> {cdrMeta.total} 按呼叫时间倒序</span>
</div>
<div className="table-actions">
<Button variant="outline" disabled={cdrLoading || cdrMeta.skip <= 0} onClick={() => changePage(-1)}>上一页</Button>
<Button variant="outline" disabled={cdrLoading || !cdrMeta.hasMore} onClick={() => changePage(1)}>下一页</Button>
</div>
</div>
</Panel>
{detailCdr ? (
<Drawer title="话单详情" aside={<Badge tone="info">{detailCdr.callId}</Badge>} onClose={closeCdrDetail}>
{detailLoading ? <Alert title="正在读取话单详情">正在加载计费录音和关联对象字段</Alert> : null}
{detailError ? <Alert title="话单详情读取失败" tone="warning">{detailError}</Alert> : null}
<div className="cdr-detail">
<section className="cdr-detail-hero">
<div>
<span>主叫</span>
<strong>{detailCdr.caller}</strong>
</div>
<Icon type="arrow" />
<div>
<span>被叫</span>
<strong>{detailCdr.callee}</strong>
</div>
</section>
<section className="cdr-detail-strip">
<KeyValue label="通话时长" value={detailCdr.durationText} />
<KeyValue label="挂断原因" value={detailCdr.hangupReason} />
<KeyValue label="地级市" value={detailCdr.location} />
<KeyValue label="运营商" value={detailCdr.operatorText} />
<KeyValue label="客户费用" value={detailCdr.customerFee} />
<KeyValue label="成本费用" value={detailCdr.costFee} />
<KeyValue label="毛利" value={detailCdr.grossProfit} />
<KeyValue label="计费秒数" value={detailCdr.billSecText} />
</section>
<section className="cdr-detail-section">
<h3>链路信息</h3>
<div className="cdr-detail-grid">
<KeyValue label="客户网关名称" value={detailCdr.customerGatewayName} />
<KeyValue label="呼叫 IP 地址" value={detailCdr.callIp} />
<KeyValue label="落地网关名称" value={detailCdr.vendorGatewayName} />
<KeyValue label="线路 IP 地址" value={detailCdr.lineIp} />
<KeyValue label="原始被叫" value={detailCdr.rawCalleeText} />
<KeyValue label="业务前缀" value={detailCdr.businessPrefixText} />
<KeyValue label="落地主叫" value={detailCdr.landingCallerText} />
<KeyValue label="落地被叫" value={detailCdr.landingCalleeText} />
<KeyValue label="号码类型" value={detailCdr.numberTypeText} />
<KeyValue label="SIP 状态码" value={detailCdr.sipCodeText} />
</div>
</section>
<section className="cdr-detail-section">
<h3>关联对象</h3>
<div className="cdr-detail-grid">
<KeyValue label="客户" value={detailCdr.customerName} />
<KeyValue label="供应商" value={detailCdr.vendorName} />
<KeyValue label="客户策略" value={detailCdr.customerGatewayPolicy?.name || detailCdr.customerGatewayPolicyId || '-'} />
<KeyValue label="落地线路组" value={detailCdr.lineGroupName} />
<KeyValue label="业务前缀名称" value={detailCdr.businessPrefixRef?.name || '-'} />
<KeyValue label="配置版本" value={detailCdr.configVersion || '-'} />
</div>
</section>
<section className="cdr-detail-section">
<h3>计费结果</h3>
<div className="cdr-detail-grid">
<KeyValue label="状态" value={detailCdr.ratingStatusText} />
<KeyValue label="计费时间" value={detailCdr.ratedAtText} />
<KeyValue label="客户费率" value={detailCdr.customerRateText} />
<KeyValue label="供应商费率" value={detailCdr.vendorRateText} />
</div>
</section>
<section className="cdr-detail-section">
<h3>时间轴</h3>
<div className="cdr-timeline">
<KeyValue label="呼叫时间" value={detailCdr.callTime} />
<KeyValue label="接通时间" value={detailCdr.connectedTime} />
<KeyValue label="结束时间" value={detailCdr.endTime} />
</div>
</section>
</div>
<div className="drawer-toolbar">
<div>
<strong>录音与信令</strong>
<span>{!canPlayRecordings ? '当前账号没有录音播放权限。' : detailCdr.recordingId ? detailCdr.recordingKeyText : '当前话单没有可播放录音。'}</span>
</div>
<div className="table-actions">
{canPlayRecordings ? <Button variant="secondary" disabled={!detailCdr.recordingId || playbackLoading} onClick={loadRecordingPlayback}>
{playbackLoading ? '读取录音' : playbackUrl ? '重新读取' : '录音播放'}
</Button> : null}
<Button variant={signalOpen ? 'secondary' : 'outline'} onClick={() => setSignalOpen((open) => !open)}>信令入口</Button>
</div>
</div>
{playbackError ? <Alert title="录音播放失败" tone="warning">{playbackError}</Alert> : null}
{playbackUrl ? (
<div className="cdr-detail-section">
<h3>录音播放</h3>
<audio className="recording-player" src={playbackUrl} controls preload="metadata" />
<div className="cdr-detail-grid">
<KeyValue label="录音状态" value={detailCdr.recordingText} />
<KeyValue label="文件大小" value={detailCdr.recordingSizeText} />
</div>
</div>
) : null}
{signalOpen ? (
<section className="cdr-detail-section">
<h3>信令索引</h3>
<pre className="trace-box">{detailCdr.traceText}</pre>
</section>
) : null}
</Drawer>
) : null}
</>
);
}
+529
View File
@@ -0,0 +1,529 @@
import { useEffect, useState } from 'react';
import { Alert, Badge, Button, Checkbox, Field, Input, Select, Textarea } from '../components/ui.jsx';
import { Icon, PageTitle, Panel, ApiNotice, Modal, ConfirmDialog, Drawer, SimpleTable } from '../components/layout.jsx';
import { authModeLabel } from '../utils/formatters.js';
import { customers, customerGatewayPolicies, customerGateways, routeGroups } from '../fixtures/devFixtures.js';
import { api, explainApiError } from '../api.js';
export function CustomerGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRows: setApiGatewayRows, customerRows: apiCustomerRows, lineGroupRows: apiLineGroupRows, apiLoading, apiError, refreshApi, can = () => true, onCreateGateway, onUpdateGateway, onToggleGatewayStatus, onDeleteGateway }) {
const [localGatewayRows, setLocalGatewayRows] = useState(customerGateways);
const gatewayRows = Array.isArray(apiGatewayRows) ? apiGatewayRows : localGatewayRows;
const setGatewayRows = setApiGatewayRows || setLocalGatewayRows;
const customerOptions = apiCustomerRows?.length ? apiCustomerRows : customers;
const lineGroupOptions = apiLineGroupRows?.length ? apiLineGroupRows : [];
const [businessPrefixOptions, setBusinessPrefixOptions] = useState([]);
const [localError, setLocalError] = useState('');
const [submitting, setSubmitting] = useState(false);
const [policyRows, setPolicyRows] = useState(customerGatewayPolicies);
const [strategyGateway, setStrategyGateway] = useState(null);
const [gatewayModalOpen, setGatewayModalOpen] = useState(false);
const [editingGateway, setEditingGateway] = useState(null);
const [policyModalOpen, setPolicyModalOpen] = useState(false);
const [editingPolicy, setEditingPolicy] = useState(null);
const [deletePolicyTarget, setDeletePolicyTarget] = useState(null);
const [gatewayConfirm, setGatewayConfirm] = useState(null);
const emptyGatewayForm = { customerId: '', name: '', authMode: 'IP', sourceIps: '', sipAccount: '', sipDomain: 'lisglosips.local', sipPassword: '', lineGroupId: '', billingCycleSec: 60, cycleRate: '0.000000', callerMatchMode: 'ANY', callerPrefixes: '', calleeMatchMode: 'ANY', businessPrefixIds: [] };
const emptyPolicyForm = { name: '', callerMode: 'any', callerValue: '', calleeMode: 'any', calleeValue: '', routeGroup: routeGroups[0].name };
const [gatewayForm, setGatewayForm] = useState(emptyGatewayForm);
const [policyForm, setPolicyForm] = useState(emptyPolicyForm);
const canManage = can('customer_gateways.manage');
const strategyGatewayData = gatewayRows.find((item) => item.id === strategyGateway);
const strategyPolicies = strategyGateway
? policyRows.filter((item) => item.gateway === strategyGateway).sort((first, second) => first.priority - second.priority)
: [];
useEffect(() => {
api.businessPrefixes({ status: 'ENABLED' })
.then((items) => setBusinessPrefixOptions(Array.isArray(items) ? items : []))
.catch((error) => setLocalError(explainApiError(error)));
}, []);
const formatMatch = (mode, value) => {
if (mode === 'equals') return `等于 ${value}`;
if (mode === 'prefix') return `前缀 ${value}`;
return '不限';
};
const openCreateGateway = () => {
setEditingGateway(null);
setGatewayForm({ ...emptyGatewayForm, customerId: customerOptions[0]?.id || '', lineGroupId: lineGroupOptions[0]?.id || '' });
setLocalError('');
setGatewayModalOpen(true);
};
const openEditGateway = (gateway) => {
setEditingGateway(gateway);
setGatewayForm({
customerId: gateway.customerId || '',
name: gateway.name,
authMode: gateway.authMode === '混合认证' ? 'MIXED' : gateway.authMode,
sourceIps: (gateway.sourceIps?.length ? gateway.sourceIps : gateway.ipAddress ? [gateway.ipAddress] : []).join('\n'),
sipAccount: gateway.sipAccount || '',
sipDomain: gateway.sipDomain || 'lisglosips.local',
sipPassword: '',
lineGroupId: gateway.lineGroupId || '',
billingCycleSec: gateway.billingCycleSec ?? 60,
cycleRate: String(gateway.cycleRate ?? '0.000000'),
callerMatchMode: gateway.callerMatchMode || 'ANY',
callerPrefixes: (gateway.callerPrefixes || []).join('\n'),
calleeMatchMode: gateway.calleeMatchMode || 'ANY',
businessPrefixIds: (gateway.businessPrefixes || []).map((item) => item.id),
});
setLocalError('');
setGatewayModalOpen(true);
};
const closeGatewayModal = () => {
setGatewayModalOpen(false);
setEditingGateway(null);
setGatewayForm(emptyGatewayForm);
setSubmitting(false);
};
const submitGateway = async (event) => {
event.preventDefault();
const name = gatewayForm.name.trim();
if (!gatewayForm.customerId || !name || !gatewayForm.lineGroupId) return;
const authMode = gatewayForm.authMode === 'SIP注册' ? 'SIP_DIGEST' : gatewayForm.authMode;
const body = {
customerId: gatewayForm.customerId,
name,
authMode,
sourceIps: gatewayForm.sourceIps.split(/[\n,\s]+/).map((item) => item.trim()).filter(Boolean),
sipUsername: gatewayForm.sipAccount.trim() || undefined,
sipDomain: gatewayForm.sipDomain.trim() || undefined,
lineGroupId: gatewayForm.lineGroupId,
billingCycleSec: Number(gatewayForm.billingCycleSec),
cycleRate: String(gatewayForm.cycleRate || '0'),
callerMatchMode: gatewayForm.callerMatchMode,
callerPrefixes: gatewayForm.callerPrefixes.split(/[\n,\s]+/).map((item) => item.trim()).filter(Boolean),
calleeMatchMode: gatewayForm.calleeMatchMode,
businessPrefixIds: gatewayForm.businessPrefixIds,
};
if (!editingGateway || gatewayForm.sipPassword.trim()) {
body.sipPassword = gatewayForm.sipPassword.trim();
}
setSubmitting(true);
setLocalError('');
try {
if (editingGateway && onUpdateGateway) {
await onUpdateGateway(editingGateway.id, body);
} else if (!editingGateway && onCreateGateway) {
await onCreateGateway(body);
} else {
const nextGateway = {
id: editingGateway?.id || `C-GW-${Date.now()}`,
customerId: body.customerId,
customer: customerOptions.find((item) => item.id === body.customerId)?.name || '-',
name,
authMode: authModeLabel(authMode),
sourceIps: body.sourceIps,
ipAddress: body.sourceIps[0] || '',
sipAccount: body.sipUsername || '',
sipDomain: body.sipDomain || '',
lineGroupId: body.lineGroupId,
lineGroupName: lineGroupOptions.find((item) => item.id === body.lineGroupId)?.name || '-',
billingCycleSec: body.billingCycleSec,
cycleRate: Number(body.cycleRate || 0),
callerMatchMode: body.callerMatchMode,
callerPrefixes: body.callerPrefixes,
calleeMatchMode: body.calleeMatchMode,
businessPrefixes: businessPrefixOptions.filter((item) => body.businessPrefixIds.includes(item.id)),
routePolicyCount: editingGateway?.routePolicyCount ?? 0,
status: editingGateway?.status ?? '启用',
};
setGatewayRows((rows) => (editingGateway ? rows.map((gateway) => (gateway.id === editingGateway.id ? nextGateway : gateway)) : [...rows, nextGateway]));
}
closeGatewayModal();
} catch (error) {
setLocalError(explainApiError(error));
} finally {
setSubmitting(false);
}
};
const toggleGatewayStatus = async (gateway) => {
setSubmitting(true);
setLocalError('');
if (onToggleGatewayStatus) {
try {
await onToggleGatewayStatus(gateway);
} catch (error) {
setLocalError(explainApiError(error));
} finally {
setSubmitting(false);
}
return;
}
setGatewayRows((rows) => rows.map((item) => (
item.id === gateway.id ? { ...item, status: item.status === '启用' ? '停用' : '启用' } : item
)));
setSubmitting(false);
};
const deleteGateway = async (gateway) => {
setSubmitting(true);
setLocalError('');
if (onDeleteGateway) {
try {
await onDeleteGateway(gateway.id);
} catch (error) {
setLocalError(explainApiError(error));
} finally {
setSubmitting(false);
}
return;
}
setGatewayRows((rows) => rows.filter((item) => item.id !== gateway.id));
setPolicyRows((rows) => rows.filter((policy) => policy.gateway !== gateway.id));
setSubmitting(false);
};
const openPolicyDrawer = (gatewayId) => {
setStrategyGateway(gatewayId);
setPolicyModalOpen(false);
setEditingPolicy(null);
setDeletePolicyTarget(null);
setPolicyForm(emptyPolicyForm);
};
const closePolicyDrawer = () => {
setStrategyGateway(null);
setPolicyModalOpen(false);
setEditingPolicy(null);
setDeletePolicyTarget(null);
setPolicyForm(emptyPolicyForm);
};
const openCreatePolicy = () => {
setEditingPolicy(null);
setPolicyForm(emptyPolicyForm);
setPolicyModalOpen(true);
};
const openEditPolicy = (policy) => {
setEditingPolicy(policy);
setPolicyForm({
name: policy.name,
callerMode: policy.callerMode,
callerValue: policy.callerValue,
calleeMode: policy.calleeMode,
calleeValue: policy.calleeValue,
routeGroup: policy.routeGroup,
});
setPolicyModalOpen(true);
};
const closePolicyModal = () => {
setPolicyModalOpen(false);
setEditingPolicy(null);
setPolicyForm(emptyPolicyForm);
};
const submitPolicy = (event) => {
event.preventDefault();
if (!strategyGatewayData || !policyForm.name.trim()) return;
const callerValue = policyForm.callerValue.trim();
const calleeValue = policyForm.calleeValue.trim();
const callerValid = policyForm.callerMode === 'any' || callerValue;
const calleeValid = policyForm.calleeMode === 'any' || calleeValue;
if (!callerValid || !calleeValid || (policyForm.callerMode === 'any' && policyForm.calleeMode === 'any')) return;
const nextPriority = strategyPolicies.length ? Math.max(...strategyPolicies.map((policy) => policy.priority)) + 10 : 10;
const nextPolicy = {
name: policyForm.name.trim(),
callerMode: policyForm.callerMode,
callerValue: policyForm.callerMode === 'any' ? '' : callerValue,
calleeMode: policyForm.calleeMode,
calleeValue: policyForm.calleeMode === 'any' ? '' : calleeValue,
routeGroup: policyForm.routeGroup,
};
if (editingPolicy) {
setPolicyRows((rows) => rows.map((policy) => (
policy.id === editingPolicy.id ? { ...policy, ...nextPolicy } : policy
)));
} else {
setPolicyRows((rows) => [
...rows,
{
...nextPolicy,
id: `CGP-${Date.now()}`,
gateway: strategyGatewayData.id,
priority: nextPriority,
status: '启用',
},
]);
setGatewayRows((rows) => rows.map((gateway) => (
gateway.id === strategyGatewayData.id ? { ...gateway, routePolicyCount: gateway.routePolicyCount + 1 } : gateway
)));
}
closePolicyModal();
};
const renumberPolicies = (policies) => policies.map((policy, index) => ({ ...policy, priority: (index + 1) * 10 }));
const movePolicy = (policyId, direction) => {
if (!strategyGatewayData) return;
const ordered = [...strategyPolicies];
const currentIndex = ordered.findIndex((policy) => policy.id === policyId);
const nextIndex = currentIndex + direction;
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= ordered.length) return;
[ordered[currentIndex], ordered[nextIndex]] = [ordered[nextIndex], ordered[currentIndex]];
const movedPolicies = renumberPolicies(ordered);
setPolicyRows((rows) => rows.map((policy) => (
policy.gateway === strategyGatewayData.id
? movedPolicies.find((item) => item.id === policy.id) || policy
: policy
)));
};
const togglePolicyStatus = (policyId) => {
setPolicyRows((rows) => rows.map((policy) => (
policy.id === policyId ? { ...policy, status: policy.status === '启用' ? '停用' : '启用' } : policy
)));
};
const deletePolicy = (policyId) => {
if (!strategyGatewayData) return;
setPolicyRows((rows) => renumberPolicies(rows.filter((policy) => policy.gateway === strategyGatewayData.id && policy.id !== policyId))
.concat(rows.filter((policy) => policy.gateway !== strategyGatewayData.id)));
setGatewayRows((rows) => rows.map((gateway) => (
gateway.id === strategyGatewayData.id ? { ...gateway, routePolicyCount: Math.max(0, gateway.routePolicyCount - 1) } : gateway
)));
setDeletePolicyTarget(null);
};
return (
<>
<PageTitle
title="客户网关管理"
desc="独立管理客户接入网关、多 IP、单落地线路组、客户侧费率和主被叫匹配规则。"
actions={canManage ? <Button icon={<Icon type="plus" />} onClick={openCreateGateway}>新增客户网关</Button> : null}
/>
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} />
{localError ? <Alert title="客户网关操作失败" tone="danger">{localError}</Alert> : null}
<section className="content-grid">
<Panel title="客户网关列表" className="wide-panel">
<SimpleTable rows={gatewayRows} columns={[
{ key: 'id', label: 'ID', width: '104px', className: 'table-cell-compact' },
{ key: 'name', label: '名称', width: '168px', className: 'table-cell-compact' },
{ key: 'customer', label: '客户', width: '148px', className: 'table-cell-compact' },
{ key: 'authMode', label: '认证方式' },
{ key: 'authTarget', label: 'IP地址 / 账号名称', render: (row) => (row.authMode === 'IP' || row.authMode === '混合认证' ? (row.sourceIps || []).join(', ') || row.ipAddress : row.sipAccount) },
{ key: 'lineGroupName', label: '落地线路组' },
{ key: 'rate', label: '客户费率', render: (row) => `${row.billingCycleSec || 60}s / ¥${Number(row.cycleRate || 0).toFixed(6)}` },
{ key: 'callerRule', label: '主叫规则', render: (row) => row.callerMatchMode === 'PREFIXES' ? (row.callerPrefixes || []).join(', ') : '任意号码' },
{ key: 'calleeRule', label: '被叫规则', render: (row) => row.calleeMatchMode === 'BUSINESS_PREFIXES' ? (row.businessPrefixes || []).map((item) => item.prefix).join(', ') : '任意号码' },
{ 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> : null}
{!canManage ? '-' : null}
</div>
),
},
]} />
</Panel>
</section>
{gatewayModalOpen ? (
<Modal title={editingGateway ? '编辑客户网关' : '新增客户网关'} onClose={submitting ? () => {} : closeGatewayModal} size="lg">
<form className="modal-form" onSubmit={submitGateway}>
<Field label={<span>所属客户 <span className="required-star">*</span></span>}>
<Select value={gatewayForm.customerId} onChange={(event) => setGatewayForm({ ...gatewayForm, customerId: event.target.value })} required>
{customerOptions.map((customer) => <option key={customer.id} value={customer.id}>{customer.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, sourceIps: '', sipAccount: '', sipPassword: '' })} required>
<option>IP</option>
<option>SIP注册</option>
<option value="MIXED">混合认证</option>
</Select>
</Field>
{gatewayForm.authMode === 'IP' || gatewayForm.authMode === 'MIXED' ? (
<Field label={<span>客户网关 IP <span className="required-star">*</span></span>}>
<Textarea rows={3} value={gatewayForm.sourceIps} onChange={(event) => setGatewayForm({ ...gatewayForm, sourceIps: event.target.value })} placeholder="每行一个 IP,例如 10.10.1.11" required />
</Field>
) : null}
{gatewayForm.authMode === 'SIP注册' || gatewayForm.authMode === 'MIXED' ? (
<>
<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={<span>SIP域 <span className="required-star">*</span></span>}>
<Input value={gatewayForm.sipDomain} onChange={(event) => setGatewayForm({ ...gatewayForm, sipDomain: event.target.value })} placeholder="例如 lisglosips.local" required />
</Field>
<Field label={editingGateway ? 'SIP密码(留空不修改)' : <span>SIP密码 <span className="required-star">*</span></span>}>
<Input type="password" value={gatewayForm.sipPassword} onChange={(event) => setGatewayForm({ ...gatewayForm, sipPassword: event.target.value })} placeholder="至少 12 位" required={!editingGateway} />
</Field>
</>
) : null}
<Field label={<span>落地线路组 <span className="required-star">*</span></span>}>
<Select value={gatewayForm.lineGroupId} onChange={(event) => setGatewayForm({ ...gatewayForm, lineGroupId: event.target.value })} required>
<option value="">请选择落地线路组</option>
{lineGroupOptions.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
</Select>
</Field>
<Field label="计费周期(秒)">
<Input type="number" min="1" value={gatewayForm.billingCycleSec} onChange={(event) => setGatewayForm({ ...gatewayForm, billingCycleSec: event.target.value })} />
</Field>
<Field label="周期内费率">
<Input value={gatewayForm.cycleRate} onChange={(event) => setGatewayForm({ ...gatewayForm, cycleRate: event.target.value })} placeholder="0.000000" />
</Field>
<Field label="主叫匹配">
<Select value={gatewayForm.callerMatchMode} onChange={(event) => setGatewayForm({ ...gatewayForm, callerMatchMode: event.target.value, callerPrefixes: '' })}>
<option value="ANY">任意号码</option>
<option value="PREFIXES">指定前缀</option>
</Select>
</Field>
{gatewayForm.callerMatchMode === 'PREFIXES' ? (
<Field label="主叫前缀">
<Textarea rows={3} value={gatewayForm.callerPrefixes} onChange={(event) => setGatewayForm({ ...gatewayForm, callerPrefixes: event.target.value })} placeholder="每行一个前缀" />
</Field>
) : null}
<Field label="被叫业务前缀">
<Select value={gatewayForm.calleeMatchMode} onChange={(event) => setGatewayForm({ ...gatewayForm, calleeMatchMode: event.target.value, businessPrefixIds: [] })}>
<option value="ANY">任意号码</option>
<option value="BUSINESS_PREFIXES">指定业务前缀</option>
</Select>
</Field>
{gatewayForm.calleeMatchMode === 'BUSINESS_PREFIXES' ? (
<div className="checkbox-grid">
{businessPrefixOptions.map((prefix) => (
<Checkbox
key={prefix.id}
checked={gatewayForm.businessPrefixIds.includes(prefix.id)}
onChange={(checked) => setGatewayForm((current) => ({
...current,
businessPrefixIds: checked
? [...current.businessPrefixIds, prefix.id]
: current.businessPrefixIds.filter((id) => id !== prefix.id),
}))}
>
{prefix.prefix} / {prefix.name}
</Checkbox>
))}
</div>
) : null}
<div className="modal-actions">
<Button type="button" variant="outline" disabled={submitting} onClick={closeGatewayModal}>取消</Button>
<Button type="submit" disabled={submitting}>{submitting ? '保存中' : editingGateway ? '保存修改' : '保存网关'}</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'}
busy={submitting}
onCancel={() => setGatewayConfirm(null)}
onConfirm={() => {
const { type, row } = gatewayConfirm;
setGatewayConfirm(null);
return type === 'delete' ? void deleteGateway(row) : void toggleGatewayStatus(row);
}}
>
{gatewayConfirm.type === 'delete' ? (
<p>确认删除客户网关{gatewayConfirm.row.name}删除后该网关不会再参与客户呼入匹配</p>
) : (
<p>确认{gatewayConfirm.row.status === '启用' ? '禁用' : '启用'}客户网关{gatewayConfirm.row.name}</p>
)}
</ConfirmDialog>
) : null}
{strategyGatewayData ? (
<Drawer title={`${strategyGatewayData.name} 策略配置`} aside={<Badge tone="info">{strategyGatewayData.id}</Badge>} onClose={closePolicyDrawer}>
<div className="drawer-toolbar">
<div>
<strong>{strategyPolicies.length} 条策略</strong>
<span>按优先级从小到大匹配主叫和被叫条件可并存</span>
</div>
{canManage ? <Button icon={<Icon type="plus" />} onClick={openCreatePolicy}>添加策略</Button> : null}
</div>
<SimpleTable rows={strategyPolicies} columns={[
{ key: 'priority', label: '优先级' },
{ key: 'name', label: '策略名称' },
{ key: 'callerMatch', label: '主叫匹配', render: (row) => formatMatch(row.callerMode, row.callerValue) },
{ key: 'calleeMatch', label: '被叫匹配', render: (row) => formatMatch(row.calleeMode, row.calleeValue) },
{ key: 'routeGroup', label: '呼叫至线路群组' },
{ key: 'status', label: '状态', status: true },
{
key: 'actions',
label: '操作',
render: (row) => (
<div className="table-actions">
{canManage ? <Button size="sm" variant="outline" onClick={() => openEditPolicy(row)}>编辑</Button> : null}
{canManage ? <Button size="sm" variant="outline" onClick={() => movePolicy(row.id, -1)}>上移</Button> : null}
{canManage ? <Button size="sm" variant="outline" onClick={() => movePolicy(row.id, 1)}>下移</Button> : null}
{canManage ? (
<Button size="sm" variant={row.status === '启用' ? 'ghost' : 'secondary'} onClick={() => togglePolicyStatus(row.id)}>
{row.status === '启用' ? '停用' : '启用'}
</Button>
) : null}
{canManage ? <Button size="sm" variant="danger" onClick={() => setDeletePolicyTarget(row)}>删除</Button> : '-'}
</div>
),
},
]} />
</Drawer>
) : null}
{policyModalOpen ? (
<Modal title={editingPolicy ? '编辑策略' : '添加策略'} onClose={closePolicyModal} size="lg">
<form className="strategy-form" onSubmit={submitPolicy}>
<Field label={<span>策略名称 <span className="required-star">*</span></span>}>
<Input value={policyForm.name} onChange={(event) => setPolicyForm({ ...policyForm, name: event.target.value })} placeholder="例如 华东移动号码优先" required />
</Field>
<div className="match-grid">
<div className="match-card">
<strong>主叫号码匹配</strong>
<Field label="匹配方式">
<Select value={policyForm.callerMode} onChange={(event) => setPolicyForm({ ...policyForm, callerMode: event.target.value, callerValue: '' })}>
<option value="any">不限</option>
<option value="equals">等于指定号码</option>
<option value="prefix">按前缀匹配</option>
</Select>
</Field>
{policyForm.callerMode !== 'any' ? (
<Field label={<span>主叫号码 <span className="required-star">*</span></span>}>
<Input value={policyForm.callerValue} onChange={(event) => setPolicyForm({ ...policyForm, callerValue: event.target.value })} placeholder={policyForm.callerMode === 'equals' ? '例如 02160010001' : '例如 0216001'} required />
</Field>
) : null}
</div>
<div className="match-card">
<strong>被叫号码匹配</strong>
<Field label="匹配方式">
<Select value={policyForm.calleeMode} onChange={(event) => setPolicyForm({ ...policyForm, calleeMode: event.target.value, calleeValue: '' })}>
<option value="any">不限</option>
<option value="equals">等于指定号码</option>
<option value="prefix">按前缀匹配</option>
</Select>
</Field>
{policyForm.calleeMode !== 'any' ? (
<Field label={<span>被叫号码 <span className="required-star">*</span></span>}>
<Input value={policyForm.calleeValue} onChange={(event) => setPolicyForm({ ...policyForm, calleeValue: event.target.value })} placeholder={policyForm.calleeMode === 'equals' ? '例如 13800138000' : '例如 13/15/18'} required />
</Field>
) : null}
</div>
</div>
<div className="sub-toolbar">
<Field label={<span>呼叫至线路群组 <span className="required-star">*</span></span>}>
<Select value={policyForm.routeGroup} onChange={(event) => setPolicyForm({ ...policyForm, routeGroup: event.target.value })} required>
{routeGroups.map((group) => <option key={group.id}>{group.name}</option>)}
</Select>
</Field>
</div>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={closePolicyModal}>取消</Button>
<Button type="submit">{editingPolicy ? '保存修改' : '保存策略'}</Button>
</div>
</form>
</Modal>
) : null}
{deletePolicyTarget ? (
<ConfirmDialog
title="删除策略确认"
confirmLabel="确认删除"
confirmVariant="danger"
onCancel={() => setDeletePolicyTarget(null)}
onConfirm={() => deletePolicy(deletePolicyTarget.id)}
>
<p>确认删除策略{deletePolicyTarget.name}删除后当前网关的策略优先级会自动重排</p>
</ConfirmDialog>
) : null}
</>
);
}
+243
View File
@@ -0,0 +1,243 @@
import { useState } from 'react';
import { Button, Field, Input, Select, Textarea } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable, KeyValue } from '../components/layout.jsx';
import { gateways } from '../fixtures/devFixtures.js';
import { explainApiError } from '../api.js';
export function CustomersPage({ customerRows, setCustomerRows, addRechargeRecord, apiLoading, apiError, refreshApi, can = () => true, onCreateCustomer, onUpdateCustomer, onDeleteCustomer, onRechargeCustomer }) {
const [showCreateCustomer, setShowCreateCustomer] = useState(false);
const [editingCustomer, setEditingCustomer] = useState(null);
const [rechargeCustomer, setRechargeCustomer] = useState(null);
const [deleteCustomerTarget, setDeleteCustomerTarget] = useState(null);
const [newCustomer, setNewCustomer] = useState({ name: '', contact: '', phone: '', email: '' });
const [editCustomerForm, setEditCustomerForm] = useState({ name: '', contact: '', phone: '', email: '' });
const [rechargeForm, setRechargeForm] = useState({ amount: '', remark: '' });
const [actionError, setActionError] = useState('');
const canManageCustomers = can('customers.manage');
const canManageRecharges = can('recharges.manage');
const openEditCustomer = (customer) => {
setEditingCustomer(customer);
setEditCustomerForm({
name: customer.name || '',
contact: customer.contact === '-' ? '' : customer.contact || '',
phone: customer.phone === '-' ? '' : customer.phone || '',
email: customer.email === '-' ? '' : customer.email || '',
});
};
const closeEditCustomer = () => {
setEditingCustomer(null);
setEditCustomerForm({ name: '', contact: '', phone: '', email: '' });
};
const openRechargeCustomer = (customer) => {
setRechargeCustomer(customer);
setRechargeForm({ amount: '', remark: '' });
};
const closeRechargeCustomer = () => {
setRechargeCustomer(null);
setRechargeForm({ amount: '', remark: '' });
};
const parseMoney = (value) => Number(String(value).replace(/[^\d.-]/g, '')) || 0;
const formatMoney = (value) => `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const submitCustomer = async (event) => {
event.preventDefault();
if (!newCustomer.name.trim()) return;
setActionError('');
try {
if (onCreateCustomer) {
await onCreateCustomer({
name: newCustomer.name.trim(),
contactName: newCustomer.contact.trim() || undefined,
phone: newCustomer.phone.trim() || undefined,
email: newCustomer.email.trim() || undefined,
billingMode: 'PREPAID',
creditLimit: '0.000000',
minBalance: '0.000000',
});
} else {
const nextIndex = customerRows.length + 1;
setCustomerRows([...customerRows, { id: `C${String(1000 + nextIndex)}`, name: newCustomer.name.trim(), contact: newCustomer.contact.trim() || '-', phone: newCustomer.phone.trim() || '-', email: newCustomer.email.trim() || '-', domain: '-', auth: '待配置', status: '启用', balance: '¥0.00', credit: '¥0', billing: '待配置', routeGroup: '待配置', gateways: 0, createdAt: '2026-06-18' }]);
}
setNewCustomer({ name: '', contact: '', phone: '', email: '' });
setShowCreateCustomer(false);
} catch (error) {
setActionError(explainApiError(error));
}
};
const submitEditCustomer = async (event) => {
event.preventDefault();
if (!editCustomerForm.name.trim() || !editingCustomer) return;
setActionError('');
try {
if (onUpdateCustomer) {
await onUpdateCustomer(editingCustomer.id, {
name: editCustomerForm.name.trim(),
contactName: editCustomerForm.contact.trim() || null,
phone: editCustomerForm.phone.trim() || null,
email: editCustomerForm.email.trim() || null,
});
} else {
setCustomerRows(customerRows.map((customer) => (
customer.id === editingCustomer.id ? { ...customer, name: editCustomerForm.name.trim(), contact: editCustomerForm.contact.trim() || '-', phone: editCustomerForm.phone.trim() || '-', email: editCustomerForm.email.trim() || '-' } : customer
)));
}
closeEditCustomer();
} catch (error) {
setActionError(explainApiError(error));
}
};
const submitRecharge = async (event) => {
event.preventDefault();
const amount = Number(rechargeForm.amount);
if (!rechargeCustomer || !Number.isFinite(amount) || amount <= 0) return;
setActionError('');
try {
if (onRechargeCustomer) {
await onRechargeCustomer(rechargeCustomer.id, {
amount: amount.toFixed(2),
remark: rechargeForm.remark.trim() || undefined,
});
} else {
const beforeBalance = parseMoney(rechargeCustomer.balance);
const afterBalance = beforeBalance + amount;
setCustomerRows(customerRows.map((customer) => (
customer.id === rechargeCustomer.id ? { ...customer, balance: formatMoney(afterBalance) } : customer
)));
addRechargeRecord({ id: `RCG-${Date.now()}`, type: 'customer', owner: rechargeCustomer.name, amount: formatMoney(amount), beforeBalance: formatMoney(beforeBalance), afterBalance: formatMoney(afterBalance), remark: rechargeForm.remark.trim() || '-', operator: '运营管理员', time: new Date().toLocaleString('zh-CN', { hour12: false }), status: '成功' });
}
closeRechargeCustomer();
} catch (error) {
setActionError(explainApiError(error));
}
};
const deleteCustomer = async (customer) => {
setActionError('');
try {
if (onDeleteCustomer) {
await onDeleteCustomer(customer.id);
} else {
if ((customer.gateways ?? 0) > 0) {
throw new Error('该客户仍有关联客户网关,不能删除。');
}
setCustomerRows((rows) => rows.filter((item) => item.id !== customer.id));
}
setDeleteCustomerTarget(null);
} catch (error) {
setActionError(explainApiError(error));
}
};
return (
<>
<PageTitle
title="客户管理"
desc="客户开通、SIP 账号/IP 白名单、费率方案、线路组、余额授信和操作日志。"
actions={canManageCustomers ? <Button icon={<Icon type="plus" />} onClick={() => setShowCreateCustomer(true)}>新增客户</Button> : null}
/>
<ApiNotice loading={apiLoading} error={apiError || actionError} onRetry={refreshApi} />
<Toolbar>
<Field label="客户名称"><Input placeholder="搜索客户名称 / 域名" /></Field>
<Field label="状态"><Select defaultValue="all"><option value="all">全部状态</option><option>启用</option><option>观察</option><option>停用</option></Select></Field>
<Button icon={<Icon type="search" />}>查询</Button>
</Toolbar>
<section className="master-detail">
<Panel title="客户列表" className="main-list wide-panel">
<SimpleTable
rows={customerRows}
columns={[
{ key: 'id', label: '客户 ID', width: '108px', className: 'table-cell-compact' },
{ key: 'name', label: '名称', width: '180px', className: 'table-cell-compact' },
{ key: 'balance', label: '余额' },
{ key: 'credit', label: '授信额度' },
{ key: 'status', label: '状态', status: true },
{ key: 'gateways', label: '客户网关数' },
{
key: 'actions',
label: '操作',
render: (row) => (
<div className="table-actions">
{canManageCustomers ? <Button size="sm" variant="outline" onClick={() => openEditCustomer(row)}>编辑</Button> : null}
{canManageRecharges ? <Button size="sm" variant="secondary" onClick={() => openRechargeCustomer(row)}>充值</Button> : null}
{canManageCustomers ? <Button size="sm" variant="danger" onClick={() => setDeleteCustomerTarget(row)}>删除</Button> : null}
{!canManageCustomers && !canManageRecharges ? '-' : null}
</div>
),
},
]}
/>
</Panel>
</section>
{showCreateCustomer ? (
<Modal title="新增客户" onClose={() => setShowCreateCustomer(false)} size="sm">
<form className="modal-form" onSubmit={submitCustomer}>
<Field label={<span>客户名称 <span className="required-star">*</span></span>}>
<Input value={newCustomer.name} onChange={(event) => setNewCustomer({ ...newCustomer, name: event.target.value })} placeholder="请输入企业名称" required />
</Field>
<Field label="联系人">
<Input value={newCustomer.contact} onChange={(event) => setNewCustomer({ ...newCustomer, contact: event.target.value })} placeholder="请输入联系人" />
</Field>
<Field label="联系电话">
<Input value={newCustomer.phone} onChange={(event) => setNewCustomer({ ...newCustomer, phone: event.target.value })} placeholder="请输入联系电话" />
</Field>
<Field label="邮箱">
<Input type="email" value={newCustomer.email} onChange={(event) => setNewCustomer({ ...newCustomer, email: event.target.value })} placeholder="请输入邮箱" />
</Field>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={() => setShowCreateCustomer(false)}>取消</Button>
<Button type="submit">保存客户</Button>
</div>
</form>
</Modal>
) : null}
{rechargeCustomer ? (
<Modal title={`${rechargeCustomer.name} 充值`} onClose={closeRechargeCustomer} size="sm">
<form className="modal-form" onSubmit={submitRecharge}>
<KeyValue label="当前余额" value={rechargeCustomer.balance} />
<Field label={<span>充值金额 <span className="required-star">*</span></span>}>
<Input type="number" min="0.01" step="0.01" value={rechargeForm.amount} onChange={(event) => setRechargeForm({ ...rechargeForm, amount: event.target.value })} placeholder="请输入充值金额" required />
</Field>
<Field label="备注">
<Textarea rows="4" value={rechargeForm.remark} onChange={(event) => setRechargeForm({ ...rechargeForm, remark: event.target.value })} placeholder="请输入备注" />
</Field>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={closeRechargeCustomer}>取消</Button>
<Button type="submit">确认充值</Button>
</div>
</form>
</Modal>
) : null}
{editingCustomer ? (
<Modal title="编辑客户" onClose={closeEditCustomer} size="sm">
<form className="modal-form" onSubmit={submitEditCustomer}>
<Field label={<span>客户名称 <span className="required-star">*</span></span>}>
<Input value={editCustomerForm.name} onChange={(event) => setEditCustomerForm({ ...editCustomerForm, name: event.target.value })} placeholder="请输入客户名称" required />
</Field>
<Field label="联系人">
<Input value={editCustomerForm.contact} onChange={(event) => setEditCustomerForm({ ...editCustomerForm, contact: event.target.value })} placeholder="请输入联系人" />
</Field>
<Field label="联系电话">
<Input value={editCustomerForm.phone} onChange={(event) => setEditCustomerForm({ ...editCustomerForm, phone: event.target.value })} placeholder="请输入联系电话" />
</Field>
<Field label="邮箱">
<Input type="email" value={editCustomerForm.email} onChange={(event) => setEditCustomerForm({ ...editCustomerForm, email: event.target.value })} placeholder="请输入邮箱" />
</Field>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={closeEditCustomer}>取消</Button>
<Button type="submit">保存修改</Button>
</div>
</form>
</Modal>
) : null}
{deleteCustomerTarget ? (
<ConfirmDialog
title="删除客户确认"
confirmLabel="确认删除"
confirmVariant="danger"
onCancel={() => setDeleteCustomerTarget(null)}
onConfirm={() => void deleteCustomer(deleteCustomerTarget)}
>
<p>确认删除客户{deleteCustomerTarget.name}删除后该客户将不再出现在客户列表中</p>
</ConfirmDialog>
) : null}
</>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { Badge, Button } from '../components/ui.jsx';
import { Icon, PageTitle, Panel, ApiNotice, EmptyState, MiniBarChart, LineChart } from '../components/layout.jsx';
import { formatCurrency } from '../utils/formatters.js';
import { metrics, callTrend, answerTrend } from '../fixtures/devFixtures.js';
function dashboardMetrics(summary) {
if (!summary) {
return metrics;
}
return [
{ label: '今日通话数', value: String(summary.calls.totalCalls), delta: '真实 API', tone: 'neutral' },
{ label: '当前在线通话', value: String(summary.realtime.onlineCalls), delta: summary.realtime.source, tone: 'neutral' },
{ label: '今日接通率', value: `${(Number(summary.calls.answerRate) * 100).toFixed(2)}%`, delta: `${summary.calls.answeredCalls}/${summary.calls.totalCalls}`, tone: 'neutral' },
{ label: '客户消费', value: formatCurrency(summary.money.customerFee), delta: '今日', tone: 'neutral' },
{ label: '供应商成本', value: formatCurrency(summary.money.vendorCost), delta: '今日', tone: 'neutral' },
{ label: '今日毛利', value: formatCurrency(summary.money.grossProfit), delta: '今日', tone: 'neutral' },
{ label: '在线注册用户', value: String(summary.realtime.registeredUsers), delta: summary.realtime.source, tone: 'neutral' },
{ label: '活跃客户', value: String(summary.entities.activeCustomers), delta: '启用', tone: 'neutral' },
{ label: '活跃落地网关', value: String(summary.entities.activeVendorGateways), delta: '启用', tone: 'neutral' },
{ label: '异常网关', value: String(summary.abnormalGateways.length), delta: '失败 Top', tone: summary.abnormalGateways.length ? 'warn' : 'neutral' },
{ label: '质检待处理', value: String(summary.quality.pendingReviews), delta: '录音', tone: summary.quality.pendingReviews ? 'warn' : 'neutral' },
];
}
export function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, apiError, refreshApi, customerRows }) {
const trendBuckets = dashboardTrends?.buckets || [];
const callTrendData = trendBuckets.length ? trendBuckets.map((bucket) => bucket.calls.totalCalls) : callTrend;
const answerTrendData = trendBuckets.length ? trendBuckets.map((bucket) => Math.round(Number(bucket.calls.answerRate) * 100)) : answerTrend;
const failureCodes = dashboardSummary?.failureCodes?.length ? dashboardSummary.failureCodes : [];
return (
<>
<PageTitle
title="概览 Dashboard"
desc="展示平台整体运行状态,覆盖通话、收入、成本、注册、节点和质检待办。"
actions={<Button icon={<Icon type="reload" />} onClick={refreshApi}>刷新指标</Button>}
/>
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} />
<section className="metric-grid">
{dashboardMetrics(dashboardSummary).map((metric) => (
<div className="metric-card" key={metric.label}>
<span>{metric.label}</span>
<strong>{metric.value}</strong>
<em className={`metric-${metric.tone}`}>{metric.delta}</em>
</div>
))}
</section>
<section className="content-grid">
<Panel title="最近 24 小时通话量趋势" aside={<Badge tone="info">OpenSIPS acc/CDR</Badge>}>
<MiniBarChart data={callTrendData.length ? callTrendData : [0]} />
</Panel>
<Panel title="最近 24 小时接通率趋势" aside={<Badge tone="info">dialog statistics</Badge>}>
<LineChart data={answerTrendData.length ? answerTrendData : [0, 0]} />
</Panel>
<Panel title="客户消费 TOP 10">
<div className="rank-list">
{customerRows.length ? customerRows.slice(0, 3).map((item, index) => (
<div key={item.id}><span>{index + 1}</span><strong>{item.name}</strong><em>{item.balance}</em></div>
)) : <EmptyState title="暂无客户数据">客户 API 返回空列表</EmptyState>}
</div>
</Panel>
<Panel title="失败响应码分布">
<div className="code-grid">
{failureCodes.length ? failureCodes.map((code) => (
<div key={code.sipCode}><strong>{code.count}</strong><span>{code.sipCode}</span></div>
)) : <EmptyState title="暂无失败码">今日没有失败 CDR API 尚未返回数据</EmptyState>}
</div>
</Panel>
</section>
</>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Badge, Button, Checkbox } from '../components/ui.jsx';
import { PageTitle, Panel, SimpleTable } from '../components/layout.jsx';
import { opsItems } from '../fixtures/devFixtures.js';
export function MonitoringPage() {
return (
<>
<PageTitle title="监控告警" desc="OpenSIPS、RTPEngine、端口监听、CDR 堆积、计费队列、录音入库和磁盘空间。" actions={<Button>新增告警规则</Button>} />
<section className="content-grid">
<Panel title="监控项" className="wide-panel">
<SimpleTable rows={opsItems} columns={[{ key: 'name', label: '监控项' }, { key: 'target', label: '目标' }, { key: 'status', label: '状态', status: true }, { key: 'value', label: '当前值' }]} />
</Panel>
<Panel title="告警方式">
<div className="setting-list">
<Checkbox label="邮件" checked readOnly />
<Checkbox label="企业微信/钉钉/飞书" checked readOnly />
<Checkbox label="短信" checked={false} readOnly />
</div>
</Panel>
<Panel title="推荐工具">
<div className="tag-cloud">{['Prometheus', 'Grafana', 'Loki', 'ELK', 'Alertmanager', 'Monit'].map((tag) => <Badge key={tag} tone="brand">{tag}</Badge>)}</div>
</Panel>
</section>
</>
);
}
+307
View File
@@ -0,0 +1,307 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Field, Input, Select, Tabs, Textarea } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, SimpleTable } from '../components/layout.jsx';
import { formatDate, zhStatus, carrierLabel } from '../utils/formatters.js';
import { api, explainApiError } from '../api.js';
export function NumberLibraryPage({ can = () => true }) {
const [activeTab, setActiveTab] = useState('cities');
const [rows, setRows] = useState(emptyNumberLibraryRows);
const [totals, setTotals] = useState(emptyNumberLibraryTotals);
const [filters, setFilters] = useState({
cities: { keyword: '' },
phoneSegments: { segment7: '', cityCode: '', carrier: 'all' },
areaCodes: { areaCode: '', cityCode: '' },
carrierPrefixRules: { prefix: '', carrier: 'all' },
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const [importTarget, setImportTarget] = useState(null);
const [importText, setImportText] = useState('');
const [importing, setImporting] = useState(false);
const canManage = can('number_library.manage');
const readTab = async (tab) => {
const params = filters[tab] || {};
if (tab === 'cities') {
const payload = await api.numberLibraryCities(params);
return normalizeNumberLibraryList(payload, (item) => ({
id: item.code,
code: item.code,
provinceName: item.provinceName,
cityName: item.cityName,
cityLevel: item.cityLevel || '-',
status: zhStatus(item.status),
effectiveFrom: formatDate(item.effectiveFrom),
effectiveTo: formatDate(item.effectiveTo),
}));
}
if (tab === 'phoneSegments') {
const payload = await api.numberLibraryPhoneSegments(params);
return normalizeNumberLibraryList(payload, (item) => ({
id: item.segment7,
segment7: item.segment7,
provinceName: item.provinceName,
cityCode: item.cityCode,
cityName: item.cityName,
carrier: carrierLabel(item.carrier),
numberType: item.numberType || '-',
source: item.source || '-',
batchId: item.batchId || '-',
}));
}
if (tab === 'areaCodes') {
const payload = await api.numberLibraryAreaCodes(params);
return normalizeNumberLibraryList(payload, (item) => ({
id: item.areaCode,
areaCode: item.areaCode,
provinceName: item.provinceName,
cityCode: item.cityCode,
cityName: item.cityName,
source: item.source || '-',
batchId: item.batchId || '-',
}));
}
const payload = await api.numberLibraryCarrierPrefixRules(params);
return normalizeNumberLibraryList(payload, (item) => ({
id: item.prefix,
prefix: item.prefix,
carrier: carrierLabel(item.carrier),
priority: item.priority,
source: item.source || '-',
batchId: item.batchId || '-',
effectiveFrom: formatDate(item.effectiveFrom),
effectiveTo: formatDate(item.effectiveTo),
}));
};
const loadTab = async (tab = activeTab) => {
setLoading(true);
setError('');
try {
const result = await readTab(tab);
setRows((current) => ({ ...current, [tab]: result.rows }));
setTotals((current) => ({ ...current, [tab]: result.total }));
} catch (loadError) {
setError(explainApiError(loadError));
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadTab('cities');
void loadTab('phoneSegments');
void loadTab('areaCodes');
void loadTab('carrierPrefixRules');
}, []);
const updateFilter = (key, value) => {
setFilters((current) => ({
...current,
[activeTab]: { ...current[activeTab], [key]: value },
}));
};
const openImport = (tab) => {
setImportTarget(tab);
setImportText(JSON.stringify(numberLibraryImportExamples[tab], null, 2));
setMessage('');
setError('');
};
const submitImport = async (event) => {
event.preventDefault();
if (!importTarget) return;
setImporting(true);
setError('');
try {
const parsed = JSON.parse(importText);
const items = Array.isArray(parsed) ? parsed : parsed.items;
if (!Array.isArray(items) || items.length === 0) {
throw new Error('导入内容必须是非空数组。');
}
const importers = {
cities: api.importNumberLibraryCities,
phoneSegments: api.importNumberLibraryPhoneSegments,
areaCodes: api.importNumberLibraryAreaCodes,
carrierPrefixRules: api.importNumberLibraryCarrierPrefixRules,
};
const result = await importers[importTarget](items);
setMessage(`导入完成:新增 ${result?.created ?? 0} 条,更新 ${result?.updated ?? 0} 条。`);
setImportTarget(null);
setImportText('');
await loadTab(importTarget);
} catch (submitError) {
setError(explainApiError(submitError));
} finally {
setImporting(false);
}
};
const renderFilters = () => {
const current = filters[activeTab];
if (activeTab === 'cities') {
return (
<>
<Field label="省份/城市"><Input value={current.keyword} onChange={(event) => updateFilter('keyword', event.target.value)} placeholder="输入省份或城市" /></Field>
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button>
</>
);
}
if (activeTab === 'phoneSegments') {
return (
<>
<Field label="前 7 位号段"><Input value={current.segment7} onChange={(event) => updateFilter('segment7', event.target.value)} placeholder="如 1380013" /></Field>
<Field label="地级市代码"><Input value={current.cityCode} onChange={(event) => updateFilter('cityCode', event.target.value)} placeholder="如 340100" /></Field>
<Field label="运营商">
<Select value={current.carrier} onChange={(event) => updateFilter('carrier', event.target.value)}>
<option value="all">全部</option>
<option value="MOBILE">移动</option>
<option value="UNICOM">联通</option>
<option value="TELECOM">电信</option>
<option value="BROADCAST">广电</option>
<option value="MVNO">虚拟运营商</option>
<option value="UNKNOWN">未知</option>
</Select>
</Field>
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button>
</>
);
}
if (activeTab === 'areaCodes') {
return (
<>
<Field label="固话区号"><Input value={current.areaCode} onChange={(event) => updateFilter('areaCode', event.target.value)} placeholder="如 0551" /></Field>
<Field label="地级市代码"><Input value={current.cityCode} onChange={(event) => updateFilter('cityCode', event.target.value)} placeholder="如 340100" /></Field>
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button>
</>
);
}
return (
<>
<Field label="号码前缀"><Input value={current.prefix} onChange={(event) => updateFilter('prefix', event.target.value)} placeholder="如 138" /></Field>
<Field label="运营商">
<Select value={current.carrier} onChange={(event) => updateFilter('carrier', event.target.value)}>
<option value="all">全部</option>
<option value="MOBILE">移动</option>
<option value="UNICOM">联通</option>
<option value="TELECOM">电信</option>
<option value="BROADCAST">广电</option>
<option value="MVNO">虚拟运营商</option>
<option value="UNKNOWN">未知</option>
</Select>
</Field>
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button>
</>
);
};
const renderTable = (tab) => {
if (tab === 'cities') {
return (
<SimpleTable rows={rows.cities} columns={[
{ key: 'code', label: '地级市代码', width: '110px' },
{ key: 'provinceName', label: '省份', width: '120px' },
{ key: 'cityName', label: '地级市', width: '140px' },
{ key: 'cityLevel', label: '级别', width: '120px' },
{ key: 'status', label: '状态', width: '90px', status: true },
{ key: 'effectiveFrom', label: '生效时间' },
{ key: 'effectiveTo', label: '失效时间' },
]} />
);
}
if (tab === 'phoneSegments') {
return (
<SimpleTable rows={rows.phoneSegments} columns={[
{ key: 'segment7', label: '前 7 位', width: '110px' },
{ key: 'provinceName', label: '省份', width: '120px' },
{ key: 'cityCode', label: '地级市代码', width: '120px' },
{ key: 'cityName', label: '地级市', width: '140px' },
{ key: 'carrier', label: '运营商', width: '100px' },
{ key: 'numberType', label: '号码类型', width: '110px' },
{ key: 'batchId', label: '批次' },
{ key: 'source', label: '来源' },
]} />
);
}
if (tab === 'areaCodes') {
return (
<SimpleTable rows={rows.areaCodes} columns={[
{ key: 'areaCode', label: '区号', width: '100px' },
{ key: 'provinceName', label: '省份', width: '120px' },
{ key: 'cityCode', label: '地级市代码', width: '120px' },
{ key: 'cityName', label: '地级市', width: '140px' },
{ key: 'batchId', label: '批次' },
{ key: 'source', label: '来源' },
]} />
);
}
return (
<SimpleTable rows={rows.carrierPrefixRules} columns={[
{ key: 'prefix', label: '前缀', width: '100px' },
{ key: 'carrier', label: '运营商', width: '110px' },
{ key: 'priority', label: '优先级', width: '90px' },
{ key: 'batchId', label: '批次' },
{ key: 'source', label: '来源' },
{ key: 'effectiveFrom', label: '生效时间' },
{ key: 'effectiveTo', label: '失效时间' },
]} />
);
};
const importTitle = importTarget ? numberLibraryTabs.find((tab) => tab.value === importTarget)?.label : '';
return (
<>
<PageTitle
title="号码库"
desc="维护地级市、手机前 7 位号段、固话区号和运营商前缀规则。"
actions={canManage ? <Button icon={<Icon type="export" />} onClick={() => openImport(activeTab)}>批量导入</Button> : null}
/>
<ApiNotice loading={loading} error={error} onRetry={() => void loadTab(activeTab)} />
{message ? <Alert title="操作完成" tone="success">{message}</Alert> : null}
<Tabs
value={activeTab}
onChange={(value) => {
setActiveTab(value);
if (rows[value].length === 0) {
void loadTab(value);
}
}}
tabs={numberLibraryTabs.map((tab) => ({
...tab,
label: `${tab.label}${totals[tab.value]}`,
content: (
<Panel
title={tab.label}
className="wide-panel"
aside={<Button size="sm" variant="outline" icon={<Icon type="reload" />} onClick={() => void loadTab(tab.value)}>刷新</Button>}
>
{tab.value === activeTab ? (
<Toolbar>
{renderFilters()}
</Toolbar>
) : null}
{renderTable(tab.value)}
</Panel>
),
}))}
/>
{importTarget ? (
<Modal title={`导入${importTitle}`} onClose={importing ? () => {} : () => setImportTarget(null)}>
<form className="modal-form" onSubmit={submitImport}>
<Field label="JSON 数据">
<Textarea rows={12} value={importText} onChange={(event) => setImportText(event.target.value)} />
</Field>
<div className="modal-actions">
<Button type="button" variant="outline" disabled={importing} onClick={() => setImportTarget(null)}>取消</Button>
<Button type="submit" disabled={importing}>{importing ? '导入中' : '确认导入'}</Button>
</div>
</form>
</Modal>
) : null}
</>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { useMemo, useState } from 'react';
import { Badge, Button, Field, Input, Select } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Drawer, SimpleTable, KeyValue } from '../components/layout.jsx';
import { operationLogRows } from '../fixtures/devFixtures.js';
export function OperationLogsPage({ logRows = operationLogRows, apiLoading, apiError, refreshApi }) {
const [keyword, setKeyword] = useState('');
const [moduleFilter, setModuleFilter] = useState('all');
const [resultFilter, setResultFilter] = useState('all');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [detailLog, setDetailLog] = useState(null);
const modules = useMemo(() => Array.from(new Set(logRows.map((log) => log.module))), [logRows]);
const visibleLogs = useMemo(() => {
const normalized = keyword.trim().toLowerCase();
return logRows.filter((log) => {
const matchesKeyword = !normalized || [log.user, log.username, log.action, log.object, log.ip].some((value) => value.toLowerCase().includes(normalized));
const logDate = log.time.slice(0, 10);
return matchesKeyword && (moduleFilter === 'all' || log.module === moduleFilter) && (resultFilter === 'all' || log.result === resultFilter) && (!startDate || logDate >= startDate) && (!endDate || logDate <= endDate);
});
}, [endDate, keyword, logRows, moduleFilter, resultFilter, startDate]);
const resetFilters = () => { setKeyword(''); setModuleFilter('all'); setResultFilter('all'); setStartDate(''); setEndDate(''); };
return (
<>
<PageTitle title="操作日志" desc="审计登录、配置变更、敏感操作及其执行结果。" actions={<Button variant="secondary" icon={<Icon type="export" />}>导出日志</Button>} />
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} />
<Toolbar>
<Field label="关键词"><Input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="用户、操作对象或 IP" /></Field>
<Field label="功能模块"><Select value={moduleFilter} onChange={(event) => setModuleFilter(event.target.value)}><option value="all">全部模块</option>{modules.map((module) => <option key={module} value={module}>{module}</option>)}</Select></Field>
<Field label="执行结果"><Select value={resultFilter} onChange={(event) => setResultFilter(event.target.value)}><option value="all">全部结果</option><option value="成功">成功</option><option value="失败">失败</option></Select></Field>
<Field label="开始日期"><Input type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} /></Field>
<Field label="结束日期"><Input type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} /></Field>
<Button variant="outline" onClick={resetFilters}>重置</Button>
</Toolbar>
<Panel title="日志列表" aside={<Badge tone="neutral"> {visibleLogs.length} </Badge>} className="wide-panel">
<SimpleTable rows={visibleLogs} columns={[
{ key: 'time', label: '操作时间' },
{ key: 'user', label: '操作用户' },
{ key: 'module', label: '功能模块' },
{ key: 'action', label: '操作类型' },
{ key: 'object', label: '操作对象' },
{ key: 'result', label: '结果', status: true },
{ key: 'ip', label: '操作 IP' },
{ key: 'actions', label: '操作', render: (row) => <Button size="sm" variant="outline" onClick={() => setDetailLog(row)}>查看详情</Button> },
]} />
</Panel>
{detailLog ? (
<Drawer title="操作日志详情" aside={<Badge tone={toneForStatus(detailLog.result)}>{detailLog.result}</Badge>} onClose={() => setDetailLog(null)}>
<div className="detail-stack">
<KeyValue label="日志 ID" value={detailLog.id} />
<KeyValue label="操作时间" value={detailLog.time} />
<KeyValue label="操作用户" value={`${detailLog.user}${detailLog.username}`} />
<KeyValue label="功能模块" value={detailLog.module} />
<KeyValue label="操作类型" value={detailLog.action} />
<KeyValue label="操作对象" value={detailLog.object} />
<KeyValue label="执行结果" value={detailLog.result} />
<KeyValue label="操作 IP" value={detailLog.ip} />
<KeyValue label="客户端" value={detailLog.userAgent} />
</div>
<section className="log-summary"><strong>操作摘要</strong><p>{detailLog.summary}</p></section>
</Drawer>
) : null}
</>
);
}
+418
View File
@@ -0,0 +1,418 @@
import { useEffect, useRef, useState } from 'react';
import { Alert, Badge, Button, Field, Input, Select, Textarea } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, Drawer, StatusBadge, SimpleTable, KeyValue } from '../components/layout.jsx';
import { reviewResultValue, normalizeQualityRule, normalizeRecording } from '../utils/formatters.js';
import { api, explainApiError } from '../api.js';
const emptySamplingRuleForm = { name: '', customerId: '', ratio: 5, lineGroupId: '', start: '', expiresAt: '', status: '启用' };
export function QualityPage({ customerRows = [], lineGroupRows = [], can = () => true }) {
const [ruleRows, setRuleRows] = useState([]);
const [recordingRows, setRecordingRows] = useState([]);
const [qualityLoading, setQualityLoading] = useState(false);
const [qualityError, setQualityError] = useState('');
const [recordingFilter, setRecordingFilter] = useState({ reviewStatus: 'all', limit: '100' });
const [showRules, setShowRules] = useState(false);
const [editingRule, setEditingRule] = useState(undefined);
const [ruleForm, setRuleForm] = useState(emptySamplingRuleForm);
const [deleteRuleTarget, setDeleteRuleTarget] = useState(null);
const [ruleBusy, setRuleBusy] = useState(false);
const [ruleError, setRuleError] = useState('');
const [detailRecording, setDetailRecording] = useState(null);
const [detailLoading, setDetailLoading] = useState(false);
const [reviewForm, setReviewForm] = useState({ result: '通过', issue: '', score: '90', issueTags: '' });
const [reviewSaving, setReviewSaving] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [autoPlay, setAutoPlay] = useState(false);
const [playbackProgress, setPlaybackProgress] = useState(0);
const [playbackUrl, setPlaybackUrl] = useState('');
const [playbackError, setPlaybackError] = useState('');
const [playbackLoading, setPlaybackLoading] = useState(false);
const [saveFeedback, setSaveFeedback] = useState('');
const audioRef = useRef(null);
const canManageQuality = can('quality.manage');
const canPlayRecordings = can('recordings.play');
const detailRecordingIndex = detailRecording ? recordingRows.findIndex((recording) => recording.id === detailRecording.id) : -1;
const closePlayback = () => {
setIsPlaying(false);
setPlaybackProgress(0);
setPlaybackError('');
setPlaybackLoading(false);
if (playbackUrl) {
URL.revokeObjectURL(playbackUrl);
setPlaybackUrl('');
}
};
const refreshQuality = async (nextFilter = recordingFilter) => {
setQualityLoading(true);
setQualityError('');
try {
const params = {
limit: nextFilter.limit,
reviewStatus: nextFilter.reviewStatus,
};
const [recordingList, ruleList] = await Promise.all([
api.recordings(params),
api.qualityRules(),
]);
setRecordingRows((recordingList || []).map(normalizeRecording));
setRuleRows((ruleList || []).map(normalizeQualityRule));
} catch (error) {
setQualityError(explainApiError(error));
} finally {
setQualityLoading(false);
}
};
useEffect(() => {
void refreshQuality();
}, []);
useEffect(() => () => {
if (playbackUrl) URL.revokeObjectURL(playbackUrl);
}, [playbackUrl]);
useEffect(() => {
if (!audioRef.current || !playbackUrl) return;
if (isPlaying) {
audioRef.current.play().catch(() => setIsPlaying(false));
} else {
audioRef.current.pause();
}
}, [isPlaying, playbackUrl]);
useEffect(() => {
if (!isPlaying || !detailRecording || playbackUrl) return undefined;
const timer = window.setInterval(() => setPlaybackProgress((progress) => Math.min(100, progress + 5)), 120);
return () => window.clearInterval(timer);
}, [detailRecording?.id, isPlaying, playbackUrl]);
useEffect(() => {
if (playbackUrl || playbackProgress < 100 || !detailRecording) return;
if (autoPlay && detailRecordingIndex < recordingRows.length - 1) {
const nextRecording = recordingRows[detailRecordingIndex + 1];
void openRecordingDetail(nextRecording, { keepAutoPlay: true, startPlayback: true });
return;
}
setIsPlaying(false);
}, [autoPlay, detailRecording, detailRecordingIndex, playbackProgress, playbackUrl, recordingRows]);
const openCreateRule = () => {
setEditingRule(null);
setRuleError('');
setRuleForm({ ...emptySamplingRuleForm, start: new Date().toISOString().slice(0, 10) });
};
const openEditRule = (rule) => {
setEditingRule(rule);
setRuleError('');
setRuleForm({
name: rule.name,
customerId: rule.customerId,
ratio: rule.ratio,
lineGroupId: rule.lineGroupId,
start: rule.start,
expiresAt: rule.expiresAt,
status: rule.status,
});
};
const closeRuleModal = () => {
setEditingRule(undefined);
setRuleForm(emptySamplingRuleForm);
setRuleError('');
};
const ruleBody = () => ({
name: ruleForm.name.trim(),
customerId: ruleForm.customerId || null,
lineGroupId: ruleForm.lineGroupId || null,
ratio: String(ruleForm.ratio),
status: ruleForm.status === '启用' ? 'ENABLED' : 'DISABLED',
effectiveAt: ruleForm.start ? new Date(`${ruleForm.start}T00:00:00`).toISOString() : undefined,
expiresAt: ruleForm.expiresAt ? new Date(`${ruleForm.expiresAt}T23:59:59`).toISOString() : null,
});
const submitRule = async (event) => {
event.preventDefault();
if (!ruleForm.name.trim()) return;
setRuleBusy(true);
setRuleError('');
try {
if (editingRule) {
await api.updateQualityRule(editingRule.id, ruleBody());
} else {
await api.createQualityRule(ruleBody());
}
await refreshQuality();
closeRuleModal();
} catch (error) {
setRuleError(explainApiError(error));
} finally {
setRuleBusy(false);
}
};
const toggleRuleStatus = async (rule) => {
setRuleBusy(true);
setRuleError('');
try {
if (rule.status === '启用') {
await api.disableQualityRule(rule.id);
} else {
await api.enableQualityRule(rule.id);
}
await refreshQuality();
} catch (error) {
setRuleError(explainApiError(error));
} finally {
setRuleBusy(false);
}
};
const deleteRule = async () => {
if (!deleteRuleTarget) return;
setRuleBusy(true);
setRuleError('');
try {
await api.deleteQualityRule(deleteRuleTarget.id);
await refreshQuality();
setDeleteRuleTarget(null);
} catch (error) {
setRuleError(explainApiError(error));
} finally {
setRuleBusy(false);
}
};
const setReviewDraft = (recording) => {
setReviewForm({
result: recording.result && recording.result !== '-' ? recording.result : '通过',
issue: recording.issue || '',
score: recording.score === '' || recording.score === null || recording.score === undefined ? '90' : String(recording.score),
issueTags: recording.issueTagsText || '',
});
};
const openRecordingDetail = async (recording, options = {}) => {
closePlayback();
setDetailRecording(recording);
setReviewDraft(recording);
setDetailLoading(true);
setIsPlaying(false);
setAutoPlay(Boolean(options.keepAutoPlay));
setPlaybackProgress(0);
setSaveFeedback('');
try {
const detail = normalizeRecording(await api.recordingDetail(recording.id));
setDetailRecording(detail);
setReviewDraft(detail);
setRecordingRows((rows) => rows.map((row) => (row.id === detail.id ? detail : row)));
if (options.startPlayback) {
await loadPlayback(detail);
}
} catch (error) {
setSaveFeedback(explainApiError(error));
} finally {
setDetailLoading(false);
}
};
const closeRecordingDetail = () => {
setDetailRecording(null);
closePlayback();
setSaveFeedback('');
};
const navigateRecording = (direction) => {
const nextIndex = detailRecordingIndex + direction;
if (nextIndex < 0 || nextIndex >= recordingRows.length) return;
const nextRecording = recordingRows[nextIndex];
void openRecordingDetail(nextRecording, { keepAutoPlay: autoPlay });
};
const loadPlayback = async (recording = detailRecording) => {
if (!recording || playbackLoading) return;
setPlaybackLoading(true);
setPlaybackError('');
try {
const blob = await api.recordingPlayback(recording.id);
if (playbackUrl) URL.revokeObjectURL(playbackUrl);
setPlaybackUrl(URL.createObjectURL(blob));
setIsPlaying(true);
setPlaybackProgress(0);
} catch (error) {
setPlaybackError(explainApiError(error));
} finally {
setPlaybackLoading(false);
}
};
const togglePlayback = async () => {
if (!playbackUrl) {
await loadPlayback();
return;
}
if (isPlaying) {
audioRef.current?.pause();
} else {
await audioRef.current?.play().catch(() => setPlaybackError('浏览器阻止了自动播放,请使用播放器控件开始试听。'));
}
setIsPlaying((playing) => !playing);
};
const handleAudioEnded = () => {
setIsPlaying(false);
setPlaybackProgress(100);
if (autoPlay && detailRecordingIndex < recordingRows.length - 1) {
const nextRecording = recordingRows[detailRecordingIndex + 1];
void openRecordingDetail(nextRecording, { keepAutoPlay: true, startPlayback: true });
}
};
const saveRecordingReview = async () => {
if (!detailRecording || reviewSaving) return;
setReviewSaving(true);
setSaveFeedback('');
try {
await api.saveRecordingReview(detailRecording.id, {
result: reviewResultValue(reviewForm.result),
score: reviewForm.score === '' ? null : Number(reviewForm.score),
notes: reviewForm.issue,
issueTags: reviewForm.issueTags.split(',').map((tag) => tag.trim()).filter(Boolean),
});
const [detail] = await Promise.all([
api.recordingDetail(detailRecording.id),
refreshQuality(),
]);
const normalized = normalizeRecording(detail);
setDetailRecording(normalized);
setReviewDraft(normalized);
setRecordingRows((rows) => rows.map((recording) => (recording.id === normalized.id ? normalized : recording)));
setSaveFeedback('质检结果已保存,并已刷新录音列表与详情。');
} catch (error) {
setSaveFeedback(explainApiError(error));
} finally {
setReviewSaving(false);
}
};
const changeRecordingFilter = (key, value) => {
const nextFilter = { ...recordingFilter, [key]: value };
setRecordingFilter(nextFilter);
void refreshQuality(nextFilter);
};
return (
<>
<PageTitle
title="质检中心"
desc="集中查看录音、完成试听、问题标注和人工质检评分。"
actions={<div className="table-actions"><Button icon={<Icon type="reload" />} disabled={qualityLoading} onClick={() => void refreshQuality()}>刷新</Button><Button variant="outline" onClick={() => setShowRules(true)}>抽检规则</Button></div>}
/>
<ApiNotice loading={qualityLoading} error={qualityError} onRetry={() => void refreshQuality()} />
<Toolbar>
<Field label="质检状态">
<Select value={recordingFilter.reviewStatus} onChange={(event) => changeRecordingFilter('reviewStatus', event.target.value)}>
<option value="all">全部</option>
<option value="PENDING">待质检</option>
<option value="REVIEWED">已完成</option>
</Select>
</Field>
<Field label="读取条数">
<Select value={recordingFilter.limit} onChange={(event) => changeRecordingFilter('limit', event.target.value)}>
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
<option value="500">500</option>
</Select>
</Field>
</Toolbar>
<Panel title="录音列表" aside={<Badge tone="neutral"> {recordingRows.length} 条录音</Badge>} className="wide-panel">
<SimpleTable rows={recordingRows} columns={[
{ key: 'callId', label: 'Call-ID' },
{ key: 'customer', label: '客户' },
{ key: 'caller', label: '主叫' },
{ key: 'callee', label: '被叫' },
{ key: 'business', label: '业务' },
{ key: 'time', label: '通话时间' },
{ key: 'duration', label: '时长' },
{ key: 'samplingText', label: '抽样状态', status: true },
{ key: 'review', label: '质检状态', status: true },
{ key: 'actions', label: '操作', render: (row) => <Button size="sm" variant="outline" onClick={() => void openRecordingDetail(row)}>录音详情</Button> },
]} />
</Panel>
{showRules ? (
<Drawer title="抽检规则" aside={<Badge tone="info">{ruleRows.length} 条规则</Badge>} onClose={() => setShowRules(false)}>
{ruleError ? <Alert title="规则操作失败" tone="warning">{ruleError}</Alert> : null}
<div className="drawer-toolbar">
<div><strong>规则管理</strong><span>按客户和线路设置录音抽检比例</span></div>
{canManageQuality ? <Button icon={<Icon type="plus" />} disabled={ruleBusy} onClick={openCreateRule}>新增抽检规则</Button> : null}
</div>
<SimpleTable rows={ruleRows} columns={[
{ key: 'name', label: '规则名称' },
{ key: 'customer', label: '客户' },
{ key: 'ratio', label: '抽检比例', render: (row) => `${row.ratio}%` },
{ key: 'route', label: '指定线路' },
{ key: 'start', label: '生效时间' },
{ key: 'status', label: '状态', status: true },
{ key: 'actions', label: '操作', render: (row) => <div className="table-actions">{canManageQuality ? <Button size="sm" variant="outline" disabled={ruleBusy} onClick={() => openEditRule(row)}>编辑</Button> : null}{canManageQuality ? <Button size="sm" variant={row.status === '启用' ? 'ghost' : 'secondary'} disabled={ruleBusy} onClick={() => void toggleRuleStatus(row)}>{row.status === '启用' ? '禁用' : '启用'}</Button> : null}{canManageQuality ? <Button size="sm" variant="danger" disabled={ruleBusy} onClick={() => setDeleteRuleTarget(row)}>删除</Button> : '-'}</div> },
]} />
</Drawer>
) : null}
{editingRule !== undefined ? (
<Modal title={editingRule ? '编辑抽检规则' : '新增抽检规则'} onClose={closeRuleModal}>
{ruleError ? <Alert title="规则保存失败" tone="warning">{ruleError}</Alert> : null}
<form className="modal-form" onSubmit={submitRule}>
<div className="form-grid admin-form-grid">
<Field label={<span>规则名称 <span className="required-star">*</span></span>}><Input value={ruleForm.name} onChange={(event) => setRuleForm({ ...ruleForm, name: event.target.value })} placeholder="请输入规则名称" required /></Field>
<Field label="客户"><Select value={ruleForm.customerId} onChange={(event) => setRuleForm({ ...ruleForm, customerId: event.target.value })}><option value="">全部客户</option>{customerRows.map((customer) => <option key={customer.id} value={customer.id}>{customer.name}</option>)}</Select></Field>
<Field label={<span>抽检比例% <span className="required-star">*</span></span>}><Input type="number" min="0" max="100" step="0.01" value={ruleForm.ratio} onChange={(event) => setRuleForm({ ...ruleForm, ratio: event.target.value })} required /></Field>
<Field label="指定线路"><Select value={ruleForm.lineGroupId} onChange={(event) => setRuleForm({ ...ruleForm, lineGroupId: event.target.value })}><option value="">全部线路</option>{lineGroupRows.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}</Select></Field>
<Field label="生效时间"><Input type="date" value={ruleForm.start} onChange={(event) => setRuleForm({ ...ruleForm, start: event.target.value })} /></Field>
<Field label="失效时间"><Input type="date" value={ruleForm.expiresAt} onChange={(event) => setRuleForm({ ...ruleForm, expiresAt: event.target.value })} /></Field>
<Field label="状态"><Select value={ruleForm.status} onChange={(event) => setRuleForm({ ...ruleForm, status: event.target.value })}><option value="启用">启用</option><option value="禁用">禁用</option></Select></Field>
</div>
<div className="modal-actions"><Button type="button" variant="outline" disabled={ruleBusy} onClick={closeRuleModal}>取消</Button><Button type="submit" disabled={ruleBusy}>{ruleBusy ? '保存中' : editingRule ? '保存修改' : '保存规则'}</Button></div>
</form>
</Modal>
) : null}
{deleteRuleTarget ? (
<ConfirmDialog
title="确认删除抽检规则"
confirmLabel="确认删除"
confirmVariant="danger"
onCancel={() => setDeleteRuleTarget(null)}
onConfirm={() => void deleteRule()}
busy={ruleBusy}
>
<p>确认删除规则{deleteRuleTarget.name}删除后该规则将不再参与后续录音抽检</p>
</ConfirmDialog>
) : null}
{detailRecording ? (
<Drawer title="录音详情" aside={<StatusBadge>{detailRecording.review}</StatusBadge>} onClose={closeRecordingDetail}>
{detailLoading ? <Alert title="正在读取录音详情">正在加载最新质检记录与抽样结果</Alert> : null}
<div className="drawer-toolbar">
<div><strong>录音导航</strong><span> {detailRecordingIndex + 1} {recordingRows.length} </span></div>
<div className="table-actions"><Button variant="outline" disabled={detailRecordingIndex <= 0} onClick={() => navigateRecording(-1)}>上一个</Button><Button variant="outline" disabled={detailRecordingIndex >= recordingRows.length - 1} onClick={() => navigateRecording(1)}>下一个</Button></div>
</div>
<div className="detail-stack">
<KeyValue label="Call-ID" value={detailRecording.callId} />
<KeyValue label="客户" value={detailRecording.customer} />
<KeyValue label="主叫号码" value={detailRecording.caller} />
<KeyValue label="被叫号码" value={detailRecording.callee} />
<KeyValue label="业务" value={detailRecording.business} />
<KeyValue label="通话时间" value={detailRecording.time} />
<KeyValue label="录音时长" value={detailRecording.duration} />
<KeyValue label="抽样状态" value={detailRecording.samplingText} />
<KeyValue label="命中规则" value={detailRecording.samplingRuleText} />
</div>
<div className="audio-bar"><span style={{ background: `linear-gradient(90deg, var(--selected) ${playbackProgress}%, #dfe3ec ${playbackProgress}%)` }} /> <strong>{detailRecording.file}</strong></div>
<div className="drawer-toolbar"><div><strong>录音试听</strong><span>{!canPlayRecordings ? '当前账号没有录音播放权限。' : playbackError || (isPlaying ? '正在播放真实录音流' : playbackUrl ? '已读取录音,可使用播放器控制。' : '用于人工抽检、申诉复核和服务质量核查。')}</span></div><div className="table-actions">{canPlayRecordings ? <Button variant="secondary" disabled={playbackLoading} onClick={() => void togglePlayback()}>{playbackLoading ? '读取中' : isPlaying ? '暂停播放' : playbackUrl ? '继续播放' : '播放录音'}</Button> : null}{canPlayRecordings ? <Button variant={autoPlay ? 'secondary' : 'outline'} onClick={() => setAutoPlay((enabled) => !enabled)}>自动播放{autoPlay ? '开' : '关'}</Button> : null}</div></div>
{playbackUrl ? <audio ref={audioRef} className="recording-player" src={playbackUrl} controls autoPlay={isPlaying} onEnded={handleAudioEnded} onPlay={() => setIsPlaying(true)} onPause={() => setIsPlaying(false)} onTimeUpdate={(event) => {
const audio = event.currentTarget;
if (Number.isFinite(audio.duration) && audio.duration > 0) {
setPlaybackProgress(Math.min(100, Math.round((audio.currentTime / audio.duration) * 100)));
}
}} /> : null}
{playbackError ? <Alert title="录音播放失败" tone="warning">{playbackError}</Alert> : null}
{canManageQuality ? <Field label="质检结果">
<Select value={reviewForm.result} onChange={(event) => setReviewForm({ ...reviewForm, result: event.target.value })}>
<option value="通过">通过</option>
<option value="有问题">有问题</option>
<option value="升级处理">升级处理</option>
</Select>
</Field> : null}
{canManageQuality ? <Field label="问题标签"><Input value={reviewForm.issueTags} onChange={(event) => setReviewForm({ ...reviewForm, issueTags: event.target.value })} placeholder="多个标签用逗号分隔" /></Field> : null}
{canManageQuality ? <Field label="问题标注"><Textarea rows="4" value={reviewForm.issue} onChange={(event) => setReviewForm({ ...reviewForm, issue: event.target.value })} placeholder="标注关键词、服务态度、合规风险" /></Field> : null}
{canManageQuality ? <Field label="评分"><Input type="number" min="0" max="100" value={reviewForm.score} onChange={(event) => setReviewForm({ ...reviewForm, score: event.target.value })} /></Field> : null}
{saveFeedback ? <Alert title={saveFeedback.includes('失败') || saveFeedback.includes('不可用') || saveFeedback.includes('failed') ? '操作提示' : '保存反馈'} tone={saveFeedback.includes('失败') || saveFeedback.includes('不可用') || saveFeedback.includes('failed') ? 'warning' : 'success'}>{saveFeedback}</Alert> : null}
<div className="drawer-actions"><Button variant="outline" onClick={closeRecordingDetail}>关闭</Button>{canManageQuality ? <Button disabled={reviewSaving} onClick={() => void saveRecordingReview()}>{reviewSaving ? '保存中' : '保存质检结果'}</Button> : null}</div>
</Drawer>
) : null}
</>
);
}
@@ -0,0 +1,56 @@
import { useState } from 'react';
import { Button, Field, Input, Select, Tabs } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, SimpleTable } from '../components/layout.jsx';
export function RechargeRecordsPage({ rechargeRows, apiLoading, apiError, refreshApi }) {
const [activeTab, setActiveTab] = useState('customer');
const visibleRows = rechargeRows.filter((row) => row.type === activeTab);
const ownerLabel = activeTab === 'customer' ? '客户' : '供应商';
const recordTable = (
<section className="content-grid">
<Panel title={`${ownerLabel}充值记录列表`} className="wide-panel">
<SimpleTable rows={visibleRows} columns={[
{ key: 'id', label: '记录 ID', width: '118px', className: 'table-cell-compact' },
{ key: 'owner', label: ownerLabel, width: '160px', className: 'table-cell-compact' },
{ key: 'amount', label: '充值金额' },
{ key: 'beforeBalance', label: '充值前余额' },
{ key: 'afterBalance', label: '充值后余额' },
{ key: 'remark', label: '备注' },
{ key: 'operator', label: '操作人' },
{ key: 'time', label: '充值时间' },
{ key: 'status', label: '状态', status: true },
]} />
</Panel>
</section>
);
return (
<>
<PageTitle
title="充值记录"
desc="记录客户和供应商充值金额、充值前后余额、备注、操作人和充值时间。"
actions={<Button icon={<Icon type="export" />} variant="outline">导出记录</Button>}
/>
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} />
<Toolbar>
<Field label={`${ownerLabel}名称`}><Input placeholder={`搜索${ownerLabel}名称`} /></Field>
<Field label="状态">
<Select defaultValue="all">
<option value="all">全部状态</option>
<option>成功</option>
<option>失败</option>
</Select>
</Field>
<Button icon={<Icon type="search" />}>查询</Button>
</Toolbar>
<Tabs
value={activeTab}
onChange={setActiveTab}
tabs={[
{ value: 'customer', label: '客户充值', content: recordTable },
{ value: 'vendor', label: '供应商充值', content: recordTable },
]}
/>
</>
);
}
+117
View File
@@ -0,0 +1,117 @@
import { useMemo, useState } from 'react';
import { Alert, Badge, Button, Checkbox, Field, Input, Select, Textarea } from '../components/ui.jsx';
import { Icon, PageTitle, Panel, ApiNotice, Modal, ConfirmDialog, Drawer, SimpleTable } from '../components/layout.jsx';
import { permissionGroups } from '../fixtures/devFixtures.js';
import { explainApiError } from '../api.js';
export function RolesPage({ roleRows, setRoleRows, userRows, apiLoading, apiError, refreshApi, can = () => true, onDeleteRole }) {
const [editingRole, setEditingRole] = useState(undefined);
const [roleForm, setRoleForm] = useState(emptyRoleForm);
const [permissionRole, setPermissionRole] = useState(null);
const [permissionDraft, setPermissionDraft] = useState([]);
const [deleteRoleTarget, setDeleteRoleTarget] = useState(null);
const [actionError, setActionError] = useState('');
const canManage = can('roles.manage');
const roleUserCounts = useMemo(() => userRows.reduce((counts, user) => ({ ...counts, [user.roleId]: (counts[user.roleId] || 0) + 1 }), {}), [userRows]);
const openCreateRole = () => { setEditingRole(null); setRoleForm(emptyRoleForm); };
const openEditRole = (role) => { setEditingRole(role); setRoleForm({ name: role.name, description: role.description, status: role.status }); };
const closeRoleModal = () => { setEditingRole(undefined); setRoleForm(emptyRoleForm); };
const submitRole = (event) => {
event.preventDefault();
if (!roleForm.name.trim()) return;
if (editingRole) {
setRoleRows((rows) => rows.map((role) => role.id === editingRole.id ? { ...role, ...roleForm } : role));
} else {
setRoleRows((rows) => [...rows, { id: `R${String(rows.length + 1).padStart(3, '0')}`, ...roleForm, builtIn: false, permissions: [] }]);
}
closeRoleModal();
};
const toggleRoleStatus = (role) => {
if (role.builtIn) return;
setRoleRows((rows) => rows.map((item) => item.id === role.id ? { ...item, status: item.status === '启用' ? '禁用' : '启用' } : item));
};
const deleteRole = async (role) => {
try {
setActionError('');
if (onDeleteRole) {
await onDeleteRole(role.id);
} else {
setRoleRows((rows) => rows.filter((item) => item.id !== role.id));
}
setDeleteRoleTarget(null);
} catch (error) {
setActionError(explainApiError(error));
}
};
const openPermissions = (role) => { setPermissionRole(role); setPermissionDraft(role.permissions); };
const togglePermission = (permissionKey) => {
setPermissionDraft((permissions) => permissions.includes(permissionKey) ? permissions.filter((key) => key !== permissionKey) : [...permissions, permissionKey]);
};
const togglePermissionGroup = (group) => {
const groupKeys = group.permissions.map((permission) => permission.key);
const allSelected = groupKeys.every((key) => permissionDraft.includes(key));
setPermissionDraft((permissions) => allSelected ? permissions.filter((key) => !groupKeys.includes(key)) : Array.from(new Set([...permissions, ...groupKeys])));
};
const savePermissions = () => {
setRoleRows((rows) => rows.map((role) => role.id === permissionRole.id ? { ...role, permissions: permissionDraft } : role));
setPermissionRole(null);
};
return (
<>
<PageTitle title="角色与权限" desc="按岗位定义菜单与操作权限,并查看角色关联用户。" actions={canManage ? <Button icon={<Icon type="plus" />} onClick={openCreateRole}>新增角色</Button> : null} />
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} />
{actionError ? <Alert title="操作失败" tone="danger">{actionError}</Alert> : null}
<Panel title="角色列表" aside={<Badge tone="neutral">{roleRows.length} 个角色</Badge>} className="wide-panel">
<SimpleTable rows={roleRows.map((role) => ({ ...role, type: role.builtIn ? '系统内置' : '自定义', userCount: role.userCount ?? roleUserCounts[role.id] ?? 0, permissionCount: role.permissions.length }))} columns={[
{ key: 'name', label: '角色名称' },
{ key: 'type', label: '类型' },
{ key: 'description', label: '角色说明' },
{ key: 'userCount', label: '用户数' },
{ key: 'permissionCount', label: '权限项' },
{ key: 'status', label: '状态', status: true },
{ key: 'actions', label: '操作', render: (row) => <div className="table-actions"><Button size="sm" variant="secondary" onClick={() => openPermissions(row)}>{canManage && !row.builtIn ? '配置权限' : '查看权限'}</Button>{canManage ? <Button size="sm" variant="outline" onClick={() => openEditRole(row)}>编辑</Button> : null}{canManage ? <Button size="sm" variant="ghost" disabled={row.builtIn} onClick={() => toggleRoleStatus(row)}>{row.status === '启用' ? '禁用' : '启用'}</Button> : null}{canManage ? <Button size="sm" variant="danger" disabled={row.builtIn} onClick={() => setDeleteRoleTarget(row)}>删除</Button> : null}</div> },
]} />
</Panel>
{editingRole !== undefined ? (
<Modal title={editingRole ? '编辑角色' : '新增角色'} onClose={closeRoleModal} size="sm">
<form className="modal-form" onSubmit={submitRole}>
<Field label={<span>角色名称 <span className="required-star">*</span></span>}><Input value={roleForm.name} onChange={(event) => setRoleForm({ ...roleForm, name: event.target.value })} placeholder="请输入角色名称" disabled={Boolean(editingRole?.builtIn)} required /></Field>
<Field label="角色说明"><Textarea rows="4" value={roleForm.description} onChange={(event) => setRoleForm({ ...roleForm, description: event.target.value })} placeholder="说明该角色的职责范围" /></Field>
<Field label="状态"><Select value={roleForm.status} onChange={(event) => setRoleForm({ ...roleForm, status: event.target.value })} disabled={Boolean(editingRole?.builtIn)}><option value="启用">启用</option><option value="禁用">禁用</option></Select></Field>
<div className="modal-actions"><Button type="button" variant="outline" onClick={closeRoleModal}>取消</Button><Button type="submit">保存角色</Button></div>
</form>
</Modal>
) : null}
{permissionRole ? (
<Drawer title={permissionRole.builtIn ? '查看角色权限' : '配置角色权限'} aside={<><Badge tone="info">{permissionRole.name}</Badge><span className="drawer-subtitle">已选择 {permissionDraft.length} 项权限</span></>} onClose={() => setPermissionRole(null)}>
{permissionRole.builtIn || !canManage ? <Alert title={permissionRole.builtIn ? '系统内置角色' : '只读权限'} tone="info">{permissionRole.builtIn ? '内置角色权限由系统维护,仅支持查看。' : '当前账号没有角色管理权限,仅支持查看。'}</Alert> : null}
<div className="permission-groups">
{permissionGroups.map((group) => {
const groupKeys = group.permissions.map((permission) => permission.key);
const selectedCount = groupKeys.filter((key) => permissionDraft.includes(key)).length;
return (
<section className="permission-group" key={group.name}>
<div className="permission-group-head"><div><strong>{group.name}</strong><span>{selectedCount}/{groupKeys.length} 已选择</span></div><Checkbox label="全选" checked={selectedCount === groupKeys.length} disabled={permissionRole.builtIn || !canManage} onChange={() => togglePermissionGroup(group)} /></div>
<div className="permission-options">{group.permissions.map((permission) => <Checkbox key={permission.key} label={permission.label} checked={permissionDraft.includes(permission.key)} disabled={permissionRole.builtIn || !canManage} onChange={() => togglePermission(permission.key)} />)}</div>
</section>
);
})}
</div>
<div className="drawer-actions"><Button variant="outline" onClick={() => setPermissionRole(null)}>关闭</Button>{permissionRole.builtIn || !canManage ? null : <Button onClick={savePermissions}>保存权限</Button>}</div>
</Drawer>
) : null}
{deleteRoleTarget ? (
<ConfirmDialog
title="删除角色确认"
confirmLabel="确认删除"
confirmVariant="danger"
onCancel={() => setDeleteRoleTarget(null)}
onConfirm={() => void deleteRole(deleteRoleTarget)}
>
<p>确认删除角色{deleteRoleTarget.name}如果仍有用户使用该角色系统会拒绝删除</p>
</ConfirmDialog>
) : null}
</>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { Badge, Button } from '../components/ui.jsx';
import { Icon, PageTitle, Panel, SimpleTable } from '../components/layout.jsx';
import { customers, gateways, customerGatewayPolicies, vendorGatewayPolicies, routeGroups, routeRules } from '../fixtures/devFixtures.js';
export function RoutesPage() {
return (
<>
<PageTitle title="线路与路由" desc="线路组、客户网关业务分流、供应商网关映射、失败重试策略、号码前缀和生效时段。" actions={<Button icon={<Icon type="reload" />}>模拟 dr_reload</Button>} />
<section className="content-grid">
<Panel title="线路组" className="wide-panel">
<SimpleTable rows={routeGroups} columns={[
{ key: 'id', label: '线路组 ID' },
{ key: 'name', label: '线路组名称' },
{ key: 'customers', label: '适用客户' },
{ key: 'gateways', label: '网关列表' },
{ key: 'strategy', label: '路由策略' },
{ key: 'retry', label: '失败重试策略' },
{ key: 'status', label: '状态', status: true },
]} />
</Panel>
<Panel title="路由规则" className="wide-panel" aside={<Badge tone="info">dr_rules / dialplan</Badge>}>
<SimpleTable rows={routeRules} columns={[
{ key: 'id', label: '规则 ID' },
{ key: 'customer', label: '客户' },
{ key: 'prefix', label: '号码前缀' },
{ key: 'routeGroup', label: '线路组' },
{ key: 'priority', label: '优先级' },
{ key: 'window', label: '生效时间' },
{ key: 'status', label: '状态', status: true },
{ key: 'remark', label: '备注' },
]} />
</Panel>
<Panel title="客户网关业务分流" className="wide-panel">
<SimpleTable rows={customerGatewayPolicies} columns={[
{ key: 'gateway', label: '客户网关' },
{ key: 'name', label: '策略名称' },
{ key: 'callerMatch', label: '主叫匹配', render: (row) => row.callerMode === 'any' ? '不限' : `${row.callerMode === 'equals' ? '等于' : '前缀'} ${row.callerValue}` },
{ key: 'calleeMatch', label: '被叫匹配', render: (row) => row.calleeMode === 'any' ? '不限' : `${row.calleeMode === 'equals' ? '等于' : '前缀'} ${row.calleeValue}` },
{ key: 'routeGroup', label: '呼叫至线路群组' },
{ key: 'priority', label: '优先级' },
{ key: 'status', label: '状态', status: true },
]} />
</Panel>
<Panel title="供应商网关业务匹配" className="wide-panel">
<SimpleTable rows={vendorGatewayPolicies} columns={[
{ key: 'vendor', label: '供应商' },
{ key: 'gateway', label: '供应商网关' },
{ key: 'caller', label: '主叫号码/号段' },
{ key: 'calleePrefix', label: '被叫前缀' },
{ key: 'business', label: '成本业务' },
{ key: 'vendorRate', label: '供应商费率' },
{ key: 'priority', label: '优先级' },
{ key: 'status', label: '状态', status: true },
]} />
</Panel>
</section>
</>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { Button, Field, Input, Select, Switch } from '../components/ui.jsx';
import { PageTitle, Panel } from '../components/layout.jsx';
export function SettingsPage() {
return (
<>
<PageTitle title="系统设置" desc="SIP 域名、监听地址、MI 连接、RTPEngine、录音存储和全局计费参数。" actions={<Button>保存配置</Button>} />
<section className="settings-grid">
<Panel title="SIP 与 MI">
<Field label="默认 SIP 域名"><Input defaultValue="voice.example.net" /></Field>
<Field label="监听地址"><Input defaultValue="udp:0.0.0.0:5060" /></Field>
<Field label="MI 连接配置"><Input defaultValue="http://10.0.2.11:8080/mi" /></Field>
</Panel>
<Panel title="媒体与录音">
<Field label="RTPEngine 节点"><Input defaultValue="udp:10.0.2.21:2223" /></Field>
<Field label="录音存储"><Input defaultValue="s3://softswitch-recordings" /></Field>
<Field label="录音保留策略"><Select defaultValue="180"><option value="30">30 </option><option value="90">90 </option><option value="180">180 </option></Select></Field>
</Panel>
<Panel title="全局策略">
<Switch label="启用预付费余额控制" checked readOnly />
<Switch label="启用页面试听录音" checked readOnly />
<Switch label="启用操作审计" checked readOnly />
<Field label="默认时区"><Input defaultValue="Asia/Shanghai" /></Field>
<Field label="时间格式"><Input defaultValue="YYYY-MM-DD HH:mm:ss" /></Field>
</Panel>
<Panel title="安全策略">
<Switch label="强制 MFA" checked={false} readOnly />
<Switch label="敏感操作二次确认" checked readOnly />
<Field label="会话超时"><Input defaultValue="30 分钟" /></Field>
<Field label="导出水印"><Input defaultValue="用户 + 时间 + IP" /></Field>
</Panel>
</section>
</>
);
}
const emptyUserForm = { username: '', name: '', phone: '', email: '', roleId: 'R002', status: '启用' };
const emptyRoleForm = { name: '', description: '', status: '启用' };
+25
View File
@@ -0,0 +1,25 @@
import { Badge, Button, Progress } from '../components/ui.jsx';
import { Icon, PageTitle, Panel, StatusBadge, SimpleTable } from '../components/layout.jsx';
import { sipAccounts, opsItems } from '../fixtures/devFixtures.js';
export function SipOpsPage() {
return (
<>
<PageTitle title="SIP 运维" desc="面向技术运维的在线注册、在线通话、SIP Trace、网关状态和 RTPEngine 状态原型。" actions={<Button icon={<Icon type="reload" />}>刷新 MI 状态</Button>} />
<section className="content-grid">
<Panel title="在线注册" className="wide-panel" aside={<Badge tone="info">usrloc</Badge>}>
<SimpleTable rows={sipAccounts} columns={[{ key: 'user', label: '账号' }, { key: 'domain', label: 'Domain' }, { key: 'register', label: '注册状态', status: true }, { key: 'contact', label: 'Contact' }, { key: 'expires', label: 'Expires' }]} />
</Panel>
<Panel title="运行检查">
{opsItems.slice(0, 3).map((item) => (
<div className="ops-row" key={item.name}><div><strong>{item.name}</strong><span>{item.target}</span></div><StatusBadge>{item.status}</StatusBadge></div>
))}
</Panel>
<Panel title="SIP Trace 摘要">
<pre className="trace-box">{'INVITE sip:13800138000@carrier.example SIP/2.0\n100 Trying\n183 Session Progress\n200 OK\nACK\nBYE\n200 OK'}</pre>
</Panel>
</section>
</>
);
}
+131
View File
@@ -0,0 +1,131 @@
import { useMemo, useState } from 'react';
import { Alert, Badge, Button, Field, Input, Select } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
import { explainApiError } from '../api.js';
export function UsersPage({ userRows, setUserRows, roleRows, apiLoading, apiError, refreshApi, can = () => true, onDeleteUser }) {
const [keyword, setKeyword] = useState('');
const [roleFilter, setRoleFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('all');
const [editingUser, setEditingUser] = useState(undefined);
const [userForm, setUserForm] = useState(emptyUserForm);
const [resetTarget, setResetTarget] = useState(null);
const [deleteUserTarget, setDeleteUserTarget] = useState(null);
const [resetMessage, setResetMessage] = useState('');
const [actionError, setActionError] = useState('');
const canManage = can('users.manage');
const roleMap = useMemo(() => new Map(roleRows.map((role) => [role.id, role.name])), [roleRows]);
const visibleUsers = useMemo(() => {
const normalized = keyword.trim().toLowerCase();
return userRows.filter((user) => {
const matchesKeyword = !normalized || [user.username, user.name, user.phone, user.email].some((value) => value.toLowerCase().includes(normalized));
return matchesKeyword && (roleFilter === 'all' || user.roleId === roleFilter) && (statusFilter === 'all' || user.status === statusFilter);
});
}, [keyword, roleFilter, statusFilter, userRows]);
const openCreateUser = () => {
setEditingUser(null);
setUserForm(emptyUserForm);
};
const openEditUser = (user) => {
setEditingUser(user);
setUserForm({ username: user.username, name: user.name, phone: user.phone, email: user.email, roleId: user.roleId, status: user.status });
};
const closeUserModal = () => {
setEditingUser(undefined);
setUserForm(emptyUserForm);
};
const submitUser = (event) => {
event.preventDefault();
if (!userForm.username.trim() || !userForm.name.trim() || !userForm.roleId) return;
if (editingUser) {
setUserRows((rows) => rows.map((user) => user.id === editingUser.id ? { ...user, ...userForm } : user));
} else {
const nextId = `U${String(1001 + userRows.length).padStart(4, '0')}`;
setUserRows((rows) => [...rows, { id: nextId, ...userForm, lastLogin: '从未登录', lastIp: '-' }]);
}
closeUserModal();
};
const toggleUserStatus = (user) => {
setUserRows((rows) => rows.map((item) => item.id === user.id ? { ...item, status: item.status === '启用' ? '禁用' : '启用' } : item));
};
const deleteUser = async (user) => {
try {
setActionError('');
if (onDeleteUser) {
await onDeleteUser(user.id);
} else {
setUserRows((rows) => rows.filter((item) => item.id !== user.id));
}
setDeleteUserTarget(null);
} catch (error) {
setActionError(explainApiError(error));
}
};
const confirmResetPassword = () => {
setResetMessage(`${resetTarget.name}${resetTarget.username})的密码已重置,下次登录需修改密码。`);
setResetTarget(null);
};
return (
<>
<PageTitle title="用户管理" desc="管理运营端登录用户、所属角色、账号状态和登录安全。" actions={canManage ? <Button icon={<Icon type="plus" />} onClick={openCreateUser}>新增用户</Button> : null} />
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} />
{resetMessage ? <Alert title="密码重置完成" tone="success">{resetMessage}</Alert> : null}
{actionError ? <Alert title="操作失败" tone="danger">{actionError}</Alert> : null}
<Toolbar>
<Field label="用户信息"><Input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="用户名、姓名、手机号或邮箱" /></Field>
<Field label="角色"><Select value={roleFilter} onChange={(event) => setRoleFilter(event.target.value)}><option value="all">全部角色</option>{roleRows.map((role) => <option key={role.id} value={role.id}>{role.name}</option>)}</Select></Field>
<Field label="状态"><Select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}><option value="all">全部状态</option><option value="启用">启用</option><option value="禁用">禁用</option></Select></Field>
<Button variant="outline" onClick={() => { setKeyword(''); setRoleFilter('all'); setStatusFilter('all'); }}>重置</Button>
</Toolbar>
<Panel title="用户列表" aside={<Badge tone="neutral"> {visibleUsers.length} 个用户</Badge>} className="wide-panel">
<SimpleTable rows={visibleUsers.map((user) => ({ ...user, roleName: roleMap.get(user.roleId) || '-' }))} columns={[
{ key: 'username', label: '用户名' },
{ key: 'name', label: '姓名' },
{ key: 'phone', label: '手机号' },
{ key: 'roleName', label: '角色' },
{ key: 'status', label: '状态', status: true },
{ key: 'lastLogin', label: '最后登录时间' },
{ key: 'lastIp', label: '最后登录 IP' },
{ key: 'actions', label: '操作', render: (row) => <div className="table-actions">{canManage ? <Button size="sm" variant="outline" onClick={() => openEditUser(row)}>编辑</Button> : null}{canManage ? <Button size="sm" variant={row.status === '启用' ? 'ghost' : 'secondary'} onClick={() => toggleUserStatus(row)}>{row.status === '启用' ? '禁用' : '启用'}</Button> : null}{canManage ? <Button size="sm" variant="outline" onClick={() => setResetTarget(row)}>重置密码</Button> : null}{canManage ? <Button size="sm" variant="danger" onClick={() => setDeleteUserTarget(row)}>删除</Button> : '-'}</div> },
]} />
</Panel>
{editingUser !== undefined ? (
<Modal title={editingUser ? '编辑用户' : '新增用户'} onClose={closeUserModal}>
<form className="modal-form" onSubmit={submitUser}>
<div className="form-grid admin-form-grid">
<Field label={<span>用户名 <span className="required-star">*</span></span>}><Input value={userForm.username} onChange={(event) => setUserForm({ ...userForm, username: event.target.value })} placeholder="用于登录,不可重复" disabled={Boolean(editingUser)} required /></Field>
<Field label={<span>姓名 <span className="required-star">*</span></span>}><Input value={userForm.name} onChange={(event) => setUserForm({ ...userForm, name: event.target.value })} placeholder="请输入用户姓名" required /></Field>
<Field label="手机号"><Input value={userForm.phone} onChange={(event) => setUserForm({ ...userForm, phone: event.target.value })} placeholder="请输入手机号" /></Field>
<Field label="邮箱"><Input type="email" value={userForm.email} onChange={(event) => setUserForm({ ...userForm, email: event.target.value })} placeholder="请输入邮箱" /></Field>
<Field label={<span>角色 <span className="required-star">*</span></span>}><Select value={userForm.roleId} onChange={(event) => setUserForm({ ...userForm, roleId: event.target.value })} required>{roleRows.filter((role) => role.status === '启用').map((role) => <option key={role.id} value={role.id}>{role.name}</option>)}</Select></Field>
<Field label="状态"><Select value={userForm.status} onChange={(event) => setUserForm({ ...userForm, status: event.target.value })}><option value="启用">启用</option><option value="禁用">禁用</option></Select></Field>
</div>
<div className="modal-actions"><Button type="button" variant="outline" onClick={closeUserModal}>取消</Button><Button type="submit">{editingUser ? '保存修改' : '保存用户'}</Button></div>
</form>
</Modal>
) : null}
{resetTarget ? (
<ConfirmDialog
title="确认重置密码"
confirmLabel="确认重置"
onCancel={() => setResetTarget(null)}
onConfirm={confirmResetPassword}
>
<p>确认重置用户{resetTarget.name}{resetTarget.username}的登录密码吗重置后该用户需要使用临时密码登录并立即修改密码</p>
</ConfirmDialog>
) : null}
{deleteUserTarget ? (
<ConfirmDialog
title="删除用户确认"
confirmLabel="确认删除"
confirmVariant="danger"
onCancel={() => setDeleteUserTarget(null)}
onConfirm={() => void deleteUser(deleteUserTarget)}
>
<p>确认删除用户{deleteUserTarget.name}{deleteUserTarget.username}删除后该用户将不能登录也不再出现在用户列表中</p>
</ConfirmDialog>
) : null}
</>
);
}
+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}
</>
);
}
+200
View File
@@ -0,0 +1,200 @@
import { useState } from 'react';
import { Badge, Button, Field, Input, Select } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, Drawer, SimpleTable } from '../components/layout.jsx';
import { formatDate, zhStatus } from '../utils/formatters.js';
import { gateways, vendorLineGroups } from '../fixtures/devFixtures.js';
import { explainApiError } from '../api.js';
export function VendorLineGroupsPage({ lineGroupRows: apiLineGroupRows, setLineGroupRows: setApiLineGroupRows, gatewayRows: apiGatewayRows, apiLoading, apiError, refreshApi, can = () => true, onDeleteLineGroup }) {
const [localLineGroupRows, setLocalLineGroupRows] = useState(vendorLineGroups);
const lineGroupRows = Array.isArray(apiLineGroupRows) ? apiLineGroupRows : localLineGroupRows;
const setLineGroupRows = setApiLineGroupRows || setLocalLineGroupRows;
const availableGateways = apiGatewayRows?.length ? apiGatewayRows : gateways;
const [actionError, setActionError] = useState('');
const [editingLineGroup, setEditingLineGroup] = useState(null);
const [deleteLineGroupTarget, setDeleteLineGroupTarget] = useState(null);
const [lineGroupForm, setLineGroupForm] = useState({ name: '', gatewayIds: [] });
const [showAddGatewayModal, setShowAddGatewayModal] = useState(false);
const [addGatewayForm, setAddGatewayForm] = useState({ gatewayId: '' });
const canManage = can('line_groups.manage');
const findGateway = (gatewayId) => availableGateways.find((gateway) => gateway.id === gatewayId);
const getLineGroupConcurrency = (group) => group.gatewayIds.reduce((total, gatewayId) => total + (findGateway(gatewayId)?.concurrencyLimit || 0), 0);
const openEditLineGroup = (group) => {
setEditingLineGroup(group);
setLineGroupForm({ name: group.name, gatewayIds: [...group.gatewayIds] });
setShowAddGatewayModal(false);
setAddGatewayForm({ gatewayId: '' });
};
const closeEditLineGroup = () => {
setEditingLineGroup(null);
setLineGroupForm({ name: '', gatewayIds: [] });
setShowAddGatewayModal(false);
setAddGatewayForm({ gatewayId: '' });
};
const addGatewayToLineGroup = () => {
if (!addGatewayForm.gatewayId || lineGroupForm.gatewayIds.includes(addGatewayForm.gatewayId)) return;
setLineGroupForm((form) => ({ ...form, gatewayIds: [...form.gatewayIds, addGatewayForm.gatewayId] }));
setAddGatewayForm({ gatewayId: '' });
setShowAddGatewayModal(false);
};
const removeGatewayFromLineGroup = (gatewayId) => {
setLineGroupForm((form) => ({ ...form, gatewayIds: form.gatewayIds.filter((id) => id !== gatewayId) }));
};
const moveLineGroupGateway = (gatewayId, direction) => {
setLineGroupForm((form) => {
const ids = [...form.gatewayIds];
const currentIndex = ids.indexOf(gatewayId);
const nextIndex = currentIndex + direction;
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= ids.length) return form;
[ids[currentIndex], ids[nextIndex]] = [ids[nextIndex], ids[currentIndex]];
return { ...form, gatewayIds: ids };
});
};
const submitLineGroup = (event) => {
event.preventDefault();
if (!editingLineGroup || !lineGroupForm.name.trim()) return;
setLineGroupRows((rows) => rows.map((group) => (
group.id === editingLineGroup.id ? { ...group, name: lineGroupForm.name.trim(), gatewayIds: lineGroupForm.gatewayIds } : group
)));
closeEditLineGroup();
};
const deleteLineGroup = async (group) => {
if ((group.customerGatewayCount ?? 0) > 0) {
setActionError(`线路组「${group.name}」仍被 ${group.customerGatewayCount} 个客户网关使用,不能删除。`);
return;
}
setActionError('');
try {
if (onDeleteLineGroup) {
await onDeleteLineGroup(group.id);
setDeleteLineGroupTarget(null);
return;
}
setLineGroupRows((rows) => rows.filter((item) => item.id !== group.id));
setDeleteLineGroupTarget(null);
} 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="线路组名称"><Input placeholder="搜索线路组名称" /></Field>
<Button icon={<Icon type="search" />}>查询</Button>
</Toolbar>
<section className="content-grid">
<Panel title="落地线路组列表" className="wide-panel">
<SimpleTable rows={lineGroupRows} columns={[
{ key: 'name', label: '名称' },
{ key: 'lineCount', label: '线路数量', render: (row) => row.gatewayIds.length },
{ key: 'customerGatewayCount', label: '使用客户网关数' },
{ key: 'concurrencyLimit', label: '并发上限', render: (row) => getLineGroupConcurrency(row) },
{
key: 'actions',
label: '操作',
render: (row) => (
<div className="table-actions">
{canManage ? <Button size="sm" variant="outline" onClick={() => openEditLineGroup(row)}>编辑</Button> : null}
{canManage ? <Button size="sm" variant="danger" onClick={() => setDeleteLineGroupTarget(row)}>删除</Button> : '-'}
</div>
),
},
]} />
</Panel>
</section>
{editingLineGroup ? (
<Drawer title="编辑落地线路组" aside={<Badge tone="info">{editingLineGroup.id}</Badge>} onClose={closeEditLineGroup}>
<form className="modal-form" onSubmit={submitLineGroup}>
<Field label={<span>线路组名称 <span className="required-star">*</span></span>}>
<Input value={lineGroupForm.name} onChange={(event) => setLineGroupForm({ ...lineGroupForm, name: event.target.value })} placeholder="请输入线路组名称" required />
</Field>
<div className="drawer-toolbar">
<div>
<strong>{lineGroupForm.gatewayIds.length} 条线路</strong>
<span>按优先级从小到大尝试落地网关</span>
</div>
<Button type="button" icon={<Icon type="plus" />} onClick={() => setShowAddGatewayModal(true)}>添加网关</Button>
</div>
<SimpleTable rows={lineGroupForm.gatewayIds.map((gatewayId, index) => {
const gateway = findGateway(gatewayId);
return {
id: gatewayId,
priority: index + 1,
vendor: gateway?.vendor || '-',
name: gateway?.name || gatewayId,
concurrencyLimit: gateway?.concurrencyLimit || 0,
};
})} columns={[
{ key: 'priority', label: '优先级' },
{ key: 'vendor', label: '供应商' },
{ key: 'name', label: '落地网关' },
{ key: 'concurrencyLimit', label: '并发上限' },
{
key: 'actions',
label: '操作',
render: (row) => (
<div className="table-actions">
<Button type="button" size="sm" variant="outline" onClick={() => moveLineGroupGateway(row.id, -1)}>上移</Button>
<Button type="button" size="sm" variant="outline" onClick={() => moveLineGroupGateway(row.id, 1)}>下移</Button>
<Button type="button" size="sm" variant="danger" onClick={() => removeGatewayFromLineGroup(row.id)}>删除</Button>
</div>
),
},
]} />
<div className="modal-actions">
<Button type="button" variant="outline" onClick={closeEditLineGroup}>取消</Button>
<Button type="submit">保存线路组</Button>
</div>
</form>
</Drawer>
) : null}
{showAddGatewayModal ? (
<Modal title="添加落地网关" onClose={() => setShowAddGatewayModal(false)} size="sm">
<form className="modal-form" onSubmit={(event) => { event.preventDefault(); addGatewayToLineGroup(); }}>
<Field label={<span>落地网关 <span className="required-star">*</span></span>}>
<Select value={addGatewayForm.gatewayId} onChange={(event) => setAddGatewayForm({ gatewayId: event.target.value })} required>
<option value="">请选择落地网关</option>
{availableGateways
.filter((gateway) => !lineGroupForm.gatewayIds.includes(gateway.id))
.map((gateway) => <option key={gateway.id} value={gateway.id}>{gateway.vendor} / {gateway.name}</option>)}
</Select>
</Field>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={() => setShowAddGatewayModal(false)}>取消</Button>
<Button type="submit">确认添加</Button>
</div>
</form>
</Modal>
) : null}
{deleteLineGroupTarget ? (
<ConfirmDialog
title="删除落地线路组确认"
confirmLabel="确认删除"
confirmVariant="danger"
onCancel={() => setDeleteLineGroupTarget(null)}
onConfirm={() => void deleteLineGroup(deleteLineGroupTarget)}
>
<p>确认删除落地线路组{deleteLineGroupTarget.name}</p>
</ConfirmDialog>
) : null}
</>
);
}
const emptyBusinessPrefixForm = { prefix: '', name: '', description: '', priority: 100, status: 'ENABLED' };
function normalizeBusinessPrefix(item) {
return {
id: item.id,
prefix: item.prefix,
name: item.name,
description: item.description || '-',
priority: item.priority ?? 100,
status: zhStatus(item.status),
gatewayCount: item.gatewayCount ?? 0,
createdAt: formatDate(item.createdAt),
};
}
+196
View File
@@ -0,0 +1,196 @@
import { useState } from 'react';
import { Button, Field, Input, Textarea } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable, KeyValue } from '../components/layout.jsx';
import { gateways } from '../fixtures/devFixtures.js';
import { explainApiError } from '../api.js';
export function VendorsPage({ vendorRows, setVendorRows, addRechargeRecord, apiLoading, apiError, refreshApi, can = () => true, onCreateVendor, onUpdateVendor, onDeleteVendor, onRechargeVendor }) {
const [showCreateVendor, setShowCreateVendor] = useState(false);
const [editingVendor, setEditingVendor] = useState(null);
const [rechargeVendor, setRechargeVendor] = useState(null);
const [deleteVendorTarget, setDeleteVendorTarget] = useState(null);
const [newVendor, setNewVendor] = useState({ name: '' });
const [editVendorForm, setEditVendorForm] = useState({ name: '' });
const [rechargeForm, setRechargeForm] = useState({ amount: '', remark: '' });
const [actionError, setActionError] = useState('');
const canManageVendors = can('vendors.manage');
const canManageRecharges = can('recharges.manage');
const parseMoney = (value) => Number(String(value).replace(/[^\d.-]/g, '')) || 0;
const formatMoney = (value) => `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const openEditVendor = (vendor) => {
setEditingVendor(vendor);
setEditVendorForm({ name: vendor.name || '' });
};
const closeEditVendor = () => {
setEditingVendor(null);
setEditVendorForm({ name: '' });
};
const openRechargeVendor = (vendor) => {
setRechargeVendor(vendor);
setRechargeForm({ amount: '', remark: '' });
};
const closeRechargeVendor = () => {
setRechargeVendor(null);
setRechargeForm({ amount: '', remark: '' });
};
const submitVendor = async (event) => {
event.preventDefault();
if (!newVendor.name.trim()) return;
setActionError('');
try {
if (onCreateVendor) {
await onCreateVendor({ name: newVendor.name.trim(), creditLimit: '0.000000' });
} else {
const nextIndex = vendorRows.length + 1;
setVendorRows((rows) => [...rows, { id: `V${String(2000 + nextIndex)}`, name: newVendor.name.trim(), balance: '¥0.00', credit: '¥0', gateways: 0, status: '启用', cycle: '待配置', ratePlan: '待配置', contact: '-', createdAt: '2026-06-19' }]);
}
setNewVendor({ name: '' });
setShowCreateVendor(false);
} catch (error) {
setActionError(explainApiError(error));
}
};
const submitEditVendor = async (event) => {
event.preventDefault();
if (!editVendorForm.name.trim() || !editingVendor) return;
setActionError('');
try {
if (onUpdateVendor) {
await onUpdateVendor(editingVendor.id, { name: editVendorForm.name.trim() });
} else {
setVendorRows((rows) => rows.map((vendor) => (
vendor.id === editingVendor.id ? { ...vendor, name: editVendorForm.name.trim() } : vendor
)));
}
closeEditVendor();
} catch (error) {
setActionError(explainApiError(error));
}
};
const submitRecharge = async (event) => {
event.preventDefault();
const amount = Number(rechargeForm.amount);
if (!rechargeVendor || !Number.isFinite(amount) || amount <= 0) return;
setActionError('');
try {
if (onRechargeVendor) {
await onRechargeVendor(rechargeVendor.id, { amount: amount.toFixed(2), remark: rechargeForm.remark.trim() || undefined });
} else {
const beforeBalance = parseMoney(rechargeVendor.balance);
const afterBalance = beforeBalance + amount;
setVendorRows((rows) => rows.map((vendor) => (
vendor.id === rechargeVendor.id ? { ...vendor, balance: formatMoney(afterBalance) } : vendor
)));
addRechargeRecord({ id: `RCG-V-${Date.now()}`, type: 'vendor', owner: rechargeVendor.name, amount: formatMoney(amount), beforeBalance: formatMoney(beforeBalance), afterBalance: formatMoney(afterBalance), remark: rechargeForm.remark.trim() || '-', operator: '运营管理员', time: new Date().toLocaleString('zh-CN', { hour12: false }), status: '成功' });
}
closeRechargeVendor();
} catch (error) {
setActionError(explainApiError(error));
}
};
const deleteVendor = async (vendor) => {
setActionError('');
try {
if (onDeleteVendor) {
await onDeleteVendor(vendor.id);
} else {
if ((vendor.gateways ?? 0) > 0) {
throw new Error('该供应商仍有关联落地网关,不能删除。');
}
setVendorRows((rows) => rows.filter((item) => item.id !== vendor.id));
}
setDeleteVendorTarget(null);
} catch (error) {
setActionError(explainApiError(error));
}
};
return (
<>
<PageTitle title="供应商管理" desc="管理供应商账户、余额、授信和充值记录。" actions={canManageVendors ? <Button icon={<Icon type="plus" />} onClick={() => setShowCreateVendor(true)}>新增供应商</Button> : null} />
<ApiNotice loading={apiLoading} error={apiError || actionError} onRetry={refreshApi} />
<Toolbar>
<Field label="供应商名称"><Input placeholder="搜索供应商名称" /></Field>
<Button icon={<Icon type="search" />}>查询</Button>
</Toolbar>
<section className="master-detail">
<Panel title="供应商列表" className="wide-panel">
<SimpleTable rows={vendorRows} columns={[
{ key: 'id', label: '供应商 ID', width: '112px', className: 'table-cell-compact' },
{ key: 'name', label: '名称', width: '168px', className: 'table-cell-compact' },
{ key: 'balance', label: '余额' },
{ key: 'credit', label: '授信额度' },
{ key: 'gateways', label: '落地网关数' },
{ key: 'status', label: '状态', status: true },
{
key: 'actions',
label: '操作',
render: (row) => (
<div className="table-actions">
{canManageVendors ? <Button size="sm" variant="outline" onClick={() => openEditVendor(row)}>编辑</Button> : null}
{canManageRecharges ? <Button size="sm" variant="secondary" onClick={() => openRechargeVendor(row)}>充值</Button> : null}
{canManageVendors ? <Button size="sm" variant="danger" onClick={() => setDeleteVendorTarget(row)}>删除</Button> : null}
{!canManageVendors && !canManageRecharges ? '-' : null}
</div>
),
},
]} />
</Panel>
</section>
{showCreateVendor ? (
<Modal title="新增供应商" onClose={() => setShowCreateVendor(false)} size="sm">
<form className="modal-form" onSubmit={submitVendor}>
<Field label={<span>供应商名称 <span className="required-star">*</span></span>}>
<Input value={newVendor.name} onChange={(event) => setNewVendor({ ...newVendor, name: event.target.value })} placeholder="请输入供应商名称" required />
</Field>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={() => setShowCreateVendor(false)}>取消</Button>
<Button type="submit">保存供应商</Button>
</div>
</form>
</Modal>
) : null}
{editingVendor ? (
<Modal title="编辑供应商" onClose={closeEditVendor} size="sm">
<form className="modal-form" onSubmit={submitEditVendor}>
<Field label={<span>供应商名称 <span className="required-star">*</span></span>}>
<Input value={editVendorForm.name} onChange={(event) => setEditVendorForm({ ...editVendorForm, name: event.target.value })} placeholder="请输入供应商名称" required />
</Field>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={closeEditVendor}>取消</Button>
<Button type="submit">保存修改</Button>
</div>
</form>
</Modal>
) : null}
{rechargeVendor ? (
<Modal title={`${rechargeVendor.name} 充值`} onClose={closeRechargeVendor} size="sm">
<form className="modal-form" onSubmit={submitRecharge}>
<KeyValue label="当前余额" value={rechargeVendor.balance} />
<Field label={<span>充值金额 <span className="required-star">*</span></span>}>
<Input type="number" min="0.01" step="0.01" value={rechargeForm.amount} onChange={(event) => setRechargeForm({ ...rechargeForm, amount: event.target.value })} placeholder="请输入充值金额" required />
</Field>
<Field label="备注">
<Textarea rows="4" value={rechargeForm.remark} onChange={(event) => setRechargeForm({ ...rechargeForm, remark: event.target.value })} placeholder="请输入备注" />
</Field>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={closeRechargeVendor}>取消</Button>
<Button type="submit">确认充值</Button>
</div>
</form>
</Modal>
) : null}
{deleteVendorTarget ? (
<ConfirmDialog
title="删除供应商确认"
confirmLabel="确认删除"
confirmVariant="danger"
onCancel={() => setDeleteVendorTarget(null)}
onConfirm={() => void deleteVendor(deleteVendorTarget)}
>
<p>确认删除供应商{deleteVendorTarget.name}删除后该供应商将不再出现在供应商列表中</p>
</ConfirmDialog>
) : null}
</>
);
}