feat: inherit channel report requirements

This commit is contained in:
hectorzhao
2026-07-12 16:14:54 +08:00
parent 053bcca990
commit 87ae4a2063
14 changed files with 612 additions and 65 deletions
+22 -23
View File
@@ -1,27 +1,29 @@
import { useEffect, useMemo, useState } from 'react';
import { ArrowLeft, Plus, Search } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelReportField } from '@/api/adminApi';
import { adminApi, type AdminChannel, type ChannelReportField, type DictionaryItem } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, Tag, type TableColumn } from '@/components/ui';
export function AdminChannelReportPage() {
const navigate = useNavigate();
const [channels, setChannels] = useState<AdminChannel[]>([]);
const [fields, setFields] = useState<ChannelReportField[]>([]);
const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]);
const [channelId, setChannelId] = useState('');
const [keyword, setKeyword] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [code, setCode] = useState('');
const [name, setName] = useState('');
const [fieldType, setFieldType] = useState('string');
const [drainageFieldId, setDrainageFieldId] = useState('');
const [reportType, setReportType] = useState<'signature' | 'drainage' | 'both'>('signature');
const [required, setRequired] = useState(false);
const [description, setDescription] = useState('');
const [error, setError] = useState('');
function loadData(nextChannelId = channelId) {
Promise.all([adminApi.listChannels(), adminApi.listChannelReportFields(nextChannelId || undefined)])
.then(([channelItems, fieldItems]) => {
Promise.all([adminApi.listChannels(), adminApi.listChannelReportFields(nextChannelId || undefined), adminApi.listDrainageFields()])
.then(([channelItems, fieldItems, libraryItems]) => {
setChannels(channelItems.filter((item) => item.status !== 'deleted'));
setFields(fieldItems);
setLibraryFields(libraryItems.filter((item) => item.status === 'active'));
setError('');
})
.catch((failure: Error) => setError(failure.message || '通道报备配置加载失败'));
@@ -34,12 +36,14 @@ export function AdminChannelReportPage() {
const filteredFields = useMemo(() => fields.filter((field) => !keyword || [field.code, field.name, field.fieldType, field.description].join(' ').includes(keyword)), [fields, keyword]);
function createField() {
adminApi.createChannelReportField({ channelId, code, name, fieldType, description, status: 'active' })
const selected = libraryFields.find((item) => item.id === drainageFieldId);
if (!selected) return;
adminApi.createChannelReportField({ channelId, drainageFieldId, reportType, required, description, status: 'active' })
.then(() => {
setModalOpen(false);
setCode('');
setName('');
setFieldType('string');
setDrainageFieldId('');
setReportType('signature');
setRequired(false);
setDescription('');
loadData();
})
@@ -51,6 +55,7 @@ export function AdminChannelReportPage() {
{ key: 'code', title: '字段代码', width: '160px', render: (record) => <strong>{record.code}</strong> },
{ key: 'name', title: '字段名称', width: '160px', render: (record) => record.name },
{ key: 'type', title: '字段类型', width: '120px', render: (record) => record.fieldType },
{ key: 'reportType', title: '报备用途', width: '140px', render: (record) => record.reportType === 'signature' ? '签名报备' : record.reportType === 'drainage' ? '引流信息报备' : '签名+引流' },
{ key: 'required', title: '必填', width: '90px', render: (record) => <Tag tone={record.required ? 'warning' : 'info'}>{record.required ? '是' : '否'}</Tag> },
{ key: 'description', title: '说明', render: (record) => record.description ?? '-' },
];
@@ -87,26 +92,20 @@ export function AdminChannelReportPage() {
</div>
<Modal
footer={<><Button onClick={() => setModalOpen(false)} variant="ghost"></Button><Button disabled={!channelId || !code || !name} onClick={createField}></Button></>}
footer={<><Button onClick={() => setModalOpen(false)} variant="ghost"></Button><Button disabled={!channelId || !drainageFieldId} onClick={createField}></Button></>}
onClose={() => setModalOpen(false)}
open={modalOpen}
title="新增通道报备字段"
>
<div className="admin-system-modal-form">
<Input label="字段代码" onChange={(event) => setCode(event.target.value)} value={code} />
<Input label="字段名称" onChange={(event) => setName(event.target.value)} value={name} />
<Select
label="字段类型"
onChange={(event) => setFieldType(event.target.value)}
options={[
{ label: '字符串', value: 'string' },
{ label: '数字', value: 'number' },
{ label: '文件', value: 'file' },
{ label: '图片', value: 'image' },
{ label: '网址', value: 'url' },
]}
value={fieldType}
label="报备字段库字段"
onChange={(event) => setDrainageFieldId(event.target.value)}
options={[{ label: '请选择字段', value: '' }, ...libraryFields.map((field) => ({ label: `${field.name ?? field.code}${field.code}`, value: field.id }))]}
value={drainageFieldId}
/>
<Select label="报备用途" onChange={(event) => setReportType(event.target.value as typeof reportType)} options={[{ label: '签名报备', value: 'signature' }, { label: '引流信息报备', value: 'drainage' }, { label: '签名+引流', value: 'both' }]} value={reportType} />
<Select label="是否必填" onChange={(event) => setRequired(event.target.value === 'true')} options={[{ label: '选填', value: 'false' }, { label: '必填', value: 'true' }]} value={String(required)} />
<Textarea label="说明" onChange={(event) => setDescription(event.target.value)} rows={4} value={description} />
</div>
</Modal>
+114 -19
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Edit3, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type FileRef, type TenantOption } from '@/api/adminApi';
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';
@@ -26,9 +26,11 @@ type DrainageInfo = {
telecom: CarrierStatus;
submittedAt: string;
remark: string;
reportValues: ReportValues;
};
type UploadedFileRef = FileRef;
type ReportValues = Record<string, string | UploadedFileRef | null>;
type SignatureProfile = {
basis: string;
@@ -55,6 +57,7 @@ type SignatureFormState = {
mobile: CarrierStatus;
unicom: CarrierStatus;
telecom: CarrierStatus;
reportValues: ReportValues;
};
const statusLabelMap: Record<CarrierStatus, string> = {
@@ -88,6 +91,7 @@ function readDrainagePayload(signature: ClientSmsSignature) {
telecom: normalizeCarrierStatus(carrierStatus.telecom, fallbackStatus),
},
signatureProfile: normalizeSignatureProfile(profile, signature),
signatureReportValues: normalizeReportValues(payload.signatureReportValues),
links: links.map((item) => ({
id: String(item.id ?? `drain-${Date.now()}`),
siteName: String(item.siteName ?? ''),
@@ -107,12 +111,22 @@ function readDrainagePayload(signature: ClientSmsSignature) {
telecom: normalizeCarrierStatus(item.telecom, 'filing'),
submittedAt: String(item.submittedAt ?? ''),
remark: String(item.remark ?? ''),
reportValues: normalizeReportValues(item.reportValues),
})),
};
}
function buildDrainagePayload(carrierStatus: { mobile: CarrierStatus; unicom: CarrierStatus; telecom: CarrierStatus }, links: DrainageInfo[], signatureProfile?: SignatureProfile) {
return { carrierStatus, links, signatureProfile };
function buildDrainagePayload(carrierStatus: { mobile: CarrierStatus; unicom: CarrierStatus; telecom: CarrierStatus }, links: DrainageInfo[], signatureProfile?: SignatureProfile, 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 {
@@ -224,6 +238,67 @@ function SignatureUploadBox({
);
}
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;
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> {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 label = `${field.required ? '* ' : ''}${field.name}`;
const hint = 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>
{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,
@@ -247,9 +322,19 @@ function SignatureFormModal({
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(() => {
if (!form.applicationId) {
setReportFields([]);
return;
}
adminApi.listApplicationReportFields(form.applicationId, 'signature').then(setReportFields).catch(() => setReportFields([]));
}, [form.applicationId]);
function update<Key extends keyof SignatureFormState>(key: Key, value: SignatureFormState[Key]) {
setForm((current) => ({ ...current, [key]: value }));
}
@@ -258,12 +343,16 @@ function SignatureFormModal({
setForm((current) => ({ ...current, profile: { ...current.profile, [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} onClick={() => onSubmit(form)}></Button>
<Button disabled={!form.tenantId || !form.name || hasMissingRequiredReportValue(reportFields, form.reportValues)} onClick={() => onSubmit(form)}></Button>
</>
)}
onClose={onClose}
@@ -347,12 +436,15 @@ function SignatureFormModal({
</div>
</section>
<DynamicReportFields fields={reportFields} onChange={updateReportValue} title="应用通道签名报备资料" values={form.reportValues} />
</div>
</Modal>
);
}
function DrainageFormModal({ item, onClose, onSubmit }: { item?: DrainageInfo; onClose: () => void; onSubmit: (item: DrainageInfo) => void }) {
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: '',
@@ -372,18 +464,28 @@ function DrainageFormModal({ item, onClose, onSubmit }: { item?: DrainageInfo; o
telecom: 'filing',
submittedAt: formatDateTime(new Date()),
remark: '',
reportValues: {},
});
useEffect(() => {
if (!applicationId) return;
adminApi.listApplicationReportFields(applicationId, 'drainage').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} onClick={() => onSubmit(form)}></Button>
<Button disabled={!form.url || hasMissingRequiredReportValue(reportFields, form.reportValues)} onClick={() => onSubmit(form)}></Button>
</>
)}
onClose={onClose}
@@ -410,19 +512,11 @@ function DrainageFormModal({ item, onClose, onSubmit }: { item?: DrainageInfo; o
</ol>
</div>
<div className="signature-form-grid">
<SignatureUploadBox compact file={form.field1File} label="* 字段名称1" onUploaded={(file) => update('field1File', file)} />
<Input label="* 字段名称2" onChange={(event) => update('field2', event.target.value)} placeholder="请输入字段2内容" value={form.field2 ?? ''} />
<Input label="* 字段名称3" onChange={(event) => { update('field3', event.target.value); update('siteName', event.target.value); }} placeholder="请输入公司名称" value={form.field3 ?? form.siteName} />
<Input label="字段名称4" onChange={(event) => update('field4', event.target.value)} placeholder="请输入统一社会信用代码" value={form.field4 ?? ''} />
<Input label="* 字段名称5" onChange={(event) => update('field5', event.target.value)} placeholder="请输入法人姓名" value={form.field5 ?? ''} />
<Input label="字段名称6" onChange={(event) => update('field6', event.target.value)} placeholder="请输入法人身份证号" value={form.field6 ?? ''} />
<SignatureUploadBox compact file={form.field7File} label="字段名称7" onUploaded={(file) => update('field7File', file)} />
<Input label="* 字段名称8" onChange={(event) => update('field8', event.target.value)} placeholder="请输入责任人身份证号" value={form.field8 ?? ''} />
<Input label="* 字段名称9" onChange={(event) => update('field9', event.target.value)} placeholder="请输入责任人姓名" value={form.field9 ?? ''} />
<Input label="* 字段名称10" onChange={(event) => update('field10', event.target.value)} placeholder="请输入责任人手机号" value={form.field10 ?? ''} />
<Input label="* 名称" onChange={(event) => update('siteName', event.target.value)} placeholder="请输入站名称" value={form.siteName} />
<Input label="提交时间" onChange={(event) => update('submittedAt', event.target.value)} value={form.submittedAt} />
<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>
@@ -544,7 +638,7 @@ export function AdminEnterpriseSignaturesPage() {
mobile: state.mobile,
unicom: state.unicom,
telecom: state.telecom,
}, existingPayload.links, state.profile);
}, existingPayload.links, state.profile, state.reportValues);
try {
if (existing) {
await adminApi.updateEnterpriseSignature(existing.id, {
@@ -579,7 +673,7 @@ export function AdminEnterpriseSignaturesPage() {
? payload.links.map((current) => current.id === item.id ? item : current)
: [item, ...payload.links];
await adminApi.updateEnterpriseSignature(signatureId, {
drainageInfo: buildDrainagePayload(payload.carrierStatus, links, payload.signatureProfile),
drainageInfo: buildDrainagePayload(payload.carrierStatus, links, payload.signatureProfile, payload.signatureReportValues),
});
setDrainageModal(null);
setExpandedSignatureId(signatureId);
@@ -597,7 +691,7 @@ export function AdminEnterpriseSignaturesPage() {
if (signature) {
const payload = readDrainagePayload(signature);
await adminApi.updateEnterpriseSignature(signature.id, {
drainageInfo: buildDrainagePayload(payload.carrierStatus, payload.links.filter((item) => item.id !== deleteTarget.id), payload.signatureProfile),
drainageInfo: buildDrainagePayload(payload.carrierStatus, payload.links.filter((item) => item.id !== deleteTarget.id), payload.signatureProfile, payload.signatureReportValues),
});
}
}
@@ -740,6 +834,7 @@ export function AdminEnterpriseSignaturesPage() {
{signatureReport ? <SignatureReportModal item={signatureReport} onClose={() => setSignatureReport(null)} /> : 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); }}