Kaynağa Gözat

Merge branch 'main' of http://gitlab.pubdata.cn/hlm/multi-platform-media-manage

Ethanfly 1 gün önce
ebeveyn
işleme
605ca0b7c6

+ 3 - 0
client/src/components.d.ts

@@ -18,6 +18,8 @@ declare module 'vue' {
     ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
     ElContainer: typeof import('element-plus/es')['ElContainer']
     ElDialog: typeof import('element-plus/es')['ElDialog']
+    ElDivider: typeof import('element-plus/es')['ElDivider']
+    ElDrawer: typeof import('element-plus/es')['ElDrawer']
     ElDropdown: typeof import('element-plus/es')['ElDropdown']
     ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
     ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
@@ -30,6 +32,7 @@ declare module 'vue' {
     ElMenu: typeof import('element-plus/es')['ElMenu']
     ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
     ElOption: typeof import('element-plus/es')['ElOption']
+    ElPagination: typeof import('element-plus/es')['ElPagination']
     ElProgress: typeof import('element-plus/es')['ElProgress']
     ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
     ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']

+ 39 - 35
client/src/views/Analytics/Overview/index.vue

@@ -79,22 +79,22 @@
         </el-table-column>
         <el-table-column prop="totalIncome" label="总收益" width="90" align="center">
           <template #default="{ row }">
-            <span>{{ row.totalIncome || '未支持' }}</span>
+            <span>{{ row.totalIncome !== null && row.totalIncome !== undefined ? row.totalIncome.toFixed(2) : '未支持' }}</span>
           </template>
         </el-table-column>
         <el-table-column prop="yesterdayIncome" label="昨日收益" width="90" align="center">
           <template #default="{ row }">
-            <span>{{ row.yesterdayIncome || '未支持' }}</span>
+            <span>{{ row.yesterdayIncome !== null && row.yesterdayIncome !== undefined ? row.yesterdayIncome.toFixed(2) : '未支持' }}</span>
           </template>
         </el-table-column>
         <el-table-column prop="totalViews" label="总播放(阅读)" width="110" align="center">
           <template #default="{ row }">
-            <span>{{ row.totalViews || '未支持' }}</span>
+            <span>{{ row.totalViews !== null && row.totalViews !== undefined ? formatNumber(row.totalViews) : '未支持' }}</span>
           </template>
         </el-table-column>
         <el-table-column prop="yesterdayViews" label="昨日播放(阅读)" width="120" align="center">
           <template #default="{ row }">
-            <span>{{ row.yesterdayViews || '未支持' }}</span>
+            <span>{{ row.yesterdayViews !== null && row.yesterdayViews !== undefined ? formatNumber(row.yesterdayViews) : '未支持' }}</span>
           </template>
         </el-table-column>
         <el-table-column prop="fansCount" label="粉丝数" width="90" align="center">
@@ -109,7 +109,7 @@
         </el-table-column>
         <el-table-column prop="yesterdayLikes" label="昨日点赞" width="90" align="center">
           <template #default="{ row }">
-            <span>{{ row.yesterdayLikes ?? 0 }}</span>
+            <span>{{ formatNumber(row.yesterdayLikes ?? 0) }}</span>
           </template>
         </el-table-column>
         <el-table-column prop="yesterdayFansIncrease" label="昨日涨粉" width="90" align="center">
@@ -233,35 +233,17 @@ const summaryData = ref<SummaryData>({
   yesterdayFansIncrease: 0,
 });
 
-// 基于过滤后账号列表的汇总统计(只统计抖音、百家号、视频号和小红书)
-const filteredSummaryData = computed(() => {
-  const allowedPlatforms: PlatformType[] = ['douyin', 'baijiahao', 'weixin_video', 'xiaohongshu'];
-  const filtered = accounts.value.filter(a => allowedPlatforms.includes(a.platform));
-  
-  return {
-    totalAccounts: filtered.length,
-    totalIncome: filtered.reduce((sum, a) => sum + (a.totalIncome || 0), 0),
-    yesterdayIncome: filtered.reduce((sum, a) => sum + (a.yesterdayIncome || 0), 0),
-    totalViews: filtered.reduce((sum, a) => sum + (a.totalViews || 0), 0),
-    yesterdayViews: filtered.reduce((sum, a) => sum + (a.yesterdayViews || 0), 0),
-    totalFans: filtered.reduce((sum, a) => sum + (a.fansCount || 0), 0),
-    yesterdayComments: filtered.reduce((sum, a) => sum + (a.yesterdayComments || 0), 0),
-    yesterdayLikes: filtered.reduce((sum, a) => sum + (a.yesterdayLikes || 0), 0),
-    yesterdayFansIncrease: filtered.reduce((sum, a) => sum + (a.yesterdayFansIncrease || 0), 0),
-  };
-});
-
-// 统计卡片数据(使用过滤后的汇总数据)
+// 统计卡片数据(使用后端返回的汇总数据)
 const summaryStats = computed(() => [
-  { label: '账号总数', value: filteredSummaryData.value.totalAccounts },
-  { label: '总收益(元)', value: filteredSummaryData.value.totalIncome },
-  { label: '昨日收益(元)', value: filteredSummaryData.value.yesterdayIncome },
-  { label: '总播放(阅读)', value: filteredSummaryData.value.totalViews },
-  { label: '昨日播放(阅读)', value: filteredSummaryData.value.yesterdayViews },
-  { label: '总粉丝', value: filteredSummaryData.value.totalFans, highlight: true },
-  { label: '昨日评论', value: filteredSummaryData.value.yesterdayComments },
-  { label: '昨日点赞', value: filteredSummaryData.value.yesterdayLikes },
-  { label: '昨日涨粉', value: filteredSummaryData.value.yesterdayFansIncrease },
+  { label: '账号总数', value: summaryData.value.totalAccounts },
+  { label: '总收益(元)', value: summaryData.value.totalIncome || 0 },
+  { label: '昨日收益(元)', value: summaryData.value.yesterdayIncome || 0 },
+  { label: '总播放(阅读)', value: formatNumber(summaryData.value.totalViews || 0) },
+  { label: '昨日播放(阅读)', value: formatNumber(summaryData.value.yesterdayViews || 0) },
+  { label: '总粉丝', value: formatNumber(summaryData.value.totalFans || 0), highlight: true },
+  { label: '昨日评论', value: summaryData.value.yesterdayComments || 0 },
+  { label: '昨日点赞', value: formatNumber(summaryData.value.yesterdayLikes || 0) },
+  { label: '昨日涨粉', value: summaryData.value.yesterdayFansIncrease || 0 },
 ]);
 
 // 过滤后的账号列表(只显示抖音、百家号、视频号和小红书)
@@ -341,11 +323,33 @@ async function loadData() {
     const result = await response.json();
     
     if (result.success && result.data) {
-      accounts.value = result.data.accounts || [];
-      summaryData.value = result.data.summary || summaryData.value;
+      // 确保只保留支持的平台
+      const allowedPlatforms: PlatformType[] = ['douyin', 'baijiahao', 'weixin_video', 'xiaohongshu'];
+      accounts.value = (result.data.accounts || []).filter((a: AccountData) => 
+        allowedPlatforms.includes(a.platform)
+      );
+      
+      // 使用后端返回的汇总数据
+      if (result.data.summary) {
+        summaryData.value = {
+          totalAccounts: result.data.summary.totalAccounts || 0,
+          totalIncome: result.data.summary.totalIncome || 0,
+          yesterdayIncome: result.data.summary.yesterdayIncome || 0,
+          totalViews: result.data.summary.totalViews || 0,
+          yesterdayViews: result.data.summary.yesterdayViews || 0,
+          totalFans: result.data.summary.totalFans || 0,
+          yesterdayComments: result.data.summary.yesterdayComments || 0,
+          yesterdayLikes: result.data.summary.yesterdayLikes || 0,
+          yesterdayFansIncrease: result.data.summary.yesterdayFansIncrease || 0,
+        };
+      }
+    } else {
+      console.error('加载数据失败:', result.error || '未知错误');
+      ElMessage.error(result.error || '加载数据失败');
     }
   } catch (error) {
     console.error('加载数据失败:', error);
+    ElMessage.error('加载数据失败,请稍后重试');
   } finally {
     loading.value = false;
   }

+ 15 - 1
database/schema.sql

@@ -189,7 +189,6 @@ CREATE TABLE IF NOT EXISTS work_day_statistics (
     id INT PRIMARY KEY AUTO_INCREMENT,
     work_id INT NOT NULL,
     record_date DATE NOT NULL,
-    fans_count INT DEFAULT 0 COMMENT '粉丝数(来自账号)',
     play_count INT DEFAULT 0 COMMENT '播放数',
     like_count INT DEFAULT 0 COMMENT '点赞数',
     comment_count INT DEFAULT 0 COMMENT '评论数',
@@ -201,3 +200,18 @@ CREATE TABLE IF NOT EXISTS work_day_statistics (
     INDEX idx_work_id (work_id),
     INDEX idx_record_date (record_date)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品每日统计数据';
+
+-- 账号每日统计表(记录每个平台账号的粉丝数和作品数,不关联作品)
+CREATE TABLE IF NOT EXISTS user_day_statistics (
+    id INT PRIMARY KEY AUTO_INCREMENT,
+    account_id INT NOT NULL COMMENT '账号ID(关联platform_accounts.id)',
+    record_date DATE NOT NULL COMMENT '记录日期',
+    fans_count INT DEFAULT 0 COMMENT '粉丝数',
+    works_count INT DEFAULT 0 COMMENT '作品数',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_account_date (account_id, record_date),
+    INDEX idx_account_id (account_id),
+    INDEX idx_record_date (record_date),
+    FOREIGN KEY (account_id) REFERENCES platform_accounts(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账号每日统计数据(按平台账号统计)';

BIN
server/python/__pycache__/app.cpython-311.pyc


+ 114 - 4
server/python/app.py

@@ -160,6 +160,15 @@ INTERNAL_API_KEY = os.environ.get('INTERNAL_API_KEY', 'internal-api-key-default'
 print(f"[API Config] Node.js API: {NODEJS_API_BASE_URL}", flush=True)
 
 
+class NodeApiError(Exception):
+    """用于把 Node 内部接口的错误状态码/内容透传给调用方。"""
+
+    def __init__(self, status_code: int, payload: dict):
+        super().__init__(payload.get("error") or payload.get("message") or "Node API error")
+        self.status_code = status_code
+        self.payload = payload
+
+
 def call_nodejs_api(method: str, endpoint: str, data: dict = None, params: dict = None) -> dict:
     """调用 Node.js 内部 API"""
     url = f"{NODEJS_API_BASE_URL}/api/internal{endpoint}"
@@ -175,12 +184,47 @@ def call_nodejs_api(method: str, endpoint: str, data: dict = None, params: dict
             response = requests.post(url, headers=headers, json=data, timeout=30)
         else:
             raise ValueError(f"Unsupported HTTP method: {method}")
-        
-        response.raise_for_status()
-        return response.json()
+
+        # 兼容 Node 可能返回非 JSON 的情况
+        try:
+            payload = response.json()
+        except Exception:
+            payload = {
+                "success": False,
+                "error": "Node.js API 返回非 JSON 响应",
+                "status": response.status_code,
+                "text": (response.text or "")[:2000],
+                "url": url,
+                "endpoint": endpoint,
+            }
+
+        if response.status_code >= 400:
+            # 把真实状态码/返回体抛出去,由路由决定如何返回给前端
+            if isinstance(payload, dict):
+                payload.setdefault("success", False)
+                payload.setdefault("status", response.status_code)
+                payload.setdefault("url", url)
+                payload.setdefault("endpoint", endpoint)
+            raise NodeApiError(response.status_code, payload if isinstance(payload, dict) else {
+                "success": False,
+                "error": "Node.js API 调用失败",
+                "status": response.status_code,
+                "data": payload,
+                "url": url,
+                "endpoint": endpoint,
+            })
+
+        return payload
     except requests.exceptions.RequestException as e:
+        # 连接失败/超时等(此时通常拿不到 response)
         print(f"[API Error] 调用 Node.js API 失败: {e}", flush=True)
-        raise
+        raise NodeApiError(502, {
+            "success": False,
+            "error": f"无法连接 Node.js API: {str(e)}",
+            "status": 502,
+            "url": url,
+            "endpoint": endpoint,
+        })
 
 
 # ==================== 签名相关(小红书专用) ====================
@@ -879,6 +923,72 @@ def get_work_statistics_history():
         return jsonify({"success": False, "error": str(e)}), 500
 
 
+@app.route("/work_day_statistics/overview", methods=["GET"])
+def get_overview():
+    """
+    获取数据总览(账号列表和汇总统计)
+    
+    查询参数:
+        user_id: 用户ID (必填)
+    
+    响应:
+    {
+        "success": true,
+        "data": {
+            "accounts": [
+                {
+                    "id": 1,
+                    "nickname": "账号名称",
+                    "username": "账号ID",
+                    "avatarUrl": "头像URL",
+                    "platform": "douyin",
+                    "groupId": 1,
+                    "fansCount": 1000,
+                    "totalIncome": null,
+                    "yesterdayIncome": null,
+                    "totalViews": 5000,
+                    "yesterdayViews": 100,
+                    "yesterdayComments": 10,
+                    "yesterdayLikes": 50,
+                    "yesterdayFansIncrease": 5,
+                    "updateTime": "2025-01-26T10:00:00Z",
+                    "status": "active"
+                },
+                ...
+            ],
+            "summary": {
+                "totalAccounts": 5,
+                "totalIncome": 0,
+                "yesterdayIncome": 0,
+                "totalViews": 10000,
+                "yesterdayViews": 200,
+                "totalFans": 5000,
+                "yesterdayComments": 20,
+                "yesterdayLikes": 100,
+                "yesterdayFansIncrease": 10
+            }
+        }
+    }
+    """
+    try:
+        user_id = request.args.get("user_id")
+        
+        if not user_id:
+            return jsonify({"success": False, "error": "缺少 user_id 参数"}), 400
+        
+        # 调用 Node.js API 获取数据
+        params = {"user_id": user_id}
+        result = call_nodejs_api('GET', '/work-day-statistics/overview', params=params)
+        
+        return jsonify(result)
+    except NodeApiError as e:
+        # 透传 Node 的真实状态码/错误内容,避免所有错误都变成 500
+        return jsonify(e.payload), e.status_code
+    except Exception as e:
+        traceback.print_exc()
+        return jsonify({"success": False, "error": str(e)}), 500
+
+
 # ==================== 获取评论列表接口 ====================
 
 @app.route("/comments", methods=["POST"])

BIN
server/python/platforms/__pycache__/baijiahao.cpython-311.pyc


BIN
server/python/platforms/__pycache__/base.cpython-311.pyc


BIN
server/python/platforms/__pycache__/douyin.cpython-311.pyc


BIN
server/python/platforms/__pycache__/kuaishou.cpython-311.pyc


BIN
server/python/platforms/__pycache__/weixin.cpython-311.pyc


BIN
server/python/platforms/__pycache__/xiaohongshu.cpython-311.pyc


+ 26 - 0
server/src/models/entities/UserDayStatistics.ts

@@ -0,0 +1,26 @@
+import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
+
+@Entity('user_day_statistics')
+@Index(['accountId', 'recordDate'], { unique: true })
+export class UserDayStatistics {
+  @PrimaryGeneratedColumn()
+  id!: number;
+
+  @Column({ name: 'account_id', type: 'int' })
+  accountId!: number;
+
+  @Column({ name: 'record_date', type: 'date' })
+  recordDate!: Date;
+
+  @Column({ name: 'fans_count', type: 'int', default: 0, comment: '粉丝数' })
+  fansCount!: number;
+
+  @Column({ name: 'works_count', type: 'int', default: 0, comment: '作品数' })
+  worksCount!: number;
+
+  @CreateDateColumn({ name: 'created_at' })
+  createdAt!: Date;
+
+  @UpdateDateColumn({ name: 'updated_at' })
+  updatedAt!: Date;
+}

+ 0 - 3
server/src/models/entities/WorkDayStatistics.ts

@@ -12,9 +12,6 @@ export class WorkDayStatistics {
   @Column({ name: 'record_date', type: 'date' })
   recordDate!: Date;
 
-  @Column({ name: 'fans_count', type: 'int', default: 0, comment: '粉丝数(来自账号)' })
-  fansCount!: number;
-
   @Column({ name: 'play_count', type: 'int', default: 0, comment: '播放数' })
   playCount!: number;
 

+ 3 - 0
server/src/models/index.ts

@@ -12,6 +12,7 @@ import { AnalyticsData } from './entities/AnalyticsData.js';
 import { OperationLog } from './entities/OperationLog.js';
 import { Work } from './entities/Work.js';
 import { WorkDayStatistics } from './entities/WorkDayStatistics.js';
+import { UserDayStatistics } from './entities/UserDayStatistics.js';
 
 export const AppDataSource = new DataSource({
   type: 'mysql',
@@ -35,6 +36,7 @@ export const AppDataSource = new DataSource({
     OperationLog,
     Work,
     WorkDayStatistics,
+    UserDayStatistics,
   ],
   charset: 'utf8mb4',
 });
@@ -59,4 +61,5 @@ export {
   OperationLog,
   Work,
   WorkDayStatistics,
+  UserDayStatistics,
 };

+ 21 - 0
server/src/routes/internal.ts

@@ -153,4 +153,25 @@ router.post(
   })
 );
 
+/**
+ * GET /api/internal/work-day-statistics/overview
+ * 获取数据总览(账号列表和汇总统计)
+ */
+router.get(
+  '/work-day-statistics/overview',
+  [
+    query('user_id').notEmpty().withMessage('user_id 不能为空'),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    const userId = parseInt(req.query.user_id as string);
+    const data = await workDayStatisticsService.getOverview(userId);
+
+    res.json({
+      success: true,
+      data,
+    });
+  })
+);
+
 export default router;

+ 18 - 0
server/src/services/AccountService.ts

@@ -17,6 +17,7 @@ import { CookieManager } from '../automation/cookie.js';
 import { logger } from '../utils/logger.js';
 import { headlessBrowserService } from './HeadlessBrowserService.js';
 import { aiService } from '../ai/index.js';
+import { UserDayStatisticsService } from './UserDayStatisticsService.js';
 
 interface GetAccountsParams {
   platform?: string;
@@ -488,6 +489,23 @@ export class AccountService {
 
     const updated = await this.accountRepository.findOne({ where: { id: accountId } });
 
+    // 保存账号每日统计数据(粉丝数、作品数)
+    // 无论是否更新了粉丝数/作品数,都要保存当前值到统计表,确保每天都有记录
+    if (updated) {
+      try {
+        const userDayStatisticsService = new UserDayStatisticsService();
+        await userDayStatisticsService.saveStatistics({
+          accountId,
+          fansCount: updated.fansCount || 0,
+          worksCount: updated.worksCount || 0,
+        });
+        logger.debug(`[AccountService] Saved account day statistics for account ${accountId} (fans: ${updated.fansCount || 0}, works: ${updated.worksCount || 0})`);
+      } catch (error) {
+        logger.error(`[AccountService] Failed to save account day statistics for account ${accountId}:`, error);
+        // 不抛出错误,不影响主流程
+      }
+    }
+
     // 通知其他客户端
     wsManager.sendToUser(userId, WS_EVENTS.ACCOUNT_UPDATED, { account: this.formatAccount(updated!) });
 

+ 107 - 0
server/src/services/UserDayStatisticsService.ts

@@ -0,0 +1,107 @@
+import { AppDataSource, UserDayStatistics } from '../models/index.js';
+import { logger } from '../utils/logger.js';
+
+export interface UserDayStatisticsItem {
+  accountId: number;
+  fansCount: number;
+  worksCount: number;
+}
+
+export interface SaveResult {
+  inserted: number;
+  updated: number;
+}
+
+export class UserDayStatisticsService {
+  private statisticsRepository = AppDataSource.getRepository(UserDayStatistics);
+
+  /**
+   * 保存用户每日统计数据
+   * 同日更新,隔日新增
+   */
+  async saveStatistics(item: UserDayStatisticsItem): Promise<SaveResult> {
+    // 使用中国时区(UTC+8)计算今天的业务日期
+    const now = new Date();
+    const chinaNow = new Date(now.getTime() + 8 * 60 * 60 * 1000);
+    const today = new Date(chinaNow);
+    today.setHours(0, 0, 0, 0);
+
+    // 检查今天是否已有记录
+    const existing = await this.statisticsRepository.findOne({
+      where: {
+        accountId: item.accountId,
+        recordDate: today,
+      },
+    });
+
+    if (existing) {
+      // 更新已有记录
+      await this.statisticsRepository.update(existing.id, {
+        fansCount: item.fansCount ?? existing.fansCount,
+        worksCount: item.worksCount ?? existing.worksCount,
+      });
+      logger.debug(`[UserDayStatistics] Updated record for account ${item.accountId}, date: ${today.toISOString().split('T')[0]}`);
+      return { inserted: 0, updated: 1 };
+    } else {
+      // 插入新记录
+      const newStat = this.statisticsRepository.create({
+        accountId: item.accountId,
+        recordDate: today,
+        fansCount: item.fansCount ?? 0,
+        worksCount: item.worksCount ?? 0,
+      });
+      await this.statisticsRepository.save(newStat);
+      logger.debug(`[UserDayStatistics] Inserted new record for account ${item.accountId}, date: ${today.toISOString().split('T')[0]}`);
+      return { inserted: 1, updated: 0 };
+    }
+  }
+
+  /**
+   * 批量保存用户每日统计数据
+   */
+  async saveStatisticsBatch(items: UserDayStatisticsItem[]): Promise<SaveResult> {
+    let insertedCount = 0;
+    let updatedCount = 0;
+
+    for (const item of items) {
+      const result = await this.saveStatistics(item);
+      insertedCount += result.inserted;
+      updatedCount += result.updated;
+    }
+
+    logger.info(`[UserDayStatistics] Batch save completed: inserted=${insertedCount}, updated=${updatedCount}`);
+    return { inserted: insertedCount, updated: updatedCount };
+  }
+
+  /**
+   * 获取账号指定日期的统计数据
+   */
+  async getStatisticsByDate(
+    accountId: number,
+    date: Date
+  ): Promise<UserDayStatistics | null> {
+    return await this.statisticsRepository.findOne({
+      where: {
+        accountId,
+        recordDate: date,
+      },
+    });
+  }
+
+  /**
+   * 获取账号指定日期范围的统计数据
+   */
+  async getStatisticsByDateRange(
+    accountId: number,
+    startDate: Date,
+    endDate: Date
+  ): Promise<UserDayStatistics[]> {
+    return await this.statisticsRepository
+      .createQueryBuilder('uds')
+      .where('uds.account_id = :accountId', { accountId })
+      .andWhere('uds.record_date >= :startDate', { startDate })
+      .andWhere('uds.record_date <= :endDate', { endDate })
+      .orderBy('uds.record_date', 'ASC')
+      .getMany();
+  }
+}

+ 406 - 17
server/src/services/WorkDayStatisticsService.ts

@@ -1,9 +1,9 @@
-import { AppDataSource, WorkDayStatistics, Work, PlatformAccount } from '../models/index.js';
+import { AppDataSource, WorkDayStatistics, Work, PlatformAccount, UserDayStatistics } from '../models/index.js';
 import { Between, In } from 'typeorm';
+import { logger } from '../utils/logger.js';
 
 interface StatisticsItem {
   workId: number;
-  fansCount?: number;
   playCount?: number;
   likeCount?: number;
   commentCount?: number;
@@ -38,7 +38,6 @@ interface PlatformStatItem {
 
 interface WorkStatisticsItem {
   recordDate: string;
-  fansCount: number;
   playCount: number;
   likeCount: number;
   commentCount: number;
@@ -50,6 +49,7 @@ export class WorkDayStatisticsService {
   private statisticsRepository = AppDataSource.getRepository(WorkDayStatistics);
   private workRepository = AppDataSource.getRepository(Work);
   private accountRepository = AppDataSource.getRepository(PlatformAccount);
+  private userDayStatisticsRepository = AppDataSource.getRepository(UserDayStatistics);
 
   /**
    * 保存作品日统计数据
@@ -74,9 +74,8 @@ export class WorkDayStatisticsService {
       });
 
       if (existing) {
-        // 更新已有记录
+        // 更新已有记录(不再包含粉丝数,粉丝数从 user_day_statistics 表获取)
         await this.statisticsRepository.update(existing.id, {
-          fansCount: stat.fansCount ?? existing.fansCount,
           playCount: stat.playCount ?? existing.playCount,
           likeCount: stat.likeCount ?? existing.likeCount,
           commentCount: stat.commentCount ?? existing.commentCount,
@@ -85,11 +84,10 @@ export class WorkDayStatisticsService {
         });
         updatedCount++;
       } else {
-        // 插入新记录
+        // 插入新记录(不再包含粉丝数,粉丝数从 user_day_statistics 表获取)
         const newStat = this.statisticsRepository.create({
           workId: stat.workId,
           recordDate: today,
-          fansCount: stat.fansCount ?? 0,
           playCount: stat.playCount ?? 0,
           likeCount: stat.likeCount ?? 0,
           commentCount: stat.commentCount ?? 0,
@@ -131,13 +129,12 @@ export class WorkDayStatisticsService {
       dateStart.setDate(dateStart.getDate() - Math.min(days, 30) + 1);
     }
 
-    // 构建查询
+    // 构建查询(不再从 work_day_statistics 读取粉丝数,粉丝数从 user_day_statistics 表获取)
     const queryBuilder = this.statisticsRepository
       .createQueryBuilder('wds')
       .innerJoin(Work, 'w', 'wds.work_id = w.id')
       .select('wds.record_date', 'recordDate')
       .addSelect('w.accountId', 'accountId')
-      .addSelect('MAX(wds.fans_count)', 'accountFans')
       .addSelect('SUM(wds.play_count)', 'accountViews')
       .addSelect('SUM(wds.like_count)', 'accountLikes')
       .addSelect('SUM(wds.comment_count)', 'accountComments')
@@ -269,8 +266,7 @@ export class WorkDayStatisticsService {
       const firstDayQuery = this.statisticsRepository
         .createQueryBuilder('wds')
         .innerJoin(Work, 'w', 'wds.work_id = w.id')
-        .select('MAX(wds.fans_count)', 'fans')
-        .addSelect('SUM(wds.play_count)', 'views')
+        .select('SUM(wds.play_count)', 'views')
         .addSelect('SUM(wds.like_count)', 'likes')
         .addSelect('SUM(wds.comment_count)', 'comments')
         .addSelect('SUM(wds.collect_count)', 'collects')
@@ -284,8 +280,7 @@ export class WorkDayStatisticsService {
       const lastDayQuery = this.statisticsRepository
         .createQueryBuilder('wds')
         .innerJoin(Work, 'w', 'wds.work_id = w.id')
-        .select('MAX(wds.fans_count)', 'fans')
-        .addSelect('SUM(wds.play_count)', 'views')
+        .select('SUM(wds.play_count)', 'views')
         .addSelect('SUM(wds.like_count)', 'likes')
         .addSelect('SUM(wds.comment_count)', 'comments')
         .addSelect('SUM(wds.collect_count)', 'collects')
@@ -301,8 +296,26 @@ export class WorkDayStatisticsService {
         lastDayQuery.getRawOne(),
       ]);
 
-      const currentFans = account.fansCount ?? 0;
-      const earliestFans = parseInt(firstDay?.fans) || currentFans;
+      // 从 user_day_statistics 表获取粉丝数
+      const todayDate = new Date();
+      todayDate.setHours(0, 0, 0, 0);
+      const todayUserStat = await this.userDayStatisticsRepository.findOne({
+        where: {
+          accountId: account.id,
+          recordDate: todayDate,
+        },
+      });
+      const currentFans = todayUserStat?.fansCount ?? account.fansCount ?? 0;
+      
+      // 获取最早日期的粉丝数
+      const earliestUserStat = await this.userDayStatisticsRepository
+        .createQueryBuilder('uds')
+        .where('uds.account_id = :accountId', { accountId: account.id })
+        .andWhere('uds.record_date >= :dateStart', { dateStart })
+        .andWhere('uds.record_date <= :dateEnd', { dateEnd })
+        .orderBy('uds.record_date', 'ASC')
+        .getOne();
+      const earliestFans = earliestUserStat?.fansCount ?? currentFans;
       const fansIncrease = currentFans - earliestFans;
 
       const viewsIncrease = (parseInt(lastDay?.views) || 0) - (parseInt(firstDay?.views) || 0);
@@ -343,7 +356,6 @@ export class WorkDayStatisticsService {
       .createQueryBuilder('wds')
       .select('wds.work_id', 'workId')
       .addSelect('wds.record_date', 'recordDate')
-      .addSelect('wds.fans_count', 'fansCount')
       .addSelect('wds.play_count', 'playCount')
       .addSelect('wds.like_count', 'likeCount')
       .addSelect('wds.comment_count', 'commentCount')
@@ -377,7 +389,6 @@ export class WorkDayStatisticsService {
 
       groupedData[workId].push({
         recordDate,
-        fansCount: parseInt(row.fansCount) || 0,
         playCount: parseInt(row.playCount) || 0,
         likeCount: parseInt(row.likeCount) || 0,
         commentCount: parseInt(row.commentCount) || 0,
@@ -388,4 +399,382 @@ export class WorkDayStatisticsService {
 
     return groupedData;
   }
+
+  /**
+   * 获取数据总览
+   * 返回账号列表和汇总统计数据
+   */
+  async getOverview(userId: number): Promise<{
+    accounts: Array<{
+      id: number;
+      nickname: string;
+      username: string;
+      avatarUrl: string | null;
+      platform: string;
+      groupId: number | null;
+      fansCount: number;
+      totalIncome: number | null;
+      yesterdayIncome: number | null;
+      totalViews: number | null;
+      yesterdayViews: number | null;
+      yesterdayComments: number;
+      yesterdayLikes: number;
+      yesterdayFansIncrease: number;
+      updateTime: string;
+      status: string;
+    }>;
+    summary: {
+      totalAccounts: number;
+      totalIncome: number;
+      yesterdayIncome: number;
+      totalViews: number;
+      yesterdayViews: number;
+      totalFans: number;
+      yesterdayComments: number;
+      yesterdayLikes: number;
+      yesterdayFansIncrease: number;
+    };
+  }> {
+    // 只查询支持的平台:抖音、百家号、视频号、小红书
+    const allowedPlatforms = ['douyin', 'baijiahao', 'weixin_video', 'xiaohongshu'];
+    
+    // 获取用户的所有账号(只包含支持的平台)
+    const accounts = await this.accountRepository.find({
+      where: {
+        userId,
+        platform: In(allowedPlatforms),
+      },
+    });
+
+    // 使用中国时区(UTC+8)计算“今天/昨天”的业务日期
+    // 思路:在当前 UTC 时间基础上 +8 小时,再取 ISO 日期部分,即为中国日历日期
+    const now = new Date();
+    const chinaNow = new Date(now.getTime() + 8 * 60 * 60 * 1000);
+    const chinaYesterday = new Date(chinaNow.getTime() - 24 * 60 * 60 * 1000);
+
+    // 格式化为 YYYY-MM-DD,与 MySQL DATE 字段匹配
+    const todayStr = chinaNow.toISOString().split('T')[0];
+    const yesterdayStr = chinaYesterday.toISOString().split('T')[0];
+    
+    logger.info(`[WorkDayStatistics] getOverview - userId: ${userId}, today: ${todayStr}, yesterday: ${yesterdayStr}`);
+
+    const accountList: Array<{
+      id: number;
+      nickname: string;
+      username: string;
+      avatarUrl: string | null;
+      platform: string;
+      groupId: number | null;
+      fansCount: number;
+      totalIncome: number | null;
+      yesterdayIncome: number | null;
+      totalViews: number | null;
+      yesterdayViews: number | null;
+      yesterdayComments: number;
+      yesterdayLikes: number;
+      yesterdayFansIncrease: number;
+      updateTime: string;
+      status: string;
+    }> = [];
+
+    // 汇总统计数据
+    let totalAccounts = 0;
+    let totalIncome = 0;
+    let yesterdayIncome = 0;
+    let totalViews = 0;
+    let yesterdayViews = 0;
+    let totalFans = 0;
+    let yesterdayComments = 0;
+    let yesterdayLikes = 0;
+    let yesterdayFansIncrease = 0;
+
+    for (const account of accounts) {
+      // 获取该账号的所有作品ID
+      const works = await this.workRepository.find({
+        where: { accountId: account.id },
+        select: ['id'],
+      });
+
+      if (works.length === 0) {
+        // 如果没有作品,只返回账号基本信息
+        // 从 user_day_statistics 表获取账号的最新粉丝数
+        // 使用日期字符串直接查询(更可靠,避免时区问题)
+        const todayUserStat = await this.userDayStatisticsRepository
+          .createQueryBuilder('uds')
+          .where('uds.account_id = :accountId', { accountId: account.id })
+          .andWhere('DATE(uds.record_date) = :today', { today: todayStr })
+          .getOne();
+        
+        const yesterdayUserStat = await this.userDayStatisticsRepository
+          .createQueryBuilder('uds')
+          .where('uds.account_id = :accountId', { accountId: account.id })
+          .andWhere('DATE(uds.record_date) = :yesterday', { yesterday: yesterdayStr })
+          .getOne();
+        
+        // 获取今天的粉丝数:优先使用统计数据,如果没有则使用账号表的当前值
+        let accountFansCount: number;
+        if (todayUserStat) {
+          accountFansCount = todayUserStat.fansCount || 0;
+        } else {
+          accountFansCount = account.fansCount || 0;
+        }
+        
+        // 计算昨日涨粉(今天粉丝数 - 昨天粉丝数)
+        let accountYesterdayFansIncrease: number;
+        if (yesterdayUserStat) {
+          // 昨天有数据,直接使用
+          const yesterdayFans = yesterdayUserStat.fansCount || 0;
+          accountYesterdayFansIncrease = Math.max(0, accountFansCount - yesterdayFans);
+          logger.debug(`[WorkDayStatistics] Account ${account.id} (no works) - yesterday: ${yesterdayFans}, today: ${accountFansCount}, increase: ${accountYesterdayFansIncrease}`);
+        } else {
+          // 昨天没有数据,查找最近一天(早于今天)的数据作为基准
+          const recentUserStat = await this.userDayStatisticsRepository
+            .createQueryBuilder('uds')
+            .where('uds.account_id = :accountId', { accountId: account.id })
+            .andWhere('DATE(uds.record_date) < :today', { today: todayStr })
+            .orderBy('uds.record_date', 'DESC')
+            .getOne();
+          
+          if (recentUserStat) {
+            const recentFans = recentUserStat.fansCount || 0;
+            accountYesterdayFansIncrease = Math.max(0, accountFansCount - recentFans);
+            logger.debug(`[WorkDayStatistics] Account ${account.id} (no works) - using recent: ${recentFans}, today: ${accountFansCount}, increase: ${accountYesterdayFansIncrease}`);
+          } else {
+            // 完全没有历史数据,涨粉为 0
+            accountYesterdayFansIncrease = 0;
+            logger.debug(`[WorkDayStatistics] Account ${account.id} (no works) - no history data, increase: 0`);
+          }
+        }
+        
+        accountList.push({
+          id: account.id,
+          nickname: account.accountName || '',
+          username: account.accountId || '',
+          avatarUrl: account.avatarUrl,
+          platform: account.platform,
+          groupId: account.groupId,
+          fansCount: accountFansCount,
+          totalIncome: null,
+          yesterdayIncome: null,
+          totalViews: null,
+          yesterdayViews: null,
+          yesterdayComments: 0,
+          yesterdayLikes: 0,
+          yesterdayFansIncrease: accountYesterdayFansIncrease,
+          updateTime: account.updatedAt.toISOString(),
+          status: account.status,
+        });
+        
+        // 即使没有作品,也要累加账号的粉丝数到总粉丝数
+        totalAccounts++;
+        totalFans += accountFansCount;
+        yesterdayFansIncrease += accountYesterdayFansIncrease;
+        continue;
+      }
+
+      const workIds = works.map(w => w.id);
+
+      // 获取每个作品的最新日期统计数据(总播放量等,不再包含粉丝数)
+      const latestStatsQuery = this.statisticsRepository
+        .createQueryBuilder('wds')
+        .select('wds.work_id', 'workId')
+        .addSelect('MAX(wds.record_date)', 'latestDate')
+        .addSelect('MAX(wds.play_count)', 'playCount')
+        .addSelect('MAX(wds.like_count)', 'likeCount')
+        .addSelect('MAX(wds.comment_count)', 'commentCount')
+        .where('wds.work_id IN (:...workIds)', { workIds })
+        .groupBy('wds.work_id');
+
+      const latestStats = await latestStatsQuery.getRawMany();
+
+      // 计算总播放量(所有作品最新日期的play_count总和)
+      let accountTotalViews = 0;
+      const latestDateMap = new Map<number, string>();
+      for (const stat of latestStats) {
+        accountTotalViews += parseInt(stat.playCount) || 0;
+        latestDateMap.set(stat.workId, stat.latestDate);
+      }
+
+      // 获取昨天和今天的数据来计算增量(不再包含粉丝数,粉丝数从 user_day_statistics 表获取)
+      const yesterdayStatsQuery = this.statisticsRepository
+        .createQueryBuilder('wds')
+        .select('wds.work_id', 'workId')
+        .addSelect('SUM(wds.play_count)', 'playCount')
+        .addSelect('SUM(wds.like_count)', 'likeCount')
+        .addSelect('SUM(wds.comment_count)', 'commentCount')
+        .where('wds.work_id IN (:...workIds)', { workIds })
+        .andWhere('wds.record_date = :yesterday', { yesterday: yesterdayStr })
+        .groupBy('wds.work_id');
+
+      const todayStatsQuery = this.statisticsRepository
+        .createQueryBuilder('wds')
+        .select('wds.work_id', 'workId')
+        .addSelect('SUM(wds.play_count)', 'playCount')
+        .addSelect('SUM(wds.like_count)', 'likeCount')
+        .addSelect('SUM(wds.comment_count)', 'commentCount')
+        .where('wds.work_id IN (:...workIds)', { workIds })
+        .andWhere('wds.record_date = :today', { today: todayStr })
+        .groupBy('wds.work_id');
+
+      const [yesterdayStats, todayStats] = await Promise.all([
+        yesterdayStatsQuery.getRawMany(),
+        todayStatsQuery.getRawMany(),
+      ]);
+      
+      logger.info(`[WorkDayStatistics] Account ${account.id} (${account.accountName}) - workIds: ${workIds.length}, yesterdayStats: ${yesterdayStats.length}, todayStats: ${todayStats.length}`);
+      
+      if (yesterdayStats.length > 0 || todayStats.length > 0) {
+        logger.debug(`[WorkDayStatistics] yesterdayStats:`, JSON.stringify(yesterdayStats.slice(0, 3)));
+        logger.debug(`[WorkDayStatistics] todayStats:`, JSON.stringify(todayStats.slice(0, 3)));
+      }
+
+      // 计算昨日增量
+      let accountYesterdayViews = 0;
+      let accountYesterdayComments = 0;
+      let accountYesterdayLikes = 0;
+      let accountYesterdayFansIncrease = 0;
+
+      // 按作品ID汇总(不再包含粉丝数)
+      const yesterdayMap = new Map<number, { play: number; like: number; comment: number }>();
+      const todayMap = new Map<number, { play: number; like: number; comment: number }>();
+
+      for (const stat of yesterdayStats) {
+        const workId = parseInt(String(stat.workId)) || 0;
+        yesterdayMap.set(workId, {
+          play: Number(stat.playCount) || 0,
+          like: Number(stat.likeCount) || 0,
+          comment: Number(stat.commentCount) || 0,
+        });
+      }
+
+      for (const stat of todayStats) {
+        const workId = parseInt(String(stat.workId)) || 0;
+        todayMap.set(workId, {
+          play: Number(stat.playCount) || 0,
+          like: Number(stat.likeCount) || 0,
+          comment: Number(stat.commentCount) || 0,
+        });
+      }
+      
+      logger.debug(`[WorkDayStatistics] Account ${account.id} - yesterdayMap size: ${yesterdayMap.size}, todayMap size: ${todayMap.size}`);
+
+      // 计算增量(今天 - 昨天,不再包含粉丝数)
+      for (const workId of workIds) {
+        const todayData = todayMap.get(workId) || { play: 0, like: 0, comment: 0 };
+        const yesterdayData = yesterdayMap.get(workId) || { play: 0, like: 0, comment: 0 };
+        
+        const viewsDiff = todayData.play - yesterdayData.play;
+        const commentsDiff = todayData.comment - yesterdayData.comment;
+        const likesDiff = todayData.like - yesterdayData.like;
+        
+        accountYesterdayViews += Math.max(0, viewsDiff);
+        accountYesterdayComments += Math.max(0, commentsDiff);
+        accountYesterdayLikes += Math.max(0, likesDiff);
+      }
+      
+      logger.info(`[WorkDayStatistics] Account ${account.id} - Calculated: views=${accountYesterdayViews}, comments=${accountYesterdayComments}, likes=${accountYesterdayLikes}`);
+
+      // 从 user_day_statistics 表获取账号的最新粉丝数和作品数
+      // 优先使用统计数据,如果没有则使用账号表的当前值
+      
+      // 使用日期字符串直接查询(更可靠,避免时区问题)
+      // todayStr 和 yesterdayStr 是中国时区的日期字符串(如 "2026-01-26")
+      // 直接使用字符串查询,TypeORM 会自动转换为 DATE 类型进行比较
+      const todayUserStat = await this.userDayStatisticsRepository
+        .createQueryBuilder('uds')
+        .where('uds.account_id = :accountId', { accountId: account.id })
+        .andWhere('DATE(uds.record_date) = :today', { today: todayStr })
+        .getOne();
+      
+      const yesterdayUserStat = await this.userDayStatisticsRepository
+        .createQueryBuilder('uds')
+        .where('uds.account_id = :accountId', { accountId: account.id })
+        .andWhere('DATE(uds.record_date) = :yesterday', { yesterday: yesterdayStr })
+        .getOne();
+      
+      // 获取今天的粉丝数:优先使用统计数据,如果没有则使用账号表的当前值
+      let accountFansCount: number;
+      let accountWorksCount: number;
+      if (todayUserStat) {
+        accountFansCount = todayUserStat.fansCount || 0;
+        accountWorksCount = todayUserStat.worksCount || 0;
+      } else {
+        // 今天没有统计数据,使用账号表的当前值
+        accountFansCount = account.fansCount || 0;
+        accountWorksCount = account.worksCount || 0;
+      }
+      
+      // 计算昨日涨粉(今天粉丝数 - 昨天粉丝数)
+      let yesterdayFans: number;
+      if (yesterdayUserStat) {
+        // 昨天有数据,直接使用
+        yesterdayFans = yesterdayUserStat.fansCount || 0;
+        logger.debug(`[WorkDayStatistics] Account ${account.id} - yesterday has data: ${yesterdayFans}, today: ${accountFansCount}`);
+      } else {
+        // 昨天没有数据,查找最近一天(早于今天)的数据作为基准
+        const recentUserStat = await this.userDayStatisticsRepository
+          .createQueryBuilder('uds')
+          .where('uds.account_id = :accountId', { accountId: account.id })
+          .andWhere('DATE(uds.record_date) < :today', { today: todayStr })
+          .orderBy('uds.record_date', 'DESC')
+          .getOne();
+        
+        if (recentUserStat) {
+          yesterdayFans = recentUserStat.fansCount || 0;
+          logger.debug(`[WorkDayStatistics] Account ${account.id} - using recent data: ${yesterdayFans}, today: ${accountFansCount}`);
+        } else {
+          // 完全没有历史数据,用账号表的当前粉丝数作为基准(涨粉为 0)
+          yesterdayFans = accountFansCount;
+          logger.debug(`[WorkDayStatistics] Account ${account.id} - no history data, using current: ${accountFansCount}`);
+        }
+      }
+      
+      // 计算涨粉数(今天 - 昨天),确保不为负数
+      accountYesterdayFansIncrease = Math.max(0, accountFansCount - yesterdayFans);
+      logger.info(`[WorkDayStatistics] Account ${account.id} - fans increase: ${accountFansCount} - ${yesterdayFans} = ${accountYesterdayFansIncrease}`);
+
+      accountList.push({
+        id: account.id,
+        nickname: account.accountName || '',
+        username: account.accountId || '',
+        avatarUrl: account.avatarUrl,
+        platform: account.platform,
+        groupId: account.groupId,
+        fansCount: accountFansCount,
+        totalIncome: null, // 收益数据需要从其他表获取,暂时为null
+        yesterdayIncome: null,
+        totalViews: accountTotalViews > 0 ? accountTotalViews : null,
+        yesterdayViews: accountYesterdayViews > 0 ? accountYesterdayViews : null,
+        yesterdayComments: accountYesterdayComments,
+        yesterdayLikes: accountYesterdayLikes,
+        yesterdayFansIncrease: accountYesterdayFansIncrease,
+        updateTime: account.updatedAt.toISOString(),
+        status: account.status,
+      });
+
+      // 累加汇总数据
+      totalAccounts++;
+      totalViews += accountTotalViews;
+      yesterdayViews += accountYesterdayViews;
+      totalFans += accountFansCount;
+      yesterdayComments += accountYesterdayComments;
+      yesterdayLikes += accountYesterdayLikes;
+      yesterdayFansIncrease += accountYesterdayFansIncrease;
+    }
+
+    return {
+      accounts: accountList,
+      summary: {
+        totalAccounts,
+        totalIncome,
+        yesterdayIncome,
+        totalViews,
+        yesterdayViews,
+        totalFans,
+        yesterdayComments,
+        yesterdayLikes,
+        yesterdayFansIncrease,
+      },
+    };
+  }
 }

+ 1 - 2
server/src/services/WorkService.ts

@@ -275,10 +275,9 @@ export class WorkService {
       return;
     }
 
-    // 构建统计数据列表
+    // 构建统计数据列表(不再包含粉丝数,粉丝数从 user_day_statistics 表获取)
     const statisticsList = works.map(work => ({
       workId: work.id,
-      fansCount: account.fansCount || 0,
       playCount: work.playCount || 0,
       likeCount: work.likeCount || 0,
       commentCount: work.commentCount || 0,