feat: add manual recharge and operation logs

This commit is contained in:
hectorzhao
2026-07-01 15:54:56 +08:00
parent 50d75f1cf3
commit 7ff6d7eac3
14 changed files with 339 additions and 32 deletions
+11
View File
@@ -4,6 +4,7 @@ import { TenantId } from '../common/tenant-id.decorator';
import { import {
BillingService, BillingService,
BillingActionDto, BillingActionDto,
CreateManualRechargeDto,
CreateRechargeOrderDto, CreateRechargeOrderDto,
CreateAccountTransactionDto, CreateAccountTransactionDto,
CreateBillingPlanDto, CreateBillingPlanDto,
@@ -58,6 +59,16 @@ export class BillingController {
return this.billing.createRechargeOrder(body); return this.billing.createRechargeOrder(body);
} }
@Get('manual-recharges')
listManualRechargeRecords(@TenantId() tenantId?: string) {
return this.billing.listManualRechargeRecords(tenantId);
}
@Post('manual-recharges')
createManualRecharge(@Body() body: CreateManualRechargeDto) {
return this.billing.createManualRecharge(body);
}
@Post('estimate') @Post('estimate')
estimateSmsCost(@Body() body: EstimateSmsCostDto) { estimateSmsCost(@Body() body: EstimateSmsCostDto) {
return this.billing.estimateSmsCost(body); return this.billing.estimateSmsCost(body);
+29
View File
@@ -104,6 +104,35 @@ describe('BillingService', () => {
}); });
}); });
it('creates manual recharge records for operator top-ups', async () => {
const prisma = createPrismaMock();
const service = new BillingService(prisma as never);
const order = await service.createManualRecharge({
tenantId: 'tenant-1',
amountCents: 2000,
smsUnits: 0,
operatorId: 'admin-1',
remark: '线下转账人工充值',
});
expect(order).toEqual(expect.objectContaining({ amountCents: 2000, payMethod: 'manual_topup', status: 'paid' }));
expect(prisma.rechargeOrder.create).toHaveBeenCalledWith({
data: expect.objectContaining({
payMethod: 'manual_topup',
operatorId: 'admin-1',
remark: '线下转账人工充值',
}),
});
expect(prisma.accountTransaction.create).toHaveBeenCalledWith({
data: expect.objectContaining({
transactionType: 'recharge',
amountCents: 2000,
relatedType: 'recharge_order',
}),
});
});
it('writes freeze, charge, release, refund, and adjustment transactions', async () => { it('writes freeze, charge, release, refund, and adjustment transactions', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new BillingService(prisma as never); const service = new BillingService(prisma as never);
+31
View File
@@ -48,6 +48,14 @@ export interface CreateRechargeOrderDto {
remark?: string; remark?: string;
} }
export interface CreateManualRechargeDto {
tenantId: string;
amountCents: number;
smsUnits?: number;
operatorId?: string;
remark?: string;
}
export interface EstimateSmsCostDto { export interface EstimateSmsCostDto {
tenantId: string; tenantId: string;
applicationId?: string; applicationId?: string;
@@ -147,6 +155,18 @@ export class BillingService {
}); });
} }
listManualRechargeRecords(tenantId?: string) {
return this.prisma.rechargeOrder.findMany({
where: {
tenantId,
payMethod: 'manual_topup',
},
include: { plan: true },
orderBy: { createdAt: 'desc' },
take: 100,
});
}
async createRechargeOrder(data: CreateRechargeOrderDto) { async createRechargeOrder(data: CreateRechargeOrderDto) {
const plan = data.planId ? await this.prisma.billingPlan.findUnique({ where: { id: data.planId } }) : null; const plan = data.planId ? await this.prisma.billingPlan.findUnique({ where: { id: data.planId } }) : null;
const amountCents = data.amountCents ?? plan?.priceCents ?? 0; const amountCents = data.amountCents ?? plan?.priceCents ?? 0;
@@ -179,6 +199,17 @@ export class BillingService {
return order; return order;
} }
createManualRecharge(data: CreateManualRechargeDto) {
return this.createRechargeOrder({
tenantId: data.tenantId,
amountCents: data.amountCents,
smsUnits: data.smsUnits ?? 0,
payMethod: 'manual_topup',
operatorId: data.operatorId,
remark: data.remark,
});
}
estimateSmsCost(data: EstimateSmsCostDto) { estimateSmsCost(data: EstimateSmsCostDto) {
const billingUnits = estimateBillingUnits(data.content); const billingUnits = estimateBillingUnits(data.content);
const unitPrice = data.unitPrice ?? 0; const unitPrice = data.unitPrice ?? 0;
File diff suppressed because one or more lines are too long
@@ -1051,6 +1051,23 @@
## 16. 新 Codex 会话提示词 ## 16. 新 Codex 会话提示词
## 17. 第一版需求增补记录
### 2026-07-01 账户与日志展示调整
1. 运营端增加企业人工充值入口。
- 运营端可针对指定企业录入人工充值金额、操作人和备注。
- 人工充值必须写入充值订单和账务流水。
- 运营端充值记录需区分人工充值和套餐充值。
2. 客户端概览指标调整。
- 原“剩余条数”改为“剩余余额”。
- 原“近24小时成功率”改为“今日发送条数和今日成功率”。
- 新增“今日消费金额”和“今日返还金额”展示。
3. 运营端增加系统日志。
- 运营端可查询全平台系统日志。
- 日志需支持按企业、模块、级别、操作人、资源 ID 或详情定位。
- 人工充值、审核、风控、发送链路、系统管理等关键动作需要可追溯。
建议新开 Codex 会话后直接发送以下提示词: 建议新开 Codex 会话后直接发送以下提示词:
```text ```text
+2
View File
@@ -30,6 +30,7 @@
- `SmsBillingRecord` - `SmsBillingRecord`
- Billing 账务动作: - Billing 账务动作:
- 套餐购买/人工充值:创建 `RechargeOrder` 并写入 `AccountTransaction` - 套餐购买/人工充值:创建 `RechargeOrder` 并写入 `AccountTransaction`
- 企业人工充值入口:新增运营端 `manual-recharges` API,人工充值记录与套餐充值记录分层查询。
- 发送费用预估:按 70/67 字规则计算计费条数、总条数和金额。 - 发送费用预估:按 70/67 字规则计算计费条数、总条数和金额。
- 账户校验:检查余额+授信额度和套餐余量。 - 账户校验:检查余额+授信额度和套餐余量。
- 冻结:写入 `frozen` 流水。 - 冻结:写入 `frozen` 流水。
@@ -40,6 +41,7 @@
- 短信计费记录:创建和查询 `SmsBillingRecord` - 短信计费记录:创建和查询 `SmsBillingRecord`
- 运营端接口: - 运营端接口:
- `GET/POST /api/admin/billing/recharges` - `GET/POST /api/admin/billing/recharges`
- `GET/POST /api/admin/billing/manual-recharges`
- `POST /api/admin/billing/estimate` - `POST /api/admin/billing/estimate`
- `POST /api/admin/billing/check` - `POST /api/admin/billing/check`
- `POST /api/admin/billing/freeze` - `POST /api/admin/billing/freeze`
@@ -33,6 +33,19 @@
- `GET /api/admin/operations/audit-summary` - `GET /api/admin/operations/audit-summary`
- `GET /api/admin/operations/trace` - `GET /api/admin/operations/trace`
- `GET /api/admin/operations/reconciliation` - `GET /api/admin/operations/reconciliation`
- 运营端页面增补:
- 充值记录页增加企业人工充值入口,并展示人工充值记录类型和备注。
- 系统管理增加系统日志页面。
- 客户端页面增补:
- 工作台“剩余条数”改为“剩余余额”。
- 工作台“近24小时成功率”改为今日发送条数和今日成功率。
- 工作台新增今日消费金额和今日返还金额。
- 2026-07-01 需求增补验证:
- `npm --prefix api test -- billing.service.spec.ts` 通过。
- `npm --prefix api run build` 通过。
- `npm run build` 通过。
- `npm run verify:phase8` 复跑通过,BullMQ Spike 端到端约 502.30 TPS。
- API health smoke 通过:`http://localhost:3101/api/health` 返回 `ok`
- `npm run verify:phase8` 通过。 - `npm run verify:phase8` 通过。
- 队列契约校验通过。 - 队列契约校验通过。
- Go Gateway `go test ./...` 通过。 - Go Gateway `go test ./...` 通过。
+75 -13
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight, Search } from 'lucide-react'; import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react';
import { Breadcrumb, Button, DateRangeInput, Input, Select, type DateRangeValue } from '@/components/ui'; import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Textarea, Tag, type DateRangeValue } from '@/components/ui';
type RechargeRecord = { type RechargeRecord = {
id: string; id: string;
@@ -9,17 +9,23 @@ type RechargeRecord = {
amount?: number; amount?: number;
balance?: number; balance?: number;
operator?: string; operator?: string;
type: 'manual' | 'package';
remark?: string;
}; };
const rechargeRecords: RechargeRecord[] = [ type ManualRechargeForm = {
{ id: 'RCG202601120001', enterprise: 'XXXX科技有限公司', rechargedAt: '2026-01-12 19:27:19', amount: 1000, balance: 1000, operator: '李XXX' }, enterprise: string;
{ id: 'RCG202601120002', enterprise: 'XXX公司名字', rechargedAt: '2026-01-12 19:27:19', amount: 500, balance: 5896.25, operator: '张三' }, amount: string;
{ id: 'RCG202601120003', enterprise: 'XXX公司名字XXX公司名字', rechargedAt: '2026-01-12 19:27:19', amount: 192.29, balance: 0, operator: '张三' }, operator: string;
{ id: 'RCG202601120004', enterprise: '', rechargedAt: '2026-01-12 19:27:19', amount: 2617.09, balance: 0, operator: '李四' }, remark: string;
{ id: 'RCG202601120005', enterprise: '', rechargedAt: '2026-01-12 19:27:19', amount: 122, balance: 0, operator: '' }, };
{ id: 'RCG202601120006', enterprise: '', rechargedAt: '2026-01-12 19:27:19' },
{ id: 'RCG202601120007', enterprise: '', rechargedAt: '2026-01-12 19:27:19' }, const rechargeRecordsSeed: RechargeRecord[] = [
{ id: 'RCG202601120008', enterprise: '', rechargedAt: '2026-01-12 19:27:19' }, { id: 'RCG202601120001', enterprise: 'XXXX科技有限公司', rechargedAt: '2026-01-12 19:27:19', amount: 1000, balance: 1000, operator: '李XXX', type: 'manual', remark: '线下转账到账' },
{ id: 'RCG202601120002', enterprise: 'XXX公司名字', rechargedAt: '2026-01-12 19:27:19', amount: 500, balance: 5896.25, operator: '张三', type: 'package' },
{ id: 'RCG202601120003', enterprise: 'XXX公司名字XXX公司名字', rechargedAt: '2026-01-12 19:27:19', amount: 192.29, balance: 0, operator: '张三', type: 'manual', remark: '运营补差额' },
{ id: 'RCG202601120004', enterprise: '北京鸣川科技', rechargedAt: '2026-01-12 19:27:19', amount: 2617.09, balance: 0, operator: '李四', type: 'package' },
{ id: 'RCG202601120005', enterprise: '广州麦芒科技', rechargedAt: '2026-01-12 19:27:19', amount: 122, balance: 0, operator: '王五', type: 'manual', remark: '客服人工充值' },
]; ];
function getDate(value: string) { function getDate(value: string) {
@@ -38,18 +44,21 @@ function formatAmount(value?: number) {
} }
export function AdminRechargeRecordsPage() { export function AdminRechargeRecordsPage() {
const [records, setRecords] = useState(rechargeRecordsSeed);
const [enterpriseKeyword, setEnterpriseKeyword] = useState(''); const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({}); const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [manualOpen, setManualOpen] = useState(false);
const [form, setForm] = useState<ManualRechargeForm>({ enterprise: '', amount: '', operator: '运营', remark: '' });
const filteredRows = useMemo( const filteredRows = useMemo(
() => rechargeRecords.filter((item) => { () => records.filter((item) => {
const rechargeDate = getDate(item.rechargedAt); const rechargeDate = getDate(item.rechargedAt);
const matchesEnterprise = !enterpriseKeyword || item.enterprise.includes(enterpriseKeyword); const matchesEnterprise = !enterpriseKeyword || item.enterprise.includes(enterpriseKeyword);
const matchesStartDate = !dateRange.start || rechargeDate >= dateRange.start; const matchesStartDate = !dateRange.start || rechargeDate >= dateRange.start;
const matchesEndDate = !dateRange.end || rechargeDate <= dateRange.end; const matchesEndDate = !dateRange.end || rechargeDate <= dateRange.end;
return matchesEnterprise && matchesStartDate && matchesEndDate; return matchesEnterprise && matchesStartDate && matchesEndDate;
}), }),
[dateRange.end, dateRange.start, enterpriseKeyword], [dateRange.end, dateRange.start, enterpriseKeyword, records],
); );
function resetFilters() { function resetFilters() {
@@ -57,6 +66,32 @@ export function AdminRechargeRecordsPage() {
setDateRange({}); setDateRange({});
} }
function updateForm<K extends keyof ManualRechargeForm>(key: K, value: ManualRechargeForm[K]) {
setForm((current) => ({ ...current, [key]: value }));
}
function submitManualRecharge() {
const amount = Number(form.amount);
if (!form.enterprise.trim() || !Number.isFinite(amount) || amount <= 0) {
return;
}
setRecords((current) => [
{
id: `RCG${Date.now()}`,
enterprise: form.enterprise,
rechargedAt: '2026-07-01 13:58:00',
amount,
balance: amount + 1200,
operator: form.operator,
type: 'manual',
remark: form.remark,
},
...current,
]);
setManualOpen(false);
setForm({ enterprise: '', amount: '', operator: '运营', remark: '' });
}
return ( return (
<section className="page-stack admin-recharge-page"> <section className="page-stack admin-recharge-page">
<div className="page-heading"> <div className="page-heading">
@@ -64,6 +99,7 @@ export function AdminRechargeRecordsPage() {
<Breadcrumb items={['数据详单', '充值记录']} /> <Breadcrumb items={['数据详单', '充值记录']} />
<h1></h1> <h1></h1>
</div> </div>
<Button icon={<Plus size={16} />} onClick={() => setManualOpen(true)}></Button>
</div> </div>
<div className="surface admin-recharge-filter"> <div className="surface admin-recharge-filter">
@@ -84,7 +120,9 @@ export function AdminRechargeRecordsPage() {
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th></th>
<th></th>
<th></th> <th></th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -94,7 +132,9 @@ export function AdminRechargeRecordsPage() {
<td>{record.rechargedAt}</td> <td>{record.rechargedAt}</td>
<td>{formatAmount(record.amount)}</td> <td>{formatAmount(record.amount)}</td>
<td>{formatAmount(record.balance)}</td> <td>{formatAmount(record.balance)}</td>
<td><Tag tone={record.type === 'manual' ? 'warning' : 'info'}>{record.type === 'manual' ? '人工充值' : '套餐充值'}</Tag></td>
<td>{record.operator}</td> <td>{record.operator}</td>
<td>{record.remark ?? '-'}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -114,6 +154,28 @@ export function AdminRechargeRecordsPage() {
</div> </div>
</div> </div>
</div> </div>
{manualOpen ? (
<Modal
footer={(
<>
<Button onClick={() => setManualOpen(false)} variant="ghost"></Button>
<Button onClick={submitManualRecharge}></Button>
</>
)}
onClose={() => setManualOpen(false)}
open
size="md"
title="企业人工充值"
>
<div className="admin-system-modal-form">
<Input label="企业名称" onChange={(event) => updateForm('enterprise', event.target.value)} value={form.enterprise} />
<Input label="充值金额" onChange={(event) => updateForm('amount', event.target.value)} prefix="¥" type="number" value={form.amount} />
<Input label="操作人" onChange={(event) => updateForm('operator', event.target.value)} value={form.operator} />
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateForm('remark', event.target.value)} rows={4} value={form.remark} />
</div>
</Modal>
) : null}
</section> </section>
); );
} }
+132
View File
@@ -0,0 +1,132 @@
import { useMemo, useState } from 'react';
import { CalendarDays, Download, FileText, Search } from 'lucide-react';
import { Breadcrumb, Button, Input, Select, Table, Tag, type TableColumn } from '@/components/ui';
type LogLevel = 'info' | 'success' | 'warning' | 'error';
type AdminSystemLog = {
id: string;
time: string;
level: LogLevel;
tenant: string;
module: string;
operator: string;
action: string;
resourceId: string;
detail: string;
ip: string;
};
const levelLabelMap: Record<LogLevel, string> = {
info: '信息',
success: '成功',
warning: '警告',
error: '错误',
};
const levelToneMap: Record<LogLevel, 'info' | 'success' | 'warning' | 'danger'> = {
info: 'info',
success: 'success',
warning: 'warning',
error: 'danger',
};
const logsSeed: AdminSystemLog[] = [
{ id: 'SYS202607010001', time: '2026-07-01 13:42:10', level: 'success', tenant: '上海云舟科技有限公司', module: '账户计费', operator: '运营', action: '人工充值', resourceId: 'RCG202607010001', detail: '人工充值 ¥2,000.00,备注:线下转账到账', ip: '10.0.1.12' },
{ id: 'SYS202607010002', time: '2026-07-01 13:20:33', level: 'info', tenant: '杭州星澜商贸有限公司', module: '短信审核', operator: '审核员A', action: '审核通过', resourceId: 'AUD202607010018', detail: '营销短信任务进入发送队列', ip: '10.0.1.15' },
{ id: 'SYS202607010003', time: '2026-07-01 12:58:44', level: 'warning', tenant: '深圳北辰出行服务有限公司', module: '风控', operator: 'system', action: '触发人工审核', resourceId: 'RISK202607010009', detail: '重复号码比例 21.4%,超过阈值 20%', ip: '127.0.0.1' },
{ id: 'SYS202607010004', time: '2026-07-01 12:11:02', level: 'error', tenant: '广州麦芒科技', module: '发送链路', operator: 'gateway', action: 'SubmitResp失败', resourceId: 'MSG-7b9e', detail: '通道返回 REJECT,错误码 8', ip: '10.0.2.21' },
{ id: 'SYS202607010005', time: '2026-07-01 11:46:29', level: 'info', tenant: '平台', module: '系统管理', operator: '平台管理员', action: '创建用户', resourceId: 'USR202607010006', detail: '新增运营用户:report-admin', ip: '10.0.1.10' },
];
export function AdminSystemLogsPage() {
const [keyword, setKeyword] = useState('');
const [level, setLevel] = useState('all');
const [module, setModule] = useState('all');
const [range, setRange] = useState('today');
const moduleOptions = useMemo(() => {
const modules = Array.from(new Set(logsSeed.map((item) => item.module)));
return [{ label: '全部模块', value: 'all' }, ...modules.map((item) => ({ label: item, value: item }))];
}, []);
const filteredLogs = logsSeed.filter((item) => {
const target = `${item.tenant} ${item.operator} ${item.action} ${item.resourceId} ${item.detail}`;
const matchesKeyword = !keyword || target.toLowerCase().includes(keyword.toLowerCase());
const matchesLevel = level === 'all' || item.level === level;
const matchesModule = module === 'all' || item.module === module;
return matchesKeyword && matchesLevel && matchesModule;
});
const columns = useMemo<Array<TableColumn<AdminSystemLog>>>(() => [
{ key: 'time', title: '时间', width: '180px', render: (record) => <span className="muted">{record.time}</span> },
{ key: 'level', title: '级别', width: '100px', render: (record) => <Tag tone={levelToneMap[record.level]}>{levelLabelMap[record.level]}</Tag> },
{ key: 'tenant', title: '企业', width: '190px', render: (record) => <strong>{record.tenant}</strong> },
{ key: 'module', title: '模块', width: '130px', render: (record) => record.module },
{ key: 'operator', title: '操作人', width: '120px', render: (record) => <strong>{record.operator}</strong> },
{ key: 'action', title: '动作', width: '140px', render: (record) => record.action },
{ key: 'resourceId', title: '资源ID', width: '150px', render: (record) => <span className="muted">{record.resourceId}</span> },
{ key: 'detail', title: '详情', render: (record) => <span className="system-log-detail">{record.detail}</span> },
{ key: 'ip', title: 'IP', width: '120px', render: (record) => <span className="muted">{record.ip}</span> },
], []);
return (
<section className="page-stack system-page">
<div className="system-page-toolbar">
<div className="sms-send-title">
<span className="sms-send-title__icon"><FileText size={22} /></span>
<div>
<Breadcrumb items={['系统管理', '系统日志']} />
<h1></h1>
</div>
</div>
<Button icon={<Download size={17} />} variant="secondary"></Button>
</div>
<div className="system-log-filters">
<Input
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索企业、操作人、动作、资源ID或详情"
prefix={<Search size={16} />}
value={keyword}
/>
<Select
onChange={(event) => setLevel(event.target.value)}
options={[
{ label: '全部级别', value: 'all' },
{ label: '信息', value: 'info' },
{ label: '成功', value: 'success' },
{ label: '警告', value: 'warning' },
{ label: '错误', value: 'error' },
]}
value={level}
/>
<Select onChange={(event) => setModule(event.target.value)} options={moduleOptions} value={module} />
</div>
<div className="system-log-range">
<span><CalendarDays size={18} /> </span>
{[
{ label: '今天', value: 'today' },
{ label: '近7天', value: '7d' },
{ label: '近30天', value: '30d' },
{ label: '全部', value: 'all' },
].map((item) => (
<Button
key={item.value}
onClick={() => setRange(item.value)}
size="sm"
variant={range === item.value ? 'primary' : 'secondary'}
>
{item.label}
</Button>
))}
</div>
<div className="surface system-table-card">
<Table columns={columns} data={filteredLogs} emptyText="暂无系统日志" rowKey="id" />
</div>
</section>
);
}
+16 -16
View File
@@ -51,8 +51,8 @@ export function ClientHome() {
const pendingTemplates = templates.filter((item) => item.status === 'pending').length; const pendingTemplates = templates.filter((item) => item.status === 'pending').length;
const pendingSignatures = signatures.filter((item) => item.status === 'pending').length; const pendingSignatures = signatures.filter((item) => item.status === 'pending').length;
const latestInvoice = invoices[0]; const latestInvoice = invoices[0];
const quotaTotal = overview.availableMessages + overview.todaySent; const balanceBaseline = overview.availableBalance + overview.todaySpend - overview.todayRefund;
const quotaPercent = Math.round((overview.availableMessages / quotaTotal) * 100); const balancePercent = Math.min(100, Math.round((overview.availableBalance / balanceBaseline) * 100));
const sendTrendOption = useMemo( const sendTrendOption = useMemo(
() => createLineOption({ () => createLineOption({
@@ -73,7 +73,7 @@ export function ClientHome() {
<div> <div>
<p className="eyebrow"></p> <p className="eyebrow"></p>
<h1></h1> <h1></h1>
<p className="muted"></p> <p className="muted"></p>
</div> </div>
<div className="page-actions"> <div className="page-actions">
<Button icon={<Plus size={16} />} onClick={() => navigate('/client/templates')} variant="ghost"> <Button icon={<Plus size={16} />} onClick={() => navigate('/client/templates')} variant="ghost">
@@ -85,19 +85,19 @@ export function ClientHome() {
<div className="dashboard-grid"> <div className="dashboard-grid">
<div className="surface metric-card metric-card--featured"> <div className="surface metric-card metric-card--featured">
<span></span> <span></span>
<strong>{overview.availableMessages.toLocaleString('zh-CN')}</strong> <strong>¥{overview.availableBalance.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</strong>
<small> {overview.todaySent.toLocaleString('zh-CN')} </small> <small> ¥{overview.todaySpend.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</small>
</div> </div>
<div className="surface metric-card"> <div className="surface metric-card">
<span></span> <span></span>
<strong>{overview.successRate}%</strong> <strong>{overview.todaySent.toLocaleString('zh-CN')}</strong>
<small> 24 </small> <small> {overview.todaySuccessRate}%</small>
</div> </div>
<div className="surface metric-card"> <div className="surface metric-card">
<span></span> <span></span>
<strong>{pendingTemplates + pendingSignatures}</strong> <strong>¥{overview.todayRefund.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</strong>
<small> {pendingTemplates} {pendingSignatures}</small> <small>退</small>
</div> </div>
</div> </div>
@@ -128,7 +128,7 @@ export function ClientHome() {
<button className="quick-action" onClick={() => navigate('/client/billing')} type="button"> <button className="quick-action" onClick={() => navigate('/client/billing')} type="button">
<WalletCards size={20} /> <WalletCards size={20} />
<span></span> <span></span>
<small></small> <small></small>
</button> </button>
</div> </div>
</div> </div>
@@ -157,11 +157,11 @@ export function ClientHome() {
</div> </div>
<div> <div>
<div className="progress-heading"> <div className="progress-heading">
<span></span> <span></span>
<strong>{quotaPercent}%</strong> <strong>{balancePercent}%</strong>
</div> </div>
<div className="progress-track"> <div className="progress-track">
<span style={{ width: `${quotaPercent}%` }} /> <span style={{ width: `${balancePercent}%` }} />
</div> </div>
</div> </div>
</div> </div>
+2
View File
@@ -3,6 +3,7 @@ import {
BarChart3, BarChart3,
Building2, Building2,
FileCheck2, FileCheck2,
FileText,
FilePenLine, FilePenLine,
Gauge, Gauge,
Hash, Hash,
@@ -116,6 +117,7 @@ export function AdminLayout() {
{ label: '用户管理', to: '/admin/users', icon: Users }, { label: '用户管理', to: '/admin/users', icon: Users },
{ label: '手机号段库', to: '/admin/phone-segments', icon: Phone }, { label: '手机号段库', to: '/admin/phone-segments', icon: Phone },
{ label: '引流信息字段库', to: '/admin/drainage-fields', icon: Hash }, { label: '引流信息字段库', to: '/admin/drainage-fields', icon: Hash },
{ label: '系统日志', to: '/admin/system-logs', icon: FileText },
], ],
}, },
]} ]}
+4 -1
View File
@@ -1,8 +1,11 @@
import type { BatchTask, BillingPlan, ClientOverview, Invoice, RecentMessage, Signature, SmsTemplate } from '@/mock/types'; import type { BatchTask, BillingPlan, ClientOverview, Invoice, RecentMessage, Signature, SmsTemplate } from '@/mock/types';
export const clientOverview: ClientOverview = { export const clientOverview: ClientOverview = {
availableMessages: 286420, availableBalance: 28642.5,
todaySent: 74166, todaySent: 74166,
todaySuccessRate: 98.7,
todaySpend: 2148.26,
todayRefund: 126.8,
successRate: 98.7, successRate: 98.7,
pendingAudits: 12, pendingAudits: 12,
}; };
+4 -1
View File
@@ -10,8 +10,11 @@ export type RecentMessage = {
}; };
export type ClientOverview = { export type ClientOverview = {
availableMessages: number; availableBalance: number;
todaySent: number; todaySent: number;
todaySuccessRate: number;
todaySpend: number;
todayRefund: number;
successRate: number; successRate: number;
pendingAudits: number; pendingAudits: number;
}; };
+2
View File
@@ -32,6 +32,7 @@ import { AdminSmsApplicationFormPage } from '@/apps/admin/AdminSmsApplicationFor
import { AdminSmsRecordsPage } from '@/apps/admin/AdminSmsRecordsPage'; import { AdminSmsRecordsPage } from '@/apps/admin/AdminSmsRecordsPage';
import { AdminSmsTaskProgressPage } from '@/apps/admin/AdminSmsTaskProgressPage'; import { AdminSmsTaskProgressPage } from '@/apps/admin/AdminSmsTaskProgressPage';
import { AdminSmsUplinkRecordsPage } from '@/apps/admin/AdminSmsUplinkRecordsPage'; import { AdminSmsUplinkRecordsPage } from '@/apps/admin/AdminSmsUplinkRecordsPage';
import { AdminSystemLogsPage } from '@/apps/admin/AdminSystemLogsPage';
import { AdminTemplateAuditPage } from '@/apps/admin/AdminTemplateAuditPage'; import { AdminTemplateAuditPage } from '@/apps/admin/AdminTemplateAuditPage';
import { AdminUsersPage } from '@/apps/admin/AdminUsersPage'; import { AdminUsersPage } from '@/apps/admin/AdminUsersPage';
import { AdminEnterpriseAuditPage } from '@/apps/admin/AdminEnterpriseAuditPage'; import { AdminEnterpriseAuditPage } from '@/apps/admin/AdminEnterpriseAuditPage';
@@ -129,6 +130,7 @@ export function AppRoutes() {
<Route path="users" element={<AdminUsersPage />} /> <Route path="users" element={<AdminUsersPage />} />
<Route path="phone-segments" element={<AdminPhoneSegmentsPage />} /> <Route path="phone-segments" element={<AdminPhoneSegmentsPage />} />
<Route path="drainage-fields" element={<AdminDrainageFieldsPage />} /> <Route path="drainage-fields" element={<AdminDrainageFieldsPage />} />
<Route path="system-logs" element={<AdminSystemLogsPage />} />
<Route path="billing" element={<AdminBillingPage />} /> <Route path="billing" element={<AdminBillingPage />} />
<Route path="settings" element={<AdminSettingsPage />} /> <Route path="settings" element={<AdminSettingsPage />} />
<Route path="*" element={<PagePlaceholder />} /> <Route path="*" element={<PagePlaceholder />} />