|
|
@@ -0,0 +1,437 @@
|
|
|
+import React, { useState, useEffect, useCallback } from 'react';
|
|
|
+import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
|
+import { RefreshCw, TrendingUp, TrendingDown } from 'lucide-react';
|
|
|
+import { dataOverviewClientManager } from '../api/dataOverviewClient';
|
|
|
+import type { InferResponseType } from 'hono/client';
|
|
|
+import type { TimeFilter, StatCardConfig, PaymentMethod, SummaryStatistics, TodayStatistics } from '../types/dataOverview';
|
|
|
+import { Button } from '@d8d/shared-ui-components/components/ui/button';
|
|
|
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@d8d/shared-ui-components/components/ui/card';
|
|
|
+import { Skeleton } from '@d8d/shared-ui-components/components/ui/skeleton';
|
|
|
+import { toast } from 'sonner';
|
|
|
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@d8d/shared-ui-components/components/ui/tabs';
|
|
|
+import { TimeFilter as TimeFilterComponent } from './TimeFilter';
|
|
|
+import { StatCard } from './StatCard';
|
|
|
+
|
|
|
+// 使用已定义的类型
|
|
|
+import type { SummaryResponse, TodayResponse } from '../types/dataOverview';
|
|
|
+
|
|
|
+// 默认时间筛选
|
|
|
+const defaultTimeFilter: TimeFilter = {
|
|
|
+ timeRange: 'today'
|
|
|
+};
|
|
|
+
|
|
|
+// 默认卡片配置
|
|
|
+const defaultCardConfigs: StatCardConfig[] = [
|
|
|
+ {
|
|
|
+ type: 'totalSales',
|
|
|
+ title: '总销售额',
|
|
|
+ description: '累计销售总额',
|
|
|
+ icon: 'dollar-sign',
|
|
|
+ format: 'currency',
|
|
|
+ showPaymentBreakdown: true,
|
|
|
+ defaultPaymentMethod: 'all'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: 'totalOrders',
|
|
|
+ title: '总订单数',
|
|
|
+ description: '累计订单总数',
|
|
|
+ icon: 'shopping-cart',
|
|
|
+ format: 'number',
|
|
|
+ showPaymentBreakdown: true,
|
|
|
+ defaultPaymentMethod: 'all'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: 'todaySales',
|
|
|
+ title: '今日销售额',
|
|
|
+ description: '今日销售总额',
|
|
|
+ icon: 'trending-up',
|
|
|
+ format: 'currency',
|
|
|
+ showPaymentBreakdown: false,
|
|
|
+ defaultPaymentMethod: 'all'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: 'todayOrders',
|
|
|
+ title: '今日订单数',
|
|
|
+ description: '今日订单总数',
|
|
|
+ icon: 'package',
|
|
|
+ format: 'number',
|
|
|
+ showPaymentBreakdown: false,
|
|
|
+ defaultPaymentMethod: 'all'
|
|
|
+ }
|
|
|
+];
|
|
|
+
|
|
|
+// 时间范围选项
|
|
|
+const defaultTimeRangeOptions = [
|
|
|
+ { value: 'today' as const, label: '今日', description: '当天数据' },
|
|
|
+ { value: 'yesterday' as const, label: '昨日', description: '前一天数据' },
|
|
|
+ { value: 'last7days' as const, label: '最近7天', description: '最近7天数据' },
|
|
|
+ { value: 'last30days' as const, label: '最近30天', description: '最近30天数据' },
|
|
|
+ { value: 'custom' as const, label: '自定义', description: '选择自定义时间范围' }
|
|
|
+];
|
|
|
+
|
|
|
+export interface DataOverviewPanelProps {
|
|
|
+ /** 租户ID(可选,从上下文中获取) */
|
|
|
+ tenantId?: number;
|
|
|
+ /** 是否显示时间筛选器(默认:true) */
|
|
|
+ showTimeFilter?: boolean;
|
|
|
+ /** 是否显示支付方式切换(默认:true) */
|
|
|
+ showPaymentToggle?: boolean;
|
|
|
+ /** 是否显示刷新按钮(默认:true) */
|
|
|
+ showRefreshButton?: boolean;
|
|
|
+ /** 自动刷新间隔(毫秒,默认:0表示不自动刷新) */
|
|
|
+ autoRefreshInterval?: number;
|
|
|
+ /** 自定义卡片配置 */
|
|
|
+ cardConfigs?: StatCardConfig[];
|
|
|
+ /** 自定义时间范围选项 */
|
|
|
+ timeRangeOptions?: typeof defaultTimeRangeOptions;
|
|
|
+ /** 权限检查回调 */
|
|
|
+ onPermissionCheck?: () => boolean;
|
|
|
+}
|
|
|
+
|
|
|
+export const DataOverviewPanel: React.FC<DataOverviewPanelProps> = ({
|
|
|
+ tenantId,
|
|
|
+ showTimeFilter = true,
|
|
|
+ showPaymentToggle = true,
|
|
|
+ showRefreshButton = true,
|
|
|
+ autoRefreshInterval = 0,
|
|
|
+ cardConfigs = defaultCardConfigs,
|
|
|
+ timeRangeOptions = defaultTimeRangeOptions,
|
|
|
+ onPermissionCheck
|
|
|
+}) => {
|
|
|
+ const [timeFilter, setTimeFilter] = useState<TimeFilter>(defaultTimeFilter);
|
|
|
+ const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>('all');
|
|
|
+ const queryClient = useQueryClient();
|
|
|
+
|
|
|
+ // 检查权限
|
|
|
+ useEffect(() => {
|
|
|
+ if (onPermissionCheck && !onPermissionCheck()) {
|
|
|
+ toast.error('权限不足,无法访问数据概览');
|
|
|
+ // 这里可以重定向到登录页面或其他处理
|
|
|
+ }
|
|
|
+ }, [onPermissionCheck]);
|
|
|
+
|
|
|
+ // 数据概览统计查询
|
|
|
+ const {
|
|
|
+ data: summaryData,
|
|
|
+ isLoading: isSummaryLoading,
|
|
|
+ error: summaryError,
|
|
|
+ refetch: refetchSummary
|
|
|
+ } = useQuery({
|
|
|
+ queryKey: ['data-overview-summary', timeFilter, tenantId],
|
|
|
+ queryFn: async () => {
|
|
|
+ try {
|
|
|
+ const res = await dataOverviewClientManager.get().summary.$get({
|
|
|
+ query: timeFilter
|
|
|
+ });
|
|
|
+
|
|
|
+ if (res.status !== 200) {
|
|
|
+ const errorData = await res.json();
|
|
|
+ throw new Error(errorData.message || '获取数据概览统计失败');
|
|
|
+ }
|
|
|
+
|
|
|
+ return await res.json() as SummaryResponse;
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取数据概览统计失败:', error);
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ enabled: !onPermissionCheck || onPermissionCheck(),
|
|
|
+ refetchOnWindowFocus: false
|
|
|
+ });
|
|
|
+
|
|
|
+ // 今日实时数据查询
|
|
|
+ const {
|
|
|
+ data: todayData,
|
|
|
+ isLoading: isTodayLoading,
|
|
|
+ error: todayError,
|
|
|
+ refetch: refetchToday
|
|
|
+ } = useQuery({
|
|
|
+ queryKey: ['data-overview-today', tenantId],
|
|
|
+ queryFn: async () => {
|
|
|
+ try {
|
|
|
+ const res = await dataOverviewClientManager.get().today.$get();
|
|
|
+
|
|
|
+ if (res.status !== 200) {
|
|
|
+ const errorData = await res.json();
|
|
|
+ throw new Error(errorData.message || '获取今日实时数据失败');
|
|
|
+ }
|
|
|
+
|
|
|
+ return await res.json() as TodayResponse;
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取今日实时数据失败:', error);
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ enabled: !onPermissionCheck || onPermissionCheck(),
|
|
|
+ refetchOnWindowFocus: false
|
|
|
+ });
|
|
|
+
|
|
|
+ // 合并统计数据
|
|
|
+ const statistics = summaryData?.data;
|
|
|
+ const todayStatistics = todayData?.data;
|
|
|
+
|
|
|
+ // 处理时间筛选变更
|
|
|
+ const handleTimeFilterChange = useCallback((filter: TimeFilter) => {
|
|
|
+ setTimeFilter(filter);
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 处理刷新数据
|
|
|
+ const handleRefresh = useCallback(() => {
|
|
|
+ refetchSummary();
|
|
|
+ refetchToday();
|
|
|
+ toast.success('数据已刷新');
|
|
|
+ }, [refetchSummary, refetchToday]);
|
|
|
+
|
|
|
+ // 自动刷新
|
|
|
+ useEffect(() => {
|
|
|
+ if (!autoRefreshInterval || autoRefreshInterval <= 0) return;
|
|
|
+
|
|
|
+ const intervalId = setInterval(() => {
|
|
|
+ handleRefresh();
|
|
|
+ }, autoRefreshInterval);
|
|
|
+
|
|
|
+ return () => clearInterval(intervalId);
|
|
|
+ }, [autoRefreshInterval, handleRefresh]);
|
|
|
+
|
|
|
+ // 处理支付方式变更
|
|
|
+ const handlePaymentMethodChange = useCallback((method: PaymentMethod) => {
|
|
|
+ setPaymentMethod(method);
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 根据支付方式筛选数据
|
|
|
+ const getFilteredValue = (value: number, breakdown?: { wechat?: number; credit?: number }) => {
|
|
|
+ if (!breakdown || paymentMethod === 'all') return value;
|
|
|
+ if (paymentMethod === 'wechat') return breakdown.wechat || 0;
|
|
|
+ if (paymentMethod === 'credit') return breakdown.credit || 0;
|
|
|
+ return value;
|
|
|
+ };
|
|
|
+
|
|
|
+ // 获取卡片值
|
|
|
+ const getCardValue = (config: StatCardConfig) => {
|
|
|
+ if (!statistics || !todayStatistics) return 0;
|
|
|
+
|
|
|
+ switch (config.type) {
|
|
|
+ case 'totalSales':
|
|
|
+ return getFilteredValue(statistics.totalSales, {
|
|
|
+ wechat: statistics.wechatSales,
|
|
|
+ credit: statistics.creditSales
|
|
|
+ });
|
|
|
+ case 'totalOrders':
|
|
|
+ return getFilteredValue(statistics.totalOrders, {
|
|
|
+ wechat: statistics.wechatOrders,
|
|
|
+ credit: statistics.creditOrders
|
|
|
+ });
|
|
|
+ case 'todaySales':
|
|
|
+ return todayStatistics.todaySales;
|
|
|
+ case 'todayOrders':
|
|
|
+ return todayStatistics.todayOrders;
|
|
|
+ case 'wechatSales':
|
|
|
+ return statistics.wechatSales;
|
|
|
+ case 'wechatOrders':
|
|
|
+ return statistics.wechatOrders;
|
|
|
+ case 'creditSales':
|
|
|
+ return statistics.creditSales;
|
|
|
+ case 'creditOrders':
|
|
|
+ return statistics.creditOrders;
|
|
|
+ default:
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 获取支付方式细分数据
|
|
|
+ const getPaymentBreakdown = (config: StatCardConfig) => {
|
|
|
+ if (!statistics || !config.showPaymentBreakdown) return undefined;
|
|
|
+
|
|
|
+ switch (config.type) {
|
|
|
+ case 'totalSales':
|
|
|
+ return {
|
|
|
+ wechat: statistics.wechatSales,
|
|
|
+ credit: statistics.creditSales
|
|
|
+ };
|
|
|
+ case 'totalOrders':
|
|
|
+ return {
|
|
|
+ wechat: statistics.wechatOrders,
|
|
|
+ credit: statistics.creditOrders
|
|
|
+ };
|
|
|
+ default:
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 错误处理
|
|
|
+ useEffect(() => {
|
|
|
+ if (summaryError) {
|
|
|
+ toast.error(`获取统计数据失败: ${summaryError instanceof Error ? summaryError.message : '未知错误'}`);
|
|
|
+ }
|
|
|
+ if (todayError) {
|
|
|
+ toast.error(`获取今日数据失败: ${todayError instanceof Error ? todayError.message : '未知错误'}`);
|
|
|
+ }
|
|
|
+ }, [summaryError, todayError]);
|
|
|
+
|
|
|
+ // 加载状态
|
|
|
+ const isLoading = isSummaryLoading || isTodayLoading;
|
|
|
+ const hasError = !!summaryError || !!todayError;
|
|
|
+
|
|
|
+ // 渲染骨架屏
|
|
|
+ const renderSkeleton = () => (
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
|
+ {cardConfigs.map((config, index) => (
|
|
|
+ <Card key={index}>
|
|
|
+ <CardHeader className="pb-2">
|
|
|
+ <Skeleton className="h-4 w-24" />
|
|
|
+ <Skeleton className="h-3 w-32 mt-1" />
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <Skeleton className="h-8 w-20" />
|
|
|
+ <Skeleton className="h-3 w-16 mt-2" />
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ ))}
|
|
|
+ </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>
|
|
|
+ );
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="space-y-6">
|
|
|
+ {/* 标题和操作栏 */}
|
|
|
+ <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
|
|
+ <div>
|
|
|
+ <h1 className="text-2xl font-bold">数据概览</h1>
|
|
|
+ <p className="text-muted-foreground">实时监控业务数据和关键指标</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ {showTimeFilter && (
|
|
|
+ <TimeFilterComponent
|
|
|
+ value={timeFilter}
|
|
|
+ onChange={handleTimeFilterChange}
|
|
|
+ options={timeRangeOptions}
|
|
|
+ disabled={isLoading || hasError}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+
|
|
|
+ {showRefreshButton && (
|
|
|
+ <Button
|
|
|
+ variant="outline"
|
|
|
+ size="icon"
|
|
|
+ onClick={handleRefresh}
|
|
|
+ disabled={isLoading}
|
|
|
+ aria-label="刷新数据"
|
|
|
+ >
|
|
|
+ <RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
|
|
+ </Button>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 支付方式切换 */}
|
|
|
+ {showPaymentToggle && (
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
+ <div className="text-sm font-medium">支付方式</div>
|
|
|
+ <Tabs
|
|
|
+ value={paymentMethod}
|
|
|
+ onValueChange={(value) => handlePaymentMethodChange(value as PaymentMethod)}
|
|
|
+ className="w-auto"
|
|
|
+ >
|
|
|
+ <TabsList>
|
|
|
+ <TabsTrigger value="all">全部</TabsTrigger>
|
|
|
+ <TabsTrigger value="wechat">微信支付</TabsTrigger>
|
|
|
+ <TabsTrigger value="credit">额度支付</TabsTrigger>
|
|
|
+ </TabsList>
|
|
|
+ </Tabs>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 数据卡片 */}
|
|
|
+ {isLoading ? (
|
|
|
+ renderSkeleton()
|
|
|
+ ) : hasError ? (
|
|
|
+ renderError()
|
|
|
+ ) : (
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
|
+ {cardConfigs.map((config, index) => (
|
|
|
+ <StatCard
|
|
|
+ key={index}
|
|
|
+ title={config.title}
|
|
|
+ value={getCardValue(config)}
|
|
|
+ description={config.description}
|
|
|
+ icon={config.icon}
|
|
|
+ format={config.format}
|
|
|
+ showPaymentBreakdown={config.showPaymentBreakdown}
|
|
|
+ paymentMethod={paymentMethod}
|
|
|
+ paymentBreakdown={getPaymentBreakdown(config)}
|
|
|
+ onPaymentMethodChange={config.showPaymentBreakdown ? handlePaymentMethodChange : undefined}
|
|
|
+ loading={isLoading}
|
|
|
+ error={hasError ? '数据加载失败' : undefined}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 统计摘要 */}
|
|
|
+ {!isLoading && !hasError && statistics && (
|
|
|
+ <Card>
|
|
|
+ <CardHeader>
|
|
|
+ <CardTitle>统计摘要</CardTitle>
|
|
|
+ <CardDescription>
|
|
|
+ {timeFilter.timeRange === 'today' ? '今日' :
|
|
|
+ timeFilter.timeRange === 'yesterday' ? '昨日' :
|
|
|
+ timeFilter.timeRange === 'last7days' ? '最近7天' :
|
|
|
+ timeFilter.timeRange === 'last30days' ? '最近30天' : '自定义时间范围'}数据概览
|
|
|
+ </CardDescription>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
|
|
|
+ <div>
|
|
|
+ <div className="text-muted-foreground">总销售额</div>
|
|
|
+ <div className="text-2xl font-bold">
|
|
|
+ ¥{statistics.totalSales.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
|
+ </div>
|
|
|
+ <div className="text-xs text-muted-foreground mt-1">
|
|
|
+ 微信支付: ¥{statistics.wechatSales.toLocaleString('zh-CN')} |
|
|
|
+ 额度支付: ¥{statistics.creditSales.toLocaleString('zh-CN')}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <div className="text-muted-foreground">总订单数</div>
|
|
|
+ <div className="text-2xl font-bold">
|
|
|
+ {statistics.totalOrders.toLocaleString('zh-CN')}
|
|
|
+ </div>
|
|
|
+ <div className="text-xs text-muted-foreground mt-1">
|
|
|
+ 微信支付: {statistics.wechatOrders.toLocaleString('zh-CN')} |
|
|
|
+ 额度支付: {statistics.creditOrders.toLocaleString('zh-CN')}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <div className="text-muted-foreground">今日销售额</div>
|
|
|
+ <div className="text-2xl font-bold">
|
|
|
+ ¥{statistics.todaySales.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <div className="text-muted-foreground">今日订单数</div>
|
|
|
+ <div className="text-2xl font-bold">
|
|
|
+ {statistics.todayOrders.toLocaleString('zh-CN')}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|