feat: harden CMPP delivery and platform workflows
This commit is contained in:
+21
-3
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user