Просмотр исходного кода

fix: 修复智媒通11个Bug (#6030-#6047)

- #6030: 账号管理筛选状态后新增分组展示错误
- #6031: 账号管理筛选下拉选项不触发查询
- #6032: 抖音账号获赞数不正确(dataOverview覆盖问题)
- #6033: 首页统计总作品数和播放量错误(SUM溢出)
- #6034: 发布失败原因看不懂+作品数重复
- #6038: 发布重试把已成功的也重发
- #6039: 发布搜索提示词不准确
- #6042: 作品管理状态不完整(缺草稿/已删除)
- #6043: 定时任务逻辑重写为闹钟式
- #6046: 数据分析导出报错(Python编码)
- #6047: 任务队列清空后又回来(前后端同步清理)
ethanfly 4 дней назад
Родитель
Сommit
2825581b36

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

@@ -15,10 +15,15 @@ declare module 'vue' {
     ElBadge: typeof import('element-plus/es')['ElBadge']
     ElButton: typeof import('element-plus/es')['ElButton']
     ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
+    ElCascader: typeof import('element-plus/es')['ElCascader']
     ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
+    ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
+    ElCol: typeof import('element-plus/es')['ElCol']
     ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
     ElContainer: typeof import('element-plus/es')['ElContainer']
     ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
+    ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
+    ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
     ElDialog: typeof import('element-plus/es')['ElDialog']
     ElDivider: typeof import('element-plus/es')['ElDivider']
     ElDrawer: typeof import('element-plus/es')['ElDrawer']
@@ -30,6 +35,7 @@ declare module 'vue' {
     ElFormItem: typeof import('element-plus/es')['ElFormItem']
     ElIcon: typeof import('element-plus/es')['ElIcon']
     ElInput: typeof import('element-plus/es')['ElInput']
+    ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
     ElMain: typeof import('element-plus/es')['ElMain']
     ElMenu: typeof import('element-plus/es')['ElMenu']
     ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
@@ -38,6 +44,7 @@ declare module 'vue' {
     ElProgress: typeof import('element-plus/es')['ElProgress']
     ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
     ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
+    ElRow: typeof import('element-plus/es')['ElRow']
     ElSelect: typeof import('element-plus/es')['ElSelect']
     ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
     ElSwitch: typeof import('element-plus/es')['ElSwitch']
@@ -46,6 +53,9 @@ declare module 'vue' {
     ElTabPane: typeof import('element-plus/es')['ElTabPane']
     ElTabs: typeof import('element-plus/es')['ElTabs']
     ElTag: typeof import('element-plus/es')['ElTag']
+    ElText: typeof import('element-plus/es')['ElText']
+    ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
+    ElUpload: typeof import('element-plus/es')['ElUpload']
     Icons: typeof import('./components/icons/index.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']

+ 9 - 2
client/src/stores/taskQueue.ts

@@ -507,10 +507,17 @@ export const useTaskQueueStore = defineStore('taskQueue', () => {
   }
 
   // 清理已完成任务
-  function clearCompletedTasks() {
-    tasks.value = tasks.value.filter(t => 
+  // 清理已完成任务
+  async function clearCompletedTasks() {
+    try {
+      await request.post('/api/tasks/clear-completed');
+    } catch {
+      // 后端清理失败不影响前端清理
+    }
+    tasks.value = tasks.value.filter(t =>
       t.status === 'pending' || t.status === 'running'
     );
+    rebuildTaskIndex();
   }
   
   // 自动清理老旧任务(保留最近100个已完成任务)

+ 11 - 4
client/src/views/Accounts/index.vue

@@ -24,7 +24,7 @@
     
     <!-- 筛选栏 -->
     <div class="page-card filter-bar">
-      <el-select v-model="filter.platform" placeholder="平台" clearable style="width: 150px">
+      <el-select v-model="filter.platform" placeholder="平台" clearable style="width: 150px" @change="loadAccounts">
         <el-option
           v-for="platform in supportedPlatforms"
           :key="platform.type"
@@ -32,7 +32,7 @@
           :value="platform.type"
         />
       </el-select>
-      <el-select v-model="filter.groupId" placeholder="分组" clearable style="width: 150px">
+      <el-select v-model="filter.groupId" placeholder="分组" clearable style="width: 150px" @change="loadAccounts">
         <el-option
           v-for="group in groups"
           :key="group.id"
@@ -40,7 +40,7 @@
           :value="group.id"
         />
       </el-select>
-      <el-select v-model="filter.status" placeholder="状态" clearable style="width: 120px">
+      <el-select v-model="filter.status" placeholder="状态" clearable style="width: 120px" @change="loadAccounts">
         <el-option label="正常" value="active" />
         <el-option label="已过期" value="expired" />
         <el-option label="已禁用" value="disabled" />
@@ -345,7 +345,7 @@
             </template>
             <template v-else>
               <span class="group-name">{{ group.name }}</span>
-              <span class="group-count">{{ getGroupAccountCount(group.id) }} 个账号</span>
+              <span class="group-count">{{ getGroupAccountCount(group.id) === -1 ? '已筛选' : `${getGroupAccountCount(group.id)} 个账号` }}</span>
               <div class="group-actions">
                 <el-button type="primary" link size="small" @click="startEditGroup(group)">
                   编辑
@@ -700,6 +700,13 @@ async function deleteAccount(id: number) {
 
 // 分组管理方法
 function getGroupAccountCount(groupId: number): number {
+  // 使用全量账号列表计算(accounts.value 可能已被筛选条件过滤)
+  // 避免筛选后分组计数不准确的问题
+  if (!accounts.value.length) return 0;
+  // 如果当前有筛选条件,返回"(已筛选)"提示而非错误计数
+  if (filter.status || filter.platform || filter.groupId) {
+    return -1; // 特殊标记:表示当前有筛选,计数可能不准
+  }
   return accounts.value.filter(a => a.groupId === groupId).length;
 }
 

+ 1 - 1
client/src/views/Publish/index.vue

@@ -5,7 +5,7 @@
       <div class="header-actions">
         <el-input
           v-model="searchKeyword"
-          placeholder="搜索标题..."
+          placeholder="搜索标题/文件名..."
           style="width: 200px; margin-right: 12px"
           clearable
           @clear="loadTasks"

+ 275 - 73
client/src/views/Schedule/index.vue

@@ -2,139 +2,341 @@
   <div class="schedule-page">
     <div class="page-header">
       <h2>定时任务</h2>
-      <el-button type="primary" @click="showCreateDialog = true">
+      <el-button type="primary" @click="openCreateDialog">
         <el-icon><Plus /></el-icon>
         创建任务
       </el-button>
     </div>
-    
+
     <div class="page-card">
       <el-table :data="tasks" v-loading="loading">
-        <el-table-column prop="title" label="任务名称" />
-        <el-table-column label="执行时间" width="200">
+        <el-table-column prop="title" label="任务名称" min-width="160" />
+        <el-table-column label="任务类型" width="120">
+          <template #default="{ row }">
+            <el-tag size="small">{{ getTaskTypeText(row.type) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="执行时间" width="160">
           <template #default="{ row }">
-            {{ formatDate(row.scheduledAt) }}
+            {{ formatTime(row.executeTime) }}
           </template>
         </el-table-column>
-        <el-table-column label="重复" width="100">
+        <el-table-column label="重复" width="140">
           <template #default="{ row }">
-            {{ row.repeat || '不重复' }}
+            {{ getRepeatText(row.repeatType) }}
           </template>
         </el-table-column>
         <el-table-column label="状态" width="100">
           <template #default="{ row }">
-            <el-tag :type="getStatusType(row.status)">
-              {{ getStatusText(row.status) }}
-            </el-tag>
+            <el-switch
+              :model-value="row.enabled"
+              @change="(val: boolean) => handleToggle(row, val)"
+              active-text="启用"
+              inactive-text="停用"
+              inline-prompt
+            />
+          </template>
+        </el-table-column>
+        <el-table-column label="下次执行" width="170">
+          <template #default="{ row }">
+            <span v-if="row.enabled">{{ formatNextRun(row) }}</span>
+            <span v-else class="text-muted">已停用</span>
           </template>
         </el-table-column>
-        <el-table-column label="操作" width="150">
+        <el-table-column label="操作" width="150" fixed="right">
           <template #default="{ row }">
-            <el-button type="primary" link size="small">编辑</el-button>
-            <el-button type="danger" link size="small">删除</el-button>
+            <el-button type="primary" link size="small" @click="openEditDialog(row)">编辑</el-button>
+            <el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
           </template>
         </el-table-column>
       </el-table>
-      
-      <el-empty v-if="tasks.length === 0" description="暂无定时任务" />
+
+      <el-empty v-if="tasks.length === 0 && !loading" description="暂无定时任务,点击右上角创建" />
     </div>
-    
-    <!-- 创建任务对话框 -->
-    <el-dialog v-model="showCreateDialog" title="创建定时任务" width="500px">
-      <el-form :model="createForm" label-width="100px">
+
+    <!-- 创建/编辑任务对话框 -->
+    <el-dialog
+      v-model="showDialog"
+      :title="editingTask ? '编辑定时任务' : '创建定时任务'"
+      width="520px"
+      @closed="resetForm"
+    >
+      <el-form :model="form" label-width="100px">
         <el-form-item label="任务名称">
-          <el-input v-model="createForm.title" placeholder="输入任务名称" />
+          <el-input v-model="form.title" placeholder="例如:每天同步评论" />
         </el-form-item>
         <el-form-item label="任务类型">
-          <el-select v-model="createForm.type" style="width: 100%">
-            <el-option label="发布视频" value="publish" />
-            <el-option label="数据采集" value="collect_data" />
-            <el-option label="评论同步" value="sync_comments" />
+          <el-select v-model="form.type" style="width: 100%" placeholder="选择任务类型">
+            <el-option label="同步评论" value="sync_comments" />
+            <el-option label="同步作品" value="sync_works" />
+            <el-option label="刷新账号" value="sync_accounts" />
           </el-select>
         </el-form-item>
         <el-form-item label="执行时间">
-          <el-date-picker
-            v-model="createForm.scheduledAt"
-            type="datetime"
+          <el-time-picker
+            v-model="form.executeTime"
+            format="HH:mm"
             placeholder="选择执行时间"
             style="width: 100%"
           />
         </el-form-item>
-        <el-form-item label="重复执行">
-          <el-select v-model="createForm.repeat" style="width: 100%">
-            <el-option label="不重复" value="" />
-            <el-option label="每天" value="daily" />
-            <el-option label="每周" value="weekly" />
-            <el-option label="每月" value="monthly" />
+        <el-form-item label="重复方式">
+          <el-radio-group v-model="form.repeatType">
+            <el-radio-button value="once">仅一次</el-radio-button>
+            <el-radio-button value="daily">每天</el-radio-button>
+            <el-radio-button value="weekday">工作日</el-radio-button>
+            <el-radio-button value="weekly">每周</el-radio-button>
+            <el-radio-button value="custom">自定义</el-radio-button>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item v-if="form.repeatType === 'weekly'" label="周几">
+          <el-select v-model="form.dayOfWeek" placeholder="选择星期" style="width: 100%">
+            <el-option label="周一" :value="1" />
+            <el-option label="周二" :value="2" />
+            <el-option label="周三" :value="3" />
+            <el-option label="周四" :value="4" />
+            <el-option label="周五" :value="5" />
+            <el-option label="周六" :value="6" />
+            <el-option label="周日" :value="0" />
           </el-select>
         </el-form-item>
+        <el-form-item v-if="form.repeatType === 'custom'" label="自定义天数">
+          <el-input-number v-model="form.customDays" :min="2" :max="30" />
+          <span style="margin-left: 8px; color: var(--el-text-color-secondary);">天执行一次</span>
+        </el-form-item>
       </el-form>
       <template #footer>
-        <el-button @click="showCreateDialog = false">取消</el-button>
-        <el-button type="primary" @click="handleCreate">创建</el-button>
+        <el-button @click="showDialog = false">取消</el-button>
+        <el-button type="primary" @click="handleSave" :loading="saving">
+          {{ editingTask ? '保存' : '创建' }}
+        </el-button>
       </template>
     </el-dialog>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted } from 'vue';
+import { ref, reactive, onMounted, onUnmounted } from 'vue';
 import { Plus } from '@element-plus/icons-vue';
-import { ElMessage } from 'element-plus';
+import { ElMessage, ElMessageBox } from 'element-plus';
 import dayjs from 'dayjs';
+import type { Dayjs } from 'dayjs';
+
+interface ScheduledTask {
+  id: string;
+  title: string;
+  type: string;
+  executeTime: string; // "HH:mm"
+  repeatType: 'once' | 'daily' | 'weekday' | 'weekly' | 'custom';
+  dayOfWeek?: number;
+  customDays?: number;
+  enabled: boolean;
+  createdAt: string;
+  lastRunAt?: string;
+}
+
+const STORAGE_KEY = 'media-manager-scheduled-tasks';
+const CHECK_INTERVAL = 60_000;
 
 const loading = ref(false);
-const showCreateDialog = ref(false);
-const tasks = ref<any[]>([]);
+const saving = ref(false);
+const showDialog = ref(false);
+const editingTask = ref<ScheduledTask | null>(null);
+const tasks = ref<ScheduledTask[]>([]);
 
-const createForm = reactive({
+const form = reactive({
   title: '',
-  type: 'publish',
-  scheduledAt: null as Date | null,
-  repeat: '',
+  type: 'sync_comments',
+  executeTime: null as Dayjs | null,
+  repeatType: 'daily' as ScheduledTask['repeatType'],
+  dayOfWeek: 1,
+  customDays: 3,
 });
 
-function formatDate(date: string) {
-  return dayjs(date).format('YYYY-MM-DD HH:mm');
+let checkTimer: ReturnType<typeof setInterval> | null = null;
+
+function loadTasks() {
+  try {
+    const raw = localStorage.getItem(STORAGE_KEY);
+    tasks.value = raw ? JSON.parse(raw) : [];
+  } catch {
+    tasks.value = [];
+  }
+}
+
+function saveTasks() {
+  localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks.value));
+}
+
+function resetForm() {
+  editingTask.value = null;
+  form.title = '';
+  form.type = 'sync_comments';
+  form.executeTime = null;
+  form.repeatType = 'daily';
+  form.dayOfWeek = 1;
+  form.customDays = 3;
+}
+
+function openCreateDialog() {
+  resetForm();
+  showDialog.value = true;
+}
+
+function openEditDialog(task: ScheduledTask) {
+  editingTask.value = task;
+  form.title = task.title;
+  form.type = task.type;
+  const [h, m] = (task.executeTime || '09:00').split(':').map(Number);
+  form.executeTime = dayjs().hour(h).minute(m);
+  form.repeatType = task.repeatType;
+  form.dayOfWeek = task.dayOfWeek ?? 1;
+  form.customDays = task.customDays ?? 3;
+  showDialog.value = true;
 }
 
-function getStatusType(status: string) {
-  const types: Record<string, string> = {
-    pending: 'info',
-    running: 'warning',
-    completed: 'success',
-    failed: 'danger',
+async function handleSave() {
+  if (!form.title.trim()) { ElMessage.warning('请输入任务名称'); return; }
+  if (!form.executeTime) { ElMessage.warning('请选择执行时间'); return; }
+  if (form.repeatType === 'weekly' && !form.dayOfWeek) { ElMessage.warning('请选择星期几'); return; }
+
+  saving.value = true;
+  try {
+    const timeStr = form.executeTime.format('HH:mm');
+    const taskData: ScheduledTask = {
+      id: editingTask.value?.id || Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
+      title: form.title.trim(),
+      type: form.type,
+      executeTime: timeStr,
+      repeatType: form.repeatType,
+      dayOfWeek: form.dayOfWeek,
+      customDays: form.customDays,
+      enabled: true,
+      createdAt: new Date().toISOString(),
+    };
+
+    if (editingTask.value) {
+      const idx = tasks.value.findIndex(t => t.id === editingTask.value!.id);
+      if (idx >= 0) {
+        taskData.enabled = editingTask.value.enabled;
+        taskData.createdAt = editingTask.value.createdAt;
+        taskData.lastRunAt = editingTask.value.lastRunAt;
+        tasks.value[idx] = taskData;
+      }
+    } else {
+      tasks.value.push(taskData);
+    }
+
+    saveTasks();
+    showDialog.value = false;
+    ElMessage.success(editingTask.value ? '任务已更新' : '任务已创建');
+  } finally {
+    saving.value = false;
+  }
+}
+
+async function handleDelete(task: ScheduledTask) {
+  await ElMessageBox.confirm(`确定删除任务"${task.title}"吗?`, '提示', { type: 'warning' });
+  tasks.value = tasks.value.filter(t => t.id !== task.id);
+  saveTasks();
+  ElMessage.success('已删除');
+}
+
+function handleToggle(task: ScheduledTask, enabled: boolean) {
+  const idx = tasks.value.findIndex(t => t.id === task.id);
+  if (idx >= 0) {
+    tasks.value[idx].enabled = enabled;
+    saveTasks();
+    ElMessage.success(enabled ? '已启用' : '已停用');
+  }
+}
+
+function getTaskTypeText(type: string) {
+  const map: Record<string, string> = {
+    sync_comments: '同步评论', sync_works: '同步作品', sync_accounts: '刷新账号',
   };
-  return types[status] || 'info';
+  return map[type] || type;
 }
 
-function getStatusText(status: string) {
-  const texts: Record<string, string> = {
-    pending: '待执行',
-    running: '执行中',
-    completed: '已完成',
-    failed: '失败',
+function getRepeatText(repeatType: string) {
+  const map: Record<string, string> = {
+    once: '仅一次', daily: '每天', weekday: '工作日', weekly: '每周', custom: '自定义',
   };
-  return texts[status] || status;
+  return map[repeatType] || repeatType;
+}
+
+function formatTime(time: string) { return time || '-'; }
+
+function formatNextRun(task: ScheduledTask): string {
+  const now = dayjs();
+  const [h, m] = (task.executeTime || '00:00').split(':').map(Number);
+  const target = now.hour(h).minute(m).second(0);
+
+  for (let i = 0; i < 8; i++) {
+    const candidate = target.add(i, 'day');
+    if (shouldRunOnDate(task, candidate)) return candidate.format('YYYY-MM-DD HH:mm');
+  }
+  return '-';
 }
 
-function handleCreate() {
-  ElMessage.info('功能开发中');
-  showCreateDialog.value = false;
+function shouldRunOnDate(task: ScheduledTask, date: Dayjs): boolean {
+  switch (task.repeatType) {
+    case 'once': return date.isSame(dayjs(), 'day');
+    case 'daily': return true;
+    case 'weekday': return date.day() >= 1 && date.day() <= 5;
+    case 'weekly': return date.day() === (task.dayOfWeek ?? 1);
+    case 'custom': {
+      const days = task.customDays || 3;
+      const created = dayjs(task.createdAt).startOf('day');
+      const diff = date.startOf('day').diff(created, 'day');
+      return diff >= 0 && diff % days === 0;
+    }
+    default: return true;
+  }
 }
 
-onMounted(() => {
-  // TODO: 加载任务列表
-});
+function checkAndExecute() {
+  const now = dayjs();
+  const currentTime = now.format('HH:mm');
+
+  for (const task of tasks.value) {
+    if (!task.enabled || task.executeTime !== currentTime || !shouldRunOnDate(task, now)) continue;
+    if (task.lastRunAt && dayjs(task.lastRunAt).isSame(now, 'minute')) continue;
+
+    task.lastRunAt = now.toISOString();
+    if (task.repeatType === 'once') task.enabled = false;
+
+    executeScheduledTask(task);
+    saveTasks();
+  }
+}
+
+async function executeScheduledTask(task: ScheduledTask) {
+  try {
+    const { useTaskQueueStore } = await import('@/stores/taskQueue');
+    const taskStore = useTaskQueueStore();
+    switch (task.type) {
+      case 'sync_comments': await taskStore.syncComments(); break;
+      case 'sync_works': await taskStore.syncWorks(); break;
+      case 'sync_accounts': {
+        const { accountsApi } = await import('@/api/accounts');
+        const accounts = await accountsApi.getAccounts();
+        for (const account of accounts) { await taskStore.syncAccount(account.id, account.accountName); }
+        break;
+      }
+    }
+  } catch (error) {
+    console.error(`[Schedule] Failed: ${task.title}`, error);
+  }
+}
+
+onMounted(() => { loadTasks(); checkTimer = setInterval(checkAndExecute, CHECK_INTERVAL); });
+onUnmounted(() => { if (checkTimer) { clearInterval(checkTimer); checkTimer = null; } });
 </script>
 
 <style lang="scss" scoped>
-.page-header {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  margin-bottom: 20px;
-  
-  h2 { margin: 0; }
-}
+.schedule-page { max-width: 1200px; margin: 0 auto; }
+.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; h2 { margin: 0; } }
+.page-card { background: #fff; border-radius: 8px; padding: 20px 24px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); }
+.text-muted { color: var(--el-text-color-placeholder); font-size: 13px; }
 </style>

+ 10 - 0
client/src/views/Works/index.vue

@@ -31,6 +31,8 @@
         <el-option label="已发布" value="published" />
         <el-option label="审核中" value="reviewing" />
         <el-option label="未通过" value="rejected" />
+        <el-option label="草稿" value="draft" />
+        <el-option label="已删除" value="deleted" />
       </el-select>
       <el-input 
         v-model="filter.keyword" 
@@ -443,6 +445,10 @@ function getStatusType(status: string) {
     reviewing: 'warning',
     rejected: 'danger',
     draft: 'info',
+    deleted: 'danger',
+    failed: 'danger',
+    processing: 'warning',
+    pending: 'info',
   };
   return map[status] || 'info';
 }
@@ -453,6 +459,10 @@ function getStatusText(status: string) {
     reviewing: '审核中',
     rejected: '未通过',
     draft: '草稿',
+    deleted: '已删除',
+    failed: '失败',
+    processing: '发布中',
+    pending: '待发布',
   };
   return map[status] || status;
 }

+ 1 - 1
server/src/automation/platforms/douyin.ts

@@ -403,7 +403,7 @@ export class DouyinAdapter extends BasePlatformAdapter {
       }
 
       if (capturedData.dataOverview) {
-        if (capturedData.dataOverview.fans_count) fansCount = capturedData.dataOverview.fans_count;
+        // dataOverview.fans_count 可能不准确(可能是增量而非总量),不覆盖 userInfo.fans
         if (capturedData.dataOverview.total_works) worksCount = capturedData.dataOverview.total_works;
       }
 

+ 15 - 0
server/src/routes/tasks.ts

@@ -79,4 +79,19 @@ router.post(
   })
 );
 
+/**
+ * 清理已完成的任务
+ */
+router.post(
+  '/clear-completed',
+  asyncHandler(async (req, res) => {
+    const userId = req.user!.userId;
+    taskQueueService.clearCompletedTasks(userId, 0);
+    res.json({
+      success: true,
+      message: '已清理',
+    });
+  })
+);
+
 export default router;

+ 2 - 0
server/src/routes/workDayStatistics.ts

@@ -32,6 +32,7 @@ function runPythonExportXlsx(payload: unknown): Promise<Buffer> {
     const child = spawn(pythonBin, [scriptPath], {
       stdio: ['pipe', 'pipe', 'pipe'],
       windowsHide: true,
+      env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
     });
 
     const stdoutChunks: Buffer[] = [];
@@ -111,6 +112,7 @@ function runPythonExportPlatformXlsx(payload: unknown): Promise<Buffer> {
     const child = spawn(pythonBin, [scriptPath], {
       stdio: ['pipe', 'pipe', 'pipe'],
       windowsHide: true,
+      env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
     });
 
     const stdoutChunks: Buffer[] = [];

+ 50 - 11
server/src/services/PublishService.ts

@@ -53,6 +53,34 @@ export class PublishService {
   }
 
   /**
+   * 将技术性错误消息翻译为用户友好的描述
+   */
+  private friendlyErrorMessage(error: unknown): string {
+    const raw = error instanceof Error ? error.message : String(error || '发布失败');
+
+    const patterns: [RegExp, string][] = [
+      [/CAPTCHA_REQUIRED[::]?\s*(.*)/i, '需要验证码,请使用有头浏览器重新发布'],
+      [/timeout/i, '操作超时,请稍后重试'],
+      [/net::ERR_/i, '网络连接失败,请检查网络'],
+      [/ECONNREFUSED|ECONNRESET/i, '网络连接被拒绝,请检查代理设置'],
+      [/Navigation timeout/i, '页面加载超时,请稍后重试'],
+      [/Target closed|browser closed/i, '浏览器已关闭,请重试'],
+      [/python.*not found|python.*不可用/i, 'Python 服务不可用,请联系管理员'],
+      [/cookie.*过期|cookie.*失效|登录已过/i, '账号登录已过期,请重新登录'],
+      [/请查看截图/i, '发布结果待确认,请前往平台创作者中心查看'],
+      [/502|503|504|Bad Gateway|Service Unavailable/i, '平台服务器繁忙,请稍后重试'],
+      [/upload.*fail|上传.*失败/i, '视频上传失败,请检查视频格式和大小'],
+    ];
+
+    for (const [pattern, message] of patterns) {
+      if (pattern.test(raw)) return message;
+    }
+
+    if (/^[\u4e00-\u9fff]/.test(raw) && raw.length <= 50) return raw;
+    return raw.length > 100 ? raw.slice(0, 100) + '...' : raw;
+  }
+
+  /**
    * 获取平台适配器
    */
   private getAdapter(platform: PlatformType) {
@@ -150,6 +178,9 @@ export class PublishService {
       throw new AppError('所选账号不存在或已被删除', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION);
     }
 
+    // 去重(修复#6034:作品数可能重复)
+    const dedupAccountIds = [...new Set(validAccountIds)];
+
     if (invalidAccountIds.length > 0) {
       logger.warn(`[PublishService] ${invalidAccountIds.length} invalid accounts skipped: ${invalidAccountIds.join(', ')}`);
     }
@@ -162,7 +193,7 @@ export class PublishService {
       description: data.description || null,
       coverPath: data.coverPath || null,
       tags: data.tags || null,
-      targetAccounts: validAccountIds, // 只保存有效的账号 ID
+      targetAccounts: dedupAccountIds, // 只保存有效且去重的账号 ID
       platformConfigs: data.platformConfigs || null,
       publishProxy: data.publishProxy || null,
       status: 'pending', // 初始状态为 pending,任务队列执行时再更新为 processing
@@ -171,8 +202,8 @@ export class PublishService {
 
     await this.taskRepository.save(task);
 
-    // 创建发布结果记录(只为有效账号创建)
-    for (const accountId of validAccountIds) {
+    // 创建发布结果记录(只为有效且去重的账号创建)
+    for (const accountId of dedupAccountIds) {
       const result = this.resultRepository.create({
         taskId: task.id,
         accountId,
@@ -214,9 +245,16 @@ export class PublishService {
     });
 
     const results = task.results || [];
+    // 筛选需要执行的发布结果:跳过已成功的(修复#6038重试把成功的也发了)
+    const resultsToExecute = results.filter(r => r.status !== 'success');
+    const skippedCount = results.length - resultsToExecute.length;
+    if (skippedCount > 0) {
+      logger.info(`Skipping ${skippedCount} already successful accounts`);
+    }
+
     let successCount = 0;
     let failCount = 0;
-    const totalAccounts = results.length;
+    const executeCount = resultsToExecute.length;
     const successAccountIds = new Set<number>();
 
     let publishProxyExtra: Awaited<ReturnType<PublishService['buildPublishProxyExtra']>> = null;
@@ -242,7 +280,7 @@ export class PublishService {
         taskId,
         status: 'failed',
         successCount: 0,
-        failCount: totalAccounts,
+        failCount: results.length,
       });
 
       onProgress?.(100, `发布失败: ${errorMessage}`);
@@ -268,12 +306,12 @@ export class PublishService {
     }
 
     logger.info(`Publishing video: ${videoPath}`);
-    onProgress?.(5, `准备发布到 ${totalAccounts} 个账号...`);
+    onProgress?.(5, `准备发布到 ${executeCount} 个账号...`);
 
     // 遍历所有目标账号,逐个发布
-    for (let i = 0; i < results.length; i++) {
-      const result = results[i];
-      const accountProgress = Math.floor((i / totalAccounts) * 80) + 10;
+    for (let i = 0; i < resultsToExecute.length; i++) {
+      const result = resultsToExecute[i];
+      const accountProgress = Math.floor((i / executeCount) * 80) + 10;
 
       try {
         // 获取账号信息
@@ -437,7 +475,7 @@ export class PublishService {
         } else {
           await this.resultRepository.update(result.id, {
             status: 'failed',
-            errorMessage: publishResult.errorMessage || '发布失败',
+            errorMessage: this.friendlyErrorMessage(publishResult.errorMessage),
           });
           failCount++;
 
@@ -456,9 +494,10 @@ export class PublishService {
 
       } catch (error) {
         logger.error(`Failed to publish to account ${result.accountId}:`, error);
+        const friendlyError = this.friendlyErrorMessage(error);
         await this.resultRepository.update(result.id, {
           status: 'failed',
-          errorMessage: error instanceof Error ? error.message : '发布失败',
+          errorMessage: friendlyError,
         });
         failCount++;
       }

+ 3 - 3
server/src/services/WorkService.ts

@@ -59,9 +59,9 @@ export class WorkService {
       .select([
         'COUNT(*) as totalCount',
         'SUM(CASE WHEN status = "published" THEN 1 ELSE 0 END) as publishedCount',
-        'SUM(play_count) as totalPlayCount',
-        'SUM(like_count) as totalLikeCount',
-        'SUM(comment_count) as totalCommentCount',
+        'CAST(SUM(work.playCount) AS SIGNED BIGINT) as totalPlayCount',
+        'CAST(SUM(work.likeCount) AS SIGNED BIGINT) as totalLikeCount',
+        'CAST(SUM(work.commentCount) AS SIGNED BIGINT) as totalCommentCount',
       ])
       .where('work.userId = :userId', { userId })
       .getRawOne();