feat: complete reporting and filing workflows

This commit is contained in:
hectorzhao
2026-07-28 20:28:47 +08:00
parent 352a6293b4
commit 99c8c7c68b
52 changed files with 3490 additions and 376 deletions
+157 -5
View File
@@ -229,9 +229,24 @@ export type EnterpriseCertification = {
rejectReason?: string | null;
submittedAt: string;
reviewedAt?: string | null;
reviewer?: { id: string; username: string; displayName: string } | null;
tenant?: { id: string; name: string; code: string };
};
export type AuditRecord = {
id: string;
tenantId?: string | null;
targetType: string;
targetId: string;
action: string;
statusBefore?: string | null;
statusAfter: string;
reason?: string | null;
reviewerId?: string | null;
reviewer?: { id: string; username: string; displayName: string } | null;
createdAt: string;
};
export type SmsTemplateAudit = {
id: string;
tenantId: string;
@@ -384,6 +399,47 @@ export type SignatureQualityStat = {
averageArrivalMs?: number | null;
};
export type SignatureChannelCarrierQualityStat = {
signatureId: string;
channelId: string;
channelName: string;
carrier: string;
total: number;
acceptedCount: number;
submitFailureCount: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
averageArrivalMs?: number | null;
};
export type SignatureChannelQualityItem = {
signatureId: string;
signatureName: string;
tenantId: string;
tenantName: string;
applicationNames?: string | null;
total: number;
acceptedCount: number;
submitFailureCount: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
averageArrivalMs?: number | null;
channelSubmitTotal: number;
breakdowns: SignatureChannelCarrierQualityStat[];
};
export type SignatureChannelQualityResponse = {
date: string;
items: SignatureChannelQualityItem[];
total: number;
page: number;
pageSize: number;
};
export type DailySendSummary = {
total: number;
successCount: number;
@@ -828,6 +884,61 @@ export type ReportMaterialPendingItem = {
application?: ClientSmsApplication | null;
};
export type PagedResult<T> = {
items: T[];
total: number;
page: number;
pageSize: number;
};
export type ReportMaterialBatch = {
id: string;
batchNo: string;
status: string;
selectedCount: number;
channelCount: number;
fileCount: number;
reportTotal: number;
successCount: number;
successRate: number;
createdAt: string;
completedAt?: string | null;
exportFiles: Array<{ id: string; fileObjectId?: string | null; fileName: string; rowCount: number; channelId?: string | null }>;
};
export type ReportImportReviewItem = {
id: string;
rowNumber: number;
reportType: 'signature' | 'drainage';
operation: 'create' | 'update' | 'invalid';
targetId?: string | null;
status: string;
payload: Record<string, unknown>;
originalSnapshot?: Record<string, unknown> | null;
errorMessage?: string | null;
reviewReason?: string | null;
reviewedAt?: string | null;
reviewer?: { id: string; username: string; displayName: string } | null;
};
export type ReportImportReviewBatch = {
id: string;
tenantId: string;
applicationId?: string | null;
fileName: string;
reportType: 'signature' | 'drainage';
status: string;
rowCount: number;
successCount: number;
failedCount: number;
createdAt: string;
reviewedAt?: string | null;
tenant?: { id: string; name: string } | null;
application?: { id: string; name: string } | null;
reviewer?: { id: string; username: string; displayName: string } | null;
items: ReportImportReviewItem[];
};
export type ReportMaterialPreflightTarget = {
id: string;
name: string;
@@ -925,9 +1036,33 @@ export type ReportTask = DictionaryItem & {
reportType?: 'signature' | 'drainage';
drainageItemId?: string | null;
status: string;
signature?: { id: string; name: string; purpose?: string | null; drainageInfo?: Record<string, unknown> | null };
signature?: {
id: string;
name: string;
purpose?: string | null;
drainageInfo?: Record<string, unknown> | null;
tenant?: { id: string; name: string };
application?: { id: string; name: string } | null;
};
drainageInfo?: SmsDrainageInfo | null;
channel?: { id: string; name: string; code: string };
reason?: string | null;
createdAt?: string;
updatedAt?: string;
exportItems?: Array<{
id: string;
rowNumber: number;
exportFile: { id: string; fileObjectId?: string | null; fileName: string; rowCount: number; batchId?: string | null };
batchItem: { id: string; materialVersion: number; batch: { id: string; batchNo: string; createdAt: string } };
}>;
records?: Array<{
id: string;
action: string;
statusBefore?: string | null;
statusAfter: string;
reason?: string | null;
createdAt: string;
}>;
deliveryStats?: {
total: number;
acceptedCount: number;
@@ -1001,6 +1136,8 @@ export type RiskReviewTask = {
rejectReason?: string | null;
createdAt: string;
reviewedAt?: string | null;
tenant?: { id: string; name: string } | null;
application?: { id: string; name: string } | null;
reviewedBy?: { id: string; username: string; displayName: string } | null;
riskHits?: Array<{ id: string; ruleName: string; reason: string }>;
_count?: { messageRecords: number };
@@ -1036,6 +1173,8 @@ export type RiskTaskMessagePage = {
pageSize: number;
};
export type BatchTaskMessagePage = RiskTaskMessagePage;
export type TenantAccount = {
id: string;
tenantId: string;
@@ -1483,6 +1622,8 @@ export type DownstreamRecoveryStatusExportQuery = {
state?: string;
failureCategory?: string;
keyword?: string;
updatedAtFrom?: string;
updatedAtTo?: string;
};
function withQuery(path: string, query: Record<string, string | number | undefined>) {
@@ -1539,6 +1680,8 @@ export const adminApi = {
request<ManagedUser>(`/admin/users/${id}/password`, { method: 'POST', body: JSON.stringify({ password, operatorId }) }),
getDashboard: (tenantId?: string) => request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
getSendQuality: (date?: string) => request<SendQualityResponse>(withQuery('/admin/operations/send-quality', { date })),
getSignatureQuality: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
request<SignatureChannelQualityResponse>(withQuery('/admin/operations/signature-quality', query)),
listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) =>
request<OperationLogResponse>(withQuery('/admin/system-logs', query)),
listProtocolInteractionLogs: (query: { protocol?: string; direction?: string; eventType?: string; status?: string; keyword?: string; range?: string; page?: number; pageSize?: number }) =>
@@ -1655,6 +1798,8 @@ export const adminApi = {
request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }),
listDrainageInfos: (query: { tenantId?: string; signatureId?: string; keyword?: string; status?: string } = {}) =>
request<SmsDrainageInfo[]>(withQuery('/admin/drainage-infos', query)),
listAuditRecords: (query: { targetType?: string; targetId?: string } = {}) =>
request<AuditRecord[]>(withQuery('/admin/audit-records', query)),
createDrainageInfo: (signatureId: string, body: { siteName: string; url: string; remark?: string; reportValues?: Record<string, unknown> }) =>
request<SmsDrainageInfo>(`/admin/enterprise-signatures/${signatureId}/drainage-infos`, { method: 'POST', body: JSON.stringify(body) }),
updateDrainageInfo: (id: string, body: { siteName?: string; url?: string; remark?: string; reportValues?: Record<string, unknown> }) =>
@@ -1709,8 +1854,8 @@ export const adminApi = {
request<ChannelReportField>('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }),
replaceChannelReportFields: (channelId: string, reportType: 'signature' | 'drainage', fields: Array<Record<string, unknown>>) =>
request<ChannelReportField[]>(`/admin/channels/${channelId}/report-fields/${reportType}`, { method: 'PUT', body: JSON.stringify({ fields }) }),
listPendingReportMaterials: (query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } = {}) =>
request<ReportMaterialPendingItem[]>(withQuery('/admin/report-materials/pending', query)),
listPendingReportMaterials: (query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string; keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResult<ReportMaterialPendingItem>>(withQuery('/admin/report-materials/pending', query)),
listReportImportProfiles: (reportType?: 'signature' | 'drainage') =>
request<ReportImportProfile[]>(withQuery('/admin/report-materials/import-profiles', { reportType })),
saveReportImportProfile: (body: Omit<ReportImportProfile, 'id'> & { id?: string }) =>
@@ -1724,7 +1869,12 @@ export const adminApi = {
},
commitReportMaterialImport: (id: string, body: { mappings: ReportImportMapping[]; profile?: Omit<ReportImportProfile, 'id'> & { id?: string } }) =>
request<Record<string, unknown>>(`/admin/report-materials/imports/${id}/commit`, { method: 'PUT', body: JSON.stringify(body) }),
listReportMaterialBatches: () => request<Array<Record<string, unknown>>>('/admin/report-materials/batches'),
listReportImportReviewBatches: (query: { reportType?: 'signature' | 'drainage'; status?: string; keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResult<ReportImportReviewBatch>>(withQuery('/admin/report-materials/imports/review-batches', query)),
reviewReportImportItems: (id: string, body: { decision: 'approve' | 'reject'; itemIds?: string[]; reason?: string }) =>
request<{ batchId: string; status: string; approvedCount: number; rejectedCount: number; failedCount: number }>(`/admin/report-materials/imports/${id}/review`, { method: 'POST', body: JSON.stringify(body) }),
listReportMaterialBatches: (query: { keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResult<ReportMaterialBatch>>(withQuery('/admin/report-materials/batches', query)),
preflightReportMaterialBatch: (body: { items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion?: number }> }) =>
request<ReportMaterialBatchPreflight>('/admin/report-materials/batches/preflight', { method: 'POST', body: JSON.stringify(body) }),
createReportMaterialBatch: (body: { idempotencyKey: string; items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion: number }> }) =>
@@ -1741,6 +1891,8 @@ export const adminApi = {
listReportRecords: (query: { taskId?: string; channelId?: string } = {}) => request<ReportRecord[]>(withQuery('/admin/report-records', query)),
listAdminBatchTasks: (query: { tenantId?: string; status?: string } = {}) =>
request<SmsBatchTask[]>(withQuery('/admin/send/batch-tasks', query)),
listAdminBatchTaskMessages: (id: string, query: { phone?: string; page?: number; pageSize?: number } = {}) =>
request<BatchTaskMessagePage>(withQuery(`/admin/send/batch-tasks/${id}/messages`, query)),
terminateAdminBatchTask: (id: string) =>
request<SmsBatchTask>(`/admin/send/batch-tasks/${id}/terminate`, { method: 'POST', body: JSON.stringify({}) }),
listAdminMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; taskId?: string; phoneNumber?: string; status?: string } = {}) =>
@@ -1761,7 +1913,7 @@ export const adminApi = {
listStatistics: (query: { tenantId?: string; groupBy?: string } = {}) => request<Array<Record<string, unknown>>>(withQuery('/admin/operations/statistics', query)),
getDownstreamDeliveryDashboard: (query: { tenantId?: string; applicationId?: string; deliveryType?: string; createdAtFrom?: string; createdAtTo?: string } = {}) =>
request<DownstreamDeliveryDashboard>(withQuery('/admin/operations/downstream-deliveries/dashboard', query)),
listDownstreamRecoveryStatuses: (query: { tenantId?: string; applicationId?: string; state?: string; failureCategory?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
listDownstreamRecoveryStatuses: (query: { tenantId?: string; applicationId?: string; state?: string; failureCategory?: string; keyword?: string; updatedAtFrom?: string; updatedAtTo?: string; page?: number; pageSize?: number } = {}) =>
request<DownstreamRecoveryStatusResponse>(withQuery('/admin/operations/downstream-recovery-statuses', query)),
getDownstreamRecoveryStatus: (id: string) =>
request<GatewayDownstreamRecoveryStatus>(`/admin/operations/downstream-recovery-statuses/${id}`),
+394 -13
View File
@@ -1,27 +1,77 @@
import { useEffect, useMemo, useState } from 'react';
import { BarChart3 } from 'lucide-react';
import { adminApi, type SendQualityResponse } from '@/api/adminApi';
import { Breadcrumb, Button, Chart, Input, Tag } from '@/components/ui';
import { BarChart3, Eye, Search, X } from 'lucide-react';
import {
adminApi,
type SendQualityResponse,
type SignatureChannelCarrierQualityStat,
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';
const carrierOrder = ['mobile', 'unicom', 'telecom', 'unknown'];
const carrierLabels: Record<string, string> = {
mobile: '移动',
unicom: '联通',
telecom: '电信',
unknown: '未知',
};
export function AdminAnalyticsPage() {
const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey());
const [quality, setQuality] = useState<SendQualityResponse | null>(null);
const [signatureQuality, setSignatureQuality] = useState<SignatureChannelQualityResponse | null>(null);
const [signatureKeyword, setSignatureKeyword] = useState('');
const [appliedKeyword, setAppliedKeyword] = useState('');
const [selectedSignature, setSelectedSignature] = useState<SignatureChannelQualityItem | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
function loadData() {
adminApi.getSendQuality(statisticsDate)
.then((qualityData) => {
setQuality(qualityData);
setError('');
})
.catch((failure: Error) => setError(failure.message || '统计数据加载失败'));
async function loadData(page = 1, keyword = appliedKeyword) {
setLoading(true);
try {
const [qualityData, signatureData] = await Promise.all([
adminApi.getSendQuality(statisticsDate),
adminApi.getSignatureQuality({
date: statisticsDate,
keyword: keyword || undefined,
page,
pageSize: 10,
}),
]);
setQuality(qualityData);
setSignatureQuality(signatureData);
setAppliedKeyword(keyword);
setSelectedSignature((current) => current
? signatureData.items.find((item) => item.signatureId === current.signatureId) ?? null
: null);
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '统计数据加载失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
loadData();
void loadData(1, '');
}, []);
useEffect(() => {
if (!selectedSignature) return undefined;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setSelectedSignature(null);
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKeyDown);
};
}, [selectedSignature]);
const applicationOption = useMemo(() => createBarOption({
labels: quality?.applications.map((item) => item.applicationName) ?? [],
series: [{ name: '发送量', data: quality?.applications.map((item) => item.total) ?? [] }],
@@ -32,6 +82,88 @@ export function AdminAnalyticsPage() {
}), [quality]);
const effectiveDate = quality?.date ?? statisticsDate;
const signatureColumns: Array<TableColumn<SignatureChannelQualityItem>> = [
{
key: 'signature',
title: '短信签名',
width: '260px',
render: (record) => (
<div className="signature-quality-name">
<strong>{record.signatureName}</strong>
<span>{record.tenantName}</span>
<small>{record.applicationNames || '全部企业应用'}</small>
</div>
),
},
{
key: 'businessTotal',
title: '业务短信',
width: '110px',
align: 'right',
render: (record) => record.total.toLocaleString('zh-CN'),
},
{
key: 'channelSubmitTotal',
title: '通道提交',
width: '110px',
align: 'right',
render: (record) => record.channelSubmitTotal.toLocaleString('zh-CN'),
},
{
key: 'successCount',
title: '送达成功',
width: '110px',
align: 'right',
render: (record) => <span className="quality-number quality-number--success">{record.successCount.toLocaleString('zh-CN')}</span>,
},
{
key: 'failureCount',
title: '送达失败',
width: '110px',
align: 'right',
render: (record) => <span className="quality-number quality-number--danger">{record.failureCount.toLocaleString('zh-CN')}</span>,
},
{
key: 'submitFailureCount',
title: '提交失败',
width: '110px',
align: 'right',
render: (record) => <span className="quality-number quality-number--warning">{record.submitFailureCount.toLocaleString('zh-CN')}</span>,
},
{
key: 'successRate',
title: '成功率',
width: '170px',
render: (record) => <QualityRate value={record.successRate} />,
},
{
key: 'averageArrivalMs',
title: '平均到达时间',
width: '140px',
align: 'right',
render: (record) => formatDuration(record.averageArrivalMs),
},
{
key: 'actions',
title: '操作',
width: '110px',
align: 'right',
render: (record) => (
<Button icon={<Eye size={15} />} onClick={() => setSelectedSignature(record)} size="sm" variant="ghost">
</Button>
),
},
];
function queryStatistics() {
void loadData(1, signatureKeyword.trim());
}
function changeSignaturePage(page: number) {
void loadData(page, appliedKeyword);
}
return (
<section className="page-stack">
<div className="page-heading">
@@ -40,13 +172,15 @@ export function AdminAnalyticsPage() {
</div>
<div className="page-actions">
<Input
aria-label="通道占比统计日期"
aria-label="统计日期"
max={shanghaiDateKey()}
onChange={(event) => setStatisticsDate(event.target.value)}
type="date"
value={statisticsDate}
/>
<Button icon={<BarChart3 size={16} />} onClick={loadData} variant="ghost"></Button>
<Button disabled={loading} icon={<BarChart3 size={16} />} onClick={queryStatistics} variant="ghost">
{loading ? '查询中' : '查询统计'}
</Button>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
@@ -86,10 +220,257 @@ export function AdminAnalyticsPage() {
<Chart height={320} option={channelOption} />
</div>
</div>
<div className="surface signature-quality-card">
<div className="signature-quality-card__heading">
<div>
<div className="section-heading__title">
<h2></h2>
<Tag tone="info"></Tag>
</div>
<p className="muted">
{signatureQuality?.date ?? effectiveDate}
</p>
</div>
<div className="signature-quality-card__query">
<Input
aria-label="搜索短信签名、企业或应用"
onChange={(event) => setSignatureKeyword(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') queryStatistics();
}}
placeholder="搜索签名、企业或应用"
value={signatureKeyword}
/>
<Button disabled={loading} icon={<Search size={16} />} onClick={queryStatistics} variant="secondary"></Button>
</div>
</div>
<div className="signature-quality-card__note">
<strong></strong>
</div>
<Table
columns={signatureColumns}
data={signatureQuality?.items ?? []}
emptyText={loading ? '正在加载签名统计…' : '所选日期暂无已登记签名发送数据'}
pagination={false}
rowKey="signatureId"
/>
{(signatureQuality?.total ?? 0) > 0 ? (
<Pagination
nextDisabled={(signatureQuality?.page ?? 1) >= Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? 10))}
onNext={() => changeSignaturePage((signatureQuality?.page ?? 1) + 1)}
onPageChange={changeSignaturePage}
onPrevious={() => changeSignaturePage((signatureQuality?.page ?? 1) - 1)}
page={signatureQuality?.page ?? 1}
previousDisabled={(signatureQuality?.page ?? 1) <= 1}
total={signatureQuality?.total ?? 0}
totalPages={Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? 10))}
/>
) : null}
</div>
{selectedSignature ? (
<SignatureQualityDrawer
date={signatureQuality?.date ?? effectiveDate}
item={selectedSignature}
onClose={() => setSelectedSignature(null)}
/>
) : null}
</section>
);
}
function SignatureQualityDrawer({
date,
item,
onClose,
}: {
date: string;
item: SignatureChannelQualityItem;
onClose: () => void;
}) {
const carriers = aggregateByCarrier(item.breakdowns);
const channels = [...new Map(item.breakdowns.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));
return (
<div className="signature-quality-drawer__backdrop" onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}>
<aside aria-labelledby="signature-quality-drawer-title" aria-modal="true" className="signature-quality-drawer" role="dialog">
<div className="signature-quality-drawer__header">
<div>
<p> / {item.signatureName}</p>
<h2 id="signature-quality-drawer-title">{item.signatureName}</h2>
<span>{date} · {item.tenantName} · {item.applicationNames || '全部企业应用'}</span>
</div>
<button aria-label="关闭签名发送质量详情" onClick={onClose} type="button"><X size={20} /></button>
</div>
<div className="signature-quality-drawer__body">
<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={formatDuration(item.averageArrivalMs)} />
</div>
<section className="signature-quality-section">
<div className="signature-quality-section__heading">
<div>
<h3></h3>
<p></p>
</div>
</div>
<div className="signature-carrier-grid">
{carriers.map((carrier) => (
<article className={`signature-carrier-card signature-carrier-card--${normalizeCarrier(carrier.carrier)}`} key={carrier.carrier}>
<div>
<Tag tone={carrierTagTone(carrier.carrier)}>{carrierLabel(carrier.carrier)}</Tag>
<strong>{carrier.total.toLocaleString('zh-CN')} </strong>
</div>
<dl>
<div><dt></dt><dd>{carrier.successRate.toFixed(1)}%</dd></div>
<div><dt></dt><dd>{formatDuration(carrier.averageArrivalMs)}</dd></div>
<div><dt></dt><dd>{carrier.channelCount} </dd></div>
</dl>
</article>
))}
</div>
</section>
<section className="signature-quality-section">
<div className="signature-quality-section__heading">
<div>
<h3> × </h3>
<p></p>
</div>
</div>
<div className="signature-quality-matrix">
<table>
<thead>
<tr>
<th></th>
{visibleCarriers.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
));
return (
<td key={carrier}>
{metric ? <MatrixMetric metric={metric} /> : <span className="signature-quality-matrix__empty"></span>}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</section>
<p className="signature-quality-drawer__footnote">
</p>
</div>
</aside>
</div>
);
}
function QualityMetric({ label, value, tone = 'default' }: { label: string; value: string; tone?: string }) {
return (
<div className={`signature-quality-metric signature-quality-metric--${tone}`}>
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function MatrixMetric({ metric }: { metric: SignatureChannelCarrierQualityStat }) {
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)}%
</span>
<small>{formatDuration(metric.averageArrivalMs)}</small>
{metric.submitFailureCount > 0 ? <em> {metric.submitFailureCount}</em> : 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>
</div>
);
}
function aggregateByCarrier(items: SignatureChannelCarrierQualityStat[]) {
return carrierOrder
.map((carrier) => {
const entries = items.filter((item) => normalizeCarrier(item.carrier) === carrier);
if (entries.length === 0) return null;
const acceptedCount = entries.reduce((sum, item) => sum + item.acceptedCount, 0);
const successCount = entries.reduce((sum, item) => sum + item.successCount, 0);
const arrivalWeight = entries.reduce((sum, item) => (
sum + (item.averageArrivalMs == null ? 0 : item.averageArrivalMs * item.successCount)
), 0);
const arrivalCount = entries.reduce((sum, item) => sum + (item.averageArrivalMs == null ? 0 : item.successCount), 0);
return {
carrier,
total: entries.reduce((sum, item) => sum + item.total, 0),
successRate: acceptedCount === 0 ? 0 : successCount * 100 / acceptedCount,
averageArrivalMs: arrivalCount === 0 ? null : Math.round(arrivalWeight / arrivalCount),
channelCount: new Set(entries.map((item) => item.channelId)).size,
};
})
.filter((item): item is NonNullable<typeof item> => item !== null);
}
function normalizeCarrier(value: string) {
const normalized = value.toLowerCase();
if (['mobile', 'cmcc', '移动'].includes(normalized)) return 'mobile';
if (['unicom', 'cucc', '联通'].includes(normalized)) return 'unicom';
if (['telecom', 'ctcc', '电信'].includes(normalized)) return 'telecom';
return 'unknown';
}
function carrierLabel(value: string) {
return carrierLabels[normalizeCarrier(value)] ?? '未知';
}
function carrierTagTone(value: string): 'info' | 'accent' | 'warning' | 'neutral' {
const carrier = normalizeCarrier(value);
if (carrier === 'mobile') return 'info';
if (carrier === 'unicom') return 'accent';
if (carrier === 'telecom') return 'warning';
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)} 毫秒`;
return `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}`;
}
function shanghaiDateKey(value = new Date()) {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
+5 -5
View File
@@ -73,7 +73,7 @@ function formFromTenant(tenant: TenantOption, creditCents = 0): EnterpriseForm {
contactPhone: profile?.contactPhone ?? '',
contactEmail: profile?.contactEmail ?? '',
photoFileObjectId: profile?.photoFileObjectId ?? '',
photoFileName: profile?.photoFileObjectId ? '已上传企业照' : '',
photoFileName: profile?.photoFileObjectId ? '已上传企业营业执照' : '',
photoContentType: '',
};
}
@@ -161,12 +161,12 @@ export function AdminCustomerFormPage() {
}));
setError('');
})
.catch((failure: Error) => setError(failure.message || '企业照上传失败'))
.catch((failure: Error) => setError(failure.message || '企业营业执照上传失败'))
.finally(() => setUploadingPhoto(false));
}
const photoFile: FileRef | null = form.photoFileObjectId
? { contentType: form.photoContentType, fileName: form.photoFileName || '企业照', fileObjectId: form.photoFileObjectId }
? { contentType: form.photoContentType, fileName: form.photoFileName || '企业营业执照', fileObjectId: form.photoFileObjectId }
: null;
return (
@@ -189,10 +189,10 @@ export function AdminCustomerFormPage() {
</div>
<div className="enterprise-upload-panel">
<span></span>
<span></span>
<label className="enterprise-upload-button">
<ImagePlus size={28} />
{uploadingPhoto ? '上传中...' : form.photoFileName || '上传企业照'}
{uploadingPhoto ? '上传中...' : form.photoFileName || '上传企业营业执照'}
<input
accept="image/png,image/jpeg,image/webp"
disabled={uploadingPhoto}
+1 -1
View File
@@ -176,7 +176,7 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
</div>
<div className="surface section-stack">
<div className="section-heading"><div><h2></h2><p className="muted"></p></div><Tag tone="info">{filteredRecords.length} </Tag></div>
<div className="section-heading"><div><h2></h2></div><Tag tone="info">{filteredRecords.length} </Tag></div>
<Table columns={columns} data={filteredRecords} emptyText="暂无企业" rowKey="id" />
</div>
@@ -42,6 +42,17 @@ const attemptStatusLabel: Record<string, string> = {
failed: '投递失败',
};
function formatLocalDate(value: Date) {
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
}
function recentSevenDays(): DateRangeValue {
const end = new Date();
const start = new Date(end);
start.setDate(end.getDate() - 6);
return { start: formatLocalDate(start), end: formatLocalDate(end) };
}
function attemptStatusTone(status: string) {
if (status === 'acknowledged') return 'success' as const;
if (status === 'rejected' || status === 'failed') return 'danger' as const;
@@ -137,7 +148,7 @@ export function AdminDownstreamDeliveriesPage() {
const [error, setError] = useState('');
const [detail, setDetail] = useState<DownstreamDeliveryRecord | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [dateRange, setDateRange] = useState<DateRangeValue>(recentSevenDays);
const loadData = useCallback(() => {
setLoading(true);
@@ -295,7 +306,7 @@ export function AdminDownstreamDeliveriesPage() {
setStatus('all');
setDeliveryType('all');
setApplicationId('all');
setDateRange({});
setDateRange(recentSevenDays());
setPage(1);
}}
variant="ghost"
@@ -1,7 +1,18 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertTriangle, CheckCircle2, Download, Eye, RefreshCw, Search, TimerReset } from 'lucide-react';
import { AlertTriangle, CheckCircle2, Download, Eye, Info, RefreshCw, Search, TimerReset } from 'lucide-react';
import { adminApi, type EnterpriseApplication, type GatewayDownstreamRecoveryStatus } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, type DateRangeValue, type TableColumn } from '@/components/ui';
function formatLocalDate(value: Date) {
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
}
function recentSevenDays(): DateRangeValue {
const end = new Date();
const start = new Date(end);
start.setDate(end.getDate() - 6);
return { start: formatLocalDate(start), end: formatLocalDate(end) };
}
const recoveryStatusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
running: 'info',
@@ -101,6 +112,7 @@ export function AdminDownstreamRecoveryStatusesPage() {
const [state, setState] = useState('all');
const [failureCategory, setFailureCategory] = useState('all');
const [applicationId, setApplicationId] = useState('all');
const [dateRange, setDateRange] = useState<DateRangeValue>(recentSevenDays);
const [page, setPage] = useState(1);
const [pageSize] = useState(10);
const [total, setTotal] = useState(0);
@@ -112,7 +124,16 @@ export function AdminDownstreamRecoveryStatusesPage() {
const loadData = useCallback(() => {
setLoading(true);
Promise.all([
adminApi.listDownstreamRecoveryStatuses({ keyword, state, failureCategory, applicationId, page, pageSize }),
adminApi.listDownstreamRecoveryStatuses({
keyword,
state,
failureCategory,
applicationId,
updatedAtFrom: dateRange.start,
updatedAtTo: dateRange.end,
page,
pageSize,
}),
adminApi.listEnterpriseApplications(),
])
.then(([response, apps]) => {
@@ -129,7 +150,7 @@ export function AdminDownstreamRecoveryStatusesPage() {
setError(failure.message || '恢复状态加载失败');
})
.finally(() => setLoading(false));
}, [applicationId, failureCategory, keyword, page, pageSize, state]);
}, [applicationId, dateRange.end, dateRange.start, failureCategory, keyword, page, pageSize, state]);
useEffect(() => {
loadData();
@@ -146,7 +167,7 @@ export function AdminDownstreamRecoveryStatusesPage() {
{ key: 'failureCategory', title: '失败分类', width: '140px', render: (record) => record.failureCategory ? <Tag tone={failureCategoryTone[record.failureCategory] ?? 'neutral'}>{failureCategoryLabel[record.failureCategory] ?? record.failureCategory}</Tag> : '-' },
{ key: 'attemptCount', title: '尝试次数', width: '96px', align: 'center', render: (record) => record.attemptCount },
{ key: 'nextRetryAt', title: '下次恢复', width: '180px', render: (record) => record.nextRetryAt ?? '-' },
{ key: 'lastError', title: '最后错误/跳过原因', render: (record) => record.lastError ?? record.lastSkipReason ?? '-' },
{ key: 'lastError', title: '最后错误/跳过原因', width: '320px', render: (record) => record.lastError ?? record.lastSkipReason ?? '-' },
{
key: 'actions',
title: '操作',
@@ -180,13 +201,21 @@ export function AdminDownstreamRecoveryStatusesPage() {
setState('all');
setFailureCategory('all');
setApplicationId('all');
setDateRange(recentSevenDays());
setPage(1);
}
async function exportCurrent() {
setExporting(true);
try {
const blob = await adminApi.exportDownstreamRecoveryStatuses({ keyword, state, failureCategory, applicationId });
const blob = await adminApi.exportDownstreamRecoveryStatuses({
keyword,
state,
failureCategory,
applicationId,
updatedAtFrom: dateRange.start,
updatedAtTo: dateRange.end,
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
@@ -217,6 +246,17 @@ export function AdminDownstreamRecoveryStatusesPage() {
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface recovery-status-explainer">
<Info size={20} />
<div>
<strong></strong>
<p>
Gateway
ACK
</p>
</div>
</div>
<div className="dashboard-grid">
<div className="surface mini-status-card">
<RefreshCw size={22} />
@@ -271,6 +311,7 @@ export function AdminDownstreamRecoveryStatusesPage() {
<div className="surface admin-task-filter">
<Input label="账号 / 企业 / 应用 / 错误" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
<DateRangeInput label="最近更新时间" onChange={(value) => { setDateRange(value); setPage(1); }} value={dateRange} />
<Select
label="状态"
options={[
@@ -324,9 +365,11 @@ export function AdminDownstreamRecoveryStatusesPage() {
</div>
<div className="surface admin-task-table-card report-task-table-card">
<div className="section-heading">
<h2></h2>
<p className="page-inline-hint"></p>
<div className="section-heading admin-task-table-card__heading">
<div>
<h2></h2>
<p className="page-inline-hint"> 7 </p>
</div>
</div>
<Table columns={columns} data={items} emptyText={loading ? '加载中...' : '暂无恢复状态'} pagination={false} rowKey="id" />
<Pagination
+7 -3
View File
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { Check, Eye, Search, X } from 'lucide-react';
import { adminApi, type FileRef, type SmsDrainageInfo } from '@/api/adminApi';
import { Breadcrumb, Button, FileActions, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
import { AuditReviewInfo, Breadcrumb, Button, FileActions, Input, Modal, Select, Table, Tabs, Tag, Textarea, type TableColumn } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
import { ReportImportAuditPanel } from './ReportImportAuditPanel';
const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'danger' }> = {
pending: { label: '待审核', tone: 'info' },
@@ -32,6 +33,7 @@ function DrainageDetail({ item, onClose }: { item: SmsDrainageInfo; onClose: ()
<div><span></span><strong>{item.siteName}</strong></div>
<div><span></span><strong>{item.url}</strong></div>
<div><span></span><strong>{formatDateTime(item.submittedAt)}</strong></div>
<AuditReviewInfo targetId={item.id} targetType="sms_drainage_info" />
<div className="detail-grid__wide"><span></span><strong>{item.remark || '-'}</strong></div>
{item.rejectReason ? <div className="detail-grid__wide"><span></span><strong>{item.rejectReason}</strong></div> : null}
</div>
@@ -86,8 +88,10 @@ export function AdminDrainageAuditPage() {
return <section className="page-stack admin-template-audit-page">
<div className="page-heading"><div><Breadcrumb items={['审核中心', '引流信息审核']} /><h1></h1></div></div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface audit-filter-card"><div className="audit-filter-grid audit-filter-grid--template"><Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索企业、应用、签名、站点或网址" prefix={<Search size={16} />} value={keyword} /><Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '待审核', value: 'pending' }, { label: '已通过', value: 'approved' }, { label: '已驳回', value: 'rejected' }]} value={status} /><div className="audit-filter-actions"><Button icon={<Search size={17} />} onClick={loadData}></Button><Button onClick={() => { setKeyword(''); setStatus('pending'); }} variant="ghost"></Button></div></div></div>
<div className="surface"><Table columns={columns} data={items} emptyText="暂无引流信息审核记录" rowKey="id" /></div>
<Tabs items={[
{ label: '单条引流信息审核', value: 'single', content: <div className="page-stack"><div className="surface audit-filter-card"><div className="audit-filter-grid audit-filter-grid--template"><Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索企业、应用、签名、站点或网址" prefix={<Search size={16} />} value={keyword} /><Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '待审核', value: 'pending' }, { label: '已通过', value: 'approved' }, { label: '已驳回', value: 'rejected' }]} value={status} /><div className="audit-filter-actions"><Button icon={<Search size={17} />} onClick={loadData}></Button><Button onClick={() => { setKeyword(''); setStatus('pending'); }} variant="ghost"></Button></div></div></div><div className="surface"><Table columns={columns} data={items} emptyText="暂无引流信息审核记录" rowKey="id" /></div></div> },
{ label: '导入批次审核', value: 'import', content: <ReportImportAuditPanel reportType="drainage" /> },
]} />
{detail ? <DrainageDetail item={detail} onClose={() => setDetail(undefined)} /> : null}
<Modal footer={<><Button onClick={() => setRejectTarget(undefined)} variant="ghost"></Button><Button disabled={!reason.trim()} onClick={() => void reject()} variant="danger"></Button></>} onClose={() => setRejectTarget(undefined)} open={Boolean(rejectTarget)} title="驳回引流信息"><Textarea label="驳回原因" onChange={(event) => setReason(event.target.value)} rows={4} value={reason} /></Modal>
</section>;
@@ -21,6 +21,8 @@ type EnterpriseAuditRecord = {
contactPhone: string;
contactEmail: string;
submittedAt: string;
reviewedAt: string;
reviewerUsername: string;
reviewRemark: string;
status: EnterpriseAuditStatus;
};
@@ -61,6 +63,8 @@ function mapCertification(record: EnterpriseCertification): EnterpriseAuditRecor
contactPhone: record.contactPhone ?? '',
contactEmail: String(materials.contactEmail ?? ''),
submittedAt: formatDateTime(record.submittedAt),
reviewedAt: formatDateTime(record.reviewedAt),
reviewerUsername: record.reviewer?.username ?? '-',
reviewRemark: record.rejectReason ?? String(materials.reviewRemark ?? ''),
status: record.status as EnterpriseAuditStatus,
};
@@ -196,6 +200,8 @@ export function AdminEnterpriseAuditPage() {
<div><span></span><strong>{detailRecord.contactPhone}</strong></div>
<div><span></span><strong>{detailRecord.contactEmail}</strong></div>
<div><span></span><strong>{detailRecord.submittedAt}</strong></div>
<div><span></span><strong>{detailRecord.reviewedAt}</strong></div>
<div><span></span><strong>{detailRecord.reviewerUsername}</strong></div>
<div><span></span><strong>{statusTextMap[detailRecord.status]}</strong></div>
<div className="enterprise-audit-detail__remark"><span></span><strong>{detailRecord.reviewRemark}</strong></div>
</section>
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Edit3, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
import { ChevronDown, ChevronRight, Edit3, FileSpreadsheet, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
import { adminApi, type ApplicationReportField, type ClientSmsApplication, type ClientSmsSignature, type FileRef, type TenantOption } from '@/api/adminApi';
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 { getSmsSignatureValidationError, hasForbiddenSmsSignatureCharacter, isCompleteSmsSignature, SMS_SIGNATURE_CHARACTER_ERROR } from '@/utils/smsSignature';
import { ReportMaterialImportModal } from './ReportMaterialImportModal';
type CarrierStatus = 'approved' | 'pending' | 'rejected' | 'filing';
@@ -460,9 +461,9 @@ function DrainageFormModal({ applicationId, item, onClose, onSubmit }: { applica
<section>
<h3></h3>
<Input
label="* 引流网址"
label="* 引流url或号码"
onChange={(event) => update('url', event.target.value)}
placeholder="请输入引流网址"
placeholder="请输入引流url或号码"
required
value={form.url}
/>
@@ -475,7 +476,6 @@ function DrainageFormModal({ applicationId, item, onClose, onSubmit }: { applica
</ol>
</div>
<div className="signature-form-grid">
<Input label="* 引流信息" onChange={(event) => update('siteName', event.target.value)} placeholder="请输入引流信息" value={form.siteName} />
<Textarea className="signature-form-grid__wide" label="备注" onChange={(event) => update('remark', event.target.value)} rows={4} value={form.remark} />
</div>
<DynamicReportFields fields={reportFields} onChange={updateReportValue} title="引流信息报备资料(通用 + 通道)" values={form.reportValues} />
@@ -541,7 +541,7 @@ function DrainageReportModal({ item, onClose, signature }: { item: DrainageInfo;
return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title="引流信息报备详情">
<div className="detail-grid">
<div><span></span><strong>{item.siteName}</strong></div>
<div><span>url或号码</span><strong>{item.url}</strong></div>
<div><span>url或号码</span><strong>{item.url}</strong></div>
<div><span></span><CarrierReportTag summary={summary?.mobile} /></div>
<div><span></span><CarrierReportTag summary={summary?.unicom} /></div>
@@ -616,6 +616,8 @@ export function AdminEnterpriseSignaturesPage() {
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [page, setPage] = useState(1);
const [importOpen, setImportOpen] = useState(false);
const [message, setMessage] = useState('');
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, signatureKeyword: appliedSignatureKeyword, drainageKeyword: appliedDrainageKeyword }) {
try {
@@ -693,7 +695,7 @@ export function AdminEnterpriseSignaturesPage() {
return;
}
const exists = readDrainagePayload(signature).links.some((current) => current.id === item.id);
const body = { siteName: item.siteName, url: item.url, remark: item.remark, reportValues: item.reportValues };
const body = { siteName: item.url.trim(), url: item.url.trim(), remark: item.remark, reportValues: item.reportValues };
if (exists) await adminApi.updateDrainageInfo(item.id, body);
else await adminApi.createDrainageInfo(signatureId, body);
setDrainageModal(null);
@@ -766,7 +768,7 @@ export function AdminEnterpriseSignaturesPage() {
<Button onClick={() => setDrainageReport({ signature, item })} size="sm" variant="ghost"></Button>
<Button disabled={item.auditStatus !== 'approved'} onClick={() => setDrainageStatusTarget({ signature, item })} size="sm" variant="ghost"></Button>
<Button onClick={() => setDrainageModal({ signatureId: signature.id, item })} size="sm" variant="ghost"></Button>
<Button onClick={() => setDeleteTarget({ kind: 'drainage', signatureId: signature.id, id: item.id, name: item.siteName })} size="sm" variant="danger"></Button>
<Button onClick={() => setDeleteTarget({ kind: 'drainage', signatureId: signature.id, id: item.id, name: item.url })} size="sm" variant="danger"></Button>
</span>
</div>
);})}
@@ -803,7 +805,10 @@ export function AdminEnterpriseSignaturesPage() {
<Breadcrumb items={['客户管理', '企业签名管理']} />
<h1></h1>
</div>
<Button icon={<Plus size={16} />} onClick={() => setSignatureModal(activeTab === 'sms' ? 'new' : null)}></Button>
<div className="page-heading-actions">
<Button icon={<FileSpreadsheet size={16} />} onClick={() => setImportOpen(true)} variant="ghost"></Button>
<Button icon={<Plus size={16} />} onClick={() => setSignatureModal(activeTab === 'sms' ? 'new' : null)}></Button>
</div>
</div>
<div className="surface admin-split-filter">
@@ -836,6 +841,7 @@ export function AdminEnterpriseSignaturesPage() {
</div>
{error ? <p className="form-error">{error}</p> : null}
{message ? <p className="form-success">{message}</p> : null}
<div className="surface section-stack">
<Tabs
@@ -857,6 +863,10 @@ export function AdminEnterpriseSignaturesPage() {
tenants={tenants}
/>
) : null}
{importOpen ? <ReportMaterialImportModal onClose={() => setImportOpen(false)} onCompleted={() => {
setMessage('导入解析完成,合格资料已进入审核中心的导入批次');
void loadData();
}} /> : null}
{signatureReport ? <SignatureReportModal item={signatureReport} onClose={() => setSignatureReport(null)} /> : null}
{reportStatusTarget ? <ChannelReportStatusModal item={reportStatusTarget} onClose={() => setReportStatusTarget(null)} onSaved={() => { setReportStatusTarget(null); void loadData(); }} /> : null}
{drainageModal ? (
+233 -45
View File
@@ -1,22 +1,49 @@
import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, CheckCircle2, Download, FileSpreadsheet, Layers3, RefreshCw, ShieldCheck } from 'lucide-react';
import { adminApi, fileDownloadUrl, type ReportMaterialBatchPreflight, type ReportMaterialBatchResult, type ReportMaterialPendingItem } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Tag } from '@/components/ui';
import { AlertTriangle, CheckCircle2, Download, Layers3, Search, ShieldCheck } from 'lucide-react';
import {
adminApi,
fileDownloadUrl,
type ReportMaterialBatch,
type ReportMaterialBatchPreflight,
type ReportMaterialBatchResult,
type ReportMaterialPendingItem,
} from '@/api/adminApi';
import {
Breadcrumb,
Button,
DateRangeInput,
Input,
Modal,
Pagination,
Select,
Table,
Tabs,
Tag,
type DateRangeValue,
type TableColumn,
} 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>> };
const statusLabel: Record<string, string> = { completed: '生成完成', partial_failed: '部分资料待补充', failed: '生成失败', processing: '生成中' };
const batchStatusLabels: Record<string, string> = {
completed: '生成完成',
partial_failed: '部分生成',
failed: '生成失败',
generating: '生成中',
processing: '生成中',
};
export function AdminReportMaterialsPage() {
const [items, setItems] = useState<ReportMaterialPendingItem[]>([]);
const [batches, setBatches] = useState<Batch[]>([]);
const [activeTab, setActiveTab] = useState<'pending' | 'batches'>('pending');
const [pendingData, setPendingData] = useState<{ items: ReportMaterialPendingItem[]; total: number }>({ items: [], total: 0 });
const [batchData, setBatchData] = useState<{ items: ReportMaterialBatch[]; total: number }>({ items: [], total: 0 });
const [selected, setSelected] = useState<Set<string>>(new Set());
const [reportType, setReportType] = useState('all');
const [keyword, setKeyword] = useState('');
const [importOpen, setImportOpen] = useState(false);
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [pendingPage, setPendingPage] = useState(1);
const [batchPage, setBatchPage] = useState(1);
const pageSize = 20;
const [busy, setBusy] = useState(false);
const [preflightBusy, setPreflightBusy] = useState(false);
const [preflight, setPreflight] = useState<ReportMaterialBatchPreflight | null>(null);
@@ -27,55 +54,216 @@ export function AdminReportMaterialsPage() {
const [error, setError] = useState('');
const [message, setMessage] = useState('');
function loadData() {
Promise.all([adminApi.listPendingReportMaterials(reportType === 'all' ? {} : { reportType: reportType as 'signature' | 'drainage' }), adminApi.listReportMaterialBatches()])
.then(async ([pendingItems, batchItems]) => {
setItems(pendingItems); setBatches(batchItems as Batch[]); setError('');
const eligibility = pendingItems.length ? await adminApi.preflightReportMaterialBatch({ items: pendingItems.map(toBatchItem) }) : null;
const eligibilityMap = new Map((eligibility?.items ?? []).map((item) => [item.id, item]));
setPoolEligibility(eligibilityMap);
setSelected((current) => new Set([...current].filter((id) => eligibilityMap.get(id)?.eligible)));
})
.catch((failure: Error) => setError(failure.message || '待报备资料加载失败'));
async function loadPending(
page = pendingPage,
filters: {
keyword?: string;
dateRange?: DateRangeValue;
reportType?: string;
} = {},
) {
const nextKeyword = filters.keyword ?? keyword;
const nextDateRange = filters.dateRange ?? dateRange;
const nextReportType = filters.reportType ?? reportType;
try {
const result = await adminApi.listPendingReportMaterials({
reportType: nextReportType === 'all' ? undefined : nextReportType as 'signature' | 'drainage',
keyword: nextKeyword.trim() || undefined,
startAt: nextDateRange.start,
endAt: nextDateRange.end,
page,
pageSize,
});
setPendingData({ items: result.items, total: result.total });
const eligibility = result.items.length ? await adminApi.preflightReportMaterialBatch({ items: result.items.map(toBatchItem) }) : null;
const eligibilityMap = new Map((eligibility?.items ?? []).map((item) => [item.id, item]));
setPoolEligibility(eligibilityMap);
setSelected((current) => new Set([...current].filter((id) => eligibilityMap.get(id)?.eligible)));
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '待生成资料加载失败');
}
}
useEffect(loadData, [reportType]);
const visibleItems = useMemo(() => items.filter((item) => !keyword.trim() || [item.name, item.detail, item.signatureName, item.tenant?.name, item.application?.name].some((value) => String(value ?? '').includes(keyword.trim()))), [items, keyword]);
const eligibleVisibleItems = visibleItems.filter((item) => poolEligibility.get(item.id)?.eligible);
const allSelected = eligibleVisibleItems.length > 0 && eligibleVisibleItems.every((item) => selected.has(item.id));
async function loadBatches(
page = batchPage,
filters: {
keyword?: string;
dateRange?: DateRangeValue;
} = {},
) {
const nextKeyword = filters.keyword ?? keyword;
const nextDateRange = filters.dateRange ?? dateRange;
try {
const result = await adminApi.listReportMaterialBatches({
keyword: nextKeyword.trim() || undefined,
startAt: nextDateRange.start,
endAt: nextDateRange.end,
page,
pageSize,
});
setBatchData({ items: result.items, total: result.total });
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '已生成批次加载失败');
}
}
function toggle(id: string) { if (!poolEligibility.get(id)?.eligible) return; setSelected((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }
function loadActive() {
if (activeTab === 'pending') void loadPending(pendingPage);
else void loadBatches(batchPage);
}
useEffect(() => {
loadActive();
}, [activeTab, pendingPage, batchPage, reportType]);
const eligibleItems = pendingData.items.filter((item) => poolEligibility.get(item.id)?.eligible);
const allSelected = eligibleItems.length > 0 && eligibleItems.every((item) => selected.has(item.id));
function toggle(id: string) {
if (!poolEligibility.get(id)?.eligible) return;
setSelected((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
async function beginCreateBatch() {
const chosen = items.filter((item) => selected.has(item.id));
if (!chosen.length) { setError('请先选择具备报备资格的资料'); return; }
setConfirmOpen(true); setPreflightBusy(true); setPreflight(null); setBatchResult(null); setError(''); setMessage('');
const chosen = pendingData.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:${createUuid()}`);
try { setPreflight(await adminApi.preflightReportMaterialBatch({ items: chosen.map(toBatchItem) })); }
catch (failure) { setError(failure instanceof Error ? failure.message : '报备资格预检失败'); }
finally { setPreflightBusy(false); }
try {
setPreflight(await adminApi.preflightReportMaterialBatch({ items: chosen.map(toBatchItem) }));
} catch (failure) {
setError(failure instanceof Error ? failure.message : '报备资格预检失败');
} finally {
setPreflightBusy(false);
}
}
async function createBatch() {
const chosen = items.filter((item) => selected.has(item.id));
if (!chosen.length) { setError('请先选择要统一报备的资料'); return; }
setBusy(true); setError(''); setMessage('');
const chosen = pendingData.items.filter((item) => selected.has(item.id));
if (!chosen.length) return;
setBusy(true);
setError('');
try {
const batch = await adminApi.createReportMaterialBatch({ idempotencyKey: operationKey, items: chosen.map((item) => ({ ...toBatchItem(item), materialVersion: item.materialVersion })) });
setBatchResult(batch); setMessage(`批次 ${batch.batchNo} 已完成:成功 ${batch.result.successCount},跳过 ${batch.result.skippedCount},失败 ${batch.result.failedCount}`); setSelected(new Set()); loadData();
} catch (failure) { setError(failure instanceof Error ? failure.message : '报备批次生成失败'); }
finally { setBusy(false); }
const batch = await adminApi.createReportMaterialBatch({
idempotencyKey: operationKey,
items: chosen.map((item) => ({ ...toBatchItem(item), materialVersion: item.materialVersion })),
});
setBatchResult(batch);
setMessage(`批次 ${batch.batchNo} 已生成:成功 ${batch.result.successCount},跳过 ${batch.result.skippedCount},失败 ${batch.result.failedCount}`);
setSelected(new Set());
await Promise.all([loadPending(pendingPage), loadBatches(1)]);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '报备批次生成失败');
} finally {
setBusy(false);
}
}
const pendingColumns = useMemo<Array<TableColumn<ReportMaterialPendingItem>>>(() => [
{
key: 'select',
title: '',
width: '48px',
render: (item) => {
const eligible = poolEligibility.get(item.id)?.eligible;
return <input aria-label={`选择${item.name}`} checked={selected.has(item.id)} disabled={!eligible} onChange={() => toggle(item.id)} type="checkbox" />;
},
},
{ key: 'name', title: '资料', render: (item) => <div><strong>{item.name}</strong><div className="muted">{item.reportType === 'signature' ? '签名资料' : `引流信息 · ${item.signatureName ?? '-'}`} · {item.detail || '-'}</div></div> },
{ key: 'tenant', title: '企业/应用', render: (item) => <div><strong>{item.tenant?.name ?? '-'}</strong><div className="muted">{item.application?.name ?? '未指定应用'}</div></div> },
{ key: 'eligibility', title: '版本/资格', render: (item) => {
const eligibility = poolEligibility.get(item.id);
const eligible = eligibility?.eligible;
return <div><Tag tone={eligible ? 'success' : 'warning'}>V{item.materialVersion} · {eligible ? `${eligibility.targets.filter((target) => target.eligible).length}个通道可生成` : '待补充'}</Tag>{!eligible ? <div className="muted">{eligibility?.blockedReasons[0] ?? '资格检查中'}</div> : null}</div>;
} },
{ key: 'changedAt', title: '资料变更时间', render: (item) => formatDateTime(item.changedAt) },
], [poolEligibility, selected]);
const batchColumns = useMemo<Array<TableColumn<ReportMaterialBatch>>>(() => [
{ key: 'batchNo', title: '报备批次号', render: (batch) => <strong>{batch.batchNo}</strong> },
{ key: 'time', title: '生成时间', render: (batch) => formatDateTime(batch.createdAt) },
{ key: 'reportTotal', title: '报备总数', render: (batch) => batch.reportTotal.toLocaleString('zh-CN') },
{ key: 'successCount', title: '成功数', render: (batch) => batch.successCount.toLocaleString('zh-CN') },
{ key: 'successRate', title: '成功率', render: (batch) => `${(batch.successRate * 100).toFixed(2)}%` },
{ key: 'channels', title: '通道/文件', render: (batch) => `${batch.channelCount}个通道 · ${batch.fileCount}份文件` },
{ key: 'status', title: '生成状态', render: (batch) => <Tag tone={batch.status === 'completed' ? 'success' : batch.status === 'failed' ? 'danger' : 'warning'}>{batchStatusLabels[batch.status] ?? batch.status}</Tag> },
{ key: 'files', title: '报备文件', align: 'right', render: (batch) => <div className="table-actions">{batch.exportFiles.map((file) => file.fileObjectId ? <a href={fileDownloadUrl(file.fileObjectId)} key={file.id}><Download size={15} />{file.fileName}{file.rowCount}</a> : null)}</div> },
], []);
const filter = <div className="surface report-material-filter">
{activeTab === 'pending' ? <Select label="资料类型" onChange={(event) => { setReportType(event.target.value); setPendingPage(1); }} options={[{ label: '全部资料', value: 'all' }, { label: '签名资料', value: 'signature' }, { label: '引流信息', value: 'drainage' }]} value={reportType} /> : null}
<Input label={activeTab === 'pending' ? '企业/应用/签名/站点' : '报备批次号'} onChange={(event) => setKeyword(event.target.value)} placeholder={activeTab === 'pending' ? '搜索待生成资料' : '搜索报备批次号'} value={keyword} />
<DateRangeInput label={activeTab === 'pending' ? '资料变更时间' : '批次生成时间'} onChange={setDateRange} value={dateRange} />
<Button icon={<Search size={16} />} onClick={() => {
if (activeTab === 'pending') {
setPendingPage(1);
void loadPending(1);
} else {
setBatchPage(1);
void loadBatches(1);
}
}}></Button>
<Button onClick={() => {
setKeyword('');
setDateRange({});
if (activeTab === 'pending') {
setReportType('all');
setPendingPage(1);
void loadPending(1, { keyword: '', dateRange: {}, reportType: 'all' });
} else {
setBatchPage(1);
void loadBatches(1, { keyword: '', dateRange: {} });
}
}} variant="ghost"></Button>
</div>;
return <section className="page-stack report-material-page">
<div className="surface page-heading"><div><Breadcrumb items={['报备任务', '待报备资料']} /><h1></h1><p></p></div><div className="page-heading-actions"><Button icon={<FileSpreadsheet size={16} />} onClick={() => setImportOpen(true)} variant="ghost"> WPS </Button><Button disabled={busy || selected.size === 0} icon={<Layers3 size={16} />} onClick={() => void beginCreateBatch()}>{busy ? '生成中...' : `预检并生成(${selected.size}`}</Button></div></div>
{error ? <p className="form-error">{error}</p> : null}{message ? <p className="form-success">{message}</p> : null}
<div className="surface report-material-filter"><Select label="资料类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部资料', value: 'all' }, { label: '签名资料', value: 'signature' }, { label: '引流信息', value: 'drainage' }]} value={reportType} /><Input label="企业/应用/签名/站点" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索待报备资料" value={keyword} /><Button icon={<RefreshCw size={15} />} onClick={loadData} variant="ghost"></Button></div>
<div className="surface report-material-pool"><div className="report-material-table-head"><input checked={allSelected} onChange={() => setSelected((current) => { const next = new Set(current); for (const item of visibleItems.filter((entry) => poolEligibility.get(entry.id)?.eligible)) allSelected ? next.delete(item.id) : next.add(item.id); return next; })} type="checkbox" /><span></span><span> / </span><span> / </span><span></span></div>{visibleItems.map((item) => { const eligibility = poolEligibility.get(item.id); const disabled = !eligibility?.eligible; return <label className={`report-material-row${disabled ? ' is-disabled' : ''}`} key={item.id}><input checked={selected.has(item.id)} disabled={disabled} onChange={() => toggle(item.id)} type="checkbox" /><span><strong>{item.name}</strong><small>{item.reportType === 'signature' ? '签名资料' : `引流信息 · 所属签名 ${item.signatureName ?? '-'}`}</small><em>{item.detail || '-'}</em></span><span><strong>{item.tenant?.name ?? '-'}</strong><small>{item.application?.name ?? '未指定应用'}</small></span><span><Tag tone={disabled ? 'warning' : 'success'}>V{item.materialVersion} · {disabled ? '待补充' : `${eligibility.targets.filter((target) => target.eligible).length} 通道可生成`}</Tag>{disabled ? <small title={eligibility?.blockedReasons.join('')}>{eligibility?.blockedReasons[0] ?? '资格检查中'}</small> : null}</span><span>{formatDateTime(item.changedAt)}</span></label>; })}{visibleItems.length === 0 ? <div className="channel-report-empty"></div> : null}</div>
<div className="surface report-material-batches"><div className="channel-field-section-head"><div><h2></h2><p> XLSX</p></div><Tag tone="neutral">{batches.length} </Tag></div>{batches.map((batch) => <article key={batch.id}><div><strong>{batch.batchNo ?? batch.id}</strong><span>{statusLabel[String(batch.status)] ?? batch.status}</span><small>{batch.createdAt ? formatDateTime(batch.createdAt) : '-'} · {batch.selectedCount ?? 0} · {batch.channelCount ?? 0} </small></div><div>{(batch.exportFiles ?? []).map((file) => <a href={fileDownloadUrl(String(file.fileObjectId))} key={String(file.id)}><Download size={15} />{String(file.fileName ?? '下载报备文件')}{String(file.rowCount ?? 0)} </a>)}</div></article>)}{batches.length === 0 ? <div className="channel-report-empty"></div> : null}</div>
{importOpen ? <ReportMaterialImportModal onClose={() => setImportOpen(false)} onCompleted={() => { setMessage('资料导入完成,已进入待报备池'); loadData(); }} /> : null}
<div className="surface page-heading">
<div><Breadcrumb items={['报备任务', '待生成报备批次']} /><h1></h1><p></p></div>
{activeTab === 'pending' ? <Button disabled={busy || selected.size === 0} icon={<Layers3 size={16} />} onClick={() => void beginCreateBatch()}>{busy ? '生成中...' : `预检并生成(${selected.size}`}</Button> : null}
</div>
{error ? <p className="form-error">{error}</p> : null}
{message ? <p className="form-success">{message}</p> : null}
<Tabs
onChange={(value) => {
setActiveTab(value as 'pending' | 'batches');
setKeyword('');
setDateRange({});
}}
value={activeTab}
items={[
{
label: `待生成资料(${pendingData.total}`,
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}`,
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>,
},
]}
/>
<Modal footer={batchResult ? <Button onClick={() => setConfirmOpen(false)}></Button> : <><Button disabled={busy} onClick={() => setConfirmOpen(false)} variant="ghost"></Button><Button disabled={preflightBusy || busy || !preflight?.eligible} icon={<ShieldCheck size={16} />} onClick={() => void createBatch()}>{busy ? '生成处理中…' : '确认生成'}</Button></>} onClose={() => { if (!busy) setConfirmOpen(false); }} open={confirmOpen} size="xl" title="报备生成资格预检">
<div className="report-batch-preflight">{preflightBusy ? <p role="status"></p> : null}{preflight ? <><div className="report-batch-summary"><span><CheckCircle2 size={17} /> {preflight.eligibleTargetCount} </span><span><AlertTriangle size={17} /> {preflight.skippedTargetCount} </span></div>{preflight.items.map((item) => <article key={item.id}><div><strong>{item.name}</strong><small>{item.tenantName} · {item.applicationName} · V{item.materialVersion}</small></div>{item.targets.length ? <ul>{item.targets.map((target) => <li key={target.businessKey} className={target.eligible ? 'is-eligible' : 'is-blocked'}><span>{target.name} · {target.carrier}</span><small>{target.eligible ? '资格通过' : target.blockedReasons.join('')}</small></li>)}</ul> : <p className="form-error">{item.blockedReasons.join('')}</p>}</article>)}</> : null}{batchResult ? <div className="risk-action-result" role="status"><ShieldCheck size={20} /><div><strong> {batchResult.batchNo} </strong><span> {batchResult.result.successCount} · {batchResult.result.skippedCount} · {batchResult.result.failedCount}</span><span>{batchResult.operationId}{batchResult.replayed ? '(幂等重放)' : ''}</span></div></div> : null}{error ? <p className="form-error" role="alert">{error}</p> : null}</div>
<div className="report-batch-preflight">
{preflightBusy ? <p role="status"></p> : null}
{preflight ? <><div className="report-batch-summary"><span><CheckCircle2 size={17} /> {preflight.eligibleTargetCount} </span><span><AlertTriangle size={17} /> {preflight.skippedTargetCount} </span></div>{preflight.items.map((item) => <article key={item.id}><div><strong>{item.name}</strong><small>{item.tenantName} · {item.applicationName} · V{item.materialVersion}</small></div>{item.targets.length ? <ul>{item.targets.map((target) => <li className={target.eligible ? 'is-eligible' : 'is-blocked'} key={target.businessKey}><span>{target.name} · {target.carrier}</span><small>{target.eligible ? '资格通过' : target.blockedReasons.join('')}</small></li>)}</ul> : <p className="form-error">{item.blockedReasons.join('')}</p>}</article>)}</> : null}
{batchResult ? <div className="risk-action-result" role="status"><ShieldCheck size={20} /><div><strong> {batchResult.batchNo} </strong><span> {batchResult.result.successCount} · {batchResult.result.skippedCount} · {batchResult.result.failedCount}</span><span>{batchResult.operationId}{batchResult.replayed ? '(幂等重放)' : ''}</span></div></div> : null}
</div>
</Modal>
</section>;
}
+118 -180
View File
@@ -1,123 +1,66 @@
import { useEffect, useMemo, useState } from 'react';
import { Download, Eye, FileUp, Search } from 'lucide-react';
import { adminApi, type FileObject, type FileRef, type ReportTask } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, FileActions, Input, Modal, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
import { Eye, Search } from 'lucide-react';
import { adminApi, fileDownloadUrl, type ReportTask } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'danger' }> = {
pending: { label: '待处理', tone: 'neutral' },
ready: { label: '待导出', tone: 'info' },
exported: { label: '已导出', tone: 'warning' },
reporting: { label: '报备中', tone: 'warning' },
partial: { label: '部分完成', tone: 'info' },
completed: { label: '已完成', tone: 'success' },
approved: { label: '报备通过', tone: 'success' },
pending: { label: '未报备', tone: 'neutral' },
waiting_material: { label: '资料待补充', tone: 'warning' },
reporting: { label: '报备中', tone: 'warning' },
exporting: { label: '报备中', tone: 'warning' },
approved: { label: '报备通过', tone: 'success' },
failed: { label: '报备失败', tone: 'danger' },
rejected: { label: '报备失败', tone: 'danger' },
abandoned: { label: '已放弃', tone: 'neutral' },
failed: { label: '有失败', tone: 'danger' },
waiting_review: { label: '等待运营审核', tone: 'warning' },
};
type ReceiptImportPayload = {
delimiter: ',' | '\t';
fileContent: string;
fileName: string;
fileObjectId: string;
remark: string;
const actionLabels: Record<string, string> = {
create: '创建报备明细',
batch_export: '生成报备批次',
export: '生成报备文件',
manual_status_change: '人工修改状态',
receipt_import: '历史回执导入',
};
function taskTargetLabel(task: ReportTask) {
if (task.reportType !== 'drainage') return task.signature?.name ?? task.signatureId;
if (task.drainageInfo) return task.drainageInfo.siteName || task.drainageInfo.url;
const payload = task.signature?.drainageInfo;
const links = payload && Array.isArray(payload.links) ? payload.links as Array<Record<string, unknown>> : [];
const item = links.find((link) => String(link.id ?? '') === task.drainageItemId);
return String(item?.siteName ?? item?.url ?? task.drainageItemId ?? '引流信息');
}
function ReceiptImportModal({ onClose, onSubmit, task }: { onClose: () => void; onSubmit: (payload: ReceiptImportPayload) => void; task: ReportTask }) {
const [file, setFile] = useState<File | null>(null);
const [fileObject, setFileObject] = useState<FileObject | null>(null);
const [uploading, setUploading] = useState(false);
const [remark, setRemark] = useState('');
const [error, setError] = useState('');
const fileRef: FileRef | null = fileObject
? { contentType: fileObject.contentType, fileName: fileObject.fileName, fileObjectId: fileObject.id }
: null;
async function uploadReceiptFile(nextFile: File | undefined) {
setFile(nextFile ?? null);
setFileObject(null);
setError('');
if (!nextFile) {
return;
}
setUploading(true);
try {
const uploaded = await adminApi.uploadFileObject(nextFile, { purpose: 'report_receipt', prefix: `report-receipts/${task.id}` });
setFileObject(uploaded);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '报备回执上传失败');
} finally {
setUploading(false);
}
}
async function submitImport() {
if (!file || !fileObject) {
return;
}
const delimiter: ',' | '\t' = file.name.toLowerCase().endsWith('.tsv') ? '\t' : ',';
onSubmit({
delimiter,
fileContent: await file.text(),
fileName: file.name,
fileObjectId: fileObject.id,
remark,
});
}
return (
<Modal
footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!fileObject || uploading} onClick={() => { void submitImport(); }}>{uploading ? '上传中...' : '确认导入'}</Button></>}
onClose={onClose}
open
size="xl"
title={<div className="template-modal-title"><h2></h2><p></p></div>}
>
<div className="report-receipt-modal">
<label className="report-upload-drop">
<FileUp size={38} />
<strong>{file?.name || '选择回执文件'}</strong>
<span> CSVTSVTXT /</span>
<FileActions file={fileRef} />
<input
accept=".csv,.tsv,.txt,text/csv,text/plain"
onChange={(event) => { void uploadReceiptFile(event.target.files?.[0]); }}
style={{ display: 'none' }}
type="file"
/>
</label>
{error ? <p className="form-error">{error}</p> : null}
<Textarea label="导入备注" onChange={(event) => setRemark(event.target.value)} placeholder="记录回执来源、运营商工单号或人工处理说明" rows={4} value={remark} />
</div>
</Modal>
);
return task.drainageInfo?.siteName || task.drainageInfo?.url || task.drainageItemId || '引流信息';
}
function TaskDetailModal({ task, onClose }: { task: ReportTask; onClose: () => void }) {
const status = statusMeta[task.status] ?? { label: task.status, tone: 'info' as const };
return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title={<div className="template-modal-title"><h2></h2><p>{task.id}</p></div>}>
<div className="admin-task-detail report-task-detail">
<div className="admin-task-metrics">
<div className="admin-task-metric"><span>{task.reportType === 'drainage' ? '引流信息' : '签名'}</span><strong>{taskTargetLabel(task)}</strong></div>
<div className="admin-task-metric admin-task-metric--primary"><span></span><strong>{task.channel?.name ?? task.channelId}</strong></div>
<div className="admin-task-metric admin-task-metric--success"><span></span><strong>{status.label}</strong></div>
const source = task.exportItems?.[0];
return <Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title="报备明细详情">
<div className="page-stack">
<div className="detail-grid">
<div><span></span><strong>{taskTargetLabel(task)}</strong></div>
<div><span></span><strong>{task.reportType === 'drainage' ? '引流信息' : '签名'}</strong></div>
<div><span></span><strong>{task.signature?.tenant?.name ?? task.tenantId}</strong></div>
<div><span></span><strong>{task.signature?.application?.name ?? '未指定应用'}</strong></div>
<div><span></span><strong>{task.channel?.name ?? task.channelId}</strong></div>
<div><span></span><Tag tone={status.tone}>{status.label}</Tag></div>
<div><span></span><strong>{formatDateTime(task.createdAt)}</strong></div>
<div><span></span><strong>{formatDateTime(task.updatedAt)}</strong></div>
<div><span></span><strong>{source ? `V${source.batchItem.materialVersion}` : '-'}</strong></div>
<div><span></span><strong>{source?.batchItem.batch.batchNo ?? '-'}</strong></div>
<div><span></span><strong>{source ? `${source.rowNumber}` : '-'}</strong></div>
<div><span></span><strong>{task.reason || '-'}</strong></div>
</div>
{source?.exportFile.fileObjectId ? <div className="surface" style={{ padding: 16 }}><a href={fileDownloadUrl(source.exportFile.fileObjectId)}>{source.exportFile.fileName}</a></div> : null}
<div className="surface" style={{ padding: 16 }}>
<h3></h3>
<div className="page-stack" style={{ marginTop: 12 }}>
{(task.records ?? []).length ? task.records!.map((record) => <div className="detail-grid" key={record.id}>
<div><span></span><strong>{formatDateTime(record.createdAt)}</strong></div>
<div><span></span><strong>{actionLabels[record.action] ?? record.action}</strong></div>
<div><span></span><strong>{statusMeta[record.statusBefore ?? '']?.label ?? record.statusBefore ?? '-'} {statusMeta[record.statusAfter]?.label ?? record.statusAfter}</strong></div>
<div><span></span><strong>{record.reason || '-'}</strong></div>
</div>) : <p className="muted"></p>}
</div>
</div>
</Modal>
);
</div>
</Modal>;
}
export function AdminReportTasksPage() {
@@ -125,12 +68,12 @@ export function AdminReportTasksPage() {
const [keyword, setKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [reportType, setReportType] = useState('all');
const [receiptTask, setReceiptTask] = useState<ReportTask | null>(null);
const [detailTask, setDetailTask] = useState<ReportTask | null>(null);
const [statusTask, setStatusTask] = useState<ReportTask | null>(null);
const [nextStatus, setNextStatus] = useState('approved');
const [statusReason, setStatusReason] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
function loadData() {
adminApi.listReportTasks({ reportType: reportType === 'all' ? undefined : reportType as 'signature' | 'drainage' })
@@ -138,98 +81,93 @@ export function AdminReportTasksPage() {
setTasks(items);
setError('');
})
.catch((failure: Error) => setError(failure.message || '报备任务加载失败'));
.catch((failure: Error) => setError(failure.message || '报备明细加载失败'));
}
useEffect(() => {
loadData();
}, [reportType]);
useEffect(loadData, [reportType]);
const filteredTasks = useMemo(() => tasks.filter((task) => {
const text = `${task.id}${task.channel?.name ?? task.channelId}${task.signature?.name ?? task.signatureId}${task.drainageItemId ?? ''}`;
const text = `${task.id}${task.channel?.name ?? task.channelId}${task.signature?.name ?? task.signatureId}${task.drainageInfo?.siteName ?? ''}${task.drainageInfo?.url ?? ''}${task.signature?.tenant?.name ?? ''}${task.signature?.application?.name ?? ''}`;
const date = task.createdAt?.slice(0, 10) ?? '';
return (!keyword || text.includes(keyword))
&& (!dateRange.start || date >= dateRange.start)
&& (!dateRange.end || date <= dateRange.end);
}), [dateRange.end, dateRange.start, keyword, tasks]);
function exportTask(task: ReportTask) {
adminApi.createReportExport(task.id, { fileName: `${task.id}.xlsx`, rowCount: 0 })
.then(loadData)
.catch((failure: Error) => setError(failure.message || '报备任务导出失败'));
}
function importReceipt(payload: ReceiptImportPayload) {
if (!receiptTask) return;
adminApi.importReportReceipt(receiptTask.id, {
delimiter: payload.delimiter,
fileContent: payload.fileContent,
fileName: payload.fileName,
fileObjectId: payload.fileObjectId,
reason: payload.remark,
})
.then(() => {
setReceiptTask(null);
loadData();
})
.catch((failure: Error) => setError(failure.message || '报备回执导入失败'));
}
function saveTaskStatus() {
async function saveTaskStatus() {
if (!statusTask) return;
adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, reportType: statusTask.reportType, drainageItemId: statusTask.drainageItemId ?? undefined, status: nextStatus }], reason: statusReason, sourceEntry: 'report_task' })
.then(() => { setStatusTask(null); setStatusReason(''); loadData(); })
.catch((failure: Error) => setError(failure.message || '报备状态保存失败'));
setBusy(true);
try {
await adminApi.changeReportTaskStatuses({
items: [{
signatureId: statusTask.signatureId,
channelId: statusTask.channelId,
reportType: statusTask.reportType,
drainageItemId: statusTask.drainageItemId ?? undefined,
status: nextStatus,
}],
reason: statusReason.trim() || undefined,
sourceEntry: 'report_task',
});
setStatusTask(null);
setStatusReason('');
loadData();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '报备状态保存失败');
} finally {
setBusy(false);
}
}
const columns: Array<TableColumn<ReportTask>> = [
{ key: 'id', title: '报备任务号', width: '190px', render: (record) => <strong className="admin-task-id">{record.id}</strong> },
{ key: 'scope', title: '通道/报备对象', width: '300px', render: (record) => <div className="admin-task-enterprise"><strong>{record.channel?.name ?? record.channelId}</strong><span>{record.reportType === 'drainage' ? `引流信息 · ${taskTargetLabel(record)}` : `签名 · ${taskTargetLabel(record)}`}</span></div> },
{ key: 'status', title: '状态', width: '130px', render: (record) => <Tag tone={(statusMeta[record.status] ?? { tone: 'info' as const }).tone}>{(statusMeta[record.status] ?? { label: record.status }).label}</Tag> },
{ key: 'time', title: '创建时间', width: '190px', render: (record) => record.createdAt ?? '-' },
{
key: 'actions',
title: '操作',
align: 'right',
width: '240px',
render: (record) => (
<div className="admin-task-actions">
<Button icon={<Eye size={14} />} onClick={() => setDetailTask(record)} size="sm" variant="ghost"></Button>
<Button onClick={() => { setStatusTask(record); setNextStatus(record.status); }} size="sm" variant="ghost"></Button>
<Button icon={<Download size={14} />} onClick={() => exportTask(record)} size="sm" variant="ghost"></Button>
<Button icon={<FileUp size={14} />} onClick={() => setReceiptTask(record)} size="sm" variant="ghost"></Button>
</div>
),
},
{ key: 'target', title: '报备对象', render: (record) => <div><strong>{taskTargetLabel(record)}</strong><div className="muted">{record.reportType === 'drainage' ? '引流信息' : '签名'} · {record.signature?.tenant?.name ?? record.tenantId}</div></div> },
{ key: 'application', title: '企业应用', render: (record) => record.signature?.application?.name ?? '未指定应用' },
{ key: 'channel', title: '通道', render: (record) => record.channel?.name ?? record.channelId },
{ key: 'batch', title: '批次/版本', render: (record) => {
const source = record.exportItems?.[0];
return source ? <div><strong>{source.batchItem.batch.batchNo}</strong><div className="muted">V{source.batchItem.materialVersion} · {source.rowNumber}</div></div> : '-';
} },
{ key: 'status', title: '状态', render: (record) => <Tag tone={(statusMeta[record.status] ?? { tone: 'info' as const }).tone}>{(statusMeta[record.status] ?? { label: record.status }).label}</Tag> },
{ key: 'time', title: '更新时间', render: (record) => formatDateTime(record.updatedAt ?? record.createdAt) },
{ key: 'actions', title: '操作', align: 'right', render: (record) => <div className="table-actions"><Button icon={<Eye size={14} />} onClick={() => setDetailTask(record)} size="sm" variant="ghost"></Button><Button onClick={() => {
setStatusTask(record);
setNextStatus(record.status);
setStatusReason('');
}} size="sm" variant="ghost"></Button></div> },
];
return (
<section className="page-stack admin-sms-task-page report-task-page">
<div className="page-heading">
<div>
<Breadcrumb items={['报备任务']} />
<h1></h1>
return <section className="page-stack admin-sms-task-page report-task-page">
<div className="page-heading"><div><Breadcrumb items={['报备任务', '报备明细']} /><h1></h1><p></p></div></div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-task-filter">
<Input label="企业/应用/通道/报备对象" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索报备明细" value={keyword} />
<Select label="报备类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部类型', value: 'all' }, { label: '签名报备', value: 'signature' }, { label: '引流信息报备', value: 'drainage' }]} value={reportType} />
<DateRangeInput label="创建时间" onChange={setDateRange} value={dateRange} />
<div className="admin-task-filter__actions"><Button icon={<Search size={16} />} onClick={loadData}></Button><Button onClick={() => {
setKeyword('');
setDateRange({});
setReportType('all');
}} variant="ghost"></Button></div>
</div>
<div className="surface report-task-table-card"><Table columns={columns} data={filteredTasks} emptyText="暂无报备明细" rowKey="id" /></div>
{detailTask ? <TaskDetailModal onClose={() => setDetailTask(null)} task={detailTask} /> : null}
<Modal footer={<><Button disabled={busy} onClick={() => setStatusTask(null)} variant="ghost"></Button><Button disabled={busy} onClick={() => void saveTaskStatus()}>{busy ? '保存中…' : '保存'}</Button></>} onClose={() => setStatusTask(null)} open={Boolean(statusTask)} title="修改报备状态">
{statusTask ? <div className="page-stack">
<div className="detail-grid">
<div><span></span><strong>{taskTargetLabel(statusTask)}</strong></div>
<div><span></span><strong>{statusTask.channel?.name ?? statusTask.channelId}</strong></div>
<div><span></span><strong>{statusMeta[statusTask.status]?.label ?? statusTask.status}</strong></div>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-task-filter">
<Input label="报备任务号/通道/报备对象" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入报备任务号、通道或报备对象" value={keyword} />
<Select label="报备类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部类型', value: 'all' }, { label: '签名报备', value: 'signature' }, { label: '引流信息报备', value: 'drainage' }]} value={reportType} />
<DateRangeInput label="创建时间" onChange={setDateRange} value={dateRange} />
<div className="admin-task-filter__actions">
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button onClick={() => { setKeyword(''); setDateRange({}); setReportType('all'); }} variant="ghost"></Button>
</div>
</div>
<div className="surface admin-task-table-card report-task-table-card">
<Table columns={columns} data={filteredTasks} emptyText="暂无报备任务" rowKey="id" />
</div>
{receiptTask ? <ReceiptImportModal onClose={() => setReceiptTask(null)} onSubmit={importReceipt} task={receiptTask} /> : null}
{detailTask ? <TaskDetailModal onClose={() => setDetailTask(null)} task={detailTask} /> : null}
<Modal footer={<><Button onClick={() => setStatusTask(null)} variant="ghost"></Button><Button onClick={saveTaskStatus}></Button></>} onClose={() => setStatusTask(null)} open={Boolean(statusTask)} title="人工修正报备任务状态"><div className="admin-system-modal-form"><Select label="报备状态" onChange={(event) => setNextStatus(event.target.value)} options={[{label:'未报备',value:'pending'},{label:'资料待补充',value:'waiting_material'},{label:'报备中',value:'reporting'},{label:'报备通过',value:'approved'},{label:'报备失败',value:'failed'},{label:'放弃报备',value:'abandoned'}]} value={nextStatus}/><Textarea label="修改原因" onChange={(event) => setStatusReason(event.target.value)} rows={3} value={statusReason}/></div></Modal>
</section>
);
<Select label="修改为" onChange={(event) => setNextStatus(event.target.value)} options={[
{ label: '未报备', value: 'pending' },
{ label: '资料待补充', value: 'waiting_material' },
{ label: '报备中', value: 'reporting' },
{ label: '报备通过', value: 'approved' },
{ label: '报备失败', value: 'failed' },
{ label: '放弃报备', value: 'abandoned' },
]} value={nextStatus} />
<Textarea label="修改原因(选填)" onChange={(event) => setStatusReason(event.target.value)} placeholder="可填写供应商反馈或人工处理说明" rows={3} value={statusReason} />
</div> : null}
</Modal>
</section>;
}
+7 -3
View File
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { Eye, Search, X } from 'lucide-react';
import { adminApi, type ClientSmsSignature, type FileRef } from '@/api/adminApi';
import { Breadcrumb, Button, FileActions, Input, Modal, RiskAction, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
import { AuditReviewInfo, Breadcrumb, Button, FileActions, Input, Modal, RiskAction, Select, Table, Tabs, Tag, Textarea, type TableColumn } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
import { ReportImportAuditPanel } from './ReportImportAuditPanel';
const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'danger' }> = {
draft: { label: '草稿', tone: 'neutral' },
@@ -57,6 +58,7 @@ function SignatureDetail({ item, onClose }: { item: ClientSmsSignature; onClose:
<div><span></span><strong>{String(profile.legalPersonName ?? '-')}</strong></div>
<div><span></span><strong>{String(profile.responsibleName ?? '-')}</strong></div>
<div><span></span><strong>{String(profile.responsiblePhone ?? '-')}</strong></div>
<AuditReviewInfo targetId={item.id} targetType="sms_signature" />
{item.rejectReason ? <div className="detail-grid__wide"><span></span><strong>{item.rejectReason}</strong></div> : null}
</div>
<div className="surface" style={{ padding: 16 }}><strong></strong><div className="table-actions" style={{ marginTop: 12 }}>{files.length ? files.map((file) => <FileActions file={file} key={file.fileObjectId} />) : <span className="muted"></span>}</div></div>
@@ -101,8 +103,10 @@ export function AdminSignatureAuditPage() {
return <section className="page-stack admin-template-audit-page">
<div className="page-heading"><div><Breadcrumb items={['审核中心', '短信签名审核']} /><h1></h1></div></div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface audit-filter-card"><div className="audit-filter-grid audit-filter-grid--template"><Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索企业、应用或签名" prefix={<Search size={16} />} value={keyword} /><Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '待审核', value: 'pending' }, { label: '已通过', value: 'approved' }, { label: '已驳回', value: 'rejected' }, { label: '草稿', value: 'draft' }]} value={status} /><div className="audit-filter-actions"><Button icon={<Search size={17} />} onClick={loadData}></Button><Button onClick={() => { setKeyword(''); setStatus('pending'); }} variant="ghost"></Button></div></div></div>
<div className="surface"><Table columns={columns} data={visible} emptyText="暂无签名审核记录" rowKey="id" /></div>
<Tabs items={[
{ label: '单条签名审核', value: 'single', content: <div className="page-stack"><div className="surface audit-filter-card"><div className="audit-filter-grid audit-filter-grid--template"><Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索企业、应用或签名" prefix={<Search size={16} />} value={keyword} /><Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '待审核', value: 'pending' }, { label: '已通过', value: 'approved' }, { label: '已驳回', value: 'rejected' }, { label: '草稿', value: 'draft' }]} value={status} /><div className="audit-filter-actions"><Button icon={<Search size={17} />} onClick={loadData}></Button><Button onClick={() => { setKeyword(''); setStatus('pending'); }} variant="ghost"></Button></div></div></div><div className="surface"><Table columns={columns} data={visible} emptyText="暂无签名审核记录" rowKey="id" /></div></div> },
{ label: '导入批次审核', value: 'import', content: <ReportImportAuditPanel reportType="signature" /> },
]} />
{detail ? <SignatureDetail item={detail} onClose={() => setDetail(undefined)} /> : null}
<Modal footer={<><Button onClick={() => setRejectTarget(undefined)} variant="ghost"></Button><Button disabled={!reason.trim()} onClick={() => void reject()} variant="danger"></Button></>} onClose={() => setRejectTarget(undefined)} open={Boolean(rejectTarget)} title="驳回签名审核"><Textarea label="驳回原因" onChange={(event) => setReason(event.target.value)} rows={4} value={reason} /></Modal>
</section>;
+8 -6
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { CalendarDays, Check, Info, Search, X } from 'lucide-react';
import { CalendarDays, Check, Eye, Search, X } from 'lucide-react';
import { adminApi, type RiskReviewTask, type RiskTaskMessagePage } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
@@ -133,12 +133,12 @@ export function AdminSmsAuditPage() {
width: '54px',
render: (record) => <input aria-label={`选择审核任务${record.taskNo}`} checked={selectedIds.includes(record.id)} disabled={record.status !== 'pending_review'} onChange={(event) => setSelectedIds((current) => event.target.checked ? [...new Set([...current, record.id])] : current.filter((id) => id !== record.id))} type="checkbox" />,
},
{ key: 'taskNo', title: '审核任务号', width: '210px', render: (record) => <strong className="admin-task-id">{record.taskNo}</strong> },
{ key: 'tenant', title: '发送企业', width: '180px', render: (record) => <strong>{record.tenant?.name ?? record.tenantId}</strong> },
{ key: 'application', title: '企业应用', width: '180px', render: (record) => record.application?.name ?? record.applicationId ?? '-' },
{ key: 'sourceType', title: '审核来源', width: '180px', render: (record) => <Tag tone={record.sourceType === 'cmpp_template_mismatch' ? 'warning' : 'info'}>{sourceLabel(record.sourceType)}</Tag> },
{ key: 'content', title: '短信内容', render: (record) => <span className="table-long-text">{record.content}</span> },
{ key: 'phoneTotal', title: '号码数量', width: '140px', render: (record) => <button className="table-link" onClick={() => { setPhoneTarget(record); setPhoneKeyword(''); setPhonePage(1); }} type="button">{(record._count?.messageRecords || record.phoneTotal).toLocaleString('zh-CN')} · </button> },
{ key: 'createdAt', title: '提交时间', width: '190px', render: (record) => formatDateTime(record.createdAt) },
{ key: 'reason', title: '审核原因', render: (record) => record.reviewReason ?? record.rejectReason ?? record.riskHits?.map((item) => item.reason).join('') ?? '-' },
{
key: 'status',
title: '状态',
@@ -152,7 +152,7 @@ export function AdminSmsAuditPage() {
align: 'right',
render: (record) => (
<div className="audit-actions">
<Button icon={<Info size={15} />} onClick={() => setDetailTarget(record)} size="sm" variant="ghost"></Button>
<Button icon={<Eye size={15} />} onClick={() => setDetailTarget(record)} size="sm" variant="ghost"></Button>
{record.status === 'pending_review' ? <>
<Button icon={<Check size={15} />} onClick={() => setApproveTarget(record)} size="sm" variant="success"></Button>
<Button icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger"></Button>
@@ -212,11 +212,13 @@ export function AdminSmsAuditPage() {
<p>{approveTarget === 'batch' ? `确认通过已选择的 ${selectedIds.length} 条待审核任务?` : '确认通过该短信审核任务?'}</p>
</Modal>
{detailTarget ? <Modal footer={<Button onClick={() => setDetailTarget(null)}></Button>} onClose={() => setDetailTarget(null)} open title="审核任务更多信息">
{detailTarget ? <Modal footer={<Button onClick={() => setDetailTarget(null)}></Button>} onClose={() => setDetailTarget(null)} open title="短信审核详情">
<div className="detail-grid">
<div><span></span><strong>{detailTarget.taskNo}</strong></div>
<div><span></span><strong>{detailTarget.tenant?.name ?? detailTarget.tenantId}</strong></div>
<div><span></span><strong>{detailTarget.application?.name ?? detailTarget.applicationId ?? '-'}</strong></div>
<div><span></span><strong>{formatDateTime(detailTarget.createdAt)}</strong></div>
<div><span></span><strong>{detailTarget.reviewedBy?.displayName || detailTarget.reviewedBy?.username || '-'}</strong></div>
<div><span></span><strong>{detailTarget.reviewedBy?.username || '-'}</strong></div>
<div><span></span><strong>{formatDateTime(detailTarget.reviewedAt)}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{detailTarget.reviewReason || '-'}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{detailTarget.rejectReason || '-'}</strong></div>
+63 -2
View File
@@ -1,6 +1,6 @@
import { Fragment, useEffect, useMemo, useState } from 'react';
import { BarChart3, CalendarClock, Eye, MapPin, Search, Send, Smartphone, StopCircle, TrendingUp } from 'lucide-react';
import { adminApi, type SmsBatchTask, type SmsMessageRecord } from '@/api/adminApi';
import { adminApi, type BatchTaskMessagePage, type SmsBatchTask, type SmsMessageRecord } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import {
Breadcrumb,
@@ -89,6 +89,21 @@ function formatTime(value?: string | null) {
return formatDateTime(value);
}
function messageStatusLabel(status: string) {
return {
pending_review: '待人工审核',
queued: '已入队',
scheduled: '等待定时发送',
submitted: '供应商已受理',
delivered: '送达成功',
submit_failed: '提交失败',
failed: '回执失败',
rejected: '已拒绝',
timeout: '超时',
canceled: '已取消',
}[status] ?? status;
}
function normalizeTaskStatus(status: string): TaskStatus {
if (['finished', 'completed', 'done'].includes(status)) return 'completed';
if (['canceled', 'cancelled', 'terminated'].includes(status)) return 'terminated';
@@ -359,6 +374,11 @@ export function AdminSmsTaskProgressPage() {
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [selectedTask, setSelectedTask] = useState<SmsTask | null>(null);
const [phoneTarget, setPhoneTarget] = useState<SmsTask | null>(null);
const [phoneKeyword, setPhoneKeyword] = useState('');
const [phonePage, setPhonePage] = useState(1);
const [phonePageSize, setPhonePageSize] = useState(20);
const [phoneData, setPhoneData] = useState<BatchTaskMessagePage>({ items: [], total: 0, page: 1, pageSize: 20 });
const [terminateTarget, setTerminateTarget] = useState<SmsTask | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -378,6 +398,17 @@ export function AdminSmsTaskProgressPage() {
loadTasks();
}, []);
function loadPhones(target = phoneTarget, page = phonePage, pageSize = phonePageSize) {
if (!target) return;
adminApi.listAdminBatchTaskMessages(target.backendId, { phone: phoneKeyword || undefined, page, pageSize })
.then(setPhoneData)
.catch((failure: Error) => setError(failure.message || '发送批次号码列表加载失败'));
}
useEffect(() => {
if (phoneTarget) loadPhones(phoneTarget, phonePage, phonePageSize);
}, [phoneTarget, phonePage, phonePageSize]);
const enterpriseOptions = useMemo(() => {
const names = Array.from(new Set(tasks.map((item) => item.enterprise)));
return [{ label: '全部企业', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
@@ -498,7 +529,7 @@ export function AdminSmsTaskProgressPage() {
<td><span>{formatTime(record.submittedAt)}</span></td>
<td>
<div className="admin-task-counts">
<strong>{formatNumber(record.phoneCount)}</strong>
<button className="table-link" onClick={() => { setPhoneTarget(record); setPhoneKeyword(''); setPhonePage(1); }} type="button">{formatNumber(record.phoneCount)} · </button>
<span>{record.wordCount}</span>
</div>
</td>
@@ -568,6 +599,36 @@ export function AdminSmsTaskProgressPage() {
</div>
{selectedTask ? <TaskDetailModal onClose={() => setSelectedTask(null)} task={selectedTask} /> : null}
{phoneTarget ? <Modal footer={<Button onClick={() => setPhoneTarget(null)}></Button>} onClose={() => setPhoneTarget(null)} open size="xl" title={`号码列表 · 发送批次号 ${phoneTarget.id}`}>
<div className="page-stack">
<div className="audit-filter-grid">
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} placeholder="输入完整或部分号码" value={phoneKeyword} />
<Select label="每页条数" onChange={(event) => { setPhonePageSize(Number(event.target.value)); setPhonePage(1); }} options={[{ label: '10条/页', value: '10' }, { label: '20条/页', value: '20' }, { label: '50条/页', value: '50' }]} value={String(phonePageSize)} />
<div className="audit-filter-actions"><Button icon={<Search size={16} />} onClick={() => { setPhonePage(1); loadPhones(phoneTarget, 1, phonePageSize); }}></Button></div>
</div>
<Table
columns={[
{ key: 'phoneNumber', title: '手机号码', render: (item) => <strong>{item.phoneNumber}</strong> },
{ key: 'province', title: '号码归属地', render: (item) => item.province || '-' },
{ key: 'carrier', title: '运营商', render: (item) => carrierLabels[item.carrier ?? '']?.label ?? item.carrier ?? '-' },
{ key: 'status', title: '短信记录状态', render: (item) => <Tag tone={item.status === 'delivered' ? 'success' : ['failed', 'submit_failed', 'rejected', 'timeout'].includes(item.status) ? 'danger' : 'info'}>{messageStatusLabel(item.status)}</Tag> },
]}
data={phoneData.items}
emptyText="暂无号码记录"
rowKey="id"
/>
<Pagination
nextDisabled={phonePage * phonePageSize >= phoneData.total}
onNext={() => setPhonePage((current) => current + 1)}
onPageChange={setPhonePage}
onPrevious={() => setPhonePage((current) => Math.max(1, current - 1))}
page={phonePage}
previousDisabled={phonePage <= 1}
total={phoneData.total}
totalPages={Math.max(1, Math.ceil(phoneData.total / phonePageSize))}
/>
</div>
</Modal> : null}
{terminateTarget ? (
<Modal
footer={(
+16 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Search, X } from 'lucide-react';
import { Breadcrumb, Button, Input, RiskAction, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { Eye, Search, X } from 'lucide-react';
import { AuditReviewInfo, Breadcrumb, Button, Input, Modal, RiskAction, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { adminApi, type SmsTemplateAudit } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
@@ -22,6 +22,7 @@ export function AdminTemplateAuditPage() {
const [audits, setAudits] = useState<SmsTemplateAudit[]>([]);
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('all');
const [detail, setDetail] = useState<SmsTemplateAudit>();
useEffect(() => {
adminApi.listTemplateAudits({ keyword, status })
@@ -55,6 +56,7 @@ export function AdminTemplateAuditPage() {
align: 'right',
render: (record) => (
<div className="table-actions">
<Button icon={<Eye size={15} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button>
<RiskAction disabled={record.auditStatus !== 'pending'} onCompleted={() => adminApi.listTemplateAudits({ keyword, status }).then(setAudits)} targetId={record.id} targetType="template" />
<Button
disabled={record.auditStatus !== 'pending'}
@@ -93,6 +95,18 @@ export function AdminTemplateAuditPage() {
<div className="surface">
<Table columns={columns} data={templateAudits} rowKey="id" />
</div>
{detail ? <Modal footer={<Button onClick={() => setDetail(undefined)}></Button>} onClose={() => setDetail(undefined)} open size="xl" title="短信模板审核详情">
<div className="detail-grid">
<div><span></span><strong>{detail.tenant?.name ?? detail.tenantId}</strong></div>
<div><span></span><strong>{detail.application?.name ?? detail.applicationId}</strong></div>
<div><span></span><strong>{detail.name}</strong></div>
<div><span></span><strong>{formatDateTime(detail.createdAt)}</strong></div>
<div><span></span><Tag tone={detail.auditStatus === 'approved' ? 'success' : detail.auditStatus === 'rejected' ? 'danger' : 'info'}>{auditStatusLabelMap[detail.auditStatus] ?? detail.auditStatus}</Tag></div>
<AuditReviewInfo targetId={detail.id} targetType="sms_template" />
<div className="detail-grid__wide"><span></span><strong>{detail.content}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{detail.rejectReason || '-'}</strong></div>
</div>
</Modal> : null}
</section>
);
}
+169
View File
@@ -0,0 +1,169 @@
import { useEffect, useMemo, useState } from 'react';
import { Check, Eye, Search, X } from 'lucide-react';
import { adminApi, type ReportImportReviewBatch, type ReportImportReviewItem } from '@/api/adminApi';
import { Button, Input, Modal, Pagination, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
const statusLabels: Record<string, string> = {
pending_review: '待审核',
partially_reviewed: '部分已审核',
partially_approved: '部分通过',
approved: '已通过',
rejected: '已驳回',
failed: '导入失败',
};
const itemStatusLabels: Record<string, string> = {
pending_review: '待审核',
approved: '已通过',
rejected: '已驳回',
invalid: '数据异常',
};
function itemName(item: ReportImportReviewItem) {
if (item.reportType === 'signature') return String(item.payload.name ?? '-');
return String(item.payload.siteName ?? item.payload.url ?? '-');
}
function tone(status: string): 'neutral' | 'info' | 'success' | 'warning' | 'danger' {
if (status === 'approved') return 'success';
if (['rejected', 'failed', 'invalid'].includes(status)) return 'danger';
if (['partially_reviewed', 'partially_approved'].includes(status)) return 'warning';
return status === 'pending_review' ? 'info' : 'neutral';
}
export function ReportImportAuditPanel({ reportType }: { reportType: 'signature' | 'drainage' }) {
const [data, setData] = useState<{ items: ReportImportReviewBatch[]; total: number; page: number; pageSize: number }>({ items: [], total: 0, page: 1, pageSize: 20 });
const [page, setPage] = useState(1);
const pageSize = 20;
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('all');
const [detail, setDetail] = useState<ReportImportReviewBatch>();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [rejectOpen, setRejectOpen] = useState(false);
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
function load(targetPage = page) {
adminApi.listReportImportReviewBatches({
reportType,
status: status === 'all' ? undefined : status,
keyword: keyword.trim() || undefined,
page: targetPage,
pageSize,
}).then((result) => {
setData(result);
setError('');
if (detail) setDetail(result.items.find((item) => item.id === detail.id));
}).catch((failure: Error) => setError(failure.message || '导入审核批次加载失败'));
}
useEffect(() => {
load(page);
}, [page, pageSize, reportType, status]);
const pendingItems = detail?.items.filter((item) => item.status === 'pending_review') ?? [];
const allPendingSelected = pendingItems.length > 0 && pendingItems.every((item) => selected.has(item.id));
async function review(decision: 'approve' | 'reject') {
if (!detail) return;
const itemIds = selected.size ? [...selected] : pendingItems.map((item) => item.id);
if (!itemIds.length) return;
setBusy(true);
setError('');
try {
await adminApi.reviewReportImportItems(detail.id, { decision, itemIds, reason: reason.trim() || undefined });
setSelected(new Set());
setReason('');
setRejectOpen(false);
const refreshed = await adminApi.listReportImportReviewBatches({
reportType,
status: status === 'all' ? undefined : status,
keyword: keyword.trim() || undefined,
page,
pageSize,
});
setData(refreshed);
setDetail(refreshed.items.find((item) => item.id === detail.id));
} catch (failure) {
setError(failure instanceof Error ? failure.message : '导入批次审核失败');
} finally {
setBusy(false);
}
}
const columns = useMemo<Array<TableColumn<ReportImportReviewBatch>>>(() => [
{ key: 'file', title: '导入批次/文件', render: (record) => <div><strong>{record.id}</strong><div className="muted">{record.fileName}</div></div> },
{ key: 'tenant', title: '企业/应用', render: (record) => <div><strong>{record.tenant?.name ?? record.tenantId}</strong><div className="muted">{record.application?.name ?? '未指定应用'}</div></div> },
{ key: 'summary', title: '明细统计', render: (record) => {
const approved = record.items.filter((item) => item.status === 'approved').length;
const pending = record.items.filter((item) => item.status === 'pending_review').length;
const abnormal = record.items.filter((item) => item.status === 'invalid').length;
return `${record.items.length} · 待审${pending} · 通过${approved} · 异常${abnormal}`;
} },
{ key: 'status', title: '状态', render: (record) => <Tag tone={tone(record.status)}>{statusLabels[record.status] ?? record.status}</Tag> },
{ key: 'createdAt', title: '导入时间', render: (record) => formatDateTime(record.createdAt) },
{ key: 'actions', title: '操作', align: 'right', render: (record) => <Button icon={<Eye size={15} />} onClick={() => {
setDetail(record);
setSelected(new Set());
}} size="sm" variant="ghost"></Button> },
], []);
const itemColumns: Array<TableColumn<ReportImportReviewItem>> = [
{ key: 'select', title: '', width: '48px', render: (item) => <input aria-label={`选择第${item.rowNumber}`} checked={selected.has(item.id)} disabled={item.status !== 'pending_review'} onChange={() => setSelected((current) => {
const next = new Set(current);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
return next;
})} type="checkbox" /> },
{ key: 'row', title: '文件行', width: '80px', render: (item) => `${item.rowNumber}` },
{ key: 'name', title: reportType === 'signature' ? '签名' : '引流url或号码', render: (item) => <strong>{itemName(item)}</strong> },
{ key: 'operation', title: '变更类型', width: '100px', render: (item) => item.operation === 'create' ? '新增' : item.operation === 'update' ? '修改' : '无效' },
{ key: 'status', title: '状态', width: '110px', render: (item) => <Tag tone={tone(item.status)}>{itemStatusLabels[item.status] ?? item.status}</Tag> },
{ key: 'reason', title: '说明', render: (item) => item.errorMessage || item.reviewReason || '-' },
];
return <div className="page-stack">
{error ? <p className="form-error">{error}</p> : null}
<div className="surface audit-filter-card">
<div className="audit-filter-grid audit-filter-grid--template">
<Input label="批次号/文件名" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索导入批次或文件" value={keyword} />
<Select label="批次状态" onChange={(event) => { setStatus(event.target.value); setPage(1); }} options={[
{ label: '全部状态', value: 'all' },
{ label: '待审核', value: 'pending_review' },
{ label: '部分已审核', value: 'partially_reviewed' },
{ label: '已通过', value: 'approved' },
{ label: '部分通过', value: 'partially_approved' },
{ label: '已驳回', value: 'rejected' },
]} value={status} />
<div className="audit-filter-actions"><Button icon={<Search size={16} />} onClick={() => { setPage(1); load(1); }}></Button></div>
</div>
</div>
<div className="surface"><Table columns={columns} data={data.items} emptyText="暂无导入审核批次" pagination={false} rowKey="id" /></div>
<Pagination
nextDisabled={page * pageSize >= data.total}
onNext={() => setPage((current) => current + 1)}
onPageChange={setPage}
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
page={page}
previousDisabled={page <= 1}
total={data.total}
totalPages={Math.max(1, Math.ceil(data.total / pageSize))}
/>
{detail ? <Modal footer={<><Button onClick={() => setDetail(undefined)} variant="ghost"></Button><Button disabled={busy || pendingItems.length === 0} icon={<X size={15} />} onClick={() => setRejectOpen(true)} variant="danger">{selected.size ? `驳回所选(${selected.size}` : `驳回全部可审核项(${pendingItems.length}`}</Button><Button disabled={busy || pendingItems.length === 0} icon={<Check size={15} />} onClick={() => void review('approve')} variant="success">{selected.size ? `通过所选(${selected.size}` : `通过全部可审核项(${pendingItems.length}`}</Button></>} onClose={() => setDetail(undefined)} open size="xl" title={`${reportType === 'signature' ? '签名' : '引流信息'}导入批次详情`}>
<div className="page-stack">
<div className="detail-grid">
<div><span></span><strong>{detail.fileName}</strong></div>
<div><span></span><strong>{detail.tenant?.name ?? detail.tenantId}</strong></div>
<div><span></span><strong>{detail.application?.name ?? '未指定应用'}</strong></div>
<div><span></span><strong>{formatDateTime(detail.createdAt)}</strong></div>
</div>
<label className="table-actions"><input checked={allPendingSelected} onChange={() => setSelected(allPendingSelected ? new Set() : new Set(pendingItems.map((item) => item.id)))} type="checkbox" /></label>
<Table columns={itemColumns} data={detail.items} emptyText="暂无导入明细" pagination={false} rowKey="id" />
</div>
</Modal> : null}
{rejectOpen ? <Modal footer={<><Button onClick={() => setRejectOpen(false)} variant="ghost"></Button><Button disabled={busy} onClick={() => void review('reject')} variant="danger"></Button></>} onClose={() => setRejectOpen(false)} open title="驳回导入资料">
<Textarea label="驳回原因(选填)" onChange={(event) => setReason(event.target.value)} rows={4} value={reason} />
</Modal> : null}
</div>;
}
+1 -1
View File
@@ -99,7 +99,7 @@ export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: (
...libraryFields.map((field) => ({ label: `报备字段 · ${String(field.name ?? field.code)}`, value: `dynamic:${String(field.code)}:${field.fieldType === 'string' ? 'string' : field.fieldType}` })),
];
return <Modal footer={<><Button onClick={onClose} variant="ghost"></Button>{analysis ? <Button disabled={busy} onClick={() => void commit()}>{busy ? '导入中...' : '确认导入待报备池'}</Button> : <Button disabled={busy || !file || !tenantId} onClick={() => void analyze()}>{busy ? '解析中...' : '解析文件并配置映射'}</Button>}</>} onClose={onClose} open size="xl" title={<div className="channel-field-config-title"><h2></h2><p> WPS XLSX </p></div>}>
return <Modal footer={<><Button onClick={onClose} variant="ghost"></Button>{analysis ? <Button disabled={busy} onClick={() => void commit()}>{busy ? '提交中...' : '提交导入审核'}</Button> : <Button disabled={busy || !file || !tenantId} onClick={() => void analyze()}>{busy ? '解析中...' : '解析文件并配置映射'}</Button>}</>} onClose={onClose} open size="xl" title={<div className="channel-field-config-title"><h2></h2><p> WPS XLSX </p></div>}>
<div className="report-import-basic-grid">
<Select label="资料类型" onChange={(event) => setReportType(event.target.value as ReportType)} options={[{ label: '签名资料', value: 'signature' }, { label: '引流信息资料', value: 'drainage' }]} value={reportType} />
<Select label="所属企业" onChange={(event) => { setTenantId(event.target.value); setApplicationId(''); }} options={[{ label: '请选择企业', value: '' }, ...tenants.map((item) => ({ label: `${item.name}${item.code}`, value: item.id }))]} value={tenantId} />
+42
View File
@@ -0,0 +1,42 @@
import { useEffect, useMemo, useState } from 'react';
import { adminApi, type AuditRecord } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
const automaticReviewActions = new Set(['admin_create_approved', 'admin_update_approved']);
const reviewActions = new Set(['approve', 'reject', ...automaticReviewActions]);
export function AuditReviewInfo({ targetId, targetType }: { targetId: string; targetType: string }) {
const [records, setRecords] = useState<AuditRecord[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let active = true;
setLoading(true);
adminApi.listAuditRecords({ targetType, targetId })
.then((items) => {
if (active) setRecords(items);
})
.catch(() => {
if (active) setRecords([]);
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [targetId, targetType]);
const review = useMemo(() => records.find((record) => reviewActions.has(record.action)), [records]);
return <>
<div><span></span><strong>{loading ? '加载中…' : formatDateTime(review?.createdAt)}</strong></div>
<div>
<span></span>
<strong>
{loading
? '加载中…'
: review?.reviewer?.username ?? (review && automaticReviewActions.has(review.action) ? '系统自动' : '-')}
</strong>
</div>
</>;
}
+1
View File
@@ -7,6 +7,7 @@ export { DetailInfoGrid, DetailProgressStats, DetailSection, DetailTitle, getRat
export { FileActions } from './FileActions';
export { SystemLogExport } from './SystemLogExport';
export { RiskAction } from './RiskAction';
export { AuditReviewInfo } from './AuditReviewInfo';
export { DeleteRiskAction } from './DeleteRiskAction';
export { ManualRechargeDialog } from './ManualRechargeDialog';
export type { ManualRechargeTarget } from './ManualRechargeDialog';
+2 -2
View File
@@ -132,8 +132,8 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
title: '报备任务',
icon: ClipboardList,
items: [
{ label: '待报备资料', to: '/admin/report-materials', icon: FileSpreadsheet },
{ label: '报备任务', to: '/admin/report-tasks', icon: ClipboardList },
{ label: '待生成报备批次', to: '/admin/report-materials', icon: FileSpreadsheet },
{ label: '报备明细', to: '/admin/report-tasks', icon: ClipboardList },
{ label: '报备记录', to: '/admin/report-records', icon: ListChecks },
],
},
+455
View File
@@ -8351,6 +8351,461 @@ h3 {
padding: 0;
}
.admin-task-table-card__heading {
padding: var(--space-5);
}
.admin-task-table-card__heading h2 {
margin: 0 0 var(--space-1);
}
.admin-task-table-card__heading .page-inline-hint {
margin: 0;
}
.recovery-status-explainer {
align-items: flex-start;
display: flex;
gap: var(--space-3);
}
.recovery-status-explainer > svg {
color: var(--color-primary);
flex: 0 0 auto;
margin-top: 2px;
}
.recovery-status-explainer strong {
color: var(--color-text-strong);
}
.recovery-status-explainer p {
color: var(--color-text-muted);
line-height: 1.7;
margin: var(--space-1) 0 0;
}
.signature-quality-card {
overflow: hidden;
}
.signature-quality-card__heading {
align-items: flex-start;
display: flex;
gap: var(--space-5);
justify-content: space-between;
padding: var(--space-5);
}
.signature-quality-card__heading h2,
.signature-quality-section h3 {
margin: 0;
}
.signature-quality-card__heading p,
.signature-quality-section__heading p {
margin: var(--space-1) 0 0;
}
.section-heading__title {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.signature-quality-card__query {
align-items: flex-end;
display: flex;
flex: 0 1 390px;
gap: var(--space-2);
}
.signature-quality-card__query .ui-field {
flex: 1;
}
.signature-quality-card__note {
background: color-mix(in srgb, var(--color-primary) 6%, white);
border-bottom: 1px solid var(--color-border);
border-top: 1px solid var(--color-border);
color: var(--color-text-muted);
font-size: 13px;
line-height: 1.6;
padding: 10px var(--space-5);
}
.signature-quality-card .ui-table-wrap {
border: 0;
border-radius: 0;
}
.signature-quality-card > .ui-pagination {
border-top: 1px solid var(--color-border);
padding: var(--space-4) var(--space-5);
}
.signature-quality-name {
display: grid;
gap: 3px;
}
.signature-quality-name strong {
color: var(--color-text-strong);
}
.signature-quality-name span,
.signature-quality-name small {
color: var(--color-text-muted);
}
.quality-number--success {
color: var(--color-success);
}
.quality-number--warning {
color: var(--color-warning);
}
.quality-number--danger {
color: var(--color-danger);
}
.signature-quality-rate {
align-items: center;
display: flex;
gap: var(--space-2);
}
.signature-quality-rate > div {
background: var(--color-surface-muted);
border-radius: 99px;
flex: 1;
height: 6px;
overflow: hidden;
}
.signature-quality-rate > div span {
background: var(--color-success);
border-radius: inherit;
display: block;
height: 100%;
}
.signature-quality-rate strong {
min-width: 50px;
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;
position: fixed;
z-index: 1200;
}
.signature-quality-drawer {
animation: signature-quality-drawer-in 180ms ease-out;
background: var(--color-bg);
box-shadow: -18px 0 48px rgb(15 23 42 / 18%);
display: flex;
flex-direction: column;
height: 100%;
margin-left: auto;
max-width: min(1120px, calc(100vw - 72px));
width: 88vw;
}
@keyframes signature-quality-drawer-in {
from {
opacity: 0;
transform: translateX(28px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.signature-quality-drawer__header {
align-items: flex-start;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
display: flex;
gap: var(--space-5);
justify-content: space-between;
padding: var(--space-5) var(--space-6);
}
.signature-quality-drawer__header p {
color: var(--color-primary);
font-size: 13px;
margin: 0 0 var(--space-2);
}
.signature-quality-drawer__header h2 {
margin: 0;
}
.signature-quality-drawer__header span {
color: var(--color-text-muted);
display: block;
margin-top: var(--space-2);
}
.signature-quality-drawer__header > button {
align-items: center;
background: transparent;
border: 0;
border-radius: var(--radius-md);
color: var(--color-text-muted);
cursor: pointer;
display: inline-flex;
justify-content: center;
padding: var(--space-2);
}
.signature-quality-drawer__header > button:hover {
background: var(--color-surface-muted);
color: var(--color-text-strong);
}
.signature-quality-drawer__body {
display: grid;
gap: var(--space-5);
overflow: auto;
padding: var(--space-5) var(--space-6) var(--space-7);
}
.signature-quality-overview {
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(4, minmax(0, 1fr));
min-width: 0;
}
.signature-quality-metric {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
display: grid;
gap: var(--space-2);
padding: var(--space-4);
}
.signature-quality-metric span {
color: var(--color-text-muted);
font-size: 13px;
}
.signature-quality-metric strong {
color: var(--color-text-strong);
font-size: 24px;
}
.signature-quality-metric--success strong {
color: var(--color-success);
}
.signature-quality-metric--warning strong {
color: var(--color-warning);
}
.signature-quality-metric--danger strong {
color: var(--color-danger);
}
.signature-quality-section {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
min-width: 0;
padding: var(--space-5);
}
.signature-quality-section__heading {
margin-bottom: var(--space-4);
}
.signature-quality-section__heading p {
color: var(--color-text-muted);
font-size: 13px;
}
.signature-carrier-grid {
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.signature-carrier-card {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
}
.signature-carrier-card > div {
align-items: center;
display: flex;
justify-content: space-between;
}
.signature-carrier-card > div strong {
color: var(--color-text-strong);
}
.signature-carrier-card dl {
display: grid;
gap: var(--space-2);
margin: var(--space-4) 0 0;
}
.signature-carrier-card dl div {
align-items: center;
display: flex;
justify-content: space-between;
}
.signature-carrier-card dt {
color: var(--color-text-muted);
}
.signature-carrier-card dd {
color: var(--color-text-strong);
font-weight: 600;
margin: 0;
}
.signature-quality-matrix {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
max-width: 100%;
overflow: auto;
width: 100%;
}
.signature-quality-matrix table {
border-collapse: collapse;
min-width: 760px;
width: 100%;
}
.signature-quality-matrix th,
.signature-quality-matrix td {
border-bottom: 1px solid var(--color-border);
border-right: 1px solid var(--color-border);
padding: var(--space-3);
text-align: left;
vertical-align: top;
}
.signature-quality-matrix th:last-child,
.signature-quality-matrix td:last-child {
border-right: 0;
}
.signature-quality-matrix tr:last-child th,
.signature-quality-matrix tr:last-child td {
border-bottom: 0;
}
.signature-quality-matrix thead th {
background: var(--color-surface-muted);
color: var(--color-text-muted);
font-size: 13px;
}
.signature-quality-matrix tbody th {
color: var(--color-text-strong);
min-width: 210px;
}
.signature-quality-matrix__metric {
display: grid;
gap: 4px;
}
.signature-quality-matrix__metric strong {
color: var(--color-text-strong);
}
.signature-quality-matrix__metric small {
color: var(--color-text-muted);
}
.signature-quality-matrix__metric em {
color: var(--color-warning);
font-size: 12px;
font-style: normal;
}
.signature-quality-matrix__rate {
font-weight: 600;
}
.signature-quality-matrix__empty {
color: var(--color-text-subtle);
}
.signature-quality-drawer__footnote {
color: var(--color-text-muted);
font-size: 13px;
line-height: 1.7;
margin: 0;
}
@media (max-width: 900px) {
.signature-quality-card__heading,
.signature-quality-card__query {
align-items: stretch;
flex-direction: column;
}
.signature-quality-card__query {
flex-basis: auto;
width: 100%;
}
.signature-quality-drawer {
max-width: none;
width: 100%;
}
.signature-quality-overview,
.signature-carrier-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 600px) {
.signature-quality-drawer__header,
.signature-quality-drawer__body {
padding-left: var(--space-4);
padding-right: var(--space-4);
}
.signature-quality-overview,
.signature-carrier-grid {
grid-template-columns: 1fr;
}
}
.admin-task-table-card .ui-table-wrap {
border: 0;
border-radius: 0;