fix: harden admin and CMPP delivery workflows

This commit is contained in:
hectorzhao
2026-07-15 11:21:02 +08:00
parent 00b6d95752
commit e47432bc9d
34 changed files with 878 additions and 226 deletions
+1 -4
View File
@@ -13,7 +13,6 @@ type CustomerRow = TenantManagementRow;
type RechargeForm = {
amount: string;
operator: string;
remark: string;
};
@@ -28,7 +27,6 @@ function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCan
function emptyRechargeForm(): RechargeForm {
return {
amount: '',
operator: '运营',
remark: '',
};
}
@@ -131,7 +129,7 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
await adminApi.createManualRecharge({
tenantId: rechargeTarget.id,
amountCents: Math.round(amount * 100),
remark: [rechargeForm.operator, rechargeForm.remark].filter(Boolean).join(' / '),
remark: rechargeForm.remark,
});
setRechargeTarget(null);
setRechargeForm(emptyRechargeForm());
@@ -210,7 +208,6 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
<Input disabled label="企业名称" value={rechargeTarget.name} />
<Input disabled label="当前余额" prefix="¥" value={formatCents(rechargeTarget.account?.balanceCents ?? 0)} />
<Input label="充值金额" onChange={(event) => updateRechargeForm('amount', event.target.value)} prefix="¥" required type="number" value={rechargeForm.amount} />
<Input label="操作人" onChange={(event) => updateRechargeForm('operator', event.target.value)} value={rechargeForm.operator} />
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateRechargeForm('remark', event.target.value)} rows={4} value={rechargeForm.remark} />
</div>
{rechargeError ? <p className="form-error">{rechargeError}</p> : null}
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertTriangle, BarChart3, CheckCircle2, Eye, RefreshCw, Search, TimerReset } from 'lucide-react';
import { adminApi, type DownstreamDeliveryDashboard, type DownstreamDeliveryRecord, type EnterpriseApplication } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag } from '@/components/ui';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
@@ -14,7 +14,6 @@ const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'd
};
const statusLabel: Record<string, string> = {
pending: '待首次投递',
awaiting_ack: '等待客户端确认',
delivered: '客户端已确认',
failed: '投递失败',
@@ -22,6 +21,13 @@ const statusLabel: Record<string, string> = {
rejected: '客户端拒绝',
};
function deliveryStatusLabel(record: DownstreamDeliveryRecord) {
if (record.status !== 'pending') return statusLabel[record.status] ?? record.status;
if (record.manualRetryCount > 0) return '人工重投排队中';
if (record.retryCount > 0) return '等待自动重试';
return '待首次投递';
}
const deliveryTypeLabel: Record<string, string> = {
receipt: '状态回执',
uplink: '上行短信',
@@ -43,9 +49,11 @@ function DeliveryDetailModal({ record, onClose }: { record: DownstreamDeliveryRe
<div><span></span><strong>{record.tenant?.name ?? record.tenantId}</strong></div>
<div><span></span><strong>{record.application?.name ?? record.applicationId}</strong></div>
<div><span></span><strong>{deliveryTypeLabel[record.deliveryType] ?? record.deliveryType}</strong></div>
<div><span></span><strong>{statusLabel[record.status] ?? record.status}</strong></div>
<div><span></span><strong>{deliveryStatusLabel(record)}</strong></div>
<div><span> ID</span><strong>{record.messageId ?? '-'}</strong></div>
<div><span></span><strong>{record.retryCount}</strong></div>
<div><span></span><strong>{record.retryCount}</strong></div>
<div><span></span><strong>{record.manualRetryCount}</strong></div>
<div><span></span><strong>{record.lastRetriedAt ? formatDateTime(record.lastRetriedAt) : '-'}</strong></div>
<div><span></span><strong>{record.nextRetryAt ?? '-'}</strong></div>
<div><span></span><strong>{record.retryEnabled ? '开启' : '关闭'}</strong></div>
<div><span></span><strong>{record.sentAt ?? '-'}</strong></div>
@@ -80,6 +88,7 @@ export function AdminDownstreamDeliveriesPage() {
const [error, setError] = useState('');
const [detail, setDetail] = useState<DownstreamDeliveryRecord | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const loadData = useCallback(() => {
setLoading(true);
@@ -87,6 +96,8 @@ export function AdminDownstreamDeliveriesPage() {
adminApi.getDownstreamDeliveryDashboard({
applicationId,
deliveryType,
createdAtFrom: dateRange.start,
createdAtTo: dateRange.end,
}),
adminApi.listDownstreamDeliveries({
keyword,
@@ -95,6 +106,8 @@ export function AdminDownstreamDeliveriesPage() {
applicationId,
page,
pageSize,
createdAtFrom: dateRange.start,
createdAtTo: dateRange.end,
}),
adminApi.listEnterpriseApplications(),
])
@@ -108,7 +121,7 @@ export function AdminDownstreamDeliveriesPage() {
})
.catch((failure: Error) => setError(failure.message || '下游投递记录加载失败'))
.finally(() => setLoading(false));
}, [applicationId, deliveryType, keyword, page, pageSize, status]);
}, [applicationId, dateRange.end, dateRange.start, deliveryType, keyword, page, pageSize, status]);
useEffect(() => {
loadData();
@@ -182,6 +195,7 @@ export function AdminDownstreamDeliveriesPage() {
<div className="surface admin-task-filter">
<Input label="消息ID / 账号 / 手机号 / 错误" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
<DateRangeInput label="创建日期" onChange={(value) => { setDateRange(value); setPage(1); }} value={dateRange} />
<Select
label="状态"
options={[
@@ -232,6 +246,7 @@ export function AdminDownstreamDeliveriesPage() {
setStatus('all');
setDeliveryType('all');
setApplicationId('all');
setDateRange({});
setPage(1);
}}
variant="ghost"
@@ -379,11 +394,11 @@ export function AdminDownstreamDeliveriesPage() {
<strong>{record.messageId ?? '-'}</strong>
</div>
<div role="cell">
<Tag tone={statusTone[record.status] ?? 'info'}>{statusLabel[record.status] ?? record.status}</Tag>
<Tag tone={statusTone[record.status] ?? 'info'}>{deliveryStatusLabel(record)}</Tag>
</div>
<div className="downstream-delivery-list__retry" role="cell">
<strong>{record.retryCount}</strong>
<span></span>
<span> / {record.manualRetryCount} </span>
</div>
<div className={`downstream-delivery-list__error${record.lastError ? '' : ' is-empty'}`} role="cell" title={record.lastError ?? undefined}>
{record.lastError ?? '无'}
+25 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Plus, Search } from 'lucide-react';
import { Plus, Search, Trash2 } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, Tag, type TableColumn } from '@/components/ui';
import { adminApi, type DictionaryItem } from '@/api/adminApi';
@@ -9,6 +9,7 @@ type DrainageField = DictionaryItem & {
fieldType?: string;
required?: boolean;
description?: string | null;
usageCount?: number;
};
type ReportFieldType = 'string' | 'image' | 'file';
@@ -34,6 +35,7 @@ export function AdminDrainageFieldsPage() {
const [fieldType, setFieldType] = useState<ReportFieldType>('string');
const [description, setDescription] = useState('');
const [error, setError] = useState('');
const [deleteTarget, setDeleteTarget] = useState<DrainageField | null>(null);
const codeError = code && !/^[A-Za-z0-9]+$/.test(code) ? '字段代码只能包含阿拉伯数字和英文大小写字母' : '';
function loadData() {
@@ -71,12 +73,24 @@ export function AdminDrainageFieldsPage() {
.catch((failure: Error) => setError(failure.message || '报备字段新增失败'));
}
function deleteField() {
if (!deleteTarget || (deleteTarget.usageCount ?? 0) > 0) return;
adminApi.deleteDrainageField(deleteTarget.id)
.then(() => {
setDeleteTarget(null);
loadData();
})
.catch((failure: Error) => setError(failure.message || '报备字段删除失败'));
}
const columns = useMemo<Array<TableColumn<DrainageField>>>(() => [
{ key: 'code', title: '字段代码', width: '160px', render: (record) => <strong>{record.code}</strong> },
{ key: 'name', title: '字段名称', width: '190px', render: (record) => record.name ?? '-' },
{ key: 'type', title: '字段类型', width: '160px', render: (record) => <span className="admin-drainage-type">{typeLabels[record.fieldType ?? ''] ?? record.fieldType}</span> },
{ key: 'description', title: '描述', render: (record) => record.description ?? '-' },
{ key: 'required', title: '是否必填', width: '120px', render: (record) => <Tag tone={record.required ? 'warning' : 'info'}>{record.required ? '必填' : '选填'}</Tag> },
{ key: 'usageCount', title: '使用通道数', width: '130px', render: (record) => <Tag tone={(record.usageCount ?? 0) > 0 ? 'warning' : 'neutral'}>{record.usageCount ?? 0}</Tag> },
{ key: 'actions', title: '操作', width: '120px', align: 'right', render: (record) => <Button disabled={(record.usageCount ?? 0) > 0} icon={<Trash2 size={14} />} onClick={() => setDeleteTarget(record)} size="sm" variant="danger"></Button> },
], []);
return (
@@ -132,6 +146,16 @@ export function AdminDrainageFieldsPage() {
/>
</div>
</Modal>
{deleteTarget ? (
<Modal
footer={<><Button onClick={() => setDeleteTarget(null)} variant="ghost"></Button><Button onClick={deleteField} variant="danger"></Button></>}
onClose={() => setDeleteTarget(null)}
open
title="删除报备字段"
>
<p>{deleteTarget.name}使</p>
</Modal>
) : null}
</section>
);
}
@@ -202,6 +202,7 @@ function CmppConnectionModal({
onClose: () => void;
}) {
const activeConnections = app.cmppConnections.filter((item) => item.state === 'open').length;
const activeConnectionItems = app.cmppConnections.filter((item) => item.state === 'open');
return (
<Modal
@@ -230,8 +231,8 @@ function CmppConnectionModal({
{ key: 'lastSubmitAt', title: '上次提交', width: '180px', render: (record: CmppConnection) => record.lastSubmitAt },
{ key: 'pendingWindow', title: '窗口占用', align: 'right', width: '120px', render: (record: CmppConnection) => record.pendingWindow },
]}
data={app.cmppConnections}
emptyText="暂无CMPP连接"
data={activeConnectionItems}
emptyText="当前暂无已连接的 CMPP 会话"
rowKey="id"
/>
</div>
@@ -523,7 +523,7 @@ function DrainageFormModal({ applicationId, item, onClose, onSubmit }: { applica
<section>
<h3></h3>
<Input
label="* 引流信息"
label="* 引流网址"
onChange={(event) => update('url', event.target.value)}
placeholder="请输入引流网址"
required
@@ -538,8 +538,7 @@ function DrainageFormModal({ applicationId, item, onClose, onSubmit }: { applica
</ol>
</div>
<div className="signature-form-grid">
<Input label="* 站名称" onChange={(event) => update('siteName', event.target.value)} placeholder="请输入站名称" value={form.siteName} />
<Input label="提交时间" onChange={(event) => update('submittedAt', event.target.value)} value={form.submittedAt} />
<Input label="* 引流信息" onChange={(event) => update('siteName', event.target.value)} placeholder="请输入引流信息" value={form.siteName} />
<Textarea className="signature-form-grid__wide" label="备注" onChange={(event) => update('remark', event.target.value)} rows={4} value={form.remark} />
</div>
<DynamicReportFields fields={reportFields} onChange={updateReportValue} title="应用通道引流信息报备资料" values={form.reportValues} />
@@ -605,12 +604,11 @@ function DrainageReportModal({ item, onClose, signature }: { item: DrainageInfo;
return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title="引流信息报备详情">
<div className="detail-grid">
<div><span></span><strong>{item.siteName}</strong></div>
<div><span></span><strong>{item.siteName}</strong></div>
<div><span></span><strong>{item.url}</strong></div>
<div><span></span><CarrierReportTag summary={summary?.mobile} /></div>
<div><span></span><CarrierReportTag summary={summary?.unicom} /></div>
<div><span></span><CarrierReportTag summary={summary?.telecom} /></div>
<div><span></span><strong>{item.submittedAt}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{item.remark || '-'}</strong></div>
</div>
<div className="page-stack" style={{ marginTop: 16 }}>{targets.map((target) => <div className="surface" key={target.channelId} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}{target.channel.carrier}</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
@@ -816,13 +814,12 @@ export function AdminEnterpriseSignaturesPage() {
{visibleDrainageLinks.length ? (
<div className="drainage-table">
<div className="drainage-table__head">
<span></span>
<span></span>
<span>URL</span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{visibleDrainageLinks.map((item) => {
@@ -835,7 +832,6 @@ export function AdminEnterpriseSignaturesPage() {
<CarrierReportTag summary={summary?.mobile} />
<CarrierReportTag summary={summary?.unicom} />
<CarrierReportTag summary={summary?.telecom} />
<span className="muted">{item.submittedAt}</span>
<span className="drainage-row-actions">
<Button onClick={() => setDrainageReport({ signature, item })} size="sm" variant="ghost"></Button>
<Button disabled={item.auditStatus !== 'approved'} onClick={() => setDrainageStatusTarget({ signature, item })} size="sm" variant="ghost"></Button>
@@ -884,7 +880,7 @@ export function AdminEnterpriseSignaturesPage() {
<Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} />
<Input label="企业应用" onChange={(event) => setApplicationKeyword(event.target.value)} placeholder="请输入企业应用名称" prefix={<Search size={16} />} value={applicationKeyword} />
<Input label="签名名称" onChange={(event) => setSignatureKeyword(event.target.value)} placeholder="请输入签名名称或用途" prefix={<Search size={16} />} value={signatureKeyword} />
<Input label="引流信息" onChange={(event) => setDrainageKeyword(event.target.value)} placeholder="请输入站名称、URL 或备注" prefix={<Search size={16} />} value={drainageKeyword} />
<Input label="引流信息" onChange={(event) => setDrainageKeyword(event.target.value)} placeholder="请输入引流信息、URL 或备注" prefix={<Search size={16} />} value={drainageKeyword} />
<div className="admin-split-filter__actions">
<Button icon={<Search size={16} />} onClick={() => {
const filters = { enterpriseKeyword: enterpriseKeyword.trim(), applicationKeyword: applicationKeyword.trim(), signatureKeyword: signatureKeyword.trim(), drainageKeyword: drainageKeyword.trim() };
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Edit3, Eye, Plus, Search, Trash2 } from 'lucide-react';
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type TenantOption } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
@@ -84,6 +84,7 @@ function TemplateFormModal({
}) {
const [customVariable, setCustomVariable] = useState('');
const [variablesOpen, setVariablesOpen] = useState(false);
const contentRef = useRef<HTMLTextAreaElement>(null);
const [form, setForm] = useState<TemplateFormState>({
tenantId: item?.tenantId ?? '',
applicationId: item?.applicationId ?? '',
@@ -110,7 +111,15 @@ function TemplateFormModal({
if (!normalized) {
return;
}
setContent(`${form.content}\${${normalized}}`);
const token = `\${${normalized}}`;
const textarea = contentRef.current;
const start = textarea?.selectionStart ?? form.content.length;
const end = textarea?.selectionEnd ?? start;
setContent(`${form.content.slice(0, start)}${token}${form.content.slice(end)}`);
requestAnimationFrame(() => {
contentRef.current?.focus();
contentRef.current?.setSelectionRange(start + token.length, start + token.length);
});
}
function updateVariableExample(name: string, example: string) {
@@ -170,6 +179,7 @@ function TemplateFormModal({
placeholder="例如:尊敬的${name},您的验证码为${code}。"
required
rows={8}
ref={contentRef}
value={form.content}
/>
<div className="template-form-meta">
+23 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Database, ListFilter, Plus, RotateCcw, Search } from 'lucide-react';
import { Database, ListFilter, Plus, RotateCcw, Search, Trash2 } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tabs, type TableColumn } from '@/components/ui';
import { adminApi, type DictionaryItem } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
@@ -42,6 +42,7 @@ export function AdminPhoneSegmentsPage() {
const [page, setPage] = useState(1);
const [rulePage, setRulePage] = useState(1);
const [reloadKey, setReloadKey] = useState(0);
const [deleteTarget, setDeleteTarget] = useState<PhoneSegment | null>(null);
useEffect(() => {
let cancelled = false;
@@ -109,12 +110,23 @@ export function AdminPhoneSegmentsPage() {
setRulePage(1);
}
function deleteSegment() {
if (!deleteTarget) return;
adminApi.deletePhoneSegment(deleteTarget.id)
.then(() => {
setDeleteTarget(null);
setReloadKey((current) => current + 1);
})
.catch((failure: Error) => setError(failure.message || '手机号段删除失败'));
}
const columns = useMemo<Array<TableColumn<PhoneSegment>>>(() => [
{ key: 'segment', title: '手机号段(手机号码前7位)', width: '230px', render: (record) => <strong>{record.prefix}</strong> },
{ key: 'carrier', title: '运营商', width: '150px', render: (record) => record.carrier ?? '-' },
{ key: 'province', title: '省份', width: '140px', render: (record) => record.province ?? '-' },
{ key: 'city', title: '城市', width: '140px', render: (record) => record.city ?? '-' },
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => formatDateTime(record.createdAt) },
{ key: 'actions', title: '操作', width: '120px', align: 'right', render: (record) => <Button icon={<Trash2 size={14} />} onClick={() => setDeleteTarget(record)} size="sm" variant="danger"></Button> },
], []);
const ruleColumns = useMemo<Array<TableColumn<CarrierRule>>>(() => [
@@ -261,6 +273,16 @@ export function AdminPhoneSegmentsPage() {
<Input label="备注" onChange={(event) => setRuleRemark(event.target.value)} value={ruleRemark} />
</div>
</Modal>
{deleteTarget ? (
<Modal
footer={<><Button onClick={() => setDeleteTarget(null)} variant="ghost"></Button><Button onClick={deleteSegment} variant="danger"></Button></>}
onClose={() => setDeleteTarget(null)}
open
title="删除手机号段"
>
<p>{deleteTarget.prefix}使</p>
</Modal>
) : null}
</section>
);
}
+3 -5
View File
@@ -8,7 +8,6 @@ import { formatAmount } from '@/utils/currency';
type ManualRechargeForm = {
tenantId: string;
amount: string;
operator: string;
remark: string;
};
@@ -30,7 +29,7 @@ export function AdminRechargeRecordsPage() {
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [manualOpen, setManualOpen] = useState(false);
const [form, setForm] = useState<ManualRechargeForm>({ tenantId: '', amount: '', operator: '运营', remark: '' });
const [form, setForm] = useState<ManualRechargeForm>({ tenantId: '', amount: '', remark: '' });
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -102,11 +101,11 @@ export function AdminRechargeRecordsPage() {
await adminApi.createManualRecharge({
tenantId: form.tenantId,
amountCents: Math.round(amount * 100),
remark: [form.operator, form.remark].filter(Boolean).join(' / '),
remark: form.remark,
});
await loadData();
setManualOpen(false);
setForm({ tenantId: tenants[0]?.id ?? '', amount: '', operator: '运营', remark: '' });
setForm({ tenantId: tenants[0]?.id ?? '', amount: '', remark: '' });
} catch (failure) {
setManualError(failure instanceof Error ? failure.message : '人工充值失败');
} finally {
@@ -204,7 +203,6 @@ export function AdminRechargeRecordsPage() {
value={form.tenantId}
/>
<Input label="充值金额" onChange={(event) => updateForm('amount', event.target.value)} prefix="¥" required type="number" value={form.amount} />
<Input label="操作人" onChange={(event) => updateForm('operator', event.target.value)} required value={form.operator} />
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateForm('remark', event.target.value)} rows={4} value={form.remark} />
</div>
{manualError ? <p className="form-error">{manualError}</p> : null}
@@ -24,7 +24,6 @@ export function AdminSmsApplicationFormPage() {
const [customerUnitPrice, setCustomerUnitPrice] = useState('0.0300');
const [queuePriority, setQueuePriority] = useState<QueuePriority>('normal');
const [cmppAccount, setCmppAccount] = useState('');
const [cmppEnterpriseCode, setCmppEnterpriseCode] = useState('');
const [passwordCipher, setPasswordCipher] = useState(() => generateApplicationPassword());
const [interfaceEnabled, setInterfaceEnabled] = useState(true);
const [interfaceType, setInterfaceType] = useState<InterfaceType>('cmpp20');
@@ -85,7 +84,6 @@ export function AdminSmsApplicationFormPage() {
setCustomerUnitPrice(((application.customerUnitPrice ?? 0) / 100).toFixed(3));
setQueuePriority(application.queuePriority === 'priority' ? 'priority' : 'normal');
setCmppAccount(application.cmppAccount ?? '');
setCmppEnterpriseCode(application.cmppEnterpriseCode ?? application.tenant?.code ?? '');
setPasswordCipher('');
setInterfaceEnabled(application.interfaceEnabled !== false);
setInterfaceType('cmpp20');
@@ -128,7 +126,6 @@ export function AdminSmsApplicationFormPage() {
customerUnitPrice: Math.round(Number(customerUnitPrice || 0) * 100),
queuePriority,
cmppAccount: cmppAccount.trim() || undefined,
cmppEnterpriseCode: cmppEnterpriseCode.trim() || undefined,
passwordCipher: passwordCipher.trim() || undefined,
interfaceEnabled,
interfaceType,
@@ -209,7 +206,7 @@ export function AdminSmsApplicationFormPage() {
<span></span>
</div>
</div>
<Input label="每任务最大号码数" onChange={(event) => setPhoneDailyLimit(event.target.value)} placeholder="10" required value={phoneDailyLimit} />
<Input hint="单个发送任务超过该数量时,后端会拒绝整个任务,不会只发送前面的号码;请拆分后重新提交。" label="每任务最大号码数" onChange={(event) => setPhoneDailyLimit(event.target.value)} placeholder="10" required value={phoneDailyLimit} />
<Select
label="不符合模板的短信"
onChange={(event) => setMismatchPolicy(event.target.value)}
@@ -251,7 +248,7 @@ export function AdminSmsApplicationFormPage() {
</div>
</div>
<Input label="CMPP 6位账号" onChange={(event) => setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} />
<Input label="企业代码" onChange={(event) => setCmppEnterpriseCode(event.target.value)} placeholder="请输入客户侧企业代码" value={cmppEnterpriseCode} />
<Input disabled hint="企业代码始终与 CMPP 6位账号一致;账号留空自动生成时,保存后自动生成相同企业代码。" label="企业代码" placeholder="跟随 CMPP 6位账号自动生成" value={cmppAccount} />
<Input
hint={isEdit ? '留空则不修改接口密码;填写 16 位字符后覆盖。' : '默认随机生成,可按需修改。'}
label="接口密码"
+15 -4
View File
@@ -28,11 +28,13 @@ export function AdminSmsAuditPage() {
const [approveTarget, setApproveTarget] = useState<RiskReviewTask | 'batch' | null>(null);
const [rejectTarget, setRejectTarget] = useState<RiskReviewTask | null>(null);
const [rejectReason, setRejectReason] = useState('');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
function loadData() {
adminApi.listRiskReviewTasks({ status: status === 'all' ? undefined : status })
.then((items) => {
setRecords(items);
setSelectedIds((current) => current.filter((id) => items.some((item) => item.id === id && item.status === 'pending_review')));
setError('');
})
.catch((failure: Error) => setError(failure.message || '短信审核任务加载失败'));
@@ -58,8 +60,9 @@ export function AdminSmsAuditPage() {
}
async function approveBatch() {
await Promise.all(filteredRecords.filter((item) => item.status === 'pending_review').map((item) => adminApi.approveRiskReviewTask(item.id, '运营批量审核通过')));
await Promise.all(selectedIds.map((id) => adminApi.approveRiskReviewTask(id, '运营批量审核通过')));
setApproveTarget(null);
setSelectedIds([]);
loadData();
}
@@ -71,7 +74,15 @@ export function AdminSmsAuditPage() {
loadData();
}
const selectableIds = filteredRecords.filter((item) => item.status === 'pending_review').map((item) => item.id);
const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.includes(id));
const columns: Array<TableColumn<RiskReviewTask>> = [
{
key: 'selection',
title: <input aria-label="全选当前筛选结果" checked={allSelected} disabled={selectableIds.length === 0} onChange={(event) => setSelectedIds(event.target.checked ? selectableIds : [])} type="checkbox" />,
width: '54px',
render: (record) => <input aria-label={`选择审核任务${record.taskNo}`} checked={selectedIds.includes(record.id)} disabled={record.status !== 'pending_review'} onChange={(event) => setSelectedIds((current) => event.target.checked ? [...new Set([...current, record.id])] : current.filter((id) => id !== record.id))} type="checkbox" />,
},
{ key: 'taskNo', title: '任务编号', width: '180px', render: (record) => <strong>{record.taskNo}</strong> },
{ key: 'sourceType', title: '审核来源', width: '180px', render: (record) => <Tag tone={record.sourceType === 'cmpp_template_mismatch' ? 'warning' : 'info'}>{sourceLabel(record.sourceType)}</Tag> },
{ key: 'content', title: '短信内容', render: (record) => <span className="table-long-text">{record.content}</span> },
@@ -124,8 +135,8 @@ export function AdminSmsAuditPage() {
</div>
</div>
<div className="sms-bulk-actions">
<span></span>
<Button disabled={filteredRecords.every((item) => item.status !== 'pending_review')} icon={<Check size={16} />} onClick={() => setApproveTarget('batch')} variant="success"></Button>
<span> {selectedIds.length} </span>
<Button disabled={selectedIds.length === 0} icon={<Check size={16} />} onClick={() => setApproveTarget('batch')} variant="success"></Button>
</div>
</div>
@@ -144,7 +155,7 @@ export function AdminSmsAuditPage() {
open={Boolean(approveTarget)}
title="确认通过"
>
<p>{approveTarget === 'batch' ? `确认通过 ${filteredRecords.filter((item) => item.status === 'pending_review').length} 条待审核任务?` : '确认通过该短信审核任务?'}</p>
<p>{approveTarget === 'batch' ? `确认通过已选择的 ${selectedIds.length} 条待审核任务?` : '确认通过该短信审核任务?'}</p>
</Modal>
<Modal
+27 -49
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Download, MessageSquare, Search, Smartphone } from 'lucide-react';
import { AlertTriangle, Download, MessageSquare, Search, Smartphone } from 'lucide-react';
import { adminApi, type SmsMessageRecord, type SmsMessageSegmentAudit, type SmsReceiptRecord, type SmsSubmitRecord } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, Table, type DateRangeValue, type TableColumn } from '@/components/ui';
@@ -251,8 +251,13 @@ function SendDetailModal({
<div><span></span><strong>{getStatusLabel(record.status)}</strong></div>
<div><span></span><strong>{record.submitStatus ?? '-'}</strong></div>
<div><span></span><strong>{record.receiptStatus ?? '-'}</strong></div>
<div><span></span><strong>{record.errorMessage ?? record.errorCode ?? '-'}</strong></div>
</div>
{['failed', 'rejected'].includes(record.status) || record.errorMessage || record.errorCode ? (
<div className="admin-sms-detail-failure" role="alert">
<AlertTriangle size={20} />
<div><span></span><strong>{record.errorMessage ?? record.errorCode ?? '未返回明确失败原因'}</strong></div>
</div>
) : null}
</section>
<section>
@@ -415,53 +420,26 @@ export function AdminSmsRecordsPage() {
<div className="admin-sms-record-toolbar">
<Button icon={<Download size={16} />} onClick={() => downloadCsv(filteredRows)} variant="ghost">CSV</Button>
</div>
<div className="ui-table-wrap">
<table className="ui-table admin-sms-record-table">
<thead>
<tr>
<th style={{ width: '170px' }}></th>
<th></th>
<th style={{ width: '170px' }}></th>
<th style={{ width: '300px' }}></th>
<th style={{ width: '120px' }}></th>
</tr>
</thead>
<tbody>
{filteredRows.length === 0 ? (
<tr>
<td className="ui-table__empty" colSpan={5}></td>
</tr>
) : visibleRows.map((record) => (
<tr key={record.id}>
<td>
<div className="admin-sms-record-sender">
<strong>{record.tenant?.name ?? record.tenantId}</strong>
<span>{record.application?.name ?? record.applicationId ?? '-'}</span>
<small>{getDate(record.queuedAt)}<br />{getClock(record.queuedAt)}</small>
</div>
</td>
<td><p className="admin-sms-record-content">{record.content}</p></td>
<td>
<div className="admin-sms-record-phone">
<strong>{record.phoneNumber}</strong>
<span>{record.province ?? '-'} {getCarrierLabel(record.carrier)}</span>
<small>{record.content.length}/{record.billingUnits}</small>
</div>
</td>
<td>
<div className="admin-sms-record-channel">
<strong>{record.channel?.name ?? record.channelId ?? '-'}</strong>
<StatusLine status={record.status} />
<span>{getTime(record.deliveredAt)}</span>
</div>
</td>
<td style={{ textAlign: 'right' }}>
<button className="admin-sms-record-detail-link" onClick={() => setSelectedRecord(record)} type="button"></button>
</td>
</tr>
))}
</tbody>
</table>
<div className="admin-sms-record-list">
{filteredRows.length === 0 ? <div className="ui-table__empty"></div> : visibleRows.map((record) => (
<article className="admin-sms-record-card" key={record.id}>
<header>
<div className="admin-sms-record-sender">
<strong>{record.tenant?.name ?? record.tenantId}</strong>
<span>{record.application?.name ?? record.applicationId ?? '-'}</span>
</div>
<StatusLine status={record.status} />
<time>{getDate(record.queuedAt)} {getClock(record.queuedAt)}</time>
</header>
<p className="admin-sms-record-content">{record.content}</p>
<div className="admin-sms-record-card__meta">
<div><span></span><strong>{record.phoneNumber}</strong><small>{record.province ?? '-'} · {getCarrierLabel(record.carrier)}</small></div>
<div><span></span><strong>{record.billingUnits} / ¥{(record.amountCents / 100).toFixed(3)}</strong><small>{record.content.length} </small></div>
<div><span></span><strong>{record.channel?.name ?? record.channelId ?? '-'}</strong><small> {getTime(record.deliveredAt)}</small></div>
</div>
<footer><button className="admin-sms-record-detail-link" onClick={() => setSelectedRecord(record)} type="button"></button></footer>
</article>
))}
</div>
<Pagination
nextDisabled={currentPage >= totalPages}
+1 -1
View File
@@ -98,7 +98,7 @@ function ClientDrainageModal({ item, onClose, onSaved, signature }: { item?: Cli
const missingRequired = fields.some((field) => field.required && !values[field.code]);
return <Modal footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!siteName.trim() || !url.trim() || missingRequired || saving || Boolean(uploadingCode)} onClick={() => void save()}>{saving ? '提交中...' : '提交审核'}</Button></>} onClose={onClose} open size="xl" title={item ? '修改引流信息' : '新增引流信息'}>
<div className="signature-form drainage-edit-form">
<Input label="站名称" onChange={(event) => setSiteName(event.target.value)} required value={siteName} />
<Input label="引流信息" onChange={(event) => setSiteName(event.target.value)} required value={siteName} />
<Input label="引流地址" onChange={(event) => setUrl(event.target.value)} placeholder="https://" required value={url} />
<Textarea label="备注" onChange={(event) => setRemark(event.target.value)} rows={3} value={remark} />
<section className="surface" style={{ padding: 16 }}><h3></h3><div className="signature-form-grid" style={{ marginTop: 12 }}>
+12 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
import { Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate } from '@/api/adminApi';
@@ -72,6 +72,7 @@ function TemplateModal({
}) {
const [customVariable, setCustomVariable] = useState('');
const [variablesOpen, setVariablesOpen] = useState(false);
const contentRef = useRef<HTMLTextAreaElement>(null);
const [form, setForm] = useState<TemplateFormState>({
applicationId: item?.applicationId ?? '',
signatureId: item?.signatureId ?? '',
@@ -99,7 +100,15 @@ function TemplateModal({
function insertVariable(name: string) {
const normalized = name.trim();
if (!normalized) return;
setContent(`${form.content}\${${normalized}}`);
const token = `\${${normalized}}`;
const textarea = contentRef.current;
const start = textarea?.selectionStart ?? form.content.length;
const end = textarea?.selectionEnd ?? start;
setContent(`${form.content.slice(0, start)}${token}${form.content.slice(end)}`);
requestAnimationFrame(() => {
contentRef.current?.focus();
contentRef.current?.setSelectionRange(start + token.length, start + token.length);
});
}
function updateVariableExample(name: string, example: string) {
@@ -134,7 +143,7 @@ function TemplateModal({
/>
<Input label="模板名称" onChange={(event) => update('name', event.target.value)} placeholder="请输入模板名称" value={form.name} />
<Input label="模板分类" onChange={(event) => update('category', event.target.value)} placeholder="行业通知/营销推广/验证码" value={form.category} />
<Textarea label="模板内容" onChange={(event) => setContent(event.target.value)} placeholder="变量格式:${code}" rows={6} value={form.content} />
<Textarea label="模板内容" onChange={(event) => setContent(event.target.value)} placeholder="变量格式:${code}" ref={contentRef} rows={6} value={form.content} />
<div className="template-form-meta">
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
<Plus size={16} /> {variablesOpen ? '收起变量面板' : '插入变量'}