feat: harden CMPP delivery and platform workflows
This commit is contained in:
@@ -119,6 +119,7 @@ export function AdminCustomerFormPage() {
|
||||
const nextErrors: EnterpriseFormErrors = {};
|
||||
if (!form.name.trim()) nextErrors.name = '请填写企业名称';
|
||||
if (!form.creditCode.trim()) nextErrors.creditCode = '请填写统一社会信用代码';
|
||||
else if (!/^[A-Za-z0-9]+$/.test(form.creditCode.trim())) nextErrors.creditCode = '统一社会信用代码只能包含英文字母和数字';
|
||||
if (!form.contactName.trim()) nextErrors.contactName = '请填写联系人姓名';
|
||||
if (!form.contactPhone.trim()) nextErrors.contactPhone = '请填写手机号';
|
||||
const creditLimit = Number(form.creditLimit);
|
||||
@@ -210,7 +211,7 @@ export function AdminCustomerFormPage() {
|
||||
error={errors.creditCode}
|
||||
hint="修改此项将同步更新该企业档案。"
|
||||
label="统一社会信用代码"
|
||||
onChange={(event) => updateForm('creditCode', event.target.value)}
|
||||
onChange={(event) => updateForm('creditCode', event.target.value.replace(/[^A-Za-z0-9]/g, ''))}
|
||||
placeholder="请填写统一社会信用代码或纳税识别号"
|
||||
required
|
||||
value={form.creditCode}
|
||||
|
||||
@@ -252,22 +252,20 @@ function CmppConnectionModal({
|
||||
<div><span>AppID</span><strong>{app.appId}</strong></div>
|
||||
<div><span>连接状态</span><Tag tone={app.cmppStatus === 'connected' ? 'success' : app.cmppStatus === 'disconnected' ? 'danger' : 'neutral'}>{app.cmppStatus === 'connected' ? '在线' : app.cmppStatus === 'disconnected' ? '离线' : '未开通'}</Tag></div>
|
||||
</div>
|
||||
<Table
|
||||
columns={[
|
||||
{ key: 'id', title: '连接ID', width: '150px', render: (record: CmppConnection) => <strong>{record.id}</strong> },
|
||||
{ key: 'state', title: '状态', width: '130px', render: (record: CmppConnection) => <Tag tone={connectionStateMeta[record.state].tone}>{connectionStateMeta[record.state].label}</Tag> },
|
||||
{ key: 'bindType', title: '绑定类型', width: '120px', render: (record: CmppConnection) => record.bindType },
|
||||
{ key: 'clientIp', title: '客户端IP', width: '170px', render: (record: CmppConnection) => record.clientIp },
|
||||
{ key: 'sourceAddr', title: '企业代码', width: '120px', render: (record: CmppConnection) => record.sourceAddr },
|
||||
{ key: 'establishedAt', title: '连接建立时间', width: '180px', render: (record: CmppConnection) => record.establishedAt },
|
||||
{ key: 'lastHeartbeatAt', title: '上次心跳', width: '180px', render: (record: CmppConnection) => record.lastHeartbeatAt },
|
||||
{ key: 'lastSubmitAt', title: '上次提交', width: '180px', render: (record: CmppConnection) => record.lastSubmitAt },
|
||||
{ key: 'pendingWindow', title: '窗口占用', align: 'right', width: '120px', render: (record: CmppConnection) => record.pendingWindow },
|
||||
]}
|
||||
data={activeConnectionItems}
|
||||
emptyText="当前暂无已连接的 CMPP 会话"
|
||||
rowKey="id"
|
||||
/>
|
||||
{activeConnectionItems.length ? <div className="cmpp-connection-list">{activeConnectionItems.map((record) => (
|
||||
<article className="cmpp-connection-card" key={record.id}>
|
||||
<div className="cmpp-connection-card__heading"><strong>{record.id}</strong><Tag tone={connectionStateMeta[record.state].tone}>{connectionStateMeta[record.state].label}</Tag></div>
|
||||
<div className="cmpp-connection-card__grid">
|
||||
<div><span>绑定类型</span><strong>{record.bindType}</strong></div>
|
||||
<div><span>客户端 IP</span><strong>{record.clientIp}</strong></div>
|
||||
<div><span>企业代码</span><strong>{record.sourceAddr}</strong></div>
|
||||
<div><span>窗口占用</span><strong>{record.pendingWindow}</strong></div>
|
||||
<div><span>连接建立时间</span><strong>{record.establishedAt}</strong></div>
|
||||
<div><span>上次心跳</span><strong>{record.lastHeartbeatAt}</strong></div>
|
||||
<div><span>上次提交</span><strong>{record.lastSubmitAt}</strong></div>
|
||||
</div>
|
||||
</article>
|
||||
))}</div> : <div className="ui-table__empty">当前暂无已连接的 CMPP 会话</div>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
@@ -461,8 +459,8 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
value={status}
|
||||
/>
|
||||
<div className="admin-split-filter__actions">
|
||||
<Button icon={<Search size={16} />} onClick={() => { setAppliedEnterpriseKeyword(enterpriseKeyword.trim()); setAppliedApplicationKeyword(applicationKeyword.trim()); setAppliedStatus(status); }}>查询</Button>
|
||||
<Button onClick={() => { setEnterpriseKeyword(''); setApplicationKeyword(''); setStatus('all'); setAppliedEnterpriseKeyword(''); setAppliedApplicationKeyword(''); setAppliedStatus('all'); }} variant="ghost">重置</Button>
|
||||
<Button icon={<Search size={16} />} onClick={() => { const filters = { enterpriseKeyword: enterpriseKeyword.trim(), applicationKeyword: applicationKeyword.trim(), status }; setAppliedEnterpriseKeyword(filters.enterpriseKeyword); setAppliedApplicationKeyword(filters.applicationKeyword); setAppliedStatus(filters.status); void loadSmsApps(filters); }}>查询</Button>
|
||||
<Button onClick={() => { const filters = { enterpriseKeyword: '', applicationKeyword: '', status: 'all' }; setEnterpriseKeyword(''); setApplicationKeyword(''); setStatus('all'); setAppliedEnterpriseKeyword(''); setAppliedApplicationKeyword(''); setAppliedStatus('all'); void loadSmsApps(filters); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -509,7 +509,7 @@ function DrainageReportModal({ item, onClose, signature }: { item: DrainageInfo;
|
||||
<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.url}</strong></div>
|
||||
<div><span>引流url或号码</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>
|
||||
@@ -717,8 +717,7 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
{visibleDrainageLinks.length ? (
|
||||
<div className="drainage-table">
|
||||
<div className="drainage-table__head">
|
||||
<span>引流信息</span>
|
||||
<span>URL</span>
|
||||
<span>引流url或号码</span>
|
||||
<span>审核状态</span>
|
||||
<span>移动</span>
|
||||
<span>联通</span>
|
||||
@@ -729,7 +728,6 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
const summary = signature.drainageCarrierReportSummary?.[item.id];
|
||||
return (
|
||||
<div className="drainage-table__row" key={item.id}>
|
||||
<strong>{item.siteName}</strong>
|
||||
<span className="drainage-table__url" title={item.url}>{item.url}</span>
|
||||
<AuditStatusTag status={item.auditStatus ?? 'pending'} />
|
||||
<CarrierReportTag summary={summary?.mobile} />
|
||||
|
||||
@@ -24,6 +24,12 @@ const carrierTone: Record<string, 'success' | 'info' | 'warning' | 'neutral'> =
|
||||
中国电信: 'warning',
|
||||
};
|
||||
|
||||
const ruleCarrierMeta: Record<string, { label: string; tone: 'success' | 'info' | 'warning' | 'neutral' }> = {
|
||||
mobile: { label: '中国移动', tone: 'success' },
|
||||
unicom: { label: '中国联通', tone: 'info' },
|
||||
telecom: { label: '中国电信', tone: 'warning' },
|
||||
};
|
||||
|
||||
type PhoneSegmentSummaryProps = {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
@@ -69,6 +75,7 @@ export function AdminPhoneSegmentsPage() {
|
||||
const [rulePage, setRulePage] = useState(1);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [deleteTarget, setDeleteTarget] = useState<PhoneSegment | null>(null);
|
||||
const [ruleDeleteTarget, setRuleDeleteTarget] = useState<CarrierRule | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -146,6 +153,16 @@ export function AdminPhoneSegmentsPage() {
|
||||
.catch((failure: Error) => setError(failure.message || '手机号段删除失败'));
|
||||
}
|
||||
|
||||
function deleteCarrierRule() {
|
||||
if (!ruleDeleteTarget) return;
|
||||
adminApi.deletePhoneCarrierRule(ruleDeleteTarget.id)
|
||||
.then(() => {
|
||||
setRuleDeleteTarget(null);
|
||||
setReloadKey((current) => current + 1);
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '运营商区分规则删除失败'));
|
||||
}
|
||||
|
||||
const columns = useMemo<Array<TableColumn<PhoneSegment>>>(() => [
|
||||
{ key: 'segment', title: '手机号段(前7位)', width: '190px', render: (record) => <strong className="phone-segment-prefix">{record.prefix}</strong> },
|
||||
{ key: 'carrier', title: '运营商', width: '130px', render: (record) => record.carrier ? <Tag tone={carrierTone[record.carrier] ?? 'neutral'}>{record.carrier}</Tag> : '-' },
|
||||
@@ -156,10 +173,11 @@ export function AdminPhoneSegmentsPage() {
|
||||
], []);
|
||||
|
||||
const ruleColumns = useMemo<Array<TableColumn<CarrierRule>>>(() => [
|
||||
{ key: 'carrier', title: '运营商', width: '140px', render: (record) => record.carrier ?? '-' },
|
||||
{ key: 'carrier', title: '运营商', width: '140px', render: (record) => { const meta = ruleCarrierMeta[record.carrier ?? '']; return meta ? <Tag tone={meta.tone}>{meta.label}</Tag> : record.carrier ?? '-'; } },
|
||||
{ key: 'pattern', title: '号码前缀正则', render: (record) => <strong>{record.pattern}</strong> },
|
||||
{ key: 'priority', title: '优先级', width: '120px', render: (record) => record.priority ?? 100 },
|
||||
{ key: 'remark', title: '备注', render: (record) => record.remark ?? '-' },
|
||||
{ key: 'actions', title: '操作', width: '90px', align: 'right', render: (record) => <Button className="phone-segment-delete" icon={<Trash2 size={14} />} onClick={() => setRuleDeleteTarget(record)} size="sm" variant="ghost">删除</Button> },
|
||||
], []);
|
||||
|
||||
const queryPanel = (
|
||||
@@ -304,6 +322,16 @@ export function AdminPhoneSegmentsPage() {
|
||||
<p>确认删除手机号段“{deleteTarget.prefix}”吗?删除后号码归属识别将不再使用该记录。</p>
|
||||
</Modal>
|
||||
) : null}
|
||||
{ruleDeleteTarget ? (
|
||||
<Modal
|
||||
footer={<><Button onClick={() => setRuleDeleteTarget(null)} variant="ghost">取消</Button><Button onClick={deleteCarrierRule} variant="danger">确认删除</Button></>}
|
||||
onClose={() => setRuleDeleteTarget(null)}
|
||||
open
|
||||
title="删除运营商区分规则"
|
||||
>
|
||||
<p>确认删除规则“{ruleDeleteTarget.pattern}”吗?删除后运营商识别将不再使用该规则。</p>
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,12 +74,12 @@ export function AdminProfitReportsPage() {
|
||||
<div className="surface">
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table">
|
||||
<thead><tr><th>发送日期</th><th>{dimensionType === 'application' ? '企业 / 企业应用' : '通道'}</th><th>日发送条数</th><th>成功条数</th><th>消费金额</th><th>成本金额</th><th>利润</th><th>利润率</th><th>生成时间</th></tr></thead>
|
||||
<thead><tr><th>发送日期</th><th>{dimensionType === 'application' ? '企业 / 企业应用' : '通道'}</th><th>发送</th><th>成功</th><th>失败</th><th>净消费</th><th>返还</th><th>成本</th><th>利润</th><th>利润率</th><th>生成时间</th></tr></thead>
|
||||
<tbody>
|
||||
{error ? <tr><td className="ui-table__empty" colSpan={9}>{error}</td></tr>
|
||||
: loading ? <tr><td className="ui-table__empty" colSpan={9}>正在加载真实利润数据...</td></tr>
|
||||
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={9}>暂无已生成的利润报表</td></tr>
|
||||
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>¥{formatCents(row.revenueCents)}</td><td>¥{formatCents(row.costCents)}</td><td style={{ color: row.profitCents < 0 ? 'var(--danger)' : undefined }}>¥{formatCents(row.profitCents)}</td><td>{(row.profitRateBps / 100).toFixed(2)}%</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
|
||||
{error ? <tr><td className="ui-table__empty" colSpan={11}>{error}</td></tr>
|
||||
: loading ? <tr><td className="ui-table__empty" colSpan={11}>正在加载真实利润数据...</td></tr>
|
||||
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={11}>暂无已生成的利润报表</td></tr>
|
||||
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>¥{formatCents(row.revenueCents)}</td><td>¥{formatCents(row.refundCents)}</td><td>¥{formatCents(row.costCents)}</td><td style={{ color: row.profitCents < 0 ? 'var(--danger)' : undefined }}>¥{formatCents(row.profitCents)}</td><td>{(row.profitRateBps / 100).toFixed(2)}%</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -81,12 +81,12 @@ export function AdminQualityReportsPage() {
|
||||
<div className="surface">
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table">
|
||||
<thead><tr><th>发送日期</th><th>{dimensionLabels[dimension]}</th><th>发送条数</th><th>成功条数</th><th>成功率</th><th>平均到达时长</th><th>生成时间</th></tr></thead>
|
||||
<thead><tr><th>发送日期</th><th>{dimensionLabels[dimension]}</th><th>发送条数</th><th>成功条数</th><th>失败条数</th><th>成功率</th><th>平均到达时长</th><th>生成时间</th></tr></thead>
|
||||
<tbody>
|
||||
{error ? <tr><td className="ui-table__empty" colSpan={7}>{error}</td></tr>
|
||||
: loading ? <tr><td className="ui-table__empty" colSpan={7}>正在加载真实发送质量数据...</td></tr>
|
||||
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={7}>暂无已生成的发送质量报表</td></tr>
|
||||
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{(row.successRateBps / 100).toFixed(2)}%</td><td>{formatDuration(row.avgArrivalMs)}</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
|
||||
{error ? <tr><td className="ui-table__empty" colSpan={8}>{error}</td></tr>
|
||||
: loading ? <tr><td className="ui-table__empty" colSpan={8}>正在加载真实发送质量数据...</td></tr>
|
||||
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={8}>暂无已生成的发送质量报表</td></tr>
|
||||
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>{(row.successRateBps / 100).toFixed(2)}%</td><td>{formatDuration(row.avgArrivalMs)}</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -80,12 +80,12 @@ export function AdminReconciliationReportsPage() {
|
||||
<div className="surface">
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table">
|
||||
<thead><tr><th>发送日期</th><th>企业</th><th>企业应用</th><th>日发送条数</th><th>成功条数</th><th>生成时间</th></tr></thead>
|
||||
<thead><tr><th>发送日期</th><th>企业</th><th>企业应用</th><th>发送条数</th><th>成功条数</th><th>失败条数</th><th>生成时间</th></tr></thead>
|
||||
<tbody>
|
||||
{error ? <tr><td className="ui-table__empty" colSpan={6}>{error}</td></tr>
|
||||
: loading ? <tr><td className="ui-table__empty" colSpan={6}>正在加载真实对账数据...</td></tr>
|
||||
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={6}>暂无已生成的对账单</td></tr>
|
||||
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td>{row.tenantName}</td><td>{row.applicationName}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
|
||||
{error ? <tr><td className="ui-table__empty" colSpan={7}>{error}</td></tr>
|
||||
: loading ? <tr><td className="ui-table__empty" colSpan={7}>正在加载真实对账数据...</td></tr>
|
||||
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={7}>暂无已生成的对账单</td></tr>
|
||||
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td>{row.tenantName}</td><td>{row.applicationName}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -54,10 +54,10 @@ export function AdminSmsApplicationFormPage() {
|
||||
const [downstreamUplinkRetryEnabled, setDownstreamUplinkRetryEnabled] = useState(true);
|
||||
const [ipAddress, setIpAddress] = useState('');
|
||||
const [httpConfig, setHttpConfig] = useState<HttpApiConfig>({
|
||||
enabled: false, sendEnabled: false, messageQueryEnabled: false, receiptWebhookEnabled: false,
|
||||
uplinkWebhookEnabled: false, uplinkQueryEnabled: false, credentialSelfServiceEnabled: false,
|
||||
enabled: false, sendEnabled: true, messageQueryEnabled: true, receiptWebhookEnabled: true,
|
||||
uplinkWebhookEnabled: true, uplinkQueryEnabled: true, credentialSelfServiceEnabled: true,
|
||||
qpsLimit: 10, timestampToleranceSeconds: 300, maxCredentialCount: 2, uplinkRetentionDays: 90,
|
||||
maxQueryRangeDays: 31, maxPageSize: 100, receiptDeliveryMode: 'cmpp', uplinkDeliveryMode: 'cmpp',
|
||||
maxQueryRangeDays: 31, maxPageSize: 100, receiptDeliveryMode: 'http', uplinkDeliveryMode: 'http',
|
||||
webhookRetryEnabled: true, webhookMaxAttempts: 7, webhookTimeoutSeconds: 10, requireHttps: true,
|
||||
allowClientManualRetry: true, allowClientTest: true,
|
||||
});
|
||||
@@ -354,7 +354,18 @@ export function AdminSmsApplicationFormPage() {
|
||||
<span className="admin-app-protocol-icon"><Globe2 size={19} /></span>
|
||||
<div><h3>HTTP 接口配置</h3><p>管理接口能力、机器鉴权、查询限制与 Webhook 投递策略。</p></div>
|
||||
</div>
|
||||
<button className={httpConfig.enabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setHttpConfig((current) => ({ ...current, enabled: !current.enabled }))} type="button"><span />{httpConfig.enabled ? '已开通' : '未开通'}</button>
|
||||
<button className={httpConfig.enabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setHttpConfig((current) => current.enabled ? { ...current, enabled: false } : {
|
||||
...current,
|
||||
enabled: true,
|
||||
sendEnabled: true,
|
||||
messageQueryEnabled: true,
|
||||
receiptWebhookEnabled: true,
|
||||
uplinkWebhookEnabled: true,
|
||||
uplinkQueryEnabled: true,
|
||||
credentialSelfServiceEnabled: true,
|
||||
receiptDeliveryMode: 'http',
|
||||
uplinkDeliveryMode: 'http',
|
||||
})} type="button"><span />{httpConfig.enabled ? '已开通' : '未开通'}</button>
|
||||
</div>
|
||||
{httpConfig.enabled ? (
|
||||
<div className="admin-app-form-grid admin-app-protocol-body">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { CalendarDays, Check, Search, X } from 'lucide-react';
|
||||
import { CalendarDays, Check, Info, Search, X } from 'lucide-react';
|
||||
import { adminApi, type RiskReviewTask } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
pending_review: '待审核',
|
||||
@@ -29,6 +30,11 @@ export function AdminSmsAuditPage() {
|
||||
const [rejectTarget, setRejectTarget] = useState<RiskReviewTask | 'batch' | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [detailTarget, setDetailTarget] = useState<RiskReviewTask | null>(null);
|
||||
|
||||
function refreshAuditCount() {
|
||||
window.dispatchEvent(new Event('cmpp-audit-count-refresh'));
|
||||
}
|
||||
|
||||
function loadData() {
|
||||
adminApi.listRiskReviewTasks({ status: status === 'all' ? undefined : status })
|
||||
@@ -57,6 +63,7 @@ export function AdminSmsAuditPage() {
|
||||
await adminApi.approveRiskReviewTask(record.id, '运营审核通过');
|
||||
setApproveTarget(null);
|
||||
loadData();
|
||||
refreshAuditCount();
|
||||
}
|
||||
|
||||
async function approveBatch() {
|
||||
@@ -64,6 +71,7 @@ export function AdminSmsAuditPage() {
|
||||
setApproveTarget(null);
|
||||
setSelectedIds([]);
|
||||
loadData();
|
||||
refreshAuditCount();
|
||||
}
|
||||
|
||||
async function rejectRecord() {
|
||||
@@ -72,6 +80,7 @@ export function AdminSmsAuditPage() {
|
||||
setRejectTarget(null);
|
||||
setRejectReason('');
|
||||
loadData();
|
||||
refreshAuditCount();
|
||||
}
|
||||
|
||||
async function rejectBatch() {
|
||||
@@ -81,6 +90,7 @@ export function AdminSmsAuditPage() {
|
||||
setRejectReason('');
|
||||
setSelectedIds([]);
|
||||
loadData();
|
||||
refreshAuditCount();
|
||||
}
|
||||
|
||||
const selectableIds = filteredRecords.filter((item) => item.status === 'pending_review').map((item) => item.id);
|
||||
@@ -96,7 +106,7 @@ export function AdminSmsAuditPage() {
|
||||
{ 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> },
|
||||
{ key: 'phoneTotal', title: '聚合号码数', width: '140px', render: (record) => (record._count?.messageRecords ?? record.phoneTotal).toLocaleString('zh-CN') },
|
||||
{ key: 'createdAt', title: '提交时间', width: '190px', render: (record) => record.createdAt },
|
||||
{ key: 'createdAt', title: '提交时间', width: '190px', render: (record) => formatDateTime(record.createdAt) },
|
||||
{ key: 'reason', title: '审核原因', render: (record) => record.reviewReason ?? record.rejectReason ?? record.riskHits?.map((item) => item.reason).join(';') ?? '-' },
|
||||
{
|
||||
key: 'status',
|
||||
@@ -107,14 +117,17 @@ export function AdminSmsAuditPage() {
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '160px',
|
||||
width: '230px',
|
||||
align: 'right',
|
||||
render: (record) => record.status === 'pending_review' ? (
|
||||
render: (record) => (
|
||||
<div className="audit-actions">
|
||||
<Button icon={<Check size={15} />} onClick={() => setApproveTarget(record)} size="sm" variant="success">通过</Button>
|
||||
<Button icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger">驳回</Button>
|
||||
<Button icon={<Info size={15} />} onClick={() => setDetailTarget(record)} size="sm" variant="ghost">更多信息</Button>
|
||||
{record.status === 'pending_review' ? <>
|
||||
<Button icon={<Check size={15} />} onClick={() => setApproveTarget(record)} size="sm" variant="success">通过</Button>
|
||||
<Button icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger">驳回</Button>
|
||||
</> : null}
|
||||
</div>
|
||||
) : null,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -168,6 +181,18 @@ export function AdminSmsAuditPage() {
|
||||
<p>{approveTarget === 'batch' ? `确认通过已选择的 ${selectedIds.length} 条待审核任务?` : '确认通过该短信审核任务?'}</p>
|
||||
</Modal>
|
||||
|
||||
{detailTarget ? <Modal footer={<Button onClick={() => setDetailTarget(null)}>关闭</Button>} onClose={() => setDetailTarget(null)} open title="审核任务更多信息">
|
||||
<div className="detail-grid">
|
||||
<div><span>任务编号</span><strong>{detailTarget.taskNo}</strong></div>
|
||||
<div><span>提交时间</span><strong>{formatDateTime(detailTarget.createdAt)}</strong></div>
|
||||
<div><span>审核人</span><strong>{detailTarget.reviewedBy?.displayName || detailTarget.reviewedBy?.username || '-'}</strong></div>
|
||||
<div><span>审核时间</span><strong>{formatDateTime(detailTarget.reviewedAt)}</strong></div>
|
||||
<div className="detail-grid__wide"><span>审核原因</span><strong>{detailTarget.reviewReason || '-'}</strong></div>
|
||||
<div className="detail-grid__wide"><span>驳回原因</span><strong>{detailTarget.rejectReason || '-'}</strong></div>
|
||||
<div className="detail-grid__wide"><span>风控命中</span><strong>{detailTarget.riskHits?.map((item) => item.reason).join(';') || '-'}</strong></div>
|
||||
</div>
|
||||
</Modal> : null}
|
||||
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
|
||||
@@ -177,6 +177,7 @@ function SendDetailModal({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const routeRows = buildRouteRows(record);
|
||||
const sentAccessNumber = `${record.channel?.srcId ?? ''}${record.applicationExtension ?? ''}`;
|
||||
return (
|
||||
<Modal
|
||||
footer={<Button onClick={onClose} variant="ghost">关闭</Button>}
|
||||
@@ -207,6 +208,14 @@ function SendDetailModal({
|
||||
<span>号码归属</span>
|
||||
<strong>{record.province ?? '-'} / {getCarrierLabel(record.carrier)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>收到的接入号</span>
|
||||
<strong>{record.clientSrcId || '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发送的接入号</span>
|
||||
<strong>{sentAccessNumber || '-'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<section>
|
||||
<h3><MessageSquare size={18} /> 短信内容</h3>
|
||||
@@ -292,8 +301,19 @@ export function AdminSmsRecordsPage() {
|
||||
const [error, setError] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
function loadData() {
|
||||
adminApi.listOperationMessages({
|
||||
type MessageFilters = {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
phoneNumber?: string;
|
||||
contentKeyword?: string;
|
||||
channelKeyword?: string;
|
||||
queuedAtFrom?: string;
|
||||
queuedAtTo?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
function currentFilters(): MessageFilters {
|
||||
return {
|
||||
tenantId: enterprise === 'all' ? undefined : enterprise,
|
||||
applicationId: application === 'all' ? undefined : application,
|
||||
phoneNumber: phoneKeyword || undefined,
|
||||
@@ -302,9 +322,14 @@ export function AdminSmsRecordsPage() {
|
||||
queuedAtFrom: dateRange.start,
|
||||
queuedAtTo: dateRange.end,
|
||||
status: status === 'all' ? undefined : status,
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function loadData(filters = currentFilters()) {
|
||||
adminApi.listOperationMessages(filters)
|
||||
.then((items) => {
|
||||
setRecords(items);
|
||||
setSelectedRecord((current) => current ? items.find((item) => item.id === current.id) ?? null : null);
|
||||
setPage(1);
|
||||
setError('');
|
||||
})
|
||||
@@ -373,6 +398,7 @@ export function AdminSmsRecordsPage() {
|
||||
setContentKeyword('');
|
||||
setChannelKeyword('');
|
||||
setStatus('all');
|
||||
loadData({});
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -412,7 +438,7 @@ export function AdminSmsRecordsPage() {
|
||||
value={status}
|
||||
/>
|
||||
<div className="admin-sms-record-filter__actions">
|
||||
<Button icon={<Search size={16} />} onClick={loadData}>查询</Button>
|
||||
<Button icon={<Search size={16} />} onClick={() => loadData()}>查询</Button>
|
||||
<Button onClick={resetFilters} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -217,7 +217,7 @@ export function ClientTemplatesPage() {
|
||||
|
||||
function loadData() {
|
||||
setLoading(true);
|
||||
Promise.all([clientApi.listApplications(), clientApi.listTemplates(), clientApi.listSignatures()])
|
||||
Promise.all([clientApi.listApplications(), clientApi.listTemplates({ includeHistory: true }), clientApi.listSignatures()])
|
||||
.then(([applicationItems, templateItems, signatureItems]) => {
|
||||
setApplications(applicationItems.filter((item) => item.status === 'active'));
|
||||
setTemplates(templateItems.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled'));
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
@media (max-width: 780px) {
|
||||
.client-users-table-card .client-user-actions {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.client-users-table-card .client-user-actions .ui-button {
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
min-width: 0;
|
||||
padding-inline: var(--space-2);
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.client-users-table-card .ui-table td[data-label] {
|
||||
grid-template-columns: 76px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { clientApi, type ManagedUser, type UserPayload } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { readSession } from '@/api/session';
|
||||
import { Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import './ClientUsersPage.css';
|
||||
|
||||
type UserForm = {
|
||||
displayName: string;
|
||||
@@ -141,7 +142,7 @@ export function ClientUsersPage() {
|
||||
title: '操作',
|
||||
width: '290px',
|
||||
render: (record) => (
|
||||
<div className="inline-actions">
|
||||
<div className="inline-actions client-user-actions" aria-label={`${record.displayName}的用户操作`}>
|
||||
<Button icon={<Edit3 size={15} />} onClick={() => openEditor(record)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button icon={<KeyRound size={15} />} onClick={() => { setPasswordUser(record); setNewPassword(''); }} size="sm" variant="ghost">改密</Button>
|
||||
<Button onClick={() => setConfirmAction({ type: 'status', user: record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>{record.status === 'active' ? '禁用' : '启用'}</Button>
|
||||
@@ -165,7 +166,7 @@ export function ClientUsersPage() {
|
||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索用户名、邮箱或手机号" prefix={<Search size={16} />} value={keyword} />
|
||||
</div>
|
||||
{error ? <div className="surface empty-state">{error}</div> : null}
|
||||
<div className="surface system-table-card">
|
||||
<div className="surface system-table-card client-users-table-card">
|
||||
<Table columns={columns} data={filteredUsers} emptyText="暂无用户" rowKey="id" />
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user