feat: complete drainage review and admin search workflows
This commit is contained in:
+54
-9
@@ -210,7 +210,7 @@ export type DashboardResponse = {
|
||||
transactions: { _count: { _all: number }; _sum: { amountCents?: number | null; smsUnits?: number | null } };
|
||||
gatewayConnections: Array<{ status: string; _count: { _all: number }; _sum: { currentConnections?: number | null; desiredConnections?: number | null } }>;
|
||||
pendingAuditCount: number;
|
||||
pendingAudits: { enterpriseCertifications: number; smsAudits: number; templates: number; signatures: number; total: number };
|
||||
pendingAudits: { enterpriseCertifications: number; smsAudits: number; templates: number; signatures: number; drainageInfos: number; total: number };
|
||||
downstreamDeliverySummary?: {
|
||||
pending: number;
|
||||
failed: number;
|
||||
@@ -289,6 +289,27 @@ export type ClientSmsSignature = {
|
||||
drainageCarrierReportSummary?: Record<string, Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>>;
|
||||
};
|
||||
|
||||
export type SmsDrainageInfo = {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
signatureId: string;
|
||||
applicationId?: string | null;
|
||||
siteName: string;
|
||||
url: string;
|
||||
remark?: string | null;
|
||||
reportValues?: Record<string, unknown> | null;
|
||||
auditStatus: string;
|
||||
rejectReason?: string | null;
|
||||
submittedAt: string;
|
||||
reviewedAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tenant?: TenantOption;
|
||||
signature?: ClientSmsSignature;
|
||||
application?: ClientSmsApplication | null;
|
||||
reportTasks?: ReportTask[];
|
||||
};
|
||||
|
||||
export type ClientSmsTemplate = {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
@@ -303,7 +324,7 @@ export type ClientSmsTemplate = {
|
||||
updatedAt: string;
|
||||
variables?: Array<{ name: string; example?: string | null; required?: boolean }>;
|
||||
application?: { id: string; name: string };
|
||||
signature?: { id: string; name: string; drainageInfo?: Record<string, unknown> | null };
|
||||
signature?: { id: string; name: string; purpose?: string | null; drainageInfo?: Record<string, unknown> | null };
|
||||
tenant?: TenantOption;
|
||||
};
|
||||
|
||||
@@ -544,7 +565,8 @@ export type ReportTask = DictionaryItem & {
|
||||
reportType?: 'signature' | 'drainage';
|
||||
drainageItemId?: string | null;
|
||||
status: string;
|
||||
signature?: { id: string; name: string; drainageInfo?: Record<string, unknown> | null };
|
||||
signature?: { id: string; name: string; purpose?: string | null; drainageInfo?: Record<string, unknown> | null };
|
||||
drainageInfo?: SmsDrainageInfo | null;
|
||||
channel?: { id: string; name: string; code: string };
|
||||
};
|
||||
|
||||
@@ -555,6 +577,9 @@ export type ReportRecord = DictionaryItem & {
|
||||
statusBefore?: string | null;
|
||||
statusAfter?: string | null;
|
||||
reason?: string | null;
|
||||
sourceEntry?: 'system' | 'enterprise_signature' | 'report_task' | 'channel_report';
|
||||
channel?: AdminChannel;
|
||||
task?: ReportTask;
|
||||
};
|
||||
|
||||
export type FileObject = {
|
||||
@@ -883,7 +908,7 @@ export const adminApi = {
|
||||
listManualRecharges: (tenantId?: string) => request<RechargeOrder[]>(withQuery('/admin/billing/manual-recharges', { tenantId })),
|
||||
createManualRecharge: (body: { tenantId: string; amountCents: number; smsUnits?: number; operatorId?: string; remark?: string }) =>
|
||||
request<RechargeOrder>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listEnterpriseApplications: (query: { tenantId?: string; keyword?: string } = {}) =>
|
||||
listEnterpriseApplications: (query: { tenantId?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; status?: string } = {}) =>
|
||||
request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)),
|
||||
getEnterpriseApplication: (id: string) =>
|
||||
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`),
|
||||
@@ -939,7 +964,7 @@ export const adminApi = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason }),
|
||||
}),
|
||||
listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string } = {}) =>
|
||||
listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string } = {}) =>
|
||||
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signatures', query)),
|
||||
createEnterpriseSignature: (body: { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }) =>
|
||||
request<ClientSmsSignature>('/admin/enterprise-signatures', { method: 'POST', body: JSON.stringify(body) }),
|
||||
@@ -947,7 +972,19 @@ export const adminApi = {
|
||||
request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
changeEnterpriseSignatureStatus: (id: string, status: string, reason?: string) =>
|
||||
request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }),
|
||||
listEnterpriseTemplates: (query: { tenantId?: string; keyword?: string; status?: string } = {}) =>
|
||||
listDrainageInfos: (query: { tenantId?: string; signatureId?: string; keyword?: string; status?: string } = {}) =>
|
||||
request<SmsDrainageInfo[]>(withQuery('/admin/drainage-infos', 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> }) =>
|
||||
request<SmsDrainageInfo>(`/admin/drainage-infos/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
approveDrainageInfo: (id: string) =>
|
||||
request<SmsDrainageInfo>(`/admin/drainage-infos/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }),
|
||||
rejectDrainageInfo: (id: string, reason: string) =>
|
||||
request<SmsDrainageInfo>(`/admin/drainage-infos/${id}/reject`, { method: 'POST', body: JSON.stringify({ reason }) }),
|
||||
changeDrainageInfoStatus: (id: string, status: string, reason?: string) =>
|
||||
request<SmsDrainageInfo>(`/admin/drainage-infos/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }),
|
||||
listEnterpriseTemplates: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; nameKeyword?: string; contentKeyword?: string } = {}) =>
|
||||
request<ClientSmsTemplate[]>(withQuery('/admin/enterprise-templates', query)),
|
||||
createEnterpriseTemplate: (body: { tenantId: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }) =>
|
||||
request<ClientSmsTemplate>('/admin/enterprise-templates', { method: 'POST', body: JSON.stringify(body) }),
|
||||
@@ -989,10 +1026,10 @@ export const adminApi = {
|
||||
listChannelReportFields: (channelId?: string) => request<ChannelReportField[]>(withQuery('/admin/channel-report-fields', { channelId })),
|
||||
createChannelReportField: (body: Record<string, unknown>) =>
|
||||
request<ChannelReportField>('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string } = {}) => request<ReportTask[]>(withQuery('/admin/report-tasks', query)),
|
||||
listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {}) => request<ReportTask[]>(withQuery('/admin/report-tasks', query)),
|
||||
createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) =>
|
||||
request<ReportTask>('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }),
|
||||
changeReportTaskStatuses: (body: { items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>; reason?: string; operatorId?: string }) =>
|
||||
changeReportTaskStatuses: (body: { items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>; reason?: string; operatorId?: string; sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report' }) =>
|
||||
request<Array<{ signatureId: string; reportStatus: string; carrierReportSummary: Record<string, { status: string; approved: number; total: number }> }>>('/admin/report-tasks/status-change', { method: 'POST', body: JSON.stringify(body) }),
|
||||
createReportExport: (id: string, body: { fileObjectId?: string; fileName: string; rowCount?: number }) =>
|
||||
request<Record<string, unknown>>(`/admin/report-tasks/${id}/export`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
@@ -1042,7 +1079,7 @@ export const adminApi = {
|
||||
createGlobalBlacklist: (body: { phoneNumber: string; reason?: string; status?: string; operatorId?: string }) =>
|
||||
request<DictionaryItem>('/admin/dictionaries/blacklists/global', { method: 'POST', body: JSON.stringify(body) }),
|
||||
deleteGlobalBlacklist: (id: string) => request<DictionaryItem>(`/admin/dictionaries/blacklists/global/${id}`, { method: 'DELETE' }),
|
||||
listEnterpriseBlacklist: (query: { tenantId?: string; applicationId?: string; keyword?: string; status?: string } = {}) => request<DictionaryItem[]>(withQuery('/admin/dictionaries/blacklists/enterprise', query)),
|
||||
listEnterpriseBlacklist: (query: { tenantId?: string; applicationId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; phoneNumber?: string; reasonKeyword?: string } = {}) => request<DictionaryItem[]>(withQuery('/admin/dictionaries/blacklists/enterprise', query)),
|
||||
createEnterpriseBlacklist: (body: { tenantId: string; applicationId: string; phoneNumber: string; reason?: string; status?: string; operatorId?: string }) =>
|
||||
request<DictionaryItem>('/admin/dictionaries/blacklists/enterprise', { method: 'POST', body: JSON.stringify(body) }),
|
||||
deleteEnterpriseBlacklist: (id: string) => request<DictionaryItem>(`/admin/dictionaries/blacklists/enterprise/${id}`, { method: 'DELETE' }),
|
||||
@@ -1118,6 +1155,8 @@ export const clientApi = {
|
||||
request<ClientSmsApplication[]>('/client/applications', { tenantId }),
|
||||
getApplicationCmppParams: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ApplicationCmppParams>(`/client/applications/${applicationId}/cmpp-params`, { tenantId }),
|
||||
listApplicationReportFields: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ApplicationReportField[]>(`/client/applications/${applicationId}/report-fields`, { tenantId }),
|
||||
listSignatures: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsSignature[]>('/client/signatures', { tenantId }),
|
||||
createSignature: (body: { tenantId?: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
@@ -1128,6 +1167,12 @@ export const clientApi = {
|
||||
request<ClientSmsSignature>(`/client/signatures/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }),
|
||||
createSignatureMaterial: (id: string, body: { fileObjectId?: string; materialType: string; title: string; description?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<Record<string, unknown>>(`/client/signatures/${id}/materials`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
|
||||
createDrainageInfo: (signatureId: string, body: { siteName: string; url: string; remark?: string; reportValues?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<SmsDrainageInfo>(`/client/signatures/${signatureId}/drainage-infos`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
|
||||
updateDrainageInfo: (id: string, body: { siteName?: string; url?: string; remark?: string; reportValues?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<SmsDrainageInfo>(`/client/drainage-infos/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
|
||||
changeDrainageInfoStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<SmsDrainageInfo>(`/client/drainage-infos/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }),
|
||||
listTemplates: (query: { status?: string; keyword?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsTemplate[]>(withQuery('/client/templates', query), { tenantId }),
|
||||
createTemplate: (body: { tenantId?: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
|
||||
@@ -128,7 +128,7 @@ export function AdminChannelReportPage() {
|
||||
|
||||
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 })
|
||||
adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, reportType: statusTask.reportType, drainageItemId: statusTask.drainageItemId ?? undefined, status: nextStatus }], reason: statusReason, sourceEntry: 'channel_report' })
|
||||
.then(() => { setStatusTask(undefined); setStatusReason(''); loadData(); })
|
||||
.catch((failure: Error) => setError(failure.message || '报备状态保存失败'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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 { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'danger' }> = {
|
||||
pending: { label: '待审核', tone: 'info' },
|
||||
approved: { label: '已通过', tone: 'success' },
|
||||
rejected: { label: '已驳回', tone: 'danger' },
|
||||
deleted: { label: '已删除', tone: 'neutral' },
|
||||
};
|
||||
|
||||
function fileRef(value: unknown): FileRef | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const item = value as Record<string, unknown>;
|
||||
const fileObjectId = String(item.fileObjectId ?? '');
|
||||
const fileName = String(item.fileName ?? '');
|
||||
return fileObjectId && fileName ? { fileObjectId, fileName, contentType: item.contentType ? String(item.contentType) : undefined } : null;
|
||||
}
|
||||
|
||||
function DrainageDetail({ item, onClose }: { item: SmsDrainageInfo; onClose: () => void }) {
|
||||
const values = item.reportValues && typeof item.reportValues === 'object' ? item.reportValues : {};
|
||||
const meta = statusMeta[item.auditStatus] ?? { label: item.auditStatus, tone: 'neutral' as const };
|
||||
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>{item.tenant?.name ?? item.tenantId}</strong></div>
|
||||
<div><span>应用</span><strong>{item.application?.name ?? '-'}</strong></div>
|
||||
<div><span>所属签名</span><strong>{item.signature?.name ?? item.signatureId}</strong></div>
|
||||
<div><span>审核状态</span><Tag tone={meta.tone}>{meta.label}</Tag></div>
|
||||
<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>
|
||||
<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>
|
||||
<div className="surface" style={{ padding: 16 }}>
|
||||
<strong>通道动态报备资料</strong>
|
||||
<div className="detail-grid" style={{ marginTop: 12 }}>
|
||||
{Object.keys(values).length ? Object.entries(values).map(([key, value]) => {
|
||||
const file = fileRef(value);
|
||||
return <div key={key}><span>{key}</span><strong>{file ? <FileActions file={file} /> : String(value ?? '-')}</strong></div>;
|
||||
}) : <span className="muted">无</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
export function AdminDrainageAuditPage() {
|
||||
const [items, setItems] = useState<SmsDrainageInfo[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('pending');
|
||||
const [detail, setDetail] = useState<SmsDrainageInfo>();
|
||||
const [rejectTarget, setRejectTarget] = useState<SmsDrainageInfo>();
|
||||
const [reason, setReason] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function loadData() {
|
||||
adminApi.listDrainageInfos({ keyword, status: status === 'all' ? undefined : status })
|
||||
.then((records) => { setItems(records); setError(''); })
|
||||
.catch((failure: Error) => setError(failure.message || '引流信息审核列表加载失败'));
|
||||
}
|
||||
|
||||
useEffect(loadData, [keyword, status]);
|
||||
|
||||
async function approve(item: SmsDrainageInfo) {
|
||||
try { await adminApi.approveDrainageInfo(item.id); loadData(); } catch (failure) { setError(failure instanceof Error ? failure.message : '引流信息审核通过失败'); }
|
||||
}
|
||||
|
||||
async function reject() {
|
||||
if (!rejectTarget || !reason.trim()) return;
|
||||
try { await adminApi.rejectDrainageInfo(rejectTarget.id, reason.trim()); setRejectTarget(undefined); setReason(''); loadData(); } catch (failure) { setError(failure instanceof Error ? failure.message : '引流信息审核驳回失败'); }
|
||||
}
|
||||
|
||||
const columns = useMemo<Array<TableColumn<SmsDrainageInfo>>>(() => [
|
||||
{ key: 'siteName', title: '站名称/引流地址', render: (record) => <div className="admin-task-enterprise"><strong>{record.siteName}</strong><span>{record.url}</span></div> },
|
||||
{ key: 'signature', title: '所属签名', render: (record) => record.signature?.name ?? record.signatureId },
|
||||
{ key: 'tenant', title: '企业/应用', render: (record) => <div className="admin-task-enterprise"><strong>{record.tenant?.name ?? record.tenantId}</strong><span>{record.application?.name ?? '-'}</span></div> },
|
||||
{ key: 'submittedAt', title: '提交时间', render: (record) => formatDateTime(record.submittedAt) },
|
||||
{ key: 'status', title: '状态', render: (record) => { const meta = statusMeta[record.auditStatus] ?? { label: record.auditStatus, tone: 'neutral' as const }; return <Tag tone={meta.tone}>{meta.label}</Tag>; } },
|
||||
{ key: 'actions', title: '操作', align: 'right', render: (record) => <div className="table-actions"><Button icon={<Eye size={15} />} onClick={() => setDetail(record)} size="sm" variant="ghost">详情</Button><Button disabled={record.auditStatus !== 'pending'} icon={<Check size={15} />} onClick={() => void approve(record)} size="sm" variant="success">通过</Button><Button disabled={record.auditStatus !== 'pending'} icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger">驳回</Button></div> },
|
||||
], []);
|
||||
|
||||
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>
|
||||
{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>;
|
||||
}
|
||||
@@ -244,6 +244,10 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
const [smsApps, setSmsApps] = useState<SmsApp[]>([]);
|
||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||
const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState('');
|
||||
const [applicationKeyword, setApplicationKeyword] = useState('');
|
||||
const [appliedApplicationKeyword, setAppliedApplicationKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [appliedStatus, setAppliedStatus] = useState('all');
|
||||
const [connectionApp, setConnectionApp] = useState<SmsApp | null>(null);
|
||||
const [paramsApp, setParamsApp] = useState<SmsApp | null>(null);
|
||||
const [paramsDetail, setParamsDetail] = useState<ApplicationCmppParams | null>(null);
|
||||
@@ -258,9 +262,9 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
| null
|
||||
>(null);
|
||||
|
||||
async function loadSmsApps(keyword = appliedEnterpriseKeyword) {
|
||||
async function loadSmsApps(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, status: appliedStatus }) {
|
||||
try {
|
||||
const applications = await adminApi.listEnterpriseApplications({ keyword });
|
||||
const applications = await adminApi.listEnterpriseApplications(filters);
|
||||
setSmsApps(applications.map(mapApplication));
|
||||
setError('');
|
||||
} catch (err) {
|
||||
@@ -271,7 +275,7 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
void loadSmsApps();
|
||||
}, [appliedEnterpriseKeyword]);
|
||||
}, [appliedApplicationKeyword, appliedEnterpriseKeyword, appliedStatus]);
|
||||
|
||||
async function openAddModal() {
|
||||
setAddModalOpen(true);
|
||||
@@ -303,7 +307,7 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
|
||||
async function confirmDelete(id: string) {
|
||||
await adminApi.changeApplicationStatus(id, 'deleted', '运营端删除应用');
|
||||
await loadSmsApps();
|
||||
await loadSmsApps();
|
||||
}
|
||||
|
||||
async function runConfirmedAction() {
|
||||
@@ -324,8 +328,10 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
}
|
||||
|
||||
const filteredSmsApps = useMemo(
|
||||
() => smsApps.filter((item) => !appliedEnterpriseKeyword || item.enterprise.includes(appliedEnterpriseKeyword)),
|
||||
[appliedEnterpriseKeyword, smsApps],
|
||||
() => smsApps.filter((item) => (!appliedEnterpriseKeyword || item.enterprise.includes(appliedEnterpriseKeyword))
|
||||
&& (!appliedApplicationKeyword || item.name.includes(appliedApplicationKeyword))
|
||||
&& (appliedStatus === 'all' || (appliedStatus === 'active' ? item.enabled : !item.enabled))),
|
||||
[appliedApplicationKeyword, appliedEnterpriseKeyword, appliedStatus, smsApps],
|
||||
);
|
||||
|
||||
const smsColumns = useMemo<Array<TableColumn<SmsApp>>>(() => [
|
||||
@@ -389,9 +395,22 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
prefix={<Search size={16} />}
|
||||
value={enterpriseKeyword}
|
||||
/>
|
||||
<Input
|
||||
label="企业应用名称"
|
||||
onChange={(event) => setApplicationKeyword(event.target.value)}
|
||||
placeholder="请输入企业应用名称"
|
||||
prefix={<Search size={16} />}
|
||||
value={applicationKeyword}
|
||||
/>
|
||||
<Select
|
||||
label="状态"
|
||||
onChange={(event) => setStatus(event.target.value)}
|
||||
options={[{ label: '全部状态', value: 'all' }, { label: '启用', value: 'active' }, { label: '停用', value: 'disabled' }]}
|
||||
value={status}
|
||||
/>
|
||||
<div className="admin-split-filter__actions">
|
||||
<Button icon={<Search size={16} />} onClick={() => setAppliedEnterpriseKeyword(enterpriseKeyword.trim())}>查询</Button>
|
||||
<Button onClick={() => { setEnterpriseKeyword(''); setAppliedEnterpriseKeyword(''); }} variant="ghost">重置</Button>
|
||||
<Button icon={<Search size={16} />} onClick={() => { setAppliedEnterpriseKeyword(enterpriseKeyword.trim()); setAppliedApplicationKeyword(applicationKeyword.trim()); setAppliedStatus(status); }}>查询</Button>
|
||||
<Button onClick={() => { setEnterpriseKeyword(''); setApplicationKeyword(''); setStatus('all'); setAppliedEnterpriseKeyword(''); setAppliedApplicationKeyword(''); setAppliedStatus('all'); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, type TableColumn } from '@/components/ui';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type DictionaryItem, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
|
||||
|
||||
type EnterpriseBlacklistItem = DictionaryItem & {
|
||||
@@ -16,9 +16,12 @@ export function AdminEnterpriseBlacklistPage() {
|
||||
const [items, setItems] = useState<EnterpriseBlacklistItem[]>([]);
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [filterTenantId, setFilterTenantId] = useState('');
|
||||
const [filterApplicationId, setFilterApplicationId] = useState('');
|
||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||
const [applicationKeyword, setApplicationKeyword] = useState('');
|
||||
const [phoneKeyword, setPhoneKeyword] = useState('');
|
||||
const [reasonKeyword, setReasonKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [appliedFilters, setAppliedFilters] = useState({ enterpriseKeyword: '', applicationKeyword: '', phoneNumber: '', reasonKeyword: '', status: 'all' });
|
||||
const [formTenantId, setFormTenantId] = useState('');
|
||||
const [formApplicationId, setFormApplicationId] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
@@ -26,9 +29,9 @@ export function AdminEnterpriseBlacklistPage() {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function loadData() {
|
||||
function loadData(filters = appliedFilters) {
|
||||
Promise.all([
|
||||
adminApi.listEnterpriseBlacklist({ tenantId: filterTenantId || undefined, applicationId: filterApplicationId || undefined, keyword }),
|
||||
adminApi.listEnterpriseBlacklist(filters),
|
||||
adminApi.listTenants(),
|
||||
adminApi.listEnterpriseApplications(),
|
||||
])
|
||||
@@ -45,11 +48,6 @@ export function AdminEnterpriseBlacklistPage() {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const filteredItems = useMemo(() => items.filter((item) => {
|
||||
const text = [item.tenant?.name, item.application?.name, item.phoneNumber, item.reason, item.status].join(' ');
|
||||
return !keyword || text.includes(keyword);
|
||||
}), [items, keyword]);
|
||||
const filterApplications = applications.filter((application) => application.tenantId === filterTenantId && application.status !== 'deleted');
|
||||
const modalApplications = applications.filter((application) => application.tenantId === formTenantId && application.status !== 'deleted');
|
||||
|
||||
const columns = useMemo<Array<TableColumn<EnterpriseBlacklistItem>>>(() => [
|
||||
@@ -58,19 +56,19 @@ export function AdminEnterpriseBlacklistPage() {
|
||||
{ key: 'phone', title: '手机号码', width: '150px', render: (record) => <strong>{record.phoneNumber}</strong> },
|
||||
{ key: 'createdAt', title: '入库时间', width: '170px', render: (record) => record.createdAt ?? '-' },
|
||||
{ key: 'reason', title: '入库原因', render: (record) => record.reason ?? '-' },
|
||||
{ key: 'status', title: '状态', width: '130px', render: (record) => record.status ?? '-' },
|
||||
{ key: 'status', title: '状态', width: '130px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '生效中' : record.status === 'deleted' ? '已删除' : record.status ?? '-'}</Tag> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '130px',
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => adminApi.deleteEnterpriseBlacklist(record.id).then(loadData).catch((failure: Error) => setError(failure.message))} size="sm" variant="danger">
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => adminApi.deleteEnterpriseBlacklist(record.id).then(() => loadData()).catch((failure: Error) => setError(failure.message))} size="sm" variant="danger">
|
||||
删除
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
], []);
|
||||
], [appliedFilters]);
|
||||
|
||||
function addItem() {
|
||||
adminApi.createEnterpriseBlacklist({ tenantId: formTenantId, applicationId: formApplicationId, phoneNumber: phone, reason, status: 'active' })
|
||||
@@ -98,32 +96,35 @@ export function AdminEnterpriseBlacklistPage() {
|
||||
|
||||
<div className="surface admin-security-filter">
|
||||
<Input
|
||||
label="搜索"
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索企业、应用、手机号或原因"
|
||||
label="企业名称"
|
||||
onChange={(event) => setEnterpriseKeyword(event.target.value)}
|
||||
placeholder="请输入企业名称"
|
||||
prefix={<Search size={16} />}
|
||||
value={keyword}
|
||||
value={enterpriseKeyword}
|
||||
/>
|
||||
<Select
|
||||
label="企业"
|
||||
onChange={(event) => { setFilterTenantId(event.target.value); setFilterApplicationId(''); }}
|
||||
options={[{ label: '全部企业', value: '' }, ...tenants.map((item) => ({ label: item.name, value: item.id }))]}
|
||||
value={filterTenantId}
|
||||
<Input
|
||||
label="企业应用"
|
||||
onChange={(event) => setApplicationKeyword(event.target.value)}
|
||||
placeholder="请输入企业应用名称"
|
||||
prefix={<Search size={16} />}
|
||||
value={applicationKeyword}
|
||||
/>
|
||||
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} placeholder="请输入手机号码" value={phoneKeyword} />
|
||||
<Input label="入库原因" onChange={(event) => setReasonKeyword(event.target.value)} placeholder="请输入入库原因" value={reasonKeyword} />
|
||||
<Select
|
||||
label="短信应用"
|
||||
onChange={(event) => setFilterApplicationId(event.target.value)}
|
||||
options={[{ label: filterTenantId ? '全部应用' : '请先选择企业', value: '' }, ...filterApplications.map((item) => ({ label: item.name, value: item.id }))]}
|
||||
value={filterApplicationId}
|
||||
label="状态"
|
||||
onChange={(event) => setStatus(event.target.value)}
|
||||
options={[{ label: '全部状态', value: 'all' }, { label: '生效中', value: 'active' }, { label: '已删除', value: 'deleted' }]}
|
||||
value={status}
|
||||
/>
|
||||
<div className="admin-security-filter__actions">
|
||||
<Button icon={<Search size={16} />} onClick={loadData}>查询</Button>
|
||||
<Button onClick={() => { setKeyword(''); setFilterTenantId(''); setFilterApplicationId(''); }} variant="ghost">重置</Button>
|
||||
<Button icon={<Search size={16} />} onClick={() => { const filters = { enterpriseKeyword: enterpriseKeyword.trim(), applicationKeyword: applicationKeyword.trim(), phoneNumber: phoneKeyword.trim(), reasonKeyword: reasonKeyword.trim(), status }; setAppliedFilters(filters); loadData(filters); }}>查询</Button>
|
||||
<Button onClick={() => { const filters = { enterpriseKeyword: '', applicationKeyword: '', phoneNumber: '', reasonKeyword: '', status: 'all' }; setEnterpriseKeyword(''); setApplicationKeyword(''); setPhoneKeyword(''); setReasonKeyword(''); setStatus('all'); setAppliedFilters(filters); loadData(filters); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-security-table-card">
|
||||
<Table columns={columns} data={filteredItems} emptyText="暂无企业黑名单记录" rowKey="id" />
|
||||
<Table columns={columns} data={items} emptyText="暂无企业黑名单记录" rowKey="id" />
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -27,6 +27,8 @@ type DrainageInfo = {
|
||||
submittedAt: string;
|
||||
remark: string;
|
||||
reportValues: ReportValues;
|
||||
auditStatus?: string;
|
||||
rejectReason?: string | null;
|
||||
};
|
||||
|
||||
type UploadedFileRef = FileRef;
|
||||
@@ -134,6 +136,8 @@ function readDrainagePayload(signature: ClientSmsSignature) {
|
||||
submittedAt: String(item.submittedAt ?? ''),
|
||||
remark: String(item.remark ?? ''),
|
||||
reportValues: normalizeReportValues(item.reportValues),
|
||||
auditStatus: String(item.auditStatus ?? 'pending'),
|
||||
rejectReason: item.rejectReason ? String(item.rejectReason) : null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -582,7 +586,7 @@ function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsS
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: item.id, channelId: target.channelId, status: statuses[target.channelId] ?? target.status })), reason });
|
||||
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: item.id, channelId: target.channelId, status: statuses[target.channelId] ?? target.status })), reason, sourceEntry: 'enterprise_signature' });
|
||||
onSaved();
|
||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '报备状态保存失败'); } finally { setSaving(false); }
|
||||
}
|
||||
@@ -623,7 +627,7 @@ function DrainageReportStatusModal({ item, onClose, onSaved, signature }: { item
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: signature.id, channelId: target.channelId, reportType: 'drainage', drainageItemId: item.id, status: statuses[target.channelId] ?? target.status })), reason });
|
||||
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: signature.id, channelId: target.channelId, reportType: 'drainage', drainageItemId: item.id, status: statuses[target.channelId] ?? target.status })), reason, sourceEntry: 'enterprise_signature' });
|
||||
onSaved();
|
||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '引流报备状态保存失败'); } finally { setSaving(false); }
|
||||
}
|
||||
@@ -663,6 +667,10 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
const [drainageStatusTarget, setDrainageStatusTarget] = useState<{ signature: ClientSmsSignature; item: DrainageInfo } | null>(null);
|
||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||
const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState('');
|
||||
const [applicationKeyword, setApplicationKeyword] = useState('');
|
||||
const [appliedApplicationKeyword, setAppliedApplicationKeyword] = useState('');
|
||||
const [drainageKeyword, setDrainageKeyword] = useState('');
|
||||
const [appliedDrainageKeyword, setAppliedDrainageKeyword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [expandedSignatureId, setExpandedSignatureId] = useState('');
|
||||
const [signatureKeyword, setSignatureKeyword] = useState('');
|
||||
@@ -674,10 +682,10 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, signatureKeyword: appliedSignatureKeyword }) {
|
||||
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, signatureKeyword: appliedSignatureKeyword, drainageKeyword: appliedDrainageKeyword }) {
|
||||
try {
|
||||
const [signatureItems, tenantItems, applicationItems] = await Promise.all([
|
||||
adminApi.listEnterpriseSignatures({ keyword: [filters.enterpriseKeyword, filters.signatureKeyword].filter(Boolean).join(' ') }),
|
||||
adminApi.listEnterpriseSignatures(filters),
|
||||
adminApi.listTenants(),
|
||||
adminApi.listEnterpriseApplications(),
|
||||
]);
|
||||
@@ -697,9 +705,12 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
const filteredSignatures = useMemo(() => signatures.filter((item) => {
|
||||
const enterprise = item.tenant?.name ?? item.tenantId;
|
||||
const application = item.application?.name ?? '';
|
||||
const drainageItems = readDrainagePayload(item).links;
|
||||
return (!appliedEnterpriseKeyword || enterprise.includes(appliedEnterpriseKeyword))
|
||||
&& (!appliedSignatureKeyword || item.name.includes(appliedSignatureKeyword) || application.includes(appliedSignatureKeyword) || (item.purpose ?? '').includes(appliedSignatureKeyword));
|
||||
}), [appliedEnterpriseKeyword, appliedSignatureKeyword, signatures]);
|
||||
&& (!appliedApplicationKeyword || application.includes(appliedApplicationKeyword))
|
||||
&& (!appliedSignatureKeyword || item.name.includes(appliedSignatureKeyword) || (item.purpose ?? '').includes(appliedSignatureKeyword))
|
||||
&& (!appliedDrainageKeyword || drainageItems.some((drainage) => `${drainage.siteName} ${drainage.url} ${drainage.remark}`.includes(appliedDrainageKeyword)));
|
||||
}), [appliedApplicationKeyword, appliedDrainageKeyword, appliedEnterpriseKeyword, appliedSignatureKeyword, signatures]);
|
||||
const pageSize = 10;
|
||||
const totalPages = Math.max(1, Math.ceil(filteredSignatures.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
@@ -707,7 +718,7 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [appliedEnterpriseKeyword, appliedSignatureKeyword, filteredSignatures.length]);
|
||||
}, [appliedApplicationKeyword, appliedDrainageKeyword, appliedEnterpriseKeyword, appliedSignatureKeyword, filteredSignatures.length]);
|
||||
|
||||
async function saveSignature(state: SignatureFormState) {
|
||||
const existing = signatureModal && signatureModal !== 'new' ? signatureModal : null;
|
||||
@@ -746,13 +757,10 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
if (!signature) {
|
||||
return;
|
||||
}
|
||||
const payload = readDrainagePayload(signature);
|
||||
const links = payload.links.some((current) => current.id === item.id)
|
||||
? payload.links.map((current) => current.id === item.id ? item : current)
|
||||
: [item, ...payload.links];
|
||||
await adminApi.updateEnterpriseSignature(signatureId, {
|
||||
drainageInfo: buildDrainagePayload(payload.carrierStatus, links, payload.signatureProfile, payload.signatureReportValues),
|
||||
});
|
||||
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 };
|
||||
if (exists) await adminApi.updateDrainageInfo(item.id, body);
|
||||
else await adminApi.createDrainageInfo(signatureId, body);
|
||||
setDrainageModal(null);
|
||||
setExpandedSignatureId(signatureId);
|
||||
await loadData();
|
||||
@@ -765,13 +773,7 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
if (deleteTarget.kind === 'signature') {
|
||||
await adminApi.changeEnterpriseSignatureStatus(deleteTarget.id, 'deleted', '运营端删除签名');
|
||||
} else {
|
||||
const signature = signatures.find((item) => item.id === deleteTarget.signatureId);
|
||||
if (signature) {
|
||||
const payload = readDrainagePayload(signature);
|
||||
await adminApi.updateEnterpriseSignature(signature.id, {
|
||||
drainageInfo: buildDrainagePayload(payload.carrierStatus, payload.links.filter((item) => item.id !== deleteTarget.id), payload.signatureProfile, payload.signatureReportValues),
|
||||
});
|
||||
}
|
||||
await adminApi.changeDrainageInfoStatus(deleteTarget.id, 'deleted', '运营端删除引流信息');
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
await loadData();
|
||||
@@ -781,9 +783,12 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
<div className="signature-list admin-enterprise-signature-list">
|
||||
{visibleSignatures.map((signature) => {
|
||||
const payload = readDrainagePayload(signature);
|
||||
const visibleDrainageLinks = appliedDrainageKeyword
|
||||
? payload.links.filter((item) => `${item.siteName} ${item.url} ${item.remark}`.includes(appliedDrainageKeyword))
|
||||
: payload.links;
|
||||
const summaryStatuses = Object.values(signature.carrierReportSummary ?? {}).map((summary) => summary.status);
|
||||
const cardTone = summaryStatuses.includes('failed') ? 'red' : summaryStatuses.length > 0 && summaryStatuses.every((status) => status === 'approved' || status === 'not_applicable') ? 'green' : summaryStatuses.some((status) => status === 'reporting') ? 'blue' : 'gray';
|
||||
const expanded = expandedSignatureId === signature.id;
|
||||
const expanded = expandedSignatureId === signature.id || Boolean(appliedDrainageKeyword);
|
||||
return (
|
||||
<article className={`signature-card signature-card--${cardTone}`} key={signature.id}>
|
||||
<div className="signature-summary">
|
||||
@@ -808,30 +813,32 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
{expanded ? (
|
||||
<div className="drainage-panel">
|
||||
<h2>引流信息列表</h2>
|
||||
{payload.links.length ? (
|
||||
{visibleDrainageLinks.length ? (
|
||||
<div className="drainage-table">
|
||||
<div className="drainage-table__head">
|
||||
<span>站名称</span>
|
||||
<span>引流信息</span>
|
||||
<span>审核状态</span>
|
||||
<span>移动</span>
|
||||
<span>联通</span>
|
||||
<span>电信</span>
|
||||
<span>提交时间</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{payload.links.map((item) => {
|
||||
{visibleDrainageLinks.map((item) => {
|
||||
const summary = signature.drainageCarrierReportSummary?.[item.id];
|
||||
return (
|
||||
<div className="drainage-table__row" key={item.id}>
|
||||
<strong>{item.siteName}</strong>
|
||||
<span className="drainage-table__url" title={item.url}>{item.url}</span>
|
||||
<AuditStatusTag status={item.auditStatus ?? 'pending'} />
|
||||
<CarrierReportTag summary={summary?.mobile} />
|
||||
<CarrierReportTag summary={summary?.unicom} />
|
||||
<CarrierReportTag summary={summary?.telecom} />
|
||||
<span className="muted">{item.submittedAt}</span>
|
||||
<span className="drainage-row-actions">
|
||||
<Button onClick={() => setDrainageReport({ signature, item })} size="sm" variant="ghost">报备详情</Button>
|
||||
<Button onClick={() => setDrainageStatusTarget({ 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>
|
||||
</span>
|
||||
@@ -875,20 +882,28 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
|
||||
<div className="surface admin-split-filter">
|
||||
<Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} />
|
||||
<Input label="签名/应用" onChange={(event) => setSignatureKeyword(event.target.value)} placeholder="请输入签名或应用名称" prefix={<Search size={16} />} value={signatureKeyword} />
|
||||
<Input label="企业应用" onChange={(event) => setApplicationKeyword(event.target.value)} placeholder="请输入企业应用名称" prefix={<Search size={16} />} value={applicationKeyword} />
|
||||
<Input label="签名名称" onChange={(event) => setSignatureKeyword(event.target.value)} placeholder="请输入签名名称或用途" prefix={<Search size={16} />} value={signatureKeyword} />
|
||||
<Input label="引流信息" onChange={(event) => setDrainageKeyword(event.target.value)} placeholder="请输入站名称、URL 或备注" prefix={<Search size={16} />} value={drainageKeyword} />
|
||||
<div className="admin-split-filter__actions">
|
||||
<Button icon={<Search size={16} />} onClick={() => {
|
||||
const filters = { enterpriseKeyword: enterpriseKeyword.trim(), signatureKeyword: signatureKeyword.trim() };
|
||||
const filters = { enterpriseKeyword: enterpriseKeyword.trim(), applicationKeyword: applicationKeyword.trim(), signatureKeyword: signatureKeyword.trim(), drainageKeyword: drainageKeyword.trim() };
|
||||
setAppliedEnterpriseKeyword(filters.enterpriseKeyword);
|
||||
setAppliedApplicationKeyword(filters.applicationKeyword);
|
||||
setAppliedSignatureKeyword(filters.signatureKeyword);
|
||||
setAppliedDrainageKeyword(filters.drainageKeyword);
|
||||
void loadData(filters);
|
||||
}}>查询</Button>
|
||||
<Button onClick={() => {
|
||||
const filters = { enterpriseKeyword: '', signatureKeyword: '' };
|
||||
const filters = { enterpriseKeyword: '', applicationKeyword: '', signatureKeyword: '', drainageKeyword: '' };
|
||||
setEnterpriseKeyword('');
|
||||
setApplicationKeyword('');
|
||||
setSignatureKeyword('');
|
||||
setDrainageKeyword('');
|
||||
setAppliedEnterpriseKeyword('');
|
||||
setAppliedApplicationKeyword('');
|
||||
setAppliedSignatureKeyword('');
|
||||
setAppliedDrainageKeyword('');
|
||||
void loadData(filters);
|
||||
}} variant="ghost">重置</Button>
|
||||
</div>
|
||||
|
||||
@@ -251,20 +251,24 @@ export function AdminEnterpriseTemplatesPage() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<ClientSmsTemplate | null>(null);
|
||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||
const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState('');
|
||||
const [applicationKeyword, setApplicationKeyword] = useState('');
|
||||
const [appliedApplicationKeyword, setAppliedApplicationKeyword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [signatureItems, setSignatureItems] = useState<ClientSmsSignature[]>([]);
|
||||
const [templateModal, setTemplateModal] = useState<ClientSmsTemplate | 'new' | null>(null);
|
||||
const [templatePreview, setTemplatePreview] = useState<ClientSmsTemplate | null>(null);
|
||||
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
|
||||
const [templateKeyword, setTemplateKeyword] = useState('');
|
||||
const [appliedTemplateKeyword, setAppliedTemplateKeyword] = useState('');
|
||||
const [templateNameKeyword, setTemplateNameKeyword] = useState('');
|
||||
const [appliedTemplateNameKeyword, setAppliedTemplateNameKeyword] = useState('');
|
||||
const [templateContentKeyword, setTemplateContentKeyword] = useState('');
|
||||
const [appliedTemplateContentKeyword, setAppliedTemplateContentKeyword] = useState('');
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, templateKeyword: appliedTemplateKeyword }) {
|
||||
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, nameKeyword: appliedTemplateNameKeyword, contentKeyword: appliedTemplateContentKeyword }) {
|
||||
try {
|
||||
const [templateItems, tenantItems, applicationItems, signatureList] = await Promise.all([
|
||||
adminApi.listEnterpriseTemplates({ keyword: [filters.enterpriseKeyword, filters.templateKeyword].filter(Boolean).join(' ') }),
|
||||
adminApi.listEnterpriseTemplates(filters),
|
||||
adminApi.listTenants(),
|
||||
adminApi.listEnterpriseApplications(),
|
||||
adminApi.listEnterpriseSignatures(),
|
||||
@@ -287,8 +291,10 @@ export function AdminEnterpriseTemplatesPage() {
|
||||
const enterprise = item.tenant?.name ?? item.tenantId;
|
||||
const application = item.application?.name ?? '';
|
||||
return (!appliedEnterpriseKeyword || enterprise.includes(appliedEnterpriseKeyword))
|
||||
&& (!appliedTemplateKeyword || item.name.includes(appliedTemplateKeyword) || item.content.includes(appliedTemplateKeyword) || application.includes(appliedTemplateKeyword));
|
||||
}), [appliedEnterpriseKeyword, appliedTemplateKeyword, templates]);
|
||||
&& (!appliedApplicationKeyword || application.includes(appliedApplicationKeyword))
|
||||
&& (!appliedTemplateNameKeyword || item.name.includes(appliedTemplateNameKeyword))
|
||||
&& (!appliedTemplateContentKeyword || item.content.includes(appliedTemplateContentKeyword));
|
||||
}), [appliedApplicationKeyword, appliedEnterpriseKeyword, appliedTemplateContentKeyword, appliedTemplateNameKeyword, templates]);
|
||||
const pageSize = 10;
|
||||
const totalPages = Math.max(1, Math.ceil(filteredTemplates.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
@@ -296,7 +302,7 @@ export function AdminEnterpriseTemplatesPage() {
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [appliedEnterpriseKeyword, appliedTemplateKeyword]);
|
||||
}, [appliedApplicationKeyword, appliedEnterpriseKeyword, appliedTemplateContentKeyword, appliedTemplateNameKeyword]);
|
||||
|
||||
async function saveTemplate(state: TemplateFormState) {
|
||||
const existing = templateModal && templateModal !== 'new' ? templateModal : null;
|
||||
@@ -349,20 +355,28 @@ export function AdminEnterpriseTemplatesPage() {
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="surface admin-split-filter">
|
||||
<Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} />
|
||||
<Input label="模板/应用/内容" onChange={(event) => setTemplateKeyword(event.target.value)} placeholder="请输入模板、应用或内容" prefix={<Search size={16} />} value={templateKeyword} />
|
||||
<Input label="企业应用" onChange={(event) => setApplicationKeyword(event.target.value)} placeholder="请输入企业应用名称" prefix={<Search size={16} />} value={applicationKeyword} />
|
||||
<Input label="模板名称" onChange={(event) => setTemplateNameKeyword(event.target.value)} placeholder="请输入模板名称" prefix={<Search size={16} />} value={templateNameKeyword} />
|
||||
<Input label="模板内容" onChange={(event) => setTemplateContentKeyword(event.target.value)} placeholder="请输入模板内容" prefix={<Search size={16} />} value={templateContentKeyword} />
|
||||
<div className="admin-split-filter__actions">
|
||||
<Button icon={<Search size={16} />} onClick={() => {
|
||||
const filters = { enterpriseKeyword: enterpriseKeyword.trim(), templateKeyword: templateKeyword.trim() };
|
||||
const filters = { enterpriseKeyword: enterpriseKeyword.trim(), applicationKeyword: applicationKeyword.trim(), nameKeyword: templateNameKeyword.trim(), contentKeyword: templateContentKeyword.trim() };
|
||||
setAppliedEnterpriseKeyword(filters.enterpriseKeyword);
|
||||
setAppliedTemplateKeyword(filters.templateKeyword);
|
||||
setAppliedApplicationKeyword(filters.applicationKeyword);
|
||||
setAppliedTemplateNameKeyword(filters.nameKeyword);
|
||||
setAppliedTemplateContentKeyword(filters.contentKeyword);
|
||||
void loadData(filters);
|
||||
}}>查询</Button>
|
||||
<Button onClick={() => {
|
||||
const filters = { enterpriseKeyword: '', templateKeyword: '' };
|
||||
const filters = { enterpriseKeyword: '', applicationKeyword: '', nameKeyword: '', contentKeyword: '' };
|
||||
setEnterpriseKeyword('');
|
||||
setTemplateKeyword('');
|
||||
setApplicationKeyword('');
|
||||
setTemplateNameKeyword('');
|
||||
setTemplateContentKeyword('');
|
||||
setAppliedEnterpriseKeyword('');
|
||||
setAppliedTemplateKeyword('');
|
||||
setAppliedApplicationKeyword('');
|
||||
setAppliedTemplateNameKeyword('');
|
||||
setAppliedTemplateContentKeyword('');
|
||||
void loadData(filters);
|
||||
}} variant="ghost">重置</Button>
|
||||
</div>
|
||||
|
||||
@@ -85,7 +85,7 @@ export function AdminHome() {
|
||||
const todaySpend = (dashboard?.today.spendCents ?? 0) / 100;
|
||||
const activeConnectionCount = dashboard?.gatewayConnections.reduce((sum, item) => sum + (item._sum.currentConnections ?? 0), 0) ?? 0;
|
||||
const downstreamAlertCount = dashboard?.downstreamDeliverySummary?.alertCount ?? 0;
|
||||
const pendingAudits = dashboard?.pendingAudits ?? { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, total: 0 };
|
||||
const pendingAudits = dashboard?.pendingAudits ?? { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0, total: 0 };
|
||||
|
||||
const sendTrendOption = useMemo(
|
||||
() => createLineOption({
|
||||
@@ -100,9 +100,9 @@ export function AdminHome() {
|
||||
|
||||
const auditTrendOption = useMemo(
|
||||
() => createBarOption({
|
||||
labels: ['企业认证', '短信审核', '模板', '签名'],
|
||||
labels: ['企业认证', '短信审核', '模板', '签名', '引流信息'],
|
||||
series: [
|
||||
{ name: '待审', data: [pendingAudits.enterpriseCertifications, pendingAudits.smsAudits, pendingAudits.templates, pendingAudits.signatures] },
|
||||
{ name: '待审', data: [pendingAudits.enterpriseCertifications, pendingAudits.smsAudits, pendingAudits.templates, pendingAudits.signatures, pendingAudits.drainageInfos] },
|
||||
],
|
||||
}),
|
||||
[pendingAudits],
|
||||
@@ -246,11 +246,16 @@ export function AdminHome() {
|
||||
<span>模板待审</span>
|
||||
<strong>{pendingAudits.templates} 条</strong>
|
||||
</Button>
|
||||
<Button className="mini-status-card" onClick={() => navigate('/admin/enterprise-signatures')} variant="ghost">
|
||||
<Button className="mini-status-card" onClick={() => navigate('/admin/signatures')} variant="ghost">
|
||||
<FileCheck2 size={22} />
|
||||
<span>签名待审</span>
|
||||
<strong>{pendingAudits.signatures} 条</strong>
|
||||
</Button>
|
||||
<Button className="mini-status-card" onClick={() => navigate('/admin/drainage-audits')} variant="ghost">
|
||||
<FileCheck2 size={22} />
|
||||
<span>引流信息待审</span>
|
||||
<strong>{pendingAudits.drainageInfos} 条</strong>
|
||||
</Button>
|
||||
<div className="mini-status-card">
|
||||
<ShieldCheck size={22} />
|
||||
<div>
|
||||
|
||||
@@ -1,32 +1,63 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Clock3, Eye, Search } from 'lucide-react';
|
||||
import { adminApi, type ReportRecord } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Table, Tag, type DateRangeValue, type TableColumn } from '@/components/ui';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Table, Tag, type DateRangeValue, type TableColumn } from '@/components/ui';
|
||||
|
||||
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
|
||||
pending: 'neutral',
|
||||
waiting_material: 'warning',
|
||||
waiting_review: 'warning',
|
||||
reporting: 'warning',
|
||||
exporting: 'info',
|
||||
partial: 'warning',
|
||||
success: 'success',
|
||||
completed: 'success',
|
||||
failed: 'danger',
|
||||
rejected: 'danger',
|
||||
};
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
pending: '待报备', waiting_material: '待补充资料', waiting_review: '待重新审核', reporting: '报备中', exporting: '导出中', partial: '部分通过', approved: '已通过', success: '成功', completed: '已完成', failed: '失败', rejected: '已驳回', abandoned: '已废弃', imported: '已导入', deleted: '已删除',
|
||||
};
|
||||
|
||||
const actionLabel: Record<string, string> = {
|
||||
create: '创建报备任务', manual_status_change: '人工修改状态', export: '导出报备资料', receipt_import: '导入回执', audit_approved_create: '引流审核通过后创建', audit_approved_reset: '引流审核通过后重置', audit_resubmit_freeze: '引流修改后冻结', audit_rejected_freeze: '引流审核驳回后冻结', drainage_deleted: '引流信息删除',
|
||||
};
|
||||
|
||||
const sourceEntryLabel: Record<string, string> = {
|
||||
enterprise_signature: '企业签名修改', report_task: '报备任务修改', channel_report: '通道信息修改', system: '系统自动处理',
|
||||
};
|
||||
|
||||
function translateStatus(value?: string | null) {
|
||||
if (!value) return '-';
|
||||
return statusLabel[value] ?? value;
|
||||
}
|
||||
|
||||
function recordSource(record: ReportRecord) {
|
||||
if (record.sourceEntry) return sourceEntryLabel[record.sourceEntry] ?? record.sourceEntry;
|
||||
return record.action === 'manual_status_change' ? '历史记录(入口未记录)' : '系统自动处理';
|
||||
}
|
||||
|
||||
function RecordDetailModal({ record, onClose }: { record: ReportRecord; onClose: () => void }) {
|
||||
const isDrainage = record.task?.reportType === 'drainage';
|
||||
const target = isDrainage ? record.task?.drainageInfo?.siteName ?? record.task?.drainageInfo?.url : record.task?.signature?.name;
|
||||
return (
|
||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open size="xl" title={<div className="template-modal-title"><h2>报备记录详情</h2><p>{record.id}</p></div>}>
|
||||
<div className="report-record-detail">
|
||||
<div className="detail-grid">
|
||||
<div><span>任务编号</span><strong>{record.taskId}</strong></div>
|
||||
<div><span>通道</span><strong>{record.channelId}</strong></div>
|
||||
<div><span>动作</span><strong>{record.action}</strong></div>
|
||||
<div><span>状态前</span><strong>{record.statusBefore ?? '-'}</strong></div>
|
||||
<div><span>状态后</span><strong>{record.statusAfter ?? '-'}</strong></div>
|
||||
<div><span>通道</span><strong>{record.channel?.name ?? '-'}</strong></div>
|
||||
<div><span>报备类型</span><strong>{isDrainage ? '引流信息' : '签名'}</strong></div>
|
||||
<div><span>报备对象</span><strong>{target ?? '-'}</strong></div>
|
||||
<div><span>动作</span><strong>{actionLabel[record.action] ?? record.action}</strong></div>
|
||||
<div><span>修改入口</span><strong>{recordSource(record)}</strong></div>
|
||||
<div><span>状态前</span><strong>{translateStatus(record.statusBefore)}</strong></div>
|
||||
<div><span>状态后</span><strong>{translateStatus(record.statusAfter)}</strong></div>
|
||||
<div className="detail-grid__wide"><span>失败/备注原因</span><strong>{record.reason ?? '-'}</strong></div>
|
||||
</div>
|
||||
<section className="report-history">
|
||||
<h3><Clock3 size={17} />状态历史</h3>
|
||||
<div><span>{record.createdAt ?? '-'}</span><strong>{record.action}</strong><em>{record.reason ?? '系统记录真实报备状态变化。'}</em></div>
|
||||
<div><span>{record.createdAt ?? '-'}</span><strong>{actionLabel[record.action] ?? record.action}</strong><em>{record.reason ?? `修改入口:${recordSource(record)}`}</em></div>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -37,6 +68,7 @@ export function AdminReportRecordsPage() {
|
||||
const [records, setRecords] = useState<ReportRecord[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [reportType, setReportType] = useState('all');
|
||||
const [detail, setDetail] = useState<ReportRecord | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@@ -54,18 +86,22 @@ export function AdminReportRecordsPage() {
|
||||
}, []);
|
||||
|
||||
const filteredRecords = useMemo(() => records.filter((record) => {
|
||||
const text = `${record.taskId}${record.channelId}${record.action}${record.reason ?? ''}`;
|
||||
const text = `${record.taskId}${record.channel?.name ?? ''}${record.action}${actionLabel[record.action] ?? ''}${recordSource(record)}${record.reason ?? ''}${record.task?.signature?.name ?? ''}${record.task?.signature?.purpose ?? ''}${record.task?.drainageInfo?.siteName ?? ''}${record.task?.drainageInfo?.url ?? ''}${record.task?.drainageInfo?.remark ?? ''}`;
|
||||
const date = record.createdAt?.slice(0, 10) ?? '';
|
||||
return (!keyword || text.includes(keyword))
|
||||
&& (!dateRange.start || date >= dateRange.start)
|
||||
&& (!dateRange.end || date <= dateRange.end);
|
||||
}), [dateRange.end, dateRange.start, keyword, records]);
|
||||
&& (!dateRange.end || date <= dateRange.end)
|
||||
&& (reportType === 'all' || record.task?.reportType === reportType);
|
||||
}), [dateRange.end, dateRange.start, keyword, records, reportType]);
|
||||
|
||||
const columns: Array<TableColumn<ReportRecord>> = [
|
||||
{ key: 'task', title: '任务编号', width: '190px', render: (record) => <strong className="admin-task-id">{record.taskId}</strong> },
|
||||
{ key: 'channel', title: '通道', width: '230px', render: (record) => record.channelId },
|
||||
{ key: 'action', title: '动作', width: '150px', render: (record) => record.action },
|
||||
{ key: 'status', title: '状态变化', width: '180px', render: (record) => <Tag tone={statusTone[record.statusAfter ?? 'pending'] ?? 'info'}>{`${record.statusBefore ?? '-'} -> ${record.statusAfter ?? '-'}`}</Tag> },
|
||||
{ key: 'channel', title: '通道名称', width: '180px', render: (record) => record.channel?.name ?? '-' },
|
||||
{ key: 'targetType', title: '变更主体', width: '110px', render: (record) => <Tag tone={record.task?.reportType === 'drainage' ? 'info' : 'neutral'}>{record.task?.reportType === 'drainage' ? '引流信息' : '签名'}</Tag> },
|
||||
{ key: 'target', title: '主体内容', width: '260px', render: (record) => record.task?.reportType === 'drainage' ? <div className="admin-task-enterprise"><strong>{record.task?.signature?.name ?? '-'}</strong><span>{record.task?.drainageInfo?.siteName ?? '-'}</span><span>{record.task?.drainageInfo?.url ?? '-'}</span>{record.task?.drainageInfo?.remark ? <span>{record.task.drainageInfo.remark}</span> : null}</div> : <div className="admin-task-enterprise"><strong>{record.task?.signature?.name ?? '-'}</strong>{record.task?.signature?.purpose ? <span>{record.task.signature.purpose}</span> : null}</div> },
|
||||
{ key: 'source', title: '修改入口', width: '150px', render: (record) => recordSource(record) },
|
||||
{ key: 'action', title: '动作', width: '170px', render: (record) => actionLabel[record.action] ?? record.action },
|
||||
{ key: 'status', title: '状态变化', width: '210px', render: (record) => <Tag tone={statusTone[record.statusAfter ?? 'pending'] ?? 'info'}>{`${translateStatus(record.statusBefore)} → ${translateStatus(record.statusAfter)}`}</Tag> },
|
||||
{ key: 'time', title: '记录时间', width: '190px', render: (record) => record.createdAt ?? '-' },
|
||||
{ key: 'reason', title: '备注', render: (record) => record.reason ?? '-' },
|
||||
{ key: 'actions', title: '操作', align: 'right', width: '120px', render: (record) => <Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost">详情</Button> },
|
||||
@@ -83,10 +119,11 @@ export function AdminReportRecordsPage() {
|
||||
|
||||
<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({}); }} variant="ghost">重置</Button>
|
||||
<Button onClick={() => { setKeyword(''); setDateRange({}); setReportType('all'); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'su
|
||||
exporting: { label: '报备中', tone: 'warning' },
|
||||
abandoned: { label: '已放弃', tone: 'neutral' },
|
||||
failed: { label: '有失败', tone: 'danger' },
|
||||
waiting_review: { label: '等待运营审核', tone: 'warning' },
|
||||
};
|
||||
|
||||
type ReceiptImportPayload = {
|
||||
@@ -27,6 +28,7 @@ type ReceiptImportPayload = {
|
||||
|
||||
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);
|
||||
@@ -122,6 +124,7 @@ export function AdminReportTasksPage() {
|
||||
const [tasks, setTasks] = useState<ReportTask[]>([]);
|
||||
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);
|
||||
@@ -130,7 +133,7 @@ export function AdminReportTasksPage() {
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function loadData() {
|
||||
adminApi.listReportTasks()
|
||||
adminApi.listReportTasks({ reportType: reportType === 'all' ? undefined : reportType as 'signature' | 'drainage' })
|
||||
.then((items) => {
|
||||
setTasks(items);
|
||||
setError('');
|
||||
@@ -140,7 +143,7 @@ export function AdminReportTasksPage() {
|
||||
|
||||
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 ?? ''}`;
|
||||
@@ -174,7 +177,7 @@ export function AdminReportTasksPage() {
|
||||
|
||||
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 })
|
||||
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 || '报备状态保存失败'));
|
||||
}
|
||||
@@ -212,10 +215,11 @@ export function AdminReportTasksPage() {
|
||||
|
||||
<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({}); }} variant="ghost">重置</Button>
|
||||
<Button onClick={() => { setKeyword(''); setDateRange({}); setReportType('all'); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -423,7 +423,7 @@ export function AdminSmsRecordsPage() {
|
||||
<th>短信内容</th>
|
||||
<th style={{ width: '170px' }}>手机号码</th>
|
||||
<th style={{ width: '300px' }}>通道与发送状态</th>
|
||||
<th style={{ textAlign: 'right', width: '120px' }}>操作</th>
|
||||
<th style={{ width: '120px' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FilePenLine, Plus, Search, Trash2, Upload } from 'lucide-react';
|
||||
import { Button, FileActions, Input, Modal, Pagination, Select, Tag } from '@/components/ui';
|
||||
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type FileRef } from '@/api/adminApi';
|
||||
import { Edit3, FilePenLine, Globe2, Plus, Search, Trash2, Upload } from 'lucide-react';
|
||||
import { Button, FileActions, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { clientApi, type ApplicationReportField, type ClientSmsApplication, type ClientSmsSignature, type FileRef } from '@/api/adminApi';
|
||||
|
||||
const statusTone: Record<string, 'success' | 'info' | 'danger' | 'warning'> = {
|
||||
approved: 'success',
|
||||
@@ -26,6 +26,89 @@ function materialToFileRef(material: Record<string, unknown>): FileRef | null {
|
||||
return { contentType, fileName, fileObjectId };
|
||||
}
|
||||
|
||||
type ClientDrainageInfo = {
|
||||
id: string;
|
||||
siteName: string;
|
||||
url: string;
|
||||
remark: string;
|
||||
reportValues: Record<string, unknown>;
|
||||
auditStatus: string;
|
||||
rejectReason?: string | null;
|
||||
submittedAt?: string;
|
||||
};
|
||||
|
||||
function drainageItems(signature: ClientSmsSignature): ClientDrainageInfo[] {
|
||||
const payload = signature.drainageInfo && typeof signature.drainageInfo === 'object' ? signature.drainageInfo : {};
|
||||
const links = Array.isArray(payload.links) ? payload.links as Array<Record<string, unknown>> : [];
|
||||
return links.map((item) => ({
|
||||
id: String(item.id ?? ''),
|
||||
siteName: String(item.siteName ?? ''),
|
||||
url: String(item.url ?? ''),
|
||||
remark: String(item.remark ?? ''),
|
||||
reportValues: item.reportValues && typeof item.reportValues === 'object' ? item.reportValues as Record<string, unknown> : {},
|
||||
auditStatus: String(item.auditStatus ?? 'pending'),
|
||||
rejectReason: item.rejectReason ? String(item.rejectReason) : null,
|
||||
submittedAt: item.submittedAt ? String(item.submittedAt) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function reportFileRef(value: unknown): FileRef | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const item = value as Record<string, unknown>;
|
||||
const fileObjectId = String(item.fileObjectId ?? '');
|
||||
const fileName = String(item.fileName ?? '');
|
||||
return fileObjectId && fileName ? { fileObjectId, fileName, contentType: item.contentType ? String(item.contentType) : undefined } : null;
|
||||
}
|
||||
|
||||
function ClientDrainageModal({ item, onClose, onSaved, signature }: { item?: ClientDrainageInfo; onClose: () => void; onSaved: () => void; signature: ClientSmsSignature }) {
|
||||
const [fields, setFields] = useState<ApplicationReportField[]>([]);
|
||||
const [siteName, setSiteName] = useState(item?.siteName ?? '');
|
||||
const [url, setUrl] = useState(item?.url ?? '');
|
||||
const [remark, setRemark] = useState(item?.remark ?? '');
|
||||
const [values, setValues] = useState<Record<string, unknown>>(item?.reportValues ?? {});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploadingCode, setUploadingCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!signature.applicationId) return;
|
||||
clientApi.listApplicationReportFields(signature.applicationId).then(setFields).catch((failure: Error) => setError(failure.message || '引流报备字段加载失败'));
|
||||
}, [signature.applicationId]);
|
||||
|
||||
async function upload(field: ApplicationReportField, file?: File) {
|
||||
if (!file) return;
|
||||
setUploadingCode(field.code);
|
||||
try {
|
||||
const uploaded = await clientApi.uploadFileObject(file, { purpose: 'drainage_report_material', prefix: `drainage-materials/${signature.id}` });
|
||||
setValues((current) => ({ ...current, [field.code]: { fileObjectId: uploaded.id, fileName: uploaded.fileName, contentType: uploaded.contentType } }));
|
||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '文件上传失败'); } finally { setUploadingCode(''); }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const body = { siteName, url, remark, reportValues: values };
|
||||
if (item) await clientApi.updateDrainageInfo(item.id, body);
|
||||
else await clientApi.createDrainageInfo(signature.id, body);
|
||||
onSaved();
|
||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '引流信息提交审核失败'); } finally { setSaving(false); }
|
||||
}
|
||||
|
||||
const missingRequired = fields.some((field) => field.required && !values[field.code]);
|
||||
return <Modal footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!siteName.trim() || !url.trim() || missingRequired || saving || Boolean(uploadingCode)} onClick={() => void save()}>{saving ? '提交中...' : '提交审核'}</Button></>} onClose={onClose} open size="xl" title={item ? '修改引流信息' : '新增引流信息'}>
|
||||
<div className="signature-form drainage-edit-form">
|
||||
<Input label="站名称" onChange={(event) => setSiteName(event.target.value)} required value={siteName} />
|
||||
<Input label="引流地址" onChange={(event) => setUrl(event.target.value)} placeholder="https://" required value={url} />
|
||||
<Textarea label="备注" onChange={(event) => setRemark(event.target.value)} rows={3} value={remark} />
|
||||
<section className="surface" style={{ padding: 16 }}><h3>应用通道引流信息报备资料</h3><div className="signature-form-grid" style={{ marginTop: 12 }}>
|
||||
{fields.map((field) => field.fieldType === 'string' ? <Input key={field.id} label={`${field.required ? '* ' : ''}${field.name}`} onChange={(event) => setValues((current) => ({ ...current, [field.code]: event.target.value }))} value={String(values[field.code] ?? '')} /> : <label className="signature-upload" key={field.id}><Upload size={28} /><strong>{reportFileRef(values[field.code])?.fileName ?? `${field.required ? '* ' : ''}上传${field.name}`}</strong><small>{uploadingCode === field.code ? '上传中...' : field.fieldType === 'image' ? '请选择图片文件' : '请选择文件'}</small><FileActions file={reportFileRef(values[field.code])} /><input accept={field.fieldType === 'image' ? 'image/*' : undefined} onChange={(event) => void upload(field, event.target.files?.[0])} style={{ display: 'none' }} type="file" /></label>)}
|
||||
</div></section>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
export function ClientSignaturesPage() {
|
||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
|
||||
@@ -38,6 +121,7 @@ export function ClientSignaturesPage() {
|
||||
const [purpose, setPurpose] = useState('');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [drainageModal, setDrainageModal] = useState<{ signature: ClientSmsSignature; item?: ClientDrainageInfo }>();
|
||||
|
||||
function loadData() {
|
||||
setLoading(true);
|
||||
@@ -99,6 +183,10 @@ export function ClientSignaturesPage() {
|
||||
.catch((reason: Error) => setError(reason.message || '签名禁用失败'));
|
||||
}
|
||||
|
||||
function deleteDrainage(id: string) {
|
||||
clientApi.changeDrainageInfoStatus(id, 'deleted').then(loadData).catch((reason: Error) => setError(reason.message || '引流信息删除失败'));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="signature-page-header">
|
||||
@@ -149,6 +237,10 @@ export function ClientSignaturesPage() {
|
||||
<Button icon={<Trash2 size={16} />} onClick={() => disableSignature(signature.id)} size="sm" variant="danger">删除</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="drainage-panel">
|
||||
<div className="section-heading"><div><h2><Globe2 size={17} /> 引流信息</h2><p className="muted">新建或修改后由运营审核,通过后自动进入通道报备。</p></div><Button disabled={signature.auditStatus !== 'approved'} icon={<Plus size={15} />} onClick={() => setDrainageModal({ signature })} size="sm" variant="ghost">新增引流信息</Button></div>
|
||||
{drainageItems(signature).length ? drainageItems(signature).map((item) => <div className="surface" key={item.id} style={{ display: 'grid', gap: 12, gridTemplateColumns: '1fr 1.5fr 120px auto', marginTop: 10, padding: 12 }}><strong>{item.siteName}</strong><span className="drainage-table__url">{item.url}</span><Tag tone={statusTone[item.auditStatus] ?? 'info'}>{statusLabel[item.auditStatus] ?? item.auditStatus}</Tag><div className="table-actions"><Button icon={<Edit3 size={14} />} onClick={() => setDrainageModal({ signature, item })} size="sm" variant="ghost">修改</Button><Button icon={<Trash2 size={14} />} onClick={() => deleteDrainage(item.id)} size="sm" variant="danger">删除</Button></div>{item.rejectReason ? <p className="form-error" style={{ gridColumn: '1 / -1' }}>驳回原因:{item.rejectReason}</p> : null}</div>) : <p className="muted">暂无引流信息。</p>}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
@@ -197,6 +289,7 @@ export function ClientSignaturesPage() {
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
{drainageModal ? <ClientDrainageModal item={drainageModal.item} onClose={() => setDrainageModal(undefined)} onSaved={() => { setDrainageModal(undefined); loadData(); }} signature={drainageModal.signature} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,16 +37,16 @@ import { AppShell } from '@/layouts/AppShell';
|
||||
|
||||
export function AdminLayout() {
|
||||
const session = readSession();
|
||||
const [pendingAudits, setPendingAudits] = useState({ enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0 });
|
||||
const [pendingAudits, setPendingAudits] = useState({ enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0 });
|
||||
const [downstreamAlertCount, setDownstreamAlertCount] = useState(0);
|
||||
const loadPendingAuditCount = useCallback(() => {
|
||||
adminApi.getDashboard()
|
||||
.then((dashboard) => {
|
||||
setPendingAudits(dashboard.pendingAudits ?? { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0 });
|
||||
setPendingAudits(dashboard.pendingAudits ?? { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0 });
|
||||
setDownstreamAlertCount(dashboard.downstreamDeliverySummary?.alertCount ?? 0);
|
||||
})
|
||||
.catch(() => {
|
||||
setPendingAudits({ enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0 });
|
||||
setPendingAudits({ enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0 });
|
||||
setDownstreamAlertCount(0);
|
||||
});
|
||||
}, []);
|
||||
@@ -81,7 +81,8 @@ export function AdminLayout() {
|
||||
{ label: '企业认证待审', count: pendingAudits.enterpriseCertifications, to: '/admin/enterprise-audit' },
|
||||
{ label: '短信审核待审', count: pendingAudits.smsAudits, to: '/admin/sms-audit' },
|
||||
{ label: '模板待审', count: pendingAudits.templates, to: '/admin/templates' },
|
||||
{ label: '签名待审', count: pendingAudits.signatures, to: '/admin/enterprise-signatures' },
|
||||
{ label: '签名待审', count: pendingAudits.signatures, to: '/admin/signatures' },
|
||||
{ label: '引流信息待审', count: pendingAudits.drainageInfos, to: '/admin/drainage-audits' },
|
||||
{ label: '下游投递告警', count: downstreamAlertCount, to: '/admin/downstream-deliveries' },
|
||||
]}
|
||||
navSections={[
|
||||
@@ -112,6 +113,7 @@ export function AdminLayout() {
|
||||
{ label: '短信审核', to: '/admin/sms-audit', icon: MessageSquare },
|
||||
{ label: '短信模板审核', to: '/admin/templates', icon: FileCheck2 },
|
||||
{ label: '短信签名审核', to: '/admin/signatures', icon: FilePenLine },
|
||||
{ label: '引流信息审核', to: '/admin/drainage-audits', icon: FilePenLine },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -28,6 +28,7 @@ import { AdminSmsRecordsPage } from '@/apps/admin/AdminSmsRecordsPage';
|
||||
import { AdminSmsTaskProgressPage } from '@/apps/admin/AdminSmsTaskProgressPage';
|
||||
import { AdminSmsUplinkRecordsPage } from '@/apps/admin/AdminSmsUplinkRecordsPage';
|
||||
import { AdminSignatureAuditPage } from '@/apps/admin/AdminSignatureAuditPage';
|
||||
import { AdminDrainageAuditPage } from '@/apps/admin/AdminDrainageAuditPage';
|
||||
import { AdminSystemLogsPage } from '@/apps/admin/AdminSystemLogsPage';
|
||||
import { AdminTemplateAuditPage } from '@/apps/admin/AdminTemplateAuditPage';
|
||||
import { AdminUsersPage } from '@/apps/admin/AdminUsersPage';
|
||||
@@ -97,6 +98,7 @@ export function AppRoutes() {
|
||||
<Route path="enterprise-templates" element={<AdminEnterpriseTemplatesPage />} />
|
||||
<Route path="templates" element={<AdminTemplateAuditPage />} />
|
||||
<Route path="signatures" element={<AdminSignatureAuditPage />} />
|
||||
<Route path="drainage-audits" element={<AdminDrainageAuditPage />} />
|
||||
<Route path="enterprise-audit" element={<AdminEnterpriseAuditPage />} />
|
||||
<Route path="sms-audit" element={<AdminSmsAuditPage />} />
|
||||
<Route path="report-tasks" element={<AdminReportTasksPage />} />
|
||||
|
||||
@@ -2783,7 +2783,7 @@ h3 {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: minmax(96px, 0.9fr) minmax(180px, 1.5fr) repeat(3, minmax(64px, 0.65fr)) minmax(128px, 1fr) minmax(196px, auto);
|
||||
grid-template-columns: minmax(96px, 0.9fr) minmax(180px, 1.5fr) minmax(80px, 0.7fr) repeat(3, minmax(64px, 0.65fr)) minmax(128px, 1fr) minmax(196px, auto);
|
||||
min-height: 58px;
|
||||
}
|
||||
|
||||
@@ -3009,7 +3009,7 @@ h3 {
|
||||
align-items: end;
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
grid-template-columns: minmax(240px, 1fr) minmax(280px, 1.2fr) auto;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.admin-split-filter__actions {
|
||||
@@ -3025,7 +3025,7 @@ h3 {
|
||||
}
|
||||
|
||||
.admin-application-filter {
|
||||
grid-template-columns: minmax(320px, 460px) auto;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.cmpp-status-cell {
|
||||
@@ -8449,6 +8449,7 @@ h3 {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-strong);
|
||||
height: 58px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin-sms-record-table td {
|
||||
@@ -8882,6 +8883,7 @@ h3 {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
height: 58px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin-recharge-table td {
|
||||
@@ -8934,7 +8936,7 @@ h3 {
|
||||
align-items: end;
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
grid-template-columns: minmax(320px, 1fr) auto;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user