feat: add phone frequency controls and modularize codebase

This commit is contained in:
hectorzhao
2026-07-31 22:25:23 +08:00
parent 0af671b4ed
commit ca4f591a13
216 changed files with 41579 additions and 23694 deletions
+29 -675
View File
@@ -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> PNGJPGJPEGPDF 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> PNGJPG 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 (