fix: enforce drainage uniqueness and carrier-specific reporting
This commit is contained in:
@@ -297,7 +297,14 @@ export type ClientSmsSignature = {
|
||||
carrierReportSummary?: Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>;
|
||||
drainageReportTargets?: Record<
|
||||
string,
|
||||
Array<{ channel: AdminChannel; channelId: string; status: string; taskId?: string }>
|
||||
Array<{
|
||||
channel: AdminChannel;
|
||||
channelId: string;
|
||||
carrier: 'mobile' | 'unicom' | 'telecom';
|
||||
status: string;
|
||||
taskId?: string;
|
||||
approvalScope?: string;
|
||||
}>
|
||||
>;
|
||||
drainageCarrierReportSummary?: Record<
|
||||
string,
|
||||
|
||||
@@ -347,11 +347,9 @@ export function AdminReportTasksPage() {
|
||||
render: (record) => (
|
||||
<div>
|
||||
<strong>{record.channel?.name ?? record.channelId}</strong>
|
||||
{record.reportType !== 'drainage' ? (
|
||||
<div className="muted">
|
||||
{record.carrier ? <CarrierTag carrier={record.carrier} /> : '历史通道级(未拆分)'}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="muted">
|
||||
{record.carrier ? <CarrierTag carrier={record.carrier} /> : '历史通道级(未拆分)'}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AdminSmsApplicationFormPage } from './AdminSmsApplicationFormPage';
|
||||
import { AdminSmsApplicationFormPage, parseIpAllowlist } from './AdminSmsApplicationFormPage';
|
||||
|
||||
vi.mock('@/api/adminApi', () => ({
|
||||
adminApi: {
|
||||
@@ -44,3 +44,12 @@ describe('application form feedback', () => {
|
||||
expect(dialog).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts comma separated HTTP IP entries including IPv6 and CIDR', () => {
|
||||
expect(parseIpAllowlist('203.0.113.1, 203.0.113.0/24,2001:db8::1\n2001:db8::/64')).toEqual([
|
||||
'203.0.113.1',
|
||||
'203.0.113.0/24',
|
||||
'2001:db8::1',
|
||||
'2001:db8::/64',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -595,7 +595,7 @@ export function AdminSmsApplicationFormPage() {
|
||||
<Input
|
||||
label="HTTP IP 白名单"
|
||||
onChange={(event) => setHttpIpAddress(event.target.value)}
|
||||
placeholder="多个 IP/CIDR 可换行填写,留空表示不限制"
|
||||
placeholder="多个 IP/CIDR 可用英文逗号、中文逗号或空白分隔,留空表示不限制"
|
||||
value={httpIpAddress}
|
||||
/>
|
||||
<Input
|
||||
@@ -779,7 +779,7 @@ function getRouteGroupId(routeRules: DictionaryItem[], carrier: Carrier) {
|
||||
return typeof rule?.groupId === 'string' ? rule.groupId : '';
|
||||
}
|
||||
|
||||
function parseIpAllowlist(value: string) {
|
||||
export function parseIpAllowlist(value: string) {
|
||||
return value
|
||||
.split(/[\s,,]+/)
|
||||
.map((item) => item.trim())
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { adminApi, type ClientSmsSignature } from '@/api/adminApi';
|
||||
import { DrainageReportStatusModal } from './SignatureReportModals';
|
||||
import type { DrainageInfo } from './signature.types';
|
||||
vi.mock('@/api/adminApi', () => ({ adminApi: { changeReportTaskStatuses: vi.fn().mockResolvedValue([]) } }));
|
||||
describe('drainage report carrier form', () => {
|
||||
it('submits three separate carrier decisions and keeps failures visible', async () => {
|
||||
const item = { id: 'd', url: 'example.com' } as DrainageInfo;
|
||||
const signature = {
|
||||
id: 's',
|
||||
name: '【测试】',
|
||||
drainageReportTargets: {
|
||||
d: ['mobile', 'unicom', 'telecom'].map((carrier) => ({
|
||||
channelId: 'c',
|
||||
channel: { id: 'c', name: '三网通道' },
|
||||
carrier,
|
||||
status: 'pending',
|
||||
})),
|
||||
},
|
||||
} as unknown as ClientSmsSignature;
|
||||
const saved = vi.fn();
|
||||
render(<DrainageReportStatusModal item={item} signature={signature} onClose={() => {}} onSaved={saved} />);
|
||||
fireEvent.click(screen.getByLabelText('三网通道移动报备状态'));
|
||||
fireEvent.click(screen.getByRole('option', { name: '报备通过' }));
|
||||
fireEvent.click(screen.getByLabelText('三网通道联通报备状态'));
|
||||
fireEvent.click(screen.getByRole('option', { name: '报备失败' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存状态' }));
|
||||
await waitFor(() => expect(saved).toHaveBeenCalled());
|
||||
expect(vi.mocked(adminApi.changeReportTaskStatuses).mock.calls[0][0].items).toEqual([
|
||||
{
|
||||
signatureId: 's',
|
||||
drainageItemId: 'd',
|
||||
reportType: 'drainage',
|
||||
channelId: 'c',
|
||||
carrier: 'mobile',
|
||||
status: 'approved',
|
||||
},
|
||||
{
|
||||
signatureId: 's',
|
||||
drainageItemId: 'd',
|
||||
reportType: 'drainage',
|
||||
channelId: 'c',
|
||||
carrier: 'unicom',
|
||||
status: 'failed',
|
||||
},
|
||||
{
|
||||
signatureId: 's',
|
||||
drainageItemId: 'd',
|
||||
reportType: 'drainage',
|
||||
channelId: 'c',
|
||||
carrier: 'telecom',
|
||||
status: 'pending',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -2,69 +2,280 @@ import { useState } from 'react';
|
||||
import { Info } from 'lucide-react';
|
||||
import { adminApi, type ClientSmsSignature } from '@/api/adminApi';
|
||||
import { Button, CarrierTag, Modal, Select, Textarea } from '@/components/ui';
|
||||
import { carrierLabel, CarrierReportTag } from './signature.helpers';
|
||||
import { carrierLabel } from './signature.helpers';
|
||||
import type { DrainageInfo } from './signature.types';
|
||||
|
||||
const reportStatusOptions = [
|
||||
{ label: '未报备', value: 'pending' }, { label: '资料待补充', value: 'waiting_material' },
|
||||
{ label: '报备中', value: 'reporting' }, { label: '报备通过', value: 'approved' },
|
||||
{ label: '报备失败', value: 'failed' }, { label: '放弃报备', value: 'abandoned' },
|
||||
{ label: '未报备', value: 'pending' },
|
||||
{ label: '资料待补充', value: 'waiting_material' },
|
||||
{ label: '报备中', value: 'reporting' },
|
||||
{ label: '报备通过', value: 'approved' },
|
||||
{ label: '报备失败', value: 'failed' },
|
||||
{ label: '放弃报备', value: 'abandoned' },
|
||||
];
|
||||
|
||||
export function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsSignature; onClose: () => void; onSaved: () => void }) {
|
||||
export function ChannelReportStatusModal({
|
||||
item,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
item: ClientSmsSignature;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const targets = item.reportTargets ?? [];
|
||||
const carriers = ['mobile', 'unicom', 'telecom'] as const;
|
||||
const [statuses, setStatuses] = useState<Record<string, string>>(() => Object.fromEntries(targets.map((target) => [`${target.channelId}:${target.carrier}`, target.status])));
|
||||
const [statuses, setStatuses] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(targets.map((target) => [`${target.channelId}:${target.carrier}`, target.status])),
|
||||
);
|
||||
const [reason, setReason] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: item.id, channelId: target.channelId, carrier: target.carrier, status: statuses[`${target.channelId}:${target.carrier}`] ?? target.status })), reason, sourceEntry: 'enterprise_signature' });
|
||||
await adminApi.changeReportTaskStatuses({
|
||||
items: targets.map((target) => ({
|
||||
signatureId: item.id,
|
||||
channelId: target.channelId,
|
||||
carrier: target.carrier,
|
||||
status: statuses[`${target.channelId}:${target.carrier}`] ?? target.status,
|
||||
})),
|
||||
reason,
|
||||
sourceEntry: 'enterprise_signature',
|
||||
});
|
||||
onSaved();
|
||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '报备状态保存失败'); } finally { setSaving(false); }
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '报备状态保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
return <Modal footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="修改签名报备状态">
|
||||
<div className="signature-report-status"><div className="signature-report-status__context"><strong>{item.name}</strong><span>{item.tenant?.name ?? item.tenantId} · {item.application?.name ?? '-'}</span></div><div className="signature-alert"><Info size={18} /><span>修改具体通道的报备状态;保存后同步通道详情、报备任务和企业签名三网状态。</span></div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{targets.length ? <div className="signature-report-status__carriers">{carriers.map((carrier) => { const carrierTargets = targets.filter((target) => target.carrier === carrier); return <section className="signature-report-status__carrier" key={carrier}><header><CarrierTag carrier={carrier} /><span>{carrierTargets.length} 个通道</span></header><div className="signature-report-status__list">{carrierTargets.length ? carrierTargets.map((target) => { const key = `${target.channelId}:${target.carrier}`; return <div className="signature-report-status__row" key={key}><strong title={target.channel.name}>{target.channel.name}</strong><Select aria-label={`${target.channel.name}${carrierLabel(target.carrier)}报备状态`} onChange={(event) => setStatuses((current) => ({ ...current, [key]: event.target.value }))} options={reportStatusOptions} value={statuses[key] ?? target.status} /></div>; }) : <div className="signature-report-status__empty">暂无{carrierLabel(carrier)}目标通道</div>}</div></section>; })}</div> : <div className="empty-state">该企业应用当前没有配置目标通道。</div>}
|
||||
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入运营商工单、确认依据或人工处理说明" rows={3} value={reason} />
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
export function DrainageReportStatusModal({ item, onClose, onSaved, signature }: { item: DrainageInfo; onClose: () => void; onSaved: () => void; signature: ClientSmsSignature }) {
|
||||
const targets = signature.drainageReportTargets?.[item.id] ?? [];
|
||||
const [statuses, setStatuses] = useState<Record<string, string>>(() => Object.fromEntries(targets.map((target) => [target.channelId, target.status])));
|
||||
const [reason, setReason] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: signature.id, channelId: target.channelId, reportType: 'drainage', drainageItemId: item.id, status: statuses[target.channelId] ?? target.status })), reason, sourceEntry: 'enterprise_signature' });
|
||||
onSaved();
|
||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '引流报备状态保存失败'); } finally { setSaving(false); }
|
||||
}
|
||||
return <Modal footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="按通道修改引流信息报备状态">
|
||||
<div className="page-stack"><div className="signature-alert"><Info size={18} /><span>修改的是当前引流信息在具体通道上的真实报备任务,保存后会同步通道报备详情和报备任务页。</span></div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{targets.length ? targets.map((target) => <div className="surface admin-report-target-row" key={target.channelId}><div><strong>{target.channel.name}</strong><div className="muted">{carrierLabel(target.channel.carrier)} · {target.channel.name}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state">当前应用的目标通道没有配置引流信息报备字段。</div>}
|
||||
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} rows={3} value={reason} />
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
export function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onCancel} variant="ghost">取消</Button>
|
||||
<Button onClick={onConfirm} variant="danger">确认删除</Button>
|
||||
<Button onClick={onClose} variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={!targets.length || saving} onClick={() => void save()}>
|
||||
{saving ? '保存中...' : '保存状态'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title="修改签名报备状态"
|
||||
>
|
||||
<div className="signature-report-status">
|
||||
<div className="signature-report-status__context">
|
||||
<strong>{item.name}</strong>
|
||||
<span>
|
||||
{item.tenant?.name ?? item.tenantId} · {item.application?.name ?? '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="signature-alert">
|
||||
<Info size={18} />
|
||||
<span>修改具体通道的报备状态;保存后同步通道详情、报备任务和企业签名三网状态。</span>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{targets.length ? (
|
||||
<div className="signature-report-status__carriers">
|
||||
{carriers.map((carrier) => {
|
||||
const carrierTargets = targets.filter((target) => target.carrier === carrier);
|
||||
return (
|
||||
<section className="signature-report-status__carrier" key={carrier}>
|
||||
<header>
|
||||
<CarrierTag carrier={carrier} />
|
||||
<span>{carrierTargets.length} 个通道</span>
|
||||
</header>
|
||||
<div className="signature-report-status__list">
|
||||
{carrierTargets.length ? (
|
||||
carrierTargets.map((target) => {
|
||||
const key = `${target.channelId}:${target.carrier}`;
|
||||
return (
|
||||
<div className="signature-report-status__row" key={key}>
|
||||
<strong title={target.channel.name}>{target.channel.name}</strong>
|
||||
<Select
|
||||
aria-label={`${target.channel.name}${carrierLabel(target.carrier)}报备状态`}
|
||||
onChange={(event) =>
|
||||
setStatuses((current) => ({ ...current, [key]: event.target.value }))
|
||||
}
|
||||
options={reportStatusOptions}
|
||||
value={statuses[key] ?? target.status}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="signature-report-status__empty">暂无{carrierLabel(carrier)}目标通道</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">该企业应用当前没有配置目标通道。</div>
|
||||
)}
|
||||
<Textarea
|
||||
label="修改原因"
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder="请输入运营商工单、确认依据或人工处理说明"
|
||||
rows={3}
|
||||
value={reason}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrainageReportStatusModal({
|
||||
item,
|
||||
onClose,
|
||||
onSaved,
|
||||
signature,
|
||||
}: {
|
||||
item: DrainageInfo;
|
||||
signature: ClientSmsSignature;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const targets = signature.drainageReportTargets?.[item.id] ?? [];
|
||||
const carriers = ['mobile', 'unicom', 'telecom'] as const;
|
||||
const [statuses, setStatuses] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(targets.map((target) => [`${target.channelId}:${target.carrier}`, target.status])),
|
||||
);
|
||||
const [reason, setReason] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await adminApi.changeReportTaskStatuses({
|
||||
items: targets.map((target) => ({
|
||||
signatureId: signature.id,
|
||||
reportType: 'drainage',
|
||||
drainageItemId: item.id,
|
||||
channelId: target.channelId,
|
||||
carrier: target.carrier,
|
||||
status: statuses[`${target.channelId}:${target.carrier}`] ?? target.status,
|
||||
})),
|
||||
reason,
|
||||
sourceEntry: 'enterprise_signature',
|
||||
});
|
||||
onSaved();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '报备状态保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Modal
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={!targets.length || saving} onClick={() => void save()}>
|
||||
{saving ? '保存中...' : '保存状态'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title="修改引流报备状态"
|
||||
>
|
||||
<div className="signature-report-status">
|
||||
<div className="signature-report-status__context">
|
||||
<strong>{item.url}</strong>
|
||||
<span>
|
||||
{signature.name} · {signature.application?.name ?? '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="signature-alert">
|
||||
<Info size={18} />
|
||||
<span>分别修改各通道的移动、联通、电信报备状态;历史通道级状态在保存后按运营商独立管理。</span>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{targets.length ? (
|
||||
<div className="signature-report-status__carriers">
|
||||
{carriers.map((carrier) => {
|
||||
const carrierTargets = targets.filter((target) => target.carrier === carrier);
|
||||
return (
|
||||
<section className="signature-report-status__carrier" key={carrier}>
|
||||
<header>
|
||||
<CarrierTag carrier={carrier} />
|
||||
<span>{carrierTargets.length} 个通道</span>
|
||||
</header>
|
||||
<div className="signature-report-status__list">
|
||||
{carrierTargets.length ? (
|
||||
carrierTargets.map((target) => {
|
||||
const key = `${target.channelId}:${target.carrier}`;
|
||||
return (
|
||||
<div className="signature-report-status__row" key={key}>
|
||||
<strong title={target.channel.name}>
|
||||
{target.channel.name}
|
||||
{target.approvalScope === 'legacy_channel' ? '(继承历史通道状态)' : ''}
|
||||
</strong>
|
||||
<Select
|
||||
aria-label={`${target.channel.name}${carrierLabel(target.carrier)}报备状态`}
|
||||
onChange={(event) =>
|
||||
setStatuses((current) => ({ ...current, [key]: event.target.value }))
|
||||
}
|
||||
options={reportStatusOptions}
|
||||
value={statuses[key] ?? target.status}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="signature-report-status__empty">暂无{carrierLabel(carrier)}目标通道</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">该企业应用当前没有配置目标通道。</div>
|
||||
)}
|
||||
<Textarea
|
||||
label="修改原因"
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder="请输入运营商工单、确认依据或人工处理说明"
|
||||
rows={3}
|
||||
value={reason}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
message,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
message: string;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onCancel} variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={onConfirm} variant="danger">
|
||||
确认删除
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={onCancel}
|
||||
open
|
||||
title="删除确认"
|
||||
|
||||
@@ -19,6 +19,9 @@ type SendDetailModalProps = {
|
||||
};
|
||||
|
||||
export function SendDetailModal({ record, segmentAudits, segmentLoading, onClose }: SendDetailModalProps) {
|
||||
const visibleWordDecisions = record.channelWordDecisions?.filter(
|
||||
(decision) => decision.snapshot.hits.length > 0 || Boolean(decision.snapshot.reason),
|
||||
);
|
||||
const routeRows = buildRouteRows(record, segmentAudits);
|
||||
const channelGroupNames = Array.from(new Set(routeRows.map((route) => route.channelGroup).filter(Boolean)));
|
||||
const orderedSegmentAudits = [...segmentAudits].sort((left, right) => {
|
||||
@@ -47,15 +50,13 @@ export function SendDetailModal({ record, segmentAudits, segmentLoading, onClose
|
||||
}
|
||||
>
|
||||
<div className="admin-sms-send-detail">
|
||||
<section aria-label="通道筛选原因">
|
||||
<h3>通道筛选原因</h3>
|
||||
{record.channelWordDecisions?.length ? (
|
||||
record.channelWordDecisions.map((decision) => (
|
||||
{visibleWordDecisions?.length ? (
|
||||
<section aria-label="通道筛选原因">
|
||||
<h3>通道筛选原因</h3>
|
||||
{visibleWordDecisions.map((decision) => (
|
||||
<div key={decision.id}>
|
||||
<p>
|
||||
{getTime(decision.decidedAt)} ·{' '}
|
||||
{decision.snapshot.reason ||
|
||||
(decision.snapshot.hits.length ? '已排除命中通道,按剩余候选选路' : '候选通道未命中通道敏感词')}
|
||||
{getTime(decision.decidedAt)} · {decision.snapshot.reason || '已排除命中通道,按剩余候选选路'}
|
||||
</p>
|
||||
{decision.snapshot.hits.map((hit) => (
|
||||
<p key={hit.channelId}>
|
||||
@@ -65,11 +66,9 @@ export function SendDetailModal({ record, segmentAudits, segmentLoading, onClose
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="muted">{segmentLoading ? '加载中…' : '暂无通道敏感词选路记录'}</p>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
<section aria-label="引流发送资格">
|
||||
<h3>引流发送资格</h3>
|
||||
{record.drainageGate ? (
|
||||
|
||||
@@ -5,17 +5,39 @@ import { SmsRecordList } from './SmsRecordList';
|
||||
import { SendDetailModal } from './SendDetailModal';
|
||||
|
||||
const record = {
|
||||
id: 'record-1', messageId: 'message-1', content: '请访问example.com查询', hasDrainageContent: true,
|
||||
id: 'record-1',
|
||||
messageId: 'message-1',
|
||||
content: '请访问example.com查询',
|
||||
hasDrainageContent: true,
|
||||
drainageDetection: { matches: [{ start: 3, end: 14, value: 'example.com' }] },
|
||||
queuedAt: '2026-08-31T01:00:00Z', deliveredAt: '2026-08-31T01:00:05Z', status: 'delivered',
|
||||
amountCents: 5, billingUnits: 1, phoneNumber: '13800138000', submitRecords: [], receiptRecords: [],
|
||||
queuedAt: '2026-08-31T01:00:00Z',
|
||||
deliveredAt: '2026-08-31T01:00:05Z',
|
||||
status: 'delivered',
|
||||
amountCents: 5,
|
||||
billingUnits: 1,
|
||||
phoneNumber: '13800138000',
|
||||
submitRecords: [],
|
||||
receiptRecords: [],
|
||||
} as unknown as SmsMessageRecord;
|
||||
|
||||
describe('SMS drainage and final receipt presentation', () => {
|
||||
it('shows only a positive drainage badge under status and removes receipt time from list', () => {
|
||||
const { container } = render(<SmsRecordList currentPage={1} loading={false} records={[record, { ...record, id: 'record-2', hasDrainageContent: false }]} total={2} totalPages={1} onExport={() => {}} onOpenDetail={() => {}} onPageChange={() => {}} />);
|
||||
const { container } = render(
|
||||
<SmsRecordList
|
||||
currentPage={1}
|
||||
loading={false}
|
||||
records={[record, { ...record, id: 'record-2', hasDrainageContent: false }]}
|
||||
total={2}
|
||||
totalPages={1}
|
||||
onExport={() => {}}
|
||||
onOpenDetail={() => {}}
|
||||
onPageChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByText('含引流')).toHaveLength(1);
|
||||
expect(container.querySelector('.admin-sms-record-status-stack .admin-sms-record-drainage-badge')).toHaveTextContent('含引流');
|
||||
expect(
|
||||
container.querySelector('.admin-sms-record-status-stack .admin-sms-record-drainage-badge'),
|
||||
).toHaveTextContent('含引流');
|
||||
expect(screen.queryByText('不含引流')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('回执时间')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('09:00:05')).not.toBeInTheDocument();
|
||||
@@ -30,10 +52,49 @@ describe('SMS drainage and final receipt presentation', () => {
|
||||
});
|
||||
|
||||
it('shows negative drainage only in details and does not mislabel untested historical records', () => {
|
||||
const { rerender } = render(<SendDetailModal record={{ ...record, hasDrainageContent: false }} segmentAudits={[]} segmentLoading={false} onClose={() => {}} />);
|
||||
const { rerender } = render(
|
||||
<SendDetailModal
|
||||
record={{ ...record, hasDrainageContent: false }}
|
||||
segmentAudits={[]}
|
||||
segmentLoading={false}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('不含引流')).toBeVisible();
|
||||
expect(document.querySelector('mark')).toBeNull();
|
||||
rerender(<SendDetailModal record={{ ...record, hasDrainageContent: undefined }} segmentAudits={[]} segmentLoading={false} onClose={() => {}} />);
|
||||
rerender(
|
||||
<SendDetailModal
|
||||
record={{ ...record, hasDrainageContent: undefined }}
|
||||
segmentAudits={[]}
|
||||
segmentLoading={false}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('未检测')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides no-hit routing snapshots while retaining sensitive-word failures', () => {
|
||||
const clean = { id: 'clean', decidedAt: '2026-09-14T07:41:51Z', snapshot: { hits: [], reason: null } };
|
||||
const props = { segmentAudits: [], segmentLoading: false, onClose: () => {} };
|
||||
const { rerender } = render(
|
||||
<SendDetailModal {...props} record={{ ...record, channelWordDecisions: [clean] } as unknown as SmsMessageRecord} />,
|
||||
);
|
||||
expect(screen.queryByRole('region', { name: '通道筛选原因' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/候选通道未命中/)).not.toBeInTheDocument();
|
||||
rerender(
|
||||
<SendDetailModal
|
||||
{...props}
|
||||
record={
|
||||
{
|
||||
...record,
|
||||
channelWordDecisions: [
|
||||
clean,
|
||||
{ ...clean, id: 'blocked', snapshot: { hits: [], reason: '可用通道均命中通道敏感词' } },
|
||||
],
|
||||
} as unknown as SmsMessageRecord
|
||||
}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/可用通道均命中通道敏感词/)).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -66,7 +66,11 @@ export function Select({
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node) && !dropdownRef.current?.contains(event.target as Node)) {
|
||||
if (
|
||||
rootRef.current &&
|
||||
!rootRef.current.contains(event.target as Node) &&
|
||||
!dropdownRef.current?.contains(event.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
setSearchKeyword('');
|
||||
}
|
||||
@@ -77,10 +81,8 @@ export function Select({
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !dropdownPortal) {
|
||||
setPortalStyle(null);
|
||||
return;
|
||||
}
|
||||
// Closed/non-portal menus do not consume portalStyle; opening measures it before paint.
|
||||
if (!open || !dropdownPortal) return;
|
||||
|
||||
function updatePosition() {
|
||||
const trigger = rootRef.current?.querySelector<HTMLElement>('.ui-select');
|
||||
@@ -122,16 +124,28 @@ export function Select({
|
||||
className={['ui-select__dropdown', dropdownPortal ? 'ui-select__dropdown--portal' : ''].filter(Boolean).join(' ')}
|
||||
ref={dropdownRef}
|
||||
role="listbox"
|
||||
style={dropdownPortal ? portalStyle ?? { visibility: 'hidden' } : undefined}
|
||||
style={dropdownPortal ? (portalStyle ?? { visibility: 'hidden' }) : undefined}
|
||||
>
|
||||
{searchEnabled ? (
|
||||
<label className="ui-select__search">
|
||||
<Search size={15} />
|
||||
<input autoFocus onChange={(event) => setSearchKeyword(event.target.value)} onKeyDown={(event) => event.stopPropagation()} placeholder={searchPlaceholder ?? '输入名称搜索'} value={searchKeyword} />
|
||||
<input
|
||||
autoFocus
|
||||
onChange={(event) => setSearchKeyword(event.target.value)}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
placeholder={searchPlaceholder ?? '输入名称搜索'}
|
||||
value={searchKeyword}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{visibleOptions.map((option) => (
|
||||
<button aria-selected={option.value === selectedValue} key={option.value} onClick={() => selectOption(option.value)} role="option" type="button">
|
||||
<button
|
||||
aria-selected={option.value === selectedValue}
|
||||
key={option.value}
|
||||
onClick={() => selectOption(option.value)}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
@@ -140,15 +154,15 @@ export function Select({
|
||||
);
|
||||
|
||||
return (
|
||||
<label
|
||||
className={['ui-field', className].filter(Boolean).join(' ')}
|
||||
htmlFor={selectId}
|
||||
ref={rootRef}
|
||||
>
|
||||
<label className={['ui-field', className].filter(Boolean).join(' ')} htmlFor={selectId} ref={rootRef}>
|
||||
{label ? (
|
||||
<span className="ui-field__label">
|
||||
{label}
|
||||
{required ? <span aria-label="必填" className="ui-field__required">*</span> : null}
|
||||
{required ? (
|
||||
<span aria-label="必填" className="ui-field__required">
|
||||
*
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
@@ -157,17 +171,23 @@ export function Select({
|
||||
open ? 'ui-select--open' : '',
|
||||
error ? 'ui-select--error' : '',
|
||||
disabled ? 'ui-select--disabled' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<button
|
||||
aria-label={props['aria-label']}
|
||||
aria-labelledby={props['aria-labelledby']}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
disabled={disabled}
|
||||
id={selectId}
|
||||
onClick={() => setOpen((current) => {
|
||||
if (current) setSearchKeyword('');
|
||||
return !current;
|
||||
})}
|
||||
onClick={() =>
|
||||
setOpen((current) => {
|
||||
if (current) setSearchKeyword('');
|
||||
return !current;
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<span className={selectedOption?.value ? '' : 'ui-select__placeholder'}>
|
||||
|
||||
Reference in New Issue
Block a user