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

Merge fix/bugfix-batch-20260318: 修复智媒通20个Bug (#6066-#6085)

ethanfly 3 дней назад
Родитель
Сommit
0d04c0e5ab

+ 116 - 0
client/src/App.vue

@@ -1,11 +1,39 @@
 <template>
   <el-config-provider :locale="zhCn">
+    <!-- 全局加载界面(Splash Screen) -->
+    <transition name="splash-fade">
+      <div v-if="showSplash" class="splash-screen">
+        <div class="splash-content">
+          <div class="splash-logo">智媒通</div>
+          <div class="splash-dots">
+            <span></span>
+            <span></span>
+            <span></span>
+          </div>
+          <p class="splash-text">正在加载...</p>
+        </div>
+      </div>
+    </transition>
     <router-view />
   </el-config-provider>
 </template>
 
 <script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import { useRouter } from 'vue-router';
 import zhCn from 'element-plus/es/locale/lang/zh-cn';
+
+const showSplash = ref(true);
+const router = useRouter();
+
+onMounted(() => {
+  // 路由首次导航完成后隐藏加载界面
+  router.isReady().then(() => {
+    setTimeout(() => {
+      showSplash.value = false;
+    }, 300);
+  });
+});
 </script>
 
 <style>
@@ -15,3 +43,91 @@ html, body, #app {
   padding: 0;
 }
 </style>
+
+<style scoped>
+.splash-screen {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 99999;
+}
+
+.splash-content {
+  text-align: center;
+  color: #fff;
+}
+
+.splash-logo {
+  font-size: 42px;
+  font-weight: 700;
+  letter-spacing: 4px;
+  margin-bottom: 40px;
+  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+}
+
+.splash-dots {
+  display: flex;
+  justify-content: center;
+  gap: 8px;
+  margin-bottom: 24px;
+}
+
+.splash-dots span {
+  width: 10px;
+  height: 10px;
+  border-radius: 50%;
+  background: rgba(255, 255, 255, 0.6);
+  animation: dot-bounce 1.4s ease-in-out infinite;
+}
+
+.splash-dots span:nth-child(1) {
+  animation-delay: 0s;
+}
+
+.splash-dots span:nth-child(2) {
+  animation-delay: 0.2s;
+}
+
+.splash-dots span:nth-child(3) {
+  animation-delay: 0.4s;
+}
+
+@keyframes dot-bounce {
+  0%, 80%, 100% {
+    transform: scale(0.6);
+    opacity: 0.4;
+    background: rgba(255, 255, 255, 0.4);
+  }
+  40% {
+    transform: scale(1);
+    opacity: 1;
+    background: #fff;
+  }
+}
+
+.splash-text {
+  font-size: 16px;
+  opacity: 0.8;
+  margin: 0;
+  animation: text-pulse 2s ease-in-out infinite;
+}
+
+@keyframes text-pulse {
+  0%, 100% { opacity: 0.5; }
+  50% { opacity: 1; }
+}
+
+.splash-fade-leave-active {
+  transition: opacity 0.4s ease;
+}
+
+.splash-fade-leave-to {
+  opacity: 0;
+}
+</style>

+ 28 - 0
client/src/api/request.ts

@@ -78,6 +78,10 @@ function addRefreshSubscriber(callback: (token: string) => void) {
   refreshSubscribers.push(callback);
 }
 
+// GET 请求重试机制:对网络错误和超时自动重试
+const GET_RETRY_COUNT = 2;
+const GET_RETRY_DELAY = 1000; // 1秒
+
 // 响应拦截器
 request.interceptors.response.use(
   (response: AxiosResponse<ApiResponse>) => {
@@ -94,6 +98,18 @@ request.interceptors.response.use(
   async (error) => {
     const originalRequest = error.config;
 
+    // GET 请求自动重试:网络错误或超时时重试,避免后端暂时不可用导致失败
+    const isGetRequest = originalRequest.method?.toLowerCase() === 'get';
+    const isRetryable = !error.response && (error.code === 'ERR_NETWORK' || error.code === 'ECONNABORTED');
+    const retryCount = (originalRequest as any)._retryCount ?? 0;
+
+    if (isGetRequest && isRetryable && retryCount < GET_RETRY_COUNT && !originalRequest.url?.includes('/api/auth/')) {
+      (originalRequest as any)._retryCount = retryCount + 1;
+      console.warn(`[Request] Retrying GET ${originalRequest.url} (attempt ${retryCount + 1}/${GET_RETRY_COUNT})`);
+      await new Promise(resolve => setTimeout(resolve, GET_RETRY_DELAY));
+      return request(originalRequest);
+    }
+
     // 排除不需要刷新 token 的请求
     const isAuthRequest = originalRequest.url?.includes('/api/auth/refresh')
       || originalRequest.url?.includes('/api/auth/login')
@@ -160,6 +176,18 @@ request.interceptors.response.use(
       return Promise.reject(error);
     }
 
+    // 网络错误(无响应):不弹错误弹窗,仅在控制台记录,避免后端未就绪时频繁弹窗干扰用户
+    if (!error.response && error.code === 'ERR_NETWORK') {
+      console.warn('[Request] Network error (backend may be unavailable):', error.message, error.config?.url);
+      return Promise.reject(error);
+    }
+
+    // 请求超时:不弹错误弹窗,仅在控制台记录
+    if (error.code === 'ECONNABORTED' && error.message?.includes('timeout')) {
+      console.warn('[Request] Request timeout:', error.config?.url);
+      return Promise.reject(error);
+    }
+
     // 其他错误
     const message = error.response?.data?.error?.message
       || error.response?.data?.message

+ 19 - 0
client/src/components/TaskProgressDialog.vue

@@ -13,6 +13,7 @@ import {
   ChatLineSquare,
 } from '@element-plus/icons-vue';
 import { useTaskQueueStore } from '@/stores/taskQueue';
+import { PLATFORMS } from '@media-manager/shared';
 import type { Task, TaskType, TaskStatus } from '@media-manager/shared';
 import dayjs from 'dayjs';
 
@@ -66,6 +67,11 @@ function getStatusConfig(status: TaskStatus) {
   return statusConfig[status] || statusConfig.pending;
 }
 
+function getPlatformName(platform?: string): string {
+  if (!platform) return '';
+  return PLATFORMS[platform as keyof typeof PLATFORMS]?.name || platform;
+}
+
 function formatTime(time?: string) {
   if (!time) return '-';
   return dayjs(time).format('HH:mm:ss');
@@ -117,6 +123,9 @@ function handleClearCompleted() {
               <div class="task-info">
                 <el-icon class="task-icon"><component :is="getIcon(task.type)" /></el-icon>
                 <span class="task-title">{{ task.title }}</span>
+                <el-tag v-if="task.platform" size="small" type="info" class="platform-tag">
+                  {{ getPlatformName(task.platform) }}
+                </el-tag>
               </div>
               <ElTag size="small" :type="getStatusConfig(task.status).type">
                 {{ getStatusConfig(task.status).text }}
@@ -149,6 +158,9 @@ function handleClearCompleted() {
               <div class="task-info">
                 <el-icon class="task-icon"><component :is="getIcon(task.type)" /></el-icon>
                 <span class="task-title">{{ task.title }}</span>
+                <el-tag v-if="task.platform" size="small" type="info" class="platform-tag">
+                  {{ getPlatformName(task.platform) }}
+                </el-tag>
               </div>
               <div class="task-actions">
                 <ElTag size="small" type="info">等待中</ElTag>
@@ -191,6 +203,9 @@ function handleClearCompleted() {
               <div class="task-info">
                 <el-icon class="task-icon"><component :is="getIcon(task.type)" /></el-icon>
                 <span class="task-title">{{ task.title }}</span>
+                <el-tag v-if="task.platform" size="small" type="info" class="platform-tag">
+                  {{ getPlatformName(task.platform) }}
+                </el-tag>
               </div>
               <ElTag size="small" :type="getStatusConfig(task.status).type">
                 {{ getStatusConfig(task.status).text }}
@@ -330,6 +345,10 @@ function handleClearCompleted() {
   color: $text-primary;
 }
 
+.platform-tag {
+  flex-shrink: 0;
+}
+
 .task-actions {
   display: flex;
   align-items: center;

+ 12 - 0
client/src/stores/taskQueue.ts

@@ -41,6 +41,11 @@ export const useTaskQueueStore = defineStore('taskQueue', () => {
 
   const activeTaskCount = computed(() => activeTasks.value.length);
 
+  // 当前正在运行的作品同步任务(用于 Works 页面显示"停止同步"按钮)
+  const runningSyncWorksTask = computed(() =>
+    tasks.value.find(t => t.type === 'sync_works' && t.status === 'running')
+  );
+
   // 获取任务类型配置
   const getTaskConfig = (type: TaskType) => TASK_TYPE_CONFIG[type];
 
@@ -284,6 +289,12 @@ export const useTaskQueueStore = defineStore('taskQueue', () => {
       accountRefreshTrigger.value++;
     }
     
+    // Bug #6070: 账号数据变更事件(新增/修改头像昵称等),触发前端模块刷新
+    if (data.type === 'account:data_changed') {
+      console.log('[TaskQueue] Account data changed event received:', data.type);
+      accountRefreshTrigger.value++;
+    }
+    
     // 处理验证码事件
     // 1. 通过 type 判断
     // 2. 或者 payload 中包含 captchaTaskId 字段(兼容 type 为 undefined 的情况)
@@ -561,6 +572,7 @@ export const useTaskQueueStore = defineStore('taskQueue', () => {
     runningTasks,
     hasActiveTasks,
     activeTaskCount,
+    runningSyncWorksTask,
     // 方法
     getTaskConfig,
     connectWebSocket,

+ 34 - 1
client/src/views/Accounts/index.vue

@@ -114,7 +114,7 @@
           </template>
         </el-table-column>
         
-        <el-table-column label="操作" width="180" fixed="right">
+        <el-table-column label="操作" width="240" fixed="right">
           <template #default="{ row }">
             <el-button type="primary" link size="small" @click="openPlatformAdmin(row)">
               后台
@@ -122,6 +122,24 @@
             <el-button type="primary" link size="small" @click="refreshAccount(row.id)">
               刷新
             </el-button>
+            <el-button
+              v-if="row.status !== 'disabled'"
+              type="danger"
+              link
+              size="small"
+              @click="toggleAccountStatus(row.id, 'disabled')"
+            >
+              禁用
+            </el-button>
+            <el-button
+              v-else
+              type="success"
+              link
+              size="small"
+              @click="toggleAccountStatus(row.id, 'active')"
+            >
+              启用
+            </el-button>
             <el-button type="danger" link size="small" @click="deleteAccount(row.id)">
               删除
             </el-button>
@@ -698,6 +716,21 @@ async function deleteAccount(id: number) {
   }
 }
 
+// 切换账号启用/禁用状态
+async function toggleAccountStatus(id: number, newStatus: 'active' | 'disabled') {
+  const actionText = newStatus === 'disabled' ? '禁用' : '启用';
+  try {
+    await ElMessageBox.confirm(`确定要${actionText}该账号吗?`, '提示', {
+      type: 'warning',
+    });
+    await accountsApi.updateAccount(id, { status: newStatus });
+    ElMessage.success(`账号已${actionText}`);
+    loadAccounts();
+  } catch {
+    // 取消或错误
+  }
+}
+
 // 分组管理方法
 function getGroupAccountCount(groupId: number): number {
   // 使用全量账号列表计算(accounts.value 可能已被筛选条件过滤)

+ 50 - 2
client/src/views/Profile/index.vue

@@ -145,8 +145,56 @@ function formatDate(date?: string | null) {
 }
 
 function handleAvatarChange(file: UploadFile) {
-  // TODO: 上传头像
-  ElMessage.info('头像上传功能开发中');
+  if (!file.raw) return;
+
+  // 校验文件类型
+  const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
+  if (!allowedTypes.includes(file.raw.type)) {
+    ElMessage.error('仅支持 JPG、PNG、GIF、WebP 格式的图片');
+    return;
+  }
+
+  // 校验文件大小(2MB)
+  if (file.raw.size > 2 * 1024 * 1024) {
+    ElMessage.error('头像图片不能超过 2MB');
+    return;
+  }
+
+  const formData = new FormData();
+  formData.append('image', file.raw);
+
+  // 使用 axios 直接上传(需要手动设置 baseURL)
+  const serverStore = (await import('@/stores/server')).useServerStore();
+  const baseUrl = serverStore.currentServer?.url;
+  if (!baseUrl) {
+    ElMessage.error('未配置服务器地址');
+    return;
+  }
+
+  const authStoreLocal = (await import('@/stores/auth')).useAuthStore();
+
+  fetch(`${baseUrl}/api/upload/avatar`, {
+    method: 'POST',
+    headers: {
+      Authorization: `Bearer ${authStoreLocal.accessToken}`,
+    },
+    body: formData,
+  })
+    .then(async (res) => {
+      if (!res.ok) throw new Error('上传失败');
+      const json = await res.json();
+      if (!json.success) throw new Error(json.message || '上传失败');
+      return json.data;
+    })
+    .then(async (data: { path: string }) => {
+      // 更新用户头像
+      const updatedUser = await authApi.updateProfile({ avatarUrl: data.path });
+      authStore.user = updatedUser;
+      ElMessage.success('头像更新成功');
+    })
+    .catch(() => {
+      ElMessage.error('头像上传失败');
+    });
 }
 
 async function saveProfile() {

+ 141 - 14
client/src/views/Publish/index.vue

@@ -37,9 +37,17 @@
           </template>
         </el-table-column>
         
-        <el-table-column label="目标账号" width="120">
+        <el-table-column label="目标账号" min-width="180">
           <template #default="{ row }">
-            {{ row.targetAccounts?.length || 0 }} 个
+            <span>{{ row.targetAccounts?.length || 0 }} 个</span>
+            <el-tag
+              v-for="platform in getTaskPlatforms(row)"
+              :key="platform"
+              size="small"
+              style="margin-left: 4px"
+            >
+              {{ getPlatformName(platform) }}
+            </el-tag>
           </template>
         </el-table-column>
         
@@ -113,7 +121,17 @@
     <!-- 创建发布对话框 -->
     <el-dialog v-model="showCreateDialog" title="新建发布" width="600px">
       <el-form :model="createForm" label-width="100px">
-        <el-form-item label="视频文件">
+        <!-- 平台提示:根据选择的目标平台动态显示必填要求 -->
+        <el-alert
+          v-if="createSelectedPlatforms.length > 0"
+          :title="createPlatformHint"
+          type="info"
+          :closable="false"
+          show-icon
+          style="margin-bottom: 16px"
+        />
+        
+        <el-form-item label="视频文件" :required="createRequireVideo">
           <el-upload
             action=""
             :auto-upload="false"
@@ -125,17 +143,26 @@
           <span v-if="createForm.videoFile" style="margin-left: 12px">
             {{ createForm.videoFile.name }}
           </span>
+          <span v-if="createRequireImage && !createForm.videoFile" class="form-tip">
+            也可上传图片发布
+          </span>
         </el-form-item>
         
-        <el-form-item label="标题">
-          <el-input v-model="createForm.title" placeholder="视频标题" />
+        <el-form-item v-if="createRequireTitle" label="标题" required>
+          <el-input v-model="createForm.title" placeholder="视频标题" show-word-limit :maxlength="createTitleMaxLength" />
+        </el-form-item>
+        <el-form-item v-else label="标题">
+          <el-input v-model="createForm.title" placeholder="视频标题(可选)" />
         </el-form-item>
         
-        <el-form-item label="描述">
-          <el-input v-model="createForm.description" type="textarea" :rows="3" placeholder="视频描述" />
+        <el-form-item v-if="createRequireDescription" label="描述" required>
+          <el-input v-model="createForm.description" type="textarea" :rows="3" placeholder="视频描述" show-word-limit :maxlength="createDescMaxLength" />
+        </el-form-item>
+        <el-form-item v-else label="描述">
+          <el-input v-model="createForm.description" type="textarea" :rows="3" placeholder="视频描述(可选)" />
         </el-form-item>
         
-        <el-form-item label="标签">
+        <el-form-item v-if="createShowTags" label="标签">
           <el-select v-model="createForm.tags" multiple filterable allow-create placeholder="添加标签" style="width: 100%">
           </el-select>
         </el-form-item>
@@ -217,7 +244,7 @@
             </el-tag>
             <span v-if="!currentTask.tags?.length">-</span>
           </el-descriptions-item>
-          <el-descriptions-item label="目标账号">{{ currentTask.targetAccounts?.length || 0 }} 个</el-descriptions-item>
+          <el-descriptions-item label="目标账号">{{ taskDetail?.results?.length || currentTask?.targetAccounts?.length || 0 }} 个</el-descriptions-item>
           <el-descriptions-item label="定时发布">
             {{ currentTask.scheduledAt ? formatDate(currentTask.scheduledAt) : '立即发布' }}
           </el-descriptions-item>
@@ -451,6 +478,74 @@ const filteredTasks = computed(() => {
   );
 });
 
+// ===== Bug #6069: 创建表单平台感知字段 =====
+
+// 当前选中的目标平台列表
+const createSelectedPlatforms = computed<PlatformType[]>(() => {
+  const ids = new Set(createForm.targetAccounts);
+  const platforms = new Set<PlatformType>();
+  for (const account of accounts.value) {
+    if (ids.has(Number(account.id))) {
+      platforms.add(account.platform);
+    }
+  }
+  return Array.from(platforms);
+});
+
+// 各平台发布要求
+const PLATFORM_PUBLISH_REQUIREMENTS: Record<string, {
+  requireTitle: boolean;
+  requireDescription: boolean;
+  requireVideo: boolean;
+  requireImage: boolean;
+  showTags: boolean;
+}> = {
+  douyin: { requireTitle: true, requireDescription: false, requireVideo: true, requireImage: false, showTags: true },
+  xiaohongshu: { requireTitle: true, requireDescription: true, requireVideo: false, requireImage: true, showTags: true },
+  weixin_video: { requireTitle: true, requireDescription: false, requireVideo: true, requireImage: false, showTags: true },
+  baijiahao: { requireTitle: true, requireDescription: true, requireVideo: false, requireImage: false, showTags: true },
+  kuaishou: { requireTitle: true, requireDescription: false, requireVideo: true, requireImage: false, showTags: true },
+  bilibili: { requireTitle: true, requireDescription: false, requireVideo: true, requireImage: false, showTags: true },
+};
+
+// 只要任一选中平台要求某字段,就显示必填
+const createRequireTitle = computed(() => createSelectedPlatforms.value.some(p => PLATFORM_PUBLISH_REQUIREMENTS[p]?.requireTitle));
+const createRequireDescription = computed(() => createSelectedPlatforms.value.some(p => PLATFORM_PUBLISH_REQUIREMENTS[p]?.requireDescription));
+const createRequireVideo = computed(() => createSelectedPlatforms.value.some(p => PLATFORM_PUBLISH_REQUIREMENTS[p]?.requireVideo));
+const createRequireImage = computed(() => createSelectedPlatforms.value.some(p => PLATFORM_PUBLISH_REQUIREMENTS[p]?.requireImage));
+const createShowTags = computed(() => createSelectedPlatforms.value.some(p => PLATFORM_PUBLISH_REQUIREMENTS[p]?.showTags));
+
+// 取最严格的标题/描述长度限制
+const createTitleMaxLength = computed(() => {
+  let max = 100;
+  for (const p of createSelectedPlatforms.value) {
+    const info = PLATFORMS[p];
+    if (info?.maxTitleLength && info.maxTitleLength < max) max = info.maxTitleLength;
+  }
+  return max;
+});
+const createDescMaxLength = computed(() => {
+  let max = 2000;
+  for (const p of createSelectedPlatforms.value) {
+    const info = PLATFORMS[p];
+    if (info?.maxDescriptionLength && info.maxDescriptionLength < max) max = info.maxDescriptionLength;
+  }
+  return max;
+});
+
+// 平台提示文案
+const createPlatformHint = computed(() => {
+  const platforms = createSelectedPlatforms.value;
+  if (!platforms.length) return '';
+  const names = platforms.map(p => PLATFORMS[p]?.name || p).join('、');
+  const tips: string[] = [];
+  if (createRequireTitle.value) tips.push('标题必填');
+  if (createRequireDescription.value) tips.push('正文必填');
+  if (createRequireVideo.value) tips.push('视频必填');
+  if (createRequireImage.value) tips.push('图片或视频必填');
+  return `已选平台:${names}。要求:${tips.join('、')}`;
+});
+
 const pagination = reactive({
   page: 1,
   pageSize: 20,
@@ -617,6 +712,18 @@ function getPlatformName(platform: PlatformType) {
   return PLATFORMS[platform]?.name || platform;
 }
 
+// 根据 targetAccounts 获取关联的平台列表(Bug #6066: 显示渠道)
+function getTaskPlatforms(task: PublishTask): PlatformType[] {
+  const ids = new Set(task.targetAccounts || []);
+  const platforms = new Set<PlatformType>();
+  for (const account of accounts.value) {
+    if (ids.has(Number(account.id))) {
+      platforms.add(account.platform);
+    }
+  }
+  return Array.from(platforms);
+}
+
 function getStatusType(status: string) {
   const types: Record<string, string> = {
     pending: 'info',
@@ -653,13 +760,13 @@ function isScreenshotPendingError(errorMessage: string | null | undefined): bool
   return !!errorMessage && errorMessage.includes('请查看截图');
 }
 
-// 是否可以使用有头浏览器重新发布(目前主要用于小红书发布失败场景)
+// 是否可以使用有头浏览器重新发布
 function canRetryWithHeadful(row: { platform: string; status: string | null | undefined }): boolean {
   if (row.status !== 'failed') return false;
-  return row.platform === 'xiaohongshu' || row.platform === 'baijiahao';
+  return row.platform === 'xiaohongshu' || row.platform === 'baijiahao' || row.platform === 'douyin' || row.platform === 'weixin_video';
 }
 
-// 打开查看截图(小红书等平台暂打开创作者中心,用户可自行查看发布状态)
+// 打开查看截图(平台暂打开创作者中心,用户可自行查看发布状态)
 function openScreenshotView(row: { platform: string; accountId: number }) {
   if (row.platform === 'xiaohongshu') {
     window.open('https://creator.xiaohongshu.com/publish/publish', '_blank', 'noopener,noreferrer');
@@ -669,6 +776,14 @@ function openScreenshotView(row: { platform: string; accountId: number }) {
     window.open('https://baijiahao.baidu.com/builder/rc/content', '_blank', 'noopener,noreferrer');
     return;
   }
+  if (row.platform === 'douyin') {
+    window.open('https://creator.douyin.com/creator-micro/content/upload', '_blank', 'noopener,noreferrer');
+    return;
+  }
+  if (row.platform === 'weixin_video') {
+    window.open('https://channels.weixin.qq.com/platform', '_blank', 'noopener,noreferrer');
+    return;
+  }
   ElMessage.info('请前往对应平台查看发布状态');
 }
 
@@ -786,8 +901,20 @@ function handleFileChange(file: UploadFile) {
 }
 
 async function handleCreate() {
-  if (!createForm.videoFile || !createForm.title || createForm.targetAccounts.length === 0) {
-    ElMessage.warning('请填写完整信息');
+  if (createForm.targetAccounts.length === 0) {
+    ElMessage.warning('请至少选择一个目标账号');
+    return;
+  }
+  if (createRequireTitle.value && !createForm.title) {
+    ElMessage.warning('所选平台要求标题必填');
+    return;
+  }
+  if (createRequireDescription.value && !createForm.description) {
+    ElMessage.warning('所选平台要求正文必填');
+    return;
+  }
+  if (createRequireVideo.value && !createForm.videoFile) {
+    ElMessage.warning('所选平台要求视频必填');
     return;
   }
 

+ 5 - 5
client/src/views/Schedule/index.vue

@@ -82,11 +82,11 @@
         </el-form-item>
         <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-button label="once">仅一次</el-radio-button>
+            <el-radio-button label="daily">每天</el-radio-button>
+            <el-radio-button label="weekday">工作日</el-radio-button>
+            <el-radio-button label="weekly">每周</el-radio-button>
+            <el-radio-button label="custom">自定义</el-radio-button>
           </el-radio-group>
         </el-form-item>
         <el-form-item v-if="form.repeatType === 'weekly'" label="周几">

+ 89 - 1
client/src/views/Settings/index.vue

@@ -113,6 +113,37 @@
             </el-table-column>
           </el-table>
         </div>
+
+        <!-- 新增用户对话框 -->
+        <el-dialog
+          v-model="showAddUserDialog"
+          title="新增用户"
+          width="480px"
+          @closed="resetUserForm"
+        >
+          <el-form :model="userForm" :rules="userFormRules" ref="userFormRef" label-width="80px">
+            <el-form-item label="用户名" prop="username">
+              <el-input v-model="userForm.username" placeholder="请输入用户名" />
+            </el-form-item>
+            <el-form-item label="密码" prop="password">
+              <el-input v-model="userForm.password" type="password" show-password placeholder="请输入密码" />
+            </el-form-item>
+            <el-form-item label="邮箱" prop="email">
+              <el-input v-model="userForm.email" placeholder="请输入邮箱(可选)" />
+            </el-form-item>
+            <el-form-item label="角色" prop="role">
+              <el-select v-model="userForm.role" style="width: 100%">
+                <el-option label="运营" value="operator" />
+                <el-option label="编辑" value="editor" />
+                <el-option label="管理员" value="admin" />
+              </el-select>
+            </el-form-item>
+          </el-form>
+          <template #footer>
+            <el-button @click="showAddUserDialog = false">取消</el-button>
+            <el-button type="primary" @click="handleAddUser" :loading="addingUser">确定</el-button>
+          </template>
+        </el-dialog>
       </el-tab-pane>
       
       <el-tab-pane label="系统状态" name="status">
@@ -142,7 +173,7 @@
 
 <script setup lang="ts">
 import { ref, reactive, onMounted } from 'vue';
-import { ElMessage, ElMessageBox } from 'element-plus';
+import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
 import request from '@/api/request';
 import { useAuthStore } from '@/stores/auth';
 import type { User } from '@media-manager/shared';
@@ -170,6 +201,63 @@ const pythonCheckResult = ref<any | null>(null);
 
 const users = ref<User[]>([]);
 const showAddUserDialog = ref(false);
+const addingUser = ref(false);
+const userFormRef = ref<FormInstance>();
+
+const userForm = reactive({
+  username: '',
+  password: '',
+  email: '',
+  role: 'operator',
+});
+
+const userFormRules: FormRules = {
+  username: [
+    { required: true, message: '请输入用户名', trigger: 'blur' },
+    { min: 3, max: 50, message: '用户名长度为3-50个字符', trigger: 'blur' },
+  ],
+  password: [
+    { required: true, message: '请输入密码', trigger: 'blur' },
+    { min: 6, message: '密码长度至少6个字符', trigger: 'blur' },
+  ],
+  email: [
+    { type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
+  ],
+  role: [
+    { required: true, message: '请选择角色', trigger: 'change' },
+  ],
+};
+
+function resetUserForm() {
+  userForm.username = '';
+  userForm.password = '';
+  userForm.email = '';
+  userForm.role = 'operator';
+  userFormRef.value?.resetFields();
+}
+
+async function handleAddUser() {
+  if (!userFormRef.value) return;
+  const valid = await userFormRef.value.validate().catch(() => false);
+  if (!valid) return;
+
+  addingUser.value = true;
+  try {
+    await request.post('/api/users', {
+      username: userForm.username,
+      password: userForm.password,
+      email: userForm.email || undefined,
+      role: userForm.role,
+    });
+    ElMessage.success('用户创建成功');
+    showAddUserDialog.value = false;
+    loadUsers();
+  } catch {
+    // 错误已由拦截器处理
+  } finally {
+    addingUser.value = false;
+  }
+}
 
 const systemStatus = reactive({
   database: 'disconnected',

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

@@ -45,10 +45,14 @@
         <el-icon><Search /></el-icon>
         搜索
       </el-button>
-      <el-button @click="refreshAllWorks" :loading="refreshing">
+      <el-button @click="refreshAllWorks" :loading="refreshing" v-if="!taskStore.runningSyncWorksTask">
         <el-icon><Refresh /></el-icon>
         同步作品
       </el-button>
+      <el-button type="danger" @click="stopSyncWorks" v-else>
+        <el-icon><CircleCloseFilled /></el-icon>
+        停止同步
+      </el-button>
       <el-button type="success" @click="syncAllComments" :loading="syncingComments">
         <el-icon><ChatDotSquare /></el-icon>
         同步评论
@@ -540,11 +544,18 @@ async function loadWorks() {
   } finally {
     loading.value = false;
   }
+  // 同步更新统计数据(应用当前筛选条件)
+  loadStats();
 }
 
 async function loadStats() {
   try {
-    const result = await request.get('/api/works/stats') as WorkStats;
+    const params: Record<string, unknown> = {};
+    if (filter.accountId) params.accountId = filter.accountId;
+    if (filter.platform) params.platform = filter.platform;
+    if (filter.status) params.status = filter.status;
+    if (filter.keyword) params.keyword = filter.keyword;
+    const result = await request.get('/api/works/stats', { params }) as WorkStats;
     if (result) {
       stats.value = result;
     }
@@ -590,6 +601,21 @@ async function refreshAllWorks() {
   }
 }
 
+// 停止正在运行的作品同步任务
+async function stopSyncWorks() {
+  const task = taskStore.runningSyncWorksTask;
+  if (!task) return;
+  
+  try {
+    const ok = await taskStore.cancelTask(task.id);
+    if (ok) {
+      ElMessage.success('已发送取消请求,同步任务将停止');
+    }
+  } catch {
+    ElMessage.error('取消同步任务失败');
+  }
+}
+
 // WebSocket 连接用于接收同步结果
 let ws: WebSocket | null = null;
 let wsReconnectTimer: ReturnType<typeof setTimeout> | null = null;

+ 2 - 0
server/python/app.py

@@ -1276,6 +1276,8 @@ def get_works():
         # 执行获取作品
         if platform == "xiaohongshu" and auto_paging and hasattr(publisher, "get_all_works"):
             result = asyncio.run(publisher.get_all_works(cookie_str))
+        elif platform == "baijiahao" and auto_paging and hasattr(publisher, "get_all_works"):
+            result = asyncio.run(publisher.get_all_works(cookie_str))
         else:
             result = asyncio.run(publisher.run_get_works(cookie_str, page, page_size))
         

+ 263 - 1
server/python/platforms/baijiahao.py

@@ -3018,7 +3018,269 @@ class BaijiahaoPublisher(BasePublisher):
             has_more=has_more,
             next_page=next_page
         )
-    
+
+    async def get_all_works(self, cookies: str) -> WorksResult:
+        """
+        获取百家号全部作品列表(自动分页,复用浏览器实例)。
+        避免每页都启动新浏览器导致的性能问题和风控触发。
+        """
+        import re
+
+        print(f"\n{'='*60}")
+        print(f"[{self.platform_name}] 获取全部作品列表(自动分页)")
+        print(f"{'='*60}")
+
+        all_works: List[WorkItem] = []
+        seen_ids = set()
+        total = 0
+        current_page = 1
+        page_size = 20
+        max_pages = 50  # 最多50页
+
+        try:
+            cookie_list = self.parse_cookies(cookies)
+            await self.init_browser()
+            await self.set_cookies(cookie_list)
+
+            if not self.page:
+                raise Exception("Page not initialized")
+
+            # 打开内容管理页以建立会话并提取 token
+            content_url = (
+                "https://baijiahao.baidu.com/builder/rc/content"
+                f"?currentPage={current_page}&pageSize={page_size}"
+                "&search=&type=&collection=&startDate=&endDate="
+            )
+            await self.page.goto(content_url, wait_until="domcontentloaded", timeout=60000)
+            await asyncio.sleep(3)
+
+            # 检查登录状态
+            current_url = self.page.url
+            if "passport.baidu.com" in current_url or "login" in current_url:
+                raise Exception("Cookie 已过期,请重新登录百家号账号")
+
+            # 提取 token
+            token = await self.page.evaluate(
+                """
+                () => {
+                  const isJwtLike = (v) => {
+                    if (!v || typeof v !== 'string') return false;
+                    const s = v.trim();
+                    if (s.length < 60) return false;
+                    const parts = s.split('.');
+                    if (parts.length !== 3) return false;
+                    return parts.every(p => /^[A-Za-z0-9_-]+$/.test(p) && p.length > 10);
+                  };
+
+                  const pickFromStorage = (storage) => {
+                    try {
+                      const keys = Object.keys(storage || {});
+                      for (const k of keys) {
+                        const v = storage.getItem(k);
+                        if (isJwtLike(v)) return v;
+                      }
+                    } catch {}
+                    return "";
+                  };
+
+                  let t = pickFromStorage(window.localStorage);
+                  if (t) return t;
+                  t = pickFromStorage(window.sessionStorage);
+                  if (t) return t;
+
+                  const meta = document.querySelector('meta[name="token"], meta[name="bjh-token"]');
+                  const metaToken = meta && meta.getAttribute('content');
+                  if (isJwtLike(metaToken)) return metaToken;
+
+                  const candidates = [
+                    (window.__INITIAL_STATE__ && window.__INITIAL_STATE__.token) || "",
+                    (window.__PRELOADED_STATE__ && window.__PRELOADED_STATE__.token) || "",
+                    (window.__NUXT__ && window.__NUXT__.state && window.__NUXT__.state.token) || "",
+                  ];
+                  for (const c of candidates) {
+                    if (isJwtLike(c)) return c;
+                  }
+
+                  return "";
+                }
+                """
+            )
+
+            if not token:
+                html = await self.page.content()
+                m = re.search(r'([A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})', html)
+                if m:
+                    token = m.group(1)
+
+            if not token:
+                raise Exception("未能从页面提取 token(可能未登录或触发风控),请重新登录百家号账号后再试")
+
+            print(f"[{self.platform_name}] ✓ Token 提取成功")
+
+            def _pick_cover(item: dict) -> str:
+                cover = item.get("crosswise_cover") or item.get("vertical_cover") or ""
+                if cover:
+                    return cover
+                raw = item.get("cover_images") or ""
+                try:
+                    parsed = json.loads(raw) if isinstance(raw, str) else raw
+                    if isinstance(parsed, list) and parsed:
+                        first = parsed[0]
+                        if isinstance(first, dict):
+                            return first.get("src") or first.get("ori_src") or ""
+                        if isinstance(first, str):
+                            return first
+                except Exception:
+                    pass
+                return ""
+
+            def _pick_duration(item: dict) -> int:
+                for k in ("rmb_duration", "duration", "long"):
+                    try:
+                        v = int(item.get(k) or 0)
+                        if v > 0:
+                            return v
+                    except Exception:
+                        pass
+                ex = item.get("displaytype_exinfo") or ""
+                try:
+                    exj = json.loads(ex) if isinstance(ex, str) and ex else (ex if isinstance(ex, dict) else {})
+                    ugc = (exj.get("ugcvideo") or {}) if isinstance(exj, dict) else {}
+                    vi = ugc.get("video_info") or {}
+                    v = int(vi.get("durationInSecond") or ugc.get("long") or 0)
+                    return v if v > 0 else 0
+                except Exception:
+                    return 0
+
+            def _pick_status(item: dict) -> str:
+                qs = str(item.get("quality_status") or "").lower()
+                st = str(item.get("status") or "").lower()
+                if qs == "rejected" or "reject" in st:
+                    return "rejected"
+                if st in ("draft", "unpublish", "unpublished"):
+                    return "draft"
+                return "published"
+
+            # 分页循环
+            for page_iter in range(max_pages):
+                page_num = page_iter + 1  # 百家号 currentPage 从 1 开始
+                api_url = (
+                    "https://baijiahao.baidu.com/pcui/article/lists"
+                    f"?currentPage={page_num}"
+                    f"&pageSize={page_size}"
+                    "&search=&type=&collection=&startDate=&endDate="
+                    "&clearBeforeFetch=false"
+                    "&dynamic=1"
+                )
+
+                resp = await self.page.evaluate(
+                    """
+                    async ({ url, token }) => {
+                      const r = await fetch(url, {
+                        method: 'GET',
+                        credentials: 'include',
+                        headers: {
+                          'accept': 'application/json, text/plain, */*',
+                          ...(token ? { token } : {}),
+                        },
+                      });
+                      const text = await r.text();
+                      return { ok: r.ok, status: r.status, text };
+                    }
+                    """,
+                    {"url": api_url, "token": token},
+                )
+
+                if not resp or not resp.get("ok"):
+                    print(f"[{self.platform_name}] 第 {page_num} 页请求失败: HTTP {resp.get('status') if isinstance(resp, dict) else 'unknown'}")
+                    break
+
+                api_result = json.loads(resp.get("text") or "{}")
+                errno = api_result.get("errno", -1)
+
+                if errno != 0:
+                    errmsg = api_result.get("errmsg", "unknown error")
+                    print(f"[{self.platform_name}] 第 {page_num} 页接口错误: errno={errno}, errmsg={errmsg}")
+                    if errno in (110, 20040001):
+                        raise Exception("百家号未登录或 Cookie/token 失效,请重新登录后再同步")
+                    # 非登录错误则停止分页
+                    break
+
+                data = api_result.get("data", {}) or {}
+                items = data.get("list", []) or []
+                page_info = data.get("page", {}) or {}
+
+                if page_iter == 0:
+                    total = int(page_info.get("totalCount", 0) or 0)
+                    print(f"[{self.platform_name}] 作品总数: {total}")
+
+                new_count = 0
+                for item in items:
+                    work_id = str(item.get("nid") or item.get("feed_id") or item.get("article_id") or item.get("id") or "")
+                    if not work_id or work_id in seen_ids:
+                        continue
+                    seen_ids.add(work_id)
+                    new_count += 1
+                    all_works.append(
+                        WorkItem(
+                            work_id=work_id,
+                            title=str(item.get("title") or ""),
+                            cover_url=_pick_cover(item),
+                            video_url=str(item.get("url") or ""),
+                            duration=_pick_duration(item),
+                            status=_pick_status(item),
+                            publish_time=str(item.get("publish_time") or item.get("publish_at") or item.get("created_at") or ""),
+                            play_count=int(item.get("read_amount") or 0),
+                            like_count=int(item.get("like_amount") or 0),
+                            comment_count=int(item.get("comment_amount") or 0),
+                            share_count=int(item.get("share_amount") or 0),
+                            collect_count=int(item.get("collection_amount") or 0),
+                        )
+                    )
+
+                total_page = int(page_info.get("totalPage", 0) or 0)
+                has_more = bool(total_page and page_num < total_page)
+
+                print(f"[{self.platform_name}] 第 {page_num}/{total_page or '?'} 页: 获取 {new_count} 个新作品, 累计 {len(all_works)}")
+
+                if not has_more or len(items) == 0 or new_count == 0:
+                    break
+
+                # 页间短暂等待,避免过快触发风控
+                await asyncio.sleep(1)
+
+            print(f"[{self.platform_name}] ✓ 自动分页完成,共获取 {len(all_works)} 个作品")
+
+        except Exception as e:
+            import traceback
+            traceback.print_exc()
+            # 如果已获取到部分作品,仍然返回成功
+            if all_works:
+                print(f"[{self.platform_name}] 虽有异常但已获取 {len(all_works)} 个作品,正常返回")
+                return WorksResult(
+                    success=True,
+                    platform=self.platform_name,
+                    works=all_works,
+                    total=total or len(all_works),
+                    has_more=False,
+                    next_page="",
+                )
+            return WorksResult(
+                success=False,
+                platform=self.platform_name,
+                error=str(e),
+                debug_info="baijiahao_get_all_works_failed"
+            )
+
+        return WorksResult(
+            success=True,
+            platform=self.platform_name,
+            works=all_works,
+            total=total or len(all_works),
+            has_more=False,
+            next_page="",
+        )
+
     async def get_article_stats(
         self,
         cookies: str,

+ 9 - 9
server/python/platforms/xiaohongshu.py

@@ -1451,7 +1451,7 @@ class XiaohongshuPublisher(BasePublisher):
                     flush=True,
                 )
 
-            async def fetch_notes_page(p):
+            async def fetch_notes_page(p, ps=None):
                 # 再次检查签名函数(每次调用前都检查)
                 sign_available = await self.page.evaluate("""() => {
                     return typeof window !== 'undefined' && typeof window._webmsxyw === 'function';
@@ -1465,10 +1465,10 @@ class XiaohongshuPublisher(BasePublisher):
                     await asyncio.sleep(2)
 
                 return await self.page.evaluate(
-                    """async (pageNum) => {
+                    """async ({ pageNum, pageSize }) => {
                         try {
                             // 使用正确的 API 端点:/api/galaxy/v2/creator/note/user/posted
-                            const url = `/api/galaxy/v2/creator/note/user/posted?tab=0&page=${pageNum}`;
+                            const url = `/api/galaxy/v2/creator/note/user/posted?tab=0&page=${pageNum}&page_size=${pageSize}`;
                             const headers = {
                                 'Accept': 'application/json, text/plain, */*',
                                 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
@@ -1578,7 +1578,7 @@ class XiaohongshuPublisher(BasePublisher):
 
             resp = None
             for attempt in range(1, 4):
-                resp = await fetch_notes_page(page)
+                resp = await fetch_notes_page(page, page_size)
 
                 # 打印调试信息
                 if resp and isinstance(resp, dict) and resp.get("_debug"):
@@ -1694,7 +1694,7 @@ class XiaohongshuPublisher(BasePublisher):
                 if len(notes) == 0:
                     has_more = False
                 else:
-                    next_resp = await fetch_notes_page(page + 1)
+                    next_resp = await fetch_notes_page(page + 1, page_size)
                     next_data = (
                         (next_resp or {}).get("data", {})
                         if isinstance(next_resp, dict)
@@ -1813,7 +1813,7 @@ class XiaohongshuPublisher(BasePublisher):
                     flush=True,
                 )
 
-            async def fetch_notes_page(p):
+            async def fetch_notes_page(p, ps=None):
                 # 再次检查签名函数(每次调用前都检查)
                 sign_available = await self.page.evaluate("""() => {
                     return typeof window !== 'undefined' && typeof window._webmsxyw === 'function';
@@ -1827,10 +1827,10 @@ class XiaohongshuPublisher(BasePublisher):
                     await asyncio.sleep(2)
 
                 return await self.page.evaluate(
-                    """async (pageNum) => {
+                    """async ({ pageNum, pageSize }) => {
                         try {
                             // 使用正确的 API 端点:/api/galaxy/v2/creator/note/user/posted
-                            const url = `/api/galaxy/v2/creator/note/user/posted?tab=0&page=${pageNum}`;
+                            const url = `/api/galaxy/v2/creator/note/user/posted?tab=0&page=${pageNum}&page_size=${pageSize}`;
                             const headers = {
                                 'Accept': 'application/json, text/plain, */*',
                                 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
@@ -2210,7 +2210,7 @@ class XiaohongshuPublisher(BasePublisher):
                     f"\n[{self.platform_name}] ---------- 第 {iters} 次请求 (cursor={cursor}) ----------",
                     flush=True,
                 )
-                resp = await fetch_notes_page(cursor)
+                resp = await fetch_notes_page(cursor, api_page_size)
 
                 # 打印调试信息
                 if resp and isinstance(resp, dict) and resp.get("_debug"):

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

@@ -1079,13 +1079,13 @@ export class DouyinAdapter extends BasePlatformAdapter {
     onCaptchaRequired?: (captchaInfo: { taskId: string; phone?: string }) => Promise<string>,
     options?: { headless?: boolean }
   ): Promise<PublishResult> {
-    // 只使用 Python 服务发布
+    // 只使用 Python 服务发布,不可用时提示用户使用有头浏览器重试
     const pythonAvailable = await this.checkPythonServiceAvailable();
     if (!pythonAvailable) {
       logger.error('[Douyin] Python service not available');
       return {
         success: false,
-        errorMessage: 'Python 发布服务不可用,请确保 Python 服务已启动',
+        errorMessage: 'CAPTCHA_REQUIRED:Python 发布服务不可用,请确保 Python 服务已启动后重试,或使用有头浏览器重新发布',
       };
     }
 

+ 22 - 0
server/src/routes/upload.ts

@@ -91,4 +91,26 @@ router.post(
   })
 );
 
+// 上传头像
+router.post(
+  '/avatar',
+  handleMulterError(uploadImage),
+  asyncHandler(async (req, res) => {
+    if (!req.file) {
+      throw new AppError('未上传文件', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION);
+    }
+    
+    res.json({
+      success: true,
+      data: {
+        filename: req.file.filename,
+        originalname: req.file.originalname,
+        path: `/uploads/images/${req.file.filename}`,
+        size: req.file.size,
+        mimetype: req.file.mimetype,
+      },
+    });
+  })
+);
+
 export default router;

+ 7 - 1
server/src/routes/works.ts

@@ -38,7 +38,13 @@ router.get(
   '/stats',
   asyncHandler(async (req, res) => {
     const userId = req.user!.userId;
-    const stats = await workService.getStats(userId);
+    const params = {
+      accountId: req.query.accountId ? Number(req.query.accountId) : undefined,
+      platform: req.query.platform as string | undefined,
+      status: req.query.status as string | undefined,
+      keyword: req.query.keyword as string | undefined,
+    };
+    const stats = await workService.getStats(userId, params);
     res.json({ success: true, data: stats });
   })
 );

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

@@ -261,6 +261,9 @@ export class AccountService {
       const updated = await this.accountRepository.findOne({ where: { id: existing.id } });
       wsManager.sendToUser(userId, WS_EVENTS.ACCOUNT_UPDATED, { account: this.formatAccount(updated!) });
 
+      // Bug #6070: 通知前端账号数据已变更,触发其他模块(如首页数据看板)刷新
+      wsManager.sendToUser(userId, WS_EVENTS.ACCOUNT_DATA_CHANGED, { accountId: existing.id });
+
       // 异步刷新账号信息(获取准确的粉丝数、作品数等)
       this.refreshAccountAsync(userId, existing.id, platform).catch(err => {
         logger.warn(`[addAccount] Background refresh failed for existing account ${existing.id}:`, err);
@@ -299,6 +302,9 @@ export class AccountService {
     // 通知其他客户端
     wsManager.sendToUser(userId, WS_EVENTS.ACCOUNT_ADDED, { account: this.formatAccount(account) });
 
+    // Bug #6070: 通知前端账号数据已变更,触发其他模块(如首页数据看板)刷新
+    wsManager.sendToUser(userId, WS_EVENTS.ACCOUNT_DATA_CHANGED, { accountId: account.id });
+
     // 异步刷新账号信息(获取准确的粉丝数、作品数等)
     // 不阻塞返回,后台执行
     this.refreshAccountAsync(userId, account.id, platform).catch(err => {
@@ -471,6 +477,17 @@ export class AccountService {
     let needReLogin = false;
     let aiRefreshSuccess = false;
 
+    // Bug #6084: cookie 为空的账号直接标记为 expired
+    if (!account.cookieData) {
+      updateData.status = 'expired';
+      needReLogin = true;
+      logger.warn(`[refreshAccount] Account ${accountId} has empty cookieData, marking as expired`);
+      await this.accountRepository.update(accountId, updateData);
+      const updated = await this.accountRepository.findOne({ where: { id: accountId } });
+      wsManager.sendToUser(userId, WS_EVENTS.ACCOUNT_UPDATED, { account: this.formatAccount(updated!) });
+      return { ...this.formatAccount(updated!), needReLogin };
+    }
+
     // 尝试通过无头浏览器刷新账号信息
     if (account.cookieData) {
       try {
@@ -603,6 +620,12 @@ export class AccountService {
                 } else {
                   // 获取的信息无效,但 Cookie 有效,保持 active 状态
                   logger.warn(`Could not fetch valid account info for ${accountId}, but cookie is valid`);
+
+                  // Bug #6075: 即使 profile 名称无效,仍然强制更新 avatarUrl(用户可能只改了头像没改昵称)
+                  if (profile.avatarUrl) {
+                    updateData.avatarUrl = profile.avatarUrl;
+                    logger.info(`[refreshAccount] Force updating avatarUrl for ${accountId} (profile name invalid but avatar changed)`);
+                  }
                 }
 
                 // 无论 profile 是否有效,都尝试更新粉丝数和作品数(来自 Python API 的数据是可靠的)
@@ -679,6 +702,20 @@ export class AccountService {
     // 通知其他客户端
     wsManager.sendToUser(userId, WS_EVENTS.ACCOUNT_UPDATED, { account: this.formatAccount(updated!) });
 
+    // Bug #6075: 检测 avatarUrl 或 accountName 是否发生变化,通知前端刷新数据
+    if (updated) {
+      const avatarChanged = updateData.avatarUrl !== undefined && updateData.avatarUrl !== account.avatarUrl;
+      const nameChanged = updateData.accountName !== undefined && updateData.accountName !== account.accountName;
+      if (avatarChanged || nameChanged) {
+        logger.info(`[refreshAccount] Account ${accountId} data changed: avatarChanged=${avatarChanged}, nameChanged=${nameChanged}`);
+        wsManager.sendToUser(userId, WS_EVENTS.ACCOUNT_DATA_CHANGED, {
+          accountId,
+          accountName: updated.accountName,
+          avatarUrl: updated.avatarUrl,
+        });
+      }
+    }
+
     return { ...this.formatAccount(updated!), needReLogin };
   }
 

+ 5 - 3
server/src/services/HeadlessBrowserService.ts

@@ -749,7 +749,7 @@ class HeadlessBrowserService {
           cookie: cookieString,
           page: pageParam,
           page_size: pageSize,
-          auto_paging: platform === 'xiaohongshu' && pageIndex === 0,
+          auto_paging: (platform === 'xiaohongshu' || platform === 'baijiahao') && pageIndex === 0,
         }),
       });
 
@@ -4123,8 +4123,9 @@ class HeadlessBrowserService {
     const cookieString = JSON.stringify(cookies);
     const pythonUrl = (await getPythonServiceBaseUrl()).replace(/\/$/, '');
 
-    // 1. 先获取作品列表
-    logger.info(`[${platform} Comments Python] Fetching works list...`);
+    // 1. 先获取作品列表(小红书使用 auto_paging 获取全部,避免只取第一页)
+    const useAutoPaging = platform === 'xiaohongshu';
+    logger.info(`[${platform} Comments Python] Fetching works list (auto_paging=${useAutoPaging})...`);
     const worksResponse = await fetch(`${pythonUrl}/works`, {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
@@ -4133,6 +4134,7 @@ class HeadlessBrowserService {
         cookie: cookieString,
         page: 0,
         page_size: 50,
+        auto_paging: useAutoPaging,
       }),
     });
 

+ 34 - 3
server/src/services/PublishService.ts

@@ -154,7 +154,26 @@ export class PublishService {
     if (!task) {
       throw new AppError('任务不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
     }
-    return this.formatTaskDetail(task);
+    // 基于 PublishResult 记录重新计算任务状态(修复 #6068:删除账号后状态不一致)
+    const results = task.results || [];
+    const actualTargetAccounts = results.map(r => r.accountId);
+    const completedResults = results.filter(r => r.status === 'success');
+    const failedResults = results.filter(r => r.status === 'failed');
+
+    let computedStatus = task.status;
+    if (results.length > 0 && task.status !== 'pending' && task.status !== 'processing' && task.status !== 'cancelled') {
+      if (completedResults.length === results.length) {
+        computedStatus = 'completed';
+      } else if (failedResults.length === results.length) {
+        computedStatus = 'failed';
+      } else if (completedResults.length + failedResults.length === results.length) {
+        computedStatus = 'failed'; // 部分失败也标记为 failed(保持向后兼容)
+      } else {
+        computedStatus = task.status;
+      }
+    }
+
+    return this.formatTaskDetail(task, actualTargetAccounts, computedStatus);
   }
 
   async createTask(userId: number, data: CreatePublishTaskRequest): Promise<PublishTaskType> {
@@ -878,9 +897,21 @@ export class PublishService {
     }
   }
 
-  private formatTaskDetail(task: PublishTask): PublishTaskDetail {
+  private formatTaskDetail(
+    task: PublishTask,
+    overrideTargetAccounts?: number[],
+    overrideStatus?: string,
+  ): PublishTaskDetail {
+    const base = this.formatTask(task);
+    // 修复 #6068:使用实际存在的 PublishResult 账号列表覆盖缓存的 targetAccounts
+    if (overrideTargetAccounts) {
+      base.targetAccounts = overrideTargetAccounts;
+    }
+    if (overrideStatus) {
+      base.status = overrideStatus as PublishTaskType['status'];
+    }
     return {
-      ...this.formatTask(task),
+      ...base,
       results: task.results?.map(r => ({
         id: r.id,
         taskId: r.taskId,

+ 1 - 4
server/src/services/WorkDayStatisticsService.ts

@@ -1835,15 +1835,12 @@ export class WorkDayStatisticsService {
    */
   private formatUpdateTime(date: Date): string {
     const y = date.getFullYear();
-    const nowYear = new Date().getFullYear();
     const month = String(date.getMonth() + 1).padStart(2, '0');
     const day = String(date.getDate()).padStart(2, '0');
     const hours = String(date.getHours()).padStart(2, '0');
     const minutes = String(date.getMinutes()).padStart(2, '0');
 
-    if (y === nowYear) {
-      return `${month}-${day} ${hours}:${minutes}`;
-    }
+    // 始终返回完整的 YYYY-MM-DD HH:mm 格式,避免前端 dayjs 解析省略年份时被误解析为 2001 年
     return `${y}-${month}-${day} ${hours}:${minutes}`;
   }
 }

+ 18 - 4
server/src/services/WorkService.ts

@@ -53,8 +53,8 @@ export class WorkService {
   /**
    * 获取作品统计
    */
-  async getStats(userId: number): Promise<WorkStats> {
-    const result = await this.workRepository
+  async getStats(userId: number, params?: WorksQueryParams): Promise<WorkStats> {
+    const queryBuilder = this.workRepository
       .createQueryBuilder('work')
       .select([
         'COUNT(*) as totalCount',
@@ -63,8 +63,22 @@ export class WorkService {
         'CAST(SUM(work.likeCount) AS SIGNED BIGINT) as totalLikeCount',
         'CAST(SUM(work.commentCount) AS SIGNED BIGINT) as totalCommentCount',
       ])
-      .where('work.userId = :userId', { userId })
-      .getRawOne();
+      .where('work.userId = :userId', { userId });
+
+    if (params?.accountId) {
+      queryBuilder.andWhere('work.accountId = :accountId', { accountId: params.accountId });
+    }
+    if (params?.platform) {
+      queryBuilder.andWhere('work.platform = :platform', { platform: params.platform });
+    }
+    if (params?.status) {
+      queryBuilder.andWhere('work.status = :status', { status: params.status });
+    }
+    if (params?.keyword) {
+      queryBuilder.andWhere('work.title LIKE :keyword', { keyword: `%${params.keyword}%` });
+    }
+
+    const result = await queryBuilder.getRawOne();
 
     return {
       totalCount: parseInt(result.totalCount) || 0,

+ 1 - 0
shared/src/constants/api.ts

@@ -102,6 +102,7 @@ export const WS_EVENTS = {
   ACCOUNT_ADDED: 'account:added',
   ACCOUNT_UPDATED: 'account:updated',
   ACCOUNT_DELETED: 'account:deleted',
+  ACCOUNT_DATA_CHANGED: 'account:data_changed',
   // 任务
   TASK_CREATED: 'task:created',
   TASK_STATUS_CHANGED: 'task:status_changed',