增加支付管理页面的搜索和逻辑

This commit is contained in:
2026-06-11 14:06:02 +08:00
parent bb087cc8a6
commit e975ccc6d2
6 changed files with 343 additions and 193 deletions
+4 -3
View File
@@ -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": {
+1
View File
@@ -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",
+18 -5
View File
@@ -211,12 +211,25 @@ export async function batchUpdatePaymentConfigs(configs: Record<string, string>)
await api.put('/admin/payment-configs/batch', configs);
}
export async function getPaymentStats(): Promise<{
byStatus: Record<string, { count: number; amount: number }>;
today: { paidCount: number; paidAmount: number };
recent: any[];
export async function getPaymentStats(params?: {
paymentMethod?: string;
status?: string;
startDate?: string;
endDate?: string;
}): Promise<{
byStatus: Record<string, { count: number; amount: number }>;
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[] }> {
+253 -174
View File
@@ -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<any>(null);
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<any>(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<string, { color: string; label: string; icon: React.ReactNode }> = {
paid: { color: 'green', label: '已支付', icon: <CheckCircleOutlined /> },
pending: { color: 'gold', label: '待支付', icon: <ClockCircleOutlined /> },
cancelled: { color: 'default', label: '已取消', icon: <CloseCircleOutlined /> },
};
const methodConfig: Record<string, { color: string; label: string }> = {
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 <Tag color={c.color}>{c.label}</Tag>;
},
},
{
title: '金额', dataIndex: 'amount', key: 'amount', width: 100,
render: (a: number) => <Typography.Text strong style={{ color: '#10b981' }}>¥{a.toFixed(2)}</Typography.Text>,
},
{ 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 <Tag color={c.color} icon={c.icon}>{c.label}</Tag>;
},
},
{ title: '支付宝交易号', dataIndex: 'tradeNo', key: 'tradeNo', width: 200, render: (v: string) => v || '-' },
{
title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 160,
render: (d: string) => <span className="date-display" style={{ color: '#94a3b8' }}>{d ? formatDate(d) : '-'}</span>,
},
{
title: '支付时间', dataIndex: 'paidAt', key: 'paidAt', width: 160,
render: (d: string) => <span className="date-display" style={{ color: '#94a3b8' }}>{d ? formatDate(d) : '-'}</span>,
},
];
if (!stats) {
return <div style={{ padding: 24, color: '#94a3b8' }}></div>;
}
};
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<string, { color: string; label: string; icon: React.ReactNode }> = {
paid: { color: 'green', label: '已支付', icon: <CheckCircleOutlined /> },
pending: { color: 'gold', label: '待支付', icon: <ClockCircleOutlined /> },
cancelled: { color: 'default', label: '已取消', icon: <CloseCircleOutlined /> },
};
return (
<div>
{/* Filters */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 24 }}>
<Row gutter={[16, 16]} align="middle">
<Col xs={24} sm={12} md={6}>
<span style={{ marginRight: 8 }}></span>
<Select
placeholder="全部"
allowClear
style={{ width: 150 }}
value={filters.paymentMethod}
onChange={(value) => setFilters(prev => ({ ...prev, paymentMethod: value }))}
>
<Option value="alipay"></Option>
<Option value="wechat"></Option>
</Select>
</Col>
<Col xs={24} sm={12} md={6}>
<span style={{ marginRight: 8 }}></span>
<Select
placeholder="全部"
allowClear
style={{ width: 150 }}
value={filters.status}
onChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
>
<Option value="paid"></Option>
<Option value="pending"></Option>
<Option value="cancelled"></Option>
</Select>
</Col>
<Col xs={24} sm={12} md={8}>
<span style={{ marginRight: 8 }}></span>
<RangePicker
value={[
dayjs(filters.startDate),
dayjs(filters.endDate),
]}
onChange={handleDateChange}
/>
</Col>
<Col xs={24} sm={12} md={4}>
<Button icon={<ReloadOutlined />} onClick={handleReset}>
</Button>
</Col>
</Row>
</Card>
const methodConfig: Record<string, { color: string; label: string }> = {
alipay: { color: 'blue', label: '支付宝' },
wechat: { color: 'green', label: '微信' },
};
{/* Summary cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={6}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="今日收入"
value={stats.today.paidAmount}
precision={2}
prefix={<DollarOutlined style={{ color: '#10b981' }} />}
suffix="元"
valueStyle={{ color: '#10b981', fontWeight: 700 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{stats.today.paidCount}
</Typography.Text>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="本月累计"
value={monthInfo.paidAmount}
precision={2}
prefix={<DollarOutlined style={{ color: '#6366f1' }} />}
suffix="元"
valueStyle={{ color: '#6366f1', fontWeight: 700 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{monthInfo.paidCount}
</Typography.Text>
</Card>
</Col>
<Col xs={12} sm={12} lg={6}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="待支付"
value={pendingInfo.count}
prefix={<ClockCircleOutlined style={{ color: '#f59e0b' }} />}
suffix="笔"
valueStyle={{ color: '#f59e0b', fontWeight: 700 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
¥{pendingInfo.amount.toFixed(2)}
</Typography.Text>
</Card>
</Col>
<Col xs={12} sm={12} lg={6}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="已取消"
value={cancelledInfo.count}
prefix={<CloseCircleOutlined style={{ color: '#94a3b8' }} />}
suffix="笔"
valueStyle={{ color: '#94a3b8', fontWeight: 700 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
¥{cancelledInfo.amount.toFixed(2)}
</Typography.Text>
</Card>
</Col>
</Row>
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 <Tag color={c.color}>{c.label}</Tag>;
},
},
{
title: '金额', dataIndex: 'amount', key: 'amount', width: 100,
render: (a: number) => <Typography.Text strong style={{ color: '#10b981' }}>¥{a.toFixed(2)}</Typography.Text>,
},
{ 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 <Tag color={c.color} icon={c.icon}>{c.label}</Tag>;
},
},
{ title: '支付宝交易号', dataIndex: 'tradeNo', key: 'tradeNo', width: 200, render: (v: string) => v || '-' },
{
title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 160,
render: (d: string) => <span className="date-display" style={{ color: '#94a3b8' }}>{d ? formatDate(d) : '-'}</span>,
},
{
title: '支付时间', dataIndex: 'paidAt', key: 'paidAt', width: 160,
render: (d: string) => <span className="date-display" style={{ color: '#94a3b8' }}>{d ? formatDate(d) : '-'}</span>,
},
];
{/* Status breakdown */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}
title={<Space><DollarOutlined /></Space>}>
<Row gutter={16}>
{['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 (
<Col span={8} key={s}>
<div style={{
padding: 16, borderRadius: 10,
background: '#fafbff', border: '1px solid #f0f0f5',
}}>
<Space>
<Tag color={c.color} icon={c.icon}>{c.label}</Tag>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{pct}%</Typography.Text>
</Space>
<div style={{ marginTop: 8, fontSize: 20, fontWeight: 700 }}>
{info.count} <span style={{ fontSize: 13, color: '#94a3b8', fontWeight: 400 }}></span>
</div>
<div style={{ fontSize: 13, color: '#64748b', marginTop: 4 }}>
¥{info.amount.toFixed(2)}
</div>
</div>
</Col>
);
})}
</Row>
</Card>
if (!stats) {
return <div style={{ padding: 24, color: '#94a3b8' }}></div>;
}
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 (
<div>
{/* Summary cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={6}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="今日收入"
value={stats.today.paidAmount}
precision={2}
prefix={<DollarOutlined style={{ color: '#10b981' }} />}
suffix="元"
valueStyle={{ color: '#10b981', fontWeight: 700 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{stats.today.paidCount}
</Typography.Text>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="累计已支付"
value={paidInfo.amount}
precision={2}
prefix={<DollarOutlined style={{ color: '#6366f1' }} />}
suffix="元"
valueStyle={{ color: '#6366f1', fontWeight: 700 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{paidInfo.count}
</Typography.Text>
</Card>
</Col>
<Col xs={12} sm={12} lg={6}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="待支付"
value={pendingInfo.count}
prefix={<ClockCircleOutlined style={{ color: '#f59e0b' }} />}
suffix="笔"
valueStyle={{ color: '#f59e0b', fontWeight: 700 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
¥{pendingInfo.amount.toFixed(2)}
</Typography.Text>
</Card>
</Col>
<Col xs={12} sm={12} lg={6}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="已取消"
value={cancelledInfo.count}
prefix={<CloseCircleOutlined style={{ color: '#94a3b8' }} />}
suffix="笔"
valueStyle={{ color: '#94a3b8', fontWeight: 700 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
¥{cancelledInfo.amount.toFixed(2)}
</Typography.Text>
</Card>
</Col>
</Row>
{/* Status breakdown */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}
title={<Space><DollarOutlined /></Space>}>
<Row gutter={16}>
{['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 (
<Col span={8} key={s}>
<div style={{
padding: 16, borderRadius: 10,
background: '#fafbff', border: '1px solid #f0f0f5',
}}>
<Space>
<Tag color={c.color} icon={c.icon}>{c.label}</Tag>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{pct}%</Typography.Text>
</Space>
<div style={{ marginTop: 8, fontSize: 20, fontWeight: 700 }}>
{info.count} <span style={{ fontSize: 13, color: '#94a3b8', fontWeight: 400 }}></span>
</div>
<div style={{ fontSize: 13, color: '#64748b', marginTop: 4 }}>
¥{info.amount.toFixed(2)}
</div>
</div>
</Col>
);
})}
</Row>
</Card>
{/* Recent orders table */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}
title={<Space><DollarOutlined /> 50 </Space>}>
<Table
columns={columns}
dataSource={stats.recent}
rowKey="id"
loading={loading}
pagination={{ pageSize: 10, size: 'small' }}
scroll={{ x: 1000 }}
size="middle"
/>
</Card>
</div>
);
{/* Recent orders table */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}
title={<Space><DollarOutlined /></Space>}>
<Table
columns={columns}
dataSource={stats.recent}
rowKey="id"
loading={loading}
pagination={{ pageSize: 10, size: 'small' }}
scroll={{ x: 1000 }}
size="middle"
/>
</Card>
</div>
);
};
export default AdminPaymentStats;
+4 -3
View File
@@ -118,9 +118,10 @@ export interface AdminStats {
}
export interface PaymentStats {
byStatus: Record<string, { count: number; amount: number }>;
today: { paidCount: number; paidAmount: number };
recent: PaymentOrder[];
byStatus: Record<string, { count: number; amount: number }>;
today: { paidCount: number; paidAmount: number };
month: { paidCount: number; paidAmount: number };
recent: PaymentOrder[];
}
export interface PaymentOrder {
+63 -8
View File
@@ -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,