feat: complete phase2 baseline cdr quality rbac
This commit is contained in:
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: '启用' };
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user