feat: improve application access and money precision
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-4~T-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} />}
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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-4~T-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} />
|
||||
|
||||
@@ -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 可独立开通;回执和上行可按 CMPP、HTTP、双投或不投递配置。</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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user