Преглед изворни кода

feat: complete P0 reliability and high-frequency workflow

Yi Jiarui пре 3 месеци
родитељ
комит
88837743ee
27 измењених фајлова са 1521 додато и 107 уклоњено
  1. 2 1
      .gitignore
  2. 2 2
      src/app/app.html
  3. 13 10
      src/app/app.ts
  4. 3 0
      src/app/components/recent-task-pill/recent-task-pill.component.ts
  5. 22 1
      src/app/models/batch-job.model.ts
  6. 26 0
      src/app/models/creator-dashboard.model.ts
  7. 77 0
      src/app/pages/batch-production/batch-production.component.css
  8. 35 0
      src/app/pages/batch-production/batch-production.component.html
  9. 27 3
      src/app/pages/batch-production/batch-production.component.ts
  10. 211 0
      src/app/pages/home/home.component.css
  11. 61 3
      src/app/pages/home/home.component.html
  12. 32 1
      src/app/pages/home/home.component.ts
  13. 2 2
      src/app/pages/pipelines/topic-to-video/topic-to-video.component.html
  14. 52 48
      src/app/pages/pipelines/topic-to-video/topic-to-video.component.ts
  15. 1 1
      src/app/pages/templates/templates.component.html
  16. 59 0
      src/app/pages/templates/templates.component.ts
  17. 1 1
      src/app/services/assistant.service.ts
  18. 139 11
      src/app/services/batch-job.service.ts
  19. 17 1
      src/app/services/content-asset.service.ts
  20. 113 0
      src/app/services/creator-dashboard.service.ts
  21. 202 2
      src/app/services/draft-storage.ts
  22. 35 0
      src/app/services/generation-task.service.ts
  23. 102 0
      src/app/services/indexed-db-json-storage.ts
  24. 4 3
      src/app/services/pipeline-session.service.ts
  25. 261 0
      src/app/services/task-recovery.service.ts
  26. 15 15
      src/app/services/template.service.ts
  27. 7 2
      src/app/services/topic-to-video-batch-runner.service.ts

+ 2 - 1
.gitignore

@@ -41,6 +41,7 @@ yarn-error.log
 testem.log
 /typings
 __screenshots__/
+.gstack/
 
 # System files
 .DS_Store
@@ -67,4 +68,4 @@ deploy.ps1
 /openclaw-voc-skill/
 /apig-pay/
 /video-workflow-skills/
-/douyin-speaking-daily/
+/douyin-speaking-daily/

+ 2 - 2
src/app/app.html

@@ -4155,7 +4155,7 @@
 
               <div>
 
-                <label style="font-size:11px;color:var(--text-secondary);display:block;margin-bottom:4px">配图提示词(英文,用于AI绘画)</label>
+                <label style="font-size:11px;color:var(--text-secondary);display:block;margin-bottom:4px">配图提示词(中文,用于 AI 绘画)</label>
 
                 <textarea [(ngModel)]="seg.imagePrompt" rows="2"
 
@@ -5359,7 +5359,7 @@
 
                         rows="2"
 
-                        placeholder="文视频提示词..."></textarea>
+                        placeholder="文视频提示词..."></textarea>
 
             </div>
 

+ 13 - 10
src/app/app.ts

@@ -71,10 +71,11 @@ import { UserCenterComponent } from './pages/account/user-center.component';
 import { AdminUsersComponent } from './pages/account/admin-users.component';
 import { AuthRequiredModalComponent } from './components/auth-required-modal/auth-required-modal.component';
 import { VoiceApiService } from './services/voice-api.service';
-import { LocalStorageDraftStorage } from './services/draft-storage';
+import { IndexedDbDraftStorage } from './services/draft-storage';
 import { CostEstimatorService } from './services/cost-estimator.service';
 import { GenerationTaskService } from './services/generation-task.service';
 import { GenerationTaskCost, GenerationTaskStatus, GenerationTaskStep } from './models/generation-task.model';
+import { TaskRecoveryService } from './services/task-recovery.service';
 import { userFailureSuggestion, userFriendlyError } from './services/user-message.util';
 import { ViralAnalysisService } from './services/viral-analysis.service';
 import { TopicPoolService } from './services/topic-pool.service';
@@ -786,6 +787,7 @@ export class App {
   private readonly voiceApi = inject(VoiceApiService);
   private readonly costEstimator = inject(CostEstimatorService);
   private readonly generationTasks = inject(GenerationTaskService);
+  private readonly taskRecovery = inject(TaskRecoveryService);
   private readonly viralAnalysis = inject(ViralAnalysisService);
   private readonly topicPool = inject(TopicPoolService);
   private readonly dailyReports = inject(DailyReportService);
@@ -1675,8 +1677,9 @@ export class App {
       this.cdr.detectChanges();
     });
 
-    this.auth.session$.subscribe(() => {
-      void this.applyDraftStorageForCurrentUser();
+    this.auth.session$.subscribe(async () => {
+      await this.applyDraftStorageForCurrentUser();
+      void this.taskRecovery.bootstrap(true);
       this.restorePersistedMonitorState();
       void this.restoreLatestDailyReport();
       this.refreshIncompleteMonitorAuthors();
@@ -2261,7 +2264,7 @@ export class App {
 
   private async applyDraftStorageForCurrentUser(): Promise<void> {
     const userId = this.auth.currentUser?.objectId || 'guest';
-    await this.pipelineSession.useStorageAndReload(new LocalStorageDraftStorage(`user.${userId}`));
+    await this.pipelineSession.useStorageAndReload(new IndexedDbDraftStorage(`user.${userId}`));
   }
 
   requireLogin(featureName = '该功能'): boolean {
@@ -11928,7 +11931,7 @@ ${taskOne}
 
       const stylePrompt = this.remixStyleOptions.find(s => s.id === this.remixStyle)?.prompt || '';
 
-      // 生成文提示词:风格 + 场景描述
+      // 生成文提示词:风格 + 场景描述
 
       const prompt = `${stylePrompt}, cinematic scene depicting: ${narration.substring(0, 80)}`;
 
@@ -15667,7 +15670,7 @@ ${this.dhPrompt || '(空)'}
 
 1. 根据内容长度自动决定片段数量(短文5-8段,长文8-15段),确保原文每一段内容都被覆盖到
 
-2. 每个片段包含:旁白文本(narration)、配图英文提示词(imagePrompt)、配图中文提示词(imagePromptCn)
+2. 每个片段包含:旁白文本(narration)、配图提示词(imagePrompt)、配图中文提示词(imagePromptCn),两个提示词字段都必须使用中文
 
 3. 旁白文本要求:必须包含原文中的所有关键信息、数据、观点,不能省略或概括,只做口语化改写
 
@@ -15685,7 +15688,7 @@ ${this.dhPrompt || '(空)'}
 
    - 注意:提示词中不要出现"PPT"、"幻灯片"、"演示文稿"等字样,只描述画面内容本身
 
-5. 配图英文提示词(imagePrompt)是备选提示词,描述同样的画面内容
+5. 配图备用提示词(imagePrompt)也必须使用中文,描述同样的画面内容
 
 
 
@@ -15693,9 +15696,9 @@ ${this.dhPrompt || '(空)'}
 
 [
 
-  {"id": "S-01", "narration": "旁白文本...", "imagePrompt": "English: 16:9 infographic...", "imagePromptCn": "16:9横版信息图,白色背景,大标题"xxx",下方用流程图展示xxx的步骤..."},
+  {"id": "S-01", "narration": "旁白文本...", "imagePrompt": "16:9横版信息图,白色背景,大标题\"xxx\",下方用流程图展示xxx的步骤...", "imagePromptCn": "16:9横版信息图,白色背景,大标题\"xxx\",下方用流程图展示xxx的步骤..."},
 
-  {"id": "S-02", "narration": "旁白文本...", "imagePrompt": "English: 16:9 infographic...", "imagePromptCn": "16:9横版信息图,浅色背景,标题"xxx",用图标和箭头展示xxx的关系..."}
+  {"id": "S-02", "narration": "旁白文本...", "imagePrompt": "16:9横版信息图,浅色背景,标题\"xxx\",用图标和箭头展示xxx的关系...", "imagePromptCn": "16:9横版信息图,浅色背景,标题\"xxx\",用图标和箭头展示xxx的关系..."}
 
 ]`;
 
@@ -15863,7 +15866,7 @@ ${this.dhPrompt || '(空)'}
 
       narration: raw.replace(/```[\s\S]*?```/g, '').trim(),
 
-      imagePrompt: 'Professional PPT slide illustration, clean white background, infographic style',
+      imagePrompt: '16:9 横版信息图,干净白色背景,扁平化图标与流程结构,中文标题与中文标注',
 
       imagePromptCn: '(脚本自动解析失败,请手动拆分片段)'
 

+ 3 - 0
src/app/components/recent-task-pill/recent-task-pill.component.ts

@@ -40,6 +40,9 @@ export class RecentTaskPillComponent {
       'download': '下载',
       'transcribe': '转录',
       'generate': '生成',
+      'script': '脚本',
+      'archive': '归档',
+      'external': '等待结果',
       'complete': '完成',
       'vg-upload': '上传',
       'vg-analyze': '分析',

+ 22 - 1
src/app/models/batch-job.model.ts

@@ -2,7 +2,7 @@ import { PipelineRoute } from '../pipelines/pipeline-registry';
 
 export type BatchJobStatus = 'draft' | 'queued' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
 
-export type BatchJobItemStatus = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';
+export type BatchJobItemStatus = 'queued' | 'running' | 'waiting_external' | 'completed' | 'failed' | 'cancelled';
 
 export interface BatchJob {
   id: string;
@@ -15,6 +15,13 @@ export interface BatchJob {
   completed: number;
   failed: number;
   estimatedCredits: number;
+  concurrency?: number;
+  intervalMs?: number;
+  startedAt?: string;
+  finishedAt?: string;
+  lastRunAt?: string;
+  runnerVersion?: number;
+  failureSummary?: Record<string, number>;
   createdAt: string;
   updatedAt: string;
 }
@@ -29,11 +36,24 @@ export interface BatchJobItem {
   status: BatchJobItemStatus;
   resultUrl?: string;
   errorMessage?: string;
+  lastErrorCode?: string;
+  externalTaskIds?: Record<string, string>;
+  lastStepId?: string;
+  startedAt?: string;
+  finishedAt?: string;
+  logs?: BatchJobItemLog[];
   retryCount: number;
   createdAt: string;
   updatedAt: string;
 }
 
+export interface BatchJobItemLog {
+  at: string;
+  level: 'info' | 'warn' | 'error';
+  step: string;
+  message: string;
+}
+
 export interface BatchJobCreateInput {
   title: string;
   pipelineId: PipelineRoute;
@@ -49,4 +69,5 @@ export interface BatchJobCreateInput {
 export interface BatchJobRunResult {
   resultUrl?: string;
   generationTaskId?: string;
+  externalTaskIds?: Record<string, string>;
 }

+ 26 - 0
src/app/models/creator-dashboard.model.ts

@@ -0,0 +1,26 @@
+export type CreatorDashboardTarget =
+  | 'tasks'
+  | 'topic-pool'
+  | 'batch-production'
+  | 'library'
+  | 'retrospective'
+  | 'topic-to-video';
+
+export interface CreatorDashboardSummary {
+  runningTasks: number;
+  recoverableTasks: number;
+  failedTasks: number;
+  pendingTopics: number;
+  readyAssets: number;
+  activeBatchJobs: number;
+  suggestedActions: CreatorDashboardAction[];
+}
+
+export interface CreatorDashboardAction {
+  id: string;
+  priority: 'high' | 'medium' | 'low';
+  title: string;
+  description: string;
+  target: CreatorDashboardTarget;
+  payload?: Record<string, any>;
+}

+ 77 - 0
src/app/pages/batch-production/batch-production.component.css

@@ -181,6 +181,37 @@
   gap: 8px;
 }
 
+.batch-diagnostics {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 10px;
+  margin-bottom: 14px;
+}
+
+.batch-diagnostic {
+  display: grid;
+  gap: 6px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-primary);
+  padding: 12px;
+}
+
+.batch-diagnostic--wide {
+  grid-column: 1 / -1;
+}
+
+.batch-diagnostic span {
+  color: var(--text-secondary);
+  font-size: 12px;
+}
+
+.batch-diagnostic strong {
+  color: var(--text-primary);
+  font-size: 14px;
+  line-height: 1.5;
+}
+
 .batch-item {
   display: flex;
   justify-content: space-between;
@@ -220,6 +251,44 @@
   color: #b91c1c !important;
 }
 
+.batch-log {
+  margin-top: 10px;
+  color: var(--text-secondary);
+}
+
+.batch-log summary {
+  cursor: pointer;
+  font-size: 13px;
+}
+
+.batch-log ol {
+  display: grid;
+  gap: 6px;
+  margin: 8px 0 0;
+  padding: 0;
+  list-style: none;
+}
+
+.batch-log li {
+  display: grid;
+  grid-template-columns: 132px 90px minmax(0, 1fr);
+  gap: 8px;
+  align-items: baseline;
+  border-top: 1px dashed var(--border-color);
+  padding-top: 6px;
+  font-size: 12px;
+}
+
+.batch-log time,
+.batch-log span {
+  color: var(--text-secondary);
+}
+
+.batch-log strong {
+  color: var(--text-primary);
+  font-weight: 500;
+}
+
 .batch-empty {
   border: 1px dashed var(--border-color);
   border-radius: 8px;
@@ -242,4 +311,12 @@
   .batch-actions {
     margin-top: 12px;
   }
+
+  .batch-diagnostics {
+    grid-template-columns: 1fr;
+  }
+
+  .batch-log li {
+    grid-template-columns: 1fr;
+  }
 }

+ 35 - 0
src/app/pages/batch-production/batch-production.component.html

@@ -99,6 +99,29 @@
       </div>
     </div>
 
+    <div class="batch-diagnostics">
+      <div class="batch-diagnostic">
+        <span>队列策略</span>
+        <strong>串行 · 间隔 {{ (job.intervalMs || 0) / 1000 }} 秒</strong>
+      </div>
+      <div class="batch-diagnostic" *ngIf="job.startedAt">
+        <span>开始时间</span>
+        <strong>{{ formatTime(job.startedAt) }}</strong>
+      </div>
+      <div class="batch-diagnostic" *ngIf="job.finishedAt">
+        <span>结束时间</span>
+        <strong>{{ formatTime(job.finishedAt) }}</strong>
+      </div>
+      <div class="batch-diagnostic batch-diagnostic--wide" *ngIf="failureSummaryEntries(job).length">
+        <span>失败摘要</span>
+        <strong>
+          <ng-container *ngFor="let failure of failureSummaryEntries(job); let last = last">
+            {{ failure.label }} {{ failure.count }}{{ last ? '' : ' · ' }}
+          </ng-container>
+        </strong>
+      </div>
+    </div>
+
     <div class="batch-items">
       <article class="batch-item" *ngFor="let item of selectedItems(); trackBy: trackByItem">
         <div>
@@ -107,7 +130,19 @@
           </span>
           <h3>{{ item.title }}</h3>
           <p *ngIf="item.generationTaskId">任务 ID:{{ item.generationTaskId }}</p>
+          <p *ngIf="item.lastStepId">当前步骤:{{ item.lastStepId }}</p>
+          <p *ngIf="item.lastErrorCode">失败类型:{{ errorCodeLabel(item.lastErrorCode) }}</p>
           <p *ngIf="item.errorMessage" class="batch-error">{{ item.errorMessage }}</p>
+          <details class="batch-log" *ngIf="item.logs?.length">
+            <summary>执行日志 {{ item.logs?.length }} 条</summary>
+            <ol>
+              <li *ngFor="let log of item.logs">
+                <time>{{ formatTime(log.at) }}</time>
+                <span>{{ log.step }}</span>
+                <strong>{{ log.message }}</strong>
+              </li>
+            </ol>
+          </details>
         </div>
         <button class="btn" type="button" (click)="retry(item)" [disabled]="item.status !== 'failed'">重试</button>
       </article>

+ 27 - 3
src/app/pages/batch-production/batch-production.component.ts

@@ -9,6 +9,7 @@ import { GenerationTaskService } from '../../services/generation-task.service';
 import { PipelineSessionService } from '../../services/pipeline-session.service';
 import { TopicToVideoBatchRunnerService } from '../../services/topic-to-video-batch-runner.service';
 import { GenerationTemplate } from '../../models/template.model';
+import { userFriendlyError } from '../../services/user-message.util';
 
 interface BatchTopicInput {
   topicId?: string;
@@ -105,7 +106,7 @@ export class BatchProductionComponent implements OnInit {
         : '批量任务已创建,正在为每条主题登记独立任务');
       await this.run(job);
     } catch (err: any) {
-      this.message.set(err?.message || '创建批量任务失败');
+      this.message.set(userFriendlyError(err, '创建批量任务失败,请稍后重试'));
     }
   }
 
@@ -131,7 +132,7 @@ export class BatchProductionComponent implements OnInit {
         ? '真实生成队列已结束,结果已回写任务中心和素材库'
         : '队列登记已结束,子任务已进入任务中心排队');
     } catch (err: any) {
-      this.message.set(err?.message || '队列执行失败');
+      this.message.set(userFriendlyError(err, '队列执行失败,请稍后重试'));
     } finally {
       this.isRunning.set(false);
     }
@@ -168,6 +169,9 @@ export class BatchProductionComponent implements OnInit {
       status: item.status,
       resultUrl: item.resultUrl || '',
       generationTaskId: item.generationTaskId || '',
+      externalTaskIds: item.externalTaskIds ? JSON.stringify(item.externalTaskIds) : '',
+      lastErrorCode: item.lastErrorCode || '',
+      logs: item.logs?.length ? JSON.stringify(item.logs) : '',
       errorMessage: item.errorMessage || '',
     }));
     const csv = this.toCsv(rows);
@@ -185,6 +189,7 @@ export class BatchProductionComponent implements OnInit {
       draft: '草稿',
       queued: '排队中',
       running: '执行中',
+      waiting_external: '等待外部结果',
       paused: '已暂停',
       completed: '已完成',
       failed: '有失败',
@@ -208,6 +213,25 @@ export class BatchProductionComponent implements OnInit {
     return item.id;
   }
 
+  failureSummaryEntries(job: BatchJob): Array<{ code: string; label: string; count: number }> {
+    return Object.entries(job.failureSummary || {})
+      .filter(([, count]) => count > 0)
+      .map(([code, count]) => ({ code, label: this.errorCodeLabel(code), count }))
+      .sort((a, b) => b.count - a.count);
+  }
+
+  errorCodeLabel(code: string): string {
+    const map: Record<string, string> = {
+      rate_limit: '上游限流',
+      timeout: '执行超时',
+      network: '网络异常',
+      credential: '凭证异常',
+      invalid_input: '参数问题',
+      unknown: '未知原因',
+    };
+    return map[code] || code || '未知原因';
+  }
+
   private selectedTemplate(): GenerationTemplate | undefined {
     const id = this.selectedTemplateId();
     return this.topicTemplates().find((item) => item.id === id);
@@ -302,7 +326,7 @@ export class BatchProductionComponent implements OnInit {
   }
 
   private toCsv(rows: Array<Record<string, string>>): string {
-    const headers = ['batchJobId', 'title', 'status', 'resultUrl', 'generationTaskId', 'errorMessage'];
+    const headers = ['batchJobId', 'title', 'status', 'resultUrl', 'generationTaskId', 'externalTaskIds', 'lastErrorCode', 'logs', 'errorMessage'];
     const escape = (value: string) => `"${String(value || '').replace(/"/g, '""')}"`;
     return [
       headers.join(','),

+ 211 - 0
src/app/pages/home/home.component.css

@@ -218,6 +218,7 @@
 }
 
 .t-strip__serial,
+.t-dashboard__serial,
 .t-monitor__serial,
 .t-recent__serial {
   font-family: var(--font-serif);
@@ -230,6 +231,7 @@
 }
 
 .t-strip__title,
+.t-dashboard__title,
 .t-monitor__title,
 .t-recent__title {
   font-family: var(--font-display);
@@ -242,6 +244,7 @@
 }
 
 .t-strip__more,
+.t-dashboard__more,
 .t-monitor__more,
 .t-recent__more {
   border: none;
@@ -256,11 +259,176 @@
 }
 
 .t-strip__more:hover,
+.t-dashboard__more:hover,
 .t-monitor__more:hover,
 .t-recent__more:hover {
   color: #0a0e1a;
 }
 
+/* ============================================================
+ * 今日生产驾驶舱(02)
+ * ============================================================ */
+.t-dashboard {
+  margin-top: 72px;
+  position: relative;
+}
+
+.t-dashboard__head {
+  display: flex;
+  align-items: baseline;
+  gap: 16px;
+  margin-bottom: 20px;
+}
+
+.t-dashboard__panel {
+  display: grid;
+  grid-template-columns: minmax(0, 0.95fr) minmax(320px, 1.05fr);
+  gap: 20px;
+  align-items: stretch;
+}
+
+.t-dashboard__stats {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 1px;
+  border: 1px solid rgba(15, 23, 42, 0.1);
+  background: rgba(15, 23, 42, 0.1);
+  border-radius: 14px;
+  overflow: hidden;
+}
+
+.t-dashboard__stat {
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  min-height: 112px;
+  padding: 18px;
+  border: none;
+  background: #ffffff;
+  text-align: left;
+  cursor: pointer;
+}
+
+.t-dashboard__stat span {
+  font-size: 12px;
+  color: rgba(15, 23, 42, 0.5);
+}
+
+.t-dashboard__stat strong {
+  font-family: var(--font-display);
+  font-size: 32px;
+  line-height: 1;
+  color: #0a0e1a;
+  font-variant-numeric: tabular-nums;
+}
+
+.t-dashboard__stat.is-alert strong {
+  color: #dc2626;
+}
+
+.t-dashboard__stat:hover strong,
+.t-dashboard__stat:focus-visible strong {
+  color: #1d4ed8;
+}
+
+.t-dashboard__actions {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  min-width: 0;
+}
+
+.t-dashboard-action {
+  display: grid;
+  grid-template-columns: 54px minmax(0, 1fr) 18px;
+  gap: 12px;
+  align-items: center;
+  min-height: 74px;
+  padding: 14px 16px;
+  border: 1px solid rgba(15, 23, 42, 0.1);
+  background: #ffffff;
+  border-radius: 10px;
+  cursor: pointer;
+  text-align: left;
+  transition: border-color 160ms ease, transform 160ms ease;
+}
+
+.t-dashboard-action:hover,
+.t-dashboard-action:focus-visible {
+  border-color: #0a0e1a;
+  transform: translateY(-1px);
+}
+
+.t-dashboard-action__priority {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 24px;
+  border-radius: 999px;
+  background: rgba(15, 23, 42, 0.06);
+  color: rgba(15, 23, 42, 0.58);
+  font-size: 11px;
+  font-weight: 600;
+}
+
+.t-dashboard-action.is-high .t-dashboard-action__priority {
+  background: rgba(220, 38, 38, 0.1);
+  color: #b91c1c;
+}
+
+.t-dashboard-action.is-medium .t-dashboard-action__priority {
+  background: rgba(29, 78, 216, 0.1);
+  color: #1d4ed8;
+}
+
+.t-dashboard-action__main {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  min-width: 0;
+}
+
+.t-dashboard-action__main strong {
+  font-family: var(--font-display);
+  font-size: 15px;
+  color: #0a0e1a;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.t-dashboard-action__main span {
+  font-size: 12px;
+  line-height: 1.45;
+  color: rgba(15, 23, 42, 0.58);
+}
+
+.t-dashboard-action__arrow {
+  font-family: var(--font-mono);
+  font-size: 16px;
+  color: #0a0e1a;
+}
+
+@media (max-width: 1080px) {
+  .t-dashboard__panel {
+    grid-template-columns: 1fr;
+  }
+}
+
+@media (max-width: 640px) {
+  .t-dashboard__stats {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .t-dashboard-action {
+    grid-template-columns: 48px minmax(0, 1fr);
+  }
+
+  .t-dashboard-action__arrow {
+    display: none;
+  }
+}
+
 /* ============================================================
  * 今日监测摘要(03)
  * ============================================================ */
@@ -640,6 +808,7 @@
 
 .app-container.theme-night .t-hero__serial,
 .app-container.theme-night .t-strip__serial,
+.app-container.theme-night .t-dashboard__serial,
 .app-container.theme-night .t-recent__serial {
   color: rgba(226, 232, 240, 0.3);
 }
@@ -695,21 +864,26 @@
 }
 
 .app-container.theme-night .t-strip__title,
+.app-container.theme-night .t-dashboard__title,
 .app-container.theme-night .t-recent__title {
   color: #f8fafc;
 }
 
 .app-container.theme-night .t-strip__more,
+.app-container.theme-night .t-dashboard__more,
 .app-container.theme-night .t-recent__more {
   color: rgba(226, 232, 240, 0.55);
 }
 
 .app-container.theme-night .t-strip__more:hover,
+.app-container.theme-night .t-dashboard__more:hover,
 .app-container.theme-night .t-recent__more:hover {
   color: #f8fafc;
 }
 
 .app-container.theme-night .t-pipe-card,
+.app-container.theme-night .t-dashboard__stat,
+.app-container.theme-night .t-dashboard-action,
 .app-container.theme-night .t-recent-card {
   background: rgba(255, 255, 255, 0.03);
   border-color: rgba(255, 255, 255, 0.08);
@@ -717,12 +891,16 @@
 
 .app-container.theme-night .t-pipe-card:hover,
 .app-container.theme-night .t-pipe-card:focus-visible,
+.app-container.theme-night .t-dashboard-action:hover,
+.app-container.theme-night .t-dashboard-action:focus-visible,
 .app-container.theme-night .t-recent-card:hover,
 .app-container.theme-night .t-recent-card:focus-visible {
   border-color: #f8fafc;
 }
 
 .app-container.theme-night .t-pipe-card__title,
+.app-container.theme-night .t-dashboard__stat strong,
+.app-container.theme-night .t-dashboard-action__main strong,
 .app-container.theme-night .t-recent-card__title {
   color: #f8fafc;
 }
@@ -731,6 +909,39 @@
   color: rgba(226, 232, 240, 0.6);
 }
 
+.app-container.theme-night .t-dashboard__stats {
+  border-color: rgba(255, 255, 255, 0.08);
+  background: rgba(255, 255, 255, 0.08);
+}
+
+.app-container.theme-night .t-dashboard__stat span,
+.app-container.theme-night .t-dashboard-action__main span {
+  color: rgba(226, 232, 240, 0.55);
+}
+
+.app-container.theme-night .t-dashboard__stat.is-alert strong {
+  color: #f87171;
+}
+
+.app-container.theme-night .t-dashboard-action__priority {
+  background: rgba(255, 255, 255, 0.08);
+  color: rgba(226, 232, 240, 0.6);
+}
+
+.app-container.theme-night .t-dashboard-action.is-high .t-dashboard-action__priority {
+  background: rgba(248, 113, 113, 0.16);
+  color: #fca5a5;
+}
+
+.app-container.theme-night .t-dashboard-action.is-medium .t-dashboard-action__priority {
+  background: rgba(96, 165, 250, 0.16);
+  color: #93c5fd;
+}
+
+.app-container.theme-night .t-dashboard-action__arrow {
+  color: #f8fafc;
+}
+
 .app-container.theme-night .t-pipe-card__badge,
 .app-container.theme-night .t-recent-card__type {
   background: rgba(255, 255, 255, 0.06);

+ 61 - 3
src/app/pages/home/home.component.html

@@ -55,10 +55,68 @@
     </form>
   </header>
 
+  <!-- ===== 今日生产驾驶舱 ===== -->
+  <section class="t-dashboard" aria-label="今日生产驾驶舱" *ngIf="creatorSummary as dash">
+    <header class="t-dashboard__head">
+      <span class="t-dashboard__serial" aria-hidden="true">02</span>
+      <h2 class="t-dashboard__title">今日生产驾驶舱</h2>
+      <button class="t-dashboard__more" type="button" (click)="navigate.emit({ tab: 'tasks' })">
+        任务中心 →
+      </button>
+    </header>
+
+    <div class="t-dashboard__panel">
+      <div class="t-dashboard__stats">
+        <button type="button" class="t-dashboard__stat" (click)="navigate.emit({ tab: 'tasks' })">
+          <span>运行中</span>
+          <strong>{{ dash.runningTasks }}</strong>
+        </button>
+        <button type="button" class="t-dashboard__stat" (click)="navigate.emit({ tab: 'tasks' })">
+          <span>可恢复</span>
+          <strong>{{ dash.recoverableTasks }}</strong>
+        </button>
+        <button type="button" class="t-dashboard__stat is-alert" (click)="navigate.emit({ tab: 'tasks' })">
+          <span>失败</span>
+          <strong>{{ dash.failedTasks }}</strong>
+        </button>
+        <button type="button" class="t-dashboard__stat" (click)="navigate.emit({ tab: 'batch-production' })">
+          <span>待生产选题</span>
+          <strong>{{ dash.pendingTopics }}</strong>
+        </button>
+        <button type="button" class="t-dashboard__stat" (click)="navigate.emit({ tab: 'library' })">
+          <span>待发布成片</span>
+          <strong>{{ dash.readyAssets }}</strong>
+        </button>
+        <button type="button" class="t-dashboard__stat" (click)="navigate.emit({ tab: 'batch-production' })">
+          <span>批量队列</span>
+          <strong>{{ dash.activeBatchJobs }}</strong>
+        </button>
+      </div>
+
+      <div class="t-dashboard__actions">
+        <button *ngFor="let action of dash.suggestedActions; trackBy: trackByDashboardAction"
+                type="button"
+                class="t-dashboard-action"
+                [class.is-high]="action.priority === 'high'"
+                [class.is-medium]="action.priority === 'medium'"
+                (click)="openDashboardAction(action)">
+          <span class="t-dashboard-action__priority">
+            {{ action.priority === 'high' ? '优先' : action.priority === 'medium' ? '跟进' : '开始' }}
+          </span>
+          <span class="t-dashboard-action__main">
+            <strong>{{ action.title }}</strong>
+            <span>{{ action.description }}</span>
+          </span>
+          <span class="t-dashboard-action__arrow" aria-hidden="true">→</span>
+        </button>
+      </div>
+    </div>
+  </section>
+
   <!-- ===== Pipeline 横向卡带 ===== -->
   <section class="t-strip" aria-label="生成模式">
     <header class="t-strip__head">
-      <span class="t-strip__serial" aria-hidden="true">02</span>
+      <span class="t-strip__serial" aria-hidden="true">03</span>
       <h2 class="t-strip__title">或直接选一种模式</h2>
       <button class="t-strip__more" type="button" (click)="enterPipeline('topic-to-video'); navigate.emit({ tab: 'pipelines' })">
         查看全部 →
@@ -88,7 +146,7 @@
   <!-- ===== 今日监测摘要 ===== -->
   <section class="t-monitor" aria-label="今日监测摘要" *ngIf="monitorSummary as monitor">
     <header class="t-monitor__head">
-      <span class="t-monitor__serial" aria-hidden="true">03</span>
+      <span class="t-monitor__serial" aria-hidden="true">04</span>
       <h2 class="t-monitor__title">今日监测摘要</h2>
       <button class="t-monitor__more" type="button" (click)="navigate.emit({ tab: 'monitor' })">
         查看监测 →
@@ -119,7 +177,7 @@
   <!-- ===== 最近作品 ===== -->
   <section class="t-recent" aria-label="最近作品">
     <header class="t-recent__head">
-      <span class="t-recent__serial" aria-hidden="true">04</span>
+      <span class="t-recent__serial" aria-hidden="true">05</span>
       <h2 class="t-recent__title">最近作品</h2>
       <span class="t-recent__count" *ngIf="recentResults.length > 0">
         共 <strong>{{ recentResults.length }}</strong> 条

+ 32 - 1
src/app/pages/home/home.component.ts

@@ -14,7 +14,8 @@ import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
 import { HttpClient } from '@angular/common/http';
 import { catchError } from 'rxjs/operators';
-import { Subscription, of } from 'rxjs';
+import { Subscription, combineLatest, of } from 'rxjs';
+import { CreatorDashboardAction, CreatorDashboardSummary } from '../../models/creator-dashboard.model';
 import { PIPELINES, PipelineDef, PipelineRoute } from '../../pipelines/pipeline-registry';
 import {
   AssistantMessage,
@@ -22,6 +23,10 @@ import {
   AssistantSuggestion,
   Conversation,
 } from '../../services/assistant.service';
+import { BatchJobService } from '../../services/batch-job.service';
+import { ContentAssetService } from '../../services/content-asset.service';
+import { CreatorDashboardService } from '../../services/creator-dashboard.service';
+import { GenerationTaskService } from '../../services/generation-task.service';
 import { MarkdownService } from '../../services/markdown.service';
 import { SafeHtml } from '@angular/platform-browser';
 
@@ -81,6 +86,7 @@ export class HomeComponent implements OnInit, OnDestroy, AfterViewChecked {
 
   recentResults: RecentResult[] = [];
   recentLoading = true;
+  creatorSummary: CreatorDashboardSummary | null = null;
 
   // ===== Chat 视图 =====
   chatMode = false;
@@ -96,6 +102,10 @@ export class HomeComponent implements OnInit, OnDestroy, AfterViewChecked {
   constructor(
     private http: HttpClient,
     private assistant: AssistantService,
+    private dashboard: CreatorDashboardService,
+    private generationTasks: GenerationTaskService,
+    private contentAssets: ContentAssetService,
+    private batchJobs: BatchJobService,
     private cdr: ChangeDetectorRef,
     private md: MarkdownService,
   ) {}
@@ -107,6 +117,14 @@ export class HomeComponent implements OnInit, OnDestroy, AfterViewChecked {
 
   ngOnInit(): void {
     this.loadRecentResults();
+    this.refreshCreatorDashboard();
+    this.subs.add(combineLatest([
+      this.generationTasks.tasks$,
+      this.contentAssets.assets$,
+      this.batchJobs.jobs$,
+    ]).subscribe(() => {
+      this.refreshCreatorDashboard();
+    }));
     // 注意:所有订阅回调都显式 markForCheck + detectChanges,
     // 因为 fetch 路径下回调可能跳出 zone,仅靠 zone 自动 CD 不可靠。
     this.subs.add(this.assistant.activeMessages$.subscribe((list) => {
@@ -206,6 +224,10 @@ export class HomeComponent implements OnInit, OnDestroy, AfterViewChecked {
     this.navigate.emit({ tab: route });
   }
 
+  openDashboardAction(action: CreatorDashboardAction): void {
+    this.navigate.emit({ tab: action.target, payload: action.payload });
+  }
+
   // ===== 最近作品 =====
   private loadRecentResults(): void {
     this.recentLoading = true;
@@ -245,6 +267,7 @@ export class HomeComponent implements OnInit, OnDestroy, AfterViewChecked {
   trackByMsg = (_: number, m: AssistantMessage) => m.id;
   trackByConv = (_: number, c: Conversation) => c.id;
   trackBySuggest = (_: number, s: AssistantSuggestion) => s.tab + s.label;
+  trackByDashboardAction = (_: number, action: CreatorDashboardAction) => action.id;
 
   formatTime(ts: number): string {
     if (!ts) return '';
@@ -266,4 +289,12 @@ export class HomeComponent implements OnInit, OnDestroy, AfterViewChecked {
     const txt = lastMsg.content.replace(/\s+/g, ' ').trim();
     return txt.length > 40 ? txt.slice(0, 40) + '…' : txt;
   }
+
+  private refreshCreatorDashboard(): void {
+    try {
+      this.creatorSummary = this.dashboard.getSummary();
+    } catch {
+      this.creatorSummary = null;
+    }
+  }
 }

+ 2 - 2
src/app/pages/pipelines/topic-to-video/topic-to-video.component.html

@@ -353,7 +353,7 @@
             <!-- 1. 画面描述(始终可编辑) -->
             <label class="t2v-card-label">画面描述</label>
             <textarea class="dh-textarea t2v-scene-item__edit" rows="2" maxlength="300"
-                      placeholder="中英文均可,推荐英文。例:a woman walks in dawn fog, golden hour, wide shot, slow zoom in"
+                      placeholder="请用中文描述画面。例:清晨薄雾中,一位女性向前行走,金色晨光,广角镜头,缓慢推进"
                       [(ngModel)]="s.scenePrompt"
                       (ngModelChange)="syncDraft()"
                       [disabled]="['image-pending','tail-pending','video-pending'].includes(s.state)"></textarea>
@@ -643,7 +643,7 @@
 
         <div class="pl-result-actions">
           <a class="dh-chip-btn" [href]="resultVideoUrl" target="_blank" rel="noopener">在新窗口打开</a>
-          <a class="dh-chip-btn" [href]="resultVideoUrl" [download]="(topic || 'topic-to-video') + '.mp4'">下载视频</a>
+          <a class="dh-chip-btn" [href]="resultVideoUrl" [download]="(topic || '主题生视频') + '.mp4'">下载视频</a>
         </div>
       </div>
     </div>

+ 52 - 48
src/app/pages/pipelines/topic-to-video/topic-to-video.component.ts

@@ -24,7 +24,7 @@ import { TemplateService } from '../../../services/template.service';
 import { GenerationTemplate } from '../../../models/template.model';
 
 interface SceneSegment {
-  /** 画面描述(英文 prompt);快速模式由 LLM 生成,手动模式由用户填入 */
+  /** 画面描述(中文提示词);快速模式由 LLM 生成,手动模式由用户填入 */
   scenePrompt: string;
   /** 镜头类型:决定默认继承哪些连续性锚点 */
   shotType?: T2vShotType;
@@ -194,11 +194,11 @@ export class TopicToVideoComponent implements OnInit, OnDestroy {
   private genSub?: Subscription;
 
   readonly stylePresets: { value: T2vStyle; label: string; promptHint: string }[] = [
-    { value: 'cinematic', label: '电影感', promptHint: 'cinematic lighting, film grain, anamorphic' },
-    { value: 'realistic', label: '写实', promptHint: 'photorealistic, ultra detailed, 4k' },
-    { value: 'anime', label: '动漫', promptHint: 'anime style, vibrant colors, studio ghibli' },
-    { value: 'illustration', label: '插画', promptHint: 'flat illustration, modern design' },
-    { value: 'documentary', label: '纪录片', promptHint: 'documentary photography, natural light' },
+    { value: 'cinematic', label: '电影感', promptHint: '电影感光影、胶片颗粒、宽银幕镜头质感' },
+    { value: 'realistic', label: '写实', promptHint: '写实照片质感,细节丰富,高清画面' },
+    { value: 'anime', label: '动漫', promptHint: '动画风格,色彩鲜明,柔和手绘质感' },
+    { value: 'illustration', label: '插画', promptHint: '扁平插画风格,现代设计感' },
+    { value: 'documentary', label: '纪录片', promptHint: '纪实摄影风格,自然光,真实现场感' },
   ];
 
   readonly aspectPresets: { value: T2vAspect; label: string; w: number; h: number }[] = [
@@ -571,6 +571,10 @@ export class TopicToVideoComponent implements OnInit, OnDestroy {
     return this.stylePresets.find((item) => item.value === value)?.label || value;
   }
 
+  private shotTypeLabel(value: T2vShotType): string {
+    return this.shotTypePresets.find((item) => item.value === value)?.label || value;
+  }
+
   private aspectLabel(value: T2vAspect): string {
     return this.aspectPresets.find((item) => item.value === value)?.label || value;
   }
@@ -673,14 +677,14 @@ export class TopicToVideoComponent implements OnInit, OnDestroy {
     if (!this.continuityEnabled) return '';
     const inherit = this.resolveInheritance(scene);
     const parts = [
-      inherit.subject && this.visualBible.subject ? `subject: ${this.visualBible.subject}` : '',
-      inherit.subject && this.visualBible.product ? `product: ${this.visualBible.product}` : '',
-      inherit.location && this.visualBible.location ? `location: ${this.visualBible.location}` : '',
-      inherit.style && this.visualBible.palette ? `palette: ${this.visualBible.palette}` : '',
-      inherit.style && this.visualBible.lighting ? `lighting: ${this.visualBible.lighting}` : '',
-      inherit.style && this.visualBible.camera ? `camera: ${this.visualBible.camera}` : '',
-      inherit.style && this.visualBible.styleNotes ? `style: ${this.visualBible.styleNotes}` : '',
-      this.visualBible.constraints ? `must keep: ${this.visualBible.constraints}` : '',
+      inherit.subject && this.visualBible.subject ? `主体:${this.visualBible.subject}` : '',
+      inherit.subject && this.visualBible.product ? `产品:${this.visualBible.product}` : '',
+      inherit.location && this.visualBible.location ? `场景:${this.visualBible.location}` : '',
+      inherit.style && this.visualBible.palette ? `配色:${this.visualBible.palette}` : '',
+      inherit.style && this.visualBible.lighting ? `光线:${this.visualBible.lighting}` : '',
+      inherit.style && this.visualBible.camera ? `镜头:${this.visualBible.camera}` : '',
+      inherit.style && this.visualBible.styleNotes ? `风格:${this.visualBible.styleNotes}` : '',
+      this.visualBible.constraints ? `必须保持:${this.visualBible.constraints}` : '',
     ].filter(Boolean);
     return parts.join(', ');
   }
@@ -715,30 +719,30 @@ export class TopicToVideoComponent implements OnInit, OnDestroy {
     const isEmptyScene = !inherit.subject;
     const frameRule = frame === 'tail'
       ? (isEmptyScene
-          ? 'ending frame, environment-only composition, clearly later moment with different camera angle and lighting beat'
-          : 'ending frame, clearly later moment after the opening frame, subject/product position and gesture changed, different camera angle, no identical composition')
+          ? '尾帧,纯环境构图,呈现明显更晚的时刻,镜头角度和光线节奏与首帧不同'
+          : '尾帧,呈现首帧之后的明确后续时刻,主体或产品的位置与姿态发生变化,镜头角度不同,不要复制首帧构图')
       : frame === 'video'
         ? (isEmptyScene
-            ? 'smooth natural environmental motion only (wind, light shift, camera drift); do not introduce people or products'
-            : 'smooth natural motion, preserve same identity and environment')
+            ? '只保留自然环境运动,例如风、光影变化、轻微镜头漂移;不要引入人物或产品'
+            : '自然流畅的运动,保持同一主体身份和同一环境')
         : (isEmptyScene
-            ? 'opening frame, environment-only / pure scenery composition, no people or products in frame'
-            : 'opening frame, subject/product visible in foreground, unique composition for this shot');
-    const shotLabel = index >= 0 ? `shot ${index + 1}` : '';
-    const narration = scene.narration ? `story beat: ${scene.narration}` : '';
+            ? '首帧,纯环境或纯风景构图,画面中不要出现人物或产品'
+            : '首帧,主体或产品在前景清晰可见,本镜头构图需要独立且有辨识度');
+    const shotLabel = index >= 0 ? `第 ${index + 1} 个镜头` : '';
+    const narration = scene.narration ? `故事节拍:${scene.narration}` : '';
     const emptySceneRule = isEmptyScene
-      ? 'STRICT: this is an empty / establishing shot; do NOT include any person, character, face, hand, body, crowd, product or branded item; render landscape, environment, objects, abstract elements only'
+      ? '严格要求:这是空镜或建立镜头;不要包含任何人物、角色、面部、手、身体、人群、产品或品牌物品;只呈现风景、环境、物体或抽象元素'
       : '';
     return [
       shotLabel,
-      `shot-specific visual action: ${scene.scenePrompt}`,
-      `shot type: ${shotType}`,
+      `本镜头画面动作:${scene.scenePrompt}`,
+      `镜头类型:${this.shotTypeLabel(shotType)}`,
       narration,
-      state ? `continuity state: ${state}` : '',
+      state ? `连续性状态:${state}` : '',
       frameRule,
-      bible ? `global consistency reference: ${bible}` : '',
+      bible ? `全局一致性参考:${bible}` : '',
       emptySceneRule,
-      'make this shot visually distinct from other shots, do not reuse the same composition, avoid generic establishing shot',
+      '让本镜头与其他镜头在视觉上明显区分,不要复用同一构图,避免泛泛的空镜',
     ].filter(Boolean).join(', ');
   }
 
@@ -883,8 +887,8 @@ ${this.topic.trim()}
 
 == 全局视觉锚点(每段都必须复用)==
 - 视觉风格:${styleHint}
-- 你需要先在脑内确定「主角/主体(subject)」+「主色调(palette,如 warm amber / cool teal)」+「场景地点(location)」+「光线时段(lighting,如 golden hour / overcast)」
-- 上述 4 项一旦确定,**全部 ${this.nScenes} 段的 scene_prompt 中都要明确包含同样的 subject / palette / lighting / location 关键词**,仅变化镜头与动作
+- 你需要先在脑内确定「主角/主体」+「主色调(如暖琥珀色 / 冷青色)」+「场景地点」+「光线时段(如金色晨光 / 阴天漫射光)」
+- 上述 4 项一旦确定,**全部 ${this.nScenes} 段的 scene_prompt 中都要明确包含同样的主体、配色、光线、场景关键词**,仅变化镜头与动作
 
 == 叙事骨架(按顺序对应每段位置)==
 ${arc}
@@ -896,8 +900,8 @@ ${arc}
 - **严禁段落独立**:禁止每段自成一个小故事;解说必须前后呼应,把整支视频读成一段完整文章
 
 == 字段格式 ==
-- scene_prompt: 英文 ≤30 词,必含 subject / palette / lighting / location 锚点 + 镜头语言(wide / close-up / tracking / low angle 等)${
-        this.mediaMode === 'video' ? ',并加入轻微动作(slow zoom in / drift / wind / rising steam 等)以驱动 i2v' : ''
+- scene_prompt: 中文 ≤38 字,必含主体 / 配色 / 光线 / 场景锚点 + 镜头语言(如广角、特写、跟拍、低角度等)${
+        this.mediaMode === 'video' ? ',并加入轻微动作(缓慢推进、轻微漂移、风吹、蒸汽上升等)以驱动图生视频' : ''
       }
 - ${narrationRule}
 
@@ -956,7 +960,7 @@ ${arc}
 ${this.topic.trim()}
 
 已有全局设定(为空则你补全):
-${existingBible || 'none'}
+${existingBible || ''}
 
 视觉风格:
 ${styleHint}
@@ -983,14 +987,14 @@ ${arc}
 只输出严格 JSON 对象,不要 markdown:
 {
   "visual_bible": {
-    "subject": "English fixed person/subject appearance",
-    "product": "English fixed product look/selling point, empty if none",
-    "location": "English fixed location and spatial layout",
-    "palette": "English fixed color palette",
-    "lighting": "English fixed lighting",
-    "camera": "English overall camera language",
-    "style_notes": "English visual style",
-    "constraints": "English things that must not change"
+    "subject": "中文填写固定人物/主体外观",
+    "product": "中文填写固定产品外观/卖点,无则留空",
+    "location": "中文填写固定场景和空间布局",
+    "palette": "中文填写固定色彩方案",
+    "lighting": "中文填写固定光线",
+    "camera": "中文填写整体镜头语言",
+    "style_notes": "中文填写视觉风格",
+    "constraints": "中文填写必须保持不变的要点"
   },
   "scenes": [
     {
@@ -999,10 +1003,10 @@ ${arc}
       "inherit_location": true,
       "inherit_style": true,
       "reference_mode": "auto | none | anchor | previous | current-head",
-      "scene_prompt": "English <= 38 words, concrete subject/product action + composition + camera change",
-      "start_state": "English state continuing from previous shot",
-      "end_state": "English ending state for next shot",
-      "continuity": "English connection to previous shot",
+      "scene_prompt": "中文,38 字以内,写清具体主体/产品动作、构图和镜头变化",
+      "start_state": "中文填写承接上一镜的起始状态",
+      "end_state": "中文填写留给下一镜承接的结束状态",
+      "continuity": "中文填写与上一镜的衔接关系",
       "narration": "中文"
     }
   ]
@@ -1044,7 +1048,7 @@ ${arc}
             referenceMode: this.normalizeReferenceMode(item.reference_mode || item.referenceMode),
             startState: String(item.start_state || item.startState || '').trim(),
             endState: String(item.end_state || item.endState || '').trim(),
-            continuity: String(item.continuity || (idx === 0 ? 'opening shot' : '')).trim(),
+            continuity: String(item.continuity || (idx === 0 ? '开场镜头' : '')).trim(),
             narration: String(item.narration || '').trim(),
             state: 'idle' as const,
             videoMode: 'first' as const,
@@ -1371,7 +1375,7 @@ ${arc}
     return new Promise<void>((resolve, reject) => {
       this.compositeHandle = this.browserFfmpeg.composeVideo({
         segments,
-        title: this.topic.trim() || 'topic-to-video',
+        title: this.topic.trim() || '主题生视频',
         onStage: (e) => {
           this.zone.run(() => {
             if (e.message) this.stageStatus = e.message;
@@ -1596,7 +1600,7 @@ ${arc}
 
   /**
    * 手动模式:AI 生成尾帧(即梦文生图)。
-   * 使用 scene.scenePrompt 作为输入,加 "ending frame, final shot of scene" 提示,
+   * 使用 scene.scenePrompt 作为输入,并补充尾帧提示,
    * 让画面与首帧保持一致风格但作为「场景尾」。
    */
   regenerateTailImage(index: number): void {

+ 1 - 1
src/app/pages/templates/templates.component.html

@@ -96,7 +96,7 @@
 
         <div class="template-config">
           <strong>配置摘要</strong>
-          <pre>{{ template.config | json }}</pre>
+          <pre>{{ configSummary(template) }}</pre>
         </div>
 
         <div class="template-tags">

+ 59 - 0
src/app/pages/templates/templates.component.ts

@@ -159,6 +159,65 @@ export class TemplatesComponent implements OnInit {
     return scope === 'system' ? '系统模板' : '我的模板';
   }
 
+  configSummary(template: GenerationTemplate): string {
+    const config = template.config || {};
+    const lines: string[] = [];
+
+    if (typeof config['nScenes'] !== 'undefined') lines.push(`分镜数:${config['nScenes']} 段`);
+    if (config['aspect']) lines.push(`画幅:${this.aspectLabel(String(config['aspect']))}`);
+    if (typeof config['quickMode'] !== 'undefined') lines.push(`生成模式:${config['quickMode'] ? '快速生成' : '手动编排'}`);
+    if (config['mediaMode']) lines.push(`素材类型:${this.mediaModeLabel(String(config['mediaMode']))}`);
+    if (config['clipFrames']) lines.push(`单镜头时长:${Number(config['clipFrames']) >= 241 ? '约 10 秒' : '约 5 秒'}`);
+    if (config['clipQuality']) lines.push(`清晰度:${config['clipQuality']}`);
+    if (typeof config['continuityEnabled'] !== 'undefined') lines.push(`一致性参考:${config['continuityEnabled'] ? '开启' : '关闭'}`);
+    if (config['width'] && config['height']) lines.push(`尺寸:${config['width']} × ${config['height']}`);
+    if (config['resolution']) lines.push(`清晰度:${config['resolution']}`);
+    if (config['voiceMood']) lines.push(`声音状态:${config['voiceMood']}`);
+    if (config['stylePrompt']) lines.push(`风格提示:${config['stylePrompt']}`);
+    if (config['scriptFrame']) lines.push(`脚本结构:${config['scriptFrame']}`);
+
+    lines.push(...this.visualBibleSummary(config['visualBible']));
+    return lines.length ? lines.join('\n') : '暂无配置摘要';
+  }
+
+  private visualBibleSummary(value: unknown): string[] {
+    if (!value || typeof value !== 'object') return [];
+    const bible = value as Record<string, unknown>;
+    const fields: Array<[string, string]> = [
+      ['subject', '主体'],
+      ['product', '产品'],
+      ['location', '场景'],
+      ['palette', '配色'],
+      ['lighting', '光线'],
+      ['camera', '镜头'],
+      ['styleNotes', '风格'],
+      ['constraints', '约束'],
+    ];
+    return fields
+      .map(([key, label]) => {
+        const text = String(bible[key] || '').trim();
+        return text ? `${label}:${text}` : '';
+      })
+      .filter(Boolean);
+  }
+
+  private aspectLabel(value: string): string {
+    const map: Record<string, string> = {
+      portrait: '竖屏 9:16',
+      landscape: '横屏 16:9',
+      square: '方屏 1:1',
+    };
+    return map[value] || value;
+  }
+
+  private mediaModeLabel(value: string): string {
+    const map: Record<string, string> = {
+      image: '静态图',
+      video: '动态视频',
+    };
+    return map[value] || value;
+  }
+
   formatTime(value: string): string {
     const date = new Date(value);
     if (Number.isNaN(date.getTime())) return '';

+ 1 - 1
src/app/services/assistant.service.ts

@@ -35,7 +35,7 @@ const MAX_MSGS_PER_CONV = 80;
 const SYSTEM_PROMPT = `你是「Tik-Tok 视频生成系统」的内置创作助手,专注于短视频内容创作。
 
 ## 你的能力范围
-1. 帮用户写视频脚本、镜头描述、提示词(中英文均可)
+1. 帮用户写视频脚本、镜头描述、中文提示词
 2. 给出风格、节奏、分镜、配乐方面的建议
 3. 解答系统使用问题(系统包含以下生成模式:主题生视频 / 图生视频 / 数字人合成 / 动作迁移 / 素材合成视频 / 标准视频生成)
 4. 当用户的需求显然适合直接进入某条生成流水线时,在回答**最末尾**用一个独立的 JSON 代码块给出建议:

+ 139 - 11
src/app/services/batch-job.service.ts

@@ -5,16 +5,25 @@ import {
   BatchJob,
   BatchJobCreateInput,
   BatchJobItem,
+  BatchJobItemLog,
   BatchJobRunResult,
 } from '../models/batch-job.model';
+import { IndexedDbJsonStorage } from './indexed-db-json-storage';
+import { userFriendlyError } from './user-message.util';
 
 const JOBS_STORAGE_KEY = 'videoWorkflow.batchJobs.v1';
 const ITEMS_STORAGE_KEY = 'videoWorkflow.batchJobItems.v1';
 
-type BatchItemRunner = (item: BatchJobItem, job: BatchJob) => Promise<BatchJobRunResult | void>;
+interface BatchItemRunContext {
+  markRunning: (stepId?: string) => void;
+  markWaitingExternal: (externalTaskIds: Record<string, string>, stepId?: string) => void;
+}
+
+export type BatchItemRunner = (item: BatchJobItem, job: BatchJob, context: BatchItemRunContext) => Promise<BatchJobRunResult | void>;
 
 @Injectable({ providedIn: 'root' })
 export class BatchJobService {
+  private readonly idb = new IndexedDbJsonStorage('batchJobs');
   private readonly jobsSubject = new BehaviorSubject<BatchJob[]>(this.readJobs());
   private readonly itemsSubject = new BehaviorSubject<BatchJobItem[]>(this.readItems());
   readonly jobs$ = this.jobsSubject.asObservable();
@@ -23,7 +32,9 @@ export class BatchJobService {
   private runningJobId = '';
   private stopRequested = false;
 
-  constructor(private auth: AuthCreditService) {}
+  constructor(private auth: AuthCreditService) {
+    void this.restoreFromIndexedDb();
+  }
 
   get jobs(): BatchJob[] {
     return this.jobsSubject.value;
@@ -70,6 +81,10 @@ export class BatchJobService {
       completed: 0,
       failed: 0,
       estimatedCredits: Math.max(0, Math.ceil(Number(input.estimatedCredits || 0))),
+      concurrency: 1,
+      intervalMs: 2000,
+      runnerVersion: 1,
+      failureSummary: {},
       createdAt: now,
       updatedAt: now,
     };
@@ -99,8 +114,10 @@ export class BatchJobService {
     this.patchItem(itemId, {
       status: 'queued',
       errorMessage: undefined,
+      lastErrorCode: undefined,
       resultUrl: undefined,
       retryCount: Number(item.retryCount || 0) + 1,
+      logs: this.appendItemLog(item, 'info', 'retry', '失败子任务已重新排队'),
     });
     this.recalculateJob(item.batchJobId, 'queued');
   }
@@ -114,7 +131,11 @@ export class BatchJobService {
 
     this.runningJobId = jobId;
     this.stopRequested = false;
-    this.patchJob(jobId, { status: 'running' });
+    this.patchJob(jobId, {
+      status: 'running',
+      startedAt: job.startedAt || new Date().toISOString(),
+      lastRunAt: new Date().toISOString(),
+    });
 
     try {
       while (!this.stopRequested) {
@@ -124,18 +145,34 @@ export class BatchJobService {
         const nextItem = this.listItems(jobId).find((item) => item.status === 'queued');
         if (!nextItem) break;
 
-        this.patchItem(nextItem.id, { status: 'running', errorMessage: undefined });
+        this.patchItem(nextItem.id, {
+          status: 'running',
+          errorMessage: undefined,
+          lastErrorCode: undefined,
+          startedAt: nextItem.startedAt || new Date().toISOString(),
+          logs: this.appendItemLog(nextItem, 'info', 'queue', '开始执行子任务'),
+        });
         try {
-          const result = await runner(nextItem, currentJob);
+          const result = await runner(nextItem, currentJob, this.createRunContext(nextItem.id));
+          const latestItem = this.items.find((item) => item.id === nextItem.id) || nextItem;
           this.patchItem(nextItem.id, {
             status: 'completed',
             resultUrl: result?.resultUrl,
             generationTaskId: result?.generationTaskId,
+            externalTaskIds: result?.externalTaskIds,
+            finishedAt: new Date().toISOString(),
+            logs: this.appendItemLog(latestItem, 'info', 'complete', '子任务执行完成'),
           });
         } catch (err: any) {
+          const latestItem = this.items.find((item) => item.id === nextItem.id) || nextItem;
+          const message = userFriendlyError(err, '子任务执行失败,请稍后重试');
+          const errorCode = this.classifyErrorCode(err);
           this.patchItem(nextItem.id, {
             status: 'failed',
-            errorMessage: err?.message || '子任务执行失败',
+            errorMessage: message,
+            lastErrorCode: errorCode,
+            finishedAt: new Date().toISOString(),
+            logs: this.appendItemLog(latestItem, 'error', errorCode, message),
           });
         }
         this.recalculateJob(jobId);
@@ -163,7 +200,7 @@ export class BatchJobService {
     const failed = items.filter((item) => item.status === 'failed').length;
     const cancelled = items.filter((item) => item.status === 'cancelled').length;
     const queued = items.filter((item) => item.status === 'queued').length;
-    const running = items.filter((item) => item.status === 'running').length;
+    const running = items.filter((item) => item.status === 'running' || item.status === 'waiting_external').length;
     const status = (() => {
       if (job.status === 'cancelled') return 'cancelled';
       if (fallbackStatus) return fallbackStatus;
@@ -173,7 +210,16 @@ export class BatchJobService {
       if (failed > 0 && completed + failed + cancelled === items.length) return 'failed';
       return job.status;
     })();
-    this.patchJob(jobId, { completed, failed, status });
+    const patch: Partial<BatchJob> = {
+      completed,
+      failed,
+      status,
+      failureSummary: this.buildFailureSummary(items),
+    };
+    if (status === 'completed' || status === 'failed' || status === 'cancelled') {
+      patch.finishedAt = job.finishedAt || new Date().toISOString();
+    }
+    this.patchJob(jobId, patch);
   }
 
   private patchJob(id: string, patch: Partial<BatchJob>): void {
@@ -186,6 +232,30 @@ export class BatchJobService {
     this.setItems(this.items.map((item) => item.id === id ? { ...item, ...patch, id, updatedAt: now } : item));
   }
 
+  private createRunContext(itemId: string): BatchItemRunContext {
+    return {
+      markRunning: (stepId?: string) => {
+        const item = this.items.find((row) => row.id === itemId);
+        this.patchItem(itemId, {
+          status: 'running',
+          lastStepId: stepId,
+          startedAt: item?.startedAt || new Date().toISOString(),
+          logs: item ? this.appendItemLog(item, 'info', stepId || 'running', '子任务进入执行步骤') : undefined,
+        });
+      },
+      markWaitingExternal: (externalTaskIds: Record<string, string>, stepId?: string) => {
+        const item = this.items.find((row) => row.id === itemId);
+        this.patchItem(itemId, {
+          status: 'waiting_external',
+          lastStepId: stepId,
+          externalTaskIds: { ...(item?.externalTaskIds || {}), ...externalTaskIds },
+          startedAt: item?.startedAt || new Date().toISOString(),
+          logs: item ? this.appendItemLog(item, 'info', stepId || 'external', '已记录外部任务 ID,等待上游结果') : undefined,
+        });
+      },
+    };
+  }
+
   private findJob(id: string): BatchJob | undefined {
     const userId = this.currentUserId();
     return this.jobs.find((job) => job.id === id && job.userId === userId);
@@ -211,7 +281,7 @@ export class BatchJobService {
   private readItems(): BatchJobItem[] {
     try {
       const parsed = JSON.parse(localStorage.getItem(ITEMS_STORAGE_KEY) || '[]');
-      return Array.isArray(parsed) ? parsed : [];
+      return this.normalizeRestoredItems(parsed);
     } catch {
       return [];
     }
@@ -220,12 +290,18 @@ export class BatchJobService {
   private setJobs(jobs: BatchJob[]): void {
     const sorted = [...jobs].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)).slice(0, 100);
     this.jobsSubject.next(sorted);
-    localStorage.setItem(JOBS_STORAGE_KEY, JSON.stringify(sorted));
+    try {
+      localStorage.setItem(JOBS_STORAGE_KEY, JSON.stringify(sorted));
+    } catch {}
+    void this.idb.writeArray(JOBS_STORAGE_KEY, sorted);
   }
 
   private setItems(items: BatchJobItem[]): void {
     this.itemsSubject.next(items);
-    localStorage.setItem(ITEMS_STORAGE_KEY, JSON.stringify(items));
+    try {
+      localStorage.setItem(ITEMS_STORAGE_KEY, JSON.stringify(items));
+    } catch {}
+    void this.idb.writeArray(ITEMS_STORAGE_KEY, items);
   }
 
   private createId(prefix: string): string {
@@ -235,4 +311,56 @@ export class BatchJobService {
   private cloneSerializable<T>(value: T): T {
     return JSON.parse(JSON.stringify(value || {}));
   }
+
+  private appendItemLog(item: BatchJobItem, level: BatchJobItemLog['level'], step: string, message: string): BatchJobItemLog[] {
+    const logs = Array.isArray(item.logs) ? item.logs : [];
+    return [
+      ...logs,
+      {
+        at: new Date().toISOString(),
+        level,
+        step,
+        message,
+      },
+    ].slice(-30);
+  }
+
+  private buildFailureSummary(items: BatchJobItem[]): Record<string, number> {
+    return items.reduce((acc, item) => {
+      if (item.status !== 'failed') return acc;
+      const code = item.lastErrorCode || 'unknown';
+      acc[code] = (acc[code] || 0) + 1;
+      return acc;
+    }, {} as Record<string, number>);
+  }
+
+  private classifyErrorCode(error: any): string {
+    const text = `${error?.code || ''} ${error?.status || ''} ${error?.message || error || ''}`.toLowerCase();
+    if (text.includes('429') || text.includes('rate') || text.includes('limit') || text.includes('限流')) return 'rate_limit';
+    if (text.includes('timeout') || text.includes('超时')) return 'timeout';
+    if (text.includes('network') || text.includes('fetch') || text.includes('网络')) return 'network';
+    if (text.includes('credential') || text.includes('unauthorized') || text.includes('401') || text.includes('403')) return 'credential';
+    if (text.includes('invalid') || text.includes('参数')) return 'invalid_input';
+    return 'unknown';
+  }
+
+  private normalizeRestoredItems(value: any): BatchJobItem[] {
+    if (!Array.isArray(value)) return [];
+    return value.map((item) => item?.status === 'running'
+      ? { ...item, status: 'queued', errorMessage: item.errorMessage || '页面刷新后已自动回到队列,可继续执行。' }
+      : item);
+  }
+
+  private async restoreFromIndexedDb(): Promise<void> {
+    const [jobs, items] = await Promise.all([
+      this.idb.readArray<BatchJob>(JOBS_STORAGE_KEY),
+      this.idb.readArray<BatchJobItem>(ITEMS_STORAGE_KEY),
+    ]);
+    if (jobs.length) {
+      this.jobsSubject.next([...jobs].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)).slice(0, 100));
+    }
+    if (items.length) {
+      this.itemsSubject.next(this.normalizeRestoredItems(items));
+    }
+  }
 }

+ 17 - 1
src/app/services/content-asset.service.ts

@@ -1,14 +1,20 @@
 import { Injectable } from '@angular/core';
 import { BehaviorSubject } from 'rxjs';
 import { ContentAssetMetadata, ContentAssetUpsertInput, ContentProject } from '../models/content-asset.model';
+import { IndexedDbJsonStorage } from './indexed-db-json-storage';
 
 const STORAGE_KEY = 'videoWorkflow.contentAssets.v1';
 
 @Injectable({ providedIn: 'root' })
 export class ContentAssetService {
+  private readonly idb = new IndexedDbJsonStorage('contentAssets');
   private readonly assetsSubject = new BehaviorSubject<ContentAssetMetadata[]>(this.restore());
   readonly assets$ = this.assetsSubject.asObservable();
 
+  constructor() {
+    void this.restoreFromIndexedDb();
+  }
+
   get assets(): ContentAssetMetadata[] {
     return this.assetsSubject.value;
   }
@@ -143,6 +149,16 @@ export class ContentAssetService {
   private setAssets(assets: ContentAssetMetadata[]): void {
     const sorted = [...assets].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 500);
     this.assetsSubject.next(sorted);
-    localStorage.setItem(STORAGE_KEY, JSON.stringify(sorted));
+    try {
+      localStorage.setItem(STORAGE_KEY, JSON.stringify(sorted));
+    } catch {}
+    void this.idb.writeArray(STORAGE_KEY, sorted);
+  }
+
+  private async restoreFromIndexedDb(): Promise<void> {
+    const assets = await this.idb.readArray<ContentAssetMetadata>(STORAGE_KEY);
+    if (assets.length) {
+      this.assetsSubject.next([...assets].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 500));
+    }
   }
 }

+ 113 - 0
src/app/services/creator-dashboard.service.ts

@@ -0,0 +1,113 @@
+import { Injectable } from '@angular/core';
+import { CreatorDashboardAction, CreatorDashboardSummary } from '../models/creator-dashboard.model';
+import { BatchJobService } from './batch-job.service';
+import { ContentAssetService } from './content-asset.service';
+import { GenerationTaskService } from './generation-task.service';
+import { TopicPoolService } from './topic-pool.service';
+
+@Injectable({ providedIn: 'root' })
+export class CreatorDashboardService {
+  constructor(
+    private generationTasks: GenerationTaskService,
+    private topicPool: TopicPoolService,
+    private contentAssets: ContentAssetService,
+    private batchJobs: BatchJobService,
+  ) {}
+
+  getSummary(): CreatorDashboardSummary {
+    const tasks = this.generationTasks.tasks;
+    const topics = this.topicPool.listTopics();
+    const assets = this.contentAssets.assets;
+    const jobs = this.batchJobs.listJobs();
+
+    const runningTasks = tasks.filter((task) => task.status === 'running' || task.status === 'waiting_external').length;
+    const recoverableTasks = tasks.filter((task) => task.recoverable && task.status !== 'completed' && task.status !== 'cancelled').length;
+    const failedTasks = tasks.filter((task) => task.status === 'failed').length;
+    const pendingTopics = topics.filter((topic) => topic.status === 'idea' || topic.status === 'script_ready').length;
+    const readyAssets = assets.filter((asset) => asset.type === 'video' && asset.source?.isFinal && !asset.archived).length;
+    const activeBatchJobs = jobs.filter((job) => job.status === 'queued' || job.status === 'running' || job.status === 'paused').length;
+
+    return {
+      runningTasks,
+      recoverableTasks,
+      failedTasks,
+      pendingTopics,
+      readyAssets,
+      activeBatchJobs,
+      suggestedActions: this.buildActions({
+        runningTasks,
+        recoverableTasks,
+        failedTasks,
+        pendingTopics,
+        readyAssets,
+        activeBatchJobs,
+      }),
+    };
+  }
+
+  private buildActions(input: Omit<CreatorDashboardSummary, 'suggestedActions'>): CreatorDashboardAction[] {
+    const actions: CreatorDashboardAction[] = [];
+
+    if (input.failedTasks > 0) {
+      actions.push({
+        id: 'failed-tasks',
+        priority: 'high',
+        title: `处理 ${input.failedTasks} 个失败任务`,
+        description: '先清理失败项,避免批量生产继续堆积不可用结果。',
+        target: 'tasks',
+      });
+    }
+
+    if (input.recoverableTasks > 0) {
+      actions.push({
+        id: 'recoverable-tasks',
+        priority: 'high',
+        title: `检查 ${input.recoverableTasks} 个可恢复任务`,
+        description: '有任务仍在等待上游结果,适合优先确认是否已恢复归档。',
+        target: 'tasks',
+      });
+    }
+
+    if (input.pendingTopics > 0) {
+      actions.push({
+        id: 'pending-topics',
+        priority: 'medium',
+        title: `生产 ${input.pendingTopics} 个待用选题`,
+        description: '选题池已有可用方向,可以进入批量生产形成成片。',
+        target: 'batch-production',
+      });
+    }
+
+    if (input.readyAssets > 0) {
+      actions.push({
+        id: 'ready-assets',
+        priority: 'medium',
+        title: `整理 ${input.readyAssets} 条待发布成片`,
+        description: '素材库里有可复用成片,适合补元数据或记录发布表现。',
+        target: 'library',
+      });
+    }
+
+    if (input.activeBatchJobs > 0) {
+      actions.push({
+        id: 'active-batch',
+        priority: 'medium',
+        title: `跟进 ${input.activeBatchJobs} 个批量任务`,
+        description: '批量队列仍有运行、排队或暂停项,需要确认下一步。',
+        target: 'batch-production',
+      });
+    }
+
+    if (!actions.length) {
+      actions.push({
+        id: 'start-topic-video',
+        priority: 'low',
+        title: '开始一条主题生视频',
+        description: '当前没有紧急阻塞,可以从一个新主题直接进入生产。',
+        target: 'topic-to-video',
+      });
+    }
+
+    return actions.slice(0, 4);
+  }
+}

+ 202 - 2
src/app/services/draft-storage.ts

@@ -1,11 +1,11 @@
 /**
  * Draft 持久化抽象层
  *
- * 默认实现:LocalStorageDraftStorage(双层 key
+ * 默认实现:IndexedDbDraftStorage(localStorage fallback
  *   - tiktok.drafts.index   → DraftMeta[]      列表渲染快
  *   - tiktok.draft.<id>     → 完整 PipelineDraft(按需 lazy load)
  *
- * 后续无痛升级到 IndexedDB / 远端 /api/drafts,业务零改动。
+ * 后续可继续升级到远端 /api/drafts,业务零改动。
  */
 
 import { DraftMeta, PipelineDraft, toDraftMeta } from '../models/pipeline-draft.model';
@@ -21,6 +21,12 @@ export interface DraftStorage {
 
 const INDEX_KEY = 'tiktok.drafts.index';
 const DRAFT_KEY_PREFIX = 'tiktok.draft.';
+const IDB_DB_NAME = 'videoWorkflow.workspaceStorage';
+const IDB_DB_VERSION = 1;
+const IDB_DRAFT_STORE = 'drafts';
+const IDB_DRAFT_INDEX_STORE = 'draftIndex';
+const IDB_SCOPE_INDEX = 'scope';
+const IDB_MIGRATION_KEY_PREFIX = 'videoWorkflow.storageMigration.draftsIndexedDb.v1.';
 
 export class LocalStorageDraftStorage implements DraftStorage {
   constructor(private readonly scope = '') {}
@@ -129,3 +135,197 @@ export class LocalStorageDraftStorage implements DraftStorage {
     } catch {}
   }
 }
+
+interface ScopedDraftRecord {
+  storageKey: string;
+  scope: string;
+  draft: PipelineDraft;
+}
+
+interface ScopedDraftMetaRecord {
+  storageKey: string;
+  scope: string;
+  meta: DraftMeta;
+}
+
+export class IndexedDbDraftStorage implements DraftStorage {
+  private dbPromise: Promise<IDBDatabase> | null = null;
+  private readonly fallback: LocalStorageDraftStorage;
+  private readonly safeScope: string;
+
+  constructor(private readonly scope = '') {
+    this.safeScope = this.normalizeScope(scope);
+    this.fallback = new LocalStorageDraftStorage(scope);
+  }
+
+  async list(): Promise<DraftMeta[]> {
+    try {
+      await this.ensureMigrated();
+      const db = await this.openDb();
+      const records = await this.getAllByScope<ScopedDraftMetaRecord>(db, IDB_DRAFT_INDEX_STORE);
+      return records
+        .map((record) => record.meta)
+        .sort((a, b) => b.updatedAt - a.updatedAt);
+    } catch (error) {
+      console.warn('[IndexedDbDraftStorage] list failed, using localStorage fallback', error);
+      return this.fallback.list();
+    }
+  }
+
+  async get(id: string): Promise<PipelineDraft | null> {
+    try {
+      await this.ensureMigrated();
+      const db = await this.openDb();
+      const record = await this.requestToPromise<ScopedDraftRecord | undefined>(
+        db.transaction(IDB_DRAFT_STORE, 'readonly').objectStore(IDB_DRAFT_STORE).get(this.storageKey(id)),
+      );
+      return record?.draft || null;
+    } catch (error) {
+      console.warn('[IndexedDbDraftStorage] get failed, using localStorage fallback', id, error);
+      return this.fallback.get(id);
+    }
+  }
+
+  async put(draft: PipelineDraft): Promise<void> {
+    try {
+      await this.ensureMigrated();
+      const db = await this.openDb();
+      await this.putRecord(db, draft);
+    } catch (error) {
+      console.warn('[IndexedDbDraftStorage] put failed, using localStorage fallback', error);
+      await this.fallback.put(draft);
+    }
+  }
+
+  async delete(id: string): Promise<void> {
+    try {
+      await this.ensureMigrated();
+      const db = await this.openDb();
+      const tx = db.transaction([IDB_DRAFT_STORE, IDB_DRAFT_INDEX_STORE], 'readwrite');
+      tx.objectStore(IDB_DRAFT_STORE).delete(this.storageKey(id));
+      tx.objectStore(IDB_DRAFT_INDEX_STORE).delete(this.storageKey(id));
+      await this.transactionDone(tx);
+    } catch (error) {
+      console.warn('[IndexedDbDraftStorage] delete failed, using localStorage fallback', id, error);
+      await this.fallback.delete(id);
+    }
+  }
+
+  async clear(): Promise<void> {
+    try {
+      await this.ensureMigrated();
+      const db = await this.openDb();
+      const drafts = await this.getAllByScope<ScopedDraftMetaRecord>(db, IDB_DRAFT_INDEX_STORE);
+      const tx = db.transaction([IDB_DRAFT_STORE, IDB_DRAFT_INDEX_STORE], 'readwrite');
+      for (const record of drafts) {
+        tx.objectStore(IDB_DRAFT_STORE).delete(record.storageKey);
+        tx.objectStore(IDB_DRAFT_INDEX_STORE).delete(record.storageKey);
+      }
+      await this.transactionDone(tx);
+    } catch (error) {
+      console.warn('[IndexedDbDraftStorage] clear failed, using localStorage fallback', error);
+      await this.fallback.clear();
+    }
+  }
+
+  private async ensureMigrated(): Promise<void> {
+    if (this.hasMigrationMarker()) return;
+    const db = await this.openDb();
+    const existingCount = await this.countByScope(db, IDB_DRAFT_INDEX_STORE);
+    if (existingCount === 0) {
+      const metas = await this.fallback.list();
+      for (const meta of metas) {
+        const draft = await this.fallback.get(meta.id);
+        if (draft) await this.putRecord(db, draft);
+      }
+    }
+    this.setMigrationMarker();
+  }
+
+  private async putRecord(db: IDBDatabase, draft: PipelineDraft): Promise<void> {
+    const storageKey = this.storageKey(draft.id);
+    const tx = db.transaction([IDB_DRAFT_STORE, IDB_DRAFT_INDEX_STORE], 'readwrite');
+    tx.objectStore(IDB_DRAFT_STORE).put({
+      storageKey,
+      scope: this.safeScope,
+      draft,
+    } satisfies ScopedDraftRecord);
+    tx.objectStore(IDB_DRAFT_INDEX_STORE).put({
+      storageKey,
+      scope: this.safeScope,
+      meta: toDraftMeta(draft),
+    } satisfies ScopedDraftMetaRecord);
+    await this.transactionDone(tx);
+  }
+
+  private openDb(): Promise<IDBDatabase> {
+    if (this.dbPromise) return this.dbPromise;
+    if (!('indexedDB' in window)) return Promise.reject(new Error('IndexedDB 不可用'));
+    this.dbPromise = new Promise((resolve, reject) => {
+      const req = indexedDB.open(IDB_DB_NAME, IDB_DB_VERSION);
+      req.onupgradeneeded = () => {
+        const db = req.result;
+        if (!db.objectStoreNames.contains(IDB_DRAFT_STORE)) {
+          const store = db.createObjectStore(IDB_DRAFT_STORE, { keyPath: 'storageKey' });
+          store.createIndex(IDB_SCOPE_INDEX, 'scope', { unique: false });
+        }
+        if (!db.objectStoreNames.contains(IDB_DRAFT_INDEX_STORE)) {
+          const store = db.createObjectStore(IDB_DRAFT_INDEX_STORE, { keyPath: 'storageKey' });
+          store.createIndex(IDB_SCOPE_INDEX, 'scope', { unique: false });
+        }
+      };
+      req.onsuccess = () => resolve(req.result);
+      req.onerror = () => reject(req.error || new Error('IndexedDB 打开失败'));
+      req.onblocked = () => reject(new Error('IndexedDB 被其他页面占用,请关闭旧页面后重试'));
+    });
+    return this.dbPromise;
+  }
+
+  private async getAllByScope<T>(db: IDBDatabase, storeName: string): Promise<T[]> {
+    const store = db.transaction(storeName, 'readonly').objectStore(storeName);
+    const index = store.index(IDB_SCOPE_INDEX);
+    return this.requestToPromise<T[]>(index.getAll(this.safeScope));
+  }
+
+  private async countByScope(db: IDBDatabase, storeName: string): Promise<number> {
+    const store = db.transaction(storeName, 'readonly').objectStore(storeName);
+    return this.requestToPromise<number>(store.index(IDB_SCOPE_INDEX).count(this.safeScope));
+  }
+
+  private requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
+    return new Promise((resolve, reject) => {
+      request.onsuccess = () => resolve(request.result);
+      request.onerror = () => reject(request.error);
+    });
+  }
+
+  private transactionDone(tx: IDBTransaction): Promise<void> {
+    return new Promise((resolve, reject) => {
+      tx.oncomplete = () => resolve();
+      tx.onerror = () => reject(tx.error);
+      tx.onabort = () => reject(tx.error || new Error('IndexedDB 事务已中止'));
+    });
+  }
+
+  private storageKey(id: string): string {
+    return `${this.safeScope}::${id}`;
+  }
+
+  private normalizeScope(scope: string): string {
+    return (scope || 'default').replace(/[^a-zA-Z0-9_.:-]/g, '_');
+  }
+
+  private hasMigrationMarker(): boolean {
+    try {
+      return localStorage.getItem(IDB_MIGRATION_KEY_PREFIX + this.safeScope) === 'done';
+    } catch {
+      return false;
+    }
+  }
+
+  private setMigrationMarker(): void {
+    try {
+      localStorage.setItem(IDB_MIGRATION_KEY_PREFIX + this.safeScope, 'done');
+    } catch {}
+  }
+}

+ 35 - 0
src/app/services/generation-task.service.ts

@@ -1,15 +1,21 @@
 import { Injectable } from '@angular/core';
 import { BehaviorSubject } from 'rxjs';
 import { GenerationTask, GenerationTaskCost, GenerationTaskStatus, GenerationTaskStep, LegacyTaskLike } from '../models/generation-task.model';
+import { IndexedDbJsonStorage } from './indexed-db-json-storage';
 import { userFriendlyError, userFailureSuggestion } from './user-message.util';
 
 const STORAGE_KEY = 'videoWorkflow.generationTasks.v1';
 
 @Injectable({ providedIn: 'root' })
 export class GenerationTaskService {
+  private readonly idb = new IndexedDbJsonStorage('generationTasks');
   private readonly tasksSubject = new BehaviorSubject<GenerationTask[]>(this.restore());
   readonly tasks$ = this.tasksSubject.asObservable();
 
+  constructor() {
+    void this.restoreFromIndexedDb();
+  }
+
   get tasks(): GenerationTask[] {
     return this.tasksSubject.value;
   }
@@ -114,6 +120,27 @@ export class GenerationTaskService {
     });
   }
 
+  markRecovering(id: string, message = '任务已进入恢复检查,正在等待上游结果同步。'): void {
+    const task = this.find(id);
+    if (!task) return;
+    this.patch(id, {
+      status: task.status === 'queued' ? 'running' : task.status,
+      recoverable: true,
+      retryable: false,
+      failureSuggestion: message,
+    });
+  }
+
+  markUnrecoverable(id: string, reason: string, suggestion = '该任务缺少可恢复信息,请从草稿或原入口重新发起。'): void {
+    this.patch(id, {
+      status: 'failed',
+      errorMessage: reason,
+      failureSuggestion: suggestion,
+      retryable: false,
+      recoverable: false,
+    });
+  }
+
   markCancelled(id: string): void {
     this.patch(id, {
       status: 'cancelled',
@@ -290,5 +317,13 @@ export class GenerationTaskService {
     try {
       localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
     } catch {}
+    void this.idb.writeArray(STORAGE_KEY, tasks);
+  }
+
+  private async restoreFromIndexedDb(): Promise<void> {
+    const tasks = await this.idb.readArray<GenerationTask>(STORAGE_KEY);
+    if (tasks.length) {
+      this.tasksSubject.next(tasks);
+    }
   }
 }

+ 102 - 0
src/app/services/indexed-db-json-storage.ts

@@ -0,0 +1,102 @@
+const JSON_DB_NAME = 'videoWorkflow.reliableJsonStorage';
+const JSON_DB_VERSION = 1;
+const JSON_STORE = 'collections';
+
+interface JsonRecord<T> {
+  key: string;
+  value: T;
+  updatedAt: number;
+}
+
+export class IndexedDbJsonStorage {
+  private dbPromise: Promise<IDBDatabase> | null = null;
+
+  constructor(private readonly namespace: string) {}
+
+  async readArray<T>(localStorageKey: string): Promise<T[]> {
+    const key = this.key(localStorageKey);
+    try {
+      const db = await this.openDb();
+      const record = await this.requestToPromise<JsonRecord<T[]> | undefined>(
+        db.transaction(JSON_STORE, 'readonly').objectStore(JSON_STORE).get(key),
+      );
+      if (Array.isArray(record?.value)) return record.value;
+    } catch (error) {
+      console.warn('[IndexedDbJsonStorage] read failed, using localStorage fallback', this.namespace, error);
+    }
+
+    const fallback = this.readLocalArray<T>(localStorageKey);
+    if (fallback.length) {
+      await this.writeArray(localStorageKey, fallback);
+    }
+    return fallback;
+  }
+
+  async writeArray<T>(localStorageKey: string, value: T[]): Promise<void> {
+    try {
+      localStorage.setItem(localStorageKey, JSON.stringify(value));
+    } catch {}
+
+    try {
+      const db = await this.openDb();
+      const tx = db.transaction(JSON_STORE, 'readwrite');
+      tx.objectStore(JSON_STORE).put({
+        key: this.key(localStorageKey),
+        value,
+        updatedAt: Date.now(),
+      } satisfies JsonRecord<T[]>);
+      await this.transactionDone(tx);
+    } catch (error) {
+      console.warn('[IndexedDbJsonStorage] write failed, localStorage fallback kept', this.namespace, error);
+    }
+  }
+
+  private readLocalArray<T>(localStorageKey: string): T[] {
+    try {
+      const parsed = JSON.parse(localStorage.getItem(localStorageKey) || '[]');
+      return Array.isArray(parsed) ? parsed : [];
+    } catch {
+      return [];
+    }
+  }
+
+  private openDb(): Promise<IDBDatabase> {
+    if (this.dbPromise) return this.dbPromise;
+    if (!('indexedDB' in window)) return Promise.reject(new Error('IndexedDB 不可用'));
+    this.dbPromise = new Promise((resolve, reject) => {
+      const req = indexedDB.open(JSON_DB_NAME, JSON_DB_VERSION);
+      req.onupgradeneeded = () => {
+        const db = req.result;
+        if (!db.objectStoreNames.contains(JSON_STORE)) {
+          db.createObjectStore(JSON_STORE, { keyPath: 'key' });
+        }
+      };
+      req.onsuccess = () => {
+        req.result.onversionchange = () => req.result.close();
+        resolve(req.result);
+      };
+      req.onerror = () => reject(req.error || new Error('IndexedDB 打开失败'));
+      req.onblocked = () => reject(new Error('IndexedDB 被其他页面占用,请关闭旧页面后重试'));
+    });
+    return this.dbPromise;
+  }
+
+  private requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
+    return new Promise((resolve, reject) => {
+      request.onsuccess = () => resolve(request.result);
+      request.onerror = () => reject(request.error);
+    });
+  }
+
+  private transactionDone(tx: IDBTransaction): Promise<void> {
+    return new Promise((resolve, reject) => {
+      tx.oncomplete = () => resolve();
+      tx.onerror = () => reject(tx.error);
+      tx.onabort = () => reject(tx.error || new Error('IndexedDB 事务已中止'));
+    });
+  }
+
+  private key(localStorageKey: string): string {
+    return `${this.namespace}::${localStorageKey}`;
+  }
+}

+ 4 - 3
src/app/services/pipeline-session.service.ts

@@ -36,7 +36,7 @@ import {
   newDraftId,
   toDraftMeta,
 } from '../models/pipeline-draft.model';
-import { DraftStorage, LocalStorageDraftStorage } from './draft-storage';
+import { DraftStorage, IndexedDbDraftStorage } from './draft-storage';
 
 interface PatchOpts {
   /** 静默更新,不触发 updatedAt 变化(一般不用) */
@@ -45,7 +45,7 @@ interface PatchOpts {
 
 @Injectable({ providedIn: 'root' })
 export class PipelineSessionService {
-  private storage: DraftStorage = new LocalStorageDraftStorage();
+  private storage: DraftStorage = new IndexedDbDraftStorage();
 
   /** 列表(DraftMeta 轻量索引) */
   readonly list = signal<DraftMeta[]>([]);
@@ -120,13 +120,14 @@ export class PipelineSessionService {
     resultUrl?: string;
     thumbnail?: string;
     artifactTitle?: string;
+    artifactType?: ArtifactType;
     artifactExtras?: Record<string, any>;
     status?: DraftStatus;
   }): Promise<PipelineDraft<T>> {
     const now = Date.now();
     const artifacts: Artifact[] = input.resultUrl ? [{
       id: newArtifactId(),
-      type: 'video',
+      type: input.artifactType || 'video',
       url: input.resultUrl,
       title: input.artifactTitle || '成片',
       createdAt: now,

+ 261 - 0
src/app/services/task-recovery.service.ts

@@ -0,0 +1,261 @@
+import { Injectable, inject } from '@angular/core';
+import { firstValueFrom } from 'rxjs';
+import { GenerationTask } from '../models/generation-task.model';
+import { PIPELINE_ROUTES, PipelineRoute } from '../pipelines/pipeline-registry';
+import { GenerationTaskService } from './generation-task.service';
+import { JimengService } from './jimeng.service';
+import { PipelineSessionService } from './pipeline-session.service';
+import { userFriendlyError, userFailureSuggestion } from './user-message.util';
+
+export interface TaskRecoveryResult {
+  status: 'running' | 'completed' | 'failed' | 'unrecoverable';
+  progress?: number;
+  resultUrl?: string;
+  resultType?: 'image' | 'video';
+  errorMessage?: string;
+  failureSuggestion?: string;
+  nextPollMs?: number;
+}
+
+export interface TaskRecoveryAdapter {
+  supports(task: GenerationTask): boolean;
+  recover(task: GenerationTask): Promise<TaskRecoveryResult>;
+}
+
+export interface TaskRecoverySummary {
+  scanned: number;
+  recoverable: number;
+  running: number;
+  completed: number;
+  failed: number;
+  unrecoverable: number;
+  skipped: number;
+}
+
+@Injectable({ providedIn: 'root' })
+export class TaskRecoveryService {
+  private readonly generationTasks = inject(GenerationTaskService);
+  private readonly jimeng = inject(JimengService);
+  private readonly sessions = inject(PipelineSessionService);
+  private readonly adapters: TaskRecoveryAdapter[] = [];
+  private readonly maxRecoveryAgeMs = 24 * 60 * 60 * 1000;
+  private bootstrapped = false;
+
+  registerAdapter(adapter: TaskRecoveryAdapter): void {
+    if (this.adapters.includes(adapter)) return;
+    this.adapters.push(adapter);
+  }
+
+  async bootstrap(force = false): Promise<TaskRecoverySummary> {
+    if (this.bootstrapped && !force) return this.emptySummary();
+    this.bootstrapped = true;
+    return this.scanAndRecover();
+  }
+
+  async scanAndRecover(): Promise<TaskRecoverySummary> {
+    const summary = this.emptySummary();
+    const tasks = this.generationTasks.tasks;
+    summary.scanned = tasks.length;
+
+    for (const task of tasks) {
+      if (!this.isRecoveryCandidate(task)) {
+        summary.skipped++;
+        continue;
+      }
+
+      summary.recoverable++;
+      const result = await this.recoverTask(task);
+      summary[result.status]++;
+    }
+
+    return summary;
+  }
+
+  private async recoverTask(task: GenerationTask): Promise<TaskRecoveryResult> {
+    const age = Date.now() - Number(task.updatedAt || task.createdAt || 0);
+    const hasExternalIds = Object.keys(task.externalTaskIds || {}).length > 0;
+
+    if (age > this.maxRecoveryAgeMs) {
+      const reason = '任务已超过 24 小时恢复窗口,系统无法确认上游是否仍保留结果。';
+      this.generationTasks.markUnrecoverable(task.id, reason, '建议从原草稿或原入口重新发起任务。');
+      return { status: 'unrecoverable', errorMessage: reason };
+    }
+
+    const adapter = this.adapters.find((item) => item.supports(task));
+    if (!adapter) {
+      if (this.isJimengWorkTask(task)) {
+        const result = await this.recoverJimengWork(task);
+        await this.applyRecoveryResult(task, result);
+        return result;
+      }
+
+      if (!hasExternalIds) {
+        const reason = '任务缺少外部任务 ID,暂时无法自动恢复。';
+        this.generationTasks.markUnrecoverable(task.id, reason, '请从草稿或任务入口重新发起;后续可为该 Pipeline 接入恢复适配器。');
+        return { status: 'unrecoverable', errorMessage: reason };
+      }
+
+      this.generationTasks.markRecovering(
+        task.id,
+        '已识别到外部任务 ID,等待该 Pipeline 的恢复适配器接入后可自动同步结果。',
+      );
+      return { status: 'running', progress: task.progress };
+    }
+
+    try {
+      const result = await adapter.recover(task);
+      await this.applyRecoveryResult(task, result);
+      return result;
+    } catch (error) {
+      const message = userFriendlyError(error, '任务恢复检查失败,请稍后重试。');
+      this.generationTasks.markRecovering(task.id, userFailureSuggestion(error) || message);
+      return { status: 'running', errorMessage: message };
+    }
+  }
+
+  private async recoverJimengWork(task: GenerationTask): Promise<TaskRecoveryResult> {
+    const workId = this.pickExternalId(task, ['workId', 'jimengWorkId', 'resultWorkId']);
+    if (!workId) {
+      return {
+        status: 'unrecoverable',
+        errorMessage: '即梦任务缺少 workId,无法查询上游结果。',
+        failureSuggestion: '请从原草稿重新发起生成,或检查任务是否正确记录了外部任务 ID。',
+      };
+    }
+
+    const result = await firstValueFrom(this.jimeng.getWorkResult(workId));
+    const videoUrl = result?.videos?.[0] || '';
+    const imageUrl = result?.images?.[0] || '';
+    const resultUrl = videoUrl || imageUrl;
+    const resultType = videoUrl ? 'video' : 'image';
+
+    if (resultUrl) {
+      return {
+        status: 'completed',
+        progress: 100,
+        resultUrl,
+        resultType,
+        failureSuggestion: '已从即梦作品结果中恢复生成结果。',
+      };
+    }
+
+    return {
+      status: 'running',
+      progress: typeof result?.progress === 'number' ? result.progress : task.progress,
+      failureSuggestion: '即梦任务仍未返回成片结果,请稍后继续查看。',
+      nextPollMs: 60_000,
+    };
+  }
+
+  private async applyRecoveryResult(task: GenerationTask, result: TaskRecoveryResult): Promise<void> {
+    if (result.status === 'completed') {
+      await this.archiveRecoveredResult(task, result);
+      this.generationTasks.markCompleted(task.id, result.resultUrl);
+      return;
+    }
+    if (result.status === 'failed') {
+      this.generationTasks.markFailed(task.id, result.errorMessage || '任务恢复后确认失败', {
+        retryable: true,
+        recoverable: false,
+      });
+      return;
+    }
+    if (result.status === 'unrecoverable') {
+      this.generationTasks.markUnrecoverable(
+        task.id,
+        result.errorMessage || '任务无法自动恢复。',
+        result.failureSuggestion || '请从草稿或原入口重新发起。',
+      );
+      return;
+    }
+    this.generationTasks.markRecovering(
+      task.id,
+      result.failureSuggestion || '任务仍在上游处理中,系统会在后续恢复适配器中继续同步结果。',
+    );
+  }
+
+  private async archiveRecoveredResult(task: GenerationTask, result: TaskRecoveryResult): Promise<void> {
+    if (!result.resultUrl || task.externalTaskIds?.['draftId']) return;
+
+    const pipelineId = this.asPipelineRoute(task.pipelineId);
+    if (!pipelineId) return;
+
+    await this.sessions.bootstrap();
+    const workId = this.pickExternalId(task, ['workId', 'jimengWorkId', 'resultWorkId']);
+    const snapshot = {
+      ...(task.snapshot || {}),
+      recoveredAt: new Date().toISOString(),
+      generationTaskId: task.id,
+      workId,
+      resultUrl: result.resultUrl,
+      resultVideoUrl: result.resultType === 'video' ? result.resultUrl : task.snapshot?.resultVideoUrl,
+      resultImageUrl: result.resultType === 'image' ? result.resultUrl : task.snapshot?.resultImageUrl,
+    };
+
+    const draft = await this.sessions.createArchivedDraft({
+      pipelineId,
+      title: task.title || '恢复的生成结果',
+      resultUrl: result.resultUrl,
+      status: 'completed',
+      artifactTitle: result.resultType === 'image' ? '恢复图片' : '恢复成片',
+      artifactType: result.resultType || 'video',
+      artifactExtras: {
+        source: 'task-recovery',
+        generationTaskId: task.id,
+        workId,
+        operation: task.operation,
+      },
+      snapshot,
+    });
+
+    this.generationTasks.markWaitingExternal(task.id, { draftId: draft.id }, 'archive', 96);
+  }
+
+  private isRecoveryCandidate(task: GenerationTask): boolean {
+    if (!task || task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') return false;
+    const hasExternalIds = Object.keys(task.externalTaskIds || {}).length > 0;
+    return task.status === 'waiting_external'
+      || (task.status === 'running' && task.recoverable === true)
+      || hasExternalIds;
+  }
+
+  private isJimengWorkTask(task: GenerationTask): boolean {
+    if (!this.pickExternalId(task, ['workId', 'jimengWorkId', 'resultWorkId'])) return false;
+    const text = `${task.pipelineId || ''} ${task.operation || ''}`.toLowerCase();
+    return text.includes('jimeng')
+      || text.includes('image-to-video')
+      || text.includes('action-transfer')
+      || text.includes('topic-to-video')
+      || text.includes('batch-topic-to-video')
+      || task.pipelineId === 'image-generation';
+  }
+
+  private pickExternalId(task: GenerationTask, keys: string[]): string {
+    const ids = task.externalTaskIds || {};
+    for (const key of keys) {
+      const value = ids[key];
+      if (value) return String(value);
+    }
+    return '';
+  }
+
+  private asPipelineRoute(value: string): PipelineRoute | null {
+    if ((PIPELINE_ROUTES as readonly string[]).includes(value)) return value as PipelineRoute;
+    const normalized = String(value || '').replace(/_/g, '-');
+    return (PIPELINE_ROUTES as readonly string[]).includes(normalized)
+      ? normalized as PipelineRoute
+      : null;
+  }
+
+  private emptySummary(): TaskRecoverySummary {
+    return {
+      scanned: 0,
+      recoverable: 0,
+      running: 0,
+      completed: 0,
+      failed: 0,
+      unrecoverable: 0,
+      skipped: 0,
+    };
+  }
+}

+ 15 - 15
src/app/services/template.service.ts

@@ -153,14 +153,14 @@ export class TemplateService {
           clipQuality: '720p',
           continuityEnabled: true,
           visualBible: {
-            subject: 'confident presenter or clean symbolic subject',
+            subject: '自信讲述者或干净的知识符号主体',
             product: '',
-            location: 'minimal studio or clean infographic background',
-            palette: 'white, blue, high contrast accent',
-            lighting: 'soft studio light',
-            camera: 'medium close-up, clean composition',
-            styleNotes: 'light infographic, large key words, clear hierarchy',
-            constraints: 'keep the same color palette and presenter style',
+            location: '极简演播室或清爽信息图背景',
+            palette: '白色、蓝色、高对比强调色',
+            lighting: '柔和棚拍光',
+            camera: '中近景,构图干净',
+            styleNotes: '轻量信息图风格,大号关键词,层级清晰',
+            constraints: '保持统一配色和讲述者风格',
           },
           scriptFrame: '开头提出反常识观点 -> 三点解释 -> 结尾抛出行动建议',
         },
@@ -186,14 +186,14 @@ export class TemplateService {
           clipQuality: '720p',
           continuityEnabled: true,
           visualBible: {
-            subject: 'target user in everyday product scenario',
-            product: 'hero product with clear details',
-            location: 'bright lifestyle scene',
-            palette: 'warm neutral, fresh accent color',
-            lighting: 'natural daylight',
-            camera: 'close-up details and smooth product movement',
-            styleNotes: 'realistic product advertising, clean composition',
-            constraints: 'keep product color and shape consistent',
+            subject: '日常使用场景中的目标用户',
+            product: '细节清晰的主推产品',
+            location: '明亮的生活方式场景',
+            palette: '温暖中性色,搭配清新的强调色',
+            lighting: '自然日光',
+            camera: '产品细节特写与平滑运镜',
+            styleNotes: '真实产品广告质感,构图干净',
+            constraints: '保持产品颜色和形状一致',
           },
           scriptFrame: '痛点场景 -> 产品出现 -> 三个卖点 -> 使用前后对比 -> 轻 CTA',
         },

+ 7 - 2
src/app/services/topic-to-video-batch-runner.service.ts

@@ -1,6 +1,7 @@
 import { Injectable } from '@angular/core';
 import { firstValueFrom } from 'rxjs';
 import { BatchJob, BatchJobItem, BatchJobRunResult } from '../models/batch-job.model';
+import { BatchItemRunner } from './batch-job.service';
 import { GenerationTaskService } from './generation-task.service';
 import { JimengService, JimengVideoQuality } from './jimeng.service';
 import { PipelineSessionService } from './pipeline-session.service';
@@ -13,7 +14,7 @@ export class TopicToVideoBatchRunnerService {
     private sessions: PipelineSessionService,
   ) {}
 
-  async run(item: BatchJobItem, job: BatchJob): Promise<BatchJobRunResult> {
+  async run(item: BatchJobItem, job: BatchJob, context?: Parameters<BatchItemRunner>[2]): Promise<BatchJobRunResult> {
     const input = item.input || {};
     const templateConfig = this.safeObject(input['templateConfig']);
     const topic = String(input['topic'] || item.title || '').trim();
@@ -54,9 +55,12 @@ export class TopicToVideoBatchRunnerService {
       quality,
     }, (status, progress, meta) => {
       if (meta?.['workId']) {
-        this.generationTasks.markWaitingExternal(task.id, { workId: String(meta['workId']) }, 'external', progress);
+        const workId = String(meta['workId']);
+        this.generationTasks.markWaitingExternal(task.id, { workId }, 'external', progress);
+        context?.markWaitingExternal({ workId }, 'external');
       } else {
         this.generationTasks.markRunning(task.id, progress >= 90 ? 'archive' : 'external', progress);
+        context?.markRunning(progress >= 90 ? 'archive' : 'external');
       }
     }));
 
@@ -95,6 +99,7 @@ export class TopicToVideoBatchRunnerService {
     return {
       generationTaskId: task.id,
       resultUrl: result.videoUrl,
+      externalTaskIds: { workId: result.workId, draftId: draft.id },
     };
   }