1、修改后台数据概览页面
2、增加对应是时间范围修改
This commit is contained in:
Vendored
+14
-3
File diff suppressed because one or more lines are too long
Vendored
+13
-13
@@ -1,13 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VideoGen.AI 管理后台</title>
|
||||
<script type="module" crossorigin src="/assets/index-CIsX_bex.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>␍
|
||||
</body>
|
||||
</html>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VideoGen.AI 管理后台</title>
|
||||
<script type="module" crossorigin src="/assets/index-D8zG-fgv.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -247,7 +247,10 @@ async def list_credit_records(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user_id: str | None = Query(None),
|
||||
user_name: str | None = Query(None),
|
||||
type: str | None = Query(None),
|
||||
start_date: str = Query(None),
|
||||
end_date: str = Query(None),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -261,9 +264,25 @@ async def list_credit_records(
|
||||
if user_id:
|
||||
query = query.where(CreditRecord.user_id == user_id)
|
||||
count_query = count_query.where(CreditRecord.user_id == user_id)
|
||||
if user_name:
|
||||
query = query.where(User.username.like(f'%{user_name}%'))
|
||||
count_query = count_query.join(User, CreditRecord.user_id == User.id).where(User.username.like(f'%{user_name}%'))
|
||||
if type:
|
||||
query = query.where(CreditRecord.type == type)
|
||||
count_query = count_query.where(CreditRecord.type == type)
|
||||
|
||||
try:
|
||||
if start_date:
|
||||
date_start = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
query = query.where(CreditRecord.created_at >= date_start)
|
||||
count_query = count_query.where(CreditRecord.created_at >= date_start)
|
||||
if end_date:
|
||||
date_end = datetime.strptime(end_date, "%Y-%m-%d")
|
||||
date_end = date_end.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
query = query.where(CreditRecord.created_at <= date_end)
|
||||
count_query = count_query.where(CreditRecord.created_at <= date_end)
|
||||
except:
|
||||
pass
|
||||
|
||||
total = (await db.execute(count_query)).scalar() or 0
|
||||
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
|
||||
@@ -1124,40 +1143,95 @@ async def list_operation_logs(
|
||||
async def get_stats(
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
start_date: str = Query(None),
|
||||
end_date: str = Query(None),
|
||||
):
|
||||
total_users = (await db.execute(
|
||||
select(func.count(User.id)).where(User.user_type == "frontend")
|
||||
)).scalar() or 0
|
||||
total_projects = (await db.execute(select(func.count(Project.id)).where(Project.deleted_at.is_(None)))).scalar() or 0
|
||||
total_generations = (
|
||||
await db.execute(select(func.count(GenerationRecord.id)).where(GenerationRecord.deleted_at.is_(None)))
|
||||
).scalar() or 0
|
||||
total_revenue = (
|
||||
await db.execute(
|
||||
select(func.coalesce(func.sum(PaymentOrder.amount), 0)).where(
|
||||
PaymentOrder.status == "paid"
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
try:
|
||||
if start_date:
|
||||
date_start = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
else:
|
||||
date_start = today_start
|
||||
if end_date:
|
||||
date_end = datetime.strptime(end_date, "%Y-%m-%d")
|
||||
date_end = date_end.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
else:
|
||||
date_end = datetime.now()
|
||||
except:
|
||||
date_start = today_start
|
||||
date_end = datetime.now()
|
||||
|
||||
today_start = datetime.now().replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
credits_today = (
|
||||
await db.execute(
|
||||
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
|
||||
CreditRecord.type == "consume",
|
||||
CreditRecord.created_at >= today_start,
|
||||
)
|
||||
total_projects = (await db.execute(
|
||||
select(func.count(Project.id)).where(
|
||||
Project.deleted_at.is_(None),
|
||||
Project.created_at >= date_start,
|
||||
Project.created_at <= date_end,
|
||||
)
|
||||
).scalar() or 0
|
||||
)).scalar() or 0
|
||||
|
||||
total_generations = (await db.execute(
|
||||
select(func.count(GenerationAITask.id)).where(
|
||||
GenerationAITask.created_at >= date_start,
|
||||
GenerationAITask.created_at <= date_end,
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
total_records = (await db.execute(
|
||||
select(func.count(GenerationRecord.id)).where(
|
||||
GenerationRecord.deleted_at.is_(None),
|
||||
GenerationRecord.created_at >= date_start,
|
||||
GenerationRecord.created_at <= date_end,
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
total_revenue = (await db.execute(
|
||||
select(func.coalesce(func.sum(PaymentOrder.amount), 0)).where(
|
||||
PaymentOrder.status == "paid",
|
||||
PaymentOrder.created_at >= date_start,
|
||||
PaymentOrder.created_at <= date_end,
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
credits_consumed = (await db.execute(
|
||||
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
|
||||
CreditRecord.type == "consume",
|
||||
CreditRecord.created_at >= date_start,
|
||||
CreditRecord.created_at <= date_end,
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
alipay_revenue = (await db.execute(
|
||||
select(func.coalesce(func.sum(PaymentOrder.amount), 0)).where(
|
||||
PaymentOrder.status == "paid",
|
||||
PaymentOrder.payment_method == "alipay",
|
||||
PaymentOrder.created_at >= date_start,
|
||||
PaymentOrder.created_at <= date_end,
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
wechat_revenue = (await db.execute(
|
||||
select(func.coalesce(func.sum(PaymentOrder.amount), 0)).where(
|
||||
PaymentOrder.status == "paid",
|
||||
PaymentOrder.payment_method == "wechat",
|
||||
PaymentOrder.created_at >= date_start,
|
||||
PaymentOrder.created_at <= date_end,
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
return AdminStatsOut(
|
||||
total_users=total_users,
|
||||
total_projects=total_projects,
|
||||
total_generations=total_generations,
|
||||
total_records=total_records,
|
||||
total_revenue=float(total_revenue),
|
||||
credits_consumed_today=float(credits_today),
|
||||
credits_consumed_today=float(credits_consumed),
|
||||
today_alipay_revenue=float(alipay_revenue),
|
||||
today_wechat_revenue=float(wechat_revenue),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+70
-28
@@ -322,13 +322,54 @@ async def _seed_data():
|
||||
# Seed menu configs
|
||||
from app.models.menu_config import MenuConfig
|
||||
|
||||
default_menus = [
|
||||
("/projects", "我的项目", "HomeOutlined", 0, "frontend"),
|
||||
("/conversation", "AI创作", "StarOutlined", 1, "frontend"),
|
||||
("/records", "项目记录", "PlayCircleOutlined", 2, "frontend"),
|
||||
("/generated", "素材云", "StarOutlined", 3, "frontend"),
|
||||
frontend_groups = [
|
||||
("AI项目行业生成", "HomeOutlined", 0),
|
||||
("AI对话生成", "HomeOutlined", 1),
|
||||
("AI视频创作", "HomeOutlined", 2),
|
||||
("资产管理", "HomeOutlined", 3),
|
||||
("媒体关联", "HomeOutlined", 4),
|
||||
]
|
||||
for path, label, icon, order, target in default_menus:
|
||||
frontend_group_ids: dict[str, str] = {}
|
||||
for label, icon, order in frontend_groups:
|
||||
existing = await db.execute(
|
||||
select(MenuConfig).where(
|
||||
MenuConfig.label == label,
|
||||
MenuConfig.menu_type == "group",
|
||||
MenuConfig.menu_target == "frontend",
|
||||
).limit(1)
|
||||
)
|
||||
group = existing.scalar_one_or_none()
|
||||
if group:
|
||||
frontend_group_ids[label] = group.id
|
||||
else:
|
||||
gid = generate_id()
|
||||
frontend_group_ids[label] = gid
|
||||
db.add(
|
||||
MenuConfig(
|
||||
id=gid,
|
||||
path="",
|
||||
label=label,
|
||||
icon=icon,
|
||||
sort_order=order,
|
||||
is_active=True,
|
||||
parent_id=None,
|
||||
menu_type="group",
|
||||
menu_target="frontend",
|
||||
is_default=True,
|
||||
)
|
||||
)
|
||||
|
||||
frontend_pages = [
|
||||
("/projects", "我的项目", "HomeOutlined", 0, "AI项目行业生成", True),
|
||||
("/conversation", "AI创作", "StarOutlined", 0, "AI对话生成", True),
|
||||
("/initial", "爆款开头复刻", "CodeOutlined", 0, "AI视频创作", False),
|
||||
("/removelens", "拆镜复刻", "CameraOutlined", 1, "AI视频创作", False),
|
||||
("/records", "项目记录", "PlayCircleOutlined", 0, "资产管理", True),
|
||||
("/generated", "素材云", "CloudOutlined", 1, "资产管理", True),
|
||||
("/authorization", "授权管理", "UserOutlined", 0, "媒体关联", False),
|
||||
("/consume", "消耗列表", "FileTextOutlined", 1, "媒体关联", False),
|
||||
]
|
||||
for path, label, icon, order, group_label, is_default in frontend_pages:
|
||||
existing = await db.execute(
|
||||
select(MenuConfig).where(MenuConfig.path == path).limit(1)
|
||||
)
|
||||
@@ -341,30 +382,33 @@ async def _seed_data():
|
||||
icon=icon,
|
||||
sort_order=order,
|
||||
is_active=True,
|
||||
parent_id=frontend_group_ids.get(group_label),
|
||||
menu_type="page",
|
||||
menu_target=target,
|
||||
is_default=True,
|
||||
menu_target="frontend",
|
||||
is_default=is_default,
|
||||
)
|
||||
)
|
||||
|
||||
# Seed admin group menus first, then page menus with parent_id
|
||||
admin_groups = [
|
||||
("模型设置", "RobotOutlined", 98),
|
||||
("模型配置", "RobotOutlined", 6),
|
||||
("系统设置", "SettingOutlined", 99),
|
||||
]
|
||||
group_ids = {}
|
||||
admin_group_ids: dict[str, str] = {}
|
||||
for label, icon, order in admin_groups:
|
||||
existing = await db.execute(
|
||||
select(MenuConfig).where(
|
||||
MenuConfig.label == label,
|
||||
MenuConfig.menu_type == "group",
|
||||
MenuConfig.menu_target == "admin",
|
||||
)
|
||||
.limit(1)
|
||||
).limit(1)
|
||||
)
|
||||
group = existing.scalar_one_or_none()
|
||||
if not group:
|
||||
if group:
|
||||
admin_group_ids[label] = group.id
|
||||
else:
|
||||
gid = generate_id()
|
||||
admin_group_ids[label] = gid
|
||||
db.add(
|
||||
MenuConfig(
|
||||
id=gid,
|
||||
@@ -377,35 +421,33 @@ async def _seed_data():
|
||||
menu_target="admin",
|
||||
)
|
||||
)
|
||||
group_ids[label] = gid
|
||||
else:
|
||||
group_ids[label] = group.id
|
||||
|
||||
# (path, label, icon, sort_order, parent_group_label or None)
|
||||
admin_menus = [
|
||||
admin_pages = [
|
||||
("/", "数据概览", "DashboardOutlined", 0, None),
|
||||
("/users", "用户管理", "UserOutlined", 1, None),
|
||||
("/credit-records", "交易流水", "WalletOutlined", 2, None),
|
||||
("/generation-records", "生成记录", "VideoCameraOutlined", 3, None),
|
||||
("/generation-ai", "创作记录", "BulbOutlined", 3, None),
|
||||
("/generation-records", "项目记录", "VideoCameraOutlined", 3, None),
|
||||
("/recharge-packages", "充值套餐", "GiftOutlined", 4, None),
|
||||
("/notifications", "消息推送", "BellOutlined", 5, None),
|
||||
("/video-engines", "视频引擎", "PlayCircleOutlined", 0, "模型配置"),
|
||||
("/models", "模型配置", "RobotOutlined", 1, "模型配置"),
|
||||
("/image-engines", "图片模型", "PictureOutlined", 2, "模型配置"),
|
||||
("/credit-ratios", "积分比例", "CalculatorOutlined", 3, "模型配置"),
|
||||
("/payment-stats", "支付统计", "LineChartOutlined", 6, None),
|
||||
("/video-engines", "视频引擎", "PlayCircleOutlined", 0, "模型设置"),
|
||||
("/models", "模型配置", "RobotOutlined", 1, "模型设置"),
|
||||
("/image-engines", "图片模型", "PictureOutlined", 2, "模型设置"),
|
||||
("/credit-ratios", "积分比例", "CalculatorOutlined", 3, "模型设置"),
|
||||
("/payment", "支付配置", "DollarOutlined", 1, "系统设置"),
|
||||
("/industries", "行业配置", "AppstoreOutlined", 2, "系统设置"),
|
||||
("/menu-configs", "菜单配置", "SettingOutlined", 3, "系统设置"),
|
||||
("/settings", "系统设置", "SettingOutlined", 4, "系统设置"),
|
||||
("/operation-logs", "操作日志", "HistoryOutlined", 5, "系统设置"),
|
||||
("/operation-logs", "操作日志", "DatabaseOutlined", 5, "系统设置"),
|
||||
("/oauthapp-list", "授权应用", "MenuOutlined", 28, "系统设置"),
|
||||
]
|
||||
for path, label, icon, order, parent_group in admin_menus:
|
||||
for path, label, icon, order, parent_group in admin_pages:
|
||||
existing = await db.execute(
|
||||
select(MenuConfig).where(
|
||||
MenuConfig.path == path,
|
||||
MenuConfig.menu_target == "admin",
|
||||
)
|
||||
.limit(1)
|
||||
).limit(1)
|
||||
)
|
||||
if not existing.scalar_one_or_none():
|
||||
db.add(
|
||||
@@ -418,7 +460,7 @@ async def _seed_data():
|
||||
is_active=True,
|
||||
menu_type="page",
|
||||
menu_target="admin",
|
||||
parent_id=group_ids.get(parent_group),
|
||||
parent_id=admin_group_ids.get(parent_group),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -93,5 +93,8 @@ class AdminStatsOut(BaseModel):
|
||||
total_users: int
|
||||
total_projects: int
|
||||
total_generations: int
|
||||
total_records: int
|
||||
total_revenue: float
|
||||
credits_consumed_today: float
|
||||
today_alipay_revenue: float
|
||||
today_wechat_revenue: float
|
||||
|
||||
Reference in New Issue
Block a user