1、修改后台数据概览页面

2、增加对应是时间范围修改
This commit is contained in:
2026-06-15 16:17:00 +08:00
parent 1de11d93d0
commit 5ee682d06e
10 changed files with 648 additions and 218 deletions
+16 -3
View File
@@ -86,8 +86,12 @@ export async function markNotificationRead(id: string): Promise<void> {
// ── Admin ─────────────────────────────────────────────────
export async function getAdminStats(): Promise<AdminStats> {
return api.get('/admin/stats');
export async function getAdminStats(startDate?: string, endDate?: string): Promise<AdminStats> {
const params: Record<string, string> = {};
if (startDate) params.start_date = startDate;
if (endDate) params.end_date = endDate;
const query = new URLSearchParams(params).toString();
return api.get(`/admin/stats${query ? `?${query}` : ''}`);
}
export async function getAdminUsers(search?: string): Promise<AdminUser[]> {
@@ -139,10 +143,19 @@ export async function uploadPdf(file: File, configKey: string): Promise<{ url: s
return res.json();
}
export async function getCreditRecords(filters?: { user_id?: string; type?: string }): Promise<any> {
export async function getCreditRecords(filters?: {
user_id?: string;
user_name?: string;
type?: string;
start_date?: string;
end_date?: string;
}): Promise<any> {
const params = new URLSearchParams();
if (filters?.user_id) params.set('user_id', filters.user_id);
if (filters?.user_name) params.set('user_name', filters.user_name);
if (filters?.type) params.set('type', filters.type);
if (filters?.start_date) params.set('start_date', filters.start_date);
if (filters?.end_date) params.set('end_date', filters.end_date);
const q = params.toString() ? `?${params}` : '';
return api.get(`/admin/credit-records${q}`);
}
@@ -1,12 +1,13 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Select, Space, Table, Tag, Typography, message,
Button, Card, Select, Space, Table, Tag, Typography, message, Input, DatePicker,
} from 'antd';
import {
WalletOutlined, ArrowUpOutlined, ArrowDownOutlined, ReloadOutlined, RollbackOutlined,
} from '@ant-design/icons';
import { getCreditRecords } from '../api';
import { formatDate } from '../utils/formatDate';
import dayjs from 'dayjs';
interface CreditRecord {
id: string;
@@ -29,11 +30,18 @@ const AdminCreditRecords: React.FC = () => {
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [typeFilter, setTypeFilter] = useState<string>('');
const [userNameFilter, setUserNameFilter] = useState<string>('');
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]);
const load = async (type?: string) => {
const load = async () => {
setLoading(true);
try {
const res = await getCreditRecords(type ? { type } : undefined);
const filters: { type?: string; user_name?: string; start_date?: string; end_date?: string } = {};
if (typeFilter) filters.type = typeFilter;
if (userNameFilter) filters.user_name = userNameFilter;
if (dateRange[0]) filters.start_date = dateRange[0].format('YYYY-MM-DD');
if (dateRange[1]) filters.end_date = dateRange[1].format('YYYY-MM-DD');
const res = await getCreditRecords(Object.keys(filters).length > 0 ? filters : undefined);
setRecords(res.items || []);
setTotal(res.total || 0);
} catch {
@@ -45,9 +53,15 @@ const AdminCreditRecords: React.FC = () => {
useEffect(() => { load(); }, []);
const handleTypeFilter = (value: string) => {
setTypeFilter(value);
load(value || undefined);
const handleSearch = () => {
load();
};
const handleReset = () => {
setTypeFilter('');
setUserNameFilter('');
setDateRange([null, null]);
load();
};
const totalRecharge = records.filter(r => r.type === 'recharge').reduce((s, r) => s + r.amount, 0);
@@ -139,11 +153,11 @@ const AdminCreditRecords: React.FC = () => {
</div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space wrap>
<Select
value={typeFilter}
onChange={handleTypeFilter}
onChange={(value) => setTypeFilter(value)}
style={{ width: 120 }}
options={[
{ value: '', label: '全部类型' },
@@ -152,8 +166,25 @@ const AdminCreditRecords: React.FC = () => {
{ value: 'refund', label: '退回' },
]}
/>
<Input
placeholder="用户名搜索"
value={userNameFilter}
onChange={(e) => setUserNameFilter(e.target.value)}
style={{ width: 180 }}
allowClear
/>
<DatePicker.RangePicker
value={dateRange}
onChange={(dates) => { if (dates) setDateRange([dates[0], dates[1]]); }}
placeholder={['开始日期', '结束日期']}
style={{ width: 250 }}
/>
</Space>
<Space>
<Button type="primary" onClick={handleSearch}></Button>
<Button onClick={handleReset}></Button>
<Button icon={<ReloadOutlined />} onClick={load}></Button>
</Space>
<Button icon={<ReloadOutlined />} onClick={() => load(typeFilter || undefined)}></Button>
</div>
<Table
columns={columns}
+323 -65
View File
@@ -1,90 +1,348 @@
import React, { useEffect, useState } from 'react';
import { Card, Col, Row, Statistic, Typography, Table, Tag } from 'antd';
import { Card, Col, Row, Typography, DatePicker, Button, Space } from 'antd';
import {
UserOutlined,
ProjectOutlined,
PlayCircleOutlined,
FileTextOutlined,
DollarOutlined,
ThunderboltOutlined,
WalletOutlined,
ArrowUpOutlined,
CalendarOutlined,
} from '@ant-design/icons';
import { getAdminStats } from '../api';
import type { AdminStats } from '../types';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
dayjs.locale('zh-cn');
const AdminDashboard: React.FC = () => {
const [stats, setStats] = useState<AdminStats | null>(null);
const [loading, setLoading] = useState(true);
const [startDate, setStartDate] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([dayjs().startOf('day'), dayjs()]);
const load = async () => {
setLoading(true);
try {
const start = startDate[0]?.format('YYYY-MM-DD') || undefined;
const end = startDate[1]?.format('YYYY-MM-DD') || undefined;
const data = await getAdminStats(start, end);
setStats(data);
} catch { /* auth error handled by client */ }
setLoading(false);
};
useEffect(() => {
const load = async () => {
setLoading(true);
try {
const data = await getAdminStats();
setStats(data);
} catch { /* auth error handled by client */ }
setLoading(false);
};
load();
}, []);
const statCards = stats ? [
{ title: '总用户数', value: stats.totalUsers, icon: <UserOutlined />, color: '#6366f1', bg: 'rgba(99,102,241,0.08)' },
{ title: '总项目数', value: stats.totalProjects, icon: <ProjectOutlined />, color: '#06b6d4', bg: 'rgba(6,182,212,0.08)' },
{ title: '总生成次数', value: stats.totalGenerations, icon: <PlayCircleOutlined />, color: '#10b981', bg: 'rgba(16,185,129,0.08)' },
{ title: '总收入(元)', value: stats.totalRevenue, icon: <DollarOutlined />, color: '#f59e0b', bg: 'rgba(245,158,11,0.08)', prefix: '¥' },
{ title: '今日消耗积分', value: stats.creditsConsumedToday, icon: <ThunderboltOutlined />, color: '#ef4444', bg: 'rgba(239,68,68,0.08)' },
const handleDateChange = () => {
load();
};
const handleToday = () => {
setStartDate([dayjs().startOf('day'), dayjs()]);
setTimeout(() => load(), 0);
};
const handleYesterday = () => {
const yesterday = dayjs().subtract(1, 'day');
setStartDate([yesterday.startOf('day'), yesterday.endOf('day')]);
setTimeout(() => load(), 0);
};
const handleWeek = () => {
setStartDate([dayjs().startOf('week'), dayjs()]);
setTimeout(() => load(), 0);
};
const handleMonth = () => {
setStartDate([dayjs().startOf('month'), dayjs()]);
setTimeout(() => load(), 0);
};
const baseStats = stats ? [
{
title: '用户数量',
value: stats.totalUsers,
icon: <UserOutlined />,
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
prefix: '',
suffix: '位用户',
description: '平台注册用户总数'
},
{
title: '总项目数',
value: stats.totalProjects,
icon: <ProjectOutlined />,
gradient: 'linear-gradient(135deg, #00d4ff 0%, #0099cc 100%)',
prefix: '',
suffix: '个项目',
description: '创建的项目总数'
},
{
title: '项目记录',
value: stats.totalRecords,
icon: <FileTextOutlined />,
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
prefix: '',
suffix: '条记录',
description: '项目记录总数'
},
{
title: '创作记录',
value: stats.totalGenerations,
icon: <PlayCircleOutlined />,
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
prefix: '',
suffix: '次创作',
description: 'AI创作记录总数'
},
] : [];
return (
<div>
{/* Stats Cards */}
<Row gutter={[16, 16]}>
{statCards.map((s, i) => (
<Col xs={12} sm={8} lg={i < 4 ? 6 : 24} key={s.title}>
<Card bordered={false} loading={loading}
style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: s.bg, display: 'flex',
alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: s.color, flexShrink: 0,
}}>
{s.icon}
</div>
<div>
<div style={{ color: '#94a3b8', fontSize: 12, marginBottom: 2 }}>{s.title}</div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#1a1a2e' }}>
{s.prefix}{typeof s.value === 'number' ? s.value.toLocaleString() : s.value}
</div>
</div>
</div>
</Card>
</Col>
))}
</Row>
const financeStats = stats ? [
{
title: '支付宝收入',
value: stats.todayAlipayRevenue,
icon: <WalletOutlined />,
gradient: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
prefix: '¥',
suffix: '',
description: '支付宝收款',
tag: '支付宝'
},
{
title: '微信收入',
value: stats.todayWechatRevenue,
icon: <DollarOutlined />,
gradient: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
prefix: '¥',
suffix: '',
description: '微信收款',
tag: '微信支付'
},
{
title: '总收入',
value: stats.totalRevenue,
icon: <ArrowUpOutlined />,
gradient: 'linear-gradient(135deg, #ec4899 0%, #be185d 100%)',
prefix: '¥',
suffix: '',
description: '平台总收入',
tag: '总收入'
},
{
title: '消耗积分',
value: stats.creditsConsumedToday,
icon: <DollarOutlined />,
gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
prefix: '',
suffix: '积分',
description: '用户消耗积分',
tag: '积分消耗'
},
] : [];
{/* Quick Info */}
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} lg={12}>
<Card title="系统信息" bordered={false}
style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{[
{ label: '平台名称', value: 'VideoGen.AI' },
{ label: 'API版本', value: 'v1.0.0' },
{ label: '数据库', value: 'PostgreSQL' },
{ label: '视频引擎', value: 'Seedance 2.0' },
].map(item => (
<div key={item.label} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid #f5f6fa' }}>
<Typography.Text type="secondary">{item.label}</Typography.Text>
<Typography.Text strong>{item.value}</Typography.Text>
</div>
))}
</div>
</Card>
</Col>
</Row>
const StatCard: React.FC<{
title: string;
value: number;
icon: React.ReactNode;
gradient: string;
prefix?: string;
suffix?: string;
description?: string;
tag?: string;
}> = ({ title, value, icon, gradient, prefix = '', suffix = '', description, tag }) => (
<Card
bordered={false}
loading={loading}
hoverable
style={{
borderRadius: 16,
border: '1px solid rgba(0,0,0,0.04)',
background: '#ffffff',
boxShadow: '0 4px 20px rgba(0,0,0,0.05)',
transition: 'all 0.3s ease',
overflow: 'hidden'
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 16,
padding: '16px 0'
}}>
<div style={{
width: 52,
height: 52,
borderRadius: 14,
background: gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 24,
color: '#fff',
flexShrink: 0,
boxShadow: '0 8px 24px rgba(0,0,0,0.1)',
}}>
{icon}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 4
}}>
<Typography.Text style={{ color: '#64748b', fontSize: 13 }}>{title}</Typography.Text>
{tag && (
<span style={{
fontSize: 10,
padding: '2px 8px',
borderRadius: 10,
background: 'rgba(99,102,241,0.1)',
color: '#6366f1',
fontWeight: 500
}}>
{tag}
</span>
)}
</div>
<div style={{
fontSize: 28,
fontWeight: 800,
color: '#1e293b',
letterSpacing: -0.5,
marginBottom: 2
}}>
{prefix}{typeof value === 'number' ? value.toLocaleString() : value}{suffix}
</div>
{description && (
<Typography.Text style={{ color: '#94a3b8', fontSize: 11 }}>
{description}
</Typography.Text>
)}
</div>
</div>
</Card>
);
return (
<div style={{ padding: 0 }}>
<div style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: '24px 24px 32px',
borderRadius: 0,
marginBottom: -24,
position: 'relative',
overflow: 'hidden'
}}>
<div style={{
position: 'absolute',
top: -50,
right: -50,
width: 200,
height: 200,
background: 'rgba(255,255,255,0.1)',
borderRadius: '50%'
}} />
<div style={{
position: 'absolute',
bottom: -30,
left: -30,
width: 150,
height: 150,
background: 'rgba(255,255,255,0.08)',
borderRadius: '50%'
}} />
<div style={{ position: 'relative', zIndex: 1 }}>
<Typography.Title level={2} style={{ color: '#fff', marginBottom: 4, fontWeight: 700 }}>
</Typography.Title>
<Typography.Text style={{ color: 'rgba(255,255,255,0.8)', fontSize: 14 }}>
</Typography.Text>
</div>
</div>
<div style={{ marginTop: 40 }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 16 }}>
<Space>
<Button
type={!startDate[0] || !startDate[1] || startDate[0]?.isSame(dayjs().startOf('day')) && startDate[1]?.isSame(dayjs(), 'day') ? 'primary' : 'default'}
size="small"
onClick={handleToday}
>
</Button>
<Button
type="default"
size="small"
onClick={handleYesterday}
>
</Button>
<Button
type="default"
size="small"
onClick={handleWeek}
>
</Button>
<Button
type="default"
size="small"
onClick={handleMonth}
>
</Button>
</Space>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, background: '#fff', border: '1px solid #e2e8f0', padding: '6px 12px', borderRadius: 8, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
<CalendarOutlined style={{ color: '#64748b', fontSize: 14 }} />
<DatePicker.RangePicker
value={startDate}
onChange={(dates) => { if (dates) setStartDate([dates[0], dates[1]]); }}
onBlur={handleDateChange}
placeholder={['开始日期', '结束日期']}
size="small"
/>
<Button
type="primary"
size="small"
onClick={handleDateChange}
style={{ borderRadius: 6 }}
>
</Button>
</div>
</div>
<div style={{ marginBottom: 16, paddingLeft: 4 }}>
<Typography.Text strong style={{ color: '#1e293b', fontSize: 15 }}></Typography.Text>
<Typography.Text style={{ color: '#94a3b8', fontSize: 12, marginLeft: 8 }}></Typography.Text>
</div>
<Row gutter={[16, 16]}>
{baseStats.map((s) => (
<Col xs={12} sm={8} lg={6} key={s.title}>
<StatCard {...s} />
</Col>
))}
</Row>
</div>
<div style={{ marginTop: 24 }}>
<div style={{ marginBottom: 16, paddingLeft: 4 }}>
<Typography.Text strong style={{ color: '#1e293b', fontSize: 15 }}></Typography.Text>
<Typography.Text style={{ color: '#94a3b8', fontSize: 12, marginLeft: 8 }}></Typography.Text>
</div>
<Row gutter={[16, 16]}>
{financeStats.map((s) => (
<Col xs={12} sm={8} lg={6} key={s.title}>
<StatCard {...s} />
</Col>
))}
</Row>
</div>
</div>
);
};
@@ -763,81 +763,76 @@ const AdminGenerationAiRecords: React.FC = () => {
};
return (
<div style={{ padding: 24 }}>
<Card
title={(
<Space>
<FileImageOutlined />
<span></span>
</Space>
)}
extra={(
<Space wrap>
<Select
allowClear
placeholder="状态筛选"
value={filterStatus || undefined}
style={{ width: 140 }}
onChange={(v) => {
setFilterStatus(v || '');
setPage(1);
}}
options={[
{ value: 'generating', label: '生成中' },
{ value: 'completed', label: '已完成' },
{ value: 'failed', label: '失败' },
]}
/>
<Select
allowClear
placeholder="类型筛选"
value={filterGenType || undefined}
style={{ width: 120 }}
onChange={(v) => {
setFilterGenType(v || '');
setPage(1);
}}
options={[
{ value: 'image', label: '图片' },
{ value: 'video', label: '视频' },
]}
/>
<Input
placeholder="用户ID"
value={inputUserId}
onChange={(e) => setInputUserId(e.target.value)}
onPressEnter={handleSearch}
style={{ width: 180 }}
allowClear
/>
<Input
placeholder="用户名"
value={inputUserName}
onChange={(e) => setInputUserName(e.target.value)}
onPressEnter={handleSearch}
style={{ width: 160 }}
allowClear
/>
<Button icon={<SearchOutlined />} onClick={handleSearch}></Button>
</Space>
)}
bordered={false}
style={{ borderRadius: 16 }}
>
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space>
<PlayCircleOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{total} </Tag>
</Space>
<Space>
<Select
allowClear
placeholder="状态筛选"
value={filterStatus || undefined}
style={{ width: 140 }}
onChange={(v) => { setFilterStatus(v || ''); setPage(1); }}
options={[
{ value: 'generating', label: '生成中' },
{ value: 'completed', label: '已完成' },
{ value: 'failed', label: '失败' },
]}
/>
<Select
allowClear
placeholder="类型筛选"
value={filterGenType || undefined}
style={{ width: 120 }}
onChange={(v) => { setFilterGenType(v || ''); setPage(1); }}
options={[
{ value: 'image', label: '图片' },
{ value: 'video', label: '视频' },
]}
/>
<Input
placeholder="用户ID搜索"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
style={{ width: 180 }}
value={inputUserId}
onChange={(e) => setInputUserId(e.target.value)}
onPressEnter={handleSearch}
allowClear
/>
<Input
placeholder="用户名搜索"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
style={{ width: 160 }}
value={inputUserName}
onChange={(e) => setInputUserName(e.target.value)}
onPressEnter={handleSearch}
allowClear
/>
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}>
</Button>
</Space>
</div>
<Table
rowKey="id"
loading={loading}
columns={columns as any}
dataSource={records}
scroll={{ x: 1180 }}
pagination={{
current: page,
pageSize: PAGE_SIZE,
total,
showSizeChanger: false,
showTotal: (t) => `${t}`,
onChange: (p) => setPage(p),
}}
rowKey="id"
loading={loading}
columns={columns as any}
dataSource={records}
scroll={{ x: 1180 }}
pagination={{
current: page,
pageSize: PAGE_SIZE,
total,
showSizeChanger: false,
showTotal: (t) => `${t}`,
onChange: (p) => setPage(p),
}}
/>
</Card>
+3
View File
@@ -113,8 +113,11 @@ export interface AdminStats {
totalUsers: number;
totalProjects: number;
totalGenerations: number;
totalRecords: number;
totalRevenue: number;
creditsConsumedToday: number;
todayAlipayRevenue: number;
todayWechatRevenue: number;
}
export interface PaymentStats {