feat: improve application access and money precision

This commit is contained in:
hectorzhao
2026-07-16 17:54:05 +08:00
parent 9d5c507007
commit faa716b8d0
49 changed files with 1699 additions and 489 deletions
+2
View File
@@ -318,6 +318,7 @@ export type ClientSmsApplication = {
sentToday?: number;
deliveryRate?: number;
cmppStatus?: 'connected' | 'degraded' | 'disconnected' | 'inactive';
interfaceEnabled?: boolean | null;
cmppConnections?: CmppDownstreamConnection[];
httpConfig?: HttpApiConfig | null;
};
@@ -919,6 +920,7 @@ export type EnterpriseApplication = {
cmppMaxConnections?: number | null;
cmppWindowSize?: number | null;
ipAllowlist?: Array<{ id: string; ipCidr: string; remark?: string | null }>;
httpConfig?: HttpApiConfig | null;
tenant?: TenantOption;
sentToday?: number;
deliveryRate?: number;
+10 -4
View File
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type ChannelTestResponse, type CmppConnectionState } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
import { formatCents, isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all';
type ChannelStatus = 'normal' | 'stopped' | 'connecting' | 'failed';
@@ -217,7 +218,8 @@ function ChannelFormModal({
const channel = modal.channel;
const [name, setName] = useState(channel?.name ?? '');
const [carrier, setCarrier] = useState<Carrier>(channel?.carrier ?? 'mobile');
const [unitPrice, setUnitPrice] = useState(channel ? String(channel.unitPrice / 100) : '0.0300');
const [unitPrice, setUnitPrice] = useState(channel ? moneyUnitsToYuan(channel.unitPrice).toFixed(4) : '0.0300');
const [unitPriceError, setUnitPriceError] = useState('');
const [region, setRegion] = useState(channel?.sendRegion ?? '全国');
const [protocol, setProtocol] = useState('CMPP');
const [gatewayHost, setGatewayHost] = useState(channel?.gatewayHost ?? '');
@@ -233,12 +235,16 @@ function ChannelFormModal({
const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16));
function submit() {
if (!isValidMoneyInput(unitPrice)) {
setUnitPriceError('单价必须是非负金额,且最多保留小数点后 4 位');
return;
}
onSubmit({
id: channel?.id ?? String(Math.floor(10000 + Math.random() * 80000)),
name: name || '新建短信通道',
carrier,
sendRegion: region,
unitPrice: Number(unitPrice || 0) * 100,
unitPrice: yuanToMoneyUnits(unitPrice),
status: channel?.status ?? 'connecting',
total: channel?.total ?? 0,
successRate: channel?.successRate ?? 0,
@@ -288,7 +294,7 @@ function ChannelFormModal({
</label>
))}
</div>
<Input label="* 单价(元)" onChange={(event) => setUnitPrice(event.target.value)} value={unitPrice} />
<Input error={unitPriceError} label="* 单价(元)" min="0" onChange={(event) => { setUnitPrice(event.target.value); setUnitPriceError(''); }} step="0.0001" type="number" value={unitPrice} />
<Select label="* 发送地区" onChange={(event) => setRegion(event.target.value)} options={regionOptions} value={region} />
</div>
</section>
@@ -621,7 +627,7 @@ export function AdminChannelsPage() {
</div>
<div className="sms-channel-carrier-price">
<Tag tone={carrierToneMap[channel.carrier]}>{carrierLabelMap[channel.carrier]}</Tag>
<strong>{channel.unitPrice.toFixed(2)} </strong>
<strong>{formatCents(channel.unitPrice)} </strong>
</div>
<div className="sms-channel-status-cell">
<Tag tone={statusToneMap[channel.status]}>{statusLabelMap[channel.status]}</Tag>
+5 -3
View File
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import { ImagePlus } from 'lucide-react';
import { adminApi, type FileRef, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, FileActions, Input, Select, Textarea } from '@/components/ui';
import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
type EnterpriseForm = {
name: string;
@@ -63,7 +64,7 @@ function formFromTenant(tenant: TenantOption, creditCents = 0): EnterpriseForm {
return {
name: tenant.name,
creditCode: profile?.creditCode ?? '',
creditLimit: String(creditCents / 100),
creditLimit: moneyUnitsToYuan(creditCents).toFixed(4),
province: profile?.province ?? '',
city: profile?.city ?? '',
address: profile?.address ?? '',
@@ -121,7 +122,7 @@ export function AdminCustomerFormPage() {
if (!form.contactName.trim()) nextErrors.contactName = '请填写联系人姓名';
if (!form.contactPhone.trim()) nextErrors.contactPhone = '请填写手机号';
const creditLimit = Number(form.creditLimit);
if (!Number.isFinite(creditLimit)) nextErrors.creditLimit = '请填写有效的授信额度';
if (!Number.isFinite(creditLimit) || !isValidMoneyInput(form.creditLimit, { allowNegative: true })) nextErrors.creditLimit = '授信额度最多支持小数点后 4 位';
setErrors(nextErrors);
return Object.keys(nextErrors).length === 0;
}
@@ -135,7 +136,7 @@ export function AdminCustomerFormPage() {
? await adminApi.updateTenant(enterpriseId, payload)
: await adminApi.createTenant(payload);
await adminApi.updateCreditLimit(tenant.id, {
creditCents: Math.round(Number(creditLimit) * 100),
creditCents: yuanToMoneyUnits(creditLimit),
remark: isEdit ? '企业编辑页调整授信额度' : '创建企业初始化授信额度',
});
navigate('/admin/customers');
@@ -223,6 +224,7 @@ export function AdminCustomerFormPage() {
label="授信额度(元)"
onChange={(event) => updateForm('creditLimit', event.target.value)}
required
step="0.0001"
type="number"
value={form.creditLimit}
/>
+4 -4
View File
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { Building2, DollarSign, Plus, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
import { adminApi, type TenantManagementRow } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
import { formatCents } from '@/utils/currency';
import { formatCents, isValidMoneyInput, yuanToMoneyUnits } from '@/utils/currency';
type AdminCustomersPageProps = {
basePath?: string;
@@ -120,7 +120,7 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
async function submitRecharge() {
if (!rechargeTarget) return;
const amount = Number(rechargeForm.amount);
if (!Number.isFinite(amount) || amount === 0) {
if (!Number.isFinite(amount) || !isValidMoneyInput(rechargeForm.amount, { allowNegative: true, allowZero: false })) {
setRechargeError('请填写非 0 的充值金额,支持负数冲正');
return;
}
@@ -128,7 +128,7 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
try {
await adminApi.createManualRecharge({
tenantId: rechargeTarget.id,
amountCents: Math.round(amount * 100),
amountCents: yuanToMoneyUnits(rechargeForm.amount),
remark: rechargeForm.remark,
});
setRechargeTarget(null);
@@ -207,7 +207,7 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
<div className="admin-system-modal-form">
<Input disabled label="企业名称" value={rechargeTarget.name} />
<Input disabled label="当前余额" prefix="¥" value={formatCents(rechargeTarget.account?.balanceCents ?? 0)} />
<Input label="充值金额" onChange={(event) => updateRechargeForm('amount', event.target.value)} prefix="¥" required type="number" value={rechargeForm.amount} />
<Input label="充值金额" onChange={(event) => updateRechargeForm('amount', event.target.value)} prefix="¥" required step="0.0001" type="number" value={rechargeForm.amount} />
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateRechargeForm('remark', event.target.value)} rows={4} value={rechargeForm.remark} />
</div>
{rechargeError ? <p className="form-error">{rechargeError}</p> : null}
@@ -2,8 +2,11 @@ import { useEffect, useMemo, useState } from 'react';
import { Copy, Edit3, Plus, Search, Settings2, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
import { adminApi, type ApplicationCmppParams, type CmppDownstreamConnection, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
import { adminApi, type ApplicationCmppParams, type CmppDownstreamConnection, type EnterpriseApplication, type HttpApiConfigResponse, type TenantOption } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { formatAmount, moneyUnitsToYuan } from '@/utils/currency';
import { copyText } from '@/utils/clipboard';
import { formatHttpApiParams } from '@/utils/interfaceParams';
type SmsApp = {
id: string;
@@ -18,6 +21,7 @@ type SmsApp = {
cmppStatus: 'connected' | 'disconnected' | 'inactive';
cmppConnections: CmppConnection[];
cmppParams: CmppParams;
httpEnabled: boolean;
};
type CmppParams = {
@@ -147,6 +151,7 @@ function formatCmppParams(app: SmsApp, params?: ApplicationCmppParams | null) {
function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: ApplicationCmppParams | null; onClose: () => void }) {
const [copied, setCopied] = useState(false);
const [copyError, setCopyError] = useState('');
const paramsText = formatCmppParams(app, params);
const host = params?.gatewayHost ?? app.cmppParams.host;
const port = params?.gatewayPort ?? app.cmppParams.port;
@@ -156,9 +161,14 @@ function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: Appli
const interfaceType = params?.interfaceType ?? app.cmppParams.interfaceType;
async function copyParams() {
await navigator.clipboard.writeText(paramsText);
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
try {
await copyText(paramsText);
setCopyError('');
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
} catch (failure) {
setCopyError(failure instanceof Error ? failure.message : '复制失败');
}
}
return (
@@ -189,11 +199,34 @@ function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: Appli
<div><span></span><strong>{params?.protocolVersion ?? app.cmppParams.protocolVersion}</strong></div>
</div>
<pre className="cmpp-param-copy">{paramsText}</pre>
{copyError ? <p className="form-error">{copyError}</p> : null}
</div>
</Modal>
);
}
function HttpParamsModal({ app, params, onClose }: { app: SmsApp; params: HttpApiConfigResponse; onClose: () => void }) {
const [copied, setCopied] = useState(false);
const [copyError, setCopyError] = useState('');
const paramsText = formatHttpApiParams(params, window.location.origin);
async function copyParams() {
try {
await copyText(paramsText);
setCopyError('');
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
} catch (failure) {
setCopyError(failure instanceof Error ? failure.message : '复制失败');
}
}
return <Modal footer={<><Button onClick={onClose} variant="ghost"></Button><Button icon={<Copy size={15} />} onClick={() => void copyParams()}>{copied ? '已复制' : '一键复制'}</Button></>} onClose={onClose} open size="xl" title={<div className="template-modal-title"><h2>HTTP接口参数</h2><p>{app.enterprise} / {app.name}</p></div>}>
<pre className="cmpp-param-copy">{paramsText}</pre>
{copyError ? <p className="form-error">{copyError}</p> : null}
</Modal>;
}
function CmppConnectionModal({
app,
onClose,
@@ -252,6 +285,8 @@ export function AdminEnterpriseApplicationsPage() {
const [connectionApp, setConnectionApp] = useState<SmsApp | null>(null);
const [paramsApp, setParamsApp] = useState<SmsApp | null>(null);
const [paramsDetail, setParamsDetail] = useState<ApplicationCmppParams | null>(null);
const [httpParamsApp, setHttpParamsApp] = useState<SmsApp | null>(null);
const [httpParamsDetail, setHttpParamsDetail] = useState<HttpApiConfigResponse | null>(null);
const [error, setError] = useState('');
const [addModalOpen, setAddModalOpen] = useState(false);
const [tenants, setTenants] = useState<TenantOption[]>([]);
@@ -324,8 +359,23 @@ export function AdminEnterpriseApplicationsPage() {
}
async function openParams(app: SmsApp) {
setParamsApp(app);
setParamsDetail(await adminApi.getApplicationCmppParams(app.id));
try {
setParamsApp(app);
setParamsDetail(await adminApi.getApplicationCmppParams(app.id));
} catch (failure) {
setParamsApp(null);
setError(failure instanceof Error ? failure.message : 'CMPP参数加载失败');
}
}
async function openHttpParams(app: SmsApp) {
try {
setHttpParamsApp(app);
setHttpParamsDetail(await adminApi.getApplicationHttpApiConfig(app.id));
} catch (failure) {
setHttpParamsApp(null);
setError(failure instanceof Error ? failure.message : 'HTTP参数加载失败');
}
}
const filteredSmsApps = useMemo(
@@ -340,7 +390,7 @@ export function AdminEnterpriseApplicationsPage() {
{ key: 'enterprise', title: '企业名称', width: '220px', render: (record) => record.enterprise },
{ key: 'sentToday', title: '今日发送', width: '120px', render: (record) => `${record.sentToday.toLocaleString('zh-CN')}` },
{ key: 'deliveryRate', title: '到达率', width: '130px', render: (record) => `${record.deliveryRate}%` },
{ key: 'unitPrice', title: '单价', width: '130px', render: (record) => `${record.unitPrice.toFixed(3)}` },
{ key: 'unitPrice', title: '单价', width: '130px', render: (record) => `${formatAmount(record.unitPrice)}` },
{
key: 'cmppStatus',
title: 'CMPP状态',
@@ -355,8 +405,9 @@ export function AdminEnterpriseApplicationsPage() {
</button>
<button className="cmpp-status-cell__params" onClick={() => { void openParams(record); }} type="button">
<Settings2 size={13} />
CMPP参数
</button>
<button className="cmpp-status-cell__params" disabled={!record.httpEnabled} onClick={() => { void openHttpParams(record); }} type="button">HTTP参数</button>
</div>
),
},
@@ -453,6 +504,7 @@ export function AdminEnterpriseApplicationsPage() {
/>
) : null}
{paramsApp ? <CmppParamsModal app={paramsApp} params={paramsDetail} onClose={() => { setParamsApp(null); setParamsDetail(null); }} /> : null}
{httpParamsApp && httpParamsDetail ? <HttpParamsModal app={httpParamsApp} params={httpParamsDetail} onClose={() => { setHttpParamsApp(null); setHttpParamsDetail(null); }} /> : null}
</section>
);
}
@@ -468,10 +520,11 @@ function mapApplication(application: EnterpriseApplication): SmsApp {
enabled: application.status === 'active',
sentToday: application.sentToday ?? 0,
deliveryRate: application.deliveryRate ?? 0,
unitPrice: (application.customerUnitPrice ?? 0) / 100,
unitPrice: moneyUnitsToYuan(application.customerUnitPrice),
cmppStatus: application.interfaceEnabled === false ? 'inactive' : application.cmppStatus === 'connected' ? 'connected' : application.cmppStatus === 'inactive' ? 'inactive' : 'disconnected',
cmppParams: { host: '', port: 0, interfaceEnabled: application.interfaceEnabled !== false, interfaceType: application.interfaceType ?? 'cmpp20', enterpriseCode: application.cmppEnterpriseCode ?? application.tenant?.code ?? application.tenantId, account: application.cmppAccount ?? application.tenantId, password: '', accessNumber: '', maxConnections: application.cmppMaxConnections ?? 1, heartbeatSeconds: 30, windowSize: application.cmppWindowSize ?? 16, protocolVersion: 'CMPP2.0' },
cmppConnections: connections,
httpEnabled: Boolean(application.httpConfig?.enabled),
};
}
@@ -496,7 +496,7 @@ function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsS
return <Modal footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="按通道修改签名报备状态">
<div className="page-stack"><div className="signature-alert"><Info size={18} /><span></span></div>
{error ? <p className="form-error">{error}</p> : null}
{targets.length ? targets.map((target) => <div className="surface" key={target.channelId} style={{ display: 'grid', gap: 16, gridTemplateColumns: '1fr 220px', padding: 16 }}><div><strong>{target.channel.name}</strong><div className="muted">{target.channel.carrier ?? '未标注运营商'} · {target.channel.code}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state"></div>}
{targets.length ? targets.map((target) => <div className="surface admin-report-target-row" key={target.channelId}><div><strong>{target.channel.name}</strong><div className="muted">{target.channel.carrier ?? '未标注运营商'} · {target.channel.code}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state"></div>}
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入运营商工单、确认依据或人工处理说明" rows={3} value={reason} />
</div>
</Modal>;
@@ -536,7 +536,7 @@ function DrainageReportStatusModal({ item, onClose, onSaved, signature }: { item
return <Modal footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="按通道修改引流信息报备状态">
<div className="page-stack"><div className="signature-alert"><Info size={18} /><span></span></div>
{error ? <p className="form-error">{error}</p> : null}
{targets.length ? targets.map((target) => <div className="surface" key={target.channelId} style={{ display: 'grid', gap: 16, gridTemplateColumns: '1fr 220px', padding: 16 }}><div><strong>{target.channel.name}</strong><div className="muted">{target.channel.carrier ?? '未标注运营商'} · {target.channel.code}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state"></div>}
{targets.length ? targets.map((target) => <div className="surface admin-report-target-row" key={target.channelId}><div><strong>{target.channel.name}</strong><div className="muted">{target.channel.carrier ?? '未标注运营商'} · {target.channel.code}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state"></div>}
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} rows={3} value={reason} />
</div>
</Modal>;
+5 -7
View File
@@ -19,6 +19,7 @@ import {
} from '@/components/ui';
import { adminApi, type DashboardResponse } from '@/api/adminApi';
import { createBarOption, createLineOption } from '@/theme/chartOptions';
import { formatAmount, moneyUnitsToYuan } from '@/utils/currency';
type EnterpriseSpendRank = {
id: string;
@@ -35,10 +36,7 @@ const balanceTone = {
} as const;
function formatCurrency(value: number) {
return value.toLocaleString('zh-CN', {
minimumFractionDigits: 3,
maximumFractionDigits: 3,
});
return formatAmount(value);
}
function formatCount(value: number) {
@@ -68,8 +66,8 @@ export function AdminHome() {
return (dashboard?.accounts ?? []).map((account) => {
const todaySpend = Math.abs(dashboard?.recentRecharges
.filter((item) => item.tenantId === account.tenantId)
.reduce((sum, item) => sum + item.amountCents, 0) ?? 0) / 100;
const availableBalance = (account.balanceCents + account.creditCents) / 100;
.reduce((sum, item) => sum + item.amountCents, 0) ?? 0) / 10_000;
const availableBalance = moneyUnitsToYuan(account.balanceCents + account.creditCents);
return {
id: account.tenantId,
enterprise: account.tenant?.name ?? account.tenantId,
@@ -82,7 +80,7 @@ export function AdminHome() {
const totalSend = dashboard?.today.sent ?? 0;
const averageSuccessRate = dashboard?.today.successRate ?? 0;
const todaySpend = (dashboard?.today.spendCents ?? 0) / 100;
const todaySpend = moneyUnitsToYuan(dashboard?.today.spendCents);
const activeConnectionCount = dashboard?.gatewayConnections.reduce((sum, item) => sum + (item._sum.currentConnections ?? 0), 0) ?? 0;
const downstreamAlertCount = dashboard?.downstreamDeliverySummary?.alertCount ?? 0;
const pendingAudits = dashboard?.pendingAudits ?? { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0, total: 0 };
+40 -24
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { Database, ListFilter, Plus, RotateCcw, Search, Trash2 } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tabs, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
import { adminApi, type DictionaryItem } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
@@ -18,6 +18,32 @@ type CarrierRule = DictionaryItem & {
remark?: string | null;
};
const carrierTone: Record<string, 'success' | 'info' | 'warning' | 'neutral'> = {
: 'success',
: 'info',
: 'warning',
};
type PhoneSegmentSummaryProps = {
icon: ReactNode;
label: string;
description: string;
total: number;
};
function PhoneSegmentSummary({ icon, label, description, total }: PhoneSegmentSummaryProps) {
return (
<section className="phone-segment-summary" aria-label={label}>
<span className="phone-segment-summary__icon">{icon}</span>
<div className="phone-segment-summary__value">
<span>{label}</span>
<strong>{total.toLocaleString('zh-CN')}</strong>
</div>
<p>{description}</p>
</section>
);
}
export function AdminPhoneSegmentsPage() {
const pageSize = 25;
const [segments, setSegments] = useState<PhoneSegment[]>([]);
@@ -121,12 +147,12 @@ export function AdminPhoneSegmentsPage() {
}
const columns = useMemo<Array<TableColumn<PhoneSegment>>>(() => [
{ key: 'segment', title: '手机号段(手机号码前7位)', width: '230px', render: (record) => <strong>{record.prefix}</strong> },
{ key: 'carrier', title: '运营商', width: '150px', render: (record) => record.carrier ?? '-' },
{ key: 'province', title: '省份', width: '140px', render: (record) => record.province ?? '-' },
{ key: 'city', title: '城市', width: '140px', render: (record) => record.city ?? '-' },
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => formatDateTime(record.createdAt) },
{ key: 'actions', title: '操作', width: '120px', align: 'right', render: (record) => <Button icon={<Trash2 size={14} />} onClick={() => setDeleteTarget(record)} size="sm" variant="danger"></Button> },
{ 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> : '-' },
{ key: 'province', title: '省份', width: '110px', render: (record) => record.province ?? '-' },
{ key: 'city', title: '城市', width: '110px', render: (record) => record.city ?? '-' },
{ key: 'createdAt', title: '创建时间', width: '170px', render: (record) => formatDateTime(record.createdAt) },
{ key: 'actions', title: '操作', width: '90px', align: 'right', render: (record) => <Button className="phone-segment-delete" icon={<Trash2 size={14} />} onClick={() => setDeleteTarget(record)} size="sm" variant="ghost"></Button> },
], []);
const ruleColumns = useMemo<Array<TableColumn<CarrierRule>>>(() => [
@@ -138,7 +164,7 @@ export function AdminPhoneSegmentsPage() {
const queryPanel = (
<div className="phone-segment-query">
<Input label="关键词" onChange={(event) => setKeyword(event.target.value)} placeholder={activeTab === 'segments' ? '手机号段、运营商、省份或城市' : '运营商、正则或备注'} prefix={<Search size={16} />} value={keyword} />
<Input aria-label="关键词" onChange={(event) => setKeyword(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') query(); }} placeholder={activeTab === 'segments' ? '搜索手机号段、运营商、省份或城市' : '搜索运营商、正则或备注'} prefix={<Search size={16} />} value={keyword} />
<div className="phone-segment-query__actions">
<Button icon={<Search size={16} />} onClick={query}></Button>
<Button icon={<RotateCcw size={16} />} onClick={reset} variant="ghost"></Button>
@@ -149,9 +175,9 @@ export function AdminPhoneSegmentsPage() {
return (
<section className="page-stack admin-system-page phone-segment-workbench">
<div className="page-heading">
<div>
<div className="phone-segment-heading">
<Breadcrumb items={['系统管理', '手机号段库']} />
<h1></h1>
<p></p>
</div>
<Button icon={<Plus size={16} />} onClick={() => activeTab === 'segments' ? setCreating(true) : setCreatingRule(true)}>
{activeTab === 'segments' ? '新增号段' : '新增规则'}
@@ -159,7 +185,7 @@ export function AdminPhoneSegmentsPage() {
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-system-table-card">
<div className="surface admin-system-table-card phone-segment-panel">
<Tabs
className="phone-segment-workbench__tabs"
onChange={(value) => {
@@ -172,12 +198,7 @@ export function AdminPhoneSegmentsPage() {
value: 'segments',
content: (
<div className="phone-segment-tab-content">
<div className="phone-segment-overview phone-segment-overview--single" aria-label="手机号段统计">
<section>
<span><Database size={20} /></span>
<div><strong>{segmentTotal.toLocaleString('zh-CN')}</strong><p></p></div>
</section>
</div>
<PhoneSegmentSummary description="当前库中可查询的号码前七位记录" icon={<Database size={22} />} label="已收录手机号段" total={segmentTotal} />
{queryPanel}
<Table columns={columns} data={segments} emptyText={loading ? '加载中...' : '暂无手机号段'} pagination={false} rowKey="id" />
<Pagination
@@ -198,12 +219,7 @@ export function AdminPhoneSegmentsPage() {
value: 'rules',
content: (
<div className="phone-segment-tab-content">
<div className="phone-segment-overview phone-segment-overview--single" aria-label="运营商区分规则统计">
<section>
<span><ListFilter size={20} /></span>
<div><strong>{ruleTotal.toLocaleString('zh-CN')}</strong><p></p></div>
</section>
</div>
<PhoneSegmentSummary description="按优先级匹配号码前缀的识别规则" icon={<ListFilter size={22} />} label="运营商识别规则" total={ruleTotal} />
{queryPanel}
<Table columns={ruleColumns} data={rules} emptyText={loading ? '加载中...' : '暂无运营商区分规则'} pagination={false} rowKey="id" />
<Pagination
+1 -1
View File
@@ -63,7 +63,7 @@ export function AdminProfitReportsPage() {
<div className="page-actions"><Button disabled={exporting} icon={<Download size={16} />} onClick={() => void exportData()} size="sm" variant="secondary">{exporting ? '导出中...' : '导出报表'}</Button><Tag tone="info">T+1 · T-4T-1</Tag></div>
</div>
<div className="surface" style={{ display: 'grid', gap: 16, gridTemplateColumns: 'minmax(270px, 1.3fr) minmax(180px, .8fr) minmax(210px, 1fr) minmax(220px, 1fr) auto', padding: 20, alignItems: 'end' }}>
<div className="surface admin-report-filter-grid admin-report-filter-grid--profit">
<DateRangeInput label="发送日期" onChange={(value) => { setDateRange(value); setPage(1); }} value={dateRange} />
<Select label="统计维度" onChange={(event) => { setDimensionType(event.target.value as 'application' | 'channel'); setTenantId(''); setApplicationId(''); setChannelId(''); setPage(1); }} options={[{ label: '按企业应用', value: 'application' }, { label: '按通道', value: 'channel' }]} value={dimensionType} />
{dimensionType === 'application' ? <Select label="企业" onChange={(event) => { setTenantId(event.target.value); setApplicationId(''); setPage(1); }} options={[{ label: '全部企业', value: '' }, ...tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))]} value={tenantId} /> : <Select label="短信通道" onChange={(event) => { setChannelId(event.target.value); setPage(1); }} options={[{ label: '全部通道', value: '' }, ...channels.map((channel) => ({ label: channel.name, value: channel.id }))]} value={channelId} />}
+1 -1
View File
@@ -72,7 +72,7 @@ export function AdminQualityReportsPage() {
const reportPanel = (
<div className="page-stack" style={{ marginTop: 16 }}>
<div className="surface" style={{ display: 'grid', gap: 16, gridTemplateColumns: 'minmax(280px, 1.4fr) minmax(210px, 1fr) minmax(220px, 1fr) auto', padding: 20, alignItems: 'end' }}>
<div className="surface admin-report-filter-grid admin-report-filter-grid--quality">
<DateRangeInput label="发送日期" onChange={(value) => { setDateRange(value); setPage(1); }} value={dateRange} />
{dimension === 'channel' ? <Select label="短信通道" onChange={(event) => { setChannelId(event.target.value); setPage(1); }} options={[{ label: '全部通道', value: '' }, ...channels.map((channel) => ({ label: channel.name, value: channel.id }))]} value={channelId} /> : <Select label="企业" onChange={(event) => { setTenantId(event.target.value); setApplicationId(''); setPage(1); }} options={[{ label: '全部企业', value: '' }, ...tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))]} value={tenantId} />}
{dimension === 'channel' ? <div /> : <Select label="企业应用" onChange={(event) => { setApplicationId(event.target.value); setPage(1); }} options={[{ label: '全部应用', value: '' }, ...availableApplications.map((application) => ({ label: application.name, value: application.id }))]} value={applicationId} />}
+6 -6
View File
@@ -3,7 +3,7 @@ import { Plus, Search } from 'lucide-react';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Textarea, Tag, type DateRangeValue } from '@/components/ui';
import { adminApi, type RechargeOrder, type TenantOption } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { formatAmount } from '@/utils/currency';
import { formatCents, isValidMoneyInput, yuanToMoneyUnits } from '@/utils/currency';
type ManualRechargeForm = {
tenantId: string;
@@ -91,7 +91,7 @@ export function AdminRechargeRecordsPage() {
async function submitManualRecharge() {
const amount = Number(form.amount);
if (!form.tenantId || !Number.isFinite(amount) || amount === 0) {
if (!form.tenantId || !Number.isFinite(amount) || !isValidMoneyInput(form.amount, { allowNegative: true, allowZero: false })) {
setManualError('请填写非 0 的充值金额;金额支持负数冲正。');
return;
}
@@ -100,7 +100,7 @@ export function AdminRechargeRecordsPage() {
try {
await adminApi.createManualRecharge({
tenantId: form.tenantId,
amountCents: Math.round(amount * 100),
amountCents: yuanToMoneyUnits(form.amount),
remark: form.remark,
});
await loadData();
@@ -158,8 +158,8 @@ export function AdminRechargeRecordsPage() {
<tr key={record.id}>
<td><strong>{tenantName}</strong></td>
<td>{formatDateTime(record.paidAt ?? record.createdAt)}</td>
<td>¥{formatAmount(record.amountCents / 100)}</td>
<td>{record.balanceAfterCents === null || record.balanceAfterCents === undefined ? '-' : `¥${formatAmount(record.balanceAfterCents / 100)}`}</td>
<td>¥{formatCents(record.amountCents)}</td>
<td>{record.balanceAfterCents === null || record.balanceAfterCents === undefined ? '-' : `¥${formatCents(record.balanceAfterCents)}`}</td>
<td><Tag tone="warning"></Tag></td>
<td><RemarkCell value={record.remark ?? undefined} /></td>
</tr>
@@ -202,7 +202,7 @@ export function AdminRechargeRecordsPage() {
required
value={form.tenantId}
/>
<Input label="充值金额" onChange={(event) => updateForm('amount', event.target.value)} prefix="¥" required type="number" value={form.amount} />
<Input label="充值金额" onChange={(event) => updateForm('amount', event.target.value)} prefix="¥" required step="0.0001" type="number" value={form.amount} />
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateForm('remark', event.target.value)} rows={4} value={form.remark} />
</div>
{manualError ? <p className="form-error">{manualError}</p> : null}
@@ -70,7 +70,7 @@ export function AdminReconciliationReportsPage() {
<div className="page-actions"><Button disabled={exporting} icon={<Download size={16} />} onClick={() => void exportData()} size="sm" variant="secondary">{exporting ? '导出中...' : '导出报表'}</Button><Tag tone="info">T+1 · T-4T-1</Tag></div>
</div>
<div className="surface" style={{ display: 'grid', gap: 16, gridTemplateColumns: 'minmax(280px, 1.4fr) minmax(200px, 1fr) minmax(220px, 1fr) auto', padding: 20, alignItems: 'end' }}>
<div className="surface admin-report-filter-grid admin-report-filter-grid--reconciliation">
<DateRangeInput label="发送日期" onChange={(value) => { setDateRange(value); setPage(1); }} value={dateRange} />
<Select label="企业" onChange={(event) => { setTenantId(event.target.value); setApplicationId(''); setPage(1); }} options={[{ label: '全部企业', value: '' }, ...tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))]} value={tenantId} />
<Select label="企业应用" onChange={(event) => { setApplicationId(event.target.value); setPage(1); }} options={[{ label: '全部应用', value: '' }, ...availableApplications.map((application) => ({ label: application.name, value: application.id }))]} value={applicationId} />
+97 -108
View File
@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, Info, RadioTower, RefreshCw } from 'lucide-react';
import { ArrowLeft, Globe2, Info, RadioTower, RefreshCw } from 'lucide-react';
import { adminApi, type ChannelGroup, type DictionaryItem, type EnterpriseApplication, type HttpApiConfig } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui';
import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
type Carrier = 'mobile' | 'unicom' | 'telecom';
type QueuePriority = 'normal' | 'priority';
@@ -21,6 +22,15 @@ const deliveryModeOptions = [
{ label: '不投递', value: 'none' },
];
const httpCapabilityOptions: Array<{ key: keyof HttpApiConfig; label: string }> = [
{ key: 'sendEnabled', label: '单条发送' },
{ key: 'messageQueryEnabled', label: '状态查询' },
{ key: 'receiptWebhookEnabled', label: '回执回调' },
{ key: 'uplinkQueryEnabled', label: '上行查询' },
{ key: 'uplinkWebhookEnabled', label: '上行回调' },
{ key: 'credentialSelfServiceEnabled', label: '客户端自助密钥' },
];
export function AdminSmsApplicationFormPage() {
const navigate = useNavigate();
const { enterpriseId, appId } = useParams();
@@ -113,7 +123,7 @@ export function AdminSmsApplicationFormPage() {
setAppName(application.name);
setScene(application.scene ?? '');
setDailyLimit(application.dailyLimit ? String(application.dailyLimit) : '');
setCustomerUnitPrice(((application.customerUnitPrice ?? 0) / 100).toFixed(3));
setCustomerUnitPrice(moneyUnitsToYuan(application.customerUnitPrice).toFixed(4));
setQueuePriority(application.queuePriority === 'priority' ? 'priority' : 'normal');
setCmppAccount(application.cmppAccount ?? '');
setApplicationExtension(application.cmppApplicationExtension ?? '');
@@ -154,6 +164,10 @@ export function AdminSmsApplicationFormPage() {
setError('请至少配置一个运营商通道组');
return;
}
if (!isValidMoneyInput(customerUnitPrice)) {
setError('客户单价必须是非负金额,且最多保留小数点后 4 位');
return;
}
const normalizedExtension = applicationExtension.trim();
const normalizedFillPrefix = accessNumberFillPrefix.trim();
if (normalizedExtension && !/^\d+$/.test(normalizedExtension)) {
@@ -176,7 +190,7 @@ export function AdminSmsApplicationFormPage() {
name: appName,
scene,
dailyLimit: Number(dailyLimit) || undefined,
customerUnitPrice: Math.round(Number(customerUnitPrice || 0) * 100),
customerUnitPrice: yuanToMoneyUnits(customerUnitPrice),
queuePriority,
cmppAccount: cmppAccount.trim() || undefined,
cmppApplicationExtension: normalizedExtension,
@@ -246,7 +260,7 @@ export function AdminSmsApplicationFormPage() {
<Input label="应用名称" onChange={(event) => setAppName(event.target.value)} placeholder="请输入应用名称" required value={appName} />
<Input label="应用场景" onChange={(event) => setScene(event.target.value)} placeholder="行业通知/营销推广/验证码" value={scene} />
<Input label="日发送数量限制" onChange={(event) => setDailyLimit(event.target.value)} placeholder="100000" required value={dailyLimit} />
<Input label="客户单价(元/条)" onChange={(event) => setCustomerUnitPrice(event.target.value)} placeholder="0.030" required value={customerUnitPrice} />
<Input label="客户单价(元/条)" onChange={(event) => setCustomerUnitPrice(event.target.value)} placeholder="0.0300" required step="0.0001" type="number" value={customerUnitPrice} />
<div className="admin-app-form-row admin-app-form-row--wide">
<span></span>
<div className="radio-row">
@@ -279,118 +293,93 @@ export function AdminSmsApplicationFormPage() {
</div>
</section>
<section className="ui-detail-section">
<div className="ui-detail-section__header">
<h3></h3>
<p>CMPP HTTP CMPPHTTP</p>
<section className="ui-detail-section admin-app-protocol-section admin-app-protocol-section--cmpp">
<div className="ui-detail-section__header admin-app-protocol-header">
<div className="admin-app-protocol-heading">
<span className="admin-app-protocol-icon"><RadioTower size={19} /></span>
<div><h3>CMPP </h3><p></p></div>
</div>
<button className={interfaceEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setInterfaceEnabled((current) => !current)} type="button">
<span />
{interfaceEnabled ? '已开通' : '未开通'}
</button>
</div>
<div className="admin-app-form-grid">
<div className="admin-app-form-row admin-app-form-row--wide">
<span>CMPP </span>
<button className={interfaceEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setInterfaceEnabled((current) => !current)} type="button">
<span />
{interfaceEnabled ? '开通' : '关闭'}
</button>
</div>
<div className="admin-app-form-row admin-app-form-row--wide">
<span>CMPP </span>
<div className="radio-row">
<label>
<input checked={interfaceType === 'cmpp20'} onChange={() => setInterfaceType('cmpp20')} type="radio" />
CMPP2.0
</label>
{interfaceEnabled ? (
<div className="admin-app-form-grid admin-app-protocol-body">
<div className="admin-app-form-row admin-app-form-row--wide">
<span>CMPP </span>
<div className="radio-row"><label><input checked={interfaceType === 'cmpp20'} onChange={() => setInterfaceType('cmpp20')} type="radio" />CMPP2.0</label></div>
</div>
</div>
<div className="admin-app-form-row admin-app-form-row--wide">
<span>HTTP </span>
<button className={httpConfig.enabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setHttpConfig((current) => ({ ...current, enabled: !current.enabled }))} type="button"><span />{httpConfig.enabled ? '开通' : '关闭'}</button>
<div className="admin-app-form-tip"><Info size={17} /><span>/访</span></div>
</div>
{httpConfig.enabled ? (
<>
<div className="admin-app-form-row admin-app-form-row--wide">
<span>HTTP </span>
<div className="radio-row">
{([
['sendEnabled', '单条发送'], ['messageQueryEnabled', '状态查询'], ['receiptWebhookEnabled', '回执回调'],
['uplinkQueryEnabled', '上行查询'], ['uplinkWebhookEnabled', '上行回调'], ['credentialSelfServiceEnabled', '客户端自助密钥'],
] as Array<[keyof HttpApiConfig, string]>).map(([key, label]) => <label key={key}><input checked={Boolean(httpConfig[key])} onChange={() => setHttpConfig((current) => ({ ...current, [key]: !current[key] }))} type="checkbox" />{label}</label>)}
</div>
</div>
<Input label="HTTP IP 白名单" onChange={(event) => setHttpIpAddress(event.target.value)} placeholder="独立于CMPP;多个IP/CIDR可换行填写,留空表示不限制" value={httpIpAddress} />
<Input label="HTTP QPS" onChange={(event) => setHttpConfig((current) => ({ ...current, qpsLimit: Number(event.target.value) || 1 }))} value={String(httpConfig.qpsLimit)} />
<Input label="签名时间容差(秒)" onChange={(event) => setHttpConfig((current) => ({ ...current, timestampToleranceSeconds: Number(event.target.value) || 300 }))} value={String(httpConfig.timestampToleranceSeconds)} />
<Input label="最多有效凭据数" onChange={(event) => setHttpConfig((current) => ({ ...current, maxCredentialCount: Number(event.target.value) || 2 }))} value={String(httpConfig.maxCredentialCount)} />
<Select label="回执投递方式" onChange={(event) => setHttpConfig((current) => ({ ...current, receiptDeliveryMode: event.target.value as HttpApiConfig['receiptDeliveryMode'] }))} options={deliveryModeOptions} value={httpConfig.receiptDeliveryMode} />
<Select label="上行投递方式" onChange={(event) => setHttpConfig((current) => ({ ...current, uplinkDeliveryMode: event.target.value as HttpApiConfig['uplinkDeliveryMode'] }))} options={deliveryModeOptions} value={httpConfig.uplinkDeliveryMode} />
<Input label="Webhook 超时(秒)" onChange={(event) => setHttpConfig((current) => ({ ...current, webhookTimeoutSeconds: Number(event.target.value) || 10 }))} value={String(httpConfig.webhookTimeoutSeconds)} />
<Input label="Webhook 最大尝试次数" onChange={(event) => setHttpConfig((current) => ({ ...current, webhookMaxAttempts: Number(event.target.value) || 7 }))} value={String(httpConfig.webhookMaxAttempts)} />
<div className="admin-app-form-row admin-app-form-row--wide"><span></span><div className="radio-row">
<label><input checked={httpConfig.requireHttps} onChange={() => setHttpConfig((current) => ({ ...current, requireHttps: !current.requireHttps }))} type="checkbox" /> HTTPS</label>
<label><input checked={httpConfig.webhookRetryEnabled} onChange={() => setHttpConfig((current) => ({ ...current, webhookRetryEnabled: !current.webhookRetryEnabled }))} type="checkbox" />Webhook </label>
<label><input checked={httpConfig.allowClientManualRetry} onChange={() => setHttpConfig((current) => ({ ...current, allowClientManualRetry: !current.allowClientManualRetry }))} type="checkbox" /></label>
</div></div>
</>
) : null}
<Input label="CMPP 6位账号" onChange={(event) => setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} />
<Input disabled hint="企业代码始终与 CMPP 6位账号一致;账号留空自动生成时,保存后自动生成相同企业代码。" label="企业代码" placeholder="跟随 CMPP 6位账号自动生成" value={cmppAccount} />
<Input
hint="真实扩展码会追加到上游通道基础接入号后,例如基础号 1069999999、扩展码 0001,最终发送号为 10699999990001。留空则继续使用通道基础号。"
label="应用扩展码"
onChange={(event) => setApplicationExtension(event.target.value)}
placeholder="例如 0001"
value={applicationExtension}
/>
<div className="admin-app-form-row admin-app-form-row--wide">
<span></span>
<button className={accessNumberFillEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setAccessNumberFillEnabled((current) => !current)} type="button">
<span />
{accessNumberFillEnabled ? '开启' : '关闭'}
</button>
<div className="admin-app-form-tip">
<Info size={17} />
<span> Src_Id </span>
</div>
</div>
{accessNumberFillEnabled ? (
<Input label="CMPP 6位账号" onChange={(event) => setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} />
<Input disabled hint="企业代码始终与 CMPP 6位账号一致;账号留空自动生成时,保存后自动生成相同企业代码。" label="企业代码" placeholder="跟随 CMPP 6位账号自动生成" value={cmppAccount} />
<Input
hint="只能填写数字,且只允许作为客户 Src_Id 的开头前缀。"
label="填充前缀"
onChange={(event) => setAccessNumberFillPrefix(event.target.value)}
placeholder="例如 00"
required
value={accessNumberFillPrefix}
hint="真实扩展码会追加到上游通道基础接入号后,例如基础号 1069999999、扩展码 0001,最终发送号为 10699999990001。留空则继续使用通道基础号。"
label="应用扩展码"
onChange={(event) => setApplicationExtension(event.target.value)}
placeholder="例如 0001"
value={applicationExtension}
/>
) : null}
<Input disabled hint="客户 CMPP SUBMIT 必须填写该完整值;填充前缀不会发送给上游。" label="客户侧接入号" placeholder="根据填充前缀和应用扩展码自动生成" value={clientSrcIdPreview} />
<Input
hint={isEdit ? '留空则不修改接口密码;填写 16 位字符后覆盖。' : '默认随机生成,可按需修改。'}
label="接口密码"
onChange={(event) => setPasswordCipher(event.target.value)}
placeholder="16 位接口密码"
suffix={<button aria-label="随机生成接口密码" className="icon-button" onClick={() => setPasswordCipher(generateApplicationPassword())} type="button"><RefreshCw size={15} /></button>}
value={passwordCipher}
/>
<Input label="客户最大连接数" onChange={(event) => setCmppMaxConnections(event.target.value)} placeholder="1" required value={cmppMaxConnections} />
<Input label="IP 白名单" onChange={(event) => setIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可用逗号、空格或换行分隔" value={ipAddress} />
<div className="admin-app-form-row admin-app-form-row--wide">
<span></span>
<div className="radio-row">
<button className={downstreamReceiptRetryEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setDownstreamReceiptRetryEnabled((current) => !current)} type="button">
<span />
{downstreamReceiptRetryEnabled ? '开启' : '关闭'}
</button>
<button className={downstreamUplinkRetryEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setDownstreamUplinkRetryEnabled((current) => !current)} type="button">
<span />
{downstreamUplinkRetryEnabled ? '开启' : '关闭'}
</button>
<div className="admin-app-form-row admin-app-form-row--wide">
<span></span>
<button className={accessNumberFillEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setAccessNumberFillEnabled((current) => !current)} type="button"><span />{accessNumberFillEnabled ? '开启' : '关闭'}</button>
<div className="admin-app-form-tip"><Info size={17} /><span> Src_Id </span></div>
</div>
<div className="admin-app-form-tip">
<Info size={17} />
<span> CMPP_DELIVER_RESP </span>
{accessNumberFillEnabled ? <Input hint="只能填写数字,且只允许作为客户 Src_Id 的开头前缀。" label="填充前缀" onChange={(event) => setAccessNumberFillPrefix(event.target.value)} placeholder="例如 00" required value={accessNumberFillPrefix} /> : null}
<Input disabled hint="客户 CMPP SUBMIT 必须填写该完整值;填充前缀不会发送给上游。" label="客户侧接入号" placeholder="根据填充前缀和应用扩展码自动生成" value={clientSrcIdPreview} />
<Input
hint={isEdit ? '留空则不修改接口密码;填写 16 位字符后覆盖。' : '默认随机生成,可按需修改。'}
label="CMPP 接口密码"
onChange={(event) => setPasswordCipher(event.target.value)}
placeholder="16 位接口密码"
suffix={<button aria-label="随机生成接口密码" className="icon-button" onClick={() => setPasswordCipher(generateApplicationPassword())} type="button"><RefreshCw size={15} /></button>}
value={passwordCipher}
/>
<Input label="客户最大连接数" onChange={(event) => setCmppMaxConnections(event.target.value)} placeholder="1" required value={cmppMaxConnections} />
<Input label="CMPP IP 白名单" onChange={(event) => setIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可用逗号、空格或换行分隔" value={ipAddress} />
<div className="admin-app-form-row admin-app-form-row--wide">
<span>CMPP </span>
<div className="radio-row">
<button className={downstreamReceiptRetryEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setDownstreamReceiptRetryEnabled((current) => !current)} type="button"><span />{downstreamReceiptRetryEnabled ? '开启' : '关闭'}</button>
<button className={downstreamUplinkRetryEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setDownstreamUplinkRetryEnabled((current) => !current)} type="button"><span />{downstreamUplinkRetryEnabled ? '开启' : '关闭'}</button>
</div>
<div className="admin-app-form-tip"><Info size={17} /><span> CMPP_DELIVER_RESP </span></div>
</div>
</div>
) : <div className="admin-app-protocol-empty">CMPP </div>}
</section>
<section className="ui-detail-section admin-app-protocol-section admin-app-protocol-section--http">
<div className="ui-detail-section__header admin-app-protocol-header">
<div className="admin-app-protocol-heading">
<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>
</div>
{httpConfig.enabled ? (
<div className="admin-app-form-grid admin-app-protocol-body">
<div className="admin-app-form-row admin-app-form-row--wide">
<span>HTTP </span>
<div className="radio-row">
{httpCapabilityOptions.map(({ key, label }) => <label key={key}><input checked={Boolean(httpConfig[key])} onChange={() => setHttpConfig((current) => ({ ...current, [key]: !current[key] }))} type="checkbox" />{label}</label>)}
</div>
<div className="admin-app-form-tip"><Info size={17} /><span>访HTTP CMPP </span></div>
</div>
<Input label="HTTP IP 白名单" onChange={(event) => setHttpIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可换行填写,留空表示不限制" value={httpIpAddress} />
<Input label="HTTP QPS" onChange={(event) => setHttpConfig((current) => ({ ...current, qpsLimit: Number(event.target.value) || 1 }))} value={String(httpConfig.qpsLimit)} />
<Input label="签名时间容差(秒)" onChange={(event) => setHttpConfig((current) => ({ ...current, timestampToleranceSeconds: Number(event.target.value) || 300 }))} value={String(httpConfig.timestampToleranceSeconds)} />
<Input label="最多有效凭据数" onChange={(event) => setHttpConfig((current) => ({ ...current, maxCredentialCount: Number(event.target.value) || 2 }))} value={String(httpConfig.maxCredentialCount)} />
<Select label="回执投递方式" onChange={(event) => setHttpConfig((current) => ({ ...current, receiptDeliveryMode: event.target.value as HttpApiConfig['receiptDeliveryMode'] }))} options={deliveryModeOptions} value={httpConfig.receiptDeliveryMode} />
<Select label="上行投递方式" onChange={(event) => setHttpConfig((current) => ({ ...current, uplinkDeliveryMode: event.target.value as HttpApiConfig['uplinkDeliveryMode'] }))} options={deliveryModeOptions} value={httpConfig.uplinkDeliveryMode} />
<Input label="Webhook 超时(秒)" onChange={(event) => setHttpConfig((current) => ({ ...current, webhookTimeoutSeconds: Number(event.target.value) || 10 }))} value={String(httpConfig.webhookTimeoutSeconds)} />
<Input label="Webhook 最大尝试次数" onChange={(event) => setHttpConfig((current) => ({ ...current, webhookMaxAttempts: Number(event.target.value) || 7 }))} value={String(httpConfig.webhookMaxAttempts)} />
<div className="admin-app-form-row admin-app-form-row--wide"><span>HTTP </span><div className="radio-row">
<label><input checked={httpConfig.requireHttps} onChange={() => setHttpConfig((current) => ({ ...current, requireHttps: !current.requireHttps }))} type="checkbox" /> HTTPS</label>
<label><input checked={httpConfig.webhookRetryEnabled} onChange={() => setHttpConfig((current) => ({ ...current, webhookRetryEnabled: !current.webhookRetryEnabled }))} type="checkbox" />Webhook </label>
<label><input checked={httpConfig.allowClientManualRetry} onChange={() => setHttpConfig((current) => ({ ...current, allowClientManualRetry: !current.allowClientManualRetry }))} type="checkbox" /></label>
</div></div>
</div>
) : <div className="admin-app-protocol-empty">HTTP Webhook </div>}
</section>
<section className="ui-detail-section">
+3 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, Download, MessageSquare, Search, Smartphone } from 'lucide-react';
import { adminApi, type SmsMessageRecord, type SmsMessageSegmentAudit, type SmsReceiptRecord, type SmsSubmitRecord } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, Table, type DateRangeValue, type TableColumn } from '@/components/ui';
import { formatCents } from '@/utils/currency';
const statusLabelMap: Record<string, string> = {
delivered: '发送成功',
@@ -146,7 +147,7 @@ function downloadCsv(records: SmsMessageRecord[]) {
record.province ?? '',
getCarrierLabel(record.carrier),
record.billingUnits,
(record.amountCents / 100).toFixed(3),
formatCents(record.amountCents),
record.channel?.name ?? record.channelId ?? '',
getStatusLabel(record.status),
getTime(record.deliveredAt),
@@ -434,7 +435,7 @@ export function AdminSmsRecordsPage() {
<p className="admin-sms-record-content">{record.content}</p>
<div className="admin-sms-record-card__meta">
<div><span></span><strong>{record.phoneNumber}</strong><small>{record.province ?? '-'} · {getCarrierLabel(record.carrier)}</small></div>
<div><span></span><strong>{record.billingUnits} / ¥{(record.amountCents / 100).toFixed(3)}</strong><small>{record.content.length} </small></div>
<div><span></span><strong>{record.billingUnits} / ¥{formatCents(record.amountCents)}</strong><small>{record.content.length} </small></div>
<div><span></span><strong>{record.channel?.name ?? record.channelId ?? '-'}</strong><small> {getTime(record.deliveredAt)}</small></div>
</div>
<footer><button className="admin-sms-record-detail-link" onClick={() => setSelectedRecord(record)} type="button"></button></footer>
+8 -2
View File
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
import { clientApi, type ApplicationCmppParams, type ClientSmsApplication } from '@/api/adminApi';
import { formatCents } from '@/utils/currency';
import { Button, Modal, Pagination, Tag } from '@/components/ui';
import { copyText } from '@/utils/clipboard';
type LinkStatus = 'connected' | 'degraded' | 'disconnected' | 'inactive';
@@ -67,6 +68,7 @@ export function ClientApplicationsPage() {
const [error, setError] = useState('');
const [paramsError, setParamsError] = useState('');
const [copied, setCopied] = useState(false);
const [copyError, setCopyError] = useState('');
function loadApplications() {
setLoading(true);
@@ -84,6 +86,7 @@ export function ClientApplicationsPage() {
}, []);
function openParams(application: ClientSmsApplication) {
if (application.interfaceEnabled === false) return;
setSelectedApp(application);
setParams(null);
setParamsError('');
@@ -113,7 +116,9 @@ export function ClientApplicationsPage() {
return;
}
const text = selectedRows.map((item) => `${item.label}: ${item.value}`).join('\n');
void navigator.clipboard.writeText(text).then(() => setCopied(true));
void copyText(text)
.then(() => { setCopied(true); setCopyError(''); })
.catch((failure: Error) => setCopyError(failure.message || '复制失败'));
}
return (
@@ -162,7 +167,7 @@ export function ClientApplicationsPage() {
</div>
<div><dt>HTTP接口</dt><dd><Tag tone={application.httpConfig?.enabled ? 'success' : 'info'}>{application.httpConfig?.enabled ? '已开通' : '未开通'}</Tag></dd></div>
</dl>
<div className="table-actions"><Button onClick={() => openParams(application)} variant="ghost">CMPP参数</Button><Button disabled={!application.httpConfig?.enabled} onClick={() => navigate('/client/http-api')} variant="ghost">HTTP接口对接</Button></div>
<div className="table-actions"><Button disabled={application.interfaceEnabled === false} onClick={() => openParams(application)} variant="ghost">{application.interfaceEnabled === false ? 'CMPP未开通' : 'CMPP参数'}</Button><Button disabled={!application.httpConfig?.enabled} onClick={() => navigate('/client/http-api')} variant="ghost">HTTP接口对接</Button></div>
</article>
);
})}
@@ -197,6 +202,7 @@ export function ClientApplicationsPage() {
))}
</div>
) : null}
{copyError ? <p className="form-error">{copyError}</p> : null}
</Modal>
</section>
);
+4 -4
View File
@@ -14,7 +14,7 @@ import { Button, Chart, Table, Tag, type TableColumn } from '@/components/ui';
import { clientApi, type DashboardResponse } from '@/api/adminApi';
import { createLineOption, createPieOption } from '@/theme/chartOptions';
import { formatDateTime } from '@/utils/dateTime';
import { formatAmount, formatCents } from '@/utils/currency';
import { formatAmount, formatCents, moneyUnitsToYuan } from '@/utils/currency';
type RecentTaskRow = {
id: string;
@@ -50,10 +50,10 @@ export function ClientHome() {
}, []);
const account = dashboard?.accounts[0];
const availableBalance = ((account?.balanceCents ?? 0) + (account?.creditCents ?? 0)) / 100;
const todaySpend = (dashboard?.today.spendCents ?? 0) / 100;
const availableBalance = moneyUnitsToYuan((account?.balanceCents ?? 0) + (account?.creditCents ?? 0));
const todaySpend = moneyUnitsToYuan(dashboard?.today.spendCents);
const todayRefundCents = Math.max(0, dashboard?.today.returnedCents ?? 0);
const todayRefund = todayRefundCents / 100;
const todayRefund = moneyUnitsToYuan(todayRefundCents);
const balanceBaseline = Math.max(availableBalance + todaySpend - todayRefund, availableBalance, 1);
const balancePercent = Math.min(100, Math.round((availableBalance / balanceBaseline) * 100));
const recentMessages = useMemo<RecentTaskRow[]>(() => (dashboard?.recentTasks ?? []).map((task) => ({
+18 -3
View File
@@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
import { BookOpen, Copy, KeyRound, RefreshCw, Webhook } from 'lucide-react';
import { clientApi, type ClientSmsApplication, type HttpApiConfigResponse, type HttpApiCredential, type HttpApiRequestLog, type HttpWebhookDelivery, type HttpWebhookEndpoint } from '@/api/adminApi';
import { Button, Input, Select, Tabs, Tag } from '@/components/ui';
import { copyText } from '@/utils/clipboard';
import { formatHttpApiParams } from '@/utils/interfaceParams';
export function ClientHttpApiPage() {
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
@@ -16,6 +18,19 @@ export function ClientHttpApiPage() {
const [revealedSecret, setRevealedSecret] = useState<{ title: string; accessKey?: string; secret: string } | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [paramsCopied, setParamsCopied] = useState(false);
async function copyHttpParams() {
if (!config) return;
try {
await copyText(formatHttpApiParams(config, window.location.origin));
setParamsCopied(true);
setError('');
window.setTimeout(() => setParamsCopied(false), 1600);
} catch (failure) {
setError(failure instanceof Error ? failure.message : 'HTTP接口参数复制失败');
}
}
useEffect(() => {
clientApi.listApplications().then((items) => {
@@ -67,7 +82,7 @@ export function ClientHttpApiPage() {
const api = config?.config;
const overview = <div className="page-stack">
{!api?.enabled ? <p className="form-error"> HTTP </p> : null}
<div className="surface" style={{ padding: 18 }}><h3>{config?.applicationName ?? '企业应用'}</h3><p className="muted">{window.location.origin}/api/openapi/v1</p><div className="table-actions">
<div className="surface" style={{ padding: 18 }}><div className="section-heading"><div><h3>{config?.applicationName ?? '企业应用'}</h3><p className="muted">{window.location.origin}/api/openapi/v1</p></div><Button disabled={!api?.enabled} icon={<Copy size={14} />} onClick={() => void copyHttpParams()} size="sm">{paramsCopied ? '已复制' : '复制HTTP参数'}</Button></div><div className="table-actions">
<Tag tone={api?.sendEnabled ? 'success' : 'info'}> {api?.sendEnabled ? '已开通' : '未开通'}</Tag>
<Tag tone={api?.messageQueryEnabled ? 'success' : 'info'}> {api?.messageQueryEnabled ? '已开通' : '未开通'}</Tag>
<Tag tone={api?.uplinkQueryEnabled ? 'success' : 'info'}> {api?.uplinkQueryEnabled ? '已开通' : '未开通'}</Tag>
@@ -78,7 +93,7 @@ export function ClientHttpApiPage() {
</div>;
const credentialPanel = <div className="page-stack"><div className="section-heading"><div><h3><KeyRound size={17} />访</h3><p className="muted"></p></div><Button disabled={!api?.credentialSelfServiceEnabled} onClick={() => void createCredential()}></Button></div>
{credentials.map((item) => <div className="surface" key={item.id} style={{ display: 'grid', gridTemplateColumns: '1fr 1.5fr 100px 1fr auto', gap: 12, padding: 14, alignItems: 'center' }}><strong>{item.name}</strong><code>{item.accessKey}</code><span>****{item.secretLast4}</span><span className="muted">使{item.lastUsedAt ? new Date(item.lastUsedAt).toLocaleString('zh-CN') : '从未'}</span>{item.status === 'active' ? <Button disabled={!api?.credentialSelfServiceEnabled} onClick={() => void clientApi.revokeHttpApiCredential(applicationId, item.id).then(() => loadApplication(applicationId))} size="sm" variant="danger"></Button> : <Tag tone="info"></Tag>}</div>)}
{credentials.map((item) => <div className="surface client-http-credential-row" key={item.id}><strong>{item.name}</strong><code>{item.accessKey}</code><span>****{item.secretLast4}</span><span className="muted">使{item.lastUsedAt ? new Date(item.lastUsedAt).toLocaleString('zh-CN') : '从未'}</span>{item.status === 'active' ? <Button disabled={!api?.credentialSelfServiceEnabled} onClick={() => void clientApi.revokeHttpApiCredential(applicationId, item.id).then(() => loadApplication(applicationId))} size="sm" variant="danger"></Button> : <Tag tone="info"></Tag>}</div>)}
{credentials.length === 0 ? <p className="muted">访</p> : null}
</div>;
@@ -104,7 +119,7 @@ SHA256(rawBody)`}</pre><p>使用访问密钥执行 HMAC-SHA256,输出小写十
return <section className="page-stack"><div className="page-heading"><div><h1></h1><p> HTTP 访</p></div><Select label="企业应用" onChange={(event) => setApplicationId(event.target.value)} options={applications.map((item) => ({ label: item.name, value: item.id }))} value={applicationId} /></div>
{loading ? <p className="muted">...</p> : null}{error ? <p className="form-error">{error}</p> : null}
{revealedSecret ? <div className="surface" style={{ border: '1px solid #f59e0b', padding: 16 }}><strong>{revealedSecret.title}</strong>{revealedSecret.accessKey ? <p>Access Key<code>{revealedSecret.accessKey}</code></p> : null}<p>Secret<code>{revealedSecret.secret}</code></p><Button icon={<Copy size={14} />} onClick={() => void navigator.clipboard.writeText([revealedSecret.accessKey, revealedSecret.secret].filter(Boolean).join('\n'))} size="sm"></Button></div> : null}
{revealedSecret ? <div className="surface" style={{ border: '1px solid #f59e0b', padding: 16 }}><strong>{revealedSecret.title}</strong>{revealedSecret.accessKey ? <p>Access Key<code>{revealedSecret.accessKey}</code></p> : null}<p>Secret<code>{revealedSecret.secret}</code></p><Button icon={<Copy size={14} />} onClick={() => void copyText([revealedSecret.accessKey, revealedSecret.secret].filter(Boolean).join('\n')).catch((failure: Error) => setError(failure.message))} size="sm"></Button></div> : null}
{!loading && applicationId ? <Tabs items={tabs} /> : null}
</section>;
}
+1
View File
@@ -76,6 +76,7 @@ export function Table<T>({ columns, data, rowKey, emptyText = '暂无数据', pa
<tr key={getRowKey(record)}>
{columns.map((column) => (
<td
data-label={typeof column.title === 'string' ? column.title : undefined}
key={column.key}
style={{ minWidth: column.width, textAlign: column.align ?? 'left', width: column.width }}
>
+45 -9
View File
@@ -7,10 +7,12 @@ import {
CircleHelp,
KeyRound,
LogOut,
Menu,
PanelLeftClose,
PanelLeftOpen,
X,
} from 'lucide-react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { adminApi } from '@/api/adminApi';
import {
clearSession,
@@ -63,6 +65,7 @@ export function AppShell({
auditNotifications = [],
}: AppShellProps) {
const [collapsed, setCollapsed] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [closedSections, setClosedSections] = useState<Record<string, boolean>>({});
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [noticeOpen, setNoticeOpen] = useState(false);
@@ -85,6 +88,7 @@ export function AppShell({
const reauthenticationReject = useRef<((error: Error) => void) | null>(null);
const lockRequested = useRef(false);
const navigate = useNavigate();
const location = useLocation();
const ToggleIcon = collapsed ? PanelLeftOpen : PanelLeftClose;
const auditTotal = useMemo(
() => auditNotifications.reduce((sum, item) => sum + item.count, 0),
@@ -180,6 +184,19 @@ export function AppShell({
}
}
useEffect(() => {
setMobileNavOpen(false);
}, [location.pathname]);
useEffect(() => {
if (!mobileNavOpen) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setMobileNavOpen(false);
};
window.addEventListener('keydown', closeOnEscape);
return () => window.removeEventListener('keydown', closeOnEscape);
}, [mobileNavOpen]);
useEffect(() => {
const activityEvents = ['pointerdown', 'keydown', 'touchstart', 'scroll'] as const;
const onActivity = () => markUserActivity();
@@ -263,11 +280,16 @@ export function AppShell({
}, [auditTotal, title]);
return (
<div className={['app-shell', collapsed ? 'app-shell--collapsed' : ''].filter(Boolean).join(' ')}>
<aside className="sidebar">
<div className="brand-block">
<img alt={`${title} logo`} className="brand-logo brand-logo--full" src="/logo/logo1.png" />
<img alt={`${title} logo`} className="brand-logo brand-logo--compact" src="/logo/logo2.png" />
<div className={['app-shell', collapsed ? 'app-shell--collapsed' : '', mobileNavOpen ? 'app-shell--mobile-nav-open' : ''].filter(Boolean).join(' ')}>
<aside aria-label="应用导航" className={['sidebar', mobileNavOpen ? 'sidebar--mobile-open' : ''].filter(Boolean).join(' ')}>
<div className="sidebar-brand-row">
<div className="brand-block">
<img alt={`${title} logo`} className="brand-logo brand-logo--full" src="/logo/logo1.png" />
<img alt={`${title} logo`} className="brand-logo brand-logo--compact" src="/logo/logo2.png" />
</div>
<button aria-label="关闭导航" className="icon-button mobile-nav-close" onClick={() => setMobileNavOpen(false)} type="button">
<X size={20} />
</button>
</div>
<nav className="side-nav" aria-label="主导航">
@@ -296,7 +318,7 @@ export function AppShell({
const Icon = item.icon;
return (
<NavLink key={item.to} to={item.to} end title={item.pending ? `${item.label}(待开发)` : item.label}>
<NavLink key={item.to} onClick={() => setMobileNavOpen(false)} to={item.to} end title={item.pending ? `${item.label}(待开发)` : item.label}>
<Icon size={17} strokeWidth={2.1} />
<span className="side-nav-label">
<span className="side-nav-label-text">{item.label}</span>
@@ -316,21 +338,35 @@ export function AppShell({
</div>
</aside>
{mobileNavOpen ? <button aria-label="关闭导航遮罩" className="mobile-nav-backdrop" onClick={() => setMobileNavOpen(false)} type="button" /> : null}
<main className="main-area">
<header className="topbar">
<div className="topbar-left">
<button
className="icon-button"
className="icon-button desktop-nav-toggle"
onClick={() => setCollapsed((value) => !value)}
type="button"
aria-label={collapsed ? '展开导航' : '收起导航'}
>
<ToggleIcon size={18} />
</button>
<button
aria-expanded={mobileNavOpen}
aria-label={mobileNavOpen ? '关闭导航' : '打开导航'}
className="icon-button mobile-nav-toggle"
onClick={() => setMobileNavOpen((open) => !open)}
type="button"
>
<Menu size={20} />
</button>
<div className="mobile-topbar-brand">
<img alt={`${title} logo`} src="/logo/logo1.png" />
</div>
</div>
<div className="topbar-actions">
<button className="icon-button" type="button" aria-label="帮助中心">
<button className="icon-button topbar-help" type="button" aria-label="帮助中心">
<CircleHelp size={18} />
</button>
<div className="notice-menu-wrap">
-12
View File
@@ -3,7 +3,6 @@ import {
ClipboardList,
FileText,
Home,
ImageIcon,
MessageSquareText,
PenLine,
ReceiptText,
@@ -52,17 +51,6 @@ export function ClientLayout() {
{ label: '接口对接', to: '/client/http-api', icon: Cable },
],
},
{
title: '彩信服务',
items: [
{ label: '签名报备', to: '/client/mms-signatures', icon: PenLine, pending: true },
{ label: '彩信模板管理', to: '/client/mms-templates', icon: ImageIcon, pending: true },
{ label: '发送彩信', to: '/client/mms-send', icon: MessageSquareText, pending: true },
{ label: '查看批量任务', to: '/client/mms-batch-tasks', icon: ClipboardList, pending: true },
{ label: '彩信发送详情', to: '/client/mms-send-detail', icon: ClipboardList, pending: true },
{ label: '查看上行彩信', to: '/client/mms-uplink-messages', icon: ImageIcon, pending: true },
],
},
{
title: '账户',
items: [
+12
View File
@@ -1260,3 +1260,15 @@
gap: var(--space-3);
}
}
@media (max-width: 780px) {
.ui-table-wrap {
background: transparent;
border: 0;
overflow: visible;
}
.ui-table {
background: transparent;
}
}
+797 -137
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
export async function copyText(text: string) {
if (!text) throw new Error('没有可复制的内容');
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
// HTTP deployments and restrictive browser policies may reject Clipboard API.
}
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
const copied = document.execCommand('copy');
textarea.remove();
if (!copied) throw new Error('浏览器未允许写入剪贴板,请手工选择参数复制');
}
+21 -3
View File
@@ -1,14 +1,32 @@
export const MONEY_UNITS_PER_YUAN = 10_000;
export function formatAmount(value: number) {
return value.toLocaleString('zh-CN', {
minimumFractionDigits: 3,
maximumFractionDigits: 3,
minimumFractionDigits: 4,
maximumFractionDigits: 4,
});
}
export function formatCents(cents?: number | null) {
return formatAmount((cents ?? 0) / 100);
return formatAmount((cents ?? 0) / MONEY_UNITS_PER_YUAN);
}
export function formatYuan(cents?: number | null) {
return `¥${formatCents(cents)}`;
}
export function yuanToMoneyUnits(value: number | string | null | undefined) {
const amount = typeof value === 'string' ? Number(value) : (value ?? 0);
return Math.round(amount * MONEY_UNITS_PER_YUAN);
}
export function isValidMoneyInput(value: string, options: { allowNegative?: boolean; allowZero?: boolean } = {}) {
const normalized = value.trim();
const pattern = options.allowNegative ? /^-?\d+(?:\.\d{1,4})?$/ : /^\d+(?:\.\d{1,4})?$/;
if (!pattern.test(normalized)) return false;
return options.allowZero !== false || Number(normalized) !== 0;
}
export function moneyUnitsToYuan(value?: number | null) {
return (value ?? 0) / MONEY_UNITS_PER_YUAN;
}
+26
View File
@@ -0,0 +1,26 @@
import type { HttpApiConfigResponse } from '@/api/adminApi';
const capabilityLabels = [
['sendEnabled', '单条发送'],
['messageQueryEnabled', '状态查询'],
['uplinkQueryEnabled', '上行查询'],
['receiptWebhookEnabled', '回执回调'],
['uplinkWebhookEnabled', '上行回调'],
] as const;
export function formatHttpApiParams(response: HttpApiConfigResponse, origin: string) {
const config = response.config;
const baseUrl = `${origin.replace(/\/$/, '')}/api/openapi/v1`;
return [
`应用名称: ${response.applicationName ?? response.applicationId}`,
`HTTP接口: ${config?.enabled ? '开通' : '关闭'}`,
`基础地址: ${baseUrl}`,
`接口文档: ${origin.replace(/\/$/, '')}/api/client-docs`,
`接口能力: ${capabilityLabels.filter(([key]) => config?.[key]).map(([, label]) => label).join('、') || '无'}`,
`QPS限制: ${config?.qpsLimit ?? '-'}`,
`签名时间容差: ${config?.timestampToleranceSeconds ?? '-'}`,
`HTTP IP白名单: ${response.ipAllowlist.join('、') || '未限制'}`,
`回执投递方式: ${config?.receiptDeliveryMode ?? '-'}`,
`上行投递方式: ${config?.uplinkDeliveryMode ?? '-'}`,
].join('\n');
}