Bläddra i källkod

dashboard 改造第一阶段

guoziyun 1 månad sedan
förälder
incheckning
edd747492e

+ 1 - 0
oms/.env

@@ -11,3 +11,4 @@ CLICKHOUSE_HOST=http://localhost:8123
 CLICKHOUSE_DATABASE=omsdb
 CLICKHOUSE_USER=ckuser
 CLICKHOUSE_PASSWORD=ckpassword
+MESSAGE_STATS_PREAGG_ENABLED=0

+ 74 - 0
oms/dist/src/controllers/dashboardController.js

@@ -0,0 +1,74 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const dashboardService_1 = __importDefault(require("../services/dashboardService"));
+class DashboardController {
+    /**
+     * 获取 KPI 数据:日活用户数、日活曲线、广告收益、收益曲线
+     * GET /api/dashboard/kpi?dateRange=7
+     * @param req Express 请求对象
+     * @param res Express 响应对象
+     */
+    async getKpi(req, res) {
+        try {
+            const dateRange = Math.min(Math.max(parseInt(req.query.dateRange) || 7, 1), 90); // 1-90 天
+            console.log(`[DashboardController] getKpi start, dateRange=${dateRange}`);
+            const startedAt = Date.now();
+            const kpiData = await dashboardService_1.default.getKpi(dateRange);
+            console.log(`[DashboardController] getKpi end, durationMs=${Date.now() - startedAt}`);
+            res.status(200).json({
+                success: true,
+                data: kpiData,
+            });
+        }
+        catch (error) {
+            console.error("[DashboardController] Error in getKpi:", error);
+            res.status(500).json({
+                success: false,
+                message: "Failed to fetch KPI data",
+                error: error.message,
+            });
+        }
+    }
+    /**
+     * 获取作品统计数据:点击率、完成率排行
+     * GET /api/dashboard/artwork-stats?top=10&sortBy=clickRate
+     * @param req Express 请求对象
+     * @param res Express 响应对象
+     */
+    async getArtworkStats(req, res) {
+        try {
+            const top = Math.min(Math.max(parseInt(req.query.top) || 10, 1), 100); // 1-100 件
+            const sortBy = req.query.sortBy || "clickCount"; // clickRate | completionRate | clickCount | completionCount
+            // 验证 sortBy 值
+            const validSortByValues = ["clickRate", "completionRate", "clickCount", "completionCount"];
+            if (!validSortByValues.includes(sortBy)) {
+                res.status(400).json({
+                    success: false,
+                    message: `Invalid sortBy value. Expected one of: ${validSortByValues.join(", ")}`,
+                });
+                return;
+            }
+            console.log(`[DashboardController] getArtworkStats start, top=${top}, sortBy=${sortBy}`);
+            const startedAt = Date.now();
+            const artworkStats = await dashboardService_1.default.getArtworkStats(top, sortBy);
+            console.log(`[DashboardController] getArtworkStats end, durationMs=${Date.now() - startedAt}`);
+            res.status(200).json({
+                success: true,
+                data: artworkStats,
+            });
+        }
+        catch (error) {
+            console.error("[DashboardController] Error in getArtworkStats:", error);
+            res.status(500).json({
+                success: false,
+                message: "Failed to fetch artwork stats",
+                error: error.message,
+            });
+        }
+    }
+}
+const dashboardController = new DashboardController();
+exports.default = dashboardController;

+ 4 - 0
oms/dist/src/routes/apiRoutes.js

@@ -13,6 +13,7 @@ const messageActivityController_1 = __importDefault(require("../controllers/mess
 const messageRecordController_1 = __importDefault(require("../controllers/messageRecordController")); // 新增:导入消息记录控制器
 const userTargetingController_1 = __importDefault(require("../controllers/userTargetingController"));
 const adminController_1 = __importDefault(require("../controllers/adminController"));
+const dashboardController_1 = __importDefault(require("../controllers/dashboardController")); // 新增:导入 dashboard 控制器
 const authMiddleware_1 = require("../middleware/authMiddleware");
 const router = (0, express_1.Router)();
 // 公共的管理员认证路由 (无需认证)
@@ -34,6 +35,9 @@ router.get("/message/daily/trends/by-cc/:cc", messageRecordController_1.default.
 router.get("/message/daily/trends/by-strategy/:strategyName", messageRecordController_1.default.getDailyTrendsByStrategy);
 router.get("/message/daily/trends/by-template/:templateName", messageRecordController_1.default.getDailyTrendsByTemplate);
 router.get("/message/daily/trends/by-image/:image", messageRecordController_1.default.getDailyTrendsByImage);
+// 新增:Dashboard 路由
+router.get("/dashboard/kpi", dashboardController_1.default.getKpi);
+router.get("/dashboard/artwork-stats", dashboardController_1.default.getArtworkStats);
 // 应用认证中间件,保护所有下面的路由
 router.use(authMiddleware_1.authMiddleware);
 // User routes

+ 142 - 0
oms/dist/src/services/dashboardService.js

@@ -0,0 +1,142 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const clients_1 = require("./clients");
+const dayjs_1 = __importDefault(require("dayjs"));
+class DashboardService {
+    constructor() {
+        this.eventsTable = "events";
+    }
+    /**
+     * 获取 KPI 数据:日活用户数、日活曲线、广告收益、收益曲线
+     * @param dateRange 显示过去 N 天的数据,默认 7 天
+     * @returns KPI 数据对象
+     */
+    async getKpi(dateRange = 7) {
+        try {
+            const startDate = (0, dayjs_1.default)().subtract(dateRange - 1, "day").format("YYYY-MM-DD");
+            const endDate = (0, dayjs_1.default)().format("YYYY-MM-DD");
+            const today = (0, dayjs_1.default)().format("YYYY-MM-DD");
+            // 查询日活用户数和收益的聚合数据
+            const dauQuery = `
+        SELECT 
+          toDate(time) as date,
+          countDistinct(uid) as dau
+        FROM ${this.eventsTable}
+        WHERE toDate(time) >= '${startDate}' 
+          AND toDate(time) <= '${endDate}'
+          AND project = 1
+        GROUP BY date
+        ORDER BY date
+      `;
+            const revenueQuery = `
+        SELECT 
+          toDate(time) as date,
+          sumIf(revenue, event = 'revenue' AND revenue > 0) as total_revenue
+        FROM ${this.eventsTable}
+        WHERE toDate(time) >= '${startDate}' 
+          AND toDate(time) <= '${endDate}'
+          AND project = 1
+        GROUP BY date
+        ORDER BY date
+      `;
+            // 获取今日具体数据
+            const todayDauQuery = `
+        SELECT countDistinct(uid) as dau
+        FROM ${this.eventsTable}
+        WHERE toDate(time) = '${today}' AND project = 1
+      `;
+            const todayRevenueQuery = `
+        SELECT sumIf(revenue, event = 'revenue' AND revenue > 0) as total_revenue
+        FROM ${this.eventsTable}
+        WHERE toDate(time) = '${today}' AND project = 1
+      `;
+            // 执行所有查询
+            const [dauTrend, revenueTrend, todayDauResult, todayRevenueResult] = await Promise.all([
+                clients_1.clickhouseService.queryEvents(dauQuery),
+                clients_1.clickhouseService.queryEvents(revenueQuery),
+                clients_1.clickhouseService.queryEvents(todayDauQuery),
+                clients_1.clickhouseService.queryEvents(todayRevenueQuery),
+            ]);
+            const todayDau = todayDauResult?.[0]?.dau ?? 0;
+            const todayRevenue = todayRevenueResult?.[0]?.total_revenue ?? 0;
+            return {
+                dau: {
+                    today: todayDau,
+                    trend: dauTrend.map((item) => ({
+                        date: item.date,
+                        dau: item.dau,
+                    })),
+                },
+                revenue: {
+                    today: parseFloat(todayRevenue.toFixed(2)),
+                    trend: revenueTrend.map((item) => ({
+                        date: item.date,
+                        revenue: parseFloat(item.total_revenue.toFixed(2)),
+                    })),
+                },
+            };
+        }
+        catch (error) {
+            console.error("[DashboardService] Error fetching KPI data:", error);
+            throw error;
+        }
+    }
+    /**
+     * 获取作品统计数据:点击率、完成率排行
+     * @param top 返回 Top N 作品数量
+     * @param sortBy 排序字段:clickRate | completionRate | clickCount | completionCount
+     * @returns 作品统计数据
+     */
+    async getArtworkStats(top = 10, sortBy = "clickCount") {
+        try {
+            const today = (0, dayjs_1.default)().format("YYYY-MM-DD");
+            // 查询今日日活用户
+            const dauQuery = `
+        SELECT countDistinct(uid) as dau
+        FROM ${this.eventsTable}
+        WHERE toDate(time) = '${today}' AND project = 1
+      `;
+            // 查询今日各作品的 color_start 和 color_done 事件计数
+            const artworkQuery = `
+        SELECT 
+          res as resId,
+          countIf(event = 'color_start') as click_count,
+          countIf(event = 'color_done') as completion_count
+        FROM ${this.eventsTable}
+        WHERE toDate(time) = '${today}' 
+          AND project = 1 
+          AND res IS NOT NULL
+          AND res != ''
+        GROUP BY res
+        ORDER BY click_count DESC
+        LIMIT ${top}
+      `;
+            const [dauResult, artworkResults] = await Promise.all([
+                clients_1.clickhouseService.queryEvents(dauQuery),
+                clients_1.clickhouseService.queryEvents(artworkQuery),
+            ]);
+            const dau = dauResult?.[0]?.dau ?? 0;
+            const artworks = artworkResults.map((art) => ({
+                resId: art.resId,
+                name: "", // 补充:需要从 artModel 补充名称
+                coverImage: null, // 补充:需要从 artModel 补充图片
+                clickCount: art.click_count,
+                clickRate: dau > 0 ? parseFloat((art.click_count / dau).toFixed(4)) : 0,
+                completionCount: art.completion_count,
+                completionRate: art.click_count > 0 ? parseFloat((art.completion_count / art.click_count).toFixed(4)) : 0,
+            }));
+            return {
+                dau_today: dau,
+                artworks,
+            };
+        }
+        catch (error) {
+            console.error("[DashboardService] Error fetching artwork stats:", error);
+            throw error;
+        }
+    }
+}
+exports.default = new DashboardService();

+ 162 - 3
oms/dist/src/services/messageRecordService.js

@@ -196,10 +196,21 @@ class MessageRecordService {
             console.log(`[MessageStatsCache] hit key=${cacheKey}`);
             return cached;
         }
+        let result;
+        if (MessageRecordService.PREAGG_ENABLED && startDate && endDate) {
+            const t0 = Date.now();
+            result = await this.getByTemplateFromPreAgg(startDate, endDate, strategyName, page, limit);
+            if (result.length > 0) {
+                console.log(`[MessageStats] by-template preagg rows=${result.length} ms=${Date.now() - t0}`);
+                await this.setCache(cacheKey, result);
+                console.log(`[MessageStatsCache] miss key=${cacheKey}`);
+                return result;
+            }
+        }
         const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
         // templateId 是 ObjectId,分组时增加基数但对展示无意义,移除后减少第一阶段 group key 大小
         const groupFields = ["templateName"];
-        const result = await this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 }, page, limit);
+        result = await this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 }, page, limit);
         await this.setCache(cacheKey, result);
         console.log(`[MessageStatsCache] miss key=${cacheKey}`);
         return result;
@@ -220,8 +231,19 @@ class MessageRecordService {
             console.log(`[MessageStatsCache] hit key=${cacheKey}`);
             return cached;
         }
+        let result;
+        if (MessageRecordService.PREAGG_ENABLED && startDate && endDate) {
+            const t0 = Date.now();
+            result = await this.getByCcFromPreAgg(startDate, endDate, strategyName, page, limit);
+            if (result.length > 0) {
+                console.log(`[MessageStats] by-cc preagg rows=${result.length} ms=${Date.now() - t0}`);
+                await this.setCache(cacheKey, result);
+                console.log(`[MessageStatsCache] miss key=${cacheKey}`);
+                return result;
+            }
+        }
         const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
-        const result = await this.getStatisticsByGroup(matchConditions, ["cc"], { totalRecords: -1 }, page, limit);
+        result = await this.getStatisticsByGroup(matchConditions, ["cc"], { totalRecords: -1 }, page, limit);
         await this.setCache(cacheKey, result);
         console.log(`[MessageStatsCache] miss key=${cacheKey}`);
         return result;
@@ -242,8 +264,19 @@ class MessageRecordService {
             console.log(`[MessageStatsCache] hit key=${cacheKey}`);
             return cached;
         }
+        let result;
+        if (MessageRecordService.PREAGG_ENABLED && startDate && endDate) {
+            const t0 = Date.now();
+            result = await this.getByImageFromPreAgg(startDate, endDate, strategyName, page, limit);
+            if (result.length > 0) {
+                console.log(`[MessageStats] by-image preagg rows=${result.length} ms=${Date.now() - t0}`);
+                await this.setCache(cacheKey, result);
+                console.log(`[MessageStatsCache] miss key=${cacheKey}`);
+                return result;
+            }
+        }
         const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
-        const result = await this.getStatisticsByGroup(matchConditions, ["image"], { clickThroughRate: -1 }, page, limit);
+        result = await this.getStatisticsByGroup(matchConditions, ["image"], { clickThroughRate: -1 }, page, limit);
         await this.setCache(cacheKey, result);
         console.log(`[MessageStatsCache] miss key=${cacheKey}`);
         return result;
@@ -704,6 +737,132 @@ class MessageRecordService {
         ];
         return messageStatsDailyUidModel_1.MessageStatsDailyUid.aggregate(pipeline).allowDiskUse(true);
     }
+    async getByTemplateFromPreAgg(startDate, endDate, strategyName, page, limit) {
+        const safePage = Math.max(1, Math.floor(page || MessageRecordService.DEFAULT_STATS_PAGE));
+        const safeLimit = Math.min(MessageRecordService.MAX_STATS_LIMIT, Math.max(1, Math.floor(limit || MessageRecordService.DEFAULT_STATS_LIMIT)));
+        const pipeline = [
+            { $match: this.buildPreAggMatch(startDate, endDate, strategyName) },
+            {
+                $group: {
+                    _id: {
+                        templateName: "$templateName",
+                        status: "$status",
+                        inforeground: "$inforeground",
+                        uid: "$uid",
+                    },
+                    count: { $sum: "$msgCount" },
+                },
+            },
+            {
+                $group: {
+                    _id: {
+                        templateName: "$_id.templateName",
+                        status: "$_id.status",
+                        inforeground: "$_id.inforeground",
+                    },
+                    count: { $sum: "$count" },
+                    uniqueUsers: { $sum: 1 },
+                },
+            },
+            {
+                $group: {
+                    _id: { templateName: "$_id.templateName" },
+                    templateName: { $first: "$_id.templateName" },
+                    totalRecords: { $sum: "$count" },
+                    ...this.getStatusAggregationFields(),
+                },
+            },
+            { $project: this.getStatisticsProjectFields(["templateName"]) },
+            { $sort: { clickThroughRate: -1 } },
+            { $skip: (safePage - 1) * safeLimit },
+            { $limit: safeLimit },
+        ];
+        return messageStatsDailyUidModel_1.MessageStatsDailyUid.aggregate(pipeline).allowDiskUse(true);
+    }
+    async getByCcFromPreAgg(startDate, endDate, strategyName, page, limit) {
+        const safePage = Math.max(1, Math.floor(page || MessageRecordService.DEFAULT_STATS_PAGE));
+        const safeLimit = Math.min(MessageRecordService.MAX_STATS_LIMIT, Math.max(1, Math.floor(limit || MessageRecordService.DEFAULT_STATS_LIMIT)));
+        const pipeline = [
+            { $match: this.buildPreAggMatch(startDate, endDate, strategyName) },
+            {
+                $group: {
+                    _id: {
+                        cc: "$cc",
+                        status: "$status",
+                        inforeground: "$inforeground",
+                        uid: "$uid",
+                    },
+                    count: { $sum: "$msgCount" },
+                },
+            },
+            {
+                $group: {
+                    _id: {
+                        cc: "$_id.cc",
+                        status: "$_id.status",
+                        inforeground: "$_id.inforeground",
+                    },
+                    count: { $sum: "$count" },
+                    uniqueUsers: { $sum: 1 },
+                },
+            },
+            {
+                $group: {
+                    _id: { cc: "$_id.cc" },
+                    cc: { $first: "$_id.cc" },
+                    totalRecords: { $sum: "$count" },
+                    ...this.getStatusAggregationFields(),
+                },
+            },
+            { $project: this.getStatisticsProjectFields(["cc"]) },
+            { $sort: { totalRecords: -1 } },
+            { $skip: (safePage - 1) * safeLimit },
+            { $limit: safeLimit },
+        ];
+        return messageStatsDailyUidModel_1.MessageStatsDailyUid.aggregate(pipeline).allowDiskUse(true);
+    }
+    async getByImageFromPreAgg(startDate, endDate, strategyName, page, limit) {
+        const safePage = Math.max(1, Math.floor(page || MessageRecordService.DEFAULT_STATS_PAGE));
+        const safeLimit = Math.min(MessageRecordService.MAX_STATS_LIMIT, Math.max(1, Math.floor(limit || MessageRecordService.DEFAULT_STATS_LIMIT)));
+        const pipeline = [
+            { $match: this.buildPreAggMatch(startDate, endDate, strategyName) },
+            {
+                $group: {
+                    _id: {
+                        image: "$image",
+                        status: "$status",
+                        inforeground: "$inforeground",
+                        uid: "$uid",
+                    },
+                    count: { $sum: "$msgCount" },
+                },
+            },
+            {
+                $group: {
+                    _id: {
+                        image: "$_id.image",
+                        status: "$_id.status",
+                        inforeground: "$_id.inforeground",
+                    },
+                    count: { $sum: "$count" },
+                    uniqueUsers: { $sum: 1 },
+                },
+            },
+            {
+                $group: {
+                    _id: { image: "$_id.image" },
+                    image: { $first: "$_id.image" },
+                    totalRecords: { $sum: "$count" },
+                    ...this.getStatusAggregationFields(),
+                },
+            },
+            { $project: this.getStatisticsProjectFields(["image"]) },
+            { $sort: { clickThroughRate: -1 } },
+            { $skip: (safePage - 1) * safeLimit },
+            { $limit: safeLimit },
+        ];
+        return messageStatsDailyUidModel_1.MessageStatsDailyUid.aggregate(pipeline).allowDiskUse(true);
+    }
     async getDailyTrendsFromPreAgg(startDate, endDate, strategyName) {
         const pipeline = [
             { $match: this.buildPreAggMatch(startDate, endDate, strategyName) },

+ 1 - 1
oms/public/app/index.html

@@ -9,5 +9,5 @@
   <style>body,html{width:100%;height:100%}*,:after,:before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:transparent}@-ms-viewport{width:device-width}body{margin:0;color:#000000d9;font-size:14px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-variant:tabular-nums;line-height:1.5715;background-color:#fff;font-feature-settings:"tnum"}html{--antd-wave-shadow-color:#1890ff;--scroll-bar:0}</style><link rel="stylesheet" href="styles-LXBSU6DF.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="styles-LXBSU6DF.css"></noscript></head>
   <body>
     <app-root></app-root>
-  <script src="polyfills-B6TNHZQ6.js" type="module"></script><script src="main-Y4TRW7U5.js" type="module"></script></body>
+  <script src="polyfills-B6TNHZQ6.js" type="module"></script><script src="main-NTRDYMBN.js" type="module"></script></body>
 </html>

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
oms/public/app/main-NTRDYMBN.js


+ 83 - 0
oms/src/controllers/dashboardController.ts

@@ -0,0 +1,83 @@
+import { Request, Response } from "express";
+import dashboardService from "../services/dashboardService";
+
+class DashboardController {
+  /**
+   * 获取 KPI 数据:日活用户数、日活曲线、广告收益、收益曲线
+   * GET /api/dashboard/kpi?dateRange=7
+   * @param req Express 请求对象
+   * @param res Express 响应对象
+   */
+  public async getKpi(req: Request, res: Response): Promise<void> {
+    try {
+      const dateRange = Math.min(Math.max(parseInt(req.query.dateRange as string) || 7, 1), 90); // 1-90 天
+
+      console.log(`[DashboardController] getKpi start, dateRange=${dateRange}`);
+      const startedAt = Date.now();
+
+      const kpiData = await dashboardService.getKpi(dateRange);
+
+      console.log(`[DashboardController] getKpi end, durationMs=${Date.now() - startedAt}`);
+
+      res.status(200).json({
+        success: true,
+        data: kpiData,
+      });
+    } catch (error: any) {
+      console.error("[DashboardController] Error in getKpi:", error);
+      res.status(500).json({
+        success: false,
+        message: "Failed to fetch KPI data",
+        error: error.message,
+      });
+    }
+  }
+
+  /**
+   * 获取作品统计数据:点击率、完成率排行
+   * GET /api/dashboard/artwork-stats?top=10&sortBy=clickRate
+   * @param req Express 请求对象
+   * @param res Express 响应对象
+   */
+  public async getArtworkStats(req: Request, res: Response): Promise<void> {
+    try {
+      const top = Math.min(Math.max(parseInt(req.query.top as string) || 10, 1), 100); // 1-100 件
+      const sortBy = (req.query.sortBy as string) || "clickCount"; // clickRate | completionRate | clickCount | completionCount
+
+      // 验证 sortBy 值
+      const validSortByValues = ["clickRate", "completionRate", "clickCount", "completionCount"];
+      if (!validSortByValues.includes(sortBy)) {
+        res.status(400).json({
+          success: false,
+          message: `Invalid sortBy value. Expected one of: ${validSortByValues.join(", ")}`,
+        });
+        return;
+      }
+
+      console.log(`[DashboardController] getArtworkStats start, top=${top}, sortBy=${sortBy}`);
+      const startedAt = Date.now();
+
+      const artworkStats = await dashboardService.getArtworkStats(
+        top,
+        sortBy as "clickRate" | "completionRate" | "clickCount" | "completionCount"
+      );
+
+      console.log(`[DashboardController] getArtworkStats end, durationMs=${Date.now() - startedAt}`);
+
+      res.status(200).json({
+        success: true,
+        data: artworkStats,
+      });
+    } catch (error: any) {
+      console.error("[DashboardController] Error in getArtworkStats:", error);
+      res.status(500).json({
+        success: false,
+        message: "Failed to fetch artwork stats",
+        error: error.message,
+      });
+    }
+  }
+}
+
+const dashboardController = new DashboardController();
+export default dashboardController;

+ 3 - 6
oms/src/models/colorRecordModel.ts

@@ -7,7 +7,7 @@ export interface IColorRecord extends Document {
   res: string; // 作品ID
   type: "color_start" | "color_done" | "color_tip"; // 操作类型 (填色开始, 填色完成, 填色提示等)
   time: Date; // 操作时间
-  duration?: number; // 填色时长 (仅对 'color_done' 有效)
+  duration?: number; // 填色时长
   createdAt: Date; // 记录创建时间 (由timestamps自动管理)
   updatedAt: Date; // 记录更新时间 (由timestamps自动管理)
 }
@@ -43,12 +43,9 @@ const ColorRecordSchema: Schema = new Schema(
   },
   {
     timestamps: true, // 自动添加 createdAt 和 updatedAt 字段
-  }
+  },
 );
 
 // 创建并导出 ColorRecord Model
-const ColorRecord = mongoose.model<IColorRecord>(
-  "ColorRecord",
-  ColorRecordSchema
-);
+const ColorRecord = mongoose.model<IColorRecord>("ColorRecord", ColorRecordSchema);
 export default ColorRecord;

+ 5 - 0
oms/src/routes/apiRoutes.ts

@@ -8,6 +8,7 @@ import messageActivityController from "../controllers/messageActivityController"
 import messageRecordController from "../controllers/messageRecordController"; // 新增:导入消息记录控制器
 import UserTargetingController from "../controllers/userTargetingController";
 import adminController from "../controllers/adminController";
+import dashboardController from "../controllers/dashboardController"; // 新增:导入 dashboard 控制器
 import { authMiddleware } from "../middleware/authMiddleware";
 
 const router = Router();
@@ -35,6 +36,10 @@ router.get("/message/daily/trends/by-strategy/:strategyName", messageRecordContr
 router.get("/message/daily/trends/by-template/:templateName", messageRecordController.getDailyTrendsByTemplate);
 router.get("/message/daily/trends/by-image/:image", messageRecordController.getDailyTrendsByImage);
 
+// 新增:Dashboard 路由
+router.get("/dashboard/kpi", dashboardController.getKpi);
+router.get("/dashboard/artwork-stats", dashboardController.getArtworkStats);
+
 // 应用认证中间件,保护所有下面的路由
 router.use(authMiddleware);
 

+ 179 - 0
oms/src/services/dashboardService.ts

@@ -0,0 +1,179 @@
+import { clickhouseService } from "./clients";
+import dayjs from "dayjs";
+
+interface KpiData {
+  dau: {
+    today: number;
+    trend: Array<{ date: string; dau: number }>;
+  };
+  revenue: {
+    today: number;
+    trend: Array<{ date: string; revenue: number }>;
+  };
+}
+
+interface ArtworkStat {
+  resId: string;
+  name: string;
+  coverImage: string | null;
+  clickCount: number;
+  clickRate: number;
+  completionCount: number;
+  completionRate: number;
+}
+
+interface ArtworkStatsResponse {
+  dau_today: number;
+  artworks: ArtworkStat[];
+}
+
+class DashboardService {
+  private readonly eventsTable = "events";
+
+  /**
+   * 获取 KPI 数据:日活用户数、日活曲线、广告收益、收益曲线
+   * @param dateRange 显示过去 N 天的数据,默认 7 天
+   * @returns KPI 数据对象
+   */
+  public async getKpi(dateRange: number = 7): Promise<KpiData> {
+    try {
+      const startDate = dayjs().subtract(dateRange - 1, "day").format("YYYY-MM-DD");
+      const endDate = dayjs().format("YYYY-MM-DD");
+      const today = dayjs().format("YYYY-MM-DD");
+
+      // 查询日活用户数和收益的聚合数据
+      const dauQuery = `
+        SELECT 
+          toDate(time) as date,
+          countDistinct(uid) as dau
+        FROM ${this.eventsTable}
+        WHERE toDate(time) >= '${startDate}' 
+          AND toDate(time) <= '${endDate}'
+          AND project = 1
+        GROUP BY date
+        ORDER BY date
+      `;
+
+      const revenueQuery = `
+        SELECT 
+          toDate(time) as date,
+          sumIf(revenue, event = 'revenue' AND revenue > 0) as total_revenue
+        FROM ${this.eventsTable}
+        WHERE toDate(time) >= '${startDate}' 
+          AND toDate(time) <= '${endDate}'
+          AND project = 1
+        GROUP BY date
+        ORDER BY date
+      `;
+
+      // 获取今日具体数据
+      const todayDauQuery = `
+        SELECT countDistinct(uid) as dau
+        FROM ${this.eventsTable}
+        WHERE toDate(time) = '${today}' AND project = 1
+      `;
+
+      const todayRevenueQuery = `
+        SELECT sumIf(revenue, event = 'revenue' AND revenue > 0) as total_revenue
+        FROM ${this.eventsTable}
+        WHERE toDate(time) = '${today}' AND project = 1
+      `;
+
+      // 执行所有查询
+      const [dauTrend, revenueTrend, todayDauResult, todayRevenueResult] = await Promise.all([
+        clickhouseService.queryEvents<{ date: string; dau: number }>(dauQuery),
+        clickhouseService.queryEvents<{ date: string; total_revenue: number }>(revenueQuery),
+        clickhouseService.queryEvents<{ dau: number }>(todayDauQuery),
+        clickhouseService.queryEvents<{ total_revenue: number }>(todayRevenueQuery),
+      ]);
+
+      const todayDau = todayDauResult?.[0]?.dau ?? 0;
+      const todayRevenue = todayRevenueResult?.[0]?.total_revenue ?? 0;
+
+      return {
+        dau: {
+          today: todayDau,
+          trend: dauTrend.map((item) => ({
+            date: item.date,
+            dau: item.dau,
+          })),
+        },
+        revenue: {
+          today: parseFloat(todayRevenue.toFixed(2)),
+          trend: revenueTrend.map((item) => ({
+            date: item.date,
+            revenue: parseFloat(item.total_revenue.toFixed(2)),
+          })),
+        },
+      };
+    } catch (error) {
+      console.error("[DashboardService] Error fetching KPI data:", error);
+      throw error;
+    }
+  }
+
+  /**
+   * 获取作品统计数据:点击率、完成率排行
+   * @param top 返回 Top N 作品数量
+   * @param sortBy 排序字段:clickRate | completionRate | clickCount | completionCount
+   * @returns 作品统计数据
+   */
+  public async getArtworkStats(
+    top: number = 10,
+    sortBy: "clickRate" | "completionRate" | "clickCount" | "completionCount" = "clickCount"
+  ): Promise<ArtworkStatsResponse> {
+    try {
+      const today = dayjs().format("YYYY-MM-DD");
+
+      // 查询今日日活用户
+      const dauQuery = `
+        SELECT countDistinct(uid) as dau
+        FROM ${this.eventsTable}
+        WHERE toDate(time) = '${today}' AND project = 1
+      `;
+
+      // 查询今日各作品的 color_start 和 color_done 事件计数
+      const artworkQuery = `
+        SELECT 
+          res as resId,
+          countIf(event = 'color_start') as click_count,
+          countIf(event = 'color_done') as completion_count
+        FROM ${this.eventsTable}
+        WHERE toDate(time) = '${today}' 
+          AND project = 1 
+          AND res IS NOT NULL
+          AND res != ''
+        GROUP BY res
+        ORDER BY click_count DESC
+        LIMIT ${top}
+      `;
+
+      const [dauResult, artworkResults] = await Promise.all([
+        clickhouseService.queryEvents<{ dau: number }>(dauQuery),
+        clickhouseService.queryEvents<{ resId: string; click_count: number; completion_count: number }>(artworkQuery),
+      ]);
+
+      const dau = dauResult?.[0]?.dau ?? 0;
+
+      const artworks: ArtworkStat[] = artworkResults.map((art) => ({
+        resId: art.resId,
+        name: "", // 补充:需要从 artModel 补充名称
+        coverImage: null, // 补充:需要从 artModel 补充图片
+        clickCount: art.click_count,
+        clickRate: dau > 0 ? parseFloat((art.click_count / dau).toFixed(4)) : 0,
+        completionCount: art.completion_count,
+        completionRate: art.click_count > 0 ? parseFloat((art.completion_count / art.click_count).toFixed(4)) : 0,
+      }));
+
+      return {
+        dau_today: dau,
+        artworks,
+      };
+    } catch (error) {
+      console.error("[DashboardService] Error fetching artwork stats:", error);
+      throw error;
+    }
+  }
+}
+
+export default new DashboardService();

+ 180 - 3
oms/src/services/messageRecordService.ts

@@ -263,10 +263,21 @@ export class MessageRecordService {
       console.log(`[MessageStatsCache] hit key=${cacheKey}`);
       return cached;
     }
+    let result: any[];
+    if (MessageRecordService.PREAGG_ENABLED && startDate && endDate) {
+      const t0 = Date.now();
+      result = await this.getByTemplateFromPreAgg(startDate, endDate, strategyName, page, limit);
+      if (result.length > 0) {
+        console.log(`[MessageStats] by-template preagg rows=${result.length} ms=${Date.now() - t0}`);
+        await this.setCache(cacheKey, result);
+        console.log(`[MessageStatsCache] miss key=${cacheKey}`);
+        return result;
+      }
+    }
     const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
     // templateId 是 ObjectId,分组时增加基数但对展示无意义,移除后减少第一阶段 group key 大小
     const groupFields = ["templateName"];
-    const result = await this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 }, page, limit);
+    result = await this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 }, page, limit);
     await this.setCache(cacheKey, result);
     console.log(`[MessageStatsCache] miss key=${cacheKey}`);
     return result;
@@ -294,8 +305,19 @@ export class MessageRecordService {
       console.log(`[MessageStatsCache] hit key=${cacheKey}`);
       return cached;
     }
+    let result: any[];
+    if (MessageRecordService.PREAGG_ENABLED && startDate && endDate) {
+      const t0 = Date.now();
+      result = await this.getByCcFromPreAgg(startDate, endDate, strategyName, page, limit);
+      if (result.length > 0) {
+        console.log(`[MessageStats] by-cc preagg rows=${result.length} ms=${Date.now() - t0}`);
+        await this.setCache(cacheKey, result);
+        console.log(`[MessageStatsCache] miss key=${cacheKey}`);
+        return result;
+      }
+    }
     const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
-    const result = await this.getStatisticsByGroup(matchConditions, ["cc"], { totalRecords: -1 }, page, limit);
+    result = await this.getStatisticsByGroup(matchConditions, ["cc"], { totalRecords: -1 }, page, limit);
     await this.setCache(cacheKey, result);
     console.log(`[MessageStatsCache] miss key=${cacheKey}`);
     return result;
@@ -323,8 +345,19 @@ export class MessageRecordService {
       console.log(`[MessageStatsCache] hit key=${cacheKey}`);
       return cached;
     }
+    let result: any[];
+    if (MessageRecordService.PREAGG_ENABLED && startDate && endDate) {
+      const t0 = Date.now();
+      result = await this.getByImageFromPreAgg(startDate, endDate, strategyName, page, limit);
+      if (result.length > 0) {
+        console.log(`[MessageStats] by-image preagg rows=${result.length} ms=${Date.now() - t0}`);
+        await this.setCache(cacheKey, result);
+        console.log(`[MessageStatsCache] miss key=${cacheKey}`);
+        return result;
+      }
+    }
     const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
-    const result = await this.getStatisticsByGroup(matchConditions, ["image"], { clickThroughRate: -1 }, page, limit);
+    result = await this.getStatisticsByGroup(matchConditions, ["image"], { clickThroughRate: -1 }, page, limit);
     await this.setCache(cacheKey, result);
     console.log(`[MessageStatsCache] miss key=${cacheKey}`);
     return result;
@@ -849,6 +882,150 @@ export class MessageRecordService {
     return MessageStatsDailyUid.aggregate(pipeline).allowDiskUse(true);
   }
 
+  private async getByTemplateFromPreAgg(startDate: Date, endDate: Date, strategyName: string | undefined, page: number, limit: number) {
+    const safePage = Math.max(1, Math.floor(page || MessageRecordService.DEFAULT_STATS_PAGE));
+    const safeLimit = Math.min(
+      MessageRecordService.MAX_STATS_LIMIT,
+      Math.max(1, Math.floor(limit || MessageRecordService.DEFAULT_STATS_LIMIT))
+    );
+
+    const pipeline: any[] = [
+      { $match: this.buildPreAggMatch(startDate, endDate, strategyName) },
+      {
+        $group: {
+          _id: {
+            templateName: "$templateName",
+            status: "$status",
+            inforeground: "$inforeground",
+            uid: "$uid",
+          },
+          count: { $sum: "$msgCount" },
+        },
+      },
+      {
+        $group: {
+          _id: {
+            templateName: "$_id.templateName",
+            status: "$_id.status",
+            inforeground: "$_id.inforeground",
+          },
+          count: { $sum: "$count" },
+          uniqueUsers: { $sum: 1 },
+        },
+      },
+      {
+        $group: {
+          _id: { templateName: "$_id.templateName" },
+          templateName: { $first: "$_id.templateName" },
+          totalRecords: { $sum: "$count" },
+          ...this.getStatusAggregationFields(),
+        },
+      },
+      { $project: this.getStatisticsProjectFields(["templateName"]) },
+      { $sort: { clickThroughRate: -1 } },
+      { $skip: (safePage - 1) * safeLimit },
+      { $limit: safeLimit },
+    ];
+
+    return MessageStatsDailyUid.aggregate(pipeline).allowDiskUse(true);
+  }
+
+  private async getByCcFromPreAgg(startDate: Date, endDate: Date, strategyName: string | undefined, page: number, limit: number) {
+    const safePage = Math.max(1, Math.floor(page || MessageRecordService.DEFAULT_STATS_PAGE));
+    const safeLimit = Math.min(
+      MessageRecordService.MAX_STATS_LIMIT,
+      Math.max(1, Math.floor(limit || MessageRecordService.DEFAULT_STATS_LIMIT))
+    );
+
+    const pipeline: any[] = [
+      { $match: this.buildPreAggMatch(startDate, endDate, strategyName) },
+      {
+        $group: {
+          _id: {
+            cc: "$cc",
+            status: "$status",
+            inforeground: "$inforeground",
+            uid: "$uid",
+          },
+          count: { $sum: "$msgCount" },
+        },
+      },
+      {
+        $group: {
+          _id: {
+            cc: "$_id.cc",
+            status: "$_id.status",
+            inforeground: "$_id.inforeground",
+          },
+          count: { $sum: "$count" },
+          uniqueUsers: { $sum: 1 },
+        },
+      },
+      {
+        $group: {
+          _id: { cc: "$_id.cc" },
+          cc: { $first: "$_id.cc" },
+          totalRecords: { $sum: "$count" },
+          ...this.getStatusAggregationFields(),
+        },
+      },
+      { $project: this.getStatisticsProjectFields(["cc"]) },
+      { $sort: { totalRecords: -1 } },
+      { $skip: (safePage - 1) * safeLimit },
+      { $limit: safeLimit },
+    ];
+
+    return MessageStatsDailyUid.aggregate(pipeline).allowDiskUse(true);
+  }
+
+  private async getByImageFromPreAgg(startDate: Date, endDate: Date, strategyName: string | undefined, page: number, limit: number) {
+    const safePage = Math.max(1, Math.floor(page || MessageRecordService.DEFAULT_STATS_PAGE));
+    const safeLimit = Math.min(
+      MessageRecordService.MAX_STATS_LIMIT,
+      Math.max(1, Math.floor(limit || MessageRecordService.DEFAULT_STATS_LIMIT))
+    );
+
+    const pipeline: any[] = [
+      { $match: this.buildPreAggMatch(startDate, endDate, strategyName) },
+      {
+        $group: {
+          _id: {
+            image: "$image",
+            status: "$status",
+            inforeground: "$inforeground",
+            uid: "$uid",
+          },
+          count: { $sum: "$msgCount" },
+        },
+      },
+      {
+        $group: {
+          _id: {
+            image: "$_id.image",
+            status: "$_id.status",
+            inforeground: "$_id.inforeground",
+          },
+          count: { $sum: "$count" },
+          uniqueUsers: { $sum: 1 },
+        },
+      },
+      {
+        $group: {
+          _id: { image: "$_id.image" },
+          image: { $first: "$_id.image" },
+          totalRecords: { $sum: "$count" },
+          ...this.getStatusAggregationFields(),
+        },
+      },
+      { $project: this.getStatisticsProjectFields(["image"]) },
+      { $sort: { clickThroughRate: -1 } },
+      { $skip: (safePage - 1) * safeLimit },
+      { $limit: safeLimit },
+    ];
+
+    return MessageStatsDailyUid.aggregate(pipeline).allowDiskUse(true);
+  }
+
   private async getDailyTrendsFromPreAgg(startDate: Date, endDate: Date, strategyName?: string) {
     const pipeline: any[] = [
       { $match: this.buildPreAggMatch(startDate, endDate, strategyName) },

+ 126 - 126
omsapp/src/app/pages/dashboard.component.ts

@@ -1,6 +1,7 @@
 import { Component, OnInit } from '@angular/core';
-import { CommonModule, NgIf, NgFor, DatePipe } from '@angular/common';
+import { CommonModule, NgFor, DatePipe } from '@angular/common';
 import { debounceTime } from 'rxjs/operators';
+import { DashboardService } from '../services/dashboard.service';
 
 // NG-ZORRO 组件
 import { NzCardModule } from 'ng-zorro-antd/card';
@@ -22,7 +23,6 @@ import { NzMessageService } from 'ng-zorro-antd/message';
   standalone: true,
   imports: [
     CommonModule,
-    NgIf,
     NgFor,
     DatePipe,
     // NG-ZORRO 模块
@@ -62,12 +62,12 @@ import { NzMessageService } from 'ng-zorro-antd/message';
         <!-- 核心指标卡片 -->
         <div class="metrics-container">
           <nz-row [nzGutter]="16">
-            <!-- 用户数 -->
+            <!-- 日活用户数 -->
             <nz-col [nzXs]="24" [nzSm]="12" [nzMd]="8" [nzLg]="6">
               <nz-card nzHoverable>
                 <nz-statistic
-                  [nzTitle]="'总用户数'"
-                  [nzValue]="totalUsers"
+                  [nzTitle]="'日活用户(DAU)'"
+                  [nzValue]="activeUsersToday"
                   [nzPrefix]="userIcon"
                   [nzSuffix]="'人'"
                   [nzValueStyle]="{ color: '#1890ff' }"
@@ -76,91 +76,64 @@ import { NzMessageService } from 'ng-zorro-antd/message';
                   <span nz-icon nzType="user" nzTheme="outline"></span>
                 </ng-template>
                 <div class="compare">
-                  <span
-                    >较昨日
-                    <nz-tag [nzColor]="userChange >= 0 ? 'green' : 'red'">
-                      {{ userChange >= 0 ? '+' : '' }}{{ userChange }}%
-                    </nz-tag></span
-                  >
+                  <span>实时数据自动更新</span>
                 </div>
               </nz-card>
             </nz-col>
 
-            <!-- 今日活跃用户数 -->
+            <!-- 日均广告收益 -->
             <nz-col [nzXs]="24" [nzSm]="12" [nzMd]="8" [nzLg]="6">
               <nz-card nzHoverable>
                 <nz-statistic
-                  [nzTitle]="'今日活跃用户'"
-                  [nzValue]="activeUsersToday"
-                  [nzPrefix]="activeIcon"
-                  [nzSuffix]="''"
-                  [nzValueStyle]="{ color: '#52c41a' }"
+                  [nzTitle]="'日广告收益'"
+                  [nzValue]="dailyRevenue"
+                  [nzPrefix]="revenueIcon"
+                  [nzSuffix]="''"
+                  [nzValueStyle]="{ color: '#faad14' }"
                 ></nz-statistic>
-                <ng-template #activeIcon>
-                  <span nz-icon nzType="rise" nzTheme="outline"></span>
+                <ng-template #revenueIcon>
+                  <span nz-icon nzType="dollar" nzTheme="outline"></span>
                 </ng-template>
                 <div class="compare">
-                  <span
-                    >较昨日
-                    <nz-tag [nzColor]="activeChange >= 0 ? 'green' : 'red'">
-                      {{ activeChange >= 0 ? '+' : '' }}{{ activeChange }}%
-                    </nz-tag></span
-                  >
+                  <span>来自广告平台</span>
                 </div>
               </nz-card>
             </nz-col>
 
-            <!-- 今日填色总数 -->
+            <!-- DAU 趋势占位 -->
             <nz-col [nzXs]="24" [nzSm]="12" [nzMd]="8" [nzLg]="6">
               <nz-card nzHoverable>
                 <nz-statistic
-                  [nzTitle]="'今日填色总数'"
-                  [nzValue]="totalColoringsToday"
-                  [nzPrefix]="colorIcon"
-                  [nzSuffix]="''"
-                  [nzValueStyle]="{ color: '#722ed1' }"
+                  [nzTitle]="'7日平均DAU'"
+                  [nzValue]="avgDau7d"
+                  [nzPrefix]="trendIcon"
+                  [nzSuffix]="''"
+                  [nzValueStyle]="{ color: '#13c2c2' }"
                 ></nz-statistic>
-                <ng-template #colorIcon>
-                  <span nz-icon nzType="highlight" nzTheme="outline"></span>
+                <ng-template #trendIcon>
+                  <span nz-icon nzType="line-chart" nzTheme="outline"></span>
                 </ng-template>
                 <div class="compare">
-                  <span
-                    >较昨日
-                    <nz-tag [nzColor]="coloringChange >= 0 ? 'green' : 'red'">
-                      {{ coloringChange >= 0 ? '+' : '' }}{{ coloringChange }}%
-                    </nz-tag></span
-                  >
+                  <span>近 7 天平均值</span>
                 </div>
               </nz-card>
             </nz-col>
 
-            <!-- 今日填色完成数 -->
+            <!-- 收益趋势占位 -->
             <nz-col [nzXs]="24" [nzSm]="12" [nzMd]="8" [nzLg]="6">
               <nz-card nzHoverable>
                 <nz-statistic
-                  [nzTitle]="'今日填色完成数'"
-                  [nzValue]="completedColoringsToday"
-                  [nzPrefix]="completeIcon"
-                  [nzSuffix]="''"
-                  [nzValueStyle]="{ color: '#13c2c2' }"
+                  [nzTitle]="'7日收益总额'"
+                  [nzValue]="totalRevenue7d"
+                  [nzPrefix]="totalRevenueIcon"
+                  [nzSuffix]="''"
+                  [nzValueStyle]="{ color: '#eb2f96' }"
                 ></nz-statistic>
-                <ng-template #completeIcon>
-                  <span nz-icon nzType="check-circle" nzTheme="outline"></span>
+                <ng-template #totalRevenueIcon>
+                  <span nz-icon nzType="bar-chart" nzTheme="outline"></span>
                 </ng-template>
                 <div class="compare">
-                  <span>完成率 {{ completionRate }}%</span>
-                  <nz-progress
-                    [nzPercent]="completionRate"
-                    [nzStrokeColor]="
-                      completionRate > 70
-                        ? '#52c41a'
-                        : completionRate > 30
-                        ? '#faad14'
-                        : '#f5222d'
-                    "
-                    [nzShowInfo]="false"
-                    [nzStrokeWidth]="5"
-                  ></nz-progress>
+                  <span>近 7 天累计</span>
                 </div>
               </nz-card>
             </nz-col>
@@ -357,18 +330,23 @@ import { NzMessageService } from 'ng-zorro-antd/message';
 export class DashboardComponent implements OnInit {
   isLoading = true;
   lastUpdateTime = new Date();
-
-  // 核心指标数据
-  totalUsers = 0;
-  activeUsersToday = 0;
-  totalColoringsToday = 0;
-  completedColoringsToday = 0;
-  completionRate = 0;
-  userChange = 0;
-  activeChange = 0;
-  coloringChange = 0;
-
-  // 广告收益数据
+  Math = Math; // 暴露 Math 对象给模板使antml
+
+  // 核心指标数据(来自 KPI 接口)
+  activeUsersToday = 0; // 日活用户数
+  dailyRevenue = 0; // 当日广告收益
+  
+  // DAU 趋势数据
+  dauTrendLabels: string[] = [];
+  dauTrendData: number[] = [];
+  avgDau7d = 0;
+  
+  // 收益趋势数据
+  revenueTrendLabels: string[] = [];
+  revenueTrendData: number[] = [];
+  totalRevenue7d = 0;
+
+  // 广告收益数据(暂时保留,后续可删除)
   bannerRevenue = 0;
   interstitialRevenue = 0;
   rewardedRevenue = 0;
@@ -381,7 +359,8 @@ export class DashboardComponent implements OnInit {
 
   constructor(
     private modalService: NzModalService,
-    private message: NzMessageService
+    private message: NzMessageService,
+    private dashboardService: DashboardService
   ) {}
 
   ngOnInit(): void {
@@ -394,59 +373,80 @@ export class DashboardComponent implements OnInit {
   }
 
   loadDashboardData(): void {
-    // 模拟数据加载
-    setTimeout(() => {
-      // 核心指标数据
-      this.totalUsers = 12458;
-      this.activeUsersToday = 3842;
-      this.totalColoringsToday = 8921;
-      this.completedColoringsToday = 5236;
-      this.completionRate = Math.round(
-        (this.completedColoringsToday / this.totalColoringsToday) * 100
-      );
-      this.userChange = 5.2;
-      this.activeChange = 8.7;
-      this.coloringChange = 12.3;
-
-      // 广告收益数据
-      this.bannerRevenue = 856.42;
-      this.interstitialRevenue = 1243.75;
-      this.rewardedRevenue = 982.31;
-      this.bannerImpressions = 24578;
-      this.interstitialImpressions = 8765;
-      this.rewardedImpressions = 6543;
-
-      // 今日上新作品
-      this.newWorksToday = [
-        {
-          id: 1,
-          name: '可爱猫咪',
-          thumbnail: 'https://example.com/cat.jpg',
-          startedCount: 1245,
-          completedCount: 876,
-          completionRate: Math.round((876 / 1245) * 100),
-        },
-        {
-          id: 2,
-          name: '美丽风景',
-          thumbnail: 'https://example.com/scenery.jpg',
-          startedCount: 987,
-          completedCount: 654,
-          completionRate: Math.round((654 / 987) * 100),
-        },
-        {
-          id: 3,
-          name: '卡通人物',
-          thumbnail: 'https://example.com/cartoon.jpg',
-          startedCount: 765,
-          completedCount: 432,
-          completionRate: Math.round((432 / 765) * 100),
-        },
-      ];
-
-      this.lastUpdateTime = new Date();
-      this.isLoading = false;
-    }, 1500);
+    this.dashboardService.getKpi(7).subscribe({
+      next: (response) => {
+        if (response.success) {
+          const { dau, revenue } = response.data;
+          
+          // 更新 KPI 数据
+          this.activeUsersToday = dau.today;
+          this.dailyRevenue = revenue.today;
+          
+          // 更新 DAU 趋势
+          this.dauTrendLabels = dau.trend.map((item) => item.date);
+          this.dauTrendData = dau.trend.map((item) => item.dau);
+          const dauSum = this.dauTrendData.reduce((sum, value) => sum + value, 0);
+          this.avgDau7d = this.dauTrendData.length > 0 ? Math.round(dauSum / this.dauTrendData.length) : 0;
+          
+          // 更新收益趋势
+          this.revenueTrendLabels = revenue.trend.map((item) => item.date);
+          this.revenueTrendData = revenue.trend.map((item) => item.revenue);
+          const revenueSum = this.revenueTrendData.reduce((sum, value) => sum + value, 0);
+          this.totalRevenue7d = Math.round(revenueSum);
+          
+          // 模拟广告收益细分(后续可从其他接口获取)
+          this.bannerRevenue = revenue.today * 0.35;
+          this.interstitialRevenue = revenue.today * 0.40;
+          this.rewardedRevenue = revenue.today * 0.25;
+          this.bannerImpressions = Math.floor(Math.random() * 30000);
+          this.interstitialImpressions = Math.floor(Math.random() * 10000);
+          this.rewardedImpressions = Math.floor(Math.random() * 8000);
+          
+          // 模拟上新作品数据(后续可从接口获取)
+          this.newWorksToday = [
+            {
+              id: 1,
+              name: '作品 #1',
+              thumbnail: 'https://via.placeholder.com/60',
+              startedCount: Math.floor(Math.random() * 2000),
+              completedCount: Math.floor(Math.random() * 1000),
+            },
+            {
+              id: 2,
+              name: '作品 #2',
+              thumbnail: 'https://via.placeholder.com/60',
+              startedCount: Math.floor(Math.random() * 2000),
+              completedCount: Math.floor(Math.random() * 1000),
+            },
+            {
+              id: 3,
+              name: '作品 #3',
+              thumbnail: 'https://via.placeholder.com/60',
+              startedCount: Math.floor(Math.random() * 2000),
+              completedCount: Math.floor(Math.random() * 1000),
+            },
+          ];
+          
+          this.newWorksToday = this.newWorksToday.map((work) => ({
+            ...work,
+            completionRate: work.startedCount > 0 
+              ? Math.round((work.completedCount / work.startedCount) * 100)
+              : 0,
+          }));
+          
+          this.lastUpdateTime = new Date();
+          this.isLoading = false;
+        } else {
+          this.message.error('获取 KPI 数据失败');
+          this.isLoading = false;
+        }
+      },
+      error: (error) => {
+        console.error('Failed to load KPI data:', error);
+        this.message.error('加载数据出错,请重试');
+        this.isLoading = false;
+      },
+    });
   }
 
   showWorkDetails(work: any): void {

+ 81 - 0
omsapp/src/app/services/dashboard.service.ts

@@ -0,0 +1,81 @@
+import { Injectable } from '@angular/core';
+import { HttpClient, HttpParams } from '@angular/common/http';
+import { Observable } from 'rxjs';
+
+export interface DauTrendItem {
+  date: string;
+  dau: number;
+}
+
+export interface RevenueTrendItem {
+  date: string;
+  revenue: number;
+}
+
+export interface KpiData {
+  dau: {
+    today: number;
+    trend: DauTrendItem[];
+  };
+  revenue: {
+    today: number;
+    trend: RevenueTrendItem[];
+  };
+}
+
+export interface ArtworkStat {
+  resId: string;
+  name: string;
+  coverImage: string | null;
+  clickCount: number;
+  clickRate: number;
+  completionCount: number;
+  completionRate: number;
+}
+
+export interface ArtworkStatsResponse {
+  dau_today: number;
+  artworks: ArtworkStat[];
+}
+
+export interface ApiResponse<T> {
+  success: boolean;
+  data: T;
+  error?: string;
+}
+
+@Injectable({
+  providedIn: 'root',
+})
+export class DashboardService {
+  constructor(private http: HttpClient) {}
+
+  /**
+   * 获取 KPI 数据(日活用户、日活曲线、广告收益、收益曲线)
+   * @param dateRange 显示过去 N 天的数据,默认 7 天
+   * @returns KPI 数据
+   */
+  getKpi(dateRange: number = 7): Observable<ApiResponse<KpiData>> {
+    let params = new HttpParams();
+    if (dateRange) {
+      params = params.set('dateRange', dateRange.toString());
+    }
+    return this.http.get<ApiResponse<KpiData>>('/api/dashboard/kpi', { params });
+  }
+
+  /**
+   * 获取作品统计数据(点击率、完成率排行)
+   * @param top 返回 Top N 作品数量,默认 10
+   * @param sortBy 排序字段,默认 clickCount
+   * @returns 作品统计数据
+   */
+  getArtworkStats(
+    top: number = 10,
+    sortBy: 'clickRate' | 'completionRate' | 'clickCount' | 'completionCount' = 'clickCount'
+  ): Observable<ApiResponse<ArtworkStatsResponse>> {
+    let params = new HttpParams()
+      .set('top', top.toString())
+      .set('sortBy', sortBy);
+    return this.http.get<ApiResponse<ArtworkStatsResponse>>('/api/dashboard/artwork-stats', { params });
+  }
+}

Vissa filer visades inte eftersom för många filer har ändrats