feat: complete phase2 baseline cdr quality rbac

This commit is contained in:
hectorzhao
2026-06-24 18:40:21 +08:00
parent 7057fd3c42
commit a86de6545f
63 changed files with 7853 additions and 3678 deletions
+271
View File
@@ -0,0 +1,271 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Field, Input, Select, Textarea } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
import { enStatus } from '../utils/formatters.js';
import { api, explainApiError } from '../api.js';
export function BusinessPrefixesPage({ can = () => true }) {
const [rows, setRows] = useState([]);
const [filters, setFilters] = useState({ keyword: '', status: 'all' });
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const [editingPrefix, setEditingPrefix] = useState(undefined);
const [prefixForm, setPrefixForm] = useState(emptyBusinessPrefixForm);
const [statusTarget, setStatusTarget] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [submitting, setSubmitting] = useState(false);
const canManage = can('customer_gateways.manage');
const loadPrefixes = async () => {
setLoading(true);
setError('');
try {
const payload = await api.businessPrefixes(filters);
setRows((Array.isArray(payload) ? payload : []).map(normalizeBusinessPrefix));
} catch (loadError) {
setError(explainApiError(loadError));
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadPrefixes();
}, []);
const openCreate = () => {
setEditingPrefix(null);
setPrefixForm(emptyBusinessPrefixForm);
setError('');
setMessage('');
};
const openEdit = (row) => {
setEditingPrefix(row);
setPrefixForm({
prefix: row.prefix,
name: row.name,
description: row.description === '-' ? '' : row.description,
priority: row.priority,
status: enStatus(row.status),
});
setError('');
setMessage('');
};
const closeForm = () => {
setEditingPrefix(undefined);
setPrefixForm(emptyBusinessPrefixForm);
setSubmitting(false);
};
const submitPrefix = async (event) => {
event.preventDefault();
setSubmitting(true);
setError('');
try {
const body = {
prefix: prefixForm.prefix.trim(),
name: prefixForm.name.trim(),
description: prefixForm.description.trim() || null,
priority: Number(prefixForm.priority),
status: prefixForm.status,
};
if (editingPrefix) {
await api.updateBusinessPrefix(editingPrefix.id, body);
setMessage(`业务前缀「${body.prefix}」已更新。`);
} else {
await api.createBusinessPrefix(body);
setMessage(`业务前缀「${body.prefix}」已创建。`);
}
closeForm();
await loadPrefixes();
} catch (submitError) {
setError(explainApiError(submitError));
} finally {
setSubmitting(false);
}
};
const toggleStatus = async (row) => {
setSubmitting(true);
setError('');
try {
if (row.status === '启用') {
await api.disableBusinessPrefix(row.id);
} else {
await api.enableBusinessPrefix(row.id);
}
setStatusTarget(null);
await loadPrefixes();
} catch (toggleError) {
setError(explainApiError(toggleError));
} finally {
setSubmitting(false);
}
};
const deletePrefix = async (row) => {
setSubmitting(true);
setError('');
try {
await api.deleteBusinessPrefix(row.id);
setDeleteTarget(null);
await loadPrefixes();
} catch (deleteError) {
setError(explainApiError(deleteError));
} finally {
setSubmitting(false);
}
};
const formOpen = editingPrefix !== undefined;
return (
<>
<PageTitle
title="业务前缀管理"
desc="维护客户呼入被叫号码前置的业务标识,后续客户网关按 IP、主叫规则和业务前缀识别归属。"
actions={canManage ? <Button icon={<Icon type="plus" />} onClick={openCreate}>新增业务前缀</Button> : null}
/>
<ApiNotice loading={loading} error={error} onRetry={loadPrefixes} />
{message ? <Alert title="操作完成" tone="success">{message}</Alert> : null}
<Panel
title="业务前缀"
className="wide-panel"
aside={<Button size="sm" variant="outline" icon={<Icon type="reload" />} onClick={loadPrefixes}>刷新</Button>}
>
<Toolbar>
<Field label="关键字">
<Input value={filters.keyword} onChange={(event) => setFilters({ ...filters, keyword: event.target.value })} placeholder="前缀或名称" />
</Field>
<Field label="状态">
<Select value={filters.status} onChange={(event) => setFilters({ ...filters, status: event.target.value })}>
<option value="all">全部</option>
<option value="ENABLED">启用</option>
<option value="DISABLED">停用</option>
</Select>
</Field>
<Button icon={<Icon type="search" />} onClick={loadPrefixes}>查询</Button>
</Toolbar>
<SimpleTable rows={rows} columns={[
{ key: 'prefix', label: '业务前缀', width: '120px' },
{ key: 'name', label: '名称', width: '160px' },
{ key: 'priority', label: '优先级', width: '90px' },
{ key: 'gatewayCount', label: '使用客户网关数', width: '140px' },
{ key: 'status', label: '状态', width: '90px', status: true },
{ key: 'description', label: '备注' },
{ key: 'createdAt', label: '创建日期', width: '120px' },
{
key: 'actions',
label: '操作',
width: '220px',
render: (row) => (
<div className="table-actions">
{canManage ? <Button size="sm" variant="outline" onClick={() => openEdit(row)}>编辑</Button> : null}
{canManage ? <Button size="sm" variant={row.status === '启用' ? 'ghost' : 'secondary'} onClick={() => setStatusTarget(row)}>{row.status === '启用' ? '禁用' : '启用'}</Button> : null}
{canManage ? <Button size="sm" variant="danger" onClick={() => setDeleteTarget(row)}>删除</Button> : '-'}
</div>
),
},
]} />
</Panel>
{formOpen ? (
<Modal title={editingPrefix ? '编辑业务前缀' : '新增业务前缀'} onClose={submitting ? () => {} : closeForm} size="sm">
<form className="modal-form" onSubmit={submitPrefix}>
<Field label={<span>业务前缀 <span className="required-star">*</span></span>}>
<Input value={prefixForm.prefix} onChange={(event) => setPrefixForm({ ...prefixForm, prefix: event.target.value })} placeholder="如 671" required />
</Field>
<Field label={<span>名称 <span className="required-star">*</span></span>}>
<Input value={prefixForm.name} onChange={(event) => setPrefixForm({ ...prefixForm, name: event.target.value })} placeholder="如 国内移动业务" required />
</Field>
<Field label="优先级">
<Input type="number" min="1" max="9999" value={prefixForm.priority} onChange={(event) => setPrefixForm({ ...prefixForm, priority: event.target.value })} />
</Field>
<Field label="状态">
<Select value={prefixForm.status} onChange={(event) => setPrefixForm({ ...prefixForm, status: event.target.value })}>
<option value="ENABLED">启用</option>
<option value="DISABLED">停用</option>
</Select>
</Field>
<Field label="备注">
<Textarea rows={3} value={prefixForm.description} onChange={(event) => setPrefixForm({ ...prefixForm, description: event.target.value })} />
</Field>
<div className="modal-actions">
<Button type="button" variant="outline" disabled={submitting} onClick={closeForm}>取消</Button>
<Button type="submit" disabled={submitting}>{submitting ? '保存中' : '保存'}</Button>
</div>
</form>
</Modal>
) : null}
{statusTarget ? (
<ConfirmDialog
title={`${statusTarget.status === '启用' ? '禁用' : '启用'}业务前缀确认`}
confirmLabel={`确认${statusTarget.status === '启用' ? '禁用' : '启用'}`}
busy={submitting}
onCancel={() => setStatusTarget(null)}
onConfirm={() => void toggleStatus(statusTarget)}
>
<p>确认{statusTarget.status === '启用' ? '禁用' : '启用'}业务前缀{statusTarget.prefix}</p>
</ConfirmDialog>
) : null}
{deleteTarget ? (
<ConfirmDialog
title="删除业务前缀确认"
confirmLabel="确认删除"
confirmVariant="danger"
busy={submitting}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => void deletePrefix(deleteTarget)}
>
<p>确认删除业务前缀{deleteTarget.prefix}删除后不能再被客户网关选择</p>
</ConfirmDialog>
) : null}
</>
);
}
const numberLibraryTabs = [
{ value: 'cities', label: '地级市字典' },
{ value: 'phoneSegments', label: '手机号码库' },
{ value: 'areaCodes', label: '城市区号' },
{ value: 'carrierPrefixRules', label: '运营商号码段规则' },
];
const numberLibraryImportExamples = {
cities: [
{ code: '340100', provinceCode: '340000', provinceName: '安徽省', cityName: '合肥市', cityLevel: 'PREFECTURE' },
],
phoneSegments: [
{ segment7: '1380013', provinceName: '北京市', cityCode: '110100', cityName: '北京市', carrier: 'MOBILE' },
],
areaCodes: [
{ areaCode: '0551', provinceName: '安徽省', cityCode: '340100', cityName: '合肥市' },
],
carrierPrefixRules: [
{ prefix: '138', carrier: 'MOBILE', priority: 100 },
],
};
const emptyNumberLibraryRows = {
cities: [],
phoneSegments: [],
areaCodes: [],
carrierPrefixRules: [],
};
const emptyNumberLibraryTotals = {
cities: 0,
phoneSegments: 0,
areaCodes: 0,
carrierPrefixRules: 0,
};
function normalizeNumberLibraryList(payload, mapItem) {
const items = Array.isArray(payload?.items) ? payload.items : [];
return {
rows: items.map(mapItem),
total: payload?.total ?? items.length,
};
}