|
|
@@ -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;
|