feat: add phone frequency controls and modularize codebase
This commit is contained in:
@@ -1,598 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Edit3, FileSpreadsheet, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
|
||||
import { adminApi, type ApplicationReportField, type ClientSmsApplication, type ClientSmsSignature, type FileRef, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DeleteRiskAction, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
||||
import { displayFileName } from '@/utils/fileName';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { getSmsSignatureValidationError, hasForbiddenSmsSignatureCharacter, isCompleteSmsSignature, SMS_SIGNATURE_CHARACTER_ERROR } from '@/utils/smsSignature';
|
||||
import { FileSpreadsheet, Plus, Search } from 'lucide-react';
|
||||
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Tabs } from '@/components/ui';
|
||||
import { ReportMaterialImportModal } from './ReportMaterialImportModal';
|
||||
import { DrainageFormModal } from './enterprise-signatures/DrainageFormModal';
|
||||
import { EnterpriseSignaturesTable } from './enterprise-signatures/EnterpriseSignaturesTable';
|
||||
import { SignatureFormModal } from './enterprise-signatures/SignatureFormModal';
|
||||
import { ChannelReportStatusModal, ConfirmModal, DrainageReportModal, DrainageReportStatusModal, SignatureReportModal } from './enterprise-signatures/SignatureReportModals';
|
||||
import { buildDrainagePayload, readDrainagePayload } from './enterprise-signatures/signature.helpers';
|
||||
import type { DrainageInfo, SignatureFormState } from './enterprise-signatures/signature.types';
|
||||
|
||||
type CarrierStatus = 'approved' | 'pending' | 'rejected' | 'filing';
|
||||
|
||||
type DrainageInfo = {
|
||||
id: string;
|
||||
siteName: string;
|
||||
url: string;
|
||||
field1File?: UploadedFileRef | null;
|
||||
field2?: string;
|
||||
field3?: string;
|
||||
field4?: string;
|
||||
field5?: string;
|
||||
field6?: string;
|
||||
field7File?: UploadedFileRef | null;
|
||||
field8?: string;
|
||||
field9?: string;
|
||||
field10?: string;
|
||||
mobile: CarrierStatus;
|
||||
unicom: CarrierStatus;
|
||||
telecom: CarrierStatus;
|
||||
submittedAt: string;
|
||||
remark: string;
|
||||
reportValues: ReportValues;
|
||||
auditStatus?: string;
|
||||
rejectReason?: string | null;
|
||||
};
|
||||
|
||||
type UploadedFileRef = FileRef;
|
||||
type ReportValues = Record<string, string | UploadedFileRef | null>;
|
||||
|
||||
type SignatureFormState = {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
name: string;
|
||||
purpose: string;
|
||||
mobile: CarrierStatus;
|
||||
unicom: CarrierStatus;
|
||||
telecom: CarrierStatus;
|
||||
reportValues: ReportValues;
|
||||
};
|
||||
|
||||
type CarrierReportSummary = { status: string; approved: number; total: number };
|
||||
type SignatureCardTone = 'green' | 'blue' | 'amber' | 'red' | 'gray';
|
||||
|
||||
const carrierLabels: Record<string, string> = {
|
||||
mobile: '移动',
|
||||
unicom: '联通',
|
||||
telecom: '电信',
|
||||
all: '全网',
|
||||
};
|
||||
|
||||
function carrierLabel(carrier?: string | null) {
|
||||
if (!carrier) return '未标注运营商';
|
||||
return carrierLabels[carrier] ?? carrier;
|
||||
}
|
||||
|
||||
function CarrierReportTag({ summary }: { summary?: CarrierReportSummary }) {
|
||||
if (!summary || summary.status === 'not_applicable' || summary.total === 0) return <Tag tone="neutral">不适用</Tag>;
|
||||
let label = '未报备';
|
||||
let tone: 'success' | 'danger' | 'warning' | 'info' | 'neutral' = 'neutral';
|
||||
if (summary.status === 'approved') { label = '全部通过'; tone = 'success'; }
|
||||
else if (summary.status === 'failed' || summary.status === 'rejected') { label = '报备失败'; tone = 'danger'; }
|
||||
else if (summary.status === 'waiting_material') { label = '资料待补充'; tone = 'warning'; }
|
||||
else if (summary.approved > 0) { label = '部分通过'; tone = 'info'; }
|
||||
else if (summary.status === 'reporting' || summary.status === 'exporting') { label = '报备中'; tone = 'warning'; }
|
||||
return <span className="carrier-report-summary"><Tag tone={tone}>{label}</Tag><small>({summary.approved}/{summary.total})</small></span>;
|
||||
}
|
||||
|
||||
function signatureCardVisual(auditStatus: string, summaries?: Record<string, CarrierReportSummary>) {
|
||||
if (auditStatus === 'rejected') return { label: '签名审核已驳回', tone: 'red' as SignatureCardTone };
|
||||
if (auditStatus === 'pending') return { label: '签名待审核', tone: 'amber' as SignatureCardTone };
|
||||
if (auditStatus !== 'approved') return { label: '签名尚未提交审核', tone: 'gray' as SignatureCardTone };
|
||||
|
||||
const values = Object.values(summaries ?? {});
|
||||
const applicable = values.filter((summary) => summary.total > 0 && summary.status !== 'not_applicable');
|
||||
if (applicable.some((summary) => ['failed', 'rejected'].includes(summary.status))) return { label: '存在报备失败', tone: 'red' as SignatureCardTone };
|
||||
if (applicable.some((summary) => summary.approved > 0 && summary.approved < summary.total)) return { label: '部分通道报备通过', tone: 'blue' as SignatureCardTone };
|
||||
if (applicable.some((summary) => summary.status === 'waiting_material')) return { label: '报备资料待补充', tone: 'amber' as SignatureCardTone };
|
||||
if (applicable.some((summary) => ['reporting', 'exporting'].includes(summary.status))) return { label: '通道报备处理中', tone: 'amber' as SignatureCardTone };
|
||||
if (applicable.length > 0 && applicable.every((summary) => summary.status === 'approved')) return { label: '所有目标通道报备通过', tone: 'green' as SignatureCardTone };
|
||||
if (applicable.some((summary) => summary.approved > 0)) return { label: '部分运营商报备通过', tone: 'blue' as SignatureCardTone };
|
||||
return { label: applicable.length > 0 ? '目标通道尚未报备' : '没有适用的目标通道', tone: 'gray' as SignatureCardTone };
|
||||
}
|
||||
|
||||
function AuditStatusTag({ status }: { status: string }) {
|
||||
const meta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'danger' }> = {
|
||||
draft: { label: '草稿', tone: 'neutral' }, pending: { label: '待审核', tone: 'info' }, approved: { label: '已通过', tone: 'success' }, rejected: { label: '已驳回', tone: 'danger' },
|
||||
};
|
||||
const current = meta[status] ?? { label: status || '-', tone: 'neutral' as const };
|
||||
return <Tag tone={current.tone}>{current.label}</Tag>;
|
||||
}
|
||||
|
||||
function readDrainagePayload(signature: ClientSmsSignature) {
|
||||
const payload = signature.drainageInfo && typeof signature.drainageInfo === 'object' ? signature.drainageInfo : {};
|
||||
const carrierStatus = typeof payload.carrierStatus === 'object' && payload.carrierStatus ? payload.carrierStatus as Record<string, unknown> : {};
|
||||
const profile = typeof payload.signatureProfile === 'object' && payload.signatureProfile ? payload.signatureProfile as Record<string, unknown> : {};
|
||||
const links = Array.isArray(payload.links) ? payload.links as Array<Record<string, unknown>> : [];
|
||||
const fallbackStatus = normalizeCarrierStatus(signature.auditStatus);
|
||||
return {
|
||||
carrierStatus: {
|
||||
mobile: normalizeCarrierStatus(carrierStatus.mobile, fallbackStatus),
|
||||
unicom: normalizeCarrierStatus(carrierStatus.unicom, fallbackStatus),
|
||||
telecom: normalizeCarrierStatus(carrierStatus.telecom, fallbackStatus),
|
||||
},
|
||||
signatureProfile: profile,
|
||||
signatureReportValues: normalizeReportValues(payload.signatureReportValues),
|
||||
links: links.map((item) => ({
|
||||
id: String(item.id ?? `drain-${Date.now()}`),
|
||||
siteName: String(item.siteName ?? ''),
|
||||
url: String(item.url ?? ''),
|
||||
field1File: normalizeUploadedFile(item.field1File),
|
||||
field2: String(item.field2 ?? ''),
|
||||
field3: String(item.field3 ?? ''),
|
||||
field4: String(item.field4 ?? ''),
|
||||
field5: String(item.field5 ?? ''),
|
||||
field6: String(item.field6 ?? ''),
|
||||
field7File: normalizeUploadedFile(item.field7File),
|
||||
field8: String(item.field8 ?? ''),
|
||||
field9: String(item.field9 ?? ''),
|
||||
field10: String(item.field10 ?? ''),
|
||||
mobile: normalizeCarrierStatus(item.mobile, 'filing'),
|
||||
unicom: normalizeCarrierStatus(item.unicom, 'filing'),
|
||||
telecom: normalizeCarrierStatus(item.telecom, 'filing'),
|
||||
submittedAt: String(item.submittedAt ?? ''),
|
||||
remark: String(item.remark ?? ''),
|
||||
reportValues: normalizeReportValues(item.reportValues),
|
||||
auditStatus: String(item.auditStatus ?? 'pending'),
|
||||
rejectReason: item.rejectReason ? String(item.rejectReason) : null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDrainagePayload(carrierStatus: { mobile: CarrierStatus; unicom: CarrierStatus; telecom: CarrierStatus }, links: DrainageInfo[], signatureProfile?: Record<string, unknown>, signatureReportValues?: ReportValues) {
|
||||
return { carrierStatus, links, signatureProfile, signatureReportValues };
|
||||
}
|
||||
|
||||
function normalizeReportValues(value: unknown): ReportValues {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [key, normalizeUploadedFile(item) ?? String(item ?? '')]));
|
||||
}
|
||||
|
||||
function hasMissingRequiredReportValue(fields: ApplicationReportField[], values: ReportValues) {
|
||||
return fields.some((field) => field.required && !values[field.code]);
|
||||
}
|
||||
|
||||
function normalizeUploadedFile(value: unknown): UploadedFileRef | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const item = value as Record<string, unknown>;
|
||||
const fileObjectId = String(item.fileObjectId ?? '');
|
||||
const fileName = String(item.fileName ?? '');
|
||||
const contentType = typeof item.contentType === 'string' ? item.contentType : undefined;
|
||||
return fileObjectId || fileName ? { contentType, fileObjectId, fileName } : null;
|
||||
}
|
||||
|
||||
function normalizeCarrierStatus(value: unknown, fallback: CarrierStatus = 'filing'): CarrierStatus {
|
||||
return value === 'approved' || value === 'pending' || value === 'rejected' || value === 'filing' ? value : fallback;
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
return formatDateTime(value);
|
||||
}
|
||||
|
||||
function SignatureUploadBox({
|
||||
compact = false,
|
||||
file,
|
||||
label,
|
||||
onUploaded,
|
||||
}: {
|
||||
compact?: boolean;
|
||||
file?: UploadedFileRef | null;
|
||||
label: string;
|
||||
onUploaded: (file: UploadedFileRef) => void;
|
||||
}) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function uploadFile(fileInput: File | undefined) {
|
||||
if (!fileInput) return;
|
||||
setUploading(true);
|
||||
setError('');
|
||||
try {
|
||||
const fileObject = await adminApi.uploadFileObject(fileInput, { purpose: 'signature_report_material', prefix: 'signature-report-materials' });
|
||||
onUploaded({ contentType: fileObject.contentType, fileObjectId: fileObject.id, fileName: fileObject.fileName });
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '文件上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<label className={compact ? 'signature-upload signature-upload--compact' : 'signature-upload'}>
|
||||
<span>{label}</span>
|
||||
<Upload size={compact ? 30 : 42} />
|
||||
<strong>{uploading ? '上传中...' : (file ? displayFileName(file.fileName) : '') || (compact ? '上传文件' : '点击上传 或拖拽文件到此处')}</strong>
|
||||
<FileActions file={file} />
|
||||
{!compact ? <small>支持 PNG、JPG、JPEG、PDF,文件大小不超过 10M</small> : null}
|
||||
{error ? <small className="form-error">{error}</small> : null}
|
||||
<input
|
||||
accept="image/png,image/jpeg,application/pdf"
|
||||
disabled={uploading}
|
||||
onChange={(event) => { void uploadFile(event.target.files?.[0]); }}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function DynamicReportFields({ fields, onChange, title, values }: { fields: ApplicationReportField[]; onChange: (code: string, value: string | UploadedFileRef | null) => void; title: string; values: ReportValues }) {
|
||||
const [explanationOpen, setExplanationOpen] = useState(false);
|
||||
if (fields.length === 0) return null;
|
||||
const channels = Array.from(new Map(fields.flatMap((field) => field.channels).map((channel) => [channel.id, channel])).values());
|
||||
const groups = Array.from(new Map(channels.map((channel) => [channel.groupId, channel.groupName])).entries());
|
||||
const requiredCount = fields.filter((field) => field.required).length;
|
||||
const commonCount = fields.filter((field) => (field.commonReportTypes?.length ?? 0) > 0).length;
|
||||
return (
|
||||
<section>
|
||||
<div className="report-requirement-heading">
|
||||
<h3>{title}</h3>
|
||||
<Button icon={<Info size={15} />} onClick={() => setExplanationOpen(true)} size="sm" variant="ghost">为什么需要这些资料?</Button>
|
||||
</div>
|
||||
<div className="signature-alert">
|
||||
<Info size={18} />
|
||||
<span>当前要求由 {commonCount} 项通用字段及 {groups.length} 个通道组、{channels.length} 个通道配置合并生成,共 {fields.length} 项,其中 {requiredCount} 项必填。保存时会固化本次要求快照。</span>
|
||||
</div>
|
||||
<div className="signature-form-grid">
|
||||
{fields.map((field) => {
|
||||
const channelHint = field.channels.map((channel) => channel.name).join('、');
|
||||
const requiredChannels = field.required ? field.channels.filter((channel) => channel.required).map((channel) => channel.name).join('、') : '';
|
||||
const isCommon = (field.commonReportTypes?.length ?? 0) > 0;
|
||||
const label = `${field.required ? '* ' : ''}${field.name}`;
|
||||
const hint = isCommon
|
||||
? `平台通用${field.required ? '必填' : '选填'}资料${channelHint ? `,适用于:${channelHint}` : ''}`
|
||||
: field.required
|
||||
? `由 ${requiredChannels} 要求,至少一个通道配置为必填`
|
||||
: `适用通道:${channelHint}`;
|
||||
return field.fieldType === 'file' || field.fieldType === 'image' ? (
|
||||
<div key={field.id}>
|
||||
<SignatureUploadBox compact file={typeof values[field.code] === 'object' ? values[field.code] as UploadedFileRef : null} label={label} onUploaded={(file) => onChange(field.code, file)} />
|
||||
<small className="report-field-source">{hint}</small>
|
||||
</div>
|
||||
) : (
|
||||
<div key={field.id}>
|
||||
<Input label={label} onChange={(event) => onChange(field.code, event.target.value)} placeholder={field.description ?? `请输入${field.name}`} required={field.required} value={typeof values[field.code] === 'string' ? values[field.code] as string : ''} />
|
||||
<small className="report-field-source">{hint}</small>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Modal footer={<Button onClick={() => setExplanationOpen(false)}>我知道了</Button>} onClose={() => setExplanationOpen(false)} open={explanationOpen} size="xl" title="这些资料从哪里来?">
|
||||
<div className="report-requirement-explanation">
|
||||
<p>资料要求由“报备字段库通用配置”和“企业应用 → 通道组 → 通道 → 通道报备字段”实时合并;相同字段只填写一次,但会按目标通道分别用于报备。</p>
|
||||
{commonCount > 0 ? (
|
||||
<section className="report-source-group">
|
||||
<h4>平台通用字段</h4>
|
||||
<ul>{fields.filter((field) => (field.commonReportTypes?.length ?? 0) > 0).map((field) => <li key={field.id}>{field.name} · {field.commonReportTypes?.includes('signature') ? '签名报备' : '引流信息报备'} · {field.required ? '必填' : '选填'}</li>)}</ul>
|
||||
</section>
|
||||
) : null}
|
||||
{groups.map(([groupId, groupName]) => (
|
||||
<section className="report-source-group" key={groupId}>
|
||||
<h4>通道组:{groupName}</h4>
|
||||
{channels.filter((channel) => channel.groupId === groupId).map((channel) => (
|
||||
<div className="report-source-channel" key={channel.id}>
|
||||
<strong>{channel.name}({channel.code})</strong>
|
||||
<ul>
|
||||
{fields.filter((field) => field.channels.some((source) => source.id === channel.id)).map((field) => (
|
||||
<li key={field.id}>{field.name} · {field.channels.find((source) => source.id === channel.id)?.reportType === 'both' ? '签名和引流共用' : field.channels.find((source) => source.id === channel.id)?.reportType === 'signature' ? '签名报备' : '引流信息报备'} · {field.channels.find((source) => source.id === channel.id)?.required ? '必填' : '选填'}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SignatureFormModal({
|
||||
applications,
|
||||
item,
|
||||
onClose,
|
||||
onSubmit,
|
||||
tenants,
|
||||
}: {
|
||||
applications: ClientSmsApplication[];
|
||||
item?: ClientSmsSignature;
|
||||
onClose: () => void;
|
||||
onSubmit: (state: SignatureFormState) => void;
|
||||
tenants: TenantOption[];
|
||||
}) {
|
||||
const payload = item ? readDrainagePayload(item) : null;
|
||||
const [form, setForm] = useState<SignatureFormState>({
|
||||
tenantId: item?.tenantId ?? '',
|
||||
applicationId: item?.applicationId ?? '',
|
||||
name: item?.name ?? '',
|
||||
purpose: item?.purpose ?? '',
|
||||
mobile: payload?.carrierStatus.mobile ?? 'filing',
|
||||
unicom: payload?.carrierStatus.unicom ?? 'filing',
|
||||
telecom: payload?.carrierStatus.telecom ?? 'filing',
|
||||
reportValues: payload?.signatureReportValues ?? {},
|
||||
});
|
||||
const [reportFields, setReportFields] = useState<ApplicationReportField[]>([]);
|
||||
const [nameInputError, setNameInputError] = useState('');
|
||||
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted');
|
||||
|
||||
useEffect(() => {
|
||||
const request = form.applicationId
|
||||
? adminApi.listApplicationReportFields(form.applicationId, 'signature')
|
||||
: adminApi.listCommonApplicationReportFields('signature');
|
||||
request.then(setReportFields).catch(() => setReportFields([]));
|
||||
}, [form.applicationId]);
|
||||
|
||||
function update<Key extends keyof SignatureFormState>(key: Key, value: SignatureFormState[Key]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function updateReportValue(code: string, value: string | UploadedFileRef | null) {
|
||||
setForm((current) => ({ ...current, reportValues: { ...current.reportValues, [code]: value } }));
|
||||
}
|
||||
|
||||
const signatureNameValid = !nameInputError && isCompleteSmsSignature(form.name);
|
||||
const signatureNameError = nameInputError || (form.name ? getSmsSignatureValidationError(form.name) : undefined);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button disabled={!form.tenantId || !signatureNameValid || hasMissingRequiredReportValue(reportFields, form.reportValues)} onClick={() => onSubmit(form)}>保存</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={(
|
||||
<div className="signature-modal-title">
|
||||
<h2>{item ? '编辑签名' : '添加签名'}</h2>
|
||||
<p>{item ? '修改短信签名的相关信息' : '新增短信签名的相关信息'}</p>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="signature-form">
|
||||
<section>
|
||||
<h3>基本信息</h3>
|
||||
<div className="signature-alert">
|
||||
<Info size={18} />
|
||||
<span>签名名称必须包含完整中文黑括号,例如:【某某科技】。签名需履行报备,并遵照管理部门审核结果方可使用。</span>
|
||||
</div>
|
||||
<div className="signature-form-grid">
|
||||
<Select
|
||||
disabled={Boolean(item)}
|
||||
label="所属企业"
|
||||
onChange={(event) => update('tenantId', event.target.value)}
|
||||
options={[
|
||||
{ label: '请选择企业', value: '' },
|
||||
...tenants.map((tenant) => ({ label: `${tenant.name}(${tenant.code})`, value: tenant.id })),
|
||||
]}
|
||||
required
|
||||
value={form.tenantId}
|
||||
/>
|
||||
<Select
|
||||
label="* 应用名称"
|
||||
onChange={(event) => update('applicationId', event.target.value)}
|
||||
options={[
|
||||
{ label: '不绑定应用', value: '' },
|
||||
...tenantApplications.map((application) => ({ label: application.name, value: application.id })),
|
||||
]}
|
||||
value={form.applicationId}
|
||||
/>
|
||||
<Input
|
||||
error={signatureNameError}
|
||||
hint="新增和编辑时必须保留完整的【】,且不能包含空格或不可见字符"
|
||||
label="短信签名"
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
if (hasForbiddenSmsSignatureCharacter(value)) {
|
||||
setNameInputError(SMS_SIGNATURE_CHARACTER_ERROR);
|
||||
return;
|
||||
}
|
||||
setNameInputError('');
|
||||
update('name', value);
|
||||
}}
|
||||
placeholder="请输入完整签名,例如:【某某科技】"
|
||||
required
|
||||
value={form.name}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DynamicReportFields fields={reportFields} onChange={updateReportValue} title="签名报备资料(通用 + 通道)" values={form.reportValues} />
|
||||
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrainageFormModal({ applicationId, item, onClose, onSubmit }: { applicationId?: string | null; item?: DrainageInfo; onClose: () => void; onSubmit: (item: DrainageInfo) => void }) {
|
||||
const [reportFields, setReportFields] = useState<ApplicationReportField[]>([]);
|
||||
const [form, setForm] = useState<DrainageInfo>(() => item ?? {
|
||||
id: `drain-${Date.now()}`,
|
||||
siteName: '',
|
||||
url: '',
|
||||
field1File: null,
|
||||
field2: '',
|
||||
field3: '',
|
||||
field4: '',
|
||||
field5: '',
|
||||
field6: '',
|
||||
field7File: null,
|
||||
field8: '',
|
||||
field9: '',
|
||||
field10: '',
|
||||
mobile: 'filing',
|
||||
unicom: 'filing',
|
||||
telecom: 'filing',
|
||||
submittedAt: formatDateTime(new Date()),
|
||||
remark: '',
|
||||
reportValues: {},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const request = applicationId
|
||||
? adminApi.listApplicationReportFields(applicationId, 'drainage')
|
||||
: adminApi.listCommonApplicationReportFields('drainage');
|
||||
request.then(setReportFields).catch(() => setReportFields([]));
|
||||
}, [applicationId]);
|
||||
|
||||
function update<Key extends keyof DrainageInfo>(key: Key, value: DrainageInfo[Key]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function updateReportValue(code: string, value: string | UploadedFileRef | null) {
|
||||
setForm((current) => ({ ...current, reportValues: { ...current.reportValues, [code]: value } }));
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button disabled={!form.url || hasMissingRequiredReportValue(reportFields, form.reportValues)} onClick={() => onSubmit(form)}>保存</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={item ? '编辑引流信息' : '添加引流信息'}
|
||||
>
|
||||
<div className="signature-form drainage-edit-form">
|
||||
<section>
|
||||
<h3>基本信息</h3>
|
||||
<Input
|
||||
label="* 引流url或号码"
|
||||
onChange={(event) => update('url', event.target.value)}
|
||||
placeholder="请输入引流url或号码"
|
||||
required
|
||||
value={form.url}
|
||||
/>
|
||||
<div className="signature-alert drainage-form-note">
|
||||
<Info size={18} />
|
||||
<ol>
|
||||
<li>本页面中所填的信息需与短信内容应用所包含的网站或服务保持一致;</li>
|
||||
<li>图片仅支持 PNG、JPG 或 JPEG 格式的正版文件,且大小不超过 3M;</li>
|
||||
<li>文件格式支持 PDF 格式或者图片,且大小不超过 10M。</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="signature-form-grid">
|
||||
<Textarea className="signature-form-grid__wide" label="备注" onChange={(event) => update('remark', event.target.value)} rows={4} value={form.remark} />
|
||||
</div>
|
||||
<DynamicReportFields fields={reportFields} onChange={updateReportValue} title="引流信息报备资料(通用 + 通道)" values={form.reportValues} />
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function SignatureReportModal({ item, onClose }: { item: ClientSmsSignature; onClose: () => void }) {
|
||||
return (
|
||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open title="签名报备详情">
|
||||
<div className="admin-report-detail">
|
||||
<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.name}</strong></div>
|
||||
<div><span>签名审核</span><AuditStatusTag status={item.auditStatus} /></div>
|
||||
<div><span>更新时间</span><strong>{formatDate(item.updatedAt)}</strong></div>
|
||||
</div>
|
||||
<div className="admin-report-tabs">
|
||||
<button className="admin-report-carrier--mobile active" type="button"><strong>移动</strong><span><CarrierReportTag summary={item.carrierReportSummary?.mobile} /></span></button>
|
||||
<button className="admin-report-carrier--unicom active" type="button"><strong>联通</strong><span><CarrierReportTag summary={item.carrierReportSummary?.unicom} /></span></button>
|
||||
<button className="admin-report-carrier--telecom active" type="button"><strong>电信</strong><span><CarrierReportTag summary={item.carrierReportSummary?.telecom} /></span></button>
|
||||
</div>
|
||||
<div className="page-stack">{(item.reportTargets ?? []).map((target) => <div className="surface" key={target.channelId} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}({carrierLabel(target.channel.carrier)})</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const reportStatusOptions = [
|
||||
{ label: '未报备', value: 'pending' }, { label: '资料待补充', value: 'waiting_material' },
|
||||
{ label: '报备中', value: 'reporting' }, { label: '报备通过', value: 'approved' },
|
||||
{ label: '报备失败', value: 'failed' }, { label: '放弃报备', value: 'abandoned' },
|
||||
];
|
||||
|
||||
function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsSignature; onClose: () => void; onSaved: () => void }) {
|
||||
const targets = item.reportTargets ?? [];
|
||||
const [statuses, setStatuses] = useState<Record<string, string>>(() => Object.fromEntries(targets.map((target) => [target.channelId, target.status])));
|
||||
const [reason, setReason] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: 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); }
|
||||
}
|
||||
return <Modal footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="按通道修改签名报备状态">
|
||||
<div className="page-stack"><div className="signature-alert"><Info size={18} /><span>企业签名只展示汇总结果;这里修改的是每个具体通道的报备任务,保存后会同步通道详情、报备任务和企业签名三网状态。</span></div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{targets.length ? targets.map((target) => <div className="surface admin-report-target-row" key={target.channelId}><div><strong>{target.channel.name}</strong><div className="muted">{carrierLabel(target.channel.carrier)} · {target.channel.name}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state">该企业应用当前没有配置目标通道。</div>}
|
||||
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入运营商工单、确认依据或人工处理说明" rows={3} value={reason} />
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
function DrainageReportModal({ item, onClose, signature }: { item: DrainageInfo; onClose: () => void; signature: ClientSmsSignature }) {
|
||||
const summary = signature.drainageCarrierReportSummary?.[item.id];
|
||||
const targets = signature.drainageReportTargets?.[item.id] ?? [];
|
||||
return (
|
||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open size="xl" title="引流信息报备详情">
|
||||
<div className="detail-grid">
|
||||
<div><span>引流url或号码</span><strong>{item.url}</strong></div>
|
||||
<div><span>引流url或号码</span><strong>{item.url}</strong></div>
|
||||
<div><span>移动</span><CarrierReportTag summary={summary?.mobile} /></div>
|
||||
<div><span>联通</span><CarrierReportTag summary={summary?.unicom} /></div>
|
||||
<div><span>电信</span><CarrierReportTag summary={summary?.telecom} /></div>
|
||||
<div className="detail-grid__wide"><span>备注</span><strong>{item.remark || '-'}</strong></div>
|
||||
</div>
|
||||
<div className="page-stack" style={{ marginTop: 16 }}>{targets.map((target) => <div className="surface" key={target.channelId} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}({carrierLabel(target.channel.carrier)})</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrainageReportStatusModal({ item, onClose, onSaved, signature }: { item: DrainageInfo; onClose: () => void; onSaved: () => void; signature: ClientSmsSignature }) {
|
||||
const targets = signature.drainageReportTargets?.[item.id] ?? [];
|
||||
const [statuses, setStatuses] = useState<Record<string, string>>(() => Object.fromEntries(targets.map((target) => [target.channelId, target.status])));
|
||||
const [reason, setReason] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: signature.id, channelId: target.channelId, reportType: 'drainage', drainageItemId: item.id, status: statuses[target.channelId] ?? target.status })), reason, sourceEntry: 'enterprise_signature' });
|
||||
onSaved();
|
||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '引流报备状态保存失败'); } finally { setSaving(false); }
|
||||
}
|
||||
return <Modal footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="按通道修改引流信息报备状态">
|
||||
<div className="page-stack"><div className="signature-alert"><Info size={18} /><span>修改的是当前引流信息在具体通道上的真实报备任务,保存后会同步通道报备详情和报备任务页。</span></div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{targets.length ? targets.map((target) => <div className="surface admin-report-target-row" key={target.channelId}><div><strong>{target.channel.name}</strong><div className="muted">{carrierLabel(target.channel.carrier)} · {target.channel.name}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state">当前应用的目标通道没有配置引流信息报备字段。</div>}
|
||||
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} rows={3} value={reason} />
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onCancel} variant="ghost">取消</Button>
|
||||
<Button onClick={onConfirm} variant="danger">确认删除</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onCancel}
|
||||
open
|
||||
title="删除确认"
|
||||
>
|
||||
<p className="admin-confirm-text">{message}</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** R4 page container: owns query state and coordinates focused presentation components. */
|
||||
export function AdminEnterpriseSignaturesPage() {
|
||||
const [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms');
|
||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||
@@ -704,89 +122,25 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
}
|
||||
|
||||
const smsSignatureContent = (
|
||||
<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 cardVisual = signatureCardVisual(signature.auditStatus, signature.carrierReportSummary);
|
||||
const expanded = expandedSignatureId === signature.id || Boolean(appliedDrainageKeyword);
|
||||
return (
|
||||
<article aria-label={`签名总体状态:${cardVisual.label}`} className={`signature-card signature-card--${cardVisual.tone}`} key={signature.id} title={`总体状态:${cardVisual.label}`}>
|
||||
<div className="signature-summary">
|
||||
<button aria-label="展开签名" onClick={() => setExpandedSignatureId(expanded ? '' : signature.id)} type="button">
|
||||
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||
</button>
|
||||
<div><span>签名名称</span><strong>{signature.name}</strong></div>
|
||||
<div><span>企业</span><strong>{signature.tenant?.name ?? signature.tenantId}</strong></div>
|
||||
<div><span>应用</span><strong>{signature.application?.name ?? '-'}</strong></div>
|
||||
<div><span>签名审核</span><AuditStatusTag status={signature.auditStatus} /></div>
|
||||
<div><span>移动</span><CarrierReportTag summary={signature.carrierReportSummary?.mobile} /></div>
|
||||
<div><span>联通</span><CarrierReportTag summary={signature.carrierReportSummary?.unicom} /></div>
|
||||
<div><span>电信</span><CarrierReportTag summary={signature.carrierReportSummary?.telecom} /></div>
|
||||
<div><span>引流信息</span><strong>{payload.links.length} 条</strong></div>
|
||||
<div className="signature-actions">
|
||||
<Button icon={<FileText size={16} />} onClick={() => setSignatureReport(signature)} size="sm" variant="ghost">报备详情</Button>
|
||||
<Button icon={<Edit3 size={16} />} onClick={() => setReportStatusTarget(signature)} size="sm" variant="ghost">报备状态</Button>
|
||||
<Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost">编辑</Button>
|
||||
<DeleteRiskAction onCompleted={() => void loadData()} portal="admin" targetId={signature.id} targetType="signature" />
|
||||
</div>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className="drainage-panel">
|
||||
<h2>引流信息列表</h2>
|
||||
{visibleDrainageLinks.length ? (
|
||||
<div className="drainage-table">
|
||||
<div className="drainage-table__head">
|
||||
<span>引流url或号码</span>
|
||||
<span>审核状态</span>
|
||||
<span>移动</span>
|
||||
<span>联通</span>
|
||||
<span>电信</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{visibleDrainageLinks.map((item) => {
|
||||
const summary = signature.drainageCarrierReportSummary?.[item.id];
|
||||
return (
|
||||
<div className="drainage-table__row" key={item.id}>
|
||||
<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="drainage-row-actions">
|
||||
<Button onClick={() => setDrainageReport({ signature, item })} size="sm" variant="ghost">报备详情</Button>
|
||||
<Button disabled={item.auditStatus !== 'approved'} onClick={() => setDrainageStatusTarget({ signature, item })} size="sm" variant="ghost">报备状态</Button>
|
||||
<Button onClick={() => setDrainageModal({ signatureId: signature.id, item })} size="sm" variant="ghost">编辑</Button>
|
||||
<Button onClick={() => setDeleteTarget({ kind: 'drainage', signatureId: signature.id, id: item.id, name: item.url })} size="sm" variant="danger">删除</Button>
|
||||
</span>
|
||||
</div>
|
||||
);})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted">暂无引流信息</p>
|
||||
)}
|
||||
<div className="drainage-panel__footer">
|
||||
<Button icon={<Plus size={16} />} onClick={() => setDrainageModal({ signatureId: signature.id })} size="sm" variant="ghost">添加引流信息</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
|
||||
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
|
||||
page={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={total}
|
||||
/>
|
||||
{filteredSignatures.length === 0 ? <div className="ui-table__empty">暂无企业签名</div> : null}
|
||||
</div>
|
||||
<EnterpriseSignaturesTable
|
||||
appliedDrainageKeyword={appliedDrainageKeyword}
|
||||
currentPage={currentPage}
|
||||
expandedSignatureId={expandedSignatureId}
|
||||
filteredSignatures={filteredSignatures}
|
||||
loadData={loadData}
|
||||
setDeleteTarget={setDeleteTarget}
|
||||
setDrainageModal={setDrainageModal}
|
||||
setDrainageReport={setDrainageReport}
|
||||
setDrainageStatusTarget={setDrainageStatusTarget}
|
||||
setExpandedSignatureId={setExpandedSignatureId}
|
||||
setPage={setPage}
|
||||
setReportStatusTarget={setReportStatusTarget}
|
||||
setSignatureModal={setSignatureModal}
|
||||
setSignatureReport={setSignatureReport}
|
||||
total={total}
|
||||
totalPages={totalPages}
|
||||
visibleSignatures={visibleSignatures}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user