|
|
@@ -0,0 +1,640 @@
|
|
|
+"use client";
|
|
|
+
|
|
|
+import { DualAxes } from "@ant-design/charts";
|
|
|
+import {
|
|
|
+ App,
|
|
|
+ Card,
|
|
|
+ Col,
|
|
|
+ DatePicker,
|
|
|
+ Row,
|
|
|
+ Segmented,
|
|
|
+ Space,
|
|
|
+ Spin,
|
|
|
+ Table,
|
|
|
+ Typography,
|
|
|
+} from "antd";
|
|
|
+import type { ColumnsType } from "antd/es/table";
|
|
|
+import type { Dayjs } from "dayjs";
|
|
|
+import { useCallback, useEffect, useState } from "react";
|
|
|
+import {
|
|
|
+ fetchOrderDataStatPage,
|
|
|
+ fetchPlaymateDataStatPage,
|
|
|
+ fetchUserDataStatPage,
|
|
|
+} from "@/services/statisticsDashboard";
|
|
|
+import type {
|
|
|
+ OrderDataStatAdminDTO,
|
|
|
+ PlaymateDataStatAdminDTO,
|
|
|
+ StatAdminQuery,
|
|
|
+ UserDataStatAdminDTO,
|
|
|
+} from "@/types/api/statisticsDashboard";
|
|
|
+import dayjs from "@/utils/dayjs";
|
|
|
+
|
|
|
+const { RangePicker } = DatePicker;
|
|
|
+const { Title, Text } = Typography;
|
|
|
+
|
|
|
+const REPORT_TYPE_OPTIONS = [
|
|
|
+ { label: "日报", value: 0 },
|
|
|
+ { label: "周报", value: 1 },
|
|
|
+ { label: "月报", value: 2 },
|
|
|
+];
|
|
|
+
|
|
|
+const PAGE_SIZE = 50;
|
|
|
+
|
|
|
+/** Returns the default [start, end] range for a given report type. */
|
|
|
+function getDefaultRange(type: number): [Dayjs, Dayjs] {
|
|
|
+ const today = dayjs().startOf("day");
|
|
|
+ switch (type) {
|
|
|
+ case 0: // 日报:过去 10 天
|
|
|
+ return [today.subtract(9, "day"), today];
|
|
|
+ case 2: // 月报:过去 6 个月
|
|
|
+ return [today.subtract(5, "month").startOf("month"), today];
|
|
|
+ default: // 周报:过去 8 周
|
|
|
+ return [today.subtract(7, "week").startOf("isoWeek"), today];
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Generates all period IDs covered by [start, end] for the given report type.
|
|
|
+ * - type 0 (daily): "YYYY-MM-DD"
|
|
|
+ * - type 1 (weekly): "YYYY-Www" (ISO week)
|
|
|
+ * - type 2 (monthly): "YYYY-MM"
|
|
|
+ */
|
|
|
+function generatePeriodIds(start: Dayjs, end: Dayjs, type: number): string[] {
|
|
|
+ const ids: string[] = [];
|
|
|
+ if (type === 0) {
|
|
|
+ let cur = start.startOf("day");
|
|
|
+ while (!cur.isAfter(end)) {
|
|
|
+ ids.push(cur.format("YYYY-MM-DD"));
|
|
|
+ cur = cur.add(1, "day");
|
|
|
+ }
|
|
|
+ } else if (type === 1) {
|
|
|
+ let cur = start.startOf("isoWeek");
|
|
|
+ while (!cur.isAfter(end)) {
|
|
|
+ const week = cur.isoWeek();
|
|
|
+ const year = cur.isoWeekYear();
|
|
|
+ ids.push(`${year}-W${String(week).padStart(2, "0")}`);
|
|
|
+ cur = cur.add(1, "week");
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ let cur = start.startOf("month");
|
|
|
+ while (!cur.isAfter(end)) {
|
|
|
+ ids.push(cur.format("YYYY-MM"));
|
|
|
+ cur = cur.add(1, "month");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return ids;
|
|
|
+}
|
|
|
+
|
|
|
+function fillUserData(
|
|
|
+ items: UserDataStatAdminDTO[],
|
|
|
+ ids: string[],
|
|
|
+): UserDataStatAdminDTO[] {
|
|
|
+ const map = new Map(items.map((x) => [x.id, x]));
|
|
|
+ return ids.map(
|
|
|
+ (id) =>
|
|
|
+ map.get(id) ?? {
|
|
|
+ id,
|
|
|
+ totalUserNum: 0,
|
|
|
+ newRegisterUserNum: 0,
|
|
|
+ activeUserNum: 0,
|
|
|
+ activeUserRetentionRate1Day: 0,
|
|
|
+ activeUserRetentionRate3Day: 0,
|
|
|
+ activeUserRetentionRate7Day: 0,
|
|
|
+ newRegisterUserRetentionRate1Day: 0,
|
|
|
+ newRegisterUserRetentionRate3Day: 0,
|
|
|
+ newRegisterUserRetentionRate7Day: 0,
|
|
|
+ },
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function fillOrderData(
|
|
|
+ items: OrderDataStatAdminDTO[],
|
|
|
+ ids: string[],
|
|
|
+): OrderDataStatAdminDTO[] {
|
|
|
+ const map = new Map(items.map((x) => [x.id, x]));
|
|
|
+ return ids.map(
|
|
|
+ (id) =>
|
|
|
+ map.get(id) ?? {
|
|
|
+ id,
|
|
|
+ totalAmount: 0,
|
|
|
+ orderCount: 0,
|
|
|
+ statusOkCount: 0,
|
|
|
+ statusRefundCount: 0,
|
|
|
+ uniqueSellersCount: 0,
|
|
|
+ totalBuyers: 0,
|
|
|
+ completedBuyers: 0,
|
|
|
+ repeatCompletedBuyers: 0,
|
|
|
+ avgAmountPerBuyer: 0,
|
|
|
+ repeatRate: 0,
|
|
|
+ successRate: 0,
|
|
|
+ refundRate: 0,
|
|
|
+ },
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function fillPlaymateData(
|
|
|
+ items: PlaymateDataStatAdminDTO[],
|
|
|
+ ids: string[],
|
|
|
+): PlaymateDataStatAdminDTO[] {
|
|
|
+ const map = new Map(items.map((x) => [x.id, x]));
|
|
|
+ return ids.map(
|
|
|
+ (id) =>
|
|
|
+ map.get(id) ?? {
|
|
|
+ id,
|
|
|
+ totalPlaymateNum: 0,
|
|
|
+ newPlaymateNum: 0,
|
|
|
+ activePlaymateNum: 0,
|
|
|
+ acceptOrderPlaymateNum: 0,
|
|
|
+ totalOrderAmount: 0,
|
|
|
+ acceptOrderRate: 0,
|
|
|
+ avgAmountAcceptOrder: 0,
|
|
|
+ },
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function fmtPct(val: number | undefined | null): string {
|
|
|
+ if (val == null) return "-";
|
|
|
+ return `${(val * 100).toFixed(2)}%`;
|
|
|
+}
|
|
|
+
|
|
|
+function fmtAmt(val: number | undefined | null): string {
|
|
|
+ if (val == null) return "-";
|
|
|
+ return `¥${val.toFixed(2)}`;
|
|
|
+}
|
|
|
+
|
|
|
+const StatsDashboardPage: React.FC = () => {
|
|
|
+ const { message } = App.useApp();
|
|
|
+
|
|
|
+ const [reportType, setReportType] = useState<number>(1);
|
|
|
+ // null = use default range derived from reportType
|
|
|
+ const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null);
|
|
|
+ const [loading, setLoading] = useState(false);
|
|
|
+
|
|
|
+ /** The range actually in effect (user-selected or type-default). */
|
|
|
+ const effectiveRange = dateRange ?? getDefaultRange(reportType);
|
|
|
+
|
|
|
+ const [userData, setUserData] = useState<UserDataStatAdminDTO[]>([]);
|
|
|
+ const [orderData, setOrderData] = useState<OrderDataStatAdminDTO[]>([]);
|
|
|
+ const [playmateData, setPlaymateData] = useState<PlaymateDataStatAdminDTO[]>(
|
|
|
+ [],
|
|
|
+ );
|
|
|
+
|
|
|
+ const buildQuery = useCallback(
|
|
|
+ (range: [Dayjs, Dayjs]): StatAdminQuery => ({
|
|
|
+ pageIndex: 1,
|
|
|
+ pageSize: PAGE_SIZE,
|
|
|
+ type: reportType,
|
|
|
+ startDay: range[0].format("YYYY-MM-DD"),
|
|
|
+ endDay: range[1].format("YYYY-MM-DD"),
|
|
|
+ }),
|
|
|
+ [reportType],
|
|
|
+ );
|
|
|
+
|
|
|
+ const loadData = useCallback(
|
|
|
+ async (range: [Dayjs, Dayjs]) => {
|
|
|
+ setLoading(true);
|
|
|
+ try {
|
|
|
+ const query = buildQuery(range);
|
|
|
+ const ids = generatePeriodIds(range[0], range[1], reportType);
|
|
|
+ const [userRes, orderRes, playmateRes] = await Promise.all([
|
|
|
+ fetchUserDataStatPage(query),
|
|
|
+ fetchOrderDataStatPage(query),
|
|
|
+ fetchPlaymateDataStatPage(query),
|
|
|
+ ]);
|
|
|
+ setUserData(fillUserData(userRes?.items ?? [], ids));
|
|
|
+ setOrderData(fillOrderData(orderRes?.items ?? [], ids));
|
|
|
+ setPlaymateData(fillPlaymateData(playmateRes?.items ?? [], ids));
|
|
|
+ } catch (err) {
|
|
|
+ console.error("Failed to load dashboard data:", err);
|
|
|
+ message.error("加载大盘数据失败");
|
|
|
+ } finally {
|
|
|
+ setLoading(false);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ [buildQuery, message, reportType],
|
|
|
+ );
|
|
|
+
|
|
|
+ // When reportType changes: clear user-selected range and reload with new default.
|
|
|
+ // biome-ignore lint/correctness/useExhaustiveDependencies: loadData changes with reportType; resetting dateRange here is intentional
|
|
|
+ useEffect(() => {
|
|
|
+ setDateRange(null);
|
|
|
+ void loadData(getDefaultRange(reportType));
|
|
|
+ }, [reportType]);
|
|
|
+
|
|
|
+ const handleRangeChange = (values: [Dayjs | null, Dayjs | null] | null) => {
|
|
|
+ if (!values || !values[0] || !values[1]) {
|
|
|
+ // User cleared the picker → fall back to default range
|
|
|
+ const defaultRange = getDefaultRange(reportType);
|
|
|
+ setDateRange(null);
|
|
|
+ void loadData(defaultRange);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const range: [Dayjs, Dayjs] = [values[0], values[1]];
|
|
|
+ setDateRange(range);
|
|
|
+ void loadData(range);
|
|
|
+ };
|
|
|
+
|
|
|
+ // ─── 用户数据表格列 ───────────────────────────────────────────
|
|
|
+ const userColumns: ColumnsType<UserDataStatAdminDTO> = [
|
|
|
+ { title: "周期", dataIndex: "id", key: "id", fixed: "left", width: 130 },
|
|
|
+ {
|
|
|
+ title: "总用户数",
|
|
|
+ dataIndex: "totalUserNum",
|
|
|
+ key: "totalUserNum",
|
|
|
+ width: 100,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "新注册",
|
|
|
+ dataIndex: "newRegisterUserNum",
|
|
|
+ key: "newRegisterUserNum",
|
|
|
+ width: 90,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "活跃数",
|
|
|
+ dataIndex: "activeUserNum",
|
|
|
+ key: "activeUserNum",
|
|
|
+ width: 90,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "活跃留存",
|
|
|
+ children: [
|
|
|
+ {
|
|
|
+ title: "次日",
|
|
|
+ dataIndex: "activeUserRetentionRate1Day",
|
|
|
+ key: "activeR1",
|
|
|
+ width: 80,
|
|
|
+ render: fmtPct,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "3日",
|
|
|
+ dataIndex: "activeUserRetentionRate3Day",
|
|
|
+ key: "activeR3",
|
|
|
+ width: 80,
|
|
|
+ render: fmtPct,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "7日",
|
|
|
+ dataIndex: "activeUserRetentionRate7Day",
|
|
|
+ key: "activeR7",
|
|
|
+ width: 80,
|
|
|
+ render: fmtPct,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "新注册活跃留存",
|
|
|
+ children: [
|
|
|
+ {
|
|
|
+ title: "次日",
|
|
|
+ dataIndex: "newRegisterUserRetentionRate1Day",
|
|
|
+ key: "newR1",
|
|
|
+ width: 80,
|
|
|
+ render: fmtPct,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "3日",
|
|
|
+ dataIndex: "newRegisterUserRetentionRate3Day",
|
|
|
+ key: "newR3",
|
|
|
+ width: 80,
|
|
|
+ render: fmtPct,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "7日",
|
|
|
+ dataIndex: "newRegisterUserRetentionRate7Day",
|
|
|
+ key: "newR7",
|
|
|
+ width: 80,
|
|
|
+ render: fmtPct,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ ];
|
|
|
+
|
|
|
+ // ─── 订单数据表格列 ───────────────────────────────────────────
|
|
|
+ const orderColumns: ColumnsType<OrderDataStatAdminDTO> = [
|
|
|
+ { title: "周期", dataIndex: "id", key: "id", fixed: "left", width: 130 },
|
|
|
+ {
|
|
|
+ title: "订单金额",
|
|
|
+ dataIndex: "totalAmount",
|
|
|
+ key: "totalAmount",
|
|
|
+ width: 110,
|
|
|
+ render: fmtAmt,
|
|
|
+ },
|
|
|
+ { title: "订单量", dataIndex: "orderCount", key: "orderCount", width: 90 },
|
|
|
+ {
|
|
|
+ title: "下单用户数",
|
|
|
+ dataIndex: "totalBuyers",
|
|
|
+ key: "totalBuyers",
|
|
|
+ width: 100,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "接单陪玩师",
|
|
|
+ dataIndex: "uniqueSellersCount",
|
|
|
+ key: "uniqueSellersCount",
|
|
|
+ width: 100,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "人均下单金额",
|
|
|
+ dataIndex: "avgAmountPerBuyer",
|
|
|
+ key: "avgAmountPerBuyer",
|
|
|
+ width: 120,
|
|
|
+ render: fmtAmt,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "复购率",
|
|
|
+ dataIndex: "repeatRate",
|
|
|
+ key: "repeatRate",
|
|
|
+ width: 90,
|
|
|
+ render: fmtPct,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "支付成功率",
|
|
|
+ dataIndex: "successRate",
|
|
|
+ key: "successRate",
|
|
|
+ width: 100,
|
|
|
+ render: fmtPct,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "退款率",
|
|
|
+ dataIndex: "refundRate",
|
|
|
+ key: "refundRate",
|
|
|
+ width: 90,
|
|
|
+ render: fmtPct,
|
|
|
+ },
|
|
|
+ ];
|
|
|
+
|
|
|
+ // ─── 陪玩师数据表格列 ─────────────────────────────────────────
|
|
|
+ const playmateColumns: ColumnsType<PlaymateDataStatAdminDTO> = [
|
|
|
+ { title: "周期", dataIndex: "id", key: "id", fixed: "left", width: 130 },
|
|
|
+ {
|
|
|
+ title: "陪玩师总数",
|
|
|
+ dataIndex: "totalPlaymateNum",
|
|
|
+ key: "totalPlaymateNum",
|
|
|
+ width: 100,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "新增陪玩师",
|
|
|
+ dataIndex: "newPlaymateNum",
|
|
|
+ key: "newPlaymateNum",
|
|
|
+ width: 90,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "活跃陪玩师数",
|
|
|
+ dataIndex: "activePlaymateNum",
|
|
|
+ key: "activePlaymateNum",
|
|
|
+ width: 90,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "接单陪玩师数",
|
|
|
+ dataIndex: "acceptOrderPlaymateNum",
|
|
|
+ key: "acceptOrderPlaymateNum",
|
|
|
+ width: 90,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "接单率",
|
|
|
+ dataIndex: "acceptOrderRate",
|
|
|
+ key: "acceptOrderRate",
|
|
|
+ width: 90,
|
|
|
+ render: fmtPct,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "人均接单金额",
|
|
|
+ dataIndex: "avgAmountAcceptOrder",
|
|
|
+ key: "avgAmountAcceptOrder",
|
|
|
+ width: 120,
|
|
|
+ render: fmtAmt,
|
|
|
+ },
|
|
|
+ ];
|
|
|
+
|
|
|
+ // ─── 图表 1:下单金额情况(柱形 + 折线双轴)──────────────────
|
|
|
+ const amountChartData = orderData.map((item) => ({
|
|
|
+ id: item.id,
|
|
|
+ totalAmount: item.totalAmount,
|
|
|
+ avgAmountPerBuyer: item.avgAmountPerBuyer,
|
|
|
+ }));
|
|
|
+
|
|
|
+ const amountChartConfig = {
|
|
|
+ data: amountChartData,
|
|
|
+ xField: "id",
|
|
|
+ legend: true,
|
|
|
+ children: [
|
|
|
+ {
|
|
|
+ type: "interval",
|
|
|
+ yField: "totalAmount",
|
|
|
+ axis: {
|
|
|
+ y: {
|
|
|
+ title: "订单金额 (¥)",
|
|
|
+ labelFormatter: (v: number) => `¥${v}`,
|
|
|
+ position: "left",
|
|
|
+ },
|
|
|
+ },
|
|
|
+ label: {
|
|
|
+ text: (d: { totalAmount: number }) => `¥${d.totalAmount.toFixed(0)}`,
|
|
|
+ position: "top",
|
|
|
+ style: { fontSize: 10 },
|
|
|
+ },
|
|
|
+ style: { fill: "#4096ff" },
|
|
|
+ tooltip: {
|
|
|
+ items: [
|
|
|
+ (d: { totalAmount: number }) => ({
|
|
|
+ name: "订单金额",
|
|
|
+ value: `¥${d.totalAmount.toFixed(2)}`,
|
|
|
+ }),
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: "line",
|
|
|
+ yField: "avgAmountPerBuyer",
|
|
|
+ axis: {
|
|
|
+ y: {
|
|
|
+ title: "人均下单金额 (¥)",
|
|
|
+ labelFormatter: (v: number) => `¥${v}`,
|
|
|
+ position: "right",
|
|
|
+ },
|
|
|
+ },
|
|
|
+ label: {
|
|
|
+ text: (d: { avgAmountPerBuyer: number }) =>
|
|
|
+ `¥${d.avgAmountPerBuyer.toFixed(0)}`,
|
|
|
+ position: "top",
|
|
|
+ style: { fontSize: 10 },
|
|
|
+ },
|
|
|
+ style: { stroke: "#ff7a00", lineWidth: 2 },
|
|
|
+ point: { fill: "#ff7a00", r: 4 },
|
|
|
+ tooltip: {
|
|
|
+ items: [
|
|
|
+ (d: { avgAmountPerBuyer: number }) => ({
|
|
|
+ name: "人均下单金额",
|
|
|
+ value: `¥${d.avgAmountPerBuyer.toFixed(2)}`,
|
|
|
+ }),
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+
|
|
|
+ // ─── 图表 2:下单用户情况(柱形 + 折线双轴)──────────────────
|
|
|
+ const buyerChartData = orderData.map((item) => ({
|
|
|
+ id: item.id,
|
|
|
+ totalBuyers: item.totalBuyers,
|
|
|
+ repeatRate: item.repeatRate,
|
|
|
+ }));
|
|
|
+
|
|
|
+ const buyerChartConfig = {
|
|
|
+ data: buyerChartData,
|
|
|
+ xField: "id",
|
|
|
+ legend: true,
|
|
|
+ children: [
|
|
|
+ {
|
|
|
+ type: "interval",
|
|
|
+ yField: "totalBuyers",
|
|
|
+ axis: {
|
|
|
+ y: {
|
|
|
+ title: "下单用户数",
|
|
|
+ position: "left",
|
|
|
+ },
|
|
|
+ },
|
|
|
+ label: {
|
|
|
+ text: (d: { totalBuyers: number }) => `${d.totalBuyers}`,
|
|
|
+ position: "top",
|
|
|
+ style: { fontSize: 10 },
|
|
|
+ },
|
|
|
+ style: { fill: "#36cfc9" },
|
|
|
+ tooltip: {
|
|
|
+ items: [
|
|
|
+ (d: { totalBuyers: number }) => ({
|
|
|
+ name: "下单用户数",
|
|
|
+ value: `${d.totalBuyers}`,
|
|
|
+ }),
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: "line",
|
|
|
+ yField: "repeatRate",
|
|
|
+ axis: {
|
|
|
+ y: {
|
|
|
+ title: "复购率",
|
|
|
+ labelFormatter: (v: number) => `${(v * 100).toFixed(1)}%`,
|
|
|
+ position: "right",
|
|
|
+ },
|
|
|
+ },
|
|
|
+ label: {
|
|
|
+ text: (d: { repeatRate: number }) =>
|
|
|
+ `${(d.repeatRate * 100).toFixed(1)}%`,
|
|
|
+ position: "top",
|
|
|
+ style: { fontSize: 10 },
|
|
|
+ },
|
|
|
+ style: { stroke: "#f759ab", lineWidth: 2 },
|
|
|
+ point: { fill: "#f759ab", r: 4 },
|
|
|
+ tooltip: {
|
|
|
+ items: [
|
|
|
+ (d: { repeatRate: number }) => ({
|
|
|
+ name: "复购率",
|
|
|
+ value: `${(d.repeatRate * 100).toFixed(2)}%`,
|
|
|
+ }),
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="flex flex-col gap-6 p-6">
|
|
|
+ {/* 页头 */}
|
|
|
+ <div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
|
|
+ <div>
|
|
|
+ <Title level={3} style={{ marginBottom: 0 }}>
|
|
|
+ 大盘数据
|
|
|
+ </Title>
|
|
|
+ <Text type="secondary">
|
|
|
+ 用户 / 订单 / 陪玩师周期统计数据,支持日报、周报、月报切换
|
|
|
+ </Text>
|
|
|
+ </div>
|
|
|
+ <Space wrap>
|
|
|
+ <Segmented
|
|
|
+ options={REPORT_TYPE_OPTIONS}
|
|
|
+ value={reportType}
|
|
|
+ onChange={(v) => setReportType(v as number)}
|
|
|
+ />
|
|
|
+ <RangePicker
|
|
|
+ allowClear
|
|
|
+ value={effectiveRange}
|
|
|
+ onChange={handleRangeChange as never}
|
|
|
+ format="YYYY-MM-DD"
|
|
|
+ />
|
|
|
+ </Space>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <Spin spinning={loading}>
|
|
|
+ <Space direction="vertical" size="large" style={{ width: "100%" }}>
|
|
|
+ {/* ── 图表区 ── */}
|
|
|
+ <Row gutter={[16, 16]}>
|
|
|
+ <Col xs={24} xl={12}>
|
|
|
+ <Card title="下单金额情况">
|
|
|
+ {orderData.length > 0 ? (
|
|
|
+ <DualAxes {...amountChartConfig} height={300} />
|
|
|
+ ) : (
|
|
|
+ <div className="flex h-[300px] items-center justify-center">
|
|
|
+ <Text type="secondary">暂无数据</Text>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </Card>
|
|
|
+ </Col>
|
|
|
+ <Col xs={24} xl={12}>
|
|
|
+ <Card title="下单用户情况">
|
|
|
+ {orderData.length > 0 ? (
|
|
|
+ <DualAxes {...buyerChartConfig} height={300} />
|
|
|
+ ) : (
|
|
|
+ <div className="flex h-[300px] items-center justify-center">
|
|
|
+ <Text type="secondary">暂无数据</Text>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </Card>
|
|
|
+ </Col>
|
|
|
+ </Row>
|
|
|
+
|
|
|
+ {/* ── 用户数据表格 ── */}
|
|
|
+ <Card title="用户数据">
|
|
|
+ <Table<UserDataStatAdminDTO>
|
|
|
+ rowKey="id"
|
|
|
+ columns={userColumns}
|
|
|
+ dataSource={userData}
|
|
|
+ pagination={false}
|
|
|
+ size="small"
|
|
|
+ scroll={{ x: "max-content" }}
|
|
|
+ bordered
|
|
|
+ />
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ {/* ── 订单数据表格 ── */}
|
|
|
+ <Card title="订单数据">
|
|
|
+ <Table<OrderDataStatAdminDTO>
|
|
|
+ rowKey="id"
|
|
|
+ columns={orderColumns}
|
|
|
+ dataSource={orderData}
|
|
|
+ pagination={false}
|
|
|
+ size="small"
|
|
|
+ scroll={{ x: "max-content" }}
|
|
|
+ bordered
|
|
|
+ />
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ {/* ── 陪玩师数据表格 ── */}
|
|
|
+ <Card title="陪玩师数据">
|
|
|
+ <Table<PlaymateDataStatAdminDTO>
|
|
|
+ rowKey="id"
|
|
|
+ columns={playmateColumns}
|
|
|
+ dataSource={playmateData}
|
|
|
+ pagination={false}
|
|
|
+ size="small"
|
|
|
+ scroll={{ x: "max-content" }}
|
|
|
+ bordered
|
|
|
+ />
|
|
|
+ </Card>
|
|
|
+ </Space>
|
|
|
+ </Spin>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+export default StatsDashboardPage;
|