Files
lislgosms/src/apps/admin/AdminEnterpriseSignaturesPage.tsx
T

855 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Edit3, 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, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
import { displayFileName } from '@/utils/fileName';
import { formatDateTime } from '@/utils/dateTime';
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';
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 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 } }));
}
return (
<Modal
footer={(
<>
<Button onClick={onClose} variant="ghost">取消</Button>
<Button disabled={!form.tenantId || !form.name || 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>签名需履行报备,并遵照管理部门审核结果方可使用。请用 PNGJPGJPEG PDF 格式上传真实材料。</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 label="* 短信签名" onChange={(event) => update('name', event.target.value)} placeholder="请输入短信签名,如【XXXX公司】" 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="* 引流网址"
onChange={(event) => update('url', event.target.value)}
placeholder="请输入引流网址"
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">
<Input label="* 引流信息" onChange={(event) => update('siteName', event.target.value)} placeholder="请输入引流信息" value={form.siteName} />
<Textarea className="signature-form-grid__wide" label="备注" onChange={(event) => update('remark', event.target.value)} rows={4} value={form.remark} />
</div>
<DynamicReportFields fields={reportFields} onChange={updateReportValue} title="引流信息报备资料(通用 + 通道)" values={form.reportValues} />
</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}{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">{target.channel.carrier ?? '未标注运营商'} · {target.channel.code}</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>引流信息</span><strong>{item.siteName}</strong></div>
<div><span>引流信息</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}{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">{target.channel.carrier ?? '未标注运营商'} · {target.channel.code}</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>
);
}
export function AdminEnterpriseSignaturesPage() {
const [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms');
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [deleteTarget, setDeleteTarget] = useState<{ kind: 'signature'; id: string; name: string } | { kind: 'drainage'; signatureId: string; id: string; name: string } | null>(null);
const [drainageModal, setDrainageModal] = useState<{ signatureId: string; item?: DrainageInfo } | null>(null);
const [drainageReport, setDrainageReport] = useState<{ signature: ClientSmsSignature; item: DrainageInfo } | null>(null);
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('');
const [appliedSignatureKeyword, setAppliedSignatureKeyword] = useState('');
const [signatureModal, setSignatureModal] = useState<ClientSmsSignature | 'new' | null>(null);
const [signatureReport, setSignatureReport] = useState<ClientSmsSignature | null>(null);
const [reportStatusTarget, setReportStatusTarget] = useState<ClientSmsSignature | null>(null);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [page, setPage] = useState(1);
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, signatureKeyword: appliedSignatureKeyword, drainageKeyword: appliedDrainageKeyword }) {
try {
const [signatureItems, tenantItems, applicationItems] = await Promise.all([
adminApi.listEnterpriseSignatures(filters),
adminApi.listTenants(),
adminApi.listEnterpriseApplications(),
]);
setSignatures(signatureItems);
setTenants(tenantItems.filter((tenant) => tenant.status !== 'deleted'));
setApplications(applicationItems);
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '企业签名加载失败');
}
}
useEffect(() => {
void loadData();
}, []);
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))
&& (!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);
const visibleSignatures = filteredSignatures.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [appliedApplicationKeyword, appliedDrainageKeyword, appliedEnterpriseKeyword, appliedSignatureKeyword, filteredSignatures.length]);
async function saveSignature(state: SignatureFormState) {
const existing = signatureModal && signatureModal !== 'new' ? signatureModal : null;
const existingPayload = existing ? readDrainagePayload(existing) : { links: [], signatureProfile: undefined };
const drainageInfo = buildDrainagePayload({
mobile: state.mobile,
unicom: state.unicom,
telecom: state.telecom,
}, existingPayload.links, existingPayload.signatureProfile, state.reportValues);
try {
if (existing) {
await adminApi.updateEnterpriseSignature(existing.id, {
applicationId: state.applicationId || null,
drainageInfo,
name: state.name,
purpose: state.purpose,
});
} else {
await adminApi.createEnterpriseSignature({
applicationId: state.applicationId || undefined,
drainageInfo,
name: state.name,
purpose: state.purpose,
tenantId: state.tenantId,
});
}
setSignatureModal(null);
await loadData();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '企业签名保存失败');
}
}
async function saveDrainage(signatureId: string, item: DrainageInfo) {
const signature = signatures.find((current) => current.id === signatureId);
if (!signature) {
return;
}
const exists = readDrainagePayload(signature).links.some((current) => current.id === item.id);
const body = { siteName: item.siteName, url: item.url, remark: item.remark, reportValues: item.reportValues };
if (exists) await adminApi.updateDrainageInfo(item.id, body);
else await adminApi.createDrainageInfo(signatureId, body);
setDrainageModal(null);
setExpandedSignatureId(signatureId);
await loadData();
}
async function confirmDelete() {
if (!deleteTarget) {
return;
}
if (deleteTarget.kind === 'signature') {
await adminApi.changeEnterpriseSignatureStatus(deleteTarget.id, 'deleted', '运营端删除签名');
} else {
await adminApi.changeDrainageInfoStatus(deleteTarget.id, 'deleted', '运营端删除引流信息');
}
setDeleteTarget(null);
await loadData();
}
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>
<Button icon={<Trash2 size={16} />} onClick={() => setDeleteTarget({ kind: 'signature', id: signature.id, name: signature.name })} size="sm" variant="danger">删除</Button>
</div>
</div>
{expanded ? (
<div className="drainage-panel">
<h2>引流信息列表</h2>
{visibleDrainageLinks.length ? (
<div className="drainage-table">
<div className="drainage-table__head">
<span>引流信息</span>
<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}>
<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="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.siteName })} 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={filteredSignatures.length}
/>
{filteredSignatures.length === 0 ? <div className="ui-table__empty">暂无企业签名</div> : null}
</div>
);
return (
<section className="page-stack admin-customer-split-page">
<div className="page-heading">
<div>
<Breadcrumb items={['客户管理', '企业签名管理']} />
<h1>企业签名管理</h1>
</div>
<Button icon={<Plus size={16} />} onClick={() => setSignatureModal(activeTab === 'sms' ? 'new' : null)}>添加签名</Button>
</div>
<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) => 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(), 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: '', applicationKeyword: '', signatureKeyword: '', drainageKeyword: '' };
setEnterpriseKeyword('');
setApplicationKeyword('');
setSignatureKeyword('');
setDrainageKeyword('');
setAppliedEnterpriseKeyword('');
setAppliedApplicationKeyword('');
setAppliedSignatureKeyword('');
setAppliedDrainageKeyword('');
void loadData(filters);
}} variant="ghost">重置</Button>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface section-stack">
<Tabs
onChange={(value) => setActiveTab(value as 'sms' | 'mms')}
value={activeTab}
items={[
{ label: '短信签名', value: 'sms', content: smsSignatureContent },
{ label: '彩信签名', pending: true, value: 'mms', content: <div className="ui-table__empty">彩信签名待后端能力确认,本页不展示演示数据。</div> },
]}
/>
</div>
{signatureModal ? (
<SignatureFormModal
applications={applications}
item={signatureModal === 'new' ? undefined : signatureModal}
onClose={() => setSignatureModal(null)}
onSubmit={(state) => { void saveSignature(state); }}
tenants={tenants}
/>
) : null}
{signatureReport ? <SignatureReportModal item={signatureReport} onClose={() => setSignatureReport(null)} /> : null}
{reportStatusTarget ? <ChannelReportStatusModal item={reportStatusTarget} onClose={() => setReportStatusTarget(null)} onSaved={() => { setReportStatusTarget(null); void loadData(); }} /> : null}
{drainageModal ? (
<DrainageFormModal
applicationId={signatures.find((item) => item.id === drainageModal.signatureId)?.applicationId}
item={drainageModal.item}
onClose={() => setDrainageModal(null)}
onSubmit={(item) => { void saveDrainage(drainageModal.signatureId, item); }}
/>
) : null}
{drainageReport ? <DrainageReportModal item={drainageReport.item} onClose={() => setDrainageReport(null)} signature={drainageReport.signature} /> : null}
{drainageStatusTarget ? <DrainageReportStatusModal item={drainageStatusTarget.item} onClose={() => setDrainageStatusTarget(null)} onSaved={() => { setDrainageStatusTarget(null); void loadData(); }} signature={drainageStatusTarget.signature} /> : null}
{deleteTarget ? (
<ConfirmModal
message={`确认删除“${deleteTarget.name}”吗?删除后会写入真实后台。`}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => { void confirmDelete(); }}
/>
) : null}
</section>
);
}