fix: connect remaining sms pages to real backend
This commit is contained in:
@@ -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>支持 Excel、CSV。导入后会按签名、引流内容和通道匹配当前报备记录。</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user