diff --git a/video-gen-admin/package-lock.json b/video-gen-admin/package-lock.json index d8a407b0..4508c7be 100644 --- a/video-gen-admin/package-lock.json +++ b/video-gen-admin/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@ant-design/icons": "^6.2.2", "antd": "^6.3.7", + "dayjs": "^1.11.21", "react": "^19.2.5", "react-dom": "^19.2.5", "react-router-dom": "^7.15.0", @@ -1333,9 +1334,9 @@ "license": "MIT" }, "node_modules/dayjs": { - "version": "1.11.20", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", - "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", "license": "MIT" }, "node_modules/detect-libc": { diff --git a/video-gen-admin/package.json b/video-gen-admin/package.json index 0693e9f2..31da407d 100644 --- a/video-gen-admin/package.json +++ b/video-gen-admin/package.json @@ -11,6 +11,7 @@ "dependencies": { "@ant-design/icons": "^6.2.2", "antd": "^6.3.7", + "dayjs": "^1.11.21", "react": "^19.2.5", "react-dom": "^19.2.5", "react-router-dom": "^7.15.0", diff --git a/video-gen-admin/src/api/index.ts b/video-gen-admin/src/api/index.ts index b98f3eda..03560fd4 100644 --- a/video-gen-admin/src/api/index.ts +++ b/video-gen-admin/src/api/index.ts @@ -211,12 +211,25 @@ export async function batchUpdatePaymentConfigs(configs: Record) await api.put('/admin/payment-configs/batch', configs); } -export async function getPaymentStats(): Promise<{ - byStatus: Record; - today: { paidCount: number; paidAmount: number }; - recent: any[]; +export async function getPaymentStats(params?: { + paymentMethod?: string; + status?: string; + startDate?: string; + endDate?: string; +}): Promise<{ + byStatus: Record; + today: { paidCount: number; paidAmount: number }; + month: { paidCount: number; paidAmount: number }; + recent: any[]; }> { - return api.get('/admin/payment-stats'); + const searchParams = new URLSearchParams(); + if (params?.paymentMethod) searchParams.set('payment_method', params.paymentMethod); + if (params?.status) searchParams.set('status', params.status); + if (params?.startDate) searchParams.set('start_date', params.startDate); + if (params?.endDate) searchParams.set('end_date', params.endDate); + const queryString = searchParams.toString(); + const url = queryString ? `/admin/payment-stats?${queryString}` : '/admin/payment-stats'; + return api.get(url); } export async function getAdminPaymentOrders(params?: { method?: string; status?: string }): Promise<{ items: any[] }> { diff --git a/video-gen-admin/src/pages/AdminPaymentStats.tsx b/video-gen-admin/src/pages/AdminPaymentStats.tsx index b95207c9..ffbea5df 100644 --- a/video-gen-admin/src/pages/AdminPaymentStats.tsx +++ b/video-gen-admin/src/pages/AdminPaymentStats.tsx @@ -1,193 +1,272 @@ import React, { useEffect, useState } from 'react'; import { - Card, Col, Row, Space, Table, Tag, Typography, Statistic, message, + Card, Col, Row, Space, Table, Tag, Typography, Statistic, message, Select, DatePicker, Button } from 'antd'; import { - DollarOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, + DollarOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, ReloadOutlined } from '@ant-design/icons'; import { getPaymentStats } from '../api'; import { formatDate } from '../utils/formatDate'; +import dayjs from 'dayjs'; + +const { Option } = Select; +const { RangePicker } = DatePicker; const AdminPaymentStats: React.FC = () => { - const [loading, setLoading] = useState(false); - const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(false); + const [stats, setStats] = useState(null); + const [filters, setFilters] = useState<{ + paymentMethod?: string; + status?: string; + startDate: string; + endDate: string; + }>({ + startDate: dayjs().format('YYYY-MM-DD'), + endDate: dayjs().format('YYYY-MM-DD'), + }); - const load = async () => { - try { - setLoading(true); - const data = await getPaymentStats(); - setStats(data); - } catch { - message.error('加载支付统计失败'); - } finally { - setLoading(false); + const load = async () => { + try { + setLoading(true); + const data = await getPaymentStats(filters); + setStats(data); + } catch { + message.error('加载支付统计失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { load(); }, [filters]); + + const handleReset = () => { + setFilters({ + startDate: dayjs().format('YYYY-MM-DD'), + endDate: dayjs().format('YYYY-MM-DD'), + }); + }; + + const handleDateChange = (dates: any) => { + if (dates && dates.length === 2) { + setFilters(prev => ({ + ...prev, + startDate: dates[0].format('YYYY-MM-DD'), + endDate: dates[1].format('YYYY-MM-DD'), + })); + } + }; + + const statusConfig: Record = { + paid: { color: 'green', label: '已支付', icon: }, + pending: { color: 'gold', label: '待支付', icon: }, + cancelled: { color: 'default', label: '已取消', icon: }, + }; + + const methodConfig: Record = { + alipay: { color: 'blue', label: '支付宝' }, + wechat: { color: 'green', label: '微信' }, + }; + + const columns = [ + { title: '订单号', dataIndex: 'orderNo', key: 'orderNo', width: 200 }, + { + title: '支付方式', dataIndex: 'paymentMethod', key: 'paymentMethod', width: 100, + render: (m: string) => { + const c = methodConfig[m] || { color: 'default', label: m }; + return {c.label}; + }, + }, + { + title: '金额', dataIndex: 'amount', key: 'amount', width: 100, + render: (a: number) => ¥{a.toFixed(2)}, + }, + { title: '积分', dataIndex: 'credits', key: 'credits', width: 80 }, + { + title: '状态', dataIndex: 'status', key: 'status', width: 100, + render: (s: string) => { + const c = statusConfig[s] || { color: 'default', label: s, icon: null }; + return {c.label}; + }, + }, + { title: '支付宝交易号', dataIndex: 'tradeNo', key: 'tradeNo', width: 200, render: (v: string) => v || '-' }, + { + title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 160, + render: (d: string) => {d ? formatDate(d) : '-'}, + }, + { + title: '支付时间', dataIndex: 'paidAt', key: 'paidAt', width: 160, + render: (d: string) => {d ? formatDate(d) : '-'}, + }, + ]; + + if (!stats) { + return
加载中…
; } - }; - useEffect(() => { load(); }, []); + const paidInfo = stats.byStatus?.paid || { count: 0, amount: 0 }; + const pendingInfo = stats.byStatus?.pending || { count: 0, amount: 0 }; + const cancelledInfo = stats.byStatus?.cancelled || { count: 0, amount: 0 }; + const totalOrders = paidInfo.count + pendingInfo.count + cancelledInfo.count; + const monthInfo = stats.month || { count: 0, amount: 0 }; - const statusConfig: Record = { - paid: { color: 'green', label: '已支付', icon: }, - pending: { color: 'gold', label: '待支付', icon: }, - cancelled: { color: 'default', label: '已取消', icon: }, - }; + return ( +
+ {/* Filters */} + + + + 支付方式: + + + + 状态: + + + + 日期范围: + + + + + + + - const methodConfig: Record = { - alipay: { color: 'blue', label: '支付宝' }, - wechat: { color: 'green', label: '微信' }, - }; + {/* Summary cards */} + + + + } + suffix="元" + valueStyle={{ color: '#10b981', fontWeight: 700 }} + /> + + {stats.today.paidCount} 笔订单 + + + + + + } + suffix="元" + valueStyle={{ color: '#6366f1', fontWeight: 700 }} + /> + + {monthInfo.paidCount} 笔订单 + + + + + + } + suffix="笔" + valueStyle={{ color: '#f59e0b', fontWeight: 700 }} + /> + + ¥{pendingInfo.amount.toFixed(2)} 待付 + + + + + + } + suffix="笔" + valueStyle={{ color: '#94a3b8', fontWeight: 700 }} + /> + + ¥{cancelledInfo.amount.toFixed(2)} 已取消 + + + + - const columns = [ - { title: '订单号', dataIndex: 'orderNo', key: 'orderNo', width: 200 }, - { - title: '支付方式', dataIndex: 'paymentMethod', key: 'paymentMethod', width: 100, - render: (m: string) => { - const c = methodConfig[m] || { color: 'default', label: m }; - return {c.label}; - }, - }, - { - title: '金额', dataIndex: 'amount', key: 'amount', width: 100, - render: (a: number) => ¥{a.toFixed(2)}, - }, - { title: '积分', dataIndex: 'credits', key: 'credits', width: 80 }, - { - title: '状态', dataIndex: 'status', key: 'status', width: 100, - render: (s: string) => { - const c = statusConfig[s] || { color: 'default', label: s, icon: null }; - return {c.label}; - }, - }, - { title: '支付宝交易号', dataIndex: 'tradeNo', key: 'tradeNo', width: 200, render: (v: string) => v || '-' }, - { - title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 160, - render: (d: string) => {d ? formatDate(d) : '-'}, - }, - { - title: '支付时间', dataIndex: 'paidAt', key: 'paidAt', width: 160, - render: (d: string) => {d ? formatDate(d) : '-'}, - }, - ]; + {/* Status breakdown */} + 订单状态分布}> + + {['paid', 'pending', 'cancelled'].map(s => { + const info = stats.byStatus?.[s] || { count: 0, amount: 0 }; + const c = statusConfig[s]; + const pct = totalOrders > 0 ? ((info.count / totalOrders) * 100).toFixed(1) : '0.0'; + return ( + +
+ + {c.label} + {pct}% + +
+ {info.count} +
+
+ ¥{info.amount.toFixed(2)} +
+
+ + ); + })} +
+
- if (!stats) { - return
加载中…
; - } - - const paidInfo = stats.byStatus?.paid || { count: 0, amount: 0 }; - const pendingInfo = stats.byStatus?.pending || { count: 0, amount: 0 }; - const cancelledInfo = stats.byStatus?.cancelled || { count: 0, amount: 0 }; - const totalOrders = paidInfo.count + pendingInfo.count + cancelledInfo.count; - - return ( -
- {/* Summary cards */} - - - - } - suffix="元" - valueStyle={{ color: '#10b981', fontWeight: 700 }} - /> - - {stats.today.paidCount} 笔订单 - - - - - - } - suffix="元" - valueStyle={{ color: '#6366f1', fontWeight: 700 }} - /> - - {paidInfo.count} 笔订单 - - - - - - } - suffix="笔" - valueStyle={{ color: '#f59e0b', fontWeight: 700 }} - /> - - ¥{pendingInfo.amount.toFixed(2)} 待付 - - - - - - } - suffix="笔" - valueStyle={{ color: '#94a3b8', fontWeight: 700 }} - /> - - ¥{cancelledInfo.amount.toFixed(2)} 已取消 - - - - - - {/* Status breakdown */} - 订单状态分布}> - - {['paid', 'pending', 'cancelled'].map(s => { - const info = stats.byStatus?.[s] || { count: 0, amount: 0 }; - const c = statusConfig[s]; - const pct = totalOrders > 0 ? ((info.count / totalOrders) * 100).toFixed(1) : '0.0'; - return ( - -
- - {c.label} - {pct}% - -
- {info.count} -
-
- ¥{info.amount.toFixed(2)} -
-
- - ); - })} -
-
- - {/* Recent orders table */} - 最近 50 笔订单}> - - - - ); + {/* Recent orders table */} + 订单列表}> +
+ + + ); }; export default AdminPaymentStats; diff --git a/video-gen-admin/src/types/index.ts b/video-gen-admin/src/types/index.ts index 8bfed122..b6a2f74d 100644 --- a/video-gen-admin/src/types/index.ts +++ b/video-gen-admin/src/types/index.ts @@ -118,9 +118,10 @@ export interface AdminStats { } export interface PaymentStats { - byStatus: Record; - today: { paidCount: number; paidAmount: number }; - recent: PaymentOrder[]; + byStatus: Record; + today: { paidCount: number; paidAmount: number }; + month: { paidCount: number; paidAmount: number }; + recent: PaymentOrder[]; } export interface PaymentOrder { diff --git a/video-gen-api/app/api/v1/admin.py b/video-gen-api/app/api/v1/admin.py index 958fda78..29382db3 100644 --- a/video-gen-api/app/api/v1/admin.py +++ b/video-gen-api/app/api/v1/admin.py @@ -442,10 +442,14 @@ async def batch_update_payment_configs( @router.get("/payment-stats") async def get_payment_stats( + payment_method: str | None = Query(None), + status: str | None = Query(None), + start_date: str | None = Query(None), + end_date: str | None = Query(None), admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db), ): - """Return payment statistics for admin dashboard.""" + """Return payment statistics for admin dashboard with filters.""" from sqlalchemy import func # Ensure by_status has all expected statuses with defaults @@ -455,13 +459,39 @@ async def get_payment_stats( "cancelled": {"count": 0, "amount": 0.0}, } + # Parse dates and build base query filters + now_cst = datetime.now(CST) + today_start = now_cst.replace(hour=0, minute=0, second=0, microsecond=0) + today_end = today_start + timedelta(days=1) + + # Default to today if no date range provided + query_start = today_start + query_end = today_end + + if start_date: + query_start = datetime.fromisoformat(start_date).replace(tzinfo=CST) + if end_date: + query_end = (datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST) + + # Build filter list for status breakdown + breakdown_filters = [] + if payment_method: + breakdown_filters.append(PaymentOrder.payment_method == payment_method) + if status: + breakdown_filters.append(PaymentOrder.status == status) + # Always apply date range to breakdown + breakdown_filters.append(PaymentOrder.created_at >= query_start) + breakdown_filters.append(PaymentOrder.created_at < query_end) + # Status breakdown status_result = await db.execute( select( PaymentOrder.status, func.count().label("count"), func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"), - ).group_by(PaymentOrder.status) + ) + .where(*breakdown_filters) + .group_by(PaymentOrder.status) ) for row in status_result.all(): if row.status in by_status: @@ -474,11 +504,7 @@ async def get_payment_stats( by_status["cancelled"]["count"] += row.count by_status["cancelled"]["amount"] += round(float(row.amount), 2) - # Today's stats (CST time zone) - now_cst = datetime.now(CST) - today_start = now_cst.replace(hour=0, minute=0, second=0, microsecond=0) - today_end = today_start + timedelta(days=1) - + # Today's stats (CST time zone) - independent of filter today_result = await db.execute( select( func.count().label("paid_count"), @@ -491,9 +517,34 @@ async def get_payment_stats( ) today_row = today_result.one() - # Recent 50 orders + # Monthly cumulative stats + month_start = now_cst.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + month_end = (month_start + timedelta(days=32)).replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + month_result = await db.execute( + select( + func.count().label("paid_count"), + func.coalesce(func.sum(PaymentOrder.amount), 0).label("paid_amount"), + ).where( + PaymentOrder.status == "paid", + PaymentOrder.paid_at >= month_start, + PaymentOrder.paid_at < month_end, + ) + ) + month_row = month_result.one() + + # Recent orders with filters + recent_filters = [] + if payment_method: + recent_filters.append(PaymentOrder.payment_method == payment_method) + if status: + recent_filters.append(PaymentOrder.status == status) + recent_filters.append(PaymentOrder.created_at >= query_start) + recent_filters.append(PaymentOrder.created_at < query_end) + recent_result = await db.execute( select(PaymentOrder) + .where(*recent_filters) .order_by(PaymentOrder.created_at.desc()) .limit(50) ) @@ -505,6 +556,10 @@ async def get_payment_stats( "paid_count": today_row.paid_count, "paid_amount": round(float(today_row.paid_amount), 2), }, + "month": { + "paid_count": month_row.paid_count, + "paid_amount": round(float(month_row.paid_amount), 2), + }, "recent": [ { "id": o.id,