Jelajahi Sumber

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

Ethanfly 15 jam lalu
induk
melakukan
6895e66ba7

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

@@ -32,6 +32,7 @@ declare module 'vue' {
     ElForm: typeof import('element-plus/es')['ElForm']
     ElFormItem: typeof import('element-plus/es')['ElFormItem']
     ElIcon: typeof import('element-plus/es')['ElIcon']
+    ElImage: typeof import('element-plus/es')['ElImage']
     ElInput: typeof import('element-plus/es')['ElInput']
     ElMain: typeof import('element-plus/es')['ElMain']
     ElMenu: typeof import('element-plus/es')['ElMenu']

+ 6 - 14
client/src/views/Accounts/index.vue

@@ -26,7 +26,7 @@
     <div class="page-card filter-bar">
       <el-select v-model="filter.platform" placeholder="平台" clearable style="width: 150px">
         <el-option
-          v-for="platform in platforms"
+          v-for="platform in supportedPlatforms"
           :key="platform.type"
           :label="platform.name"
           :value="platform.type"
@@ -136,17 +136,11 @@
         <el-form-item label="平台">
           <el-select v-model="addForm.platform" placeholder="选择平台" style="width: 100%">
             <el-option
-              v-for="platform in platforms"
+              v-for="platform in supportedPlatforms"
               :key="platform.type"
               :label="platform.name"
               :value="platform.type"
-              :disabled="!platform.supported"
-            >
-              <span class="platform-option">
-                <span>{{ platform.name }}</span>
-                <el-tag v-if="!platform.supported" size="small" type="info">适配中</el-tag>
-              </span>
-            </el-option>
+            />
           </el-select>
         </el-form-item>
         
@@ -383,7 +377,7 @@ import { ref, reactive, onMounted, onUnmounted, watch } from 'vue';
 import { Plus, Refresh, Monitor, Loading, CircleCheck, CircleClose, Setting } from '@element-plus/icons-vue';
 import { ElMessage, ElMessageBox } from 'element-plus';
 import { accountsApi } from '@/api/accounts';
-import { PLATFORMS, PLATFORM_TYPES } from '@media-manager/shared';
+import { PLATFORMS, AVAILABLE_PLATFORM_TYPES } from '@media-manager/shared';
 import type { PlatformAccount, AccountGroup, PlatformType } from '@media-manager/shared';
 import { useTaskQueueStore } from '@/stores/taskQueue';
 import { useTabsStore } from '@/stores/tabs';
@@ -455,10 +449,8 @@ const browserLoginForm = reactive({
   groupId: undefined as number | undefined,
 });
 
-const platforms = PLATFORM_TYPES.map(type => PLATFORMS[type]);
-
-// 只显示已支持的平台
-const supportedPlatforms = platforms.filter(p => p.supported);
+// 平台选项(统一配置:小红书、抖音、视频号、百家号)
+const supportedPlatforms = AVAILABLE_PLATFORM_TYPES.map(type => PLATFORMS[type]);
 
 const filter = reactive({
   platform: '',

+ 16 - 12
client/src/views/Analytics/Account/index.vue

@@ -11,6 +11,8 @@
           format="YYYY-MM-DD"
           value-format="YYYY-MM-DD"
           style="width: 140px"
+          :disabled-date="(date: Date) => endDate ? date > new Date(endDate) : false"
+          @change="handleQuery"
         />
         <span class="filter-label">结束时间</span>
         <el-date-picker
@@ -20,6 +22,8 @@
           format="YYYY-MM-DD"
           value-format="YYYY-MM-DD"
           style="width: 140px"
+          :disabled-date="(date: Date) => startDate ? date < new Date(startDate) : false"
+          @change="handleQuery"
         />
         <div class="quick-btns">
           <el-button 
@@ -32,7 +36,7 @@
             {{ btn.label }}
           </el-button>
         </div>
-        <el-select v-model="selectedGroup" placeholder="全部分组" clearable style="width: 120px">
+        <el-select v-model="selectedGroup" placeholder="全部分组" clearable style="width: 120px" @change="handleQuery">
           <el-option label="全部分组" value="" />
           <el-option 
             v-for="group in accountGroups" 
@@ -41,7 +45,7 @@
             :value="group.id" 
           />
         </el-select>
-        <el-select v-model="selectedPlatform" placeholder="全部平台" clearable style="width: 120px">
+        <el-select v-model="selectedPlatform" placeholder="全部平台" clearable style="width: 120px" @change="handleQuery">
           <el-option label="全部平台" value="" />
           <el-option 
             v-for="platform in availablePlatforms" 
@@ -50,7 +54,6 @@
             :value="platform.value"
           />
         </el-select>
-        <el-button type="primary" @click="handleQuery">查询</el-button>
       </div>
       <div class="filter-right">
         <el-button @click="handleExport">导出数据</el-button>
@@ -77,8 +80,6 @@
         placeholder="请输入要搜索的账号" 
         clearable
         style="width: 300px"
-        @clear="handleQuery"
-        @keyup.enter="handleQuery"
       >
         <template #prefix>
           <el-icon><Search /></el-icon>
@@ -119,7 +120,7 @@
           </template>
         </el-table-column>
         -->
-        <el-table-column prop="viewsCount" label="阅读(播放)量" width="130" align="center">
+        <el-table-column prop="viewsCount" label="播放(阅读)量" width="130" align="center">
           <template #default="{ row }">
             <span>{{ row.viewsCount ?? 0 }}</span>
           </template>
@@ -190,6 +191,8 @@
             format="YYYY-MM-DD"
             value-format="YYYY-MM-DD"
             style="width: 140px"
+            :disabled-date="(date: Date) => detailEndDate ? date > new Date(detailEndDate) : false"
+            @change="loadAccountDetailData"
           />
           <span class="filter-label">结束时间</span>
           <el-date-picker
@@ -199,6 +202,8 @@
             format="YYYY-MM-DD"
             value-format="YYYY-MM-DD"
             style="width: 140px"
+            :disabled-date="(date: Date) => detailStartDate ? date < new Date(detailStartDate) : false"
+            @change="loadAccountDetailData"
           />
           <div class="quick-btns">
             <el-button
@@ -211,7 +216,6 @@
               {{ btn.label }}
             </el-button>
           </div>
-          <el-button type="primary" @click="loadAccountDetailData">查询</el-button>
         </div>
 
         <!-- 详情 Tab -->
@@ -325,7 +329,7 @@ import { ref, computed, onMounted, watch, nextTick } from 'vue';
 import { useRoute } from 'vue-router';
 import * as echarts from 'echarts';
 import { Search, User, View, ChatDotRound, Star, TrendCharts } from '@element-plus/icons-vue';
-import { PLATFORMS } from '@media-manager/shared';
+import { PLATFORMS, AVAILABLE_PLATFORM_TYPES } from '@media-manager/shared';
 import type { PlatformType } from '@media-manager/shared';
 import { ElMessage } from 'element-plus';
 import dayjs from 'dayjs';
@@ -367,11 +371,11 @@ interface AccountGroup {
 }
 const accountGroups = ref<AccountGroup[]>([]);
 
-// 可用平台
+// 可用平台(统一配置:小红书、抖音、视频号、百家号)
 const availablePlatforms = computed(() => {
-  return Object.entries(PLATFORMS).map(([key, value]) => ({
-    value: key as PlatformType,
-    label: value.name,
+  return AVAILABLE_PLATFORM_TYPES.map(key => ({
+    value: key,
+    label: PLATFORMS[key].name,
   }));
 });
 

+ 7 - 10
client/src/views/Analytics/Overview/index.vue

@@ -151,7 +151,7 @@
 <script setup lang="ts">
 import { ref, computed, onMounted } from 'vue';
 import { Search } from '@element-plus/icons-vue';
-import { PLATFORMS } from '@media-manager/shared';
+import { PLATFORMS, AVAILABLE_PLATFORM_TYPES } from '@media-manager/shared';
 import type { PlatformType } from '@media-manager/shared';
 import { useAuthStore } from '@/stores/auth';
 import { useServerStore } from '@/stores/server';
@@ -176,10 +176,9 @@ interface AccountGroup {
 }
 const accountGroups = ref<AccountGroup[]>([]);
 
-// 可用平台(只显示抖音、百家号、视频号和小红书
+// 可用平台(统一配置:小红书、抖音、视频号、百家号
 const availablePlatforms = computed(() => {
-  const allowedPlatforms: PlatformType[] = ['douyin', 'baijiahao', 'weixin_video', 'xiaohongshu'];
-  return allowedPlatforms.map(key => ({
+  return AVAILABLE_PLATFORM_TYPES.map(key => ({
     value: key,
     label: PLATFORMS[key].name,
   }));
@@ -246,10 +245,9 @@ const summaryStats = computed(() => [
   { label: '昨日涨粉', value: summaryData.value.yesterdayFansIncrease || 0 },
 ]);
 
-// 过滤后的账号列表(只显示抖音、百家号、视频号和小红书
+// 过滤后的账号列表(只显示统一配置的平台:小红书、抖音、视频号、百家号
 const filteredAccounts = computed(() => {
-  const allowedPlatforms: PlatformType[] = ['douyin', 'baijiahao', 'weixin_video', 'xiaohongshu'];
-  let result = accounts.value.filter(a => allowedPlatforms.includes(a.platform));
+  let result = accounts.value.filter(a => AVAILABLE_PLATFORM_TYPES.includes(a.platform));
   
   if (selectedGroup.value) {
     result = result.filter(a => a.groupId === selectedGroup.value);
@@ -324,10 +322,9 @@ async function loadData() {
     });
 
     if (data) {
-      // 确保只保留支持的平台
-      const allowedPlatforms: PlatformType[] = ['douyin', 'baijiahao', 'weixin_video', 'xiaohongshu'];
+      // 确保只保留统一配置的平台
       accounts.value = (data.accounts || []).filter((a: AccountData) => 
-        allowedPlatforms.includes(a.platform)
+        AVAILABLE_PLATFORM_TYPES.includes(a.platform)
       );
       
       // 使用后端返回的汇总数据

+ 5 - 2
client/src/views/Analytics/Platform/index.vue

@@ -11,6 +11,8 @@
           format="YYYY-MM-DD"
           value-format="YYYY-MM-DD"
           style="width: 140px"
+          :disabled-date="(date: Date) => endDate ? date > new Date(endDate) : false"
+          @change="handleQuery"
         />
         <span class="filter-label">结束时间</span>
         <el-date-picker
@@ -20,6 +22,8 @@
           format="YYYY-MM-DD"
           value-format="YYYY-MM-DD"
           style="width: 140px"
+          :disabled-date="(date: Date) => startDate ? date < new Date(startDate) : false"
+          @change="handleQuery"
         />
         <div class="quick-btns">
           <el-button 
@@ -32,7 +36,6 @@
             {{ btn.label }}
           </el-button>
         </div>
-        <el-button type="primary" @click="handleQuery">查询</el-button>
       </div>
       <div class="filter-right">
         <el-button @click="handleExport">导出数据</el-button>
@@ -50,7 +53,7 @@
             </div>
           </template>
         </el-table-column>
-        <el-table-column prop="viewsCount" label="阅读(播放)量" width="140" align="center">
+        <el-table-column prop="viewsCount" label="播放(阅读)量" width="140" align="center">
           <template #default="{ row }">
             <span>{{ row.viewsCount ?? '未支持' }}</span>
           </template>

+ 15 - 12
client/src/views/Analytics/PlatformDetail/index.vue

@@ -15,6 +15,8 @@
           format="YYYY-MM-DD"
           value-format="YYYY-MM-DD"
           style="width: 140px"
+          :disabled-date="(date: Date) => endDate ? date > new Date(endDate) : false"
+          @change="handleQuery"
         />
         <span class="filter-label">结束时间</span>
         <el-date-picker
@@ -24,6 +26,8 @@
           format="YYYY-MM-DD"
           value-format="YYYY-MM-DD"
           style="width: 140px"
+          :disabled-date="(date: Date) => startDate ? date < new Date(startDate) : false"
+          @change="handleQuery"
         />
         <div class="quick-btns">
           <el-button 
@@ -36,7 +40,7 @@
             {{ btn.label }}
           </el-button>
         </div>
-        <el-select v-model="selectedPlatform" placeholder="全部平台" clearable style="width: 120px">
+        <el-select v-model="selectedPlatform" placeholder="全部平台" clearable style="width: 120px" @change="handleQuery">
           <el-option label="全部平台" value="" />
           <el-option 
             v-for="platform in availablePlatforms" 
@@ -45,7 +49,6 @@
             :value="platform.value"
           />
         </el-select>
-        <el-button type="primary" @click="handleQuery">查询</el-button>
       </div>
       <div class="filter-right">
         <!-- <el-button @click="handleViewReport">查看报表</el-button>
@@ -188,6 +191,8 @@
             format="YYYY-MM-DD"
             value-format="YYYY-MM-DD"
             style="width: 140px"
+            :disabled-date="(date: Date) => detailEndDate ? date > new Date(detailEndDate) : false"
+            @change="loadAccountDetailData"
           />
           <span class="filter-label">结束时间</span>
           <el-date-picker
@@ -197,6 +202,8 @@
             format="YYYY-MM-DD"
             value-format="YYYY-MM-DD"
             style="width: 140px"
+            :disabled-date="(date: Date) => detailStartDate ? date < new Date(detailStartDate) : false"
+            @change="loadAccountDetailData"
           />
           <div class="quick-btns">
             <el-button
@@ -209,7 +216,6 @@
               {{ btn.label }}
             </el-button>
           </div>
-          <el-button type="primary" @click="loadAccountDetailData">查询</el-button>
         </div>
 
         <!-- 详情 Tab -->
@@ -324,7 +330,7 @@ console.log('[PlatformDetail] ========== 组件脚本开始执行 ==========');
 
 import { ref, computed, onMounted, nextTick, watch } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
-import { PLATFORMS } from '@media-manager/shared';
+import { PLATFORMS, AVAILABLE_PLATFORM_TYPES } from '@media-manager/shared';
 import type { PlatformType } from '@media-manager/shared';
 import { ElMessage } from 'element-plus';
 import dayjs from 'dayjs';
@@ -359,15 +365,12 @@ const quickDateBtns = [
   { label: '近一个月', value: 'lastMonth' },
 ];
 
-// 可用平台(只保留与总览列表一致的 4 个:抖音、百家号、视频号、小红书
+// 可用平台(统一配置:小红书、抖音、视频号、百家号
 const availablePlatforms = computed(() => {
-  const allowed: PlatformType[] = ['douyin', 'baijiahao', 'weixin_video', 'xiaohongshu'];
-  return Object.entries(PLATFORMS)
-    .filter(([key]) => allowed.includes(key as PlatformType))
-    .map(([key, value]) => ({
-      value: key,
-      label: value.name,
-    }));
+  return AVAILABLE_PLATFORM_TYPES.map(key => ({
+    value: key,
+    label: PLATFORMS[key].name,
+  }));
 });
 
 // 汇总数据

+ 35 - 10
client/src/views/Analytics/Work/index.vue

@@ -11,6 +11,8 @@
           format="YYYY-MM-DD"
           value-format="YYYY-MM-DD"
           style="width: 140px"
+          :disabled-date="(date: Date) => endDate ? date > new Date(endDate) : false"
+          @change="handleFilterChange"
         />
         <span class="filter-label">结束时间</span>
         <el-date-picker
@@ -20,6 +22,8 @@
           format="YYYY-MM-DD"
           value-format="YYYY-MM-DD"
           style="width: 140px"
+          :disabled-date="(date: Date) => startDate ? date < new Date(startDate) : false"
+          @change="handleFilterChange"
         />
         <div class="quick-btns">
           <el-button 
@@ -39,6 +43,7 @@
           collapse-tags-tooltip
           placeholder="选择账号" 
           style="width: 160px"
+          @change="handleFilterChange"
         >
           <el-option 
             v-for="account in accountList" 
@@ -47,7 +52,6 @@
             :value="account.id"
           />
         </el-select>
-        <el-button type="primary" @click="handleQuery">查询</el-button>
       </div>
       <div class="filter-right">
         <el-button @click="handleExport">导出数据</el-button>
@@ -70,7 +74,7 @@
     <!-- 第二行筛选 -->
     <div class="filter-bar secondary">
       <div class="filter-left">
-        <el-select v-model="selectedGroup" placeholder="全部" clearable style="width: 120px">
+        <el-select v-model="selectedGroup" placeholder="全部" clearable style="width: 120px" @change="handleFilterChange">
           <el-option label="全部" value="" />
           <el-option 
             v-for="group in accountGroups" 
@@ -79,7 +83,7 @@
             :value="group.id" 
           />
         </el-select>
-        <el-select v-model="selectedPlatform" placeholder="全部平台" clearable style="width: 120px">
+        <el-select v-model="selectedPlatform" placeholder="全部平台" clearable style="width: 120px" @change="handleFilterChange">
           <el-option label="全部平台" value="" />
           <el-option 
             v-for="platform in availablePlatforms" 
@@ -97,8 +101,9 @@
           placeholder="请输入要搜索的作品标题" 
           clearable
           style="width: 240px"
-          @clear="handleQuery"
-          @keyup.enter="handleQuery"
+          @input="handleSearchInput"
+          @clear="handleFilterChange"
+          @keyup.enter="handleFilterChange"
         >
           <template #prefix>
             <el-icon><Search /></el-icon>
@@ -154,6 +159,7 @@
             <span class="publish-time">{{ formatTime(row.publishTime) }}</span>
           </template>
         </el-table-column>
+        <!-- 操作列暂时注释
         <el-table-column label="操作" width="80" align="center" fixed="right">
           <template #default="{ row }">
             <el-button type="primary" link @click="handleView(row)">
@@ -161,6 +167,7 @@
             </el-button>
           </template>
         </el-table-column>
+        -->
       </el-table>
       
       <!-- 分页 -->
@@ -239,7 +246,7 @@
 <script setup lang="ts">
 import { ref, computed, onMounted } from 'vue';
 import { Search, Picture, Document, View, ChatDotRound, Share, Star, Pointer } from '@element-plus/icons-vue';
-import { PLATFORMS } from '@media-manager/shared';
+import { PLATFORMS, AVAILABLE_PLATFORM_TYPES } from '@media-manager/shared';
 import type { PlatformType } from '@media-manager/shared';
 import { useAuthStore } from '@/stores/auth';
 import { ElMessage } from 'element-plus';
@@ -278,11 +285,11 @@ interface AccountGroup {
 }
 const accountGroups = ref<AccountGroup[]>([]);
 
-// 可用平台
+// 可用平台(统一配置:小红书、抖音、视频号、百家号)
 const availablePlatforms = computed(() => {
-  return Object.entries(PLATFORMS).map(([key, value]) => ({
-    value: key as PlatformType,
-    label: value.name,
+  return AVAILABLE_PLATFORM_TYPES.map(key => ({
+    value: key,
+    label: PLATFORMS[key].name,
   }));
 });
 
@@ -382,6 +389,7 @@ function handleQuickDate(type: string) {
       endDate.value = today.format('YYYY-MM-DD');
       break;
   }
+  handleQuery();
 }
 
 // 查询
@@ -389,6 +397,23 @@ function handleQuery() {
   loadData();
 }
 
+// 平台/分组/日期/账号变更时重置页码并查询
+function handleFilterChange() {
+  currentPage.value = 1;
+  handleQuery();
+}
+
+// 搜索框防抖(300ms 后触发查询)
+let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
+function handleSearchInput() {
+  if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
+  searchDebounceTimer = setTimeout(() => {
+    currentPage.value = 1;
+    handleQuery();
+    searchDebounceTimer = null;
+  }, 300);
+}
+
 // 加载账号列表
 async function loadAccountList() {
   try {

+ 3 - 2
client/src/views/Works/index.vue

@@ -363,7 +363,7 @@ import { ElMessageBox } from 'element-plus';
 import { ElMessage } from 'element-plus';
 import request from '@/api/request';
 import { accountsApi } from '@/api/accounts';
-import { PLATFORMS, PLATFORM_TYPES, WS_EVENTS } from '@media-manager/shared';
+import { PLATFORMS, AVAILABLE_PLATFORM_TYPES, WS_EVENTS } from '@media-manager/shared';
 import type { Work, WorkStats, PlatformAccount, PlatformType, Comment } from '@media-manager/shared';
 import { useServerStore } from '@/stores/server';
 import { useAuthStore } from '@/stores/auth';
@@ -425,8 +425,9 @@ const commentsPagination = reactive({
   total: 0,
 });
 
+// 平台选项(统一配置:小红书、抖音、视频号、百家号)
 const platforms = computed(() => 
-  PLATFORM_TYPES.map(type => PLATFORMS[type]).filter(p => p.supported)
+  AVAILABLE_PLATFORM_TYPES.map(type => PLATFORMS[type])
 );
 
 const filter = reactive({

+ 1 - 1
server/python/export_platform_statistics_xlsx.py

@@ -45,7 +45,7 @@ except Exception as e:  # pragma: no cover
 
 HEADERS = [
   "平台",
-  "阅读(播放量",
+  "播放(阅读)量",
   "评论量",
   "点赞量",
   "涨粉量",

+ 3 - 3
server/python/export_work_day_overview_xlsx.py

@@ -51,8 +51,8 @@ except Exception as e:  # pragma: no cover - 仅作为运行时保护
 HEADERS = [
   "账号",
   "平台",
-  "总播放量",
-  "昨日播放量",
+  "总播放(阅读)量",
+  "昨日播放(阅读)量",
   "粉丝数",
   "昨日评论",
   "昨日点赞",
@@ -60,7 +60,7 @@ HEADERS = [
   "更新时间",
 ]
 
-COL_WIDTHS = [22, 12, 12, 12, 10, 10, 10, 10, 20]
+COL_WIDTHS = [22, 12, 16, 18, 10, 10, 10, 10, 20]
 
 
 def _safe_int(v):

TEMPAT SAMPAH
server/python/weixin_private_msg_17318.png


+ 83 - 0
server/src/scripts/test-data-sync.ts

@@ -0,0 +1,83 @@
+#!/usr/bin/env tsx
+/**
+ * 手动测试四个平台的数据同步任务
+ * 用于测试账号刷新重试机制
+ * 
+ * 运行: cd server && pnpm exec tsx src/scripts/test-data-sync.ts
+ */
+import { initDatabase } from '../models/index.js';
+import { XiaohongshuAccountOverviewImportService } from '../services/XiaohongshuAccountOverviewImportService.js';
+import { DouyinAccountOverviewImportService } from '../services/DouyinAccountOverviewImportService.js';
+import { BaijiahaoContentOverviewImportService } from '../services/BaijiahaoContentOverviewImportService.js';
+import { WeixinVideoDataCenterImportService } from '../services/WeixinVideoDataCenterImportService.js';
+import { logger } from '../utils/logger.js';
+
+async function main() {
+  logger.info('========================================');
+  logger.info('Starting manual data sync test...');
+  logger.info('========================================');
+
+  // 初始化数据库连接
+  await initDatabase();
+  logger.info('Database initialized');
+
+  const args = process.argv.slice(2);
+  const platform = args[0]?.toLowerCase();
+
+  if (!platform || !['xhs', 'dy', 'bj', 'wx', 'all'].includes(platform)) {
+    console.log('\nUsage: pnpm exec tsx src/scripts/test-data-sync.ts <platform>');
+    console.log('\nPlatforms:');
+    console.log('  xhs  - 小红书 (Xiaohongshu)');
+    console.log('  dy   - 抖音 (Douyin)');
+    console.log('  bj   - 百家号 (Baijiahao)');
+    console.log('  wx   - 视频号 (Weixin Video)');
+    console.log('  all  - 所有平台');
+    console.log('\nExample: pnpm exec tsx src/scripts/test-data-sync.ts xhs');
+    process.exit(1);
+  }
+
+  try {
+    if (platform === 'xhs' || platform === 'all') {
+      logger.info('\n========================================');
+      logger.info('Testing Xiaohongshu (小红书) data sync...');
+      logger.info('========================================');
+      await XiaohongshuAccountOverviewImportService.runDailyImport();
+      logger.info('✓ Xiaohongshu sync completed');
+    }
+
+    if (platform === 'dy' || platform === 'all') {
+      logger.info('\n========================================');
+      logger.info('Testing Douyin (抖音) data sync...');
+      logger.info('========================================');
+      await DouyinAccountOverviewImportService.runDailyImport();
+      logger.info('✓ Douyin sync completed');
+    }
+
+    if (platform === 'bj' || platform === 'all') {
+      logger.info('\n========================================');
+      logger.info('Testing Baijiahao (百家号) data sync...');
+      logger.info('========================================');
+      await BaijiahaoContentOverviewImportService.runDailyImport();
+      logger.info('✓ Baijiahao sync completed');
+    }
+
+    if (platform === 'wx' || platform === 'all') {
+      logger.info('\n========================================');
+      logger.info('Testing Weixin Video (视频号) data sync...');
+      logger.info('========================================');
+      await WeixinVideoDataCenterImportService.runDailyImport();
+      logger.info('✓ Weixin Video sync completed');
+    }
+
+    logger.info('\n========================================');
+    logger.info('All tests completed successfully!');
+    logger.info('========================================');
+  } catch (error) {
+    logger.error('Test failed:', error);
+    process.exit(1);
+  }
+
+  process.exit(0);
+}
+
+main();

+ 35 - 2
server/src/services/BaijiahaoContentOverviewImportService.ts

@@ -6,6 +6,7 @@ import { AppDataSource, PlatformAccount } from '../models/index.js';
 import { BrowserManager } from '../automation/browser.js';
 import { logger } from '../utils/logger.js';
 import { UserDayStatisticsService } from './UserDayStatisticsService.js';
+import { AccountService } from './AccountService.js';
 import type { ProxyConfig } from '@media-manager/shared';
 import { WS_EVENTS } from '@media-manager/shared';
 import { wsManager } from '../websocket/index.js';
@@ -483,7 +484,7 @@ export class BaijiahaoContentOverviewImportService {
   /**
    * 单账号:导出 Excel → 解析 → 入库 → 删除文件
    */
-  async importAccountLast30Days(account: PlatformAccount): Promise<void> {
+  async importAccountLast30Days(account: PlatformAccount, isRetry = false): Promise<void> {
     const cookies = parseCookiesFromAccount(account.cookieData);
     if (!cookies.length) {
       throw new Error('cookieData 为空或无法解析');
@@ -513,7 +514,39 @@ export class BaijiahaoContentOverviewImportService {
       await page.waitForTimeout(1500);
 
       if (page.url().includes('passport') || page.url().includes('login')) {
-        throw new Error('未登录/需要重新登录(跳转到登录页)');
+        // 第一次检测到登录失效时,尝试刷新账号
+        if (!isRetry) {
+          logger.info(`[BJ Import] Login expired detected for account ${account.id}, attempting to refresh...`);
+          await context.close();
+          if (shouldClose) await browser.close();
+          
+          try {
+            const accountService = new AccountService();
+            const refreshResult = await accountService.refreshAccount(account.userId, account.id);
+            
+            if (refreshResult.needReLogin) {
+              // 刷新后仍需要重新登录,走原先的失效流程
+              logger.warn(`[BJ Import] Account ${account.id} refresh failed, still needs re-login`);
+              throw new Error('未登录/需要重新登录(跳转到登录页)');
+            }
+            
+            // 刷新成功,重新获取账号信息并重试导入
+            logger.info(`[BJ Import] Account ${account.id} refreshed successfully, retrying import...`);
+            const refreshedAccount = await this.accountRepository.findOne({ where: { id: account.id } });
+            if (!refreshedAccount) {
+              throw new Error('账号刷新后未找到');
+            }
+            
+            // 递归调用,标记为重试
+            return await this.importAccountLast30Days(refreshedAccount, true);
+          } catch (refreshError) {
+            logger.error(`[BJ Import] Account ${account.id} refresh failed:`, refreshError);
+            throw new Error('未登录/需要重新登录(跳转到登录页)');
+          }
+        } else {
+          // 已经是重试了,不再尝试刷新
+          throw new Error('未登录/需要重新登录(跳转到登录页)');
+        }
       }
 
       const bodyText = (await page.textContent('body').catch(() => '')) || '';

+ 84 - 9
server/src/services/DouyinAccountOverviewImportService.ts

@@ -6,6 +6,7 @@ import { AppDataSource, PlatformAccount } from '../models/index.js';
 import { BrowserManager } from '../automation/browser.js';
 import { logger } from '../utils/logger.js';
 import { UserDayStatisticsService } from './UserDayStatisticsService.js';
+import { AccountService } from './AccountService.js';
 import type { ProxyConfig } from '@media-manager/shared';
 import { WS_EVENTS } from '@media-manager/shared';
 import { wsManager } from '../websocket/index.js';
@@ -340,7 +341,7 @@ export class DouyinAccountOverviewImportService {
   /**
    * 单账号:导出 Excel → 解析 → 入库 → 删除文件
    */
-  async importAccountLast30Days(account: PlatformAccount): Promise<void> {
+  async importAccountLast30Days(account: PlatformAccount, isRetry = false): Promise<void> {
     const cookies = parseCookiesFromAccount(account.cookieData);
     if (!cookies.length) {
       throw new Error('cookieData 为空或无法解析');
@@ -365,13 +366,46 @@ export class DouyinAccountOverviewImportService {
       }
 
       const page = await context.newPage();
+      logger.info(`[DY Import] accountId=${account.id} goto data-center...`);
       await page.goto('https://creator.douyin.com/creator-micro/data-center/operation', {
         waitUntil: 'domcontentloaded',
       });
       await page.waitForTimeout(1500);
 
       if (page.url().includes('login')) {
-        throw new Error('未登录/需要重新登录(跳转到 login)');
+        // 第一次检测到登录失效时,尝试刷新账号
+        if (!isRetry) {
+          logger.info(`[DY Import] Login expired detected for account ${account.id}, attempting to refresh...`);
+          await context.close();
+          if (shouldClose) await browser.close();
+          
+          try {
+            const accountService = new AccountService();
+            const refreshResult = await accountService.refreshAccount(account.userId, account.id);
+            
+            if (refreshResult.needReLogin) {
+              // 刷新后仍需要重新登录,走原先的失效流程
+              logger.warn(`[DY Import] Account ${account.id} refresh failed, still needs re-login`);
+              throw new Error('未登录/需要重新登录(跳转到 login)');
+            }
+            
+            // 刷新成功,重新获取账号信息并重试导入
+            logger.info(`[DY Import] Account ${account.id} refreshed successfully, retrying import...`);
+            const refreshedAccount = await this.accountRepository.findOne({ where: { id: account.id } });
+            if (!refreshedAccount) {
+              throw new Error('账号刷新后未找到');
+            }
+            
+            // 递归调用,标记为重试
+            return await this.importAccountLast30Days(refreshedAccount, true);
+          } catch (refreshError) {
+            logger.error(`[DY Import] Account ${account.id} refresh failed:`, refreshError);
+            throw new Error('未登录/需要重新登录(跳转到 login)');
+          }
+        } else {
+          // 已经是重试了,不再尝试刷新
+          throw new Error('未登录/需要重新登录(跳转到 login)');
+        }
       }
 
       // 检测“暂无访问权限 / 权限申请中 / 暂无数据”提示:标记账号 expired + 推送提示
@@ -395,14 +429,38 @@ export class DouyinAccountOverviewImportService {
         throw new Error('抖音数据看板暂无访问权限/申请中,已标记 expired 并通知用户');
       }
 
-      // 统一入口:数据中心 -> 账号总览 -> 短视频
-      await page.getByText('数据中心', { exact: false }).first().click().catch(() => undefined);
-      await page.getByText('账号总览', { exact: true }).first().click().catch(() => undefined);
-      await page.getByText('短视频', { exact: true }).first().click();
+      // 已直达账号总览页(data-center/operation),无需再点「数据中心/账号总览」,直接点「短视频」和「近30天」
+      await page.waitForTimeout(500);
+      logger.info(`[DY Import] accountId=${account.id} on 账号总览页, click 短视频 tab (#semiTabaweme)...`);
+      const shortVideoById = page.locator('#semiTabaweme');
+      if ((await shortVideoById.count().catch(() => 0)) > 0) {
+        await shortVideoById.first().click();
+      } else {
+        const shortVideoCandidates = ['短视频', '短视频数据'];
+        let shortVideoClicked = false;
+        for (const text of shortVideoCandidates) {
+          const loc = page.getByText(text, { exact: false }).first();
+          if ((await loc.count().catch(() => 0)) > 0) {
+            await loc.click().catch(() => undefined);
+            shortVideoClicked = true;
+            break;
+          }
+        }
+        if (!shortVideoClicked) {
+          throw new Error('页面上未找到「短视频」入口,请确认抖音创作者后台是否改版');
+        }
+      }
 
-      // 切换“近30天”
-      await page.getByText(/近\d+天?/).first().click().catch(() => undefined);
-      await page.getByText('近30天', { exact: true }).click();
+      // 切换“近30天”(优先用 ID #addon-aoc08fi,兜底文案)
+      await page.waitForTimeout(500);
+      logger.info(`[DY Import] accountId=${account.id} click 近30天 (#addon-aoc08fi)...`);
+      const last30DaysById = page.locator('#addon-aoc08fi');
+      if ((await last30DaysById.count().catch(() => 0)) > 0) {
+        await last30DaysById.first().click();
+      } else {
+        await page.getByText(/近\d+天?/).first().click().catch(() => undefined);
+        await page.getByText('近30天', { exact: true }).click();
+      }
       await page.waitForTimeout(1200);
 
       // 逐个指标导出(排除:主页访问 / 取关粉丝)
@@ -421,6 +479,7 @@ export class DouyinAccountOverviewImportService {
       let totalInserted = 0;
       let totalUpdated = 0;
       let mergedDays = new Map<string, { recordDate: Date } & Record<string, any>>();
+      const savedExcelPaths: string[] = [];
 
       const clickMetric = async (metric: { name: string; candidates: string[] }) => {
         // 先精确匹配,失败后用包含匹配(适配 UI 文案差异)
@@ -447,6 +506,7 @@ export class DouyinAccountOverviewImportService {
       };
 
       for (const metric of metricsToExport) {
+        logger.info(`[DY Import] accountId=${account.id} exporting metric: ${metric.name}...`);
         await clickMetric(metric);
 
         const [download] = await Promise.all([
@@ -457,6 +517,12 @@ export class DouyinAccountOverviewImportService {
         const filename = `${account.id}_${Date.now()}_${download.suggestedFilename()}`;
         const filePath = path.join(this.downloadDir, filename);
         await download.saveAs(filePath);
+        // 保留 Excel 不删除,便于核对数据;路径打日志方便查看
+        const absolutePath = path.resolve(filePath);
+        savedExcelPaths.push(absolutePath);
+        logger.info(
+          `[DY Import] Excel saved (${metric.name}): ${absolutePath}`
+        );
 
         try {
           const perDay = parseDouyinExcel(filePath);
@@ -470,6 +536,7 @@ export class DouyinAccountOverviewImportService {
             `[DY Import] metric exported & parsed. accountId=${account.id} metric=${metric.name} file=${path.basename(filePath)} days=${perDay.size}`
           );
         } finally {
+          // 默认导入后删除 Excel,避免磁盘堆积;仅在显式 KEEP_DY_XLSX=true 时保留(用于调试)
           if (process.env.KEEP_DY_XLSX === 'true') {
             logger.warn(`[DY Import] KEEP_DY_XLSX=true, keep file: ${filePath}`);
           } else {
@@ -478,6 +545,14 @@ export class DouyinAccountOverviewImportService {
         }
       }
 
+      // 汇总:本账号导出的 7 个 Excel 已解析
+      logger.info(
+        `[DY Import] accountId=${account.id} 共 ${savedExcelPaths.length} 个 Excel 已解析`
+      );
+      if (savedExcelPaths.length !== 7) {
+        logger.warn(`[DY Import] accountId=${account.id} 预期 7 个 Excel,实际 ${savedExcelPaths.length} 个`);
+      }
+
       // 合并完成后统一入库(避免同一天多次 update)
       for (const v of mergedDays.values()) {
         const { recordDate, ...patch } = v;

+ 2 - 2
server/src/services/SystemService.ts

@@ -1,6 +1,6 @@
 import { AppDataSource, SystemConfig, User, PlatformAccount, PublishTask } from '../models/index.js';
 import type { SystemConfig as SystemConfigType } from '@media-manager/shared';
-import { PLATFORM_TYPES } from '@media-manager/shared';
+import { AVAILABLE_PLATFORM_TYPES } from '@media-manager/shared';
 import { wsManager } from '../websocket/index.js';
 
 interface UpdateConfigParams {
@@ -32,7 +32,7 @@ export class SystemService {
       allowRegistration: configMap.get('allow_registration') === 'true',
       defaultUserRole: configMap.get('default_user_role') || 'operator',
       maxUploadSize: 4096 * 1024 * 1024, // 4GB
-      supportedPlatforms: PLATFORM_TYPES,
+      supportedPlatforms: AVAILABLE_PLATFORM_TYPES,
     };
   }
 

+ 35 - 2
server/src/services/WeixinVideoDataCenterImportService.ts

@@ -6,6 +6,7 @@ import { AppDataSource, PlatformAccount } from '../models/index.js';
 import { BrowserManager } from '../automation/browser.js';
 import { logger } from '../utils/logger.js';
 import { UserDayStatisticsService } from './UserDayStatisticsService.js';
+import { AccountService } from './AccountService.js';
 import type { ProxyConfig } from '@media-manager/shared';
 import { WS_EVENTS } from '@media-manager/shared';
 import { wsManager } from '../websocket/index.js';
@@ -420,7 +421,7 @@ export class WeixinVideoDataCenterImportService {
     logger.info('[WX Import] Done.');
   }
 
-  async importAccountLast30Days(account: PlatformAccount): Promise<void> {
+  async importAccountLast30Days(account: PlatformAccount, isRetry = false): Promise<void> {
     const cookies = parseCookiesFromAccount(account.cookieData);
     if (!cookies.length) throw new Error('cookieData 为空或无法解析');
 
@@ -447,7 +448,39 @@ export class WeixinVideoDataCenterImportService {
       await page.waitForTimeout(1500);
 
       if (page.url().includes('login') || page.url().includes('passport')) {
-        throw new Error('未登录/需要重新登录(跳转到登录页)');
+        // 第一次检测到登录失效时,尝试刷新账号
+        if (!isRetry) {
+          logger.info(`[WX Import] Login expired detected for account ${account.id}, attempting to refresh...`);
+          await context.close();
+          if (shouldClose) await browser.close();
+          
+          try {
+            const accountService = new AccountService();
+            const refreshResult = await accountService.refreshAccount(account.userId, account.id);
+            
+            if (refreshResult.needReLogin) {
+              // 刷新后仍需要重新登录,走原先的失效流程
+              logger.warn(`[WX Import] Account ${account.id} refresh failed, still needs re-login`);
+              throw new Error('未登录/需要重新登录(跳转到登录页)');
+            }
+            
+            // 刷新成功,重新获取账号信息并重试导入
+            logger.info(`[WX Import] Account ${account.id} refreshed successfully, retrying import...`);
+            const refreshedAccount = await this.accountRepository.findOne({ where: { id: account.id } });
+            if (!refreshedAccount) {
+              throw new Error('账号刷新后未找到');
+            }
+            
+            // 递归调用,标记为重试
+            return await this.importAccountLast30Days(refreshedAccount, true);
+          } catch (refreshError) {
+            logger.error(`[WX Import] Account ${account.id} refresh failed:`, refreshError);
+            throw new Error('未登录/需要重新登录(跳转到登录页)');
+          }
+        } else {
+          // 已经是重试了,不再尝试刷新
+          throw new Error('未登录/需要重新登录(跳转到登录页)');
+        }
       }
 
       // 进入 数据中心

+ 35 - 2
server/src/services/XiaohongshuAccountOverviewImportService.ts

@@ -6,6 +6,7 @@ import { AppDataSource, PlatformAccount } from '../models/index.js';
 import { BrowserManager } from '../automation/browser.js';
 import { logger } from '../utils/logger.js';
 import { UserDayStatisticsService } from './UserDayStatisticsService.js';
+import { AccountService } from './AccountService.js';
 import type { ProxyConfig } from '@media-manager/shared';
 import { WS_EVENTS } from '@media-manager/shared';
 import { wsManager } from '../websocket/index.js';
@@ -349,7 +350,7 @@ export class XiaohongshuAccountOverviewImportService {
   /**
    * 单账号:导出 Excel → 解析 → 入库 → 删除文件
    */
-  async importAccountLast30Days(account: PlatformAccount): Promise<void> {
+  async importAccountLast30Days(account: PlatformAccount, isRetry = false): Promise<void> {
     const cookies = parseCookiesFromAccount(account.cookieData);
     if (!cookies.length) {
       throw new Error('cookieData 为空或无法解析');
@@ -378,7 +379,39 @@ export class XiaohongshuAccountOverviewImportService {
       await page.waitForTimeout(1500);
 
       if (page.url().includes('login')) {
-        throw new Error('未登录/需要重新登录(跳转到 login)');
+        // 第一次检测到登录失效时,尝试刷新账号
+        if (!isRetry) {
+          logger.info(`[XHS Import] Login expired detected for account ${account.id}, attempting to refresh...`);
+          await context.close();
+          if (shouldClose) await browser.close();
+          
+          try {
+            const accountService = new AccountService();
+            const refreshResult = await accountService.refreshAccount(account.userId, account.id);
+            
+            if (refreshResult.needReLogin) {
+              // 刷新后仍需要重新登录,走原先的失效流程
+              logger.warn(`[XHS Import] Account ${account.id} refresh failed, still needs re-login`);
+              throw new Error('未登录/需要重新登录(跳转到 login)');
+            }
+            
+            // 刷新成功,重新获取账号信息并重试导入
+            logger.info(`[XHS Import] Account ${account.id} refreshed successfully, retrying import...`);
+            const refreshedAccount = await this.accountRepository.findOne({ where: { id: account.id } });
+            if (!refreshedAccount) {
+              throw new Error('账号刷新后未找到');
+            }
+            
+            // 递归调用,标记为重试
+            return await this.importAccountLast30Days(refreshedAccount, true);
+          } catch (refreshError) {
+            logger.error(`[XHS Import] Account ${account.id} refresh failed:`, refreshError);
+            throw new Error('未登录/需要重新登录(跳转到 login)');
+          }
+        } else {
+          // 已经是重试了,不再尝试刷新
+          throw new Error('未登录/需要重新登录(跳转到 login)');
+        }
       }
 
       // 检测“暂无访问权限 / 权限申请中”提示:仅推送提示,不修改账号状态(避免误判或用户不想自动变更)

+ 11 - 0
shared/src/constants/platforms.ts

@@ -177,6 +177,17 @@ export const PLATFORMS: Record<PlatformType, PlatformInfo> = {
 export const PLATFORM_TYPES = Object.keys(PLATFORMS) as PlatformType[];
 
 /**
+ * 可用平台类型(统一配置:仅展示小红书、抖音、视频号、百家号)
+ * 用于平台筛选、下拉选项等所有平台选项卡
+ */
+export const AVAILABLE_PLATFORM_TYPES: PlatformType[] = [
+  'xiaohongshu',  // 小红书
+  'douyin',       // 抖音
+  'weixin_video', // 视频号
+  'baijiahao',    // 百家号
+];
+
+/**
  * 获取平台信息
  */
 export function getPlatformInfo(platform: PlatformType): PlatformInfo {