Initial CMPP frontend prototype

This commit is contained in:
hectorzhao
2026-06-30 16:09:46 +08:00
commit 2f3c274a30
98 changed files with 25255 additions and 0 deletions
+565
View File
@@ -0,0 +1,565 @@
import { useMemo, useState } from 'react';
import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Eye,
FileSliders,
GripVertical,
Pencil,
Plus,
Search,
Settings2,
Trash2,
} from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom';
import { 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 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 initialSelectedDrainageFields: SelectedReportField[] = [
{ id: 'companyName', label: '公司名称', type: '文本', required: false, mapping: '' },
{ id: 'legalId', label: '法人身份证号', type: '文本', required: false, mapping: '' },
];
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,
onChange,
onClose,
}: {
fields: SelectedReportField[];
onChange: (fields: SelectedReportField[]) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useState(fields);
const [searchText, setSearchText] = useState('');
const availableFields = useMemo(() => drainageFieldPool.filter((field) => (
!draft.some((item) => item.id === field.id)
&& (!searchText || field.label.includes(searchText))
)), [draft, 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></h2><p></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>
);
}
export function AdminChannelReportPage() {
const navigate = useNavigate();
const { channelId = '88827' } = useParams();
const [reports, setReports] = useState(initialReports);
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('all');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(['sig-1']));
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 [drainageFields, setDrainageFields] = useState(initialSelectedDrainageFields);
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 toggleSelected(id: string) {
setSelectedIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
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 });
}
return (
<section className="page-stack channel-report-page">
<div className="surface channel-report-hero">
<div className="breadcrumb-line"><span></span><span>/</span><strong></strong></div>
<div className="channel-report-heading">
<Button icon={<ChevronLeft size={16} />} onClick={() => navigate('/admin/channels')} variant="ghost"></Button>
<h1>{channelNames[channelId] ?? `短信通道 ${channelId}`}</h1>
<Button icon={<ChevronRight size={16} />} variant="ghost"></Button>
<div className="channel-report-config-actions">
<Button icon={<Settings2 size={16} />} onClick={() => setFieldConfigOpen(true)} variant="ghost"></Button>
<Button icon={<FileSliders size={16} />} variant="ghost"></Button>
</div>
</div>
</div>
<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
fields={drainageFields}
onChange={setDrainageFields}
onClose={() => setFieldConfigOpen(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}
</section>
);
}