|
|
@@ -0,0 +1,624 @@
|
|
|
+import React, { useState, useEffect, useCallback } from 'react';
|
|
|
+import { useQuery } from '@tanstack/react-query';
|
|
|
+import {
|
|
|
+ ChevronUp,
|
|
|
+ ChevronDown,
|
|
|
+ User,
|
|
|
+ Phone,
|
|
|
+ ShoppingBag,
|
|
|
+ CreditCard,
|
|
|
+ Calendar,
|
|
|
+ DollarSign
|
|
|
+} from 'lucide-react';
|
|
|
+import { dataOverviewClientManager } from '../api/dataOverviewClient';
|
|
|
+import type {
|
|
|
+ TimeFilter,
|
|
|
+ UserConsumptionItem,
|
|
|
+ UserConsumptionPagination,
|
|
|
+ PaginationParams
|
|
|
+} from '../types/dataOverview';
|
|
|
+import { Button } from '@d8d/shared-ui-components/components/ui/button';
|
|
|
+import { Card, CardContent } from '@d8d/shared-ui-components/components/ui/card';
|
|
|
+import { Skeleton } from '@d8d/shared-ui-components/components/ui/skeleton';
|
|
|
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@d8d/shared-ui-components/components/ui/table';
|
|
|
+import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from '@d8d/shared-ui-components/components/ui/pagination';
|
|
|
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@d8d/shared-ui-components/components/ui/select';
|
|
|
+import { toast } from 'sonner';
|
|
|
+import { cn } from '@d8d/shared-ui-components/utils/cn';
|
|
|
+
|
|
|
+export interface UserConsumptionTableProps {
|
|
|
+ /** 租户ID */
|
|
|
+ tenantId?: number;
|
|
|
+ /** 时间筛选参数 */
|
|
|
+ timeFilter: TimeFilter;
|
|
|
+ /** 是否显示表格标题(默认:true) */
|
|
|
+ showTitle?: boolean;
|
|
|
+ /** 是否显示分页(默认:true) */
|
|
|
+ showPagination?: boolean;
|
|
|
+ /** 是否显示排序控件(默认:true) */
|
|
|
+ showSortControls?: boolean;
|
|
|
+ /** 是否自动加载数据(默认:true) */
|
|
|
+ autoLoad?: boolean;
|
|
|
+ /** 初始分页参数 */
|
|
|
+ initialPagination?: PaginationParams;
|
|
|
+ /** 权限检查回调 */
|
|
|
+ onPermissionCheck?: () => boolean;
|
|
|
+}
|
|
|
+
|
|
|
+// 默认分页参数
|
|
|
+const defaultPagination: PaginationParams = {
|
|
|
+ page: 1,
|
|
|
+ limit: 10,
|
|
|
+ sortBy: 'totalSpent',
|
|
|
+ sortOrder: 'desc'
|
|
|
+};
|
|
|
+
|
|
|
+// 列配置
|
|
|
+const columns = [
|
|
|
+ {
|
|
|
+ key: 'userName' as const,
|
|
|
+ label: '用户',
|
|
|
+ icon: <User className="h-3 w-3 mr-1" />,
|
|
|
+ sortable: false
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: 'userPhone' as const,
|
|
|
+ label: '手机号',
|
|
|
+ icon: <Phone className="h-3 w-3 mr-1" />,
|
|
|
+ sortable: false
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: 'totalSpent' as const,
|
|
|
+ label: '累计消费',
|
|
|
+ icon: <DollarSign className="h-3 w-3 mr-1" />,
|
|
|
+ sortable: true,
|
|
|
+ format: 'currency' as const
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: 'orderCount' as const,
|
|
|
+ label: '订单数',
|
|
|
+ icon: <ShoppingBag className="h-3 w-3 mr-1" />,
|
|
|
+ sortable: true,
|
|
|
+ format: 'number' as const
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: 'avgOrderAmount' as const,
|
|
|
+ label: '平均订单金额',
|
|
|
+ icon: <CreditCard className="h-3 w-3 mr-1" />,
|
|
|
+ sortable: true,
|
|
|
+ format: 'currency' as const
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: 'lastOrderDate' as const,
|
|
|
+ label: '最后下单时间',
|
|
|
+ icon: <Calendar className="h-3 w-3 mr-1" />,
|
|
|
+ sortable: true,
|
|
|
+ format: 'date' as const
|
|
|
+ }
|
|
|
+];
|
|
|
+
|
|
|
+export const UserConsumptionTable: React.FC<UserConsumptionTableProps> = ({
|
|
|
+ tenantId,
|
|
|
+ timeFilter,
|
|
|
+ showTitle = true,
|
|
|
+ showPagination = true,
|
|
|
+ showSortControls = true,
|
|
|
+ autoLoad = true,
|
|
|
+ initialPagination = defaultPagination,
|
|
|
+ onPermissionCheck
|
|
|
+}) => {
|
|
|
+ const [pagination, setPagination] = useState<PaginationParams>(initialPagination);
|
|
|
+
|
|
|
+ // 构建查询参数
|
|
|
+ const buildQueryParams = useCallback(() => {
|
|
|
+ const params: Record<string, string | number> = {};
|
|
|
+
|
|
|
+ // 分页参数 - 确保传递数字类型
|
|
|
+ if (pagination.page !== undefined) {
|
|
|
+ params.page = Number(pagination.page);
|
|
|
+ }
|
|
|
+ if (pagination.limit !== undefined) {
|
|
|
+ params.limit = Number(pagination.limit);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (pagination.sortBy) {
|
|
|
+ params.sortBy = pagination.sortBy;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (pagination.sortOrder) {
|
|
|
+ params.sortOrder = pagination.sortOrder;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 添加时间筛选参数
|
|
|
+ if (timeFilter.timeRange) {
|
|
|
+ params.timeRange = timeFilter.timeRange;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (timeFilter.startDate) {
|
|
|
+ params.startDate = timeFilter.startDate;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (timeFilter.endDate) {
|
|
|
+ params.endDate = timeFilter.endDate;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (timeFilter.year !== undefined) {
|
|
|
+ params.year = Number(timeFilter.year); // 确保传递数字
|
|
|
+ }
|
|
|
+
|
|
|
+ // 确保有默认值(数字类型)
|
|
|
+ if (!params.page) params.page = 1;
|
|
|
+ if (!params.limit) params.limit = 10;
|
|
|
+
|
|
|
+ // 调试:记录参数类型
|
|
|
+ console.debug('用户消费统计API调试 - 参数类型检查:', {
|
|
|
+ page: { value: params.page, type: typeof params.page },
|
|
|
+ limit: { value: params.limit, type: typeof params.limit },
|
|
|
+ year: { value: params.year, type: typeof params.year }
|
|
|
+ });
|
|
|
+
|
|
|
+ return params;
|
|
|
+ }, [pagination, timeFilter]);
|
|
|
+
|
|
|
+ // 用户消费统计查询
|
|
|
+ const {
|
|
|
+ data: consumptionData,
|
|
|
+ isLoading: isConsumptionLoading,
|
|
|
+ error: consumptionError,
|
|
|
+ refetch: refetchConsumption
|
|
|
+ } = useQuery({
|
|
|
+ queryKey: ['data-overview-user-consumption', tenantId, timeFilter, pagination],
|
|
|
+ queryFn: async () => {
|
|
|
+ let res;
|
|
|
+ try {
|
|
|
+ const queryParams = buildQueryParams();
|
|
|
+ console.debug('用户消费统计API调试 - 完整请求URL:', '/api/v1/data-overview/user-consumption');
|
|
|
+ console.debug('用户消费统计API调试 - 查询参数:', queryParams);
|
|
|
+ console.debug('用户消费统计API调试 - 客户端管理器:', dataOverviewClientManager);
|
|
|
+ res = await dataOverviewClientManager.get()['user-consumption'].$get({
|
|
|
+ query: queryParams
|
|
|
+ });
|
|
|
+
|
|
|
+ console.debug('用户消费统计API调试 - 响应状态:', res.status);
|
|
|
+ console.debug('用户消费统计API调试 - 响应Headers:', Object.fromEntries(res.headers.entries()));
|
|
|
+
|
|
|
+ // 先读取原始响应文本,以便调试
|
|
|
+ console.debug('用户消费统计API调试 - 准备读取响应文本');
|
|
|
+ const responseText = await res.text();
|
|
|
+ console.debug('用户消费统计API调试 - 原始响应文本(前200字符):', responseText.substring(0, 200));
|
|
|
+ console.debug('用户消费统计API调试 - 响应长度:', responseText.length);
|
|
|
+ console.debug('用户消费统计API调试 - 响应是否为HTML:', responseText.trim().startsWith('<!DOCTYPE') || responseText.trim().startsWith('<html'));
|
|
|
+
|
|
|
+ if (res.status !== 200) {
|
|
|
+ // 尝试解析错误响应为JSON
|
|
|
+ try {
|
|
|
+ const errorData = JSON.parse(responseText);
|
|
|
+ console.error('用户消费统计API调试 - 错误响应数据:', errorData);
|
|
|
+
|
|
|
+ // 处理Zod验证错误
|
|
|
+ if (errorData.error?.name === 'ZodError') {
|
|
|
+ let errorMessage = '参数验证失败: ';
|
|
|
+ try {
|
|
|
+ // 尝试解析Zod错误详情
|
|
|
+ const zodErrors = errorData.error.message;
|
|
|
+ if (Array.isArray(zodErrors)) {
|
|
|
+ errorMessage += zodErrors.map((err: any) =>
|
|
|
+ `字段 "${err.path?.join('.')}" ${err.message || '验证失败'}`
|
|
|
+ ).join('; ');
|
|
|
+ } else if (typeof zodErrors === 'string') {
|
|
|
+ errorMessage += zodErrors;
|
|
|
+ } else {
|
|
|
+ errorMessage += JSON.stringify(zodErrors).substring(0, 200);
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ errorMessage += errorData.error.message || '未知验证错误';
|
|
|
+ }
|
|
|
+ throw new Error(errorMessage);
|
|
|
+ }
|
|
|
+
|
|
|
+ throw new Error(errorData.message || errorData.error?.message || '获取用户消费统计失败');
|
|
|
+ } catch (jsonError) {
|
|
|
+ console.error('用户消费统计API调试 - 无法解析错误响应为JSON:', jsonError);
|
|
|
+ console.error('用户消费统计API调试 - 原始错误响应文本:', responseText.substring(0, 500));
|
|
|
+ throw new Error(`获取用户消费统计失败: ${res.status} ${responseText.substring(0, 100)}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 解析成功响应为JSON
|
|
|
+ try {
|
|
|
+ const responseData = JSON.parse(responseText);
|
|
|
+ console.debug('用户消费统计API调试 - 解析后的数据:', responseData);
|
|
|
+ return responseData;
|
|
|
+ } catch (jsonError) {
|
|
|
+ console.error('用户消费统计API调试 - JSON解析错误:', jsonError);
|
|
|
+ console.error('用户消费统计API调试 - 无法解析的响应文本:', responseText);
|
|
|
+ throw new Error('API返回了无效的JSON响应');
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取用户消费统计失败:', error);
|
|
|
+
|
|
|
+ // 记录响应对象状态(注意:响应体可能已被读取)
|
|
|
+ console.error('用户消费统计API调试 - 响应对象状态:', {
|
|
|
+ resExists: !!res,
|
|
|
+ resType: typeof res,
|
|
|
+ resStatus: res?.status,
|
|
|
+ resOk: res?.ok,
|
|
|
+ resHeaders: res ? Object.fromEntries(res.headers?.entries() || []) : '无响应',
|
|
|
+ bodyUsed: res?.bodyUsed // 检查响应体是否已被使用
|
|
|
+ });
|
|
|
+
|
|
|
+ if (error instanceof Error) {
|
|
|
+ console.error('用户消费统计API调试 - 错误详情:', {
|
|
|
+ name: error.name,
|
|
|
+ message: error.message,
|
|
|
+ stack: error.stack
|
|
|
+ });
|
|
|
+
|
|
|
+ // 检查是否是JSON解析错误
|
|
|
+ if (error.name === 'SyntaxError' && error.message.includes('JSON')) {
|
|
|
+ console.error('用户消费统计API调试 - 检测到JSON解析错误,响应可能不是有效的JSON');
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ console.error('用户消费统计API调试 - 未知错误类型:', error);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 不再尝试读取响应文本,因为可能已经读取过了
|
|
|
+ if (!res) {
|
|
|
+ console.error('用户消费统计API调试 - 响应对象不存在,可能是网络错误或API调用失败');
|
|
|
+ } else if (res.bodyUsed) {
|
|
|
+ console.error('用户消费统计API调试 - 响应体已被读取,无法再次读取');
|
|
|
+ }
|
|
|
+
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ enabled: autoLoad && (!onPermissionCheck || onPermissionCheck()),
|
|
|
+ refetchOnWindowFocus: false
|
|
|
+ });
|
|
|
+
|
|
|
+ // 处理排序
|
|
|
+ const handleSort = useCallback((sortBy: PaginationParams['sortBy']) => {
|
|
|
+ setPagination(prev => {
|
|
|
+ if (prev.sortBy === sortBy) {
|
|
|
+ // 切换排序方向
|
|
|
+ return {
|
|
|
+ ...prev,
|
|
|
+ sortOrder: prev.sortOrder === 'asc' ? 'desc' : 'asc'
|
|
|
+ };
|
|
|
+ } else {
|
|
|
+ // 切换排序字段,默认降序
|
|
|
+ return {
|
|
|
+ ...prev,
|
|
|
+ sortBy,
|
|
|
+ sortOrder: 'desc'
|
|
|
+ };
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 处理页码变更
|
|
|
+ const handlePageChange = useCallback((page: number) => {
|
|
|
+ setPagination(prev => ({
|
|
|
+ ...prev,
|
|
|
+ page
|
|
|
+ }));
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 处理每页数量变更
|
|
|
+ const handleLimitChange = useCallback((limit: number) => {
|
|
|
+ setPagination(prev => ({
|
|
|
+ ...prev,
|
|
|
+ limit,
|
|
|
+ page: 1 // 重置到第一页
|
|
|
+ }));
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 处理刷新数据
|
|
|
+ const handleRefresh = useCallback(() => {
|
|
|
+ refetchConsumption();
|
|
|
+ toast.success('用户消费数据已刷新');
|
|
|
+ }, [refetchConsumption]);
|
|
|
+
|
|
|
+ // 错误处理
|
|
|
+ useEffect(() => {
|
|
|
+ if (consumptionError) {
|
|
|
+ toast.error(`获取用户消费统计失败: ${consumptionError instanceof Error ? consumptionError.message : '未知错误'}`);
|
|
|
+ }
|
|
|
+ }, [consumptionError]);
|
|
|
+
|
|
|
+ // 获取统计数据和分页信息
|
|
|
+ const isSuccessResponse = consumptionData && 'success' in consumptionData && consumptionData.success === true;
|
|
|
+ const consumptionStats = isSuccessResponse ? consumptionData.data?.items || [] : [];
|
|
|
+ const paginationInfo: UserConsumptionPagination = isSuccessResponse ? consumptionData.data?.pagination || {
|
|
|
+ page: pagination.page || 1,
|
|
|
+ limit: pagination.limit || 10,
|
|
|
+ total: 0,
|
|
|
+ totalPages: 0
|
|
|
+ } : {
|
|
|
+ page: pagination.page || 1,
|
|
|
+ limit: pagination.limit || 10,
|
|
|
+ total: 0,
|
|
|
+ totalPages: 0
|
|
|
+ };
|
|
|
+
|
|
|
+ // 格式化值
|
|
|
+ const formatValue = (value: any, format?: 'currency' | 'number' | 'date') => {
|
|
|
+ if (value === undefined || value === null) return '-';
|
|
|
+
|
|
|
+ switch (format) {
|
|
|
+ case 'currency':
|
|
|
+ return `¥${Number(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
|
+ case 'number':
|
|
|
+ return Number(value).toLocaleString('zh-CN');
|
|
|
+ case 'date':
|
|
|
+ try {
|
|
|
+ const date = new Date(value);
|
|
|
+ return date.toLocaleDateString('zh-CN', {
|
|
|
+ year: 'numeric',
|
|
|
+ month: 'short',
|
|
|
+ day: 'numeric',
|
|
|
+ hour: '2-digit',
|
|
|
+ minute: '2-digit'
|
|
|
+ });
|
|
|
+ } catch {
|
|
|
+ return '-';
|
|
|
+ }
|
|
|
+ default:
|
|
|
+ return value;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 渲染排序图标
|
|
|
+ const renderSortIcon = (columnKey: PaginationParams['sortBy']) => {
|
|
|
+ if (columnKey !== pagination.sortBy) return null;
|
|
|
+
|
|
|
+ return pagination.sortOrder === 'asc' ?
|
|
|
+ <ChevronUp className="h-4 w-4 ml-1" /> :
|
|
|
+ <ChevronDown className="h-4 w-4 ml-1" />;
|
|
|
+ };
|
|
|
+
|
|
|
+ // 渲染骨架屏
|
|
|
+ const renderSkeleton = () => (
|
|
|
+ <div className="space-y-3">
|
|
|
+ {showTitle && (
|
|
|
+ <Skeleton className="h-6 w-48" />
|
|
|
+ )}
|
|
|
+ <div className="rounded-md border">
|
|
|
+ <Table>
|
|
|
+ <TableHeader>
|
|
|
+ <TableRow>
|
|
|
+ {columns.map((_, index) => (
|
|
|
+ <TableHead key={index}>
|
|
|
+ <Skeleton className="h-4 w-20" />
|
|
|
+ </TableHead>
|
|
|
+ ))}
|
|
|
+ </TableRow>
|
|
|
+ </TableHeader>
|
|
|
+ <TableBody>
|
|
|
+ {Array.from({ length: 5 }).map((_, rowIndex) => (
|
|
|
+ <TableRow key={rowIndex}>
|
|
|
+ {columns.map((_, colIndex) => (
|
|
|
+ <TableCell key={colIndex}>
|
|
|
+ <Skeleton className="h-4 w-full" />
|
|
|
+ </TableCell>
|
|
|
+ ))}
|
|
|
+ </TableRow>
|
|
|
+ ))}
|
|
|
+ </TableBody>
|
|
|
+ </Table>
|
|
|
+ </div>
|
|
|
+ {showPagination && (
|
|
|
+ <div className="flex justify-between items-center">
|
|
|
+ <Skeleton className="h-4 w-32" />
|
|
|
+ <Skeleton className="h-8 w-48" />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+
|
|
|
+ // 渲染错误状态
|
|
|
+ const renderError = () => (
|
|
|
+ <Card className="border-red-200 bg-red-50">
|
|
|
+ <CardContent className="pt-6">
|
|
|
+ <div className="text-center text-red-600">
|
|
|
+ <p className="font-medium">用户消费数据加载失败</p>
|
|
|
+ <p className="text-sm mt-1">请检查网络连接或稍后重试</p>
|
|
|
+ <Button variant="outline" className="mt-3" onClick={handleRefresh}>
|
|
|
+ 重新加载
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ );
|
|
|
+
|
|
|
+ // 渲染空状态
|
|
|
+ const renderEmpty = () => (
|
|
|
+ <Card>
|
|
|
+ <CardContent className="pt-6">
|
|
|
+ <div className="text-center text-gray-500">
|
|
|
+ <ShoppingBag className="h-12 w-12 mx-auto mb-3 opacity-20" />
|
|
|
+ <p className="font-medium">暂无用户消费数据</p>
|
|
|
+ <p className="text-sm mt-1">当前筛选条件下没有找到用户消费记录</p>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ );
|
|
|
+
|
|
|
+ // 渲染分页控件
|
|
|
+ const renderPagination = () => {
|
|
|
+ if (!showPagination || paginationInfo.totalPages <= 1) return null;
|
|
|
+
|
|
|
+ const { page, totalPages } = paginationInfo;
|
|
|
+ const maxVisiblePages = 5;
|
|
|
+ const halfVisible = Math.floor(maxVisiblePages / 2);
|
|
|
+
|
|
|
+ let startPage = Math.max(1, page - halfVisible);
|
|
|
+ let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
|
|
|
+
|
|
|
+ if (endPage - startPage + 1 < maxVisiblePages) {
|
|
|
+ startPage = Math.max(1, endPage - maxVisiblePages + 1);
|
|
|
+ }
|
|
|
+
|
|
|
+ const pageNumbers = [];
|
|
|
+ for (let i = startPage; i <= endPage; i++) {
|
|
|
+ pageNumbers.push(i);
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="flex flex-col sm:flex-row justify-between items-center gap-4 mt-4">
|
|
|
+ <div className="text-sm text-muted-foreground">
|
|
|
+ 显示第 {(page - 1) * paginationInfo.limit + 1} - {Math.min(page * paginationInfo.limit, paginationInfo.total)} 条,
|
|
|
+ 共 {paginationInfo.total} 条记录
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <Select
|
|
|
+ value={paginationInfo.limit.toString()}
|
|
|
+ onValueChange={(value) => handleLimitChange(Number(value))}
|
|
|
+ >
|
|
|
+ <SelectTrigger className="h-8 w-20">
|
|
|
+ <SelectValue placeholder="10条" />
|
|
|
+ </SelectTrigger>
|
|
|
+ <SelectContent>
|
|
|
+ <SelectItem value="10">10条</SelectItem>
|
|
|
+ <SelectItem value="20">20条</SelectItem>
|
|
|
+ <SelectItem value="50">50条</SelectItem>
|
|
|
+ <SelectItem value="100">100条</SelectItem>
|
|
|
+ </SelectContent>
|
|
|
+ </Select>
|
|
|
+
|
|
|
+ <Pagination>
|
|
|
+ <PaginationContent>
|
|
|
+ <PaginationItem>
|
|
|
+ <PaginationPrevious
|
|
|
+ onClick={() => page > 1 && handlePageChange(page - 1)}
|
|
|
+ className={cn(page <= 1 && "pointer-events-none opacity-50")}
|
|
|
+ />
|
|
|
+ </PaginationItem>
|
|
|
+
|
|
|
+ {pageNumbers.map((pageNum) => (
|
|
|
+ <PaginationItem key={pageNum}>
|
|
|
+ <PaginationLink
|
|
|
+ onClick={() => handlePageChange(pageNum)}
|
|
|
+ isActive={pageNum === page}
|
|
|
+ >
|
|
|
+ {pageNum}
|
|
|
+ </PaginationLink>
|
|
|
+ </PaginationItem>
|
|
|
+ ))}
|
|
|
+
|
|
|
+ <PaginationItem>
|
|
|
+ <PaginationNext
|
|
|
+ onClick={() => page < totalPages && handlePageChange(page + 1)}
|
|
|
+ className={cn(page >= totalPages && "pointer-events-none opacity-50")}
|
|
|
+ />
|
|
|
+ </PaginationItem>
|
|
|
+ </PaginationContent>
|
|
|
+ </Pagination>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ };
|
|
|
+
|
|
|
+ // 主渲染逻辑
|
|
|
+ if (isConsumptionLoading) {
|
|
|
+ return renderSkeleton();
|
|
|
+ }
|
|
|
+
|
|
|
+ if (consumptionError) {
|
|
|
+ return renderError();
|
|
|
+ }
|
|
|
+
|
|
|
+ if (consumptionStats.length === 0) {
|
|
|
+ return renderEmpty();
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="space-y-4">
|
|
|
+ {showTitle && (
|
|
|
+ <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
|
|
|
+ <div>
|
|
|
+ <h3 className="text-lg font-semibold">用户消费统计</h3>
|
|
|
+ <p className="text-sm text-muted-foreground">
|
|
|
+ 按{pagination.sortBy === 'totalSpent' ? '累计消费' :
|
|
|
+ pagination.sortBy === 'orderCount' ? '订单数' :
|
|
|
+ pagination.sortBy === 'avgOrderAmount' ? '平均订单金额' :
|
|
|
+ '最后下单时间'}
|
|
|
+ {pagination.sortOrder === 'asc' ? '升序' : '降序'}排列
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <Button
|
|
|
+ variant="outline"
|
|
|
+ size="sm"
|
|
|
+ onClick={handleRefresh}
|
|
|
+ aria-label="刷新用户消费数据"
|
|
|
+ >
|
|
|
+ 刷新
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <div className="rounded-md border">
|
|
|
+ <Table>
|
|
|
+ <TableHeader>
|
|
|
+ <TableRow>
|
|
|
+ {columns.map((column) => (
|
|
|
+ <TableHead
|
|
|
+ key={column.key}
|
|
|
+ className={cn(
|
|
|
+ column.sortable && showSortControls && "cursor-pointer hover:bg-gray-50",
|
|
|
+ "whitespace-nowrap"
|
|
|
+ )}
|
|
|
+ onClick={() => {
|
|
|
+ if (column.sortable && showSortControls) {
|
|
|
+ // 确保只传递有效的排序字段
|
|
|
+ const validSortFields: (PaginationParams['sortBy'])[] = ['totalSpent', 'orderCount', 'avgOrderAmount', 'lastOrderDate'];
|
|
|
+ if (validSortFields.includes(column.key as any)) {
|
|
|
+ handleSort(column.key as PaginationParams['sortBy']);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div className="flex items-center">
|
|
|
+ {column.icon}
|
|
|
+ <span>{column.label}</span>
|
|
|
+ {column.sortable && showSortControls && renderSortIcon(column.key as PaginationParams['sortBy'])}
|
|
|
+ </div>
|
|
|
+ </TableHead>
|
|
|
+ ))}
|
|
|
+ </TableRow>
|
|
|
+ </TableHeader>
|
|
|
+ <TableBody>
|
|
|
+ {consumptionStats.map((item: UserConsumptionItem) => (
|
|
|
+ <TableRow key={item.userId}>
|
|
|
+ <TableCell className="font-medium">
|
|
|
+ {item.userName || `用户 ${item.userId}`}
|
|
|
+ </TableCell>
|
|
|
+ <TableCell>
|
|
|
+ {item.userPhone || '-'}
|
|
|
+ </TableCell>
|
|
|
+ <TableCell className="font-medium">
|
|
|
+ {formatValue(item.totalSpent, 'currency')}
|
|
|
+ </TableCell>
|
|
|
+ <TableCell>
|
|
|
+ {formatValue(item.orderCount, 'number')}
|
|
|
+ </TableCell>
|
|
|
+ <TableCell>
|
|
|
+ {formatValue(item.avgOrderAmount, 'currency')}
|
|
|
+ </TableCell>
|
|
|
+ <TableCell>
|
|
|
+ {item.lastOrderDate ? formatValue(item.lastOrderDate, 'date') : '-'}
|
|
|
+ </TableCell>
|
|
|
+ </TableRow>
|
|
|
+ ))}
|
|
|
+ </TableBody>
|
|
|
+ </Table>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {renderPagination()}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|