feat: 优化签名热力图与金额展示

This commit is contained in:
hectorzhao
2026-08-12 11:41:47 +08:00
parent e64b5e23fe
commit 0cd353450a
33 changed files with 344 additions and 121 deletions
-2
View File
@@ -361,7 +361,6 @@ export type ReconciliationReportSummary = ReportVolumeSummary;
export type ProfitReportSummary = ReportVolumeSummary & {
revenueCents: number;
refundCents: number;
costCents: number;
profitCents: number;
profitRateBps: number;
@@ -387,7 +386,6 @@ export type DailyProfitReport = {
successUnits: number;
failedUnits: number;
revenueCents: number;
refundCents: number;
costCents: number;
profitCents: number;
profitRateBps: number;
+2 -1
View File
@@ -42,7 +42,7 @@ export type SignatureRetirementDetection = {
acceptedBusinessCount: number;
deliveredBusinessCount: number;
approvedAt: string;
status: 'alert' | 'healthy';
status: 'alert' | 'healthy' | 'observing';
suppressed: boolean;
};
@@ -76,6 +76,7 @@ export type SignatureRetirementSuppression = {
};
export type SignatureRetirementHeatmapItem = SignatureRetirementDetection & {
activityDate: string;
signatureName?: string;
channelName?: string | null;
tenantName?: string;
+2 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { adminApi, clientApi, type CaptchaResponse } from '@/api/adminApi';
import { consumeSessionRecovery, markUserActivity, readSessionRecovery, writeSession, type Portal } from '@/api/session';
import { Button, Input, Modal } from '@/components/ui';
import { Button, ClientLoginCanvas, Input, Modal } from '@/components/ui';
type LoginPageProps = {
portal: Portal;
@@ -75,6 +75,7 @@ export function LoginPage({ portal }: LoginPageProps) {
return (
<main className="login-page">
{!isAdmin ? <ClientLoginCanvas /> : null}
<section className="login-panel">
<div className="login-brand">
<img alt={isAdmin ? 'CMPP 运营端 logo' : 'CMPP 客户端 logo'} src="/logo/logo1.png" />
+7 -4
View File
@@ -292,6 +292,7 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
const deferredKeyword = useDeferredValue(keyword.trim().toLocaleLowerCase('zh-CN'));
const visible = items.filter((item) => item.dimensionType === dimensionType);
const dates = previousDateKeys(date, 30);
const cellMap = new Map(visible.map((item) => [`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${item.activityDate.slice(0, 10)}`, item]));
const rows = dimensions
.filter((item) => item.dimensionType === dimensionType)
.filter((item) => !deferredKeyword || [item.tenantName, item.applicationName, item.signatureName]
@@ -304,11 +305,12 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
applicationName: item.applicationName,
carrier: item.carrier,
approvedAt: item.approvedAt.slice(0, 10),
}));
total: dates.reduce((sum, dateKey) => sum + (cellMap.get(`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${dateKey}`)?.acceptedBusinessCount ?? 0), 0),
}))
.sort((left, right) => right.total - left.total || left.signatureName.localeCompare(right.signatureName, 'zh-CN'));
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
const currentPage = Math.min(page, totalPages);
const pagedRows = rows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const cellMap = new Map(visible.map((item) => [`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${item.detectionDate.slice(0, 10)}`, item]));
useEffect(() => {
setPage(1);
@@ -336,7 +338,7 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
<div className="signature-retirement-heatmap__scroll">
<table>
<thead>
<tr><th></th>{dates.map((dateKey) => <th key={dateKey}>{dateKey.slice(5)}</th>)}</tr>
<tr><th></th><th>30</th>{dates.map((dateKey) => <th key={dateKey}>{dateKey.slice(5)}</th>)}</tr>
</thead>
<tbody>
{pagedRows.map((row) => (
@@ -348,12 +350,13 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
</span>
<Tag tone="neutral">{carrierLabels[row.carrier] ?? row.carrier}</Tag>
</th>
<td className="signature-retirement-heatmap__total">{row.total.toLocaleString('zh-CN')}</td>
{dates.map((dateKey) => {
const item = cellMap.get(`${row.key}:${dateKey}`);
const beforeApproval = dateKey < row.approvedAt;
const successRate = item?.acceptedBusinessCount ? item.deliveredBusinessCount / item.acceptedBusinessCount * 100 : 0;
const className = beforeApproval ? 'is-inapplicable' : !item ? '' : item.acceptedBusinessCount === 0 ? 'is-zero' : `is-rate-${successRateTone(successRate)}`;
const titleText = beforeApproval ? '报备前,不适用' : item ? `提交条数:${item.submittedAttempts}\n上游接受条数:${item.acceptedBusinessCount}\n发送成功条数:${item.deliveredBusinessCount}\n发送成功率:${successRate.toFixed(1)}%\n预警阈值:${item.threshold}` : '当日无检测快照';
const titleText = beforeApproval ? '报备前,不适用' : item ? `提交条数:${item.submittedAttempts}\n上游接受条数:${item.acceptedBusinessCount}\n发送成功条数:${item.deliveredBusinessCount}\n发送成功率:${successRate.toFixed(1)}%\n检测状态:${item.status === 'observing' ? '观察中(不预警)' : item.status === 'alert' ? '预警' : '正常'}\n预警阈值:${item.threshold}` : '当日无检测快照';
return <td className={className} key={dateKey} title={titleText}>{beforeApproval ? 'N/A' : item ? item.acceptedBusinessCount : '—'}</td>;
})}
</tr>
+3 -3
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, FileText, MessageSquare, Server } from 'lucide-react';
import { adminApi, type ClientSmsSignature, type ClientSmsTemplate, type EnterpriseApplication, type TenantAccount, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, Table, Tag, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, MoneyText, Table, Tag, type TableColumn } from '@/components/ui';
import { formatCents } from '@/utils/currency';
export function AdminCustomerDetailPage() {
@@ -68,8 +68,8 @@ export function AdminCustomerDetailPage() {
<div className="dashboard-grid enterprise-summary-grid">
<div className="surface mini-status-card"><Server size={22} /><div><span></span><strong>{tenant?.code ?? '-'}</strong><small>{tenant?.status ?? '-'}</small></div></div>
<div className="surface mini-status-card"><MessageSquare size={22} /><div><span></span><strong></strong><small></small></div></div>
<div className="surface mini-status-card"><FileText size={22} /><div><span></span><strong>¥{formatCents(account?.balanceCents)}</strong><small></small></div></div>
<div className="surface mini-status-card"><FileText size={22} /><div><span></span><strong>¥{formatCents(account?.creditCents)}</strong><small> 0</small></div></div>
<div className="surface mini-status-card"><FileText size={22} /><div><span></span><strong><MoneyText>¥{formatCents(account?.balanceCents)}</MoneyText></strong><small></small></div></div>
<div className="surface mini-status-card"><FileText size={22} /><div><span></span><strong><MoneyText>¥{formatCents(account?.creditCents)}</MoneyText></strong><small> 0</small></div></div>
</div>
<div className="surface section-stack">
+6 -6
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
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, ManualRechargeDialog, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, Input, ManualRechargeDialog, Modal, MoneyText, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { formatCents } from '@/utils/currency';
type AdminCustomersPageProps = {
@@ -85,15 +85,15 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
const balance = record.account?.balanceCents ?? 0;
return (
<span className={balance < 0 ? 'status-danger' : ''}>
¥{formatCents(balance)}
<MoneyText>¥{formatCents(balance)}</MoneyText>
{balance < 0 ? <Tag tone="danger" className="enterprise-inline-tag"></Tag> : null}
</span>
);
},
},
{ key: 'creditLimit', title: '授信额度', width: '150px', align: 'right', render: (record) => `¥${formatCents(record.account?.creditCents ?? 0)}` },
{ key: 'todaySpend', title: '今日消费', width: '150px', align: 'right', render: (record) => `¥${formatCents(record.todaySpendCents)}` },
{ key: 'todayRefund', title: '今日返还', width: '150px', align: 'right', render: (record) => `¥${formatCents(record.todayRefundCents)}` },
{ key: 'creditLimit', title: '授信额度', width: '150px', align: 'right', render: (record) => <MoneyText>¥{formatCents(record.account?.creditCents ?? 0)}</MoneyText> },
{ key: 'todaySpend', title: '今日消费', width: '150px', align: 'right', render: (record) => <MoneyText>¥{formatCents(record.todaySpendCents)}</MoneyText> },
{ key: 'todayRefund', title: '今日返还', width: '150px', align: 'right', render: (record) => <MoneyText>¥{formatCents(record.todayRefundCents)}</MoneyText> },
{ key: 'status', title: '企业状态', width: '130px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'warning'}>{record.status === 'active' ? '正常' : '已禁用'}</Tag> },
{
key: 'actions',
@@ -160,7 +160,7 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
<div className="surface mini-status-card"><Building2 size={22} /><div><span></span><strong>{records.length}</strong><small></small></div></div>
<div className="surface mini-status-card"><TrendingUp size={22} /><div><span></span><strong>{activeCount}</strong><small></small></div></div>
<div className="surface mini-status-card"><TrendingDown size={22} /><div><span></span><strong>{disabledCount}</strong><small></small></div></div>
<div className="surface mini-status-card"><DollarSign size={22} /><div><span></span><strong>¥{formatCents(totalBalance)}</strong><small></small></div></div>
<div className="surface mini-status-card"><DollarSign size={22} /><div><span></span><strong><MoneyText>¥{formatCents(totalBalance)}</MoneyText></strong><small></small></div></div>
</div>
<div className="surface ui-query-panel">
+2 -2
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Check, FileSearch, Search, X } from 'lucide-react';
import { adminApi, type EnterpriseCertification } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Table, Tag, type DateRangeValue, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, MoneyText, Select, Table, Tag, type DateRangeValue, type TableColumn } from '@/components/ui';
type EnterpriseAuditStatus = 'pending' | 'approved' | 'rejected';
@@ -194,7 +194,7 @@ export function AdminEnterpriseAuditPage() {
<div><span></span><strong>{detailRecord.bankAccountName}</strong></div>
<div><span></span><strong>{detailRecord.bankName}</strong></div>
<div><span></span><strong>{detailRecord.bankAccountNo}</strong></div>
<div><span></span><strong>{detailRecord.verificationAmount}</strong></div>
<div><span></span><strong><MoneyText>{detailRecord.verificationAmount}</MoneyText></strong></div>
</section>
<section>
<h3></h3>
+4 -3
View File
@@ -11,6 +11,7 @@ import {
Button,
Chart,
Modal,
MoneyText,
Pagination,
Table,
Tag,
@@ -130,7 +131,7 @@ export function AdminHome() {
</div>
),
},
{ key: 'todaySpend', title: '今日消费(元)', align: 'right', render: (record) => `¥${formatCurrency(record.todaySpend)}` },
{ key: 'todaySpend', title: '今日消费(元)', align: 'right', render: (record) => <MoneyText>¥{formatCurrency(record.todaySpend)}</MoneyText> },
{ key: 'availableBalance', title: '可用余额', align: 'right', render: (record) => formatCount(record.availableBalance) },
{ key: 'balanceStatus', title: '余额状态', render: (record) => <Tag tone={balanceTone[record.balanceStatus]}>{record.balanceStatus}</Tag> },
{
@@ -205,7 +206,7 @@ export function AdminHome() {
</div>
<div className="surface metric-card">
<span></span>
<strong>¥{formatCurrency(todaySpend)}</strong>
<strong><MoneyText>¥{formatCurrency(todaySpend)}</MoneyText></strong>
<small></small>
</div>
<div className="surface metric-card">
@@ -372,7 +373,7 @@ export function AdminHome() {
</div>
<div className="ui-detail-info-grid__item">
<span></span>
<strong>¥{formatCurrency(selectedEnterprise.todaySpend)}</strong>
<strong><MoneyText>¥{formatCurrency(selectedEnterprise.todaySpend)}</MoneyText></strong>
</div>
<div className="ui-detail-info-grid__item">
<span></span>
+9 -9
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Download, Search } from 'lucide-react';
import { adminApi, type AdminChannel, type DailyProfitReport, type EnterpriseApplication, type ProfitReportSummary, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
import { Breadcrumb, Button, DateRangeInput, MoneyText, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
import { formatCents } from '@/utils/currency';
import { formatDateTime } from '@/utils/dateTime';
@@ -78,19 +78,19 @@ export function AdminProfitReportsPage() {
<div className="admin-report-summary__heading"><strong></strong><span></span></div>
<div className="admin-report-summary__grid">{[
['提交合计', summary.submittedUnits.toLocaleString('zh-CN')], ['发送合计', summary.sentUnits.toLocaleString('zh-CN')], ['未知合计', summary.unknownUnits.toLocaleString('zh-CN')], ['成功合计', summary.successUnits.toLocaleString('zh-CN')], ['失败合计', summary.failedUnits.toLocaleString('zh-CN')],
['净消费合计', `¥${formatCents(summary.revenueCents)}`], ['返还合计', `¥${formatCents(summary.refundCents)}`], ['成本合计', `¥${formatCents(summary.costCents)}`], ['利润合计', `¥${formatCents(summary.profitCents)}`], ['综合利润率', `${(summary.profitRateBps / 100).toFixed(2)}%`],
].map(([label, value]) => <div key={label}><span>{label}</span><strong>{value}</strong></div>)}</div>
['收入合计', `¥${formatCents(summary.revenueCents)}`], ['成本合计', `¥${formatCents(summary.costCents)}`], ['利润合计', `¥${formatCents(summary.profitCents)}`], ['综合利润率', `${(summary.profitRateBps / 100).toFixed(2)}%`],
].map(([label, value]) => <div key={label}><span>{label}</span><strong>{label.includes('合计') && ['收入合计', '成本合计', '利润合计'].includes(label) ? <MoneyText>{value}</MoneyText> : value}</strong></div>)}</div>
</div>
<div className="surface">
<div className="ui-table-wrap">
<table className="ui-table">
<thead><tr><th></th><th>{dimensionType === 'application' ? '企业 / 企业应用' : '通道'}</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<thead><tr><th></th><th>{dimensionType === 'application' ? '企业 / 企业应用' : '通道'}</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{error ? <tr><td className="ui-table__empty" colSpan={13}>{error}</td></tr>
: loading ? <tr><td className="ui-table__empty" colSpan={13}>...</td></tr>
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={13}></td></tr>
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.submittedUnits.toLocaleString('zh-CN')}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.unknownUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>¥{formatCents(row.revenueCents)}</td><td>¥{formatCents(row.refundCents)}</td><td>¥{formatCents(row.costCents)}</td><td style={{ color: row.profitCents < 0 ? 'var(--danger)' : undefined }}>¥{formatCents(row.profitCents)}</td><td>{(row.profitRateBps / 100).toFixed(2)}%</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
{error ? <tr><td className="ui-table__empty" colSpan={12}>{error}</td></tr>
: loading ? <tr><td className="ui-table__empty" colSpan={12}>...</td></tr>
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={12}></td></tr>
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.submittedUnits.toLocaleString('zh-CN')}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.unknownUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td><MoneyText>¥{formatCents(row.revenueCents)}</MoneyText></td><td><MoneyText>¥{formatCents(row.costCents)}</MoneyText></td><td style={{ color: row.profitCents < 0 ? 'var(--danger)' : undefined }}><MoneyText>¥{formatCents(row.profitCents)}</MoneyText></td><td>{(row.profitRateBps / 100).toFixed(2)}%</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
</tbody>
</table>
</div>
@@ -100,7 +100,7 @@ export function AdminProfitReportsPage() {
);
}
const emptyProfitSummary: ProfitReportSummary = { submittedUnits: 0, sentUnits: 0, unknownUnits: 0, successUnits: 0, failedUnits: 0, revenueCents: 0, refundCents: 0, costCents: 0, profitCents: 0, profitRateBps: 0 };
const emptyProfitSummary: ProfitReportSummary = { submittedUnits: 0, sentUnits: 0, unknownUnits: 0, successUnits: 0, failedUnits: 0, revenueCents: 0, costCents: 0, profitCents: 0, profitRateBps: 0 };
function defaultDateRange(): DateRangeValue {
const end = new Date();
+3 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Plus, ReceiptText, Search } from 'lucide-react';
import { Breadcrumb, Button, DateRangeInput, Input, ManualRechargeDialog, Pagination, RechargeReceiptDialog, Tag, type DateRangeValue } from '@/components/ui';
import { Breadcrumb, Button, DateRangeInput, Input, ManualRechargeDialog, MoneyText, Pagination, RechargeReceiptDialog, Tag, type DateRangeValue } from '@/components/ui';
import { adminApi, type RechargeOrder, type TenantAccount, type TenantOption } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { formatCents } from '@/utils/currency';
@@ -123,8 +123,8 @@ export function AdminRechargeRecordsPage() {
<tr key={record.id}>
<td><strong>{tenantName}</strong></td>
<td>{formatDateTime(record.paidAt ?? record.createdAt)}</td>
<td>¥{formatCents(record.amountCents)}</td>
<td>{record.balanceAfterCents === null || record.balanceAfterCents === undefined ? '-' : `¥${formatCents(record.balanceAfterCents)}`}</td>
<td><MoneyText>¥{formatCents(record.amountCents)}</MoneyText></td>
<td>{record.balanceAfterCents === null || record.balanceAfterCents === undefined ? '-' : <MoneyText>¥{formatCents(record.balanceAfterCents)}</MoneyText>}</td>
<td><Tag tone="warning"></Tag></td>
<td><RemarkCell value={record.remark ?? undefined} /></td>
<td>
+2 -2
View File
@@ -1,5 +1,5 @@
import { Copy, Eye, FileText, Pencil, Power, Send } from 'lucide-react';
import { DeleteRiskAction, Pagination, Tag } from '@/components/ui';
import { DeleteRiskAction, MoneyText, Pagination, Tag } from '@/components/ui';
import { formatCents } from '@/utils/currency';
import { successRateClassName } from '@/utils/successRate';
import { carrierLabelMap, carrierToneMap, statusLabelMap, statusToneMap } from './channelModel';
@@ -58,7 +58,7 @@ export function ChannelTable({
</div>
<div className="sms-channel-carrier-price">
<div>{channel.carriers.map((carrier) => <Tag key={carrier} tone={carrierToneMap[carrier]}>{carrierLabelMap[carrier]}</Tag>)}</div>
<strong>{formatCents(channel.unitPrice)} </strong>
<strong><MoneyText>{formatCents(channel.unitPrice)} </MoneyText></strong>
</div>
<div className="sms-channel-status-cell">
<Tag tone={statusToneMap[channel.status]}>{statusLabelMap[channel.status]}</Tag>
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { Edit3, Settings2, Trash2 } from 'lucide-react';
import { Button, Pagination, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
import { Button, MoneyText, Pagination, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
import { formatAmount } from '@/utils/currency';
import { formatDateTime } from '@/utils/dateTime';
import type { ConfirmAction, SmsApp } from './applicationTypes';
@@ -56,7 +56,7 @@ export function EnterpriseApplicationTable({
{ 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) => `${formatAmount(record.unitPrice)}` },
{ key: 'unitPrice', title: '单价', width: '130px', render: (record) => <MoneyText>{formatAmount(record.unitPrice)} </MoneyText> },
{
key: 'cmppStatus',
title: '客户连接状态',
@@ -60,8 +60,8 @@ export function EnterpriseSignaturesTable({
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
<div><span></span><strong>{signature.name}</strong></div>
<div><span></span><strong>{signature.tenant?.name ?? signature.tenantId}</strong></div>
<div><span></span><strong>{signature.application?.name ?? '-'}</strong></div>
<div><span></span><span className="signature-summary__regular-value">{signature.tenant?.name ?? signature.tenantId}</span></div>
<div><span></span><span className="signature-summary__regular-value">{signature.application?.name ?? '-'}</span></div>
<div><span></span><AuditStatusTag status={signature.auditStatus} /></div>
<div><span></span><CarrierReportTag summary={signature.carrierReportSummary?.mobile} /></div>
<div><span></span><CarrierReportTag summary={signature.carrierReportSummary?.unicom} /></div>
+2 -2
View File
@@ -1,7 +1,7 @@
import { Download } from 'lucide-react';
import type { ReactNode } from 'react';
import type { SmsMessageRecord } from '@/api/adminApi';
import { Button, Pagination } from '@/components/ui';
import { Button, MoneyText, Pagination } from '@/components/ui';
import { formatCents } from '@/utils/currency';
import {
getCarrierLabel,
@@ -85,7 +85,7 @@ export function SmsRecordList({
</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} / ¥{formatCents(record.amountCents)}</strong><small>{record.content.length} </small></div>
<div><span></span><strong>{record.billingUnits} / <MoneyText>¥{formatCents(record.amountCents)}</MoneyText></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={() => onOpenDetail(record)} type="button"></button></footer>
+2 -6
View File
@@ -3,7 +3,7 @@ import { ClipboardCopy, FileText } from 'lucide-react';
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 { Button, Modal, MoneyText, Pagination, Tag } from '@/components/ui';
import { copyText } from '@/utils/clipboard';
type LinkStatus = 'connected' | 'degraded' | 'disconnected' | 'inactive';
@@ -35,10 +35,6 @@ function normalizeStatus(application: ClientSmsApplication): LinkStatus {
return application.cmppStatus ?? 'inactive';
}
function formatPrice(cents?: number | null) {
return `${formatCents(cents)}`;
}
function mapParams(params: ApplicationCmppParams): ParamRow[] {
return [
{ label: 'ID', value: params.applicationId },
@@ -157,7 +153,7 @@ export function ClientApplicationsPage() {
</div>
<div>
<dt></dt>
<dd>{formatPrice(application.customerUnitPrice)}</dd>
<dd><MoneyText>{formatCents(application.customerUnitPrice)} </MoneyText></dd>
</div>
<div>
<dt>CMPP连接状态</dt>
+4 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { WalletCards } from 'lucide-react';
import { Pagination, Tag } from '@/components/ui';
import { MoneyText, Pagination, Tag } from '@/components/ui';
import { clientApi, type RechargeOrder } from '@/api/adminApi';
import { formatCents } from '@/utils/currency';
import { formatDateTime } from '@/utils/dateTime';
@@ -40,11 +40,11 @@ export function ClientBillingPage() {
<div className="dashboard-grid enterprise-summary-grid">
<div className="surface mini-status-card">
<WalletCards size={22} />
<div><span></span><strong>¥{formatCents(balanceCents)}</strong><small></small></div>
<div><span></span><strong><MoneyText>¥{formatCents(balanceCents)}</MoneyText></strong><small></small></div>
</div>
<div className="surface mini-status-card">
<WalletCards size={22} />
<div><span></span><strong>¥{formatCents(balanceCents + creditCents)}</strong><small> 0 </small></div>
<div><span></span><strong><MoneyText>¥{formatCents(balanceCents + creditCents)}</MoneyText></strong><small> 0 </small></div>
</div>
</div>
<div className="surface section-stack">
@@ -60,7 +60,7 @@ export function ClientBillingPage() {
<tr key={order.id}>
<td><span className="table-mono-id">{order.orderNo}</span></td>
<td>{formatDateTime(order.paidAt ?? order.createdAt)}</td>
<td>¥{formatCents(order.amountCents)}</td>
<td><MoneyText>¥{formatCents(order.amountCents)}</MoneyText></td>
<td><Tag tone={order.status === 'paid' ? 'success' : 'warning'}>{order.status === 'paid' ? '已入账' : order.status}</Tag></td>
<td>{order.remark || '-'}</td>
</tr>
+6 -6
View File
@@ -10,7 +10,7 @@ import {
WalletCards,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Button, Chart, Table, Tag, type TableColumn } from '@/components/ui';
import { Button, Chart, MoneyText, 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';
@@ -101,8 +101,8 @@ export function ClientHome() {
<div className="dashboard-grid dashboard-grid--four">
<div className="surface metric-card metric-card--featured">
<span></span>
<strong>¥{formatAmount(availableBalance)}</strong>
<small> ¥{formatCents(account?.balanceCents)}</small>
<strong><MoneyText>¥{formatAmount(availableBalance)}</MoneyText></strong>
<small> <MoneyText>¥{formatCents(account?.balanceCents)}</MoneyText></small>
</div>
<div className="surface metric-card">
<span></span>
@@ -111,12 +111,12 @@ export function ClientHome() {
</div>
<div className="surface metric-card">
<span></span>
<strong>¥{formatCents(dashboard?.today.spendCents)}</strong>
<strong><MoneyText>¥{formatCents(dashboard?.today.spendCents)}</MoneyText></strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong>¥{formatCents(todayRefundCents)}</strong>
<strong><MoneyText>¥{formatCents(todayRefundCents)}</MoneyText></strong>
<small>退</small>
</div>
</div>
@@ -175,7 +175,7 @@ export function ClientHome() {
</div>
<div>
<span></span>
<strong>{latestRecharge ? `¥${formatCents(latestRecharge.amountCents)}` : '暂无充值'}</strong>
<strong>{latestRecharge ? <MoneyText>¥{formatCents(latestRecharge.amountCents)}</MoneyText> : '暂无充值'}</strong>
</div>
</div>
<div>
+2 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Check, Download, FileText, Plus, Search, Send, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Button, DateTimeInput, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
import { Button, DateTimeInput, Input, Modal, MoneyText, Select, Tag, Textarea } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsSignatureView, type ClientSmsTemplate, type ImportPreviewResponse, type SmsBatchTask } from '@/api/adminApi';
import { formatCents } from '@/utils/currency';
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
@@ -419,7 +419,7 @@ export function ClientSendPage() {
</div>
<div>
<span></span>
<strong>¥{formatCents(selectedApplication?.customerUnitPrice)} / </strong>
<strong><MoneyText>¥{formatCents(selectedApplication?.customerUnitPrice)} / </MoneyText></strong>
</div>
</div>
<div className="preview-note"> 70 / 67 /</div>
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useRef } from 'react';
type Particle = { x: number; y: number; vx: number; vy: number; radius: number; alpha: number };
export function ClientLoginCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const context = canvas?.getContext('2d');
if (!canvas || !context) return undefined;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
let particles: Particle[] = []; let frame = 0; let width = 0; let height = 0;
const resize = () => {
const rect = canvas.getBoundingClientRect(); const ratio = Math.min(window.devicePixelRatio || 1, 2);
width = rect.width; height = rect.height; canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); context.setTransform(ratio, 0, 0, ratio, 0, 0);
const count = Math.max(18, Math.min(42, Math.round(width / 34)));
particles = Array.from({ length: count }, (_, index) => ({ x: (index * 83.7) % Math.max(width, 1), y: (index * 47.3) % Math.max(height, 1), vx: 0.08 + (index % 4) * 0.025, vy: (index % 3 - 1) * 0.035, radius: 1.2 + (index % 3) * 0.45, alpha: 0.18 + (index % 5) * 0.045 }));
};
const draw = () => {
context.clearRect(0, 0, width, height);
for (const particle of particles) {
if (!reduceMotion.matches && !document.hidden) { particle.x = (particle.x + particle.vx + width) % width; particle.y = (particle.y + particle.vy + height) % height; }
context.beginPath(); context.fillStyle = `rgba(59, 130, 246, ${particle.alpha})`; context.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2); context.fill();
}
for (let left = 0; left < particles.length; left += 1) for (let right = left + 1; right < particles.length; right += 1) {
const dx = particles[left].x - particles[right].x; const dy = particles[left].y - particles[right].y; const distance = Math.hypot(dx, dy);
if (distance <= 105) { context.beginPath(); context.strokeStyle = `rgba(59, 130, 246, ${0.09 * (1 - distance / 105)})`; context.moveTo(particles[left].x, particles[left].y); context.lineTo(particles[right].x, particles[right].y); context.stroke(); }
}
frame = window.requestAnimationFrame(draw);
};
const observer = new ResizeObserver(resize); observer.observe(canvas); resize(); draw();
return () => { observer.disconnect(); window.cancelAnimationFrame(frame); };
}, []);
return <canvas aria-hidden="true" className="client-login-canvas" ref={canvasRef} />;
}
+5 -4
View File
@@ -5,6 +5,7 @@ import { createUuid } from '@/utils/randomId';
import { Button } from './Button';
import { Input } from './Input';
import { Modal } from './Modal';
import { MoneyText } from './MoneyText';
import { Select } from './Select';
import { Textarea } from './Textarea';
@@ -130,7 +131,7 @@ export function ManualRechargeDialog({ initialTargetId, lockTarget = false, onCl
<div className="manual-recharge-result" role="status">
<strong>{result.amountCents > 0 ? '充值已入账' : '余额冲正已入账'}</strong>
<span>{result.orderNo}</span>
<span>¥{formatCents(result.balanceAfterCents)}</span>
<span><MoneyText>¥{formatCents(result.balanceAfterCents)}</MoneyText></span>
<span>{result.operationId}{result.replayed ? '(幂等重放)' : ''}</span>
</div>
) : review ? (
@@ -139,9 +140,9 @@ export function ManualRechargeDialog({ initialTargetId, lockTarget = false, onCl
<dl>
<div><dt></dt><dd><strong>{review.tenant.name}</strong><span>{review.tenant.code} · {review.tenant.id}</span></dd></div>
<div><dt></dt><dd>{review.direction === 'topup' ? '余额充值' : '余额冲正'}</dd></div>
<div><dt></dt><dd>¥{formatCents(review.balanceCents)}</dd></div>
<div><dt></dt><dd className={review.amountCents > 0 ? 'is-positive' : 'is-negative'}>{review.amountCents > 0 ? '+' : '-'}¥{formatCents(Math.abs(review.amountCents))}</dd></div>
<div><dt></dt><dd><strong>¥{formatCents(review.balanceAfterCents)}</strong></dd></div>
<div><dt></dt><dd><MoneyText>¥{formatCents(review.balanceCents)}</MoneyText></dd></div>
<div><dt></dt><dd className={review.amountCents > 0 ? 'is-positive' : 'is-negative'}><MoneyText>{review.amountCents > 0 ? '+' : '-'}¥{formatCents(Math.abs(review.amountCents))}</MoneyText></dd></div>
<div><dt></dt><dd><strong><MoneyText>¥{formatCents(review.balanceAfterCents)}</MoneyText></strong></dd></div>
</dl>
{remark.trim() ? <p className="manual-recharge-review__remark"><strong></strong>{remark.trim()}</p> : null}
</div>
+8
View File
@@ -0,0 +1,8 @@
import { Children, type ReactNode } from 'react';
export function MoneyText({ children, className }: { children: ReactNode; className?: string }) {
const text = Children.toArray(children).map((value) => typeof value === 'string' || typeof value === 'number' ? String(value) : '').join('');
const match = text.match(/^(.*?)(\.\d+)(\s*[^\d]*)$/);
if (!match) return <span className={className}>{text}</span>;
return <span className={className}>{match[1]}<span className="money-text__fraction">{match[2]}</span>{match[3]}</span>;
}
+4 -3
View File
@@ -4,6 +4,7 @@ import { formatCents } from '@/utils/currency';
import { formatDateTime } from '@/utils/dateTime';
import { Button } from './Button';
import { Modal } from './Modal';
import { MoneyText } from './MoneyText';
type RechargeReceiptDialogProps = {
open: boolean;
@@ -57,7 +58,7 @@ export function RechargeReceiptDialog({
<strong>
{isCorrection ? '' : '+'}
<small>¥</small>
{formatReceiptAmount(record.amountCents)}
<MoneyText>{formatReceiptAmount(record.amountCents)}</MoneyText>
</strong>
</section>
@@ -78,11 +79,11 @@ export function RechargeReceiptDialog({
</div>
<div>
<dt></dt>
<dd>{balanceBefore === null ? '-' : `¥${formatCents(balanceBefore)}`}</dd>
<dd>{balanceBefore === null ? '-' : <MoneyText>¥{formatCents(balanceBefore)}</MoneyText>}</dd>
</div>
<div>
<dt></dt>
<dd>{balanceAfter === null || balanceAfter === undefined ? '-' : `¥${formatCents(balanceAfter)}`}</dd>
<dd>{balanceAfter === null || balanceAfter === undefined ? '-' : <MoneyText>¥{formatCents(balanceAfter)}</MoneyText>}</dd>
</div>
<div>
<dt></dt>
+2
View File
@@ -14,6 +14,8 @@ export type { ManualRechargeTarget } from './ManualRechargeDialog';
export { RechargeReceiptDialog } from './RechargeReceiptDialog';
export { Input } from './Input';
export { Modal } from './Modal';
export { MoneyText } from './MoneyText';
export { ClientLoginCanvas } from './ClientLoginCanvas';
export { InlineTextPreview, Pagination, QueryPanel } from './PagePrimitives';
export { Select } from './Select';
export { Table } from './Table';
+29
View File
@@ -2010,6 +2010,22 @@
padding-right: 8px;
}
.signature-summary .signature-summary__regular-value {
color: var(--color-text-strong);
font-weight: 400;
}
.money-text__fraction {
color: var(--color-text-muted);
font-size: 0.92em;
}
.signature-retirement-heatmap__total {
color: var(--color-text-strong);
font-weight: 700;
background: var(--color-surface-muted);
}
.carrier-report-summary {
align-items: flex-start;
display: inline-flex;
@@ -3300,6 +3316,8 @@
}
.login-page {
position: relative;
isolation: isolate;
min-height: 100vh;
display: grid;
grid-template-columns: minmax(0, 1fr);
@@ -3312,7 +3330,18 @@
var(--color-bg);
}
.client-login-canvas {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.login-panel {
position: relative;
z-index: 1;
box-sizing: border-box;
max-width: 428px;
min-width: 0;