feat: improve operations diagnostics and channel management

This commit is contained in:
hectorzhao
2026-08-09 14:27:19 +08:00
parent 44352aeb2f
commit 4724b9db6a
65 changed files with 1211 additions and 293 deletions
+70 -53
View File
@@ -4,14 +4,20 @@ import {
adminApi,
type SendQualityResponse,
type SignatureChannelCarrierQualityStat,
type SignatureChannelCarrierDrainageQualityStat,
type SignatureChannelQualityItem,
type SignatureChannelQualityResponse,
} from '@/api/adminApi';
import { Breadcrumb, Button, Chart, Input, Pagination, Table, Tag, type TableColumn } from '@/components/ui';
import { createBarOption, createPieOption } from '@/theme/chartOptions';
import { successRateClassName } from '@/utils/successRate';
const carrierOrder = ['mobile', 'unicom', 'telecom', 'unknown'];
const majorCarrierOrder = ['mobile', 'unicom', 'telecom'] as const;
const drainageStates = [
{ value: 'with', label: '含引流' },
{ value: 'without', label: '不含引流' },
{ value: 'unknown', label: '未检测' },
] as const;
const carrierLabels: Record<string, string> = {
mobile: '移动',
unicom: '联通',
@@ -308,7 +314,8 @@ function SignatureQualityDrawer({
return (leftRank < 0 ? carrierOrder.length : leftRank)
- (rightRank < 0 ? carrierOrder.length : rightRank);
});
const channels = [...new Map(item.breakdowns.map((entry) => [entry.channelId, entry.channelName])).entries()]
const channels = [...new Map([...item.breakdowns, ...item.drainageBreakdowns]
.map((entry) => [entry.channelId, entry.channelName])).entries()]
.map(([channelId, channelName]) => ({ channelId, channelName }));
const visibleCarriers = carrierOrder.filter((carrier) => item.breakdowns.some((entry) => normalizeCarrier(entry.carrier) === carrier));
@@ -330,7 +337,7 @@ function SignatureQualityDrawer({
<div className="signature-quality-overview">
<QualityMetric label="业务短信" value={item.total.toLocaleString('zh-CN')} />
<QualityMetric label="通道提交" value={item.channelSubmitTotal.toLocaleString('zh-CN')} />
<QualityMetric label="最终成功率" tone={rateTone(item.successRate)} value={`${item.successRate.toFixed(1)}%`} />
<QualityMetric label="最终成功率" value={`${item.successRate.toFixed(1)}%`} valueClassName={successRateClassName(item.successRate)} />
<QualityMetric label="平均到达时间" value={formatDuration(item.averageArrivalMs)} />
</div>
@@ -349,7 +356,7 @@ function SignatureQualityDrawer({
<strong>{carrier.businessMessageCount.toLocaleString('zh-CN')} </strong>
</div>
<dl>
<div><dt></dt><dd>{carrier.finalSuccessRate.toFixed(1)}%</dd></div>
<div><dt></dt><dd className={successRateClassName(carrier.finalSuccessRate)}>{carrier.finalSuccessRate.toFixed(1)}%</dd></div>
<div><dt></dt><dd>{formatDuration(carrier.averageArrivalMs)}</dd></div>
<div><dt></dt><dd>{carrier.channelCount} </dd></div>
</dl>
@@ -362,39 +369,59 @@ function SignatureQualityDrawer({
<div className="signature-quality-section__heading">
<div>
<h3> × </h3>
<p>{matrixMode === 'overall' ? '整体口径展示该组合全部真实提交。' : '引流切分口径分别展示含引流、不含引流和历史未检测数据。'}</p>
<p>{matrixMode === 'overall'
? '整体口径展示该组合全部真实提交;“—”表示所选日期没有真实提交。'
: '固定按含引流、不含引流、未检测三行及移动、联通、电信三列展示;没有真实提交的组合显示 0。'}</p>
</div>
<div className="page-actions"><Button onClick={() => setMatrixMode('overall')} size="sm" variant={matrixMode === 'overall' ? 'primary' : 'ghost'}></Button><Button onClick={() => setMatrixMode('drainage')} size="sm" variant={matrixMode === 'drainage' ? 'primary' : 'ghost'}></Button></div>
</div>
<div className="signature-quality-matrix">
<table>
<thead>
<tr>
<th></th>
{visibleCarriers.map((carrier) => <th key={carrier}>{carrierLabel(carrier)}</th>)}
</tr>
{matrixMode === 'overall' ? (
<tr>
<th></th>
{visibleCarriers.map((carrier) => <th key={carrier}>{carrierLabel(carrier)}</th>)}
</tr>
) : (
<tr>
<th></th>
<th></th>
{majorCarrierOrder.map((carrier) => <th key={carrier}>{carrierLabel(carrier)}</th>)}
</tr>
)}
</thead>
<tbody>
{channels.map((channel) => (
<tr key={channel.channelId}>
<th>{channel.channelName}</th>
{visibleCarriers.map((carrier) => {
const metric = item.breakdowns.find((entry) => (
entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier
));
const drainageMetrics = item.drainageBreakdowns.filter((entry) => (
entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier
));
return (
<td key={carrier}>
{matrixMode === 'overall'
? metric ? <MatrixMetric metric={metric} /> : <span className="signature-quality-matrix__empty"></span>
: drainageMetrics.length ? <DrainageMatrixMetrics metrics={drainageMetrics} /> : <span className="signature-quality-matrix__empty"></span>}
</td>
);
})}
</tr>
))}
{matrixMode === 'overall'
? channels.map((channel) => (
<tr key={channel.channelId}>
<th>{channel.channelName}</th>
{visibleCarriers.map((carrier) => {
const metric = item.breakdowns.find((entry) => (
entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier
));
return (
<td key={carrier}>
{metric ? <MatrixMetric metric={metric} /> : <span className="signature-quality-matrix__empty"></span>}
</td>
);
})}
</tr>
))
: channels.flatMap((channel) => drainageStates.map((state, stateIndex) => (
<tr key={`${channel.channelId}-${state.value}`}>
{stateIndex === 0 ? <th rowSpan={drainageStates.length}>{channel.channelName}</th> : null}
<th className="signature-quality-matrix__drainage-label">{state.label}</th>
{majorCarrierOrder.map((carrier) => {
const metric = item.drainageBreakdowns.find((entry) => (
entry.channelId === channel.channelId
&& normalizeCarrier(entry.carrier) === carrier
&& entry.drainageState === state.value
));
return <td key={carrier}><MatrixMetric metric={metric} zeroWhenEmpty /></td>;
})}
</tr>
)))}
</tbody>
</table>
</div>
@@ -409,41 +436,37 @@ function SignatureQualityDrawer({
);
}
function QualityMetric({ label, value, tone = 'default' }: { label: string; value: string; tone?: string }) {
function QualityMetric({ label, value, valueClassName }: { label: string; value: string; valueClassName?: string }) {
return (
<div className={`signature-quality-metric signature-quality-metric--${tone}`}>
<div className="signature-quality-metric">
<span>{label}</span>
<strong>{value}</strong>
<strong className={valueClassName}>{value}</strong>
</div>
);
}
function MatrixMetric({ metric }: { metric: SignatureChannelCarrierQualityStat }) {
function MatrixMetric({ metric, zeroWhenEmpty = false }: { metric?: SignatureChannelCarrierQualityStat; zeroWhenEmpty?: boolean }) {
const total = metric?.total ?? 0;
const successRate = metric?.successRate ?? 0;
if (zeroWhenEmpty && total === 0) return <span className="signature-quality-matrix__zero">0</span>;
return (
<div className="signature-quality-matrix__metric">
<strong>{metric.total.toLocaleString('zh-CN')} </strong>
<span className={`signature-quality-matrix__rate signature-quality-matrix__rate--${rateTone(metric.successRate)}`}>
{metric.successRate.toFixed(1)}%
<strong>{total.toLocaleString('zh-CN')} </strong>
<span className={`signature-quality-matrix__rate ${successRateClassName(successRate)}`}>
{successRate.toFixed(1)}%
</span>
<small>{formatDuration(metric.averageArrivalMs)}</small>
{metric.submitFailureCount > 0 ? <em> {metric.submitFailureCount}</em> : null}
<small>{formatDuration(metric?.averageArrivalMs)}</small>
{(metric?.submitFailureCount ?? 0) > 0 ? <em> {metric?.submitFailureCount}</em> : null}
</div>
);
}
function DrainageMatrixMetrics({ metrics }: { metrics: SignatureChannelCarrierDrainageQualityStat[] }) {
const labels = { with: '含引流', without: '不含引流', unknown: '未检测' };
return <div className="signature-quality-matrix__drainage">{(['with', 'without', 'unknown'] as const).map((state) => {
const metric = metrics.find((item) => item.drainageState === state);
return metric ? <div key={state}><b>{labels[state]}</b><MatrixMetric metric={metric} /></div> : null;
})}</div>;
}
function QualityRate({ value }: { value: number }) {
return (
<div className="signature-quality-rate">
<div><span style={{ width: `${Math.min(100, Math.max(0, value))}%` }} /></div>
<strong className={`signature-quality-rate--${rateTone(value)}`}>{value.toFixed(1)}%</strong>
<strong className={successRateClassName(value)}>{value.toFixed(1)}%</strong>
</div>
);
}
@@ -468,12 +491,6 @@ function carrierTagTone(value: string): 'info' | 'accent' | 'warning' | 'neutral
return 'neutral';
}
function rateTone(value: number) {
if (value >= 98) return 'success';
if (value >= 95) return 'warning';
return 'danger';
}
function formatDuration(value?: number | null) {
if (value == null) return '—';
if (value < 1000) return `${Math.round(value)} 毫秒`;
+46 -10
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Clock3, Layers3, Pencil, Plus, RadioTower, Search, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Breadcrumb, Button, Input, Modal, Pagination, Tag } from '@/components/ui';
import { adminApi, type ChannelGroup } from '@/api/adminApi';
import { adminApi, type ChannelGroup, type ChannelGroupDeletionImpact } from '@/api/adminApi';
type GroupCarrier = 'mobile' | 'unicom' | 'telecom';
@@ -35,6 +35,9 @@ export function AdminChannelGroupsPage() {
const [groupName, setGroupName] = useState('');
const [groups, setGroups] = useState<ChannelGroup[]>([]);
const [deleteTarget, setDeleteTarget] = useState<ChannelGroup | null>(null);
const [deletionImpact, setDeletionImpact] = useState<ChannelGroupDeletionImpact | null>(null);
const [deletionImpactLoading, setDeletionImpactLoading] = useState(false);
const [deleting, setDeleting] = useState(false);
const [page, setPage] = useState(1);
const [error, setError] = useState('');
const pageSize = 10;
@@ -61,14 +64,34 @@ export function AdminChannelGroupsPage() {
setPage(1);
}, [groupName, groups.length]);
function closeDeleteModal() {
if (deleting) return;
setDeleteTarget(null);
setDeletionImpact(null);
}
function openDeleteModal(group: ChannelGroup) {
setDeleteTarget(group);
setDeletionImpact(null);
setDeletionImpactLoading(true);
setError('');
adminApi.getChannelGroupDeletionImpact(group.id)
.then(setDeletionImpact)
.catch((failure: Error) => setError(failure.message || '删除影响数据加载失败'))
.finally(() => setDeletionImpactLoading(false));
}
function deleteGroup() {
if (!deleteTarget) return;
if (!deleteTarget || !deletionImpact || deleting) return;
setDeleting(true);
adminApi.deleteChannelGroup(deleteTarget.id)
.then(() => {
setDeleteTarget(null);
setDeletionImpact(null);
loadData();
})
.catch((failure: Error) => setError(failure.message || '通道组删除失败'));
.catch((failure: Error) => setError(failure.message || '通道组删除失败'))
.finally(() => setDeleting(false));
}
return (
@@ -144,7 +167,7 @@ export function AdminChannelGroupsPage() {
<button onClick={() => navigate(`/admin/channel-groups/${group.id}/edit`)} type="button">
<Pencil size={15} />
</button>
<button className="is-danger" onClick={() => setDeleteTarget(group)} type="button">
<button className="is-danger" onClick={() => openDeleteModal(group)} type="button">
<Trash2 size={15} />
</button>
</div>
@@ -167,17 +190,30 @@ export function AdminChannelGroupsPage() {
<Modal
footer={(
<>
<Button onClick={() => setDeleteTarget(null)} variant="ghost"></Button>
<Button onClick={deleteGroup} variant="danger"></Button>
<Button disabled={deleting} onClick={closeDeleteModal} variant="ghost"></Button>
<Button disabled={deletionImpactLoading || !deletionImpact || deleting} onClick={deleteGroup} variant="danger">
{deleting ? '删除中...' : '确认删除'}
</Button>
</>
)}
onClose={() => setDeleteTarget(null)}
onClose={closeDeleteModal}
open={Boolean(deleteTarget)}
title="删除通道组"
title={`删除通道组:${deleteTarget?.name ?? ''}`}
>
<div className="channel-confirm">
<strong>{deleteTarget?.name}</strong>
<p>使</p>
{deletionImpactLoading ? <span>...</span> : null}
{deletionImpact ? (
<>
<span>{deletionImpact.normalApplicationCount} </span>
<span>{deletionImpact.deletedApplicationCount} </span>
<span>{deletionImpact.channelCount} </span>
<span>{deletionImpact.pendingSupplierSubmitCount} </span>
<p>
<br />
</p>
</>
) : null}
</div>
</Modal>
</div>
+5 -4
View File
@@ -4,6 +4,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelReportField, type ClientSmsSignature, type DictionaryItem, type ReportRecord, type ReportTask } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
import { successRateClassName } from '@/utils/successRate';
import { ReportFieldMappingModal } from './ReportFieldMappingModal';
type ReportType = 'signature' | 'drainage';
@@ -58,10 +59,10 @@ function DeliveryStats({ task }: { task: ReportTask }) {
failureRate: 0,
};
return <div className="channel-report-stats">
<span><strong className="is-success">{stats.successRate}%</strong><b>{stats.successCount.toLocaleString('zh-CN')}</b></span>
<span><strong className="is-warning">{stats.unknownRate}%</strong><b>{stats.unknownCount.toLocaleString('zh-CN')}</b></span>
<span><strong className="is-danger">{stats.failureRate}%</strong><b>{stats.failureCount.toLocaleString('zh-CN')}</b></span>
<span><strong className="is-danger">{stats.submitFailureRate}%</strong><b>{stats.submitFailureCount.toLocaleString('zh-CN')}</b></span>
<span><strong className={successRateClassName(stats.successRate)}>{stats.successRate}%</strong><b>{stats.successCount.toLocaleString('zh-CN')}</b></span>
<span><strong>{stats.unknownRate}%</strong><b>{stats.unknownCount.toLocaleString('zh-CN')}</b></span>
<span><strong>{stats.failureRate}%</strong><b>{stats.failureCount.toLocaleString('zh-CN')}</b></span>
<span><strong>{stats.submitFailureRate}%</strong><b>{stats.submitFailureCount.toLocaleString('zh-CN')}</b></span>
</div>;
}
+30 -26
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ImagePlus } from 'lucide-react';
import { adminApi, type FileRef, type TenantOption } from '@/api/adminApi';
import { adminApi, type AdministrativeRegion, type FileRef, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, FileActions, Input, Select, Textarea } from '@/components/ui';
import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
@@ -23,26 +23,6 @@ type EnterpriseForm = {
type EnterpriseFormErrors = Partial<Record<keyof EnterpriseForm, string>>;
const provinceOptions = [
{ label: '请选择省/直辖市', value: '' },
...'北京,上海,广东,山东,河南,江苏,浙江,四川,重庆,湖北,湖南,陕西'.split(',').map((item) => ({ label: item, value: item })),
];
const cityOptionsByProvince: Record<string, Array<{ label: string; value: string }>> = {
: [{ label: '北京市', value: '北京市' }],
: [{ label: '上海市', value: '上海市' }],
广: ['广州市', '深圳市', '东莞市'].map((item) => ({ label: item, value: item })),
: ['济南市', '青岛市', '烟台市'].map((item) => ({ label: item, value: item })),
: ['郑州市', '洛阳市', '开封市'].map((item) => ({ label: item, value: item })),
: ['南京市', '苏州市', '无锡市'].map((item) => ({ label: item, value: item })),
: ['杭州市', '宁波市', '温州市'].map((item) => ({ label: item, value: item })),
: ['成都市', '绵阳市', '德阳市'].map((item) => ({ label: item, value: item })),
: [{ label: '重庆市', value: '重庆市' }],
: ['武汉市', '宜昌市', '襄阳市'].map((item) => ({ label: item, value: item })),
: ['长沙市', '株洲市', '湘潭市'].map((item) => ({ label: item, value: item })),
西: ['西安市', '咸阳市', '宝鸡市'].map((item) => ({ label: item, value: item })),
};
const emptyForm: EnterpriseForm = {
name: '',
creditCode: '',
@@ -87,6 +67,16 @@ export function AdminCustomerFormPage() {
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const [uploadingPhoto, setUploadingPhoto] = useState(false);
const [regions, setRegions] = useState<AdministrativeRegion[]>([]);
const [regionsLoading, setRegionsLoading] = useState(true);
const [regionError, setRegionError] = useState('');
useEffect(() => {
adminApi.listAdministrativeRegions()
.then((items) => { setRegions(items); setRegionError(''); })
.catch((failure: Error) => { setRegions([]); setRegionError(failure.message || '省市字典加载失败'); })
.finally(() => setRegionsLoading(false));
}, []);
useEffect(() => {
if (!enterpriseId) {
@@ -101,10 +91,23 @@ export function AdminCustomerFormPage() {
.catch((failure: Error) => setError(failure.message || '企业信息加载失败'));
}, [enterpriseId]);
const cityOptions = useMemo(() => [
{ label: '请选择市/区', value: '' },
...(cityOptionsByProvince[form.province] ?? []),
], [form.province]);
const provinceOptions = useMemo(() => {
const values = regions.map((item) => item.province);
if (form.province && !values.includes(form.province)) values.push(form.province);
return [
{ label: regionsLoading ? '正在加载省市字典...' : '请选择省/直辖市', value: '' },
...values.map((item) => ({ label: item, value: item })),
];
}, [form.province, regions, regionsLoading]);
const cityOptions = useMemo(() => {
const values = [...(regions.find((item) => item.province === form.province)?.cities ?? [])];
if (form.city && !values.includes(form.city)) values.push(form.city);
return [
{ label: form.province ? '请选择地市' : '请先选择省份', value: '' },
...values.map((item) => ({ label: item, value: item })),
];
}, [form.city, form.province, regions]);
function updateForm<K extends keyof EnterpriseForm>(key: K, value: EnterpriseForm[K]) {
setForm((current) => ({
@@ -178,6 +181,7 @@ export function AdminCustomerFormPage() {
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
{regionError ? <p className="form-error">{regionError}</p> : null}
<div className="surface enterprise-form-card">
<section className="ui-detail-section">
@@ -233,7 +237,7 @@ export function AdminCustomerFormPage() {
<div className="form-grid form-grid--two">
<Select label="省/直辖市" onChange={(event) => updateForm('province', event.target.value)} options={provinceOptions} value={form.province} />
<Select label="市/区" onChange={(event) => updateForm('city', event.target.value)} options={cityOptions} value={form.city} />
<Select disabled={!form.province || regionsLoading} label="市" onChange={(event) => updateForm('city', event.target.value)} options={cityOptions} value={form.city} />
</div>
<Textarea
+17 -1
View File
@@ -6,7 +6,7 @@ import { adminApi, type AdminChannel } from '@/api/adminApi';
const columns: Array<TableColumn<AdminChannel>> = [
{ key: 'id', title: '通道编号', render: (record) => record.id },
{ key: 'name', title: '通道名称', render: (record) => record.name },
{ key: 'carrier', title: '运营商', render: (record) => record.carrier ?? '-' },
{ key: 'carrier', title: '运营商', render: (record) => carrierLabel(record.carrier) },
{ key: 'gatewayHost', title: '网关地址', render: (record) => `${record.gatewayHost}:${record.gatewayPort}` },
{ key: 'rateLimitPerSecond', title: '限速', render: (record) => `${record.rateLimitPerSecond} 条/秒` },
{
@@ -16,6 +16,22 @@ const columns: Array<TableColumn<AdminChannel>> = [
},
];
const carrierLabels: Record<string, string> = {
mobile: '移动',
cmcc: '移动',
unicom: '联通',
cucc: '联通',
telecom: '电信',
ctcc: '电信',
all: '三网',
unknown: '未识别',
};
function carrierLabel(value?: string | null) {
if (!value) return '-';
return carrierLabels[value.trim().toLowerCase()] ?? value;
}
export function AdminMonitorPage() {
const [channels, setChannels] = useState<AdminChannel[]>([]);
const [monitor, setMonitor] = useState<Record<string, unknown>>({});
+14 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Download, Search } from 'lucide-react';
import { adminApi, type AdminChannel, type DailyProfitReport, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
import { adminApi, type AdminChannel, type DailyProfitReport, type EnterpriseApplication, type ProfitReportSummary, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
import { formatCents } from '@/utils/currency';
import { formatDateTime } from '@/utils/dateTime';
@@ -19,6 +19,7 @@ export function AdminProfitReportsPage() {
const [channelId, setChannelId] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [summary, setSummary] = useState<ProfitReportSummary>(emptyProfitSummary);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [exporting, setExporting] = useState(false);
@@ -37,9 +38,11 @@ export function AdminProfitReportsPage() {
const response = await adminApi.listProfitReports({ dateFrom: dateRange.start, dateTo: dateRange.end, dimensionType, tenantId: tenantId || undefined, applicationId: applicationId || undefined, channelId: channelId || undefined, page, pageSize });
setRows(response.items);
setTotal(response.total);
setSummary(response.summary);
} catch (failure) {
setRows([]);
setTotal(0);
setSummary(emptyProfitSummary);
setError(failure instanceof Error ? failure.message : '利润报表加载失败');
} finally {
setLoading(false);
@@ -71,6 +74,14 @@ export function AdminProfitReportsPage() {
<Button icon={<Search size={16} />} onClick={() => void loadData()}></Button>
</div>
<div className="surface admin-report-summary">
<div className="admin-report-summary__heading"><strong></strong><span></span></div>
<div className="admin-report-summary__grid">{[
['提交合计', summary.submittedUnits.toLocaleString('zh-CN')], ['发送合计', summary.sentUnits.toLocaleString('zh-CN')], ['未知合计', summary.unknownUnits.toLocaleString('zh-CN')], ['成功合计', summary.successUnits.toLocaleString('zh-CN')], ['失败合计', summary.failedUnits.toLocaleString('zh-CN')],
['净消费合计', `¥${formatCents(summary.revenueCents)}`], ['返还合计', `¥${formatCents(summary.refundCents)}`], ['成本合计', `¥${formatCents(summary.costCents)}`], ['利润合计', `¥${formatCents(summary.profitCents)}`], ['综合利润率', `${(summary.profitRateBps / 100).toFixed(2)}%`],
].map(([label, value]) => <div key={label}><span>{label}</span><strong>{value}</strong></div>)}</div>
</div>
<div className="surface">
<div className="ui-table-wrap">
<table className="ui-table">
@@ -89,6 +100,8 @@ export function AdminProfitReportsPage() {
);
}
const emptyProfitSummary: ProfitReportSummary = { submittedUnits: 0, sentUnits: 0, unknownUnits: 0, successUnits: 0, failedUnits: 0, revenueCents: 0, refundCents: 0, costCents: 0, profitCents: 0, profitRateBps: 0 };
function defaultDateRange(): DateRangeValue {
const end = new Date();
end.setDate(end.getDate() - 1);
+12 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Download, Search } from 'lucide-react';
import { adminApi, type AdminChannel, type DailyQualityReport, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
import { adminApi, type AdminChannel, type DailyQualityReport, type EnterpriseApplication, type QualityReportSummary, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Pagination, Select, Tabs, Tag, type DateRangeValue } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
@@ -22,6 +22,7 @@ export function AdminQualityReportsPage() {
const [channelId, setChannelId] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [summary, setSummary] = useState<QualityReportSummary>(emptyQualitySummary);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [exporting, setExporting] = useState(false);
@@ -46,9 +47,11 @@ export function AdminQualityReportsPage() {
});
setRows(response.items);
setTotal(response.total);
setSummary(response.summary);
} catch (failure) {
setRows([]);
setTotal(0);
setSummary(emptyQualitySummary);
setError(failure instanceof Error ? failure.message : '发送质量报表加载失败');
} finally {
setLoading(false);
@@ -78,6 +81,12 @@ export function AdminQualityReportsPage() {
{dimension === 'channel' ? <div /> : <Select label="企业应用" onChange={(event) => { setApplicationId(event.target.value); setPage(1); }} options={[{ label: '全部应用', value: '' }, ...availableApplications.map((application) => ({ label: application.name, value: application.id }))]} value={applicationId} />}
<Button icon={<Search size={16} />} onClick={() => void loadData()}></Button>
</div>
<div className="surface admin-report-summary">
<div className="admin-report-summary__heading"><strong></strong><span></span></div>
<div className="admin-report-summary__grid">{[
['提交合计', summary.submittedUnits.toLocaleString('zh-CN')], ['发送合计', summary.sentUnits.toLocaleString('zh-CN')], ['未知合计', summary.unknownUnits.toLocaleString('zh-CN')], ['成功合计', summary.successUnits.toLocaleString('zh-CN')], ['失败合计', summary.failedUnits.toLocaleString('zh-CN')], ['综合成功率', `${(summary.successRateBps / 100).toFixed(2)}%`],
].map(([label, value]) => <div key={label}><span>{label}</span><strong>{value}</strong></div>)}</div>
</div>
<div className="surface">
<div className="ui-table-wrap">
<table className="ui-table">
@@ -103,6 +112,8 @@ export function AdminQualityReportsPage() {
);
}
const emptyQualitySummary: QualityReportSummary = { submittedUnits: 0, sentUnits: 0, unknownUnits: 0, successUnits: 0, failedUnits: 0, successRateBps: 0 };
function formatDuration(milliseconds?: number | null) {
if (milliseconds === null || milliseconds === undefined) return '-';
if (milliseconds < 1000) return `${milliseconds} 毫秒`;
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Download, Search } from 'lucide-react';
import { adminApi, type DailyReconciliationReport, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
import { adminApi, type DailyReconciliationReport, type EnterpriseApplication, type ReconciliationReportSummary, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
@@ -15,6 +15,7 @@ export function AdminReconciliationReportsPage() {
const [applicationId, setApplicationId] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [summary, setSummary] = useState<ReconciliationReportSummary>(emptyVolumeSummary);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [exporting, setExporting] = useState(false);
@@ -41,9 +42,11 @@ export function AdminReconciliationReportsPage() {
});
setRows(response.items);
setTotal(response.total);
setSummary(response.summary);
} catch (failure) {
setRows([]);
setTotal(0);
setSummary(emptyVolumeSummary);
setError(failure instanceof Error ? failure.message : '对账单加载失败');
} finally {
setLoading(false);
@@ -77,6 +80,8 @@ export function AdminReconciliationReportsPage() {
<Button icon={<Search size={16} />} onClick={() => void loadData()}></Button>
</div>
<ReportVolumeSummaryView summary={summary} />
<div className="surface">
<div className="ui-table-wrap">
<table className="ui-table">
@@ -95,6 +100,14 @@ export function AdminReconciliationReportsPage() {
);
}
const emptyVolumeSummary: ReconciliationReportSummary = { submittedUnits: 0, sentUnits: 0, unknownUnits: 0, successUnits: 0, failedUnits: 0 };
function ReportVolumeSummaryView({ summary }: { summary: ReconciliationReportSummary }) {
return <div className="surface admin-report-summary"><div className="admin-report-summary__heading"><strong></strong><span></span></div><div className="admin-report-summary__grid">{[
['提交合计', summary.submittedUnits], ['发送合计', summary.sentUnits], ['未知合计', summary.unknownUnits], ['成功合计', summary.successUnits], ['失败合计', summary.failedUnits],
].map(([label, value]) => <div key={String(label)}><span>{label}</span><strong>{Number(value).toLocaleString('zh-CN')}</strong></div>)}</div></div>;
}
function defaultDateRange(): DateRangeValue {
const end = new Date();
end.setDate(end.getDate() - 1);
+2 -2
View File
@@ -249,12 +249,12 @@ export function AdminReportMaterialsPage() {
value={activeTab}
items={[
{
label: `待生成资料${pendingData.total}`,
label: '待生成资料',
value: 'pending',
content: <div className="page-stack">{filter}<div className="surface"><label className="table-actions"><input checked={allSelected} onChange={() => setSelected(allSelected ? new Set() : new Set(eligibleItems.map((item) => item.id)))} type="checkbox" /></label><Table columns={pendingColumns} data={pendingData.items} emptyText="暂无符合条件的待生成资料" pagination={false} rowKey="id" /></div><Pagination nextDisabled={pendingPage * pageSize >= pendingData.total} onNext={() => setPendingPage((page) => page + 1)} onPageChange={setPendingPage} onPrevious={() => setPendingPage((page) => Math.max(1, page - 1))} page={pendingPage} previousDisabled={pendingPage <= 1} total={pendingData.total} totalPages={Math.max(1, Math.ceil(pendingData.total / pageSize))} /></div>,
},
{
label: `已生成批次${batchData.total}`,
label: '已生成批次',
value: 'batches',
content: <div className="page-stack">{filter}<div className="surface"><Table columns={batchColumns} data={batchData.items} emptyText="尚未生成报备批次" pagination={false} rowKey="id" /></div><Pagination nextDisabled={batchPage * pageSize >= batchData.total} onNext={() => setBatchPage((page) => page + 1)} onPageChange={setBatchPage} onPrevious={() => setBatchPage((page) => Math.max(1, page - 1))} page={batchPage} previousDisabled={batchPage <= 1} total={batchData.total} totalPages={Math.max(1, Math.ceil(batchData.total / pageSize))} /></div>,
},
+1 -1
View File
@@ -318,7 +318,7 @@ export function AdminRiskRulesPage() {
return (
<section className="page-stack">
<div className="page-heading">
<div><Breadcrumb items={['审核中心', '风控规则']} /><h1></h1><p></p></div>
<div><Breadcrumb items={['安全控制', '风控规则']} /><h1></h1><p></p></div>
<div className="page-heading__actions">
<Button icon={<RefreshCw size={16} />} onClick={load} variant="ghost"></Button>
<Button icon={<Plus size={16} />} onClick={() => setEditor(editorFromRule())}></Button>
+19 -8
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { adminApi, type SmsMessageRecord, type SmsMessageSegmentAudit } from '@/api/adminApi';
import { adminApi, type AdminChannel, type SmsMessageRecord, type SmsMessageSegmentAudit } from '@/api/adminApi';
import { Breadcrumb, type DateRangeValue } from '@/components/ui';
import { SendDetailModal } from './sms-records/SendDetailModal';
import { SmsRecordFilter } from './sms-records/SmsRecordFilter';
@@ -17,7 +17,7 @@ export function AdminSmsRecordsPage() {
const [dateRange, setDateRange] = useState<DateRangeValue>(defaultSmsRecordDateRange);
const [phoneKeyword, setPhoneKeyword] = useState('');
const [contentKeyword, setContentKeyword] = useState('');
const [channelKeyword, setChannelKeyword] = useState('');
const [channel, setChannel] = useState('all');
const [carrier, setCarrier] = useState('all');
const [status, setStatus] = useState('all');
const [hasDrainage, setHasDrainage] = useState('all');
@@ -30,6 +30,7 @@ export function AdminSmsRecordsPage() {
const [loading, setLoading] = useState(false);
const [filterTenants, setFilterTenants] = useState<TenantOption[]>([]);
const [filterApplications, setFilterApplications] = useState<ApplicationOption[]>([]);
const [filterChannels, setFilterChannels] = useState<AdminChannel[]>([]);
function currentFilters(): MessageFilters {
return {
@@ -37,7 +38,7 @@ export function AdminSmsRecordsPage() {
applicationId: application === 'all' ? undefined : application,
phoneNumber: phoneKeyword || undefined,
contentKeyword: contentKeyword || undefined,
channelKeyword: channelKeyword || undefined,
channelId: channel === 'all' ? undefined : channel,
carrier: carrier === 'all' ? undefined : carrier,
queuedAtFrom: dateRange.start,
queuedAtTo: dateRange.end,
@@ -64,14 +65,15 @@ export function AdminSmsRecordsPage() {
}, [page]);
useEffect(() => {
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplicationOptions()])
.then(([tenants, applications]) => {
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplicationOptions(), adminApi.listChannels()])
.then(([tenants, applications, channels]) => {
setFilterTenants(tenants
.filter((item) => item.status !== 'deleted')
.map((item) => ({ id: item.id, name: item.name })));
setFilterApplications(applications
.filter((item) => item.status !== 'deleted')
.map((item) => ({ id: item.id, tenantId: item.tenantId, name: item.name })));
setFilterChannels(channels.filter((item) => item.status !== 'deleted'));
})
.catch((failure: Error) => setError(failure.message || '短信记录筛选项加载失败'));
}, []);
@@ -103,6 +105,14 @@ export function AdminSmsRecordsPage() {
[enterprise, filterApplications],
);
const channelOptions = useMemo(
() => [{ label: '全部通道', value: 'all' }, ...filterChannels.map((item) => ({
label: item.code ? `${item.name}${item.code}` : item.name,
value: item.id,
}))],
[filterChannels],
);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(page, totalPages);
@@ -113,7 +123,7 @@ export function AdminSmsRecordsPage() {
setDateRange(defaultDateRange);
setPhoneKeyword('');
setContentKeyword('');
setChannelKeyword('');
setChannel('all');
setCarrier('all');
setStatus('all');
setHasDrainage('all');
@@ -149,7 +159,8 @@ export function AdminSmsRecordsPage() {
application={application}
applicationOptions={applicationOptions}
carrier={carrier}
channelKeyword={channelKeyword}
channel={channel}
channelOptions={channelOptions}
contentKeyword={contentKeyword}
dateRange={dateRange}
enterprise={enterprise}
@@ -159,7 +170,7 @@ export function AdminSmsRecordsPage() {
status={status}
onApplicationChange={setApplication}
onCarrierChange={setCarrier}
onChannelKeywordChange={setChannelKeyword}
onChannelChange={setChannel}
onContentKeywordChange={setContentKeyword}
onDateRangeChange={setDateRange}
onEnterpriseChange={(value) => {
+30 -3
View File
@@ -33,6 +33,7 @@ export function AdminSystemLogsPage() {
const [modules, setModules] = useState<string[]>([]);
const [total, setTotal] = useState(0);
const [error, setError] = useState('');
const [operationDetail, setOperationDetail] = useState<OperationLogItem | null>(null);
useEffect(() => {
adminApi.listSystemLogs({ ...filters, page, pageSize })
@@ -87,6 +88,9 @@ export function AdminSystemLogsPage() {
<strong>{record.action}</strong>
<span>{JSON.stringify(record.detail)}</span>
<small>{record.resourceId}</small>
{record.action === 'cmpp_connection.connect_requested'
? <Button onClick={() => setOperationDetail(record)} size="sm" variant="ghost"></Button>
: null}
</div>
),
},
@@ -155,10 +159,33 @@ export function AdminSystemLogsPage() {
onChange={setActiveTab}
value={activeTab}
/>
<Modal footer={<Button onClick={() => setOperationDetail(null)}></Button>} onClose={() => setOperationDetail(null)} open={Boolean(operationDetail)} title="CMPP连接请求详情">
{operationDetail ? <OperationLogDetail record={operationDetail} /> : null}
</Modal>
</section>
);
}
function OperationLogDetail({ record }: { record: OperationLogItem }) {
const detail = record.detail ?? {};
const request = detail.request && typeof detail.request === 'object' && !Array.isArray(detail.request)
? detail.request as Record<string, unknown>
: {};
const values = ([
['请求IP地址', request.remoteIp ?? record.ip],
['账号(Source_Addr', request.account],
['密码', request.password ?? '标准CMPP连接不传明文密码'],
['AuthenticatorSource', request.authSource],
['时间戳', request.timestamp],
['协议版本', request.version],
['原始版本值', request.requestedVersion],
['处理结果', detail.result],
['失败原因', detail.error],
['应用ID', detail.applicationId],
] as Array<[string, unknown]>).filter(([, value]) => value !== null && value !== undefined && value !== '');
return <dl className="protocol-log-detail">{values.map(([label, value]) => <div key={String(label)}><dt>{label}</dt><dd>{String(value)}</dd></div>)}</dl>;
}
const directionLabels: Record<ProtocolInteractionLogItem['direction'], string> = {
client_to_platform: '企业应用 → 平台',
platform_to_channel: '平台 → 供应商通道',
@@ -232,7 +259,7 @@ function ProtocolInteractionPanel({ active }: { active: boolean }) {
{ key: 'direction', title: '方向', width: '150px', render: (record) => directionLabels[record.direction] },
{ key: 'eventType', title: '协议报文', width: '190px', render: (record) => <strong>{protocolEventLabels[record.eventType] ?? record.eventType}</strong> },
{ key: 'messageId', title: '消息标识', width: '220px', render: (record) => <div className="protocol-log-identifiers"><span>{record.messageId || '-'}</span><small>{record.gatewayMessageId || record.requestId || ''}</small></div> },
{ key: 'target', title: '对象', width: '160px', render: (record) => <div className="protocol-log-identifiers"><span>{record.phoneMasked || record.account || '-'}</span><small>{record.channelId || record.applicationId || ''}</small></div> },
{ key: 'target', title: '对象', width: '160px', render: (record) => <div className="protocol-log-identifiers"><span>{record.phoneNumber || record.phoneMasked || record.account || '-'}</span><small>{record.channelId || record.applicationId || ''}</small></div> },
{ key: 'status', title: '处理结果', width: '150px', render: (record) => <div className="protocol-log-result"><Tag tone={protocolStatusTone[record.status]}>{protocolStatusLabel(record)}</Tag><small>{record.resultCode || ''}</small></div> },
{ key: 'durationMs', title: '耗时', width: '90px', render: (record) => record.durationMs == null ? '-' : `${record.durationMs} ms` },
{ key: 'detail', title: '详情', width: '90px', render: (record) => <Button onClick={() => setDetail(record)} size="sm" variant="ghost"></Button> },
@@ -252,9 +279,9 @@ function ProtocolInteractionPanel({ active }: { active: boolean }) {
return (
<div className="page-stack protocol-log-panel">
<div className="protocol-log-hint"> CMPP </div>
<div className="protocol-log-hint"> CMPP </div>
<div className="system-log-filters protocol-log-filters">
<Input onChange={(event) => setInputs((value) => ({ ...value, keyword: event.target.value }))} placeholder="消息ID、请求ID、账号、脱敏手机号或结果码" prefix={<Search size={16} />} value={inputs.keyword} />
<Input onChange={(event) => setInputs((value) => ({ ...value, keyword: event.target.value }))} placeholder="消息ID、请求ID、账号、完整手机号或结果码" prefix={<Search size={16} />} value={inputs.keyword} />
<Select onChange={(event) => setInputs((value) => ({ ...value, protocol: event.target.value }))} options={[{ label: '全部协议', value: 'all' }, { label: 'CMPP', value: 'cmpp' }, { label: 'HTTP', value: 'http' }]} value={inputs.protocol} />
<Select onChange={(event) => setInputs((value) => ({ ...value, direction: event.target.value }))} options={[{ label: '全部方向', value: 'all' }, ...Object.entries(directionLabels).map(([value, label]) => ({ value, label }))]} value={inputs.direction} />
<Select onChange={(event) => setInputs((value) => ({ ...value, eventType: event.target.value }))} options={[{ label: '全部事件', value: 'all' }, ...eventTypes.map((value) => ({ label: value, value }))]} value={inputs.eventType} />
+7 -6
View File
@@ -1,14 +1,15 @@
import { Copy, Eye, FileText, Pencil, Power, Send } from 'lucide-react';
import { DeleteRiskAction, Pagination, Tag } from '@/components/ui';
import { formatCents } from '@/utils/currency';
import { successRateClassName } from '@/utils/successRate';
import { carrierLabelMap, carrierToneMap, statusLabelMap, statusToneMap } from './channelModel';
import type { ChannelConfirmAction, ChannelModalState, SmsChannel } from './channelTypes';
function RateBlock({ label, rate, count, tone = 'neutral' }: { label: string; rate: number; count: number; tone?: 'success' | 'warning' | 'danger' | 'neutral' }) {
function RateBlock({ label, rate, count, isSuccess = false }: { label: string; rate: number; count: number; isSuccess?: boolean }) {
return (
<div className={`sms-channel-rate sms-channel-rate--${tone}`}>
<div className="sms-channel-rate">
<small>{label}</small>
<strong>{rate}%</strong>
<strong className={isSuccess ? successRateClassName(rate) : undefined}>{rate}%</strong>
<span>{count.toLocaleString('zh-CN')}</span>
</div>
);
@@ -67,10 +68,10 @@ export function ChannelTable({
</div>
<strong className="sms-channel-total">{channel.total.toLocaleString('zh-CN')}</strong>
<div className="sms-channel-quality">
<RateBlock count={channel.submitFailureCount} label="提交失败" rate={channel.submitFailureRate} tone={channel.submitFailureCount > 0 ? 'danger' : 'neutral'} />
<RateBlock count={channel.successCount} label="送达成功" rate={channel.successRate} tone={channel.successRate >= 80 ? 'success' : 'warning'} />
<RateBlock count={channel.submitFailureCount} label="提交失败" rate={channel.submitFailureRate} />
<RateBlock count={channel.successCount} isSuccess label="送达成功" rate={channel.successRate} />
<RateBlock count={channel.unknownCount} label="回执未知" rate={channel.unknownRate} />
<RateBlock count={channel.failureCount} label="送达失败" rate={channel.failureRate} tone={channel.failureRate >= 50 ? 'danger' : 'neutral'} />
<RateBlock count={channel.failureCount} label="送达失败" rate={channel.failureRate} />
</div>
<div className="sms-channel-actions">
<button className="sms-channel-report-entry" onClick={() => onOpenReports(channel)} type="button">
@@ -34,8 +34,12 @@ export function signatureCardVisual(auditStatus: string, summaries?: Record<stri
const values = Object.values(summaries ?? {});
const applicable = values.filter((summary) => summary.total > 0 && summary.status !== 'not_applicable');
if (applicable.some((summary) => ['failed', 'rejected'].includes(summary.status))) return { label: '存在报备失败', tone: 'red' as SignatureCardTone };
if (applicable.some((summary) => summary.approved > 0 && summary.approved < summary.total)) return { label: '部分通道报备通过', tone: 'blue' as SignatureCardTone };
const approved = applicable.reduce((total, summary) => total + summary.approved, 0);
const allTargetsFailed = applicable.length > 0 && applicable.every((summary) => ['failed', 'rejected'].includes(summary.status));
if (allTargetsFailed) return { label: '所有目标通道报备失败', tone: 'red' as SignatureCardTone };
if (approved > 0 && applicable.some((summary) => summary.status !== 'approved')) return { label: '部分通道报备通过', tone: 'blue' as SignatureCardTone };
// Keep mixed failure/in-progress states actionable without mislabeling the whole signature as failed.
if (applicable.some((summary) => ['failed', 'rejected'].includes(summary.status))) return { label: '部分通道报备失败,仍待处理', tone: 'amber' as SignatureCardTone };
if (applicable.some((summary) => summary.status === 'waiting_material')) return { label: '报备资料待补充', tone: 'amber' as SignatureCardTone };
if (applicable.some((summary) => ['reporting', 'exporting'].includes(summary.status))) return { label: '通道报备处理中', tone: 'amber' as SignatureCardTone };
if (applicable.length > 0 && applicable.every((summary) => summary.status === 'approved')) return { label: '所有目标通道报备通过', tone: 'green' as SignatureCardTone };
@@ -13,7 +13,8 @@ type SmsRecordFilterProps = {
application: string;
applicationOptions: SelectOption[];
carrier: string;
channelKeyword: string;
channel: string;
channelOptions: SelectOption[];
contentKeyword: string;
dateRange: DateRangeValue;
enterprise: string;
@@ -23,7 +24,7 @@ type SmsRecordFilterProps = {
status: string;
onApplicationChange: (value: string) => void;
onCarrierChange: (value: string) => void;
onChannelKeywordChange: (value: string) => void;
onChannelChange: (value: string) => void;
onContentKeywordChange: (value: string) => void;
onDateRangeChange: (value: DateRangeValue) => void;
onEnterpriseChange: (value: string) => void;
@@ -61,7 +62,8 @@ export function SmsRecordFilter({
application,
applicationOptions,
carrier,
channelKeyword,
channel,
channelOptions,
contentKeyword,
dateRange,
enterprise,
@@ -71,7 +73,7 @@ export function SmsRecordFilter({
status,
onApplicationChange,
onCarrierChange,
onChannelKeywordChange,
onChannelChange,
onContentKeywordChange,
onDateRangeChange,
onEnterpriseChange,
@@ -89,7 +91,7 @@ export function SmsRecordFilter({
<Input label="手机号码" onChange={(event) => onPhoneKeywordChange(event.target.value)} prefix={<Smartphone size={16} />} value={phoneKeyword} />
<Select label="运营商" onChange={(event) => onCarrierChange(event.target.value)} options={carrierOptions} value={carrier} />
<Input label="短信内容" onChange={(event) => onContentKeywordChange(event.target.value)} value={contentKeyword} />
<Input label="通道名称" onChange={(event) => onChannelKeywordChange(event.target.value)} value={channelKeyword} />
<Select label="通道" onChange={(event) => onChannelChange(event.target.value)} options={channelOptions} searchable searchPlaceholder="输入通道名称搜索" value={channel} />
<Select label="发送状态" onChange={(event) => onStatusChange(event.target.value)} options={statusOptions} value={status} />
<Select label="是否含引流信息" onChange={(event) => onHasDrainageChange(event.target.value)} options={drainageOptions} value={hasDrainage} />
<div className="admin-sms-record-filter__actions">
+1 -1
View File
@@ -14,7 +14,7 @@ export type MessageFilters = {
applicationId?: string;
phoneNumber?: string;
contentKeyword?: string;
channelKeyword?: string;
channelId?: string;
carrier?: string;
queuedAtFrom?: string;
queuedAtTo?: string;