Ethanfly 1 روز پیش
والد
کامیت
e7c9f1aa3b

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

@@ -18,6 +18,7 @@ declare module 'vue' {
     ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
     ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
     ElContainer: typeof import('element-plus/es')['ElContainer']
+    ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
     ElDialog: typeof import('element-plus/es')['ElDialog']
     ElDivider: typeof import('element-plus/es')['ElDivider']
     ElDrawer: typeof import('element-plus/es')['ElDrawer']
@@ -28,6 +29,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']

+ 12 - 34
client/src/views/Works/index.vue

@@ -69,22 +69,13 @@
           @click="openWorkDetail(work)"
         >
           <div class="work-cover">
-            <img :src="getSecureCoverUrl(work.coverUrl)" :alt="work.title" @error="handleImageError" />
-            <span class="work-duration">{{ formatDuration(work.duration) }}</span>
-            <el-tag 
-              class="work-status" 
-              :type="getStatusType(work.status)" 
-              size="small"
-            >
-              {{ getStatusText(work.status) }}
-            </el-tag>
+            <img :src="getWorkCoverSrc(work)" :alt="work.title" @error="handleImageError" />
           </div>
           
           <div class="work-info">
             <div class="work-title" :title="work.title">{{ work.title || '无标题' }}</div>
             <div class="work-meta">
               <el-tag size="small" type="info">{{ getPlatformName(work.platform) }}</el-tag>
-              <span class="work-time">{{ formatDate(work.publishTime) }}</span>
             </div>
             <div class="work-stats">
               <span><el-icon><VideoPlay /></el-icon> {{ formatNumber(work.playCount) }}</span>
@@ -193,7 +184,7 @@
       destroy-on-close
     >
       <div class="comments-drawer-header" v-if="commentsWork">
-        <img :src="getSecureCoverUrl(commentsWork.coverUrl)" class="work-thumb" @error="handleImageError" />
+        <img :src="getWorkCoverSrc(commentsWork)" class="work-thumb" @error="handleImageError" />
         <div class="work-brief">
           <div class="work-brief-title">{{ commentsWork.title || '无标题' }}</div>
           <div class="work-brief-meta">
@@ -495,6 +486,15 @@ function formatDuration(seconds: number | string | undefined): string {
   return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
 }
 
+// 作品列表/评论等处的封面:兼容 coverUrl / cover_url,无封面时用占位图
+const COVER_PLACEHOLDER = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect fill="%23f0f0f0" width="100" height="100"/><text x="50" y="55" text-anchor="middle" fill="%23999" font-size="12">无封面</text></svg>';
+
+function getWorkCoverSrc(work: Work): string {
+  const url = (work as { coverUrl?: string; cover_url?: string }).coverUrl ?? (work as { cover_url?: string }).cover_url ?? '';
+  if (!url || typeof url !== 'string' || !url.trim()) return COVER_PLACEHOLDER;
+  return getSecureCoverUrl(url);
+}
+
 // 将 HTTP 图片 URL 转换为 HTTPS(小红书等平台的图片 URL 可能是 HTTP)
 function getSecureCoverUrl(url: string): string {
   if (!url) return '';
@@ -507,7 +507,7 @@ function getSecureCoverUrl(url: string): string {
 
 function handleImageError(e: Event) {
   const img = e.target as HTMLImageElement;
-  img.src = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect fill="%23f0f0f0" width="100" height="100"/><text x="50" y="55" text-anchor="middle" fill="%23999" font-size="12">无封面</text></svg>';
+  img.src = COVER_PLACEHOLDER;
 }
 
 async function loadWorks() {
@@ -936,23 +936,6 @@ onUnmounted(() => {
       height: 100%;
       object-fit: cover;
     }
-    
-    .work-duration {
-      position: absolute;
-      bottom: 8px;
-      right: 8px;
-      padding: 2px 6px;
-      background: rgba(0, 0, 0, 0.7);
-      color: #fff;
-      font-size: 12px;
-      border-radius: 4px;
-    }
-    
-    .work-status {
-      position: absolute;
-      top: 8px;
-      left: 8px;
-    }
   }
   
   .work-info {
@@ -973,11 +956,6 @@ onUnmounted(() => {
       align-items: center;
       gap: 8px;
       margin-bottom: 8px;
-      
-      .work-time {
-        font-size: 12px;
-        color: $text-secondary;
-      }
     }
     
     .work-stats {

+ 2 - 0
server/python/platforms/douyin.py

@@ -777,10 +777,12 @@ class DouyinPublisher(BasePublisher):
                 create_time = aweme.get('create_time', 0)
                 publish_time = datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M:%S') if create_time else ''
                 
+                video_url = f"https://www.douyin.com/video/{aweme_id}" if aweme_id else ""
                 works.append(WorkItem(
                     work_id=aweme_id,
                     title=title,
                     cover_url=cover_url,
+                    video_url=video_url,
                     duration=duration,
                     status='published',
                     publish_time=publish_time,

+ 4 - 0
server/python/platforms/xiaohongshu.py

@@ -913,10 +913,12 @@ class XiaohongshuPublisher(BasePublisher):
                     elif tab_status == 3:
                         status = 'rejected'
                     
+                    video_url = f"https://www.xiaohongshu.com/explore/{note_id}" if note_id else ""
                     parsed.append(WorkItem(
                         work_id=note_id,
                         title=note.get('display_title', '') or '无标题',
                         cover_url=cover_url,
+                        video_url=video_url,
                         duration=duration,
                         status=status,
                         publish_time=note.get('time', ''),
@@ -1179,10 +1181,12 @@ class XiaohongshuPublisher(BasePublisher):
                     elif tab_status == 3:
                         status = 'rejected'
 
+                    video_url = f"https://www.xiaohongshu.com/explore/{note_id}" if note_id else ""
                     parsed.append(WorkItem(
                         work_id=note_id,
                         title=note.get('display_title', '') or '无标题',
                         cover_url=cover_url,
+                        video_url=video_url,
                         duration=duration,
                         status=status,
                         publish_time=note.get('time', ''),

+ 1 - 0
server/src/automation/platforms/base.ts

@@ -13,6 +13,7 @@ export interface WorkItem {
   videoId?: string;
   title: string;
   coverUrl: string;
+  videoUrl?: string;
   duration: string;
   publishTime: string;
   status: string;

+ 8 - 0
server/src/services/HeadlessBrowserService.ts

@@ -89,6 +89,8 @@ export interface WorkItem {
   videoId?: string;
   title: string;
   coverUrl: string;
+  /** 作品播放/详情页 URL,同步到 works.video_url */
+  videoUrl?: string;
   duration: string;
   publishTime: string;
   status: string;
@@ -777,6 +779,7 @@ class HeadlessBrowserService {
         work_id: string;
         title: string;
         cover_url: string;
+        video_url?: string;
         duration: number;
         publish_time: string;
         status: string;
@@ -789,6 +792,7 @@ class HeadlessBrowserService {
         videoId: work.work_id,
         title: work.title,
         coverUrl: work.cover_url,
+        videoUrl: work.video_url || '',
         duration: String(work.duration || 0),
         publishTime: work.publish_time,
         status: work.status || 'published',
@@ -1425,6 +1429,7 @@ class HeadlessBrowserService {
           videoId: w.awemeId,
           title: w.title,
           coverUrl: w.coverUrl,
+          videoUrl: w.awemeId ? `https://www.douyin.com/video/${w.awemeId}` : '',
           duration: '00:00',
           publishTime: w.createTime ? new Date(w.createTime * 1000).toISOString() : '',
           status: 'published',
@@ -1441,6 +1446,7 @@ class HeadlessBrowserService {
           videoId: w.awemeId,
           title: w.title,
           coverUrl: w.coverUrl,
+          videoUrl: w.awemeId ? `https://www.douyin.com/video/${w.awemeId}` : '',
           duration: this.formatDuration(w.duration),
           publishTime: w.createTime ? new Date(w.createTime * 1000).toISOString() : '',
           status: 'published',
@@ -2287,6 +2293,7 @@ class HeadlessBrowserService {
             videoId: note.noteId,
             title: note.title || '无标题',
             coverUrl: note.coverUrl,
+            videoUrl: note.noteId ? `https://www.xiaohongshu.com/explore/${note.noteId}` : '',
             duration: durationStr,
             publishTime: note.publishTime,
             status: statusStr,
@@ -2653,6 +2660,7 @@ class HeadlessBrowserService {
               videoId: item.id || item.article_id || `bjh_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
               title: item.title || '',
               coverUrl: coverUrl,
+              videoUrl: item.url || item.article_url || '',
               duration: '00:00',
               publishTime: item.created_at || item.create_time || new Date().toISOString(),
               status: item.status || 'published',

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

@@ -336,6 +336,7 @@ export class WorkService {
           await this.workRepository.update(work.id, {
             title: workItem.title || work.title,
             coverUrl: workItem.coverUrl || work.coverUrl,
+            videoUrl: workItem.videoUrl !== undefined ? workItem.videoUrl || null : work.videoUrl,
             duration: workItem.duration || work.duration,
             status: workItem.status || work.status,
             playCount: workItem.playCount ?? work.playCount,
@@ -353,6 +354,7 @@ export class WorkService {
             platformVideoId: canonicalVideoId,
             title: workItem.title || '',
             coverUrl: workItem.coverUrl || '',
+            videoUrl: workItem.videoUrl || null,
             duration: workItem.duration || '00:00',
             status: this.normalizeStatus(workItem.status),
             publishTime: this.parsePublishTime(workItem.publishTime),