fix: connect remaining sms pages to real backend

This commit is contained in:
hectorzhao
2026-07-02 19:30:04 +08:00
parent 06562e42dd
commit 131f344ac4
50 changed files with 2074 additions and 6400 deletions
+91 -645
View File
@@ -1,664 +1,110 @@
import { useMemo, useState } from 'react';
import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Eye,
FileSliders,
FileUp,
GripVertical,
Pencil,
Plus,
Search,
Settings2,
Trash2,
} from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
import type { DateRangeValue } from '@/components/ui';
type ReportStatus = 'success' | 'failed' | 'reporting' | 'unreported' | 'withdrawn' | 'abandoned';
type DeliveryStats = {
successRate: number;
successCount: number;
unknownRate: number;
unknownCount: number;
failureRate: number;
failureCount: number;
};
type DrainageReport = {
id: string;
value: string;
status: ReportStatus;
submittedAt: string;
reportedAt?: string;
lastSentAt?: string;
stats: DeliveryStats;
remark?: string;
};
type SignatureReport = {
id: string;
name: string;
status: ReportStatus;
submittedAt: string;
reportedAt?: string;
lastSentAt?: string;
stats: DeliveryStats;
drainage: DrainageReport[];
details: SignatureDetails;
remark?: string;
};
type SignatureDetails = {
basis: string;
companyName: string;
creditCode: string;
legalName: string;
legalId: string;
contactName: string;
contactPhone: string;
contactId: string;
};
type ReportDetail =
| { kind: 'signature'; report: SignatureReport }
| { kind: 'drainage'; title: string; status: ReportStatus; submittedAt: string; reportedAt?: string; lastSentAt?: string };
type ReportField = {
id: string;
label: string;
type: '文本' | '图片' | '文件';
};
type SelectedReportField = ReportField & {
required: boolean;
mapping: string;
};
const channelNames: Record<string, string> = {
'88827': '行北-集市三甲医院-39',
'77': '移动-行北-上海甲医院-38',
'78': '联通-行政-杭州甲医院-37',
'67': '联通-行政-上海甲医院-34',
};
const channelCopyStorageKey = 'cmpp-channel-copies';
type ChannelCopyMeta = {
sourceId: string;
name: string;
};
function readChannelCopyMeta(): Record<string, ChannelCopyMeta> {
try {
const raw = window.localStorage.getItem(channelCopyStorageKey);
return raw ? JSON.parse(raw) as Record<string, ChannelCopyMeta> : {};
} catch {
return {};
}
}
function getChannelName(channelId: string) {
const copyMeta = readChannelCopyMeta()[channelId];
return copyMeta?.name ?? channelNames[channelId] ?? `短信通道 ${channelId}`;
}
const statusOptions = [
{ label: '全部状态', value: 'all' },
{ label: '报备成功', value: 'success' },
{ label: '报备失败', value: 'failed' },
{ label: '报备中', value: 'reporting' },
{ label: '未报备', value: 'unreported' },
{ label: '被清退', value: 'withdrawn' },
{ label: '放弃报备', value: 'abandoned' },
];
const statusMeta: Record<ReportStatus, { label: string; tone: 'success' | 'danger' | 'warning' | 'neutral' }> = {
success: { label: '报备成功', tone: 'success' },
failed: { label: '报备失败', tone: 'danger' },
reporting: { label: '报备中', tone: 'warning' },
unreported: { label: '未报备', tone: 'neutral' },
withdrawn: { label: '被清退', tone: 'danger' },
abandoned: { label: '放弃报备', tone: 'warning' },
};
const statusChoices: Array<{ value: ReportStatus; label: string; className: string }> = [
{ value: 'unreported', label: '未报备', className: 'is-neutral' },
{ value: 'reporting', label: '报备中', className: 'is-info' },
{ value: 'success', label: '报备成功', className: 'is-success' },
{ value: 'failed', label: '报备失败', className: 'is-danger' },
{ value: 'withdrawn', label: '被清退', className: 'is-danger' },
{ value: 'abandoned', label: '放弃报备', className: 'is-warning' },
];
const drainageFieldPool: ReportField[] = [
{ id: 'businessScope', label: '营业范围', type: '文本' },
{ id: 'legalName', label: '法人姓名', type: '文本' },
{ id: 'legalPhone', label: '法人手机号', type: '文本' },
{ id: 'legalIdImage', label: '法人身份证图片', type: '图片' },
{ id: 'managerIdImage', label: '经办人身份证图片', type: '图片' },
{ id: 'managerPhone', label: '经办人手机号', type: '文本' },
{ id: 'managerName', label: '经办人姓名', type: '文本' },
{ id: 'creditCode', label: '统一社会信用代码', type: '文本' },
{ id: 'licenseImage', label: '营业执照图片', type: '图片' },
{ id: 'brandName', label: '品牌名称', type: '文本' },
{ id: 'drainageInfo', label: '引流信息', type: '文本' },
{ id: 'companyAddress', label: '公司地址', type: '文本' },
{ id: 'authorization', label: '授权证明', type: '文件' },
];
const signatureFieldPool: ReportField[] = [
{ id: 'signatureName', label: '短信签名', type: '文本' },
{ id: 'signatureBasis', label: '签名依据', type: '文本' },
{ id: 'qualificationFile', label: '资质凭证', type: '文件' },
{ id: 'companyName', label: '公司名称', type: '文本' },
{ id: 'creditCode', label: '统一社会信用代码', type: '文本' },
{ id: 'legalName', label: '法人姓名', type: '文本' },
{ id: 'legalId', label: '法人身份证号', type: '文本' },
{ id: 'legalIdFront', label: '法人身份证人像面', type: '图片' },
{ id: 'legalIdBack', label: '法人身份证国徽面', type: '图片' },
{ id: 'managerName', label: '责任人姓名', type: '文本' },
{ id: 'managerPhone', label: '责任人手机号', type: '文本' },
{ id: 'managerId', label: '责任人身份证号', type: '文本' },
{ id: 'authorization', label: '授权委托书', type: '文件' },
];
const initialSelectedDrainageFields: SelectedReportField[] = [
{ id: 'companyName', label: '公司名称', type: '文本', required: false, mapping: '' },
{ id: 'legalId', label: '法人身份证号', type: '文本', required: false, mapping: '' },
];
const initialSelectedSignatureFields: SelectedReportField[] = [
{ id: 'signatureName', label: '短信签名', type: '文本', required: true, mapping: 'sign_name' },
{ id: 'qualificationFile', label: '资质凭证', type: '文件', required: true, mapping: 'license_file' },
{ id: 'companyName', label: '公司名称', type: '文本', required: true, mapping: 'enterprise_name' },
{ id: 'creditCode', label: '统一社会信用代码', type: '文本', required: true, mapping: 'credit_code' },
];
const emptyStats: DeliveryStats = {
successRate: 0,
successCount: 0,
unknownRate: 0,
unknownCount: 0,
failureRate: 0,
failureCount: 0,
};
const initialReports: SignatureReport[] = [
{
id: 'sig-1',
name: '中华长城签名1',
status: 'failed',
submittedAt: '2025-12-28 18:08:08',
reportedAt: '2025-12-29 12:03:01',
lastSentAt: '2025-12-30 08:13:21',
stats: { successRate: 88.2, successCount: 1130200, unknownRate: 29.1, unknownCount: 372940, failureRate: 8, failureCount: 102480 },
details: { basis: '企业自用签名', companyName: '示例科技有限公司', creditCode: '91110000XXXXXXXXXX', legalName: '张三', legalId: '110101199001011234', contactName: '李四', contactPhone: '13800138000', contactId: '110101199002021234' },
drainage: [
{ id: 'flow-1', value: '400-123-4567', status: 'success', submittedAt: '2025-12-28 18:08:08', reportedAt: '2025-12-28 18:08:08', lastSentAt: '2025-12-28 18:08:08', stats: { ...emptyStats, failureRate: 100, failureCount: 50 } },
{ id: 'flow-2', value: 'www.example.com', status: 'success', submittedAt: '2025-12-28 18:08:08', reportedAt: '2025-12-28 18:08:08', lastSentAt: '2025-12-28 18:08:08', stats: { ...emptyStats, failureRate: 100, failureCount: 54 } },
{ id: 'flow-3', value: 'service@example.com', status: 'success', submittedAt: '2025-12-28 18:08:08', reportedAt: '2025-12-28 18:08:08', lastSentAt: '2025-12-28 18:08:08', stats: { ...emptyStats, failureRate: 100, failureCount: 50 } },
{ id: 'flow-4', value: '18912345678', status: 'unreported', submittedAt: '2025-12-28 18:08:08', stats: emptyStats },
],
},
{
id: 'sig-2',
name: '医长长长长长长长长长长',
status: 'success',
submittedAt: '2025-12-28 18:08:08',
reportedAt: '2025-12-29 12:03:01',
stats: emptyStats,
details: { basis: '企业自用签名', companyName: '上海医长信息科技有限公司', creditCode: '91310000XXXXXXXXXX', legalName: '王强', legalId: '310101198805061234', contactName: '赵敏', contactPhone: '13900139000', contactId: '310101199006081234' },
drainage: [
{ id: 'flow-5', value: '18912345678', status: 'unreported', submittedAt: '2025-12-28 18:08:08', stats: emptyStats },
],
},
{
id: 'sig-3',
name: '国信委科技服务',
status: 'reporting',
submittedAt: '2025-12-28 18:08:08',
reportedAt: '2025-12-29 12:03:01',
lastSentAt: '2025-12-30 08:13:21',
stats: { successRate: 78.2, successCount: 8804, unknownRate: 1.2, unknownCount: 2046, failureRate: 5.8, failureCount: 1916 },
details: { basis: '企事业单位全称或简称', companyName: '国信委科技服务有限公司', creditCode: '91110108XXXXXXXXXX', legalName: '陈杰', legalId: '110108198812121234', contactName: '周宁', contactPhone: '13700137000', contactId: '110108199103151234' },
drainage: [],
},
];
function DateTime({ value }: { value?: string }) {
if (!value) return <span className="muted">-</span>;
const [date, time] = value.split(' ');
return <span className="channel-report-date"><span>{date}</span><span>{time}</span></span>;
}
function Stats({ stats }: { stats: DeliveryStats }) {
return (
<div className="channel-report-stats">
<span> <strong className="is-success">{stats.successRate}%</strong><b>{stats.successCount.toLocaleString('zh-CN')}</b></span>
<span> <strong className="is-warning">{stats.unknownRate}%</strong><b>{stats.unknownCount.toLocaleString('zh-CN')}</b></span>
<span> <strong className="is-danger">{stats.failureRate}%</strong><b>{stats.failureCount.toLocaleString('zh-CN')}</b></span>
</div>
);
}
function RowActions({ onDelete, onStatus, onView }: { onDelete: () => void; onStatus: () => void; onView: () => void }) {
return (
<div className="channel-report-actions">
<button onClick={onView} type="button"><Eye size={16} /></button>
<button className="is-warning" onClick={onStatus} type="button"><Pencil size={16} /></button>
<button className="is-danger" onClick={onDelete} type="button"><Trash2 size={16} /></button>
</div>
);
}
function ReadonlyUpload({ label }: { label: string }) {
return (
<div className="channel-signature-upload">
<span>{label}</span>
<div></div>
</div>
);
}
function SignatureDetailModal({ report, onClose }: { report: SignatureReport; onClose: () => void }) {
const details = report.details;
return (
<Modal
footer={<Button onClick={onClose}></Button>}
onClose={onClose}
open
size="xl"
title={<div className="channel-signature-title"><h2></h2><p></p></div>}
>
<div className="channel-signature-detail">
<section>
<h3></h3>
<div className="channel-signature-grid">
<Select disabled label="签名依据" options={[{ label: details.basis, value: details.basis }]} value={details.basis} />
<Input label="短信签名" readOnly value={`${report.name}`} />
<ReadonlyUpload label="资质凭证" />
</div>
</section>
<section>
<h3></h3>
<div className="channel-signature-grid">
<Input label="公司名称" readOnly value={details.companyName} />
<Input label="统一社会信用代码" readOnly value={details.creditCode} />
<Input label="法人姓名" readOnly value={details.legalName} />
<Input label="法人身份证号" readOnly value={details.legalId} />
<ReadonlyUpload label="法人身份证照片-人像面" />
<ReadonlyUpload label="法人身份证照片-国徽面" />
</div>
</section>
<section>
<h3></h3>
<div className="channel-signature-grid">
<Input label="责任人姓名" readOnly value={details.contactName} />
<Input label="责任人手机号" readOnly value={details.contactPhone} />
<Input className="channel-signature-grid__wide" label="责任人身份证号" readOnly value={details.contactId} />
<ReadonlyUpload label="责任人身份证照片-人像面" />
<ReadonlyUpload label="责任人身份证照片-国徽面" />
</div>
</section>
</div>
</Modal>
);
}
function DrainageFieldConfigModal({
fields,
fieldPool,
onChange,
onClose,
title = '配置报备字段',
description = '从字段池中选择字段,并配置是否必填',
}: {
fields: SelectedReportField[];
fieldPool: ReportField[];
onChange: (fields: SelectedReportField[]) => void;
onClose: () => void;
title?: string;
description?: string;
}) {
const [draft, setDraft] = useState(fields);
const [searchText, setSearchText] = useState('');
const availableFields = useMemo(() => fieldPool.filter((field) => (
!draft.some((item) => item.id === field.id)
&& (!searchText || field.label.includes(searchText))
)), [draft, fieldPool, searchText]);
function addField(field: ReportField) {
setDraft((items) => [...items, { ...field, required: false, mapping: '' }]);
}
function updateField(id: string, patch: Partial<SelectedReportField>) {
setDraft((items) => items.map((item) => item.id === id ? { ...item, ...patch } : item));
}
function moveField(index: number, offset: number) {
setDraft((items) => {
const target = index + offset;
if (target < 0 || target >= items.length) return items;
const next = [...items];
[next[index], next[target]] = [next[target], next[index]];
return next;
});
}
return (
<Modal
footer={(
<>
<Button onClick={onClose} variant="ghost"></Button>
<Button onClick={() => { onChange(draft); onClose(); }}></Button>
</>
)}
onClose={onClose}
open
size="xl"
title={<div className="channel-field-config-title"><h2>{title}</h2><p>{description}</p></div>}
>
<div className="channel-field-config">
<section className="channel-field-pool">
<div className="channel-field-section-head">
<h3></h3>
<Tag tone="neutral">{availableFields.length} </Tag>
</div>
<Input onChange={(event) => setSearchText(event.target.value)} placeholder="搜索字段..." prefix={<Search size={16} />} value={searchText} />
<div className="channel-field-pool-list">
{availableFields.map((field) => (
<button key={field.id} onClick={() => addField(field)} type="button">
<span><strong>{field.label}</strong><Tag tone="neutral">{field.type}</Tag></span>
<span> <Plus size={15} /></span>
</button>
))}
{availableFields.length === 0 ? <p></p> : null}
</div>
</section>
<section className="channel-selected-fields">
<div className="channel-field-section-head">
<div><h3></h3><p></p></div>
<Tag tone="info">{draft.length} </Tag>
</div>
<div className="channel-selected-field-list">
{draft.map((field, index) => (
<article key={field.id}>
<div className="channel-selected-field-head">
<span className="channel-selected-field-index">{index + 1}</span>
<GripVertical size={17} />
<strong>{field.label}</strong>
<Tag tone="neutral">{field.type}</Tag>
<div className="channel-selected-field-order">
<button disabled={index === 0} onClick={() => moveField(index, -1)} type="button"><ChevronUp size={16} /><span className="sr-only"></span></button>
<button disabled={index === draft.length - 1} onClick={() => moveField(index, 1)} type="button"><ChevronDown size={16} /><span className="sr-only"></span></button>
</div>
</div>
<div className="channel-selected-field-controls">
<label><input checked={field.required} onChange={() => updateField(field.id, { required: true })} type="radio" /></label>
<label><input checked={!field.required} onChange={() => updateField(field.id, { required: false })} type="radio" /></label>
<button aria-label={`删除${field.label}`} onClick={() => setDraft((items) => items.filter((item) => item.id !== field.id))} type="button"><Trash2 size={17} /></button>
</div>
<Input label="映射通道字段" onChange={(event) => updateField(field.id, { mapping: event.target.value })} placeholder="请输入映射字段名" value={field.mapping} />
</article>
))}
{draft.length === 0 ? <div className="channel-report-empty"></div> : null}
</div>
</section>
</div>
</Modal>
);
}
function ReceiptImportModal({ onClose, onSubmit }: { onClose: () => void; onSubmit: () => void }) {
return (
<Modal
footer={<><Button onClick={onClose} variant="ghost"></Button><Button onClick={onSubmit}></Button></>}
onClose={onClose}
open
size="xl"
title={<div className="template-modal-title"><h2></h2><p></p></div>}
>
<div className="report-receipt-modal">
<div className="report-upload-drop">
<FileUp size={38} />
<strong></strong>
<span> ExcelCSV</span>
</div>
<div className="report-receipt-preview">
<h3></h3>
<div><span></span><strong>12 </strong></div>
<div><span></span><strong>5 </strong></div>
<div><span></span><strong>2 </strong></div>
</div>
<Textarea label="导入备注" placeholder="记录运营商工单号、回执来源或人工说明" rows={4} />
</div>
</Modal>
);
}
import { useEffect, useMemo, useState } from 'react';
import { Plus, Search } from 'lucide-react';
import { adminApi, type AdminChannel, type ChannelReportField } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, Tag, type TableColumn } from '@/components/ui';
export function AdminChannelReportPage() {
const navigate = useNavigate();
const { channelId = '88827' } = useParams();
const channelName = getChannelName(channelId);
const [reports, setReports] = useState(initialReports);
const [channels, setChannels] = useState<AdminChannel[]>([]);
const [fields, setFields] = useState<ChannelReportField[]>([]);
const [channelId, setChannelId] = useState('');
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('all');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
const [statusTarget, setStatusTarget] = useState<{ signatureId: string; drainageId?: string } | null>(null);
const [nextStatus, setNextStatus] = useState<ReportStatus>('success');
const [nextRemark, setNextRemark] = useState('');
const [detail, setDetail] = useState<ReportDetail | null>(null);
const [fieldConfigOpen, setFieldConfigOpen] = useState(false);
const [signatureFieldConfigOpen, setSignatureFieldConfigOpen] = useState(false);
const [receiptOpen, setReceiptOpen] = useState(false);
const [drainageFields, setDrainageFields] = useState(initialSelectedDrainageFields);
const [signatureFields, setSignatureFields] = useState(initialSelectedSignatureFields);
const [modalOpen, setModalOpen] = useState(false);
const [code, setCode] = useState('');
const [name, setName] = useState('');
const [fieldType, setFieldType] = useState('string');
const [description, setDescription] = useState('');
const [error, setError] = useState('');
const filteredReports = useMemo(() => reports.filter((report) => {
const matchesKeyword = !keyword || report.name.includes(keyword) || report.drainage.some((item) => item.value.includes(keyword));
const matchesStatus = status === 'all' || report.status === status;
const date = report.submittedAt.slice(0, 10);
const matchesStart = !dateRange.start || date >= dateRange.start;
const matchesEnd = !dateRange.end || date <= dateRange.end;
return matchesKeyword && matchesStatus && matchesStart && matchesEnd;
}), [dateRange.end, dateRange.start, keyword, reports, status]);
function toggleExpanded(id: string) {
setExpanded((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
function loadData(nextChannelId = channelId) {
Promise.all([adminApi.listChannels(), adminApi.listChannelReportFields(nextChannelId || undefined)])
.then(([channelItems, fieldItems]) => {
setChannels(channelItems.filter((item) => item.status !== 'deleted'));
setFields(fieldItems);
setError('');
})
.catch((failure: Error) => setError(failure.message || '通道报备配置加载失败'));
}
function toggleSelected(id: string) {
setSelectedIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
useEffect(() => {
loadData('');
}, []);
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' })
.then(() => {
setModalOpen(false);
setCode('');
setName('');
setFieldType('string');
setDescription('');
loadData();
})
.catch((failure: Error) => setError(failure.message || '报备字段保存失败'));
}
function removeItem(signatureId: string, drainageId?: string) {
setReports((items) => drainageId
? items.map((item) => item.id === signatureId ? { ...item, drainage: item.drainage.filter((flow) => flow.id !== drainageId) } : item)
: items.filter((item) => item.id !== signatureId));
}
function applyStatus() {
if (!statusTarget) return;
setReports((items) => items.map((item) => {
if (item.id !== statusTarget.signatureId) return item;
if (!statusTarget.drainageId) return { ...item, status: nextStatus, remark: nextRemark };
return { ...item, drainage: item.drainage.map((flow) => flow.id === statusTarget.drainageId ? { ...flow, status: nextStatus, remark: nextRemark } : flow) };
}));
setStatusTarget(null);
setNextRemark('');
}
function openStatus(signatureId: string, currentStatus: ReportStatus, drainageId?: string, remark = '') {
setNextStatus(currentStatus);
setNextRemark(remark);
setStatusTarget({ signatureId, drainageId });
}
const columns: Array<TableColumn<ChannelReportField>> = [
{ key: 'channel', title: '通道', width: '220px', render: (record) => channels.find((item) => item.id === record.channelId)?.name ?? record.channelId },
{ 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: 'required', title: '必填', width: '90px', render: (record) => <Tag tone={record.required ? 'warning' : 'info'}>{record.required ? '是' : '否'}</Tag> },
{ key: 'description', title: '说明', render: (record) => record.description ?? '-' },
];
return (
<section className="page-stack channel-report-page">
<div className="surface channel-report-hero">
<Breadcrumb items={[channelName]} />
<div className="channel-report-heading">
<Button icon={<ChevronLeft size={16} />} onClick={() => navigate('/admin/channels')} variant="ghost"></Button>
<h1>{channelName}</h1>
<Button icon={<ChevronRight size={16} />} variant="ghost"></Button>
<div className="channel-report-config-actions">
<Button icon={<FileUp size={16} />} onClick={() => setReceiptOpen(true)} variant="secondary"></Button>
<Button icon={<Settings2 size={16} />} onClick={() => setFieldConfigOpen(true)} variant="ghost"></Button>
<Button icon={<FileSliders size={16} />} onClick={() => setSignatureFieldConfigOpen(true)} variant="ghost"></Button>
</div>
<section className="page-stack admin-system-page admin-drainage-page">
<div className="page-heading">
<div>
<Breadcrumb items={['报备管理', '通道报备配置']} />
<h1></h1>
</div>
<Button disabled={!channelId} icon={<Plus size={16} />} onClick={() => setModalOpen(true)}></Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface channel-report-filter">
<div className="channel-report-filter-grid">
<Input label="签名或引流信息" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入签名、网址、电话或邮箱" value={keyword} />
<Select label="报备状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
<DateRangeInput label="提交报备时间" onChange={setDateRange} value={dateRange} />
</div>
<div className="channel-report-filter-footer">
<div>
<Button disabled={selectedIds.size === 0} icon={<Pencil size={16} />} onClick={() => setSelectedIds(new Set())} variant="ghost"></Button>
<Button disabled={selectedIds.size === 0} icon={<Trash2 size={16} />} onClick={() => { setReports((items) => items.filter((item) => !selectedIds.has(item.id))); setSelectedIds(new Set()); }} variant="danger"></Button>
</div>
<div>
<Button onClick={() => { setKeyword(''); setStatus('all'); setDateRange({}); }} variant="ghost"></Button>
<Button icon={<Search size={16} />}></Button>
</div>
</div>
</div>
<div className="surface channel-report-table">
<div className="channel-report-table__head">
<span />
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{filteredReports.map((report) => {
const isExpanded = expanded.has(report.id);
return (
<div className="channel-report-group" key={report.id}>
<div className="channel-report-row channel-report-row--signature">
<input aria-label={`选择${report.name}`} checked={selectedIds.has(report.id)} onChange={() => toggleSelected(report.id)} type="checkbox" />
<div className="channel-report-name">
<button aria-label={isExpanded ? '收起引流信息' : '展开引流信息'} disabled={report.drainage.length === 0} onClick={() => toggleExpanded(report.id)} type="button">
{isExpanded ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
</button>
<span><strong>{report.name}</strong><small> <b>{report.drainage.length}</b></small></span>
</div>
<Tag tone={statusMeta[report.status].tone}>{statusMeta[report.status].label}</Tag>
<DateTime value={report.submittedAt} />
<DateTime value={report.reportedAt} />
<DateTime value={report.lastSentAt} />
<Stats stats={report.stats} />
<RowActions onDelete={() => removeItem(report.id)} onStatus={() => openStatus(report.id, report.status, undefined, report.remark)} onView={() => setDetail({ kind: 'signature', report })} />
</div>
{isExpanded ? report.drainage.map((flow) => (
<div className="channel-report-row channel-report-row--drainage" key={flow.id}>
<input aria-label={`选择${flow.value}`} type="checkbox" />
<div className="channel-report-name channel-report-name--flow"><i /> <strong>{flow.value}</strong></div>
<Tag tone={statusMeta[flow.status].tone}>{statusMeta[flow.status].label}</Tag>
<DateTime value={flow.submittedAt} />
<DateTime value={flow.reportedAt} />
<DateTime value={flow.lastSentAt} />
<Stats stats={flow.stats} />
<RowActions onDelete={() => removeItem(report.id, flow.id)} onStatus={() => openStatus(report.id, flow.status, flow.id, flow.remark)} onView={() => setDetail({ kind: 'drainage', title: flow.value, ...flow })} />
</div>
)) : null}
</div>
);
})}
{filteredReports.length === 0 ? <div className="channel-report-empty"></div> : null}
</div>
{statusTarget ? (
<Modal
footer={<><Button onClick={() => setStatusTarget(null)} variant="ghost"></Button><Button onClick={applyStatus}></Button></>}
onClose={() => setStatusTarget(null)}
open
size="md"
title={<div className="channel-status-title"><h2></h2><p></p></div>}
>
<div className="channel-status-form">
<div className="channel-status-options">
{statusChoices.map((choice) => (
<button
aria-pressed={nextStatus === choice.value}
className={`${choice.className} ${nextStatus === choice.value ? 'is-selected' : ''}`}
key={choice.value}
onClick={() => setNextStatus(choice.value)}
type="button"
>
{choice.label}
{nextStatus === choice.value ? <span></span> : null}
</button>
))}
</div>
<Textarea label="备注" onChange={(event) => setNextRemark(event.target.value)} placeholder="备注内容" rows={5} value={nextRemark} />
</div>
</Modal>
) : null}
{detail?.kind === 'signature' ? <SignatureDetailModal onClose={() => setDetail(null)} report={detail.report} /> : null}
{fieldConfigOpen ? (
<DrainageFieldConfigModal
description="这些字段会驱动客户端引流资料补充,并映射到当前通道的导出模板。"
fieldPool={drainageFieldPool}
fields={drainageFields}
onChange={setDrainageFields}
onClose={() => setFieldConfigOpen(false)}
title="个性化引流信息报备字段"
<div className="surface admin-drainage-toolbar">
<Select
onChange={(event) => {
setChannelId(event.target.value);
loadData(event.target.value);
}}
options={[{ label: '全部通道', value: '' }, ...channels.map((item) => ({ label: item.name, value: item.id }))]}
value={channelId}
/>
) : null}
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索字段代码、名称或说明" prefix={<Search size={16} />} value={keyword} />
<Button icon={<Search size={16} />} onClick={() => loadData()}></Button>
</div>
{signatureFieldConfigOpen ? (
<DrainageFieldConfigModal
description="这些字段会驱动客户端签名资料补充,并映射到当前通道的签名报备导出模板。"
fieldPool={signatureFieldPool}
fields={signatureFields}
onChange={setSignatureFields}
onClose={() => setSignatureFieldConfigOpen(false)}
title="个性化签名报备字段"
/>
) : null}
<div className="surface admin-system-table-card admin-drainage-table-card">
<Table columns={columns} data={filteredFields} emptyText="暂无通道报备字段" rowKey="id" />
</div>
{receiptOpen ? <ReceiptImportModal onClose={() => setReceiptOpen(false)} onSubmit={() => setReceiptOpen(false)} /> : null}
{detail?.kind === 'drainage' ? (
<Modal footer={<Button onClick={() => setDetail(null)} variant="ghost"></Button>} onClose={() => setDetail(null)} open size="md" title="引流信息报备详情">
<div className="channel-report-detail">
<strong>{detail.title}</strong>
<p><span></span><Tag tone={statusMeta[detail.status].tone}>{statusMeta[detail.status].label}</Tag></p>
<p><span></span>{detail.submittedAt}</p>
<p><span></span>{detail.reportedAt ?? '-'}</p>
<p><span></span>{detail.lastSentAt ?? '-'}</p>
</div>
</Modal>
) : null}
<Modal
footer={<><Button onClick={() => setModalOpen(false)} variant="ghost"></Button><Button disabled={!channelId || !code || !name} 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}
/>
<Textarea label="说明" onChange={(event) => setDescription(event.target.value)} rows={4} value={description} />
</div>
</Modal>
</section>
);
}