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
+7 -2
View File
@@ -252,6 +252,7 @@ export type DashboardResponse = {
failed: number;
delivered: number;
stalledPending: number;
stalledAck: number;
recentFailed: number;
alertCount: number;
};
@@ -801,6 +802,8 @@ export type DownstreamDeliveryRecord = {
status: string;
payload: Record<string, unknown>;
retryCount: number;
manualRetryCount: number;
lastRetriedAt?: string | null;
retryEnabled: boolean;
nextRetryAt?: string | null;
sentAt?: string | null;
@@ -1103,7 +1106,7 @@ export const adminApi = {
request<SmsUplinkMessage>(`/admin/operations/uplink-messages/${uplinkMessageId}/claim`, { method: 'POST', body: JSON.stringify(body) }),
listMonitor: (query: { tenantId?: string; channelId?: string } = {}) => request<Record<string, unknown>>(withQuery('/admin/operations/monitor', query)),
listStatistics: (query: { tenantId?: string; groupBy?: string } = {}) => request<Array<Record<string, unknown>>>(withQuery('/admin/operations/statistics', query)),
getDownstreamDeliveryDashboard: (query: { tenantId?: string; applicationId?: string; deliveryType?: string } = {}) =>
getDownstreamDeliveryDashboard: (query: { tenantId?: string; applicationId?: string; deliveryType?: string; createdAtFrom?: string; createdAtTo?: string } = {}) =>
request<DownstreamDeliveryDashboard>(withQuery('/admin/operations/downstream-deliveries/dashboard', query)),
listDownstreamRecoveryStatuses: (query: { tenantId?: string; applicationId?: string; state?: string; failureCategory?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
request<DownstreamRecoveryStatusResponse>(withQuery('/admin/operations/downstream-recovery-statuses', query)),
@@ -1111,7 +1114,7 @@ export const adminApi = {
request<GatewayDownstreamRecoveryStatus>(`/admin/operations/downstream-recovery-statuses/${id}`),
exportDownstreamRecoveryStatuses: (query: DownstreamRecoveryStatusExportQuery = {}) =>
requestBlob(withQuery('/admin/operations/downstream-recovery-statuses/export', query)),
listDownstreamDeliveries: (query: { tenantId?: string; applicationId?: string; deliveryType?: string; status?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
listDownstreamDeliveries: (query: { tenantId?: string; applicationId?: string; deliveryType?: string; status?: string; keyword?: string; page?: number; pageSize?: number; createdAtFrom?: string; createdAtTo?: string } = {}) =>
request<PagedResponse<DownstreamDeliveryRecord>>(withQuery('/admin/operations/downstream-deliveries', query)),
requeueDownstreamDelivery: (id: string) =>
request<DownstreamDeliveryRecord>(`/admin/operations/downstream-deliveries/${id}/requeue`, { method: 'POST', body: JSON.stringify({}) }),
@@ -1138,6 +1141,7 @@ export const adminApi = {
request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(withQuery('/admin/dictionaries/phone-segments', query)),
createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) =>
request<DictionaryItem>('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }),
deletePhoneSegment: (id: string) => request<DictionaryItem>(`/admin/dictionaries/phone-segments/${id}`, { method: 'DELETE' }),
listPhoneCarrierRules: (query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(withQuery('/admin/dictionaries/phone-carrier-rules', query)),
createPhoneCarrierRule: (body: { carrier: string; pattern: string; priority?: number; status?: string; remark?: string }) =>
@@ -1145,6 +1149,7 @@ export const adminApi = {
listDrainageFields: () => request<DictionaryItem[]>('/admin/dictionaries/drainage-fields'),
createDrainageField: (body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; required?: boolean; status?: string; description?: string }) =>
request<DictionaryItem>('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }),
deleteDrainageField: (id: string) => request<DictionaryItem>(`/admin/dictionaries/drainage-fields/${id}`, { method: 'DELETE' }),
uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }, tenantId?: string) => {
const form = new FormData();
form.set('file', file);
+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 ? '收起变量面板' : '插入变量'}
+1 -1
View File
@@ -4,7 +4,7 @@ import { Pagination } from './PagePrimitives';
export type TableColumn<T> = {
key: string;
title: string;
title: ReactNode;
width?: string;
align?: 'left' | 'center' | 'right';
render: (record: T, index: number) => ReactNode;
+4 -3
View File
@@ -1,4 +1,4 @@
import type { TextareaHTMLAttributes } from 'react';
import { forwardRef, type TextareaHTMLAttributes } from 'react';
type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
label?: string;
@@ -6,7 +6,7 @@ type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
error?: string;
};
export function Textarea({ className = '', label, hint, error, id, ...props }: TextareaProps) {
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea({ className = '', label, hint, error, id, ...props }, ref) {
const textareaId = id ?? props.name;
const required = Boolean(props.required);
@@ -21,10 +21,11 @@ export function Textarea({ className = '', label, hint, error, id, ...props }: T
<textarea
className={['ui-textarea', error ? 'ui-textarea--error' : ''].filter(Boolean).join(' ')}
id={textareaId}
ref={ref}
{...props}
/>
{error ? <span className="ui-field__error">{error}</span> : null}
{!error && hint ? <span className="ui-field__hint">{hint}</span> : null}
</label>
);
}
});
+2 -2
View File
@@ -349,12 +349,12 @@ export function AppShell({
<div className="notice-popover" role="menu">
<div className="notice-popover__header">
<strong></strong>
<span>{auditTotal} </span>
<span className={auditTotal === 0 ? 'is-zero' : ''}>{auditTotal} </span>
</div>
{auditNotifications.length ? auditNotifications.map((item) => (
<NavLink key={item.to} onClick={() => setNoticeOpen(false)} role="menuitem" to={item.to}>
<span>{item.label}</span>
<strong>{item.count}</strong>
<strong className={item.count === 0 ? 'is-zero' : ''}>{item.count}</strong>
</NavLink>
)) : <p></p>}
</div>
+128 -1
View File
@@ -577,6 +577,17 @@ h3 {
font-weight: var(--font-weight-semibold);
}
.notice-popover__header span.is-zero,
.notice-popover a strong.is-zero {
background: var(--color-surface-muted);
color: var(--color-text-strong);
}
.notice-popover__header span.is-zero {
border-radius: var(--radius-full);
padding: 2px 8px;
}
.notice-popover a,
.notice-popover p {
align-items: center;
@@ -2788,7 +2799,7 @@ h3 {
align-items: center;
display: grid;
gap: var(--space-4);
grid-template-columns: minmax(96px, 0.9fr) minmax(180px, 1.5fr) minmax(80px, 0.7fr) repeat(3, minmax(64px, 0.65fr)) minmax(128px, 1fr) minmax(196px, auto);
grid-template-columns: minmax(96px, 0.9fr) minmax(180px, 1.5fr) minmax(80px, 0.7fr) repeat(3, minmax(64px, 0.65fr)) minmax(196px, auto);
min-height: 58px;
}
@@ -7791,6 +7802,14 @@ h3 {
gap: var(--space-3);
}
.report-record-page .admin-task-filter {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.report-record-page .admin-task-filter__actions {
justify-content: flex-end;
}
.admin-task-table-card {
overflow: hidden;
padding: 0;
@@ -8579,6 +8598,73 @@ h3 {
border-radius: 0;
}
.admin-sms-record-list {
display: grid;
gap: var(--space-4);
padding: var(--space-5);
}
.admin-sms-record-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
display: grid;
gap: var(--space-4);
padding: var(--space-5);
}
.admin-sms-record-card:hover {
border-color: color-mix(in srgb, var(--color-selected) 35%, var(--color-border));
box-shadow: var(--shadow-sm);
}
.admin-sms-record-card > header {
align-items: center;
display: grid;
gap: var(--space-4);
grid-template-columns: minmax(180px, 1fr) auto auto;
}
.admin-sms-record-card > header time {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.admin-sms-record-card .admin-sms-record-content {
background: var(--color-bg-subtle);
border-radius: var(--radius-md);
max-width: none;
padding: var(--space-4);
}
.admin-sms-record-card__meta {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.admin-sms-record-card__meta > div {
display: grid;
gap: var(--space-1);
}
.admin-sms-record-card__meta span,
.admin-sms-record-card__meta small {
color: var(--color-text-muted);
}
.admin-sms-record-card__meta strong {
color: var(--color-text-strong);
overflow-wrap: anywhere;
}
.admin-sms-record-card > footer {
border-top: 1px solid var(--color-border);
display: flex;
justify-content: flex-end;
padding-top: var(--space-3);
}
.admin-sms-record-table {
min-width: 1180px;
}
@@ -8738,6 +8824,47 @@ h3 {
overflow-wrap: anywhere;
}
.admin-sms-detail-failure {
align-items: flex-start;
background: var(--color-danger-soft);
border: 1px solid color-mix(in srgb, var(--color-danger) 28%, transparent);
border-radius: var(--radius-md);
color: var(--color-danger);
display: flex;
gap: var(--space-3);
margin-top: var(--space-4);
padding: var(--space-4);
}
.admin-sms-detail-failure div {
display: grid;
gap: var(--space-1);
}
.admin-sms-detail-failure span {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
}
.admin-sms-detail-failure strong {
color: var(--color-danger);
overflow-wrap: anywhere;
}
@media (max-width: 900px) {
.admin-sms-record-card > header,
.admin-sms-record-card__meta,
.admin-sms-detail-overview,
.admin-sms-detail-status-grid,
.admin-sms-route-list dl {
grid-template-columns: 1fr;
}
.admin-sms-record-card > header time {
justify-self: start;
}
}
.admin-sms-route-list {
display: grid;
}