feat: add manual recharge and operation logs
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Search } from 'lucide-react';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, Select, type DateRangeValue } from '@/components/ui';
|
||||
import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Textarea, Tag, type DateRangeValue } from '@/components/ui';
|
||||
|
||||
type RechargeRecord = {
|
||||
id: string;
|
||||
@@ -9,17 +9,23 @@ type RechargeRecord = {
|
||||
amount?: number;
|
||||
balance?: number;
|
||||
operator?: string;
|
||||
type: 'manual' | 'package';
|
||||
remark?: string;
|
||||
};
|
||||
|
||||
const rechargeRecords: RechargeRecord[] = [
|
||||
{ id: 'RCG202601120001', enterprise: 'XXXX科技有限公司', rechargedAt: '2026-01-12 19:27:19', amount: 1000, balance: 1000, operator: '李XXX' },
|
||||
{ id: 'RCG202601120002', enterprise: 'XXX公司名字', rechargedAt: '2026-01-12 19:27:19', amount: 500, balance: 5896.25, operator: '张三' },
|
||||
{ id: 'RCG202601120003', enterprise: 'XXX公司名字XXX公司名字', rechargedAt: '2026-01-12 19:27:19', amount: 192.29, balance: 0, operator: '张三' },
|
||||
{ id: 'RCG202601120004', enterprise: '', rechargedAt: '2026-01-12 19:27:19', amount: 2617.09, balance: 0, operator: '李四' },
|
||||
{ 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' },
|
||||
{ id: 'RCG202601120008', enterprise: '', rechargedAt: '2026-01-12 19:27:19' },
|
||||
type ManualRechargeForm = {
|
||||
enterprise: string;
|
||||
amount: string;
|
||||
operator: string;
|
||||
remark: string;
|
||||
};
|
||||
|
||||
const rechargeRecordsSeed: RechargeRecord[] = [
|
||||
{ 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) {
|
||||
@@ -38,18 +44,21 @@ function formatAmount(value?: number) {
|
||||
}
|
||||
|
||||
export function AdminRechargeRecordsPage() {
|
||||
const [records, setRecords] = useState(rechargeRecordsSeed);
|
||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [manualOpen, setManualOpen] = useState(false);
|
||||
const [form, setForm] = useState<ManualRechargeForm>({ enterprise: '', amount: '', operator: '运营', remark: '' });
|
||||
|
||||
const filteredRows = useMemo(
|
||||
() => rechargeRecords.filter((item) => {
|
||||
() => records.filter((item) => {
|
||||
const rechargeDate = getDate(item.rechargedAt);
|
||||
const matchesEnterprise = !enterpriseKeyword || item.enterprise.includes(enterpriseKeyword);
|
||||
const matchesStartDate = !dateRange.start || rechargeDate >= dateRange.start;
|
||||
const matchesEndDate = !dateRange.end || rechargeDate <= dateRange.end;
|
||||
return matchesEnterprise && matchesStartDate && matchesEndDate;
|
||||
}),
|
||||
[dateRange.end, dateRange.start, enterpriseKeyword],
|
||||
[dateRange.end, dateRange.start, enterpriseKeyword, records],
|
||||
);
|
||||
|
||||
function resetFilters() {
|
||||
@@ -57,6 +66,32 @@ export function AdminRechargeRecordsPage() {
|
||||
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 (
|
||||
<section className="page-stack admin-recharge-page">
|
||||
<div className="page-heading">
|
||||
@@ -64,6 +99,7 @@ export function AdminRechargeRecordsPage() {
|
||||
<Breadcrumb items={['数据详单', '充值记录']} />
|
||||
<h1>充值记录</h1>
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />} onClick={() => setManualOpen(true)}>人工充值</Button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -94,7 +132,9 @@ export function AdminRechargeRecordsPage() {
|
||||
<td>{record.rechargedAt}</td>
|
||||
<td>{formatAmount(record.amount)}</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.remark ?? '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -114,6 +154,28 @@ export function AdminRechargeRecordsPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,8 +51,8 @@ export function ClientHome() {
|
||||
const pendingTemplates = templates.filter((item) => item.status === 'pending').length;
|
||||
const pendingSignatures = signatures.filter((item) => item.status === 'pending').length;
|
||||
const latestInvoice = invoices[0];
|
||||
const quotaTotal = overview.availableMessages + overview.todaySent;
|
||||
const quotaPercent = Math.round((overview.availableMessages / quotaTotal) * 100);
|
||||
const balanceBaseline = overview.availableBalance + overview.todaySpend - overview.todayRefund;
|
||||
const balancePercent = Math.min(100, Math.round((overview.availableBalance / balanceBaseline) * 100));
|
||||
|
||||
const sendTrendOption = useMemo(
|
||||
() => createLineOption({
|
||||
@@ -73,7 +73,7 @@ export function ClientHome() {
|
||||
<div>
|
||||
<p className="eyebrow">客户端概览</p>
|
||||
<h1>短信服务工作台</h1>
|
||||
<p className="muted">查看账户余量、审核进度、发送趋势和常用业务入口。</p>
|
||||
<p className="muted">查看账户余额、审核进度、发送趋势和常用业务入口。</p>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<Button icon={<Plus size={16} />} onClick={() => navigate('/client/templates')} variant="ghost">
|
||||
@@ -85,19 +85,19 @@ export function ClientHome() {
|
||||
|
||||
<div className="dashboard-grid">
|
||||
<div className="surface metric-card metric-card--featured">
|
||||
<span>账户可用短信</span>
|
||||
<strong>{overview.availableMessages.toLocaleString('zh-CN')}</strong>
|
||||
<small>今日已发送 {overview.todaySent.toLocaleString('zh-CN')} 条</small>
|
||||
<span>账户剩余余额</span>
|
||||
<strong>¥{overview.availableBalance.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</strong>
|
||||
<small>今日消费 ¥{overview.todaySpend.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>发送成功率</span>
|
||||
<strong>{overview.successRate}%</strong>
|
||||
<small>近 24 小时实时统计</small>
|
||||
<span>今日发送</span>
|
||||
<strong>{overview.todaySent.toLocaleString('zh-CN')}</strong>
|
||||
<small>成功率 {overview.todaySuccessRate}%</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>待审核事项</span>
|
||||
<strong>{pendingTemplates + pendingSignatures}</strong>
|
||||
<small>模板 {pendingTemplates},签名 {pendingSignatures}</small>
|
||||
<span>今日返还金额</span>
|
||||
<strong>¥{overview.todayRefund.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</strong>
|
||||
<small>异常回执与退费返还</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -128,7 +128,7 @@ export function ClientHome() {
|
||||
<button className="quick-action" onClick={() => navigate('/client/billing')} type="button">
|
||||
<WalletCards size={20} />
|
||||
<span>账户充值</span>
|
||||
<small>查看套餐余量</small>
|
||||
<small>查看余额与流水</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,11 +157,11 @@ export function ClientHome() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="progress-heading">
|
||||
<span>短信余量</span>
|
||||
<strong>{quotaPercent}%</strong>
|
||||
<span>余额水位</span>
|
||||
<strong>{balancePercent}%</strong>
|
||||
</div>
|
||||
<div className="progress-track">
|
||||
<span style={{ width: `${quotaPercent}%` }} />
|
||||
<span style={{ width: `${balancePercent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
BarChart3,
|
||||
Building2,
|
||||
FileCheck2,
|
||||
FileText,
|
||||
FilePenLine,
|
||||
Gauge,
|
||||
Hash,
|
||||
@@ -116,6 +117,7 @@ export function AdminLayout() {
|
||||
{ label: '用户管理', to: '/admin/users', icon: Users },
|
||||
{ label: '手机号段库', to: '/admin/phone-segments', icon: Phone },
|
||||
{ label: '引流信息字段库', to: '/admin/drainage-fields', icon: Hash },
|
||||
{ label: '系统日志', to: '/admin/system-logs', icon: FileText },
|
||||
],
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { BatchTask, BillingPlan, ClientOverview, Invoice, RecentMessage, Signature, SmsTemplate } from '@/mock/types';
|
||||
|
||||
export const clientOverview: ClientOverview = {
|
||||
availableMessages: 286420,
|
||||
availableBalance: 28642.5,
|
||||
todaySent: 74166,
|
||||
todaySuccessRate: 98.7,
|
||||
todaySpend: 2148.26,
|
||||
todayRefund: 126.8,
|
||||
successRate: 98.7,
|
||||
pendingAudits: 12,
|
||||
};
|
||||
|
||||
+4
-1
@@ -10,8 +10,11 @@ export type RecentMessage = {
|
||||
};
|
||||
|
||||
export type ClientOverview = {
|
||||
availableMessages: number;
|
||||
availableBalance: number;
|
||||
todaySent: number;
|
||||
todaySuccessRate: number;
|
||||
todaySpend: number;
|
||||
todayRefund: number;
|
||||
successRate: number;
|
||||
pendingAudits: number;
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ import { AdminSmsApplicationFormPage } from '@/apps/admin/AdminSmsApplicationFor
|
||||
import { AdminSmsRecordsPage } from '@/apps/admin/AdminSmsRecordsPage';
|
||||
import { AdminSmsTaskProgressPage } from '@/apps/admin/AdminSmsTaskProgressPage';
|
||||
import { AdminSmsUplinkRecordsPage } from '@/apps/admin/AdminSmsUplinkRecordsPage';
|
||||
import { AdminSystemLogsPage } from '@/apps/admin/AdminSystemLogsPage';
|
||||
import { AdminTemplateAuditPage } from '@/apps/admin/AdminTemplateAuditPage';
|
||||
import { AdminUsersPage } from '@/apps/admin/AdminUsersPage';
|
||||
import { AdminEnterpriseAuditPage } from '@/apps/admin/AdminEnterpriseAuditPage';
|
||||
@@ -129,6 +130,7 @@ export function AppRoutes() {
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="phone-segments" element={<AdminPhoneSegmentsPage />} />
|
||||
<Route path="drainage-fields" element={<AdminDrainageFieldsPage />} />
|
||||
<Route path="system-logs" element={<AdminSystemLogsPage />} />
|
||||
<Route path="billing" element={<AdminBillingPage />} />
|
||||
<Route path="settings" element={<AdminSettingsPage />} />
|
||||
<Route path="*" element={<PagePlaceholder />} />
|
||||
|
||||
Reference in New Issue
Block a user