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
+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>;
}