fix: harden admin and CMPP delivery workflows
This commit is contained in:
@@ -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 ?? '无'}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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="接口密码"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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 ? '收起变量面板' : '插入变量'}
|
||||
|
||||
Reference in New Issue
Block a user