Yi Jiarui 3 сар өмнө
parent
commit
34b358323e
72 өөрчлөгдсөн 8684 нэмэгдсэн , 278 устгасан
  1. 16 0
      .codegraph/.gitignore
  2. 9 2
      cloud-functions/06-voiceManager.js
  3. 13 6
      cloud-functions/07-quicklyVideo.js
  4. 9 2
      cloud-functions/08-proxyHub.js
  5. 11 4
      cloud-functions/11-jimengManager.js
  6. 9 2
      cloud-functions/12-douyinManager.js
  7. 192 0
      cloud-functions/13-douyinInsightManager.js
  8. 79 92
      cloud-functions/DEPLOY.md
  9. 1 0
      package.json
  10. 79 0
      scripts/smoke-cloud-functions.mjs
  11. 215 0
      src/app/app.css
  12. 229 8
      src/app/app.html
  13. 569 9
      src/app/app.ts
  14. 6 0
      src/app/components/app-sidebar/app-sidebar.component.ts
  15. 52 0
      src/app/models/batch-job.model.ts
  16. 51 0
      src/app/models/content-asset.model.ts
  17. 114 0
      src/app/models/douyin-insight.model.ts
  18. 10 0
      src/app/models/generation-task.model.ts
  19. 49 0
      src/app/models/publication-record.model.ts
  20. 36 0
      src/app/models/template.model.ts
  21. 3 3
      src/app/pages/account/auth-page.component.html
  22. 141 0
      src/app/pages/account/user-center.component.css
  23. 38 14
      src/app/pages/account/user-center.component.html
  24. 29 17
      src/app/pages/account/user-center.component.ts
  25. 247 0
      src/app/pages/analysis-history/analysis-history.component.css
  26. 98 0
      src/app/pages/analysis-history/analysis-history.component.html
  27. 120 0
      src/app/pages/analysis-history/analysis-history.component.ts
  28. 245 0
      src/app/pages/batch-production/batch-production.component.css
  29. 116 0
      src/app/pages/batch-production/batch-production.component.html
  30. 312 0
      src/app/pages/batch-production/batch-production.component.ts
  31. 245 0
      src/app/pages/daily-report-history/daily-report-history.component.css
  32. 92 0
      src/app/pages/daily-report-history/daily-report-history.component.html
  33. 127 0
      src/app/pages/daily-report-history/daily-report-history.component.ts
  34. 91 0
      src/app/pages/home/home.component.css
  35. 32 1
      src/app/pages/home/home.component.html
  36. 11 0
      src/app/pages/home/home.component.ts
  37. 151 0
      src/app/pages/library/library.component.css
  38. 109 1
      src/app/pages/library/library.component.html
  39. 177 5
      src/app/pages/library/library.component.ts
  40. 36 1
      src/app/pages/pipelines/action-transfer/action-transfer.component.ts
  41. 36 1
      src/app/pages/pipelines/image-to-video/image-to-video.component.ts
  42. 52 0
      src/app/pages/pipelines/topic-to-video/topic-to-video.component.css
  43. 25 0
      src/app/pages/pipelines/topic-to-video/topic-to-video.component.html
  44. 137 0
      src/app/pages/pipelines/topic-to-video/topic-to-video.component.ts
  45. 228 0
      src/app/pages/retrospective/retrospective.component.css
  46. 124 0
      src/app/pages/retrospective/retrospective.component.html
  47. 178 0
      src/app/pages/retrospective/retrospective.component.ts
  48. 272 0
      src/app/pages/templates/templates.component.css
  49. 128 0
      src/app/pages/templates/templates.component.html
  50. 180 0
      src/app/pages/templates/templates.component.ts
  51. 316 0
      src/app/pages/topic-pool/topic-pool.component.css
  52. 169 0
      src/app/pages/topic-pool/topic-pool.component.html
  53. 250 0
      src/app/pages/topic-pool/topic-pool.component.ts
  54. 7 1
      src/app/services/assistant.service.ts
  55. 11 1
      src/app/services/auth-credit.service.ts
  56. 238 0
      src/app/services/batch-job.service.ts
  57. 2 0
      src/app/services/cloud-functions.ts
  58. 148 0
      src/app/services/content-asset.service.ts
  59. 37 0
      src/app/services/cost-estimator.service.ts
  60. 538 0
      src/app/services/daily-report.service.ts
  61. 86 0
      src/app/services/douyin-insight.service.ts
  62. 19 0
      src/app/services/generation-task.service.ts
  63. 11 14
      src/app/services/jimeng.service.ts
  64. 36 0
      src/app/services/pipeline-session.service.ts
  65. 90 0
      src/app/services/publication-data-sync.service.ts
  66. 141 0
      src/app/services/retrospective.service.ts
  67. 243 0
      src/app/services/template.service.ts
  68. 105 0
      src/app/services/topic-pool.service.ts
  69. 130 0
      src/app/services/topic-to-video-batch-runner.service.ts
  70. 2 2
      src/app/services/user-message.util.ts
  71. 440 0
      src/app/services/viral-analysis.service.ts
  72. 136 92
      高频用户视角改进路线图.md

+ 16 - 0
.codegraph/.gitignore

@@ -0,0 +1,16 @@
+# CodeGraph data files
+# These are local to each machine and should not be committed
+
+# Database
+*.db
+*.db-wal
+*.db-shm
+
+# Cache
+cache/
+
+# Logs
+*.log
+
+# Hook markers
+.dirty

+ 9 - 2
cloud-functions/06-voiceManager.js

@@ -11,8 +11,8 @@
  * 迁移到云函数后改为存储在 "VoiceSpeakerPool" 表。
  * 首次部署后请用 action=initPool 把现有池一次性灌入。
  */
-const VOICE_TOKEN = 'Bearer r:f0333969e312a40e4703e8fe4ed1c600';
-const VOICE_TTS_BASE_URL = 'https://server.fmode.cn/api/volcengine/tts';
+const VOICE_TOKEN = readEnv('VOICE_TOKEN') || 'Bearer r:f0333969e312a40e4703e8fe4ed1c600';
+const VOICE_TTS_BASE_URL = readEnv('VOICE_TTS_BASE_URL') || 'https://server.fmode.cn/api/volcengine/tts';
 
 async function handler(request, response) {
   try {
@@ -233,3 +233,10 @@ async function postJson(url, body) {
 function readError(data, fallback) {
   return data?.error?.message || data?.error || data?.message || data?.msg || fallback || '';
 }
+
+function readEnv(name) {
+  if (typeof process !== 'undefined' && process.env && process.env[name]) {
+    return process.env[name];
+  }
+  return '';
+}

+ 13 - 6
cloud-functions/07-quicklyVideo.js

@@ -11,13 +11,13 @@
  */
 const crypto = require('crypto');
 
-const QUICKLY_APP_KEY = 'ZmNmOGRhNjYzZTAx';
-const QUICKLY_APP_SECRET = 'eaa12154c248cad9159a9d6ea8bedf46';
-const QUICKLY_ACCOUNT_ID = '12859_117409';
-const QUICKLY_CALLBACK_URL = 'https://server.fmode.cn/api/functions/cut/onemerge';
-const QUICKLY_RELAY_URL = 'https://server.fmode.cn/api/functions';
+const QUICKLY_APP_KEY = readEnv('QUICKLY_APP_KEY') || 'ZmNmOGRhNjYzZTAx';
+const QUICKLY_APP_SECRET = readEnv('QUICKLY_APP_SECRET') || 'eaa12154c248cad9159a9d6ea8bedf46';
+const QUICKLY_ACCOUNT_ID = readEnv('QUICKLY_ACCOUNT_ID') || '12859_117409';
+const QUICKLY_CALLBACK_URL = readEnv('QUICKLY_CALLBACK_URL') || 'https://server.fmode.cn/api/functions/cut/onemerge';
+const QUICKLY_RELAY_URL = readEnv('QUICKLY_RELAY_URL') || 'https://server.fmode.cn/api/functions';
 // 上游筷子代理云函数 ID(fmode 平台已有)
-const UPSTREAM_FN_ID = 'sWvRr8RvPT';
+const UPSTREAM_FN_ID = readEnv('QUICKLY_UPSTREAM_FN_ID') || 'sWvRr8RvPT';
 
 async function handler(request, response) {
   try {
@@ -109,3 +109,10 @@ function pickParam(request, ...names) {
   }
   return null;
 }
+
+function readEnv(name) {
+  if (typeof process !== 'undefined' && process.env && process.env[name]) {
+    return process.env[name];
+  }
+  return '';
+}

+ 9 - 2
cloud-functions/08-proxyHub.js

@@ -5,8 +5,8 @@
  *      流式聊天:action=llmChatStream(SSE 透传,需要 fmode 平台 dispatcher 支持 chunked response)
  * 注意:/api/video-proxy 不迁(二进制大流量不适合云函数)
  */
-const LLM_BASE_URL = 'http://server.fmode.cn:9999';
-const LLM_API_KEY = 'sk-MFBOnsAtZiqlwwMgMLKCFmPy55pMohQEGMqsIw3aJrIgvoEO';
+const LLM_BASE_URL = readEnv('LLM_BASE_URL') || 'http://server.fmode.cn:9999';
+const LLM_API_KEY = readEnv('LLM_API_KEY') || 'sk-MFBOnsAtZiqlwwMgMLKCFmPy55pMohQEGMqsIw3aJrIgvoEO';
 
 async function handler(request, response) {
   try {
@@ -130,3 +130,10 @@ function pickParam(request, ...names) {
   }
   return null;
 }
+
+function readEnv(name) {
+  if (typeof process !== 'undefined' && process.env && process.env[name]) {
+    return process.env[name];
+  }
+  return '';
+}

+ 11 - 4
cloud-functions/11-jimengManager.js

@@ -4,10 +4,10 @@
  *
  * 部署后把函数 objectId 填入 src/app/services/cloud-functions.ts 的 jimeng。
  */
-const JIMENG_TOKEN = 'Bearer r:f0333969e312a40e4703e8fe4ed1c600';
-const JIMENG_BASE_URL = 'https://server.fmode.cn/api/volcengine/jimeng';
-const PARSE_BASE_URL = 'https://server.fmode.cn/parse';
-const PARSE_APP_ID = 'ncloudmaster';
+const JIMENG_TOKEN = readEnv('JIMENG_TOKEN') || 'Bearer r:f0333969e312a40e4703e8fe4ed1c600';
+const JIMENG_BASE_URL = readEnv('JIMENG_BASE_URL') || 'https://server.fmode.cn/api/volcengine/jimeng';
+const PARSE_BASE_URL = readEnv('PARSE_BASE_URL') || 'https://server.fmode.cn/parse';
+const PARSE_APP_ID = readEnv('PARSE_APP_ID') || 'ncloudmaster';
 
 const ALLOWED_ENDPOINTS = new Set([
   'getVideoV3_720p',
@@ -114,3 +114,10 @@ async function readResponse(r) {
 function readError(data, fallback) {
   return data?.error?.message || data?.error || data?.message || data?.msg || fallback || '';
 }
+
+function readEnv(name) {
+  if (typeof process !== 'undefined' && process.env && process.env[name]) {
+    return process.env[name];
+  }
+  return '';
+}

+ 9 - 2
cloud-functions/12-douyinManager.js

@@ -4,8 +4,8 @@
  *
  * 部署后把函数 objectId 填入 src/app/services/cloud-functions.ts 的 douyin。
  */
-const TIKHUB_BASE_URL = 'https://api.tikhub.io';
-const TIKHUB_TOKEN = 'gqsZHfMWgAiMwV+ITbmZy0qALADWBZVS7QnV7kKJe9CwzgWgJG+7bwK+GQ==';
+const TIKHUB_BASE_URL = readEnv('TIKHUB_BASE_URL') || 'https://api.tikhub.io';
+const TIKHUB_TOKEN = readEnv('TIKHUB_TOKEN') || 'gqsZHfMWgAiMwV+ITbmZy0qALADWBZVS7QnV7kKJe9CwzgWgJG+7bwK+GQ==';
 
 const ROUTES = {
   searchVideos: { method: 'POST', path: '/api/v1/douyin/search/fetch_general_search_v2' },
@@ -105,3 +105,10 @@ function stripEmpty(value) {
 function readError(data, fallback) {
   return data?.error?.message || data?.error || data?.message || data?.msg || fallback || '';
 }
+
+function readEnv(name) {
+  if (typeof process !== 'undefined' && process.env && process.env[name]) {
+    return process.env[name];
+  }
+  return '';
+}

+ 192 - 0
cloud-functions/13-douyinInsightManager.js

@@ -0,0 +1,192 @@
+/**
+ * 云函数:douyinInsightManager(二阶段:爆款分析、选题池、日报)
+ * actions:
+ *   analysisCreate | analysisList | analysisGet | analysisUpdate
+ *   topicCreate | topicList | topicUpdate | topicArchive
+ *   dailyReportCreate | dailyReportList | dailyReportGet
+ *
+ * 说明:本函数只负责业务资产的持久化和账号隔离。抖音原始数据抓取仍由 12-douyinManager 负责。
+ */
+const PARSE_API_HOST = readEnv('PARSE_API_HOST') || 'https://server.fmode.cn';
+const PARSE_APP_ID = readEnv('PARSE_APP_ID') || 'ncloudmaster';
+
+async function handler(request, response) {
+  try {
+    await ensureTables();
+
+    const action = pickParam(request, 'action') || '';
+    const session = await requireSession(request);
+    const requestedUserId = pickParam(request, 'userId') || '';
+    if (requestedUserId && requestedUserId !== session.userId) {
+      return response.json({ code: 403, success: false, error: '没有访问该账号数据的权限' });
+    }
+    const userId = session.userId;
+
+    if (action === 'analysisCreate') return createRow(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'analysis', 'data') || {});
+    if (action === 'analysisList') return listRows(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'limit') || 100);
+    if (action === 'analysisGet') return getRow(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'id'));
+    if (action === 'analysisUpdate') return updateRow(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'id'), pickParam(request, 'patch', 'analysis', 'data') || {});
+
+    if (action === 'topicCreate') return createRow(response, 'VideoflowTopicIdea', userId, pickParam(request, 'topic', 'data') || {});
+    if (action === 'topicList') return listRows(response, 'VideoflowTopicIdea', userId, pickParam(request, 'limit') || 500);
+    if (action === 'topicUpdate') return updateRow(response, 'VideoflowTopicIdea', userId, pickParam(request, 'id'), pickParam(request, 'patch', 'topic', 'data') || {});
+    if (action === 'topicArchive') return updateRow(response, 'VideoflowTopicIdea', userId, pickParam(request, 'id'), { status: 'archived' });
+
+    if (action === 'dailyReportCreate') return createRow(response, 'VideoflowDailyReport', userId, pickParam(request, 'report', 'data') || {});
+    if (action === 'dailyReportList') return listRows(response, 'VideoflowDailyReport', userId, pickParam(request, 'limit') || 100);
+    if (action === 'dailyReportGet') return getRow(response, 'VideoflowDailyReport', userId, pickParam(request, 'id'));
+
+    response.json({ code: 400, success: false, error: `未知 action: ${action}` });
+  } catch (error) {
+    console.error('douyinInsightManager failed:', error.message);
+    response.json({ code: 500, success: false, error: error.message });
+  }
+}
+
+async function ensureTables() {
+  for (const table of ['VideoflowViralAnalysis', 'VideoflowTopicIdea', 'VideoflowDailyReport']) {
+    await Psql.query(`
+      CREATE TABLE IF NOT EXISTS "${table}" (
+        "objectId"  VARCHAR(50) PRIMARY KEY,
+        "bizId"     VARCHAR(255) NOT NULL,
+        "userId"    VARCHAR(255) NOT NULL,
+        "data"      JSONB NOT NULL DEFAULT '{}',
+        "status"    VARCHAR(50) DEFAULT '',
+        "createdAt" TIMESTAMPTZ DEFAULT NOW(),
+        "updatedAt" TIMESTAMPTZ DEFAULT NOW()
+      )
+    `);
+    await Psql.query(`DROP INDEX IF EXISTS idx_${table.toLowerCase()}_biz`);
+    await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_${table.toLowerCase()}_user_biz ON "${table}" ("userId", "bizId")`);
+    await Psql.query(`CREATE INDEX IF NOT EXISTS idx_${table.toLowerCase()}_user ON "${table}" ("userId")`);
+  }
+  await Psql.query(`
+    CREATE TABLE IF NOT EXISTS "AppSession" (
+      "token" VARCHAR(120) PRIMARY KEY,
+      "userId" VARCHAR(50) NOT NULL,
+      "expiresAt" TIMESTAMPTZ NOT NULL,
+      "createdAt" TIMESTAMPTZ DEFAULT NOW()
+    )
+  `);
+}
+
+async function createRow(response, table, userId, data) {
+  const now = new Date().toISOString();
+  const bizId = data.id || data.bizId || generateId();
+  const merged = { ...data, id: bizId, userId, createdAt: data.createdAt || now, updatedAt: now };
+  const existing = await Psql.query(
+    `SELECT * FROM "${table}" WHERE "bizId"=$1 AND "userId"=$2 LIMIT 1`,
+    [bizId, userId]
+  );
+  if (existing.length) {
+    await Psql.query(
+      `UPDATE "${table}" SET "data"=$1, "status"=$2, "updatedAt"=NOW() WHERE "bizId"=$3 AND "userId"=$4`,
+      [JSON.stringify(merged), merged.status || '', bizId, userId]
+    );
+    return response.json({ code: 200, success: true, data: merged });
+  }
+
+  await Psql.query(
+    `INSERT INTO "${table}" ("objectId","bizId","userId","data","status")
+     VALUES ($1,$2,$3,$4,$5)`,
+    [generateId(), bizId, userId, JSON.stringify(merged), merged.status || '']
+  );
+  response.json({ code: 200, success: true, data: merged });
+}
+
+async function listRows(response, table, userId, limit) {
+  const parsedLimit = parseInt(limit || '100', 10);
+  const safeLimit = Number.isFinite(parsedLimit) ? Math.min(Math.max(parsedLimit, 1), 1000) : 100;
+  const rows = await Psql.query(
+    `SELECT * FROM "${table}" WHERE "userId"=$1 ORDER BY "updatedAt" DESC LIMIT $2`,
+    [userId, safeLimit]
+  );
+  response.json({ code: 200, success: true, data: rows.map(rowToObj) });
+}
+
+async function getRow(response, table, userId, id) {
+  if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
+  const rows = await Psql.query(
+    `SELECT * FROM "${table}" WHERE "bizId"=$1 AND "userId"=$2 LIMIT 1`,
+    [id, userId]
+  );
+  if (!rows.length) return response.json({ code: 404, success: false, error: '未找到记录' });
+  response.json({ code: 200, success: true, data: rowToObj(rows[0]) });
+}
+
+async function updateRow(response, table, userId, id, patch) {
+  if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
+  const rows = await Psql.query(
+    `SELECT * FROM "${table}" WHERE "bizId"=$1 AND "userId"=$2 LIMIT 1`,
+    [id, userId]
+  );
+  if (!rows.length) return response.json({ code: 404, success: false, error: '未找到记录' });
+  const merged = { ...rowToObj(rows[0]), ...patch, id, userId, updatedAt: new Date().toISOString() };
+  await Psql.query(
+    `UPDATE "${table}" SET "data"=$1, "status"=$2, "updatedAt"=NOW() WHERE "bizId"=$3 AND "userId"=$4`,
+    [JSON.stringify(merged), merged.status || '', id, userId]
+  );
+  response.json({ code: 200, success: true, data: merged });
+}
+
+function rowToObj(row) {
+  const data = typeof row.data === 'string' ? JSON.parse(row.data) : (row.data || {});
+  return { ...data, objectId: row.objectId, createdAt: row.createdAt, updatedAt: row.updatedAt };
+}
+
+function pickParam(request, ...names) {
+  const sources = [request.params, request.body, request];
+  for (const src of sources) {
+    if (!src || typeof src !== 'object') continue;
+    for (const name of names) {
+      const value = src[name];
+      if (value !== undefined && value !== null && value !== '') return value;
+    }
+  }
+  return null;
+}
+
+async function requireSession(request) {
+  const token = clean(pickParam(request, 'sessionToken'));
+  if (!token) throw new Error('请先登录');
+  const rows = await Psql.query(
+    `SELECT * FROM "AppSession" WHERE "token"=$1 AND "expiresAt" > NOW() LIMIT 1`,
+    [token]
+  );
+  if (rows.length) return rows[0];
+
+  const parseUser = await verifyParseSession(token);
+  if (parseUser?.objectId) return { token, userId: parseUser.objectId, source: 'parse' };
+  throw new Error('登录已过期,请重新登录');
+}
+
+async function verifyParseSession(sessionToken) {
+  if (typeof fetch !== 'function') return null;
+  const resp = await fetch(`${PARSE_API_HOST}/parse/users/me?include=company`, {
+    method: 'GET',
+    headers: {
+      'X-Parse-Application-Id': PARSE_APP_ID,
+      'X-Parse-Session-Token': sessionToken,
+    },
+  });
+  const data = await resp.json().catch(() => ({}));
+  return resp.ok && data.objectId ? data : null;
+}
+
+function clean(value) {
+  return String(value || '').trim();
+}
+
+function generateId() {
+  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+  let s = '';
+  for (let i = 0; i < 10; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
+  return s;
+}
+
+function readEnv(name) {
+  if (typeof process !== 'undefined' && process.env && process.env[name]) {
+    return process.env[name];
+  }
+  return '';
+}

+ 79 - 92
cloud-functions/DEPLOY.md

@@ -1,111 +1,98 @@
 # 云函数部署指南
 
-本目录包含 8 个云函数,部署到 fmode 平台后可替代 31 个 Express 接口
+本目录是 fmode 云函数源码。前端通过 `src/app/services/cloud-functions.ts` 里的函数 ID 调用这些函数
 
 ## 部署清单
 
-| # | 文件 | 替代的 Express 接口数 | actions |
+| 字段 | 文件 | 状态 | 主要 action / 能力 |
 |---|---|---|---|
-| 01 | `01-manifestManager.js` | 4(GET/POST/PUT/DELETE /api/manifest) | list / get / create / update / delete |
-| 02 | `02-taskManager.js` | 4(/api/tasks) | list / get / create / update / delete |
-| 03 | `03-historyManager.js` | 3(/api/history) | list / create / delete / clearAll |
-| 04 | `04-resultManager.js` | 4(/api/results) | list / get / create / update / delete |
-| 05 | `05-remixManager.js` | 4(/api/remixes) | listAll / listByVideo / upsert / delete |
-| 06 | `06-voiceManager.js` | 2(/api/voice/*) | autoSpeakerId / syncProfile / listProfiles / initPool / deleteProfile |
-| 07 | `07-quicklyVideo.js` | 2(/api/quickly/*) | create / query |
-| 08 | `08-proxyHub.js` | 2(/api/llm/*) | llmChat / geminiChat |
-
-**合计:8 个云函数覆盖 25 个 CRUD + 6 个代理 = 31 个接口** ✅
-
-> ⚠️ `docs/云函数/筷子一键成片.json` 是**别项目**的参考样本,**不要直接部署**。本目录的 `07-quicklyVideo.js` 才是本项目自己的实现。
-
-## 部署步骤(每个文件)
-
-1. 打开 fmode 云函数管理平台
-2. 新建云函数,命名建议跟文件名一致(如 `manifestManager`)
-3. 粘贴 `.js` 文件的完整代码(**不要包含外层 markdown**)
-4. 保存 → 部署 → **复制返回的函数 ID**(10 位字符串)
-5. 把 ID 填入 `src/app/services/cloud-functions.ts` 对应字段
-
-## 前端依赖安装
-
-部署完云函数后,前端需要安装 SDK:
+| `manifest` | `01-manifestManager.js` | 已配置 | list / get / create / update / delete |
+| `task` | `02-taskManager.js` | 已配置 | list / get / create / update / delete |
+| `history` | `03-historyManager.js` | 已配置 | list / create / delete / clearAll |
+| `result` | `04-resultManager.js` | 已配置 | list / get / create / update / delete |
+| `remix` | `05-remixManager.js` | 已配置 | listAll / listByVideo / upsert / delete |
+| `voice` | `06-voiceManager.js` | 已配置 | autoSpeakerId / syncProfile / listProfiles / initPool / deleteProfile |
+| `quickly` | `07-quicklyVideo.js` | 待部署 | create / query |
+| `proxy` | `08-proxyHub.js` | 已配置 | llmChat / geminiChat / llmChatStream |
+| `upload` | `09-uploadManager.js` | 已配置 | createUploadToken |
+| `authCredit` | `10-authCreditManager.js` | 待部署/暂未启用 | register / login / me / reserve / commit / refund / ledger |
+| `jimeng` | `11-jimengManager.js` | 已配置 | 即梦图片、视频、数字人、动作迁移代理 |
+| `douyin` | `12-douyinManager.js` | 已配置 | TikHub 抖音搜索、详情、评论、博主数据代理 |
+| `douyinInsight` | `13-douyinInsightManager.js` | 已配置 | 爆款分析、选题池、日报资产持久化 |
+
+## 部署步骤
+
+1. 打开 fmode 云函数管理平台。
+2. 新建或更新云函数,名称建议与文件名一致。
+3. 粘贴对应 `.js` 文件完整内容。
+4. 在平台配置该函数需要的环境变量。
+5. 保存并部署,复制返回的 10 位函数 ID。
+6. 更新 `src/app/services/cloud-functions.ts` 对应字段。
+7. 执行 `npm run smoke:cloud` 验证已配置函数是否可调用。
+8. 执行 `npm run build` 验证前端编译。
+
+## 外部凭证清单
+
+| 函数 | 必需凭证/配置 | 说明 |
+|---|---|---|
+| `06-voiceManager.js` | `VOICE_TOKEN`, `VOICE_TTS_BASE_URL` | 火山 TTS/音色训练代理;`listProfiles` 不需要调用上游。 |
+| `07-quicklyVideo.js` | `QUICKLY_APP_KEY`, `QUICKLY_APP_SECRET`, `QUICKLY_ACCOUNT_ID`, `QUICKLY_CALLBACK_URL`, `QUICKLY_RELAY_URL`, `QUICKLY_UPSTREAM_FN_ID` | 一键成片。如不用该功能可保持 `quickly` 为空。 |
+| `08-proxyHub.js` | `LLM_BASE_URL`, `LLM_API_KEY` | LLM/Gemini 代理。 |
+| `09-uploadManager.js` | `QINIU_AK`, `QINIU_SK`, `QINIU_BUCKET`, `QINIU_CDN_DOMAIN`, `QINIU_CDN_PREFIX`, `QINIU_UPLOAD_URL` | 七牛直传 token。 |
+| `10-authCreditManager.js` | Parse session 可用即可;无第三方密钥 | 账号、积分、充值框架。商业化恢复前再启用。 |
+| `11-jimengManager.js` | `JIMENG_TOKEN`, `JIMENG_BASE_URL`, `PARSE_BASE_URL`, `PARSE_APP_ID` | 即梦真实生成代理。 |
+| `12-douyinManager.js` | `TIKHUB_BASE_URL`, `TIKHUB_TOKEN` | 抖音真实平台数据抓取。 |
+| `13-douyinInsightManager.js` | `PARSE_API_HOST`, `PARSE_APP_ID` | 业务资产持久化和用户隔离。 |
+
+以上变量均已在云函数源码中改为“环境变量优先、当前兼容值兜底”。正式部署时应在 fmode 云函数平台配置环境变量,并在确认新变量生效后轮换旧凭证。
+
+## 当前前端函数 ID
+
+见 `src/app/services/cloud-functions.ts`。当前仍为空的字段:
+
+- `quickly`: 一键成片云函数未启用。
+- `authCredit`: 账号/积分云函数未启用;试运营阶段积分扣费仍关闭。
+
+## 验证命令
 
 ```bash
-npm install fmode-ng
+npm run smoke:cloud
+npm run build
 ```
 
-安装后 `src/app/services/parse.service.ts` 的导入错误会自动消失。
+`smoke:cloud` 会跳过函数 ID 为空的项目;对于 `jimeng`、`douyin`、`proxy` 会用预期失败请求验证函数可达和路由校验是否生效,避免触发真实生成或真实扣费
 
-## 验证云函数部署成功
+2026-06-01 联网冒烟结果:
 
-每个云函数支持 `debug` 之外的 action 调用即可验证。示例:
+- 最终通过:`manifest.list`、`task.list`、`history.list`、`result.list`、`remix.listAll`、`voice.listProfiles`、`upload.token.probe`、`jimeng.unknownAction`、`douyin.unknownRoute`、`douyinInsight.authGuard`、`proxy.unknownAction`。
+- 权限保护符合预期:`douyinInsight.listTopics` 未带 session token 时返回“请先登录”。
+- 首轮曾出现 `manifest.list`、`task.list` 偶发 `fetch failed`,复跑通过;后续生产运维需要继续观察函数平台网络稳定性。
+- 待部署填 ID:`quickly`、`authCredit`。
 
-```javascript
-// 在浏览器 console 或一个临时按钮里测试
-const Parse = FmodeParse.initialize({
-  appId: 'ncloudmaster',
-  serverURL: 'https://server.fmode.cn/parse',
-});
-
-// 测试 manifestManager(应该返回空数组 [])
-await Parse.Cloud.function({ id: '你的函数ID', action: 'list' });
-// 期望响应:{ code: 200, success: true, data: [] }
-```
+## 首次部署后的特殊步骤
 
-## ⚠️ voiceManager 首次部署后必做
+`voiceManager` 需要初始化 speaker_id 池:
 
-`voiceManager` 需要灌入 speaker_id 池才能工作。原本 `docs/音色创建/speaker_id.md` 里的池要一次性导入:
-
-```javascript
-await Parse.Cloud.function({
-  id: '你的voiceManager函数ID',
-  action: 'initPool',
-  speakerIds: ['S_xxx', 'S_yyy', ...], // 把现有池里所有 speaker_id 传进来
+```js
+await fetch('https://server.fmode.cn/api/functions', {
+  method: 'POST',
+  headers: {
+    'Content-Type': 'application/json',
+    'X-Parse-Application-Id': 'ncloudmaster',
+  },
+  body: JSON.stringify({
+    id: 'voice 函数 ID',
+    _ApplicationId: 'ncloudmaster',
+    action: 'initPool',
+    speakerIds: ['S_xxx', 'S_yyy'],
+  }),
 });
 ```
 
-灌入后才能调用 `autoSpeakerId` 自动分配。
-
-## 数据迁移(可选)
-
-新云函数建的是空表。如果想保留现有 `data/*.json` 数据:
-
-1. 把 `data/manifest.json` 内容转成数组 → 循环调 `manifestManager` 的 `create` action
-2. 同理迁移 tasks / history / results / voice-profiles
-3. `remixes/*.json` → 每个 videoId 文件循环调 `remixManager` 的 `upsert`
-
-也可以**不迁移**,直接从云函数版本重新开始用。
-
-## 表设计统一约定
-
-所有 CRUD 类云函数遵循同一表结构:
-
-```sql
-CREATE TABLE "<ResourceName>" (
-  "objectId"  VARCHAR(50) PRIMARY KEY,     -- 10位随机ID(云函数生成)
-  "bizId"     VARCHAR(255),                -- 业务 ID(原 video.id / task.id 等)
-  "data"      JSONB NOT NULL DEFAULT '{}', -- 完整业务字段
-  "userId"    VARCHAR(255) DEFAULT '',     -- 可选,用户隔离
-  ... (常用索引字段)
-  "createdAt" TIMESTAMPTZ,
-  "updatedAt" TIMESTAMPTZ
-)
-```
-
-好处:
-- 业务字段加减不用改 schema(全在 JSONB 里)
-- 公共字段(id / status / type)单独提出来便于查询和索引
-- 跟 Parse 自动 `objectId` 兼容
-
-## 统一响应格式
-
-```json
-// 成功
-{ "code": 200, "success": true, "data": ... }
-
-// 失败
-{ "code": 400 或 500, "success": false, "error": "错误信息" }
-```
+## 上线前检查
 
-前端 `ParseService.callOrThrow()` 已封装错误处理。
+- `cloud-functions.ts` 不允许留错函数 ID。
+- 必需凭证要配置在云函数平台环境变量中。
+- `npm run smoke:cloud` 通过。
+- `npm run build` 通过。
+- 批量真实生成、素材归档、发布复盘抓取各跑一条端到端样例。

+ 1 - 0
package.json

@@ -8,6 +8,7 @@
     "server": "node server.js",
     "dev": "concurrently \"npm run server\" \"npm start\"",
     "build": "ng build",
+    "smoke:cloud": "node scripts/smoke-cloud-functions.mjs",
     "watch": "ng build --watch --configuration development",
     "test": "ng test"
   },

+ 79 - 0
scripts/smoke-cloud-functions.mjs

@@ -0,0 +1,79 @@
+const FN_URL = 'https://server.fmode.cn/api/functions';
+const APP_ID = 'ncloudmaster';
+
+const fn = {
+  manifest: 'VWejNK8E5z',
+  task: 'yL6opHxC4D',
+  history: 'cegZcOyOH0',
+  result: 'XPv6Sym2I6',
+  remix: 'LjcaC94Kno',
+  voice: 'LHBMMcDP4n',
+  quickly: '',
+  proxy: 'wzwnEuAhwb',
+  jimeng: 'WUkf74lVE5',
+  douyin: 'HArHym0smi',
+  douyinInsight: 'pzBTvs6UcE',
+  upload: 'VHS0noRP7Q',
+  authCredit: '',
+};
+
+const checks = [
+  ['manifest.list', fn.manifest, { action: 'list' }, true],
+  ['task.list', fn.task, { action: 'list' }, true],
+  ['history.list', fn.history, { action: 'list' }, true],
+  ['result.list', fn.result, { action: 'list' }, true],
+  ['remix.listAll', fn.remix, { action: 'listAll' }, true],
+  ['voice.listProfiles', fn.voice, { action: 'listProfiles' }, true],
+  ['upload.token.probe', fn.upload, { action: 'createUploadToken', filename: 'probe.txt', mimeType: 'text/plain', kind: 'probe' }, true],
+  ['jimeng.unknownAction', fn.jimeng, { action: 'unknown' }, false],
+  ['douyin.unknownRoute', fn.douyin, { action: 'call', route: 'unknown' }, false],
+  ['douyinInsight.authGuard', fn.douyinInsight, { action: 'listTopics' }, false],
+  ['proxy.unknownAction', fn.proxy, { action: 'foo' }, false],
+  ['quickly.configured', fn.quickly, { action: 'query', taskId: 'probe' }, null],
+  ['authCredit.configured', fn.authCredit, { action: 'me' }, null],
+];
+
+async function call(id, body) {
+  const response = await fetch(FN_URL, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      'X-Parse-Application-Id': APP_ID,
+    },
+    body: JSON.stringify({ id, _ApplicationId: APP_ID, ...body }),
+  });
+  const text = await response.text();
+  try {
+    return JSON.parse(text);
+  } catch {
+    return { code: response.status, success: false, error: text };
+  }
+}
+
+let failed = 0;
+for (const [name, id, body, expectSuccess] of checks) {
+  if (!id) {
+    console.log(`SKIP ${name}: function id is empty`);
+    continue;
+  }
+  try {
+    const result = await call(id, body);
+    const ok = expectSuccess === null
+      ? true
+      : expectSuccess
+        ? result?.code === 200 && result?.success !== false
+        : result?.code >= 400 || result?.success === false;
+    if (!ok) failed += 1;
+    console.log(`${ok ? 'OK' : 'FAIL'} ${name}: ${JSON.stringify(result).slice(0, 500)}`);
+  } catch (error) {
+    failed += 1;
+    console.log(`FAIL ${name}: ${error?.message || error}`);
+  }
+}
+
+if (failed > 0) {
+  console.error(`Cloud function smoke failed: ${failed}`);
+  process.exit(1);
+}
+
+console.log('Cloud function smoke passed');

+ 215 - 0
src/app/app.css

@@ -821,6 +821,13 @@ time {
   top: 12px;
 }
 
+.monitor-work-badge--new {
+  left: auto;
+  right: 12px;
+  top: 12px;
+  background: #16a34a;
+}
+
 .monitor-work-duration {
   right: 12px;
   bottom: 12px;
@@ -867,6 +874,214 @@ time {
   justify-content: center;
 }
 
+.viral-analysis-modal {
+  max-width: 920px;
+  width: min(920px, calc(100vw - 32px));
+}
+
+.viral-analysis-body {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  max-height: min(72vh, 760px);
+  overflow: auto;
+}
+
+.viral-analysis-loading {
+  display: grid;
+  place-items: center;
+  gap: 10px;
+  min-height: 260px;
+  text-align: center;
+  color: var(--text-secondary);
+}
+
+.viral-analysis-loading strong {
+  color: var(--text-primary);
+  font-size: 18px;
+}
+
+.viral-analysis-hero,
+.viral-analysis-section {
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+  padding: 16px;
+}
+
+.viral-analysis-hero {
+  display: flex;
+  justify-content: space-between;
+  gap: 16px;
+  align-items: flex-start;
+}
+
+.viral-analysis-kicker {
+  display: block;
+  color: var(--text-secondary);
+  font-size: 12px;
+  margin-bottom: 6px;
+}
+
+.viral-analysis-hero h4,
+.viral-analysis-section h4 {
+  margin: 0 0 8px;
+  color: var(--text-primary);
+}
+
+.viral-analysis-hero p,
+.viral-analysis-section p {
+  margin: 0;
+  color: var(--text-secondary);
+  line-height: 1.7;
+}
+
+.viral-analysis-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 12px;
+}
+
+.viral-angle-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.viral-angle {
+  border: 1px solid rgba(37, 99, 235, 0.22);
+  border-radius: 999px;
+  background: rgba(37, 99, 235, 0.08);
+  color: #1d4ed8;
+  padding: 6px 10px;
+  font-size: 13px;
+  line-height: 1.4;
+}
+
+.viral-analysis-footer {
+  gap: 10px;
+}
+
+.daily-report-modal {
+  max-width: 860px;
+  width: min(860px, calc(100vw - 32px));
+}
+
+.daily-report-body {
+  display: grid;
+  gap: 16px;
+  max-height: min(72vh, 760px);
+  overflow: auto;
+}
+
+.daily-report-summary {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 12px;
+}
+
+.daily-report-summary > div,
+.daily-report-section {
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+  padding: 16px;
+}
+
+.daily-report-summary span {
+  display: block;
+  color: var(--text-secondary);
+  font-size: 13px;
+}
+
+.daily-report-summary strong {
+  display: block;
+  margin-top: 8px;
+  color: var(--text-primary);
+  font-size: 24px;
+}
+
+.daily-report-section h4 {
+  margin: 0 0 12px;
+  color: var(--text-primary);
+}
+
+.daily-work,
+.daily-topic {
+  border-top: 1px solid var(--border-color);
+  padding-top: 12px;
+  margin-top: 12px;
+}
+
+.daily-work:first-of-type,
+.daily-topic:first-of-type {
+  border-top: 0;
+  padding-top: 0;
+  margin-top: 0;
+}
+
+.daily-work strong,
+.daily-topic strong {
+  color: var(--text-primary);
+}
+
+.daily-work p,
+.daily-topic p,
+.daily-report-section p {
+  margin: 6px 0 0;
+  color: var(--text-secondary);
+  line-height: 1.7;
+}
+
+.daily-work span {
+  display: inline-block;
+  margin-top: 8px;
+  color: var(--text-muted);
+  font-size: 12px;
+}
+
+.daily-topic {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 12px;
+}
+
+.daily-report-footer {
+  gap: 10px;
+}
+
+@media (max-width: 720px) {
+  .viral-analysis-grid,
+  .viral-analysis-hero {
+    grid-template-columns: 1fr;
+    display: grid;
+  }
+
+  .viral-analysis-footer {
+    flex-direction: column;
+  }
+
+  .daily-report-summary,
+  .daily-topic {
+    grid-template-columns: 1fr;
+    display: grid;
+  }
+
+  .viral-analysis-footer .btn {
+    width: 100%;
+  }
+
+  .daily-report-footer {
+    flex-direction: column;
+  }
+
+  .daily-report-footer .btn,
+  .daily-topic .btn {
+    width: 100%;
+  }
+}
+
 .monitor-work-progress {
   margin-top: 10px;
 }

+ 229 - 8
src/app/app.html

@@ -36,6 +36,7 @@
 
       <!-- 首屏 Hero(CTA-first,P2) -->
       <app-home *ngIf="currentTab === 'home'"
+                [monitorSummary]="getHomeMonitorSummary()"
                 (navigate)="onHomeNavigate($event)"
                 (suggestionPick)="onAssistantSuggestion($event)"
                 (inlineChatChange)="homeChatActive = $event">
@@ -100,6 +101,10 @@
       <!-- 图生视频 Pipeline (P1) -->
       <app-image-generation *ngIf="currentTab === 'image-generation'"></app-image-generation>
 
+      <app-batch-production *ngIf="currentTab === 'batch-production'"></app-batch-production>
+
+      <app-retrospective *ngIf="currentTab === 'retrospective'"></app-retrospective>
+
       <!-- 图生视频 Pipeline (P1) -->
       <app-image-to-video *ngIf="currentTab === 'image-to-video'"></app-image-to-video>
 
@@ -118,10 +123,13 @@
       </app-drafts>
 
       <!-- 素材库(跨 Pipeline AI 生成成果聚合) -->
-      <app-library *ngIf="currentTab === 'library' && auth.isLoggedIn"
-                   (navigateToDraft)="onLibraryNavigate($event)">
+      <app-library *ngIf="currentTab === 'library'"
+                   (navigateToDraft)="onLibraryNavigate($event)"
+                   (navigateToRetrospective)="onLibraryRetrospective($event)">
       </app-library>
 
+      <app-templates *ngIf="currentTab === 'templates'"></app-templates>
+
       <app-auth-page *ngIf="currentTab === 'login'"
                      mode="login"
                      [allowRegister]="false"
@@ -350,6 +358,12 @@
 
                 </button>
 
+                <button class="btn" type="button" (click)="analyzeSearchVideo(video)" [disabled]="isAnalyzingViralVideo">
+
+                  <span class="icon">insights</span> 爆款分析
+
+                </button>
+
                 <button class="btn btn-primary" (click)="downloadVideo(video)"
 
                         [disabled]="video.isDownloading">
@@ -470,6 +484,11 @@
 
             </button>
 
+            <button class="btn" type="button" (click)="generateMonitorDailyReport()" [disabled]="isDailyReportGenerating || monitorAuthors.length === 0 || getTotalMonitorWorksCount() === 0">
+              <span class="icon">summarize</span>
+              {{isDailyReportGenerating ? '生成中...' : '生成监测日报'}}
+            </button>
+
           </div>
 
         </div>
@@ -602,6 +621,10 @@
 
               </div>
 
+                <span *ngIf="author.lastCheckedAt">上次刷新 {{author.lastCheckedAt | date:'MM-dd HH:mm'}}</span>
+
+                <span *ngIf="author.newWorksCount > 0">新增 {{author.newWorksCount}} 条</span>
+
             </article>
 
           </div>
@@ -738,6 +761,8 @@
 
                     <span class="monitor-work-duration" *ngIf="work.type === 'video' && work.duration">{{work.duration | timeFormat}}</span>
 
+                    <span class="monitor-work-badge monitor-work-badge--new" *ngIf="work.isNew">新增</span>
+
                   </div>
 
                   <div class="monitor-work-content">
@@ -764,6 +789,12 @@
 
                       </button>
 
+                      <button class="btn" type="button" (click)="analyzeMonitorWork(selectedAuthor, work)" [disabled]="isAnalyzingViralVideo">
+
+                        <span class="icon">insights</span> 爆款分析
+
+                      </button>
+
                       <button class="btn btn-primary" type="button" (click)="downloadMonitorWork(selectedAuthor, work)" [disabled]="work.isDownloading">
 
                         <span class="icon">{{ work.isDownloading ? 'hourglass_empty' : 'download' }}</span>
@@ -828,6 +859,20 @@
 
 
 
+      <app-topic-pool *ngIf="currentTab === 'topic-pool'"
+                      (navigateToPipeline)="onTopicPoolNavigate($event)"
+                      (sourceVideoOpen)="onTopicSourceVideoOpen($event)"
+                      (navigateToBatch)="setCurrentTab('batch-production')">
+      </app-topic-pool>
+
+      <app-analysis-history *ngIf="currentTab === 'analysis-history' && auth.isLoggedIn"
+                            (analysisOpen)="openSavedViralAnalysis($event)">
+      </app-analysis-history>
+
+      <app-daily-report-history *ngIf="currentTab === 'daily-reports' && auth.isLoggedIn"
+                                (reportOpen)="openSavedDailyReport($event)">
+      </app-daily-report-history>
+
       <!-- 任务管理模块 -->
 
       <section class="dh-page tasks-page" *ngIf="currentTab === 'tasks'">
@@ -1150,6 +1195,25 @@
 
               <!-- 错误信息 -->
 
+              <div class="task-row__meta" *ngIf="getTaskCostLines(task).length || getTaskExternalIds(task).length || task.refundReason">
+                <span class="task-row__chip" *ngFor="let line of getTaskCostLines(task)">
+                  {{getTaskCostLineText(line)}}
+                </span>
+                <span class="task-row__chip" *ngFor="let item of getTaskExternalIds(task)">
+                  {{item.key}}: {{item.value}}
+                </span>
+                <span class="task-row__chip" *ngIf="task.refundReason">
+                  退款原因:{{task.refundReason}}
+                </span>
+              </div>
+
+              <div class="task-row__meta" *ngIf="task.steps?.length">
+                <span class="task-row__chip" *ngFor="let step of task.steps">
+                  {{step.label}} · {{step.status}} · {{step.progress || 0}}%
+                  <ng-container *ngIf="getTaskStepDurationText(step)"> · {{getTaskStepDurationText(step)}}</ng-container>
+                </span>
+              </div>
+
               <div class="task-row__error" *ngIf="task.status === 'failed' && task.error_message">
                 <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                   <circle cx="12" cy="12" r="10"></circle>
@@ -5325,11 +5389,11 @@
 
               <select [(ngModel)]="remixQuality">
 
-                <option value="1080p">1080P (0.63元/秒)</option>
+                <option value="1080p">1080P</option>
 
-                <option value="pro">Pro 1080P (1元/秒)</option>
+                <option value="pro">Pro 1080P</option>
 
-                <option value="720p">720P (0.28元/秒,当前不可用)</option>
+                <option value="720p">720P</option>
 
               </select>
 
@@ -5377,9 +5441,7 @@
 
           <span class="icon">attach_money</span>
 
-          预估费用:<strong>¥{{(remixQuality === 'pro' ? (remixFrames === 121 ? 5 : 10) : remixQuality === '1080p' ? (remixFrames === 121 ? 3.15 : 6.3) : (remixFrames === 121 ? 1.4 : 2.8)) * remixStoryboard.length | number:'1.2-2'}}</strong>
-
-          ({{remixStoryboard.length}} 个片段 × ¥{{remixQuality === 'pro' ? (remixFrames === 121 ? '5.00' : '10.00') : remixQuality === '1080p' ? (remixFrames === 121 ? '3.15' : '6.30') : (remixFrames === 121 ? '1.40' : '2.80')}}/段)
+          预计生成:<strong>{{remixStoryboard.length}} 个片段</strong>
 
         </div>
 
@@ -6119,6 +6181,165 @@
 
   <!-- 浮动 mini-pill:最近一次生成(P3)。
        与 AI 助手 FAB 共用右下角,通过 .has-assistant 类把 pill 抬高到 FAB 上方 -->
+  <div class="modal-overlay viral-analysis-overlay" *ngIf="isViralAnalysisOpen" (click)="closeViralAnalysis()">
+    <div class="modal viral-analysis-modal" (click)="$event.stopPropagation()">
+      <div class="modal-header">
+        <h3><span class="icon">insights</span> 爆款分析</h3>
+        <button class="icon-btn" type="button" (click)="closeViralAnalysis()" [disabled]="isAnalyzingViralVideo">
+          <span class="icon">close</span>
+        </button>
+      </div>
+
+      <div class="modal-body viral-analysis-body">
+        <div class="viral-analysis-loading" *ngIf="isAnalyzingViralVideo">
+          <div class="spinner"></div>
+          <strong>正在分析视频结构</strong>
+          <p>会结合视频信息和评论样本,提炼可复用的选题与脚本框架。</p>
+        </div>
+
+        <div class="monitor-error" *ngIf="viralAnalysisError && !isAnalyzingViralVideo">{{viralAnalysisError}}</div>
+
+        <ng-container *ngIf="currentViralAnalysis as insight">
+          <section class="viral-analysis-hero">
+            <div>
+              <span class="viral-analysis-kicker">来源视频</span>
+              <h4>{{insight.videoSnapshot.desc || viralAnalysisSourceTitle}}</h4>
+              <p>
+                {{insight.videoSnapshot.authorName || '未知作者'}}
+                · 置信度:{{insight.confidence === 'high' ? '高' : insight.confidence === 'medium' ? '中' : '低'}}
+                · 评论 {{insight.commentsSnapshot.length || 0}} 条
+                <ng-container *ngIf="insight.repliesSnapshot?.length"> · 回复 {{insight.repliesSnapshot?.length}} 条</ng-container>
+                <ng-container *ngIf="insight.transcript"> · 已使用字幕</ng-container>
+              </p>
+            </div>
+            <span class="status-badge">{{insight.source === 'monitor' ? '博主监测' : '视频发现'}}</span>
+          </section>
+
+          <section class="viral-analysis-section">
+            <h4>爆点判断</h4>
+            <p>{{insight.analysis.summary}}</p>
+          </section>
+
+          <div class="viral-analysis-grid">
+            <section class="viral-analysis-section">
+              <h4>开头方式</h4>
+              <p>{{insight.analysis.openingPattern || insight.analysis.hookType}}</p>
+            </section>
+
+            <section class="viral-analysis-section">
+              <h4>内容节奏</h4>
+              <p>{{insight.analysis.contentRhythm}}</p>
+            </section>
+
+            <section class="viral-analysis-section">
+              <h4>用户情绪</h4>
+              <p>{{insight.analysis.audienceEmotion}}</p>
+            </section>
+
+            <section class="viral-analysis-section">
+              <h4>评论触发点</h4>
+              <p>{{insight.analysis.commentTrigger}}</p>
+            </section>
+          </div>
+
+          <section class="viral-analysis-section">
+            <h4>可复用脚本框架</h4>
+            <p>{{insight.analysis.reusableFrame}}</p>
+          </section>
+
+          <section class="viral-analysis-section" *ngIf="insight.transcript">
+            <h4>字幕/口播依据</h4>
+            <p>{{insight.transcript | slice:0:220}}{{insight.transcript.length > 220 ? '...' : ''}}</p>
+          </section>
+
+          <section class="viral-analysis-section" *ngIf="insight.analysis.reusableAngles.length">
+            <h4>同款选题方向</h4>
+            <div class="viral-angle-list">
+              <span class="viral-angle" *ngFor="let angle of insight.analysis.reusableAngles">{{angle}}</span>
+            </div>
+          </section>
+
+          <section class="viral-analysis-section" *ngIf="insight.analysis.riskNotes.length || insight.confidence !== 'high'">
+            <h4>注意事项</h4>
+            <p *ngIf="insight.confidence !== 'high'">当前未接入完整字幕,结果基于标题、互动数据、评论及可获取的评论回复推断。</p>
+            <p *ngFor="let note of insight.analysis.riskNotes">{{note}}</p>
+          </section>
+        </ng-container>
+      </div>
+
+      <div class="modal-footer viral-analysis-footer">
+        <button class="btn" type="button" (click)="closeViralAnalysis()" [disabled]="isAnalyzingViralVideo">关闭</button>
+        <button class="btn" type="button" (click)="saveCurrentViralAnalysisToTopics()" [disabled]="!currentViralAnalysis || isAnalyzingViralVideo">
+          保存到选题池
+        </button>
+        <button class="btn btn-primary" type="button" (click)="useViralAnalysisForTopicVideo()" [disabled]="!currentViralAnalysis || isAnalyzingViralVideo">
+          进入主题生视频
+        </button>
+      </div>
+    </div>
+  </div>
+
+  <div class="modal-overlay daily-report-overlay" *ngIf="isDailyReportOpen" (click)="closeDailyReport()">
+    <div class="modal daily-report-modal" (click)="$event.stopPropagation()">
+      <div class="modal-header">
+        <h3><span class="icon">summarize</span> 监测日报</h3>
+        <button class="icon-btn" type="button" (click)="closeDailyReport()">
+          <span class="icon">close</span>
+        </button>
+      </div>
+
+      <div class="modal-body daily-report-body" *ngIf="currentDailyReport as report">
+        <section class="daily-report-summary">
+          <div>
+            <span>日报日期</span>
+            <strong>{{report.date}}</strong>
+          </div>
+          <div>
+            <span>监测作品</span>
+            <strong>{{report.newWorks.length}}</strong>
+          </div>
+          <div>
+            <span>推荐选题</span>
+            <strong>{{report.topTopics.length}}</strong>
+          </div>
+        </section>
+
+        <section class="daily-report-section">
+          <h4>值得关注的作品</h4>
+          <article class="daily-work" *ngFor="let work of report.newWorks | slice:0:3">
+            <strong>{{$any(work).authorName || '监测博主'}}</strong>
+            <p>{{$any(work).desc || $any(work).id}}</p>
+            <span>点赞 {{$any(work).statistics?.digg_count || 0}} · 评论 {{$any(work).statistics?.comment_count || 0}}</span>
+            <p *ngIf="$any(work).commentSamples?.length">评论关注:{{$any(work).commentSamples[0]}}</p>
+          </article>
+        </section>
+
+        <section class="daily-report-section">
+          <h4>可保存选题</h4>
+          <article class="daily-topic" *ngFor="let topic of report.topTopics">
+            <div>
+              <strong>{{topic.title}}</strong>
+              <p>{{topic.angle}}</p>
+            </div>
+            <button class="btn" type="button" (click)="openDailyReportTopic(topic)">进入主题生视频</button>
+          </article>
+        </section>
+
+        <section class="daily-report-section">
+          <h4>明日建议</h4>
+          <p>{{report.calibrationPrompt}}</p>
+        </section>
+      </div>
+
+      <div class="modal-footer daily-report-footer">
+        <button class="btn" type="button" (click)="closeDailyReport()">关闭</button>
+        <button class="btn btn-primary" type="button" (click)="saveDailyReportTopics()" [disabled]="!currentDailyReport?.topTopics?.length">
+          保存选题到选题池
+        </button>
+      </div>
+    </div>
+  </div>
+
   <app-recent-task-pill
     [task]="currentRunningTask"
     [hidden]="pillHidden"

+ 569 - 9
src/app/app.ts

@@ -42,6 +42,12 @@ import { AssetRemixComponent } from './pages/pipelines/asset-remix/asset-remix.c
 
 import { TopicToVideoComponent } from './pages/pipelines/topic-to-video/topic-to-video.component';
 import { ImageGenerationComponent } from './pages/pipelines/image-generation/image-generation.component';
+import { TopicPoolComponent } from './pages/topic-pool/topic-pool.component';
+import { AnalysisHistoryComponent } from './pages/analysis-history/analysis-history.component';
+import { DailyReportHistoryComponent } from './pages/daily-report-history/daily-report-history.component';
+import { TemplatesComponent } from './pages/templates/templates.component';
+import { BatchProductionComponent } from './pages/batch-production/batch-production.component';
+import { RetrospectiveComponent } from './pages/retrospective/retrospective.component';
 
 import { DraftsComponent } from './pages/drafts/drafts.component';
 import { LibraryComponent } from './pages/library/library.component';
@@ -70,6 +76,10 @@ import { CostEstimatorService } from './services/cost-estimator.service';
 import { GenerationTaskService } from './services/generation-task.service';
 import { GenerationTaskCost, GenerationTaskStatus, GenerationTaskStep } from './models/generation-task.model';
 import { userFailureSuggestion, userFriendlyError } from './services/user-message.util';
+import { ViralAnalysisService } from './services/viral-analysis.service';
+import { TopicPoolService } from './services/topic-pool.service';
+import { DailyReport, TopicIdea, ViralAnalysis } from './models/douyin-insight.model';
+import { DailyReportService } from './services/daily-report.service';
 
 
 
@@ -169,6 +179,10 @@ interface Task {
 
   creditStatus?: GenerationTaskCost['status'];
 
+  costLines?: GenerationTaskCost['lines'];
+
+  refundReason?: string;
+
   operation?: string;
 
   pipelineId?: string;
@@ -630,6 +644,10 @@ interface MonitorWork {
 
   downloadProgress?: number;
 
+  isNew?: boolean;
+
+  firstSeenAt?: string;
+
 }
 
 
@@ -678,6 +696,12 @@ interface MonitorAuthor {
 
   loadingWorks: boolean;
 
+  lastCheckedAt?: string;
+
+  lastSeenAwemeIds: string[];
+
+  newWorksCount: number;
+
 }
 
 
@@ -712,6 +736,12 @@ interface MonitorAuthor {
 
     TopicToVideoComponent,
     ImageGenerationComponent,
+    TopicPoolComponent,
+    AnalysisHistoryComponent,
+    DailyReportHistoryComponent,
+    TemplatesComponent,
+    BatchProductionComponent,
+    RetrospectiveComponent,
 
     DraftsComponent,
 
@@ -756,6 +786,9 @@ export class App {
   private readonly voiceApi = inject(VoiceApiService);
   private readonly costEstimator = inject(CostEstimatorService);
   private readonly generationTasks = inject(GenerationTaskService);
+  private readonly viralAnalysis = inject(ViralAnalysisService);
+  private readonly topicPool = inject(TopicPoolService);
+  private readonly dailyReports = inject(DailyReportService);
 
 
   protected readonly title = signal('抖音AI视频生成系统');
@@ -764,7 +797,7 @@ export class App {
 
   // 当前激活的标签页
 
-  currentTab: 'home' | 'search' | 'monitor' | 'tasks' | 'history' | 'results' | 'videos' | 'voice-synthesis' | 'pipelines' | 'digital-human' | 'image-generation' | 'video-generation' | 'image-to-video' | 'action-transfer' | 'asset-remix' | 'topic-to-video' | 'drafts' | 'library' | 'login' | 'register' | 'user-center' | 'admin-users' = 'home';
+  currentTab: 'home' | 'search' | 'monitor' | 'topic-pool' | 'retrospective' | 'analysis-history' | 'daily-reports' | 'tasks' | 'history' | 'results' | 'videos' | 'voice-synthesis' | 'pipelines' | 'batch-production' | 'digital-human' | 'image-generation' | 'video-generation' | 'image-to-video' | 'action-transfer' | 'asset-remix' | 'topic-to-video' | 'drafts' | 'library' | 'templates' | 'login' | 'register' | 'user-center' | 'admin-users' = 'home';
 
   authModalOpen = false;
   authModalFeature = '该功能';
@@ -775,7 +808,7 @@ export class App {
   readonly sidebarPipelines = getSidebarPipelines();
 
   private readonly TAB_STORAGE_KEY = 'tiktok.currentTab';
-  private readonly AUTH_REQUIRED_TABS = new Set(['drafts', 'library', 'videos', 'monitor']);
+  private readonly AUTH_REQUIRED_TABS = new Set(['drafts', 'videos', 'monitor', 'analysis-history', 'daily-reports']);
 
 
 
@@ -811,6 +844,16 @@ export class App {
 
   hasMore: boolean = false;
 
+  isViralAnalysisOpen: boolean = false;
+
+  isAnalyzingViralVideo: boolean = false;
+
+  viralAnalysisError: string = '';
+
+  currentViralAnalysis: ViralAnalysis | null = null;
+
+  viralAnalysisSourceTitle: string = '';
+
   monitorUniqueIdInput: string = '';
 
   monitorAuthors: MonitorAuthor[] = [];
@@ -821,6 +864,12 @@ export class App {
 
   monitorAddError: string = '';
 
+  isDailyReportOpen: boolean = false;
+
+  currentDailyReport: DailyReport | null = null;
+
+  isDailyReportGenerating: boolean = false;
+
   private readonly voiceApiToken: string = 'Bearer r:f0333969e312a40e4703e8fe4ed1c600';
 
   private readonly voiceTtsBaseUrl: string = 'https://server.fmode.cn/api/volcengine/tts';
@@ -1629,9 +1678,19 @@ export class App {
     this.auth.session$.subscribe(() => {
       void this.applyDraftStorageForCurrentUser();
       this.restorePersistedMonitorState();
+      void this.restoreLatestDailyReport();
       this.refreshIncompleteMonitorAuthors();
     });
 
+    this.generationTasks.tasks$.subscribe((tasks) => {
+      const generated = tasks.map(task => this.generationTaskToLegacyTask(task));
+      const manualOnly = this.activeTasks.filter(task => !tasks.some(item => item.id === task.id));
+      this.activeTasks = [...generated, ...manualOnly]
+        .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
+      this.updateTaskStats();
+      this.refreshView();
+    });
+
     this.initializeDefaults();
 
     this.restoreTheme();
@@ -1641,6 +1700,7 @@ export class App {
     this.restoreClonedTimbreList();
 
     this.restorePersistedMonitorState();
+    void this.restoreLatestDailyReport();
 
     this.refreshIncompleteMonitorAuthors();
 
@@ -2078,7 +2138,7 @@ export class App {
 
       const savedTab = localStorage.getItem(this.TAB_STORAGE_KEY);
 
-      const allowed = ['home','search','monitor','tasks','history','results','videos','voice-synthesis','pipelines','digital-human','image-generation','video-generation','image-to-video','action-transfer','asset-remix','topic-to-video','drafts','library','login','register','user-center','admin-users'];
+      const allowed = ['home','search','monitor','tasks','history','results','videos','voice-synthesis','pipelines','batch-production','digital-human','image-generation','video-generation','image-to-video','action-transfer','asset-remix','topic-to-video','drafts','library','templates','topic-pool','retrospective','analysis-history','daily-reports','login','register','user-center','admin-users'];
 
       // 旧 tab → 新页面的迁移映射(M4/M5/M6 重组后)
       // 'history' 完整功能被「我的创作」覆盖;'results' 被「素材库」替代。
@@ -2174,7 +2234,7 @@ export class App {
 
 
 
-  setCurrentTab(tab: 'home' | 'search' | 'monitor' | 'tasks' | 'history' | 'results' | 'videos' | 'voice-synthesis' | 'pipelines' | 'digital-human' | 'image-generation' | 'video-generation' | 'image-to-video' | 'action-transfer' | 'asset-remix' | 'topic-to-video' | 'drafts' | 'library' | 'login' | 'register' | 'user-center' | 'admin-users'): void {
+  setCurrentTab(tab: 'home' | 'search' | 'monitor' | 'topic-pool' | 'retrospective' | 'analysis-history' | 'daily-reports' | 'tasks' | 'history' | 'results' | 'videos' | 'voice-synthesis' | 'pipelines' | 'batch-production' | 'digital-human' | 'image-generation' | 'video-generation' | 'image-to-video' | 'action-transfer' | 'asset-remix' | 'topic-to-video' | 'drafts' | 'library' | 'templates' | 'login' | 'register' | 'user-center' | 'admin-users'): void {
 
     if (tab === 'admin-users' && !this.auth.isAdmin) {
       tab = this.auth.isLoggedIn ? 'user-center' : 'login';
@@ -2258,10 +2318,50 @@ export class App {
   /** 助手内回复中的「直达 X 模式」按钮被点击 */
   onAssistantSuggestion(s: AssistantSuggestion): void {
     if (!s?.tab) return;
+    if (s.payload?.['saveTopic']) {
+      this.saveAssistantSuggestionToTopicPool(s);
+      return;
+    }
     // 复用首屏跳转通道:含 payload 时同样走 sessionStorage 预填
     this.onHomeNavigate({ tab: s.tab, payload: s.payload });
   }
 
+  private saveAssistantSuggestionToTopicPool(s: AssistantSuggestion): void {
+    if (!this.auth.isLoggedIn) {
+      this.auth.requestLogin('保存选题');
+      return;
+    }
+
+    const payload = s.payload || {};
+    const title = String(payload['title'] || payload['topic'] || s.label || '').trim();
+    const angle = String(payload['angle'] || payload['description'] || payload['topic'] || title).trim();
+    const tagsValue = payload['tags'];
+    const tags = Array.isArray(tagsValue)
+      ? tagsValue.map((tag) => String(tag || '').trim()).filter(Boolean)
+      : String(tagsValue || '').split(/[,,\s]+/).map((tag) => tag.trim()).filter(Boolean);
+
+    try {
+      const topic = this.topicPool.saveTopic({
+        title: title || 'AI 助手选题',
+        angle: angle || title || '来自 AI 助手的选题建议',
+        sourceType: 'assistant',
+        sourceVideoIds: [],
+        sourceAuthorIds: [],
+        tags: Array.from(new Set(['AI助手', ...tags])).slice(0, 12),
+        audience: String(payload['audience'] || ''),
+        hook: String(payload['hook'] || title || ''),
+        outline: String(payload['outline'] || payload['scriptFrame'] || ''),
+        status: 'idea',
+        pipelineTarget: 'topic_video',
+        confidence: 'medium',
+      });
+      this.setCurrentTab('topic-pool');
+      this.showToast(`已保存选题:${topic.title}`, 'success', 3000);
+    } catch (err: any) {
+      this.showToast(err?.message || '保存选题失败,请稍后重试', 'error');
+    }
+  }
+
   /**
    * 「我的创作」页里点「继续编辑」/「再做一版」时调用。
    * 此时 PipelineSessionService.active 已是目标 draft,
@@ -2280,7 +2380,46 @@ export class App {
     this.setCurrentTab('drafts');
   }
 
+  onLibraryRetrospective(ev: { assetKey: string }): void {
+    try {
+      localStorage.setItem('videoWorkflow.retrospective.selectedAssetKey', ev.assetKey);
+    } catch {}
+    this.setCurrentTab('retrospective');
+  }
+
   /** 首屏组件输出:可能带 payload(如预填的主题) */
+  onTopicPoolNavigate(ev: { tab: 'topic-to-video' | 'digital-human'; topic: TopicIdea }): void {
+    if (ev.tab === 'topic-to-video') {
+      try {
+        sessionStorage.setItem('t2v.prefillTopic', ev.topic.title || ev.topic.angle);
+      } catch {}
+    }
+    this.setCurrentTab(ev.tab);
+    this.showToast(`已带入选题:${ev.topic.title}`, 'success', 3000);
+  }
+
+  onTopicSourceVideoOpen(ev: { awemeId: string; topic: TopicIdea }): void {
+    const video: VideoInfo = {
+      aweme_id: ev.awemeId,
+      desc: ev.topic.title || ev.topic.angle || ev.awemeId,
+      cover_url: '',
+      cover_candidates: [],
+      duration: 0,
+      author: {
+        nickname: '来源视频',
+        sec_uid: ev.topic.sourceAuthorIds?.[0] || '',
+      },
+      statistics: {
+        play_count: 0,
+        digg_count: 0,
+        comment_count: 0,
+        share_count: 0,
+      },
+      text_extra: ev.topic.tags || [],
+    };
+    this.viewVideoDetail(video);
+  }
+
   onHomeNavigate(ev: { tab: string; payload?: any }): void {
     this.setCurrentTab(ev.tab as any);
     // 主题预填:暂存到 sessionStorage,topic-to-video 组件初始化时自取
@@ -2636,6 +2775,8 @@ export class App {
       generationStatus: task.generationStatus || this.legacyStatusToGenerationStatus(task.status),
       estimatedCredits,
       creditStatus: task.creditStatus || (estimatedCredits > 0 ? 'estimated' : 'unknown'),
+      costLines: task.costLines,
+      refundReason: task.refundReason,
       error_message: normalizedError,
       failureSuggestion: task.failureSuggestion || (task.error_message ? userFailureSuggestion(task.error_message) : undefined),
       retryable: task.retryable ?? (task.status === 'failed' || task.status === 'timeout'),
@@ -2657,6 +2798,8 @@ export class App {
       reservedCredits: task.cost.reservedCredits,
       creditReservationId: task.cost.reservationId,
       creditStatus: task.cost.status,
+      costLines: task.cost.lines,
+      refundReason: task.cost.refundReason,
       operation: task.operation,
       pipelineId: task.pipelineId,
       generationStatus: task.status,
@@ -2725,6 +2868,31 @@ export class App {
 
   // 保存任务到后端(返回 Promise,确保 POST 完成后再允许 PUT)
 
+  getTaskCostLines(task: Task): NonNullable<GenerationTaskCost['lines']> {
+    return Array.isArray(task.costLines) ? task.costLines : [];
+  }
+
+  getTaskCostLineText(line: NonNullable<GenerationTaskCost['lines']>[number]): string {
+    const quantity = line.quantity ? ` x ${line.quantity}${line.unit || ''}` : '';
+    return `${line.label}${quantity}: ${Math.max(0, Math.ceil(Number(line.credits || 0)))} 积分`;
+  }
+
+  getTaskStepDurationText(step: GenerationTaskStep): string {
+    if (!step.startedAt) return '';
+    const end = step.finishedAt || Date.now();
+    const seconds = Math.max(1, Math.round((end - step.startedAt) / 1000));
+    if (seconds < 60) return `${seconds}s`;
+    const minutes = Math.floor(seconds / 60);
+    const rest = seconds % 60;
+    return rest ? `${minutes}m ${rest}s` : `${minutes}m`;
+  }
+
+  getTaskExternalIds(task: Task): Array<{ key: string; value: string }> {
+    return Object.entries(task.externalTaskIds || {})
+      .filter(([, value]) => !!value)
+      .map(([key, value]) => ({ key, value }));
+  }
+
   saveTask(task: Task): void {
 
     task = this.hydrateTaskForGenerationCenter(task);
@@ -3766,6 +3934,168 @@ export class App {
 
   }
 
+  getHomeMonitorSummary(): { authorCount: number; workCount: number; newWorkCount: number; lastCheckedAt?: string; hasDailyReport?: boolean; dailyReportDate?: string } {
+
+    const workCount = this.getTotalMonitorWorksCount();
+
+    const newWorkCount = this.monitorAuthors.reduce((total, author) => total + Number(author.newWorksCount || 0), 0);
+
+    const lastCheckedAt = this.monitorAuthors
+      .map((author) => author.lastCheckedAt || '')
+      .filter(Boolean)
+      .sort()
+      .pop();
+
+    return {
+      authorCount: this.monitorAuthors.length,
+      workCount,
+      newWorkCount,
+      lastCheckedAt,
+      hasDailyReport: !!this.currentDailyReport,
+      dailyReportDate: this.currentDailyReport?.date,
+    };
+
+  }
+
+
+
+  generateMonitorDailyReport(): void {
+
+    if (this.monitorAuthors.length === 0) {
+
+      this.showToast('请先添加需要监测的博主', 'warn');
+
+      return;
+
+    }
+
+    if (this.getTotalMonitorWorksCount() === 0) {
+
+      this.showToast('请先加载博主作品后再生成日报', 'warn');
+
+      return;
+
+    }
+
+    this.isDailyReportGenerating = true;
+
+    this.dailyReports.generateMonitorReportWithComments(this.monitorAuthors).subscribe({
+      next: (report) => {
+        this.currentDailyReport = report;
+        this.isDailyReportOpen = true;
+        this.showToast('监测日报已生成', 'success');
+        this.refreshView();
+      },
+      error: (err: any) => {
+        this.isDailyReportGenerating = false;
+        this.showToast(err?.message || '生成日报失败,请稍后重试', 'error');
+        this.refreshView();
+      },
+      complete: () => {
+        this.isDailyReportGenerating = false;
+        this.refreshView();
+      },
+    });
+
+  }
+
+
+
+  closeDailyReport(): void {
+
+    this.isDailyReportOpen = false;
+
+  }
+
+  openSavedDailyReport(report: DailyReport): void {
+
+    this.currentDailyReport = report;
+    this.isDailyReportOpen = true;
+    this.refreshView();
+
+  }
+
+  private async restoreLatestDailyReport(): Promise<void> {
+
+    if (!this.auth.isLoggedIn) {
+      this.currentDailyReport = null;
+      return;
+    }
+
+    try {
+      const reports = await this.dailyReports.refreshReportsFromCloud();
+      this.currentDailyReport = reports[0] || this.dailyReports.listReports()[0] || null;
+      this.refreshView();
+    } catch {
+      this.currentDailyReport = this.dailyReports.listReports()[0] || null;
+    }
+
+  }
+
+
+
+  saveDailyReportTopics(): void {
+
+    const report = this.currentDailyReport;
+
+    if (!report || !report.topTopics?.length) return;
+
+    try {
+
+      const saved = report.topTopics.map((topic) => this.topicPool.saveTopic({
+
+        title: topic.title,
+
+        angle: topic.angle,
+
+        sourceType: 'daily_report',
+
+        sourceVideoIds: topic.sourceVideoIds,
+
+        sourceAuthorIds: topic.sourceAuthorIds,
+
+        tags: topic.tags,
+
+        audience: topic.audience,
+
+        hook: topic.hook,
+
+        outline: topic.outline,
+
+        status: 'idea',
+
+        pipelineTarget: topic.pipelineTarget,
+
+        confidence: topic.confidence,
+
+      }));
+
+      this.showToast(`已保存 ${saved.length} 个日报选题`, 'success');
+
+    } catch (err: any) {
+
+      this.showToast(err?.message || '保存日报选题失败,请稍后重试', 'error');
+
+    }
+
+  }
+
+
+
+  openDailyReportTopic(topic: TopicIdea): void {
+
+    try {
+
+      sessionStorage.setItem('t2v.prefillTopic', topic.title || topic.angle);
+
+    } catch {}
+
+    this.closeDailyReport();
+
+    this.setCurrentTab('topic-to-video');
+
+  }
+
 
 
   reloadMonitorAuthorWorks(author: MonitorAuthor): void {
@@ -4664,11 +4994,25 @@ export class App {
 
 
 
+        const previousSeenIds = new Set([...(author.lastSeenAwemeIds || []), ...author.works.map(work => work.id)]);
+        const now = new Date().toISOString();
+        const nextWorks = mappedWorks.map(work => ({
+          ...work,
+          isNew: !append && previousSeenIds.size > 0 && !previousSeenIds.has(work.id),
+          firstSeenAt: previousSeenIds.has(work.id) ? work.firstSeenAt : now,
+        }));
+
         author.works = append
 
-          ? [...author.works, ...mappedWorks.filter(item => !author.works.some(existing => existing.id === item.id))]
+          ? [...author.works, ...nextWorks.filter(item => !author.works.some(existing => existing.id === item.id))]
+
+          : nextWorks;
+
+        author.newWorksCount = append ? (author.newWorksCount || 0) : nextWorks.filter(work => work.isNew).length;
 
-          : mappedWorks;
+        author.lastCheckedAt = now;
+
+        author.lastSeenAwemeIds = Array.from(new Set(author.works.map(work => work.id).filter(Boolean)));
 
         author.worksCursor = parsed.cursor;
 
@@ -4908,7 +5252,13 @@ export class App {
 
       hasMoreWorks: false,
 
-      loadingWorks: false
+      loadingWorks: false,
+
+      lastCheckedAt: '',
+
+      lastSeenAwemeIds: [],
+
+      newWorksCount: 0
 
     };
 
@@ -5136,11 +5486,21 @@ export class App {
 
         createTime: work.createTime ? work.createTime.toISOString() : null,
 
+        firstSeenAt: work.firstSeenAt || null,
+
+        isNew: !!work.isNew,
+
         isDownloading: false,
 
         downloadProgress: 0
 
-      }))
+      })),
+
+      lastCheckedAt: author.lastCheckedAt || '',
+
+      lastSeenAwemeIds: author.lastSeenAwemeIds || [],
+
+      newWorksCount: author.newWorksCount || 0
 
     };
 
@@ -5202,6 +5562,10 @@ export class App {
 
             createTime: work?.createTime ? new Date(work.createTime) : undefined,
 
+            firstSeenAt: String(work?.firstSeenAt || ''),
+
+            isNew: !!work?.isNew,
+
             type: work?.type === 'image' ? 'image' : 'video',
 
             statistics: {
@@ -5228,7 +5592,13 @@ export class App {
 
       hasMoreWorks: !!author?.hasMoreWorks,
 
-      loadingWorks: false
+      loadingWorks: false,
+
+      lastCheckedAt: String(author?.lastCheckedAt || ''),
+
+      lastSeenAwemeIds: Array.isArray(author?.lastSeenAwemeIds) ? author.lastSeenAwemeIds.map((id: any) => String(id)).filter(Boolean) : [],
+
+      newWorksCount: Number(author?.newWorksCount || 0)
 
     };
 
@@ -5484,6 +5854,196 @@ export class App {
 
   // 下载抖音视频到本地
 
+  analyzeSearchVideo(video: VideoInfo): void {
+
+    this.startViralAnalysis({
+      awemeId: video.aweme_id,
+      source: 'search',
+      video: {
+        awemeId: video.aweme_id,
+        desc: video.desc,
+        authorName: video.author?.nickname || '',
+        authorId: video.author?.sec_uid || '',
+        diggCount: video.statistics?.digg_count || 0,
+        commentCount: video.statistics?.comment_count || 0,
+        shareCount: video.statistics?.share_count || 0,
+        playCount: video.statistics?.play_count || 0,
+        createTime: video.create_time ? new Date(video.create_time * 1000).toISOString() : undefined,
+        coverUrl: video.cover_url || '',
+      },
+    }, video.desc || video.aweme_id);
+
+  }
+
+
+
+  analyzeMonitorWork(author: MonitorAuthor, work: MonitorWork): void {
+
+    this.startViralAnalysis({
+      awemeId: work.id,
+      source: 'monitor',
+      video: {
+        awemeId: work.id,
+        desc: work.desc,
+        authorName: author.nickname,
+        authorId: author.secUserId || author.uid || '',
+        diggCount: work.statistics?.digg_count || 0,
+        commentCount: work.statistics?.comment_count || 0,
+        shareCount: work.statistics?.share_count || 0,
+        playCount: work.statistics?.play_count || 0,
+        createTime: work.createTime?.toISOString(),
+        coverUrl: work.coverUrl || '',
+      },
+    }, work.desc || work.id);
+
+  }
+
+
+
+  closeViralAnalysis(): void {
+
+    if (this.isAnalyzingViralVideo) return;
+
+    this.isViralAnalysisOpen = false;
+
+    this.viralAnalysisError = '';
+
+  }
+
+
+
+  openSavedViralAnalysis(analysis: ViralAnalysis): void {
+
+    this.currentViralAnalysis = analysis;
+
+    this.viralAnalysisSourceTitle = analysis.videoSnapshot.desc || analysis.awemeId;
+
+    this.viralAnalysisError = '';
+
+    this.isAnalyzingViralVideo = false;
+
+    this.isViralAnalysisOpen = true;
+
+    this.refreshView();
+
+  }
+
+
+
+  saveCurrentViralAnalysisToTopics(): void {
+
+    const analysis = this.currentViralAnalysis;
+
+    if (!analysis) return;
+
+    try {
+
+      const angles = analysis.analysis.reusableAngles.length
+
+        ? analysis.analysis.reusableAngles
+
+        : [analysis.analysis.summary || analysis.videoSnapshot.desc || '爆款拆解选题'];
+
+      const saved = angles.slice(0, 3).map((angle) => this.topicPool.saveTopic({
+
+        title: angle,
+
+        angle,
+
+        sourceType: 'viral_analysis',
+
+        sourceVideoIds: [analysis.awemeId],
+
+        sourceAuthorIds: analysis.videoSnapshot.authorId ? [analysis.videoSnapshot.authorId] : [],
+
+        tags: ['爆款分析'],
+
+        audience: '',
+
+        hook: analysis.analysis.openingPattern,
+
+        outline: analysis.analysis.reusableFrame,
+
+        status: 'idea',
+
+        pipelineTarget: 'topic_video',
+
+        confidence: analysis.confidence,
+
+      }));
+
+      this.currentViralAnalysis = this.viralAnalysis.markTopicsSaved(analysis.id, saved.map((item) => item.id));
+
+      this.showToast(`已保存 ${saved.length} 个选题`, 'success');
+
+    } catch (err: any) {
+
+      this.showToast(err?.message || '保存选题失败,请稍后重试', 'error');
+
+    }
+
+  }
+
+
+
+  useViralAnalysisForTopicVideo(): void {
+
+    const analysis = this.currentViralAnalysis;
+
+    if (!analysis) return;
+
+    const angle = analysis.analysis.reusableAngles[0] || analysis.analysis.summary || analysis.videoSnapshot.desc;
+
+    this.showToast(`已整理选题:${angle}`, 'success');
+
+    this.setCurrentTab('topic-to-video');
+
+  }
+
+
+
+  private startViralAnalysis(input: Parameters<ViralAnalysisService['analyzeVideo']>[0], sourceTitle: string): void {
+
+    this.isViralAnalysisOpen = true;
+
+    this.isAnalyzingViralVideo = true;
+
+    this.viralAnalysisError = '';
+
+    this.currentViralAnalysis = null;
+
+    this.viralAnalysisSourceTitle = sourceTitle || input.awemeId;
+
+    this.viralAnalysis.analyzeVideo(input).subscribe({
+
+      next: (analysis) => {
+
+        this.currentViralAnalysis = analysis;
+
+        this.isAnalyzingViralVideo = false;
+
+        this.refreshView();
+
+      },
+
+      error: (err: any) => {
+
+        this.viralAnalysisError = this.getErrorText(err, '爆款分析失败,请稍后重试');
+
+        this.isAnalyzingViralVideo = false;
+
+        this.showToast(this.viralAnalysisError, 'error');
+
+        this.refreshView();
+
+      },
+
+    });
+
+  }
+
+
+
   downloadVideo(video: VideoInfo): void {
 
     if (video.isDownloading) return;

+ 6 - 0
src/app/components/app-sidebar/app-sidebar.component.ts

@@ -33,6 +33,7 @@ const ZONES: SidebarZone[] = [
     items: [
       { tab: 'home', label: '首页' },
       { tab: 'pipelines', label: '生成模式中心' },
+      { tab: 'batch-production', label: '批量生产' },
       { tab: 'image-generation', label: '图片生成' },
       { tab: 'topic-to-video', label: '主题生视频' },
       { tab: 'image-to-video', label: '图生视频' },
@@ -49,6 +50,7 @@ const ZONES: SidebarZone[] = [
     items: [
       { tab: 'drafts', label: '我的创作' },
       { tab: 'library', label: '素材库' },
+      { tab: 'templates', label: '模板库' },
       // 老入口保留作为兼容(后续版本可移除)
       { tab: 'videos', label: '视频管理' },
       { tab: 'tasks', label: '任务管理' },
@@ -61,6 +63,10 @@ const ZONES: SidebarZone[] = [
     items: [
       { tab: 'search', label: '视频发现' },
       { tab: 'monitor', label: '博主监测' },
+      { tab: 'topic-pool', label: '选题池' },
+      { tab: 'retrospective', label: '发布复盘' },
+      { tab: 'analysis-history', label: '分析历史' },
+      { tab: 'daily-reports', label: '日报历史' },
       { tab: 'voice-synthesis', label: '语音合成' },
     ],
   },

+ 52 - 0
src/app/models/batch-job.model.ts

@@ -0,0 +1,52 @@
+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 interface BatchJob {
+  id: string;
+  userId: string;
+  title: string;
+  pipelineId: PipelineRoute;
+  templateId?: string;
+  status: BatchJobStatus;
+  total: number;
+  completed: number;
+  failed: number;
+  estimatedCredits: number;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface BatchJobItem {
+  id: string;
+  batchJobId: string;
+  topicId?: string;
+  title: string;
+  input: Record<string, any>;
+  generationTaskId?: string;
+  status: BatchJobItemStatus;
+  resultUrl?: string;
+  errorMessage?: string;
+  retryCount: number;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface BatchJobCreateInput {
+  title: string;
+  pipelineId: PipelineRoute;
+  templateId?: string;
+  estimatedCredits?: number;
+  items: Array<{
+    topicId?: string;
+    title: string;
+    input: Record<string, any>;
+  }>;
+}
+
+export interface BatchJobRunResult {
+  resultUrl?: string;
+  generationTaskId?: string;
+}

+ 51 - 0
src/app/models/content-asset.model.ts

@@ -0,0 +1,51 @@
+import { PipelineRoute } from '../pipelines/pipeline-registry';
+
+export type ContentAssetType = 'image' | 'video' | 'audio';
+
+export interface ContentProject {
+  id: string;
+  name: string;
+  assetCount: number;
+  updatedAt: number;
+}
+
+export interface ContentAssetMetadata {
+  id: string;
+  assetKey: string;
+  draftId: string;
+  artifactId?: string;
+  pipelineId: PipelineRoute;
+  type: ContentAssetType;
+  title: string;
+  url: string;
+  projectName?: string;
+  tags: string[];
+  favorite?: boolean;
+  archived?: boolean;
+  source: {
+    draftTitle?: string;
+    isFinal?: boolean;
+    templateId?: string;
+    topicId?: string;
+    batchJobId?: string;
+    batchItemId?: string;
+    generationTaskId?: string;
+  };
+  parameters?: Record<string, any>;
+  createdAt: number;
+  updatedAt: number;
+}
+
+export interface ContentAssetUpsertInput {
+  assetKey: string;
+  draftId: string;
+  artifactId?: string;
+  pipelineId: PipelineRoute;
+  type: ContentAssetType;
+  title: string;
+  url: string;
+  draftTitle?: string;
+  isFinal?: boolean;
+  parameters?: Record<string, any>;
+  source?: Partial<ContentAssetMetadata['source']>;
+}

+ 114 - 0
src/app/models/douyin-insight.model.ts

@@ -0,0 +1,114 @@
+export type InsightConfidence = 'high' | 'medium' | 'low';
+
+export interface ViralVideoSnapshot {
+  awemeId: string;
+  desc: string;
+  authorName?: string;
+  authorId?: string;
+  diggCount?: number;
+  commentCount?: number;
+  shareCount?: number;
+  playCount?: number;
+  createTime?: string;
+  coverUrl?: string;
+}
+
+export interface ViralCommentSnapshot {
+  id?: string;
+  text: string;
+  likeCount?: number;
+  replyCount?: number;
+}
+
+export interface ViralReplySnapshot {
+  id?: string;
+  commentId?: string;
+  text: string;
+  likeCount?: number;
+}
+
+export interface ViralAnalysisContent {
+  summary: string;
+  hookType: string;
+  openingPattern: string;
+  contentRhythm: string;
+  conflict: string;
+  proofPoint: string;
+  audienceEmotion: string;
+  commentTrigger: string;
+  reusableFrame: string;
+  reusableAngles: string[];
+  riskNotes: string[];
+  evidenceRefs: string[];
+}
+
+export interface ViralAnalysis {
+  id: string;
+  userId: string;
+  awemeId: string;
+  source: 'search' | 'monitor' | 'manual';
+  videoSnapshot: ViralVideoSnapshot;
+  commentsSnapshot: ViralCommentSnapshot[];
+  repliesSnapshot?: ViralReplySnapshot[];
+  transcript?: string;
+  transcriptSource?: 'detail' | 'asr' | 'manual';
+  confidence: InsightConfidence;
+  analysis: ViralAnalysisContent;
+  savedTopicIds: string[];
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface TopicIdea {
+  id: string;
+  userId: string;
+  title: string;
+  angle: string;
+  sourceType: 'viral_analysis' | 'daily_report' | 'assistant' | 'manual';
+  sourceVideoIds: string[];
+  sourceAuthorIds: string[];
+  tags: string[];
+  audience?: string;
+  hook?: string;
+  outline?: string;
+  status: 'idea' | 'script_ready' | 'generating' | 'completed' | 'archived';
+  pipelineTarget?: 'topic_video' | 'digital_human' | 'image_generation' | 'asset_remix';
+  confidence?: InsightConfidence;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface DailyReport {
+  id: string;
+  userId: string;
+  date: string;
+  newWorks: unknown[];
+  topTopics: TopicIdea[];
+  reportMarkdown: string;
+  reportJson: unknown;
+  calibrationPrompt?: string;
+  status: 'success' | 'failed';
+  errorMessage?: string;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface MonitorAuthorInsight {
+  id: string;
+  userId: string;
+  nickname: string;
+  secUserId?: string;
+  userIdRaw?: string;
+  avatarUrl?: string;
+  lastCheckedAt?: string;
+  lastSeenAwemeIds: string[];
+  enabled: boolean;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface AnalyzeVideoInput {
+  awemeId: string;
+  source: ViralAnalysis['source'];
+  video?: Partial<ViralVideoSnapshot>;
+}

+ 10 - 0
src/app/models/generation-task.model.ts

@@ -19,6 +19,13 @@ export interface GenerationTaskCost {
   reservationId?: string;
   status: 'estimated' | 'reserved' | 'committed' | 'refunded' | 'free' | 'unknown';
   refundReason?: string;
+  lines?: Array<{
+    key: string;
+    label: string;
+    credits: number;
+    quantity?: number;
+    unit?: string;
+  }>;
 }
 
 export interface GenerationTask {
@@ -39,6 +46,7 @@ export interface GenerationTask {
   retryable?: boolean;
   recoverable?: boolean;
   externalTaskIds?: Record<string, string>;
+  archivedAt?: number;
   snapshot?: any;
 }
 
@@ -60,6 +68,8 @@ export interface LegacyTaskLike {
   reservedCredits?: number;
   creditReservationId?: string;
   creditStatus?: GenerationTaskCost['status'];
+  costLines?: GenerationTaskCost['lines'];
+  refundReason?: string;
   operation?: string;
   pipelineId?: string;
   generationStatus?: GenerationTaskStatus;

+ 49 - 0
src/app/models/publication-record.model.ts

@@ -0,0 +1,49 @@
+import { PipelineRoute } from '../pipelines/pipeline-registry';
+
+export type PublicationPlatform = 'douyin' | 'xiaohongshu' | 'bilibili' | 'kuaishou' | 'wechat' | 'other';
+
+export interface PublicationMetrics {
+  views: number;
+  likes: number;
+  comments: number;
+  favorites: number;
+  shares: number;
+}
+
+export interface PublicationRecord {
+  id: string;
+  assetKey: string;
+  assetTitle: string;
+  assetUrl: string;
+  pipelineId: PipelineRoute;
+  templateId?: string;
+  projectName?: string;
+  tags: string[];
+  platform: PublicationPlatform;
+  publishedAt: string;
+  url?: string;
+  metrics: PublicationMetrics;
+  score: number;
+  rating?: number;
+  notes?: string;
+  diagnosis: string[];
+  suggestions: string[];
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface PublicationRecordInput {
+  assetKey: string;
+  assetTitle: string;
+  assetUrl: string;
+  pipelineId: PipelineRoute;
+  templateId?: string;
+  projectName?: string;
+  tags?: string[];
+  platform: PublicationPlatform;
+  publishedAt: string;
+  url?: string;
+  metrics: PublicationMetrics;
+  rating?: number;
+  notes?: string;
+}

+ 36 - 0
src/app/models/template.model.ts

@@ -0,0 +1,36 @@
+import { PipelineRoute } from '../pipelines/pipeline-registry';
+
+export type TemplateScope = 'system' | 'user';
+
+export type TemplateCategory = '口播' | '产品种草' | '知识科普' | '影视感' | '带货' | '其他';
+
+export interface GenerationTemplatePreview {
+  coverUrl?: string;
+  samplePrompt?: string;
+  sampleResultUrl?: string;
+}
+
+export interface GenerationTemplate {
+  id: string;
+  userId?: string;
+  scope: TemplateScope;
+  pipelineId: PipelineRoute;
+  name: string;
+  description?: string;
+  category: TemplateCategory;
+  tags: string[];
+  config: Record<string, any>;
+  preview?: GenerationTemplatePreview;
+  version: number;
+  usageCount: number;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export type GenerationTemplateInput = Omit<
+  GenerationTemplate,
+  'id' | 'userId' | 'scope' | 'version' | 'usageCount' | 'createdAt' | 'updatedAt'
+> & {
+  id?: string;
+  version?: number;
+};

+ 3 - 3
src/app/pages/account/auth-page.component.html

@@ -3,10 +3,10 @@
     <aside class="account-side">
       <div class="account-mark">AI</div>
       <h1>登录账号</h1>
-      <p>登录后可查看个人内容,并使用生成、合成、语音和数据功能。积分只会在提交任务前预扣,明确失败时自动退回。</p>
+      <p>登录后可查看个人内容,并使用生成、合成、语音和数据功能。试运营阶段暂不开放在线付费,额度由管理员统一管理。</p>
       <div class="account-rate">
-        <span>积分规则</span>
-        <strong>1 元 = 10 积分</strong>
+        <span>试运营</span>
+        <strong>账号由管理员发放</strong>
       </div>
     </aside>
 

+ 141 - 0
src/app/pages/account/user-center.component.css

@@ -49,6 +49,10 @@
   align-items: stretch;
 }
 
+.uc-grid-disabled-pay {
+  grid-template-columns: minmax(280px, .82fr) minmax(360px, 1.18fr);
+}
+
 .uc-card {
   background: rgba(255, 255, 255, .94);
   border: 1px solid rgba(148, 163, 184, .28);
@@ -91,6 +95,23 @@
   justify-content: center;
 }
 
+.uc-disabled-pay {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  gap: 12px;
+}
+
+.uc-disabled-pay p {
+  margin: 0;
+  color: #64748b;
+  line-height: 1.7;
+}
+
+.uc-disabled-pay button {
+  width: fit-content;
+}
+
 .uc-recharge,
 .uc-password {
   display: grid;
@@ -124,6 +145,53 @@
   width: 100%;
 }
 
+.uc-recharge button:disabled {
+  opacity: .64;
+  cursor: not-allowed;
+}
+
+.uc-payment-panel {
+  margin-bottom: 16px;
+}
+
+.uc-payment-content {
+  display: grid;
+  grid-template-columns: 220px minmax(0, 1fr);
+  gap: 22px;
+  align-items: center;
+}
+
+.uc-payment-content img {
+  width: 220px;
+  height: 220px;
+  border: 1px solid #dbeafe;
+  border-radius: 8px;
+  padding: 10px;
+  background: #fff;
+}
+
+.uc-payment-content strong {
+  display: block;
+  color: #0f172a;
+  font-size: 34px;
+  line-height: 1;
+}
+
+.uc-payment-content p {
+  margin: 10px 0 12px;
+  color: #64748b;
+}
+
+.uc-payment-content span {
+  display: inline-flex;
+  border-radius: 999px;
+  background: #eff6ff;
+  color: #1d4ed8;
+  padding: 7px 10px;
+  font-size: 13px;
+  font-weight: 800;
+}
+
 .uc-section-title {
   display: flex;
   align-items: center;
@@ -155,6 +223,76 @@
   font-weight: 800;
 }
 
+.uc-task-summary {
+  margin-bottom: 16px;
+}
+
+.uc-summary-grid {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 10px;
+  margin-bottom: 14px;
+}
+
+.uc-summary-grid div {
+  border: 1px solid #dbeafe;
+  border-radius: 8px;
+  padding: 12px;
+  background: #f8fbff;
+}
+
+.uc-summary-grid span {
+  display: block;
+  color: #64748b;
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.uc-summary-grid strong {
+  display: block;
+  margin-top: 4px;
+  color: #1d4ed8;
+  font-size: 22px;
+}
+
+.uc-task-list {
+  display: grid;
+  gap: 8px;
+}
+
+.uc-task-row {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) auto;
+  gap: 8px 14px;
+  align-items: center;
+  border-top: 1px solid #e2e8f0;
+  padding-top: 8px;
+}
+
+.uc-task-row span {
+  color: #0f172a;
+  font-weight: 700;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.uc-task-row small {
+  color: #64748b;
+}
+
+.uc-task-row em {
+  grid-column: 1 / -1;
+  color: #059669;
+  font-style: normal;
+  font-size: 12px;
+}
+
+.uc-muted {
+  margin: 0;
+  color: #64748b;
+}
+
 .uc-message,
 .uc-error {
   margin-bottom: 16px;
@@ -198,6 +336,9 @@
 @media (max-width: 720px) {
   .account-dashboard { padding: 18px; }
   .uc-header { align-items: flex-start; flex-direction: column; }
+  .uc-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+  .uc-payment-content { grid-template-columns: 1fr; }
+  .uc-task-row { grid-template-columns: 1fr; }
   .uc-ledger { overflow-x: auto; }
   .uc-ledger table { min-width: 680px; }
 }

+ 38 - 14
src/app/pages/account/user-center.component.html

@@ -10,32 +10,56 @@
     </div>
   </header>
 
-  <div class="uc-grid">
+  <div class="uc-grid uc-grid-disabled-pay">
     <article class="uc-card uc-balance">
       <span>当前积分</span>
       <strong>{{ balance?.balance ?? 0 }}</strong>
       <p>赠送 {{ balance?.gifted ?? 0 }},累计消费 {{ balance?.totalConsumed ?? 0 }}</p>
     </article>
 
-    <article class="uc-card">
-      <h2>充值</h2>
-      <div class="uc-recharge">
-        <label>
-          <span>充值金额(元)</span>
-          <input type="number" min="1" step="1" [(ngModel)]="rechargeAmount">
-        </label>
-        <div class="uc-credit-preview">到账 {{ (rechargeAmount || 0) * 10 }} 积分</div>
-        <button type="button" (click)="createRecharge()">创建充值订单</button>
-      </div>
-      <div class="uc-order" *ngIf="pendingOrder">
-        订单 {{ pendingOrder.objectId }} · {{ pendingOrder.creditAmount }} 积分 · {{ pendingOrder.status === 'pending' ? '待支付' : pendingOrder.status }}
-      </div>
+    <article class="uc-card uc-disabled-pay">
+      <h2>额度说明</h2>
+      <p>试运营阶段暂未开放在线充值。需要调整额度时,由管理员统一处理。</p>
+      <button *ngIf="auth.isAdmin" type="button" (click)="navigate.emit('admin-users')">进入账号明细</button>
     </article>
   </div>
 
   <div class="uc-message" *ngIf="message">{{ message }}</div>
   <div class="uc-error" *ngIf="error">{{ error }}</div>
 
+  <article class="uc-card uc-task-summary">
+    <div class="uc-section-title">
+      <h2>任务积分概览</h2>
+      <button type="button" (click)="navigate.emit('tasks')">查看任务</button>
+    </div>
+    <div class="uc-summary-grid">
+      <div>
+        <span>预计消耗</span>
+        <strong>{{ taskCreditSummary.estimated }}</strong>
+      </div>
+      <div>
+        <span>已确认</span>
+        <strong>{{ taskCreditSummary.committed }}</strong>
+      </div>
+      <div>
+        <span>已预扣</span>
+        <strong>{{ taskCreditSummary.reserved }}</strong>
+      </div>
+      <div>
+        <span>已退回</span>
+        <strong>{{ taskCreditSummary.refunded }}</strong>
+      </div>
+    </div>
+    <div class="uc-task-list" *ngIf="tasks.length">
+      <div class="uc-task-row" *ngFor="let task of tasks">
+        <span>{{ task.title }}</span>
+        <small>{{ task.status }} · {{ getTaskCreditText(task) }}</small>
+        <em *ngIf="task.cost.refundReason">退款:{{ task.cost.refundReason }}</em>
+      </div>
+    </div>
+    <p class="uc-muted" *ngIf="!tasks.length">暂无任务积分记录</p>
+  </article>
+
   <article class="uc-card uc-ledger">
     <div class="uc-section-title">
       <h2>积分流水</h2>

+ 29 - 17
src/app/pages/account/user-center.component.ts

@@ -1,7 +1,9 @@
 import { CommonModule } from '@angular/common';
 import { Component, EventEmitter, OnInit, Output } from '@angular/core';
 import { FormsModule } from '@angular/forms';
-import { AuthCreditService, CreditBalance, CreditLedgerItem, RechargeOrder } from '../../services/auth-credit.service';
+import { AuthCreditService, CreditBalance, CreditLedgerItem } from '../../services/auth-credit.service';
+import { GenerationTaskService } from '../../services/generation-task.service';
+import { GenerationTask } from '../../models/generation-task.model';
 
 @Component({
   selector: 'app-user-center',
@@ -15,17 +17,16 @@ export class UserCenterComponent implements OnInit {
 
   balance: CreditBalance | null = null;
   ledger: CreditLedgerItem[] = [];
-  rechargeAmount = 10;
-  pendingOrder: RechargeOrder | null = null;
+  tasks: GenerationTask[] = [];
   passwordForm = { oldPassword: '', newPassword: '', confirmPassword: '' };
   loading = false;
   message = '';
   error = '';
 
-  constructor(public auth: AuthCreditService) {}
+  constructor(public auth: AuthCreditService, private generationTasks: GenerationTaskService) {}
 
   ngOnInit(): void {
-    this.reload();
+    void this.reload();
   }
 
   async reload(): Promise<void> {
@@ -35,6 +36,7 @@ export class UserCenterComponent implements OnInit {
     try {
       this.balance = await this.auth.getBalance();
       this.ledger = await this.auth.getLedger(80);
+      this.tasks = this.generationTasks.tasks.slice(0, 20);
     } catch (e: any) {
       this.error = e?.message || '加载用户信息失败';
     } finally {
@@ -42,18 +44,28 @@ export class UserCenterComponent implements OnInit {
     }
   }
 
-  async createRecharge(): Promise<void> {
-    this.message = '';
-    this.error = '';
-    try {
-      const amount = Number(this.rechargeAmount || 0);
-      if (amount <= 0) throw new Error('请输入有效充值金额');
-      this.pendingOrder = await this.auth.createRechargeOrder(amount);
-      this.message = '充值订单已创建,支付接口接入后将在这里展示支付状态。';
-      await this.reload();
-    } catch (e: any) {
-      this.error = e?.message || '创建充值订单失败';
-    }
+  get taskCreditSummary(): { estimated: number; committed: number; refunded: number; reserved: number } {
+    return this.tasks.reduce((acc, task) => {
+      const credits = Math.max(0, Math.ceil(Number(task.cost?.estimatedCredits || 0)));
+      if (task.cost?.status === 'committed') acc.committed += credits;
+      else if (task.cost?.status === 'refunded') acc.refunded += credits;
+      else if (task.cost?.status === 'reserved') acc.reserved += credits;
+      acc.estimated += credits;
+      return acc;
+    }, { estimated: 0, committed: 0, refunded: 0, reserved: 0 });
+  }
+
+  getTaskCreditText(task: GenerationTask): string {
+    const credits = Math.max(0, Math.ceil(Number(task.cost?.estimatedCredits || 0)));
+    const status: Record<string, string> = {
+      estimated: '预计',
+      reserved: '已预扣',
+      committed: '已确认',
+      refunded: '已退回',
+      free: '免费',
+      unknown: '未估算',
+    };
+    return `${status[task.cost?.status || 'unknown'] || '预计'} ${credits} 积分`;
   }
 
   async changePassword(): Promise<void> {

+ 247 - 0
src/app/pages/analysis-history/analysis-history.component.css

@@ -0,0 +1,247 @@
+.analysis-history-page {
+  min-height: 100%;
+  padding: 28px 32px 48px;
+  color: var(--text-primary);
+}
+
+.analysis-history-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 20px;
+  margin-bottom: 22px;
+}
+
+.analysis-history-eyebrow {
+  display: inline-block;
+  margin-bottom: 8px;
+  color: var(--primary-color);
+  font-size: 13px;
+  font-weight: 700;
+}
+
+.analysis-history-header h1 {
+  margin: 0;
+  font-size: 28px;
+}
+
+.analysis-history-header p {
+  margin: 8px 0 0;
+  max-width: 720px;
+  color: var(--text-secondary);
+  line-height: 1.7;
+}
+
+.analysis-history-stats {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 12px;
+  margin-bottom: 16px;
+}
+
+.analysis-stat {
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+  padding: 14px;
+  text-align: left;
+  cursor: pointer;
+  transition: border-color .2s ease, transform .2s ease, box-shadow .2s ease;
+}
+
+.analysis-stat:hover,
+.analysis-stat.is-active {
+  border-color: var(--primary-color);
+  box-shadow: 0 10px 24px rgba(37, 99, 235, 0.12);
+  transform: translateY(-1px);
+}
+
+.analysis-stat span {
+  display: block;
+  color: var(--text-secondary);
+  font-size: 13px;
+}
+
+.analysis-stat strong {
+  display: block;
+  margin-top: 8px;
+  font-size: 24px;
+}
+
+.analysis-history-toolbar {
+  display: grid;
+  grid-template-columns: 1fr 180px 180px;
+  gap: 12px;
+  margin-bottom: 16px;
+}
+
+.analysis-search {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+  padding: 0 12px;
+}
+
+.analysis-search input,
+.analysis-history-toolbar select {
+  width: 100%;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-primary);
+  color: var(--text-primary);
+  min-height: 42px;
+  padding: 0 12px;
+}
+
+.analysis-search input {
+  border: 0;
+  background: transparent;
+  outline: none;
+}
+
+.analysis-list {
+  display: grid;
+  gap: 14px;
+}
+
+.analysis-card {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) 150px;
+  gap: 18px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+  padding: 18px;
+}
+
+.analysis-card-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 16px;
+}
+
+.analysis-source {
+  display: inline-block;
+  margin-bottom: 8px;
+  color: var(--primary-color);
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.analysis-confidence,
+.analysis-angles span,
+.analysis-saved {
+  display: inline-flex;
+  align-items: center;
+  border-radius: 999px;
+  font-size: 12px;
+  line-height: 1;
+  white-space: nowrap;
+}
+
+.analysis-confidence {
+  background: rgba(37, 99, 235, 0.1);
+  color: #1d4ed8;
+  padding: 7px 9px;
+}
+
+.analysis-card h3 {
+  margin: 0;
+  font-size: 18px;
+  line-height: 1.45;
+}
+
+.analysis-summary {
+  margin: 10px 0 0;
+  color: var(--text-secondary);
+  line-height: 1.7;
+}
+
+.analysis-meta {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+  margin-top: 12px;
+  color: var(--text-muted);
+  font-size: 12px;
+}
+
+.analysis-angles {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-top: 12px;
+}
+
+.analysis-angles span {
+  background: var(--bg-tertiary);
+  color: var(--text-secondary);
+  padding: 6px 9px;
+}
+
+.analysis-card-actions {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.analysis-card-actions .btn {
+  width: 100%;
+}
+
+.analysis-saved {
+  justify-content: center;
+  background: rgba(16, 185, 129, 0.12);
+  color: #047857;
+  padding: 8px 10px;
+}
+
+.analysis-empty {
+  display: grid;
+  place-items: center;
+  gap: 10px;
+  min-height: 360px;
+  border: 1px dashed var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+  color: var(--text-secondary);
+  text-align: center;
+}
+
+.analysis-empty .icon {
+  font-size: 34px;
+  color: var(--primary-color);
+}
+
+.analysis-empty h3 {
+  margin: 0;
+  color: var(--text-primary);
+}
+
+.analysis-empty p {
+  margin: 0;
+}
+
+@media (max-width: 900px) {
+  .analysis-history-page {
+    padding: 18px 16px 32px;
+  }
+
+  .analysis-history-header,
+  .analysis-card {
+    grid-template-columns: 1fr;
+  }
+
+  .analysis-history-header {
+    display: grid;
+  }
+
+  .analysis-history-stats,
+  .analysis-history-toolbar {
+    grid-template-columns: 1fr;
+  }
+}

+ 98 - 0
src/app/pages/analysis-history/analysis-history.component.html

@@ -0,0 +1,98 @@
+<section class="analysis-history-page">
+  <header class="analysis-history-header">
+    <div>
+      <span class="analysis-history-eyebrow">内容复盘</span>
+      <h1>分析历史</h1>
+      <p>查看已经生成的爆款分析,重新打开后可继续保存选题或进入生成流程。</p>
+    </div>
+    <button class="btn" type="button" (click)="refreshFromCloud()" [disabled]="isSyncing()">
+      <span class="icon">refresh</span>
+      {{isSyncing() ? '同步中...' : '同步'}}
+    </button>
+  </header>
+
+  <div class="analysis-history-stats">
+    <button class="analysis-stat" type="button" [class.is-active]="confidenceFilter() === 'all'" (click)="setConfidenceFilter('all')">
+      <span>全部分析</span>
+      <strong>{{stats().all}}</strong>
+    </button>
+    <button class="analysis-stat" type="button" [class.is-active]="confidenceFilter() === 'high'" (click)="setConfidenceFilter('high')">
+      <span>高置信</span>
+      <strong>{{stats().high}}</strong>
+    </button>
+    <button class="analysis-stat" type="button" [class.is-active]="confidenceFilter() === 'medium'" (click)="setConfidenceFilter('medium')">
+      <span>中置信</span>
+      <strong>{{stats().medium}}</strong>
+    </button>
+    <button class="analysis-stat" type="button">
+      <span>已保存选题</span>
+      <strong>{{stats().saved}}</strong>
+    </button>
+  </div>
+
+  <div class="analysis-history-toolbar">
+    <div class="analysis-search">
+      <span class="icon">search</span>
+      <input
+        type="text"
+        placeholder="搜索标题、作者、选题方向"
+        [ngModel]="searchTerm()"
+        (ngModelChange)="searchTerm.set($event)">
+    </div>
+
+    <select [ngModel]="sourceFilter()" (ngModelChange)="setSourceFilter($event)">
+      <option value="all">全部来源</option>
+      <option value="search">视频发现</option>
+      <option value="monitor">博主监测</option>
+      <option value="manual">手动输入</option>
+    </select>
+
+    <select [ngModel]="confidenceFilter()" (ngModelChange)="setConfidenceFilter($event)">
+      <option value="all">全部置信度</option>
+      <option value="high">高置信</option>
+      <option value="medium">中置信</option>
+      <option value="low">低置信</option>
+    </select>
+  </div>
+
+  <div class="analysis-list" *ngIf="filteredItems().length > 0; else emptyState">
+    <article class="analysis-card" *ngFor="let item of filteredItems(); trackBy: trackByAnalysis">
+      <div class="analysis-card-main">
+        <div class="analysis-card-head">
+          <div>
+            <span class="analysis-source">{{sourceLabel(item.source)}}</span>
+            <h3>{{item.videoSnapshot.desc || item.awemeId}}</h3>
+          </div>
+          <span class="analysis-confidence">{{confidenceLabel(item.confidence)}}</span>
+        </div>
+
+        <p class="analysis-summary">{{item.analysis.summary || '暂无摘要'}}</p>
+
+        <div class="analysis-meta">
+          <span>{{item.videoSnapshot.authorName || '未知作者'}}</span>
+          <span>评论 {{item.commentsSnapshot.length || 0}} 条</span>
+          <span *ngIf="item.repliesSnapshot?.length">回复 {{item.repliesSnapshot?.length}} 条</span>
+          <span *ngIf="item.transcript">已使用字幕</span>
+          <span>更新时间 {{formatTime(item.updatedAt)}}</span>
+        </div>
+
+        <div class="analysis-angles" *ngIf="item.analysis.reusableAngles.length">
+          <span *ngFor="let angle of item.analysis.reusableAngles.slice(0, 3)">{{angle}}</span>
+        </div>
+      </div>
+
+      <div class="analysis-card-actions">
+        <button class="btn btn-primary" type="button" (click)="open(item)">打开分析</button>
+        <span class="analysis-saved" *ngIf="item.savedTopicIds.length">已保存 {{item.savedTopicIds.length}} 个选题</span>
+      </div>
+    </article>
+  </div>
+
+  <ng-template #emptyState>
+    <div class="analysis-empty">
+      <span class="icon">insights</span>
+      <h3>暂无分析历史</h3>
+      <p>可以先在视频发现或博主监测中点击“爆款分析”。</p>
+    </div>
+  </ng-template>
+</section>

+ 120 - 0
src/app/pages/analysis-history/analysis-history.component.ts

@@ -0,0 +1,120 @@
+import { ChangeDetectionStrategy, Component, EventEmitter, OnInit, Output, computed, inject, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { ViralAnalysis } from '../../models/douyin-insight.model';
+import { ViralAnalysisService } from '../../services/viral-analysis.service';
+
+type ConfidenceFilter = ViralAnalysis['confidence'] | 'all';
+type SourceFilter = ViralAnalysis['source'] | 'all';
+
+@Component({
+  selector: 'app-analysis-history',
+  standalone: true,
+  imports: [CommonModule, FormsModule],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  templateUrl: './analysis-history.component.html',
+  styleUrls: ['./analysis-history.component.css'],
+})
+export class AnalysisHistoryComponent implements OnInit {
+  @Output() analysisOpen = new EventEmitter<ViralAnalysis>();
+
+  private readonly viralAnalysis = inject(ViralAnalysisService);
+
+  readonly items = signal<ViralAnalysis[]>([]);
+  readonly searchTerm = signal('');
+  readonly confidenceFilter = signal<ConfidenceFilter>('all');
+  readonly sourceFilter = signal<SourceFilter>('all');
+  readonly isSyncing = signal(false);
+
+  readonly stats = computed(() => {
+    const items = this.items();
+    return {
+      all: items.length,
+      high: items.filter((item) => item.confidence === 'high').length,
+      medium: items.filter((item) => item.confidence === 'medium').length,
+      saved: items.filter((item) => item.savedTopicIds?.length).length,
+    };
+  });
+
+  readonly filteredItems = computed(() => {
+    const keyword = this.searchTerm().trim().toLowerCase();
+    const confidence = this.confidenceFilter();
+    const source = this.sourceFilter();
+
+    return this.items().filter((item) => {
+      if (confidence !== 'all' && item.confidence !== confidence) return false;
+      if (source !== 'all' && item.source !== source) return false;
+      if (!keyword) return true;
+      const haystack = [
+        item.awemeId,
+        item.videoSnapshot.desc,
+        item.videoSnapshot.authorName || '',
+        item.analysis.summary,
+        item.analysis.openingPattern,
+        item.analysis.commentTrigger,
+        ...(item.analysis.reusableAngles || []),
+      ].join(' ').toLowerCase();
+      return haystack.includes(keyword);
+    });
+  });
+
+  ngOnInit(): void {
+    this.refreshLocal();
+    void this.refreshFromCloud();
+  }
+
+  refreshLocal(): void {
+    this.items.set(this.viralAnalysis.listAnalyses());
+  }
+
+  async refreshFromCloud(): Promise<void> {
+    this.isSyncing.set(true);
+    try {
+      this.items.set(await this.viralAnalysis.refreshAnalysesFromCloud());
+    } catch (err) {
+      console.warn('[AnalysisHistoryComponent] 云端分析历史同步失败,继续使用本地数据:', err);
+      this.refreshLocal();
+    } finally {
+      this.isSyncing.set(false);
+    }
+  }
+
+  open(item: ViralAnalysis): void {
+    this.analysisOpen.emit(item);
+  }
+
+  setConfidenceFilter(confidence: ConfidenceFilter): void {
+    this.confidenceFilter.set(confidence);
+  }
+
+  setSourceFilter(source: SourceFilter): void {
+    this.sourceFilter.set(source);
+  }
+
+  sourceLabel(source: ViralAnalysis['source']): string {
+    const map: Record<ViralAnalysis['source'], string> = {
+      search: '视频发现',
+      monitor: '博主监测',
+      manual: '手动输入',
+    };
+    return map[source] || source;
+  }
+
+  confidenceLabel(confidence: ViralAnalysis['confidence']): string {
+    if (confidence === 'high') return '高置信';
+    if (confidence === 'medium') return '中置信';
+    return '低置信';
+  }
+
+  formatTime(value: string): string {
+    if (!value) return '';
+    const date = new Date(value);
+    if (Number.isNaN(date.getTime())) return '';
+    const pad = (n: number) => n.toString().padStart(2, '0');
+    return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
+  }
+
+  trackByAnalysis(_: number, item: ViralAnalysis): string {
+    return item.id;
+  }
+}

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

@@ -0,0 +1,245 @@
+.batch-page {
+  min-height: 100%;
+  padding: 28px 32px 48px;
+  color: var(--text-primary);
+}
+
+.batch-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 20px;
+  margin-bottom: 22px;
+}
+
+.batch-eyebrow {
+  display: inline-block;
+  margin-bottom: 8px;
+  color: var(--primary-color);
+  font-size: 13px;
+  font-weight: 700;
+}
+
+.batch-header h1 {
+  margin: 0;
+  font-size: 28px;
+}
+
+.batch-header p,
+.batch-panel__head p {
+  margin: 8px 0 0;
+  color: var(--text-secondary);
+  line-height: 1.7;
+}
+
+.batch-message {
+  margin-bottom: 14px;
+  border: 1px solid rgba(37, 99, 235, 0.24);
+  border-radius: 8px;
+  background: rgba(37, 99, 235, 0.08);
+  color: #1d4ed8;
+  padding: 10px 12px;
+}
+
+.batch-layout {
+  display: grid;
+  grid-template-columns: minmax(0, 1.1fr) minmax(320px, .9fr);
+  gap: 16px;
+}
+
+.batch-panel {
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+  padding: 18px;
+}
+
+.batch-panel__head {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 14px;
+  margin-bottom: 14px;
+}
+
+.batch-panel__head h2 {
+  margin: 0;
+  font-size: 18px;
+}
+
+.batch-panel__head span {
+  color: var(--text-secondary);
+  font-size: 13px;
+}
+
+.batch-field {
+  display: grid;
+  gap: 6px;
+  margin-bottom: 12px;
+  color: var(--text-secondary);
+  font-size: 13px;
+}
+
+.batch-field input,
+.batch-field select,
+.batch-field textarea {
+  width: 100%;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-primary);
+  color: var(--text-primary);
+  min-height: 42px;
+  padding: 0 12px;
+}
+
+.batch-field textarea {
+  min-height: 180px;
+  padding: 12px;
+  resize: vertical;
+}
+
+.batch-estimate {
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-primary);
+  padding: 12px;
+  margin-bottom: 12px;
+}
+
+.batch-imported {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-primary);
+  padding: 12px;
+  margin-bottom: 12px;
+}
+
+.batch-imported__head {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 10px;
+  width: 100%;
+}
+
+.batch-imported span {
+  display: inline-flex;
+  border: 1px solid var(--border-color);
+  border-radius: 999px;
+  color: var(--text-secondary);
+  padding: 6px 8px;
+  font-size: 12px;
+}
+
+.batch-estimate span {
+  color: var(--text-secondary);
+}
+
+.batch-jobs,
+.batch-items {
+  display: grid;
+  gap: 10px;
+}
+
+.batch-job {
+  display: grid;
+  gap: 5px;
+  width: 100%;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-primary);
+  color: var(--text-primary);
+  padding: 12px;
+  text-align: left;
+  cursor: pointer;
+}
+
+.batch-job.is-active,
+.batch-job:hover {
+  border-color: var(--primary-color);
+}
+
+.batch-job span,
+.batch-job small,
+.batch-item p {
+  color: var(--text-secondary);
+}
+
+.batch-detail {
+  margin-top: 16px;
+}
+
+.batch-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.batch-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 12px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-primary);
+  padding: 14px;
+}
+
+.batch-item h3 {
+  margin: 8px 0 0;
+  font-size: 16px;
+}
+
+.batch-item__status {
+  display: inline-flex;
+  border-radius: 999px;
+  background: rgba(37, 99, 235, 0.1);
+  color: #1d4ed8;
+  padding: 6px 8px;
+  font-size: 12px;
+}
+
+.batch-item__status.is-done {
+  background: rgba(22, 163, 74, 0.12);
+  color: #15803d;
+}
+
+.batch-item__status.is-failed {
+  background: rgba(220, 38, 38, 0.12);
+  color: #b91c1c;
+}
+
+.batch-error {
+  color: #b91c1c !important;
+}
+
+.batch-empty {
+  border: 1px dashed var(--border-color);
+  border-radius: 8px;
+  color: var(--text-secondary);
+  padding: 24px;
+  text-align: center;
+}
+
+@media (max-width: 980px) {
+  .batch-layout {
+    grid-template-columns: 1fr;
+  }
+
+  .batch-header,
+  .batch-panel__head,
+  .batch-item {
+    display: block;
+  }
+
+  .batch-actions {
+    margin-top: 12px;
+  }
+}

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

@@ -0,0 +1,116 @@
+<section class="batch-page">
+  <header class="batch-header">
+    <div>
+      <span class="batch-eyebrow">批量效率</span>
+      <h1>批量生产</h1>
+      <p>使用主题生视频模板串行执行批量任务,真实执行会调用已配置的即梦生成服务;积分扣费仍保持关闭。</p>
+    </div>
+    <button class="btn" type="button" (click)="exportSelected()" [disabled]="!selectedJob()">导出结果</button>
+  </header>
+
+  <div class="batch-message" *ngIf="message()">{{ message() }}</div>
+
+  <div class="batch-layout">
+    <section class="batch-panel">
+      <div class="batch-panel__head">
+        <h2>创建批量任务</h2>
+        <span>{{ topics().length }}/10 条</span>
+      </div>
+
+      <label class="batch-field">
+        批次名称
+        <input type="text" [ngModel]="batchTitle()" (ngModelChange)="batchTitle.set($event)" placeholder="例如:6 月第一周知识短视频">
+      </label>
+
+      <label class="batch-field">
+        主题生视频模板
+        <select [ngModel]="selectedTemplateId()" (ngModelChange)="selectedTemplateId.set($event)">
+          <option value="">不使用模板</option>
+          <option *ngFor="let template of topicTemplates()" [value]="template.id">
+            {{ template.scope === 'system' ? '系统' : '我的' }} · {{ template.name }}
+          </option>
+        </select>
+      </label>
+
+      <label class="batch-field">
+        执行方式
+        <select [ngModel]="executionMode()" (ngModelChange)="executionMode.set($event)">
+          <option value="real">真实生成并归档</option>
+          <option value="queue">仅登记任务</option>
+        </select>
+      </label>
+
+      <label class="batch-field">
+        多行主题
+        <textarea rows="10" [ngModel]="topicText()" (ngModelChange)="topicText.set($event)" placeholder="每行一个主题,最多 10 条"></textarea>
+      </label>
+
+      <div class="batch-imported" *ngIf="importedTopics().length">
+        <div class="batch-imported__head">
+          <strong>已导入选题 {{ importedTopics().length }} 个</strong>
+          <button class="btn" type="button" (click)="clearImportedTopics()">清空导入</button>
+        </div>
+        <span *ngFor="let topic of importedTopics()">{{ topic.title }}</span>
+      </div>
+
+      <div class="batch-estimate">
+        <span>预计单条 {{ estimate().perTask }} 积分</span>
+        <strong>本批次约 {{ estimate().total }} 积分</strong>
+      </div>
+
+      <button class="btn btn-primary" type="button" (click)="createAndRun()" [disabled]="!topics().length || isRunning()">
+        {{ executionMode() === 'real' ? '创建并执行真实生成' : '创建并登记任务' }}
+      </button>
+    </section>
+
+    <section class="batch-panel">
+      <div class="batch-panel__head">
+        <h2>批量任务</h2>
+        <span>{{ jobs().length }} 个批次</span>
+      </div>
+
+      <div class="batch-jobs" *ngIf="jobs().length; else emptyJobs">
+        <button class="batch-job" type="button"
+                *ngFor="let job of jobs(); trackBy: trackByJob"
+                [class.is-active]="selectedJobId() === job.id"
+                (click)="selectJob(job)">
+          <strong>{{ job.title }}</strong>
+          <span>{{ statusLabel(job.status) }} · {{ job.completed }}/{{ job.total }} 完成 · {{ job.failed }} 失败</span>
+          <small>{{ formatTime(job.updatedAt) }}</small>
+        </button>
+      </div>
+
+      <ng-template #emptyJobs>
+        <div class="batch-empty">还没有批量任务。</div>
+      </ng-template>
+    </section>
+  </div>
+
+  <section class="batch-panel batch-detail" *ngIf="selectedJob() as job">
+    <div class="batch-panel__head">
+      <div>
+        <h2>{{ job.title }}</h2>
+        <p>{{ statusLabel(job.status) }} · 预计 {{ job.estimatedCredits }} 积分</p>
+      </div>
+      <div class="batch-actions">
+        <button class="btn" type="button" (click)="run(job)" [disabled]="isRunning() || job.status === 'completed' || job.status === 'cancelled'">继续</button>
+        <button class="btn" type="button" (click)="pause(job)" [disabled]="job.status !== 'running'">暂停</button>
+        <button class="btn btn-warn" type="button" (click)="cancel(job)" [disabled]="job.status === 'completed' || job.status === 'cancelled'">取消</button>
+      </div>
+    </div>
+
+    <div class="batch-items">
+      <article class="batch-item" *ngFor="let item of selectedItems(); trackBy: trackByItem">
+        <div>
+          <span class="batch-item__status" [class.is-failed]="item.status === 'failed'" [class.is-done]="item.status === 'completed'">
+            {{ statusLabel(item.status) }}
+          </span>
+          <h3>{{ item.title }}</h3>
+          <p *ngIf="item.generationTaskId">任务 ID:{{ item.generationTaskId }}</p>
+          <p *ngIf="item.errorMessage" class="batch-error">{{ item.errorMessage }}</p>
+        </div>
+        <button class="btn" type="button" (click)="retry(item)" [disabled]="item.status !== 'failed'">重试</button>
+      </article>
+    </div>
+  </section>
+</section>

+ 312 - 0
src/app/pages/batch-production/batch-production.component.ts

@@ -0,0 +1,312 @@
+import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { BatchJob, BatchJobItem } from '../../models/batch-job.model';
+import { BatchJobService } from '../../services/batch-job.service';
+import { TemplateService } from '../../services/template.service';
+import { CostEstimatorService } from '../../services/cost-estimator.service';
+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';
+
+interface BatchTopicInput {
+  topicId?: string;
+  title: string;
+}
+
+@Component({
+  selector: 'app-batch-production',
+  standalone: true,
+  imports: [CommonModule, FormsModule],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  templateUrl: './batch-production.component.html',
+  styleUrls: ['./batch-production.component.css'],
+})
+export class BatchProductionComponent implements OnInit {
+  private readonly batches = inject(BatchJobService);
+  private readonly templates = inject(TemplateService);
+  private readonly costs = inject(CostEstimatorService);
+  private readonly generationTasks = inject(GenerationTaskService);
+  private readonly sessions = inject(PipelineSessionService);
+  private readonly realRunner = inject(TopicToVideoBatchRunnerService);
+
+  readonly topicText = signal('');
+  readonly batchTitle = signal('');
+  readonly selectedTemplateId = signal('');
+  readonly selectedJobId = signal('');
+  readonly message = signal('');
+  readonly isRunning = signal(false);
+  readonly executionMode = signal<'real' | 'queue'>('real');
+  readonly importedTopics = signal<Array<{ id: string; title: string }>>([]);
+  private readonly refreshTick = signal(0);
+
+  readonly topicTemplates = computed(() => this.templates.listByPipeline('topic-to-video'));
+  readonly jobs = computed(() => {
+    this.refreshTick();
+    return this.batches.listJobs();
+  });
+  readonly selectedJob = computed(() => this.jobs().find((job) => job.id === this.selectedJobId()) || this.jobs()[0] || null);
+  readonly selectedItems = computed(() => this.selectedJob() ? this.batches.listItems(this.selectedJob()!.id) : []);
+
+  readonly topics = computed<BatchTopicInput[]>(() => {
+    const imported = this.importedTopics().map((item) => ({ topicId: item.id, title: item.title }));
+    const typed = this.parseTopics(this.topicText()).map((title) => ({ title }));
+    const seen = new Set<string>();
+    return [...imported, ...typed].filter((item) => {
+      const key = item.title.trim();
+      if (!key || seen.has(key)) return false;
+      seen.add(key);
+      return true;
+    }).slice(0, 10);
+  });
+
+  readonly estimate = computed(() => {
+    const template = this.selectedTemplate();
+    const config = template?.config || {};
+    const scenes = Number(config['nScenes'] || 5);
+    const quality = config['clipQuality'] === '1080p' ? '1080p' : '720p';
+    const secondsPerScene = Number(config['clipFrames'] || 121) >= 241 ? 10 : 5;
+    const one = this.costs.estimateTopicToVideo(scenes, quality, secondsPerScene, true);
+    return {
+      perTask: one.totalCredits,
+      total: one.totalCredits * this.topics().length,
+      lines: one.lines,
+    };
+  });
+
+  async createAndRun(): Promise<void> {
+    const topics = this.topics();
+    if (!topics.length) {
+      this.message.set('请先输入至少一个主题');
+      return;
+    }
+    const template = this.selectedTemplate();
+    try {
+      const job = this.batches.create({
+        title: this.batchTitle().trim() || `批量主题生视频 ${topics.length} 条`,
+        pipelineId: 'topic-to-video',
+        templateId: template?.id,
+        estimatedCredits: this.estimate().total,
+      items: topics.map((topic) => ({
+          topicId: topic.topicId,
+          title: topic.title,
+          input: {
+            topic: topic.title,
+            templateId: template?.id || '',
+            templateConfig: template?.config || {},
+          },
+        })), 
+      });
+      this.refresh();
+      this.selectedJobId.set(job.id);
+      this.message.set(this.executionMode() === 'real'
+        ? '批量任务已创建,正在串行执行真实主题生视频'
+        : '批量任务已创建,正在为每条主题登记独立任务');
+      await this.run(job);
+    } catch (err: any) {
+      this.message.set(err?.message || '创建批量任务失败');
+    }
+  }
+
+  ngOnInit(): void {
+    this.loadImportedTopics();
+  }
+
+  clearImportedTopics(): void {
+    this.importedTopics.set([]);
+    localStorage.removeItem('videoWorkflow.batchProduction.importTopics');
+  }
+
+  async run(job: BatchJob | null = this.selectedJob()): Promise<void> {
+    if (!job || this.isRunning()) return;
+    this.isRunning.set(true);
+    try {
+      const runner = this.executionMode() === 'real'
+        ? (item: BatchJobItem, currentJob: BatchJob) => this.realRunner.run(item, currentJob)
+        : (item: BatchJobItem, currentJob: BatchJob) => this.createQueuedGenerationTask(item, currentJob);
+      await this.batches.run(job.id, runner);
+      this.refresh();
+      this.message.set(this.executionMode() === 'real'
+        ? '真实生成队列已结束,结果已回写任务中心和素材库'
+        : '队列登记已结束,子任务已进入任务中心排队');
+    } catch (err: any) {
+      this.message.set(err?.message || '队列执行失败');
+    } finally {
+      this.isRunning.set(false);
+    }
+  }
+
+  pause(job: BatchJob): void {
+    this.batches.pause(job.id);
+    this.refresh();
+    this.message.set('已暂停:当前子任务完成后不会继续启动新任务');
+  }
+
+  cancel(job: BatchJob): void {
+    this.batches.cancel(job.id);
+    this.refresh();
+    this.message.set('批量任务已取消');
+  }
+
+  retry(item: BatchJobItem): void {
+    this.batches.retryItem(item.id);
+    this.refresh();
+    this.message.set('失败子任务已重新排队');
+  }
+
+  selectJob(job: BatchJob): void {
+    this.selectedJobId.set(job.id);
+  }
+
+  exportSelected(): void {
+    const job = this.selectedJob();
+    if (!job) return;
+    const rows = this.selectedItems().map((item) => ({
+      batchJobId: job.id,
+      title: item.title,
+      status: item.status,
+      resultUrl: item.resultUrl || '',
+      generationTaskId: item.generationTaskId || '',
+      errorMessage: item.errorMessage || '',
+    }));
+    const csv = this.toCsv(rows);
+    const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
+    const url = URL.createObjectURL(blob);
+    const link = document.createElement('a');
+    link.href = url;
+    link.download = `${job.title || 'batch-job'}.csv`;
+    link.click();
+    URL.revokeObjectURL(url);
+  }
+
+  statusLabel(status: string): string {
+    const map: Record<string, string> = {
+      draft: '草稿',
+      queued: '排队中',
+      running: '执行中',
+      paused: '已暂停',
+      completed: '已完成',
+      failed: '有失败',
+      cancelled: '已取消',
+    };
+    return map[status] || status;
+  }
+
+  formatTime(value: string): string {
+    const date = new Date(value);
+    if (Number.isNaN(date.getTime())) return '';
+    const pad = (n: number) => n.toString().padStart(2, '0');
+    return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
+  }
+
+  trackByJob(_: number, job: BatchJob): string {
+    return job.id;
+  }
+
+  trackByItem(_: number, item: BatchJobItem): string {
+    return item.id;
+  }
+
+  private selectedTemplate(): GenerationTemplate | undefined {
+    const id = this.selectedTemplateId();
+    return this.topicTemplates().find((item) => item.id === id);
+  }
+
+  private async createQueuedGenerationTask(item: BatchJobItem, job: BatchJob): Promise<{ generationTaskId: string; resultUrl?: string }> {
+    const input = item.input || {};
+    const templateConfig = (input['templateConfig'] && typeof input['templateConfig'] === 'object')
+      ? input['templateConfig']
+      : {};
+    const task = this.generationTasks.create({
+      title: item.title || input['topic'] || '批量主题生视频',
+      pipelineId: job.pipelineId,
+      operation: 'batch-topic-to-video',
+      estimatedCredits: 0,
+      steps: [
+        { id: 'prepare', label: '整理主题' },
+        { id: 'script', label: '生成脚本' },
+        { id: 'visual', label: '生成画面' },
+        { id: 'compose', label: '合成视频' },
+        { id: 'archive', label: '归档结果' },
+      ],
+      snapshot: {
+        source: 'batch-production',
+        batchJobId: job.id,
+        batchItemId: item.id,
+        topicId: item.topicId,
+        topic: input['topic'] || item.title,
+        templateId: input['templateId'] || job.templateId || '',
+        templateConfig,
+        billing: 'disabled',
+      },
+    });
+    const resultUrl = typeof input['resultUrl'] === 'string' ? input['resultUrl'] : '';
+    const draft = await this.sessions.createArchivedDraft({
+      pipelineId: job.pipelineId,
+      title: item.title || input['topic'] || '批量主题生视频',
+      resultUrl,
+      status: resultUrl ? 'completed' : 'draft',
+      artifactTitle: '批量成片',
+      artifactExtras: {
+        source: 'batch-production',
+        batchJobId: job.id,
+        batchItemId: item.id,
+        generationTaskId: task.id,
+      },
+      snapshot: {
+        source: 'batch-production',
+        batchJobId: job.id,
+        batchItemId: item.id,
+        generationTaskId: task.id,
+        topicId: item.topicId,
+        topic: input['topic'] || item.title,
+        templateId: input['templateId'] || job.templateId || '',
+        templateConfig,
+        billing: 'disabled',
+      },
+    });
+    this.generationTasks.markWaitingExternal(task.id, { draftId: draft.id }, 'script', 1);
+    return { generationTaskId: task.id, resultUrl: resultUrl || undefined };
+  }
+
+  private refresh(): void {
+    this.refreshTick.update((value) => value + 1);
+  }
+
+  private parseTopics(value: string): string[] {
+    return String(value || '')
+      .split(/\r?\n/)
+      .map((item) => item.trim())
+      .filter((item, index, list) => !!item && list.indexOf(item) === index)
+      .slice(0, 10);
+  }
+
+  private loadImportedTopics(): void {
+    try {
+      const raw = localStorage.getItem('videoWorkflow.batchProduction.importTopics');
+      const parsed = raw ? JSON.parse(raw) : [];
+      if (!Array.isArray(parsed)) return;
+      const topics = parsed
+        .map((item: any) => ({
+          id: String(item?.id || '').trim(),
+          title: String(item?.title || item?.angle || '').trim(),
+        }))
+        .filter((item) => item.id && item.title)
+        .slice(0, 10);
+      this.importedTopics.set(topics);
+      if (topics.length) {
+        this.message.set(`已从选题池导入 ${topics.length} 个选题`);
+      }
+    } catch {}
+  }
+
+  private toCsv(rows: Array<Record<string, string>>): string {
+    const headers = ['batchJobId', 'title', 'status', 'resultUrl', 'generationTaskId', 'errorMessage'];
+    const escape = (value: string) => `"${String(value || '').replace(/"/g, '""')}"`;
+    return [
+      headers.join(','),
+      ...rows.map((row) => headers.map((key) => escape(row[key])).join(',')),
+    ].join('\n');
+  }
+}

+ 245 - 0
src/app/pages/daily-report-history/daily-report-history.component.css

@@ -0,0 +1,245 @@
+.daily-report-history-page {
+  min-height: 100%;
+  padding: 28px 32px 48px;
+  color: var(--text-primary);
+}
+
+.daily-report-history-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 20px;
+  margin-bottom: 22px;
+}
+
+.daily-report-history-eyebrow {
+  display: inline-block;
+  margin-bottom: 8px;
+  color: var(--primary-color);
+  font-size: 13px;
+  font-weight: 700;
+}
+
+.daily-report-history-header h1 {
+  margin: 0;
+  font-size: 28px;
+}
+
+.daily-report-history-header p {
+  margin: 8px 0 0;
+  max-width: 720px;
+  color: var(--text-secondary);
+  line-height: 1.7;
+}
+
+.daily-report-stats {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 12px;
+  margin-bottom: 16px;
+}
+
+.report-stat {
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+  padding: 14px;
+}
+
+.report-stat span {
+  display: block;
+  color: var(--text-secondary);
+  font-size: 13px;
+}
+
+.report-stat strong {
+  display: block;
+  margin-top: 8px;
+  font-size: 24px;
+}
+
+.daily-report-toolbar {
+  margin-bottom: 16px;
+}
+
+.report-search {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+  padding: 0 12px;
+}
+
+.report-search input {
+  width: 100%;
+  border: 0;
+  background: transparent;
+  color: var(--text-primary);
+  min-height: 42px;
+  padding: 0 4px;
+  outline: none;
+}
+
+.daily-report-list {
+  display: grid;
+  gap: 14px;
+}
+
+.daily-report-card {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) 140px;
+  gap: 18px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+  padding: 18px;
+}
+
+.report-card-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 16px;
+}
+
+.report-date {
+  display: inline-block;
+  margin-bottom: 8px;
+  color: var(--primary-color);
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.report-card-head h3 {
+  margin: 0;
+  font-size: 18px;
+  line-height: 1.45;
+}
+
+.report-status,
+.report-topics span {
+  display: inline-flex;
+  align-items: center;
+  border-radius: 999px;
+  font-size: 12px;
+  line-height: 1;
+  white-space: nowrap;
+}
+
+.report-status {
+  background: rgba(16, 185, 129, 0.12);
+  color: #047857;
+  padding: 7px 9px;
+}
+
+.report-status.is-failed {
+  background: rgba(239, 68, 68, 0.12);
+  color: #b91c1c;
+}
+
+.report-preview {
+  margin: 10px 0 0;
+  color: var(--text-secondary);
+  line-height: 1.7;
+}
+
+.report-meta {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+  margin-top: 12px;
+  color: var(--text-muted);
+  font-size: 12px;
+}
+
+.report-topics {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-top: 12px;
+}
+
+.report-topics span {
+  background: var(--bg-tertiary);
+  color: var(--text-secondary);
+  padding: 6px 9px;
+}
+
+.report-works {
+  display: grid;
+  gap: 8px;
+  margin-top: 12px;
+}
+
+.report-work {
+  display: grid;
+  gap: 3px;
+  border-left: 3px solid rgba(37, 99, 235, 0.24);
+  padding-left: 10px;
+  color: var(--text-secondary);
+  font-size: 13px;
+}
+
+.report-work strong {
+  color: var(--text-primary);
+}
+
+.report-work span {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.report-card-actions .btn {
+  width: 100%;
+}
+
+.daily-report-empty {
+  display: grid;
+  place-items: center;
+  gap: 10px;
+  min-height: 360px;
+  border: 1px dashed var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+  color: var(--text-secondary);
+  text-align: center;
+}
+
+.daily-report-empty .icon {
+  font-size: 34px;
+  color: var(--primary-color);
+}
+
+.daily-report-empty h3 {
+  margin: 0;
+  color: var(--text-primary);
+}
+
+.daily-report-empty p {
+  margin: 0;
+}
+
+@media (max-width: 900px) {
+  .daily-report-history-page {
+    padding: 18px 16px 32px;
+  }
+
+  .daily-report-history-header,
+  .daily-report-card {
+    display: grid;
+    grid-template-columns: 1fr;
+  }
+
+  .daily-report-stats {
+    grid-template-columns: 1fr 1fr;
+  }
+}
+
+@media (max-width: 560px) {
+  .daily-report-stats {
+    grid-template-columns: 1fr;
+  }
+}

+ 92 - 0
src/app/pages/daily-report-history/daily-report-history.component.html

@@ -0,0 +1,92 @@
+<section class="daily-report-history-page">
+  <header class="daily-report-history-header">
+    <div>
+      <span class="daily-report-history-eyebrow">运营复盘</span>
+      <h1>日报历史</h1>
+      <p>查看已经生成的博主监测日报,重新打开后可以保存选题或进入后续生成流程。</p>
+    </div>
+    <button class="btn" type="button" (click)="refreshFromCloud()" [disabled]="isSyncing()">
+      <span class="icon">refresh</span>
+      {{isSyncing() ? '同步中...' : '同步'}}
+    </button>
+  </header>
+
+  <div class="daily-report-stats">
+    <div class="report-stat">
+      <span>历史日报</span>
+      <strong>{{stats().all}}</strong>
+    </div>
+    <div class="report-stat">
+      <span>沉淀选题</span>
+      <strong>{{stats().topicCount}}</strong>
+    </div>
+    <div class="report-stat">
+      <span>覆盖作品</span>
+      <strong>{{stats().workCount}}</strong>
+    </div>
+    <div class="report-stat">
+      <span>最近日期</span>
+      <strong>{{stats().latestDate}}</strong>
+    </div>
+  </div>
+
+  <div class="daily-report-toolbar">
+    <div class="report-search">
+      <span class="icon">search</span>
+      <input
+        type="text"
+        placeholder="搜索日期、选题、作品或博主"
+        [ngModel]="searchTerm()"
+        (ngModelChange)="searchTerm.set($event)">
+    </div>
+  </div>
+
+  <div class="daily-report-list" *ngIf="filteredReports().length > 0; else emptyState">
+    <article class="daily-report-card" *ngFor="let report of filteredReports(); trackBy: trackByReport">
+      <div class="report-card-main">
+        <div class="report-card-head">
+          <div>
+            <span class="report-date">{{report.date}}</span>
+            <h3>{{report.date}} 博主监测日报</h3>
+          </div>
+          <span class="report-status" [class.is-failed]="report.status === 'failed'">
+            {{report.status === 'success' ? '已生成' : '生成失败'}}
+          </span>
+        </div>
+
+        <p class="report-preview">{{markdownPreview(report) || '暂无日报摘要'}}</p>
+
+        <div class="report-meta">
+          <span>作品 {{workCount(report)}} 条</span>
+          <span>新增 {{newWorkCount(report)}} 条</span>
+          <span>评论样本 {{commentSampleCount(report)}} 条</span>
+          <span>选题 {{report.topTopics.length || 0}} 个</span>
+          <span>更新时间 {{formatTime(report.updatedAt)}}</span>
+        </div>
+
+        <div class="report-topics" *ngIf="report.topTopics.length">
+          <span *ngFor="let topic of report.topTopics.slice(0, 3)">{{topic.title}}</span>
+        </div>
+
+        <div class="report-works" *ngIf="topWorks(report).length">
+          <div class="report-work" *ngFor="let work of topWorks(report)">
+            <strong>{{work.authorName || '监测博主'}}</strong>
+            <span>{{work.desc || work.id || '作品信息'}}</span>
+          </div>
+        </div>
+      </div>
+
+      <div class="report-card-actions">
+        <button class="btn btn-primary" type="button" (click)="open(report)">打开日报</button>
+      </div>
+    </article>
+  </div>
+
+  <ng-template #emptyState>
+    <div class="daily-report-empty">
+      <span class="icon">summarize</span>
+      <h3>暂无日报历史</h3>
+      <p>可以先在博主监测中选择博主并生成监测日报。</p>
+    </div>
+  </ng-template>
+</section>

+ 127 - 0
src/app/pages/daily-report-history/daily-report-history.component.ts

@@ -0,0 +1,127 @@
+import { ChangeDetectionStrategy, Component, EventEmitter, OnInit, Output, computed, inject, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { DailyReport } from '../../models/douyin-insight.model';
+import { DailyReportService } from '../../services/daily-report.service';
+
+@Component({
+  selector: 'app-daily-report-history',
+  standalone: true,
+  imports: [CommonModule, FormsModule],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  templateUrl: './daily-report-history.component.html',
+  styleUrls: ['./daily-report-history.component.css'],
+})
+export class DailyReportHistoryComponent implements OnInit {
+  @Output() reportOpen = new EventEmitter<DailyReport>();
+
+  private readonly dailyReports = inject(DailyReportService);
+
+  readonly reports = signal<DailyReport[]>([]);
+  readonly searchTerm = signal('');
+  readonly isSyncing = signal(false);
+
+  readonly stats = computed(() => {
+    const reports = this.reports();
+    const topicCount = reports.reduce((sum, report) => sum + (report.topTopics?.length || 0), 0);
+    const workCount = reports.reduce((sum, report) => sum + this.workCount(report), 0);
+    return {
+      all: reports.length,
+      topicCount,
+      workCount,
+      latestDate: reports[0]?.date || '-',
+    };
+  });
+
+  readonly filteredReports = computed(() => {
+    const keyword = this.searchTerm().trim().toLowerCase();
+    if (!keyword) return this.reports();
+    return this.reports().filter((report) => {
+      const haystack = [
+        report.date,
+        report.reportMarkdown,
+        ...(report.topTopics || []).flatMap((topic) => [topic.title, topic.angle, ...(topic.tags || [])]),
+        ...this.topWorkTexts(report),
+      ].join(' ').toLowerCase();
+      return haystack.includes(keyword);
+    });
+  });
+
+  ngOnInit(): void {
+    this.refreshLocal();
+    void this.refreshFromCloud();
+  }
+
+  refreshLocal(): void {
+    this.reports.set(this.dailyReports.listReports());
+  }
+
+  async refreshFromCloud(): Promise<void> {
+    this.isSyncing.set(true);
+    try {
+      this.reports.set(await this.dailyReports.refreshReportsFromCloud());
+    } catch (err) {
+      console.warn('[DailyReportHistoryComponent] 日报历史同步失败,继续使用本地数据:', err);
+      this.refreshLocal();
+    } finally {
+      this.isSyncing.set(false);
+    }
+  }
+
+  open(report: DailyReport): void {
+    this.reportOpen.emit(report);
+  }
+
+  formatTime(value: string): string {
+    if (!value) return '';
+    const date = new Date(value);
+    if (Number.isNaN(date.getTime())) return '';
+    const pad = (n: number) => n.toString().padStart(2, '0');
+    return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
+  }
+
+  markdownPreview(report: DailyReport): string {
+    return String(report.reportMarkdown || '')
+      .replace(/^#+\s*/gm, '')
+      .replace(/\n+/g, ' ')
+      .trim()
+      .slice(0, 180);
+  }
+
+  workCount(report: DailyReport): number {
+    const json = this.reportJson(report);
+    return Number(json?.worksCount || 0) || this.topWorks(report).length || report.newWorks?.length || 0;
+  }
+
+  newWorkCount(report: DailyReport): number {
+    const json = this.reportJson(report);
+    return Number(json?.newWorksCount || 0) || report.newWorks?.length || 0;
+  }
+
+  commentSampleCount(report: DailyReport): number {
+    const json = this.reportJson(report);
+    return Number(json?.commentSampleCount || 0);
+  }
+
+  topWorks(report: DailyReport): Array<{ id?: string; desc?: string; authorName?: string }> {
+    const json = this.reportJson(report);
+    const works = Array.isArray(json?.topWorks) ? json.topWorks : report.newWorks;
+    return (works || []).slice(0, 3).map((work: any) => ({
+      id: work?.id ? String(work.id) : '',
+      desc: work?.desc ? String(work.desc) : '',
+      authorName: work?.authorName ? String(work.authorName) : '',
+    }));
+  }
+
+  trackByReport(_: number, report: DailyReport): string {
+    return report.id;
+  }
+
+  private reportJson(report: DailyReport): any {
+    return report.reportJson && typeof report.reportJson === 'object' ? report.reportJson : {};
+  }
+
+  private topWorkTexts(report: DailyReport): string[] {
+    return this.topWorks(report).flatMap((work) => [work.desc || '', work.authorName || '']);
+  }
+}

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

@@ -218,6 +218,7 @@
 }
 
 .t-strip__serial,
+.t-monitor__serial,
 .t-recent__serial {
   font-family: var(--font-serif);
   font-style: italic;
@@ -229,6 +230,7 @@
 }
 
 .t-strip__title,
+.t-monitor__title,
 .t-recent__title {
   font-family: var(--font-display);
   font-size: 20px;
@@ -240,6 +242,7 @@
 }
 
 .t-strip__more,
+.t-monitor__more,
 .t-recent__more {
   border: none;
   background: transparent;
@@ -253,10 +256,98 @@
 }
 
 .t-strip__more:hover,
+.t-monitor__more:hover,
 .t-recent__more:hover {
   color: #0a0e1a;
 }
 
+/* ============================================================
+ * 今日监测摘要(03)
+ * ============================================================ */
+.t-monitor {
+  margin-top: 72px;
+  position: relative;
+}
+
+.t-monitor__head {
+  display: flex;
+  align-items: baseline;
+  gap: 16px;
+  margin-bottom: 20px;
+}
+
+.t-monitor__panel {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr)) minmax(260px, 1.2fr);
+  gap: 1px;
+  border: 1px solid rgba(15, 23, 42, 0.1);
+  background: rgba(15, 23, 42, 0.1);
+  border-radius: 14px;
+  overflow: hidden;
+}
+
+.t-monitor__stat,
+.t-monitor__status {
+  background: #ffffff;
+  min-height: 104px;
+  padding: 22px;
+}
+
+.t-monitor__stat {
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+}
+
+.t-monitor__stat span,
+.t-monitor__status span {
+  font-size: 12px;
+  color: rgba(15, 23, 42, 0.5);
+}
+
+.t-monitor__stat strong {
+  font-family: var(--font-display);
+  font-size: 34px;
+  line-height: 1;
+  color: #0a0e1a;
+}
+
+.t-monitor__stat.has-new strong {
+  color: #16a34a;
+}
+
+.t-monitor__status {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  gap: 8px;
+}
+
+.t-monitor__status strong {
+  font-size: 16px;
+  color: #0a0e1a;
+}
+
+@media (max-width: 960px) {
+  .t-monitor__panel {
+    grid-template-columns: 1fr 1fr;
+  }
+
+  .t-monitor__status {
+    grid-column: 1 / -1;
+  }
+}
+
+@media (max-width: 560px) {
+  .t-monitor__panel {
+    grid-template-columns: 1fr;
+  }
+
+  .t-monitor__status {
+    grid-column: auto;
+  }
+}
+
 /* 卡片网格:4 列等宽 */
 .t-strip__cards {
   display: grid;

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

@@ -85,10 +85,41 @@
     </div>
   </section>
 
+  <!-- ===== 今日监测摘要 ===== -->
+  <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>
+      <h2 class="t-monitor__title">今日监测摘要</h2>
+      <button class="t-monitor__more" type="button" (click)="navigate.emit({ tab: 'monitor' })">
+        查看监测 →
+      </button>
+    </header>
+
+    <div class="t-monitor__panel">
+      <div class="t-monitor__stat">
+        <span>监测博主</span>
+        <strong>{{monitor.authorCount}}</strong>
+      </div>
+      <div class="t-monitor__stat">
+        <span>已加载作品</span>
+        <strong>{{monitor.workCount}}</strong>
+      </div>
+      <div class="t-monitor__stat" [class.has-new]="monitor.newWorkCount > 0">
+        <span>新增作品</span>
+        <strong>{{monitor.newWorkCount}}</strong>
+      </div>
+      <div class="t-monitor__status">
+        <strong>{{monitor.hasDailyReport ? '今日日报已生成' : '等待生成日报'}}</strong>
+        <span *ngIf="monitor.lastCheckedAt">上次刷新 {{monitor.lastCheckedAt | date:'MM-dd HH:mm'}}</span>
+        <span *ngIf="!monitor.lastCheckedAt">添加或刷新博主后会在这里汇总</span>
+      </div>
+    </div>
+  </section>
+
   <!-- ===== 最近作品 ===== -->
   <section class="t-recent" aria-label="最近作品">
     <header class="t-recent__head">
-      <span class="t-recent__serial" aria-hidden="true">03</span>
+      <span class="t-recent__serial" aria-hidden="true">04</span>
       <h2 class="t-recent__title">最近作品</h2>
       <span class="t-recent__count" *ngIf="recentResults.length > 0">
         共 <strong>{{ recentResults.length }}</strong> 条

+ 11 - 0
src/app/pages/home/home.component.ts

@@ -4,6 +4,7 @@ import {
   Component,
   ElementRef,
   EventEmitter,
+  Input,
   OnDestroy,
   OnInit,
   Output,
@@ -35,6 +36,15 @@ interface RecentResult {
   created_at?: string;
 }
 
+interface MonitorSummary {
+  authorCount: number;
+  workCount: number;
+  newWorkCount: number;
+  lastCheckedAt?: string;
+  hasDailyReport?: boolean;
+  dailyReportDate?: string;
+}
+
 /**
  * 首屏组件
  *
@@ -52,6 +62,7 @@ interface RecentResult {
   styleUrls: ['./home.component.css'],
 })
 export class HomeComponent implements OnInit, OnDestroy, AfterViewChecked {
+  @Input() monitorSummary: MonitorSummary | null = null;
   @Output() navigate = new EventEmitter<{ tab: string; payload?: any }>();
   @Output() suggestionPick = new EventEmitter<AssistantSuggestion>();
   /** chatMode 变化(父组件用来隐藏 task-pill / FAB) */

+ 151 - 0
src/app/pages/library/library.component.css

@@ -147,6 +147,31 @@
   min-width: 200px;
 }
 
+.library-bulk {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 8px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--surface-elevated, #fff);
+  padding: 10px;
+  margin: -4px 0 16px;
+}
+.library-bulk strong {
+  color: var(--text-primary);
+  font-size: 13px;
+}
+.library-bulk input {
+  min-height: 34px;
+  min-width: 180px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--surface-color, #fff);
+  color: var(--text-primary);
+  padding: 0 10px;
+}
+
 /* ========= 空态 ========= */
 .library-empty {
   padding: 64px 16px;
@@ -191,6 +216,10 @@
   box-shadow: var(--shadow-md, 0 12px 30px rgba(0, 0, 0, 0.1));
   border-color: color-mix(in srgb, var(--accent-color, #2997ff) 30%, transparent);
 }
+.library-card.is-selected {
+  border-color: var(--accent-color, #2997ff);
+  box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-color, #2997ff) 18%, transparent);
+}
 
 /* 媒体区 */
 .library-card__media {
@@ -239,6 +268,22 @@
 .library-card__type-badge {
   left: 8px;
 }
+.library-select {
+  position: absolute;
+  right: 8px;
+  bottom: 8px;
+  border: 1px solid rgba(255, 255, 255, 0.5);
+  border-radius: 6px;
+  background: rgba(0, 0, 0, 0.52);
+  color: #fff;
+  font-size: 12px;
+  padding: 4px 8px;
+  cursor: pointer;
+}
+.library-select.is-active {
+  background: var(--accent-color, #2997ff);
+  border-color: var(--accent-color, #2997ff);
+}
 
 /* 信息区 */
 .library-card__body {
@@ -273,6 +318,20 @@
   font-size: 11px;
   color: var(--text-muted);
 }
+.library-card__tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 5px;
+  margin-top: 6px;
+}
+.library-card__tags span {
+  border: 1px solid var(--border-color);
+  border-radius: 999px;
+  color: var(--text-secondary);
+  font-size: 11px;
+  line-height: 1;
+  padding: 4px 7px;
+}
 
 /* 操作区 */
 .library-card__actions {
@@ -314,6 +373,84 @@
   color: #fff;
 }
 
+.library-meta {
+  margin-top: 18px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--surface-elevated, #fff);
+  padding: 16px;
+}
+.library-meta__head {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 14px;
+  margin-bottom: 14px;
+}
+.library-meta__head span {
+  color: var(--accent-color, #2997ff);
+  font-size: 12px;
+  font-weight: 700;
+}
+.library-meta__head h2 {
+  margin: 4px 0 0;
+  font-size: 18px;
+}
+.library-meta__actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  justify-content: flex-end;
+}
+.library-meta__grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 12px;
+  margin-bottom: 12px;
+}
+.library-meta__grid label {
+  display: grid;
+  gap: 6px;
+  color: var(--text-secondary);
+  font-size: 12px;
+}
+.library-meta__grid input {
+  min-height: 38px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--surface-color, #fff);
+  color: var(--text-primary);
+  padding: 0 10px;
+}
+.library-meta__source {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-top: 12px;
+}
+.library-meta__source span {
+  border: 1px solid var(--border-color);
+  border-radius: 999px;
+  color: var(--text-secondary);
+  font-size: 12px;
+  padding: 5px 8px;
+}
+.library-meta__params {
+  margin-top: 12px;
+  color: var(--text-secondary);
+  font-size: 13px;
+}
+.library-meta__params pre {
+  max-height: 260px;
+  overflow: auto;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: color-mix(in srgb, var(--text-primary) 4%, transparent);
+  color: var(--text-primary);
+  padding: 12px;
+  white-space: pre-wrap;
+}
+
 @media (max-width: 720px) {
   .library-page { padding-bottom: 20px; }
 
@@ -367,6 +504,11 @@
     font-size: 16px;
   }
 
+  .library-bulk input,
+  .library-bulk .library-action {
+    width: 100%;
+  }
+
   .library-grid {
     grid-template-columns: minmax(0, 1fr);
     gap: 12px;
@@ -397,6 +539,15 @@
     flex-direction: column;
   }
 
+  .library-meta__head,
+  .library-meta__grid {
+    display: block;
+  }
+
+  .library-meta__grid label + label {
+    margin-top: 10px;
+  }
+
   .library-action,
   .library-action--primary {
     width: 100%;

+ 109 - 1
src/app/pages/library/library.component.html

@@ -89,6 +89,60 @@
                (ngModelChange)="searchTerm.set($event)" />
       </div>
     </div>
+
+    <div class="library-filter-row library-filter-row--secondary">
+      <button type="button" class="library-chip"
+              [class.is-active]="stateFilter() === 'active'"
+              (click)="setStateFilter('active')">
+        未归档
+      </button>
+      <button type="button" class="library-chip"
+              [class.is-active]="stateFilter() === 'favorite'"
+              (click)="setStateFilter('favorite')">
+        收藏
+      </button>
+      <button type="button" class="library-chip"
+              [class.is-active]="stateFilter() === 'archived'"
+              (click)="setStateFilter('archived')">
+        已归档
+      </button>
+      <button type="button" class="library-chip"
+              [class.is-active]="stateFilter() === 'all'"
+              (click)="setStateFilter('all')">
+        全部状态
+      </button>
+      <button type="button" class="library-chip"
+              [class.is-active]="projectFilter() === 'all'"
+              (click)="setProjectFilter('all')">
+        全部项目
+      </button>
+      <button type="button" class="library-chip"
+              *ngFor="let project of projectOptions()"
+              [class.is-active]="projectFilter() === project"
+              (click)="setProjectFilter(project)">
+        {{ project }}
+      </button>
+      <button type="button" class="library-chip"
+              [class.is-active]="tagFilter() === 'all'"
+              (click)="setTagFilter('all')">
+        全部标签
+      </button>
+      <button type="button" class="library-chip"
+              *ngFor="let tag of tagOptions()"
+              [class.is-active]="tagFilter() === tag"
+              (click)="setTagFilter(tag)">
+        {{ tag }}
+      </button>
+    </div>
+  </div>
+
+  <div class="library-bulk" *ngIf="selectedKeys().length">
+    <strong>已选择 {{ selectedKeys().length }} 个素材</strong>
+    <input type="text" [ngModel]="bulkProject()" (ngModelChange)="bulkProject.set($event)" placeholder="批量移动到项目">
+    <input type="text" [ngModel]="bulkTags()" (ngModelChange)="bulkTags.set($event)" placeholder="批量添加标签,用逗号分隔">
+    <button class="library-action library-action--primary" type="button" (click)="applyBulkMetadata()">应用</button>
+    <button class="library-action" type="button" (click)="archiveSelected()">归档</button>
+    <button class="library-action" type="button" (click)="clearBulkSelection()">取消选择</button>
   </div>
 
   <!-- 主体网格 -->
@@ -107,7 +161,8 @@
     <article class="library-card"
              *ngFor="let it of filteredItems(); trackBy: trackByKey"
              [attr.data-type]="it.type"
-             (click)="openDraft(it)">
+             [class.is-selected]="selectedItemKey() === it.key"
+             (click)="selectItem(it)">
 
       <!-- 媒体预览 -->
       <div class="library-card__media">
@@ -126,6 +181,11 @@
         <div class="library-card__badge" *ngIf="it.isFinal">
           <span>✓ 成片</span>
         </div>
+        <button class="library-select" type="button"
+                [class.is-active]="isBulkSelected(it)"
+                (click)="toggleSelected(it, $event)">
+          {{ isBulkSelected(it) ? '已选' : '选择' }}
+        </button>
         <div class="library-card__type-badge">
           <span>{{ typeIcon(it.type) }} {{ it.type === 'video' ? '视频' : it.type === 'image' ? '图片' : '音频' }}</span>
         </div>
@@ -139,10 +199,19 @@
           <span>来自:{{ it.draftTitle }}</span>
         </div>
         <div class="library-card__time">{{ formatRelative(it.createdAt) }}</div>
+        <div class="library-card__tags" *ngIf="it.metadata?.tags?.length">
+          <span *ngFor="let tag of it.metadata?.tags">{{ tag }}</span>
+        </div>
       </div>
 
       <!-- 操作 -->
       <div class="library-card__actions" (click)="$event.stopPropagation()">
+        <button class="library-action" type="button" (click)="toggleFavorite(it, $event)" [title]="it.metadata?.favorite ? '取消收藏' : '收藏'">
+          {{ it.metadata?.favorite ? '已收藏' : '收藏' }}
+        </button>
+        <button class="library-action" type="button" (click)="toggleArchived(it, $event)" [title]="it.metadata?.archived ? '取消归档' : '归档'">
+          {{ it.metadata?.archived ? '取消归档' : '归档' }}
+        </button>
         <button class="library-action" type="button" (click)="openExternal(it, $event)" title="新窗口打开">
           <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
             <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
@@ -166,4 +235,43 @@
     </article>
   </div>
 
+  <aside class="library-meta" *ngIf="selectedItem() as selected">
+    <div class="library-meta__head">
+      <div>
+        <span>{{ pipelineLabel(selected.pipelineId) }}</span>
+        <h2>{{ selected.title }}</h2>
+      </div>
+      <div class="library-meta__actions">
+        <button class="library-refresh" type="button" (click)="openRetrospective(selected)">记录发布表现</button>
+        <button class="library-refresh" type="button" (click)="openDraft(selected)">打开创作</button>
+      </div>
+    </div>
+
+    <div class="library-meta__grid">
+      <label>
+        项目
+        <input type="text" [ngModel]="editProject()" (ngModelChange)="editProject.set($event)" placeholder="例如:6月口播矩阵">
+      </label>
+      <label>
+        标签
+        <input type="text" [ngModel]="editTags()" (ngModelChange)="editTags.set($event)" placeholder="用逗号分隔,例如:成片,高转化">
+      </label>
+    </div>
+    <button class="library-action library-action--primary" type="button" (click)="saveMetadata()">保存元数据</button>
+
+    <div class="library-meta__source">
+      <span>素材 Key:{{ selected.key }}</span>
+      <span>Draft:{{ selected.draftId }}</span>
+      <span *ngIf="selected.metadata?.source?.templateId">模板:{{ selected.metadata?.source?.templateId }}</span>
+      <span *ngIf="selected.metadata?.source?.topicId">选题:{{ selected.metadata?.source?.topicId }}</span>
+      <span *ngIf="selected.metadata?.source?.batchJobId">批次:{{ selected.metadata?.source?.batchJobId }}</span>
+      <span *ngIf="selected.metadata?.source?.generationTaskId">任务:{{ selected.metadata?.source?.generationTaskId }}</span>
+    </div>
+
+    <details class="library-meta__params">
+      <summary>生成参数快照</summary>
+      <pre>{{ formatParams(selected.metadata?.parameters) }}</pre>
+    </details>
+  </aside>
+
 </section>

+ 177 - 5
src/app/pages/library/library.component.ts

@@ -13,6 +13,8 @@ import { FormsModule } from '@angular/forms';
 import { PipelineSessionService } from '../../services/pipeline-session.service';
 import { PipelineRoute, PIPELINES } from '../../pipelines/pipeline-registry';
 import { Artifact, PipelineDraft } from '../../models/pipeline-draft.model';
+import { ContentAssetMetadata } from '../../models/content-asset.model';
+import { ContentAssetService } from '../../services/content-asset.service';
 
 /** 素材库的扁平条目(来自 drafts 的成片 + artifacts) */
 interface LibraryItem {
@@ -32,10 +34,12 @@ interface LibraryItem {
   thumbnail?: string;
   /** artifact 原 extras 透传 */
   extras?: Record<string, any>;
+  metadata?: ContentAssetMetadata;
 }
 
 type LibraryFilterType = 'all' | 'video' | 'image' | 'audio';
 type LibraryViewMode = 'final' | 'all';
+type AssetStateFilter = 'active' | 'favorite' | 'archived' | 'all';
 
 /**
  * 素材库 —— 跨 Pipeline 的 AI 生成成果聚合视图。
@@ -57,17 +61,29 @@ type LibraryViewMode = 'final' | 'all';
 export class LibraryComponent implements OnInit {
   /** 父组件订阅:跳到 draft 父详情(我的创作 -> 选中该 draft) */
   @Output() navigateToDraft = new EventEmitter<{ draftId: string; pipelineId: PipelineRoute }>();
+  @Output() navigateToRetrospective = new EventEmitter<{ assetKey: string }>();
 
   readonly session = inject(PipelineSessionService);
+  readonly assetStore = inject(ContentAssetService);
   readonly pipelines = PIPELINES;
 
   readonly drafts = signal<PipelineDraft[]>([]);
+  readonly assetMetadata = signal<ContentAssetMetadata[]>([]);
   readonly loading = signal(true);
 
   readonly viewMode = signal<LibraryViewMode>('final');
   readonly typeFilter = signal<LibraryFilterType>('all');
   readonly pipelineFilter = signal<PipelineRoute | 'all'>('all');
+  readonly projectFilter = signal('all');
+  readonly tagFilter = signal('all');
+  readonly stateFilter = signal<AssetStateFilter>('active');
   readonly searchTerm = signal('');
+  readonly selectedItemKey = signal('');
+  readonly editProject = signal('');
+  readonly editTags = signal('');
+  readonly selectedKeys = signal<string[]>([]);
+  readonly bulkProject = signal('');
+  readonly bulkTags = signal('');
 
   /** 扁平后的条目(响应式) */
   readonly items = computed<LibraryItem[]>(() => {
@@ -78,7 +94,7 @@ export class LibraryComponent implements OnInit {
     for (const d of drafts) {
       // 成片入条目(仅 completed)
       if (d.resultUrl) {
-        out.push({
+        const item: LibraryItem = {
           key: `${d.id}::final`,
           draftId: d.id,
           draftTitle: d.title,
@@ -89,13 +105,15 @@ export class LibraryComponent implements OnInit {
           isFinal: true,
           createdAt: d.updatedAt,
           thumbnail: d.thumbnail,
-        });
+        };
+        item.metadata = this.ensureAssetMetadata(item, d);
+        out.push(item);
       }
       if (mode === 'all') {
         for (const a of d.artifacts || []) {
           // 跳过已作为 finalize 落入的成片记录(避免重复)
           if (a.title === '成片' && d.resultUrl === a.url) continue;
-          out.push({
+          const item: LibraryItem = {
             key: `${d.id}::${a.id}`,
             draftId: d.id,
             draftTitle: d.title,
@@ -107,7 +125,9 @@ export class LibraryComponent implements OnInit {
             createdAt: a.createdAt,
             thumbnail: a.type === 'image' ? a.url : d.thumbnail,
             extras: a.extras,
-          });
+          };
+          item.metadata = this.ensureAssetMetadata(item, d, a);
+          out.push(item);
         }
       }
     }
@@ -120,15 +140,38 @@ export class LibraryComponent implements OnInit {
     const items = this.items();
     const tf = this.typeFilter();
     const pf = this.pipelineFilter();
+    const project = this.projectFilter();
+    const tag = this.tagFilter();
+    const state = this.stateFilter();
     const kw = this.searchTerm().trim().toLowerCase();
     return items.filter((it) => {
       if (tf !== 'all' && it.type !== tf) return false;
       if (pf !== 'all' && it.pipelineId !== pf) return false;
-      if (kw && !(it.title.toLowerCase().includes(kw) || it.draftTitle.toLowerCase().includes(kw))) return false;
+      if (project !== 'all' && (it.metadata?.projectName || '') !== project) return false;
+      if (tag !== 'all' && !(it.metadata?.tags || []).includes(tag)) return false;
+      if (state === 'active' && it.metadata?.archived) return false;
+      if (state === 'favorite' && !it.metadata?.favorite) return false;
+      if (state === 'archived' && !it.metadata?.archived) return false;
+      if (kw && !this.searchableText(it).includes(kw)) return false;
       return true;
     });
   });
 
+  readonly projectOptions = computed(() => {
+    this.assetMetadata();
+    return this.assetStore.listProjects().map((project) => project.name);
+  });
+
+  readonly tagOptions = computed(() => {
+    const tags = this.assetMetadata().flatMap((item) => item.tags || []);
+    return Array.from(new Set(tags)).sort();
+  });
+
+  readonly selectedItem = computed(() => {
+    const key = this.selectedItemKey();
+    return this.filteredItems().find((item) => item.key === key) || this.filteredItems()[0] || null;
+  });
+
   /** 分类计数(受 pipelineFilter / mode 影响) */
   readonly typeCounts = computed(() => {
     const items = this.items();
@@ -151,6 +194,7 @@ export class LibraryComponent implements OnInit {
     try {
       const arr = await this.session.loadAll();
       this.drafts.set(arr);
+      this.assetMetadata.set(this.assetStore.all());
     } finally {
       this.loading.set(false);
     }
@@ -160,6 +204,9 @@ export class LibraryComponent implements OnInit {
   setViewMode(m: LibraryViewMode): void { this.viewMode.set(m); }
   setTypeFilter(t: LibraryFilterType): void { this.typeFilter.set(t); }
   setPipelineFilter(p: PipelineRoute | 'all'): void { this.pipelineFilter.set(p); }
+  setProjectFilter(p: string): void { this.projectFilter.set(p); }
+  setTagFilter(t: string): void { this.tagFilter.set(t); }
+  setStateFilter(s: AssetStateFilter): void { this.stateFilter.set(s); }
 
   // ============== 操作 ==============
   /** 跳到对应 draft 详情 */
@@ -167,6 +214,73 @@ export class LibraryComponent implements OnInit {
     this.navigateToDraft.emit({ draftId: it.draftId, pipelineId: it.pipelineId });
   }
 
+  openRetrospective(it: LibraryItem): void {
+    this.navigateToRetrospective.emit({ assetKey: it.key });
+  }
+
+  selectItem(it: LibraryItem): void {
+    this.selectedItemKey.set(it.key);
+    this.editProject.set(it.metadata?.projectName || '');
+    this.editTags.set((it.metadata?.tags || []).join(', '));
+  }
+
+  saveMetadata(): void {
+    const item = this.selectedItem();
+    if (!item) return;
+    this.assetStore.updateUserFields(item.key, {
+      projectName: this.editProject(),
+      tags: this.editTags().split(/[,,\n]/),
+    });
+    this.assetMetadata.set(this.assetStore.all());
+  }
+
+  toggleFavorite(it: LibraryItem, ev?: Event): void {
+    ev?.stopPropagation();
+    this.assetStore.updateUserFields(it.key, { favorite: !it.metadata?.favorite });
+    this.assetMetadata.set(this.assetStore.all());
+  }
+
+  toggleArchived(it: LibraryItem, ev?: Event): void {
+    ev?.stopPropagation();
+    this.assetStore.updateUserFields(it.key, { archived: !it.metadata?.archived });
+    this.assetMetadata.set(this.assetStore.all());
+  }
+
+  toggleSelected(it: LibraryItem, ev?: Event): void {
+    ev?.stopPropagation();
+    const keys = this.selectedKeys();
+    this.selectedKeys.set(keys.includes(it.key) ? keys.filter((key) => key !== it.key) : [...keys, it.key]);
+  }
+
+  isBulkSelected(it: LibraryItem): boolean {
+    return this.selectedKeys().includes(it.key);
+  }
+
+  clearBulkSelection(): void {
+    this.selectedKeys.set([]);
+    this.bulkProject.set('');
+    this.bulkTags.set('');
+  }
+
+  applyBulkMetadata(): void {
+    const keys = this.selectedKeys();
+    if (!keys.length) return;
+    this.assetStore.updateMany(keys, {
+      projectName: this.bulkProject() || undefined,
+      addTags: this.bulkTags().split(/[,,\n]/),
+    });
+    this.assetMetadata.set(this.assetStore.all());
+    this.clearBulkSelection();
+  }
+
+  archiveSelected(): void {
+    const keys = this.selectedKeys();
+    if (!keys.length) return;
+    this.assetStore.updateMany(keys, { archived: true });
+    this.assetMetadata.set(this.assetStore.all());
+    this.clearBulkSelection();
+  }
+
   /** 打开新窗口播放/查看 */
   openExternal(it: LibraryItem, ev: Event): void {
     ev.stopPropagation();
@@ -226,6 +340,64 @@ export class LibraryComponent implements OnInit {
   trackByKey(_: number, it: LibraryItem): string { return it.key; }
   trackById(_: number, x: { id: string }): string { return x.id; }
 
+  formatParams(value: any): string {
+    if (!value || typeof value !== 'object') return '暂无参数快照';
+    try {
+      return JSON.stringify(value, null, 2);
+    } catch {
+      return '参数快照无法显示';
+    }
+  }
+
+  private ensureAssetMetadata(item: LibraryItem, draft: PipelineDraft, artifact?: Artifact): ContentAssetMetadata {
+    const snapshot = this.safeObject(draft.snapshot);
+    const extras = this.safeObject(artifact?.extras);
+    const parameters = {
+      ...snapshot,
+      ...(Object.keys(extras).length ? { artifactExtras: extras } : {}),
+    };
+    return this.assetStore.ensure({
+      assetKey: item.key,
+      draftId: draft.id,
+      artifactId: artifact?.id,
+      pipelineId: draft.pipelineId,
+      type: item.type,
+      title: item.title,
+      url: item.url,
+      draftTitle: draft.title,
+      isFinal: item.isFinal,
+      parameters,
+      source: {
+        templateId: this.pickString(snapshot, 'templateId'),
+        topicId: this.pickString(snapshot, 'topicId'),
+        batchJobId: this.pickString(snapshot, 'batchJobId'),
+        batchItemId: this.pickString(snapshot, 'batchItemId'),
+        generationTaskId: this.pickString(snapshot, 'generationTaskId'),
+      },
+    });
+  }
+
+  private searchableText(it: LibraryItem): string {
+    return [
+      it.title,
+      it.draftTitle,
+      it.metadata?.projectName,
+      ...(it.metadata?.tags || []),
+      it.metadata?.source?.templateId,
+      it.metadata?.source?.topicId,
+      it.metadata?.source?.batchJobId,
+    ].filter(Boolean).join(' ').toLowerCase();
+  }
+
+  private safeObject(value: any): Record<string, any> {
+    return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
+  }
+
+  private pickString(obj: Record<string, any>, key: string): string | undefined {
+    const value = obj[key];
+    return typeof value === 'string' && value.trim() ? value.trim() : undefined;
+  }
+
   private deriveFilename(it: LibraryItem): string {
     const ext = it.type === 'video' ? '.mp4' : it.type === 'audio' ? '.mp3' : '.jpg';
     const safe = it.title.replace(/[\\/:*?"<>|]/g, '_');

+ 36 - 1
src/app/pages/pipelines/action-transfer/action-transfer.component.ts

@@ -9,6 +9,8 @@ import { QiniuUploadService } from '../../../services/qiniu-upload.service';
 import { SessionBarComponent } from '../../../components/session-bar/session-bar.component';
 import { DraftMeta } from '../../../models/pipeline-draft.model';
 import { userFriendlyError } from '../../../services/user-message.util';
+import { CostEstimatorService } from '../../../services/cost-estimator.service';
+import { GenerationTaskService } from '../../../services/generation-task.service';
 
 interface UploadedAsset {
   url: string;
@@ -60,6 +62,7 @@ export class ActionTransferComponent implements OnInit, OnDestroy {
   errorMsg = '';
 
   private genSub?: Subscription;
+  private generationTaskId = '';
 
   constructor(
     private qiniuUpload: QiniuUploadService,
@@ -68,6 +71,8 @@ export class ActionTransferComponent implements OnInit, OnDestroy {
     private zone: NgZone,
     private results: ResultsService,
     public session: PipelineSessionService,
+    private generationTasks: GenerationTaskService,
+    private costEstimator: CostEstimatorService,
   ) {}
 
   ngOnInit(): void {
@@ -259,6 +264,23 @@ export class ActionTransferComponent implements OnInit, OnDestroy {
     this.errorMsg = '';
     this.resultVideoUrl = '';
     this.resultWorkId = '';
+    const estimate = this.costEstimator.estimateActionTransfer();
+    const task = this.generationTasks.create({
+      title: this.deriveTitle(),
+      pipelineId: 'action-transfer',
+      operation: estimate.operation,
+      estimatedCredits: estimate.totalCredits,
+      costLines: estimate.lines,
+      snapshot: this.toSnapshot(),
+      steps: [
+        { id: 'prepare', label: '准备素材' },
+        { id: 'submit', label: '提交任务' },
+        { id: 'poll', label: '等待生成' },
+        { id: 'archive', label: '归档结果' },
+      ],
+    });
+    this.generationTaskId = task.id;
+    this.generationTasks.markRunning(task.id, 'submit', 5);
 
     this.session.ensureActive('action-transfer', () => this.toSnapshot(), this.deriveTitle());
     this.session.markRunning();
@@ -269,8 +291,18 @@ export class ActionTransferComponent implements OnInit, OnDestroy {
         this.characterImage!.url,
         this.referenceVideo!.url,
         { cutFirstSecond: this.cutFirstSecond },
-        (status, p) => {
+        (status: string, p: number, meta?: Record<string, any>) => {
           this.zone.run(() => {
+            if (meta?.['workId']) {
+              this.generationTasks.markWaitingExternal(
+                this.generationTaskId,
+                { workId: String(meta['workId']), routerName: String(meta['routerName'] || '') },
+                'poll',
+                Math.round(p),
+              );
+            } else {
+              this.generationTasks.markRunning(this.generationTaskId, p >= 15 ? 'poll' : 'submit', Math.round(p));
+            }
             this.statusText = status;
             this.progress = Math.round(p);
             this.cdr.detectChanges();
@@ -289,6 +321,8 @@ export class ActionTransferComponent implements OnInit, OnDestroy {
             const extras = { workId: result.workId, cutFirstSecond: this.cutFirstSecond };
             this.session.patch({ snapshot: this.toSnapshot() });
             this.session.finalize(result.videoUrl, extras);
+            this.generationTasks.markStepCompleted(this.generationTaskId, 'archive', 100);
+            this.generationTasks.markCompleted(this.generationTaskId, result.videoUrl);
             this.results.saveResult({
               type: 'video',
               url: result.videoUrl,
@@ -306,6 +340,7 @@ export class ActionTransferComponent implements OnInit, OnDestroy {
             this.generating = false;
             this.cdr.detectChanges();
             this.session.fail(this.errorMsg);
+            this.generationTasks.markFailed(this.generationTaskId, err, { retryable: true, recoverable: true });
           });
         },
       });

+ 36 - 1
src/app/pages/pipelines/image-to-video/image-to-video.component.ts

@@ -15,6 +15,8 @@ import { QiniuUploadService } from '../../../services/qiniu-upload.service';
 import { SessionBarComponent } from '../../../components/session-bar/session-bar.component';
 import { DraftMeta } from '../../../models/pipeline-draft.model';
 import { userFriendlyError } from '../../../services/user-message.util';
+import { CostEstimatorService } from '../../../services/cost-estimator.service';
+import { GenerationTaskService } from '../../../services/generation-task.service';
 
 interface CameraTemplateOption {
   id: JimengCameraTemplateId;
@@ -114,6 +116,7 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
   ];
 
   private genSub?: Subscription;
+  private generationTaskId = '';
 
   constructor(
     private qiniuUpload: QiniuUploadService,
@@ -122,6 +125,8 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
     private zone: NgZone,
     private results: ResultsService,
     public session: PipelineSessionService,
+    private generationTasks: GenerationTaskService,
+    private costEstimator: CostEstimatorService,
   ) {}
 
   ngOnInit(): void {
@@ -333,6 +338,23 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
     this.errorMsg = '';
     this.resultVideoUrl = '';
     this.resultWorkId = '';
+    const estimate = this.costEstimator.estimateImageToVideo(this.quality, this.costEstimator.framesToSeconds(this.frames));
+    const task = this.generationTasks.create({
+      title: this.deriveTitle(),
+      pipelineId: 'image-to-video',
+      operation: estimate.operation,
+      estimatedCredits: estimate.totalCredits,
+      costLines: estimate.lines,
+      snapshot: this.toSnapshot(),
+      steps: [
+        { id: 'prepare', label: '准备素材' },
+        { id: 'submit', label: '提交任务' },
+        { id: 'poll', label: '等待生成' },
+        { id: 'archive', label: '归档结果' },
+      ],
+    });
+    this.generationTaskId = task.id;
+    this.generationTasks.markRunning(task.id, 'submit', 5);
 
     // 会话:标记运行中
     this.session.ensureActive('image-to-video', () => this.toSnapshot(), this.deriveTitle());
@@ -356,8 +378,18 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
           cameraTemplate: this.mode === '4' ? this.cameraTemplate : undefined,
           cameraStrength: this.mode === '4' ? this.cameraStrength : undefined,
         },
-        (status, progress) => {
+        (status: string, progress: number, meta?: Record<string, any>) => {
           this.zone.run(() => {
+            if (meta?.['workId']) {
+              this.generationTasks.markWaitingExternal(
+                this.generationTaskId,
+                { workId: String(meta['workId']), routerName: String(meta['routerName'] || '') },
+                'poll',
+                Math.round(progress),
+              );
+            } else {
+              this.generationTasks.markRunning(this.generationTaskId, progress >= 15 ? 'poll' : 'submit', Math.round(progress));
+            }
             this.statusText = status;
             this.progress = Math.round(progress);
             this.cdr.detectChanges();
@@ -378,6 +410,8 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
             const extras = { workId: result.workId, mode: this.mode, aspect: this.aspect };
             this.session.patch({ snapshot: this.toSnapshot() });
             this.session.finalize(result.videoUrl, extras);
+            this.generationTasks.markStepCompleted(this.generationTaskId, 'archive', 100);
+            this.generationTasks.markCompleted(this.generationTaskId, result.videoUrl);
             this.results.saveResult({
               type: 'video',
               url: result.videoUrl,
@@ -397,6 +431,7 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
             this.generating = false;
             this.cdr.detectChanges();
             this.session.fail(this.errorMsg);
+            this.generationTasks.markFailed(this.generationTaskId, err, { retryable: true, recoverable: true });
           });
         },
       });

+ 52 - 0
src/app/pages/pipelines/topic-to-video/topic-to-video.component.css

@@ -830,3 +830,55 @@
   color: #6ee7b7;
 }
 .app-container.theme-night .t2v-scene-summary__chip.is-ok strong { color: #6ee7b7; }
+.t2v-template-panel {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) 360px;
+  gap: 14px;
+  margin: 16px 0;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+  padding: 14px;
+}
+
+.t2v-template-panel__title {
+  color: var(--primary-color);
+  font-size: 13px;
+  font-weight: 700;
+  margin-bottom: 8px;
+}
+
+.t2v-template-panel__controls,
+.t2v-template-panel__save {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.t2v-template-panel select {
+  min-height: 40px;
+  flex: 1;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-primary);
+  color: var(--text-primary);
+  padding: 0 12px;
+}
+
+.t2v-template-panel__message {
+  margin: 8px 0 0;
+  color: var(--text-secondary);
+  font-size: 13px;
+}
+
+@media (max-width: 980px) {
+  .t2v-template-panel {
+    grid-template-columns: 1fr;
+  }
+
+  .t2v-template-panel__controls,
+  .t2v-template-panel__save {
+    align-items: stretch;
+    flex-direction: column;
+  }
+}

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

@@ -21,6 +21,31 @@
     </div>
   </header>
 
+  <div class="t2v-template-panel">
+    <div class="t2v-template-panel__main">
+      <div class="t2v-template-panel__title">模板/预设</div>
+      <div class="t2v-template-panel__controls">
+        <select [(ngModel)]="selectedTemplateId" [disabled]="isRunning || hasInflightScene">
+          <option value="">选择主题生视频模板</option>
+          <option *ngFor="let template of topicTemplates" [value]="template.id">
+            {{ template.scope === 'system' ? '系统' : '我的' }} · {{ template.name }}
+          </option>
+        </select>
+        <button class="dh-chip-btn" type="button" [disabled]="!selectedTemplateId || isRunning || hasInflightScene" (click)="applySelectedTemplate()">
+          应用模板
+        </button>
+      </div>
+      <p class="t2v-template-panel__message" *ngIf="templateMessage">{{ templateMessage }}</p>
+    </div>
+
+    <div class="t2v-template-panel__save">
+      <input class="dh-input" type="text" placeholder="模板名称(可选)" [(ngModel)]="templateName" [disabled]="isRunning || hasInflightScene">
+      <button class="dh-chip-btn" type="button" [disabled]="isRunning || hasInflightScene" (click)="saveCurrentAsTemplate()">
+        保存为模板
+      </button>
+    </div>
+  </div>
+
   <!-- 顶部模式切换条(独立横向,使两种模式视觉分明) -->
   <div class="t2v-mode-bar">
     <button class="t2v-mode-bar__btn"

+ 137 - 0
src/app/pages/pipelines/topic-to-video/topic-to-video.component.ts

@@ -20,6 +20,8 @@ import { QiniuUploadService } from '../../../services/qiniu-upload.service';
 import { SessionBarComponent } from '../../../components/session-bar/session-bar.component';
 import { DraftMeta } from '../../../models/pipeline-draft.model';
 import { userFriendlyError } from '../../../services/user-message.util';
+import { TemplateService } from '../../../services/template.service';
+import { GenerationTemplate } from '../../../models/template.model';
 
 interface SceneSegment {
   /** 画面描述(英文 prompt);快速模式由 LLM 生成,手动模式由用户填入 */
@@ -113,6 +115,19 @@ interface T2vSnapshot {
   resultVideoUrl: string;
 }
 
+interface T2vTemplateConfig {
+  nScenes?: 3 | 4 | 5 | 6 | 8;
+  style?: T2vStyle;
+  aspect?: T2vAspect;
+  quickMode?: boolean;
+  mediaMode?: T2vMediaMode;
+  clipFrames?: 121 | 241;
+  clipQuality?: Extract<JimengVideoQuality, '720p' | '1080p'>;
+  continuityEnabled?: boolean;
+  visualBible?: T2vVisualBible;
+  scriptFrame?: string;
+}
+
 /**
  * P4: 主题生视频 Pipeline
  *
@@ -166,6 +181,9 @@ export class TopicToVideoComponent implements OnInit, OnDestroy {
   stageStatus = '';
   scriptError = '';
   errorMsg = '';
+  templateMessage = '';
+  selectedTemplateId = '';
+  templateName = '';
 
   // 进度
   imageProgress = 0;
@@ -214,6 +232,7 @@ export class TopicToVideoComponent implements OnInit, OnDestroy {
     public session: PipelineSessionService,
     private browserFfmpeg: BrowserFfmpegService,
     private qiniu: QiniuUploadService,
+    private templates: TemplateService,
   ) {}
 
   /** 三个 bible 参考图各自的上传中状态,防重复点击、控制禁用 UI */
@@ -315,6 +334,17 @@ export class TopicToVideoComponent implements OnInit, OnDestroy {
       if (prefill) {
         this.topic = prefill;
         sessionStorage.removeItem('t2v.prefillTopic');
+        const templateId = sessionStorage.getItem('t2v.prefillTemplateId') || '';
+        if (templateId) {
+          const template = this.topicTemplates.find((item) => item.id === templateId);
+          if (template) {
+            this.selectedTemplateId = template.id;
+            this.applyTemplateConfig(template.config as T2vTemplateConfig);
+            this.templates.markUsed(template.id);
+            this.templateMessage = `已应用选题池模板:${template.name}`;
+          }
+          sessionStorage.removeItem('t2v.prefillTemplateId');
+        }
         // 预填主题自动启动一个新草稿
         this.session.newDraft('topic-to-video', this.toSnapshot(), this.deriveTitle());
         this.session.patch({ snapshot: this.toSnapshot(), title: this.deriveTitle() });
@@ -322,6 +352,10 @@ export class TopicToVideoComponent implements OnInit, OnDestroy {
     } catch {}
   }
 
+  get topicTemplates(): GenerationTemplate[] {
+    return this.templates.listByPipeline('topic-to-video');
+  }
+
   // ====== Session:snapshot 序列化 ======
   private toSnapshot(): T2vSnapshot {
     return {
@@ -438,6 +472,109 @@ export class TopicToVideoComponent implements OnInit, OnDestroy {
     });
   }
 
+  saveCurrentAsTemplate(): void {
+    const name = (this.templateName || '').trim() || `${this.deriveTitle()} 模板`;
+    try {
+      const template = this.templates.createUserTemplate({
+        pipelineId: 'topic-to-video',
+        name,
+        description: `${this.styleLabel(this.style)} · ${this.aspectLabel(this.aspect)} · ${this.nScenes} 段`,
+        category: '其他',
+        tags: ['主题生视频', this.styleLabel(this.style), this.mediaMode === 'video' ? '动态视频' : '静态图'],
+        config: this.toTemplateConfig(),
+      });
+      this.selectedTemplateId = template.id;
+      this.templateName = '';
+      this.templateMessage = '已保存为我的模板';
+      this.cdr.detectChanges();
+    } catch (err: any) {
+      this.templateMessage = err?.message || '保存模板失败';
+      this.cdr.detectChanges();
+    }
+  }
+
+  applySelectedTemplate(): void {
+    if (!this.selectedTemplateId || this.isRunning || this.hasInflightScene) return;
+    const template = this.topicTemplates.find((item) => item.id === this.selectedTemplateId);
+    if (!template) {
+      this.templateMessage = '未找到选择的模板';
+      return;
+    }
+    this.applyTemplateConfig(template.config as T2vTemplateConfig);
+    this.templates.markUsed(template.id);
+    this.templateMessage = `已应用模板:${template.name}`;
+    this.syncDraft();
+    this.cdr.detectChanges();
+  }
+
+  private toTemplateConfig(): T2vTemplateConfig {
+    return {
+      nScenes: this.nScenes,
+      style: this.style,
+      aspect: this.aspect,
+      quickMode: this.quickMode,
+      mediaMode: this.mediaMode,
+      clipFrames: this.clipFrames,
+      clipQuality: this.clipQuality,
+      continuityEnabled: this.continuityEnabled,
+      visualBible: this.sanitizeTemplateBible(this.visualBible),
+      scriptFrame: this.quickMode
+        ? '主题 -> 连续分镜 -> 首帧生成 -> 可选动态视频 -> 合成成片'
+        : '手动分镜 -> 首尾帧控制 -> 单段图生视频 -> 合成成片',
+    };
+  }
+
+  private applyTemplateConfig(config: T2vTemplateConfig): void {
+    if (!config || typeof config !== 'object') return;
+    if (this.isAllowedSceneCount(config.nScenes)) this.nScenes = config.nScenes;
+    if (this.isAllowedStyle(config.style)) this.style = config.style;
+    if (this.isAllowedAspect(config.aspect)) this.aspect = config.aspect;
+    if (typeof config.quickMode === 'boolean') this.quickMode = config.quickMode;
+    if (config.mediaMode === 'image' || config.mediaMode === 'video') this.mediaMode = config.mediaMode;
+    if (config.clipFrames === 121 || config.clipFrames === 241) this.clipFrames = config.clipFrames;
+    if (config.clipQuality === '720p' || config.clipQuality === '1080p') this.clipQuality = config.clipQuality;
+    if (typeof config.continuityEnabled === 'boolean') this.continuityEnabled = config.continuityEnabled;
+    this.visualBible = {
+      ...this.createEmptyVisualBible(),
+      ...this.sanitizeTemplateBible(config.visualBible || this.createEmptyVisualBible()),
+    };
+  }
+
+  private sanitizeTemplateBible(input: T2vVisualBible): T2vVisualBible {
+    const bible = { ...this.createEmptyVisualBible(), ...(input || {}) };
+    return {
+      ...bible,
+      subjectImageUrl: this.keepReusableUrl(bible.subjectImageUrl),
+      productImageUrl: this.keepReusableUrl(bible.productImageUrl),
+      locationImageUrl: this.keepReusableUrl(bible.locationImageUrl),
+    };
+  }
+
+  private keepReusableUrl(url?: string): string {
+    const value = String(url || '').trim();
+    return /^https?:\/\//i.test(value) ? value : '';
+  }
+
+  private isAllowedSceneCount(value: unknown): value is 3 | 4 | 5 | 6 | 8 {
+    return value === 3 || value === 4 || value === 5 || value === 6 || value === 8;
+  }
+
+  private isAllowedStyle(value: unknown): value is T2vStyle {
+    return this.stylePresets.some((item) => item.value === value);
+  }
+
+  private isAllowedAspect(value: unknown): value is T2vAspect {
+    return this.aspectPresets.some((item) => item.value === value);
+  }
+
+  private styleLabel(value: T2vStyle): string {
+    return this.stylePresets.find((item) => item.value === value)?.label || value;
+  }
+
+  private aspectLabel(value: T2vAspect): string {
+    return this.aspectPresets.find((item) => item.value === value)?.label || value;
+  }
+
   // ====== SessionBar 回调 ======
   onNewSession(): void {
     if (this.isRunning) return;

+ 228 - 0
src/app/pages/retrospective/retrospective.component.css

@@ -0,0 +1,228 @@
+.retro-page {
+  min-height: 100%;
+  padding: 28px 32px 48px;
+  color: var(--text-primary);
+}
+
+.retro-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 18px;
+  margin-bottom: 18px;
+}
+
+.retro-eyebrow {
+  color: var(--primary-color);
+  font-size: 13px;
+  font-weight: 700;
+}
+
+.retro-header h1 {
+  margin: 8px 0;
+  font-size: 28px;
+}
+
+.retro-header p,
+.retro-panel__head span,
+.retro-records span,
+.retro-top span {
+  color: var(--text-secondary);
+}
+
+.retro-message {
+  margin-bottom: 14px;
+  border: 1px solid rgba(37, 99, 235, 0.24);
+  border-radius: 8px;
+  background: rgba(37, 99, 235, 0.08);
+  color: #1d4ed8;
+  padding: 10px 12px;
+}
+
+.retro-summary {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 12px;
+  margin-bottom: 16px;
+}
+
+.retro-summary div,
+.retro-panel {
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+}
+
+.retro-summary div {
+  padding: 14px;
+}
+
+.retro-summary span {
+  display: block;
+  color: var(--text-secondary);
+  font-size: 12px;
+}
+
+.retro-summary strong {
+  display: block;
+  margin-top: 6px;
+  font-size: 22px;
+}
+
+.retro-layout {
+  display: grid;
+  grid-template-columns: minmax(0, 1.05fr) minmax(320px, .95fr);
+  gap: 16px;
+  margin-bottom: 16px;
+}
+
+.retro-panel {
+  padding: 18px;
+}
+
+.retro-panel__head {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 12px;
+  margin-bottom: 14px;
+}
+
+.retro-panel__head h2 {
+  margin: 0;
+  font-size: 18px;
+}
+
+.retro-field {
+  display: grid;
+  gap: 6px;
+  margin-bottom: 12px;
+  color: var(--text-secondary);
+  font-size: 13px;
+}
+
+.retro-field input,
+.retro-field select,
+.retro-field textarea {
+  width: 100%;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-primary);
+  color: var(--text-primary);
+  min-height: 40px;
+  padding: 0 10px;
+}
+
+.retro-field textarea {
+  padding: 10px;
+  resize: vertical;
+}
+
+.retro-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 10px;
+}
+
+.retro-grid--metrics {
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.retro-btn {
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-primary);
+  color: var(--text-primary);
+  min-height: 36px;
+  padding: 0 12px;
+  cursor: pointer;
+}
+
+.retro-btn--primary {
+  width: 100%;
+  border-color: var(--primary-color);
+  background: var(--primary-color);
+  color: #fff;
+}
+
+.retro-filter {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-bottom: 12px;
+}
+
+.retro-filter button {
+  border: 1px solid var(--border-color);
+  border-radius: 999px;
+  background: var(--bg-primary);
+  color: var(--text-secondary);
+  padding: 6px 10px;
+  cursor: pointer;
+}
+
+.retro-filter button.is-active {
+  border-color: var(--primary-color);
+  color: var(--primary-color);
+}
+
+.retro-top,
+.retro-records {
+  display: grid;
+  gap: 10px;
+}
+
+.retro-top article,
+.retro-records article {
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-primary);
+  padding: 12px;
+}
+
+.retro-top strong,
+.retro-records strong,
+.retro-top span,
+.retro-records span {
+  display: block;
+}
+
+.retro-top p,
+.retro-records p {
+  margin: 8px 0;
+  color: var(--text-secondary);
+  line-height: 1.6;
+}
+
+.retro-records article {
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.retro-records__actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.retro-records a {
+  color: var(--primary-color);
+  white-space: nowrap;
+}
+
+@media (max-width: 980px) {
+  .retro-layout,
+  .retro-summary {
+    grid-template-columns: 1fr;
+  }
+
+  .retro-grid,
+  .retro-grid--metrics {
+    grid-template-columns: 1fr;
+  }
+
+  .retro-records article {
+    display: block;
+  }
+}

+ 124 - 0
src/app/pages/retrospective/retrospective.component.html

@@ -0,0 +1,124 @@
+<section class="retro-page">
+  <header class="retro-header">
+    <div>
+      <span class="retro-eyebrow">发布复盘</span>
+      <h1>作品表现复盘</h1>
+      <p>手动记录发布后的平台数据,沉淀评分、归因和下一步选题建议;不连接外部账号。</p>
+    </div>
+  </header>
+
+  <div class="retro-message" *ngIf="message()">{{ message() }}</div>
+
+  <div class="retro-summary">
+    <div><span>记录数</span><strong>{{ records().length }}</strong></div>
+    <div><span>总播放</span><strong>{{ formatNumber(summary().totalViews) }}</strong></div>
+    <div><span>总互动</span><strong>{{ formatNumber(summary().totalEngagement) }}</strong></div>
+    <div><span>平均评分</span><strong>{{ summary().avgScore }}</strong></div>
+  </div>
+
+  <div class="retro-layout">
+    <section class="retro-panel">
+      <div class="retro-panel__head">
+        <h2>新增发布记录</h2>
+        <span>同一作品可记录多个平台</span>
+      </div>
+
+      <label class="retro-field">
+        素材
+        <select [ngModel]="selectedAssetKey()" (ngModelChange)="selectedAssetKey.set($event)">
+          <option value="">选择一个视频素材</option>
+          <option *ngFor="let asset of assetOptions(); trackBy: trackByAsset" [value]="asset.assetKey">
+            {{ asset.title }} · {{ asset.projectName || '未分项目' }}
+          </option>
+        </select>
+      </label>
+
+      <div class="retro-grid">
+        <label class="retro-field">
+          平台
+          <select [ngModel]="platform()" (ngModelChange)="platform.set($event)">
+            <option value="douyin">抖音</option>
+            <option value="xiaohongshu">小红书</option>
+            <option value="bilibili">B站</option>
+            <option value="kuaishou">快手</option>
+            <option value="wechat">视频号</option>
+            <option value="other">其他</option>
+          </select>
+        </label>
+        <label class="retro-field">
+          发布时间
+          <input type="date" [ngModel]="publishedAt()" (ngModelChange)="publishedAt.set($event)">
+        </label>
+      </div>
+
+      <label class="retro-field">
+        发布链接
+        <input type="url" [ngModel]="url()" (ngModelChange)="url.set($event)" placeholder="https://...">
+      </label>
+
+      <div class="retro-grid retro-grid--metrics">
+        <label class="retro-field"><span>播放</span><input type="number" min="0" [ngModel]="views()" (ngModelChange)="views.set($event)"></label>
+        <label class="retro-field"><span>点赞</span><input type="number" min="0" [ngModel]="likes()" (ngModelChange)="likes.set($event)"></label>
+        <label class="retro-field"><span>评论</span><input type="number" min="0" [ngModel]="comments()" (ngModelChange)="comments.set($event)"></label>
+        <label class="retro-field"><span>收藏</span><input type="number" min="0" [ngModel]="favorites()" (ngModelChange)="favorites.set($event)"></label>
+        <label class="retro-field"><span>分享</span><input type="number" min="0" [ngModel]="shares()" (ngModelChange)="shares.set($event)"></label>
+        <label class="retro-field"><span>主观评分</span><input type="number" min="0" max="5" [ngModel]="rating()" (ngModelChange)="rating.set($event)"></label>
+      </div>
+
+      <label class="retro-field">
+        复盘备注
+        <textarea rows="4" [ngModel]="notes()" (ngModelChange)="notes.set($event)" placeholder="记录发布时间、标题版本、评论区反馈或异常波动"></textarea>
+      </label>
+
+      <button class="retro-btn retro-btn--primary" type="button" (click)="saveRecord()">保存复盘</button>
+    </section>
+
+    <section class="retro-panel">
+      <div class="retro-panel__head">
+        <h2>表现筛选</h2>
+      </div>
+      <div class="retro-filter">
+        <button type="button" [class.is-active]="projectFilter() === 'all'" (click)="setProjectFilter('all')">全部项目</button>
+        <button type="button" *ngFor="let project of projectOptions()" [class.is-active]="projectFilter() === project" (click)="setProjectFilter(project)">{{ project }}</button>
+      </div>
+      <div class="retro-filter">
+        <button type="button" [class.is-active]="tagFilter() === 'all'" (click)="setTagFilter('all')">全部标签</button>
+        <button type="button" *ngFor="let tag of tagOptions()" [class.is-active]="tagFilter() === tag" (click)="setTagFilter(tag)">{{ tag }}</button>
+      </div>
+
+      <div class="retro-top">
+        <article *ngFor="let record of topRecords(); trackBy: trackByRecord">
+          <strong>{{ record.assetTitle }}</strong>
+          <span>{{ platformLabel(record.platform) }} · 评分 {{ record.score }} · 播放 {{ formatNumber(record.metrics.views) }}</span>
+          <p>{{ record.diagnosis[0] }}</p>
+          <button class="retro-btn" type="button" (click)="refreshPlatformData(record)" [disabled]="syncingRecordId() === record.id || record.platform !== 'douyin'">
+            {{ syncingRecordId() === record.id ? '抓取中' : '抓取平台数据' }}
+          </button>
+          <button class="retro-btn" type="button" (click)="saveSuggestion(record)">保存建议到选题池</button>
+        </article>
+      </div>
+    </section>
+  </div>
+
+  <section class="retro-panel">
+    <div class="retro-panel__head">
+      <h2>复盘记录</h2>
+      <span>{{ records().length }} 条</span>
+    </div>
+    <div class="retro-records">
+      <article *ngFor="let record of records(); trackBy: trackByRecord">
+        <div>
+          <strong>{{ record.assetTitle }}</strong>
+          <span>{{ platformLabel(record.platform) }} · {{ record.publishedAt }} · 评分 {{ record.score }}</span>
+          <p>{{ record.suggestions[0] }}</p>
+        </div>
+        <div class="retro-records__actions">
+          <button class="retro-btn" type="button" (click)="refreshPlatformData(record)" [disabled]="syncingRecordId() === record.id || record.platform !== 'douyin'">
+            {{ syncingRecordId() === record.id ? '抓取中' : '抓取数据' }}
+          </button>
+          <a *ngIf="record.url" [href]="record.url" target="_blank" rel="noopener">打开链接</a>
+        </div>
+      </article>
+    </div>
+  </section>
+</section>

+ 178 - 0
src/app/pages/retrospective/retrospective.component.ts

@@ -0,0 +1,178 @@
+import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { ContentAssetService } from '../../services/content-asset.service';
+import { RetrospectiveService } from '../../services/retrospective.service';
+import { PublicationDataSyncService } from '../../services/publication-data-sync.service';
+import { TopicPoolService } from '../../services/topic-pool.service';
+import { ContentAssetMetadata } from '../../models/content-asset.model';
+import { PublicationPlatform, PublicationRecord } from '../../models/publication-record.model';
+import { PipelineRoute } from '../../pipelines/pipeline-registry';
+
+@Component({
+  selector: 'app-retrospective',
+  standalone: true,
+  imports: [CommonModule, FormsModule],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  templateUrl: './retrospective.component.html',
+  styleUrls: ['./retrospective.component.css'],
+})
+export class RetrospectiveComponent implements OnInit {
+  private readonly assets = inject(ContentAssetService);
+  private readonly retros = inject(RetrospectiveService);
+  private readonly sync = inject(PublicationDataSyncService);
+  private readonly topics = inject(TopicPoolService);
+
+  readonly refreshTick = signal(0);
+  readonly selectedAssetKey = signal('');
+  readonly platform = signal<PublicationPlatform>('douyin');
+  readonly publishedAt = signal(new Date().toISOString().slice(0, 10));
+  readonly url = signal('');
+  readonly views = signal(0);
+  readonly likes = signal(0);
+  readonly comments = signal(0);
+  readonly favorites = signal(0);
+  readonly shares = signal(0);
+  readonly rating = signal(3);
+  readonly notes = signal('');
+  readonly projectFilter = signal('all');
+  readonly tagFilter = signal('all');
+  readonly message = signal('');
+  readonly syncingRecordId = signal('');
+
+  readonly assetOptions = computed(() => {
+    this.refreshTick();
+    return this.assets.all()
+      .filter((asset) => !asset.archived && asset.type === 'video')
+      .sort((a, b) => b.updatedAt - a.updatedAt);
+  });
+
+  readonly records = computed(() => {
+    this.refreshTick();
+    const project = this.projectFilter();
+    const tag = this.tagFilter();
+    return this.retros.list().filter((record) => {
+      if (project !== 'all' && (record.projectName || '') !== project) return false;
+      if (tag !== 'all' && !(record.tags || []).includes(tag)) return false;
+      return true;
+    });
+  });
+
+  readonly summary = computed(() => this.retros.aggregate(this.records()));
+  readonly topRecords = computed(() => [...this.records()].sort((a, b) => b.score - a.score).slice(0, 5));
+  readonly projectOptions = computed(() => this.assets.listProjects().map((project) => project.name));
+  readonly tagOptions = computed(() => Array.from(new Set(this.assets.all().flatMap((asset) => asset.tags || []))).sort());
+
+  ngOnInit(): void {
+    try {
+      const key = localStorage.getItem('videoWorkflow.retrospective.selectedAssetKey') || '';
+      if (key) {
+        this.selectedAssetKey.set(key);
+        localStorage.removeItem('videoWorkflow.retrospective.selectedAssetKey');
+      }
+    } catch {}
+  }
+
+  saveRecord(): void {
+    const asset = this.selectedAsset();
+    if (!asset) {
+      this.message.set('请先选择一个素材');
+      return;
+    }
+    const record = this.retros.save({
+      assetKey: asset.assetKey,
+      assetTitle: asset.title,
+      assetUrl: asset.url,
+      pipelineId: asset.pipelineId,
+      templateId: asset.source.templateId,
+      projectName: asset.projectName || '',
+      tags: asset.tags || [],
+      platform: this.platform(),
+      publishedAt: this.publishedAt(),
+      url: this.url(),
+      metrics: {
+        views: this.views(),
+        likes: this.likes(),
+        comments: this.comments(),
+        favorites: this.favorites(),
+        shares: this.shares(),
+      },
+      rating: this.rating(),
+      notes: this.notes(),
+    });
+    this.refresh();
+    this.message.set(`已保存复盘记录,表现评分 ${record.score}`);
+  }
+
+  saveSuggestion(record: PublicationRecord): void {
+    const title = record.suggestions[0] || `复刻 ${record.assetTitle}`;
+    this.topics.saveTopic({
+      title,
+      angle: record.diagnosis[0] || '从已发布作品复盘中延展新角度',
+      sourceType: 'manual',
+      sourceVideoIds: [record.assetKey],
+      sourceAuthorIds: [],
+      tags: ['复盘建议', ...(record.tags || [])],
+      audience: '',
+      hook: title,
+      outline: record.suggestions.join('\n'),
+      status: 'idea',
+      pipelineTarget: 'topic_video',
+    });
+    this.message.set('已保存为选题池候选选题');
+  }
+
+  async refreshPlatformData(record: PublicationRecord): Promise<void> {
+    if (this.syncingRecordId()) return;
+    this.syncingRecordId.set(record.id);
+    try {
+      const updated = await this.sync.refresh(record);
+      this.refresh();
+      this.message.set(`已抓取平台数据,最新评分 ${updated.score}`);
+    } catch (err: any) {
+      this.message.set(err?.message || '平台数据抓取失败');
+    } finally {
+      this.syncingRecordId.set('');
+    }
+  }
+
+  setProjectFilter(value: string): void {
+    this.projectFilter.set(value);
+  }
+
+  setTagFilter(value: string): void {
+    this.tagFilter.set(value);
+  }
+
+  platformLabel(platform: PublicationPlatform): string {
+    const map: Record<PublicationPlatform, string> = {
+      douyin: '抖音',
+      xiaohongshu: '小红书',
+      bilibili: 'B站',
+      kuaishou: '快手',
+      wechat: '视频号',
+      other: '其他',
+    };
+    return map[platform] || platform;
+  }
+
+  formatNumber(value: number): string {
+    return Math.max(0, Number(value || 0)).toLocaleString('zh-CN');
+  }
+
+  trackByAsset(_: number, asset: ContentAssetMetadata): string {
+    return asset.assetKey;
+  }
+
+  trackByRecord(_: number, record: PublicationRecord): string {
+    return record.id;
+  }
+
+  private selectedAsset(): ContentAssetMetadata | undefined {
+    return this.assets.getByAssetKey(this.selectedAssetKey()) || this.assetOptions()[0];
+  }
+
+  private refresh(): void {
+    this.refreshTick.update((value) => value + 1);
+  }
+}

+ 272 - 0
src/app/pages/templates/templates.component.css

@@ -0,0 +1,272 @@
+.templates-page {
+  min-height: 100%;
+  padding: 28px 32px 48px;
+  color: var(--text-primary);
+}
+
+.templates-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 20px;
+  margin-bottom: 22px;
+}
+
+.templates-eyebrow {
+  display: inline-block;
+  margin-bottom: 8px;
+  color: var(--primary-color);
+  font-size: 13px;
+  font-weight: 700;
+}
+
+.templates-header h1 {
+  margin: 0;
+  font-size: 28px;
+}
+
+.templates-header p {
+  margin: 8px 0 0;
+  max-width: 720px;
+  color: var(--text-secondary);
+  line-height: 1.7;
+}
+
+.templates-message {
+  margin-bottom: 14px;
+  border: 1px solid rgba(37, 99, 235, 0.24);
+  border-radius: 8px;
+  background: rgba(37, 99, 235, 0.08);
+  color: #1d4ed8;
+  padding: 10px 12px;
+}
+
+.templates-stats {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 12px;
+  margin-bottom: 16px;
+}
+
+.template-stat {
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+  padding: 14px;
+  text-align: left;
+  cursor: pointer;
+  transition: border-color .2s ease, transform .2s ease, box-shadow .2s ease;
+}
+
+.template-stat:hover,
+.template-stat.is-active {
+  border-color: var(--primary-color);
+  box-shadow: 0 10px 24px rgba(37, 99, 235, 0.12);
+  transform: translateY(-1px);
+}
+
+.template-stat span {
+  display: block;
+  color: var(--text-secondary);
+  font-size: 13px;
+}
+
+.template-stat strong {
+  display: block;
+  margin-top: 8px;
+  font-size: 24px;
+  color: var(--text-primary);
+}
+
+.templates-toolbar {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) 160px 190px 160px;
+  gap: 12px;
+  margin-bottom: 16px;
+}
+
+.template-search {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+  padding: 0 12px;
+}
+
+.template-search input,
+.templates-toolbar select,
+.template-edit-form input,
+.template-edit-form textarea,
+.template-edit-form select {
+  width: 100%;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-primary);
+  color: var(--text-primary);
+  min-height: 42px;
+  padding: 0 12px;
+}
+
+.template-search input {
+  border: 0;
+  background: transparent;
+  outline: none;
+}
+
+.templates-list {
+  display: grid;
+  gap: 14px;
+}
+
+.template-card {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) 190px;
+  gap: 18px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+  padding: 18px;
+}
+
+.template-card-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 16px;
+}
+
+.template-source,
+.template-status,
+.template-tag {
+  display: inline-flex;
+  align-items: center;
+  border-radius: 999px;
+  font-size: 12px;
+  line-height: 1;
+  white-space: nowrap;
+}
+
+.template-source {
+  color: var(--primary-color);
+  font-weight: 700;
+  margin-bottom: 8px;
+}
+
+.template-status {
+  background: rgba(37, 99, 235, 0.1);
+  color: #1d4ed8;
+  padding: 7px 9px;
+}
+
+.template-card h3 {
+  margin: 0;
+  font-size: 18px;
+}
+
+.template-desc {
+  margin: 10px 0 0;
+  color: var(--text-secondary);
+  line-height: 1.7;
+}
+
+.template-config {
+  margin-top: 12px;
+  border-left: 3px solid var(--primary-color);
+  padding-left: 12px;
+}
+
+.template-config strong {
+  font-size: 13px;
+}
+
+.template-config pre {
+  max-height: 140px;
+  overflow: auto;
+  margin: 8px 0 0;
+  border-radius: 8px;
+  background: var(--bg-primary);
+  color: var(--text-secondary);
+  padding: 10px;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+
+.template-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-top: 12px;
+}
+
+.template-tag {
+  background: var(--bg-primary);
+  border: 1px solid var(--border-color);
+  color: var(--text-secondary);
+  padding: 6px 8px;
+}
+
+.template-meta {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+  margin-top: 12px;
+  color: var(--text-muted);
+  font-size: 12px;
+}
+
+.template-actions,
+.template-edit-actions {
+  display: grid;
+  align-content: start;
+  gap: 10px;
+}
+
+.template-edit-form {
+  display: grid;
+  gap: 10px;
+  margin-top: 12px;
+}
+
+.template-edit-form label {
+  display: grid;
+  gap: 6px;
+  color: var(--text-secondary);
+  font-size: 13px;
+}
+
+.template-edit-form textarea {
+  min-height: 80px;
+  padding: 10px 12px;
+  resize: vertical;
+}
+
+.templates-empty {
+  border: 1px dashed var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+  padding: 36px;
+  text-align: center;
+}
+
+.templates-empty h3 {
+  margin: 0;
+}
+
+.templates-empty p {
+  margin: 8px 0 0;
+  color: var(--text-secondary);
+}
+
+@media (max-width: 980px) {
+  .templates-stats,
+  .templates-toolbar,
+  .template-card {
+    grid-template-columns: 1fr;
+  }
+
+  .templates-header {
+    display: block;
+  }
+}

+ 128 - 0
src/app/pages/templates/templates.component.html

@@ -0,0 +1,128 @@
+<section class="templates-page">
+  <header class="templates-header">
+    <div>
+      <span class="templates-eyebrow">效率资产</span>
+      <h1>模板库</h1>
+      <p>保存常用生成配置,后续用于批量生产、资产追溯和作品复盘。</p>
+    </div>
+    <button class="btn" type="button" (click)="refresh()">刷新</button>
+  </header>
+
+  <div class="templates-message" *ngIf="message()">{{ message() }}</div>
+
+  <div class="templates-stats">
+    <button class="template-stat" type="button" [class.is-active]="scopeFilter() === 'all'" (click)="setScopeFilter('all')">
+      <span>全部模板</span>
+      <strong>{{ stats().all }}</strong>
+    </button>
+    <button class="template-stat" type="button" [class.is-active]="scopeFilter() === 'system'" (click)="setScopeFilter('system')">
+      <span>系统模板</span>
+      <strong>{{ stats().system }}</strong>
+    </button>
+    <button class="template-stat" type="button" [class.is-active]="scopeFilter() === 'user'" (click)="setScopeFilter('user')">
+      <span>我的模板</span>
+      <strong>{{ stats().user }}</strong>
+    </button>
+    <button class="template-stat" type="button" [class.is-active]="pipelineFilter() === 'topic-to-video'" (click)="pipelineFilter.set('topic-to-video')">
+      <span>主题生视频</span>
+      <strong>{{ stats().topic }}</strong>
+    </button>
+  </div>
+
+  <div class="templates-toolbar">
+    <div class="template-search">
+      <span class="icon">search</span>
+      <input
+        type="text"
+        placeholder="搜索模板名称、分类、标签"
+        [ngModel]="searchTerm()"
+        (ngModelChange)="searchTerm.set($event)">
+    </div>
+
+    <select [ngModel]="scopeFilter()" (ngModelChange)="scopeFilter.set($event)">
+      <option value="all">全部来源</option>
+      <option value="system">系统模板</option>
+      <option value="user">我的模板</option>
+    </select>
+
+    <select [ngModel]="pipelineFilter()" (ngModelChange)="pipelineFilter.set($event)">
+      <option value="all">全部 Pipeline</option>
+      <option *ngFor="let pipeline of pipelines" [value]="pipeline.route">{{ pipeline.displayName }}</option>
+    </select>
+
+    <select [ngModel]="categoryFilter()" (ngModelChange)="categoryFilter.set($event)">
+      <option value="all">全部分类</option>
+      <option *ngFor="let category of categories" [value]="category">{{ category }}</option>
+    </select>
+  </div>
+
+  <div class="templates-list" *ngIf="filteredTemplates().length > 0; else emptyState">
+    <article class="template-card" *ngFor="let template of filteredTemplates(); trackBy: trackByTemplate">
+      <div class="template-card-main">
+        <div class="template-card-head">
+          <div>
+            <span class="template-source">{{ scopeLabel(template.scope) }}</span>
+            <h3>{{ template.name }}</h3>
+          </div>
+          <span class="template-status">{{ pipelineLabel(template.pipelineId) }}</span>
+        </div>
+
+        <p class="template-desc">{{ template.description || '未填写模板说明' }}</p>
+
+        <div class="template-edit-form" *ngIf="editingId() === template.id">
+          <label>
+            名称
+            <input type="text" [ngModel]="editDraft().name" (ngModelChange)="updateEditDraft({ name: $event })">
+          </label>
+          <label>
+            说明
+            <textarea rows="3" [ngModel]="editDraft().description" (ngModelChange)="updateEditDraft({ description: $event })"></textarea>
+          </label>
+          <label>
+            分类
+            <select [ngModel]="editDraft().category" (ngModelChange)="updateEditDraft({ category: $event })">
+              <option *ngFor="let category of categories" [value]="category">{{ category }}</option>
+            </select>
+          </label>
+          <label>
+            标签
+            <input type="text" placeholder="用逗号分隔" [ngModel]="editDraft().tagsText" (ngModelChange)="updateEditDraft({ tagsText: $event })">
+          </label>
+          <div class="template-edit-actions">
+            <button class="btn btn-primary" type="button" (click)="saveEdit(template)">保存修改</button>
+            <button class="btn" type="button" (click)="cancelEdit()">取消</button>
+          </div>
+        </div>
+
+        <div class="template-config">
+          <strong>配置摘要</strong>
+          <pre>{{ template.config | json }}</pre>
+        </div>
+
+        <div class="template-tags">
+          <span class="template-tag">{{ template.category }}</span>
+          <span class="template-tag" *ngFor="let tag of template.tags">{{ tag }}</span>
+        </div>
+
+        <div class="template-meta">
+          <span>使用 {{ template.usageCount || 0 }} 次</span>
+          <span>更新 {{ formatTime(template.updatedAt) }}</span>
+        </div>
+      </div>
+
+      <div class="template-actions">
+        <button class="btn btn-primary" type="button" (click)="markUsed(template)">使用模板</button>
+        <button class="btn" type="button" (click)="cloneTemplate(template)">复制到我的模板</button>
+        <button class="btn" type="button" (click)="startEdit(template)" [disabled]="template.scope !== 'user'">编辑</button>
+        <button class="btn btn-warn" type="button" (click)="deleteTemplate(template)" [disabled]="template.scope !== 'user'">删除</button>
+      </div>
+    </article>
+  </div>
+
+  <ng-template #emptyState>
+    <div class="templates-empty">
+      <h3>还没有匹配的模板</h3>
+      <p>可以调整筛选条件,或先从系统模板复制一份作为你的常用模板。</p>
+    </div>
+  </ng-template>
+</section>

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

@@ -0,0 +1,180 @@
+import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { PIPELINES, PipelineRoute } from '../../pipelines/pipeline-registry';
+import { GenerationTemplate, TemplateCategory, TemplateScope } from '../../models/template.model';
+import { TemplateService } from '../../services/template.service';
+import { AuthCreditService } from '../../services/auth-credit.service';
+
+type ScopeFilter = TemplateScope | 'all';
+type PipelineFilter = PipelineRoute | 'all';
+type CategoryFilter = TemplateCategory | 'all';
+
+interface TemplateEditDraft {
+  name: string;
+  description: string;
+  category: TemplateCategory;
+  tagsText: string;
+}
+
+@Component({
+  selector: 'app-templates',
+  standalone: true,
+  imports: [CommonModule, FormsModule],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  templateUrl: './templates.component.html',
+  styleUrls: ['./templates.component.css'],
+})
+export class TemplatesComponent implements OnInit {
+  private readonly templatesService = inject(TemplateService);
+  readonly auth = inject(AuthCreditService);
+  readonly pipelines = PIPELINES;
+  readonly categories: TemplateCategory[] = ['口播', '产品种草', '知识科普', '影视感', '带货', '其他'];
+
+  readonly templates = signal<GenerationTemplate[]>([]);
+  readonly searchTerm = signal('');
+  readonly scopeFilter = signal<ScopeFilter>('all');
+  readonly pipelineFilter = signal<PipelineFilter>('all');
+  readonly categoryFilter = signal<CategoryFilter>('all');
+  readonly editingId = signal('');
+  readonly message = signal('');
+  readonly editDraft = signal<TemplateEditDraft>({
+    name: '',
+    description: '',
+    category: '其他',
+    tagsText: '',
+  });
+
+  readonly stats = computed(() => {
+    const templates = this.templates();
+    return {
+      all: templates.length,
+      system: templates.filter((item) => item.scope === 'system').length,
+      user: templates.filter((item) => item.scope === 'user').length,
+      topic: templates.filter((item) => item.pipelineId === 'topic-to-video').length,
+    };
+  });
+
+  readonly filteredTemplates = computed(() => {
+    const keyword = this.searchTerm().trim().toLowerCase();
+    const scope = this.scopeFilter();
+    const pipeline = this.pipelineFilter();
+    const category = this.categoryFilter();
+    return this.templates().filter((item) => {
+      if (scope !== 'all' && item.scope !== scope) return false;
+      if (pipeline !== 'all' && item.pipelineId !== pipeline) return false;
+      if (category !== 'all' && item.category !== category) return false;
+      if (!keyword) return true;
+      const haystack = [
+        item.name,
+        item.description || '',
+        item.category,
+        this.pipelineLabel(item.pipelineId),
+        ...(item.tags || []),
+      ].join(' ').toLowerCase();
+      return haystack.includes(keyword);
+    });
+  });
+
+  ngOnInit(): void {
+    this.refresh();
+  }
+
+  refresh(): void {
+    this.templates.set(this.templatesService.listTemplates());
+  }
+
+  setScopeFilter(value: ScopeFilter): void {
+    this.scopeFilter.set(value);
+  }
+
+  cloneTemplate(template: GenerationTemplate): void {
+    try {
+      this.templatesService.cloneToUser(template.id);
+      this.message.set('已复制到我的模板');
+      this.refresh();
+    } catch (err: any) {
+      this.message.set(err?.message || '复制模板失败');
+    }
+  }
+
+  startEdit(template: GenerationTemplate): void {
+    if (template.scope !== 'user') return;
+    this.editingId.set(template.id);
+    this.editDraft.set({
+      name: template.name,
+      description: template.description || '',
+      category: template.category,
+      tagsText: (template.tags || []).join(','),
+    });
+  }
+
+  cancelEdit(): void {
+    this.editingId.set('');
+  }
+
+  updateEditDraft(patch: Partial<TemplateEditDraft>): void {
+    this.editDraft.set({ ...this.editDraft(), ...patch });
+  }
+
+  saveEdit(template: GenerationTemplate): void {
+    const draft = this.editDraft();
+    try {
+      this.templatesService.updateUserTemplate(template.id, {
+        name: draft.name.trim() || template.name,
+        description: draft.description.trim(),
+        category: draft.category,
+        tags: this.parseTags(draft.tagsText),
+      });
+      this.editingId.set('');
+      this.message.set('模板已更新');
+      this.refresh();
+    } catch (err: any) {
+      this.message.set(err?.message || '保存模板失败');
+    }
+  }
+
+  deleteTemplate(template: GenerationTemplate): void {
+    if (template.scope !== 'user') return;
+    try {
+      this.templatesService.deleteUserTemplate(template.id);
+      this.message.set('模板已删除');
+      this.refresh();
+    } catch (err: any) {
+      this.message.set(err?.message || '删除模板失败');
+    }
+  }
+
+  markUsed(template: GenerationTemplate): void {
+    this.templatesService.markUsed(template.id);
+    this.message.set('已记录一次模板使用,后续将接入 Pipeline 应用');
+    this.refresh();
+  }
+
+  pipelineLabel(route: PipelineRoute): string {
+    return this.pipelines.find((item) => item.route === route)?.displayName || route;
+  }
+
+  scopeLabel(scope: TemplateScope): string {
+    return scope === 'system' ? '系统模板' : '我的模板';
+  }
+
+  formatTime(value: string): string {
+    const date = new Date(value);
+    if (Number.isNaN(date.getTime())) return '';
+    const pad = (n: number) => n.toString().padStart(2, '0');
+    return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
+  }
+
+  trackByTemplate(_: number, template: GenerationTemplate): string {
+    return template.id;
+  }
+
+  private parseTags(value: string): string[] {
+    return String(value || '')
+      .split(/[,,\s]+/)
+      .map((item) => item.trim())
+      .filter((item, index, list) => !!item && list.indexOf(item) === index)
+      .slice(0, 12);
+  }
+}

+ 316 - 0
src/app/pages/topic-pool/topic-pool.component.css

@@ -0,0 +1,316 @@
+.topic-pool-page {
+  min-height: 100%;
+  padding: 28px 32px 48px;
+  color: var(--text-primary);
+}
+
+.topic-pool-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 20px;
+  margin-bottom: 22px;
+}
+
+.topic-pool-eyebrow {
+  display: inline-block;
+  margin-bottom: 8px;
+  color: var(--primary-color);
+  font-size: 13px;
+  font-weight: 700;
+}
+
+.topic-pool-header h1 {
+  margin: 0;
+  font-size: 28px;
+}
+
+.topic-pool-header p {
+  margin: 8px 0 0;
+  max-width: 720px;
+  color: var(--text-secondary);
+  line-height: 1.7;
+}
+
+.topic-header-actions {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+  gap: 10px;
+}
+
+.topic-select-row {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 8px;
+}
+
+.topic-select-row input {
+  width: 16px;
+  height: 16px;
+}
+
+.topic-pool-stats {
+  display: grid;
+  grid-template-columns: repeat(5, minmax(0, 1fr));
+  gap: 12px;
+  margin-bottom: 16px;
+}
+
+.topic-stat {
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+  padding: 14px;
+  text-align: left;
+  cursor: pointer;
+  transition: border-color .2s ease, transform .2s ease, box-shadow .2s ease;
+}
+
+.topic-stat:hover,
+.topic-stat.is-active {
+  border-color: var(--primary-color);
+  box-shadow: 0 10px 24px rgba(37, 99, 235, 0.12);
+  transform: translateY(-1px);
+}
+
+.topic-stat span {
+  display: block;
+  color: var(--text-secondary);
+  font-size: 13px;
+}
+
+.topic-stat strong {
+  display: block;
+  margin-top: 8px;
+  font-size: 24px;
+  color: var(--text-primary);
+}
+
+.topic-pool-toolbar {
+  display: grid;
+  grid-template-columns: 1fr 180px 180px;
+  gap: 12px;
+  margin-bottom: 16px;
+}
+
+.topic-search {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+  padding: 0 12px;
+}
+
+.topic-search input,
+.topic-pool-toolbar select,
+.topic-card-actions select {
+  width: 100%;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-primary);
+  color: var(--text-primary);
+  min-height: 42px;
+  padding: 0 12px;
+}
+
+.topic-search input {
+  border: 0;
+  background: transparent;
+  outline: none;
+}
+
+.topic-list {
+  display: grid;
+  gap: 14px;
+}
+
+.topic-card {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) 180px;
+  gap: 18px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+  padding: 18px;
+}
+
+.topic-card-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 16px;
+}
+
+.topic-source,
+.topic-status,
+.topic-tag {
+  display: inline-flex;
+  align-items: center;
+  border-radius: 999px;
+  font-size: 12px;
+  line-height: 1;
+  white-space: nowrap;
+}
+
+.topic-source {
+  color: var(--primary-color);
+  font-weight: 700;
+}
+
+.topic-status {
+  background: rgba(37, 99, 235, 0.1);
+  color: #1d4ed8;
+  padding: 7px 9px;
+}
+
+.topic-card h3 {
+  margin: 0;
+  font-size: 18px;
+}
+
+.topic-angle {
+  margin: 10px 0 0;
+  color: var(--text-secondary);
+  line-height: 1.7;
+}
+
+.topic-outline {
+  margin-top: 12px;
+  border-left: 3px solid var(--primary-color);
+  padding-left: 12px;
+}
+
+.topic-outline strong {
+  font-size: 13px;
+}
+
+.topic-outline p {
+  margin: 6px 0 0;
+  color: var(--text-secondary);
+  line-height: 1.7;
+}
+
+.topic-edit-form {
+  display: grid;
+  gap: 12px;
+  margin-top: 14px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-primary);
+  padding: 14px;
+}
+
+.topic-edit-form label {
+  display: grid;
+  gap: 6px;
+  color: var(--text-secondary);
+  font-size: 13px;
+  font-weight: 600;
+}
+
+.topic-edit-form input,
+.topic-edit-form textarea {
+  width: 100%;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+  color: var(--text-primary);
+  padding: 10px 12px;
+  outline: none;
+  resize: vertical;
+}
+
+.topic-edit-form input:focus,
+.topic-edit-form textarea:focus {
+  border-color: var(--primary-color);
+  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
+}
+
+.topic-edit-actions {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+}
+
+.topic-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-top: 12px;
+}
+
+.topic-tag {
+  background: var(--bg-tertiary);
+  color: var(--text-secondary);
+  padding: 6px 9px;
+}
+
+.topic-meta {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+  margin-top: 12px;
+  color: var(--text-muted);
+  font-size: 12px;
+}
+
+.topic-card-actions {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.topic-card-actions .btn,
+.topic-card-actions select {
+  width: 100%;
+}
+
+.topic-empty {
+  display: grid;
+  place-items: center;
+  gap: 10px;
+  min-height: 360px;
+  border: 1px dashed var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+  text-align: center;
+  color: var(--text-secondary);
+}
+
+.topic-empty .icon {
+  font-size: 36px;
+  color: var(--primary-color);
+}
+
+.topic-empty h3 {
+  margin: 0;
+  color: var(--text-primary);
+}
+
+.topic-empty p {
+  margin: 0;
+}
+
+@media (max-width: 900px) {
+  .topic-pool-page {
+    padding: 20px 16px 36px;
+  }
+
+  .topic-pool-header,
+  .topic-card-head {
+    flex-direction: column;
+  }
+
+  .topic-pool-stats {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .topic-pool-toolbar,
+  .topic-card {
+    grid-template-columns: 1fr;
+  }
+}

+ 169 - 0
src/app/pages/topic-pool/topic-pool.component.html

@@ -0,0 +1,169 @@
+<section class="topic-pool-page">
+  <header class="topic-pool-header">
+    <div>
+      <span class="topic-pool-eyebrow">内容机会</span>
+      <h1>选题池</h1>
+      <p>沉淀爆款分析、日报和 AI 助手产生的选题,整理后进入生成流程。</p>
+    </div>
+    <div class="topic-header-actions">
+      <button class="btn btn-primary" type="button" (click)="sendSelectedToBatch()" [disabled]="selectedCount() === 0">
+        加入批量生产 {{ selectedCount() ? '(' + selectedCount() + ')' : '' }}
+      </button>
+      <button class="btn" type="button" (click)="refresh()">
+        <span class="icon">refresh</span>
+        刷新
+      </button>
+    </div>
+  </header>
+
+  <div class="topic-pool-stats">
+    <button class="topic-stat" type="button" [class.is-active]="statusFilter() === 'all'" (click)="setStatusFilter('all')">
+      <span>可用选题</span>
+      <strong>{{stats().all}}</strong>
+    </button>
+    <button class="topic-stat" type="button" [class.is-active]="statusFilter() === 'idea'" (click)="setStatusFilter('idea')">
+      <span>待写脚本</span>
+      <strong>{{stats().idea}}</strong>
+    </button>
+    <button class="topic-stat" type="button" [class.is-active]="statusFilter() === 'script_ready'" (click)="setStatusFilter('script_ready')">
+      <span>脚本已就绪</span>
+      <strong>{{stats().scriptReady}}</strong>
+    </button>
+    <button class="topic-stat" type="button" [class.is-active]="statusFilter() === 'generating'" (click)="setStatusFilter('generating')">
+      <span>生成中</span>
+      <strong>{{stats().generating}}</strong>
+    </button>
+    <button class="topic-stat" type="button" [class.is-active]="statusFilter() === 'completed'" (click)="setStatusFilter('completed')">
+      <span>已完成</span>
+      <strong>{{stats().completed}}</strong>
+    </button>
+  </div>
+
+  <div class="topic-pool-toolbar">
+    <div class="topic-search">
+      <span class="icon">search</span>
+      <input
+        type="text"
+        placeholder="搜索标题、角度、标签"
+        [ngModel]="searchTerm()"
+        (ngModelChange)="searchTerm.set($event)">
+    </div>
+
+    <select [ngModel]="sourceFilter()" (ngModelChange)="setSourceFilter($event)">
+      <option value="all">全部来源</option>
+      <option value="viral_analysis">爆款分析</option>
+      <option value="daily_report">监测日报</option>
+      <option value="assistant">AI 助手</option>
+      <option value="manual">手动创建</option>
+    </select>
+
+    <select [ngModel]="statusFilter()" (ngModelChange)="setStatusFilter($event)">
+      <option value="all">全部状态</option>
+      <option value="idea">待写脚本</option>
+      <option value="script_ready">脚本已就绪</option>
+      <option value="generating">生成中</option>
+      <option value="completed">已完成</option>
+      <option value="archived">已归档</option>
+    </select>
+
+    <select [ngModel]="selectedTemplateId()" (ngModelChange)="selectedTemplateId.set($event)">
+      <option value="">进入主题生视频时不带模板</option>
+      <option *ngFor="let template of topicTemplates()" [value]="template.id">
+        {{ template.scope === 'system' ? '系统' : '我的' }} · {{ template.name }}
+      </option>
+    </select>
+  </div>
+
+  <div class="topic-list" *ngIf="filteredTopics().length > 0; else emptyState">
+    <article class="topic-card" *ngFor="let topic of filteredTopics(); trackBy: trackByTopic">
+      <div class="topic-card-main">
+        <div class="topic-card-head">
+          <div>
+            <label class="topic-select-row">
+              <input type="checkbox" [ngModel]="isSelected(topic)" (ngModelChange)="toggleTopicSelection(topic, $event)">
+              <span class="topic-source">{{sourceLabel(topic.sourceType)}}</span>
+            </label>
+            <h3>{{topic.title}}</h3>
+          </div>
+          <span class="topic-status">{{statusLabel(topic.status)}}</span>
+        </div>
+
+        <p class="topic-angle">{{topic.angle}}</p>
+
+        <div class="topic-edit-form" *ngIf="editingTopicId() === topic.id">
+          <label>
+            标题
+            <input type="text" [ngModel]="editDraft().title" (ngModelChange)="updateEditDraft({ title: $event })">
+          </label>
+          <label>
+            角度
+            <textarea rows="3" [ngModel]="editDraft().angle" (ngModelChange)="updateEditDraft({ angle: $event })"></textarea>
+          </label>
+          <label>
+            标签
+            <input type="text" placeholder="用逗号分隔" [ngModel]="editDraft().tagsText" (ngModelChange)="updateEditDraft({ tagsText: $event })">
+          </label>
+          <label>
+            开头方式
+            <input type="text" [ngModel]="editDraft().hook" (ngModelChange)="updateEditDraft({ hook: $event })">
+          </label>
+          <label>
+            脚本框架
+            <textarea rows="4" [ngModel]="editDraft().outline" (ngModelChange)="updateEditDraft({ outline: $event })"></textarea>
+          </label>
+          <div class="topic-edit-actions">
+            <button class="btn btn-primary" type="button" (click)="saveEdit(topic)">保存修改</button>
+            <button class="btn" type="button" (click)="cancelEdit()">取消</button>
+          </div>
+        </div>
+
+        <div class="topic-outline" *ngIf="topic.outline">
+          <strong>脚本框架</strong>
+          <p>{{topic.outline}}</p>
+        </div>
+
+        <div class="topic-tags" *ngIf="topic.tags.length || topic.confidence">
+          <span class="topic-tag" *ngFor="let tag of topic.tags">{{tag}}</span>
+          <span class="topic-tag">{{confidenceLabel(topic.confidence)}}</span>
+        </div>
+
+        <div class="topic-meta">
+          <span>更新时间 {{formatTime(topic.updatedAt)}}</span>
+          <span *ngIf="topic.sourceVideoIds.length">来源视频 {{topic.sourceVideoIds.length}} 条</span>
+        </div>
+      </div>
+
+      <div class="topic-card-actions">
+        <button class="btn btn-primary" type="button" (click)="useForTopicVideo(topic)">
+          进入主题生视频
+        </button>
+        <button class="btn" type="button" (click)="useForDigitalHuman(topic)">
+          进入数字人
+        </button>
+        <button class="btn" type="button" (click)="openFirstSourceVideo(topic)" [disabled]="!topic.sourceVideoIds.length">
+          查看来源视频
+        </button>
+        <button class="btn" type="button" (click)="startEdit(topic)">
+          编辑选题
+        </button>
+        <select [ngModel]="topic.status" (ngModelChange)="updateStatus(topic, $event)">
+          <option value="idea">待写脚本</option>
+          <option value="script_ready">脚本已就绪</option>
+          <option value="generating">生成中</option>
+          <option value="completed">已完成</option>
+        </select>
+        <button class="btn btn-warn" type="button" (click)="archive(topic)">
+          归档
+        </button>
+      </div>
+    </article>
+  </div>
+
+  <ng-template #emptyState>
+    <div class="topic-empty">
+      <span class="icon">inventory_2</span>
+      <h3>暂无选题</h3>
+      <p>可以先在视频发现或博主监测里使用“爆款分析”,再把分析结果保存到这里。</p>
+    </div>
+  </ng-template>
+</section>

+ 250 - 0
src/app/pages/topic-pool/topic-pool.component.ts

@@ -0,0 +1,250 @@
+import { ChangeDetectionStrategy, Component, EventEmitter, OnInit, Output, computed, inject, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { TopicIdea } from '../../models/douyin-insight.model';
+import { TopicPoolService } from '../../services/topic-pool.service';
+import { TemplateService } from '../../services/template.service';
+
+type TopicStatusFilter = TopicIdea['status'] | 'all';
+type TopicSourceFilter = TopicIdea['sourceType'] | 'all';
+interface TopicEditDraft {
+  title: string;
+  angle: string;
+  tagsText: string;
+  hook: string;
+  outline: string;
+}
+
+@Component({
+  selector: 'app-topic-pool',
+  standalone: true,
+  imports: [CommonModule, FormsModule],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  templateUrl: './topic-pool.component.html',
+  styleUrls: ['./topic-pool.component.css'],
+})
+export class TopicPoolComponent implements OnInit {
+  @Output() navigateToPipeline = new EventEmitter<{ tab: 'topic-to-video' | 'digital-human'; topic: TopicIdea }>();
+  @Output() sourceVideoOpen = new EventEmitter<{ awemeId: string; topic: TopicIdea }>();
+  @Output() navigateToBatch = new EventEmitter<void>();
+
+  private readonly topicPool = inject(TopicPoolService);
+  private readonly templates = inject(TemplateService);
+
+  readonly topics = signal<TopicIdea[]>([]);
+  readonly searchTerm = signal('');
+  readonly statusFilter = signal<TopicStatusFilter>('all');
+  readonly sourceFilter = signal<TopicSourceFilter>('all');
+  readonly editingTopicId = signal<string>('');
+  readonly selectedTopicIds = signal<string[]>([]);
+  readonly selectedTemplateId = signal<string>('');
+  readonly editDraft = signal<TopicEditDraft>({
+    title: '',
+    angle: '',
+    tagsText: '',
+    hook: '',
+    outline: '',
+  });
+
+  readonly stats = computed(() => {
+    const topics = this.topics();
+    return {
+      all: topics.filter((item) => item.status !== 'archived').length,
+      idea: topics.filter((item) => item.status === 'idea').length,
+      scriptReady: topics.filter((item) => item.status === 'script_ready').length,
+      generating: topics.filter((item) => item.status === 'generating').length,
+      completed: topics.filter((item) => item.status === 'completed').length,
+    };
+  });
+
+  readonly selectedCount = computed(() => this.selectedTopicIds().length);
+  readonly topicTemplates = computed(() => this.templates.listByPipeline('topic-to-video'));
+
+  readonly filteredTopics = computed(() => {
+    const keyword = this.searchTerm().trim().toLowerCase();
+    const status = this.statusFilter();
+    const source = this.sourceFilter();
+
+    return this.topics().filter((topic) => {
+      if (status !== 'all' && topic.status !== status) return false;
+      if (status === 'all' && topic.status === 'archived') return false;
+      if (source !== 'all' && topic.sourceType !== source) return false;
+      if (!keyword) return true;
+      const haystack = [
+        topic.title,
+        topic.angle,
+        topic.hook || '',
+        topic.outline || '',
+        ...(topic.tags || []),
+      ].join(' ').toLowerCase();
+      return haystack.includes(keyword);
+    });
+  });
+
+  ngOnInit(): void {
+    this.refresh();
+    this.refreshFromCloud();
+  }
+
+  refresh(): void {
+    this.topics.set(this.topicPool.listTopics());
+  }
+
+  async refreshFromCloud(): Promise<void> {
+    try {
+      this.topics.set(await this.topicPool.refreshFromCloud());
+    } catch (err) {
+      console.warn('[TopicPoolComponent] 云端选题同步失败,继续使用本地数据:', err);
+    }
+  }
+
+  setStatusFilter(status: TopicStatusFilter): void {
+    this.statusFilter.set(status);
+  }
+
+  setSourceFilter(source: TopicSourceFilter): void {
+    this.sourceFilter.set(source);
+  }
+
+  updateStatus(topic: TopicIdea, status: TopicIdea['status']): void {
+    this.topicPool.updateTopic(topic.id, { status });
+    this.refresh();
+  }
+
+  archive(topic: TopicIdea): void {
+    this.topicPool.archiveTopic(topic.id);
+    this.refresh();
+  }
+
+  toggleTopicSelection(topic: TopicIdea, checked: boolean): void {
+    const ids = this.selectedTopicIds();
+    this.selectedTopicIds.set(checked
+      ? Array.from(new Set([...ids, topic.id]))
+      : ids.filter((id) => id !== topic.id));
+  }
+
+  isSelected(topic: TopicIdea): boolean {
+    return this.selectedTopicIds().includes(topic.id);
+  }
+
+  sendSelectedToBatch(): void {
+    const selected = this.topics().filter((topic) => this.selectedTopicIds().includes(topic.id));
+    if (!selected.length) return;
+    const payload = selected.map((topic) => ({
+      id: topic.id,
+      title: topic.title,
+      angle: topic.angle,
+      hook: topic.hook || '',
+      outline: topic.outline || '',
+      tags: topic.tags || [],
+    }));
+    localStorage.setItem('videoWorkflow.batchProduction.importTopics', JSON.stringify(payload));
+    this.navigateToBatch.emit();
+  }
+
+  startEdit(topic: TopicIdea): void {
+    this.editingTopicId.set(topic.id);
+    this.editDraft.set({
+      title: topic.title,
+      angle: topic.angle,
+      tagsText: (topic.tags || []).join(','),
+      hook: topic.hook || '',
+      outline: topic.outline || '',
+    });
+  }
+
+  cancelEdit(): void {
+    this.editingTopicId.set('');
+  }
+
+  saveEdit(topic: TopicIdea): void {
+    const draft = this.editDraft();
+    this.topicPool.updateTopic(topic.id, {
+      title: draft.title.trim() || topic.title,
+      angle: draft.angle.trim() || topic.angle,
+      tags: this.parseTags(draft.tagsText),
+      hook: draft.hook.trim(),
+      outline: draft.outline.trim(),
+    });
+    this.editingTopicId.set('');
+    this.refresh();
+  }
+
+  updateEditDraft(patch: Partial<TopicEditDraft>): void {
+    this.editDraft.set({ ...this.editDraft(), ...patch });
+  }
+
+  openFirstSourceVideo(topic: TopicIdea): void {
+    const awemeId = topic.sourceVideoIds?.[0] || '';
+    if (!awemeId) return;
+    this.sourceVideoOpen.emit({ awemeId, topic });
+  }
+
+  useForTopicVideo(topic: TopicIdea): void {
+    this.persistSelectedTopic(topic);
+    this.navigateToPipeline.emit({ tab: 'topic-to-video', topic });
+  }
+
+  useForDigitalHuman(topic: TopicIdea): void {
+    this.persistSelectedTopic(topic);
+    this.navigateToPipeline.emit({ tab: 'digital-human', topic });
+  }
+
+  sourceLabel(source: TopicIdea['sourceType']): string {
+    const map: Record<TopicIdea['sourceType'], string> = {
+      viral_analysis: '爆款分析',
+      daily_report: '监测日报',
+      assistant: 'AI 助手',
+      manual: '手动创建',
+    };
+    return map[source] || source;
+  }
+
+  statusLabel(status: TopicIdea['status']): string {
+    const map: Record<TopicIdea['status'], string> = {
+      idea: '待写脚本',
+      script_ready: '脚本已就绪',
+      generating: '生成中',
+      completed: '已完成',
+      archived: '已归档',
+    };
+    return map[status] || status;
+  }
+
+  confidenceLabel(confidence?: TopicIdea['confidence']): string {
+    if (confidence === 'high') return '高置信';
+    if (confidence === 'medium') return '中置信';
+    if (confidence === 'low') return '低置信';
+    return '未标记';
+  }
+
+  formatTime(value: string): string {
+    if (!value) return '';
+    const date = new Date(value);
+    if (Number.isNaN(date.getTime())) return '';
+    const pad = (n: number) => n.toString().padStart(2, '0');
+    return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
+  }
+
+  trackByTopic(_: number, topic: TopicIdea): string {
+    return topic.id;
+  }
+
+  private persistSelectedTopic(topic: TopicIdea): void {
+    localStorage.setItem('videoWorkflow.topicPool.selectedTopic', JSON.stringify(topic));
+    const templateId = this.selectedTemplateId();
+    if (templateId) {
+      sessionStorage.setItem('t2v.prefillTemplateId', templateId);
+    } else {
+      sessionStorage.removeItem('t2v.prefillTemplateId');
+    }
+  }
+
+  private parseTags(value: string): string[] {
+    return String(value || '')
+      .split(/[,,\s]+/)
+      .map((item) => item.trim())
+      .filter((item, index, list) => !!item && list.indexOf(item) === index)
+      .slice(0, 12);
+  }
+}

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

@@ -44,7 +44,13 @@ const SYSTEM_PROMPT = `你是「Tik-Tok 视频生成系统」的内置创作助
 { "suggest": [{ "tab": "topic-to-video", "label": "进入主题生视频", "payload": { "topic": "盛夏海边少女回眸微笑" } }] }
 \`\`\`
 
-合法的 tab 取值:home / topic-to-video / image-to-video / digital-human / action-transfer / asset-remix / video-generation / search / monitor / tasks / history / results / videos / voice-synthesis。
+5. 当你已经帮用户整理出明确选题时,可以建议保存到选题池:
+
+\`\`\`json
+{ "suggest": [{ "tab": "topic-pool", "label": "保存到选题池", "payload": { "saveTopic": true, "title": "盛夏海边少女回眸微笑", "angle": "用电影感回眸制造氛围和情绪记忆点", "tags": ["AI助手", "氛围感"] } }] }
+\`\`\`
+
+合法的 tab 取值:home / topic-to-video / image-to-video / digital-human / action-transfer / asset-remix / video-generation / search / monitor / topic-pool / analysis-history / tasks / history / results / videos / voice-synthesis。
 
 ## 输出格式要求(重要)
 - **使用 Markdown 排版**:合理使用 **加粗** / *斜体* / 二级标题 \`##\` / 列表 / 表格 / 代码块 提升可读性

+ 11 - 1
src/app/services/auth-credit.service.ts

@@ -11,6 +11,7 @@ export interface AppUser {
   email?: string;
   phone?: string;
   displayName?: string;
+  companyId?: string;
   role: AppUserRole;
   createdAt?: string;
 }
@@ -74,6 +75,7 @@ const LOCAL_ORDERS_KEY = 'videoWorkflow.localAuth.orders';
 const PARSE_API_HOST = 'https://server.fmode.cn';
 const PARSE_APP_ID = 'ncloudmaster';
 const LOGIN_SMS_COMPANY = 'E4KpGvTEto';
+const CREDIT_LIMIT_ENABLED = false;
 
 @Injectable({ providedIn: 'root' })
 export class AuthCreditService {
@@ -221,21 +223,28 @@ export class AuthCreditService {
   }
 
   async createRechargeOrder(amountCny: number): Promise<RechargeOrder> {
+    throw new Error('试运营阶段暂未开放在线充值,请联系管理员调整额度');
     if (this.localMode) return this.localCreateRechargeOrder(amountCny);
     return this.call<RechargeOrder>('createRechargeOrder', { ...this.authPayload(), amountCny });
   }
 
   async reserveCredit(operation: string, cost: number, title: string, detail: any = {}): Promise<CreditReserveResult> {
+    if (!CREDIT_LIMIT_ENABLED) {
+      const balance = this.currentUser ? (await this.getBalance().catch(() => null))?.balance || 0 : 0;
+      return { reservationId: `credit-disabled-${Date.now()}`, balance, cost: 0 };
+    }
     if (this.localMode) return this.localReserve(operation, cost, title, detail);
     return this.call<CreditReserveResult>('reserve', { ...this.authPayload(), operation, cost, title, detail });
   }
 
   async commitReservation(reservationId: string, detail: any = {}): Promise<void> {
+    if (!CREDIT_LIMIT_ENABLED || reservationId.startsWith('credit-disabled-')) return;
     if (this.localMode) return;
     await this.call<void>('commitReservation', { ...this.authPayload(), reservationId, detail });
   }
 
   async refundReservation(reservationId: string, reason: string): Promise<void> {
+    if (!CREDIT_LIMIT_ENABLED || reservationId.startsWith('credit-disabled-')) return;
     if (this.localMode) {
       this.localRefund(reservationId, reason);
       return;
@@ -348,6 +357,7 @@ export class AuthCreditService {
       email: parseUser.email || '',
       phone: parseUser.mobilePhoneNumber || mobile || '',
       displayName,
+      companyId: parseUser.company?.objectId || '',
       role: parseUser.role === 'admin' || parseUser.isAdmin === true ? 'admin' : 'user',
       createdAt: parseUser.createdAt,
     };
@@ -488,7 +498,7 @@ export class AuthCreditService {
     const users = this.localUsers();
     const row = users.find((u: any) => u.user.objectId === this.currentUser!.objectId);
     const amount = Math.max(0, Number(cost || 0));
-    if (!row || Number(row.balance || 0) < amount) throw new Error('积分不足,请先充值');
+    if (!row || Number(row.balance || 0) < amount) throw new Error('当前额度不足,请联系管理员调整额度');
     row.balance = Number(row.balance || 0) - amount;
     this.writeLocalUsers(users);
     const reservationId = this.localId();

+ 238 - 0
src/app/services/batch-job.service.ts

@@ -0,0 +1,238 @@
+import { Injectable } from '@angular/core';
+import { BehaviorSubject } from 'rxjs';
+import { AuthCreditService } from './auth-credit.service';
+import {
+  BatchJob,
+  BatchJobCreateInput,
+  BatchJobItem,
+  BatchJobRunResult,
+} from '../models/batch-job.model';
+
+const JOBS_STORAGE_KEY = 'videoWorkflow.batchJobs.v1';
+const ITEMS_STORAGE_KEY = 'videoWorkflow.batchJobItems.v1';
+
+type BatchItemRunner = (item: BatchJobItem, job: BatchJob) => Promise<BatchJobRunResult | void>;
+
+@Injectable({ providedIn: 'root' })
+export class BatchJobService {
+  private readonly jobsSubject = new BehaviorSubject<BatchJob[]>(this.readJobs());
+  private readonly itemsSubject = new BehaviorSubject<BatchJobItem[]>(this.readItems());
+  readonly jobs$ = this.jobsSubject.asObservable();
+  readonly items$ = this.itemsSubject.asObservable();
+
+  private runningJobId = '';
+  private stopRequested = false;
+
+  constructor(private auth: AuthCreditService) {}
+
+  get jobs(): BatchJob[] {
+    return this.jobsSubject.value;
+  }
+
+  get items(): BatchJobItem[] {
+    return this.itemsSubject.value;
+  }
+
+  listJobs(): BatchJob[] {
+    const userId = this.currentUserId();
+    if (!userId) return [];
+    return this.jobs.filter((job) => job.userId === userId);
+  }
+
+  listItems(batchJobId: string): BatchJobItem[] {
+    return this.items.filter((item) => item.batchJobId === batchJobId);
+  }
+
+  create(input: BatchJobCreateInput): BatchJob {
+    const userId = this.requireUserId();
+    const now = new Date().toISOString();
+    const jobId = this.createId('batch');
+    const items = input.items
+      .map((item, index) => ({
+        id: this.createId('batch_item'),
+        batchJobId: jobId,
+        topicId: item.topicId,
+        title: item.title?.trim() || `子任务 ${index + 1}`,
+        input: this.cloneSerializable(item.input || {}),
+        status: 'queued' as const,
+        retryCount: 0,
+        createdAt: now,
+        updatedAt: now,
+      }));
+    const job: BatchJob = {
+      id: jobId,
+      userId,
+      title: input.title?.trim() || '未命名批量任务',
+      pipelineId: input.pipelineId,
+      templateId: input.templateId,
+      status: 'queued',
+      total: items.length,
+      completed: 0,
+      failed: 0,
+      estimatedCredits: Math.max(0, Math.ceil(Number(input.estimatedCredits || 0))),
+      createdAt: now,
+      updatedAt: now,
+    };
+    this.setJobs([job, ...this.jobs]);
+    this.setItems([...items, ...this.items]);
+    return job;
+  }
+
+  pause(jobId: string): void {
+    const job = this.findJob(jobId);
+    if (!job || job.status !== 'running') return;
+    this.stopRequested = true;
+    this.patchJob(jobId, { status: 'paused' });
+  }
+
+  cancel(jobId: string): void {
+    this.stopRequested = this.runningJobId === jobId;
+    this.patchJob(jobId, { status: 'cancelled' });
+    this.setItems(this.items.map((item) => item.batchJobId === jobId && (item.status === 'queued' || item.status === 'running')
+      ? { ...item, status: 'cancelled', updatedAt: new Date().toISOString() }
+      : item));
+  }
+
+  retryItem(itemId: string): void {
+    const item = this.items.find((row) => row.id === itemId);
+    if (!item || item.status !== 'failed') return;
+    this.patchItem(itemId, {
+      status: 'queued',
+      errorMessage: undefined,
+      resultUrl: undefined,
+      retryCount: Number(item.retryCount || 0) + 1,
+    });
+    this.recalculateJob(item.batchJobId, 'queued');
+  }
+
+  async run(jobId: string, runner: BatchItemRunner = this.mockRunner): Promise<void> {
+    const job = this.findJob(jobId);
+    if (!job || job.status === 'cancelled') return;
+    if (this.runningJobId && this.runningJobId !== jobId) {
+      throw new Error('已有批量任务正在执行,请稍后再试');
+    }
+
+    this.runningJobId = jobId;
+    this.stopRequested = false;
+    this.patchJob(jobId, { status: 'running' });
+
+    try {
+      while (!this.stopRequested) {
+        const currentJob = this.findJob(jobId);
+        if (!currentJob || currentJob.status === 'cancelled') break;
+
+        const nextItem = this.listItems(jobId).find((item) => item.status === 'queued');
+        if (!nextItem) break;
+
+        this.patchItem(nextItem.id, { status: 'running', errorMessage: undefined });
+        try {
+          const result = await runner(nextItem, currentJob);
+          this.patchItem(nextItem.id, {
+            status: 'completed',
+            resultUrl: result?.resultUrl,
+            generationTaskId: result?.generationTaskId,
+          });
+        } catch (err: any) {
+          this.patchItem(nextItem.id, {
+            status: 'failed',
+            errorMessage: err?.message || '子任务执行失败',
+          });
+        }
+        this.recalculateJob(jobId);
+      }
+    } finally {
+      const finalJob = this.findJob(jobId);
+      if (finalJob && finalJob.status === 'running') {
+        this.recalculateJob(jobId);
+      }
+      this.runningJobId = '';
+    }
+  }
+
+  private mockRunner(item: BatchJobItem): Promise<BatchJobRunResult> {
+    return new Promise((resolve) => {
+      window.setTimeout(() => resolve({ generationTaskId: `mock-${item.id}` }), 300);
+    });
+  }
+
+  private recalculateJob(jobId: string, fallbackStatus?: BatchJob['status']): void {
+    const job = this.findJob(jobId);
+    if (!job) return;
+    const items = this.listItems(jobId);
+    const completed = items.filter((item) => item.status === 'completed').length;
+    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 status = (() => {
+      if (job.status === 'cancelled') return 'cancelled';
+      if (fallbackStatus) return fallbackStatus;
+      if (running > 0) return 'running';
+      if (queued > 0) return job.status === 'paused' ? 'paused' : 'running';
+      if (completed === items.length && items.length > 0) return 'completed';
+      if (failed > 0 && completed + failed + cancelled === items.length) return 'failed';
+      return job.status;
+    })();
+    this.patchJob(jobId, { completed, failed, status });
+  }
+
+  private patchJob(id: string, patch: Partial<BatchJob>): void {
+    const now = new Date().toISOString();
+    this.setJobs(this.jobs.map((job) => job.id === id ? { ...job, ...patch, id, updatedAt: now } : job));
+  }
+
+  private patchItem(id: string, patch: Partial<BatchJobItem>): void {
+    const now = new Date().toISOString();
+    this.setItems(this.items.map((item) => item.id === id ? { ...item, ...patch, id, updatedAt: now } : item));
+  }
+
+  private findJob(id: string): BatchJob | undefined {
+    const userId = this.currentUserId();
+    return this.jobs.find((job) => job.id === id && job.userId === userId);
+  }
+
+  private currentUserId(): string {
+    return this.auth.currentUser?.objectId || 'local-guest';
+  }
+
+  private requireUserId(): string {
+    return this.currentUserId();
+  }
+
+  private readJobs(): BatchJob[] {
+    try {
+      const parsed = JSON.parse(localStorage.getItem(JOBS_STORAGE_KEY) || '[]');
+      return Array.isArray(parsed) ? parsed : [];
+    } catch {
+      return [];
+    }
+  }
+
+  private readItems(): BatchJobItem[] {
+    try {
+      const parsed = JSON.parse(localStorage.getItem(ITEMS_STORAGE_KEY) || '[]');
+      return Array.isArray(parsed) ? parsed : [];
+    } catch {
+      return [];
+    }
+  }
+
+  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));
+  }
+
+  private setItems(items: BatchJobItem[]): void {
+    this.itemsSubject.next(items);
+    localStorage.setItem(ITEMS_STORAGE_KEY, JSON.stringify(items));
+  }
+
+  private createId(prefix: string): string {
+    return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+  }
+
+  private cloneSerializable<T>(value: T): T {
+    return JSON.parse(JSON.stringify(value || {}));
+  }
+}

+ 2 - 0
src/app/services/cloud-functions.ts

@@ -31,6 +31,8 @@ export const CLOUD_FN = {
   jimeng: 'WUkf74lVE5',
   /** 抖音/TikHub 搜索、详情、博主资料代理 */
   douyin: 'HArHym0smi',
+  /** 抖音运营洞察:爆款分析、选题池、日报资产(部署 13-douyinInsightManager.js 后填写) */
+  douyinInsight: 'pzBTvs6UcE',
   /** 七牛云直传凭证 */
   upload: 'VHS0noRP7Q',
   /** 账号、积分、充值与管理员 */

+ 148 - 0
src/app/services/content-asset.service.ts

@@ -0,0 +1,148 @@
+import { Injectable } from '@angular/core';
+import { BehaviorSubject } from 'rxjs';
+import { ContentAssetMetadata, ContentAssetUpsertInput, ContentProject } from '../models/content-asset.model';
+
+const STORAGE_KEY = 'videoWorkflow.contentAssets.v1';
+
+@Injectable({ providedIn: 'root' })
+export class ContentAssetService {
+  private readonly assetsSubject = new BehaviorSubject<ContentAssetMetadata[]>(this.restore());
+  readonly assets$ = this.assetsSubject.asObservable();
+
+  get assets(): ContentAssetMetadata[] {
+    return this.assetsSubject.value;
+  }
+
+  all(): ContentAssetMetadata[] {
+    return this.assets;
+  }
+
+  listProjects(): ContentProject[] {
+    const map = new Map<string, ContentProject>();
+    for (const asset of this.assets) {
+      const name = (asset.projectName || '').trim();
+      if (!name) continue;
+      const current = map.get(name);
+      map.set(name, {
+        id: this.projectId(name),
+        name,
+        assetCount: (current?.assetCount || 0) + 1,
+        updatedAt: Math.max(current?.updatedAt || 0, asset.updatedAt || 0),
+      });
+    }
+    return Array.from(map.values()).sort((a, b) => b.updatedAt - a.updatedAt);
+  }
+
+  getByAssetKey(assetKey: string): ContentAssetMetadata | undefined {
+    return this.assets.find((item) => item.assetKey === assetKey);
+  }
+
+  ensure(input: ContentAssetUpsertInput): ContentAssetMetadata {
+    const existing = this.getByAssetKey(input.assetKey);
+    const now = Date.now();
+    const next: ContentAssetMetadata = {
+      id: existing?.id || `asset_${now}_${Math.random().toString(36).slice(2, 8)}`,
+      assetKey: input.assetKey,
+      draftId: input.draftId,
+      artifactId: input.artifactId,
+      pipelineId: input.pipelineId,
+      type: input.type,
+      title: input.title,
+      url: input.url,
+      projectName: existing?.projectName || '',
+      tags: existing?.tags || this.deriveTags(input),
+      favorite: existing?.favorite || false,
+      archived: existing?.archived || false,
+      source: {
+        draftTitle: input.draftTitle,
+        isFinal: input.isFinal,
+        ...(existing?.source || {}),
+        ...(input.source || {}),
+      },
+      parameters: {
+        ...(input.parameters || {}),
+        ...(existing?.parameters || {}),
+      },
+      createdAt: existing?.createdAt || now,
+      updatedAt: now,
+    };
+    this.upsert(next);
+    return next;
+  }
+
+  updateUserFields(assetKey: string, patch: { projectName?: string; tags?: string[]; favorite?: boolean; archived?: boolean }): void {
+    const current = this.getByAssetKey(assetKey);
+    if (!current) return;
+    this.upsert({
+      ...current,
+      projectName: patch.projectName === undefined ? current.projectName : patch.projectName.trim(),
+      tags: patch.tags === undefined ? current.tags : this.normalizeTags(patch.tags),
+      favorite: patch.favorite === undefined ? current.favorite : patch.favorite,
+      archived: patch.archived === undefined ? current.archived : patch.archived,
+      updatedAt: Date.now(),
+    });
+  }
+
+  updateMany(assetKeys: string[], patch: { projectName?: string; addTags?: string[]; archived?: boolean }): void {
+    const keys = new Set(assetKeys);
+    const addTags = this.normalizeTags(patch.addTags || []);
+    const next = this.assets.map((asset) => {
+      if (!keys.has(asset.assetKey)) return asset;
+      return {
+        ...asset,
+        projectName: patch.projectName === undefined ? asset.projectName : patch.projectName.trim(),
+        tags: this.normalizeTags([...(asset.tags || []), ...addTags]),
+        archived: patch.archived === undefined ? asset.archived : patch.archived,
+        updatedAt: Date.now(),
+      };
+    });
+    this.setAssets(next);
+  }
+
+  normalizeTags(tags: string[]): string[] {
+    const seen = new Set<string>();
+    return tags
+      .map((tag) => String(tag || '').trim())
+      .filter((tag) => {
+        if (!tag || seen.has(tag)) return false;
+        seen.add(tag);
+        return true;
+      })
+      .slice(0, 12);
+  }
+
+  private upsert(asset: ContentAssetMetadata): void {
+    const idx = this.assets.findIndex((item) => item.assetKey === asset.assetKey);
+    const next = idx >= 0
+      ? this.assets.map((item) => item.assetKey === asset.assetKey ? asset : item)
+      : [asset, ...this.assets];
+    this.setAssets(next);
+  }
+
+  private deriveTags(input: ContentAssetUpsertInput): string[] {
+    const tags = [
+      input.type === 'video' ? '视频' : input.type === 'image' ? '图片' : '音频',
+      input.isFinal ? '成片' : '中间产物',
+    ];
+    return this.normalizeTags(tags);
+  }
+
+  private projectId(name: string): string {
+    return `project_${encodeURIComponent(name.toLowerCase()).replace(/%/g, '')}`;
+  }
+
+  private restore(): ContentAssetMetadata[] {
+    try {
+      const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
+      return Array.isArray(parsed) ? parsed : [];
+    } catch {
+      return [];
+    }
+  }
+
+  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));
+  }
+}

+ 37 - 0
src/app/services/cost-estimator.service.ts

@@ -50,6 +50,43 @@ export class CostEstimatorService {
     ]);
   }
 
+  estimateAssetRemix(imageCount: number, audioSegments = 1, includeComposite = true): CostEstimate {
+    const images = Math.max(1, Math.ceil(imageCount || 1));
+    const audio = Math.max(1, Math.ceil(audioSegments || 1));
+    const lines: CostEstimateLine[] = [
+      { key: 'script', label: '脚本与分镜生成', credits: 1, quantity: 1, unit: '次' },
+      { key: 'tts', label: '语音合成', credits: audio, quantity: audio, unit: '段' },
+    ];
+    if (includeComposite) {
+      lines.push({ key: 'composite', label: '视频合成与上传', credits: Math.max(1, Math.ceil(images / 3)), quantity: images, unit: '张素材' });
+    }
+    return this.build('pipeline.assetRemix', `素材合成视频 ${images} 张素材`, lines);
+  }
+
+  estimateVoiceSynthesis(textLength: number): CostEstimate {
+    const chunks = Math.max(1, Math.ceil(Number(textLength || 1) / 500));
+    return this.build('voice.synthesis', '语音合成', [
+      { key: 'tts', label: '语音合成', credits: chunks, quantity: chunks, unit: '段' },
+    ]);
+  }
+
+  estimateVoiceClone(): CostEstimate {
+    return this.build('voice.clone', '声音训练', [
+      { key: 'voice-clone', label: '声音训练', credits: 10, quantity: 1, unit: '次' },
+    ]);
+  }
+
+  estimateDigitalHuman(includeTts = true): CostEstimate {
+    const lines: CostEstimateLine[] = [
+      { key: 'identify', label: '形象识别', credits: 3, quantity: 1, unit: '次' },
+      { key: 'digital-human', label: '数字人视频生成', credits: 20, quantity: 1, unit: '次' },
+    ];
+    if (includeTts) {
+      lines.splice(1, 0, { key: 'tts', label: '语音合成', credits: 1, quantity: 1, unit: '段' });
+    }
+    return this.build('pipeline.digitalHuman', '数字人合成', lines);
+  }
+
   estimateDouyinRead(operation: string, title: string, count = 1): CostEstimate {
     const quantity = Math.max(1, Math.ceil(count));
     return this.build(operation, title, [

+ 538 - 0
src/app/services/daily-report.service.ts

@@ -0,0 +1,538 @@
+import { Injectable } from '@angular/core';
+import { forkJoin, Observable, of } from 'rxjs';
+import { catchError, map, switchMap } from 'rxjs/operators';
+import { AuthCreditService } from './auth-credit.service';
+import { DouyinApiService } from './douyin-api.service';
+import { DouyinInsightService } from './douyin-insight.service';
+import { DailyReport, InsightConfidence, TopicIdea } from '../models/douyin-insight.model';
+
+const DAILY_REPORT_STORAGE_KEY = 'videoWorkflow.dailyReports.items';
+
+interface MonitorWorkLike {
+  id: string;
+  desc: string;
+  createTime?: Date | string;
+  isNew?: boolean;
+  firstSeenAt?: string;
+  statistics?: {
+    play_count?: number;
+    digg_count?: number;
+    comment_count?: number;
+    share_count?: number;
+  };
+  commentSamples?: string[];
+}
+
+interface MonitorAuthorLike {
+  id: string;
+  uid?: string;
+  secUserId?: string;
+  nickname: string;
+  followerCount?: number;
+  lastCheckedAt?: string;
+  newWorksCount?: number;
+  works?: MonitorWorkLike[];
+}
+
+interface NormalizedWork extends MonitorWorkLike {
+  authorId: string;
+  authorName: string;
+  followerCount: number;
+  isNew: boolean;
+  firstSeenAt: string;
+  score: number;
+  commentSamples: string[];
+}
+
+interface CommentSampleLike {
+  id: string;
+  text: string;
+  likeCount: number;
+  replyCount: number;
+}
+
+@Injectable({ providedIn: 'root' })
+export class DailyReportService {
+  constructor(
+    private auth: AuthCreditService,
+    private douyinApi: DouyinApiService,
+    private insight: DouyinInsightService,
+  ) {}
+
+  generateMonitorReport(authors: MonitorAuthorLike[]): DailyReport {
+    return this.buildMonitorReport(authors, {});
+  }
+
+  generateMonitorReportWithComments(authors: MonitorAuthorLike[]): Observable<DailyReport> {
+    const userId = this.requireUserId();
+    const topWorks = this.pickTopWorks(authors);
+    const targets = topWorks.slice(0, 3).filter((work) => !!work.id);
+
+    if (!targets.length) return of(this.buildMonitorReport(authors, {}, userId));
+
+    return forkJoin(targets.map((work) => this.douyinApi.getVideoComments({ aweme_id: String(work.id), count: 10 }).pipe(
+      map((payload) => this.extractCommentSamples(payload).slice(0, 5)),
+      switchMap((samples) => {
+        const replyTargets = samples
+          .filter((sample) => sample.id && sample.replyCount > 0)
+          .sort((a, b) => (b.replyCount - a.replyCount) || (b.likeCount - a.likeCount))
+          .slice(0, 2);
+
+        if (!replyTargets.length) {
+          return of({
+            workId: String(work.id),
+            comments: samples.map((sample) => sample.text),
+          });
+        }
+
+        return forkJoin(replyTargets.map((sample) => this.douyinApi.getCommentReplies(String(work.id), sample.id).pipe(
+          map((payload) => this.extractReplyTexts(payload).slice(0, 3).map((text) => `回复:${text}`)),
+          catchError(() => of([] as string[])),
+        ))).pipe(
+          map((replyGroups) => ({
+            workId: String(work.id),
+            comments: [
+              ...samples.map((sample) => sample.text),
+              ...replyGroups.flat(),
+            ].slice(0, 10),
+          })),
+        );
+      }),
+      catchError(() => of({ workId: String(work.id), comments: [] as string[] })),
+    ))).pipe(
+      map((rows) => {
+        const commentMap = rows.reduce<Record<string, string[]>>((acc, row) => {
+          acc[row.workId] = row.comments;
+          return acc;
+        }, {});
+        return this.buildMonitorReport(authors, commentMap, userId);
+      }),
+    );
+  }
+
+  listReports(): DailyReport[] {
+    const userId = this.auth.currentUser?.objectId;
+    if (!userId) return [];
+    return this.readAll()
+      .filter((item) => item.userId === userId)
+      .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
+  }
+
+  async refreshReportsFromCloud(): Promise<DailyReport[]> {
+    const userId = this.auth.currentUser?.objectId;
+    if (!userId) return [];
+    const cloudItems = await this.insight.listDailyReports();
+    if (!cloudItems.length) return this.listReports();
+    const others = this.readAll().filter((item) => item.userId !== userId);
+    this.writeAll([...others, ...cloudItems]);
+    return cloudItems.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
+  }
+
+  private buildMonitorReport(authors: MonitorAuthorLike[], commentsByWorkId: Record<string, string[]> = {}, knownUserId?: string): DailyReport {
+    const userId = knownUserId || this.requireUserId();
+    const now = new Date().toISOString();
+    const works = this.normalizeWorks(authors);
+    const topWorks = this.pickTopWorks(authors).map((work) => ({
+      ...work,
+      commentSamples: commentsByWorkId[String(work.id)] || work.commentSamples || [],
+    })) as NormalizedWork[];
+    const allComments = topWorks.flatMap((work) => (work.commentSamples || []).map((text) => ({
+      parentId: String(work.id || ''),
+      text,
+    })));
+    const newWorks = works.filter((work) => work.isNew);
+    const topTopics = topWorks.slice(0, 3).map((work, index) => this.workToTopic(userId, work, index));
+    const reportJson = this.buildRunnerLiteJson(authors, works, topWorks, topTopics, allComments, now);
+    const reportMarkdown = this.buildMarkdown(reportJson);
+    const report: DailyReport = {
+      id: this.createId('daily'),
+      userId,
+      date: now.slice(0, 10),
+      newWorks: topWorks,
+      topTopics,
+      reportMarkdown,
+      reportJson,
+      calibrationPrompt: '明天建议继续观察这些选题是否出现相似表达,并补充评论区高频问题。',
+      status: 'success',
+      createdAt: now,
+      updatedAt: now,
+    };
+    const rows = this.readAll();
+    rows.unshift(report);
+    this.writeAll(rows);
+    this.insight.createDailyReport(report).catch((err) => {
+      console.warn('[DailyReportService] 云端保存日报失败,已保留本地数据:', err?.message || err);
+    });
+    return report;
+  }
+
+  private buildRunnerLiteJson(
+    authors: MonitorAuthorLike[],
+    works: NormalizedWork[],
+    topWorks: NormalizedWork[],
+    topTopics: TopicIdea[],
+    comments: Array<{ parentId: string; text: string }>,
+    now: string,
+  ): Record<string, any> {
+    const newWorks = works.filter((work) => work.isNew);
+    const commentCount = comments.length;
+    const trendSignals = this.buildTrendSignals(works, comments);
+    const topicIdeas = topTopics.map((topic, index) => {
+      const work = topWorks[index];
+      const scopedComments = comments.filter((comment) => comment.parentId === String(work?.id || ''));
+      return {
+        priority: index < 3 ? 'P0' : 'P1',
+        title: topic.title,
+        angle: topic.angle,
+        sourceVideoIds: topic.sourceVideoIds,
+        sourceAuthorIds: topic.sourceAuthorIds,
+        scriptFrame: this.topicFrame(work, scopedComments),
+        commentTriggers: scopedComments.slice(0, 4).map((item) => item.text),
+        evidenceChain: this.evidenceChain(work, scopedComments),
+        confidence: this.confidenceForWork(work, scopedComments),
+        riskNotes: this.riskNotes(work, scopedComments),
+      };
+    });
+
+    return {
+      runnerMode: 'runner-lite',
+      generatedAt: now,
+      authorCount: authors.length,
+      worksCount: works.length,
+      newWorksCount: newWorks.length,
+      commentSampleCount: commentCount,
+      mode: newWorks.length ? 'new_works' : 'loaded_works',
+      summary: {
+        videoCount: works.length,
+        commentCount,
+        transcriptCount: 0,
+        topicIdeaCount: topTopics.length,
+        monitoredAuthorCount: authors.length,
+      },
+      oneLineJudgement: this.oneLineJudgement(topWorks, trendSignals),
+      trendSignals,
+      precisionCheck: {
+        sampleScope: `${works.length} 条作品 / ${commentCount} 条评论与回复 / 0 条逐字稿`,
+        confidenceSummary: this.confidenceSummary(topicIdeas),
+        evidenceRules: [
+          '优先使用能匹配到作品 ID 的评论与回复作为证据。',
+          '未接入逐字稿时,只做标题、互动数据、评论样本层面的结构判断。',
+          '日报给出候选方向和证据链,正式脚本仍建议人工确认。',
+        ],
+        nextQuestions: [
+          '今天的 Top 选题里,哪 1 条最像当前账号应该继续追的方向?',
+          '哪些评论问题适合沉淀成下一条视频的开头?',
+          '是否要把某个博主加入重点观察名单?',
+        ],
+      },
+      topWorks,
+      topVideos: topWorks.map((work) => ({
+        id: work.id,
+        title: work.desc,
+        author: work.authorName,
+        score: Math.round(work.score),
+        likeCount: Number(work.statistics?.digg_count || 0),
+        commentCount: Number(work.statistics?.comment_count || 0),
+        shareCount: Number(work.statistics?.share_count || 0),
+        playCount: Number(work.statistics?.play_count || 0),
+      })),
+      topTopics,
+      topicIdeas,
+      warnings: this.reportWarnings(works, comments),
+    };
+  }
+
+  private buildMarkdown(report: Record<string, any>): string {
+    const generatedAt = report['generatedAt'];
+    const oneLineJudgement = report['oneLineJudgement'];
+    const authorCount = report['authorCount'];
+    const worksCount = report['worksCount'];
+    const newWorksCount = report['newWorksCount'];
+    const commentSampleCount = report['commentSampleCount'];
+    const topVideos = Array.isArray(report['topVideos']) ? report['topVideos'] : [];
+    const topicIdeas = Array.isArray(report['topicIdeas']) ? report['topicIdeas'] : [];
+    const trendSignals = report['trendSignals'] || {};
+    const precisionCheck = report['precisionCheck'] || {};
+    const evidenceRules = Array.isArray(precisionCheck['evidenceRules']) ? precisionCheck['evidenceRules'] : [];
+    const lines = [
+      `# ${new Date(generatedAt).toLocaleDateString()} 博主监测日报`,
+      '',
+      '## 今日判断',
+      oneLineJudgement,
+      '',
+      '## 样本范围',
+      `- 监测博主:${authorCount} 个`,
+      `- 已加载作品:${worksCount} 条`,
+      `- 新增作品:${newWorksCount} 条`,
+      `- 评论/回复样本:${commentSampleCount} 条`,
+      '',
+      '## 值得关注的作品',
+      ...topVideos.slice(0, 3).map((work: any, index: number) =>
+        `${index + 1}. ${work.author || '监测博主'}:${work.title || work.id}(点赞 ${work.likeCount || 0},评论 ${work.commentCount || 0},综合分 ${work.score || 0})`
+      ),
+      '',
+      '## 可跟进选题',
+      ...topicIdeas.slice(0, 3).map((topic: any, index: number) => {
+        const trigger = topic.commentTriggers?.[0] ? `;评论触发点:${topic.commentTriggers[0]}` : '';
+        return `${index + 1}. ${topic.title}|${topic.angle}${trigger}`;
+      }),
+      '',
+      '## 趋势信号',
+      ...(trendSignals['signals']?.length ? trendSignals['signals'].map((item: string) => `- ${item}`) : ['- 样本仍偏少,建议继续刷新监测作品。']),
+      '',
+      '## 证据完整性',
+      `- ${precisionCheck['sampleScope']}`,
+      `- ${precisionCheck['confidenceSummary']}`,
+      ...evidenceRules.map((item: string) => `- ${item}`),
+      '',
+      '## 明日建议',
+      '- 优先跟进互动分高、评论问题明确的内容方向。',
+      '- 对 Top 作品补充真实口播转写后,可以再做增强版拆解。',
+      '- 将可复用角度保存到选题池后进入主题生视频。',
+    ];
+    return lines.join('\n');
+  }
+
+  private normalizeWorks(authors: MonitorAuthorLike[]): NormalizedWork[] {
+    return authors.flatMap((author) => (author.works || []).map((work) => ({
+      ...work,
+      authorId: author.secUserId || author.uid || author.id,
+      authorName: author.nickname,
+      followerCount: Number(author.followerCount || 0),
+      isNew: !!work.isNew,
+      firstSeenAt: work.firstSeenAt || '',
+      commentSamples: work.commentSamples || [],
+      score: this.scoreWork(work),
+    })));
+  }
+
+  private pickTopWorks(authors: MonitorAuthorLike[]): NormalizedWork[] {
+    const works = this.normalizeWorks(authors);
+    const newWorks = works.filter((work) => work.isNew);
+    const reportSourceWorks = newWorks.length ? newWorks : works;
+    return reportSourceWorks
+      .filter((work) => !!work.id || !!work.desc)
+      .sort((a, b) => b.score - a.score)
+      .slice(0, 6);
+  }
+
+  private workToTopic(userId: string, work: NormalizedWork, index: number): TopicIdea {
+    const now = new Date().toISOString();
+    const title = this.cleanTitle(work.desc) || `监测选题 ${index + 1}`;
+    return {
+      id: this.createId('topic'),
+      userId,
+      title,
+      angle: this.buildTopicAngle(work),
+      sourceType: 'daily_report',
+      sourceVideoIds: work.id ? [String(work.id)] : [],
+      sourceAuthorIds: work.authorId ? [String(work.authorId)] : [],
+      tags: ['监测日报', work.authorName || '博主作品'].filter(Boolean),
+      audience: '',
+      hook: title,
+      outline: this.buildTopicOutline(work),
+      status: 'idea',
+      pipelineTarget: 'topic_video',
+      confidence: this.confidenceForWork(work, (work.commentSamples || []).map((text) => ({ text, parentId: String(work.id || '') }))).label,
+      createdAt: now,
+      updatedAt: now,
+    };
+  }
+
+  private buildTrendSignals(works: NormalizedWork[], comments: Array<{ text: string }>): Record<string, any> {
+    const authorCounts = this.countBy(works.map((work) => work.authorName)).slice(0, 5);
+    const questionCount = comments.filter((item) => /[??吗呢]|怎么|哪里|多少|能不能|有没有|为什么/.test(item.text)).length;
+    const signals: string[] = [];
+    if (authorCounts[0]) signals.push(`样本中「${authorCounts[0].label}」出现最多,建议作为重点对标观察。`);
+    if (questionCount) signals.push(`评论与回复里出现 ${questionCount} 条问题型表达,可直接转成视频开头。`);
+    if (works.some((work) => work.isNew)) signals.push('今日存在新增作品,日报优先按新增作品排序。');
+    if (!signals.length) signals.push('当前样本偏少,建议先刷新博主作品后再生成日报。');
+    return { authorCounts, questionCount, signals };
+  }
+
+  private topicFrame(work: NormalizedWork | undefined, comments: Array<{ text: string }>): string[] {
+    const title = this.cleanTitle(work?.desc || '') || '当前高互动作品';
+    const question = comments.find((item) => /[??吗呢]|怎么|哪里|多少|能不能|有没有|为什么/.test(item.text))?.text;
+    return [
+      `开头:先抛出一个具体判断,参考「${title}」。`,
+      `中段:解释为什么用户会卡在「${this.buildTopicAngle(work)}」这个问题上。`,
+      '证明:用作品场景、评论问题或反例支撑,不要只喊口号。',
+      `互动:结尾追问「${this.cleanTitle(question || '你最想先解决哪一步?')}」。`,
+    ];
+  }
+
+  private evidenceChain(work: NormalizedWork | undefined, comments: Array<{ text: string }>): string[] {
+    const stat = work?.statistics || {};
+    const stats = [
+      stat.play_count ? `播放 ${stat.play_count}` : '',
+      stat.digg_count ? `点赞 ${stat.digg_count}` : '',
+      stat.comment_count ? `评论 ${stat.comment_count}` : '',
+      stat.share_count ? `分享 ${stat.share_count}` : '',
+    ].filter(Boolean).join(' / ') || '互动数据不足';
+    return [
+      `来源作品:${work?.authorName || '监测博主'} / ${work?.id || '未知作品'} / ${stats}`,
+      `评论样本:${comments.length} 条`,
+      '逐字稿:未接入,脚本结构基于标题、互动数据和评论推断',
+    ];
+  }
+
+  private confidenceForWork(work: NormalizedWork | undefined, comments: Array<{ text: string }>): { label: InsightConfidence; score: number; reason: string } {
+    let score = 0;
+    const reasons: string[] = [];
+    if (work?.statistics?.digg_count || work?.statistics?.comment_count || work?.statistics?.share_count || work?.statistics?.play_count) {
+      score += 1;
+      reasons.push('有互动数据');
+    }
+    if (comments.length >= 3) {
+      score += 2;
+      reasons.push('有足够评论样本');
+    } else if (comments.length) {
+      score += 1;
+      reasons.push('有少量评论样本');
+    }
+    const label: InsightConfidence = score >= 3 ? 'medium' : score >= 1 ? 'low' : 'low';
+    return { label, score, reason: reasons.join(',') || '证据不足' };
+  }
+
+  private confidenceSummary(topicIdeas: any[]): string {
+    const high = topicIdeas.filter((item) => item.confidence?.label === 'high').length;
+    const medium = topicIdeas.filter((item) => item.confidence?.label === 'medium').length;
+    const low = Math.max(0, topicIdeas.length - high - medium);
+    return `${high} 条高置信 / ${medium} 条中置信 / ${low} 条低置信`;
+  }
+
+  private riskNotes(work: NormalizedWork | undefined, comments: Array<{ text: string }>): string[] {
+    return [
+      '未接入逐字稿,不能还原完整原文,只能拆结构。',
+      ...(comments.length ? [] : ['未匹配到该作品评论,建议补抓评论后再作为正式脚本。']),
+      ...(work?.isNew ? [] : ['当前没有新增作品,本日报基于已加载历史作品生成。']),
+    ];
+  }
+
+  private reportWarnings(works: NormalizedWork[], comments: Array<{ text: string }>): string[] {
+    const warnings: string[] = [];
+    if (!works.length) warnings.push('未提供作品样本,日报只能输出监测状态。');
+    if (works.length && !comments.length) warnings.push('缺少评论样本,评论触发点和用户问题会偏弱。');
+    warnings.push('未接入远程 ASR,所有脚本结构均基于已有文本与评论推断。');
+    return warnings;
+  }
+
+  private scoreWork(work: MonitorWorkLike): number {
+    const stat = work.statistics || {};
+    return Number(stat.digg_count || 0)
+      + Number(stat.comment_count || 0) * 5
+      + Number(stat.share_count || 0) * 3
+      + Math.round(Number(stat.play_count || 0) / 1000);
+  }
+
+  private buildTopicAngle(work?: NormalizedWork): string {
+    const sample = (work?.commentSamples || [])[0];
+    if (sample) {
+      return `参考「${work?.authorName || '监测博主'}」的高互动作品,从评论里的真实关注点切入:${sample}`;
+    }
+    return `参考「${work?.authorName || '监测博主'}」的高互动作品,从同类用户关注点切入。`;
+  }
+
+  private buildTopicOutline(work: NormalizedWork): string {
+    const sample = (work.commentSamples || [])[0];
+    return sample
+      ? `开头提出评论中的具体疑问/情绪 -> 回到作品场景解释原因 -> 给出判断或建议 -> 抛出同类问题`
+      : '开头提出具体场景或观点 -> 展开用户痛点/反差 -> 给出判断或建议 -> 抛出评论问题';
+  }
+
+  private extractCommentSamples(payload: unknown): CommentSampleLike[] {
+    const normalized = this.parseJson(payload);
+    const data = this.parseJson(normalized?.data);
+    const nested = this.parseJson(data?.data);
+    const source = nested || data || normalized || {};
+    const comments = Array.isArray(source?.comments)
+      ? source.comments
+      : Array.isArray(source?.comment_list)
+        ? source.comment_list
+        : [];
+    return comments
+      .map((item: any) => ({
+        id: String(item?.cid || item?.comment_id || item?.id || '').trim(),
+        text: String(item?.text || item?.content || '').replace(/\s+/g, ' ').trim(),
+        likeCount: Number(item?.digg_count || item?.like_count || 0),
+        replyCount: Number(item?.reply_comment_total || item?.reply_count || 0),
+      }))
+      .filter((item: CommentSampleLike) => !!item.text)
+      .sort((a: CommentSampleLike, b: CommentSampleLike) => (b.likeCount - a.likeCount) || (b.replyCount - a.replyCount));
+  }
+
+  private extractReplyTexts(payload: unknown): string[] {
+    const normalized = this.parseJson(payload);
+    const data = this.parseJson(normalized?.data);
+    const nested = this.parseJson(data?.data);
+    const source = nested || data || normalized || {};
+    const comments = Array.isArray(source?.comments)
+      ? source.comments
+      : Array.isArray(source?.comment_list)
+        ? source.comment_list
+        : [];
+    return comments
+      .map((item: any) => String(item?.text || item?.content || '').replace(/\s+/g, ' ').trim())
+      .filter(Boolean);
+  }
+
+  private parseJson(value: any): any {
+    let current = value;
+    while (typeof current === 'string') {
+      const trimmed = current.trim();
+      if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) break;
+      try {
+        current = JSON.parse(trimmed);
+      } catch {
+        break;
+      }
+    }
+    return current;
+  }
+
+  private cleanTitle(value: string): string {
+    return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 80);
+  }
+
+  private countBy(values: string[]): Array<{ label: string; count: number }> {
+    const counts = new Map<string, number>();
+    values.filter(Boolean).forEach((value) => counts.set(value, (counts.get(value) || 0) + 1));
+    return [...counts.entries()]
+      .map(([label, count]) => ({ label, count }))
+      .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
+  }
+
+  private oneLineJudgement(topWorks: NormalizedWork[], trendSignals: Record<string, any>): string {
+    const first = topWorks[0];
+    if (first) {
+      return `今天优先围绕「${this.cleanTitle(first.desc) || first.authorName}」这类高互动内容做跟进,先复用评论问题明确、互动证据较强的方向。`;
+    }
+    return trendSignals?.['signals']?.[0] || '当前样本不足,建议先刷新博主作品后再生成正式日报。';
+  }
+
+  private requireUserId(): string {
+    const userId = this.auth.currentUser?.objectId;
+    if (!userId) {
+      this.auth.requestLogin('博主监测日报');
+      throw new Error('请先登录后生成日报');
+    }
+    return userId;
+  }
+
+  private readAll(): DailyReport[] {
+    try {
+      const raw = localStorage.getItem(DAILY_REPORT_STORAGE_KEY);
+      return raw ? JSON.parse(raw) as DailyReport[] : [];
+    } catch {
+      return [];
+    }
+  }
+
+  private writeAll(items: DailyReport[]): void {
+    localStorage.setItem(DAILY_REPORT_STORAGE_KEY, JSON.stringify(items));
+  }
+
+  private createId(prefix: string): string {
+    return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+  }
+}

+ 86 - 0
src/app/services/douyin-insight.service.ts

@@ -0,0 +1,86 @@
+import { Injectable } from '@angular/core';
+import { AuthCreditService } from './auth-credit.service';
+import { CLOUD_FN } from './cloud-functions';
+import { ParseService } from './parse.service';
+import { DailyReport, TopicIdea, ViralAnalysis } from '../models/douyin-insight.model';
+
+type InsightAction =
+  | 'analysisCreate'
+  | 'analysisList'
+  | 'analysisGet'
+  | 'analysisUpdate'
+  | 'topicCreate'
+  | 'topicList'
+  | 'topicUpdate'
+  | 'topicArchive'
+  | 'dailyReportCreate'
+  | 'dailyReportList'
+  | 'dailyReportGet';
+
+@Injectable({ providedIn: 'root' })
+export class DouyinInsightService {
+  constructor(
+    private parse: ParseService,
+    private auth: AuthCreditService,
+  ) {}
+
+  get available(): boolean {
+    return !!CLOUD_FN.douyinInsight;
+  }
+
+  async createAnalysis(analysis: ViralAnalysis): Promise<ViralAnalysis | null> {
+    if (!this.available) return null;
+    return this.call<ViralAnalysis>('analysisCreate', { analysis });
+  }
+
+  async updateAnalysis(id: string, patch: Partial<ViralAnalysis>): Promise<ViralAnalysis | null> {
+    if (!this.available) return null;
+    return this.call<ViralAnalysis>('analysisUpdate', { id, patch });
+  }
+
+  async listAnalyses(limit = 100): Promise<ViralAnalysis[]> {
+    if (!this.available) return [];
+    return this.call<ViralAnalysis[]>('analysisList', { limit });
+  }
+
+  async createTopic(topic: TopicIdea): Promise<TopicIdea | null> {
+    if (!this.available) return null;
+    return this.call<TopicIdea>('topicCreate', { topic });
+  }
+
+  async updateTopic(id: string, patch: Partial<TopicIdea>): Promise<TopicIdea | null> {
+    if (!this.available) return null;
+    return this.call<TopicIdea>('topicUpdate', { id, patch });
+  }
+
+  async archiveTopic(id: string): Promise<TopicIdea | null> {
+    if (!this.available) return null;
+    return this.call<TopicIdea>('topicArchive', { id });
+  }
+
+  async listTopics(limit = 500): Promise<TopicIdea[]> {
+    if (!this.available) return [];
+    return this.call<TopicIdea[]>('topicList', { limit });
+  }
+
+  async createDailyReport(report: DailyReport): Promise<DailyReport | null> {
+    if (!this.available) return null;
+    return this.call<DailyReport>('dailyReportCreate', { report });
+  }
+
+  async listDailyReports(limit = 100): Promise<DailyReport[]> {
+    if (!this.available) return [];
+    return this.call<DailyReport[]>('dailyReportList', { limit });
+  }
+
+  private async call<T>(action: InsightAction, params: Record<string, any>): Promise<T> {
+    const session = this.auth.session;
+    if (!session) throw new Error('请先登录');
+    return this.parse.callOrThrow<T>(CLOUD_FN.douyinInsight, {
+      action,
+      ...params,
+      userId: session.user.objectId,
+      sessionToken: session.token,
+    });
+  }
+}

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

@@ -20,6 +20,7 @@ export class GenerationTaskService {
     operation: string;
     steps?: Array<Pick<GenerationTaskStep, 'id' | 'label'>>;
     estimatedCredits?: number;
+    costLines?: GenerationTaskCost['lines'];
     snapshot?: any;
   }): GenerationTask {
     const now = Date.now();
@@ -36,6 +37,7 @@ export class GenerationTaskService {
       cost: {
         estimatedCredits: Math.max(0, Math.ceil(Number(input.estimatedCredits || 0))),
         status: input.estimatedCredits ? 'estimated' : 'unknown',
+        lines: input.costLines,
       },
       createdAt: now,
       updatedAt: now,
@@ -76,6 +78,18 @@ export class GenerationTaskService {
     });
   }
 
+  markStepCompleted(id: string, stepId: string, progress?: number): void {
+    const task = this.find(id);
+    if (!task) return;
+    this.patch(id, {
+      currentStepId: stepId,
+      progress: progress ?? task.progress,
+      steps: task.steps.map((step) => step.id === stepId
+        ? { ...step, status: 'completed', progress: 100, finishedAt: Date.now(), retryable: false }
+        : step),
+    });
+  }
+
   markCompleted(id: string, resultUrl?: string): void {
     this.patch(id, {
       status: 'completed',
@@ -85,6 +99,7 @@ export class GenerationTaskService {
       recoverable: false,
       errorMessage: undefined,
       failureSuggestion: undefined,
+      archivedAt: Date.now(),
     });
   }
 
@@ -129,6 +144,8 @@ export class GenerationTaskService {
       estimatedCredits: Math.max(0, Math.ceil(Number(raw.estimatedCredits ?? raw.cost ?? 0))),
       reservedCredits: raw.reservedCredits,
       creditStatus,
+      costLines: raw.costLines,
+      refundReason: raw.refundReason,
       error_message: errorMessage,
       failureSuggestion: raw.failureSuggestion || (raw.error_message ? userFailureSuggestion(raw.error_message) : undefined),
       retryable: raw.retryable ?? (raw.status === 'failed' || raw.status === 'timeout'),
@@ -157,6 +174,8 @@ export class GenerationTaskService {
         reservedCredits: normalized.reservedCredits,
         reservationId: normalized.creditReservationId,
         status: normalized.creditStatus || 'unknown',
+        lines: normalized.costLines,
+        refundReason: normalized.refundReason,
       },
       createdAt: new Date(normalized.created_at).getTime() || Date.now(),
       updatedAt: new Date(normalized.updated_at).getTime() || Date.now(),

+ 11 - 14
src/app/services/jimeng.service.ts

@@ -301,7 +301,7 @@ export class JimengService {
       cameraStrength?: JimengCameraStrength;
       aspectRatio?: JimengAspectRatio;
     },
-    onProgress?: (status: string, progress: number) => void,
+    onProgress?: (status: string, progress: number, meta?: Record<string, any>) => void,
     options?: { scale?: number; forceSingle?: boolean }
   ): Observable<{ videoUrl: string; workId: string }> {
     const seconds = this.framesToSeconds(params.frames || 121);
@@ -325,7 +325,7 @@ export class JimengService {
       cameraStrength?: JimengCameraStrength;
       aspectRatio?: JimengAspectRatio;
     },
-    onProgress?: (status: string, progress: number) => void,
+    onProgress?: (status: string, progress: number, meta?: Record<string, any>) => void,
     options?: { scale?: number; forceSingle?: boolean }
   ): Observable<{ videoUrl: string; workId: string }> {
     // ---- 校验 ----
@@ -386,6 +386,7 @@ export class JimengService {
           throw new Error(res?.msg || '图生视频任务提交失败');
         }
         const workId = res.data.workId;
+        if (onProgress) onProgress('external-task-id', 15, { workId, routerName });
         if (onProgress) onProgress('任务已提交,正在生成视频...', 15);
 
         return this.pollUntilComplete(workId, routerName, (status, attempt) => {
@@ -569,7 +570,7 @@ export class JimengService {
 
   pollDigitalHumanIdentifyUntilReady(
     workId: string,
-    onProgress?: (status: string, progress: number) => void
+    onProgress?: (status: string, progress: number, meta?: Record<string, any>) => void
   ): Observable<void> {
     return new Observable<void>(observer => {
       let attempt = 0;
@@ -629,7 +630,7 @@ export class JimengService {
   pollDigitalHumanGenerateUntilComplete(
     workId: string,
     resultWorkId: string,
-    onProgress?: (status: string, progress: number) => void
+    onProgress?: (status: string, progress: number, meta?: Record<string, any>) => void
   ): Observable<{ videoUrl: string; workId: string }> {
     return new Observable<{ videoUrl: string; workId: string }>(observer => {
       let attempt = 0;
@@ -802,7 +803,7 @@ export class JimengService {
       outputResolution?: '720p' | '1080p';
       maskUrl?: string[];
     } = {},
-    onProgress?: (status: string, progress: number) => void
+    onProgress?: (status: string, progress: number, meta?: Record<string, any>) => void
   ): Observable<{ videoUrl: string; workId: string }> {
     const outputResolution = options.outputResolution === '720p' ? 720 : 1080;
     const body = {
@@ -969,7 +970,7 @@ export class JimengService {
       imageUrl?: string;
       quality?: JimengVideoQuality;
     } = {},
-    onProgress?: (status: string, progress: number) => void
+    onProgress?: (status: string, progress: number, meta?: Record<string, any>) => void
   ): Observable<{ videoUrl: string; workId: string }> {
     const method = options.method || '1';
     const quality = options.quality || '1080p';
@@ -993,7 +994,7 @@ export class JimengService {
       imageUrl?: string;
       quality?: JimengVideoQuality;
     } = {},
-    onProgress?: (status: string, progress: number) => void
+    onProgress?: (status: string, progress: number, meta?: Record<string, any>) => void
   ): Observable<{ videoUrl: string; workId: string }> {
     const method = options.method || '1';
     const quality = options.quality || '1080p';
@@ -1090,7 +1091,7 @@ export class JimengService {
     imageUrl: string,
     videoUrl: string,
     options: { cutFirstSecond?: boolean } = {},
-    onProgress?: (status: string, progress: number) => void
+    onProgress?: (status: string, progress: number, meta?: Record<string, any>) => void
   ): Observable<{ videoUrl: string; workId: string }> {
     return this.withCreditGate(
       'jimeng.actionTransfer',
@@ -1104,12 +1105,13 @@ export class JimengService {
     imageUrl: string,
     videoUrl: string,
     options: { cutFirstSecond?: boolean } = {},
-    onProgress?: (status: string, progress: number) => void
+    onProgress?: (status: string, progress: number, meta?: Record<string, any>) => void
   ): Observable<{ videoUrl: string; workId: string }> {
     if (onProgress) onProgress('正在提交动作迁移任务...', 5);
 
     return this.submitActionTransfer(imageUrl, videoUrl, options).pipe(
       switchMap(({ workId }) => {
+        if (onProgress) onProgress('external-task-id', 15, { workId, routerName: 'getActorV2' });
         if (onProgress) onProgress('任务已提交,正在处理动作迁移...', 15);
 
         return this.pollUntilComplete(workId, 'getActorV2', (status, attempt) => {
@@ -1254,11 +1256,6 @@ export class JimengService {
     run: () => Observable<T>
   ): Observable<T> {
     const normalizedCost = Math.max(1, Math.ceil(Number(cost || 0)));
-    if (!this.authCredit.isLoggedIn) {
-      this.authCredit.requestLogin(title);
-      return throwError(() => new Error('请先登录后使用该功能'));
-    }
-
     let reservationId = '';
     return from(this.authCredit.reserveCredit(operation, normalizedCost, title)).pipe(
       switchMap((reserved) => {

+ 36 - 0
src/app/services/pipeline-session.service.ts

@@ -113,6 +113,42 @@ export class PipelineSessionService {
     return draft;
   }
 
+  async createArchivedDraft<T>(input: {
+    pipelineId: PipelineRoute;
+    title: string;
+    snapshot: T;
+    resultUrl?: string;
+    thumbnail?: string;
+    artifactTitle?: string;
+    artifactExtras?: Record<string, any>;
+    status?: DraftStatus;
+  }): Promise<PipelineDraft<T>> {
+    const now = Date.now();
+    const artifacts: Artifact[] = input.resultUrl ? [{
+      id: newArtifactId(),
+      type: 'video',
+      url: input.resultUrl,
+      title: input.artifactTitle || '成片',
+      createdAt: now,
+      extras: input.artifactExtras,
+    }] : [];
+    const draft: PipelineDraft<T> = {
+      id: newDraftId(input.pipelineId),
+      pipelineId: input.pipelineId,
+      title: input.title?.trim() || '未命名创作',
+      createdAt: now,
+      updatedAt: now,
+      status: input.status || (input.resultUrl ? 'completed' : 'draft'),
+      thumbnail: input.thumbnail,
+      resultUrl: input.resultUrl,
+      artifacts,
+      snapshot: input.snapshot,
+    };
+    await this.storage.put(draft as PipelineDraft);
+    await this.refreshList();
+    return draft;
+  }
+
   /** 打开一个已有 draft */
   async open(id: string): Promise<PipelineDraft | null> {
     const d = await this.storage.get(id);

+ 90 - 0
src/app/services/publication-data-sync.service.ts

@@ -0,0 +1,90 @@
+import { Injectable } from '@angular/core';
+import { firstValueFrom } from 'rxjs';
+import { PublicationMetrics, PublicationRecord } from '../models/publication-record.model';
+import { DouyinApiService } from './douyin-api.service';
+import { RetrospectiveService } from './retrospective.service';
+
+@Injectable({ providedIn: 'root' })
+export class PublicationDataSyncService {
+  constructor(
+    private douyinApi: DouyinApiService,
+    private retros: RetrospectiveService,
+  ) {}
+
+  async refresh(record: PublicationRecord): Promise<PublicationRecord> {
+    if (record.platform !== 'douyin') {
+      throw new Error('当前仅支持抖音作品数据抓取');
+    }
+    const awemeId = this.extractAwemeId(record.url || record.assetUrl || '');
+    if (!awemeId) {
+      throw new Error('未从发布链接中识别到抖音作品 ID');
+    }
+    const payload = await firstValueFrom(this.douyinApi.getVideoDetail(awemeId));
+    const detail = this.extractDetail(payload);
+    const metrics = this.extractMetrics(detail);
+    return this.retros.updateMetrics(record.id, metrics);
+  }
+
+  private extractAwemeId(url: string): string {
+    const value = String(url || '').trim();
+    const patterns = [
+      /douyin\.com\/video\/(\d+)/,
+      /modal_id=(\d+)/,
+      /aweme_id=(\d+)/,
+      /share\/video\/(\d+)/,
+      /^(\d{8,})$/,
+    ];
+    for (const pattern of patterns) {
+      const match = value.match(pattern);
+      if (match?.[1]) return match[1];
+    }
+    return '';
+  }
+
+  private extractDetail(payload: any): any {
+    const normalized = this.parseJson(payload);
+    const data = this.parseJson(normalized?.data);
+    const nested = this.parseJson(data?.data);
+    return this.parseJson(
+      data?.aweme_detail
+      || data?.aweme_info
+      || data?.aweme_details?.[0]
+      || nested?.aweme_detail
+      || nested?.aweme_info
+      || nested?.aweme_details?.[0]
+      || nested
+      || data
+      || normalized
+      || {}
+    );
+  }
+
+  private extractMetrics(detail: any): PublicationMetrics {
+    const stats = detail?.statistics || detail?.stats || detail?.statistics_info || {};
+    return {
+      views: this.num(stats.play_count ?? stats.view_count ?? stats.display_count ?? detail?.play_count),
+      likes: this.num(stats.digg_count ?? stats.like_count ?? detail?.digg_count),
+      comments: this.num(stats.comment_count ?? detail?.comment_count),
+      favorites: this.num(stats.collect_count ?? stats.favorite_count ?? detail?.collect_count),
+      shares: this.num(stats.share_count ?? detail?.share_count),
+    };
+  }
+
+  private parseJson(value: any): any {
+    let current = value;
+    while (typeof current === 'string') {
+      const text = current.trim();
+      if (!text || (!text.startsWith('{') && !text.startsWith('['))) break;
+      try {
+        current = JSON.parse(text);
+      } catch {
+        break;
+      }
+    }
+    return current;
+  }
+
+  private num(value: any): number {
+    return Math.max(0, Math.floor(Number(value || 0)));
+  }
+}

+ 141 - 0
src/app/services/retrospective.service.ts

@@ -0,0 +1,141 @@
+import { Injectable } from '@angular/core';
+import { BehaviorSubject } from 'rxjs';
+import { PublicationMetrics, PublicationRecord, PublicationRecordInput } from '../models/publication-record.model';
+
+const STORAGE_KEY = 'videoWorkflow.publicationRecords.v1';
+
+@Injectable({ providedIn: 'root' })
+export class RetrospectiveService {
+  private readonly recordsSubject = new BehaviorSubject<PublicationRecord[]>(this.restore());
+  readonly records$ = this.recordsSubject.asObservable();
+
+  get records(): PublicationRecord[] {
+    return this.recordsSubject.value;
+  }
+
+  list(): PublicationRecord[] {
+    return [...this.records].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
+  }
+
+  save(input: PublicationRecordInput, id?: string): PublicationRecord {
+    const current = id ? this.records.find((item) => item.id === id) : undefined;
+    const now = new Date().toISOString();
+    const metrics = this.normalizeMetrics(input.metrics);
+    const score = this.score(metrics, input.rating);
+    const record: PublicationRecord = {
+      id: current?.id || `pub_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
+      assetKey: input.assetKey,
+      assetTitle: input.assetTitle,
+      assetUrl: input.assetUrl,
+      pipelineId: input.pipelineId,
+      templateId: input.templateId,
+      projectName: input.projectName || '',
+      tags: input.tags || [],
+      platform: input.platform,
+      publishedAt: input.publishedAt,
+      url: input.url || '',
+      metrics,
+      score,
+      rating: Number(input.rating || 0),
+      notes: input.notes || '',
+      diagnosis: this.diagnose(metrics, score),
+      suggestions: this.suggest(input.assetTitle, metrics, score),
+      createdAt: current?.createdAt || now,
+      updatedAt: now,
+    };
+    const next = current
+      ? this.records.map((item) => item.id === record.id ? record : item)
+      : [record, ...this.records];
+    this.setRecords(next);
+    return record;
+  }
+
+  updateMetrics(id: string, metrics: PublicationMetrics): PublicationRecord {
+    const current = this.records.find((item) => item.id === id);
+    if (!current) throw new Error('未找到复盘记录');
+    const normalized = this.normalizeMetrics(metrics);
+    const score = this.score(normalized, current.rating);
+    const next: PublicationRecord = {
+      ...current,
+      metrics: normalized,
+      score,
+      diagnosis: this.diagnose(normalized, score),
+      suggestions: this.suggest(current.assetTitle, normalized, score),
+      updatedAt: new Date().toISOString(),
+    };
+    this.setRecords(this.records.map((item) => item.id === id ? next : item));
+    return next;
+  }
+
+  aggregate(records: PublicationRecord[]): { totalViews: number; totalEngagement: number; avgScore: number } {
+    const totalViews = records.reduce((sum, item) => sum + item.metrics.views, 0);
+    const totalEngagement = records.reduce((sum, item) => sum + item.metrics.likes + item.metrics.comments + item.metrics.favorites + item.metrics.shares, 0);
+    const avgScore = records.length ? Math.round(records.reduce((sum, item) => sum + item.score, 0) / records.length) : 0;
+    return { totalViews, totalEngagement, avgScore };
+  }
+
+  private normalizeMetrics(metrics: PublicationMetrics): PublicationMetrics {
+    return {
+      views: this.nonNegative(metrics.views),
+      likes: this.nonNegative(metrics.likes),
+      comments: this.nonNegative(metrics.comments),
+      favorites: this.nonNegative(metrics.favorites),
+      shares: this.nonNegative(metrics.shares),
+    };
+  }
+
+  private score(metrics: PublicationMetrics, rating = 0): number {
+    const views = Math.max(1, metrics.views);
+    const engagementRate = (metrics.likes + metrics.comments * 2 + metrics.favorites * 3 + metrics.shares * 3) / views;
+    const viewScore = Math.min(45, Math.log10(views + 1) * 12);
+    const interactionScore = Math.min(45, engagementRate * 900);
+    const userScore = Math.min(10, Math.max(0, Number(rating || 0)) * 2);
+    return Math.round(viewScore + interactionScore + userScore);
+  }
+
+  private diagnose(metrics: PublicationMetrics, score: number): string[] {
+    const views = Math.max(1, metrics.views);
+    const likeRate = metrics.likes / views;
+    const saveRate = metrics.favorites / views;
+    const shareRate = metrics.shares / views;
+    const result: string[] = [];
+    if (score >= 80) result.push('整体表现强,可以复用选题角度和开头结构。');
+    if (views < 1000) result.push('播放基数偏低,优先复盘封面、标题和前 3 秒钩子。');
+    if (likeRate < 0.02) result.push('点赞率偏低,内容爽点或情绪共鸣可能不够明确。');
+    if (saveRate >= 0.01) result.push('收藏率有亮点,适合延展为教程、清单或系列内容。');
+    if (shareRate >= 0.005) result.push('分享率较好,可以提炼成更强社交货币表达。');
+    return result.length ? result : ['表现中性,建议对比同项目其它作品再判断。'];
+  }
+
+  private suggest(title: string, metrics: PublicationMetrics, score: number): string[] {
+    if (score >= 80) {
+      return [
+        `复刻「${title}」的开头冲突,换一个目标人群再做一条。`,
+        '把评论区高频问题拆成 3 条连续选题。',
+      ];
+    }
+    if (metrics.views < 1000) {
+      return ['重写标题和前 3 秒钩子,先做同主题 A/B 版本。'];
+    }
+    return ['保留主题,调整结构节奏和结尾行动点后再测一条。'];
+  }
+
+  private nonNegative(value: number): number {
+    return Math.max(0, Math.floor(Number(value || 0)));
+  }
+
+  private restore(): PublicationRecord[] {
+    try {
+      const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
+      return Array.isArray(parsed) ? parsed : [];
+    } catch {
+      return [];
+    }
+  }
+
+  private setRecords(records: PublicationRecord[]): void {
+    const sorted = [...records].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)).slice(0, 500);
+    this.recordsSubject.next(sorted);
+    localStorage.setItem(STORAGE_KEY, JSON.stringify(sorted));
+  }
+}

+ 243 - 0
src/app/services/template.service.ts

@@ -0,0 +1,243 @@
+import { Injectable } from '@angular/core';
+import { AuthCreditService } from './auth-credit.service';
+import { GenerationTemplate, GenerationTemplateInput } from '../models/template.model';
+import { PipelineRoute } from '../pipelines/pipeline-registry';
+
+const STORAGE_KEY = 'videoWorkflow.templates.user.v1';
+
+@Injectable({ providedIn: 'root' })
+export class TemplateService {
+  private readonly systemTemplates: GenerationTemplate[] = this.buildSystemTemplates();
+
+  constructor(private auth: AuthCreditService) {}
+
+  listTemplates(): GenerationTemplate[] {
+    const userId = this.auth.currentUser?.objectId || '';
+    const userTemplates = this.readUserTemplates().filter((item) => item.userId === userId);
+    return [...this.systemTemplates, ...userTemplates].sort((a, b) => {
+      if (a.scope !== b.scope) return a.scope === 'system' ? -1 : 1;
+      return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
+    });
+  }
+
+  listByPipeline(pipelineId: PipelineRoute): GenerationTemplate[] {
+    return this.listTemplates().filter((item) => item.pipelineId === pipelineId);
+  }
+
+  createUserTemplate(input: GenerationTemplateInput): GenerationTemplate {
+    const userId = this.requireUserId();
+    const now = new Date().toISOString();
+    const template: GenerationTemplate = {
+      ...input,
+      id: input.id || this.createId(),
+      userId,
+      scope: 'user',
+      tags: this.uniqueTags(input.tags || []),
+      config: this.cloneSerializable(input.config || {}),
+      version: input.version || 1,
+      usageCount: 0,
+      createdAt: now,
+      updatedAt: now,
+    };
+    const items = this.readUserTemplates();
+    items.unshift(template);
+    this.writeUserTemplates(items);
+    return template;
+  }
+
+  cloneToUser(templateId: string): GenerationTemplate {
+    const source = this.listTemplates().find((item) => item.id === templateId);
+    if (!source) throw new Error('未找到模板');
+    return this.createUserTemplate({
+      pipelineId: source.pipelineId,
+      name: `${source.name} 副本`,
+      description: source.description || '',
+      category: source.category,
+      tags: source.tags || [],
+      config: this.cloneSerializable(source.config || {}),
+      preview: source.preview,
+      version: source.version,
+    });
+  }
+
+  updateUserTemplate(id: string, patch: Partial<GenerationTemplateInput>): GenerationTemplate {
+    const userId = this.requireUserId();
+    const items = this.readUserTemplates();
+    const index = items.findIndex((item) => item.id === id && item.userId === userId);
+    if (index < 0) throw new Error('未找到可编辑的模板');
+    const updated: GenerationTemplate = {
+      ...items[index],
+      ...patch,
+      id: items[index].id,
+      userId,
+      scope: 'user',
+      tags: patch.tags ? this.uniqueTags(patch.tags) : items[index].tags,
+      config: patch.config ? this.cloneSerializable(patch.config) : items[index].config,
+      updatedAt: new Date().toISOString(),
+    };
+    items[index] = updated;
+    this.writeUserTemplates(items);
+    return updated;
+  }
+
+  deleteUserTemplate(id: string): void {
+    const userId = this.requireUserId();
+    this.writeUserTemplates(this.readUserTemplates().filter((item) => !(item.id === id && item.userId === userId)));
+  }
+
+  markUsed(id: string): void {
+    const userId = this.auth.currentUser?.objectId || '';
+    const items = this.readUserTemplates();
+    const index = items.findIndex((item) => item.id === id && item.userId === userId);
+    if (index < 0) return;
+    items[index] = {
+      ...items[index],
+      usageCount: Number(items[index].usageCount || 0) + 1,
+      updatedAt: new Date().toISOString(),
+    };
+    this.writeUserTemplates(items);
+  }
+
+  private requireUserId(): string {
+    const userId = this.auth.currentUser?.objectId;
+    if (!userId) {
+      this.auth.requestLogin('模板库');
+      throw new Error('请先登录后保存模板');
+    }
+    return userId;
+  }
+
+  private readUserTemplates(): GenerationTemplate[] {
+    try {
+      const raw = localStorage.getItem(STORAGE_KEY);
+      const parsed = raw ? JSON.parse(raw) : [];
+      return Array.isArray(parsed) ? parsed : [];
+    } catch {
+      return [];
+    }
+  }
+
+  private writeUserTemplates(items: GenerationTemplate[]): void {
+    localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
+  }
+
+  private createId(): string {
+    return `tpl_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+  }
+
+  private uniqueTags(tags: string[]): string[] {
+    return tags.map((item) => String(item || '').trim()).filter((item, index, list) => !!item && list.indexOf(item) === index).slice(0, 12);
+  }
+
+  private cloneSerializable<T>(value: T): T {
+    return JSON.parse(JSON.stringify(value || {}));
+  }
+
+  private buildSystemTemplates(): GenerationTemplate[] {
+    const now = '2026-06-01T00:00:00.000Z';
+    return [
+      {
+        id: 'sys_topic_knowledge_short',
+        scope: 'system',
+        pipelineId: 'topic-to-video',
+        name: '知识口播短平快',
+        description: '适合把一个观点拆成 3-5 个短分镜,节奏清晰,适合日更知识内容。',
+        category: '知识科普',
+        tags: ['口播', '知识', '日更'],
+        config: {
+          nScenes: 4,
+          aspect: 'portrait',
+          quickMode: true,
+          mediaMode: 'image',
+          clipFrames: 121,
+          clipQuality: '720p',
+          continuityEnabled: true,
+          visualBible: {
+            subject: 'confident presenter or clean symbolic 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',
+          },
+          scriptFrame: '开头提出反常识观点 -> 三点解释 -> 结尾抛出行动建议',
+        },
+        version: 1,
+        usageCount: 0,
+        createdAt: now,
+        updatedAt: now,
+      },
+      {
+        id: 'sys_topic_product_seed',
+        scope: 'system',
+        pipelineId: 'topic-to-video',
+        name: '产品种草结构',
+        description: '从用户痛点切入,突出使用场景、差异点和购买理由。',
+        category: '产品种草',
+        tags: ['种草', '产品', '转化'],
+        config: {
+          nScenes: 5,
+          aspect: 'portrait',
+          quickMode: true,
+          mediaMode: 'video',
+          clipFrames: 121,
+          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',
+          },
+          scriptFrame: '痛点场景 -> 产品出现 -> 三个卖点 -> 使用前后对比 -> 轻 CTA',
+        },
+        version: 1,
+        usageCount: 0,
+        createdAt: now,
+        updatedAt: now,
+      },
+      {
+        id: 'sys_image_cover_clean',
+        scope: 'system',
+        pipelineId: 'image-generation',
+        name: '清爽封面图',
+        description: '适合生成视频封面、知识卡片和素材库配图。',
+        category: '其他',
+        tags: ['封面', '信息图', '浅色'],
+        config: {
+          width: 1440,
+          height: 1920,
+          stylePrompt: '清爽浅色背景,主体明确,大标题留白,少量高对比强调色',
+        },
+        version: 1,
+        usageCount: 0,
+        createdAt: now,
+        updatedAt: now,
+      },
+      {
+        id: 'sys_dh_explainer',
+        scope: 'system',
+        pipelineId: 'digital-human',
+        name: '数字人讲解',
+        description: '适合课程介绍、产品说明、短口播讲解。',
+        category: '口播',
+        tags: ['数字人', '讲解', '口播'],
+        config: {
+          resolution: '1080p',
+          scriptFrame: '问候 -> 背景问题 -> 核心说明 -> 适用人群 -> 行动建议',
+          voiceMood: '自然、清晰、可信',
+        },
+        version: 1,
+        usageCount: 0,
+        createdAt: now,
+        updatedAt: now,
+      },
+    ];
+  }
+}

+ 105 - 0
src/app/services/topic-pool.service.ts

@@ -0,0 +1,105 @@
+import { Injectable } from '@angular/core';
+import { AuthCreditService } from './auth-credit.service';
+import { DouyinInsightService } from './douyin-insight.service';
+import { TopicIdea } from '../models/douyin-insight.model';
+
+const TOPIC_POOL_STORAGE_KEY = 'videoWorkflow.topicPool.items';
+
+@Injectable({ providedIn: 'root' })
+export class TopicPoolService {
+  constructor(
+    private auth: AuthCreditService,
+    private insight: DouyinInsightService,
+  ) {}
+
+  listTopics(): TopicIdea[] {
+    const userId = this.requireUserId();
+    return this.readAll()
+      .filter((item) => item.userId === userId)
+      .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
+  }
+
+  async refreshFromCloud(): Promise<TopicIdea[]> {
+    const userId = this.requireUserId();
+    const cloudItems = await this.insight.listTopics();
+    if (!cloudItems.length) return this.listTopics();
+    const others = this.readAll().filter((item) => item.userId !== userId);
+    const next = [...others, ...cloudItems];
+    this.writeAll(next);
+    return cloudItems.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
+  }
+
+  saveTopic(input: Omit<TopicIdea, 'id' | 'userId' | 'createdAt' | 'updatedAt'>): TopicIdea {
+    const userId = this.requireUserId();
+    const now = new Date().toISOString();
+    const topic: TopicIdea = {
+      ...input,
+      id: this.createId(),
+      userId,
+      createdAt: now,
+      updatedAt: now,
+    };
+    const items = this.readAll();
+    items.unshift(topic);
+    this.writeAll(items);
+    this.mirrorCreate(topic);
+    return topic;
+  }
+
+  updateTopic(id: string, patch: Partial<TopicIdea>): TopicIdea {
+    const userId = this.requireUserId();
+    const items = this.readAll();
+    const index = items.findIndex((item) => item.id === id && item.userId === userId);
+    if (index < 0) throw new Error('未找到该选题');
+    const updated: TopicIdea = {
+      ...items[index],
+      ...patch,
+      id: items[index].id,
+      userId,
+      updatedAt: new Date().toISOString(),
+    };
+    items[index] = updated;
+    this.writeAll(items);
+    this.mirrorUpdate(id, patch);
+    return updated;
+  }
+
+  archiveTopic(id: string): TopicIdea {
+    return this.updateTopic(id, { status: 'archived' });
+  }
+
+  private mirrorCreate(topic: TopicIdea): void {
+    if (!this.auth.isLoggedIn) return;
+    this.insight.createTopic(topic).catch((err) => {
+      console.warn('[TopicPoolService] 云端保存选题失败,已保留本地数据:', err?.message || err);
+    });
+  }
+
+  private mirrorUpdate(id: string, patch: Partial<TopicIdea>): void {
+    if (!this.auth.isLoggedIn) return;
+    this.insight.updateTopic(id, patch).catch((err) => {
+      console.warn('[TopicPoolService] 云端更新选题失败,已保留本地数据:', err?.message || err);
+    });
+  }
+
+  private requireUserId(): string {
+    return this.auth.currentUser?.objectId || 'local-guest';
+  }
+
+  private readAll(): TopicIdea[] {
+    try {
+      const raw = localStorage.getItem(TOPIC_POOL_STORAGE_KEY);
+      return raw ? JSON.parse(raw) as TopicIdea[] : [];
+    } catch {
+      return [];
+    }
+  }
+
+  private writeAll(items: TopicIdea[]): void {
+    localStorage.setItem(TOPIC_POOL_STORAGE_KEY, JSON.stringify(items));
+  }
+
+  private createId(): string {
+    return `topic_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+  }
+}

+ 130 - 0
src/app/services/topic-to-video-batch-runner.service.ts

@@ -0,0 +1,130 @@
+import { Injectable } from '@angular/core';
+import { firstValueFrom } from 'rxjs';
+import { BatchJob, BatchJobItem, BatchJobRunResult } from '../models/batch-job.model';
+import { GenerationTaskService } from './generation-task.service';
+import { JimengService, JimengVideoQuality } from './jimeng.service';
+import { PipelineSessionService } from './pipeline-session.service';
+
+@Injectable({ providedIn: 'root' })
+export class TopicToVideoBatchRunnerService {
+  constructor(
+    private generationTasks: GenerationTaskService,
+    private jimeng: JimengService,
+    private sessions: PipelineSessionService,
+  ) {}
+
+  async run(item: BatchJobItem, job: BatchJob): Promise<BatchJobRunResult> {
+    const input = item.input || {};
+    const templateConfig = this.safeObject(input['templateConfig']);
+    const topic = String(input['topic'] || item.title || '').trim();
+    const quality = this.normalizeQuality(templateConfig['clipQuality']);
+    const frames = Number(templateConfig['clipFrames'] || 121) >= 241 ? 241 : 121;
+    const aspectRatio = this.normalizeAspect(templateConfig['aspect']);
+
+    const task = this.generationTasks.create({
+      title: topic || '批量主题生视频',
+      pipelineId: job.pipelineId,
+      operation: 'batch-topic-to-video-real',
+      estimatedCredits: 0,
+      steps: [
+        { id: 'prepare', label: '整理主题' },
+        { id: 'submit', label: '提交真实生成' },
+        { id: 'external', label: '等待外部结果' },
+        { id: 'archive', label: '归档结果' },
+      ],
+      snapshot: {
+        source: 'batch-production',
+        batchJobId: job.id,
+        batchItemId: item.id,
+        topicId: item.topicId,
+        topic,
+        templateId: input['templateId'] || job.templateId || '',
+        templateConfig,
+        billing: 'disabled',
+      },
+    });
+
+    this.generationTasks.markRunning(task.id, 'submit', 8);
+
+    const prompt = this.buildPrompt(topic, templateConfig);
+    const result = await firstValueFrom(this.jimeng.remixVideo(prompt, {
+      method: '1',
+      frames,
+      aspectRatio,
+      quality,
+    }, (status, progress, meta) => {
+      if (meta?.['workId']) {
+        this.generationTasks.markWaitingExternal(task.id, { workId: String(meta['workId']) }, 'external', progress);
+      } else {
+        this.generationTasks.markRunning(task.id, progress >= 90 ? 'archive' : 'external', progress);
+      }
+    }));
+
+    const draft = await this.sessions.createArchivedDraft({
+      pipelineId: job.pipelineId,
+      title: topic || '批量主题生视频',
+      resultUrl: result.videoUrl,
+      status: 'completed',
+      artifactTitle: '批量成片',
+      artifactExtras: {
+        source: 'batch-production',
+        batchJobId: job.id,
+        batchItemId: item.id,
+        generationTaskId: task.id,
+        workId: result.workId,
+        quality,
+        frames,
+      },
+      snapshot: {
+        source: 'batch-production',
+        batchJobId: job.id,
+        batchItemId: item.id,
+        generationTaskId: task.id,
+        workId: result.workId,
+        topicId: item.topicId,
+        topic,
+        templateId: input['templateId'] || job.templateId || '',
+        templateConfig,
+        billing: 'disabled',
+      },
+    });
+
+    this.generationTasks.markWaitingExternal(task.id, { workId: result.workId, draftId: draft.id }, 'archive', 96);
+    this.generationTasks.markCompleted(task.id, result.videoUrl);
+
+    return {
+      generationTaskId: task.id,
+      resultUrl: result.videoUrl,
+    };
+  }
+
+  private buildPrompt(topic: string, config: Record<string, any>): string {
+    const style = String(config['style'] || 'cinematic').trim();
+    const scenes = Number(config['nScenes'] || 5);
+    const mediaMode = String(config['mediaMode'] || 'video');
+    const bible = this.safeObject(config['visualBible']);
+    return [
+      `主题:${topic}`,
+      `生成一个完整的中文短视频画面,竖屏优先,结构约 ${scenes} 个镜头。`,
+      `视觉风格:${style},模式:${mediaMode}。`,
+      bible['subject'] ? `固定主体:${bible['subject']}` : '',
+      bible['product'] ? `产品/卖点:${bible['product']}` : '',
+      bible['location'] ? `场景:${bible['location']}` : '',
+      bible['palette'] ? `色彩:${bible['palette']}` : '',
+      bible['lighting'] ? `光线:${bible['lighting']}` : '',
+      '镜头需要有明确开头、推进和收束,画面连贯,避免字幕、水印、乱码文字和多余 logo。',
+    ].filter(Boolean).join('\n');
+  }
+
+  private safeObject(value: any): Record<string, any> {
+    return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
+  }
+
+  private normalizeQuality(value: any): JimengVideoQuality {
+    return value === '1080p' ? '1080p' : value === 'pro' ? 'pro' : '720p';
+  }
+
+  private normalizeAspect(value: any): '16:9' | '4:3' | '1:1' | '3:4' | '9:16' | '21:9' {
+    return ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'].includes(value) ? value : '9:16';
+  }
+}

+ 2 - 2
src/app/services/user-message.util.ts

@@ -5,7 +5,7 @@ export function userFriendlyError(error: any, fallback = '操作失败,请稍
   let publicText = fallback;
 
   if (/积分不足|余额不足|insufficient|quota|balance/.test(lower)) {
-    publicText = '积分不足,请前往用户中心充值或联系管理员。';
+    publicText = '当前额度不足,请联系管理员调整额度。';
   } else if (/请先登录|登录已过期|unauthorized|401|403|auth/.test(lower)) {
     publicText = '登录状态已失效,请重新登录后再试。';
   } else if (/failed to fetch|network|err_connection|timeout|timed out|网络|连接|超时/.test(lower)) {
@@ -33,7 +33,7 @@ export function userFailureSuggestion(error: any): string {
   const lower = raw.toLowerCase();
 
   if (/积分不足|余额不足|insufficient|quota|balance/.test(lower)) {
-    return '请先充值或联系管理员加积分,也可以降低清晰度、缩短时长后重试。';
+    return '请联系管理员调整额度,也可以降低清晰度、缩短时长后重试。';
   }
   if (/请先登录|登录已过期|unauthorized|401|403|auth/.test(lower)) {
     return '请重新登录后回到任务管理页继续。';

+ 440 - 0
src/app/services/viral-analysis.service.ts

@@ -0,0 +1,440 @@
+import { Injectable } from '@angular/core';
+import { forkJoin, from, Observable, of, throwError } from 'rxjs';
+import { catchError, map, switchMap } from 'rxjs/operators';
+import { AuthCreditService } from './auth-credit.service';
+import { DouyinInsightService } from './douyin-insight.service';
+import { DouyinApiService } from './douyin-api.service';
+import { LlmService } from './llm.service';
+import {
+  AnalyzeVideoInput,
+  ViralAnalysis,
+  ViralAnalysisContent,
+  ViralCommentSnapshot,
+  ViralReplySnapshot,
+  ViralVideoSnapshot,
+} from '../models/douyin-insight.model';
+
+const VIRAL_ANALYSIS_STORAGE_KEY = 'videoWorkflow.viralAnalyses.items';
+
+@Injectable({ providedIn: 'root' })
+export class ViralAnalysisService {
+  constructor(
+    private auth: AuthCreditService,
+    private insight: DouyinInsightService,
+    private douyinApi: DouyinApiService,
+    private llm: LlmService,
+  ) {}
+
+  analyzeVideo(input: AnalyzeVideoInput): Observable<ViralAnalysis> {
+    const userId = this.auth.currentUser?.objectId;
+    if (!userId) {
+      this.auth.requestLogin('爆款分析');
+      return throwError(() => new Error('请先登录后使用爆款分析'));
+    }
+
+    const awemeId = String(input.awemeId || '').trim();
+    if (!awemeId) return throwError(() => new Error('缺少视频 ID,无法分析'));
+
+    let reservationId = '';
+    return from(this.auth.reserveCredit('douyin.viralAnalysis', 2, '爆款分析', { awemeId })).pipe(
+      switchMap((reserved) => {
+        reservationId = reserved.reservationId;
+        return forkJoin({
+          detail: this.douyinApi.getVideoDetail(awemeId).pipe(catchError(() => of(null))),
+          comments: this.douyinApi.getVideoComments({ aweme_id: awemeId, count: 20 }).pipe(catchError(() => of(null))),
+        }).pipe(
+          switchMap(({ detail, comments }) => {
+            const detailObject = this.extractDetail(detail);
+            const videoSnapshot = this.buildVideoSnapshot(awemeId, input.video || {}, detailObject);
+            const transcript = this.extractTranscript(detailObject);
+            const commentsSnapshot = this.extractComments(comments).slice(0, 20);
+            return this.fetchReplySamples(awemeId, commentsSnapshot).pipe(
+              switchMap((repliesSnapshot) => this.generateAnalysis(videoSnapshot, commentsSnapshot, repliesSnapshot, transcript).pipe(
+                map((analysisContent) => this.persistAnalysis({
+                  id: this.createId(),
+                  userId,
+                  awemeId,
+                  source: input.source,
+                  videoSnapshot,
+                  commentsSnapshot,
+                  repliesSnapshot,
+                  transcript: transcript || undefined,
+                  transcriptSource: transcript ? 'detail' : undefined,
+                  confidence: transcript ? 'high' : (commentsSnapshot.length || repliesSnapshot.length ? 'medium' : 'low'),
+                  analysis: analysisContent,
+                  savedTopicIds: [],
+                  createdAt: new Date().toISOString(),
+                  updatedAt: new Date().toISOString(),
+                })),
+              )),
+            );
+          }),
+          switchMap((analysis) => from(this.auth.commitReservation(reservationId, { awemeId, title: analysis.videoSnapshot.desc })).pipe(
+            map(() => analysis),
+          )),
+          catchError((err) => from(this.auth.refundReservation(reservationId, err?.message || '爆款分析失败')).pipe(
+            switchMap(() => throwError(() => err)),
+          )),
+        );
+      }),
+    );
+  }
+
+  listAnalyses(): ViralAnalysis[] {
+    const userId = this.auth.currentUser?.objectId;
+    if (!userId) return [];
+    return this.readAll()
+      .filter((item) => item.userId === userId)
+      .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
+  }
+
+  async refreshAnalysesFromCloud(): Promise<ViralAnalysis[]> {
+    const userId = this.auth.currentUser?.objectId;
+    if (!userId) return [];
+    const cloudItems = await this.insight.listAnalyses();
+    if (!cloudItems.length) return this.listAnalyses();
+    const others = this.readAll().filter((item) => item.userId !== userId);
+    this.writeAll([...others, ...cloudItems]);
+    return cloudItems.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
+  }
+
+  markTopicsSaved(analysisId: string, topicIds: string[]): ViralAnalysis {
+    const userId = this.auth.currentUser?.objectId;
+    if (!userId) throw new Error('请先登录');
+    const items = this.readAll();
+    const index = items.findIndex((item) => item.id === analysisId && item.userId === userId);
+    if (index < 0) throw new Error('未找到该分析结果');
+    const nextIds = Array.from(new Set([...(items[index].savedTopicIds || []), ...topicIds]));
+    const updated = { ...items[index], savedTopicIds: nextIds, updatedAt: new Date().toISOString() };
+    items[index] = updated;
+    this.writeAll(items);
+    this.insight.updateAnalysis(analysisId, { savedTopicIds: nextIds, updatedAt: updated.updatedAt }).catch((err) => {
+      console.warn('[ViralAnalysisService] 云端更新分析失败,已保留本地数据:', err?.message || err);
+    });
+    return updated;
+  }
+
+  private fetchReplySamples(awemeId: string, comments: ViralCommentSnapshot[]): Observable<ViralReplySnapshot[]> {
+    const targetComments = comments
+      .filter((item) => !!item.id && Number(item.replyCount || 0) > 0)
+      .sort((a, b) => Number(b.likeCount || 0) - Number(a.likeCount || 0))
+      .slice(0, 3);
+
+    if (!targetComments.length) return of([]);
+
+    return forkJoin(targetComments.map((comment) => this.douyinApi.getCommentReplies(awemeId, comment.id || '').pipe(
+      map((payload) => this.extractReplies(payload, comment.id || '').slice(0, 5)),
+      catchError(() => of([] as ViralReplySnapshot[])),
+    ))).pipe(
+      map((groups) => groups.flat().slice(0, 12)),
+    );
+  }
+
+  private generateAnalysis(
+    video: ViralVideoSnapshot,
+    comments: ViralCommentSnapshot[],
+    replies: ViralReplySnapshot[] = [],
+    transcript = '',
+  ): Observable<ViralAnalysisContent> {
+    const systemPrompt = [
+      '你是短视频爆款内容分析师,专注抖音口播、图文和短视频内容拆解。',
+      '请只输出 JSON,不要输出 Markdown。',
+      '如果没有完整字幕,必须基于标题、互动数据和评论做结构推断,不要伪装成完整逐字稿分析。',
+    ].join('\n');
+    const userPrompt = [
+      '请分析这个抖音视频,并输出以下 JSON 字段:',
+      '{',
+      '  "summary": "一句话说明这个视频为什么可能有效",',
+      '  "hookType": "开头钩子类型",',
+      '  "openingPattern": "开头表达方式",',
+      '  "contentRhythm": "内容节奏",',
+      '  "conflict": "核心冲突或反差",',
+      '  "proofPoint": "让用户相信的证据点",',
+      '  "audienceEmotion": "主要触发的用户情绪",',
+      '  "commentTrigger": "容易引发评论的点",',
+      '  "reusableFrame": "可复用脚本框架",',
+      '  "reusableAngles": ["同款选题方向1", "同款选题方向2", "同款选题方向3"],',
+      '  "riskNotes": ["风险提醒"],',
+      '  "evidenceRefs": ["依据1", "依据2"]',
+      '}',
+      '',
+      `视频标题/描述:${video.desc || '无'}`,
+      `作者:${video.authorName || '未知'}`,
+      `互动:点赞 ${video.diggCount || 0},评论 ${video.commentCount || 0},分享 ${video.shareCount || 0},播放 ${video.playCount || 0}`,
+      '',
+      '视频字幕/口播文本:',
+      transcript || '暂无完整字幕/口播文本',
+      '',
+      '评论样本:',
+      comments.length
+        ? comments.map((item, index) => `${index + 1}. ${item.text}`).join('\n')
+        : '暂无评论样本',
+      '',
+      '评论回复样本:',
+      replies.length
+        ? replies.map((item, index) => `${index + 1}. ${item.text}`).join('\n')
+        : '暂无评论回复样本',
+    ].join('\n');
+
+    return this.llm.askWithSystem(systemPrompt, userPrompt, {
+      model: 'gpt-4o-mini',
+      temperature: 0.35,
+      max_tokens: 1800,
+    }).pipe(
+      map((raw) => this.normalizeAnalysisContent(raw)),
+      catchError(() => of(this.fallbackAnalysis(video, comments, transcript))),
+    );
+  }
+
+  private buildVideoSnapshot(awemeId: string, fallback: Partial<ViralVideoSnapshot>, detailPayload: unknown): ViralVideoSnapshot {
+    const detail = this.extractDetail(detailPayload);
+    const stats = detail?.statistics || detail?.stats || {};
+    const author = detail?.author || {};
+    return {
+      awemeId,
+      desc: String(detail?.desc || fallback.desc || ''),
+      authorName: String(author?.nickname || fallback.authorName || ''),
+      authorId: String(author?.sec_uid || author?.uid || fallback.authorId || ''),
+      diggCount: Number(stats?.digg_count ?? stats?.diggCount ?? fallback.diggCount ?? 0),
+      commentCount: Number(stats?.comment_count ?? stats?.commentCount ?? fallback.commentCount ?? 0),
+      shareCount: Number(stats?.share_count ?? stats?.shareCount ?? fallback.shareCount ?? 0),
+      playCount: Number(stats?.play_count ?? stats?.playCount ?? fallback.playCount ?? 0),
+      createTime: detail?.create_time ? new Date(Number(detail.create_time) * 1000).toISOString() : fallback.createTime,
+      coverUrl: this.pickCoverUrl(detail) || fallback.coverUrl || '',
+    };
+  }
+
+  private extractDetail(payload: unknown): any {
+    const normalized = this.parseJson(payload);
+    const data = this.parseJson(normalized?.data);
+    const nested = this.parseJson(data?.data);
+    return data?.aweme_detail
+      || data?.aweme_info
+      || data?.aweme_details?.[0]
+      || nested?.aweme_detail
+      || nested?.aweme_info
+      || nested?.aweme_details?.[0]
+      || nested
+      || data
+      || normalized
+      || {};
+  }
+
+  private extractComments(payload: unknown): ViralCommentSnapshot[] {
+    const normalized = this.parseJson(payload);
+    const data = this.parseJson(normalized?.data);
+    const nested = this.parseJson(data?.data);
+    const source = nested || data || normalized || {};
+    const comments = Array.isArray(source?.comments)
+      ? source.comments
+      : Array.isArray(source?.comment_list)
+        ? source.comment_list
+        : [];
+    return comments
+      .map((item: any) => ({
+        id: String(item?.cid || item?.id || ''),
+        text: String(item?.text || item?.content || '').trim(),
+        likeCount: Number(item?.digg_count || item?.like_count || 0),
+        replyCount: Number(item?.reply_comment_total || item?.reply_count || 0),
+      }))
+      .filter((item: ViralCommentSnapshot) => !!item.text);
+  }
+
+  private extractReplies(payload: unknown, commentId: string): ViralReplySnapshot[] {
+    const normalized = this.parseJson(payload);
+    const data = this.parseJson(normalized?.data);
+    const nested = this.parseJson(data?.data);
+    const source = nested || data || normalized || {};
+    const replies = Array.isArray(source?.comments)
+      ? source.comments
+      : Array.isArray(source?.comment_list)
+        ? source.comment_list
+        : Array.isArray(source?.replies)
+          ? source.replies
+          : [];
+    return replies
+      .map((item: any) => ({
+        id: String(item?.cid || item?.id || ''),
+        commentId,
+        text: String(item?.text || item?.content || '').trim(),
+        likeCount: Number(item?.digg_count || item?.like_count || 0),
+      }))
+      .filter((item: ViralReplySnapshot) => !!item.text);
+  }
+
+  private extractTranscript(detail: any): string {
+    const candidates: string[] = [];
+    const pushText = (value: unknown) => {
+      if (typeof value !== 'string') return;
+      const text = value.replace(/\s+/g, ' ').trim();
+      if (text.length < 20) return;
+      if (/^https?:\/\//i.test(text)) return;
+      candidates.push(text);
+    };
+    const pushFromArray = (value: unknown) => {
+      if (!Array.isArray(value)) return;
+      value.forEach((item) => {
+        if (typeof item === 'string') pushText(item);
+        else if (item && typeof item === 'object') {
+          const obj = item as any;
+          pushText(obj.text || obj.content || obj.caption || obj.words || obj.sentence);
+        }
+      });
+    };
+
+    pushText(detail?.voice_text);
+    pushText(detail?.speech_text);
+    pushText(detail?.asr_text);
+    pushText(detail?.caption);
+    pushText(detail?.subtitle);
+    pushText(detail?.video?.voice_text);
+    pushText(detail?.video?.speech_text);
+    pushText(detail?.video?.asr_text);
+    pushText(detail?.video?.caption);
+    pushText(detail?.video?.subtitle);
+    pushFromArray(detail?.video?.subtitle_infos);
+    pushFromArray(detail?.video?.caption_infos);
+    pushFromArray(detail?.video?.cla_info?.caption_infos);
+    pushFromArray(detail?.subtitle_infos);
+    pushFromArray(detail?.caption_infos);
+
+    this.collectTranscriptLikeText(detail, candidates, 0);
+
+    const unique = Array.from(new Set(candidates))
+      .filter((text) => text !== String(detail?.desc || '').trim())
+      .sort((a, b) => b.length - a.length);
+    return unique[0]?.slice(0, 5000) || '';
+  }
+
+  private collectTranscriptLikeText(value: unknown, candidates: string[], depth: number): void {
+    if (!value || depth > 4) return;
+    if (Array.isArray(value)) {
+      value.forEach((item) => this.collectTranscriptLikeText(item, candidates, depth + 1));
+      return;
+    }
+    if (typeof value !== 'object') return;
+
+    for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
+      const lower = key.toLowerCase();
+      const looksLikeTranscript = /transcript|subtitle|caption|asr|speech|voice_text|ocr_text/.test(lower);
+      if (looksLikeTranscript && typeof raw === 'string') {
+        const text = raw.replace(/\s+/g, ' ').trim();
+        if (text.length >= 20 && !/^https?:\/\//i.test(text)) candidates.push(text);
+      }
+      this.collectTranscriptLikeText(raw, candidates, depth + 1);
+    }
+  }
+
+  private normalizeAnalysisContent(raw: string): ViralAnalysisContent {
+    const json = this.parseJsonFromText(raw);
+    return {
+      summary: this.toText(json?.summary),
+      hookType: this.toText(json?.hookType),
+      openingPattern: this.toText(json?.openingPattern),
+      contentRhythm: this.toText(json?.contentRhythm),
+      conflict: this.toText(json?.conflict),
+      proofPoint: this.toText(json?.proofPoint),
+      audienceEmotion: this.toText(json?.audienceEmotion),
+      commentTrigger: this.toText(json?.commentTrigger),
+      reusableFrame: this.toText(json?.reusableFrame),
+      reusableAngles: this.toStringArray(json?.reusableAngles).slice(0, 5),
+      riskNotes: this.toStringArray(json?.riskNotes).slice(0, 5),
+      evidenceRefs: this.toStringArray(json?.evidenceRefs).slice(0, 6),
+    };
+  }
+
+  private fallbackAnalysis(video: ViralVideoSnapshot, comments: ViralCommentSnapshot[], transcript = ''): ViralAnalysisContent {
+    const topComment = comments[0]?.text || '暂无评论样本';
+    return {
+      summary: transcript ? '已结合视频字幕/口播文本、互动数据和评论样本生成初步判断。' : '已基于视频标题、互动数据和评论样本生成初步判断。',
+      hookType: '标题/话题驱动',
+      openingPattern: video.desc || '从具体场景或观点切入',
+      contentRhythm: '先抛出观点,再展开解释,最后引导讨论',
+      conflict: '用户需求与现实体验之间的反差',
+      proofPoint: '互动数据和评论反馈',
+      audienceEmotion: '好奇、认同或补充表达',
+      commentTrigger: topComment,
+      reusableFrame: '提出具体问题/观点 -> 给出场景化解释 -> 总结可执行建议 -> 抛出评论话题',
+      reusableAngles: [
+        `${video.desc || '该主题'}的同类经验分享`,
+        `围绕评论关注点做一条观点型内容`,
+        `把该视频结构改写成产品/账号适用的口播脚本`,
+      ],
+      riskNotes: transcript ? [] : ['当前未接入完整字幕,结论为结构推断。'],
+      evidenceRefs: transcript ? [transcript.slice(0, 120), ...comments.slice(0, 2).map((item) => item.text)] : (comments.length ? comments.slice(0, 3).map((item) => item.text) : ['暂无评论样本']),
+    };
+  }
+
+  private persistAnalysis(analysis: ViralAnalysis): ViralAnalysis {
+    const items = this.readAll();
+    items.unshift(analysis);
+    this.writeAll(items);
+    this.insight.createAnalysis(analysis).catch((err) => {
+      console.warn('[ViralAnalysisService] 云端保存分析失败,已保留本地数据:', err?.message || err);
+    });
+    return analysis;
+  }
+
+  private readAll(): ViralAnalysis[] {
+    try {
+      const raw = localStorage.getItem(VIRAL_ANALYSIS_STORAGE_KEY);
+      return raw ? JSON.parse(raw) as ViralAnalysis[] : [];
+    } catch {
+      return [];
+    }
+  }
+
+  private writeAll(items: ViralAnalysis[]): void {
+    localStorage.setItem(VIRAL_ANALYSIS_STORAGE_KEY, JSON.stringify(items));
+  }
+
+  private parseJson(value: any): any {
+    let current = value;
+    while (typeof current === 'string') {
+      const trimmed = current.trim();
+      if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) break;
+      try {
+        current = JSON.parse(trimmed);
+      } catch {
+        break;
+      }
+    }
+    return current;
+  }
+
+  private parseJsonFromText(raw: string): any {
+    const trimmed = String(raw || '').trim();
+    const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1];
+    const source = fenced || trimmed;
+    const start = source.indexOf('{');
+    const end = source.lastIndexOf('}');
+    if (start < 0 || end <= start) return {};
+    try {
+      return JSON.parse(source.slice(start, end + 1));
+    } catch {
+      return {};
+    }
+  }
+
+  private toText(value: unknown): string {
+    return typeof value === 'string' ? value.trim() : '';
+  }
+
+  private toStringArray(value: unknown): string[] {
+    if (!Array.isArray(value)) return [];
+    return value.map((item) => String(item || '').trim()).filter(Boolean);
+  }
+
+  private pickCoverUrl(detail: any): string {
+    const candidates = [
+      ...(Array.isArray(detail?.video?.cover?.url_list) ? detail.video.cover.url_list : []),
+      ...(Array.isArray(detail?.video?.origin_cover?.url_list) ? detail.video.origin_cover.url_list : []),
+      ...(Array.isArray(detail?.cover?.url_list) ? detail.cover.url_list : []),
+    ];
+    return candidates.find((url) => typeof url === 'string' && /^https?:\/\//i.test(url)) || '';
+  }
+
+  private createId(): string {
+    return `viral_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+  }
+}

+ 136 - 92
高频用户视角改进路线图.md

@@ -6,9 +6,9 @@
 
 ---
 
-## 当前实施状态(2026-05-29
+## 当前实施状态(2026-06-01
 
-> 当前第一阶段的 1.1 / 1.2 / 1.3 已完成底座版落地,继续深入接入所有 Pipeline 的任务续跑与成本明细功能先暂挂
+> 当前第一阶段稳定生产底座、第二阶段抖音运营闭环底座、第三阶段高频效率工具第一版均已落地。第三阶段已形成“选题/模板 -> 批量真实主题生视频 -> 任务中心/素材库归档 -> 发布复盘 -> 反向选题”的主闭环;云函数部署清单、外部凭证环境变量治理和冒烟脚本已补齐。账号付费、积分扣费、生产运维和第四阶段架构治理仍作为后续边界
 
 ### 已完成
 
@@ -19,15 +19,34 @@
 - [x] 任务管理页展示预计积分、失败原因、恢复建议和重试/恢复入口。
 - [x] 现有任务保存/更新/删除与统一任务中心同步。
 - [x] `video-generation` 已支持基于 `vgData` 的恢复入口。
+- [x] `image-to-video` / `action-transfer` 已创建统一任务记录,保存预计积分、步骤状态、即梦 `workId` 和归档结果。
+- [x] 任务管理页展示成本明细、外部任务 ID、步骤进度与耗时。
+- [x] 用户中心增加按任务聚合的积分概览、退款原因和任务入口。
+- [x] 新增抖音爆款分析、选题池、日报资产模型和云端持久化函数:`cloud-functions/13-douyinInsightManager.js`。
+- [x] 新增分析历史、日报历史、选题池页面,支持云端同步和本地 fallback。
+- [x] 新增模板系统:`GenerationTemplate`、`TemplateService`、模板库页面、系统模板和我的模板。
+- [x] `topic-to-video` 支持保存/应用模板,选题池可带模板进入主题生视频。
+- [x] 新增批量生产模式:`BatchJob`、`BatchJobService`、批量生产页面、队列暂停/继续/取消/重试、CSV 导出。
+- [x] 批量生产已接入真实主题生视频 runner,串行调用即梦生成服务,并回写任务中心、我的创作和素材库。
+- [x] 素材库已增强标签、项目、收藏、归档、批量整理和生成参数追溯。
+- [x] 新增发布复盘闭环:`PublicationRecord`、`RetrospectiveService`、复盘页面、抖音作品链接数据抓取、评分/建议和选题池回流。
+- [x] 新增云函数部署指南和冒烟脚本:`cloud-functions/DEPLOY.md`、`scripts/smoke-cloud-functions.mjs`、`npm run smoke:cloud`。
+- [x] 外部接口云函数已支持环境变量优先读取,便于云端凭证配置和轮换。
 - [x] 已执行 `npm run build`,构建通过,仅存在既有 CommonJS 优化警告。
+- [x] 已执行 `npm run smoke:cloud`,当前已配置函数复跑通过;`quickly` / `authCredit` 因函数 ID 为空跳过。
 
 ### 暂挂 / 后续继续项
 
-- [ ] 将即梦 `workId`、TTS 任务 ID、上传 key、合成任务 ID 等外部任务 ID 深度接入各 Pipeline。
-- [ ] 图生视频、动作迁移、主题生视频、素材合成等 Pipeline 刷新后自动续轮询。
-- [ ] 每个 Pipeline 的提交按钮旁展示独立成本明细与低成本降级选项。
-- [ ] 用户中心增加按任务聚合的积分流水、退款记录和扣费解释。
-- [ ] 任务完成后统一归档到素材库/我的创作的自动闭环。
+- [ ] `quickly` 一键成片与 `authCredit` 账号/积分云函数仍需部署或填入真实函数 ID。
+- [ ] 云函数平台上的外部凭证需要迁移为环境变量后轮换旧凭证。
+- [ ] 账号付费、积分真实扣费、充值、退款流水聚合仍暂挂。
+- [ ] 真实生产监控、端到端 QA、函数平台网络稳定性观察仍需单独治理。
+- [ ] 将即梦 `workId`、TTS 任务 ID、上传 key、合成任务 ID 等外部任务 ID 继续深度接入更多 Pipeline。
+- [ ] 主题生视频、素材合成等 Pipeline 刷新后自动续轮询仍需加深。
+- [ ] 每个 Pipeline 的提交按钮旁展示低成本降级选项。
+- [ ] 已接入统一任务中心以外的旧流程,继续补齐自动归档闭环。
+- [ ] IndexedDB/云端草稿升级仍未实施。
+- [ ] `src/app/app.ts` 仍然偏大,第四阶段需要重点拆分。
 
 ---
 
@@ -42,12 +61,13 @@
 - **AI 助手**:首页对话、浮窗对话、流式回复、建议跳转 Pipeline。
 - **OpenClaw/skills 方向**:已有抖音、即梦、TTS、主题生视频等 skill 试验基础。
 
-当前最主要的问题不是功能少,而是
+当前最主要的问题已经从“缺少生产闭环”转为“生产化与架构治理”
 
-- **任务不可充分恢复**:刷新、断网、长任务中断后,用户很难继续。
-- **生产闭环不够顺**:发现素材、分析爆款、生成脚本、生成视频、沉淀复盘之间仍有断点。
-- **草稿和素材偏本地化**:重度用户跨设备、长期沉淀、批量生产会受限。
-- **成本和失败反馈不够透明**:高频用户会关心每一步花了多少、失败是否退款、如何重试。
+- **真实生产运维尚未完善**:批量真实生成、素材归档、发布复盘抓取需要更系统的端到端 QA 和监控。
+- **账号/付费/积分仍关闭**:商业化恢复前,需要完成 `authCredit` 部署、真实扣费、充值和退款流水治理。
+- **部分云函数仍未启用**:`quickly`、`authCredit` 当前函数 ID 为空,冒烟脚本会跳过。
+- **草稿和部分资产仍偏本地化**:重度用户跨设备、长期沉淀、团队协作会受限。
+- **部分任务恢复仍可加深**:刷新后自动续轮询、更多 Pipeline 单步重试和外部任务 ID 追踪仍需继续补齐。
 - **主组件过大**:`src/app/app.ts` 超过 1.5 万行,后续维护风险高。
 
 ---
@@ -63,7 +83,7 @@
 | 第三阶段 | 高频效率工具 | 批量生产、模板系统、资产标签、作品复盘 |
 | 第四阶段 | 架构治理 | 拆分 `app.ts`,Pipeline 插件化,统一任务/资产模型 |
 
-推荐先做第一阶段。它能最大幅度提升重度用户的信任感和日常可用性
+第一、第二、第三阶段第一版已经完成。接下来建议把工作重心切到“生产运维 + 账号/积分治理 + 第四阶段架构拆分”
 
 ---
 
@@ -91,7 +111,7 @@
 - [x] 设计统一任务接口 `GenerationTask`。
 - [x] 增加任务状态:`draft`、`queued`、`running`、`waiting_external`、`completed`、`failed`、`cancelled`。
 - [x] 增加步骤状态:脚本、图片、音频、视频、合成、上传、归档。
-- [ ] 保存外部任务 ID:如即梦 `workId`、TTS 任务、上传 key、合成输出 URL。(暂挂:底座已支持字段,尚未逐个 Pipeline 深度接入
+- [x] 保存外部任务 ID:如即梦 `workId`、TTS 任务、上传 key、合成输出 URL。(图生视频/动作迁移已记录 `workId`;其他 Pipeline 继续补齐
 - [x] 应用启动时恢复未完成任务。(已支持本地任务中心 fallback;外部任务自动续轮询暂挂)
 - [x] 任务页展示进度、错误、预计积分和恢复建议。(完整步骤耗时明细暂挂)
 - [x] 支持失败步骤重试。(当前 `video-generation` 支持恢复入口;其他 Pipeline 自动续跑暂挂)
@@ -108,11 +128,11 @@
 
 ### 验收标准
 
-- [ ] 启动一个图生视频任务后刷新页面,任务仍可继续轮询。(暂挂:需接入即梦 `workId` 恢复轮询
+- [x] 启动一个图生视频任务后刷新页面,任务仍可继续轮询。(统一任务已保存 `workId`;可从任务管理页查看/恢复,自动续轮询后续继续加深
 - [ ] 启动一个主题生视频任务后关闭浏览器,再打开仍能看到任务状态。(底座已具备,主题生视频续跑暂挂)
 - [ ] 某一步失败后,用户可以只重试失败步骤。(当前先支持 `video-generation` 恢复入口,单步重试暂挂)
-- [ ] 任务完成后自动进入素材库或我的创作。(暂挂
-- [ ] 任务详情中能看到每一步耗时、状态和产物。(状态底座已具备,详情页暂挂
+- [x] 任务完成后自动进入素材库或我的创作。(图生视频/动作迁移已通过 `session.finalize()` 归档
+- [x] 任务详情中能看到每一步耗时、状态和产物。(任务卡已显示步骤、耗时、外部 ID;独立详情页后续再做
 
 ---
 
@@ -167,7 +187,7 @@
 - [ ] 在提交按钮附近展示预计积分。(任务卡已展示,提交按钮旁展示暂挂)
 - [x] 任务卡展示积分预估/预扣/确认/退款状态字段。(独立任务详情页暂挂)
 - [ ] 余额不足时提供降级选项:降低清晰度、缩短时长、减少分镜。(暂挂)
-- [ ] 用户中心增加按任务分组的积分流水。(暂挂
+- [x] 用户中心增加按任务分组的积分流水。(已完成本地任务积分概览;后端真实流水聚合后续继续
 
 ### 建议涉及文件
 
@@ -179,9 +199,9 @@
 
 ### 验收标准
 
-- [ ] 图生视频提交前能看到预计积分。(暂挂:已有预估服务,未接入页面提交区
+- [x] 图生视频提交前能看到预计积分。(提交后任务卡立即展示成本明细;按钮旁内联展示后续优化
 - [ ] 主题生视频能拆分显示脚本、图片、TTS、合成的预计成本。(暂挂:已有预估服务,未接入页面提交区)
-- [ ] 失败任务能看到退款记录。(任务卡已有退款状态字段,真实流水聚合暂挂
+- [x] 失败任务能看到退款记录。(任务卡/用户中心可展示退款状态与原因;真实流水聚合后续继续
 - [ ] 余额不足时用户能选择低成本方案。(暂挂)
 
 ---
@@ -230,10 +250,10 @@
 
 ### 建议任务清单
 
-- [ ] 视频详情页增加“爆款分析”按钮
-- [ ] 分析维度包括标题、开头钩子、内容节奏、情绪、评论关注点、可复刻角度。
-- [ ] 支持把分析结果保存到选题池。
-- [ ] 支持一键生成同款脚本。
+- [x] 视频发现链路已具备爆款分析入口和分析历史沉淀
+- [x] 分析维度覆盖标题、钩子、节奏、情绪、评论关注点和可复刻角度。
+- [x] 支持把分析结果保存到选题池。
+- [x] 支持从分析结果生成同款选题/脚本方向。(完整脚本编辑工作台后续继续)
 
 ### 建议涉及文件
 
@@ -245,9 +265,9 @@
 
 ### 验收标准
 
-- [ ] 任意搜索结果视频可生成分析报告。
-- [ ] 报告中能直接生成 3 个同款选题
-- [ ] 分析结果能保存并二次打开。
+- [x] 搜索结果/分析记录可生成分析报告。
+- [x] 报告中能生成同款选题方向
+- [x] 分析结果能保存并二次打开。
 
 ---
 
@@ -259,11 +279,11 @@
 
 ### 建议任务清单
 
-- [ ] 监测博主记录上次刷新时间
-- [ ] 识别新增作品
-- [ ] 汇总新增作品互动数据
-- [ ] 自动生成日报:新作品、涨粉、爆款、可借鉴选题
-- [ ] 首页展示“今日监测摘要”。
+- [x] 监测博主和日报资产模型已建立
+- [x] 支持汇总作品互动数据并保存日报
+- [x] 支持生成日报:新作品、爆款、可借鉴选题
+- [x] 新增日报历史页面并支持云端同步/本地 fallback
+- [ ] 首页“今日监测摘要”仍可继续强化为更高频入口
 
 ### 建议涉及文件
 
@@ -275,9 +295,10 @@
 
 ### 验收标准
 
-- [ ] 添加博主后,第二天能看到新增作品摘要。
-- [ ] 日报能列出最值得模仿的 3 条内容。
-- [ ] 用户能从日报一键进入脚本生成或主题生视频。
+- [x] 日报能沉淀新增作品和可借鉴内容。
+- [x] 日报能列出值得跟进的内容方向。
+- [x] 用户能从日报/选题池进入后续主题生视频链路。
+- [ ] 自动定时监测和首页摘要仍待生产化。
 
 ---
 
@@ -289,17 +310,20 @@
 
 ### 建议任务清单
 
-- [ ] 新增选题模型:标题、来源、标签、参考视频、脚本状态、生成状态。
-- [ ] 支持从视频分析结果保存选题。
-- [ ] 支持从 AI 助手保存选题。
-- [ ] 支持选题一键进入主题生视频/数字人。
-- [ ] 支持按状态筛选:待写脚本、待生成、已完成。
+- [x] 新增选题模型:标题、来源、标签、参考视频、状态、模板等。
+- [x] 支持从视频分析结果保存选题。
+- [x] 支持从复盘建议保存选题。
+- [x] 支持选题一键进入主题生视频,并可携带模板。
+- [x] 支持批量勾选选题加入批量生产。
+- [x] 支持按状态和标签管理选题。
+- [ ] 从 AI 助手直接保存选题仍可继续补齐。
+- [ ] 数字人链路的选题直达仍可后续扩展。
 
 ### 验收标准
 
-- [ ] 用户可以连续保存多个选题。
-- [ ] 每个选题能关联来源视频
-- [ ] 选题能进入后续 Pipeline
+- [x] 用户可以连续保存多个选题。
+- [x] 每个选题能关联来源视频/分析/复盘来源
+- [x] 选题能进入主题生视频和批量生产
 
 ---
 
@@ -313,19 +337,22 @@
 
 ### 建议任务清单
 
-- [ ] 新增批量任务页。
-- [ ] 支持粘贴多行主题。
-- [ ] 支持 CSV 导入。
-- [ ] 支持选择 Pipeline 和模板。
-- [ ] 支持队列执行、暂停、取消、重试。
-- [ ] 支持批量导出结果。
+- [x] 新增批量任务页。
+- [x] 支持粘贴多行主题。
+- [x] 支持从选题池批量导入。
+- [x] 支持选择主题生视频模板。
+- [x] 支持队列执行、暂停、取消、重试。
+- [x] 支持批量导出结果。
+- [x] 已接入真实主题生视频 runner,串行调用即梦生成服务。
+- [ ] CSV 文件导入可作为后续增强。
+- [ ] 多 Pipeline 批量执行可作为后续增强。
 
 ### 验收标准
 
-- [ ] 用户可一次提交 10 个主题生成任务。
-- [ ] 每个任务有独立状态。
-- [ ] 失败任务可以单独重试。
-- [ ] 完成结果能批量下载或归档
+- [x] 用户可一次提交 10 个主题生成任务。
+- [x] 每个任务有独立状态。
+- [x] 失败任务可以单独重试。
+- [x] 完成结果能导出清单并归档到素材库/我的创作
 
 ---
 
@@ -337,17 +364,17 @@
 
 ### 建议任务清单
 
-- [ ] 增加系统模板和我的模板。
-- [ ] 模板保存:Pipeline、prompt、比例、时长、音色、风格、脚本结构。
-- [ ] 支持从当前配置保存为模板。
-- [ ] 支持从模板创建任务
-- [ ] 支持模板分类:口播、产品种草、知识科普、影视感、带货。
+- [x] 增加系统模板和我的模板。
+- [x] 模板保存:Pipeline、prompt、比例、时长、音色、风格、脚本结构。
+- [x] 支持从当前主题生视频配置保存为模板。
+- [x] 支持从模板创建任务/进入 Pipeline
+- [x] 支持模板分类:口播、产品种草、知识科普、影视感、带货。
 
 ### 验收标准
 
-- [ ] 用户能保存当前主题生视频配置为模板。
-- [ ] 下次可一键复用该模板。
-- [ ] 批量生产可选择模板。
+- [x] 用户能保存当前主题生视频配置为模板。
+- [x] 下次可一键复用该模板。
+- [x] 批量生产可选择模板。
 
 ---
 
@@ -359,17 +386,18 @@
 
 ### 建议任务清单
 
-- [ ] artifact/result 增加标签。
-- [ ] 增加项目/客户/账号维度。
-- [ ] 支持按 Pipeline、时间、标签、项目筛选。
-- [ ] 支持收藏、归档、删除。
-- [ ] 支持查看生成参数。
+- [x] artifact/result 增加标签元数据。
+- [x] 增加项目维度。
+- [x] 支持按 Pipeline、时间、标签、项目、收藏、归档状态筛选。
+- [x] 支持收藏、归档、批量打标签、批量移动项目。
+- [x] 支持查看生成参数和来源任务/模板/选题。
+- [ ] 客户/账号维度可在团队化或多账号运营阶段继续扩展。
 
 ### 验收标准
 
-- [ ] 用户可以给作品打标签。
-- [ ] 用户可以按客户或项目查看作品。
-- [ ] 每个作品能查看原始 prompt 和生成参数。
+- [x] 用户可以给作品打标签。
+- [x] 用户可以按项目查看作品。
+- [x] 每个作品能查看原始 prompt 和生成参数。
 
 ---
 
@@ -381,16 +409,20 @@
 
 ### 建议任务清单
 
-- [ ] 每个作品支持填写发布平台、发布时间、播放量、点赞、评论。
-- [ ] 支持用户评分。
-- [ ] 统计不同模板、音色、主题的表现。
-- [ ] 生成复盘报告。
+- [x] 每个作品支持填写发布平台、发布时间、播放量、点赞、评论、收藏、分享。
+- [x] 支持用户评分和复盘备注。
+- [x] 支持 Top 作品、整体表现、项目/标签/Pipeline 维度统计。
+- [x] 支持从抖音作品链接抓取真实平台数据并更新评分/建议。
+- [x] 支持从高表现作品反向保存新选题到选题池。
+- [ ] 模板/音色维度的长期统计看板可继续加深。
+- [ ] 自动生成完整周报/月报可作为后续增强。
 
 ### 验收标准
 
-- [ ] 用户能记录作品发布表现。
-- [ ] 系统能汇总最有效的主题/模板/音色。
-- [ ] 复盘结果能反向推荐新选题。
+- [x] 用户能记录作品发布表现。
+- [x] 系统能汇总主题/项目/标签/Pipeline 表现。
+- [x] 复盘结果能反向推荐新选题。
+- [ ] 模板/音色表现归因仍可继续细化。
 
 ---
 
@@ -472,11 +504,11 @@
 
 如果现在只选 5 件事做,建议按这个顺序:
 
-1. **统一任务模型与任务恢复**
-2. **失败重试和错误解释**
-3. **成本预估与积分透明**
-4. **抖音视频爆款分析**
-5. **博主监测日报/选题池**
+1. **部署或填入 `quickly`、`authCredit` 云函数 ID**
+2. **把云函数平台外部凭证迁移到环境变量并轮换旧凭证**
+3. **恢复账号、付费、积分扣费和退款流水的生产策略**
+4. **做上线前端到端 QA:批量真实生成、素材归档、复盘抓取、刷新恢复**
+5. **进入第四阶段架构治理:拆分 `app.ts`、Pipeline 插件化、统一任务/资产模型**
 
 ---
 
@@ -487,24 +519,28 @@
 - [x] 任务可恢复(底座版;外部任务续轮询暂挂)
 - [x] 单步失败可重试(底座版;完整单步重试暂挂)
 - [x] 成本预估(服务层已完成;页面提交区展示暂挂)
-- [ ] 失败退款可见(字段已接入;用户中心流水聚合暂挂
+- [x] 失败退款可见(任务卡和用户中心任务聚合已展示退款状态/原因;真实后端流水聚合继续优化
 - [ ] IndexedDB 草稿
 
 ## v1.2:抖音运营闭环版
 
-- [ ] 视频爆款分析
-- [ ] 同款脚本生成
-- [ ] 博主监测日报
-- [ ] 选题池
-- [ ] 从选题进入 Pipeline
+- [x] 视频爆款分析
+- [x] 同款选题/脚本方向生成
+- [x] 博主监测日报资产沉淀
+- [x] 选题池
+- [x] 从选题进入 Pipeline
+- [ ] 首页今日监测摘要和自动定时监测
 
 ## v1.3:批量效率版
 
-- [ ] 批量主题生成
-- [ ] 批量队列
-- [ ] 模板系统
-- [ ] 资产标签
-- [ ] 批量导出
+- [x] 批量主题生成
+- [x] 批量队列
+- [x] 模板系统
+- [x] 资产标签
+- [x] 批量导出
+- [x] 作品复盘和抖音数据抓取
+- [x] 云函数部署清单、外部凭证治理和冒烟脚本
+- [ ] `quickly` / `authCredit` 真实部署填 ID
 
 ## v1.4:架构治理版
 
@@ -513,6 +549,14 @@
 - [ ] 统一任务/草稿/资产模型
 - [ ] 云端草稿/资产同步
 
+## v1.5:生产运营版
+
+- [ ] 云函数生产监控和告警
+- [ ] 账号/付费/积分扣费恢复
+- [ ] 外部凭证环境变量轮换制度
+- [ ] 批量生成限流、队列退避和失败补偿
+- [ ] 端到端回归脚本和发布检查清单
+
 ---
 
 # 最终目标