Ethanfly 16 saat önce
ebeveyn
işleme
8005ba46ab

+ 37 - 0
client/src/views/Analytics/Work/index.vue

@@ -260,6 +260,23 @@
                     </div>
                   </template>
 
+                  <!-- 百家号:推荐量、播放(阅读)量、评论量、点赞量、收藏量、分享量、完播率、涨粉量 -->
+                  <template v-else-if="selectedWork.platform === 'baijiahao'">
+                    <div
+                      v-for="item in baijiahaoMetricCards"
+                      :key="item.key || item.label"
+                      class="data-card"
+                      :class="{ highlight: item.key && activeTrendMetric === item.key }"
+                      role="button"
+                      tabindex="0"
+                      @click="item.key && setTrendMetric(item.key)"
+                      @keyup.enter="item.key && setTrendMetric(item.key)"
+                    >
+                      <div class="card-label">{{ item.label }}</div>
+                      <div class="card-value">{{ item.value }}</div>
+                    </div>
+                  </template>
+
                   <!-- 其他平台:保持原口径 -->
                   <template v-else>
                     <div
@@ -472,6 +489,7 @@ interface WorkDetailData {
   shareCount: number;
   fansIncrease: number;
   followCount: number; // 视频号:关注数
+  recommendCount: number; // 百家号等:推荐量
   coverClickRate: string;
   avgWatchDuration: string;
   completionRate: string;
@@ -490,6 +508,7 @@ const workDetailData = ref<WorkDetailData>({
   shareCount: 0,
   fansIncrease: 0,
   followCount: 0,
+  recommendCount: 0,
   coverClickRate: '0',
   avgWatchDuration: '0',
   completionRate: '0',
@@ -837,6 +856,22 @@ const weixinMetricCards = computed<MetricCardConfig[]>(() => {
   ];
 });
 
+// 百家号:推荐量、播放(阅读)量、评论量、点赞量、收藏量、分享量、完播率、涨粉量
+const baijiahaoMetricCards = computed<MetricCardConfig[]>(() => {
+  const d = workDetailData.value;
+  const base = selectedWork.value;
+  return [
+    { label: '推荐量', value: formatNumber(d.recommendCount || 0) },
+    { key: 'playCount' as const, label: '播放(阅读)量', value: formatNumber(d.playCount || base?.viewsCount || 0) },
+    { key: 'commentCount' as const, label: '评论量', value: formatNumber(d.commentCount || base?.commentsCount || 0) },
+    { key: 'likeCount' as const, label: '点赞量', value: formatNumber(d.likeCount || base?.likesCount || 0) },
+    { key: 'collectCount' as const, label: '收藏量', value: formatNumber(d.collectCount || base?.collectsCount || 0) },
+    { key: 'shareCount' as const, label: '分享量', value: formatNumber(d.shareCount || base?.sharesCount || 0) },
+    { key: 'completionRate' as const, label: '完播率', value: formatRate(d.completionRate) },
+    { key: 'fansIncrease' as const, label: '涨粉量', value: formatNumber(d.fansIncrease || 0) },
+  ];
+});
+
 // 查看详情
 async function handleView(row: WorkData) {
   selectedWork.value = row;
@@ -872,6 +907,7 @@ async function handleView(row: WorkData) {
     shareCount: row.sharesCount || 0,
     fansIncrease: 0,
     followCount: 0,
+    recommendCount: 0,
     coverClickRate: '0',
     avgWatchDuration: '0',
     completionRate: '0',
@@ -912,6 +948,7 @@ async function loadWorkBase(workId: number) {
       shareCount: toIntSafe(data.yesterdayShareCount ?? 0),
       fansIncrease: toIntSafe(data.yesterdayFansIncrease ?? 0),
       followCount: toIntSafe(data.yesterdayFollowCount ?? 0),
+      recommendCount: toIntSafe(data.yesterdayRecommendCount ?? 0),
       coverClickRate: String(data.yesterdayCoverClickRate ?? '0'),
       avgWatchDuration: String(data.yesterdayAvgWatchDuration ?? '0'),
       completionRate: String(data.yesterdayCompletionRate ?? '0'),

+ 7 - 0
database/migrations/add_exposure_count_to_user_day_statistics.sql

@@ -0,0 +1,7 @@
+-- 为 user_day_statistics 表添加 exposure_count(曝光数/展现量)
+-- 数据来源:小红书创作者中心 - 账号概览 - 笔记数据 - 观看数据 - 曝光趋势
+
+USE media_manager;
+
+ALTER TABLE user_day_statistics
+ADD COLUMN exposure_count INT NOT NULL DEFAULT 0 COMMENT '曝光数/展现量' AFTER play_count;

+ 1 - 0
database/schema.sql

@@ -244,6 +244,7 @@ CREATE TABLE IF NOT EXISTS user_day_statistics (
     fans_count INT DEFAULT 0 COMMENT '粉丝数',
     works_count INT DEFAULT 0 COMMENT '作品数',
     play_count INT DEFAULT 0 COMMENT '播放数',
+    exposure_count INT DEFAULT 0 COMMENT '曝光数/展现量',
     comment_count INT DEFAULT 0 COMMENT '评论数',
     fans_increase INT DEFAULT 0 COMMENT '涨粉数',
     like_count INT DEFAULT 0 COMMENT '点赞数',

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

@@ -21,6 +21,9 @@ export class UserDayStatistics {
   @Column({ name: 'play_count', type: 'int', default: 0, comment: '播放数' })
   playCount!: number;
 
+  @Column({ name: 'exposure_count', type: 'int', default: 0, comment: '曝光数/展现量' })
+  exposureCount!: number;
+
   @Column({ name: 'comment_count', type: 'int', default: 0, comment: '评论数' })
   commentCount!: number;
 

+ 17 - 1
server/src/routes/auth.ts

@@ -1,5 +1,6 @@
-import { Router } from 'express';
+import { Router, type Request, type Response, type NextFunction } from 'express';
 import { body } from 'express-validator';
+import { AppDataSource } from '../models/index.js';
 import { AuthService } from '../services/AuthService.js';
 import { authenticate } from '../middleware/auth.js';
 import { asyncHandler } from '../middleware/error.js';
@@ -8,6 +9,18 @@ import { validateRequest } from '../middleware/validate.js';
 const router = Router();
 const authService = new AuthService();
 
+/** 数据库未连接时返回 503,避免 TypeORM "No metadata for 'User'" 等报错 */
+function requireDatabase(_req: Request, res: Response, next: NextFunction) {
+  if (!AppDataSource.isInitialized) {
+    res.status(503).json({
+      success: false,
+      message: '数据库未连接,请检查后端启动日志是否出现 "Database connected",并确认 .env 中 DB_HOST/DB_PORT/DB_USERNAME/DB_PASSWORD/DB_DATABASE 与当前 MySQL 一致',
+    });
+    return;
+  }
+  next();
+}
+
 // 获取注册配置(公开接口)
 router.get('/config', (_req, res) => {
   // 环境变量优先,必须明确设置为 'true' 才开放注册
@@ -23,6 +36,7 @@ router.get('/config', (_req, res) => {
 // 登录
 router.post(
   '/login',
+  requireDatabase,
   [
     body('username').notEmpty().withMessage('用户名不能为空'),
     body('password').notEmpty().withMessage('密码不能为空'),
@@ -45,6 +59,7 @@ router.post(
 // 注册
 router.post(
   '/register',
+  requireDatabase,
   [
     body('username')
       .notEmpty().withMessage('用户名不能为空')
@@ -70,6 +85,7 @@ router.post(
 // 刷新 Token
 router.post(
   '/refresh',
+  requireDatabase,
   [
     body('refreshToken').notEmpty().withMessage('刷新令牌不能为空'),
     validateRequest,

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

@@ -6,6 +6,7 @@ export interface UserDayStatisticsItem {
   fansCount?: number;
   worksCount?: number;
   playCount?: number;
+  exposureCount?: number;
   commentCount?: number;
   fansIncrease?: number;
   likeCount?: number;
@@ -66,6 +67,7 @@ export class UserDayStatisticsService {
         fansCount: item.fansCount ?? existing.fansCount,
         worksCount: item.worksCount ?? existing.worksCount,
         playCount: item.playCount ?? existing.playCount,
+        exposureCount: item.exposureCount ?? existing.exposureCount,
         commentCount: item.commentCount ?? existing.commentCount,
         fansIncrease: item.fansIncrease ?? existing.fansIncrease,
         likeCount: item.likeCount ?? existing.likeCount,
@@ -86,6 +88,7 @@ export class UserDayStatisticsService {
         fansCount: item.fansCount ?? 0,
         worksCount: item.worksCount ?? 0,
         playCount: item.playCount ?? 0,
+        exposureCount: item.exposureCount ?? 0,
         commentCount: item.commentCount ?? 0,
         fansIncrease: item.fansIncrease ?? 0,
         likeCount: item.likeCount ?? 0,
@@ -123,6 +126,7 @@ export class UserDayStatisticsService {
         fansCount: patch.fansCount ?? existing.fansCount,
         worksCount: patch.worksCount ?? existing.worksCount,
         playCount: patch.playCount ?? existing.playCount,
+        exposureCount: patch.exposureCount ?? existing.exposureCount,
         commentCount: patch.commentCount ?? existing.commentCount,
         fansIncrease: patch.fansIncrease ?? existing.fansIncrease,
         likeCount: patch.likeCount ?? existing.likeCount,
@@ -142,6 +146,7 @@ export class UserDayStatisticsService {
       fansCount: patch.fansCount ?? 0,
       worksCount: patch.worksCount ?? 0,
       playCount: patch.playCount ?? 0,
+      exposureCount: patch.exposureCount ?? 0,
       commentCount: patch.commentCount ?? 0,
       fansIncrease: patch.fansIncrease ?? 0,
       likeCount: patch.likeCount ?? 0,

+ 18 - 6
server/src/services/XiaohongshuAccountOverviewImportService.ts

@@ -29,17 +29,19 @@ type PlaywrightCookie = {
 
 type MetricKind =
   | 'playCount'
+  | 'exposureCount'
   | 'likeCount'
   | 'commentCount'
   | 'shareCount'
   | 'collectCount'
   | 'fansIncrease'
+  | 'worksCount'
   | 'coverClickRate'
   | 'avgWatchDuration'
   | 'totalWatchDuration'
   | 'completionRate';
 
-type ExportMode = 'watch' | 'interaction' | 'fans';
+type ExportMode = 'watch' | 'interaction' | 'fans' | 'publish';
 
 function ensureDir(p: string) {
   return fs.mkdir(p, { recursive: true });
@@ -97,6 +99,7 @@ function detectMetricKind(sheetName: string): MetricKind | null {
   const n = sheetName.trim();
   // 观看数据:子表命名可能是「观看趋势」或「观看数趋势」
   if (n.includes('观看趋势') || n.includes('观看数')) return 'playCount';
+  if (n.includes('曝光趋势')) return 'exposureCount';
   if (n.includes('封面点击率')) return 'coverClickRate';
   if (n.includes('平均观看时长')) return 'avgWatchDuration';
   if (n.includes('观看总时长')) return 'totalWatchDuration';
@@ -110,6 +113,9 @@ function detectMetricKind(sheetName: string): MetricKind | null {
 
   // 涨粉数据(只取净涨粉趋势)
   if (n.includes('净涨粉') && n.includes('趋势')) return 'fansIncrease';
+
+  // 发布数据:总发布趋势 → 每日发布数,入库 works_count
+  if (n.includes('总发布趋势')) return 'worksCount';
   return null;
 }
 
@@ -210,9 +216,10 @@ export function parseXhsExcel(
     // 按导出类型过滤不相关子表,避免误写字段
     if (
       (mode === 'watch' &&
-        !['playCount', 'coverClickRate', 'avgWatchDuration', 'totalWatchDuration', 'completionRate'].includes(kind)) ||
+        !['playCount', 'exposureCount', 'coverClickRate', 'avgWatchDuration', 'totalWatchDuration', 'completionRate'].includes(kind)) ||
       (mode === 'interaction' && !['likeCount', 'commentCount', 'shareCount', 'collectCount'].includes(kind)) ||
-      (mode === 'fans' && kind !== 'fansIncrease')
+      (mode === 'fans' && kind !== 'fansIncrease') ||
+      (mode === 'publish' && kind !== 'worksCount')
     ) {
       continue;
     }
@@ -236,11 +243,13 @@ export function parseXhsExcel(
       if (!result.has(key)) result.set(key, { recordDate: d });
       const obj = result.get(key)!;
 
-      if (kind === 'playCount' || kind === 'likeCount' || kind === 'commentCount' || kind === 'shareCount' || kind === 'collectCount' || kind === 'fansIncrease') {
+      if (kind === 'playCount' || kind === 'exposureCount' || kind === 'likeCount' || kind === 'commentCount' || kind === 'shareCount' || kind === 'collectCount' || kind === 'fansIncrease' || kind === 'worksCount') {
         const n = parseChineseNumberLike(valueVal);
         if (typeof n === 'number') {
           if (kind === 'playCount') obj.playCount = n;
+          if (kind === 'exposureCount') obj.exposureCount = n;
           if (kind === 'likeCount') obj.likeCount = n;
+          if (kind === 'worksCount') obj.worksCount = n;
           if (kind === 'commentCount') obj.commentCount = n;
           if (kind === 'shareCount') obj.shareCount = n;
           if (kind === 'collectCount') obj.collectCount = n;
@@ -433,7 +442,7 @@ export class XiaohongshuAccountOverviewImportService {
       await page.getByText('账号概览', { exact: true }).first().click().catch(() => undefined);
       await page.getByText('笔记数据', { exact: true }).first().click();
 
-      const exportAndImport = async (tabText: '观看数据' | '互动数据' | '涨粉数据', mode: ExportMode) => {
+      const exportAndImport = async (tabText: '观看数据' | '互动数据' | '涨粉数据' | '发布数据', mode: ExportMode) => {
         await page.getByText(tabText, { exact: true }).first().click();
         await page.getByText(/近\d+日/).first().click().catch(() => undefined);
         await page.getByText('近30日', { exact: true }).click();
@@ -496,7 +505,10 @@ export class XiaohongshuAccountOverviewImportService {
       // 3) 涨粉数据:只取“净涨粉趋势”(解析器已过滤)
       await exportAndImport('涨粉数据', 'fans');
 
-      // 4) 粉丝数据页:打开粉丝数据、点击近30天,解析 overall_new 接口,将每日粉丝总数写入 user_day_statistics.fans_count
+      // 4) 发布数据:近30日导出,解析「总发布趋势」→ user_day_statistics.works_count
+      await exportAndImport('发布数据', 'publish');
+
+      // 5) 粉丝数据页:打开粉丝数据、点击近30天,解析 overall_new 接口,将每日粉丝总数写入 user_day_statistics.fans_count
       await this.importFansDataTrendFromPage(context, page, account);
 
       logger.info(`[XHS Import] Account all tabs done. accountId=${account.id}`);