feat: improve operations diagnostics and channel management
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { request, requestBlob, requestForm, withQuery } from '../core/httpClient';
|
||||
import type { AdminChannel, ChannelConnectionLogResponse, ChannelGroup, ChannelReportField, ChannelTestResponse, CmppConnectionState, DeleteTargetRequest, DeletionPreflight, DeletionResult, DeletionTargetType, DictionaryItem, PagedResult, ReportImportMapping, ReportImportProfile, ReportImportReviewBatch, ReportMaterialBatch, ReportMaterialBatchPreflight, ReportMaterialBatchResult, ReportMaterialPendingItem, ReportRecord, ReportTask } from '../types';
|
||||
import type { AdminChannel, ChannelConnectionLogResponse, ChannelGroup, ChannelGroupDeletionImpact, ChannelReportField, ChannelTestResponse, CmppConnectionState, DeleteTargetRequest, DeletionPreflight, DeletionResult, DeletionTargetType, DictionaryItem, PagedResult, ReportImportMapping, ReportImportProfile, ReportImportReviewBatch, ReportMaterialBatch, ReportMaterialBatchPreflight, ReportMaterialBatchResult, ReportMaterialPendingItem, ReportRecord, ReportTask } from '../types';
|
||||
import { assertUploadFileSize } from '@/utils/fileUpload';
|
||||
|
||||
// Report generation consumes channel report fields, so these endpoints keep one
|
||||
@@ -36,6 +36,8 @@ export const adminChannelsReportsApi = {
|
||||
request<ChannelGroup>('/admin/channel-groups', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateChannelGroup: (id: string, body: { code?: string; name?: string; carrier?: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; retryTimeLimitMinutes?: number; items?: Array<Record<string, unknown>> }) =>
|
||||
request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
getChannelGroupDeletionImpact: (id: string) =>
|
||||
request<ChannelGroupDeletionImpact>(`/admin/channel-groups/${id}/deletion-impact`),
|
||||
deleteChannelGroup: (id: string) =>
|
||||
request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'DELETE' }),
|
||||
addChannelGroupItem: (body: Record<string, unknown>) =>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { request, requestBlob, requestForm, withQuery } from '../core/httpClient';
|
||||
import type { AuditRecord, ClientSmsSignature, ClientSmsTemplate, CommonReportField, DictionaryItem, DrainageDetectionResult, DrainageDetectionRule, EnterpriseCertification, ManualRechargePreflight, ManualRechargeResult, PagedResult, PhoneFrequencyHit, PhoneFrequencyWhitelistItem, RechargeOrder, ReviewDecisionResult, ReviewPreflight, RiskReviewTask, RiskRuleItem, RiskTaskMessagePage, SmsDrainageInfo, SmsTemplateAudit, TenantAccount } from '../types';
|
||||
import type { AdministrativeRegion, AuditRecord, ClientSmsSignature, ClientSmsTemplate, CommonReportField, DictionaryItem, DrainageDetectionResult, DrainageDetectionRule, EnterpriseCertification, ManualRechargePreflight, ManualRechargeResult, PagedResult, PhoneFrequencyHit, PhoneFrequencyWhitelistItem, RechargeOrder, ReviewDecisionResult, ReviewPreflight, RiskReviewTask, RiskRuleItem, RiskTaskMessagePage, SmsDrainageInfo, SmsTemplateAudit, TenantAccount } from '../types';
|
||||
|
||||
// Review, risk and billing mutations keep their original URLs, payloads and
|
||||
// response types behind one governance boundary.
|
||||
export const adminGovernanceApi = {
|
||||
listAdministrativeRegions: () => request<AdministrativeRegion[]>('/admin/dictionaries/administrative-regions'),
|
||||
listAccounts: () => request<TenantAccount[]>('/admin/billing/accounts'),
|
||||
updateCreditLimit: (tenantId: string, body: { creditCents: number; operatorId?: string; remark?: string }) =>
|
||||
request<TenantAccount>(`/admin/billing/accounts/${tenantId}/credit-limit`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { request, requestBlob, requestForm, withQuery } from '../core/httpClient';
|
||||
import type { BatchRequeueResponse, BatchTaskMessagePage, DailyProfitReport, DailyQualityReport, DailyReconciliationReport, DashboardResponse, DownstreamDeliveryDashboard, DownstreamDeliveryRecord, DownstreamRecoveryStatusExportQuery, DownstreamRecoveryStatusResponse, GatewayDownstreamRecoveryStatus, GatewaySubmitException, GatewaySubmitExceptionResponse, OperationLogResponse, PagedResponse, PagedResult, PendingAuditCounts, ProtocolInteractionLogResponse, ReceiptAnomalyResponse, SendQualityResponse, SignatureChannelQualityResponse, SmsBatchTask, SmsMessageRecord, SmsMessageSegmentAudit, SmsUplinkMessage, SystemLogExportResult } from '../types';
|
||||
import type { BatchRequeueResponse, BatchTaskMessagePage, DailyProfitReport, DailyQualityReport, DailyReconciliationReport, DashboardResponse, DownstreamDeliveryDashboard, DownstreamDeliveryRecord, DownstreamRecoveryStatusExportQuery, DownstreamRecoveryStatusResponse, GatewayDownstreamRecoveryStatus, GatewaySubmitException, GatewaySubmitExceptionResponse, OperationLogResponse, PagedResponse, PagedResult, PendingAuditCounts, ProfitReportSummary, ProtocolInteractionLogResponse, QualityReportSummary, ReceiptAnomalyResponse, ReconciliationReportSummary, SendQualityResponse, SignatureChannelQualityResponse, SmsBatchTask, SmsMessageRecord, SmsMessageSegmentAudit, SmsUplinkMessage, SystemLogExportResult } from '../types';
|
||||
|
||||
// Read-heavy operations endpoints are isolated from configuration mutations.
|
||||
export const adminOperationsApi = {
|
||||
@@ -15,15 +15,15 @@ export const adminOperationsApi = {
|
||||
exportSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string }) =>
|
||||
request<SystemLogExportResult>('/admin/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }),
|
||||
listReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PagedResponse<DailyReconciliationReport>>(withQuery('/admin/reports/reconciliation', query)),
|
||||
request<PagedResponse<DailyReconciliationReport> & { summary: ReconciliationReportSummary }>(withQuery('/admin/reports/reconciliation', query)),
|
||||
exportReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string } = {}) =>
|
||||
requestBlob(withQuery('/admin/reports/reconciliation/export', query)),
|
||||
listProfitReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PagedResponse<DailyProfitReport> & { dimensionType: 'application' | 'channel' }>(withQuery('/admin/reports/profit', query)),
|
||||
request<PagedResponse<DailyProfitReport> & { dimensionType: 'application' | 'channel'; summary: ProfitReportSummary }>(withQuery('/admin/reports/profit', query)),
|
||||
exportProfitReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel'; tenantId?: string; applicationId?: string; channelId?: string } = {}) =>
|
||||
requestBlob(withQuery('/admin/reports/profit/export', query)),
|
||||
listQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PagedResponse<DailyQualityReport> & { dimensionType: DailyQualityReport['dimensionType'] }>(withQuery('/admin/reports/quality', query)),
|
||||
request<PagedResponse<DailyQualityReport> & { dimensionType: DailyQualityReport['dimensionType']; summary: QualityReportSummary }>(withQuery('/admin/reports/quality', query)),
|
||||
exportQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string } = {}) =>
|
||||
requestBlob(withQuery('/admin/reports/quality/export', query)),
|
||||
listAdminBatchTasks: (query: { tenantId?: string; status?: string } = {}) =>
|
||||
|
||||
@@ -66,6 +66,15 @@ export type ChannelGroup = DictionaryItem & {
|
||||
items?: ChannelGroupItem[];
|
||||
};
|
||||
|
||||
export type ChannelGroupDeletionImpact = {
|
||||
groupId: string;
|
||||
groupName: string;
|
||||
normalApplicationCount: number;
|
||||
deletedApplicationCount: number;
|
||||
channelCount: number;
|
||||
pendingSupplierSubmitCount: number;
|
||||
};
|
||||
|
||||
export type ChannelGroupItem = DictionaryItem & {
|
||||
groupId: string;
|
||||
channelId: string;
|
||||
|
||||
@@ -95,6 +95,11 @@ export type RiskTaskMessagePage = {
|
||||
|
||||
export type BatchTaskMessagePage = RiskTaskMessagePage;
|
||||
|
||||
export type AdministrativeRegion = {
|
||||
province: string;
|
||||
cities: string[];
|
||||
};
|
||||
|
||||
export type DrainageDetectionRule = {
|
||||
id: string;
|
||||
code: string;
|
||||
|
||||
@@ -306,6 +306,7 @@ export type ProtocolInteractionLogItem = {
|
||||
traceId?: string | null;
|
||||
requestId?: string | null;
|
||||
phoneMasked?: string | null;
|
||||
phoneNumber?: string | null;
|
||||
resultCode?: string | null;
|
||||
durationMs?: number | null;
|
||||
payloadBytes?: number | null;
|
||||
@@ -348,6 +349,28 @@ export type DailyReconciliationReport = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ReportVolumeSummary = {
|
||||
submittedUnits: number;
|
||||
sentUnits: number;
|
||||
unknownUnits: number;
|
||||
successUnits: number;
|
||||
failedUnits: number;
|
||||
};
|
||||
|
||||
export type ReconciliationReportSummary = ReportVolumeSummary;
|
||||
|
||||
export type ProfitReportSummary = ReportVolumeSummary & {
|
||||
revenueCents: number;
|
||||
refundCents: number;
|
||||
costCents: number;
|
||||
profitCents: number;
|
||||
profitRateBps: number;
|
||||
};
|
||||
|
||||
export type QualityReportSummary = ReportVolumeSummary & {
|
||||
successRateBps: number;
|
||||
};
|
||||
|
||||
export type DailyProfitReport = {
|
||||
id: string;
|
||||
reportDate: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { adminApi, clientApi, type CaptchaResponse } from '@/api/adminApi';
|
||||
import { consumeSessionRecovery, readSessionRecovery, writeSession, type Portal } from '@/api/session';
|
||||
import { consumeSessionRecovery, markUserActivity, readSessionRecovery, writeSession, type Portal } from '@/api/session';
|
||||
import { Button, Input, Modal } from '@/components/ui';
|
||||
|
||||
type LoginPageProps = {
|
||||
@@ -57,6 +57,10 @@ export function LoginPage({ portal }: LoginPageProps) {
|
||||
captchaText,
|
||||
});
|
||||
writeSession(session);
|
||||
// A login can happen without a full page reload after the previous session
|
||||
// expired. Reset the in-memory activity clock so the new session is not
|
||||
// immediately locked using the previous session's stale idle duration.
|
||||
markUserActivity();
|
||||
const target = consumeSessionRecovery(portal)?.returnUrl;
|
||||
navigate(target ?? (isAdmin ? '/admin' : '/client'), { replace: true });
|
||||
} catch (err) {
|
||||
|
||||
@@ -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)} 毫秒`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>>({});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>,
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -14,7 +14,7 @@ export type MessageFilters = {
|
||||
applicationId?: string;
|
||||
phoneNumber?: string;
|
||||
contentKeyword?: string;
|
||||
channelKeyword?: string;
|
||||
channelId?: string;
|
||||
carrier?: string;
|
||||
queuedAtFrom?: string;
|
||||
queuedAtTo?: string;
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
UserX,
|
||||
} from 'lucide-react';
|
||||
import { adminApi } from '@/api/adminApi';
|
||||
import type { LoginSession } from '@/api/session';
|
||||
import { getLastUserActivityAt, readSession, type LoginSession } from '@/api/session';
|
||||
import { AppShell } from '@/layouts/AppShell';
|
||||
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
|
||||
|
||||
@@ -46,7 +46,13 @@ export function AdminLayout() {
|
||||
|
||||
function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
const [pendingAudits, setPendingAudits] = useState(EMPTY_PENDING_AUDITS);
|
||||
const [sessionLocked, setSessionLocked] = useState(Boolean(session.locked));
|
||||
const loadPendingAuditCount = useCallback(() => {
|
||||
const currentSession = readSession('admin');
|
||||
if (!currentSession || currentSession.locked
|
||||
|| Date.now() - getLastUserActivityAt() >= currentSession.idleTimeoutSeconds * 1000) {
|
||||
return;
|
||||
}
|
||||
// This runs globally and on a timer, so it must not fan out through the full dashboard aggregation.
|
||||
adminApi.getPendingAudits()
|
||||
.then((counts) => {
|
||||
@@ -58,7 +64,7 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (session.portal !== 'admin' || session.locked) {
|
||||
if (session.portal !== 'admin' || sessionLocked) {
|
||||
return;
|
||||
}
|
||||
loadPendingAuditCount();
|
||||
@@ -72,7 +78,7 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
window.removeEventListener('cmpp-audit-count-refresh', onAuditRefresh);
|
||||
};
|
||||
}, [loadPendingAuditCount, session.locked, session.portal]);
|
||||
}, [loadPendingAuditCount, session.portal, sessionLocked]);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
@@ -83,6 +89,7 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
portal="admin"
|
||||
userName={session.user.displayName}
|
||||
userRole="平台管理员"
|
||||
onSessionLockedChange={setSessionLocked}
|
||||
auditNotifications={[
|
||||
{ label: '企业认证待审', count: pendingAudits.enterpriseCertifications, to: '/admin/enterprise-audit' },
|
||||
{ label: '短信审核待审', count: pendingAudits.smsAudits, to: '/admin/sms-audit' },
|
||||
@@ -120,7 +127,6 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
{ label: '短信模板审核', to: '/admin/templates', icon: FileCheck2 },
|
||||
{ label: '短信签名审核', to: '/admin/signatures', icon: FilePenLine },
|
||||
{ label: '引流信息审核', to: '/admin/drainage-audits', icon: FilePenLine },
|
||||
{ label: '风控规则', to: '/admin/risk-rules', icon: Shield },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -174,6 +180,7 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
title: '安全控制',
|
||||
icon: Shield,
|
||||
items: [
|
||||
{ label: '风控规则', to: '/admin/risk-rules', icon: Shield },
|
||||
{ label: '企业黑名单', to: '/admin/enterprise-blacklist', icon: UserX },
|
||||
{ label: '全局黑名单', to: '/admin/global-blacklist', icon: ShieldOff },
|
||||
{ label: '敏感词管理', to: '/admin/sensitive-words', icon: Shield },
|
||||
|
||||
@@ -59,6 +59,7 @@ type AppShellProps = {
|
||||
userRole: string;
|
||||
navSections: ShellNavSection[];
|
||||
auditNotifications?: AuditNotificationItem[];
|
||||
onSessionLockedChange?: (locked: boolean) => void;
|
||||
};
|
||||
|
||||
export function AppShell({
|
||||
@@ -70,6 +71,7 @@ export function AppShell({
|
||||
userRole,
|
||||
navSections,
|
||||
auditNotifications = [],
|
||||
onSessionLockedChange,
|
||||
}: AppShellProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
@@ -199,6 +201,10 @@ export function AppShell({
|
||||
setMobileNavOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
onSessionLockedChange?.(locked);
|
||||
}, [locked, onSessionLockedChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileNavOpen) return;
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
@@ -213,8 +219,21 @@ export function AppShell({
|
||||
const onActivity = () => markUserActivity();
|
||||
activityEvents.forEach((eventName) => window.addEventListener(eventName, onActivity, { passive: true }));
|
||||
|
||||
const onLocked = () => setLocked(true);
|
||||
const onUnlocked = () => { setLocked(false); markUserActivity(); };
|
||||
const onLocked = () => {
|
||||
updateSessionTiming(portal, { locked: true });
|
||||
setLocked(true);
|
||||
// Business routes must be unmounted while the server session is locked.
|
||||
// This prevents background list and badge requests from repeatedly
|
||||
// receiving 401 responses and guarantees a fresh read after unlocking.
|
||||
setRoutesSuspended(true);
|
||||
};
|
||||
const onUnlocked = () => {
|
||||
updateSessionTiming(portal, { locked: false });
|
||||
setLocked(false);
|
||||
setRoutesSuspended(false);
|
||||
lockRequested.current = false;
|
||||
markUserActivity();
|
||||
};
|
||||
const onLogout = () => { clearSession(portal); navigate(loginPath, { replace: true }); };
|
||||
const lockedEvent = sessionEvent(portal, 'locked');
|
||||
const unlockedEvent = sessionEvent(portal, 'unlocked');
|
||||
@@ -261,8 +280,8 @@ export function AppShell({
|
||||
const remaining = session.idleTimeoutSeconds * 1000 - (now - getLastUserActivityAt());
|
||||
if (remaining <= 0 && !lockRequested.current) {
|
||||
lockRequested.current = true;
|
||||
setLocked(true);
|
||||
setIdleWarningSeconds(null);
|
||||
dispatchSessionEvent(portal, 'locked', { reason: 'client_idle_timer' });
|
||||
void portalSessionApi.lock(portal).catch(() => undefined);
|
||||
} else if (remaining <= 5 * 60 * 1000) {
|
||||
setIdleWarningSeconds(Math.ceil(remaining / 1000));
|
||||
|
||||
@@ -619,6 +619,44 @@
|
||||
grid-template-columns: minmax(270px, 1.3fr) minmax(180px, 0.8fr) minmax(210px, 1fr) minmax(220px, 1fr) auto;
|
||||
}
|
||||
|
||||
.admin-report-summary {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.admin-report-summary__heading {
|
||||
align-items: baseline;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2) var(--space-4);
|
||||
}
|
||||
|
||||
.admin-report-summary__heading span,
|
||||
.admin-report-summary__grid span {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-report-summary__grid {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: repeat(auto-fit, minmax(128px, 1fr));
|
||||
}
|
||||
|
||||
.admin-report-summary__grid > div {
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.admin-report-summary__grid strong {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
|
||||
.audit-filter-grid--enterprise,
|
||||
|
||||
+32
-36
@@ -5519,6 +5519,30 @@
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.success-rate-text--red {
|
||||
color: #dc2626 !important;
|
||||
}
|
||||
|
||||
.success-rate-text--orange {
|
||||
color: #ea580c !important;
|
||||
}
|
||||
|
||||
.success-rate-text--yellow {
|
||||
color: #ca8a04 !important;
|
||||
}
|
||||
|
||||
.success-rate-text--blue {
|
||||
color: #2563eb !important;
|
||||
}
|
||||
|
||||
.success-rate-text--green {
|
||||
color: #16a34a !important;
|
||||
}
|
||||
|
||||
.success-rate-text--deep-green {
|
||||
color: #047857 !important;
|
||||
}
|
||||
|
||||
.channel-report-stats .is-success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
@@ -6601,21 +6625,6 @@
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.signature-quality-rate--success,
|
||||
.signature-quality-matrix__rate--success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.signature-quality-rate--warning,
|
||||
.signature-quality-matrix__rate--warning {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.signature-quality-rate--danger,
|
||||
.signature-quality-matrix__rate--danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.signature-quality-drawer__backdrop {
|
||||
background: rgb(15 23 42 / 42%);
|
||||
inset: 0;
|
||||
@@ -6859,28 +6868,10 @@
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.signature-quality-matrix__drainage {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.signature-quality-matrix__drainage > div {
|
||||
border-bottom: 1px dashed var(--color-border);
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
grid-template-columns: 58px 1fr;
|
||||
padding-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.signature-quality-matrix__drainage > div:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.signature-quality-matrix__drainage b {
|
||||
.signature-quality-matrix__drainage-label {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
min-width: 88px;
|
||||
}
|
||||
|
||||
.signature-quality-matrix__rate {
|
||||
@@ -6891,6 +6882,11 @@
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.signature-quality-matrix__zero {
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.signature-quality-drawer__footnote {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export type SuccessRateTone = 'red' | 'orange' | 'yellow' | 'blue' | 'green' | 'deep-green';
|
||||
|
||||
export function successRateTone(value: number): SuccessRateTone {
|
||||
const rate = Number.isFinite(value) ? value : 0;
|
||||
|
||||
// Keep decimal percentages in one continuous band at the agreed boundaries.
|
||||
if (rate <= 0) return 'red';
|
||||
if (rate <= 25) return 'orange';
|
||||
if (rate <= 50) return 'yellow';
|
||||
if (rate <= 75) return 'blue';
|
||||
if (rate < 96) return 'green';
|
||||
return 'deep-green';
|
||||
}
|
||||
|
||||
export function successRateClassName(value: number) {
|
||||
return `success-rate-text success-rate-text--${successRateTone(value)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user