feat: harden CMPP delivery and platform workflows

This commit is contained in:
hectorzhao
2026-07-20 18:07:29 +08:00
parent 80fb5a8f53
commit f02c33cbb7
61 changed files with 1834 additions and 281 deletions
+21 -3
View File
@@ -1,4 +1,5 @@
import { clearSession, dispatchSessionEvent, getSessionTenantId, hasRecentUserActivity, readSession, requestReauthentication, type LoginSession } from './session';
import { assertUploadFileSize } from '@/utils/fileUpload';
type RequestOptions = RequestInit & {
tenantId?: string;
@@ -346,8 +347,10 @@ export type ClientSmsSignature = {
};
export type ClientSmsSignatureView = Pick<ClientSmsSignature,
'id' | 'tenantId' | 'applicationId' | 'name' | 'purpose' | 'auditStatus' | 'rejectReason' | 'createdAt' | 'updatedAt' | 'materials'
'id' | 'tenantId' | 'applicationId' | 'name' | 'purpose' | 'auditStatus' | 'reportStatus' | 'rejectReason' | 'createdAt' | 'updatedAt' | 'materials'
> & {
pendingReport?: boolean;
reportChangedAt?: string;
application?: Pick<ClientSmsApplication, 'id' | 'name' | 'status'> | null;
submittedMaterialCount: number;
reportValues: Record<string, unknown>;
@@ -474,6 +477,8 @@ export type SmsMessageRecord = {
carrier?: string | null;
province?: string | null;
content: string;
clientSrcId?: string | null;
applicationExtension?: string | null;
billingUnits: number;
amountCents: number;
status: string;
@@ -794,6 +799,7 @@ export type RiskReviewTask = {
rejectReason?: string | null;
createdAt: string;
reviewedAt?: string | null;
reviewedBy?: { id: string; username: string; displayName: string } | null;
riskHits?: Array<{ id: string; ruleName: string; reason: string }>;
_count?: { messageRecords: number };
};
@@ -845,6 +851,7 @@ export type DailyReconciliationReport = {
applicationName: string;
sentUnits: number;
successUnits: number;
failedUnits: number;
generatedAt: string;
updatedAt: string;
};
@@ -861,7 +868,9 @@ export type DailyProfitReport = {
channelId?: string | null;
sentUnits: number;
successUnits: number;
failedUnits: number;
revenueCents: number;
refundCents: number;
costCents: number;
profitCents: number;
profitRateBps: number;
@@ -883,6 +892,7 @@ export type DailyQualityReport = {
drainageInfoId?: string | null;
sentUnits: number;
successUnits: number;
failedUnits: number;
successRateBps: number;
avgArrivalMs?: number | null;
generatedAt: string;
@@ -1351,6 +1361,7 @@ export const adminApi = {
saveReportImportProfile: (body: Omit<ReportImportProfile, 'id'> & { id?: string }) =>
request<ReportImportProfile>('/admin/report-materials/import-profiles', { method: 'POST', body: JSON.stringify(body) }),
analyzeReportMaterialImport: (file: File, body: { tenantId: string; applicationId?: string; reportType: 'signature' | 'drainage'; sheetName?: string; headerRowCount?: number; dataStartRow?: number; profileId?: string }) => {
assertUploadFileSize(file);
const form = new FormData();
form.set('file', file);
Object.entries(body).forEach(([key, value]) => { if (value !== undefined) form.set(key, String(value)); });
@@ -1433,6 +1444,7 @@ export const adminApi = {
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 }) =>
request<DictionaryItem>('/admin/dictionaries/phone-carrier-rules', { method: 'POST', body: JSON.stringify(body) }),
deletePhoneCarrierRule: (id: string) => request<DictionaryItem>(`/admin/dictionaries/phone-carrier-rules/${id}`, { method: 'DELETE' }),
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) }),
@@ -1442,6 +1454,7 @@ export const adminApi = {
request<CommonReportField>('/admin/dictionaries/common-report-fields', { method: 'POST', body: JSON.stringify(body) }),
deleteCommonReportField: (id: string) => request<CommonReportField>(`/admin/dictionaries/common-report-fields/${id}`, { method: 'DELETE' }),
uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }, tenantId?: string) => {
assertUploadFileSize(file);
const form = new FormData();
form.set('file', file);
form.set('purpose', body.purpose);
@@ -1539,8 +1552,12 @@ export const clientApi = {
request<SmsDrainageInfo>(`/client/drainage-infos/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
changeDrainageInfoStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsDrainageInfo>(`/client/drainage-infos/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }),
listTemplates: (query: { status?: string; keyword?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsTemplate[]>(withQuery('/client/templates', query), { tenantId }),
listTemplates: (query: { status?: string; keyword?: string; includeHistory?: boolean } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsTemplate[]>(withQuery('/client/templates', {
status: query.status,
keyword: query.keyword,
includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory),
}), { tenantId }),
createTemplate: (body: { tenantId?: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsTemplate>('/client/templates', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
updateTemplate: (id: string, body: { applicationId?: string; signatureId?: string | null; name?: string; content?: string; category?: string; auditStatus?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
@@ -1568,6 +1585,7 @@ export const clientApi = {
createFileObject: (body: { bucket?: string; objectKey: string; fileName: string; contentType: string; sizeBytes: number; purpose: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<FileObject>('/admin/files', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId, bucket: body.bucket ?? 'cmpp-platform' }) }),
uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => {
assertUploadFileSize(file);
const form = new FormData();
form.set('file', file);
form.set('purpose', body.purpose);
+2 -1
View File
@@ -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} />
+29 -1
View File
@@ -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>
);
}
+5 -5
View File
@@ -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>
+5 -5
View File
@@ -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>
+15 -4
View File
@@ -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">
+32 -7
View File
@@ -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={(
<>
+30 -4
View File
@@ -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>
+1 -1
View File
@@ -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'));
+22
View File
@@ -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);
}
}
+3 -2
View File
@@ -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>
+1 -1
View File
@@ -27,7 +27,7 @@ export function FileActions({ file }: FileActionsProps) {
return (
<span className="file-actions" onClick={(event) => event.stopPropagation()}>
{isImageFile(file) ? (
<Button icon={<Eye size={14} />} onClick={() => setPreviewOpen(true)} size="sm" variant="ghost">
<Button className="file-action-preview" icon={<Eye size={14} />} onClick={() => setPreviewOpen(true)} size="sm" variant="ghost">
</Button>
) : null}
+3
View File
@@ -57,10 +57,13 @@ export function AdminLayout() {
loadPendingAuditCount();
const timer = window.setInterval(loadPendingAuditCount, 30000);
const onFocus = () => loadPendingAuditCount();
const onAuditRefresh = () => loadPendingAuditCount();
window.addEventListener('focus', onFocus);
window.addEventListener('cmpp-audit-count-refresh', onAuditRefresh);
return () => {
window.clearInterval(timer);
window.removeEventListener('focus', onFocus);
window.removeEventListener('cmpp-audit-count-refresh', onAuditRefresh);
};
}, [loadPendingAuditCount, session?.portal]);
+81 -3
View File
@@ -2896,7 +2896,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(196px, auto);
grid-template-columns: minmax(180px, 1.5fr) minmax(80px, 0.7fr) repeat(3, minmax(64px, 0.65fr)) minmax(196px, auto);
min-height: 58px;
}
@@ -3202,6 +3202,75 @@ h3 {
.cmpp-connection-summary strong {
color: var(--color-text-strong);
font-size: var(--font-size-lg);
overflow-wrap: anywhere;
}
.cmpp-connection-list {
display: grid;
gap: var(--space-4);
}
.cmpp-connection-card {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: grid;
gap: var(--space-4);
min-width: 0;
padding: var(--space-4);
}
.cmpp-connection-card__heading {
align-items: center;
display: flex;
gap: var(--space-3);
justify-content: space-between;
min-width: 0;
}
.cmpp-connection-card__heading > strong {
min-width: 0;
overflow-wrap: anywhere;
}
.cmpp-connection-card__grid {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.cmpp-connection-card__grid > div {
display: grid;
gap: var(--space-1);
min-width: 0;
}
.cmpp-connection-card__grid span {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.cmpp-connection-card__grid strong {
overflow-wrap: anywhere;
}
@media (max-width: 900px) {
.cmpp-connection-summary,
.cmpp-connection-card__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 520px) {
.cmpp-connection-summary,
.cmpp-connection-card__grid {
grid-template-columns: 1fr;
}
}
@media (min-width: 781px) {
.mobile-nav-close {
display: none !important;
}
}
.cmpp-param-detail {
@@ -5251,6 +5320,13 @@ h3 {
text-align: center;
}
.enterprise-upload-button {
min-width: 0;
overflow-wrap: anywhere;
padding: var(--space-3);
width: 176px;
}
.enterprise-upload-panel p {
color: var(--color-text-muted);
line-height: var(--line-height-base);
@@ -9779,7 +9855,8 @@ h3 {
margin-top: 6px;
}
.file-action-link {
.file-action-link,
.file-action-preview.ui-button {
align-items: center;
background: #fff;
border: 1px solid var(--border);
@@ -9794,7 +9871,8 @@ h3 {
text-decoration: none;
}
.file-action-link:hover {
.file-action-link:hover,
.file-action-preview.ui-button:hover {
border-color: var(--primary);
color: var(--primary);
}
+16
View File
@@ -0,0 +1,16 @@
export const IMAGE_UPLOAD_MAX_BYTES = 2 * 1024 * 1024;
export const FILE_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
const IMAGE_FILE_EXTENSION = /\.(?:avif|bmp|gif|heic|heif|jpe?g|png|svg|webp)$/i;
export function isImageUpload(file: Pick<File, 'name' | 'type'>) {
return file.type.toLowerCase().startsWith('image/') || IMAGE_FILE_EXTENSION.test(file.name);
}
export function assertUploadFileSize(file: Pick<File, 'name' | 'size' | 'type'>) {
const image = isImageUpload(file);
const limit = image ? IMAGE_UPLOAD_MAX_BYTES : FILE_UPLOAD_MAX_BYTES;
if (file.size > limit) {
throw new Error(image ? '图片大小不能超过 2MB' : '文件大小不能超过 10MB');
}
}
+9 -2
View File
@@ -8,6 +8,13 @@ const capabilityLabels = [
['uplinkWebhookEnabled', '上行回调'],
] as const;
const deliveryModeLabels: Record<string, string> = {
cmpp: 'CMPP 长连接',
http: 'HTTP Webhook',
both: 'CMPP 长连接 + HTTP Webhook',
none: '不投递',
};
export function formatHttpApiParams(response: HttpApiConfigResponse, origin: string) {
const config = response.config;
const baseUrl = `${origin.replace(/\/$/, '')}/api/openapi/v1`;
@@ -20,7 +27,7 @@ export function formatHttpApiParams(response: HttpApiConfigResponse, origin: str
`QPS限制: ${config?.qpsLimit ?? '-'}`,
`签名时间容差: ${config?.timestampToleranceSeconds ?? '-'}`,
`HTTP IP白名单: ${response.ipAllowlist.join('、') || '未限制'}`,
`回执投递方式: ${config?.receiptDeliveryMode ?? '-'}`,
`上行投递方式: ${config?.uplinkDeliveryMode ?? '-'}`,
`回执投递方式: ${config?.receiptDeliveryMode ? deliveryModeLabels[config.receiptDeliveryMode] ?? config.receiptDeliveryMode : '-'}`,
`上行投递方式: ${config?.uplinkDeliveryMode ? deliveryModeLabels[config.uplinkDeliveryMode] ?? config.uplinkDeliveryMode : '-'}`,
].join('\n');
}