feat: complete drainage review and admin search workflows

This commit is contained in:
hectorzhao
2026-07-14 09:58:18 +08:00
parent dec2430b7e
commit 6421671259
33 changed files with 1107 additions and 255 deletions
+54 -9
View File
@@ -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) =>
+1 -1
View File
@@ -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 || '报备状态保存失败'));
}
+94
View File
@@ -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>
+31 -30
View File
@@ -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>
+27 -13
View File
@@ -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>
+9 -4
View File
@@ -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>
+50 -13
View File
@@ -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>
+8 -4
View File
@@ -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>
+1 -1
View File
@@ -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>
+96 -3
View File
@@ -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>
);
}
+6 -4
View File
@@ -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 },
],
},
{
+2
View File
@@ -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 />} />
+6 -4
View File
@@ -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);
}