fix: validate signatures and restore report metrics

This commit is contained in:
hectorzhao
2026-07-27 20:51:14 +08:00
parent 04a497c791
commit 94aeacd3a2
19 changed files with 438 additions and 42 deletions
+33 -11
View File
@@ -46,7 +46,26 @@ function ReportStatus({ value }: { value?: string }) {
return <Tag tone={meta.tone}>{meta.label}</Tag>;
}
function DetailModal({ drainage, signature, task, onClose }: { drainage?: DrainageItem; signature?: ClientSmsSignature; task: ReportTask; onClose: () => void }) {
function DeliveryStats({ task }: { task: ReportTask }) {
const stats = task.deliveryStats ?? {
submitFailureCount: 0,
submitFailureRate: 0,
successCount: 0,
successRate: 0,
unknownCount: 0,
unknownRate: 0,
failureCount: 0,
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>
</div>;
}
function DetailModal({ drainage, reportedAt, signature, task, onClose }: { drainage?: DrainageItem; reportedAt?: string | null; signature?: ClientSmsSignature; task: ReportTask; onClose: () => void }) {
const payload = asRecord(signature?.drainageInfo);
const profile = asRecord(payload.signatureProfile);
const reportValues = asRecord(drainage ? drainage.reportValues : payload.signatureReportValues);
@@ -56,9 +75,13 @@ function DetailModal({ drainage, signature, task, onClose }: { drainage?: Draina
<strong>{drainage ? String(drainage.siteName || drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong>
<p><span></span><span>{signature?.tenant?.name ?? task.tenantId}</span></p>
<p><span></span><span>{signature?.application?.name ?? '-'}</span></p>
<p><span></span><DateTime value={drainage?.submittedAt ?? task.createdAt} /></p>
<p><span></span><DateTime value={reportedAt} /></p>
<p><span></span><DateTime value={task.lastSuccessfulSentAt} /></p>
{!drainage ? <><p><span></span><span>{String(profile.basis ?? '-')}</span></p><p><span></span><span>{String(profile.companyName ?? '-')}</span></p><p><span></span><span>{String(profile.creditCode ?? '-')}</span></p></> : null}
{drainage ? <><p><span></span><span>{String(drainage.url ?? '-')}</span></p><p><span></span><span>{String(drainage.remark ?? '-')}</span></p></> : null}
{Object.entries(reportValues).map(([key, value]) => <p key={key}><span>{key}</span><span>{typeof value === 'object' ? String(asRecord(value).fileName ?? asRecord(value).fileObjectId ?? '-') : String(value ?? '-')}</span></p>)}
<section><h3></h3><DeliveryStats task={task} /></section>
</div>
</Modal>
);
@@ -75,7 +98,7 @@ export function AdminChannelReportPage() {
const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]);
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('all');
const [detail, setDetail] = useState<{ task: ReportTask; signature?: ClientSmsSignature; drainage?: DrainageItem }>();
const [detail, setDetail] = useState<{ task: ReportTask; reportedAt?: string | null; signature?: ClientSmsSignature; drainage?: DrainageItem }>();
const [statusTask, setStatusTask] = useState<ReportTask>();
const [nextStatus, setNextStatus] = useState('approved');
const [statusReason, setStatusReason] = useState('');
@@ -111,8 +134,8 @@ export function AdminChannelReportPage() {
return matchesKeyword && (status === 'all' || task.status === status);
}), [keyword, signatureMap, status, tasks]);
function lastRecord(taskId: string, action: string) {
return records.find((record) => record.taskId === taskId && record.action === action);
function approvedRecord(taskId: string) {
return records.find((record) => record.taskId === taskId && record.statusAfter === 'approved');
}
async function saveFieldMapping(nextFields: Parameters<typeof adminApi.replaceChannelReportFields>[2]) {
@@ -154,21 +177,20 @@ export function AdminChannelReportPage() {
</div>
<div className="surface channel-report-table">
<div className="channel-report-table__head"><span /><span> / </span><span></span><span></span><span></span><span></span><span></span><span></span></div>
<div className="channel-report-table__head"><span /><span> / </span><span></span><span></span><span></span><span></span><span></span><span></span></div>
{visibleTasks.length === 0 ? <div className="channel-report-empty"></div> : visibleTasks.map((task) => {
const signature = signatureMap.get(task.signatureId);
const drainage = task.reportType === 'drainage' ? drainageItems(signature).find((item) => String(item.id) === task.drainageItemId) : undefined;
const exported = lastRecord(task.id, 'export');
const imported = lastRecord(task.id, 'receipt_import');
const reportedAt = approvedRecord(task.id)?.createdAt;
return <div className={`channel-report-row ${drainage ? 'channel-report-row--drainage' : 'channel-report-row--signature'}`} key={task.id}>
<span />
<div className={`channel-report-name ${drainage ? 'channel-report-name--flow' : ''}`}>{drainage ? <i /> : null}<span><strong>{drainage ? String(drainage.siteName || drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong><small>{drainage ? `${String(drainage.url ?? '')} · ${formatSignatureName(signature?.name ?? task.signature?.name)}` : signature?.tenant?.name ?? task.tenantId}</small></span></div>
<ReportStatus value={task.status} />
<DateTime value={drainage?.submittedAt ?? task.createdAt} />
<DateTime value={imported?.createdAt ?? exported?.createdAt} />
<DateTime value={task.updatedAt} />
<div className="channel-report-stats"><span><strong className="is-success">-</strong><b></b></span><span><strong className="is-warning">-</strong><b></b></span><span><strong className="is-danger">-</strong><b></b></span></div>
<div className="channel-report-actions"><button onClick={() => setDetail({ task, signature, drainage })} type="button"><Eye size={16} /></button><button className="is-warning" onClick={() => { setStatusTask(task); setNextStatus(task.status); }} type="button"></button></div>
<DateTime value={reportedAt} />
<DateTime value={task.lastSuccessfulSentAt} />
<DeliveryStats task={task} />
<div className="channel-report-actions"><button onClick={() => setDetail({ task, reportedAt, signature, drainage })} type="button"><Eye size={16} /></button><button className="is-warning" onClick={() => { setStatusTask(task); setNextStatus(task.status); }} type="button"></button></div>
</div>;
})}
</div>
@@ -4,7 +4,7 @@ import { adminApi, type ApplicationReportField, type ClientSmsApplication, type
import { Breadcrumb, Button, DeleteRiskAction, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
import { displayFileName } from '@/utils/fileName';
import { formatDateTime } from '@/utils/dateTime';
import { isCompleteSmsSignature } from '@/utils/smsSignature';
import { getSmsSignatureValidationError, hasForbiddenSmsSignatureCharacter, isCompleteSmsSignature, SMS_SIGNATURE_CHARACTER_ERROR } from '@/utils/smsSignature';
type CarrierStatus = 'approved' | 'pending' | 'rejected' | 'filing';
@@ -310,6 +310,7 @@ function SignatureFormModal({
reportValues: payload?.signatureReportValues ?? {},
});
const [reportFields, setReportFields] = useState<ApplicationReportField[]>([]);
const [nameInputError, setNameInputError] = useState('');
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted');
useEffect(() => {
@@ -327,12 +328,15 @@ function SignatureFormModal({
setForm((current) => ({ ...current, reportValues: { ...current.reportValues, [code]: value } }));
}
const signatureNameValid = !nameInputError && isCompleteSmsSignature(form.name);
const signatureNameError = nameInputError || (form.name ? getSmsSignatureValidationError(form.name) : undefined);
return (
<Modal
footer={(
<>
<Button onClick={onClose} variant="ghost"></Button>
<Button disabled={!form.tenantId || !isCompleteSmsSignature(form.name) || hasMissingRequiredReportValue(reportFields, form.reportValues)} onClick={() => onSubmit(form)}></Button>
<Button disabled={!form.tenantId || !signatureNameValid || hasMissingRequiredReportValue(reportFields, form.reportValues)} onClick={() => onSubmit(form)}></Button>
</>
)}
onClose={onClose}
@@ -374,10 +378,18 @@ function SignatureFormModal({
value={form.applicationId}
/>
<Input
error={form.name.trim() && !isCompleteSmsSignature(form.name) ? '必须填写完整中文黑括号签名,例如:【某某科技】' : undefined}
hint="新增和编辑时必须保留完整的【】"
error={signatureNameError}
hint="新增和编辑时必须保留完整的【】,且不能包含空格或不可见字符"
label="短信签名"
onChange={(event) => update('name', event.target.value)}
onChange={(event) => {
const value = event.target.value;
if (hasForbiddenSmsSignatureCharacter(value)) {
setNameInputError(SMS_SIGNATURE_CHARACTER_ERROR);
return;
}
setNameInputError('');
update('name', value);
}}
placeholder="请输入完整签名,例如:【某某科技】"
required
value={form.name}
+2 -1
View File
@@ -3,6 +3,7 @@ import { AlertTriangle, CheckCircle2, Download, FileSpreadsheet, Layers3, Refres
import { adminApi, fileDownloadUrl, type ReportMaterialBatchPreflight, type ReportMaterialBatchResult, type ReportMaterialPendingItem } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Tag } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
import { createUuid } from '@/utils/randomId';
import { ReportMaterialImportModal } from './ReportMaterialImportModal';
type Batch = Record<string, unknown> & { id: string; batchNo?: string; status?: string; createdAt?: string; selectedCount?: number; channelCount?: number; exportFiles?: Array<Record<string, unknown>> };
@@ -49,7 +50,7 @@ export function AdminReportMaterialsPage() {
const chosen = items.filter((item) => selected.has(item.id));
if (!chosen.length) { setError('请先选择具备报备资格的资料'); return; }
setConfirmOpen(true); setPreflightBusy(true); setPreflight(null); setBatchResult(null); setError(''); setMessage('');
setOperationKey(`report-batch:${crypto.randomUUID()}`);
setOperationKey(`report-batch:${createUuid()}`);
try { setPreflight(await adminApi.preflightReportMaterialBatch({ items: chosen.map(toBatchItem) })); }
catch (failure) { setError(failure instanceof Error ? failure.message : '报备资格预检失败'); }
finally { setPreflightBusy(false); }
@@ -4,6 +4,7 @@ import { ArrowLeft, Globe2, Info, RadioTower, RefreshCw } from 'lucide-react';
import { adminApi, type ChannelGroup, type DictionaryItem, type EnterpriseApplication, type HttpApiConfig } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui';
import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
import { createRandomHex } from '@/utils/randomId';
type Carrier = 'mobile' | 'unicom' | 'telecom';
type QueuePriority = 'normal' | 'priority';
@@ -489,8 +490,5 @@ function parseIpAllowlist(value: string) {
}
function generateApplicationPassword() {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID().replace(/-/g, '').slice(0, 16);
}
return Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
return createRandomHex(16);
}