Procházet zdrojové kódy

Add new menu item for "新用户统计" in menu configuration

- Introduced a new menu entry for user statistics with the corresponding path and permissions.
- Enhanced the menu structure to improve navigation and access to new user statistics.
0es před 2 týdny
rodič
revize
0ed56d3e86

+ 523 - 0
src/app/(dashboard)/statistics/new-user/page.tsx

@@ -0,0 +1,523 @@
+"use client";
+
+import {
+  DownloadOutlined,
+  ReloadOutlined,
+  SearchOutlined,
+} from "@ant-design/icons";
+import {
+  App,
+  Button,
+  Card,
+  Col,
+  DatePicker,
+  Form,
+  Input,
+  Row,
+  Space,
+  Spin,
+  Statistic,
+  Table,
+  Typography,
+} from "antd";
+import type { ColumnsType, TablePaginationConfig } from "antd/es/table";
+import type { Dayjs } from "dayjs";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useGlobalConsts } from "@/contexts/GlobalConstsContext";
+import {
+  exportNewUserEngagementExcel,
+  fetchNewUserEngagementPage,
+  fetchNewUserEngagementSummary,
+} from "@/services/newUserEngagementReport";
+import type {
+  NewUserEngagementMetricItemAdminDTO,
+  NewUserEngagementReportAdminDTO,
+  NewUserEngagementReportAdminQuery,
+  NewUserEngagementReportSummaryAdminDTO,
+} from "@/types/api/newUserEngagementReport";
+import { getLast7DaysRange } from "@/utils/date";
+import dayjs from "@/utils/dayjs";
+
+const { RangePicker } = DatePicker;
+const { Title, Text } = Typography;
+
+const PAGE_SIZE = 20;
+
+/** Build query from form values and pagination */
+function buildQuery(
+  startDay: string,
+  endDay: string,
+  playmateKeyword?: string,
+  userKeyword?: string,
+  pageIndex = 1,
+  pageSize = PAGE_SIZE,
+): NewUserEngagementReportAdminQuery {
+  return {
+    startDay,
+    endDay,
+    playmateKeyword: playmateKeyword?.trim() || undefined,
+    userKeyword: userKeyword?.trim() || undefined,
+    pageIndex,
+    pageSize,
+  };
+}
+
+/** Summary query (no pagination) for summary and export */
+function buildSummaryQuery(
+  startDay: string,
+  endDay: string,
+  playmateKeyword?: string,
+  userKeyword?: string,
+): NewUserEngagementReportAdminQuery {
+  return {
+    startDay,
+    endDay,
+    playmateKeyword: playmateKeyword?.trim() || undefined,
+    userKeyword: userKeyword?.trim() || undefined,
+  };
+}
+
+const NewUserReportPage: React.FC = () => {
+  const { message } = App.useApp();
+  const { getGroup } = useGlobalConsts();
+  const [form] = Form.useForm();
+
+  const moduleLabelMap = useMemo(() => {
+    const list = getGroup("NewUserEngagementModuleCodeConsts");
+    return new Map(list.map((item) => [item.value, item.name]));
+  }, [getGroup]);
+  const indicatorLabelMap = useMemo(() => {
+    const list = getGroup("NewUserEngagementIndicatorCodeConsts");
+    return new Map(list.map((item) => [item.value, item.name]));
+  }, [getGroup]);
+
+  const [summary, setSummary] =
+    useState<NewUserEngagementReportSummaryAdminDTO | null>(null);
+  const [loading, setLoading] = useState(false);
+  const [exportLoading, setExportLoading] = useState(false);
+  const [dataSource, setDataSource] = useState<
+    NewUserEngagementReportAdminDTO[]
+  >([]);
+  const [total, setTotal] = useState(0);
+  const [queryParams, setQueryParams] = useState<{
+    startDay: string;
+    endDay: string;
+    playmateKeyword?: string;
+    userKeyword?: string;
+    pageIndex: number;
+    pageSize: number;
+  }>(() => {
+    const last7 = getLast7DaysRange();
+    return {
+      startDay: last7.beginDateTime,
+      endDay: last7.endDateTime,
+      pageIndex: 1,
+      pageSize: PAGE_SIZE,
+    };
+  });
+
+  const loadSummary = useCallback(
+    async (
+      startDay: string,
+      endDay: string,
+      playmateKeyword?: string,
+      userKeyword?: string,
+    ) => {
+      try {
+        const summaryRes = await fetchNewUserEngagementSummary(
+          buildSummaryQuery(startDay, endDay, playmateKeyword, userKeyword),
+        );
+        setSummary(summaryRes ?? null);
+      } catch (err) {
+        console.error("Failed to load summary:", err);
+        message.error("加载汇总数据失败");
+        setSummary(null);
+      }
+    },
+    [message],
+  );
+
+  const loadPage = useCallback(
+    async (params: typeof queryParams) => {
+      setLoading(true);
+      try {
+        const res = await fetchNewUserEngagementPage(
+          buildQuery(
+            params.startDay,
+            params.endDay,
+            params.playmateKeyword,
+            params.userKeyword,
+            params.pageIndex,
+            params.pageSize,
+          ),
+        );
+        setDataSource(res?.items ?? []);
+        setTotal(res?.total ?? 0);
+      } catch (err) {
+        console.error("Failed to load report page:", err);
+        message.error("加载列表失败");
+        setDataSource([]);
+        setTotal(0);
+      } finally {
+        setLoading(false);
+      }
+    },
+    [message],
+  );
+
+  const loadAll = useCallback(
+    async (params: typeof queryParams) => {
+      await Promise.all([
+        loadSummary(
+          params.startDay,
+          params.endDay,
+          params.playmateKeyword,
+          params.userKeyword,
+        ),
+        loadPage(params),
+      ]);
+    },
+    [loadSummary, loadPage],
+  );
+
+  useEffect(() => {
+    const last7 = getLast7DaysRange();
+    form.setFieldsValue({
+      dateRange: [dayjs(last7.beginDateTime), dayjs(last7.endDateTime)],
+      playmateKeyword: "",
+      userKeyword: "",
+    });
+  }, [form]);
+
+  useEffect(() => {
+    void loadAll(queryParams);
+  }, [queryParams, loadAll]);
+
+  const handleSearch = () => {
+    const values = form.getFieldsValue();
+    const range = values.dateRange as [Dayjs, Dayjs] | undefined;
+    if (!range?.[0] || !range?.[1]) {
+      message.warning("请选择日期范围");
+      return;
+    }
+    const startDay = range[0].format("YYYY-MM-DD");
+    const endDay = range[1].format("YYYY-MM-DD");
+    setQueryParams({
+      startDay,
+      endDay,
+      playmateKeyword: values.playmateKeyword,
+      userKeyword: values.userKeyword,
+      pageIndex: 1,
+      pageSize: queryParams.pageSize,
+    });
+  };
+
+  const handleReset = () => {
+    const last7 = getLast7DaysRange();
+    form.setFieldsValue({
+      dateRange: [dayjs(last7.beginDateTime), dayjs(last7.endDateTime)],
+      playmateKeyword: "",
+      userKeyword: "",
+    });
+    setQueryParams({
+      startDay: last7.beginDateTime,
+      endDay: last7.endDateTime,
+      pageIndex: 1,
+      pageSize: PAGE_SIZE,
+    });
+  };
+
+  const handleExport = async () => {
+    const values = form.getFieldsValue();
+    const range = values.dateRange as [Dayjs, Dayjs] | undefined;
+    if (!range?.[0] || !range?.[1]) {
+      message.warning("请选择日期范围");
+      return;
+    }
+    setExportLoading(true);
+    try {
+      const { blob, filename } = await exportNewUserEngagementExcel(
+        buildSummaryQuery(
+          range[0].format("YYYY-MM-DD"),
+          range[1].format("YYYY-MM-DD"),
+          values.playmateKeyword,
+          values.userKeyword,
+        ),
+      );
+      if (!blob || blob.size === 0) {
+        message.error("导出失败:文件为空");
+        return;
+      }
+      const url = window.URL.createObjectURL(blob);
+      const a = document.createElement("a");
+      a.href = url;
+      a.download = filename ?? `new_user_engagement_${Date.now()}.xlsx`;
+      document.body.appendChild(a);
+      a.click();
+      a.remove();
+      window.URL.revokeObjectURL(url);
+      message.success("导出成功");
+    } catch (err) {
+      console.error("Export failed:", err);
+      message.error("导出失败");
+    } finally {
+      setExportLoading(false);
+    }
+  };
+
+  const handleTableChange = (pagination: TablePaginationConfig) => {
+    setQueryParams((prev) => ({
+      ...prev,
+      pageIndex: pagination.current ?? 1,
+      pageSize: pagination.pageSize ?? PAGE_SIZE,
+    }));
+  };
+
+  const columns: ColumnsType<NewUserEngagementReportAdminDTO> = [
+    { title: "日期", dataIndex: "day", key: "day", width: 120 },
+    {
+      title: "陪玩师编号",
+      dataIndex: "playmateUserNo",
+      key: "playmateUserNo",
+      width: 140,
+    },
+    {
+      title: "陪玩师昵称",
+      dataIndex: "playmateNickname",
+      key: "playmateNickname",
+      width: 140,
+    },
+    { title: "用户编号", dataIndex: "userNo", key: "userNo", width: 140 },
+    {
+      title: "用户昵称",
+      dataIndex: "userNickname",
+      key: "userNickname",
+      width: 140,
+    },
+    {
+      title: "欢迎语发送次数",
+      dataIndex: "welcomeSendCount",
+      key: "welcomeSendCount",
+      width: 120,
+      align: "right",
+    },
+    {
+      title: "首次私聊次数",
+      dataIndex: "pushFirstChatCount",
+      key: "pushFirstChatCount",
+      width: 120,
+      align: "right",
+    },
+  ];
+
+  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>
+      </div>
+
+      <Card size="small">
+        <Form form={form} layout="inline" className="flex flex-wrap gap-2">
+          <Form.Item
+            name="dateRange"
+            label="日期范围"
+            rules={[{ required: true, message: "请选择日期范围" }]}
+          >
+            <RangePicker format="YYYY-MM-DD" style={{ width: 260 }} />
+          </Form.Item>
+          <Form.Item name="playmateKeyword" label="陪玩师ID/昵称">
+            <Input placeholder="可选" allowClear style={{ width: 180 }} />
+          </Form.Item>
+          <Form.Item name="userKeyword" label="用户ID">
+            <Input placeholder="可选" allowClear style={{ width: 180 }} />
+          </Form.Item>
+          <Form.Item>
+            <Space>
+              <Button
+                type="primary"
+                icon={<SearchOutlined />}
+                onClick={handleSearch}
+              >
+                搜索
+              </Button>
+              <Button onClick={handleReset}>重置</Button>
+              <Button
+                icon={<DownloadOutlined />}
+                onClick={handleExport}
+                loading={exportLoading}
+              >
+                导出
+              </Button>
+              <Button
+                icon={<ReloadOutlined />}
+                onClick={() => loadAll(queryParams)}
+                loading={loading}
+              >
+                刷新
+              </Button>
+            </Space>
+          </Form.Item>
+        </Form>
+      </Card>
+
+      <Spin spinning={loading}>
+        {summary && (
+          <Card title="汇总" size="small">
+            <Row gutter={[24, 16]}>
+              <Col xs={24} sm={12} md={8} lg={6}>
+                <Statistic
+                  title="欢迎语发送总次数"
+                  value={summary.welcomeSendCount ?? 0}
+                />
+              </Col>
+              <Col xs={24} sm={12} md={8} lg={6}>
+                <Statistic
+                  title="欢迎语触达用户数"
+                  value={summary.welcomeUserCount ?? 0}
+                />
+              </Col>
+              <Col xs={24} sm={12} md={8} lg={6}>
+                <Statistic
+                  title="欢迎语涉及陪玩师数"
+                  value={summary.welcomePlaymateCount ?? 0}
+                />
+              </Col>
+              <Col xs={24} sm={12} md={8} lg={6}>
+                <Statistic
+                  title="首次私聊总次数"
+                  value={summary.pushFirstChatCount ?? 0}
+                />
+              </Col>
+              <Col xs={24} sm={12} md={8} lg={6}>
+                <Statistic
+                  title="首次私聊触达用户数"
+                  value={summary.pushUserCount ?? 0}
+                />
+              </Col>
+              <Col xs={24} sm={12} md={8} lg={6}>
+                <Statistic
+                  title="首次私聊陪玩师数"
+                  value={summary.pushPlaymateCount ?? 0}
+                />
+              </Col>
+              <Col xs={24} sm={12} md={8} lg={6}>
+                <Statistic
+                  title="无用户可推的陪玩师比例(%)"
+                  value={summary.noCandidatePlaymateRatio ?? 0}
+                  precision={2}
+                  suffix="%"
+                />
+              </Col>
+              <Col xs={24} sm={12} md={8} lg={6}>
+                <Statistic
+                  title="私聊集中度 TOP10(%)"
+                  value={summary.top10PlaymateConcentration ?? 0}
+                  precision={2}
+                  suffix="%"
+                />
+              </Col>
+              <Col xs={24} sm={12} md={8} lg={6}>
+                <Statistic
+                  title="用户平均被私聊次数"
+                  value={summary.avgChatPerUser ?? 0}
+                  precision={2}
+                />
+              </Col>
+              <Col xs={24} sm={12} md={8} lg={6}>
+                <Statistic
+                  title="单小时触达上限命中比例(%)"
+                  value={summary.hourlyLimitHitRatio ?? 0}
+                  precision={2}
+                  suffix="%"
+                />
+              </Col>
+            </Row>
+            {summary.metricItems && summary.metricItems.length > 0 && (
+              <div className="mt-4">
+                <Text strong>指标明细</Text>
+                <Table<NewUserEngagementMetricItemAdminDTO>
+                  size="small"
+                  bordered
+                  className="mt-2"
+                  rowKey={(r, i) =>
+                    `${r.module ?? ""}-${r.indicator ?? ""}-${i}`
+                  }
+                  dataSource={summary.metricItems}
+                  pagination={false}
+                  columns={[
+                    {
+                      title: "模块",
+                      dataIndex: "module",
+                      key: "module",
+                      width: 120,
+                      render: (code: string) =>
+                        moduleLabelMap.get(code ?? "") ?? code ?? "-",
+                    },
+                    {
+                      title: "指标",
+                      dataIndex: "indicator",
+                      key: "indicator",
+                      width: 180,
+                      render: (code: string) =>
+                        indicatorLabelMap.get(code ?? "") ?? code ?? "-",
+                    },
+                    {
+                      title: "今日",
+                      dataIndex: "today",
+                      key: "today",
+                      width: 100,
+                    },
+                    {
+                      title: "昨日",
+                      dataIndex: "yesterday",
+                      key: "yesterday",
+                      width: 100,
+                    },
+                    {
+                      title: "环比",
+                      dataIndex: "dayOverDay",
+                      key: "dayOverDay",
+                      width: 100,
+                    },
+                  ]}
+                />
+              </div>
+            )}
+          </Card>
+        )}
+
+        <Card title="明细列表" size="small" className="mt-4">
+          <Table<NewUserEngagementReportAdminDTO>
+            columns={columns}
+            dataSource={dataSource}
+            rowKey={(r, i) =>
+              `${r.day ?? ""}-${r.playmateUserNo ?? ""}-${r.userNo ?? ""}-${i}`
+            }
+            loading={loading}
+            pagination={{
+              current: queryParams.pageIndex,
+              pageSize: queryParams.pageSize,
+              total,
+              showSizeChanger: true,
+              showTotal: (t) => `共 ${t} 条`,
+              pageSizeOptions: ["10", "20", "50", "100"],
+            }}
+            onChange={handleTableChange}
+            scroll={{ x: 900 }}
+            size="small"
+            bordered
+          />
+        </Card>
+      </Spin>
+    </div>
+  );
+};
+
+export default NewUserReportPage;

+ 7 - 0
src/config/menus.tsx

@@ -397,6 +397,13 @@ export const menuConfig: MenuItem[] = [
         icon: <BarChartOutlined />,
         permission: "/statistics/index",
       },
+      {
+        key: "statistics-new-user",
+        label: "新用户统计",
+        path: "/statistics/new-user",
+        icon: <BarChartOutlined />,
+        permission: "/statistics/new-user",
+      },
     ],
   },
   {

+ 34 - 0
src/services/newUserEngagementReport.ts

@@ -0,0 +1,34 @@
+import { post, postBlob } from "@/lib/request";
+import type {
+  NewUserEngagementReportAdminQuery,
+  NewUserEngagementReportSummaryAdminDTO,
+  PagerNewUserEngagementReportAdminDTO,
+} from "@/types/api/newUserEngagementReport";
+
+/** Fetch new user engagement report summary */
+export async function fetchNewUserEngagementSummary(
+  query: NewUserEngagementReportAdminQuery,
+): Promise<NewUserEngagementReportSummaryAdminDTO | undefined> {
+  const res = await post<NewUserEngagementReportSummaryAdminDTO>(
+    "/report/new-user-engagement/summary",
+    query,
+  );
+  return res;
+}
+
+/** Fetch new user engagement report paginated list */
+export async function fetchNewUserEngagementPage(
+  query: NewUserEngagementReportAdminQuery,
+): Promise<PagerNewUserEngagementReportAdminDTO> {
+  return post<PagerNewUserEngagementReportAdminDTO>(
+    "/report/new-user-engagement/page",
+    query,
+  );
+}
+
+/** Export new user engagement report as Excel */
+export async function exportNewUserEngagementExcel(
+  query: NewUserEngagementReportAdminQuery,
+) {
+  return postBlob("/report/new-user-engagement/export-excel", query);
+}

+ 85 - 0
src/types/api/newUserEngagementReport.ts

@@ -0,0 +1,85 @@
+// New user engagement report API types
+
+import type { QuerySort } from "./common";
+
+/** Query for new user engagement report (summary / page / export) */
+export interface NewUserEngagementReportAdminQuery {
+  pageSize?: number;
+  pageIndex?: number;
+  sort?: QuerySort;
+  /** 开始日期 yyyy-MM-dd */
+  startDay?: string;
+  /** 结束日期 yyyy-MM-dd */
+  endDay?: string;
+  /** 陪玩师ID/昵称 */
+  playmateKeyword?: string;
+  /** 用户ID/昵称 */
+  userKeyword?: string;
+  skip?: number;
+}
+
+/** Summary response for new user engagement report */
+export interface NewUserEngagementReportSummaryAdminDTO {
+  /** 欢迎语发送总次数 */
+  welcomeSendCount?: number;
+  /** 欢迎语触达用户数 */
+  welcomeUserCount?: number;
+  /** 欢迎语涉及陪玩师数 */
+  welcomePlaymateCount?: number;
+  /** 首次私聊总次数 */
+  pushFirstChatCount?: number;
+  /** 首次私聊触达用户数 */
+  pushUserCount?: number;
+  /** 首次私聊陪玩师数 */
+  pushPlaymateCount?: number;
+  /** 无用户可推的陪玩师比例(%) */
+  noCandidatePlaymateRatio?: number;
+  /** 私聊集中度(TOP10%)(%) */
+  top10PlaymateConcentration?: number;
+  /** 用户平均被私聊次数 */
+  avgChatPerUser?: number;
+  /** 用户单小时触达上限命中比例(%) */
+  hourlyLimitHitRatio?: number;
+  /** 新用户承接策略指标行 */
+  metricItems?: NewUserEngagementMetricItemAdminDTO[];
+}
+
+/** Single metric row (today / yesterday / dayOverDay) */
+export interface NewUserEngagementMetricItemAdminDTO {
+  /** 模块代码 */
+  module?: string;
+  /** 指标代码 */
+  indicator?: string;
+  /** 今日值(纯数值) */
+  today?: string;
+  /** 昨日值(纯数值) */
+  yesterday?: string;
+  /** 环比值(纯数值) */
+  dayOverDay?: string;
+}
+
+/** Single report row in paginated list */
+export interface NewUserEngagementReportAdminDTO {
+  /** 日期 */
+  day?: string;
+  /** 陪玩师编号 */
+  playmateUserNo?: string;
+  /** 陪玩师昵称 */
+  playmateNickname?: string;
+  /** 用户编号 */
+  userNo?: string;
+  /** 用户昵称 */
+  userNickname?: string;
+  /** 欢迎语发送次数 */
+  welcomeSendCount?: number;
+  /** 首次私聊次数 */
+  pushFirstChatCount?: number;
+}
+
+/** Pager response for new user engagement report */
+export interface PagerNewUserEngagementReportAdminDTO {
+  total: number;
+  pageIndex: number;
+  pageSize: number;
+  items: NewUserEngagementReportAdminDTO[];
+}