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
+114 -354
View File
@@ -1,400 +1,160 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { CalendarDays, Check, Search, X } from 'lucide-react';
import { adminApi, type RiskReviewTask } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
type SmsAuditStatus = 'pending' | 'approved' | 'rejected';
type SmsAuditRecord = {
id: string;
customer: string;
industry: string;
application: string;
submittedAt: string;
content: string;
chars: number;
billCount: number;
phoneCount: number;
reasons: string[];
status: SmsAuditStatus;
reviewedAt?: string;
const statusLabel: Record<string, string> = {
pending_review: '待审核',
approved: '已通过',
rejected: '已驳回',
};
type PhoneRecord = {
id: number;
phone: string;
location: string;
carrier: string;
const statusTone: Record<string, 'warning' | 'success' | 'danger'> = {
pending_review: 'warning',
approved: 'success',
rejected: 'danger',
};
const initialSmsAudits: SmsAuditRecord[] = [
{
id: 'SMS-20250106-001',
customer: '广州XXXXX有限公司',
industry: 'XXXXXXXX行业',
application: '应用名称示例',
submittedAt: '2025-01-06 09:02:28',
content: '【深圳市北健科技有限公司】KH58户主,您录名的您好您好。1)童套充不要禁运控。2)禁止去速1200m左右的来水,收费收在2;3)恐怕接待,我接待您恐来子。单,现现确下里况要可图的注明技术想上,统统统他只有禁运信息;最可以请点仙应出他有记。',
chars: 300,
billCount: 1,
phoneCount: 256,
reasons: ['模板', '签名', '引流信息'],
status: 'pending',
},
{
id: 'SMS-20250105-001',
customer: '上海XXXXX有限公司',
industry: 'XXXXXXXX行业',
application: '客户通知服务',
submittedAt: '2025-01-05 08:02:28',
content: '【某某公司】尊敬的客户您好,您好。感谢您选择光纤宽带!如遇问题请联系客服...',
chars: 100,
billCount: 1,
phoneCount: 156,
reasons: ['模板', '签名', '引流信息'],
status: 'approved',
reviewedAt: '2026-01-12 17:27:28',
},
{
id: 'SMS-20250105-002',
customer: '上海XXXXX有限公司',
industry: 'XXXXXXXX行业',
application: '会员通知',
submittedAt: '2025-01-05 08:02:28',
content: '【某某公司】尊敬的客户您好,您好。',
chars: 50,
billCount: 1,
phoneCount: 2762,
reasons: ['模板', '签名'],
status: 'approved',
reviewedAt: '2026-01-12 17:27:28',
},
{
id: 'SMS-20250105-003',
customer: '北京XXXXX有限公司',
industry: 'XXXXXXXX营销',
application: '营销推广平台',
submittedAt: '2025-01-05 08:02:28',
content: '【大童芝】签住链,应控计 移奇亭某间遇道进使用请您动幼时此从此出收在2026-01-06 17:00-17:30至取到交投买元画山点。',
chars: 200,
billCount: 1,
phoneCount: 256,
reasons: ['模板'],
status: 'pending',
},
];
const rejectReasons = ['内容不发', '模板未提交', '签名未报备', '引流未报备完成', '内容包含敏感词', '号码格式错误', '缺少必要信息', '违反运营商规定', '签名与内容不符', '模板变量不匹配'];
const initialPhoneRows: PhoneRecord[] = [
{ id: 1, phone: '188xxxx9999', location: '河南 信阳', carrier: '移动' },
{ id: 2, phone: '133xxxx1111', location: '河南 信阳', carrier: '移动' },
{ id: 3, phone: '134xxxx2222', location: '河南 信阳', carrier: '移动' },
{ id: 4, phone: '156xxxxyyyy', location: '山东 青岛', carrier: '联通' },
{ id: 5, phone: '178625xxxxx', location: '山东 青岛', carrier: '联通' },
{ id: 6, phone: '190xxxxyyyy', location: '山东 青岛', carrier: '联通' },
{ id: 7, phone: '190xxxxyyyy', location: '山东 青岛', carrier: '联通' },
{ id: 8, phone: '190xxxxyyyy', location: '山东 青岛', carrier: '电信' },
{ id: 9, phone: '177xxxx5555', location: '北京', carrier: '移动' },
{ id: 10, phone: '189xxxx6666', location: '上海', carrier: '电信' },
];
function SmsEditModal({
record,
onClose,
onSubmit,
}: {
record: SmsAuditRecord;
onClose: () => void;
onSubmit: (content: string) => void;
}) {
const [content, setContent] = useState(record.content);
const billingCount = Math.max(1, Math.ceil(content.length / 67));
return (
<Modal
footer={(
<>
<Button onClick={onClose} variant="ghost"></Button>
<Button onClick={() => onSubmit(content)}></Button>
</>
)}
onClose={onClose}
open
size="xl"
title={<div className="template-modal-title"><h2></h2><p></p></div>}
>
<div className="sms-audit-edit">
<div className="sms-audit-edit__meta">
<span><strong>{record.customer}</strong></span>
<span><strong>{record.application}</strong></span>
<span><strong>{record.submittedAt}</strong></span>
</div>
<Textarea label="短信内容" onChange={(event) => setContent(event.target.value)} rows={9} value={content} />
<div className="sms-audit-edit__count">
<span> {content.length} </span>
<strong> {billingCount} </strong>
</div>
</div>
</Modal>
);
}
function SmsRejectModal({
record,
onClose,
onSubmit,
}: {
record: SmsAuditRecord;
onClose: () => void;
onSubmit: (reason: string) => void;
}) {
const [reason, setReason] = useState('');
function appendReason(nextReason: string) {
setReason((current) => (current ? `${current}${nextReason}` : nextReason));
}
return (
<Modal
footer={(
<>
<Button onClick={() => onSubmit(reason)} disabled={!reason.trim()}></Button>
<Button onClick={onClose} variant="ghost"></Button>
</>
)}
onClose={onClose}
open
title={<div className="template-modal-title"><h2></h2><p></p></div>}
>
<div className="sms-reject-modal">
<Textarea label="* 驳回原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入驳回原因" rows={4} value={reason} />
<div>
<strong></strong>
<div className="reject-reason-tags">
{rejectReasons.map((item) => (
<button key={item} onClick={() => appendReason(item)} type="button">{item}</button>
))}
</div>
</div>
<button className="audit-collapse-link" type="button"></button>
<p className="muted">{record.customer}</p>
</div>
</Modal>
);
}
function PhoneListModal({
record,
onClose,
}: {
record: SmsAuditRecord;
onClose: () => void;
}) {
const [keyword, setKeyword] = useState('');
const phoneRows = useMemo(
() => initialPhoneRows.filter((item) => !keyword || item.phone.includes(keyword)),
[keyword],
);
const columns: Array<TableColumn<PhoneRecord>> = [
{ key: 'id', title: '序号', width: '120px', render: (item) => item.id },
{ key: 'phone', title: '手机号码', render: (item) => <strong>{item.phone}</strong> },
{ key: 'location', title: '号码归属地', render: (item) => item.location },
{ key: 'carrier', title: '运营商', render: (item) => item.carrier },
];
return (
<Modal
footer={<Button onClick={onClose} variant="ghost"></Button>}
onClose={onClose}
open
size="xl"
title={<div className="template-modal-title"><h2></h2><p>{record.customer} {record.phoneCount} </p></div>}
>
<div className="phone-list-modal">
<Input
autoFocus
onChange={(event) => setKeyword(event.target.value)}
placeholder="手机号"
prefix={<Search size={18} />}
value={keyword}
/>
<div className="phone-list-toolbar">
<Select
options={[
{ label: '10条/页', value: '10' },
{ label: '20条/页', value: '20' },
{ label: '50条/页', value: '50' },
]}
value="10"
/>
<div>
<Button disabled size="sm" variant="ghost"></Button>
<Button size="sm" variant="ghost">24</Button>
<Button size="sm" variant="secondary">25</Button>
<Button size="sm" variant="ghost">26</Button>
<span>...</span>
<Button size="sm" variant="ghost">63</Button>
<Button size="sm" variant="ghost"></Button>
</div>
</div>
<div className="phone-list-table">
<Table columns={columns} data={phoneRows} rowKey="id" />
</div>
</div>
</Modal>
);
}
export function AdminSmsAuditPage() {
const [records, setRecords] = useState(initialSmsAudits);
const [records, setRecords] = useState<RiskReviewTask[]>([]);
const [keyword, setKeyword] = useState('');
const [company, setCompany] = useState('');
const [application, setApplication] = useState('');
const [status, setStatus] = useState('pending_review');
const [date, setDate] = useState('');
const [editRecord, setEditRecord] = useState<SmsAuditRecord | null>(null);
const [rejectRecord, setRejectRecord] = useState<SmsAuditRecord | null>(null);
const [phoneListRecord, setPhoneListRecord] = useState<SmsAuditRecord | null>(null);
const [error, setError] = useState('');
const [approveTarget, setApproveTarget] = useState<RiskReviewTask | 'batch' | null>(null);
const [rejectTarget, setRejectTarget] = useState<RiskReviewTask | null>(null);
const [rejectReason, setRejectReason] = useState('');
function loadData() {
adminApi.listRiskReviewTasks({ status: status === 'all' ? undefined : status })
.then((items) => {
setRecords(items);
setError('');
})
.catch((failure: Error) => setError(failure.message || '短信审核任务加载失败'));
}
useEffect(() => {
loadData();
}, [status]);
const filteredRecords = useMemo(
() => records.filter((record) => {
const matchesCompany = !company || record.customer === company;
const matchesApplication = !application || record.application === application;
const matchesKeyword = !keyword || record.content.includes(keyword);
const matchesDate = !date || record.submittedAt.startsWith(date);
return matchesCompany && matchesApplication && matchesKeyword && matchesDate;
const matchesKeyword = !keyword || [record.taskNo, record.content, record.reviewReason, record.rejectReason].join(' ').includes(keyword);
const matchesDate = !date || record.createdAt.startsWith(date);
return matchesKeyword && matchesDate;
}),
[application, company, date, keyword, records],
[date, keyword, records],
);
function updateStatus(id: string, status: SmsAuditStatus) {
setRecords((items) => items.map((item) => (
item.id === id ? { ...item, status, reviewedAt: status === 'pending' ? undefined : '2026-01-12 17:27:28' } : item
)));
async function approveRecord(record: RiskReviewTask) {
await adminApi.approveRiskReviewTask(record.id, '运营审核通过');
setApproveTarget(null);
loadData();
}
function updateContent(content: string) {
if (!editRecord) return;
setRecords((items) => items.map((item) => (
item.id === editRecord.id ? { ...item, content, chars: content.length, billCount: Math.max(1, Math.ceil(content.length / 67)) } : item
)));
setEditRecord(null);
async function approveBatch() {
await Promise.all(filteredRecords.filter((item) => item.status === 'pending_review').map((item) => adminApi.approveRiskReviewTask(item.id, '运营批量审核通过')));
setApproveTarget(null);
loadData();
}
function rejectRecordWithReason() {
if (!rejectRecord) return;
updateStatus(rejectRecord.id, 'rejected');
setRejectRecord(null);
async function rejectRecord() {
if (!rejectTarget) return;
await adminApi.rejectRiskReviewTask(rejectTarget.id, rejectReason || '运营审核驳回');
setRejectTarget(null);
setRejectReason('');
loadData();
}
const columns: Array<TableColumn<RiskReviewTask>> = [
{ key: 'taskNo', title: '任务编号', width: '180px', render: (record) => <strong>{record.taskNo}</strong> },
{ key: 'content', title: '短信内容', render: (record) => <span className="table-long-text">{record.content}</span> },
{ key: 'phoneTotal', title: '号码数', width: '110px', render: (record) => record.phoneTotal.toLocaleString('zh-CN') },
{ key: 'createdAt', title: '提交时间', width: '190px', render: (record) => record.createdAt },
{ key: 'reason', title: '审核原因', render: (record) => record.reviewReason ?? record.rejectReason ?? record.riskHits?.map((item) => item.reason).join('') ?? '-' },
{
key: 'status',
title: '状态',
width: '110px',
render: (record) => <Tag tone={statusTone[record.status] ?? 'warning'}>{statusLabel[record.status] ?? record.status}</Tag>,
},
{
key: 'actions',
title: '操作',
width: '160px',
align: 'right',
render: (record) => record.status === 'pending_review' ? (
<div className="audit-actions">
<Button icon={<Check size={15} />} onClick={() => setApproveTarget(record)} size="sm" variant="success"></Button>
<Button icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger"></Button>
</div>
) : null,
},
];
return (
<section className="page-stack admin-audit-page">
<Breadcrumb items={['审核中心', '短信审核']} />
{error ? <p className="form-error">{error}</p> : null}
<div className="surface sms-audit-filter">
<h2></h2>
<div className="audit-filter-grid audit-filter-grid--sms">
<Select
label="企业"
onChange={(event) => setCompany(event.target.value)}
options={[{ label: '请选择企业', value: '' }, ...Array.from(new Set(records.map((item) => item.customer))).map((item) => ({ label: item, value: item }))]}
value={company}
label="审核状态"
onChange={(event) => setStatus(event.target.value)}
options={[
{ label: '全部状态', value: 'all' },
{ label: '待审核', value: 'pending_review' },
{ label: '已通过', value: 'approved' },
{ label: '已驳回', value: 'rejected' },
]}
value={status}
/>
<Select
disabled={!company}
label="应用"
onChange={(event) => setApplication(event.target.value)}
options={[{ label: company ? '请选择应用' : '请先选择企业', value: '' }, ...Array.from(new Set(records.filter((item) => !company || item.customer === company).map((item) => item.application))).map((item) => ({ label: item, value: item }))]}
value={application}
/>
<Input label="短信内容" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入短信内容关键词" value={keyword} />
<Input label="短信内容" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入任务编号、内容或审核原因" value={keyword} />
<Input label="提交日期" onChange={(event) => setDate(event.target.value)} placeholder="yyyy-mm-dd" prefix={<CalendarDays size={16} />} value={date} />
<div className="audit-filter-actions">
<Button icon={<Search size={17} />}></Button>
<Button onClick={() => { setCompany(''); setApplication(''); setKeyword(''); setDate(''); }} variant="ghost"></Button>
<Button icon={<Search size={17} />} onClick={loadData}></Button>
<Button onClick={() => { setKeyword(''); setDate(''); setStatus('pending_review'); }} variant="ghost"></Button>
</div>
</div>
<div className="sms-bulk-actions">
<span></span>
<Button icon={<Check size={16} />} onClick={() => setRecords((items) => items.map((item) => ({ ...item, status: 'approved', reviewedAt: '2026-01-12 17:27:28' })))} variant="secondary"></Button>
<Button icon={<X size={16} />} onClick={() => setRejectRecord(filteredRecords[0] ?? null)} variant="danger"></Button>
</div>
</div>
<div className="sms-audit-toolbar">
<span>10/</span>
<div>
<Button disabled size="sm" variant="ghost"></Button>
<Button size="sm" variant="secondary">1</Button>
<Button size="sm" variant="ghost">2</Button>
<Button size="sm" variant="ghost">63</Button>
<Button size="sm" variant="ghost"></Button>
<Button disabled={filteredRecords.every((item) => item.status !== 'pending_review')} icon={<Check size={16} />} onClick={() => setApproveTarget('batch')} variant="success"></Button>
</div>
</div>
<div className="surface sms-audit-list">
<div className="sms-audit-head">
<span><input aria-label="全选" type="checkbox" /></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{filteredRecords.map((record) => (
<article className="sms-audit-row" key={record.id}>
<span><input aria-label={`选择 ${record.id}`} type="checkbox" /></span>
<div className="sms-audit-customer">
<strong>{record.customer}</strong>
<span>{record.industry}</span>
<small>{record.submittedAt} </small>
</div>
<p className="sms-audit-content">{record.content}</p>
<div className="sms-audit-count">
<span><strong>{record.chars}</strong></span>
<span><strong>{record.billCount}</strong></span>
<span><a>{record.phoneCount}</a></span>
<button onClick={() => setPhoneListRecord(record)} type="button"></button>
</div>
<div className="sms-audit-reasons">
{record.reasons.map((reason) => <Tag key={reason} tone={reason === '模板' ? 'success' : 'neutral'}>{reason}</Tag>)}
</div>
<div className="sms-audit-result">
{record.status === 'pending' ? <span className="audit-dot audit-dot--warning"></span> : null}
{record.status === 'approved' ? <span className="audit-dot audit-dot--success"></span> : null}
{record.status === 'rejected' ? <span className="audit-dot audit-dot--danger"></span> : null}
{record.reviewedAt ? <small>{record.reviewedAt}</small> : null}
</div>
<div className="sms-audit-actions">
<Button onClick={() => updateStatus(record.id, 'approved')} size="sm" variant="secondary"></Button>
<Button onClick={() => setRejectRecord(record)} size="sm" variant="danger"></Button>
<Button onClick={() => setEditRecord(record)} size="sm" variant="ghost"></Button>
</div>
</article>
))}
<Table columns={columns} data={filteredRecords} emptyText="暂无短信审核任务" rowKey="id" />
</div>
{editRecord ? (
<SmsEditModal
onClose={() => setEditRecord(null)}
onSubmit={updateContent}
record={editRecord}
/>
) : null}
<Modal
footer={(
<>
<Button onClick={() => setApproveTarget(null)} variant="ghost"></Button>
<Button onClick={() => approveTarget === 'batch' ? void approveBatch() : approveTarget ? void approveRecord(approveTarget) : undefined} variant="success"></Button>
</>
)}
onClose={() => setApproveTarget(null)}
open={Boolean(approveTarget)}
title="确认通过"
>
<p>{approveTarget === 'batch' ? `确认通过 ${filteredRecords.filter((item) => item.status === 'pending_review').length} 条待审核任务?` : '确认通过该短信审核任务?'}</p>
</Modal>
{rejectRecord ? (
<SmsRejectModal
onClose={() => setRejectRecord(null)}
onSubmit={rejectRecordWithReason}
record={rejectRecord}
/>
) : null}
{phoneListRecord ? (
<PhoneListModal
onClose={() => setPhoneListRecord(null)}
record={phoneListRecord}
/>
) : null}
<Modal
footer={(
<>
<Button onClick={() => setRejectTarget(null)} variant="ghost"></Button>
<Button disabled={!rejectReason.trim()} onClick={() => void rejectRecord()} variant="danger"></Button>
</>
)}
onClose={() => setRejectTarget(null)}
open={Boolean(rejectTarget)}
title="确认驳回"
>
<Textarea label="驳回原因" onChange={(event) => setRejectReason(event.target.value)} rows={4} value={rejectReason} />
</Modal>
</section>
);
}