Bladeren bron

commit "IP分析"

Yi Jiarui 3 maanden geleden
bovenliggende
commit
e9a11389ce
100 gewijzigde bestanden met toevoegingen van 12993 en 5719 verwijderingen
  1. 84 0
      .env.example
  2. 1 1
      angular.json
  3. 174 16
      cloud-functions/06-voiceManager.js
  4. 3 0
      cloud-functions/08-proxyHub.js
  5. 666 119
      cloud-functions/11-jimengManager.js
  6. 44 11
      cloud-functions/13-douyinInsightManager.js
  7. 27 1
      cloud-functions/DEPLOY.md
  8. 142 0
      e2e/ip-operator-p2-core.spec.ts
  9. 175 0
      e2e/ip-operator-real-llm.spec.ts
  10. 152 0
      e2e/ip-operator-workbench.spec.ts
  11. 326 14
      package-lock.json
  12. 9 1
      package.json
  13. 19 0
      playwright.config.ts
  14. 174 0
      scripts/probe-jimeng-cloud.mjs
  15. 125 0
      scripts/review-guard.mjs
  16. 179 0
      scripts/validation/ip-operator-ready-seed.js
  17. 217 0
      scripts/validation/ip-operator-scenario-seed.js
  18. 73 0
      scripts/validation/jimeng-billing-policy.mjs
  19. 95 0
      scripts/validation/run-ip-operator-e2e.mjs
  20. 360 1809
      server.js
  21. 110 0
      server/config/runtime-config.js
  22. 149 0
      server/routes/audio-extraction.js
  23. 17 0
      server/routes/cloud-managed-legacy-reference.js
  24. 53 0
      server/routes/files.js
  25. 25 0
      server/routes/health.js
  26. 74 0
      server/routes/llm.js
  27. 15 0
      server/routes/manifest-legacy-reference.js
  28. 97 0
      server/routes/quickly.js
  29. 70 0
      server/routes/remix-asset-upload.js
  30. 51 0
      server/routes/upload.js
  31. 336 0
      server/routes/video-composite.js
  32. 142 0
      server/routes/video-download.js
  33. 160 0
      server/routes/video-proxy.js
  34. 105 0
      server/routes/video-proxy.test.js
  35. 127 0
      server/routes/whisper.js
  36. 48 0
      server/startup-log.js
  37. 327 1909
      src/app/app.css
  38. 150 26
      src/app/app.html
  39. 1 0
      src/app/app.spec.ts
  40. 234 686
      src/app/app.ts
  41. 3 3
      src/app/components/api-test/api-test.component.ts
  42. 7 7
      src/app/components/app-sidebar/app-sidebar.component.ts
  43. 6 9
      src/app/components/debug-panel/debug-panel.component.ts
  44. 20 0
      src/app/components/recent-task-pill/recent-task-pill.component.css
  45. 6 0
      src/app/components/recent-task-pill/recent-task-pill.component.html
  46. 1 0
      src/app/components/recent-task-pill/recent-task-pill.component.ts
  47. 582 0
      src/app/models/app-shell.model.ts
  48. 37 0
      src/app/models/app-tab.model.ts
  49. 2 0
      src/app/models/batch-job.model.ts
  50. 3 1
      src/app/models/content-asset.model.ts
  51. 2 2
      src/app/models/creation-brief.model.ts
  52. 12 0
      src/app/models/creator-dashboard.model.ts
  53. 1 1
      src/app/models/douyin-insight.model.ts
  54. 418 0
      src/app/models/ip-operator.model.ts
  55. 3 8
      src/app/pages/account/auth-page.component.css
  56. 12 34
      src/app/pages/account/auth-page.component.html
  57. 2 16
      src/app/pages/account/auth-page.component.ts
  58. 80 111
      src/app/pages/account/user-center.component.css
  59. 32 64
      src/app/pages/account/user-center.component.html
  60. 150 40
      src/app/pages/account/user-center.component.ts
  61. 8 0
      src/app/pages/batch-production/batch-production.component.html
  62. 1065 0
      src/app/pages/ip-operator/ip-operator.component.css
  63. 743 0
      src/app/pages/ip-operator/ip-operator.component.html
  64. 312 0
      src/app/pages/ip-operator/ip-operator.component.spec.ts
  65. 1233 0
      src/app/pages/ip-operator/ip-operator.component.ts
  66. 80 1
      src/app/pages/library/library.component.css
  67. 79 13
      src/app/pages/library/library.component.html
  68. 139 18
      src/app/pages/library/library.component.ts
  69. 279 0
      src/app/pages/pipelines/topic-to-video/services/topic-continuity.service.ts
  70. 57 0
      src/app/pages/pipelines/topic-to-video/services/topic-jimeng-generation.service.ts
  71. 255 0
      src/app/pages/pipelines/topic-to-video/services/topic-storyboard.service.ts
  72. 41 0
      src/app/pages/pipelines/topic-to-video/services/topic-template-config.service.ts
  73. 56 0
      src/app/pages/pipelines/topic-to-video/services/topic-video-artifact-store.service.ts
  74. 66 0
      src/app/pages/pipelines/topic-to-video/services/topic-video-artifact.service.ts
  75. 73 0
      src/app/pages/pipelines/topic-to-video/services/topic-video-clip.service.ts
  76. 27 0
      src/app/pages/pipelines/topic-to-video/services/topic-video-compose.service.ts
  77. 56 0
      src/app/pages/pipelines/topic-to-video/services/topic-video-diagnostics.service.ts
  78. 29 0
      src/app/pages/pipelines/topic-to-video/services/topic-video-presets.ts
  79. 65 0
      src/app/pages/pipelines/topic-to-video/services/topic-video-progress.service.ts
  80. 132 0
      src/app/pages/pipelines/topic-to-video/services/topic-video-scene-state.service.ts
  81. 36 0
      src/app/pages/pipelines/topic-to-video/services/topic-video-session.service.ts
  82. 164 0
      src/app/pages/pipelines/topic-to-video/services/topic-video-snapshot.service.ts
  83. 101 0
      src/app/pages/pipelines/topic-to-video/services/topic-video-stage.service.ts
  84. 5 5
      src/app/pages/pipelines/topic-to-video/topic-to-video.component.css
  85. 13 13
      src/app/pages/pipelines/topic-to-video/topic-to-video.component.html
  86. 181 651
      src/app/pages/pipelines/topic-to-video/topic-to-video.component.ts
  87. 17 7
      src/app/pages/topic-pool/topic-pool.component.css
  88. 19 18
      src/app/pages/topic-pool/topic-pool.component.html
  89. 1 0
      src/app/pages/topic-pool/topic-pool.component.ts
  90. 295 61
      src/app/services/auth-credit.service.ts
  91. 64 0
      src/app/services/batch-job.service.ts
  92. 21 0
      src/app/services/cloud-api.interceptor.spec.ts
  93. 41 4
      src/app/services/cloud-api.interceptor.ts
  94. 18 0
      src/app/services/content-asset.service.ts
  95. 64 0
      src/app/services/content-production-entry.service.spec.ts
  96. 134 0
      src/app/services/content-production-entry.service.ts
  97. 64 26
      src/app/services/cost-estimator.service.ts
  98. 106 0
      src/app/services/creation-brief.service.spec.ts
  99. 91 13
      src/app/services/creation-brief.service.ts
  100. 48 0
      src/app/services/creator-dashboard.service.ts

+ 84 - 0
.env.example

@@ -0,0 +1,84 @@
+# 本地开发配置模板
+# 使用方法:复制为 .env 后填入真实值。.env 已在 .gitignore 中忽略,禁止提交真实凭证。
+
+# ------------------------------
+# 基础服务
+# ------------------------------
+PORT=3000
+PARSE_API_HOST=https://server.fmode.cn
+PARSE_APP_ID=ncloudmaster
+PARSE_BASE_URL=https://server.fmode.cn/api/functions
+
+# ------------------------------
+# 即梦生成
+# 获取位置:即梦/FMode 语音或生成网关后台
+# 用途:文生视频、图生视频、图片生成、数字人、动作迁移等真实生成代理
+# ------------------------------
+JIMENG_BASE_URL=https://server.fmode.cn/api/volcengine/jimeng
+JIMENG_TOKEN=
+
+# ------------------------------
+# 抖音数据 / 爆款分析 / 博主监测
+# 获取位置:FMode voc-social 网关 token;如直连 TikHub,另填 TIKHUB 配置
+# 用途:视频详情、评论、博主作品、爆款分析证据抓取
+# ------------------------------
+DOUYIN_API_BASE_URL=https://server.fmode.cn/api/voc-social
+DOUYIN_API_TOKEN=
+VOC_TOKEN=
+VOC_SOCIAL_TOKEN=
+TIKHUB_BASE_URL=https://api.tikhub.io
+TIKHUB_TOKEN=
+
+# ------------------------------
+# 逐字稿 / 转写网关
+# 获取位置:FMode/讯飞转写网关 token
+# 用途:爆款分析补逐字稿、视频音频转写
+# ------------------------------
+IFLYTEK_GATEWAY_BASE_URL=https://server.fmode.cn/api/apig/transcription
+TRANSCRIPTION_VOC_TOKEN=
+VOICE_TOKEN=
+OPENCLAW_VOC_TOKEN=
+
+# ------------------------------
+# LLM / Gemini 代理
+# 获取位置:LLM 网关后台
+# 用途:AI 助手、脚本生成、素材理解、Gemini 媒体识别
+# ------------------------------
+LLM_BASE_URL=https://server.fmode.cn/api/openai/v1
+LLM_API_KEY=
+
+# ------------------------------
+# 七牛上传
+# 获取位置:七牛云控制台
+# 用途:本地素材、生成产物、上传 token
+# ------------------------------
+QINIU_AK=
+QINIU_SK=
+QINIU_BUCKET=
+QINIU_DOMAIN=
+QINIU_CDN_DOMAIN=
+QINIU_CDN_PREFIX=
+QINIU_UPLOAD_URL=https://upload.qiniup.com
+
+# ------------------------------
+# 火山 / 语音合成
+# 获取位置:火山语音或 FMode 语音网关后台
+# 用途:TTS、音色训练、语音合成
+# ------------------------------
+VOLC_TTS_TOKEN=
+VOLC_SPEECH_API_KEY=
+VOLC_SPEECH_APP_KEY=
+VOLC_SPEECH_ACCESS_KEY=
+VOICE_TTS_BASE_URL=https://server.fmode.cn/api/apig
+
+# ------------------------------
+# Quickly 一键成片
+# 获取位置:Quickly 平台
+# 用途:素材一键成片。未启用时可留空。
+# ------------------------------
+QUICKLY_APP_KEY=
+QUICKLY_APP_SECRET=
+QUICKLY_ACCOUNT_ID=
+QUICKLY_CALLBACK_URL=
+QUICKLY_RELAY_URL=
+QUICKLY_UPSTREAM_FN_ID=

+ 1 - 1
angular.json

@@ -55,7 +55,7 @@
                 {
                 {
                   "type": "initial",
                   "type": "initial",
                   "maximumWarning": "1.5MB",
                   "maximumWarning": "1.5MB",
-                  "maximumError": "2MB"
+                  "maximumError": "2.14MB"
                 },
                 },
                 {
                 {
                   "type": "anyComponentStyle",
                   "type": "anyComponentStyle",

+ 174 - 16
cloud-functions/06-voiceManager.js

@@ -13,6 +13,7 @@
  */
  */
 const VOICE_TOKEN = readEnv('VOICE_TOKEN') || 'Bearer r:f0333969e312a40e4703e8fe4ed1c600';
 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';
 const VOICE_TTS_BASE_URL = readEnv('VOICE_TTS_BASE_URL') || 'https://server.fmode.cn/api/volcengine/tts';
+const VOICE_MAX_ATTEMPTS = Math.max(1, Number(readEnv('VOICE_MAX_ATTEMPTS') || 3));
 
 
 async function handler(request, response) {
 async function handler(request, response) {
   try {
   try {
@@ -132,11 +133,7 @@ async function handler(request, response) {
       if (!payload.text && !payload.ssml) {
       if (!payload.text && !payload.ssml) {
         return response.json({ code: 400, success: false, error: '缺少合成文本' });
         return response.json({ code: 400, success: false, error: '缺少合成文本' });
       }
       }
-      const upstreamPayload = stripEmpty({
-        ...payload,
-        token: VOICE_TOKEN,
-        isStream: payload.isStream === true,
-      });
+      const upstreamPayload = normalizeSynthesisPayload(payload);
       const res = await postJson(`${VOICE_TTS_BASE_URL}/unidirectional`, upstreamPayload);
       const res = await postJson(`${VOICE_TTS_BASE_URL}/unidirectional`, upstreamPayload);
       response.json(res);
       response.json(res);
       return;
       return;
@@ -176,6 +173,23 @@ async function handler(request, response) {
   }
   }
 }
 }
 
 
+function normalizeSynthesisPayload(input) {
+  const payload = { ...(input || {}) };
+  const officialVoiceType = String(payload.volcengine_voice_type || payload.voiceType || payload.voice_type || '').trim();
+  if (officialVoiceType && !payload.speaker_id && !payload.speakerId && !payload.timbreId && !payload.timbre_id) {
+    payload.speaker_id = officialVoiceType;
+  }
+  if (payload.timbre_id && !payload.timbreId) {
+    payload.timbreId = payload.timbre_id;
+  }
+
+  return stripEmpty({
+    ...payload,
+    token: VOICE_TOKEN,
+    isStream: payload.isStream === true,
+  });
+}
+
 function rowToObj(row) {
 function rowToObj(row) {
   const data = typeof row.data === 'string' ? JSON.parse(row.data) : (row.data || {});
   const data = typeof row.data === 'string' ? JSON.parse(row.data) : (row.data || {});
   return { ...data, objectId: row.objectId, occupied: row.occupied, createdAt: row.createdAt, updatedAt: row.updatedAt };
   return { ...data, objectId: row.objectId, occupied: row.occupied, createdAt: row.createdAt, updatedAt: row.updatedAt };
@@ -216,24 +230,168 @@ function stripEmpty(value) {
 }
 }
 
 
 async function postJson(url, body) {
 async function postJson(url, body) {
-  const r = await fetch(url, {
-    method: 'POST',
-    headers: { 'Content-Type': 'application/json' },
-    body: JSON.stringify(body),
-  });
-  const text = await r.text();
-  let data = null;
-  try { data = text ? JSON.parse(text) : null; } catch {}
-  if (!r.ok) {
-    return { code: r.status, success: false, error: readError(data, text) || `HTTP ${r.status}` };
+  let lastError = null;
+
+  for (let attempt = 1; attempt <= VOICE_MAX_ATTEMPTS; attempt += 1) {
+    try {
+      const { status, ok, rawText, data, transport } = await requestJson('POST', url, {
+        headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
+        body: JSON.stringify(body),
+      });
+      const code = Number(data && data.code ? data.code : status || 0);
+      const retryable = !ok || code === 408 || code === 429 || code >= 500;
+
+      if (!retryable || attempt >= VOICE_MAX_ATTEMPTS) {
+        if (!ok) {
+          return {
+            code: status || code || 500,
+            success: false,
+            error: readError(data, rawText) || `HTTP ${status}`,
+            upstream: { transport, attempt, maxAttempts: VOICE_MAX_ATTEMPTS },
+          };
+        }
+        return data || { code: 500, success: false, error: '服务返回异常' };
+      }
+    } catch (error) {
+      lastError = error;
+      if (attempt >= VOICE_MAX_ATTEMPTS) break;
+    }
+
+    await sleep(Math.min(12000, 1200 * attempt * attempt));
+  }
+
+  return {
+    code: 500,
+    success: false,
+    error: `TTS upstream request failed: ${formatFetchError(lastError)}`,
+  };
+}
+
+async function requestJson(method, url, init) {
+  let lastError = null;
+
+  if (typeof fetch === 'function') {
+    try {
+      const response = await fetch(url, { method, ...(init || {}) });
+      return readResponse(response, 'fetch');
+    } catch (error) {
+      lastError = error;
+    }
   }
   }
-  return data || { code: 500, success: false, error: '服务返回异常' };
+
+  if (typeof require === 'function') {
+    try {
+      return await requestJsonWithNodeHttps(method, url, init);
+    } catch (error) {
+      lastError = error;
+    }
+  }
+
+  if (typeof XMLHttpRequest !== 'undefined') {
+    try {
+      return await requestJsonWithXhr(method, url, init);
+    } catch (error) {
+      lastError = error;
+    }
+  }
+
+  throw lastError || new Error('No HTTP client is available in this cloud runtime');
+}
+
+async function readResponse(response, transport) {
+  const rawText = await response.text();
+  let data = null;
+  try { data = rawText ? JSON.parse(rawText) : null; } catch {}
+  return {
+    status: response.status,
+    ok: !!response.ok,
+    rawText,
+    data,
+    transport,
+  };
+}
+
+function requestJsonWithNodeHttps(method, url, init) {
+  return new Promise((resolve, reject) => {
+    try {
+      const parsed = new URL(url);
+      const lib = parsed.protocol === 'http:' ? require('http') : require('https');
+      const headers = init && init.headers ? init.headers : {};
+      const body = init && init.body ? init.body : '';
+      const req = lib.request({
+        method,
+        protocol: parsed.protocol,
+        hostname: parsed.hostname,
+        port: parsed.port,
+        path: `${parsed.pathname}${parsed.search}`,
+        headers: body ? { ...headers, 'Content-Length': Buffer.byteLength(body) } : headers,
+      }, (res) => {
+        const chunks = [];
+        res.on('data', (chunk) => chunks.push(chunk));
+        res.on('end', () => {
+          const rawText = Buffer.concat(chunks).toString('utf8');
+          let data = null;
+          try { data = rawText ? JSON.parse(rawText) : null; } catch {}
+          resolve({
+            status: res.statusCode || 0,
+            ok: res.statusCode >= 200 && res.statusCode < 300,
+            rawText,
+            data,
+            transport: 'node-https',
+          });
+        });
+      });
+      req.on('error', reject);
+      if (body) req.write(body);
+      req.end();
+    } catch (error) {
+      reject(error);
+    }
+  });
+}
+
+function requestJsonWithXhr(method, url, init) {
+  return new Promise((resolve, reject) => {
+    const xhr = new XMLHttpRequest();
+    xhr.open(method, url, true);
+    const headers = init && init.headers ? init.headers : {};
+    for (const [key, value] of Object.entries(headers)) {
+      xhr.setRequestHeader(key, value);
+    }
+    xhr.onreadystatechange = function onReadyStateChange() {
+      if (xhr.readyState !== 4) return;
+      let data = null;
+      try { data = xhr.responseText ? JSON.parse(xhr.responseText) : null; } catch {}
+      resolve({
+        status: xhr.status,
+        ok: xhr.status >= 200 && xhr.status < 300,
+        rawText: xhr.responseText || '',
+        data,
+        transport: 'xhr',
+      });
+    };
+    xhr.onerror = function onXhrError() {
+      reject(new Error('XMLHttpRequest failed'));
+    };
+    xhr.send(init && init.body ? init.body : null);
+  });
 }
 }
 
 
 function readError(data, fallback) {
 function readError(data, fallback) {
   return data?.error?.message || data?.error || data?.message || data?.msg || fallback || '';
   return data?.error?.message || data?.error || data?.message || data?.msg || fallback || '';
 }
 }
 
 
+function formatFetchError(error) {
+  if (!error) return 'unknown error';
+  const message = error.message || String(error);
+  const cause = error.cause ? `; cause=${error.cause.code || error.cause.message || error.cause}` : '';
+  return `${message}${cause}`;
+}
+
+function sleep(ms) {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
 function readEnv(name) {
 function readEnv(name) {
   if (typeof process !== 'undefined' && process.env && process.env[name]) {
   if (typeof process !== 'undefined' && process.env && process.env[name]) {
     return process.env[name];
     return process.env[name];

+ 3 - 0
cloud-functions/08-proxyHub.js

@@ -10,6 +10,9 @@ const LLM_API_KEY = readEnv('LLM_API_KEY') || 'sk-MFBOnsAtZiqlwwMgMLKCFmPy55pMoh
 
 
 async function handler(request, response) {
 async function handler(request, response) {
   try {
   try {
+    if (!LLM_API_KEY) {
+      return response.json({ code: 400, success: false, error: 'missing LLM_API_KEY' });
+    }
     const action = pickParam(request, 'action');
     const action = pickParam(request, 'action');
 
 
     // ============ 流式聊天(SSE 透传) ============
     // ============ 流式聊天(SSE 透传) ============

+ 666 - 119
cloud-functions/11-jimengManager.js

@@ -1,190 +1,737 @@
 /**
 /**
- * 云函数:jimengManager
- * 代理即梦图片、视频、数字人、动作迁移等任务接口,避免前端暴露上游地址和 token。
+ * Cloud function: jimengManager
  *
  *
- * 部署后把函数 objectId 填入 src/app/services/cloud-functions.ts 的 jimeng。
+ * Secure proxy for Jimeng generation endpoints. The browser calls this cloud
+ * function with { action: 'call', endpoint, payload }, and the function injects
+ * the server-side token before forwarding to JIMENG_BASE_URL.
+ *
+ * actions:
+ *   call          -> proxy a whitelisted Jimeng endpoint
+ *   getWorkResult -> read Parse ImagineWork by workId/objectId
+ *   diagnose      -> non-secret deployment diagnostics
  */
  */
-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 REQUEST_TIMEOUT_MS = Number(readEnv('JIMENG_REQUEST_TIMEOUT_MS') || 60000);
-const MAX_ATTEMPTS = Math.max(1, Number(readEnv('JIMENG_MAX_ATTEMPTS') || 3));
-
-const ALLOWED_ENDPOINTS = new Set([
+
+const JIMENG_ALLOWED_ENDPOINTS = new Set([
+  'getClothesV2',
+  'getImgByImg',
+  'getImgV4',
+  'getText2ImgV3',
+  'getText2ImgV31',
+  'getImg2ImgV3',
+  'getImgV4_pod',
+  'getImgV4_goods',
+  'getInpaint',
+  'getSuperResolution',
   'getVideoV3_720p',
   'getVideoV3_720p',
   'getVideoV3_1080p',
   'getVideoV3_1080p',
   'getVideoV3_Pro',
   'getVideoV3_Pro',
-  'getText2ImgV31',
-  'getImgV4',
+  'getActor',
+  'getActorV2',
   'getDataByTask02',
   'getDataByTask02',
   'getOhIdentifyMain',
   'getOhIdentifyMain',
   'getOhDateByTask',
   'getOhDateByTask',
+  'getOhDetectMain',
   'getOmniHuman',
   'getOmniHuman',
-  'getActorV2',
 ]);
 ]);
 
 
+const JIMENG_BASE_URL = (readEnv('JIMENG_BASE_URL') || 'https://server.fmode.cn/api/volcengine/jimeng').replace(/\/+$/, '');
+const PARSE_API_HOST = normalizeParseApiHost(readEnv('PARSE_API_HOST') || readEnv('PARSE_BASE_URL') || 'https://server.fmode.cn/api');
+const PARSE_APP_ID = readEnv('PARSE_APP_ID') || readEnv('PARSE_APPLICATION_ID') || readEnv('X_PARSE_APPLICATION_ID') || 'ncloudmaster';
+const JIMENG_MAX_ATTEMPTS = Math.max(1, Number(readEnv('JIMENG_MAX_ATTEMPTS') || readEnv('JIMENG_LOCAL_MAX_ATTEMPTS') || 3));
+const JIMENG_TOKEN_FALLBACK = 'Bearer r:f0333969e312a40e4703e8fe4ed1c600';
+const VIDEO_WORKFLOW_APIG_ID = '6pF6EAdKT';
+const DEFAULT_APIG_UNIT_PRICE_CNY = 0.1;
+
+const JIMENG_PRICE_CNY = {
+  getImgV4: { unit: 'image', cny: 0.22 },
+  getText2ImgV3: { unit: 'call', cny: 0.2 },
+  getText2ImgV31: { unit: 'call', cny: 0.2 },
+  getImg2ImgV3: { unit: 'call', cny: 0.2 },
+  getImgV4_pod: { unit: 'image', cny: 0.22 },
+  getImgV4_goods: { unit: 'image', cny: 0.22 },
+  getInpaint: { unit: 'call', cny: 0.2 },
+  getSuperResolution: { unit: 'call', cny: 0.4 },
+  getVideoV3_720p: { unit: 'second', cny: 0.28 },
+  getVideoV3_1080p: { unit: 'second', cny: 0.63 },
+  getVideoV3_Pro: { unit: 'second', cny: 1 },
+  getActor: { unit: 'second', cny: 0.5 },
+  getActorV2: { unit: 'second', cny: 0.4 },
+  getOmniHuman: { unit: 'second', cny: 1 },
+};
+
+const JIMENG_QUERY_ENDPOINTS = new Set(['getDataByTask02', 'getOhDateByTask', 'getWorkResult']);
+
 async function handler(request, response) {
 async function handler(request, response) {
   try {
   try {
-    const action = pickParam(request, 'action') || 'call';
-
-    if (action === 'call') {
-      const endpoint = String(pickParam(request, 'endpoint') || '').trim();
-      const payload = pickParam(request, 'payload', 'data') || {};
-      if (!ALLOWED_ENDPOINTS.has(endpoint)) {
-        return response.json({ code: 400, success: false, error: '不支持的生成接口' });
-      }
+    const action = String(pickParam(request, 'action') || 'call').trim();
 
 
-      const upstreamPayload = stripEmpty({ ...payload, token: JIMENG_TOKEN });
-      const data = await postJson(`${JIMENG_BASE_URL}/${endpoint}`, upstreamPayload);
-      response.json(data);
-      return;
+    if (action === 'diagnose') {
+      return response.json({
+        code: 200,
+        success: true,
+        data: {
+          baseUrl: maskUrl(JIMENG_BASE_URL),
+          parseApiHost: maskUrl(PARSE_API_HOST),
+          parseAppIdConfigured: !!PARSE_APP_ID,
+          tokenConfigured: !!readJimengToken(),
+          tokenSource: jimengTokenSource(),
+          allowedEndpoints: Array.from(JIMENG_ALLOWED_ENDPOINTS).sort(),
+          maxAttempts: JIMENG_MAX_ATTEMPTS,
+        },
+      });
     }
     }
 
 
-    if (action === 'getWorkResult') {
-      const workId = String(pickParam(request, 'workId', 'objectId') || '').trim();
+    if (action === 'getWorkResult' || action === 'work') {
+      const workId = String(pickParam(request, 'workId', 'objectId', 'id') || '').trim();
       if (!workId) {
       if (!workId) {
-        return response.json({ code: 400, success: false, error: '缺少作品 ID' });
+        return response.json({ code: 400, success: false, error: 'Missing workId' });
       }
       }
-      const data = await getJson(`${PARSE_BASE_URL}/classes/ImagineWork/${encodeURIComponent(workId)}`, {
-        'X-Parse-Application-Id': PARSE_APP_ID,
+      const result = await getWorkResult(workId);
+      return response.json(result);
+    }
+
+    if (action !== 'call') {
+      return response.json({ code: 400, success: false, error: `Unknown action: ${action}` });
+    }
+
+    const endpoint = String(pickParam(request, 'endpoint', 'routerName', 'route') || '').trim();
+    if (!JIMENG_ALLOWED_ENDPOINTS.has(endpoint)) {
+      return response.json({ code: 400, success: false, error: `Unsupported Jimeng endpoint: ${endpoint || '(empty)'}` });
+    }
+
+    const token = normalizeBearerToken(pickParam(request, 'token') || readJimengToken());
+    if (!token) {
+      return response.json({
+        code: 400,
+        success: false,
+        error: 'Jimeng token is not configured. Set JIMENG_TOKEN, VOLC_JIMENG_TOKEN, VOICE_TOKEN, VOC_TOKEN, or VOLC_TOKEN, or update JIMENG_TOKEN_FALLBACK.',
       });
       });
-      response.json({ code: 200, success: true, data });
-      return;
     }
     }
 
 
-    response.json({ code: 400, success: false, error: `未知 action: ${action}` });
+    const rawPayload = pickParam(request, 'payload', 'data') || request.body || {};
+    const payload = normalizeJimengPayload(endpoint, rawPayload, token);
+    const sessionToken = String(pickParam(request, 'sessionToken') || '').trim();
+    const idempotencyKey = String(pickParam(request, 'idempotencyKey') || rawPayload.idempotencyKey || rawPayload.generationTaskId || '').trim();
+    const data = await requestJimengWithBilling({ endpoint, payload, sessionToken, idempotencyKey });
+    return response.json(data);
   } catch (error) {
   } catch (error) {
     console.error('jimengManager failed:', error && error.message ? error.message : error);
     console.error('jimengManager failed:', error && error.message ? error.message : error);
-    response.json({ code: 500, success: false, error: error && error.message ? error.message : '生成服务调用失败' });
+    return response.json({
+      code: error && error.status ? error.status : 500,
+      success: false,
+      error: error && error.message ? error.message : 'Jimeng service call failed',
+      detail: error && error.detail ? error.detail : '',
+      upstream: error && error.upstream ? error.upstream : undefined,
+    });
   }
   }
 }
 }
 
 
-function pickParam(request, ...names) {
-  const sources = [request.params, request.body, request];
-  for (const src of sources) {
-    if (!src || typeof src !== 'object') continue;
-    for (const n of names) {
-      const v = src[n];
-      if (v !== undefined && v !== null && v !== '') return v;
-    }
+function normalizeJimengPayload(endpoint, input, token) {
+  const payload = { ...(input || {}) };
+  delete payload.action;
+  delete payload.endpoint;
+  delete payload.route;
+  delete payload.data;
+  delete payload.payload;
+
+  if (endpoint === 'getDataByTask02' || endpoint === 'getOhDateByTask') {
+    payload.routerName = payload.routerName || payload.endpoint || payload.routeName;
   }
   }
-  return null;
+
+  if (endpoint === 'getDataByTask02' && !payload.routerName) {
+    payload.routerName = 'getImgV4';
+  }
+
+  if (endpoint === 'getOhDateByTask' && !payload.routerName) {
+    payload.routerName = 'getOmniHuman';
+  }
+
+  if (payload.task_id && !payload.taskId) payload.taskId = payload.task_id;
+  if (payload.objectId && !payload.workId) payload.workId = payload.objectId;
+  if (endpoint === 'getVideoV3_Pro' && Array.isArray(payload.image_urls) && payload.image_urls[0] && !payload.image_url) {
+    payload.image_url = payload.image_urls[0];
+    delete payload.image_urls;
+  }
+
+  payload.token = token;
+  return stripEmpty(payload);
 }
 }
 
 
-function stripEmpty(value) {
-  if (!value || typeof value !== 'object') return value;
-  const out = Array.isArray(value) ? [] : {};
-  for (const [key, val] of Object.entries(value)) {
-    if (val === undefined || val === null || val === '') continue;
-    if (val && typeof val === 'object' && !Array.isArray(val)) {
-      const nested = stripEmpty(val);
-      if (Object.keys(nested).length) out[key] = nested;
-    } else {
-      out[key] = val;
-    }
+function billingFramesToSeconds(frames) {
+  const raw = Number(frames || 121);
+  return raw >= 241 ? 10 : 5;
+}
+
+function billingMediaSeconds(endpoint, payload) {
+  if (endpoint === 'getActor' || endpoint === 'getActorV2') {
+    return Math.max(1, Math.ceil(Number(payload.durationSeconds || payload.duration || 5)));
   }
   }
-  return out;
+  if (endpoint === 'getOmniHuman') {
+    return Math.max(1, Math.ceil(Number(payload.durationSeconds || payload.audioDurationSeconds || payload.duration || 5)));
+  }
+  return billingFramesToSeconds(payload.frames);
 }
 }
 
 
-async function postJson(url, body) {
-  return requestJson(url, {
-    method: 'POST',
-    headers: { 'Content-Type': 'application/json' },
-    body: JSON.stringify(body),
+function billingImageQuantity(payload) {
+  if (payload.force_single === false || payload.forceSingle === false) return 2;
+  return Math.max(1, Math.ceil(Number(payload.quantity || payload.count || 1)));
+}
+
+function estimateJimengBilling(endpoint, payload, unitPriceCny) {
+  if (JIMENG_QUERY_ENDPOINTS.has(endpoint)) return null;
+  const rule = JIMENG_PRICE_CNY[endpoint];
+  if (!rule) return null;
+  const price = Math.max(0.01, Number(unitPriceCny || DEFAULT_APIG_UNIT_PRICE_CNY));
+  const body = payload || {};
+  const quantity = rule.unit === 'second'
+    ? billingMediaSeconds(endpoint, body)
+    : rule.unit === 'image'
+      ? billingImageQuantity(body)
+      : 1;
+  const costCny = Math.round(rule.cny * quantity * 100) / 100;
+  return {
+    endpoint,
+    unit: rule.unit,
+    quantity,
+    costCny,
+    unitPriceCny: price,
+    credits: Math.max(1, Math.ceil(costCny / price)),
+  };
+}
+
+function sanitizeBillingSnapshot(payload) {
+  const copy = { ...(payload || {}) };
+  delete copy.token;
+  delete copy.apiKey;
+  delete copy.apiSecret;
+  delete copy.binary_data_base64;
+  return copy;
+}
+
+function stableJson(value) {
+  if (value === null || typeof value !== 'object') return JSON.stringify(value);
+  if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
+  return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
+}
+
+async function requestJimengWithBilling({ endpoint, payload, sessionToken, idempotencyKey }) {
+  const initialBilling = estimateJimengBilling(endpoint, payload, DEFAULT_APIG_UNIT_PRICE_CNY);
+  if (!initialBilling) {
+    return requestJimengWithRetry(endpoint, payload);
+  }
+
+  const user = await verifyBillingUser(sessionToken);
+  const auth = await ensureVideoWorkflowAuth(user.objectId, sessionToken);
+  const unitPriceCny = Number(auth && auth.api && auth.api.price ? auth.api.price : DEFAULT_APIG_UNIT_PRICE_CNY);
+  const billing = estimateJimengBilling(endpoint, payload, unitPriceCny);
+  const stableKey = idempotencyKey || `jimeng:${endpoint}:${user.objectId}:${stableJson(sanitizeBillingSnapshot(payload))}`;
+  const lastBilling = auth && auth.lastBilling && typeof auth.lastBilling === 'object' ? auth.lastBilling : null;
+
+  if (lastBilling && lastBilling.idempotencyKey === stableKey) {
+    if (lastBilling.workId) {
+      return {
+        code: 200,
+        success: true,
+        data: {
+          workId: lastBilling.workId,
+          billing: lastBilling,
+          reused: true,
+        },
+      };
+    }
+    if (lastBilling.status === 'reserved') {
+      const error = new Error('上一条相同即梦生成请求仍在处理中,请稍后再试');
+      error.status = 409;
+      error.detail = { idempotencyKey: stableKey, lastBilling };
+      throw error;
+    }
+  }
+
+  const reservation = await reserveJimengCredits({
+    user,
+    auth,
+    billing,
+    endpoint,
+    payload,
+    idempotencyKey: stableKey,
+    sessionToken,
   });
   });
+
+  try {
+    const data = await requestJimengWithRetry(endpoint, payload);
+    const workId = extractJimengWorkId(data);
+    if (!workId) {
+      await refundJimengCredits({
+        user,
+        auth,
+        reservation,
+        reason: 'Jimeng submit returned no workId',
+        sessionToken,
+      });
+      return data;
+    }
+
+    const submittedBilling = {
+      ...reservation.lastBilling,
+      status: 'submitted',
+      workId,
+      responseSnapshot: sanitizeBillingSnapshot(data),
+      updatedAt: new Date().toISOString(),
+    };
+    await updateApigAuthBilling(auth, { lastBilling: submittedBilling }, sessionToken);
+    return {
+      ...data,
+      billing: {
+        costCredits: billing.credits,
+        costCny: billing.costCny,
+        balanceAfter: reservation.nextCount,
+        workId,
+      },
+    };
+  } catch (error) {
+    await refundJimengCredits({
+      user,
+      auth,
+      reservation,
+      reason: error && error.message ? error.message : 'Jimeng submit failed before workId',
+      sessionToken,
+    });
+    throw error;
+  }
 }
 }
 
 
-async function getJson(url, headers) {
-  const data = await requestJson(url, { method: 'GET', headers });
-  if (data && data.success === false) throw new Error(data.error || '查询结果失败');
+function extractJimengWorkId(data) {
+  return data && (
+    data.workId
+    || data.taskId
+    || data.objectId
+    || data.data && (data.data.workId || data.data.taskId || data.data.objectId)
+    || data.result && (data.result.workId || data.result.taskId || data.result.objectId)
+  ) || '';
+}
+
+async function parseRequest(method, path, body, sessionToken) {
+  const base = String(PARSE_API_HOST || '').replace(/\/+$/, '').replace(/\/parse$/i, '');
+  const url = `${base}/parse${path}`;
+  const headers = {
+    'Content-Type': 'application/json',
+    'Accept': 'application/json',
+    'X-Parse-Application-Id': PARSE_APP_ID,
+  };
+  if (sessionToken) headers['X-Parse-Session-Token'] = sessionToken;
+  const init = { method, headers };
+  if (body !== undefined) init.body = JSON.stringify(body);
+  const { status, ok, rawText, data } = await requestJson(method, url, init);
+  if (!ok) {
+    const error = new Error(readErrorMessage(data, rawText) || `Parse ${method} ${path} failed`);
+    error.status = status || 500;
+    error.detail = data || rawText || '';
+    throw error;
+  }
   return data;
   return data;
 }
 }
 
 
-async function requestJson(url, options) {
+async function verifyBillingUser(sessionToken) {
+  if (!sessionToken) {
+    const error = new Error('请先登录后再生成');
+    error.status = 401;
+    throw error;
+  }
+  return parseRequest('GET', '/users/me?include=company', undefined, sessionToken);
+}
+
+async function findVideoWorkflowAuth(userId, sessionToken) {
+  const where = encodeURIComponent(JSON.stringify({
+    api: { __type: 'Pointer', className: 'APIG', objectId: VIDEO_WORKFLOW_APIG_ID },
+    user: { __type: 'Pointer', className: '_User', objectId: userId },
+  }));
+  const data = await parseRequest('GET', `/classes/APIGAuth?where=${where}&include=api&limit=1`, undefined, sessionToken);
+  return Array.isArray(data.results) ? data.results[0] || null : null;
+}
+
+async function ensureVideoWorkflowAuth(userId, sessionToken) {
+  const existing = await findVideoWorkflowAuth(userId, sessionToken);
+  if (existing) return existing;
+  const body = {
+    api: { __type: 'Pointer', className: 'APIG', objectId: VIDEO_WORKFLOW_APIG_ID },
+    user: { __type: 'Pointer', className: '_User', objectId: userId },
+    count: 0,
+    used: 0,
+  };
+  const created = await parseRequest('POST', '/classes/APIGAuth', body, sessionToken);
+  return { ...body, ...created };
+}
+
+async function updateApigAuthBilling(auth, patch, sessionToken) {
+  return parseRequest('PUT', `/classes/APIGAuth/${encodeURIComponent(auth.objectId)}`, patch, sessionToken);
+}
+
+async function reserveJimengCredits({ auth, billing, endpoint, payload, idempotencyKey, sessionToken }) {
+  const oldCount = Number(auth.count || 0);
+  const oldUsed = Number(auth.used || 0);
+  if (oldCount < billing.credits) {
+    const error = new Error(`余额不足:当前 ${oldCount},本次需要 ${billing.credits}`);
+    error.status = 402;
+    throw error;
+  }
+
+  const nextCount = oldCount - billing.credits;
+  const nextUsed = oldUsed + billing.credits;
+  const lastBilling = {
+    kind: 'jimeng_consume',
+    status: 'reserved',
+    endpoint,
+    costCredits: billing.credits,
+    costCny: billing.costCny,
+    unitPriceCny: billing.unitPriceCny,
+    quantity: billing.quantity,
+    unit: billing.unit,
+    idempotencyKey,
+    balanceBefore: oldCount,
+    balanceAfter: nextCount,
+    usedBefore: oldUsed,
+    usedAfter: nextUsed,
+    requestSnapshot: sanitizeBillingSnapshot(payload),
+    updatedAt: new Date().toISOString(),
+  };
+
+  await updateApigAuthBilling(auth, { count: nextCount, used: nextUsed, lastBilling }, sessionToken);
+  return { oldCount, oldUsed, nextCount, nextUsed, lastBilling };
+}
+
+async function refundJimengCredits({ user, auth, reservation, reason, sessionToken }) {
+  const latest = await findVideoWorkflowAuth(user.objectId, sessionToken);
+  const current = latest || auth;
+  const currentCount = Number(current.count || 0);
+  const currentUsed = Number(current.used || 0);
+  const refundCredits = Number(reservation.lastBilling.costCredits || 0);
+  const nextCount = currentCount + refundCredits;
+  const nextUsed = Math.max(0, currentUsed - refundCredits);
+
+  await updateApigAuthBilling(current, {
+    count: nextCount,
+    used: nextUsed,
+    lastBilling: {
+      ...reservation.lastBilling,
+      status: 'refunded',
+      refundReason: reason,
+      balanceAfter: nextCount,
+      usedAfter: nextUsed,
+      updatedAt: new Date().toISOString(),
+    },
+  }, sessionToken);
+}
+
+async function requestJimengWithRetry(endpoint, payload) {
   let lastError = null;
   let lastError = null;
-  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
+  const url = `${JIMENG_BASE_URL}/${encodeURIComponent(endpoint)}`;
+
+  for (let attempt = 1; attempt <= JIMENG_MAX_ATTEMPTS; attempt += 1) {
     try {
     try {
-      const r = await fetchWithTimeout(url, options, REQUEST_TIMEOUT_MS);
-      const data = await readResponse(r, url, attempt);
-      if (data && data.success === false && isRetryableStatus(data.code) && attempt < MAX_ATTEMPTS) {
-        await sleep(backoffMs(attempt));
-        continue;
+      const { status, ok, rawText, data, transport } = await postJson(url, payload);
+      const code = Number(data && data.code ? data.code : status || 0);
+      const retryable = !ok || code === 408 || code === 429 || code >= 500;
+
+      if (!retryable || attempt >= JIMENG_MAX_ATTEMPTS) {
+        if (!ok) {
+          const error = new Error(readErrorMessage(data, rawText) || `Jimeng HTTP ${status}`);
+          error.status = status || code || 500;
+          error.detail = data || rawText || '';
+          error.upstream = { endpoint, status, transport, attempt, maxAttempts: JIMENG_MAX_ATTEMPTS };
+          throw error;
+        }
+        if (!data) {
+          return { code: 500, success: false, error: 'Jimeng returned an empty response' };
+        }
+        return data;
       }
       }
-      return data;
     } catch (error) {
     } catch (error) {
       lastError = error;
       lastError = error;
-      if (!isRetryableNetworkError(error) || attempt >= MAX_ATTEMPTS) break;
-      await sleep(backoffMs(attempt));
+      if (attempt >= JIMENG_MAX_ATTEMPTS) break;
     }
     }
+
+    await sleep(Math.min(12000, 1200 * attempt * attempt));
   }
   }
 
 
-  const message = lastError && lastError.message ? lastError.message : 'fetch failed';
-  throw new Error(`即梦上游网络请求失败:${message};url=${maskUrl(url)};attempts=${MAX_ATTEMPTS}`);
+  const error = new Error(`Jimeng upstream request failed: ${formatFetchError(lastError)}; endpoint=${endpoint}`);
+  error.status = (lastError && lastError.status) || 500;
+  error.detail = (lastError && lastError.detail) || '';
+  error.upstream = { endpoint, attempt: JIMENG_MAX_ATTEMPTS, maxAttempts: JIMENG_MAX_ATTEMPTS };
+  throw error;
 }
 }
 
 
-async function fetchWithTimeout(url, options, timeoutMs) {
-  if (typeof AbortController === 'undefined') {
-    return fetch(url, options);
+async function getWorkResult(workId) {
+  const urls = buildParseClassUrls('ImagineWork', workId);
+  const headers = { 'X-Parse-Application-Id': PARSE_APP_ID };
+  let lastError = null;
+
+  for (let attempt = 1; attempt <= JIMENG_MAX_ATTEMPTS; attempt += 1) {
+    for (const url of urls) {
+      try {
+        const { status, ok, rawText, data } = await getJson(url, headers);
+        const wrongRoute = status === 404 && /Cannot\s+GET\s+\/api\/parse\/classes/i.test(rawText || '');
+        const retryable = !ok && !wrongRoute && (status === 408 || status === 429 || status >= 500);
+
+        if (ok) {
+          return { code: 200, success: true, data };
+        }
+
+        if (wrongRoute && urls.length > 1) {
+          lastError = new Error(`Wrong Parse route: ${url}`);
+          continue;
+        }
+
+        if (!retryable || attempt >= JIMENG_MAX_ATTEMPTS) {
+          return {
+            code: status || 500,
+            success: false,
+            error: readErrorMessage(data, rawText) || `Failed to query Jimeng work result from ${maskUrl(url)}`,
+          };
+        }
+      } catch (error) {
+        lastError = error;
+        if (attempt >= JIMENG_MAX_ATTEMPTS && url === urls[urls.length - 1]) break;
+      }
+    }
+
+    await sleep(Math.min(12000, 1200 * attempt * attempt));
   }
   }
-  const controller = new AbortController();
-  const timer = setTimeout(() => controller.abort(), timeoutMs);
-  try {
-    return await fetch(url, { ...options, signal: controller.signal });
-  } finally {
-    clearTimeout(timer);
+
+  return {
+    code: 500,
+    success: false,
+    error: `Failed to query Jimeng work result: ${formatFetchError(lastError)}`,
+  };
+}
+
+async function postJson(url, body) {
+  return requestJson('POST', url, {
+    headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
+    body: JSON.stringify(body || {}),
+  });
+}
+
+async function getJson(url, headers) {
+  return requestJson('GET', url, {
+    headers: { 'Accept': 'application/json', ...(headers || {}) },
+  });
+}
+
+async function requestJson(method, url, init) {
+  let lastError = null;
+
+  if (typeof fetch === 'function') {
+    try {
+      const response = await fetch(url, { method, ...(init || {}) });
+      return readResponse(response, 'fetch');
+    } catch (error) {
+      lastError = error;
+    }
+  }
+
+  if (typeof require === 'function') {
+    try {
+      return await requestJsonWithNodeHttps(method, url, init);
+    } catch (error) {
+      lastError = error;
+    }
+  }
+
+  if (typeof XMLHttpRequest !== 'undefined') {
+    try {
+      return await requestJsonWithXhr(method, url, init);
+    } catch (error) {
+      lastError = error;
+    }
   }
   }
+
+  throw lastError || new Error('No HTTP client is available in this cloud runtime');
 }
 }
 
 
-async function readResponse(r, url, attempt) {
-  const text = await r.text();
+async function readResponse(response, transport) {
+  const rawText = await response.text();
   let data = null;
   let data = null;
-  try { data = text ? JSON.parse(text) : null; } catch {}
-  if (!r.ok) {
-    return {
-      code: r.status,
-      success: false,
-      error: readError(data, text) || `HTTP ${r.status}`,
-      upstream: {
-        status: r.status,
-        url: maskUrl(url),
-        attempt,
-        maxAttempts: MAX_ATTEMPTS,
-      },
+  try { data = rawText ? JSON.parse(rawText) : null; } catch {}
+  return {
+    status: response.status,
+    ok: !!response.ok,
+    rawText,
+    data,
+    transport,
+  };
+}
+
+function requestJsonWithNodeHttps(method, url, init) {
+  return new Promise((resolve, reject) => {
+    try {
+      const parsed = new URL(url);
+      const lib = parsed.protocol === 'http:' ? require('http') : require('https');
+      const headers = init && init.headers ? init.headers : {};
+      const body = init && init.body ? init.body : '';
+      const req = lib.request({
+        method,
+        protocol: parsed.protocol,
+        hostname: parsed.hostname,
+        port: parsed.port,
+        path: `${parsed.pathname}${parsed.search}`,
+        headers: body ? { ...headers, 'Content-Length': Buffer.byteLength(body) } : headers,
+      }, (res) => {
+        const chunks = [];
+        res.on('data', (chunk) => chunks.push(chunk));
+        res.on('end', () => {
+          const rawText = Buffer.concat(chunks).toString('utf8');
+          let data = null;
+          try { data = rawText ? JSON.parse(rawText) : null; } catch {}
+          resolve({
+            status: res.statusCode || 0,
+            ok: res.statusCode >= 200 && res.statusCode < 300,
+            rawText,
+            data,
+            transport: 'node-https',
+          });
+        });
+      });
+      req.on('error', reject);
+      if (body) req.write(body);
+      req.end();
+    } catch (error) {
+      reject(error);
+    }
+  });
+}
+
+function requestJsonWithXhr(method, url, init) {
+  return new Promise((resolve, reject) => {
+    const xhr = new XMLHttpRequest();
+    xhr.open(method, url, true);
+    const headers = init && init.headers ? init.headers : {};
+    for (const [key, value] of Object.entries(headers)) {
+      xhr.setRequestHeader(key, value);
+    }
+    xhr.onreadystatechange = function onReadyStateChange() {
+      if (xhr.readyState !== 4) return;
+      let data = null;
+      try { data = xhr.responseText ? JSON.parse(xhr.responseText) : null; } catch {}
+      resolve({
+        status: xhr.status,
+        ok: xhr.status >= 200 && xhr.status < 300,
+        rawText: xhr.responseText || '',
+        data,
+        transport: 'xhr',
+      });
+    };
+    xhr.onerror = function onXhrError() {
+      reject(new Error('XMLHttpRequest failed'));
     };
     };
+    xhr.send(init && init.body ? init.body : null);
+  });
+}
+
+function pickParam(request, ...names) {
+  const sources = [request && request.params, request && 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;
+}
+
+function stripEmpty(value) {
+  if (!value || typeof value !== 'object') return value;
+  const out = Array.isArray(value) ? [] : {};
+  for (const [key, val] of Object.entries(value)) {
+    if (val === undefined || val === null || val === '') continue;
+    if (val && typeof val === 'object' && !Array.isArray(val)) {
+      const nested = stripEmpty(val);
+      if (Object.keys(nested).length) out[key] = nested;
+    } else {
+      out[key] = val;
+    }
+  }
+  return out;
+}
+
+function readJimengToken() {
+  return readEnv('JIMENG_TOKEN')
+    || readEnv('VOLC_JIMENG_TOKEN')
+    || readEnv('VOICE_TOKEN')
+    || readEnv('VOC_TOKEN')
+    || readEnv('VOLC_TOKEN')
+    || readEnv('TRANSCRIPTION_VOC_TOKEN')
+    || JIMENG_TOKEN_FALLBACK
+    || '';
+}
+
+function jimengTokenSource() {
+  const names = ['JIMENG_TOKEN', 'VOLC_JIMENG_TOKEN', 'VOICE_TOKEN', 'VOC_TOKEN', 'VOLC_TOKEN', 'TRANSCRIPTION_VOC_TOKEN'];
+  for (const name of names) {
+    if (readEnv(name)) return name;
   }
   }
-  return data || { code: 500, success: false, error: '服务返回异常' };
+  if (JIMENG_TOKEN_FALLBACK) return 'JIMENG_TOKEN_FALLBACK';
+  return '';
+}
+
+function normalizeBearerToken(token) {
+  const value = String(token || '').trim();
+  if (!value) return '';
+  return /^Bearer\s+/i.test(value) ? value : `Bearer ${value}`;
 }
 }
 
 
-function isRetryableStatus(status) {
-  const code = Number(status || 0);
-  return code === 408 || code === 429 || code >= 500;
+function readErrorMessage(data, fallback) {
+  if (!data || typeof data !== 'object') return fallback || '';
+  const nested = data.message && typeof data.message === 'object' ? data.message.errmsg : null;
+  return nested && nested.message
+    || data.errmsg && data.errmsg.message
+    || data.error && data.error.message
+    || data.message
+    || data.msg
+    || data.error
+    || data.detail
+    || fallback
+    || '';
 }
 }
 
 
-function isRetryableNetworkError(error) {
-  const message = error && error.message ? error.message : String(error || '');
-  return /fetch failed|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|network|abort|timeout/i.test(message);
+function buildParseClassUrls(className, objectId) {
+  const base = String(PARSE_API_HOST || '').replace(/\/+$/, '');
+  const primaryPath = /\/parse$/i.test(base)
+    ? `/classes/${encodeURIComponent(className)}/${encodeURIComponent(objectId)}`
+    : `/parse/classes/${encodeURIComponent(className)}/${encodeURIComponent(objectId)}`;
+  const primary = `${base}${primaryPath}`;
+
+  const originMatch = base.match(/^(https?:\/\/[^/]+)/i);
+  const origin = originMatch ? originMatch[1] : '';
+  const parseDirect = origin
+    ? `${origin}/parse/classes/${encodeURIComponent(className)}/${encodeURIComponent(objectId)}`
+    : primary;
+
+  return Array.from(new Set([parseDirect, primary]));
 }
 }
 
 
-function backoffMs(attempt) {
-  return Math.min(12000, 1200 * attempt * attempt);
+function normalizeParseApiHost(value) {
+  return String(value || 'https://server.fmode.cn/parse')
+    .replace(/\/+$/, '')
+    .replace(/\/api\/functions$/i, '')
+    .replace(/\/api$/i, '');
 }
 }
 
 
-function sleep(ms) {
-  return new Promise(resolve => setTimeout(resolve, ms));
+function formatFetchError(error) {
+  if (!error) return 'unknown error';
+  const message = error.message || String(error);
+  const cause = error.cause ? `; cause=${error.cause.code || error.cause.message || error.cause}` : '';
+  return `${message}${cause}`;
 }
 }
 
 
-function maskUrl(url) {
-  try {
-    const u = new URL(url);
-    return `${u.origin}${u.pathname}`;
-  } catch {
-    return String(url || '').split('?')[0];
-  }
+function maskUrl(value) {
+  return String(value || '')
+    .replace(/(token=)[^&]+/ig, '$1***')
+    .replace(/(Bearer\s+)[^&\s]+/ig, '$1***');
 }
 }
 
 
-function readError(data, fallback) {
-  return data?.error?.message || data?.error || data?.message || data?.msg || fallback || '';
+function sleep(ms) {
+  return new Promise((resolve) => setTimeout(resolve, ms));
 }
 }
 
 
 function readEnv(name) {
 function readEnv(name) {

+ 44 - 11
cloud-functions/13-douyinInsightManager.js

@@ -354,10 +354,11 @@ async function diagnose(request, response) {
   const sessionToken = clean(pickParam(request, 'sessionToken'));
   const sessionToken = clean(pickParam(request, 'sessionToken'));
   if (sessionToken) {
   if (sessionToken) {
     try {
     try {
-      const user = await verifyParseSession(sessionToken);
+      const user = await verifyParseSessionFromDb(sessionToken) || await verifyParseSession(sessionToken);
       result.data.session = {
       result.data.session = {
         success: !!user?.objectId,
         success: !!user?.objectId,
         userId: user?.objectId || '',
         userId: user?.objectId || '',
+        source: user?.source || '',
       };
       };
     } catch (error) {
     } catch (error) {
       result.data.session = {
       result.data.session = {
@@ -571,22 +572,54 @@ async function requireSession(request) {
   );
   );
   if (rows.length) return rows[0];
   if (rows.length) return rows[0];
 
 
-  const parseUser = await verifyParseSession(token);
+  const parseUser = await verifyParseSessionFromDb(token) || await verifyParseSession(token);
   if (parseUser?.objectId) return { token, userId: parseUser.objectId, source: 'parse' };
   if (parseUser?.objectId) return { token, userId: parseUser.objectId, source: 'parse' };
   throw new Error('登录已过期,请重新登录');
   throw new Error('登录已过期,请重新登录');
 }
 }
 
 
+async function verifyParseSessionFromDb(sessionToken) {
+  try {
+    const rows = await Psql.query(
+      `SELECT s."objectId" AS "sessionObjectId",
+              s."sessionToken" AS "sessionToken",
+              s."expiresAt" AS "expiresAt",
+              s."_p_user" AS "userPointer",
+              u."objectId" AS "objectId",
+              u."username" AS "username"
+         FROM "_Session" s
+         LEFT JOIN "_User" u ON s."_p_user" = CONCAT('_User$', u."objectId")
+        WHERE s."sessionToken"=$1
+          AND (s."expiresAt" IS NULL OR s."expiresAt" > NOW())
+        LIMIT 1`,
+      [sessionToken]
+    );
+    if (!rows.length) return null;
+    const row = rows[0];
+    const pointerUserId = String(row.userPointer || '').replace(/^_User\$/, '');
+    const objectId = row.objectId || pointerUserId;
+    return objectId ? { objectId, username: row.username || '', source: 'parse-db' } : null;
+  } catch (error) {
+    console.warn('verifyParseSessionFromDb skipped:', error.message);
+    return null;
+  }
+}
+
 async function verifyParseSession(sessionToken) {
 async function verifyParseSession(sessionToken) {
   if (typeof fetch !== 'function') return null;
   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;
+  try {
+    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(() => ({}));
+    if (!resp.ok) throw new Error(data.error || data.message || `HTTP ${resp.status}`);
+    return data.objectId ? { ...data, source: 'parse-rest' } : null;
+  } catch (error) {
+    throw new Error(`Parse 会话网络校验失败:${formatFetchError(error)}`);
+  }
 }
 }
 
 
 function clean(value) {
 function clean(value) {

+ 27 - 1
cloud-functions/DEPLOY.md

@@ -40,7 +40,7 @@
 | `08-proxyHub.js` | `LLM_BASE_URL`, `LLM_API_KEY` | LLM/Gemini 代理。 |
 | `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。 |
 | `09-uploadManager.js` | `QINIU_AK`, `QINIU_SK`, `QINIU_BUCKET`, `QINIU_CDN_DOMAIN`, `QINIU_CDN_PREFIX`, `QINIU_UPLOAD_URL` | 七牛直传 token。 |
 | `10-authCreditManager.js` | Parse session 可用即可;无第三方密钥 | 账号、积分、充值框架。商业化恢复前再启用。 |
 | `10-authCreditManager.js` | Parse session 可用即可;无第三方密钥 | 账号、积分、充值框架。商业化恢复前再启用。 |
-| `11-jimengManager.js` | `JIMENG_TOKEN`, `JIMENG_BASE_URL`, `PARSE_BASE_URL`, `PARSE_APP_ID` | 即梦真实生成代理。 |
+| `11-jimengManager.js` | `JIMENG_TOKEN`, `JIMENG_BASE_URL`, `PARSE_BASE_URL`, `PARSE_APP_ID` | 即梦真实生成代理。为保留公司计费,线上 `JIMENG_BASE_URL` 应指向 `https://server.fmode.cn/api/volcengine/jimeng`;云函数会依次尝试 `fetch`、Node HTTPS、XMLHttpRequest 访问同一公司接口。 |
 | `12-douyinManager.js` | `DOUYIN_API_BASE_URL`, `DOUYIN_API_TOKEN`(可复用 `VOC_TOKEN` / `VOICE_TOKEN`) | 抖音真实平台数据抓取。默认走 `https://server.fmode.cn/api/voc-social`。 |
 | `12-douyinManager.js` | `DOUYIN_API_BASE_URL`, `DOUYIN_API_TOKEN`(可复用 `VOC_TOKEN` / `VOICE_TOKEN`) | 抖音真实平台数据抓取。默认走 `https://server.fmode.cn/api/voc-social`。 |
 | `13-douyinInsightManager.js` | `PARSE_API_HOST`, `PARSE_APP_ID`, `DOUYIN_API_BASE_URL`, `DOUYIN_API_TOKEN`, `VOC_TOKEN`(或 `TRANSCRIPTION_VOC_TOKEN` / `VOICE_TOKEN`), `IFLYTEK_GATEWAY_BASE_URL` | 业务资产持久化和用户隔离;逐字稿任务会通过抖音数据网关获取视频详情并调用讯飞网关转写。 |
 | `13-douyinInsightManager.js` | `PARSE_API_HOST`, `PARSE_APP_ID`, `DOUYIN_API_BASE_URL`, `DOUYIN_API_TOKEN`, `VOC_TOKEN`(或 `TRANSCRIPTION_VOC_TOKEN` / `VOICE_TOKEN`), `IFLYTEK_GATEWAY_BASE_URL` | 业务资产持久化和用户隔离;逐字稿任务会通过抖音数据网关获取视频详情并调用讯飞网关转写。 |
 
 
@@ -96,3 +96,29 @@ await fetch('https://server.fmode.cn/api/functions', {
 - `npm run smoke:cloud` 通过。
 - `npm run smoke:cloud` 通过。
 - `npm run build` 通过。
 - `npm run build` 通过。
 - 批量真实生成、素材归档、发布复盘抓取各跑一条端到端样例。
 - 批量真实生成、素材归档、发布复盘抓取各跑一条端到端样例。
+
+## 2026-06-03 凭证治理补充规范
+
+本项目已经新增根目录 `.env.example` 作为本地开发配置模板。后续本地联调请复制为 `.env` 后填写真实值,`.env` 已被 `.gitignore` 忽略,禁止提交真实凭证。
+
+云函数部署时必须在 fmode 云函数平台配置环境变量。代码中后续会逐步移除真实 token/key fallback,因此不要依赖源码里的兼容兜底值。
+
+### 变量分组
+
+| 能力 | 变量 | 用途 |
+|---|---|---|
+| 即梦生成 | `JIMENG_TOKEN`, `JIMENG_BASE_URL` | 文生视频、图生视频、图片生成、数字人、动作迁移。为保留公司计费,生产建议使用 `https://server.fmode.cn/api/volcengine/jimeng`。 |
+| 抖音数据 | `DOUYIN_API_BASE_URL`, `DOUYIN_API_TOKEN`, `VOC_TOKEN`, `VOC_SOCIAL_TOKEN` | 视频详情、评论、博主作品、爆款分析证据抓取 |
+| TikHub 直连 | `TIKHUB_BASE_URL`, `TIKHUB_TOKEN` | 仅在明确直连 TikHub 时使用 |
+| 逐字稿 | `IFLYTEK_GATEWAY_BASE_URL`, `TRANSCRIPTION_VOC_TOKEN`, `VOICE_TOKEN`, `OPENCLAW_VOC_TOKEN` | 爆款分析补逐字稿、音频转写 |
+| LLM/Gemini | `LLM_BASE_URL`, `LLM_API_KEY` | AI 助手、脚本生成、素材理解 |
+| 七牛上传 | `QINIU_AK`, `QINIU_SK`, `QINIU_BUCKET`, `QINIU_DOMAIN`, `QINIU_CDN_DOMAIN`, `QINIU_CDN_PREFIX`, `QINIU_UPLOAD_URL` | 上传 token 和素材归档 |
+| 语音合成 | `VOLC_TTS_TOKEN`, `VOLC_SPEECH_API_KEY`, `VOLC_SPEECH_APP_KEY`, `VOLC_SPEECH_ACCESS_KEY`, `VOICE_TTS_BASE_URL` | TTS、音色训练、语音合成 |
+| Quickly | `QUICKLY_APP_KEY`, `QUICKLY_APP_SECRET`, `QUICKLY_ACCOUNT_ID`, `QUICKLY_CALLBACK_URL`, `QUICKLY_RELAY_URL`, `QUICKLY_UPSTREAM_FN_ID` | 一键成片 |
+
+### 安全要求
+
+- 文档、源码、提交记录中不记录真实 token/key。
+- `diagnose` 类接口只能返回 `tokenConfigured`、`tokenSource`、`baseUrl`,不能返回明文 token。
+- 已经暴露在源码或聊天记录中的凭证,上线前需要在对应平台轮换。
+- 缺少凭证时应返回中文配置错误,提示具体变量名,不允许静默使用错误账号。

+ 142 - 0
e2e/ip-operator-p2-core.spec.ts

@@ -0,0 +1,142 @@
+import { expect, Page, test } from '@playwright/test';
+import path from 'node:path';
+
+declare global {
+  interface Window {
+    __seedIpOperatorScenario?: (scenario: 'ready') => unknown;
+  }
+}
+
+const scenarioSeedPath = path.resolve(__dirname, '../scripts/validation/ip-operator-scenario-seed.js');
+const planKey = 'tiktok.ipOperator.plan.ip_plan_e2e_ready.local-guest';
+const viralAnalysisKey = 'videoWorkflow.viralAnalyses.items';
+
+async function seedP2Scenario(page: Page) {
+  await page.addInitScript({ path: scenarioSeedPath });
+  await page.addInitScript(([nextPlanKey, nextViralAnalysisKey]) => {
+    const now = new Date().toISOString();
+    window.__seedIpOperatorScenario?.('ready');
+    localStorage.setItem('tiktok.currentTab', 'ip-operator');
+
+    const raw = localStorage.getItem(nextPlanKey);
+    if (!raw) return;
+    const plan = JSON.parse(raw);
+    plan.benchmarkEvidence = [{
+      id: 'ip_evidence_e2e_existing',
+      planId: plan.id,
+      profileId: plan.profileId,
+      benchmarkId: 'benchmark_e2e_1',
+      source: 'viral_analysis',
+      sourceId: 'analysis_e2e_existing',
+      awemeId: '7123456789012345678',
+      inputUrl: 'https://www.douyin.com/video/7123456789012345678',
+      title: 'E2E benchmark comment evidence',
+      authorName: 'Benchmark Owner Account',
+      summary: 'Users reacted to the account because it named the trust problem before selling.',
+      hookPattern: 'Question hook + hard contrast',
+      reusableFrame: 'Name the hidden trust problem, then replace the case with this IP assets.',
+      commentPainPoints: ['Looks like an ad account', 'No real case proof', 'Unsure whether the owner understands my problem'],
+      migrationSuggestion: 'Use the comment pain to build a trust-diagnosis opportunity, then rewrite topics and scripts around one real owner scenario.',
+      nonCopyableRisk: 'Do not copy the benchmark title, exact wording, claimed result, or industry-specific case.',
+      evidenceRefs: ['comment: Looks like an ad account', 'comment: No real case proof'],
+      confidence: 'high',
+      createdAt: now,
+    }];
+    plan.evidenceFetchJobs = [];
+    plan.evidenceSuggestions = [];
+    localStorage.setItem(nextPlanKey, JSON.stringify(plan));
+
+    localStorage.setItem(nextViralAnalysisKey, JSON.stringify([{
+      id: 'analysis_e2e_saved',
+      userId: 'local-guest',
+      awemeId: '7987654321098765432',
+      source: 'manual',
+      videoSnapshot: {
+        awemeId: '7987654321098765432',
+        desc: 'E2E saved viral analysis',
+        authorName: 'Saved Benchmark',
+        diggCount: 12000,
+        commentCount: 800,
+        shareCount: 450,
+      },
+      commentsSnapshot: [
+        { id: 'c1', text: 'This feels useful because it does not sell first.', likeCount: 99 },
+        { id: 'c2', text: 'I want to see a real before-after case.', likeCount: 80 },
+      ],
+      confidence: 'medium',
+      analysis: {
+        summary: 'Saved analysis highlights trust-first owner content.',
+        hookType: 'pain question',
+        openingPattern: 'Ask why users distrust owner accounts',
+        contentRhythm: 'hook-proof-action',
+        conflict: 'selling first vs earning trust first',
+        proofPoint: 'real case detail',
+        audienceEmotion: 'skeptical but interested',
+        commentTrigger: 'asks for before-after examples',
+        reusableFrame: 'replace the case with this IP own client story',
+        reusableAngles: ['trust diagnosis', 'case proof'],
+        riskNotes: ['avoid copying exact claim'],
+        evidenceRefs: ['c1', 'c2'],
+      },
+      savedTopicIds: [],
+      createdAt: now,
+      updatedAt: now,
+    }]));
+  }, [planKey, viralAnalysisKey]);
+}
+
+test('P2 core links benchmark evidence back to opportunities, topics, scripts, and production', async ({ page }) => {
+  await seedP2Scenario(page);
+  await page.goto('/');
+
+  await expect(page.locator('.ip-operator-page')).toBeVisible();
+  await expect(page.locator('.ip-quality-audit')).toBeVisible();
+  await expect(page.locator('.ip-evidence-panel')).toBeVisible();
+  await expect(page.getByText('E2E benchmark comment evidence')).toBeVisible();
+
+  await page.locator('.ip-evidence-grid section').first().locator('article button').first().click();
+  await expect(page.locator('.ip-evidence-suggestions article')).toHaveCount(3);
+  await page.locator('.ip-evidence-suggestions article .btn-primary').first().click();
+
+  await expect.poll(async () => page.evaluate((nextPlanKey) => {
+    const plan = JSON.parse(localStorage.getItem(nextPlanKey) || '{}');
+    return Boolean(
+      plan.evidenceSuggestions?.some((item: { status?: string }) => item.status === 'applied')
+      && plan.opportunityMap?.some((item: { type?: string; reason?: string }) => (
+        item.type === '评论痛点迁移' && String(item.reason || '').includes('Looks like an ad account')
+      ))
+    );
+  }, planKey)).toBe(true);
+
+  await page.locator('.ip-evidence-form').first().locator('input').fill('https://v.douyin.com/short-only/');
+  await page.locator('.ip-evidence-form').first().locator('button').click();
+  await expect.poll(async () => page.evaluate((nextPlanKey) => {
+    const plan = JSON.parse(localStorage.getItem(nextPlanKey) || '{}');
+    return plan.evidenceFetchJobs?.some((job: { status?: string; failureCode?: string }) => (
+      job.status === 'failed' && job.failureCode === 'invalid_url'
+    ));
+  }, planKey)).toBe(true);
+
+  const savedForm = page.locator('.ip-evidence-form--saved');
+  await savedForm.locator('button').first().click();
+  await savedForm.locator('select').selectOption('analysis_e2e_saved');
+  await savedForm.locator('button').nth(1).click();
+  await expect.poll(async () => page.evaluate((nextPlanKey) => {
+    const plan = JSON.parse(localStorage.getItem(nextPlanKey) || '{}');
+    return Boolean(
+      plan.benchmarkEvidence?.some((item: { sourceId?: string }) => item.sourceId === 'analysis_e2e_saved')
+      && plan.evidenceFetchJobs?.some((job: { analysisId?: string; status?: string }) => (
+        job.analysisId === 'analysis_e2e_saved' && job.status === 'completed'
+      ))
+    );
+  }, planKey)).toBe(true);
+
+  await page.locator('.ip-result-tabs button').nth(1).click();
+  await expect(page.locator('.ip-topic-card').first()).toBeVisible();
+
+  await page.locator('.ip-result-tabs button').nth(2).click();
+  await expect(page.locator('.ip-script-detail').first()).toBeVisible();
+  await page.locator('.ip-script-detail').first().locator('.btn-primary').click();
+  await expect(page.locator('.dh-page')).toBeVisible();
+  await expect(page.locator('textarea.dh-textarea').first()).not.toHaveValue('');
+});

+ 175 - 0
e2e/ip-operator-real-llm.spec.ts

@@ -0,0 +1,175 @@
+import { expect, Page, test } from '@playwright/test';
+import fs from 'node:fs';
+import path from 'node:path';
+
+declare global {
+  interface Window {
+    __seedIpOperatorScenario?: (scenario: 'empty') => unknown;
+  }
+}
+
+const scenarioSeedPath = path.resolve(__dirname, '../scripts/validation/ip-operator-scenario-seed.js');
+const sampleRecordPath = path.resolve(
+  __dirname,
+  '../docs/项目概括/20-实施方案/10-IP操盘工作台V1/IP操盘工作台V1-真实LLM样例验收记录.md',
+);
+const debugPlanPath = path.resolve(__dirname, '../test-results/ip-operator-real-llm-plan.json');
+
+test.describe('IP操盘真实 LLM 样例验收', () => {
+  test.skip(process.env.IP_OPERATOR_REAL_LLM !== '1', 'Set IP_OPERATOR_REAL_LLM=1 to run the paid/network real LLM validation.');
+
+  test('can generate a real evaluable IP operator plan', async ({ page }) => {
+    test.setTimeout(300_000);
+    await seedEmptyLoggedInWorkbench(page);
+    await page.goto('/');
+
+    const profileCard = page.locator('.ip-card').first();
+    await profileCard.locator('input').nth(0).fill('林川老板增长笔记');
+    await profileCard.locator('input').nth(1).fill('传统行业增长顾问');
+    await profileCard.locator('input').nth(2).fill('本地生活 / 家居建材 / 教育培训');
+    await profileCard.locator('input').nth(3).fill('传统行业中小企业老板');
+    await profileCard.locator('input').nth(4).fill('老板 IP 诊断与私域转化陪跑');
+    await profileCard.locator('textarea').nth(0).fill('内容像广告、不知道如何表达专业判断、缺少真实案例支撑。');
+    await profileCard.locator('textarea').nth(1).fill('服务过本地商家,做过短视频账号定位和私域转化。');
+    await profileCard.locator('textarea').nth(2).fill('账号定位、选题策划、脚本诊断、私域转化路径设计。');
+    await profileCard.locator('textarea').nth(3).fill('直接、务实、案例化。');
+    await profileCard.locator('textarea').nth(4).fill('不夸大收益,不做成功学。');
+    await profileCard.locator('textarea').nth(5).fill('生成可执行的 7 天内容测试方案。');
+    await profileCard.locator('.btn-primary').click();
+
+    const questionnaireCard = page.locator('.ip-card').first();
+    const answers = [
+      '老板想用内容建立信任,但担心拍出来像广告。',
+      '我能把真实服务经验翻译成用户听得懂的判断。',
+      '过去服务过本地门店和中小企业,有转化案例。',
+      '目前通过咨询、诊断和陪跑服务承接。',
+      '不想被误解成卖课或夸大结果的人。',
+      '擅长直接讲问题、讲案例、给判断标准。',
+      '对标本地商业顾问和老板 IP 账号。',
+      '借鉴他们的问题切入、案例表达和评论区痛点捕捉。',
+      '我的不同是更强调本地业务和私域转化。',
+      '目前最缺稳定选题、脚本和拍摄节奏。',
+    ];
+    for (let index = 0; index < answers.length; index += 1) {
+      await questionnaireCard.locator('textarea').nth(index).fill(answers[index]);
+    }
+    await questionnaireCard.locator('.btn-primary').click();
+
+    const benchmarkCard = page.locator('.ip-card').first();
+    await benchmarkCard.locator('input').nth(0).fill('商业小纸条');
+    await benchmarkCard.locator('input').nth(1).fill('https://example.com/benchmark-account');
+    await benchmarkCard.locator('input').nth(2).fill('https://www.douyin.com/video/7123456789012345678');
+    await benchmarkCard.locator('textarea').nth(0).fill('问题切入直接,能让老板快速代入;希望 AI 判断爆款点如何迁移到本地老板 IP。');
+
+    const optionalDetails = benchmarkCard.locator('.ip-benchmark-optional');
+    await optionalDetails.locator('summary').click();
+    await benchmarkCard.locator('input').nth(3).fill('为什么你的短视频越拍越像广告?');
+    await benchmarkCard.locator('input').nth(4).fill('点赞 3.2 万,评论 1800+,收藏明显高于普通内容');
+    await benchmarkCard.locator('textarea').nth(1).fill('开头直接戳中老板害怕广告感的痛点,中段用咨询案例拆解信任建立,结尾引导评论区自查。');
+    await benchmarkCard.locator('textarea').nth(2).fill('评论区大量用户问“怎么表达才不像卖东西”“私域怎么接”,说明转化链路可以从诊断清单切入。');
+    await benchmarkCard.locator('.btn-primary').click();
+
+    const generateCard = page.locator('.ip-card').first();
+    await expect(generateCard.locator('.btn-primary')).toBeEnabled();
+    await generateCard.locator('.btn-primary').click();
+
+    await expect(page.locator('.ip-result')).toBeVisible({ timeout: 240_000 });
+    const plan = await page.evaluate(() => {
+      const planIndex = JSON.parse(localStorage.getItem('tiktok.ipOperator.plans.local-guest') || '[]');
+      const planId = planIndex[0];
+      return JSON.parse(localStorage.getItem(`tiktok.ipOperator.plan.${planId}.local-guest`) || 'null');
+    });
+
+    writeDebugPlan(plan);
+
+    expect(plan).toBeTruthy();
+    expect(['ready', 'needs_input']).toContain(plan.status);
+    expect(plan.diagnosis?.positioningStatement || '').not.toHaveLength(0);
+    expect(plan.benchmarkAnalyses?.[0]?.borrowablePoints?.length || 0).toBeGreaterThanOrEqual(2);
+    expect(plan.benchmarkAnalyses?.[0]?.nonCopyablePoints?.length || 0).toBeGreaterThanOrEqual(2);
+    expect(plan.opportunityMap?.length || 0).toBeGreaterThanOrEqual(3);
+    expect(plan.topics?.length || 0).toBeGreaterThanOrEqual(8);
+    expect((plan.scripts || []).filter((script: any) => script.type === 'full').length).toBeGreaterThanOrEqual(3);
+    expect(plan.sevenDayTestPlan?.length || 0).toBe(7);
+    expect(plan.qualityCheck?.overallScore).toBeGreaterThanOrEqual(0);
+    expect(plan.qualityCheck?.overallScore).toBeLessThanOrEqual(100);
+
+    const topicsWithQuality = (plan.topics || []).filter((topic: any) => topic.qualityLevel && typeof topic.productionReadinessScore === 'number');
+    const scriptsWithQuality = (plan.scripts || []).filter((script: any) => script.qualityLevel && script.scriptSummary);
+    expect(topicsWithQuality.length).toBeGreaterThanOrEqual(8);
+    expect(scriptsWithQuality.length).toBeGreaterThanOrEqual(3);
+    expect(plan.qualityAudit?.overallScore).toBeGreaterThanOrEqual(0);
+    expect(plan.qualityAudit?.overallScore).toBeLessThanOrEqual(100);
+    expect(plan.qualityAudit?.dimensions?.length || 0).toBeGreaterThanOrEqual(6);
+    expect(plan.qualityAudit?.productionGate?.reason || '').not.toHaveLength(0);
+
+    writeRealLlmSampleRecord(plan);
+  });
+});
+
+async function seedEmptyLoggedInWorkbench(page: Page) {
+  await page.addInitScript({ path: scenarioSeedPath });
+  await page.addInitScript(() => {
+    window.__seedIpOperatorScenario?.('empty');
+    localStorage.setItem('tiktok.currentTab', 'ip-operator');
+  });
+}
+
+function writeDebugPlan(plan: any): void {
+  fs.mkdirSync(path.dirname(debugPlanPath), { recursive: true });
+  fs.writeFileSync(debugPlanPath, `${JSON.stringify(plan, null, 2)}\n`, 'utf8');
+}
+
+function writeRealLlmSampleRecord(plan: any): void {
+  const fullScript = (plan.scripts || []).find((script: any) => script.type === 'full');
+  const lines = [
+    '# IP操盘工作台 V1 真实 LLM 样例验收记录',
+    '',
+    `验收时间:${new Date().toISOString()}`,
+    '',
+    '## 输入 IP 档案',
+    '',
+    '- IP 名称:林川老板增长笔记',
+    '- 身份:传统行业增长顾问',
+    '- 行业:本地生活 / 家居建材 / 教育培训',
+    '- 目标用户:传统行业中小企业老板',
+    '- 目标:生成可执行的 7 天内容测试方案',
+    '',
+    '## 输出摘要',
+    '',
+    `- 最终状态:${plan.status}`,
+    `- 质量分:${plan.qualityCheck?.overallScore ?? '未返回'}/100`,
+    `- P2A 质量审计分:${plan.qualityAudit?.overallScore ?? '未返回'}/100`,
+    `- 选题质量分层数量:${(plan.topics || []).filter((topic: any) => topic.qualityLevel).length}`,
+    `- 脚本质量分层数量:${(plan.scripts || []).filter((script: any) => script.qualityLevel).length}`,
+    `- 制作门槛说明:${text(plan.qualityAudit?.productionGate?.reason)}`,
+    `- 定位一句话:${text(plan.diagnosis?.positioningStatement)}`,
+    `- 待补资料:${text((plan.missingInputs || []).map((item: any) => `${item.type}:${item.description}`))}`,
+    '',
+    '## 代表选题',
+    '',
+    ...(plan.topics || []).slice(0, 3).map((topic: any) => `- ${text(topic.title)}:${text(topic.fitReason)};质量 ${text(topic.qualityLevel)};可制作分 ${text(topic.productionReadinessScore)}`),
+    '',
+    '## 完整口播摘要',
+    '',
+    `- 标题:${text(fullScript?.title)}`,
+    `- 脚本质量:${text(fullScript?.qualityLevel)}`,
+    `- 脚本摘要:${text(fullScript?.scriptSummary)}`,
+    `- 钩子:${text(fullScript?.hook)}`,
+    `- 观点:${text(fullScript?.viewpoint)}`,
+    '',
+    '## 人工复核结论',
+    '',
+    '- 结论:自动结构验收通过;仍需人工复核内容是否符合具体客户语气、案例真实性和业务边界。',
+    '',
+  ];
+  fs.mkdirSync(path.dirname(sampleRecordPath), { recursive: true });
+  fs.writeFileSync(sampleRecordPath, `${lines.join('\n').trim()}\n`, 'utf8');
+}
+
+function text(value: unknown): string {
+  if (value === null || value === undefined || value === '') return '待补充';
+  if (Array.isArray(value)) return value.map(text).filter(Boolean).join(';') || '待补充';
+  if (typeof value === 'object') return JSON.stringify(value);
+  return String(value);
+}

+ 152 - 0
e2e/ip-operator-workbench.spec.ts

@@ -0,0 +1,152 @@
+import { expect, Page, test } from '@playwright/test';
+import path from 'node:path';
+
+declare global {
+  interface Window {
+    __seedIpOperatorScenario?: (scenario: 'empty' | 'ready' | 'needs_input' | 'failed') => unknown;
+  }
+}
+
+const scenarioSeedPath = path.resolve(__dirname, '../scripts/validation/ip-operator-scenario-seed.js');
+
+async function seedScenario(page: Page, scenario: 'empty' | 'ready' | 'needs_input' | 'failed', startTab: 'home' | 'ip-operator' = 'ip-operator') {
+  await page.addInitScript({ path: scenarioSeedPath });
+  await page.addInitScript(([nextScenario, nextTab]) => {
+    window.__seedIpOperatorScenario?.(nextScenario);
+    localStorage.setItem('tiktok.currentTab', nextTab);
+  }, [scenario, startTab]);
+}
+
+test('sidebar entry and wizard form reach generation confirmation', async ({ page }) => {
+  await seedScenario(page, 'empty', 'home');
+  await page.goto('/');
+
+  await page.getByRole('button', { name: /IP操盘/ }).click();
+  await expect(page.locator('.ip-operator-page')).toBeVisible();
+  await expect(page.locator('.ip-card h2')).toContainText('创建 IP 档案');
+
+  const profileCard = page.locator('.ip-card').first();
+  await profileCard.locator('input').nth(0).fill('E2E New Profile');
+  await profileCard.locator('input').nth(1).fill('Local growth consultant');
+  await profileCard.locator('input').nth(2).fill('Local service');
+  await profileCard.locator('input').nth(3).fill('Traditional business owners');
+  await profileCard.locator('input').nth(4).fill('IP diagnosis and content operation');
+  await profileCard.locator('textarea').nth(0).fill('Content looks like advertising');
+  await profileCard.locator('textarea').nth(1).fill('Served local stores with short-video conversion');
+  await profileCard.locator('textarea').nth(2).fill('Positioning, topics, scripts');
+  await profileCard.locator('textarea').nth(5).fill('Generate a usable 7-day test plan');
+  await profileCard.locator('.btn-primary').click();
+
+  const questionnaireCard = page.locator('.ip-card').first();
+  await expect(questionnaireCard.locator('h2')).toContainText('轻问卷');
+  await questionnaireCard.locator('textarea').nth(0).fill('They want trust but dislike hard selling.');
+  await questionnaireCard.locator('textarea').nth(1).fill('I can turn cases into practical content.');
+  await questionnaireCard.locator('textarea').nth(2).fill('I have real local-service experience.');
+  await questionnaireCard.locator('.btn-primary').click();
+
+  const benchmarkCard = page.locator('.ip-card').first();
+  await expect(benchmarkCard.locator('h2')).toContainText('对标账号');
+  await benchmarkCard.locator('input').nth(0).fill('Benchmark Owner Account');
+  await benchmarkCard.locator('input').nth(2).fill('Why owner content fails to convert');
+  await benchmarkCard.locator('textarea').nth(1).fill('It uses direct pain-point diagnosis.');
+  await benchmarkCard.locator('.btn-primary').click();
+
+  const generateCard = page.locator('.ip-card').first();
+  await expect(generateCard.locator('h2')).toContainText('确认生成范围');
+  await expect(generateCard.locator('.ip-generate-scope span')).toHaveCount(9);
+  await expect(generateCard.locator('.btn-primary')).toBeEnabled();
+});
+
+test('needs_input plan blocks production entry points in the browser', async ({ page }) => {
+  await seedScenario(page, 'needs_input');
+  await page.goto('/');
+
+  await expect(page.locator('.ip-result')).toBeVisible();
+  await expect(page.locator('.ip-readiness-panel.is-blocked')).toBeVisible();
+  await expect(page.locator('.ip-summary-card').filter({ hasText: '64/100' })).toBeVisible();
+  await expect(page.locator('.ip-result-hero .ip-actions button').nth(1)).toBeDisabled();
+  await expect(page.locator('.ip-result-hero .ip-actions .btn-primary')).toBeDisabled();
+});
+
+test('topic and script edits persist userEdited markers and audit trail', async ({ page }) => {
+  await seedScenario(page, 'ready');
+  await page.goto('/');
+
+  await page.locator('.ip-result-tabs button').nth(1).click();
+  const firstTopicTitle = page.locator('.ip-topic-card input').first();
+  await firstTopicTitle.fill('Edited owner IP topic title');
+  await firstTopicTitle.press('Tab');
+
+  await expect.poll(async () => page.evaluate(() => {
+    const planId = 'ip_plan_e2e_ready';
+    const raw = localStorage.getItem(`tiktok.ipOperator.plan.${planId}.local-guest`);
+    const plan = raw ? JSON.parse(raw) : null;
+    return plan?.topics?.some((topic: { title: string; userEdited?: boolean }) => (
+      topic.title === 'Edited owner IP topic title' && topic.userEdited === true
+    ));
+  })).toBe(true);
+  await expect(page.locator('.ip-topic-card').first()).toContainText('已手动编辑');
+
+  await page.locator('.ip-result-tabs button').nth(2).click();
+  const firstScriptHook = page.locator('.ip-script-detail input').first();
+  await firstScriptHook.fill('Edited script hook for browser validation');
+  await firstScriptHook.press('Tab');
+
+  await expect.poll(async () => page.evaluate(() => {
+    const planId = 'ip_plan_e2e_ready';
+    const raw = localStorage.getItem(`tiktok.ipOperator.plan.${planId}.local-guest`);
+    const plan = raw ? JSON.parse(raw) : null;
+    return plan?.scripts?.some((script: { hook: string; userEdited?: boolean }) => (
+      script.hook === 'Edited script hook for browser validation' && script.userEdited === true
+    ));
+  })).toBe(true);
+
+  const auditCount = await page.evaluate(() => {
+    const raw = localStorage.getItem('tiktok.ipOperator.auditTrail.local-guest') || '[]';
+    return JSON.parse(raw).length;
+  });
+  expect(auditCount).toBeGreaterThanOrEqual(3);
+});
+
+test('failed plan still renders diagnostics instead of a blank page', async ({ page }) => {
+  await seedScenario(page, 'failed');
+  await page.goto('/');
+
+  await expect(page.locator('.ip-result')).toBeVisible();
+  await expect(page.locator('.ip-summary-card').filter({ hasText: '生成失败' })).toBeVisible();
+  await page.locator('.ip-debug-panel details summary').click();
+  await expect(page.locator('.ip-debug-panel')).toContainText('llm_malformed_json');
+  await expect(page.locator('.ip-debug-panel')).toContainText('Malformed JSON in diagnosis stage');
+});
+
+test('ready plan syncs high-priority topics and enters topic-to-video', async ({ page }) => {
+  await seedScenario(page, 'ready');
+  await page.goto('/');
+
+  await expect(page.locator('.ip-operator-page h1')).toContainText('IP操盘工作台');
+  await expect(page.locator('.ip-readiness-panel')).toContainText('可进入生产链路');
+  await expect(page.locator('.ip-summary-card').filter({ hasText: '88/100' })).toBeVisible();
+
+  await page.locator('.ip-result-hero .ip-actions .btn-primary').click();
+  await expect.poll(async () => {
+    return page.evaluate(() => {
+      const items = JSON.parse(localStorage.getItem('videoWorkflow.topicPool.items') || '[]');
+      return items.filter((item: { sourceType?: string }) => item.sourceType === 'ip_operator').length;
+    });
+  }).toBe(4);
+
+  await page.locator('.ip-result-hero .ip-actions button').nth(1).click();
+  await expect(page.locator('.topic-list')).toBeVisible();
+  await expect(page.getByText('Why owner IP should not look like an ad account')).toBeVisible();
+
+  await page.locator('.topic-pool-toolbar select').first().selectOption('ip_operator');
+  await expect(page.getByText('Why owner IP should not look like an ad account')).toBeVisible();
+  await expect(page.locator('.topic-source-card')).toHaveCount(4);
+
+  await page.locator('.topic-card-actions .btn-primary').first().click();
+  await expect(page.locator('.t2v-page')).toBeVisible();
+  await expect(page.locator('.t2v-brief-card')).toBeVisible();
+
+  const topicValue = await page.locator('textarea.dh-textarea').first().inputValue();
+  expect(topicValue).toMatch(/Why owner IP should not look like an ad account|Owner IP trust topic [2-4]/);
+});

+ 326 - 14
package-lock.json

@@ -17,19 +17,23 @@
         "@ffmpeg/core": "^0.12.10",
         "@ffmpeg/core": "^0.12.10",
         "@ffmpeg/ffmpeg": "^0.12.15",
         "@ffmpeg/ffmpeg": "^0.12.15",
         "@ffmpeg/util": "^0.12.2",
         "@ffmpeg/util": "^0.12.2",
+        "@types/qrcode": "^1.5.6",
         "axios": "^1.15.0",
         "axios": "^1.15.0",
         "cors": "^2.8.5",
         "cors": "^2.8.5",
         "express": "^4.21.0",
         "express": "^4.21.0",
         "fmode-ng": "^0.0.246",
         "fmode-ng": "^0.0.246",
         "marked": "^18.0.4",
         "marked": "^18.0.4",
         "multer": "^2.1.1",
         "multer": "^2.1.1",
+        "qrcode": "^1.5.4",
         "rxjs": "~7.8.0",
         "rxjs": "~7.8.0",
-        "tslib": "^2.3.0"
+        "tslib": "^2.3.0",
+        "undici": "^7.24.4"
       },
       },
       "devDependencies": {
       "devDependencies": {
         "@angular/build": "^21.2.3",
         "@angular/build": "^21.2.3",
         "@angular/cli": "^21.2.3",
         "@angular/cli": "^21.2.3",
         "@angular/compiler-cli": "^21.2.0",
         "@angular/compiler-cli": "^21.2.0",
+        "@playwright/test": "^1.60.0",
         "concurrently": "^9.1.0",
         "concurrently": "^9.1.0",
         "jsdom": "^28.0.0",
         "jsdom": "^28.0.0",
         "prettier": "^3.8.1",
         "prettier": "^3.8.1",
@@ -1647,9 +1651,9 @@
       }
       }
     },
     },
     "node_modules/@emnapi/wasi-threads": {
     "node_modules/@emnapi/wasi-threads": {
-      "version": "1.2.1",
-      "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
-      "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+      "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "optional": true,
       "optional": true,
@@ -4036,6 +4040,22 @@
       "license": "MIT",
       "license": "MIT",
       "optional": true
       "optional": true
     },
     },
+    "node_modules/@playwright/test": {
+      "version": "1.60.0",
+      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
+      "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright": "1.60.0"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/@rolldown/binding-android-arm64": {
     "node_modules/@rolldown/binding-android-arm64": {
       "version": "1.0.0-rc.4",
       "version": "1.0.0-rc.4",
       "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.4.tgz",
       "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.4.tgz",
@@ -4897,6 +4917,24 @@
       "dev": true,
       "dev": true,
       "license": "MIT"
       "license": "MIT"
     },
     },
+    "node_modules/@types/node": {
+      "version": "25.9.2",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz",
+      "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==",
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": ">=7.24.0 <7.24.7"
+      }
+    },
+    "node_modules/@types/qrcode": {
+      "version": "1.5.6",
+      "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
+      "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
     "node_modules/@types/retry": {
     "node_modules/@types/retry": {
       "version": "0.12.0",
       "version": "0.12.0",
       "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
       "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
@@ -6219,6 +6257,12 @@
         "node": ">=8"
         "node": ">=8"
       }
       }
     },
     },
+    "node_modules/dijkstrajs": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+      "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+      "license": "MIT"
+    },
     "node_modules/dingtalk-jsapi": {
     "node_modules/dingtalk-jsapi": {
       "version": "3.2.9",
       "version": "3.2.9",
       "resolved": "https://registry.npmjs.org/dingtalk-jsapi/-/dingtalk-jsapi-3.2.9.tgz",
       "resolved": "https://registry.npmjs.org/dingtalk-jsapi/-/dingtalk-jsapi-3.2.9.tgz",
@@ -6741,6 +6785,19 @@
       "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
       "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
       "license": "MIT"
       "license": "MIT"
     },
     },
+    "node_modules/find-up": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+      "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+      "license": "MIT",
+      "dependencies": {
+        "locate-path": "^5.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/fmode-ng": {
     "node_modules/fmode-ng": {
       "version": "0.0.246",
       "version": "0.0.246",
       "resolved": "https://registry.npmjs.org/fmode-ng/-/fmode-ng-0.0.246.tgz",
       "resolved": "https://registry.npmjs.org/fmode-ng/-/fmode-ng-0.0.246.tgz",
@@ -6878,7 +6935,6 @@
       "version": "2.0.5",
       "version": "2.0.5",
       "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
       "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
       "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
       "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
-      "dev": true,
       "license": "ISC",
       "license": "ISC",
       "engines": {
       "engines": {
         "node": "6.* || 8.* || >= 10.*"
         "node": "6.* || 8.* || >= 10.*"
@@ -7652,6 +7708,18 @@
         "@lmdb/lmdb-win32-x64": "3.5.1"
         "@lmdb/lmdb-win32-x64": "3.5.1"
       }
       }
     },
     },
+    "node_modules/locate-path": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+      "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+      "license": "MIT",
+      "dependencies": {
+        "p-locate": "^4.1.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/log-symbols": {
     "node_modules/log-symbols": {
       "version": "7.0.1",
       "version": "7.0.1",
       "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
       "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
@@ -8488,6 +8556,33 @@
         "node": ">=4"
         "node": ">=4"
       }
       }
     },
     },
+    "node_modules/p-limit": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+      "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+      "license": "MIT",
+      "dependencies": {
+        "p-try": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-locate": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+      "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+      "license": "MIT",
+      "dependencies": {
+        "p-limit": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/p-map": {
     "node_modules/p-map": {
       "version": "7.0.4",
       "version": "7.0.4",
       "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
       "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
@@ -8557,6 +8652,15 @@
         "node": ">=8"
         "node": ">=8"
       }
       }
     },
     },
+    "node_modules/p-try": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+      "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/pacote": {
     "node_modules/pacote": {
       "version": "21.3.1",
       "version": "21.3.1",
       "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.3.1.tgz",
       "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.3.1.tgz",
@@ -8671,6 +8775,15 @@
         "node": ">= 0.8"
         "node": ">= 0.8"
       }
       }
     },
     },
+    "node_modules/path-exists": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/path-expression-matcher": {
     "node_modules/path-expression-matcher": {
       "version": "1.5.0",
       "version": "1.5.0",
       "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
       "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
@@ -8779,6 +8892,62 @@
         "node": ">=16.20.0"
         "node": ">=16.20.0"
       }
       }
     },
     },
+    "node_modules/playwright": {
+      "version": "1.60.0",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
+      "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright-core": "1.60.0"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.2"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.60.0",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
+      "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/playwright/node_modules/fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/pngjs": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+      "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
     "node_modules/postcss": {
     "node_modules/postcss": {
       "version": "8.5.9",
       "version": "8.5.9",
       "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
       "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
@@ -8932,6 +9101,134 @@
         "spark-md5": "^3.0.0"
         "spark-md5": "^3.0.0"
       }
       }
     },
     },
+    "node_modules/qrcode": {
+      "version": "1.5.4",
+      "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+      "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+      "license": "MIT",
+      "dependencies": {
+        "dijkstrajs": "^1.0.1",
+        "pngjs": "^5.0.0",
+        "yargs": "^15.3.1"
+      },
+      "bin": {
+        "qrcode": "bin/qrcode"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/qrcode/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/qrcode/node_modules/camelcase": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+      "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/qrcode/node_modules/cliui": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+      "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+      "license": "ISC",
+      "dependencies": {
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.0",
+        "wrap-ansi": "^6.2.0"
+      }
+    },
+    "node_modules/qrcode/node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "license": "MIT"
+    },
+    "node_modules/qrcode/node_modules/is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/qrcode/node_modules/string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "license": "MIT",
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/qrcode/node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/qrcode/node_modules/y18n": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+      "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+      "license": "ISC"
+    },
+    "node_modules/qrcode/node_modules/yargs": {
+      "version": "15.4.1",
+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+      "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+      "license": "MIT",
+      "dependencies": {
+        "cliui": "^6.0.0",
+        "decamelize": "^1.2.0",
+        "find-up": "^4.1.0",
+        "get-caller-file": "^2.0.1",
+        "require-directory": "^2.1.1",
+        "require-main-filename": "^2.0.0",
+        "set-blocking": "^2.0.0",
+        "string-width": "^4.2.0",
+        "which-module": "^2.0.0",
+        "y18n": "^4.0.0",
+        "yargs-parser": "^18.1.2"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/qrcode/node_modules/yargs-parser": {
+      "version": "18.1.3",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+      "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+      "license": "ISC",
+      "dependencies": {
+        "camelcase": "^5.0.0",
+        "decamelize": "^1.2.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/qs": {
     "node_modules/qs": {
       "version": "6.14.2",
       "version": "6.14.2",
       "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
       "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
@@ -9027,7 +9324,6 @@
       "version": "2.1.1",
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
       "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
       "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
       "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
-      "dev": true,
       "license": "MIT",
       "license": "MIT",
       "engines": {
       "engines": {
         "node": ">=0.10.0"
         "node": ">=0.10.0"
@@ -9043,6 +9339,12 @@
         "node": ">=0.10.0"
         "node": ">=0.10.0"
       }
       }
     },
     },
+    "node_modules/require-main-filename": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+      "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+      "license": "ISC"
+    },
     "node_modules/restore-cursor": {
     "node_modules/restore-cursor": {
       "version": "5.1.0",
       "version": "5.1.0",
       "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
       "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
@@ -9348,6 +9650,12 @@
         "node": ">= 0.8.0"
         "node": ">= 0.8.0"
       }
       }
     },
     },
+    "node_modules/set-blocking": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+      "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+      "license": "ISC"
+    },
     "node_modules/setprototypeof": {
     "node_modules/setprototypeof": {
       "version": "1.2.0",
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
       "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -9975,12 +10283,17 @@
       "version": "7.24.4",
       "version": "7.24.4",
       "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz",
       "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz",
       "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==",
       "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==",
-      "dev": true,
       "license": "MIT",
       "license": "MIT",
       "engines": {
       "engines": {
         "node": ">=20.18.1"
         "node": ">=20.18.1"
       }
       }
     },
     },
+    "node_modules/undici-types": {
+      "version": "7.24.6",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+      "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
+      "license": "MIT"
+    },
     "node_modules/unpipe": {
     "node_modules/unpipe": {
       "version": "1.0.0",
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
       "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -10322,6 +10635,12 @@
         "node": ">= 8"
         "node": ">= 8"
       }
       }
     },
     },
+    "node_modules/which-module": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+      "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+      "license": "ISC"
+    },
     "node_modules/why-is-node-running": {
     "node_modules/why-is-node-running": {
       "version": "2.3.0",
       "version": "2.3.0",
       "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
       "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -10343,7 +10662,6 @@
       "version": "6.2.0",
       "version": "6.2.0",
       "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
       "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
       "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
       "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
-      "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
         "ansi-styles": "^4.0.0",
         "ansi-styles": "^4.0.0",
@@ -10358,7 +10676,6 @@
       "version": "5.0.1",
       "version": "5.0.1",
       "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
       "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
       "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
       "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
-      "dev": true,
       "license": "MIT",
       "license": "MIT",
       "engines": {
       "engines": {
         "node": ">=8"
         "node": ">=8"
@@ -10368,7 +10685,6 @@
       "version": "4.3.0",
       "version": "4.3.0",
       "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
       "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
       "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
       "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
-      "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
         "color-convert": "^2.0.1"
         "color-convert": "^2.0.1"
@@ -10384,14 +10700,12 @@
       "version": "8.0.0",
       "version": "8.0.0",
       "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
       "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
       "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
       "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
-      "dev": true,
       "license": "MIT"
       "license": "MIT"
     },
     },
     "node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": {
     "node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": {
       "version": "3.0.0",
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
       "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
       "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
       "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
-      "dev": true,
       "license": "MIT",
       "license": "MIT",
       "engines": {
       "engines": {
         "node": ">=8"
         "node": ">=8"
@@ -10401,7 +10715,6 @@
       "version": "4.2.3",
       "version": "4.2.3",
       "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
       "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
       "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
       "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
-      "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
         "emoji-regex": "^8.0.0",
         "emoji-regex": "^8.0.0",
@@ -10416,7 +10729,6 @@
       "version": "6.0.1",
       "version": "6.0.1",
       "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
       "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
       "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
       "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
-      "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
         "ansi-regex": "^5.0.1"
         "ansi-regex": "^5.0.1"

+ 9 - 1
package.json

@@ -8,7 +8,11 @@
     "server": "node server.js",
     "server": "node server.js",
     "dev": "concurrently \"npm run server\" \"npm start\"",
     "dev": "concurrently \"npm run server\" \"npm start\"",
     "build": "ng build",
     "build": "ng build",
+    "validate:jimeng-billing": "node scripts/validation/jimeng-billing-policy.mjs",
+    "e2e:ip-operator": "node scripts/validation/run-ip-operator-e2e.mjs",
+    "e2e:ip-operator:real": "node scripts/validation/run-ip-operator-e2e.mjs e2e/ip-operator-real-llm.spec.ts",
     "smoke:cloud": "node scripts/smoke-cloud-functions.mjs",
     "smoke:cloud": "node scripts/smoke-cloud-functions.mjs",
+    "review:guard": "node scripts/review-guard.mjs",
     "watch": "ng build --watch --configuration development",
     "watch": "ng build --watch --configuration development",
     "test": "ng test"
     "test": "ng test"
   },
   },
@@ -24,19 +28,23 @@
     "@ffmpeg/core": "^0.12.10",
     "@ffmpeg/core": "^0.12.10",
     "@ffmpeg/ffmpeg": "^0.12.15",
     "@ffmpeg/ffmpeg": "^0.12.15",
     "@ffmpeg/util": "^0.12.2",
     "@ffmpeg/util": "^0.12.2",
+    "@types/qrcode": "^1.5.6",
     "axios": "^1.15.0",
     "axios": "^1.15.0",
     "cors": "^2.8.5",
     "cors": "^2.8.5",
     "express": "^4.21.0",
     "express": "^4.21.0",
     "fmode-ng": "^0.0.246",
     "fmode-ng": "^0.0.246",
     "marked": "^18.0.4",
     "marked": "^18.0.4",
     "multer": "^2.1.1",
     "multer": "^2.1.1",
+    "qrcode": "^1.5.4",
     "rxjs": "~7.8.0",
     "rxjs": "~7.8.0",
-    "tslib": "^2.3.0"
+    "tslib": "^2.3.0",
+    "undici": "^7.24.4"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@angular/build": "^21.2.3",
     "@angular/build": "^21.2.3",
     "@angular/cli": "^21.2.3",
     "@angular/cli": "^21.2.3",
     "@angular/compiler-cli": "^21.2.0",
     "@angular/compiler-cli": "^21.2.0",
+    "@playwright/test": "^1.60.0",
     "concurrently": "^9.1.0",
     "concurrently": "^9.1.0",
     "jsdom": "^28.0.0",
     "jsdom": "^28.0.0",
     "prettier": "^3.8.1",
     "prettier": "^3.8.1",

+ 19 - 0
playwright.config.ts

@@ -0,0 +1,19 @@
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+  testDir: './e2e',
+  timeout: 60_000,
+  expect: {
+    timeout: 10_000,
+  },
+  fullyParallel: false,
+  reporter: [['list']],
+  use: {
+    baseURL: 'http://127.0.0.1:4300',
+    channel: 'msedge',
+    headless: true,
+    trace: 'retain-on-failure',
+    screenshot: 'only-on-failure',
+    ...devices['Desktop Chrome'],
+  },
+});

+ 174 - 0
scripts/probe-jimeng-cloud.mjs

@@ -0,0 +1,174 @@
+const FN_URL = process.env.FMODE_FUNCTIONS_URL || 'https://server.fmode.cn/api/functions';
+const APP_ID = process.env.PARSE_APP_ID || 'ncloudmaster';
+const JIMENG_FN_ID = process.env.JIMENG_FN_ID || 'WUkf74lVE5';
+
+const args = parseArgs(process.argv.slice(2));
+
+async function main() {
+  const checks = [];
+
+  if (!args.baseProbe) {
+    checks.push(['diagnose', { action: 'diagnose' }]);
+  }
+
+  if (!args.skipUnknown) {
+    checks.unshift(['unknownAction', { action: 'unknown' }]);
+  }
+
+  if (args.workId) {
+    checks.push(['diagnose.work', { action: 'diagnose', workId: args.workId }]);
+  }
+
+  if (args.assetUrl) {
+    checks.push(['diagnose.asset', { action: 'diagnose', assetUrl: args.assetUrl }]);
+  }
+
+  if (args.connectivity) {
+    checks.push([
+      'diagnose.connectivity.getOhDateByTask',
+      {
+        action: 'diagnose',
+        probeEndpoint: 'getOhDateByTask',
+        allowCharge: '1',
+        payload: {
+          workId: 'codex-connectivity-probe',
+          routerName: 'getOhIdentifyMain',
+        },
+      },
+    ]);
+  }
+
+  if (args.baseProbe) {
+    checks.push([
+      'diagnoseBase.getOhDateByTask',
+      {
+        action: 'diagnoseBase',
+        endpoint: args.endpoint || 'getOhDateByTask',
+        payload: parsePayload(args.payload) || {
+          workId: 'codex-connectivity-probe',
+          routerName: 'getOhIdentifyMain',
+        },
+      },
+    ]);
+  }
+
+  if (args.candidateBases) {
+    checks.push([
+      'diagnose.candidateBases',
+      {
+        action: 'diagnose',
+        probeCandidates: '1',
+        payload: {
+          workId: 'codex-connectivity-probe',
+          routerName: 'getOhIdentifyMain',
+        },
+      },
+    ]);
+  }
+
+  if (args.endpoint) {
+    checks.push([
+      `diagnose.${args.endpoint}`,
+      {
+        action: 'diagnose',
+        probeEndpoint: args.endpoint,
+        allowCharge: args.allowCharge ? '1' : '0',
+        payload: parsePayload(args.payload),
+      },
+    ]);
+  }
+
+  let failed = 0;
+  for (const [name, body] of checks) {
+    const startedAt = Date.now();
+    try {
+      const result = await call(body);
+      const ok = name === 'unknownAction'
+        ? result?.success === false || Number(result?.code || 0) >= 400
+        : result?.success !== false && Number(result?.code || 200) < 400;
+      if (!ok) failed += 1;
+      console.log(`${ok ? 'OK' : 'FAIL'} ${name} ${Date.now() - startedAt}ms`);
+      console.log(JSON.stringify(redact(result), null, 2));
+    } catch (error) {
+      failed += 1;
+      console.log(`FAIL ${name} ${Date.now() - startedAt}ms`);
+      console.log(error?.message || error);
+    }
+  }
+
+  if (failed) process.exitCode = 1;
+}
+
+async function call(body) {
+  const response = await fetch(FN_URL, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      'X-Parse-Application-Id': APP_ID,
+    },
+    body: JSON.stringify({
+      id: JIMENG_FN_ID,
+      _ApplicationId: APP_ID,
+      ...body,
+    }),
+  });
+  const text = await response.text();
+  try {
+    return JSON.parse(text);
+  } catch {
+    return { code: response.status, success: false, error: text };
+  }
+}
+
+function parseArgs(argv) {
+  const out = {};
+  const booleanFlags = new Set(['allow-charge', 'skip-unknown', 'connectivity', 'candidate-bases', 'base-probe']);
+  for (let i = 0; i < argv.length; i += 1) {
+    const arg = argv[i];
+    if (arg.startsWith('--')) {
+      const key = arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
+      if (booleanFlags.has(arg.slice(2))) {
+        out[key] = true;
+        continue;
+      }
+      out[key] = argv[i + 1] || '';
+      i += 1;
+    }
+  }
+  return out;
+}
+
+function parsePayload(value) {
+  if (!value) return {};
+  try {
+    return JSON.parse(value);
+  } catch {
+    throw new Error('--payload 必须是合法 JSON 字符串');
+  }
+}
+
+function redact(value) {
+  if (Array.isArray(value)) return value.map(redact);
+  if (!value || typeof value !== 'object') return value;
+  const out = {};
+  for (const [key, raw] of Object.entries(value)) {
+    if (isSecretField(key, raw)) {
+      out[key] = typeof raw === 'boolean' ? raw : '[redacted]';
+    } else {
+      out[key] = redact(raw);
+    }
+  }
+  return out;
+}
+
+function isSecretField(key, value) {
+  if (typeof value === 'boolean' || typeof value === 'number') return false;
+  if (/tokenSource|tokenConfigured|allowedEndpoints|jimengBaseUrl|parseBaseUrl/i.test(key)) return false;
+  return /authorization|secret|password|accessKey|secretKey/i.test(key)
+    || (/token/i.test(key) && !/Source|Configured|Format/i.test(key));
+}
+
+main().catch((error) => {
+  console.error(error?.message || error);
+  process.exitCode = 1;
+});

+ 125 - 0
scripts/review-guard.mjs

@@ -0,0 +1,125 @@
+import { execFileSync } from 'node:child_process';
+import { existsSync, readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { relative, resolve } from 'node:path';
+
+const root = resolve(fileURLToPath(new URL('..', import.meta.url)));
+
+const failures = [];
+const warnings = [];
+
+const secretTargets = [
+  'server.js',
+  'server',
+  'src',
+  'package.json',
+  'proxy.conf.json',
+];
+
+const cloudFallbackFiles = [
+  'cloud-functions/06-voiceManager.js',
+  'cloud-functions/07-quicklyVideo.js',
+  'cloud-functions/08-proxyHub.js',
+  'cloud-functions/09-uploadManager.js',
+  'cloud-functions/11-jimengManager.js',
+  'cloud-functions/12-douyinManager.js',
+  'cloud-functions/13-douyinInsightManager.js',
+];
+
+const largeFileBudgets = [
+  { file: 'src/app/app.ts', maxLines: 3000 },
+  { file: 'src/app/app.css', maxLines: 7000 },
+  { file: 'server.js', maxLines: 1800 },
+  { file: 'src/app/pages/pipelines/topic-to-video/topic-to-video.component.ts', maxLines: 1200 },
+];
+
+const secretPatterns = [
+  { name: 'Bearer r token', pattern: /Bearer\s+r:[A-Za-z0-9_-]{20,}/g },
+  { name: 'r token', pattern: /['"`]r:[A-Za-z0-9_-]{20,}['"`]/g },
+  { name: 'sk key', pattern: /['"`]sk-[A-Za-z0-9_-]{20,}['"`]/g },
+];
+
+const contextualSecretPatterns = [
+  {
+    name: 'long token/key literal',
+    pattern: /\b(token|secret|apiKey|appSecret|accessKey|currentToken)\b[^;\n=:{]*[:=]\s*['"`][A-Za-z0-9+/=_-]{32,}['"`]/gi,
+  },
+];
+
+function runGit(args) {
+  try {
+    return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
+  } catch {
+    return '';
+  }
+}
+
+function listTrackedFiles(paths) {
+  const files = runGit(['ls-files', ...paths]);
+  return files ? files.split(/\r?\n/).filter(Boolean) : [];
+}
+
+function read(file) {
+  return readFileSync(resolve(root, file), 'utf8');
+}
+
+function lineOf(content, index) {
+  return content.slice(0, index).split(/\r?\n/).length;
+}
+
+function formatFile(file) {
+  return relative(root, resolve(root, file)).replace(/\\/g, '/');
+}
+
+const trackedEnv = runGit(['ls-files', '.env']);
+if (trackedEnv) {
+  failures.push('根目录 .env 已被 Git 跟踪,请移出版本库,只保留 .env.example。');
+}
+
+for (const file of listTrackedFiles(secretTargets)) {
+  if (!existsSync(resolve(root, file))) continue;
+  if (file.endsWith('.map') || file.endsWith('.png') || file.endsWith('.jpg') || file.endsWith('.jpeg') || file.endsWith('.webp')) continue;
+  const content = read(file);
+  for (const item of secretPatterns) {
+    for (const match of content.matchAll(item.pattern)) {
+      failures.push(`${formatFile(file)}:${lineOf(content, match.index || 0)} 命中疑似真实凭证:${item.name}`);
+    }
+  }
+  for (const item of contextualSecretPatterns) {
+    for (const match of content.matchAll(item.pattern)) {
+      failures.push(`${formatFile(file)}:${lineOf(content, match.index || 0)} 命中疑似真实凭证:${item.name}`);
+    }
+  }
+}
+
+for (const file of cloudFallbackFiles) {
+  if (!existsSync(resolve(root, file))) continue;
+  const content = read(file);
+  for (const item of secretPatterns) {
+    if (item.pattern.test(content)) {
+      warnings.push(`${formatFile(file)} 保留云函数源码兜底凭证。当前按用户要求暂缓治理,后续确认云函数部署窗口后再处理。`);
+      break;
+    }
+  }
+}
+
+for (const item of largeFileBudgets) {
+  if (!existsSync(resolve(root, item.file))) continue;
+  const count = read(item.file).split(/\r?\n/).length;
+  if (count > item.maxLines) {
+    warnings.push(`${formatFile(item.file)} 当前 ${count} 行,超过治理阈值 ${item.maxLines} 行。`);
+  }
+}
+
+if (warnings.length) {
+  console.warn('\nReview guard warnings:');
+  for (const warning of warnings) console.warn(`- ${warning}`);
+}
+
+if (failures.length) {
+  console.error('\nReview guard failed:');
+  for (const failure of failures) console.error(`- ${failure}`);
+  process.exit(1);
+}
+
+console.log('Review guard passed');

+ 179 - 0
scripts/validation/ip-operator-ready-seed.js

@@ -0,0 +1,179 @@
+(() => {
+  const scope = 'local-guest';
+  const now = new Date().toISOString();
+  const profileId = 'ip_profile_e2e_ready';
+  const planId = 'ip_plan_e2e_ready';
+  const user = {
+    objectId: scope,
+    username: 'ip-operator-e2e',
+    displayName: 'IP Operator E2E',
+    role: 'user',
+    createdAt: now,
+  };
+
+  const profile = {
+    id: profileId,
+    userId: scope,
+    name: 'E2E IP Operator Profile',
+    stage: 'started',
+    identity: 'Traditional industry consultant for local business owners',
+    industry: 'Local service business',
+    targetAudience: 'Owners who need practical IP-driven content and trust conversion',
+    audiencePainPoints: ['Content looks like advertising', 'No stable topic system'],
+    productsOrServices: 'IP positioning and content operation consulting',
+    personalStories: ['Helped a local service team turn cases into short video topics'],
+    expertise: ['Positioning', 'Topic planning', 'Script diagnosis'],
+    expressionStyle: ['Direct', 'Practical'],
+    boundaries: ['No exaggerated income promise'],
+    goals: ['Build trust', 'Collect consultation leads'],
+    questionnaire: [],
+    createdAt: now,
+    updatedAt: now,
+  };
+
+  const topics = Array.from({ length: 12 }, (_, index) => {
+    const id = `ip_topic_e2e_${index + 1}`;
+    return {
+      id,
+      title: index === 0 ? 'Why owner IP should not look like an ad account' : `Owner IP trust topic ${index + 1}`,
+      column: index % 3 === 0 ? 'Positioning diagnosis' : index % 3 === 1 ? 'Trust content' : 'Conversion path',
+      trafficLayer: index < 4 ? 'vertical' : index < 8 ? 'conversion' : 'broad',
+      contentGoal: 'Help the target user understand the trust problem and next action',
+      priority: index < 4 ? 'high' : index < 9 ? 'medium' : 'low',
+      fitReason: 'Derived from benchmark hooks and adapted to this IP positioning',
+      source: 'Benchmark migration',
+      requiredMaterials: ['Real case', 'Service before-after comparison'],
+      scriptStatus: index < 3 ? 'full' : 'outline',
+      riskNote: 'Avoid hard selling and exaggerated claims',
+    };
+  });
+
+  const scripts = topics.map((topic, index) => ({
+    id: `ip_script_e2e_${index + 1}`,
+    topicId: topic.id,
+    type: index < 3 ? 'full' : 'outline',
+    title: topic.title,
+    hook: 'If your account keeps introducing products, users will treat it as advertising.',
+    painPoint: 'The content has information but no trust context.',
+    viewpoint: 'Owner IP must first transmit judgement, not product inventory.',
+    caseOrMethod: 'Use one benchmark hook, one real scenario, and one conversion boundary.',
+    goldenSentence: 'Trust comes before transaction.',
+    closingCta: 'Save this and check whether your next video is selling or building trust.',
+    fullScript: index < 3
+      ? 'Opening: many owner accounts look professional but still fail to convert. Middle: users do not reject business, they reject content that only pushes products. Example: when a local service owner changed from product introductions to decision-making stories, consultation quality improved. Ending: the next video should answer one real customer doubt before mentioning the product.'
+      : '',
+    shootingTips: ['Talk to camera', 'Use one service case'],
+    subtitleHighlights: ['Do not sell first', 'Show judgement first'],
+    replaceableSlots: ['Insert local case'],
+    riskNote: 'Do not claim guaranteed conversion.',
+  }));
+
+  const plan = {
+    id: planId,
+    userId: scope,
+    profileId,
+    status: 'ready',
+    generationProgress: [
+      { stage: 'intake_summary', label: 'Intake summary', status: 'completed', durationMs: 120 },
+      { stage: 'diagnosis', label: 'Diagnosis', status: 'completed', durationMs: 120 },
+      { stage: 'benchmark', label: 'Benchmark analysis', status: 'completed', durationMs: 120 },
+      { stage: 'opportunities', label: 'Opportunity map', status: 'completed', durationMs: 120 },
+      { stage: 'topics_scripts_plan', label: 'Topics and scripts', status: 'completed', durationMs: 120 },
+      { stage: 'quality_check', label: 'Quality check', status: 'completed', durationMs: 120 },
+    ],
+    qualityCheck: {
+      overallScore: 88,
+      positioningClarityScore: 90,
+      audienceClarityScore: 86,
+      evidenceSupportScore: 84,
+      benchmarkTransferScore: 88,
+      topicUsabilityScore: 91,
+      passed: true,
+      blockingIssues: [],
+      improvementSuggestions: ['Validate the first three full scripts with real material.'],
+    },
+    intakeSummary: {
+      identitySummary: 'Local business IP operator',
+      audienceSummary: 'Traditional business owners',
+      advantageSummary: 'Can translate business cases into practical content',
+      businessGoalSummary: 'Build trust and collect consultation leads',
+      missingInputs: [],
+    },
+    diagnosis: {
+      positioningStatement: 'A practical operator who turns local business experience into trusted owner IP content.',
+      targetAudience: {
+        summary: 'Owners who need short video content but do not want to become ad accounts.',
+        painPoints: ['No topic system', 'Content lacks trust', 'Hard to convert'],
+      },
+      marketOpportunity: 'Many local businesses have real cases but lack content packaging.',
+      ipType: 'Operator partner',
+      personaTags: ['Practical', 'Case-driven', 'Trust-first'],
+      differentiation: 'Turns benchmark hot points into positioning-matched topics instead of copying formats.',
+      contentTracks: ['Positioning diagnosis', 'Trust cases', 'Conversion path'],
+      monetizationPath: 'Consultation and operation service leads.',
+      risks: ['Avoid exaggerated conversion promises'],
+      alternativeDirections: ['Focus on one local industry first'],
+      missingInputs: [],
+    },
+    benchmarkAnalyses: [{
+      benchmarkId: 'benchmark_e2e_1',
+      accountName: 'Benchmark Owner Account',
+      positioning: 'Direct business advice for owners',
+      contentColumns: ['Pain point diagnosis', 'Case breakdown'],
+      viralPatterns: ['Question hook', 'Concrete contrast'],
+      scriptStructure: 'Hook - pain - viewpoint - case - action',
+      conversionPath: 'Content trust to consultation',
+      borrowablePoints: ['Direct question hooks', 'Case contrast'],
+      nonCopyablePoints: ['Personal background', 'Exact industry claims'],
+      migrationDirection: 'Use the hook logic, replace the case and judgement with this IP assets.',
+      fitScore: 86,
+    }],
+    opportunityMap: [{
+      id: 'ip_opp_e2e_1',
+      type: 'Trust diagnosis',
+      fitScore: 90,
+      reason: 'Matches audience pain and available expertise.',
+      audiencePainPoint: 'Users do not trust product-only content.',
+      requiredMaterials: ['Before-after case'],
+      extendableTopics: ['Ad account problem', 'Trust building'],
+      riskBoundary: 'No guaranteed result claim.',
+    }],
+    topics,
+    scripts,
+    sevenDayTestPlan: topics.slice(0, 7).map((topic, index) => ({
+      day: index + 1,
+      topicId: topic.id,
+      topicTitle: topic.title,
+      testGoal: 'Validate hook clarity and trust response.',
+      contentType: topic.trafficLayer,
+      shootingFocus: 'Talk to camera with one real scenario.',
+      observeMetrics: ['Completion', 'Comment quality', 'Consultation intent'],
+      reviewQuestions: ['Did users understand the pain?', 'Was the CTA too hard?'],
+    })),
+    thirtyDayDirection: {
+      columnStructure: [
+        { column: 'Positioning diagnosis', ratio: 40, role: 'Trust entry' },
+        { column: 'Trust content', ratio: 35, role: 'Relationship building' },
+        { column: 'Conversion path', ratio: 25, role: 'Lead collection' },
+      ],
+      weeklyFocus: ['Validate hooks', 'Add case material', 'Test conversion CTA', 'Review topic columns'],
+      materialDirections: ['Customer doubt list', 'Before-after cases', 'Service process screenshots'],
+      iterationPath: ['Keep high-comment questions', 'Rewrite weak hooks', 'Promote proven topics'],
+    },
+    missingInputs: [],
+    createdAt: now,
+    updatedAt: now,
+  };
+
+  localStorage.setItem(`tiktok.ipOperator.profiles.${scope}`, JSON.stringify([profileId]));
+  localStorage.setItem(`tiktok.ipOperator.profile.${profileId}.${scope}`, JSON.stringify(profile));
+  localStorage.setItem(`tiktok.ipOperator.plans.${scope}`, JSON.stringify([planId]));
+  localStorage.setItem(`tiktok.ipOperator.plan.${planId}.${scope}`, JSON.stringify(plan));
+  localStorage.setItem(`tiktok.ipOperator.activeProfileId.${scope}`, profileId);
+  localStorage.setItem('videoWorkflow.authSession', JSON.stringify({ token: 'ip-operator-e2e-token', user }));
+  localStorage.setItem('tiktok.currentTab', 'ip-operator');
+  localStorage.removeItem('videoWorkflow.topicPool.items');
+  sessionStorage.clear();
+
+  return { profileId, planId, topics: topics.length, scripts: scripts.length };
+})();

+ 217 - 0
scripts/validation/ip-operator-scenario-seed.js

@@ -0,0 +1,217 @@
+(() => {
+  const scope = 'local-guest';
+
+  function seedIpOperatorScenario(scenario = 'ready') {
+    const now = new Date().toISOString();
+    const user = {
+      objectId: scope,
+      username: 'ip-operator-e2e',
+      displayName: 'IP Operator E2E',
+      role: 'user',
+      createdAt: now,
+    };
+
+    clearIpOperatorData();
+    localStorage.setItem('videoWorkflow.authSession', JSON.stringify({ token: 'ip-operator-e2e-token', user }));
+    localStorage.setItem('tiktok.currentTab', 'ip-operator');
+    sessionStorage.clear();
+
+    if (scenario === 'empty') return { scenario, profileId: '', planId: '' };
+
+    const profileId = `ip_profile_e2e_${scenario}`;
+    const planId = `ip_plan_e2e_${scenario}`;
+    const profile = createProfile(profileId, now);
+    const plan = createPlan({ scenario, profileId, planId, now });
+
+    localStorage.setItem(`tiktok.ipOperator.profiles.${scope}`, JSON.stringify([profileId]));
+    localStorage.setItem(`tiktok.ipOperator.profile.${profileId}.${scope}`, JSON.stringify(profile));
+    localStorage.setItem(`tiktok.ipOperator.plans.${scope}`, JSON.stringify([planId]));
+    localStorage.setItem(`tiktok.ipOperator.plan.${planId}.${scope}`, JSON.stringify(plan));
+    localStorage.setItem(`tiktok.ipOperator.activeProfileId.${scope}`, profileId);
+    localStorage.setItem(`tiktok.ipOperator.auditTrail.${scope}`, JSON.stringify([{
+      id: 'ip_audit_e2e_switch',
+      action: 'switch_profile',
+      entityType: 'profile',
+      entityId: profileId,
+      userId: scope,
+      createdAt: now,
+      summary: `Switch current IP profile: ${profileId}`,
+    }]));
+
+    return { scenario, profileId, planId };
+  }
+
+  function clearIpOperatorData() {
+    for (const key of Object.keys(localStorage)) {
+      if (
+        key.startsWith('tiktok.ipOperator.')
+        || key === 'videoWorkflow.authSession'
+        || key === 'videoWorkflow.topicPool.items'
+      ) {
+        localStorage.removeItem(key);
+      }
+    }
+  }
+
+  function createProfile(id, now) {
+    return {
+      id,
+      userId: scope,
+      name: 'E2E IP Operator Profile',
+      stage: 'started',
+      identity: 'Traditional industry consultant for local business owners',
+      industry: 'Local service business',
+      targetAudience: 'Owners who need practical IP-driven content and trust conversion',
+      audiencePainPoints: ['Content looks like advertising', 'No stable topic system'],
+      productsOrServices: 'IP positioning and content operation consulting',
+      personalStories: ['Helped a local service team turn cases into short video topics'],
+      expertise: ['Positioning', 'Topic planning', 'Script diagnosis'],
+      expressionStyle: ['Direct', 'Practical'],
+      boundaries: ['No exaggerated income promise'],
+      goals: ['Build trust', 'Collect consultation leads'],
+      questionnaire: [],
+      createdAt: now,
+      updatedAt: now,
+    };
+  }
+
+  function createPlan({ scenario, profileId, planId, now }) {
+    const topics = createTopics();
+    const scripts = createScripts(topics);
+    const failed = scenario === 'failed';
+    const needsInput = scenario === 'needs_input';
+    return {
+      id: planId,
+      userId: scope,
+      profileId,
+      status: failed ? 'failed' : needsInput ? 'needs_input' : 'ready',
+      generationProgress: [
+        { stage: 'intake_summary', label: 'Intake summary', status: 'completed', durationMs: 120 },
+        { stage: 'diagnosis', label: 'Diagnosis', status: failed ? 'failed' : 'completed', durationMs: 120, failureCode: failed ? 'llm_malformed_json' : undefined, errorMessage: failed ? 'Malformed JSON in diagnosis stage' : undefined },
+      ],
+      qualityCheck: {
+        overallScore: needsInput ? 64 : failed ? 40 : 88,
+        positioningClarityScore: needsInput ? 60 : 90,
+        audienceClarityScore: needsInput ? 62 : 86,
+        evidenceSupportScore: needsInput ? 50 : 84,
+        benchmarkTransferScore: needsInput ? 58 : 88,
+        topicUsabilityScore: needsInput ? 65 : 91,
+        passed: !needsInput && !failed,
+        blockingIssues: needsInput ? ['Missing real cases and proof material'] : failed ? ['Generation failed before quality gate'] : [],
+        improvementSuggestions: needsInput ? ['Add two real client cases before syncing topics'] : ['Validate the first three full scripts with real material.'],
+      },
+      intakeSummary: {
+        identitySummary: 'Local business IP operator',
+        audienceSummary: 'Traditional business owners',
+        advantageSummary: 'Can translate business cases into practical content',
+        businessGoalSummary: 'Build trust and collect consultation leads',
+        missingInputs: needsInput ? [{ type: 'case', description: 'Add real client before-after cases', priority: 'high' }] : [],
+      },
+      diagnosis: failed ? undefined : {
+        positioningStatement: 'A practical operator who turns local business experience into trusted owner IP content.',
+        targetAudience: {
+          summary: 'Owners who need short video content but do not want to become ad accounts.',
+          painPoints: ['No topic system', 'Content lacks trust', 'Hard to convert'],
+        },
+        marketOpportunity: 'Many local businesses have real cases but lack content packaging.',
+        ipType: 'Operator partner',
+        personaTags: ['Practical', 'Case-driven', 'Trust-first'],
+        differentiation: 'Turns benchmark hot points into positioning-matched topics instead of copying formats.',
+        contentTracks: ['Positioning diagnosis', 'Trust cases', 'Conversion path'],
+        monetizationPath: 'Consultation and operation service leads.',
+        risks: ['Avoid exaggerated conversion promises'],
+        alternativeDirections: ['Focus on one local industry first'],
+        missingInputs: needsInput ? [{ type: 'case', description: 'Add real proof material', priority: 'high' }] : [],
+      },
+      benchmarkAnalyses: failed ? [] : [{
+        benchmarkId: 'benchmark_e2e_1',
+        accountName: 'Benchmark Owner Account',
+        positioning: 'Direct business advice for owners',
+        contentColumns: ['Pain point diagnosis', 'Case breakdown'],
+        viralPatterns: ['Question hook', 'Concrete contrast'],
+        scriptStructure: 'Hook - pain - viewpoint - case - action',
+        conversionPath: 'Content trust to consultation',
+        borrowablePoints: ['Direct question hooks', 'Case contrast'],
+        nonCopyablePoints: ['Personal background', 'Exact industry claims'],
+        migrationDirection: 'Use the hook logic, replace the case and judgement with this IP assets.',
+        fitScore: 86,
+      }],
+      opportunityMap: failed ? [] : [{
+        id: 'ip_opp_e2e_1',
+        type: 'Trust diagnosis',
+        fitScore: 90,
+        reason: 'Matches audience pain and available expertise.',
+        audiencePainPoint: 'Users do not trust product-only content.',
+        requiredMaterials: ['Before-after case'],
+        extendableTopics: ['Ad account problem', 'Trust building'],
+        riskBoundary: 'No guaranteed result claim.',
+      }],
+      topics: failed ? [] : topics,
+      scripts: failed ? [] : scripts,
+      sevenDayTestPlan: failed ? [] : topics.slice(0, 7).map((topic, index) => ({
+        day: index + 1,
+        topicId: topic.id,
+        topicTitle: topic.title,
+        testGoal: 'Validate hook clarity and trust response.',
+        contentType: topic.trafficLayer,
+        shootingFocus: 'Talk to camera with one real scenario.',
+        observeMetrics: ['Completion', 'Comment quality', 'Consultation intent'],
+        reviewQuestions: ['Did users understand the pain?', 'Was the CTA too hard?'],
+      })),
+      thirtyDayDirection: failed ? undefined : {
+        columnStructure: [
+          { column: 'Positioning diagnosis', ratio: 40, role: 'Trust entry' },
+          { column: 'Trust content', ratio: 35, role: 'Relationship building' },
+          { column: 'Conversion path', ratio: 25, role: 'Lead collection' },
+        ],
+        weeklyFocus: ['Validate hooks', 'Add case material', 'Test conversion CTA', 'Review topic columns'],
+        materialDirections: ['Customer doubt list', 'Before-after cases', 'Service process screenshots'],
+        iterationPath: ['Keep high-comment questions', 'Rewrite weak hooks', 'Promote proven topics'],
+      },
+      missingInputs: needsInput ? [{ type: 'case', description: 'Add real client before-after cases', priority: 'high' }] : [],
+      errorMessage: failed ? 'Generation stopped because diagnosis JSON was malformed.' : undefined,
+      createdAt: now,
+      updatedAt: now,
+    };
+  }
+
+  function createTopics() {
+    return Array.from({ length: 12 }, (_, index) => ({
+      id: `ip_topic_e2e_${index + 1}`,
+      title: index === 0 ? 'Why owner IP should not look like an ad account' : `Owner IP trust topic ${index + 1}`,
+      column: index % 3 === 0 ? 'Positioning diagnosis' : index % 3 === 1 ? 'Trust content' : 'Conversion path',
+      trafficLayer: index < 4 ? 'vertical' : index < 8 ? 'conversion' : 'broad',
+      contentGoal: 'Help the target user understand the trust problem and next action',
+      priority: index < 4 ? 'high' : index < 9 ? 'medium' : 'low',
+      fitReason: 'Derived from benchmark hooks and adapted to this IP positioning',
+      source: 'Benchmark migration',
+      requiredMaterials: ['Real case', 'Service before-after comparison'],
+      scriptStatus: index < 3 ? 'full' : 'outline',
+      riskNote: 'Avoid hard selling and exaggerated claims',
+    }));
+  }
+
+  function createScripts(topics) {
+    return topics.map((topic, index) => ({
+      id: `ip_script_e2e_${index + 1}`,
+      topicId: topic.id,
+      type: index < 3 ? 'full' : 'outline',
+      title: topic.title,
+      hook: 'If your account keeps introducing products, users will treat it as advertising.',
+      painPoint: 'The content has information but no trust context.',
+      viewpoint: 'Owner IP must first transmit judgement, not product inventory.',
+      caseOrMethod: 'Use one benchmark hook, one real scenario, and one conversion boundary.',
+      goldenSentence: 'Trust comes before transaction.',
+      closingCta: 'Save this and check whether your next video is selling or building trust.',
+      fullScript: index < 3
+        ? 'Opening: many owner accounts look professional but still fail to convert. Middle: users do not reject business, they reject content that only pushes products. Example: when a local service owner changed from product introductions to decision-making stories, consultation quality improved. Ending: the next video should answer one real customer doubt before mentioning the product.'
+        : '',
+      shootingTips: ['Talk to camera', 'Use one service case'],
+      subtitleHighlights: ['Do not sell first', 'Show judgement first'],
+      replaceableSlots: ['Insert local case'],
+      riskNote: 'Do not claim guaranteed conversion.',
+    }));
+  }
+
+  window.__seedIpOperatorScenario = seedIpOperatorScenario;
+})();

+ 73 - 0
scripts/validation/jimeng-billing-policy.mjs

@@ -0,0 +1,73 @@
+import assert from 'node:assert/strict';
+
+const APIG_ID = '6pF6EAdKT';
+const APIG_PRICE_CNY = 0.1;
+
+const BILLABLE_ENDPOINTS = {
+  getImgV4: { unit: 'image', cny: 0.22 },
+  getText2ImgV3: { unit: 'call', cny: 0.2 },
+  getText2ImgV31: { unit: 'call', cny: 0.2 },
+  getImg2ImgV3: { unit: 'call', cny: 0.2 },
+  getImgV4_pod: { unit: 'image', cny: 0.22 },
+  getImgV4_goods: { unit: 'image', cny: 0.22 },
+  getInpaint: { unit: 'call', cny: 0.2 },
+  getSuperResolution: { unit: 'call', cny: 0.4 },
+  getVideoV3_720p: { unit: 'second', cny: 0.28 },
+  getVideoV3_1080p: { unit: 'second', cny: 0.63 },
+  getVideoV3_Pro: { unit: 'second', cny: 1 },
+  getActor: { unit: 'second', cny: 0.5 },
+  getActorV2: { unit: 'second', cny: 0.4 },
+  getOmniHuman: { unit: 'second', cny: 1 },
+};
+
+const NON_BILLABLE_ENDPOINTS = ['getDataByTask02', 'getOhDateByTask', 'getWorkResult'];
+
+function creditsFor(endpoint, quantity = 1) {
+  const pricing = BILLABLE_ENDPOINTS[endpoint];
+  assert.ok(pricing, `unknown billable endpoint: ${endpoint}`);
+  return Math.ceil((pricing.cny * quantity) / APIG_PRICE_CNY);
+}
+
+function assertBillingCase(endpoint, quantity, expectedCredits) {
+  const actualCredits = creditsFor(endpoint, quantity);
+  assert.equal(
+    actualCredits,
+    expectedCredits,
+    `${endpoint}(${quantity}) expected ${expectedCredits} credits, got ${actualCredits}`,
+  );
+}
+
+function assertNonBillingEndpoint(endpoint) {
+  assert.equal(
+    BILLABLE_ENDPOINTS[endpoint],
+    undefined,
+    `${endpoint} should not be billed, but appears in the billable mapping`,
+  );
+  assert.ok(NON_BILLABLE_ENDPOINTS.includes(endpoint), `${endpoint} is missing from the no-charge list`);
+}
+
+function main() {
+  assert.equal(APIG_ID, '6pF6EAdKT', 'APIG id must remain 6pF6EAdKT');
+  assert.equal(APIG_PRICE_CNY, 0.1, 'APIG price must remain 0.1 CNY per credit');
+
+  assertBillingCase('getImgV4', 1, 3);
+  assertBillingCase('getText2ImgV3', 1, 2);
+  assertBillingCase('getText2ImgV31', 1, 2);
+  assertBillingCase('getImg2ImgV3', 1, 2);
+  assertBillingCase('getImgV4_pod', 1, 3);
+  assertBillingCase('getImgV4_goods', 1, 3);
+  assertBillingCase('getInpaint', 1, 2);
+  assertBillingCase('getSuperResolution', 1, 4);
+  assertBillingCase('getVideoV3_720p', 5, 14);
+  assertBillingCase('getVideoV3_1080p', 5, 32);
+  assertBillingCase('getVideoV3_Pro', 10, 100);
+  assertBillingCase('getActor', 5, 25);
+  assertBillingCase('getActorV2', 5, 20);
+  assertBillingCase('getOmniHuman', 10, 100);
+
+  NON_BILLABLE_ENDPOINTS.forEach(assertNonBillingEndpoint);
+
+  console.log('jimeng billing policy validation passed');
+}
+
+main();

+ 95 - 0
scripts/validation/run-ip-operator-e2e.mjs

@@ -0,0 +1,95 @@
+import { spawn, spawnSync } from 'node:child_process';
+
+const baseUrl = 'http://127.0.0.1:4300';
+const isWindows = process.platform === 'win32';
+const specFile = process.argv[2] || 'e2e/ip-operator-workbench.spec.ts';
+let serverProcess = null;
+
+async function main() {
+  const alreadyRunning = await isServerReady();
+  if (!alreadyRunning) {
+    const { command, args } = commandFor('npm run start -- --host 127.0.0.1 --port 4300', 'npm', [
+      'run',
+      'start',
+      '--',
+      '--host',
+      '127.0.0.1',
+      '--port',
+      '4300',
+    ]);
+    serverProcess = spawn(command, args, {
+      stdio: 'inherit',
+      shell: false,
+    });
+    await waitForServer();
+  }
+
+  const code = await runPlaywright();
+  cleanupServer();
+  process.exit(code);
+}
+
+async function isServerReady() {
+  try {
+    const response = await fetch(baseUrl);
+    return response.ok;
+  } catch {
+    return false;
+  }
+}
+
+async function waitForServer() {
+  const startedAt = Date.now();
+  while (Date.now() - startedAt < 120_000) {
+    if (await isServerReady()) return;
+    await new Promise((resolve) => setTimeout(resolve, 1000));
+  }
+  cleanupServer();
+  throw new Error(`Angular dev server did not become ready at ${baseUrl}`);
+}
+
+function runPlaywright() {
+  return new Promise((resolve) => {
+    const { command, args } = commandFor(`npx playwright test ${specFile}`, 'npx', [
+      'playwright',
+      'test',
+      specFile,
+    ]);
+    const child = spawn(command, args, {
+      stdio: 'inherit',
+      shell: false,
+    });
+    child.on('close', (code) => resolve(code ?? 1));
+  });
+}
+
+function commandFor(windowsCommand, command, args) {
+  if (!isWindows) return { command, args };
+  return { command: 'cmd.exe', args: ['/d', '/s', '/c', windowsCommand] };
+}
+
+function cleanupServer() {
+  if (!serverProcess?.pid) return;
+  if (isWindows) {
+    spawnSync('taskkill', ['/pid', String(serverProcess.pid), '/t', '/f'], { stdio: 'ignore' });
+  } else {
+    serverProcess.kill('SIGTERM');
+  }
+  serverProcess = null;
+}
+
+process.on('SIGINT', () => {
+  cleanupServer();
+  process.exit(130);
+});
+
+process.on('SIGTERM', () => {
+  cleanupServer();
+  process.exit(143);
+});
+
+main().catch((error) => {
+  cleanupServer();
+  console.error(error);
+  process.exit(1);
+});

File diff suppressed because it is too large
+ 360 - 1809
server.js


+ 110 - 0
server/config/runtime-config.js

@@ -0,0 +1,110 @@
+const DEFAULTS = {
+  parseApiHost: 'https://server.fmode.cn',
+  parseAppId: 'ncloudmaster',
+  parseBaseUrl: 'https://server.fmode.cn/api/functions',
+  douyinApiBaseUrl: 'https://server.fmode.cn/api/voc-social',
+  transcriptionGatewayBaseUrl: 'https://server.fmode.cn/api/apig/transcription',
+  qiniuUploadUrl: 'https://up-z2.qiniup.com',
+  volcSpeechBaseUrl: 'https://openspeech.bytedance.com',
+  volcTtsProxyBaseUrl: 'https://server.fmode.cn/api/volcengine/tts',
+  llmBaseUrl: 'http://server.fmode.cn:9999',
+  quicklyCallbackUrl: 'https://server.fmode.cn/api/functions/cut/onemerge',
+  quicklyRelayUrl: 'https://server.fmode.cn/api/functions',
+};
+
+function env(name, fallback = '') {
+  const value = process.env[name];
+  return value === undefined || value === null || value === '' ? fallback : String(value).trim();
+}
+
+function firstEnv(names, fallback = '') {
+  for (const name of names) {
+    const value = env(name);
+    if (value) return value;
+  }
+  return fallback;
+}
+
+function stripTrailingSlash(value) {
+  return String(value || '').replace(/\/+$/, '');
+}
+
+function normalizeBearerToken(token) {
+  const value = String(token || '').trim();
+  if (!value) return '';
+  return /^Bearer\s+/i.test(value) ? value : `Bearer ${value}`;
+}
+
+function requireSecret(value, label, names) {
+  if (String(value || '').trim()) return String(value).trim();
+  const list = Array.isArray(names) ? names.join('、') : names;
+  const err = new Error(`未配置 ${label},请设置 ${list}`);
+  err.statusCode = 400;
+  throw err;
+}
+
+function redact(value) {
+  const raw = String(value || '').trim();
+  if (!raw) return '';
+  if (raw.length <= 10) return `${raw.slice(0, 2)}***`;
+  return `${raw.slice(0, 6)}***${raw.slice(-4)}`;
+}
+
+const config = {
+  port: Number(env('PORT', '3000')) || 3000,
+  parseApiHost: stripTrailingSlash(env('PARSE_API_HOST', DEFAULTS.parseApiHost)),
+  parseAppId: env('PARSE_APP_ID', DEFAULTS.parseAppId),
+  parseBaseUrl: stripTrailingSlash(env('PARSE_BASE_URL', DEFAULTS.parseBaseUrl)),
+  douyinApiBaseUrl: stripTrailingSlash(env('DOUYIN_API_BASE_URL', DEFAULTS.douyinApiBaseUrl)),
+  douyinGatewayMaxAttempts: Math.max(1, Number(env('DOUYIN_GATEWAY_MAX_ATTEMPTS', '4')) || 4),
+  transcriptionGatewayBaseUrl: stripTrailingSlash(env('IFLYTEK_GATEWAY_BASE_URL', DEFAULTS.transcriptionGatewayBaseUrl)),
+  qiniu: {
+    accessKey: firstEnv(['QINIU_AK', 'QINIU_ACCESS_KEY']),
+    secretKey: firstEnv(['QINIU_SK', 'QINIU_SECRET_KEY']),
+    bucket: env('QINIU_BUCKET', 'nova-repos'),
+    cdnDomain: env('QINIU_CDN_DOMAIN', env('QINIU_DOMAIN', 'https://repos.fmode.cn')),
+    cdnPrefix: env('QINIU_CDN_PREFIX', 'x/openclaw-skills'),
+    uploadUrl: env('QINIU_UPLOAD_URL', DEFAULTS.qiniuUploadUrl),
+  },
+  volc: {
+    speechBaseUrl: stripTrailingSlash(env('VOLC_SPEECH_BASE_URL', DEFAULTS.volcSpeechBaseUrl)),
+    ttsProxyBaseUrl: stripTrailingSlash(env('VOLC_TTS_PROXY_BASE_URL', DEFAULTS.volcTtsProxyBaseUrl)),
+    speechApiKey: firstEnv(['VOLC_SPEECH_API_KEY', 'BYTEDANCE_SPEECH_API_KEY', 'VOICE_CREATION_API_KEY']),
+    ttsResourceId: env('VOLC_TTS_RESOURCE_ID'),
+    speechAppKey: firstEnv(['VOLC_SPEECH_APP_KEY', 'VOLC_SPEECH_APP_ID']),
+    speechAccessKey: env('VOLC_SPEECH_ACCESS_KEY'),
+  },
+  quickly: {
+    appKey: env('QUICKLY_APP_KEY'),
+    appSecret: env('QUICKLY_APP_SECRET'),
+    accountId: env('QUICKLY_ACCOUNT_ID'),
+    callbackUrl: env('QUICKLY_CALLBACK_URL', DEFAULTS.quicklyCallbackUrl),
+    relayUrl: env('QUICKLY_RELAY_URL', DEFAULTS.quicklyRelayUrl),
+  },
+  llm: {
+    baseUrl: stripTrailingSlash(env('LLM_BASE_URL', DEFAULTS.llmBaseUrl)),
+    apiKey: env('LLM_API_KEY'),
+  },
+  voice: {
+    defaultToken: normalizeBearerToken(firstEnv(['VOLC_TTS_TOKEN', 'VOICE_TOKEN'])),
+  },
+};
+
+function getVocToken() {
+  return firstEnv([
+    'DOUYIN_API_TOKEN',
+    'VOC_TOKEN',
+    'TRANSCRIPTION_VOC_TOKEN',
+    'VOICE_TOKEN',
+    'OPENCLAW_VOC_TOKEN',
+    'VOC_SOCIAL_TOKEN',
+  ]);
+}
+
+module.exports = {
+  ...config,
+  getVocToken,
+  normalizeBearerToken,
+  requireSecret,
+  redact,
+};

+ 149 - 0
server/routes/audio-extraction.js

@@ -0,0 +1,149 @@
+const fs = require('fs');
+const path = require('path');
+const { spawn } = require('child_process');
+
+function registerAudioExtractionRoutes(app, deps) {
+  app.post('/api/remix/extract-audio', (req, res) => {
+    const { videoId } = req.body || {};
+    if (!videoId) {
+      return res.status(400).json({ error: '缺少 videoId 参数' });
+    }
+
+    const manifest = deps.readManifest();
+    const video = manifest.find(v => v.id === videoId);
+    if (!video) {
+      return res.status(404).json({ error: `未找到视频: ${videoId}` });
+    }
+
+    const videoPath = deps.resolveVideoPath(video.filename);
+    if (!fs.existsSync(videoPath)) {
+      return res.status(404).json({ error: `视频文件不存在: ${video.filename}` });
+    }
+
+    const safeBaseName = path.basename(video.filename, path.extname(video.filename)).replace(/[^a-zA-Z0-9._-]/g, '_') || `audio-${Date.now()}`;
+    const outputFilename = `${safeBaseName}-${Date.now()}.wav`;
+    const outputPath = path.join(deps.dataRemixAssetDir, outputFilename);
+    const ffmpegArgs = ['-y', '-i', videoPath, '-vn', '-acodec', 'pcm_s16le', '-ar', '44100', '-ac', '2', outputPath];
+    const proc = spawn('ffmpeg', ffmpegArgs, { cwd: deps.projectRoot });
+
+    let stderr = '';
+    proc.stderr.on('data', (data) => {
+      stderr += data.toString();
+    });
+
+    proc.on('error', (err) => {
+      deps.removeFileIfExists(outputPath);
+      const message = /ENOENT/i.test(err.message)
+        ? '未检测到 ffmpeg,请先安装 ffmpeg 并确保已加入系统 PATH 环境变量'
+        : `无法启动 ffmpeg:${err.message}`;
+      res.status(500).json({ error: message });
+    });
+
+    proc.on('close', (code) => {
+      if (code !== 0 || !fs.existsSync(outputPath)) {
+        deps.removeFileIfExists(outputPath);
+        const lastErrorLine = stderr.split('\n').filter(Boolean).slice(-1)[0] || '';
+        const normalizedMessage = /ffmpeg/i.test(stderr) && /not recognized|not found|no such file/i.test(stderr)
+          ? '未检测到 ffmpeg,请先安装 ffmpeg 并确保已加入系统 PATH 环境变量'
+          : `音频提取失败${lastErrorLine ? `: ${lastErrorLine}` : ''}`;
+        return res.status(500).json({ error: normalizedMessage });
+      }
+
+      res.setHeader('Content-Type', 'audio/wav');
+      res.setHeader('Content-Disposition', `attachment; filename="${outputFilename}"`);
+
+      const stream = fs.createReadStream(outputPath);
+      stream.on('close', () => {
+        deps.removeFileIfExists(outputPath);
+      });
+      stream.on('error', () => {
+        deps.removeFileIfExists(outputPath);
+        if (!res.headersSent) {
+          res.status(500).json({ error: '音频文件读取失败' });
+        } else {
+          res.end();
+        }
+      });
+      stream.pipe(res);
+    });
+  });
+
+  app.post('/api/extract-audio-mp3', (req, res) => {
+    const { videoId } = req.body || {};
+    if (!videoId) {
+      return res.status(400).json({ error: '缺少 videoId 参数' });
+    }
+
+    const manifest = deps.readManifest();
+    const video = manifest.find(v => v.id === videoId);
+    if (!video) {
+      console.warn(`⚠️ extract-audio-mp3: 未找到 videoId=${videoId}`);
+      return res.status(404).json({ error: `未找到视频: ${videoId}` });
+    }
+
+    const videoPath = deps.resolveVideoPath(video.filename);
+    if (!fs.existsSync(videoPath)) {
+      console.warn(`⚠️ extract-audio-mp3: 视频文件不存在: ${videoPath}`);
+      return res.status(404).json({ error: `视频文件不存在: ${video.filename}` });
+    }
+
+    const safeBaseName = path.basename(video.filename, path.extname(video.filename)).replace(/[^a-zA-Z0-9._-]/g, '_') || `audio-${Date.now()}`;
+    const outputFilename = `${safeBaseName}-${Date.now()}.m4a`;
+    const outputPath = path.join(deps.dataRemixAssetDir, outputFilename);
+    const outputMimeType = 'audio/mp4';
+
+    console.log(`🎵 开始音频提取: ${videoPath} → ${outputPath}`);
+
+    const ffmpegArgs = ['-y', '-i', videoPath, '-vn', '-c:a', 'aac', '-b:a', '64k', '-ar', '16000', '-ac', '1', outputPath];
+    const proc = spawn('ffmpeg', ffmpegArgs, { cwd: deps.projectRoot });
+
+    let stderr = '';
+    let responded = false;
+    const safeRespond = (status, body) => {
+      if (responded) return;
+      responded = true;
+      res.status(status).json(body);
+    };
+    proc.stderr.on('data', (data) => { stderr += data.toString(); });
+
+    proc.on('error', (err) => {
+      deps.removeFileIfExists(outputPath);
+      const message = /ENOENT/i.test(err.message)
+        ? '未检测到 ffmpeg,请先安装 ffmpeg 并确保已加入系统 PATH 环境变量'
+        : `无法启动 ffmpeg:${err.message}`;
+      console.error(`❌ extract-audio-mp3 spawn error: ${err.message}`);
+      safeRespond(500, { error: message });
+    });
+
+    proc.on('close', (code) => {
+      if (code !== 0 || !fs.existsSync(outputPath)) {
+        const tail = stderr.split('\n').filter(Boolean).slice(-5).join(' | ');
+        console.error(`❌ ffmpeg exit code=${code} 输出文件存在=${fs.existsSync(outputPath)}`);
+        console.error(`   stderr 末尾: ${tail}`);
+        deps.removeFileIfExists(outputPath);
+        const lastErrorLine = stderr.split('\n').filter(Boolean).slice(-1)[0] || '';
+        return safeRespond(500, { error: `音频提取失败 (exit=${code})${lastErrorLine ? `: ${lastErrorLine}` : ''}` });
+      }
+
+      const stats = fs.statSync(outputPath);
+      const sizeMB = (stats.size / 1024 / 1024).toFixed(2);
+      console.log(`🎵 音频提取完成: ${outputFilename} (${sizeMB}MB)`);
+
+      const audioBuffer = fs.readFileSync(outputPath);
+      const audioBase64 = audioBuffer.toString('base64');
+      deps.removeFileIfExists(outputPath);
+
+      safeRespond(200, {
+        success: true,
+        audio: {
+          base64: audioBase64,
+          mimeType: outputMimeType,
+          sizeMB: parseFloat(sizeMB),
+          filename: outputFilename,
+        },
+      });
+    });
+  });
+}
+
+module.exports = { registerAudioExtractionRoutes };

+ 17 - 0
server/routes/cloud-managed-legacy-reference.js

@@ -0,0 +1,17 @@
+/**
+ * Legacy local route references for cloud-managed resources.
+ *
+ * These route groups have moved to cloud functions and are normally reached
+ * through cloud-api.interceptor:
+ * - remixManager:   GET/POST/DELETE     /api/remixes
+ * - voiceManager:   POST                /api/voice/auto-speaker-id
+ * - voiceManager:   POST                /api/voice-profiles/sync
+ * - taskManager:    GET/POST/PUT/DELETE /api/tasks
+ * - historyManager: GET/POST/DELETE     /api/history
+ * - resultManager:  GET/POST/PUT/DELETE /api/results
+ *
+ * Keep this note outside server.js so the local server entry stays focused on
+ * routes that are still active in local development.
+ */
+
+module.exports = {};

+ 53 - 0
server/routes/files.js

@@ -0,0 +1,53 @@
+function registerFileRoutes(app, deps) {
+  app.get('/api/files/whisper/:videoId', (req, res) => {
+    try {
+      const manifest = deps.readManifest();
+      const video = manifest.find((item) => item.id === req.params.videoId);
+      if (!video) {
+        return res.status(404).json({ error: `未找到视频: ${req.params.videoId}` });
+      }
+
+      const baseName = video.filename.replace(/\.[^.]+$/, '');
+      const outputDir = deps.path.join(deps.whisperDir, baseName);
+
+      if (!deps.fs.existsSync(outputDir)) {
+        return res.json({ files: [], exists: false });
+      }
+
+      const files = deps.fs.readdirSync(outputDir).map((fileName) => ({
+        name: fileName,
+        path: `Whisper/${baseName}/${fileName}`,
+        size: deps.fs.statSync(deps.path.join(outputDir, fileName)).size
+      }));
+
+      res.json({ files, exists: true });
+    } catch (err) {
+      res.status(500).json({ error: err.message });
+    }
+  });
+
+  app.get('/api/files/read', (req, res) => {
+    const filePath = req.query.path;
+    if (!filePath) {
+      return res.status(400).json({ error: '缺少 path 参数' });
+    }
+
+    const fullPath = deps.path.join(deps.projectRoot, filePath);
+    if (!fullPath.startsWith(deps.projectRoot)) {
+      return res.status(403).json({ error: '路径不在项目目录内' });
+    }
+
+    if (!deps.fs.existsSync(fullPath)) {
+      return res.status(404).json({ error: '文件不存在' });
+    }
+
+    const ext = deps.path.extname(fullPath).toLowerCase();
+    if (ext === '.json') {
+      return res.json(JSON.parse(deps.fs.readFileSync(fullPath, 'utf-8')));
+    }
+
+    res.type('text/plain').send(deps.fs.readFileSync(fullPath, 'utf-8'));
+  });
+}
+
+module.exports = { registerFileRoutes };

+ 25 - 0
server/routes/health.js

@@ -0,0 +1,25 @@
+function registerHealthRoutes(app, deps) {
+  app.get('/api/health', (req, res) => {
+    res.json({
+      status: 'ok',
+      timestamp: new Date().toISOString(),
+      project: deps.projectRoot,
+      services: {
+        manifest: deps.fs.existsSync(deps.manifestPath),
+        whisperDir: deps.fs.existsSync(deps.whisperDir),
+        videoDir: deps.fs.existsSync(deps.dataVideoDir) || deps.fs.existsSync(deps.legacyVideoDir),
+        douyinTranscriptWorker: {
+          enabled: true,
+          tempDir: deps.transcriptTempDir,
+          taskCount: deps.transcriptTasks.size,
+          douyinGateway: deps.douyinApiBaseUrl,
+          transcriptionGateway: deps.transcriptionGatewayBaseUrl,
+          douyinGatewayMaxAttempts: deps.douyinGatewayMaxAttempts,
+          hasVocToken: !!deps.getVocToken()
+        }
+      }
+    });
+  });
+}
+
+module.exports = { registerHealthRoutes };

+ 74 - 0
server/routes/llm.js

@@ -0,0 +1,74 @@
+function registerLlmRoutes(app, options) {
+  const llmBaseUrl = options.llmBaseUrl;
+  const llmApiKey = options.llmApiKey;
+
+  // 非流式调用已迁移至云函数 proxyHub(cloud-functions/08-proxyHub.js)。
+  // 但流式 SSE 调用必须走本地 Express(云函数不支持流式输出),由前端 fetch 直连。
+  app.post('/api/llm/chat/stream', async (req, res) => {
+    try {
+      const { model, messages, temperature, max_tokens, ...rest } = req.body || {};
+      if (!Array.isArray(messages) || messages.length === 0) {
+        return res.status(400).json({ error: '缺少 messages 参数' });
+      }
+
+      const payload = {
+        model: model || 'gpt-4o',
+        messages,
+        temperature: temperature ?? 0.7,
+        max_tokens: max_tokens || 4096,
+        ...rest,
+        stream: true,
+      };
+
+      const url = `${llmBaseUrl}/v1/chat/completions`;
+      console.log(`🤖 [Stream] LLM Chat 请求: model=${payload.model}, messages=${messages.length}条`);
+
+      const upstream = await fetch(url, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'Authorization': `Bearer ${llmApiKey}`,
+        },
+        body: JSON.stringify(payload),
+      });
+
+      if (!upstream.ok) {
+        const errText = await upstream.text();
+        console.error('🤖 [Stream] 上游错误:', upstream.status, errText);
+        return res.status(upstream.status).json({ error: errText || `upstream ${upstream.status}` });
+      }
+
+      res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
+      res.setHeader('Cache-Control', 'no-cache, no-transform');
+      res.setHeader('Connection', 'keep-alive');
+      res.setHeader('X-Accel-Buffering', 'no');
+      res.flushHeaders?.();
+
+      const reader = upstream.body.getReader();
+      const onAbort = () => {
+        try { reader.cancel(); } catch {}
+      };
+      req.on('close', onAbort);
+
+      try {
+        while (true) {
+          const { value, done } = await reader.read();
+          if (done) break;
+          if (value) res.write(Buffer.from(value));
+        }
+      } finally {
+        req.off('close', onAbort);
+        res.end();
+      }
+    } catch (err) {
+      console.error('🤖 [Stream] 异常:', err.message);
+      if (!res.headersSent) {
+        res.status(500).json({ error: `LLM 流式请求失败: ${err.message}` });
+      } else {
+        try { res.end(); } catch {}
+      }
+    }
+  });
+}
+
+module.exports = { registerLlmRoutes };

+ 15 - 0
server/routes/manifest-legacy-reference.js

@@ -0,0 +1,15 @@
+/**
+ * Manifest legacy Express routes
+ *
+ * These endpoints have moved to cloud-functions/01-manifestManager.js and are
+ * normally reached through cloud-api.interceptor. Keep this file as a compact
+ * reference for the previous local fallback behavior without bloating server.js.
+ *
+ * Historical endpoints:
+ * - GET    /api/manifest
+ * - POST   /api/manifest
+ * - PUT    /api/manifest/:videoId
+ * - DELETE /api/manifest/:videoId
+ */
+
+module.exports = {};

+ 97 - 0
server/routes/quickly.js

@@ -0,0 +1,97 @@
+const crypto = require('crypto');
+
+function registerQuicklyRoutes(app, options) {
+  const appKey = options.appKey;
+  const appSecret = options.appSecret;
+  const accountId = options.accountId;
+  const callbackUrl = options.callbackUrl;
+  const relayUrl = options.relayUrl;
+  const queryUrl = options.queryUrl || 'https://server.fmode.cn/api/functions';
+
+  app.post('/api/quickly/create', async (req, res) => {
+    try {
+      const { videoUrls, options: createOptions = {} } = req.body;
+      if (!videoUrls || !Array.isArray(videoUrls) || videoUrls.length === 0) {
+        return res.status(400).json({ error: '缺少 videoUrls 参数' });
+      }
+
+      const timestamp = Date.now().toString();
+      const signStr = timestamp + '#' + appSecret;
+      const sign = crypto.createHash('md5').update(signStr).digest('hex');
+
+      const materialList = videoUrls.map(url => ({ type: 'video', value: url }));
+      const preId = timestamp;
+
+      const apiBody = {
+        account_id: accountId,
+        callback_url: callbackUrl,
+        material_list: materialList,
+        tags: createOptions.tags || '视频,AI生成',
+        proportion: createOptions.proportion || '9:16',
+        video_duration: createOptions.videoDuration || { min: 10, max: 30 },
+        pre_id: preId,
+        compose_number: 1,
+        ai_voice: createOptions.aiVoice ?? 1,
+        ai_bgm: createOptions.aiBgm ?? 1,
+        ai_flower: 1,
+        ai_subtitle: createOptions.aiSubtitle ?? 0,
+        original_voice: 0,
+      };
+
+      const relayData = JSON.stringify({
+        apiPath: '/v2/video/vlog/create',
+        apiBody,
+        appKey,
+        timestamp,
+        sign,
+      });
+
+      const body = JSON.stringify({ action: 'relay', relayData });
+
+      console.log(`📦 一键成片 - timestamp=${timestamp}, sign=${sign}`);
+      console.log(`   材料: ${videoUrls.length} 个视频`);
+
+      const response = await fetch(relayUrl, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body,
+      });
+
+      const result = await response.json();
+      console.log('📦 一键成片 - 响应:', JSON.stringify(result).substring(0, 300));
+
+      res.json(result);
+    } catch (err) {
+      console.error('❌ 一键成片代理失败:', err.message);
+      res.status(500).json({ error: err.message });
+    }
+  });
+
+  app.post('/api/quickly/query', async (req, res) => {
+    try {
+      const { taskId } = req.body;
+      if (!taskId) return res.status(400).json({ error: '缺少 taskId 参数' });
+
+      const body = JSON.stringify({
+        id: 'sWvRr8RvPT',
+        _ApplicationId: 'ncloudmaster',
+        action: 'query',
+        taskId,
+      });
+
+      const response = await fetch(queryUrl, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body,
+      });
+
+      const result = await response.json();
+      res.json(result);
+    } catch (err) {
+      console.error('❌ 一键成片查询失败:', err.message);
+      res.status(500).json({ error: err.message });
+    }
+  });
+}
+
+module.exports = { registerQuicklyRoutes };

+ 70 - 0
server/routes/remix-asset-upload.js

@@ -0,0 +1,70 @@
+function registerRemixAssetUploadRoutes(app, deps) {
+  const {
+    path,
+    assetUpload,
+    qiniuUploadUrl,
+    qiniuCdnDomain,
+    buildDigitalHumanAssetKey,
+    buildQiniuUploadToken,
+  } = deps;
+
+  app.post('/api/remix/upload-asset', assetUpload.single('file'), async (req, res) => {
+    try {
+      if (!req.file) {
+        return res.status(400).json({ error: '未收到素材文件' });
+      }
+
+      const mimeType = req.file.mimetype || 'application/octet-stream';
+      const kind = mimeType.startsWith('audio/')
+        ? 'audio'
+        : mimeType.startsWith('video/')
+          ? 'video'
+          : 'image';
+      const key = buildDigitalHumanAssetKey(req.file.originalname, kind);
+      const token = buildQiniuUploadToken(key);
+      const formData = new FormData();
+      formData.append('token', token);
+      formData.append('key', key);
+      formData.append('file', new Blob([req.file.buffer], { type: mimeType }), path.basename(key));
+
+      const response = await fetch(qiniuUploadUrl, {
+        method: 'POST',
+        body: formData,
+      });
+
+      const text = await response.text();
+      let payload = null;
+      try {
+        payload = text ? JSON.parse(text) : null;
+      } catch {
+        payload = null;
+      }
+
+      if (!response.ok) {
+        return res.status(response.status).json({
+          error: payload?.error || payload?.message || text || '七牛素材上传失败',
+          detail: payload || text || '',
+        });
+      }
+
+      const uploadedKey = payload?.key || key;
+      const url = `${qiniuCdnDomain.replace(/\/$/, '')}/${uploadedKey}`;
+      if (!uploadedKey || !url) {
+        return res.status(500).json({ error: '七牛未返回素材 Key', detail: payload || text || '' });
+      }
+
+      res.json({
+        success: true,
+        url,
+        key: uploadedKey,
+        mimeType,
+        kind,
+      });
+    } catch (error) {
+      console.error('❌ 上传重塑素材失败:', error);
+      res.status(500).json({ error: `上传重塑素材失败: ${error.message}` });
+    }
+  });
+}
+
+module.exports = { registerRemixAssetUploadRoutes };

+ 51 - 0
server/routes/upload.js

@@ -0,0 +1,51 @@
+const fs = require('fs');
+const path = require('path');
+
+function registerUploadRoutes(app, deps) {
+  app.post('/api/upload/video', deps.upload.single('video'), (req, res) => {
+    try {
+      if (!req.file) {
+        return res.status(400).json({ error: '未收到视频文件' });
+      }
+
+      const file = req.file;
+      const filename = file.filename;
+      const ext = path.extname(filename).replace('.', '').toLowerCase();
+      const videoId = `VID-${Date.now()}`;
+      const fileStat = fs.statSync(file.path);
+
+      const videoEntry = {
+        id: videoId,
+        title: req.body.title || path.basename(filename, path.extname(filename)),
+        filename,
+        size: fileStat.size,
+        duration: 0,
+        category: 'uploaded',
+        tags: req.body.tags ? JSON.parse(req.body.tags) : [],
+        description: req.body.description || '用户上传的视频',
+        source: 'uploaded',
+        metadata: {
+          resolution: '未知',
+          format: ext || 'mp4',
+        },
+      };
+
+      const manifest = deps.readManifest();
+      manifest.push(videoEntry);
+      deps.writeManifest(manifest);
+
+      console.log(`📤 视频上传成功: ${filename} (${(fileStat.size / 1024 / 1024).toFixed(1)}MB) → ${videoId}`);
+
+      res.json({
+        success: true,
+        video: videoEntry,
+        filepath: `/backend/video/${filename}`,
+      });
+    } catch (err) {
+      console.error('❌ 视频上传失败:', err.message);
+      res.status(500).json({ error: `上传失败: ${err.message}` });
+    }
+  });
+}
+
+module.exports = { registerUploadRoutes };

+ 336 - 0
server/routes/video-composite.js

@@ -0,0 +1,336 @@
+function registerVideoCompositeRoutes(app, deps) {
+  const { fs, path, spawn, dataDir, removeFileIfExists } = deps;
+  const compositeDir = path.join(dataDir, 'composite');
+  if (!fs.existsSync(compositeDir)) fs.mkdirSync(compositeDir, { recursive: true });
+
+  async function downloadFile(url, destPath) {
+    const response = await fetch(url, { redirect: 'follow' });
+    if (!response.ok) throw new Error(`下载失败 (${response.status}): ${url}`);
+    const buffer = Buffer.from(await response.arrayBuffer());
+    fs.writeFileSync(destPath, buffer);
+    return destPath;
+  }
+
+  function getMediaDuration(mediaPath) {
+    return new Promise((resolve, reject) => {
+      const proc = spawn('ffprobe', [
+        '-v', 'error', '-show_entries', 'format=duration',
+        '-of', 'default=noprint_wrappers=1:nokey=1', mediaPath,
+      ]);
+      let stdout = '';
+      proc.stdout.on('data', d => stdout += d.toString());
+      proc.on('error', reject);
+      proc.on('close', code => {
+        const dur = parseFloat(stdout.trim());
+        if (code !== 0 || isNaN(dur)) reject(new Error('无法获取媒体时长'));
+        else resolve(dur);
+      });
+    });
+  }
+
+  const getAudioDuration = getMediaDuration;
+
+  function convertImageToJpg(inputPath, outputPath) {
+    return new Promise((resolve, reject) => {
+      const proc = spawn('ffmpeg', ['-y', '-i', inputPath, '-frames:v', '1', outputPath]);
+      let stderr = '';
+      proc.stderr.on('data', d => stderr += d.toString());
+      proc.on('error', reject);
+      proc.on('close', code => {
+        if (code !== 0) reject(new Error(`图片转换失败: ${stderr.split('\n').filter(Boolean).slice(-1)[0]}`));
+        else resolve(outputPath);
+      });
+    });
+  }
+
+  const SEG_VF = 'scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:black';
+  const SEG_AUDIO_ARGS = ['-c:a', 'aac', '-b:a', '192k', '-ar', '44100', '-ac', '2'];
+
+  function createSegmentVideo(imagePath, audioPath, outputPath, duration) {
+    return new Promise(async (resolve, reject) => {
+      try {
+        const jpgPath = imagePath.replace(/\.[^.]+$/, '') + '_converted.jpg';
+        await convertImageToJpg(imagePath, jpgPath);
+
+        const args = [
+          '-y',
+          '-loop', '1', '-i', jpgPath,
+          '-i', audioPath,
+          '-c:v', 'h264_mf',
+          ...SEG_AUDIO_ARGS,
+          '-vf', SEG_VF,
+          '-pix_fmt', 'yuv420p',
+          '-t', String(duration),
+          '-shortest',
+          outputPath,
+        ];
+        const proc = spawn('ffmpeg', args);
+        let stderr = '';
+        proc.stderr.on('data', d => stderr += d.toString());
+        proc.on('error', err => reject(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg' : err.message)));
+        proc.on('close', code => {
+          removeFileIfExists(jpgPath);
+          if (code !== 0) reject(new Error(`片段合成失败: ${stderr.split('\n').filter(Boolean).slice(-2).join(' ')}`));
+          else resolve(outputPath);
+        });
+      } catch (err) {
+        reject(err);
+      }
+    });
+  }
+
+  function createVideoAudioSegment(videoPath, audioPath, outputPath, audioDuration, videoDuration) {
+    return new Promise((resolve, reject) => {
+      const target = Number(audioDuration);
+      if (!target || target <= 0) {
+        reject(new Error('createVideoAudioSegment 需要正数 audioDuration'));
+        return;
+      }
+
+      let vfChain = SEG_VF;
+      let needPad = false;
+      if (typeof videoDuration === 'number' && videoDuration > 0 && videoDuration < target - 0.05) {
+        const padSec = (target - videoDuration).toFixed(3);
+        vfChain += `,tpad=stop_mode=clone:stop_duration=${padSec}`;
+        needPad = true;
+      }
+
+      const args = [
+        '-y',
+        '-i', videoPath,
+        '-i', audioPath,
+        '-map', '0:v:0',
+        '-map', '1:a:0',
+        '-c:v', 'h264_mf',
+        '-vf', vfChain,
+        '-pix_fmt', 'yuv420p',
+        ...SEG_AUDIO_ARGS,
+        '-t', String(target),
+        outputPath,
+      ];
+      console.log(`    ↳ case B align: audio=${target.toFixed(2)}s, video=${videoDuration ? videoDuration.toFixed(2) + 's' : '?'}, ${needPad ? 'PAD freeze last frame' : 'TRIM/EXACT'}`);
+      const proc = spawn('ffmpeg', args);
+      let stderr = '';
+      proc.stderr.on('data', d => stderr += d.toString());
+      proc.on('error', err => reject(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg' : err.message)));
+      proc.on('close', code => {
+        if (code !== 0) reject(new Error(`视频+音频片段合成失败: ${stderr.split('\n').filter(Boolean).slice(-2).join(' ')}`));
+        else resolve(outputPath);
+      });
+    });
+  }
+
+  function createVideoOnlySegment(videoPath, outputPath) {
+    return new Promise((resolve, reject) => {
+      const args = [
+        '-y',
+        '-i', videoPath,
+        '-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100',
+        '-map', '0:v:0',
+        '-map', '1:a:0',
+        '-c:v', 'h264_mf',
+        '-vf', SEG_VF,
+        '-pix_fmt', 'yuv420p',
+        ...SEG_AUDIO_ARGS,
+        '-shortest',
+        outputPath,
+      ];
+      const proc = spawn('ffmpeg', args);
+      let stderr = '';
+      proc.stderr.on('data', d => stderr += d.toString());
+      proc.on('error', err => reject(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg' : err.message)));
+      proc.on('close', code => {
+        if (code !== 0) reject(new Error(`纯视频片段合成失败: ${stderr.split('\n').filter(Boolean).slice(-2).join(' ')}`));
+        else resolve(outputPath);
+      });
+    });
+  }
+
+  function createImageOnlySegment(imagePath, outputPath, duration) {
+    return new Promise(async (resolve, reject) => {
+      try {
+        const jpgPath = imagePath.replace(/\.[^.]+$/, '') + '_converted.jpg';
+        await convertImageToJpg(imagePath, jpgPath);
+        const args = [
+          '-y',
+          '-loop', '1', '-i', jpgPath,
+          '-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100',
+          '-map', '0:v:0',
+          '-map', '1:a:0',
+          '-c:v', 'h264_mf',
+          '-vf', SEG_VF,
+          '-pix_fmt', 'yuv420p',
+          ...SEG_AUDIO_ARGS,
+          '-t', String(duration),
+          '-shortest',
+          outputPath,
+        ];
+        const proc = spawn('ffmpeg', args);
+        let stderr = '';
+        proc.stderr.on('data', d => stderr += d.toString());
+        proc.on('error', err => reject(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg' : err.message)));
+        proc.on('close', code => {
+          removeFileIfExists(jpgPath);
+          if (code !== 0) reject(new Error(`静音图片片段合成失败: ${stderr.split('\n').filter(Boolean).slice(-2).join(' ')}`));
+          else resolve(outputPath);
+        });
+      } catch (err) {
+        reject(err);
+      }
+    });
+  }
+
+  async function composeOneSegment({ seg, jobDir, i }) {
+    const segVideoPath = path.join(jobDir, `seg-${i}.mp4`);
+    const hasImage = !!seg.imageUrl;
+    const hasVideo = !!seg.videoUrl;
+    const hasAudio = !!seg.audioUrl;
+
+    if (!hasImage && !hasVideo) {
+      throw new Error(`片段 ${seg.id != null ? seg.id : i} 缺少 imageUrl/videoUrl`);
+    }
+
+    let audioPath = null;
+    let audioDuration = null;
+    if (hasAudio) {
+      const ext = (seg.audioUrl.match(/\.(mp3|wav|aac|ogg|m4a)/i) || ['.mp3'])[0] || '.mp3';
+      audioPath = path.join(jobDir, `audio-${i}${ext}`);
+      await downloadFile(seg.audioUrl, audioPath);
+      audioDuration = await getAudioDuration(audioPath);
+    }
+
+    if (hasVideo) {
+      const videoPath = path.join(jobDir, `video-${i}.mp4`);
+      await downloadFile(seg.videoUrl, videoPath);
+
+      let videoDuration;
+      try {
+        videoDuration = await getMediaDuration(videoPath);
+      } catch (e) {
+        console.warn(`  ⚠️  片段 ${i + 1} 探测视频时长失败,退化为 -shortest 对齐:`, e?.message || e);
+      }
+      if (hasAudio) {
+        await createVideoAudioSegment(videoPath, audioPath, segVideoPath, audioDuration, videoDuration);
+        return { path: segVideoPath, duration: audioDuration, mode: 'B' };
+      }
+      await createVideoOnlySegment(videoPath, segVideoPath);
+      return { path: segVideoPath, duration: null, mode: 'C' };
+    }
+
+    const ext = (seg.imageUrl.match(/\.(jpg|jpeg|png|webp|gif)/i) || ['.jpg'])[0] || '.jpg';
+    const imgPath = path.join(jobDir, `img-${i}${ext}`);
+    await downloadFile(seg.imageUrl, imgPath);
+
+    if (hasAudio) {
+      await createSegmentVideo(imgPath, audioPath, segVideoPath, audioDuration);
+      return { path: segVideoPath, duration: audioDuration, mode: 'A' };
+    }
+    const dur = Math.max(0.5, Number(seg.duration) || 3);
+    await createImageOnlySegment(imgPath, segVideoPath, dur);
+    return { path: segVideoPath, duration: dur, mode: 'D' };
+  }
+
+  function concatVideos(segmentPaths, outputPath) {
+    return new Promise((resolve, reject) => {
+      const listPath = outputPath + '.txt';
+      const listContent = segmentPaths.map(p => `file '${p.replace(/\\/g, '/')}'`).join('\n');
+      fs.writeFileSync(listPath, listContent, 'utf-8');
+
+      const args = [
+        '-y', '-f', 'concat', '-safe', '0',
+        '-i', listPath,
+        '-c', 'copy',
+        outputPath,
+      ];
+      const proc = spawn('ffmpeg', args);
+      let stderr = '';
+      proc.stderr.on('data', d => stderr += d.toString());
+      proc.on('error', err => reject(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg' : err.message)));
+      proc.on('close', code => {
+        removeFileIfExists(listPath);
+        if (code !== 0) reject(new Error(`视频拼接失败: ${stderr.split('\n').filter(Boolean).slice(-2).join(' ')}`));
+        else resolve(outputPath);
+      });
+    });
+  }
+
+  app.post('/api/video/composite', async (req, res) => {
+    const { segments, title } = req.body;
+    if (!Array.isArray(segments) || segments.length === 0) {
+      return res.status(400).json({ error: '缺少 segments 参数' });
+    }
+
+    const jobId = `VG-${Date.now()}`;
+    const jobDir = path.join(compositeDir, jobId);
+    fs.mkdirSync(jobDir, { recursive: true });
+
+    try {
+      console.log(`🎬 开始合成视频: ${jobId}, ${segments.length} 个片段`);
+
+      const segmentPaths = [];
+      for (let i = 0; i < segments.length; i++) {
+        const seg = segments[i];
+        if (!seg.imageUrl && !seg.videoUrl) {
+          console.warn(`⚠️ 片段 ${seg.id || i} 缺少 imageUrl/videoUrl,跳过`);
+          continue;
+        }
+        console.log(`  📥 处理片段 ${i + 1}/${segments.length}...`);
+        const { path: segPath, duration, mode } = await composeOneSegment({ seg, jobDir, i });
+        console.log(`  🎞️ 已完成片段 ${i + 1}/${segments.length} [mode=${mode}${duration ? `, ${duration.toFixed(1)}s` : ''}]`);
+        segmentPaths.push(segPath);
+      }
+
+      if (segmentPaths.length === 0) {
+        return res.status(400).json({ error: '没有有效的素材片段' });
+      }
+
+      const safeTitle = String(title || 'video').replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g, '_').substring(0, 50);
+      const finalFilename = `${safeTitle}-${jobId}.mp4`;
+      const finalPath = path.join(compositeDir, finalFilename);
+
+      console.log(`  🔗 拼接 ${segmentPaths.length} 个片段...`);
+
+      if (segmentPaths.length === 1) {
+        fs.copyFileSync(segmentPaths[0], finalPath);
+      } else {
+        await concatVideos(segmentPaths, finalPath);
+      }
+
+      try {
+        fs.rmSync(jobDir, { recursive: true, force: true });
+      } catch {}
+
+      const fileSize = fs.statSync(finalPath).size;
+      console.log(`✅ 视频合成完成: ${finalFilename} (${(fileSize / 1024 / 1024).toFixed(1)}MB)`);
+
+      res.json({
+        success: true,
+        videoUrl: `/api/video/composite/${finalFilename}`,
+        filename: finalFilename,
+        size: fileSize,
+        segments: segmentPaths.length,
+      });
+    } catch (err) {
+      console.error('❌ 视频合成失败:', err.message);
+      try { fs.rmSync(jobDir, { recursive: true, force: true }); } catch {}
+      res.status(500).json({ error: `视频合成失败: ${err.message}` });
+    }
+  });
+
+  app.post('/api/video/composite/stream', async (req, res) => {
+    return res.status(410).json({
+      error: 'deprecated: client now composes in browser via ffmpeg.wasm; see BrowserFfmpegService.composeVideo()',
+    });
+  });
+
+  app.get('/api/video/composite/:filename', (req, res) => {
+    const filePath = path.join(compositeDir, req.params.filename);
+    if (!fs.existsSync(filePath)) {
+      return res.status(404).json({ error: '视频文件不存在' });
+    }
+    res.setHeader('Content-Type', 'video/mp4');
+    fs.createReadStream(filePath).pipe(res);
+  });
+}
+
+module.exports = { registerVideoCompositeRoutes };

+ 142 - 0
server/routes/video-download.js

@@ -0,0 +1,142 @@
+function registerVideoDownloadRoutes(app, deps) {
+  const {
+    path,
+    fs,
+    crypto,
+    Readable,
+    Transform,
+    pipeline,
+    dataVideoDir,
+    downloadTasks,
+    normalizeRemoteUrls,
+    ensureVideoFilename,
+    ensureUniqueVideoFilename,
+    fetchRemoteVideoResponse,
+    createManagedVideoEntry,
+    readManifest,
+    writeManifest,
+    removeFileIfExists,
+  } = deps;
+
+  app.post('/api/download/video', (req, res) => {
+    const {
+      url,
+      urls,
+      filename,
+      title,
+      description,
+      tags,
+      thumbnail,
+      duration,
+      resolution,
+      awemeId,
+      authorName,
+    } = req.body || {};
+
+    if (!url || typeof url !== 'string') {
+      return res.status(400).json({ error: '缺少 url 参数' });
+    }
+
+    const candidateUrls = normalizeRemoteUrls(url, urls);
+    if (candidateUrls.length === 0) {
+      return res.status(400).json({ error: '无效的视频地址' });
+    }
+
+    const safeFilename = ensureUniqueVideoFilename(ensureVideoFilename(filename || title || awemeId || 'douyin-video.mp4', url));
+    const filePath = path.join(dataVideoDir, safeFilename);
+    const taskId = `DL-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
+    const task = {
+      id: taskId,
+      status: 'pending',
+      progress: 0,
+      downloadedBytes: 0,
+      totalBytes: 0,
+      filename: safeFilename,
+      created_at: new Date().toISOString(),
+    };
+
+    downloadTasks.set(taskId, task);
+    res.json({ success: true, taskId, filename: safeFilename });
+
+    (async () => {
+      try {
+        task.status = 'downloading';
+        console.log(`📥 开始下载远程视频: ${safeFilename} ← ${candidateUrls[0]}`);
+
+        const { url: resolvedUrl, response } = await fetchRemoteVideoResponse(candidateUrls, {
+          Accept: '*/*',
+          'User-Agent': 'Mozilla/5.0',
+          Referer: 'https://www.douyin.com/',
+          Origin: 'https://www.douyin.com',
+        });
+        task.sourceUrl = resolvedUrl;
+
+        const totalBytes = Number.parseInt(response.headers.get('content-length') || '0', 10) || 0;
+        task.totalBytes = totalBytes;
+
+        let downloadedBytes = 0;
+        let chunkCount = 0;
+
+        const progressStream = new Transform({
+          transform(chunk, encoding, callback) {
+            downloadedBytes += chunk.length;
+            chunkCount += 1;
+            task.downloadedBytes = downloadedBytes;
+            task.progress = totalBytes > 0
+              ? Math.min(99, Math.round((downloadedBytes / totalBytes) * 100))
+              : Math.min(95, Math.max(task.progress || 0, Math.min(95, chunkCount)));
+            callback(null, chunk);
+          },
+        });
+
+        await pipeline(
+          Readable.fromWeb(response.body),
+          progressStream,
+          fs.createWriteStream(filePath),
+        );
+
+        const stat = fs.statSync(filePath);
+        const manifest = readManifest();
+        const videoEntry = createManagedVideoEntry({
+          filename: safeFilename,
+          title,
+          description,
+          tags,
+          thumbnail,
+          duration,
+          resolution,
+          awemeId,
+          authorName,
+          size: stat.size,
+        });
+
+        manifest.push(videoEntry);
+        writeManifest(manifest);
+
+        task.status = 'completed';
+        task.progress = 100;
+        task.completed_at = new Date().toISOString();
+        task.video = videoEntry;
+
+        console.log(`✅ 远程视频下载完成: ${safeFilename} (${(stat.size / 1024 / 1024).toFixed(1)}MB)`);
+      } catch (error) {
+        removeFileIfExists(filePath);
+        task.status = 'failed';
+        task.error = error.message;
+        task.failed_at = new Date().toISOString();
+        console.error('❌ 远程视频下载失败:', error);
+      }
+    })();
+  });
+
+  app.get('/api/download/video/:taskId', (req, res) => {
+    const task = downloadTasks.get(req.params.taskId);
+    if (!task) {
+      return res.status(404).json({ error: '未找到下载任务' });
+    }
+
+    res.json(task);
+  });
+}
+
+module.exports = { registerVideoDownloadRoutes };

+ 160 - 0
server/routes/video-proxy.js

@@ -0,0 +1,160 @@
+const { Readable } = require('stream');
+const { pipeline } = require('stream/promises');
+const { Agent } = require('undici');
+
+const CONNECT_TIMEOUT_MS = 30_000;
+const MAX_ATTEMPTS = 3;
+const RETRY_DELAYS_MS = [300, 800];
+
+const videoProxyAgent = new Agent({
+  connect: {
+    timeout: CONNECT_TIMEOUT_MS
+  }
+});
+
+const PASSTHROUGH_HEADERS = [
+  'content-type',
+  'content-length',
+  'content-range',
+  'accept-ranges',
+  'etag',
+  'last-modified',
+  'cache-control'
+];
+
+function sleep(ms) {
+  return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+function networkErrorCode(error) {
+  return error?.cause?.code || error?.code || '';
+}
+
+function isRetryableNetworkError(error) {
+  return [
+    'UND_ERR_CONNECT_TIMEOUT',
+    'UND_ERR_SOCKET',
+    'ECONNRESET',
+    'ETIMEDOUT',
+    'ENETUNREACH',
+    'EHOSTUNREACH'
+  ].includes(networkErrorCode(error));
+}
+
+async function fetchVideoWithRetry(url, init, deps) {
+  const fetchImpl = deps.fetchImpl || fetch;
+  const maxAttempts = deps.maxAttempts || MAX_ATTEMPTS;
+  let lastError = null;
+
+  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
+    try {
+      return await fetchImpl(url, init);
+    } catch (error) {
+      lastError = error;
+      if (!isRetryableNetworkError(error) || attempt >= maxAttempts || init.signal?.aborted) {
+        throw error;
+      }
+
+      console.warn(
+        `[video-proxy] connect attempt ${attempt}/${maxAttempts} failed: ${error.message}; cause=${networkErrorCode(error)}`
+      );
+      await (deps.sleep || sleep)(RETRY_DELAYS_MS[Math.min(attempt - 1, RETRY_DELAYS_MS.length - 1)]);
+    }
+  }
+
+  throw lastError || new Error('视频代理连接失败');
+}
+
+function registerVideoProxyRoutes(app, deps) {
+  app.get('/api/video-proxy', async (req, res) => {
+    const { url, filename = 'douyin-video.mp4', download } = req.query;
+
+    if (!url || typeof url !== 'string') {
+      return res.status(400).json({ error: '缺少 url 参数' });
+    }
+
+    if (!deps.isSafeRemoteUrl(url)) {
+      return res.status(400).json({ error: '无效的视频地址' });
+    }
+
+    const abortController = new AbortController();
+    const abortUpstream = () => {
+      if (!res.writableEnded) abortController.abort();
+    };
+    res.once('close', abortUpstream);
+
+    try {
+      const upstreamHeaders = {
+        'Accept': req.headers.accept || '*/*',
+        'User-Agent': req.headers['user-agent'] || 'Mozilla/5.0',
+        'Referer': 'https://www.douyin.com/',
+        'Origin': 'https://www.douyin.com'
+      };
+
+      if (req.headers.range) {
+        upstreamHeaders.Range = req.headers.range;
+      }
+
+      const response = await fetchVideoWithRetry(url, {
+        method: 'GET',
+        headers: upstreamHeaders,
+        redirect: 'follow',
+        dispatcher: deps.dispatcher || videoProxyAgent,
+        signal: abortController.signal
+      }, deps);
+
+      if (!response.ok && response.status !== 206) {
+        const errorText = await response.text().catch(() => '');
+        return res.status(response.status).json({
+          error: '远程视频请求失败',
+          status: response.status,
+          detail: errorText
+        });
+      }
+
+      res.status(response.status);
+      for (const headerName of PASSTHROUGH_HEADERS) {
+        const value = response.headers.get(headerName);
+        if (value) res.setHeader(headerName, value);
+      }
+
+      if (!response.headers.get('content-type')) {
+        res.setHeader('Content-Type', 'video/mp4');
+      }
+
+      res.setHeader(
+        'Content-Disposition',
+        download === '1'
+          ? `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`
+          : `inline; filename*=UTF-8''${encodeURIComponent(filename)}`
+      );
+
+      if (!response.body) {
+        return res.end();
+      }
+
+      await pipeline(Readable.fromWeb(response.body), res);
+    } catch (error) {
+      if (abortController.signal.aborted && res.destroyed) {
+        return;
+      }
+
+      const detail = `${error.message}${networkErrorCode(error) ? `; cause=${networkErrorCode(error)}` : ''}`;
+      console.error('[video-proxy] request failed:', detail);
+
+      if (!res.headersSent) {
+        res.status(500).json({ error: `视频代理失败: ${detail}` });
+      } else if (!res.destroyed) {
+        res.destroy();
+      }
+    } finally {
+      res.off('close', abortUpstream);
+    }
+  });
+}
+
+module.exports = {
+  registerVideoProxyRoutes,
+  fetchVideoWithRetry,
+  isRetryableNetworkError
+};

+ 105 - 0
server/routes/video-proxy.test.js

@@ -0,0 +1,105 @@
+const assert = require('node:assert/strict');
+const http = require('node:http');
+const test = require('node:test');
+const express = require('express');
+const { registerVideoProxyRoutes } = require('./video-proxy');
+
+function startServer(deps) {
+  const app = express();
+  registerVideoProxyRoutes(app, {
+    isSafeRemoteUrl: () => true,
+    sleep: async () => {},
+    ...deps
+  });
+  app.get('/health', (req, res) => res.json({ status: 'ok' }));
+
+  return new Promise(resolve => {
+    const server = app.listen(0, '127.0.0.1', () => {
+      resolve({
+        server,
+        baseUrl: `http://127.0.0.1:${server.address().port}`
+      });
+    });
+  });
+}
+
+function request(url, headers = {}) {
+  return new Promise((resolve, reject) => {
+    const req = http.get(url, { headers }, res => {
+      const chunks = [];
+      res.on('data', chunk => chunks.push(chunk));
+      res.on('end', () => resolve({
+        status: res.statusCode,
+        body: Buffer.concat(chunks).toString('utf8')
+      }));
+      res.on('error', reject);
+    });
+    req.on('error', reject);
+  });
+}
+
+test('retries retryable connection errors before streaming', async t => {
+  let attempts = 0;
+  const { server, baseUrl } = await startServer({
+    maxAttempts: 3,
+    fetchImpl: async () => {
+      attempts += 1;
+      if (attempts < 3) {
+        throw Object.assign(new Error('fetch failed'), {
+          cause: { code: 'UND_ERR_CONNECT_TIMEOUT' }
+        });
+      }
+      return new Response('video-bytes', {
+        status: 200,
+        headers: { 'content-type': 'video/mp4' }
+      });
+    }
+  });
+  t.after(() => server.close());
+
+  const response = await request(`${baseUrl}/api/video-proxy?url=https://example.com/video.mp4`);
+
+  assert.equal(attempts, 3);
+  assert.equal(response.status, 200);
+  assert.equal(response.body, 'video-bytes');
+});
+
+test('keeps serving requests when the upstream stream fails', async t => {
+  const failingBody = new ReadableStream({
+    start(controller) {
+      controller.enqueue(new TextEncoder().encode('partial'));
+      controller.error(Object.assign(new Error('terminated'), {
+        cause: { code: 'ECONNRESET' }
+      }));
+    }
+  });
+  const { server, baseUrl } = await startServer({
+    fetchImpl: async () => new Response(failingBody, {
+      status: 200,
+      headers: { 'content-type': 'video/mp4' }
+    })
+  });
+  t.after(() => server.close());
+
+  await request(`${baseUrl}/api/video-proxy?url=https://example.com/video.mp4`).catch(() => {});
+  const health = await request(`${baseUrl}/health`);
+
+  assert.equal(health.status, 200);
+  assert.deepEqual(JSON.parse(health.body), { status: 'ok' });
+});
+
+test('does not retry an upstream HTTP error', async t => {
+  let attempts = 0;
+  const { server, baseUrl } = await startServer({
+    fetchImpl: async () => {
+      attempts += 1;
+      return new Response('not found', { status: 404 });
+    }
+  });
+  t.after(() => server.close());
+
+  const response = await request(`${baseUrl}/api/video-proxy?url=https://example.com/missing.mp4`);
+
+  assert.equal(attempts, 1);
+  assert.equal(response.status, 404);
+});

+ 127 - 0
server/routes/whisper.js

@@ -0,0 +1,127 @@
+function registerWhisperRoutes(app, deps) {
+  const {
+    exec,
+    fs,
+    path,
+    projectRoot,
+    whisperDir,
+    readManifest,
+    writeManifest,
+    resolveVideoPath,
+  } = deps;
+
+  app.post('/api/whisper/transcribe', async (req, res) => {
+    const { videoId, language = 'Chinese', model = 'base' } = req.body;
+
+    if (!videoId) {
+      return res.status(400).json({ error: '缺少 videoId 参数' });
+    }
+
+    const manifest = readManifest();
+    const video = manifest.find(v => v.id === videoId);
+    if (!video) {
+      return res.status(404).json({ error: `未找到视频: ${videoId}` });
+    }
+
+    const videoPath = resolveVideoPath(video.filename);
+    if (!fs.existsSync(videoPath)) {
+      return res.status(404).json({ error: `视频文件不存在: ${video.filename}` });
+    }
+
+    const baseName = video.filename.replace(/\.[^.]+$/, '');
+    const outputDir = path.join(whisperDir, baseName);
+    if (!fs.existsSync(outputDir)) {
+      fs.mkdirSync(outputDir, { recursive: true });
+    }
+
+    console.log(`🎙️ 开始 Whisper 转录: ${video.filename}`);
+    console.log(`   模型: ${model}, 语言: ${language}`);
+    console.log(`   输出目录: ${outputDir}`);
+
+    const cmd = `whisper "${videoPath}" --model ${model} --language ${language} --output_dir "${outputDir}"`;
+
+    try {
+      await new Promise((resolve, reject) => {
+        const process = exec(cmd, {
+          cwd: projectRoot,
+          timeout: 10 * 60 * 1000,
+          maxBuffer: 10 * 1024 * 1024,
+        });
+
+        let stdout = '';
+        let stderr = '';
+
+        process.stdout.on('data', (data) => {
+          stdout += data;
+          console.log(`   [whisper] ${data.toString().trim()}`);
+        });
+
+        process.stderr.on('data', (data) => {
+          stderr += data;
+        });
+
+        process.on('close', (code) => {
+          if (code === 0) {
+            resolve({ stdout, stderr });
+          } else {
+            reject(new Error(`Whisper 退出码: ${code}\n${stderr}`));
+          }
+        });
+
+        process.on('error', (err) => {
+          reject(new Error(`无法启动 Whisper: ${err.message}`));
+        });
+      });
+
+      const txtFile = path.join(outputDir, `${baseName}.txt`);
+      const srtFile = path.join(outputDir, `${baseName}.srt`);
+
+      const transcript = fs.existsSync(txtFile) ? fs.readFileSync(txtFile, 'utf-8') : '';
+      const srt = fs.existsSync(srtFile) ? fs.readFileSync(srtFile, 'utf-8') : '';
+
+      if (!transcript) {
+        return res.status(500).json({ error: 'Whisper 执行完成但未生成文字稿' });
+      }
+
+      const whisperPaths = {
+        transcript: `Whisper/${baseName}/${baseName}.txt`,
+        srt: `Whisper/${baseName}/${baseName}.srt`,
+      };
+
+      const jsonFile = path.join(outputDir, `${baseName}.json`);
+      if (fs.existsSync(jsonFile)) whisperPaths.segments = `Whisper/${baseName}/${baseName}.json`;
+
+      video.whisper = whisperPaths;
+      writeManifest(manifest);
+
+      console.log(`✅ Whisper 转录完成: ${baseName}`);
+
+      res.json({
+        success: true,
+        videoId: video.id,
+        transcript,
+        srt,
+        whisper: whisperPaths,
+        outputDir: `Whisper/${baseName}`,
+      });
+    } catch (err) {
+      console.error('❌ Whisper 转录失败:', err.message);
+      res.status(500).json({
+        error: `Whisper 转录失败: ${err.message}`,
+        hint: '请确保已安装 Whisper: pip install openai-whisper',
+      });
+    }
+  });
+
+  app.get('/api/whisper/status', (req, res) => {
+    exec('whisper --help', { timeout: 5000 }, (err) => {
+      if (err) {
+        res.json({ available: false, message: '未检测到 Whisper,请执行: pip install openai-whisper' });
+      } else {
+        res.json({ available: true, message: 'Whisper 已安装' });
+      }
+    });
+  });
+}
+
+module.exports = { registerWhisperRoutes };

+ 48 - 0
server/startup-log.js

@@ -0,0 +1,48 @@
+const ROUTE_LOG_LINES = [
+  '     GET  /api/health              — 健康检查',
+  '     GET  /api/whisper/status       — Whisper 可用性',
+  '     POST /api/whisper/transcribe   — 语音转文字',
+  '     GET  /api/manifest             — 获取视频清单',
+  '     PUT  /api/manifest/:videoId    — 更新视频信息',
+  '     POST /api/manifest             — 添加视频条目',
+  '     GET  /api/files/whisper/:id    — Whisper 输出文件',
+  '     GET  /api/files/read?path=     — 读取项目文件',
+  '     POST /api/upload/video         — 上传视频文件',
+  '     POST /api/remix/extract-audio  — 提取视频音频',
+  '     POST /api/remix/upload-asset   — 上传重塑素材到 Parse',
+  '     POST /api/download/video       — 下载远程视频到本地库',
+  '     GET  /api/download/video/:id   — 查询下载任务进度',
+  '     ---',
+  '     GET  /api/tasks               — 获取任务列表',
+  '     POST /api/tasks               — 创建任务',
+  '     PUT  /api/tasks/:id            — 更新任务',
+  '     DEL  /api/tasks/:id            — 删除任务',
+  '     ---',
+  '     GET  /api/history              — 获取历史记录',
+  '     POST /api/history              — 添加历史',
+  '     DEL  /api/history/:id           — 删除历史',
+  '     DEL  /api/history               — 清空历史',
+  '     ---',
+  '     GET  /api/results              — 获取结果',
+  '     POST /api/results              — 添加结果',
+  '     PUT  /api/results/:id          — 更新结果',
+  '     DEL  /api/results/:id           — 删除结果',
+  '     ---',
+  '     POST /api/llm/chat             — LLM 对话(OpenAI格式)',
+  '     POST /api/llm/gemini           — Gemini 媒体识别'
+];
+
+function printStartupLog({ port, projectRoot }) {
+  console.log('');
+  console.log('========================================');
+  console.log(`  🚀 后端服务已启动: http://localhost:${port}`);
+  console.log(`  📁 项目根目录: ${projectRoot}`);
+  console.log('  📋 接口列表:');
+  for (const line of ROUTE_LOG_LINES) {
+    console.log(line);
+  }
+  console.log('========================================');
+  console.log('');
+}
+
+module.exports = { printStartupLog };

File diff suppressed because it is too large
+ 327 - 1909
src/app/app.css


+ 150 - 26
src/app/app.html

@@ -24,6 +24,7 @@
 
 
     <!-- 侧边栏导航(P1 重构:3 大分区纯字排版,参考 components/app-sidebar) -->
     <!-- 侧边栏导航(P1 重构:3 大分区纯字排版,参考 components/app-sidebar) -->
     <app-sidebar
     <app-sidebar
+      *ngIf="auth.isLoggedIn"
       [currentTab]="currentTab"
       [currentTab]="currentTab"
       (tabChange)="setCurrentTab($any($event))">
       (tabChange)="setCurrentTab($any($event))">
     </app-sidebar>
     </app-sidebar>
@@ -34,6 +35,17 @@
 
 
     <main class="workbench">
     <main class="workbench">
 
 
+      <ng-container *ngIf="!auth.isLoggedIn">
+        <app-auth-page
+          mode="login"
+          [allowRegister]="false"
+          (modeChange)="setCurrentTab($event)"
+          (signedIn)="onAuthSignedIn()">
+        </app-auth-page>
+      </ng-container>
+
+      <ng-container *ngIf="auth.isLoggedIn">
+
       <!-- 首屏 Hero(CTA-first,P2) -->
       <!-- 首屏 Hero(CTA-first,P2) -->
       <app-home *ngIf="currentTab === 'home'"
       <app-home *ngIf="currentTab === 'home'"
                 [monitorSummary]="getHomeMonitorSummary()"
                 [monitorSummary]="getHomeMonitorSummary()"
@@ -150,8 +162,6 @@
                        (navigate)="setCurrentTab($any($event))">
                        (navigate)="setCurrentTab($any($event))">
       </app-user-center>
       </app-user-center>
 
 
-      <app-admin-users *ngIf="currentTab === 'admin-users'"></app-admin-users>
-
       <!-- 视频发现模块 -->
       <!-- 视频发现模块 -->
 
 
       <section class="dh-page search-page" *ngIf="currentTab === 'search'">
       <section class="dh-page search-page" *ngIf="currentTab === 'search'">
@@ -270,6 +280,28 @@
 
 
 
 
 
 
+        <div class="video-link-analysis-card">
+          <div class="video-link-analysis-copy">
+            <span class="video-link-analysis-kicker">快速分析</span>
+            <strong>粘贴抖音视频链接,直接进入爆款分析</strong>
+          </div>
+          <div class="video-link-analysis-form">
+            <input type="text"
+                   placeholder="粘贴抖音视频链接、分享文案或作品 ID"
+                   [(ngModel)]="videoLinkAnalysisInput"
+                   (keyup.enter)="analyzeVideoLink()">
+            <button class="btn btn-primary" type="button"
+                    (click)="analyzeVideoLink()"
+                    [disabled]="!videoLinkAnalysisInput.trim() || isAnalyzingVideoLink || isAnalyzingViralVideo">
+              <span class="icon">{{isAnalyzingVideoLink ? 'hourglass_empty' : 'link'}}</span>
+              {{isAnalyzingVideoLink ? '解析中...' : '解析并分析'}}
+            </button>
+          </div>
+          <div class="video-link-analysis-error" *ngIf="videoLinkAnalysisError">{{videoLinkAnalysisError}}</div>
+        </div>
+
+
+
         <div class="video-results" *ngIf="searchResults.length > 0">
         <div class="video-results" *ngIf="searchResults.length > 0">
 
 
           <div class="results-header">
           <div class="results-header">
@@ -867,6 +899,13 @@
                       (navigateToBatch)="setCurrentTab('batch-production')">
                       (navigateToBatch)="setCurrentTab('batch-production')">
       </app-topic-pool>
       </app-topic-pool>
 
 
+      <app-ip-operator *ngIf="currentTab === 'ip-operator'"
+                       (navigateToTopicPool)="setCurrentTab('topic-pool')"
+                       (navigateToTopicVideo)="setCurrentTab('topic-to-video')"
+                       (navigateToDigitalHuman)="onIpOperatorDigitalHuman($event)"
+                       (navigateToLibrary)="setCurrentTab('library')">
+      </app-ip-operator>
+
       <app-analysis-history *ngIf="currentTab === 'analysis-history' && auth.isLoggedIn"
       <app-analysis-history *ngIf="currentTab === 'analysis-history' && auth.isLoggedIn"
                             (analysisOpen)="openSavedViralAnalysis($event)">
                             (analysisOpen)="openSavedViralAnalysis($event)">
       </app-analysis-history>
       </app-analysis-history>
@@ -1025,6 +1064,50 @@
 
 
 
 
 
 
+        <section class="task-recovery-audit"
+                 *ngIf="latestRecoveryRun as recoveryRun"
+                 [class.task-recovery-audit--highlight]="isHighlightedRecoveryRun(recoveryRun)">
+          <div class="task-recovery-audit__main">
+            <span class="task-recovery-audit__label">最近恢复扫描</span>
+            <strong>{{formatRecoveryRunTime(recoveryRun.finishedAt)}}</strong>
+            <span>
+              已扫描 {{recoveryRun.summary.scanned}} 个任务,可恢复 {{recoveryRun.summary.recoverable}} 个,完成 {{recoveryRun.summary.completed}} 个,失败 {{recoveryRun.summary.failed}} 个,不可恢复 {{recoveryRun.summary.unrecoverable}} 个
+            </span>
+          </div>
+          <div class="task-recovery-audit__actions">
+            <span class="task-recovery-audit__id">Run {{recoveryRun.runId}}</span>
+            <button type="button"
+                    class="task-btn task-btn--ghost"
+                    *ngIf="isHighlightedRecoveryRun(recoveryRun)"
+                    (click)="clearRecoveryRunHighlight()">
+              取消高亮
+            </button>
+          </div>
+        </section>
+
+        <section class="task-recovery-audit task-recovery-audit--storage"
+                 *ngIf="latestStorageAuditEvent as storageEvent"
+                 [class.task-recovery-audit--highlight]="showStorageAudit">
+          <div class="task-recovery-audit__main">
+            <span class="task-recovery-audit__label">存储审计</span>
+            <strong>最近 {{storageAuditEvents.length}} 条</strong>
+            <span>
+              {{storageEvent.namespace}} / {{storageEvent.operation}} / {{storageEvent.status}},{{formatRecoveryRunTime(storageEvent.at)}}
+            </span>
+          </div>
+          <div class="task-recovery-audit__actions">
+            <span class="task-recovery-audit__id" [title]="storageEvent.message || storageEvent.eventId">
+              {{storageEvent.storageKey}}
+            </span>
+            <button type="button"
+                    class="task-btn task-btn--ghost"
+                    *ngIf="showStorageAudit"
+                    (click)="clearStorageAuditHighlight()">
+              取消高亮
+            </button>
+          </div>
+        </section>
+
         <!-- 任务列表 -->
         <!-- 任务列表 -->
 
 
         <div class="task-list-section" *ngIf="activeTasks.length > 0">
         <div class="task-list-section" *ngIf="activeTasks.length > 0">
@@ -1039,6 +1122,12 @@
 
 
             </h3>
             </h3>
 
 
+            <button type="button" class="task-btn task-btn--ghost" (click)="exportP0Diagnostics()">
+
+              导出 P0 诊断
+
+            </button>
+
             <button type="button" class="task-btn task-btn--ghost" *ngIf="tasksFilter" (click)="toggleTasksFilter(tasksFilter)">
             <button type="button" class="task-btn task-btn--ghost" *ngIf="tasksFilter" (click)="toggleTasksFilter(tasksFilter)">
 
 
               清除筛选
               清除筛选
@@ -4803,6 +4892,8 @@
 
 
 
 
 
 
+      </ng-container>
+
     </main>
     </main>
 
 
   </div>
   </div>
@@ -6183,8 +6274,8 @@
 
 
   <!-- 浮动 mini-pill:最近一次生成(P3)。
   <!-- 浮动 mini-pill:最近一次生成(P3)。
        与 AI 助手 FAB 共用右下角,通过 .has-assistant 类把 pill 抬高到 FAB 上方 -->
        与 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-overlay viral-analysis-overlay vf-modal-backdrop" *ngIf="isViralAnalysisOpen" (click)="closeViralAnalysis()">
+    <div class="modal viral-analysis-modal vf-modal" (click)="$event.stopPropagation()">
       <div class="modal-header">
       <div class="modal-header">
         <h3><span class="icon">insights</span> 爆款分析</h3>
         <h3><span class="icon">insights</span> 爆款分析</h3>
         <button class="icon-btn" type="button" (click)="closeViralAnalysis()" [disabled]="isAnalyzingViralVideo">
         <button class="icon-btn" type="button" (click)="closeViralAnalysis()" [disabled]="isAnalyzingViralVideo">
@@ -6217,13 +6308,21 @@
             <span class="status-badge">{{insight.source === 'monitor' ? '博主监测' : '视频发现'}}</span>
             <span class="status-badge">{{insight.source === 'monitor' ? '博主监测' : '视频发现'}}</span>
           </section>
           </section>
 
 
-          <section class="viral-analysis-section">
+          <section class="viral-analysis-section viral-analysis-section--summary">
             <h4>爆点判断</h4>
             <h4>爆点判断</h4>
             <p>{{insight.analysis.summary}}</p>
             <p>{{insight.analysis.summary}}</p>
           </section>
           </section>
 
 
-          <section class="viral-analysis-section">
-            <h4>证据状态</h4>
+          <section class="viral-analysis-section viral-analysis-section--evidence">
+            <div class="viral-section-heading">
+              <div>
+                <span class="viral-analysis-kicker">分析依据</span>
+                <h4>证据与逐字稿</h4>
+              </div>
+              <span class="vf-status-pill" *ngIf="insight.transcriptJob">
+                {{viralTranscriptStatusLabel(insight.transcriptJob)}}
+              </span>
+            </div>
             <p>
             <p>
               互动:播放 {{insight.videoSnapshot.playCount || 0}} / 点赞 {{insight.videoSnapshot.diggCount || 0}} / 评论 {{insight.videoSnapshot.commentCount || 0}} / 分享 {{insight.videoSnapshot.shareCount || 0}}
               互动:播放 {{insight.videoSnapshot.playCount || 0}} / 点赞 {{insight.videoSnapshot.diggCount || 0}} / 评论 {{insight.videoSnapshot.commentCount || 0}} / 分享 {{insight.videoSnapshot.shareCount || 0}}
             </p>
             </p>
@@ -6249,13 +6348,15 @@
               <p *ngIf="insight.transcriptJob?.errorMessage">{{insight.transcriptJob?.errorMessage}}</p>
               <p *ngIf="insight.transcriptJob?.errorMessage">{{insight.transcriptJob?.errorMessage}}</p>
             </div>
             </div>
             <div class="viral-transcript-actions" *ngIf="!insight.transcript">
             <div class="viral-transcript-actions" *ngIf="!insight.transcript">
-              <button class="btn" type="button" *ngIf="canStartViralTranscript(insight)"
+              <button class="vf-btn vf-btn-primary vf-btn-lg viral-transcript-action--primary" type="button" *ngIf="canStartViralTranscript(insight)"
                       (click)="startViralTranscript()" [disabled]="viralTranscriptBusy">
                       (click)="startViralTranscript()" [disabled]="viralTranscriptBusy">
+                <span class="icon">subtitles</span>
                 {{viralTranscriptBusy ? '提交中...' : (insight.transcriptJob ? '重新提交转写' : '补逐字稿')}}
                 {{viralTranscriptBusy ? '提交中...' : (insight.transcriptJob ? '重新提交转写' : '补逐字稿')}}
               </button>
               </button>
-              <button class="btn" type="button" *ngIf="insight.transcriptJob && insight.transcriptJob.status !== 'completed'"
+              <button class="vf-btn vf-btn-lg viral-transcript-action--refresh" type="button" *ngIf="insight.transcriptJob && insight.transcriptJob.status !== 'completed'"
                       (click)="refreshViralTranscript()" [disabled]="viralTranscriptBusy">
                       (click)="refreshViralTranscript()" [disabled]="viralTranscriptBusy">
-                {{viralTranscriptBusy ? '查询中...' : '刷新转写结果'}}
+                <span class="icon">refresh</span>
+                {{viralTranscriptBusy ? '自动跟踪中' : '刷新转写结果'}}
               </button>
               </button>
             </div>
             </div>
           </section>
           </section>
@@ -6323,11 +6424,11 @@
       </div>
       </div>
 
 
       <div class="modal-footer viral-analysis-footer">
       <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 class="vf-btn vf-btn-lg" type="button" (click)="closeViralAnalysis()" [disabled]="isAnalyzingViralVideo">关闭</button>
+        <button class="vf-btn vf-btn-lg" type="button" (click)="saveCurrentViralAnalysisToTopics()" [disabled]="!currentViralAnalysis || isAnalyzingViralVideo">
           保存到选题池
           保存到选题池
         </button>
         </button>
-        <button class="btn btn-primary" type="button" (click)="useViralAnalysisForTopicVideo()" [disabled]="!currentViralAnalysis || isAnalyzingViralVideo">
+        <button class="vf-btn vf-btn-primary vf-btn-lg" type="button" (click)="useViralAnalysisForTopicVideo()" [disabled]="!currentViralAnalysis || isAnalyzingViralVideo">
           进入主题生视频
           进入主题生视频
         </button>
         </button>
       </div>
       </div>
@@ -6336,8 +6437,11 @@
 
 
   <div class="modal-overlay daily-report-overlay" *ngIf="isDailyReportOpen" (click)="closeDailyReport()">
   <div class="modal-overlay daily-report-overlay" *ngIf="isDailyReportOpen" (click)="closeDailyReport()">
     <div class="modal daily-report-modal" (click)="$event.stopPropagation()">
     <div class="modal daily-report-modal" (click)="$event.stopPropagation()">
-      <div class="modal-header">
-        <h3><span class="icon">summarize</span> 监测日报</h3>
+      <div class="modal-header daily-report-header">
+        <div>
+          <span class="daily-report-eyebrow">博主热点监测</span>
+          <h3><span class="icon">summarize</span> 监测日报</h3>
+        </div>
         <button class="icon-btn" type="button" (click)="closeDailyReport()">
         <button class="icon-btn" type="button" (click)="closeDailyReport()">
           <span class="icon">close</span>
           <span class="icon">close</span>
         </button>
         </button>
@@ -6350,8 +6454,12 @@
             <strong>{{report.date}}</strong>
             <strong>{{report.date}}</strong>
           </div>
           </div>
           <div>
           <div>
-            <span>监测作品</span>
-            <strong>{{report.newWorks.length}}</strong>
+            <span>监测博主</span>
+            <strong>{{getDailyReportMetric(report, 'authorCount', monitorAuthors.length)}}</strong>
+          </div>
+          <div>
+            <span>覆盖作品</span>
+            <strong>{{getDailyReportMetric(report, 'worksCount', report.newWorks.length)}}</strong>
           </div>
           </div>
           <div>
           <div>
             <span>推荐选题</span>
             <span>推荐选题</span>
@@ -6360,23 +6468,36 @@
         </section>
         </section>
 
 
         <section class="daily-report-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>
+          <div class="daily-report-section-head">
+            <h4>值得关注的作品</h4>
+            <span>优先覆盖已加载作品的不同博主,再按互动热度补充</span>
+          </div>
+          <article class="daily-work" *ngFor="let work of report.newWorks | slice:0:6">
+            <div class="daily-work__head">
+              <strong>{{$any(work).authorName || '监测博主'}}</strong>
+              <span>互动分 {{($any(work).score || 0) | number:'1.0-0'}}</span>
+            </div>
             <p>{{$any(work).desc || $any(work).id}}</p>
             <p>{{$any(work).desc || $any(work).id}}</p>
-            <span>点赞 {{$any(work).statistics?.digg_count || 0}} · 评论 {{$any(work).statistics?.comment_count || 0}}</span>
+            <div class="daily-work__stats">
+              <span>点赞 {{$any(work).statistics?.digg_count || 0}}</span>
+              <span>评论 {{$any(work).statistics?.comment_count || 0}}</span>
+              <span>分享 {{$any(work).statistics?.share_count || 0}}</span>
+            </div>
             <p *ngIf="$any(work).commentSamples?.length">评论关注:{{$any(work).commentSamples[0]}}</p>
             <p *ngIf="$any(work).commentSamples?.length">评论关注:{{$any(work).commentSamples[0]}}</p>
           </article>
           </article>
         </section>
         </section>
 
 
         <section class="daily-report-section">
         <section class="daily-report-section">
-          <h4>可保存选题</h4>
+          <div class="daily-report-section-head">
+            <h4>可保存选题</h4>
+            <span>可直接进入主题生视频继续生产</span>
+          </div>
           <article class="daily-topic" *ngFor="let topic of report.topTopics">
           <article class="daily-topic" *ngFor="let topic of report.topTopics">
             <div>
             <div>
               <strong>{{topic.title}}</strong>
               <strong>{{topic.title}}</strong>
               <p>{{topic.angle}}</p>
               <p>{{topic.angle}}</p>
             </div>
             </div>
-            <button class="btn" type="button" (click)="openDailyReportTopic(topic)">进入主题生视频</button>
+            <button class="daily-report-topic-action" type="button" (click)="openDailyReportTopic(topic)">进入主题生视频</button>
           </article>
           </article>
         </section>
         </section>
 
 
@@ -6387,8 +6508,8 @@
       </div>
       </div>
 
 
       <div class="modal-footer daily-report-footer">
       <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 class="vf-btn vf-btn-secondary" type="button" (click)="closeDailyReport()">关闭</button>
+        <button class="vf-btn vf-btn-primary" type="button" (click)="saveDailyReportTopics()" [disabled]="!currentDailyReport?.topTopics?.length">
           保存选题到选题池
           保存选题到选题池
         </button>
         </button>
       </div>
       </div>
@@ -6396,14 +6517,17 @@
   </div>
   </div>
 
 
   <app-recent-task-pill
   <app-recent-task-pill
+    *ngIf="auth.isLoggedIn"
     [task]="currentRunningTask"
     [task]="currentRunningTask"
     [hidden]="pillHidden"
     [hidden]="pillHidden"
     [class.has-assistant]="!assistantExpanded"
     [class.has-assistant]="!assistantExpanded"
-    (open)="setCurrentTab('tasks')">
+    (open)="setCurrentTab('tasks')"
+    (dismiss)="dismissCurrentRunningTask()">
   </app-recent-task-pill>
   </app-recent-task-pill>
 
 
   <!-- 全局 AI 创作助手(永远在场,跨 tab 持久化) -->
   <!-- 全局 AI 创作助手(永远在场,跨 tab 持久化) -->
   <app-assistant
   <app-assistant
+    *ngIf="auth.isLoggedIn"
     (suggestionPick)="onAssistantSuggestion($event)"
     (suggestionPick)="onAssistantSuggestion($event)"
     (expandedChange)="assistantExpanded = $event">
     (expandedChange)="assistantExpanded = $event">
   </app-assistant>
   </app-assistant>

+ 1 - 0
src/app/app.spec.ts

@@ -10,6 +10,7 @@ describe('App', () => {
 
 
   it('should create the app', () => {
   it('should create the app', () => {
     const fixture = TestBed.createComponent(App);
     const fixture = TestBed.createComponent(App);
+    expect(() => fixture.detectChanges()).not.toThrow();
     const app = fixture.componentInstance;
     const app = fixture.componentInstance;
     expect(app).toBeTruthy();
     expect(app).toBeTruthy();
   });
   });

File diff suppressed because it is too large
+ 234 - 686
src/app/app.ts


+ 3 - 3
src/app/components/api-test/api-test.component.ts

@@ -60,7 +60,7 @@ import { DouyinApiService } from '../../services/douyin-api.service';
       <div class="test-section">
       <div class="test-section">
         <h3>Token状态</h3>
         <h3>Token状态</h3>
         <div class="token-status">
         <div class="token-status">
-          <p>当前Token: <code>{{ currentToken }}</code></p>
+          <p>Token来源: <code>{{ currentToken }}</code></p>
           <p class="status" [class.active]="tokenActive">状态: {{ tokenActive ? '✅ 活跃' : '❌ 未配置' }}</p>
           <p class="status" [class.active]="tokenActive">状态: {{ tokenActive ? '✅ 活跃' : '❌ 未配置' }}</p>
         </div>
         </div>
       </div>
       </div>
@@ -151,7 +151,7 @@ export class ApiTestComponent {
   videoDetail: any = null;
   videoDetail: any = null;
 
 
   // 环境配置Token信息
   // 环境配置Token信息
-  currentToken = '1a8mHd5MhAecmTK4Md2LhRfWMTLRYoZOz0LbglqkbdaBu02aIn6FZjagKw==';
+  currentToken = '本地/云函数环境变量';
   tokenActive = true;
   tokenActive = true;
 
 
   constructor(private douyinApiService: DouyinApiService) {}
   constructor(private douyinApiService: DouyinApiService) {}
@@ -205,4 +205,4 @@ export class ApiTestComponent {
       }
       }
     });
     });
   }
   }
-}
+}

+ 7 - 7
src/app/components/app-sidebar/app-sidebar.component.ts

@@ -1,6 +1,6 @@
-import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
+import { Component, EventEmitter, Input, Output } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { CommonModule } from '@angular/common';
-import { AuthCreditService } from '../../services/auth-credit.service';
+import { IP_OPERATOR_ENABLED } from '../../services/ip-operator-flags';
 
 
 /**
 /**
  * 全局侧栏 — 3 大分区纯字排版
  * 全局侧栏 — 3 大分区纯字排版
@@ -63,6 +63,7 @@ const ZONES: SidebarZone[] = [
     items: [
     items: [
       { tab: 'search', label: '视频发现' },
       { tab: 'search', label: '视频发现' },
       { tab: 'monitor', label: '博主监测' },
       { tab: 'monitor', label: '博主监测' },
+      { tab: 'ip-operator', label: 'IP操盘', badge: 'V1' },
       { tab: 'topic-pool', label: '选题池' },
       { tab: 'topic-pool', label: '选题池' },
       { tab: 'retrospective', label: '发布复盘' },
       { tab: 'retrospective', label: '发布复盘' },
       { tab: 'analysis-history', label: '分析历史' },
       { tab: 'analysis-history', label: '分析历史' },
@@ -76,7 +77,6 @@ const ZONES: SidebarZone[] = [
     label: '账号',
     label: '账号',
     items: [
     items: [
       { tab: 'user-center', label: '用户中心' },
       { tab: 'user-center', label: '用户中心' },
-      { tab: 'admin-users', label: '账号明细' },
     ],
     ],
   },
   },
 ];
 ];
@@ -92,14 +92,14 @@ export class AppSidebarComponent {
   @Input() currentTab: string = '';
   @Input() currentTab: string = '';
   @Output() tabChange = new EventEmitter<string>();
   @Output() tabChange = new EventEmitter<string>();
 
 
-  private readonly auth = inject(AuthCreditService);
-
   get zones(): SidebarZone[] {
   get zones(): SidebarZone[] {
-    if (this.auth.isAdmin) return ZONES;
     return ZONES
     return ZONES
       .map((zone) => ({
       .map((zone) => ({
         ...zone,
         ...zone,
-        items: zone.items.filter((item) => item.tab !== 'admin-users'),
+        items: zone.items.filter((item) => {
+          if (!IP_OPERATOR_ENABLED && item.tab === 'ip-operator') return false;
+          return true;
+        }),
       }))
       }))
       .filter((zone) => zone.items.length > 0);
       .filter((zone) => zone.items.length > 0);
   }
   }

+ 6 - 9
src/app/components/debug-panel/debug-panel.component.ts

@@ -14,7 +14,7 @@ import { DouyinService } from '../../services/douyin.service';
       <div class="debug-section">
       <div class="debug-section">
         <h3>Token状态</h3>
         <h3>Token状态</h3>
         <div class="token-info">
         <div class="token-info">
-          <p><strong>当前Token:</strong></p>
+          <p><strong>Token来源:</strong></p>
           <code>{{ currentToken }}</code>
           <code>{{ currentToken }}</code>
           <button (click)="testToken()" class="debug-btn">测试Token</button>
           <button (click)="testToken()" class="debug-btn">测试Token</button>
         </div>
         </div>
@@ -178,8 +178,7 @@ export class DebugPanelComponent {
   networkStatus = '';
   networkStatus = '';
   corsStatus = '';
   corsStatus = '';
 
 
-  // 硬编码Token
-  currentToken = 'r:a5a19ea9868043b15d9b10423234ca43';
+  currentToken = '本地/云函数环境变量';
 
 
   constructor(private douyinService: DouyinService) {}
   constructor(private douyinService: DouyinService) {}
 
 
@@ -187,13 +186,11 @@ export class DebugPanelComponent {
     this.tokenTestResult = null;
     this.tokenTestResult = null;
 
 
     // 简单的Token格式验证
     // 简单的Token格式验证
-    if (this.currentToken.startsWith('r:') && this.currentToken.length > 10) {
+    if (this.currentToken) {
       this.tokenTestResult = {
       this.tokenTestResult = {
-        status: '格式正确',
-        format: 'Bearer Token',
-        prefix: 'r:',
-        length: this.currentToken.length,
-        message: 'Token格式看起来正确'
+        status: '已改为服务端配置',
+        format: '环境变量',
+        message: '前端不再展示或保存真实 Token,请在本地 .env 或云函数环境变量中配置。'
       };
       };
     } else {
     } else {
       this.tokenTestResult = {
       this.tokenTestResult = {

+ 20 - 0
src/app/components/recent-task-pill/recent-task-pill.component.css

@@ -64,6 +64,7 @@
   display: flex;
   display: flex;
   justify-content: space-between;
   justify-content: space-between;
   align-items: center;
   align-items: center;
+  gap: 8px;
 }
 }
 
 
 .t-pill__type {
 .t-pill__type {
@@ -82,6 +83,25 @@
   font-variant-numeric: tabular-nums;
   font-variant-numeric: tabular-nums;
   color: #f8fafc;
   color: #f8fafc;
   letter-spacing: -0.01em;
   letter-spacing: -0.01em;
+  margin-left: auto;
+}
+
+.t-pill__close {
+  width: 22px;
+  height: 22px;
+  border-radius: 50%;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  color: rgba(248, 250, 252, 0.74);
+  background: rgba(255, 255, 255, 0.08);
+  font-size: 16px;
+  line-height: 1;
+}
+
+.t-pill__close:hover {
+  color: #ffffff;
+  background: rgba(255, 255, 255, 0.16);
 }
 }
 
 
 /* ===== 中部:任务标题 ===== */
 /* ===== 中部:任务标题 ===== */

+ 6 - 0
src/app/components/recent-task-pill/recent-task-pill.component.html

@@ -8,6 +8,12 @@
   <header class="t-pill__head">
   <header class="t-pill__head">
     <span class="t-pill__type">{{ typeLabel(task.type) }}</span>
     <span class="t-pill__type">{{ typeLabel(task.type) }}</span>
     <span class="t-pill__pct">{{ task.progress.toFixed(0) }}%</span>
     <span class="t-pill__pct">{{ task.progress.toFixed(0) }}%</span>
+    <span class="t-pill__close"
+          role="button"
+          tabindex="0"
+          aria-label="取消任务"
+          title="取消任务"
+          (click)="$event.stopPropagation(); dismiss.emit()">×</span>
   </header>
   </header>
 
 
   <!-- 中部:当前任务标题 -->
   <!-- 中部:当前任务标题 -->

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

@@ -28,6 +28,7 @@ export interface RecentTaskView {
 })
 })
 export class RecentTaskPillComponent {
 export class RecentTaskPillComponent {
   @Input() task: RecentTaskView | null = null;
   @Input() task: RecentTaskView | null = null;
+  @Output() dismiss = new EventEmitter<void>();
   @Input() hidden: boolean = false;  // 当前 tab 是 tasks 时可由父隐藏,避免重复
   @Input() hidden: boolean = false;  // 当前 tab 是 tasks 时可由父隐藏,避免重复
   @Output() open = new EventEmitter<void>();
   @Output() open = new EventEmitter<void>();
 
 

+ 582 - 0
src/app/models/app-shell.model.ts

@@ -0,0 +1,582 @@
+import type { GenerationTaskCost, GenerationTaskStatus, GenerationTaskStep } from './generation-task.model';
+
+// 类型定义
+export interface VideoInfo {
+
+  aweme_id: string;
+
+  desc: string;
+
+  cover_url: string;
+
+  cover_candidates?: string[];
+
+  duration: number;
+
+  create_time?: number;
+
+  publishDate?: Date | null;
+
+  author: {
+
+    nickname: string;
+
+    sec_uid: string;
+
+  };
+
+  statistics: {
+
+    play_count: number;
+
+    digg_count: number;
+
+    comment_count: number;
+
+    share_count: number;
+
+  };
+
+  text_extra?: string[];
+
+  // 下载相关
+
+  downloadProgress?: number;
+
+  isDownloading?: boolean;
+
+  downloadUrl?: string;
+
+}
+export interface Task {
+
+  id: string;
+
+  keyword: string;
+
+  status: 'pending' | 'processing' | 'completed' | 'failed' | 'timeout';
+
+  step: 'search' | 'detail' | 'comments' | 'download' | 'transcribe' | 'generate' | 'complete'
+
+      | 'vg-upload' | 'vg-analyze' | 'vg-script' | 'vg-image' | 'vg-voice' | 'vg-composite';
+
+  progress: number;
+
+  cost: number;
+
+  created_at: Date;
+
+  updated_at: Date;
+
+  result_url?: string;
+
+  error_message?: string;
+
+  failureSuggestion?: string;
+
+  retryable?: boolean;
+
+  recoverable?: boolean;
+
+  retryCount?: number;
+
+  lastErrorAt?: string;
+
+  generationStatus?: GenerationTaskStatus;
+
+  estimatedCredits?: number;
+
+  reservedCredits?: number;
+
+  creditReservationId?: string;
+
+  creditStatus?: GenerationTaskCost['status'];
+
+  costLines?: GenerationTaskCost['lines'];
+
+  refundReason?: string;
+
+  operation?: string;
+
+  pipelineId?: string;
+
+  externalTaskIds?: Record<string, string>;
+
+  steps?: GenerationTaskStep[];
+
+  snapshot?: any;
+
+  original_video_title?: string;
+
+  duration?: string;
+
+  // 任务类型:remix=AI重塑, video-generation=视频生成
+
+  type?: 'remix' | 'video-generation';
+
+  // 视频生成专用持久化数据
+
+  vgData?: {
+
+    videoId: string;
+
+    sourceFileName: string;
+
+    sourceVideoUrl: string;
+
+    step: number;
+
+    transcript: string;
+
+    analysisText: string;
+
+    script: string;
+
+    scriptSegments: { id: string; narration: string; imagePrompt: string; imagePromptCn?: string; }[];
+
+    imageResults: { id: string; prompt: string; imageUrl: string; status: 'pending' | 'generating' | 'done' | 'failed'; }[];
+
+    audioUrls: { segId: string; audioUrl: string; }[];
+
+    voiceMode: 'clone' | 'system';
+
+    voiceSpeakerId: string;
+
+    voiceTimbreId: string;
+    voiceName?: string;
+
+    finalVideoUrl: string;
+
+  };
+
+}
+export interface TaskStats {
+
+  pending: number;
+
+  processing: number;
+
+  completed: number;
+
+  failed: number;
+
+}
+export interface VoiceProfile {
+
+  id: string;
+
+  name: string;
+
+  creation_mode?: string;
+
+  speaker_id: string;
+
+  timbre_id?: string;
+
+  sample_text: string;
+
+  text_prompt?: string;
+
+  source_audio_text?: string;
+
+  source_audio_name?: string;
+
+  source_audio_format?: string;
+
+  language: number;
+
+  status: number | null;
+
+  status_label: string;
+
+  demo_audio: string;
+
+  available_training_times: number | null;
+
+  image_prompt_name?: string;
+
+  x_api_resource_id?: string;
+
+  model_version?: string;
+
+  icl_speaker_id?: string;
+
+  occupied?: boolean;
+
+  synthesized_audio_url?: string;
+
+  synthesized_work_id?: string;
+
+  last_synthesis_text?: string;
+
+  last_synthesis_ssml?: string;
+
+  last_synthesis_x_api_resource_id?: string;
+
+  last_synthesis_model?: string;
+
+  last_synthesis_format?: string;
+
+  last_synthesis_sample_rate?: number;
+
+  last_synthesis_speech_rate?: number;
+
+  last_synthesis_loudness_rate?: number;
+
+  last_synthesis_emotion?: string;
+
+  last_synthesis_emotion_scale?: number;
+
+  last_synthesis_enable_subtitle?: boolean;
+
+  last_synthesis_silence_duration?: number;
+
+  last_synthesis_enable_language_detector?: boolean;
+
+  last_synthesis_disable_markdown_filter?: boolean;
+
+  last_synthesis_disable_emoji_filter?: boolean;
+
+  last_synthesis_explicit_language?: string;
+
+  latest_audio_url?: string;
+
+  draft_synthesis_text?: string;
+
+  draft_synthesis_ssml?: string;
+
+  draft_synthesis_x_api_resource_id?: string;
+
+  draft_synthesis_model?: string;
+
+  draft_synthesis_format?: string;
+
+  draft_synthesis_sample_rate?: number;
+
+  draft_synthesis_speech_rate?: number;
+
+  draft_synthesis_loudness_rate?: number;
+
+  draft_synthesis_emotion?: string;
+
+  draft_synthesis_emotion_scale?: number;
+
+  draft_synthesis_enable_subtitle?: boolean;
+
+  draft_synthesis_silence_duration?: number;
+
+  draft_synthesis_enable_language_detector?: boolean;
+
+  draft_synthesis_disable_markdown_filter?: boolean;
+
+  draft_synthesis_disable_emoji_filter?: boolean;
+
+  draft_synthesis_explicit_language?: string;
+
+  is_synthesizing?: boolean;
+
+  message?: string;
+
+  request_id?: string;
+
+  created_at: Date;
+
+  updated_at?: Date;
+
+}
+export interface GeneratedResult {
+
+  id: string;
+
+  type: 'video' | 'image';
+
+  url: string;
+
+  title: string;
+
+  created_at: Date;
+
+  quality: string;
+
+  duration: string;
+
+  cost: number;
+
+  remixId?: string;
+
+  videoId?: string;
+
+  voiceProfileId?: string;
+
+  voiceProfileName?: string;
+
+  audioSourceLabel?: string;
+
+  segments?: { id: string; videoUrl: string; prompt: string; status: string }[];
+
+  /** P0+: 所属生成模式(来自 pipeline-registry,如 'digital_human' / 'image_to_video' 等) */
+  pipelineId?: string;
+
+}
+export interface HistoryRecord {
+
+  id: string;
+
+  original_video_title?: string;
+
+  keyword: string;
+
+  status: 'completed' | 'failed';
+
+  cost: number;
+
+  created_at: Date;
+
+  duration?: string;
+
+  remixId?: string;
+
+  videoId?: string;
+
+  styleName?: string;
+
+  segmentCount?: number;
+
+  successCount?: number;
+
+  resultUrl?: string;
+
+  quality?: string;
+
+  voiceProfileId?: string;
+
+  voiceProfileName?: string;
+
+  audioSourceLabel?: string;
+
+  /** P0+: 所属生成模式 id(与 pipeline-registry.PIPELINES[*].id 对应) */
+  pipelineId?: string;
+
+}
+export interface ManagedVideo {
+
+  id: string;
+
+  title: string;
+
+  filename: string;
+
+  filepath: string;
+
+  size: number;
+
+  duration: number;
+
+  created_at: Date;
+
+  modified_at: Date;
+
+  category: string;
+
+  tags: string[];
+
+  description: string;
+
+  thumbnail?: string;
+
+  source: 'downloaded' | 'generated' | 'uploaded';
+
+  metadata: {
+
+    resolution: string;
+
+    format: string;
+
+    bitrate?: number;
+
+  };
+
+  // Whisper 转录文件路径
+
+  whisper?: {
+
+    transcript?: string;
+
+    srt?: string;
+
+    storyboard?: string;
+
+    beautified?: string;
+
+  };
+
+  // AI重塑相关字段
+
+  isRemixing?: boolean;
+
+  remixProgress?: number;
+
+  remixStyle?: string;
+
+  originalVideoId?: string;
+
+  remixVersions?: RemixVersion[];
+
+}
+export interface RemixVersion {
+
+  id: string;
+
+  style: string;
+
+  created_at: Date;
+
+  file_path: string;
+
+  thumbnail: string;
+
+  description: string;
+
+  parameters: RemixParameters;
+
+}
+export interface RemixParameters {
+
+  style: string;
+
+  intensity: number;
+
+  preserve_audio: boolean;
+
+  quality: 'standard' | 'high' | 'ultra';
+
+  effects: string[];
+
+}
+export interface VideoCategory {
+
+  id: string;
+
+  name: string;
+
+  color: string;
+
+  count: number;
+
+}
+export interface StoryboardSegment {
+
+  id: string;
+
+  act: string;
+
+  narration: string;
+
+  prompt: string;
+
+  duration: string;
+
+  videoUrl?: string;
+
+  workId?: string;
+
+  status: 'pending' | 'generating' | 'completed' | 'failed';
+
+  error?: string;
+
+}
+export interface RemixFlowStep {
+
+  step: number;
+
+  label: string;
+
+}
+export interface MonitorWork {
+
+  id: string;
+
+  desc: string;
+
+  coverUrl: string;
+
+  coverCandidates: string[];
+
+  duration: number;
+
+  createTime?: Date;
+
+  type: 'video' | 'image';
+
+  statistics: {
+
+    play_count: number;
+
+    digg_count: number;
+
+    comment_count: number;
+
+    share_count: number;
+
+  };
+
+  isDownloading?: boolean;
+
+  downloadProgress?: number;
+
+  isNew?: boolean;
+
+  firstSeenAt?: string;
+
+}
+export interface MonitorAuthor {
+
+  id: string;
+
+  uid: string;
+
+  secUserId: string;
+
+  nickname: string;
+
+  uniqueId: string;
+
+  shortId: string;
+
+  signature: string;
+
+  ipLocation: string;
+
+  verifiedText: string;
+
+  avatarUrl: string;
+
+  avatarCandidates: string[];
+
+  coverUrl: string;
+
+  coverCandidates: string[];
+
+  followerCount: number;
+
+  followingCount: number;
+
+  totalFavorited: number;
+
+  awemeCount: number;
+
+  works: MonitorWork[];
+
+  worksCursor: string;
+
+  hasMoreWorks: boolean;
+
+  loadingWorks: boolean;
+
+  lastCheckedAt?: string;
+
+  lastSeenAwemeIds: string[];
+
+  newWorksCount: number;
+
+}

+ 37 - 0
src/app/models/app-tab.model.ts

@@ -0,0 +1,37 @@
+export const APP_TABS = [
+  'home',
+  'search',
+  'monitor',
+  'topic-pool',
+  'ip-operator',
+  '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',
+] as const;
+
+export type AppTab = typeof APP_TABS[number];
+
+export function isAppTab(value: string | null | undefined): value is AppTab {
+  return !!value && (APP_TABS as readonly string[]).includes(value);
+}

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

@@ -20,6 +20,8 @@ export interface BatchJob {
   startedAt?: string;
   startedAt?: string;
   finishedAt?: string;
   finishedAt?: string;
   lastRunAt?: string;
   lastRunAt?: string;
+  nextRunAt?: string;
+  pauseReason?: string;
   runnerVersion?: number;
   runnerVersion?: number;
   failureSummary?: Record<string, number>;
   failureSummary?: Record<string, number>;
   createdAt: string;
   createdAt: string;

+ 3 - 1
src/app/models/content-asset.model.ts

@@ -1,6 +1,6 @@
 import { PipelineRoute } from '../pipelines/pipeline-registry';
 import { PipelineRoute } from '../pipelines/pipeline-registry';
 
 
-export type ContentAssetType = 'image' | 'video' | 'audio';
+export type ContentAssetType = 'image' | 'video' | 'audio' | 'text';
 
 
 export interface ContentProject {
 export interface ContentProject {
   id: string;
   id: string;
@@ -30,6 +30,8 @@ export interface ContentAssetMetadata {
     batchJobId?: string;
     batchJobId?: string;
     batchItemId?: string;
     batchItemId?: string;
     generationTaskId?: string;
     generationTaskId?: string;
+    ipOperatorPlanId?: string;
+    ipOperatorProfileId?: string;
   };
   };
   parameters?: Record<string, any>;
   parameters?: Record<string, any>;
   createdAt: number;
   createdAt: number;

+ 2 - 2
src/app/models/creation-brief.model.ts

@@ -1,9 +1,9 @@
 import { InsightConfidence } from './douyin-insight.model';
 import { InsightConfidence } from './douyin-insight.model';
 
 
-export type CreationBriefSource = 'topic_pool' | 'viral_analysis' | 'daily_report' | 'assistant' | 'manual';
+export type CreationBriefSource = 'topic_pool' | 'viral_analysis' | 'daily_report' | 'assistant' | 'manual' | 'ip_operator';
 
 
 export interface CreationBriefEvidence {
 export interface CreationBriefEvidence {
-  type: 'video' | 'comment' | 'transcript' | 'analysis' | 'daily_report';
+  type: 'video' | 'comment' | 'transcript' | 'analysis' | 'daily_report' | 'ip_operator';
   id?: string;
   id?: string;
   text?: string;
   text?: string;
   url?: string;
   url?: string;

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

@@ -13,9 +13,21 @@ export interface CreatorDashboardSummary {
   pendingTopics: number;
   pendingTopics: number;
   readyAssets: number;
   readyAssets: number;
   activeBatchJobs: number;
   activeBatchJobs: number;
+  lastRecoveryRun?: CreatorDashboardRecoveryRun;
+  storageWarnings: number;
   suggestedActions: CreatorDashboardAction[];
   suggestedActions: CreatorDashboardAction[];
 }
 }
 
 
+export interface CreatorDashboardRecoveryRun {
+  runId: string;
+  finishedAt: string;
+  scanned: number;
+  recoverable: number;
+  completed: number;
+  failed: number;
+  unrecoverable: number;
+}
+
 export interface CreatorDashboardAction {
 export interface CreatorDashboardAction {
   id: string;
   id: string;
   priority: 'high' | 'medium' | 'low';
   priority: 'high' | 'medium' | 'low';

+ 1 - 1
src/app/models/douyin-insight.model.ts

@@ -107,7 +107,7 @@ export interface TopicIdea {
   userId: string;
   userId: string;
   title: string;
   title: string;
   angle: string;
   angle: string;
-  sourceType: 'viral_analysis' | 'daily_report' | 'assistant' | 'manual';
+  sourceType: 'viral_analysis' | 'daily_report' | 'assistant' | 'manual' | 'ip_operator';
   sourceVideoIds: string[];
   sourceVideoIds: string[];
   sourceAuthorIds: string[];
   sourceAuthorIds: string[];
   tags: string[];
   tags: string[];

+ 418 - 0
src/app/models/ip-operator.model.ts

@@ -0,0 +1,418 @@
+export type IpOperatorStage = 'new' | 'started' | 'restart' | 'existing';
+export type IpPlanStatus = 'draft' | 'generating' | 'ready' | 'needs_input' | 'failed';
+export type IpTrafficLayer = 'broad' | 'vertical' | 'conversion';
+export type IpPriority = 'high' | 'medium' | 'low';
+export type IpScriptStatus = 'outline' | 'full' | 'needs_input';
+export type IpScriptType = 'outline' | 'full';
+export type IpStorageMode = 'local' | 'temporary';
+export type IpGenerationStatus = 'pending' | 'running' | 'completed' | 'failed';
+export type IpTopicQualityLevel = 'make_now' | 'polish_first' | 'needs_material' | 'hold';
+export type IpScriptQualityLevel = 'ready_to_record' | 'polish_first' | 'needs_material' | 'outline_only';
+
+export type IpGenerationSection =
+  | 'intake_summary'
+  | 'diagnosis'
+  | 'benchmark'
+  | 'opportunities'
+  | 'topics_scripts_plan'
+  | 'quality_check';
+
+export type IpGenerationFailureCode =
+  | 'llm_timeout'
+  | 'llm_rate_limited'
+  | 'llm_empty_response'
+  | 'llm_malformed_json'
+  | 'llm_refusal'
+  | 'llm_insufficient_balance'
+  | 'llm_upstream_error'
+  | 'quality_gate_failed'
+  | 'storage_quota_exceeded'
+  | 'unknown';
+
+export interface IpQuestionAnswer {
+  id: string;
+  question: string;
+  answer: string;
+}
+
+export interface IpOperatorProfile {
+  id: string;
+  userId: string;
+  name: string;
+  stage: IpOperatorStage;
+  identity: string;
+  industry: string;
+  targetAudience: string;
+  audiencePainPoints: string[];
+  productsOrServices: string;
+  personalStories: string[];
+  expertise: string[];
+  expressionStyle: string[];
+  boundaries: string[];
+  goals: string[];
+  questionnaire: IpQuestionAnswer[];
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface BenchmarkViralExample {
+  id: string;
+  title: string;
+  url?: string;
+  performance?: string;
+  perceivedHook?: string;
+  notes?: string;
+}
+
+export interface BenchmarkAccount {
+  id: string;
+  profileId: string;
+  name: string;
+  url?: string;
+  bio?: string;
+  reasonToBenchmark: string;
+  viralExamples: BenchmarkViralExample[];
+  conversionNotes?: string;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface MissingInput {
+  type: string;
+  description: string;
+  priority: IpPriority;
+}
+
+export interface IpIntakeSummary {
+  identitySummary: string;
+  audienceSummary: string;
+  advantageSummary: string;
+  businessGoalSummary: string;
+  missingInputs: MissingInput[];
+}
+
+export interface IpDiagnosis {
+  positioningStatement: string;
+  targetAudience: {
+    summary: string;
+    painPoints: string[];
+  };
+  marketOpportunity: string;
+  ipType: string;
+  personaTags: string[];
+  differentiation: string;
+  contentTracks: string[];
+  monetizationPath: string;
+  risks: string[];
+  alternativeDirections: string[];
+  missingInputs: MissingInput[];
+}
+
+export interface BenchmarkAnalysis {
+  benchmarkId: string;
+  accountName: string;
+  positioning: string;
+  contentColumns: string[];
+  viralPatterns: string[];
+  scriptStructure: string;
+  conversionPath: string;
+  borrowablePoints: string[];
+  nonCopyablePoints: string[];
+  migrationDirection: string;
+  fitScore: number;
+}
+
+export interface ContentOpportunity {
+  id: string;
+  type: string;
+  fitScore: number;
+  reason: string;
+  audiencePainPoint: string;
+  requiredMaterials: string[];
+  extendableTopics: string[];
+  riskBoundary: string;
+}
+
+export interface IpTopic {
+  id: string;
+  title: string;
+  column: string;
+  trafficLayer: IpTrafficLayer;
+  contentGoal: string;
+  priority: IpPriority;
+  fitReason: string;
+  source: string;
+  requiredMaterials: string[];
+  scriptStatus: IpScriptStatus;
+  riskNote?: string;
+  syncedTopicId?: string;
+  userEdited?: boolean;
+  qualityLevel?: IpTopicQualityLevel;
+  polishSuggestions?: string[];
+  productionReadinessScore?: number;
+}
+
+export interface IpScript {
+  id: string;
+  topicId: string;
+  type: IpScriptType;
+  title?: string;
+  hook: string;
+  painPoint: string;
+  viewpoint: string;
+  caseOrMethod: string;
+  goldenSentence: string;
+  closingCta: string;
+  fullScript?: string;
+  shootingTips?: string[];
+  subtitleHighlights?: string[];
+  replaceableSlots?: string[];
+  riskNote?: string;
+  userEdited?: boolean;
+  qualityLevel?: IpScriptQualityLevel;
+  scriptSummary?: string;
+  productionNotes?: string[];
+}
+
+export interface ContentTestDay {
+  day: number;
+  topicId?: string;
+  topicTitle: string;
+  testGoal: string;
+  contentType: IpTrafficLayer;
+  shootingFocus: string;
+  observeMetrics: string[];
+  reviewQuestions: string[];
+}
+
+export interface ThirtyDayDirection {
+  columnStructure: Array<{
+    column: string;
+    ratio: number;
+    role: string;
+  }>;
+  weeklyFocus: string[];
+  materialDirections: string[];
+  iterationPath: string[];
+}
+
+export interface IpPlanQualityCheck {
+  overallScore: number;
+  positioningClarityScore: number;
+  audienceClarityScore: number;
+  evidenceSupportScore: number;
+  benchmarkTransferScore: number;
+  topicUsabilityScore: number;
+  passed: boolean;
+  blockingIssues: string[];
+  improvementSuggestions: string[];
+}
+
+export type IpPlanQualityDimensionKey =
+  | 'positioning'
+  | 'benchmark'
+  | 'opportunity'
+  | 'topics'
+  | 'scripts'
+  | 'test_plan';
+
+export interface IpPlanQualityAuditDimension {
+  key: IpPlanQualityDimensionKey;
+  label: string;
+  score: number;
+  conclusion: string;
+  blockingIssues: string[];
+  improvementActions: string[];
+}
+
+export interface IpPlanQualityAudit {
+  id: string;
+  planId: string;
+  overallScore: number;
+  status: 'ready' | 'needs_input' | 'failed';
+  dimensions: IpPlanQualityAuditDimension[];
+  productionGate: {
+    canEnterTopicPool: boolean;
+    canCreateDraft: boolean;
+    canExportExecution: boolean;
+    reason: string;
+  };
+  createdAt: string;
+}
+
+export interface IpSupplementInput {
+  id: string;
+  planId: string;
+  profileId: string;
+  type: string;
+  title: string;
+  content: string;
+  relatedMissingInputId?: string;
+  relatedAssetKey?: string;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface IpOperatorBridgeRecord {
+  id: string;
+  planId: string;
+  profileId: string;
+  sourceType: 'topic' | 'script' | 'asset' | 'supplement_input';
+  sourceId: string;
+  target: 'topic_to_video' | 'digital_human' | 'supplement_input' | 'topic_pool';
+  targetId: string;
+  title: string;
+  status: 'created' | 'skipped' | 'failed';
+  message?: string;
+  createdAt: string;
+}
+
+export interface IpEvidenceFetchJob {
+  id: string;
+  planId: string;
+  profileId: string;
+  benchmarkId?: string;
+  inputUrl: string;
+  awemeId?: string;
+  status: 'idle' | 'fetching_detail' | 'fetching_comments' | 'analyzing' | 'completed' | 'failed' | 'partial';
+  evidenceId?: string;
+  analysisId?: string;
+  failureCode?: 'invalid_url' | 'detail_failed' | 'comments_failed' | 'analysis_failed' | 'platform_limited' | 'unknown';
+  failureMessage?: string;
+  evidenceStrength: 'none' | 'weak' | 'medium' | 'strong';
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface IpBenchmarkEvidence {
+  id: string;
+  planId: string;
+  profileId: string;
+  benchmarkId?: string;
+  source: 'viral_analysis' | 'douyin_fetch' | 'manual';
+  sourceId: string;
+  awemeId?: string;
+  inputUrl?: string;
+  title: string;
+  authorName?: string;
+  summary: string;
+  hookPattern: string;
+  reusableFrame: string;
+  commentPainPoints: string[];
+  migrationSuggestion: string;
+  nonCopyableRisk: string;
+  evidenceRefs: string[];
+  confidence: 'high' | 'medium' | 'low';
+  createdAt: string;
+}
+
+export interface IpEvidencePolishSuggestion {
+  id: string;
+  planId: string;
+  profileId: string;
+  evidenceId: string;
+  targetType: 'opportunity' | 'topic' | 'script';
+  targetId?: string;
+  title: string;
+  rationale: string;
+  suggestedChange: string;
+  migrationSuggestion?: string;
+  nonCopyableRisk?: string;
+  evidenceRefs: string[];
+  status: 'draft' | 'applied' | 'dismissed';
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface IpMaterialSyncResult {
+  created: number;
+  existing: number;
+  total: number;
+}
+
+export interface IpSupplementImportResult {
+  imported: number;
+  skipped: number;
+  total: number;
+  supplementIds: string[];
+}
+
+export interface IpGenerationProgress {
+  stage: IpGenerationSection;
+  label: string;
+  status: IpGenerationStatus;
+  startedAt?: string;
+  completedAt?: string;
+  durationMs?: number;
+  failureCode?: IpGenerationFailureCode;
+  errorMessage?: string;
+  rawPreview?: string;
+}
+
+export interface IpRegenerationNote {
+  id: string;
+  stage: IpGenerationSection;
+  createdAt: string;
+  attempt: number;
+  summary: string;
+  preservedUserEdits?: number;
+  status: 'completed' | 'failed';
+  failureCode?: IpGenerationFailureCode;
+}
+
+export interface IpOperatorPlan {
+  id: string;
+  userId: string;
+  profileId: string;
+  status: IpPlanStatus;
+  generationProgress: IpGenerationProgress[];
+  qualityCheck?: IpPlanQualityCheck;
+  intakeSummary?: IpIntakeSummary;
+  diagnosis?: IpDiagnosis;
+  benchmarkAnalyses: BenchmarkAnalysis[];
+  opportunityMap: ContentOpportunity[];
+  topics: IpTopic[];
+  scripts: IpScript[];
+  sevenDayTestPlan: ContentTestDay[];
+  thirtyDayDirection?: ThirtyDayDirection;
+  missingInputs: MissingInput[];
+  supplementInputs?: IpSupplementInput[];
+  regenerationNotes?: IpRegenerationNote[];
+  bridgeRecords?: IpOperatorBridgeRecord[];
+  evidenceFetchJobs?: IpEvidenceFetchJob[];
+  benchmarkEvidence?: IpBenchmarkEvidence[];
+  evidenceSuggestions?: IpEvidencePolishSuggestion[];
+  qualityAudit?: IpPlanQualityAudit;
+  errorMessage?: string;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface GenerateIpPlanInput {
+  profile: IpOperatorProfile;
+  benchmarks: BenchmarkAccount[];
+  storageMode?: IpStorageMode;
+}
+
+export interface IpGenerationEvent {
+  stage: IpGenerationSection;
+  label: string;
+  status: 'running' | 'completed' | 'failed';
+  plan?: IpOperatorPlan;
+  durationMs?: number;
+  failureCode?: IpGenerationFailureCode;
+  rawPreview?: string;
+  errorMessage?: string;
+}
+
+export interface IpOperatorAuditEvent {
+  id: string;
+  action: string;
+  entityType: 'profile' | 'benchmark' | 'plan' | 'topic' | 'script' | 'storage' | 'generation';
+  entityId?: string;
+  userId: string;
+  createdAt: string;
+  summary: string;
+  before?: unknown;
+  after?: unknown;
+}

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

@@ -53,7 +53,6 @@
   color: #64748b;
   color: #64748b;
 }
 }
 
 
-.account-rate,
 .account-note {
 .account-note {
   border-radius: 8px;
   border-radius: 8px;
   padding: 14px 16px;
   padding: 14px 16px;
@@ -78,8 +77,7 @@
   gap: 18px;
   gap: 18px;
 }
 }
 
 
-.account-tabs,
-.account-login-methods {
+.account-tabs {
   display: grid;
   display: grid;
   grid-template-columns: 1fr 1fr;
   grid-template-columns: 1fr 1fr;
   padding: 4px;
   padding: 4px;
@@ -87,8 +85,7 @@
   background: #eef2ff;
   background: #eef2ff;
 }
 }
 
 
-.account-tabs button,
-.account-login-methods button {
+.account-tabs button {
   border: 0;
   border: 0;
   border-radius: 6px;
   border-radius: 6px;
   padding: 12px;
   padding: 12px;
@@ -98,8 +95,7 @@
   cursor: pointer;
   cursor: pointer;
 }
 }
 
 
-.account-tabs button.is-active,
-.account-login-methods button.is-active {
+.account-tabs button.is-active {
   background: #fff;
   background: #fff;
   color: #1d4ed8;
   color: #1d4ed8;
   box-shadow: 0 6px 18px rgba(37, 99, 235, .12);
   box-shadow: 0 6px 18px rgba(37, 99, 235, .12);
@@ -197,7 +193,6 @@
   color: #94a3b8;
   color: #94a3b8;
 }
 }
 
 
-.theme-night .account-rate,
 .theme-night .account-tabs {
 .theme-night .account-tabs {
   background: rgba(30, 41, 59, .8);
   background: rgba(30, 41, 59, .8);
   border-color: rgba(148, 163, 184, .18);
   border-color: rgba(148, 163, 184, .18);

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

@@ -3,45 +3,23 @@
     <aside class="account-side">
     <aside class="account-side">
       <div class="account-mark">AI</div>
       <div class="account-mark">AI</div>
       <h1>登录账号</h1>
       <h1>登录账号</h1>
-      <p>登录后可查看个人内容,并使用生成、合成、语音和数据功能。试运营阶段暂不开放在线付费,额度由管理员统一管理。</p>
-      <div class="account-rate">
-        <span>试运营</span>
-        <strong>账号由管理员发放</strong>
-      </div>
+      <p>登录后可查看个人内容,并使用生成、合成、语音、数据和账户充值功能。</p>
     </aside>
     </aside>
 
 
     <form class="account-card" (ngSubmit)="submit()">
     <form class="account-card" (ngSubmit)="submit()">
-      <div class="account-login-methods">
-        <button type="button" [class.is-active]="loginMethod === 'mobile'" (click)="switchLoginMethod('mobile')">手机号验证码</button>
-        <button type="button" [class.is-active]="loginMethod === 'password'" (click)="switchLoginMethod('password')">账号密码</button>
-      </div>
-
-      <ng-container *ngIf="loginMethod === 'mobile'; else passwordLoginTpl">
+      <label class="account-field">
+        <span>手机号</span>
+        <input name="mobile" [(ngModel)]="mobile" (ngModelChange)="normalizeMobile()" placeholder="请输入手机号" autocomplete="tel" inputmode="numeric" maxlength="11" required>
+      </label>
+      <div class="account-code-row">
         <label class="account-field">
         <label class="account-field">
-          <span>手机号</span>
-          <input name="mobile" [(ngModel)]="mobile" (ngModelChange)="normalizeMobile()" placeholder="请输入手机号" autocomplete="tel" inputmode="numeric" maxlength="11" required>
+          <span>验证码</span>
+          <input name="code" [(ngModel)]="code" placeholder="请输入验证码" autocomplete="one-time-code" inputmode="numeric" maxlength="6" required>
         </label>
         </label>
-        <div class="account-code-row">
-          <label class="account-field">
-            <span>验证码</span>
-            <input name="code" [(ngModel)]="code" placeholder="请输入验证码" autocomplete="one-time-code" inputmode="numeric" maxlength="6" required>
-          </label>
-          <button class="account-code-btn" type="button" [disabled]="!canSendCode()" (click)="sendCode()">
-            {{ countdown > 0 ? countdown + 's' : '发送验证码' }}
-          </button>
-        </div>
-      </ng-container>
-
-      <ng-template #passwordLoginTpl>
-        <label class="account-field">
-          <span>账号</span>
-          <input name="identifier" [(ngModel)]="identifier" placeholder="请输入 _User 用户名" autocomplete="username" required>
-        </label>
-        <label class="account-field">
-          <span>密码</span>
-          <input name="password" [(ngModel)]="password" type="password" placeholder="请输入密码" autocomplete="current-password" required>
-        </label>
-      </ng-template>
+        <button class="account-code-btn" type="button" [disabled]="!canSendCode()" (click)="sendCode()">
+          {{ countdown > 0 ? countdown + 's' : '发送验证码' }}
+        </button>
+      </div>
 
 
       <div class="account-error" *ngIf="error">{{ error }}</div>
       <div class="account-error" *ngIf="error">{{ error }}</div>
 
 

+ 2 - 16
src/app/pages/account/auth-page.component.ts

@@ -16,12 +16,10 @@ export class AuthPageComponent implements OnChanges {
   @Output() modeChange = new EventEmitter<'login' | 'register'>();
   @Output() modeChange = new EventEmitter<'login' | 'register'>();
   @Output() signedIn = new EventEmitter<void>();
   @Output() signedIn = new EventEmitter<void>();
 
 
-  loginMethod: 'mobile' | 'password' = 'mobile';
   mobile = '';
   mobile = '';
   code = '';
   code = '';
   countdown = 0;
   countdown = 0;
   private countdownTimer: ReturnType<typeof setInterval> | null = null;
   private countdownTimer: ReturnType<typeof setInterval> | null = null;
-  identifier = '';
   password = '';
   password = '';
   username = '';
   username = '';
   email = '';
   email = '';
@@ -55,13 +53,9 @@ export class AuthPageComponent implements OnChanges {
     this.loading = true;
     this.loading = true;
     try {
     try {
       if (this.mode === 'login') {
       if (this.mode === 'login') {
-        if (this.loginMethod === 'password') {
-          await this.auth.loginWithParsePassword(this.identifier, this.password);
-        } else {
-          await this.auth.loginWithMobileCode(this.mobile, this.code);
-        }
+        await this.auth.loginWithMobileCode(this.mobile, this.code);
       } else {
       } else {
-        if (!this.allowRegister) throw new Error('试运营阶段暂未开放自助注册,请联系管理员获取账号');
+        if (!this.allowRegister) throw new Error('请使用手机号验证码登录或注册');
         if (this.password !== this.confirmPassword) throw new Error('两次输入的密码不一致');
         if (this.password !== this.confirmPassword) throw new Error('两次输入的密码不一致');
         await this.auth.register({
         await this.auth.register({
           username: this.username,
           username: this.username,
@@ -89,17 +83,9 @@ export class AuthPageComponent implements OnChanges {
 
 
   canSubmit(): boolean {
   canSubmit(): boolean {
     if (this.mode !== 'login') return true;
     if (this.mode !== 'login') return true;
-    if (this.loginMethod === 'password') {
-      return !!this.identifier.trim() && !!this.password && !this.loading;
-    }
     return /^1[3-9]\d{9}$/.test(this.mobile) && this.code.trim().length >= 4 && !this.loading;
     return /^1[3-9]\d{9}$/.test(this.mobile) && this.code.trim().length >= 4 && !this.loading;
   }
   }
 
 
-  switchLoginMethod(method: 'mobile' | 'password'): void {
-    this.loginMethod = method;
-    this.error = '';
-  }
-
   async sendCode(): Promise<void> {
   async sendCode(): Promise<void> {
     if (!this.canSendCode()) return;
     if (!this.canSendCode()) return;
     this.error = '';
     this.error = '';

+ 80 - 111
src/app/pages/account/user-center.component.css

@@ -46,11 +46,7 @@
   grid-template-columns: minmax(280px, .82fr) minmax(420px, 1.18fr);
   grid-template-columns: minmax(280px, .82fr) minmax(420px, 1.18fr);
   gap: 16px;
   gap: 16px;
   margin-bottom: 16px;
   margin-bottom: 16px;
-  align-items: stretch;
-}
-
-.uc-grid-disabled-pay {
-  grid-template-columns: minmax(280px, .82fr) minmax(360px, 1.18fr);
+  align-items: start;
 }
 }
 
 
 .uc-card {
 .uc-card {
@@ -65,6 +61,10 @@
   min-height: 190px;
   min-height: 190px;
 }
 }
 
 
+.uc-grid .uc-recharge {
+  min-height: 320px;
+}
+
 .uc-card h2 {
 .uc-card h2 {
   margin: 0 0 14px;
   margin: 0 0 14px;
   font-size: 17px;
   font-size: 17px;
@@ -92,30 +92,76 @@
 .uc-balance {
 .uc-balance {
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
-  justify-content: center;
+  justify-content: flex-start;
 }
 }
 
 
-.uc-disabled-pay {
-  display: flex;
-  flex-direction: column;
-  justify-content: center;
+.uc-recharge,
+.uc-password {
+  display: grid;
   gap: 12px;
   gap: 12px;
 }
 }
 
 
-.uc-disabled-pay p {
+.uc-recharge p {
   margin: 0;
   margin: 0;
   color: #64748b;
   color: #64748b;
   line-height: 1.7;
   line-height: 1.7;
 }
 }
 
 
-.uc-disabled-pay button {
-  width: fit-content;
+.uc-recharge a {
+  color: #1d4ed8;
+  font-weight: 800;
+  text-decoration: none;
 }
 }
 
 
-.uc-recharge,
-.uc-password {
+.uc-recharge-hint,
+.uc-recharge-error {
+  border-radius: 8px;
+  padding: 10px 12px;
+  font-size: 13px;
+  font-weight: 700;
+}
+
+.uc-recharge-hint {
+  background: #eff6ff;
+  color: #1d4ed8;
+}
+
+.uc-recharge-error {
+  background: #fef2f2;
+  color: #b91c1c;
+}
+
+.uc-tier-grid {
   display: grid;
   display: grid;
-  gap: 12px;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 10px;
+}
+
+.uc-tier {
+  height: auto !important;
+  min-height: 76px;
+  display: grid;
+  gap: 4px;
+  justify-items: start;
+  align-content: center;
+  text-align: left;
+  border-color: #dbeafe !important;
+  background: #f8fbff !important;
+}
+
+.uc-tier strong {
+  color: #0f172a;
+  font-size: 18px;
+}
+
+.uc-tier span {
+  color: #64748b;
+  font-size: 13px;
+}
+
+.uc-tier.is-active {
+  border-color: #2563eb !important;
+  background: #eff6ff !important;
 }
 }
 
 
 .uc-recharge label {
 .uc-recharge label {
@@ -192,105 +238,34 @@
   font-weight: 800;
   font-weight: 800;
 }
 }
 
 
-.uc-section-title {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 12px;
-  margin-bottom: 12px;
-}
-
-.uc-ledger table {
-  width: 100%;
-  border-collapse: collapse;
-}
-
-.uc-ledger th,
-.uc-ledger td {
-  padding: 12px 10px;
-  border-bottom: 1px solid #e2e8f0;
-  text-align: left;
-  color: #334155;
-}
-
-.uc-ledger th {
-  color: #64748b;
-  font-size: 12px;
-}
-
-.uc-ledger .is-plus {
-  color: #059669;
-  font-weight: 800;
-}
-
-.uc-task-summary {
-  margin-bottom: 16px;
-}
-
-.uc-summary-grid {
+.uc-payment-info {
   display: grid;
   display: grid;
-  grid-template-columns: repeat(4, minmax(0, 1fr));
   gap: 10px;
   gap: 10px;
-  margin-bottom: 14px;
+  min-width: 0;
 }
 }
 
 
-.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;
+.uc-payment-info a {
   color: #1d4ed8;
   color: #1d4ed8;
-  font-size: 22px;
+  font-weight: 800;
+  text-decoration: none;
 }
 }
 
 
-.uc-task-list {
-  display: grid;
-  gap: 8px;
+.uc-payment-info code {
+  display: block;
+  max-width: 100%;
+  overflow-wrap: anywhere;
+  border-radius: 8px;
+  background: #f8fafc;
+  color: #334155;
+  padding: 10px;
 }
 }
 
 
-.uc-task-row {
-  display: grid;
-  grid-template-columns: minmax(0, 1fr) auto;
-  gap: 8px 14px;
+.uc-section-title {
+  display: flex;
   align-items: center;
   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;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 12px;
 }
 }
 
 
 .uc-message,
 .uc-message,
@@ -322,8 +297,6 @@
   border-color: rgba(148, 163, 184, .22);
   border-color: rgba(148, 163, 184, .22);
 }
 }
 
 
-.theme-night .uc-ledger th,
-.theme-night .uc-ledger td,
 .theme-night .uc-header p,
 .theme-night .uc-header p,
 .theme-night .uc-balance p {
 .theme-night .uc-balance p {
   color: #94a3b8;
   color: #94a3b8;
@@ -336,9 +309,5 @@
 @media (max-width: 720px) {
 @media (max-width: 720px) {
   .account-dashboard { padding: 18px; }
   .account-dashboard { padding: 18px; }
   .uc-header { align-items: flex-start; flex-direction: column; }
   .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-payment-content { grid-template-columns: 1fr; }
-  .uc-task-row { grid-template-columns: 1fr; }
-  .uc-ledger { overflow-x: auto; }
-  .uc-ledger table { min-width: 680px; }
 }
 }

+ 32 - 64
src/app/pages/account/user-center.component.html

@@ -2,91 +2,59 @@
   <header class="uc-header">
   <header class="uc-header">
     <div>
     <div>
       <h1>用户中心</h1>
       <h1>用户中心</h1>
-      <p>{{ auth.currentUser?.displayName || auth.currentUser?.username }} · {{ auth.currentUser?.role === 'admin' ? '管理员' : '普通用户' }}</p>
+      <p>{{ auth.currentUser?.displayName || auth.currentUser?.username }}</p>
     </div>
     </div>
     <div class="uc-actions">
     <div class="uc-actions">
-      <button *ngIf="auth.isAdmin" type="button" (click)="navigate.emit('admin-users')">账号明细</button>
       <button type="button" (click)="logout()">退出登录</button>
       <button type="button" (click)="logout()">退出登录</button>
     </div>
     </div>
   </header>
   </header>
 
 
-  <div class="uc-grid uc-grid-disabled-pay">
+  <div class="uc-grid">
     <article class="uc-card uc-balance">
     <article class="uc-card uc-balance">
       <span>当前积分</span>
       <span>当前积分</span>
       <strong>{{ balance?.balance ?? 0 }}</strong>
       <strong>{{ balance?.balance ?? 0 }}</strong>
-      <p>赠送 {{ balance?.gifted ?? 0 }},累计消费 {{ balance?.totalConsumed ?? 0 }}</p>
+      <p>累计使用 {{ balance?.totalConsumed ?? 0 }},1 积分 = 0.1 元</p>
+      <button type="button" (click)="refreshBalance()" [disabled]="loading">刷新余额</button>
     </article>
     </article>
 
 
-    <article class="uc-card uc-disabled-pay">
-      <h2>额度说明</h2>
-      <p>试运营阶段暂未开放在线充值。需要调整额度时,由管理员统一处理。</p>
-      <button *ngIf="auth.isAdmin" type="button" (click)="navigate.emit('admin-users')">进入账号明细</button>
+    <article class="uc-card uc-recharge">
+      <h2>账户充值</h2>
+      <p>支付成功后将自动写入 APIGOrder,并增加当前 APIGAuth 余额。</p>
+      <div class="uc-recharge-hint" *ngIf="rechargeLoading">正在加载充值套餐...</div>
+      <div class="uc-recharge-error" *ngIf="rechargeError">{{ rechargeError }}</div>
+      <div class="uc-tier-grid" *ngIf="rechargeContext">
+        <button type="button"
+                *ngFor="let tier of rechargeContext.apig.priceStep"
+                class="uc-tier"
+                [class.is-active]="selectedTier === tier"
+                (click)="selectTier(tier)">
+          <strong>{{ tier.price | number:'1.0-2' }} 元</strong>
+          <span>{{ tier.count }} 积分</span>
+        </button>
+      </div>
+      <button type="button" (click)="startRecharge()" [disabled]="recharging || rechargeLoading">
+        {{ recharging ? '生成中...' : (selectedTier ? '微信扫码支付' : '重新加载套餐') }}
+      </button>
     </article>
     </article>
   </div>
   </div>
 
 
   <div class="uc-message" *ngIf="message">{{ message }}</div>
   <div class="uc-message" *ngIf="message">{{ message }}</div>
   <div class="uc-error" *ngIf="error">{{ error }}</div>
   <div class="uc-error" *ngIf="error">{{ error }}</div>
 
 
-  <article class="uc-card uc-task-summary">
+  <article class="uc-card uc-payment-panel" *ngIf="paymentSession">
     <div class="uc-section-title">
     <div class="uc-section-title">
-      <h2>任务积分概览</h2>
-      <button type="button" (click)="navigate.emit('tasks')">查看任务</button>
+      <h2>微信扫码支付</h2>
+      <button type="button" (click)="refreshBalance()" [disabled]="loading">我已支付,刷新余额</button>
     </div>
     </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 class="uc-payment-content">
+      <img *ngIf="qrDataUrl" [src]="qrDataUrl" alt="微信支付二维码">
+      <div class="uc-payment-info">
+        <strong>{{ paymentSession.tier.price | number:'1.0-2' }} 元</strong>
+        <p>到账 {{ paymentSession.tier.count }} 积分</p>
+        <span>{{ paymentStatus || '等待支付' }}</span>
+        <code>订单号 {{ paymentSession.tradeNo }}</code>
       </div>
       </div>
     </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>
 
 
-  <article class="uc-card uc-ledger">
-    <div class="uc-section-title">
-      <h2>积分流水</h2>
-      <button type="button" (click)="reload()" [disabled]="loading">刷新</button>
-    </div>
-    <table>
-      <thead>
-      <tr>
-        <th>时间</th>
-        <th>类型</th>
-        <th>说明</th>
-        <th>积分</th>
-        <th>余额</th>
-      </tr>
-      </thead>
-      <tbody>
-      <tr *ngFor="let item of ledger">
-        <td>{{ item.createdAt | date:'yyyy-MM-dd HH:mm' }}</td>
-        <td>{{ item.type }}</td>
-        <td>{{ item.title }}</td>
-        <td [class.is-plus]="item.amount > 0">{{ item.amount > 0 ? '+' : '' }}{{ item.amount }}</td>
-        <td>{{ item.balanceAfter }}</td>
-      </tr>
-      <tr *ngIf="!ledger.length">
-        <td colspan="5">暂无积分流水</td>
-      </tr>
-      </tbody>
-    </table>
-  </article>
 </section>
 </section>

+ 150 - 40
src/app/pages/account/user-center.component.ts

@@ -1,14 +1,17 @@
 import { CommonModule } from '@angular/common';
 import { CommonModule } from '@angular/common';
-import { Component, EventEmitter, OnInit, Output } from '@angular/core';
-import { FormsModule } from '@angular/forms';
-import { AuthCreditService, CreditBalance, CreditLedgerItem } from '../../services/auth-credit.service';
-import { GenerationTaskService } from '../../services/generation-task.service';
-import { GenerationTask } from '../../models/generation-task.model';
+import { ChangeDetectorRef, Component, EventEmitter, OnInit, Output } from '@angular/core';
+import {
+  ApigPaymentSession,
+  ApigPriceStep,
+  ApigRechargeContext,
+  AuthCreditService,
+  CreditBalance,
+} from '../../services/auth-credit.service';
 
 
 @Component({
 @Component({
   selector: 'app-user-center',
   selector: 'app-user-center',
   standalone: true,
   standalone: true,
-  imports: [CommonModule, FormsModule],
+  imports: [CommonModule],
   templateUrl: './user-center.component.html',
   templateUrl: './user-center.component.html',
   styleUrls: ['./user-center.component.css'],
   styleUrls: ['./user-center.component.css'],
 })
 })
@@ -16,75 +19,182 @@ export class UserCenterComponent implements OnInit {
   @Output() navigate = new EventEmitter<string>();
   @Output() navigate = new EventEmitter<string>();
 
 
   balance: CreditBalance | null = null;
   balance: CreditBalance | null = null;
-  ledger: CreditLedgerItem[] = [];
-  tasks: GenerationTask[] = [];
-  passwordForm = { oldPassword: '', newPassword: '', confirmPassword: '' };
+  rechargeContext: ApigRechargeContext | null = null;
+  selectedTier: ApigPriceStep | null = null;
+  paymentSession: ApigPaymentSession | null = null;
+  qrDataUrl = '';
+  paymentStatus = '';
   loading = false;
   loading = false;
+  recharging = false;
+  polling = false;
+  rechargeLoading = false;
   message = '';
   message = '';
   error = '';
   error = '';
+  rechargeError = '';
+  private pollTimer: ReturnType<typeof setInterval> | null = null;
+  private checkingPayment = false;
+  private destroyed = false;
 
 
-  constructor(public auth: AuthCreditService, private generationTasks: GenerationTaskService) {}
+  constructor(public auth: AuthCreditService, private cdr: ChangeDetectorRef) {}
 
 
   ngOnInit(): void {
   ngOnInit(): void {
+    this.destroyed = false;
     void this.reload();
     void this.reload();
   }
   }
 
 
+  ngOnDestroy(): void {
+    this.destroyed = true;
+    this.stopPolling();
+  }
+
   async reload(): Promise<void> {
   async reload(): Promise<void> {
     if (!this.auth.isLoggedIn) return;
     if (!this.auth.isLoggedIn) return;
     this.loading = true;
     this.loading = true;
+    this.rechargeLoading = true;
     this.error = '';
     this.error = '';
+    this.rechargeError = '';
+    this.refreshView();
+
+    void this.loadBalance();
+    void this.loadRechargeContext();
+  }
+
+  private async loadBalance(): Promise<void> {
     try {
     try {
       this.balance = await this.auth.getBalance();
       this.balance = await this.auth.getBalance();
-      this.ledger = await this.auth.getLedger(80);
-      this.tasks = this.generationTasks.tasks.slice(0, 20);
     } catch (e: any) {
     } catch (e: any) {
       this.error = e?.message || '加载用户信息失败';
       this.error = e?.message || '加载用户信息失败';
     } finally {
     } finally {
       this.loading = false;
       this.loading = false;
+      this.refreshView();
+    }
+  }
+
+  private async loadRechargeContext(): Promise<void> {
+    this.rechargeLoading = true;
+    this.rechargeError = '';
+    this.refreshView();
+    try {
+      this.rechargeContext = await this.auth.getVideoWorkflowRechargeContext();
+      this.selectedTier = this.rechargeContext.apig.priceStep[0] || null;
+    } catch (e: any) {
+      this.rechargeContext = null;
+      this.selectedTier = null;
+      this.rechargeError = e?.message || '加载充值套餐失败';
+    } finally {
+      this.rechargeLoading = false;
+      this.refreshView();
     }
     }
   }
   }
 
 
-  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 });
+  selectTier(tier: ApigPriceStep): void {
+    this.selectedTier = tier;
   }
   }
 
 
-  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 startRecharge(): Promise<void> {
+    if (!this.rechargeContext || !this.selectedTier) {
+      await this.loadRechargeContext();
+      if (!this.rechargeContext || !this.selectedTier) return;
+    }
+    this.message = '';
+    this.error = '';
+    this.paymentStatus = '';
+    this.paymentSession = null;
+    this.qrDataUrl = '';
+    this.stopPolling();
+    this.recharging = true;
+    this.refreshView();
+    try {
+      this.paymentSession = await this.auth.startVideoWorkflowPayment(this.rechargeContext, this.selectedTier);
+      this.refreshView();
+      const QRCode = await import('qrcode');
+      this.qrDataUrl = await QRCode.toDataURL(this.paymentSession.codeUrl, {
+        width: 240,
+        margin: 1,
+        color: { dark: '#0f172a', light: '#ffffff' },
+      });
+      this.paymentStatus = '等待微信扫码支付';
+      this.startPolling();
+      this.refreshView();
+    } catch (e: any) {
+      this.error = e?.message || '发起充值失败';
+      this.refreshView();
+    } finally {
+      this.recharging = false;
+      this.refreshView();
+    }
   }
   }
 
 
-  async changePassword(): Promise<void> {
+  async refreshBalance(): Promise<void> {
     this.message = '';
     this.message = '';
     this.error = '';
     this.error = '';
+    this.loading = true;
+    this.refreshView();
     try {
     try {
-      if (this.passwordForm.newPassword !== this.passwordForm.confirmPassword) {
-        throw new Error('两次输入的新密码不一致');
-      }
-      await this.auth.changePassword(this.passwordForm.oldPassword, this.passwordForm.newPassword);
-      this.passwordForm = { oldPassword: '', newPassword: '', confirmPassword: '' };
-      this.message = '密码已更新';
+      this.balance = await this.auth.getBalance();
+      this.message = '余额已刷新';
     } catch (e: any) {
     } catch (e: any) {
-      this.error = e?.message || '修改密码失败';
+      this.error = e?.message || '刷新余额失败';
+    } finally {
+      this.loading = false;
+      this.refreshView();
     }
     }
   }
   }
 
 
   logout(): void {
   logout(): void {
+    this.stopPolling();
     this.auth.logout();
     this.auth.logout();
     this.navigate.emit('home');
     this.navigate.emit('home');
   }
   }
+
+  private startPolling(): void {
+    if (!this.paymentSession) return;
+    this.polling = true;
+    this.stopPolling(false);
+    this.pollTimer = setInterval(() => {
+      void this.checkPayment();
+    }, 3000);
+  }
+
+  private async checkPayment(): Promise<void> {
+    if (!this.paymentSession || this.checkingPayment) return;
+    this.checkingPayment = true;
+    try {
+      const paid = await this.auth.checkApigPayment(this.paymentSession);
+      if (!paid) return;
+      this.paymentStatus = '支付成功,正在等待自动到账';
+      this.stopPolling();
+      this.refreshView();
+      this.rechargeContext = await this.auth.completeApigRecharge(this.paymentSession);
+      this.balance = await this.auth.getBalance();
+      this.paymentStatus = '充值成功';
+      this.message = '充值成功,余额已刷新';
+      this.paymentSession = null;
+      this.qrDataUrl = '';
+      this.selectedTier = this.rechargeContext.apig.priceStep[0] || null;
+      this.refreshView();
+    } catch (e: any) {
+      this.error = e?.message || '查询支付状态失败';
+      this.refreshView();
+    } finally {
+      this.checkingPayment = false;
+      this.refreshView();
+    }
+  }
+
+  private stopPolling(reset = true): void {
+    if (this.pollTimer) {
+      clearInterval(this.pollTimer);
+      this.pollTimer = null;
+    }
+    if (reset) this.polling = false;
+  }
+
+  private refreshView(): void {
+    if (this.destroyed) return;
+    this.cdr.detectChanges();
+    setTimeout(() => {
+      if (!this.destroyed) this.cdr.detectChanges();
+    }, 0);
+  }
 }
 }

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

@@ -112,6 +112,14 @@
         <span>结束时间</span>
         <span>结束时间</span>
         <strong>{{ formatTime(job.finishedAt) }}</strong>
         <strong>{{ formatTime(job.finishedAt) }}</strong>
       </div>
       </div>
+      <div class="batch-diagnostic" *ngIf="job.nextRunAt">
+        <span>下一任务预计</span>
+        <strong>{{ formatTime(job.nextRunAt) }}</strong>
+      </div>
+      <div class="batch-diagnostic batch-diagnostic--wide" *ngIf="job.pauseReason">
+        <span>暂停原因</span>
+        <strong>{{ job.pauseReason }}</strong>
+      </div>
       <div class="batch-diagnostic batch-diagnostic--wide" *ngIf="failureSummaryEntries(job).length">
       <div class="batch-diagnostic batch-diagnostic--wide" *ngIf="failureSummaryEntries(job).length">
         <span>失败摘要</span>
         <span>失败摘要</span>
         <strong>
         <strong>

+ 1065 - 0
src/app/pages/ip-operator/ip-operator.component.css

@@ -0,0 +1,1065 @@
+.ip-operator-page {
+  color: var(--text-primary);
+}
+
+.ip-operator-header {
+  align-items: flex-start;
+}
+
+.ip-header-actions,
+.ip-actions {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+  flex-wrap: wrap;
+}
+
+.ip-mode-alert {
+  display: flex;
+  gap: 10px;
+  align-items: center;
+  flex-wrap: wrap;
+  margin: 12px 0;
+  padding: 12px;
+  border: 1px solid #f4c36a;
+  border-radius: 8px;
+  background: #fff8e6;
+  color: #5f4306;
+}
+
+.ip-shell {
+  display: grid;
+  grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
+  gap: 16px;
+}
+
+.ip-side,
+.ip-card,
+.ip-result > .ip-card,
+.ip-quality {
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-primary);
+  padding: 16px;
+}
+
+.ip-side {
+  align-self: start;
+  position: sticky;
+  top: 12px;
+}
+
+.ip-profile-item {
+  display: block;
+  width: 100%;
+  margin: 8px 0;
+  padding: 10px;
+  text-align: left;
+  border: 1px solid var(--border-color);
+  border-radius: 6px;
+  background: var(--bg-secondary);
+  color: inherit;
+  cursor: pointer;
+}
+
+.ip-profile-item span,
+.ip-audit span,
+.ip-result-head p,
+.ip-progress-item small {
+  display: block;
+  color: var(--text-secondary);
+  font-size: 12px;
+}
+
+.ip-audit {
+  margin-top: 18px;
+}
+
+.ip-audit article {
+  padding: 8px 0;
+  border-top: 1px solid var(--border-color);
+}
+
+.ip-main {
+  min-width: 0;
+}
+
+.ip-steps {
+  display: grid;
+  grid-template-columns: repeat(5, minmax(0, 1fr));
+  gap: 8px;
+  padding: 0;
+  margin: 0 0 12px;
+  list-style: none;
+}
+
+.ip-steps li {
+  padding: 10px;
+  border-radius: 6px;
+  background: var(--bg-secondary);
+  color: var(--text-secondary);
+  text-align: center;
+}
+
+.ip-steps li.is-active {
+  background: #111827;
+  color: #fff;
+}
+
+.ip-card {
+  margin-bottom: 14px;
+}
+
+.ip-card h2,
+.ip-card h3,
+.ip-card h4 {
+  margin-top: 0;
+}
+
+.ip-card label {
+  display: block;
+  margin-bottom: 12px;
+  font-weight: 600;
+}
+
+.ip-card input,
+.ip-card textarea,
+.ip-card select,
+.ip-topic-row input,
+.ip-topic-row textarea {
+  width: 100%;
+  margin-top: 6px;
+  padding: 9px 10px;
+  border: 1px solid var(--border-color);
+  border-radius: 6px;
+  background: var(--bg-primary);
+  color: var(--text-primary);
+}
+
+.ip-form-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 12px;
+}
+
+.ip-benchmark {
+  margin-bottom: 14px;
+  padding: 12px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+}
+
+.ip-benchmark-head,
+.ip-result-head {
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+  align-items: flex-start;
+}
+
+.ip-result-hero {
+  margin-bottom: 12px;
+  padding: 18px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
+}
+
+.ip-result-hero h2 {
+  max-width: 980px;
+  margin: 4px 0 6px;
+  font-size: 24px;
+  line-height: 1.35;
+}
+
+.ip-kicker {
+  display: inline-block;
+  color: #2563eb;
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.ip-regeneration-meta {
+  display: block;
+  margin-top: 4px;
+  color: var(--text-secondary);
+  font-size: 12px;
+  font-weight: 500;
+}
+
+.ip-summary-grid {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 10px;
+  margin-bottom: 12px;
+}
+
+.ip-summary-card {
+  padding: 14px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-primary);
+}
+
+.ip-summary-card span,
+.ip-summary-card small {
+  display: block;
+  color: var(--text-secondary);
+  font-size: 12px;
+}
+
+.ip-summary-card strong {
+  display: block;
+  margin: 4px 0;
+  font-size: 20px;
+}
+
+.ip-quality-audit {
+  display: grid;
+  gap: 12px;
+  margin-bottom: 12px;
+  padding: 14px;
+  border: 1px solid #c7d2fe;
+  border-radius: 8px;
+  background: #f8fbff;
+}
+
+.ip-quality-audit header {
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+  align-items: flex-start;
+}
+
+.ip-quality-audit header strong {
+  display: block;
+  margin-top: 4px;
+  font-size: 22px;
+}
+
+.ip-quality-audit header small,
+.ip-quality-dimensions small,
+.ip-topic-quality small,
+.ip-script-quality small {
+  display: block;
+  color: var(--text-secondary);
+  line-height: 1.6;
+}
+
+.ip-gate-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  justify-content: flex-end;
+}
+
+.ip-gate-tags span {
+  padding: 5px 9px;
+  border-radius: 999px;
+  background: #fee2e2;
+  color: #991b1b;
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.ip-gate-tags span.is-pass {
+  background: #dcfce7;
+  color: #166534;
+}
+
+.ip-quality-dimensions {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 10px;
+}
+
+.ip-quality-dimensions section {
+  padding: 12px;
+  border: 1px solid rgba(148, 163, 184, 0.35);
+  border-radius: 8px;
+  background: #fff;
+}
+
+.ip-quality-dimensions section.is-blocked {
+  border-color: #fed7aa;
+  background: #fff7ed;
+}
+
+.ip-quality-dimensions strong {
+  display: flex;
+  justify-content: space-between;
+  gap: 8px;
+}
+
+.ip-quality-dimensions p {
+  margin: 8px 0 4px;
+  line-height: 1.6;
+}
+
+.ip-stage-note {
+  display: grid;
+  grid-template-columns: 150px minmax(0, 1fr);
+  gap: 12px;
+  margin-bottom: 12px;
+  padding: 12px 14px;
+  border: 1px solid #bfdbfe;
+  border-radius: 8px;
+  background: #eff6ff;
+  color: #1e3a8a;
+}
+
+.ip-stage-note span {
+  line-height: 1.7;
+}
+
+.ip-supplement-panel h4 {
+  margin: 0 0 10px;
+}
+
+.ip-supplement-grid {
+  display: grid;
+  grid-template-columns: minmax(280px, 0.9fr) minmax(0, 1.1fr);
+  gap: 14px;
+}
+
+.ip-supplement-list {
+  display: grid;
+  gap: 10px;
+}
+
+.ip-supplement-list article {
+  padding: 12px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+}
+
+.ip-evidence-form {
+  display: grid;
+  grid-template-columns: minmax(180px, 0.7fr) minmax(280px, 1.3fr) auto;
+  gap: 10px;
+  align-items: end;
+}
+
+.ip-evidence-form--saved {
+  grid-template-columns: minmax(280px, 1fr) auto auto;
+  margin-top: 10px;
+}
+
+.ip-evidence-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 12px;
+  margin-top: 12px;
+}
+
+.ip-evidence-grid section {
+  padding: 12px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+}
+
+.ip-evidence-grid h4 {
+  margin: 0 0 10px;
+}
+
+.ip-evidence-grid article {
+  display: grid;
+  gap: 4px;
+  padding: 10px;
+  border: 1px solid rgba(148, 163, 184, 0.35);
+  border-radius: 8px;
+  background: var(--bg-primary);
+}
+
+.ip-evidence-grid article + article {
+  margin-top: 8px;
+}
+
+.ip-evidence-grid article.is-failed {
+  border-color: #fecaca;
+  background: #fff1f2;
+}
+
+.ip-evidence-grid span,
+.ip-evidence-grid small {
+  color: var(--text-secondary);
+  font-size: 12px;
+}
+
+.ip-evidence-suggestions {
+  display: grid;
+  gap: 8px;
+  margin-top: 12px;
+  padding: 12px;
+  border: 1px solid #bfdbfe;
+  border-radius: 8px;
+  background: #f8fbff;
+}
+
+.ip-evidence-suggestions h4 {
+  margin: 0;
+}
+
+.ip-evidence-suggestions article {
+  display: grid;
+  gap: 6px;
+  padding: 10px;
+  border: 1px solid rgba(148, 163, 184, 0.35);
+  border-radius: 8px;
+  background: var(--bg-primary);
+}
+
+.ip-evidence-suggestions article.is-applied {
+  border-color: #bbf7d0;
+  background: #f0fdf4;
+}
+
+.ip-evidence-suggestions article.is-dismissed {
+  opacity: 0.72;
+}
+
+.ip-evidence-suggestions article > div:first-child {
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.ip-evidence-suggestions span,
+.ip-evidence-suggestions small {
+  color: var(--text-secondary);
+  font-size: 12px;
+}
+
+.ip-export {
+  position: relative;
+}
+
+.ip-export-menu {
+  position: absolute;
+  right: 0;
+  z-index: 10;
+  display: grid;
+  min-width: 150px;
+  margin-top: 6px;
+  padding: 6px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-primary);
+  box-shadow: 0 12px 32px rgba(15, 23, 42, 0.14);
+}
+
+.ip-export-menu button {
+  padding: 8px 10px;
+  border: 0;
+  border-radius: 6px;
+  background: transparent;
+  color: var(--text-primary);
+  text-align: left;
+  cursor: pointer;
+}
+
+.ip-export-menu button:hover {
+  background: var(--bg-secondary);
+}
+
+.ip-debug-panel {
+  margin-bottom: 12px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-primary);
+}
+
+.ip-debug-panel summary {
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+  padding: 12px 14px;
+  cursor: pointer;
+}
+
+.ip-debug-panel summary span,
+.ip-debug-grid small {
+  color: var(--text-secondary);
+  font-size: 12px;
+}
+
+.ip-debug-actions {
+  padding: 0 14px 10px;
+}
+
+.ip-debug-grid {
+  display: grid;
+  gap: 8px;
+  padding: 0 14px 14px;
+}
+
+.ip-debug-grid section {
+  padding: 10px;
+  border-radius: 6px;
+  background: var(--bg-secondary);
+}
+
+.ip-debug-grid section.is-failed {
+  border: 1px solid #fecaca;
+  background: #fff1f2;
+}
+
+.ip-debug-grid section.is-skipped {
+  border: 1px solid #fde68a;
+  background: #fffbeb;
+}
+
+.ip-bridge-panel {
+  border-color: #bfdbfe;
+  background: #f8fbff;
+}
+
+.ip-bridge-grid {
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.ip-debug-grid section > * {
+  display: block;
+  margin-bottom: 4px;
+}
+
+.ip-debug-grid pre {
+  max-height: 160px;
+  overflow: auto;
+  padding: 8px;
+  border-radius: 6px;
+  background: #0f172a;
+  color: #dbeafe;
+  white-space: pre-wrap;
+}
+
+.ip-result-tabs {
+  display: flex;
+  gap: 8px;
+  margin-bottom: 12px;
+  padding: 6px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-primary);
+}
+
+.ip-result-tabs button {
+  flex: 1;
+  padding: 10px 12px;
+  border: 0;
+  border-radius: 6px;
+  background: transparent;
+  color: var(--text-secondary);
+  font-weight: 700;
+  cursor: pointer;
+}
+
+.ip-result-tabs button.is-active {
+  background: #111827;
+  color: #fff;
+}
+
+.ip-generate-scope,
+.ip-chip-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.ip-generate-scope span,
+.ip-chip-row span {
+  padding: 6px 10px;
+  border-radius: 999px;
+  background: var(--bg-secondary);
+  color: var(--text-secondary);
+  font-size: 13px;
+}
+
+.ip-progress {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 8px;
+  margin-top: 14px;
+}
+
+.ip-progress-item {
+  padding: 10px;
+  border: 1px solid var(--border-color);
+  border-radius: 6px;
+}
+
+.ip-progress-item.is-running {
+  border-color: #2563eb;
+}
+
+.ip-progress-item.is-completed {
+  border-color: #16a34a;
+}
+
+.ip-progress-item.is-failed,
+.ip-error {
+  color: #b91c1c;
+}
+
+.ip-success {
+  color: #047857;
+  font-weight: 600;
+}
+
+.ip-failure-panel {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(260px, 0.55fr);
+  gap: 12px;
+  margin-bottom: 14px;
+  padding: 14px;
+  border: 1px solid #fecaca;
+  border-radius: 8px;
+  background: #fff7ed;
+}
+
+.ip-failure-panel > div {
+  min-width: 0;
+}
+
+.ip-failure-panel span {
+  display: block;
+  color: #9f1239;
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.ip-failure-panel strong {
+  display: block;
+  margin: 4px 0;
+  color: #7f1d1d;
+}
+
+.ip-failure-panel p {
+  margin: 0;
+  color: #7c2d12;
+}
+
+.ip-quality {
+  margin-bottom: 14px;
+}
+
+.ip-quality--panel {
+  display: grid;
+  gap: 10px;
+}
+
+.ip-quality strong {
+  margin-right: 10px;
+}
+
+.ip-quality span {
+  padding: 4px 8px;
+  border-radius: 999px;
+  background: #fee2e2;
+  color: #991b1b;
+}
+
+.ip-quality span.is-pass {
+  background: #dcfce7;
+  color: #166534;
+}
+
+.ip-empty {
+  margin: 8px 0 0;
+  color: var(--text-secondary);
+  font-size: 13px;
+}
+
+.ip-quality-scores {
+  display: flex;
+  gap: 8px;
+  flex-wrap: wrap;
+}
+
+.ip-quality-scores span {
+  background: var(--bg-secondary);
+  color: var(--text-secondary);
+}
+
+.ip-warning-list {
+  display: grid;
+  gap: 6px;
+}
+
+.ip-warning-list p {
+  margin: 0;
+  padding: 8px 10px;
+  border-radius: 6px;
+  background: #fff7ed;
+  color: #9a3412;
+}
+
+.ip-readiness-panel {
+  display: grid;
+  grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
+  gap: 14px;
+  margin: 14px 0;
+  padding: 16px;
+  border: 1px solid #bbf7d0;
+  border-radius: 8px;
+  background: #f0fdf4;
+}
+
+.ip-readiness-panel.is-blocked {
+  border-color: #fed7aa;
+  background: #fff7ed;
+}
+
+.ip-readiness-main {
+  display: grid;
+  gap: 6px;
+}
+
+.ip-readiness-main strong {
+  font-size: 17px;
+  color: var(--text-primary);
+}
+
+.ip-readiness-main p,
+.ip-readiness-list p,
+.ip-script-help {
+  margin: 0;
+  color: var(--text-secondary);
+  line-height: 1.7;
+}
+
+.ip-readiness-list {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 10px;
+}
+
+.ip-readiness-list section {
+  padding: 12px;
+  border: 1px solid rgba(148, 163, 184, 0.35);
+  border-radius: 8px;
+  background: rgba(255, 255, 255, 0.72);
+}
+
+.ip-readiness-list strong {
+  display: block;
+  margin-bottom: 6px;
+}
+
+.ip-section-card header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 12px;
+  margin-bottom: 12px;
+}
+
+.ip-section-card header h3 {
+  margin: 2px 0 0;
+}
+
+.ip-diagnosis-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 10px;
+  margin-bottom: 12px;
+}
+
+.ip-diagnosis-grid p {
+  margin: 0;
+  padding: 12px;
+  border-radius: 8px;
+  background: var(--bg-secondary);
+  line-height: 1.7;
+}
+
+.ip-diagnosis-grid strong {
+  display: block;
+  margin-bottom: 4px;
+}
+
+.ip-analysis-grid,
+.ip-plan-days {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 12px;
+}
+
+.ip-analysis-grid section,
+.ip-plan-days section,
+.ip-script {
+  padding: 12px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+}
+
+.ip-opportunity-list,
+.ip-topic-cards {
+  display: grid;
+  gap: 10px;
+}
+
+.ip-opportunity-list section,
+.ip-topic-card {
+  padding: 14px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+}
+
+.ip-opportunity-list section > div,
+.ip-topic-card__head,
+.ip-topic-card__actions {
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+  align-items: center;
+}
+
+.ip-opportunity-list small,
+.ip-topic-card small,
+.ip-topic-card__actions span {
+  color: var(--text-secondary);
+  font-size: 12px;
+}
+
+.ip-topic-card.is-high {
+  border-color: #60a5fa;
+  background: #f8fbff;
+}
+
+.ip-topic-card label {
+  margin-top: 10px;
+}
+
+.ip-topic-quality,
+.ip-script-quality {
+  display: grid;
+  grid-template-columns: auto auto minmax(0, 1fr);
+  gap: 8px;
+  align-items: center;
+  margin-top: 10px;
+  padding: 9px 10px;
+  border: 1px solid rgba(148, 163, 184, 0.35);
+  border-radius: 8px;
+  background: #fff;
+}
+
+.ip-topic-quality strong,
+.ip-script-quality strong {
+  font-size: 13px;
+}
+
+.ip-topic-quality span {
+  color: var(--text-secondary);
+  font-size: 12px;
+}
+
+.ip-topic-quality small,
+.ip-script-quality small {
+  grid-column: 1 / -1;
+}
+
+.ip-topic-quality.is-make_now,
+.ip-script-quality.is-ready_to_record {
+  border-color: #86efac;
+  background: #f0fdf4;
+}
+
+.ip-topic-quality.is-polish_first,
+.ip-script-quality.is-polish_first {
+  border-color: #bfdbfe;
+  background: #eff6ff;
+}
+
+.ip-topic-quality.is-needs_material,
+.ip-script-quality.is-needs_material {
+  border-color: #fed7aa;
+  background: #fff7ed;
+}
+
+.ip-topic-quality.is-hold,
+.ip-script-quality.is-outline_only {
+  border-color: #e5e7eb;
+  background: #f8fafc;
+}
+
+.ip-script-group {
+  margin-top: 12px;
+}
+
+.ip-script-group h4 {
+  margin: 0 0 8px;
+}
+
+.ip-script-help {
+  margin-bottom: 10px;
+  font-size: 13px;
+}
+
+.ip-script-detail {
+  margin-bottom: 10px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+}
+
+.ip-script-detail summary {
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+  padding: 12px 14px;
+  cursor: pointer;
+}
+
+.ip-script-detail summary span {
+  color: var(--text-secondary);
+  font-size: 12px;
+}
+
+.ip-script-edit,
+.ip-script-outline {
+  padding: 0 14px 14px;
+}
+
+.ip-script-priority {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 10px;
+  padding: 0 14px 12px;
+}
+
+.ip-script-priority section {
+  padding: 10px;
+  border: 1px solid rgba(148, 163, 184, 0.35);
+  border-radius: 8px;
+  background: #fff;
+}
+
+.ip-script-priority span {
+  display: block;
+  margin-bottom: 4px;
+  color: var(--text-secondary);
+  font-size: 12px;
+}
+
+.ip-script-priority strong {
+  display: block;
+  line-height: 1.6;
+}
+
+.ip-script-fulltext {
+  margin: 10px 0;
+  border: 1px solid rgba(148, 163, 184, 0.35);
+  border-radius: 8px;
+  background: #fff;
+}
+
+.ip-script-fulltext summary {
+  padding: 10px 12px;
+  cursor: pointer;
+  color: var(--text-secondary);
+}
+
+.ip-script-fulltext label {
+  padding: 0 12px 12px;
+}
+
+.ip-script-outline p,
+.ip-missing-list p {
+  margin: 8px 0;
+  line-height: 1.7;
+}
+
+.ip-step-help {
+  margin: 0 0 14px;
+  padding: 10px 12px;
+  border-radius: 8px;
+  background: #eff6ff;
+  color: #1e3a8a;
+  font-size: 13px;
+  line-height: 1.7;
+}
+
+.ip-benchmark-intake {
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+  margin: 10px 0 12px;
+  padding: 10px 12px;
+  border-radius: 8px;
+  background: #f0f9ff;
+}
+
+.ip-benchmark-intake span {
+  color: var(--text-secondary);
+  font-size: 13px;
+}
+
+.ip-benchmark-optional {
+  margin-top: 12px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: #fff;
+}
+
+.ip-benchmark-optional summary {
+  padding: 10px 12px;
+  cursor: pointer;
+  color: var(--text-secondary);
+  font-size: 13px;
+}
+
+.ip-benchmark-optional label,
+.ip-benchmark-optional .ip-form-grid {
+  padding: 0 12px 12px;
+}
+
+.spacer {
+  flex: 1;
+}
+
+@media (max-width: 900px) {
+  .ip-shell,
+  .ip-form-grid,
+  .ip-summary-grid,
+  .ip-stage-note,
+  .ip-readiness-panel,
+  .ip-readiness-list,
+  .ip-quality-dimensions,
+  .ip-diagnosis-grid,
+  .ip-analysis-grid,
+  .ip-plan-days,
+  .ip-script-priority {
+    grid-template-columns: 1fr;
+  }
+
+  .ip-side {
+    position: static;
+  }
+
+  .ip-steps {
+    grid-template-columns: 1fr 1fr;
+  }
+
+  .ip-result-head,
+  .ip-quality-audit header,
+  .ip-section-card header,
+  .ip-result-tabs,
+  .ip-benchmark-intake {
+    flex-direction: column;
+  }
+
+  .ip-gate-tags {
+    justify-content: flex-start;
+  }
+
+  .ip-topic-quality,
+  .ip-script-quality {
+    grid-template-columns: 1fr;
+  }
+}

+ 743 - 0
src/app/pages/ip-operator/ip-operator.component.html

@@ -0,0 +1,743 @@
+<section class="dh-page ip-operator-page">
+  <header class="dh-page-header ip-operator-header">
+    <div class="dh-header-icon"><span class="icon">hub</span></div>
+    <div class="dh-header-text">
+      <h1>IP操盘工作台</h1>
+      <p>从建档、轻问卷、对标账号,到定位诊断、选题脚本和 7 天测试计划,形成可继续打磨的起步方案。</p>
+    </div>
+    <div class="ip-header-actions">
+      <button class="btn" type="button" (click)="startNewProfile()">新建档案</button>
+      <button class="btn" type="button" (click)="reloadState()">刷新记录</button>
+    </div>
+  </header>
+
+  <div class="ip-mode-alert" *ngIf="!auth.isLoggedIn">
+    <strong>临时试用模式</strong>
+    <span>未登录时只写入 sessionStorage,刷新或清理浏览器数据后可能丢失,请勿输入敏感客户资料。</span>
+    <button class="btn btn-sm" type="button" (click)="storageMode.set('temporary')">使用临时试用</button>
+    <button class="btn btn-sm" type="button" (click)="clearTemporaryData()">清空临时资料</button>
+  </div>
+
+  <div class="ip-shell">
+    <aside class="ip-side">
+      <h3>IP 档案</h3>
+      <button class="ip-profile-item"
+              type="button"
+              *ngFor="let profile of profiles(); trackBy: trackById"
+              (click)="loadProfile(profile)">
+        <strong>{{ profile.name }}</strong>
+        <span>{{ profile.identity }} / {{ profile.industry }}</span>
+      </button>
+
+      <div class="ip-audit">
+        <h3>修改记录</h3>
+        <p *ngIf="!auditTrail().length">暂无记录</p>
+        <article *ngFor="let event of auditTrail() | slice:0:8; trackBy: trackById">
+          <strong>{{ event.summary }}</strong>
+          <span>{{ event.createdAt | date:'MM-dd HH:mm:ss' }}</span>
+        </article>
+      </div>
+    </aside>
+
+    <main class="ip-main">
+      <ol class="ip-steps">
+        <li [class.is-active]="step() === 1">1 建档</li>
+        <li [class.is-active]="step() === 2">2 轻问卷</li>
+        <li [class.is-active]="step() === 3">3 对标</li>
+        <li [class.is-active]="step() === 4">4 生成</li>
+        <li [class.is-active]="step() === 5">5 方案</li>
+      </ol>
+
+      <section class="ip-card" *ngIf="step() === 1">
+        <h2>创建 IP 档案</h2>
+        <div class="ip-form-grid">
+          <label>IP 名称<input [ngModel]="profileDraft().name" (ngModelChange)="updateProfileDraft({name: $event})"></label>
+          <label>当前身份<input [ngModel]="profileDraft().identity" (ngModelChange)="updateProfileDraft({identity: $event})"></label>
+          <label>所在行业<input [ngModel]="profileDraft().industry" (ngModelChange)="updateProfileDraft({industry: $event})"></label>
+          <label>账号阶段
+            <select [ngModel]="profileDraft().stage" (ngModelChange)="updateProfileDraft({stage: $event})">
+              <option value="new">未开始</option>
+              <option value="started">刚注册/少量内容</option>
+              <option value="restart">老号重启</option>
+              <option value="existing">已有稳定账号</option>
+            </select>
+          </label>
+          <label>目标用户<input [ngModel]="profileDraft().targetAudience" (ngModelChange)="updateProfileDraft({targetAudience: $event})"></label>
+          <label>产品/服务<input [ngModel]="profileDraft().productsOrServices" (ngModelChange)="updateProfileDraft({productsOrServices: $event})"></label>
+        </div>
+        <label>用户痛点<textarea rows="3" [ngModel]="profileDraft().audiencePainPointsText" (ngModelChange)="updateProfileDraft({audiencePainPointsText: $event})"></textarea></label>
+        <label>个人经历<textarea rows="3" [ngModel]="profileDraft().personalStoriesText" (ngModelChange)="updateProfileDraft({personalStoriesText: $event})"></textarea></label>
+        <label>专业能力<textarea rows="3" [ngModel]="profileDraft().expertiseText" (ngModelChange)="updateProfileDraft({expertiseText: $event})"></textarea></label>
+        <label>表达风格<textarea rows="2" [ngModel]="profileDraft().expressionStyleText" (ngModelChange)="updateProfileDraft({expressionStyleText: $event})"></textarea></label>
+        <label>不想讲的内容<textarea rows="2" [ngModel]="profileDraft().boundariesText" (ngModelChange)="updateProfileDraft({boundariesText: $event})"></textarea></label>
+        <label>期望结果<textarea rows="2" [ngModel]="profileDraft().goalsText" (ngModelChange)="updateProfileDraft({goalsText: $event})"></textarea></label>
+        <div class="ip-actions">
+          <button class="btn btn-primary" type="button" (click)="step.set(2)">下一步:轻问卷</button>
+        </div>
+      </section>
+
+      <section class="ip-card" *ngIf="step() === 2">
+        <h2>轻问卷</h2>
+        <label *ngFor="let question of questionnaire; let i = index; trackBy: trackByIndex">
+          {{ i + 1 }}. {{ question }}
+          <textarea rows="2" [ngModel]="questionnaireAnswers()[i]" (ngModelChange)="updateQuestionAnswer(i, $event)"></textarea>
+        </label>
+        <div class="ip-actions">
+          <button class="btn" type="button" (click)="step.set(1)">上一步</button>
+          <button class="btn btn-primary" type="button" (click)="step.set(3)">下一步:对标账号</button>
+        </div>
+      </section>
+
+      <section class="ip-card" *ngIf="step() === 3">
+        <p class="ip-step-help">只填对标线索即可。爆点、数据、评论痛点和转化观察由 AI 分析,人工观察为可选补充。</p>
+        <h2>对标账号 / 爆款线索</h2>
+        <article class="ip-benchmark" *ngFor="let item of benchmarkDrafts(); let i = index; trackBy: trackByIndex">
+          <div class="ip-benchmark-head">
+            <strong>对标 {{ i + 1 }}</strong>
+            <button class="btn btn-sm" type="button" (click)="removeBenchmark(i)">删除</button>
+          </div>
+          <div class="ip-benchmark-intake">
+            <strong>AI 分析所需线索</strong>
+            <span>至少填账号、主页或爆款链接/ID中的一项。</span>
+          </div>
+          <div class="ip-form-grid">
+            <label>账号名称<input [ngModel]="item.name" (ngModelChange)="updateBenchmark(i, {name: $event})" placeholder="例如:老张聊实体生意"></label>
+            <label>账号链接<input [ngModel]="item.url" (ngModelChange)="updateBenchmark(i, {url: $event})" placeholder="主页链接,可选"></label>
+          </div>
+          <label>爆款作品链接 / awemeId<input [ngModel]="item.viralUrl" (ngModelChange)="updateBenchmark(i, {viralUrl: $event})" placeholder="推荐填写,用于后续证据分析"></label>
+          <label>值得借鉴原因(可选)<textarea rows="2" [ngModel]="item.reasonToBenchmark" (ngModelChange)="updateBenchmark(i, {reasonToBenchmark: $event})" placeholder="你的主观判断,可留空"></textarea></label>
+          <details class="ip-benchmark-optional">
+            <summary>人工补充观察(可选,不填也可以生成)</summary>
+            <label>账号简介<textarea rows="2" [ngModel]="item.bio" (ngModelChange)="updateBenchmark(i, {bio: $event})" placeholder="可留空"></textarea></label>
+            <div class="ip-form-grid">
+              <label>已知爆款标题/描述<input [ngModel]="item.viralTitle" (ngModelChange)="updateBenchmark(i, {viralTitle: $event})" placeholder="可留空"></label>
+              <label>已知数据表现<input [ngModel]="item.performance" (ngModelChange)="updateBenchmark(i, {performance: $event})" placeholder="可留空"></label>
+            </div>
+            <label>你观察到的爆点<textarea rows="2" [ngModel]="item.perceivedHook" (ngModelChange)="updateBenchmark(i, {perceivedHook: $event})" placeholder="可留空"></textarea></label>
+            <label>你观察到的转化线索<textarea rows="2" [ngModel]="item.conversionNotes" (ngModelChange)="updateBenchmark(i, {conversionNotes: $event})" placeholder="可留空"></textarea></label>
+          </details>
+        </article>
+        <div class="ip-actions">
+          <button class="btn" type="button" (click)="addBenchmark()">添加对标</button>
+          <span class="spacer"></span>
+          <button class="btn" type="button" (click)="step.set(2)">上一步</button>
+          <button class="btn btn-primary" type="button" [disabled]="!hasBenchmarkInput()" (click)="step.set(4)">下一步:确认生成</button>
+        </div>
+      </section>
+
+      <section class="ip-card" *ngIf="step() === 4">
+        <h2>确认生成范围</h2>
+        <div class="ip-generate-scope">
+          <span>IP 定位报告</span>
+          <span>对标拆解</span>
+          <span>机会地图</span>
+          <span>12 个核心选题</span>
+          <span>9 条脚本大纲</span>
+          <span>3 条完整口播</span>
+          <span>7 天测试计划</span>
+          <span>30 天方向</span>
+          <span>质量检查</span>
+        </div>
+        <div class="ip-progress" *ngIf="activePlan() as plan">
+          <div *ngFor="let item of plan.generationProgress; trackBy: trackByStage" [class]="'ip-progress-item is-' + item.status">
+            <strong>{{ item.label }}</strong>
+            <span>{{ item.status }}</span>
+            <small *ngIf="item.durationMs">{{ item.durationMs }}ms</small>
+          </div>
+        </div>
+        <p class="ip-error" *ngIf="generationError()">{{ generationError() }}</p>
+        <div class="ip-actions">
+          <button class="btn" type="button" (click)="step.set(3)">上一步</button>
+          <button class="btn btn-primary" type="button" [disabled]="!canGenerate()" (click)="generatePlan()">
+            {{ isGenerating() ? '生成中...' : '生成 IP 起步方案' }}
+          </button>
+        </div>
+      </section>
+
+      <section class="ip-result" *ngIf="step() === 5 && activePlan() as plan">
+        <div class="ip-result-head ip-result-hero">
+          <div>
+            <span class="ip-kicker">IP 起步方案</span>
+            <h2>{{ plan.diagnosis?.positioningStatement || '定位一句话待补充' }}</h2>
+            <p>{{ planStatusHint(plan) }}</p>
+          </div>
+          <div class="ip-actions">
+            <button class="btn" type="button" (click)="step.set(1)">补充资料</button>
+            <button class="btn" type="button" [disabled]="plan.status !== 'ready'" (click)="navigateToTopicPool.emit()">去选题池</button>
+            <button class="btn" type="button" (click)="syncMissingMaterials()">同步素材待办</button>
+            <button class="btn" type="button" (click)="openIpMaterialLibrary()">去素材库</button>
+            <div class="ip-export">
+              <button class="btn" type="button" (click)="exportMenuOpen.set(!exportMenuOpen())">导出 Markdown</button>
+              <div class="ip-export-menu" *ngIf="exportMenuOpen()">
+                <button type="button" (click)="exportMarkdown('full')">完整方案</button>
+                <button type="button" (click)="exportMarkdown('client')">客户沟通版</button>
+                <button type="button" (click)="exportMarkdown('execution')">执行版</button>
+              </div>
+            </div>
+            <button class="btn btn-primary" type="button" [disabled]="plan.status !== 'ready' || !productionReadyTopics().length" (click)="syncHighPriorityTopics()">同步可制作选题</button>
+          </div>
+        </div>
+        <p class="ip-error" *ngIf="generationError()">{{ generationError() }}</p>
+        <p class="ip-success" *ngIf="materialSyncMessage()">{{ materialSyncMessage() }}</p>
+        <p class="ip-success" *ngIf="productionEntryMessage()">{{ productionEntryMessage() }}</p>
+
+        <div class="ip-failure-panel" *ngIf="plan.status === 'failed' || planFailureCode(plan)">
+          <div>
+            <span class="ip-kicker">失败原因</span>
+            <strong>{{ planFailureCode(plan) || 'unknown' }}</strong>
+            <p>{{ failureMessage(planFailureCode(plan)) }}</p>
+          </div>
+          <div>
+            <span>失败阶段</span>
+            <strong>{{ planFailureStage(plan) }}</strong>
+            <p>{{ failureNextAction(planFailureCode(plan)) }}</p>
+          </div>
+        </div>
+
+        <div class="ip-summary-grid">
+          <section class="ip-summary-card">
+            <span>状态</span>
+            <strong>{{ planStatusLabel(plan.status) }}</strong>
+            <small>{{ plan.updatedAt | date:'yyyy-MM-dd HH:mm' }}</small>
+          </section>
+          <section class="ip-summary-card" *ngIf="plan.qualityCheck as q">
+            <span>质量分</span>
+            <strong>{{ q.overallScore }}/100</strong>
+            <small>{{ q.passed ? '已过质量门槛' : '未过质量门槛' }}</small>
+          </section>
+          <section class="ip-summary-card">
+            <span>选题</span>
+            <strong>{{ plan.topics.length }}</strong>
+            <small>可制作 {{ productionReadyTopics().length }} / 需打磨 {{ polishFirstTopics().length }} / 需素材 {{ materialNeededTopics().length }}</small>
+          </section>
+          <section class="ip-summary-card">
+            <span>脚本</span>
+            <strong>{{ fullScripts().length }} 完整 / {{ outlineScripts().length }} 大纲</strong>
+            <small>可拍摄 {{ recordableScripts().length }}</small>
+          </section>
+        </div>
+
+        <div class="ip-quality-audit" *ngIf="qualityAudit(plan) as audit">
+          <header>
+            <div>
+              <span class="ip-kicker">P2A 核心质量审计</span>
+              <strong>{{ audit.overallScore }}/100</strong>
+              <small>{{ audit.productionGate.reason }}</small>
+            </div>
+            <div class="ip-gate-tags">
+              <span [class.is-pass]="audit.productionGate.canEnterTopicPool">选题池</span>
+              <span [class.is-pass]="audit.productionGate.canCreateDraft">内容制作</span>
+              <span [class.is-pass]="audit.productionGate.canExportExecution">执行导出</span>
+            </div>
+            <button class="btn btn-sm" type="button" [disabled]="!plan.benchmarkEvidence?.length || evidenceLoading()" (click)="generateEvidenceBasedSuggestions()">
+              {{ evidenceLoading() ? '生成中...' : '生成增强建议' }}
+            </button>
+          </header>
+          <div class="ip-quality-dimensions">
+            <section *ngFor="let item of audit.dimensions; trackBy: trackByIndex" [class.is-blocked]="item.blockingIssues.length">
+              <strong>{{ item.label }} <span>{{ item.score }}</span></strong>
+              <p>{{ item.conclusion }}</p>
+              <small *ngIf="item.improvementActions.length">{{ displayText(item.improvementActions) }}</small>
+            </section>
+          </div>
+        </div>
+
+        <div class="ip-readiness-panel" [class.is-blocked]="plan.status !== 'ready'">
+          <div class="ip-readiness-main">
+            <span class="ip-kicker">{{ plan.status === 'ready' ? '可进入生产链路' : '当前仍是待完善草稿' }}</span>
+            <strong>{{ plan.status === 'ready' ? '方案已通过质量门槛,可以继续同步可制作选题。' : '先补齐关键资料,再判断是否进入选题池和生产 Pipeline。' }}</strong>
+            <p>{{ plan.status === 'ready' ? '建议先同步可制作选题,进入选题池继续打磨脚本和制作。' : '页面下方内容可以阅读和编辑,但不能当成最终执行方案;优先处理阻断问题和待补充资料。' }}</p>
+          </div>
+          <div class="ip-readiness-list" *ngIf="plan.status !== 'ready'">
+            <section *ngIf="plan.qualityCheck?.blockingIssues?.length">
+              <strong>阻断问题</strong>
+              <p *ngFor="let issue of firstItems(plan.qualityCheck?.blockingIssues, 3)">{{ displayText(issue) }}</p>
+            </section>
+            <section *ngIf="plan.missingInputs.length">
+              <strong>待补资料</strong>
+              <p *ngFor="let item of firstItems(plan.missingInputs, 3)">{{ displayText(item.type) }}:{{ displayText(item.description) }}</p>
+            </section>
+          </div>
+        </div>
+
+        <article class="ip-card ip-section-card ip-supplement-panel">
+          <header>
+            <div>
+              <span class="ip-kicker">补充资料闭环</span>
+              <h3>把待补资料变成下一次质量检查的依据</h3>
+              <small class="ip-regeneration-meta">{{ supplementMessage() || '补充资料不会自动覆盖正文,保存后请手动重新质量检查或重生成相关模块。' }}</small>
+            </div>
+            <button class="btn btn-sm" type="button" [disabled]="!!regeneratingStage()" (click)="regenerateSection('quality_check')">
+              {{ regeneratingStage() === 'quality_check' ? '检查中...' : '重新质量检查' }}
+            </button>
+            <button class="btn btn-sm" type="button" (click)="importFilledMaterials()">导入已补素材</button>
+          </header>
+
+          <div class="ip-supplement-grid">
+            <section>
+              <h4>新增补充资料</h4>
+              <div class="ip-form-grid">
+                <label>资料类型
+                  <select [ngModel]="supplementDraft().type" (ngModelChange)="updateSupplementDraft({type: $event})">
+                    <option *ngFor="let type of supplementTypes" [value]="type">{{ type }}</option>
+                  </select>
+                </label>
+                <label>关联待补资料
+                  <select [ngModel]="supplementDraft().relatedMissingInputId" (ngModelChange)="updateSupplementDraft({relatedMissingInputId: $event})">
+                    <option value="">不关联</option>
+                    <option *ngFor="let item of plan.missingInputs; let i = index" [value]="i + ':' + displayText(item.type)">
+                      {{ displayText(item.type) }}:{{ displayText(item.description) }}
+                    </option>
+                  </select>
+                </label>
+              </div>
+              <label>标题<input [ngModel]="supplementDraft().title" (ngModelChange)="updateSupplementDraft({title: $event})"></label>
+              <label>内容<textarea rows="4" [ngModel]="supplementDraft().content" (ngModelChange)="updateSupplementDraft({content: $event})"></textarea></label>
+              <div class="ip-actions">
+                <button class="btn btn-primary" type="button" [disabled]="!supplementDraft().title.trim() || !supplementDraft().content.trim()" (click)="addSupplementInput()">保存补充资料</button>
+              </div>
+            </section>
+
+            <section>
+              <h4>已补充资料</h4>
+              <p class="ip-empty" *ngIf="!plan.supplementInputs?.length">还没有补充资料。优先补客户案例、客户问题、服务前后对比和对标数据。</p>
+              <div class="ip-supplement-list" *ngIf="plan.supplementInputs?.length">
+                <article *ngFor="let item of plan.supplementInputs; trackBy: trackById">
+                  <label>类型
+                    <select [(ngModel)]="item.type" (change)="saveSupplementInput(item, {type: item.type})">
+                      <option *ngFor="let type of supplementTypes" [value]="type">{{ type }}</option>
+                    </select>
+                  </label>
+                  <label>标题<input [(ngModel)]="item.title" (change)="saveSupplementInput(item, {title: item.title})"></label>
+                  <label>内容<textarea rows="3" [(ngModel)]="item.content" (change)="saveSupplementInput(item, {content: item.content})"></textarea></label>
+                  <div class="ip-actions">
+                    <small>{{ item.updatedAt | date:'MM-dd HH:mm:ss' }}</small>
+                    <button class="btn btn-sm" type="button" (click)="deleteSupplementInput(item)">删除</button>
+                  </div>
+                </article>
+              </div>
+            </section>
+          </div>
+        </article>
+
+        <article class="ip-card ip-section-card ip-evidence-panel">
+          <header>
+            <div>
+              <span class="ip-kicker">P2C 对标证据</span>
+              <h3>抓取单条对标作品,回到机会地图、选题和脚本判断</h3>
+              <small class="ip-regeneration-meta">{{ evidenceMessage() || '当前只做用户触发的单条作品证据采集,不做无人值守批量抓取。短链需要先展开为 douyin.com/video/{id}。' }}</small>
+            </div>
+          </header>
+          <div class="ip-evidence-form">
+            <label>对标账号
+              <select [ngModel]="evidenceBenchmarkId()" (ngModelChange)="evidenceBenchmarkId.set($event)">
+                <option value="">不绑定账号</option>
+                <option *ngFor="let benchmark of benchmarkOptions(plan); trackBy: trackById" [value]="benchmark.id">{{ benchmark.name }}</option>
+              </select>
+            </label>
+            <label>作品链接或 awemeId
+              <input [ngModel]="evidenceUrl()" (ngModelChange)="evidenceUrl.set($event)" placeholder="https://www.douyin.com/video/..." />
+            </label>
+            <button class="btn btn-primary" type="button" [disabled]="!evidenceUrl().trim() || evidenceLoading()" (click)="fetchBenchmarkEvidence()">
+              {{ evidenceLoading() ? '采集中...' : '采集证据' }}
+            </button>
+          </div>
+          <div class="ip-evidence-form ip-evidence-form--saved">
+            <label>已保存爆款分析
+              <select [ngModel]="savedAnalysisId()" (ngModelChange)="savedAnalysisId.set($event)">
+                <option value="">请选择分析记录</option>
+                <option *ngFor="let analysis of savedAnalyses(); trackBy: trackById" [value]="analysis.id">
+                  {{ displayText(analysis.videoSnapshot.desc || analysis.awemeId) }}
+                </option>
+              </select>
+            </label>
+            <button class="btn" type="button" [disabled]="savedAnalysisLoading()" (click)="refreshSavedAnalyses()">
+              {{ savedAnalysisLoading() ? '加载中...' : '刷新分析记录' }}
+            </button>
+            <button class="btn btn-primary" type="button" [disabled]="!savedAnalysisId() || savedAnalysisLoading()" (click)="bindSavedAnalysisEvidence()">
+              绑定为证据
+            </button>
+          </div>
+          <p class="ip-success" *ngIf="savedAnalysisMessage()">{{ savedAnalysisMessage() }}</p>
+          <div class="ip-evidence-grid" *ngIf="plan.benchmarkEvidence?.length || plan.evidenceFetchJobs?.length">
+            <section>
+              <h4>最近证据</h4>
+              <p class="ip-empty" *ngIf="!plan.benchmarkEvidence?.length">暂无已保存对标证据。</p>
+              <article *ngFor="let item of recentBenchmarkEvidence(plan); trackBy: trackById">
+                <strong>{{ displayText(item.title) }}</strong>
+                <span>{{ item.confidence }} / {{ displayText(item.authorName) }}</span>
+                <p>{{ displayText(item.summary) }}</p>
+                <small>{{ displayText(item.migrationSuggestion) }}</small>
+                <button class="btn btn-sm" type="button" (click)="generateEvidenceSuggestions(item)">生成打磨建议</button>
+              </article>
+            </section>
+            <section>
+              <h4>采集记录</h4>
+              <p class="ip-empty" *ngIf="!plan.evidenceFetchJobs?.length">暂无采集记录。</p>
+              <article *ngFor="let job of recentEvidenceJobs(plan); trackBy: trackById" [class.is-failed]="job.status === 'failed'">
+                <strong>{{ evidenceJobStatusLabel(job.status) }} / {{ evidenceStrengthLabel(job.evidenceStrength) }}</strong>
+                <span>{{ job.awemeId || job.inputUrl }}</span>
+                <small>{{ job.updatedAt | date:'MM-dd HH:mm:ss' }}</small>
+                <p *ngIf="job.failureMessage">{{ job.failureMessage }}</p>
+              </article>
+            </section>
+          </div>
+          <div class="ip-evidence-suggestions" *ngIf="plan.evidenceSuggestions?.length">
+            <h4>证据打磨建议</h4>
+            <article *ngFor="let suggestion of recentEvidenceSuggestions(plan); trackBy: trackById" [class.is-applied]="suggestion.status === 'applied'" [class.is-dismissed]="suggestion.status === 'dismissed'">
+              <div>
+                <strong>{{ displayText(suggestion.title) }}</strong>
+                <span>{{ suggestionTargetLabel(suggestion.targetType) }} / {{ suggestion.status }}</span>
+              </div>
+              <p>{{ displayText(suggestion.rationale) }}</p>
+              <small>{{ displayText(suggestion.suggestedChange) }}</small>
+              <div class="ip-actions" *ngIf="suggestion.status === 'draft'">
+                <button class="btn btn-sm btn-primary" type="button" (click)="applyEvidenceSuggestion(suggestion)">应用</button>
+                <button class="btn btn-sm" type="button" (click)="dismissEvidenceSuggestion(suggestion)">忽略</button>
+              </div>
+            </article>
+          </div>
+        </article>
+
+        <div class="ip-stage-note">
+          <strong>如何判断当前问题</strong>
+          <span>质量分、待补充资料、定位字段为空、同步按钮禁用,属于当前 P0a 必须继续优化的问题;局部重新生成、素材库联动、自动对标数据和更细的脚本版本管理,属于后续 P1/P2 增强。</span>
+        </div>
+
+        <div class="ip-debug-panel ip-bridge-panel" *ngIf="plan.bridgeRecords?.length">
+          <details open>
+            <summary>
+              <strong>制作/回填记录</strong>
+              <span>最近 {{ recentBridgeRecords(plan).length }} 条动作,可用于追踪生产闭环</span>
+            </summary>
+            <div class="ip-debug-grid ip-bridge-grid">
+              <section *ngFor="let record of recentBridgeRecords(plan); trackBy: trackById" [class.is-failed]="record.status === 'failed'" [class.is-skipped]="record.status === 'skipped'">
+                <strong>{{ displayText(record.title) }}</strong>
+                <span>{{ bridgeTargetLabel(record.target) }} / {{ bridgeStatusLabel(record.status) }}</span>
+                <small>{{ record.createdAt | date:'MM-dd HH:mm:ss' }}</small>
+                <small>{{ record.sourceType }} -> {{ record.targetId }}</small>
+                <p>{{ displayText(record.message) }}</p>
+              </section>
+            </div>
+          </details>
+        </div>
+
+        <div class="ip-debug-panel" *ngIf="plan.regenerationNotes?.length">
+          <details>
+            <summary>
+              <strong>局部重生成记录</strong>
+              <span>同一阶段最多允许手动重试 2 次</span>
+            </summary>
+            <div class="ip-debug-grid">
+              <section *ngFor="let note of recentRegenerationNotes(plan); trackBy: trackById" [class.is-failed]="note.status === 'failed'">
+                <strong>{{ stageLabel(note.stage) }}</strong>
+                <span>第 {{ note.attempt }} 次 / {{ note.status === 'failed' ? '失败' : '完成' }}</span>
+                <small>{{ note.createdAt | date:'MM-dd HH:mm:ss' }}</small>
+                <small *ngIf="note.preservedUserEdits">保留 {{ note.preservedUserEdits }} 处手动编辑</small>
+                <small *ngIf="note.failureCode">{{ note.failureCode }}</small>
+                <p>{{ note.summary }}</p>
+              </section>
+            </div>
+          </details>
+        </div>
+
+        <div class="ip-debug-panel" *ngIf="plan.status === 'failed' || plan.errorMessage || plan.generationProgress.length">
+          <details>
+            <summary>
+              <strong>生成过程与调试信息</strong>
+              <span>{{ copyMessage() || '用于定位接口、JSON 和质量门槛问题' }}</span>
+            </summary>
+            <div class="ip-debug-actions">
+              <button class="btn btn-sm" type="button" (click)="copyPlanDebugInfo(plan)">复制调试信息</button>
+            </div>
+            <div class="ip-debug-grid">
+              <section *ngFor="let item of plan.generationProgress; trackBy: trackByStage">
+                <strong>{{ item.label }}</strong>
+                <span>{{ item.status }}</span>
+                <small *ngIf="item.durationMs">{{ item.durationMs }}ms</small>
+                <small *ngIf="item.failureCode">{{ item.failureCode }}</small>
+                <p *ngIf="item.errorMessage">{{ item.errorMessage }}</p>
+                <pre *ngIf="item.rawPreview">{{ item.rawPreview }}</pre>
+              </section>
+            </div>
+          </details>
+        </div>
+
+        <nav class="ip-result-tabs" aria-label="方案内容">
+          <button type="button" [class.is-active]="resultView() === 'overview'" (click)="setResultView('overview')">总览判断</button>
+          <button type="button" [class.is-active]="resultView() === 'topics'" (click)="setResultView('topics')">选题打磨</button>
+          <button type="button" [class.is-active]="resultView() === 'scripts'" (click)="setResultView('scripts')">脚本稿</button>
+          <button type="button" [class.is-active]="resultView() === 'plan'" (click)="setResultView('plan')">测试计划</button>
+        </nav>
+
+        <ng-container *ngIf="resultView() === 'overview'">
+          <div class="ip-quality ip-quality--panel" *ngIf="plan.qualityCheck as q">
+            <div>
+              <strong>质量检查</strong>
+              <span [class.is-pass]="q.passed">{{ q.passed ? '可进入生产链路' : '需要补充资料' }}</span>
+            </div>
+            <div class="ip-quality-scores">
+              <span>定位 {{ q.positioningClarityScore }}</span>
+              <span>用户 {{ q.audienceClarityScore }}</span>
+              <span>素材 {{ q.evidenceSupportScore }}</span>
+              <span>迁移 {{ q.benchmarkTransferScore }}</span>
+              <span>选题 {{ q.topicUsabilityScore }}</span>
+            </div>
+            <div class="ip-warning-list" *ngIf="q.blockingIssues.length || q.improvementSuggestions.length">
+              <p *ngFor="let issue of q.blockingIssues">阻断:{{ displayText(issue) }}</p>
+              <p *ngFor="let suggestion of q.improvementSuggestions">建议:{{ displayText(suggestion) }}</p>
+            </div>
+          </div>
+
+          <article class="ip-card ip-section-card">
+            <header>
+              <div>
+                <span class="ip-kicker">定位诊断</span>
+                <h3>先判断这个 IP 是否成立</h3>
+                <small class="ip-regeneration-meta">{{ lastRegenerationText(plan, 'diagnosis') }}</small>
+              </div>
+              <button class="btn btn-sm" type="button" [disabled]="!!regeneratingStage()" (click)="regenerateSection('diagnosis')">
+                {{ regeneratingStage() === 'diagnosis' ? '重生成中...' : '重生成定位' }}
+              </button>
+            </header>
+            <ng-container *ngIf="plan.diagnosis; else diagnosisPending">
+              <div class="ip-diagnosis-grid">
+                <p><strong>目标用户</strong>{{ displayText(plan.diagnosis.targetAudience.summary) }}</p>
+                <p><strong>市场机会</strong>{{ displayText(plan.diagnosis.marketOpportunity) }}</p>
+                <p><strong>差异化</strong>{{ displayText(plan.diagnosis.differentiation) }}</p>
+                <p><strong>变现路径</strong>{{ displayText(plan.diagnosis.monetizationPath) }}</p>
+              </div>
+              <div class="ip-chip-row">
+                <span *ngFor="let tag of plan.diagnosis.personaTags || []">{{ displayText(tag) }}</span>
+              </div>
+            </ng-container>
+            <ng-template #diagnosisPending>
+              <p class="ip-empty">定位诊断尚未生成完成,请等待生成流程结束或查看错误信息。</p>
+            </ng-template>
+          </article>
+
+          <article class="ip-card ip-section-card">
+            <header>
+              <div>
+                <span class="ip-kicker">对标迁移</span>
+                <h3>看哪些能学,哪些不能照搬</h3>
+                <small class="ip-regeneration-meta">{{ lastRegenerationText(plan, 'benchmark') }}</small>
+              </div>
+              <button class="btn btn-sm" type="button" [disabled]="!!regeneratingStage()" (click)="regenerateSection('benchmark')">
+                {{ regeneratingStage() === 'benchmark' ? '重生成中...' : '重生成对标' }}
+              </button>
+            </header>
+            <p class="ip-empty" *ngIf="!plan.benchmarkAnalyses.length">对标拆解尚未生成完成。</p>
+            <div class="ip-analysis-grid">
+              <section *ngFor="let item of plan.benchmarkAnalyses; trackBy: trackByIndex">
+                <h4>{{ displayText(item.accountName) }} <small>{{ item.fitScore }}分</small></h4>
+                <p>{{ displayText(item.positioning) }}</p>
+                <p><strong>可借鉴:</strong>{{ displayText(item.borrowablePoints) }}</p>
+                <p><strong>不可照搬:</strong>{{ displayText(item.nonCopyablePoints) }}</p>
+                <p><strong>迁移方向:</strong>{{ displayText(item.migrationDirection) }}</p>
+              </section>
+            </div>
+          </article>
+
+          <article class="ip-card ip-section-card">
+            <header>
+              <div>
+                <span class="ip-kicker">机会地图</span>
+                <h3>从爆款点转成自己的内容方向</h3>
+                <small class="ip-regeneration-meta">{{ lastRegenerationText(plan, 'opportunities') }}</small>
+              </div>
+              <button class="btn btn-sm" type="button" [disabled]="!!regeneratingStage()" (click)="regenerateSection('opportunities')">
+                {{ regeneratingStage() === 'opportunities' ? '重生成中...' : '重生成机会地图' }}
+              </button>
+            </header>
+            <p class="ip-empty" *ngIf="!plan.opportunityMap.length">机会地图尚未生成完成。</p>
+            <div class="ip-opportunity-list">
+              <section *ngFor="let item of plan.opportunityMap; trackBy: trackById">
+                <div>
+                  <strong>{{ displayText(item.type) }}</strong>
+                  <span>{{ item.fitScore }}分</span>
+                </div>
+                <p>{{ displayText(item.reason) }}</p>
+                <small>风险边界:{{ displayText(item.riskBoundary) }}</small>
+              </section>
+            </div>
+          </article>
+        </ng-container>
+
+        <ng-container *ngIf="resultView() === 'topics'">
+          <article class="ip-card ip-section-card">
+            <header>
+              <div>
+                <span class="ip-kicker">P0b 可编辑</span>
+                <h3>优先打磨高优先级选题</h3>
+                <small class="ip-regeneration-meta">{{ lastRegenerationText(plan, 'topics_scripts_plan') }}</small>
+              </div>
+              <div class="ip-actions">
+                <button class="btn btn-sm" type="button" [disabled]="!!regeneratingStage()" (click)="regenerateSection('topics_scripts_plan')">
+                  {{ regeneratingStage() === 'topics_scripts_plan' ? '重生成中...' : '重生成选题脚本' }}
+                </button>
+                <button class="btn btn-primary" type="button" [disabled]="plan.status !== 'ready' || !productionReadyTopics().length" (click)="syncHighPriorityTopics()">同步可制作</button>
+              </div>
+            </header>
+            <p class="ip-empty" *ngIf="!plan.topics.length">选题尚未生成完成。</p>
+            <div class="ip-topic-cards">
+              <section *ngFor="let topic of sortedTopics(); trackBy: trackById" class="ip-topic-card" [class.is-high]="topic.priority === 'high'">
+                <div class="ip-topic-card__head">
+                  <span>{{ priorityLabel(topic.priority) }}优先级</span>
+                  <small>{{ trafficLayerLabel(topic.trafficLayer) }} / {{ topic.column }}</small>
+                </div>
+                <div class="ip-topic-quality" [ngClass]="topicQualityClass(topic.qualityLevel)">
+                  <strong>{{ topicQualityLabel(topic.qualityLevel) }}</strong>
+                  <span>{{ topic.productionReadinessScore || 0 }}/100</span>
+                  <small *ngIf="topic.polishSuggestions?.length">{{ displayText(topic.polishSuggestions) }}</small>
+                </div>
+                <label>标题<input [(ngModel)]="topic.title" (change)="saveTopicField(topic, {title: topic.title})"></label>
+                <label>适配理由<textarea rows="3" [(ngModel)]="topic.fitReason" (change)="saveTopicField(topic, {fitReason: topic.fitReason})"></textarea></label>
+                <p><strong>内容目标:</strong>{{ displayText(topic.contentGoal) }}</p>
+                <p><strong>所需素材:</strong>{{ displayText(topic.requiredMaterials) }}</p>
+                <p><strong>风险:</strong>{{ displayText(topic.riskNote) }}</p>
+                <div class="ip-topic-card__actions">
+                  <span *ngIf="topic.userEdited">已手动编辑</span>
+                  <button class="btn btn-sm" type="button" [disabled]="plan.status !== 'ready' || topic.qualityLevel !== 'make_now' || !!topic.syncedTopicId" (click)="syncTopic(topic)">
+                    {{ topic.syncedTopicId ? '已同步选题池' : '同步选题池' }}
+                  </button>
+                  <button class="btn btn-sm btn-primary" type="button" [disabled]="plan.status !== 'ready' || topic.qualityLevel !== 'make_now'" (click)="createTopicVideoDraft(topic)">
+                    生成主题视频草稿
+                  </button>
+                </div>
+              </section>
+            </div>
+          </article>
+        </ng-container>
+
+        <ng-container *ngIf="resultView() === 'scripts'">
+          <article class="ip-card ip-section-card">
+            <header>
+              <div>
+                <span class="ip-kicker">脚本稿</span>
+                <h3>先看完整口播,再看大纲</h3>
+                <small class="ip-regeneration-meta">{{ lastRegenerationText(plan, 'topics_scripts_plan') }}</small>
+              </div>
+              <button class="btn btn-sm" type="button" [disabled]="!!regeneratingStage()" (click)="regenerateSection('topics_scripts_plan')">
+                {{ regeneratingStage() === 'topics_scripts_plan' ? '重生成中...' : '重生成脚本' }}
+              </button>
+            </header>
+            <p class="ip-empty" *ngIf="!plan.scripts.length">脚本尚未生成完成。</p>
+
+            <section class="ip-script-group" *ngIf="fullScripts().length">
+              <h4>完整口播(优先测试)</h4>
+              <p class="ip-script-help">默认先看钩子、观点和可拍摄性;完整口播折叠查看,避免长文本干扰判断。</p>
+              <details class="ip-script-detail" *ngFor="let script of fullScripts(); let i = index; trackBy: trackById" [open]="i === 0">
+                <summary>
+                  <strong>{{ script.title || script.id }}</strong>
+                  <span>{{ scriptQualityLabel(script.qualityLevel) }} / {{ script.userEdited ? '已手动编辑' : 'AI 草稿' }}</span>
+                </summary>
+                <div class="ip-script-quality" [ngClass]="scriptQualityClass(script.qualityLevel)">
+                  <strong>{{ displayText(script.scriptSummary) }}</strong>
+                  <small *ngIf="script.productionNotes?.length">{{ displayText(script.productionNotes) }}</small>
+                </div>
+                <div class="ip-script-priority">
+                  <section>
+                    <span>开头钩子</span>
+                    <strong>{{ displayText(script.hook) }}</strong>
+                  </section>
+                  <section>
+                    <span>核心观点</span>
+                    <strong>{{ displayText(script.viewpoint) }}</strong>
+                  </section>
+                  <section>
+                    <span>风险提示</span>
+                    <strong>{{ displayText(script.riskNote) }}</strong>
+                  </section>
+                </div>
+                <div class="ip-script-edit">
+                  <label>开头钩子<input [(ngModel)]="script.hook" (change)="saveScriptField(script, {hook: script.hook})"></label>
+                  <label>观点<textarea rows="2" [(ngModel)]="script.viewpoint" (change)="saveScriptField(script, {viewpoint: script.viewpoint})"></textarea></label>
+                  <details class="ip-script-fulltext">
+                    <summary>展开完整口播</summary>
+                  <label>完整口播<textarea rows="6" [(ngModel)]="script.fullScript" (change)="saveScriptField(script, {fullScript: script.fullScript})"></textarea></label>
+                  </details>
+                  <p><strong>拍摄建议:</strong>{{ displayText(script.shootingTips) }}</p>
+                  <p><strong>可替换素材位:</strong>{{ displayText(script.replaceableSlots) }}</p>
+                  <div class="ip-actions">
+                    <button class="btn btn-sm btn-primary" type="button" [disabled]="plan.status !== 'ready' || script.qualityLevel !== 'ready_to_record'" (click)="createDigitalHumanDraft(script)">
+                      生成数字人口播草稿
+                    </button>
+                  </div>
+                </div>
+              </details>
+            </section>
+
+            <section class="ip-script-group" *ngIf="outlineScripts().length">
+              <h4>脚本大纲(后续打磨)</h4>
+              <details class="ip-script-detail" *ngFor="let script of outlineScripts(); trackBy: trackById">
+                <summary>
+                  <strong>{{ script.title || script.id }}</strong>
+                  <span>{{ scriptQualityLabel(script.qualityLevel) }}</span>
+                </summary>
+                <div class="ip-script-quality" [ngClass]="scriptQualityClass(script.qualityLevel)">
+                  <strong>{{ displayText(script.scriptSummary) }}</strong>
+                  <small *ngIf="script.productionNotes?.length">{{ displayText(script.productionNotes) }}</small>
+                </div>
+                <div class="ip-script-outline">
+                  <p><strong>钩子:</strong>{{ displayText(script.hook) }}</p>
+                  <p><strong>痛点:</strong>{{ displayText(script.painPoint) }}</p>
+                  <p><strong>观点:</strong>{{ displayText(script.viewpoint) }}</p>
+                  <p><strong>案例/方法:</strong>{{ displayText(script.caseOrMethod) }}</p>
+                  <p><strong>结尾:</strong>{{ displayText(script.closingCta) }}</p>
+                </div>
+              </details>
+            </section>
+          </article>
+        </ng-container>
+
+        <ng-container *ngIf="resultView() === 'plan'">
+          <article class="ip-card ip-section-card">
+            <header>
+              <div>
+                <span class="ip-kicker">内容测试</span>
+                <h3>7 天详细测试 + 30 天方向</h3>
+                <small class="ip-regeneration-meta">{{ lastRegenerationText(plan, 'quality_check') }}</small>
+              </div>
+              <div class="ip-actions">
+                <button class="btn btn-sm" type="button" [disabled]="!!regeneratingStage()" (click)="regenerateSection('topics_scripts_plan')">
+                  {{ regeneratingStage() === 'topics_scripts_plan' ? '重生成中...' : '重生成测试计划' }}
+                </button>
+                <button class="btn btn-sm" type="button" [disabled]="!!regeneratingStage()" (click)="regenerateSection('quality_check')">
+                  {{ regeneratingStage() === 'quality_check' ? '检查中...' : '重新质量检查' }}
+                </button>
+              </div>
+            </header>
+            <p class="ip-empty" *ngIf="!plan.sevenDayTestPlan.length">7 天测试计划尚未生成完成。</p>
+            <div class="ip-plan-days">
+              <section *ngFor="let day of plan.sevenDayTestPlan; trackBy: trackByIndex">
+                <strong>第 {{ day.day }} 天:{{ displayText(day.topicTitle) }}</strong>
+                <p>{{ displayText(day.testGoal) }}</p>
+                <p><strong>拍摄重点:</strong>{{ displayText(day.shootingFocus) }}</p>
+                <p><strong>观察:</strong>{{ displayText(day.observeMetrics) }}</p>
+                <p><strong>复盘问题:</strong>{{ displayText(day.reviewQuestions) }}</p>
+              </section>
+            </div>
+          </article>
+
+          <article class="ip-card ip-section-card" *ngIf="plan.thirtyDayDirection as direction">
+            <h3>30 天栏目测试方向</h3>
+            <div class="ip-chip-row">
+              <span *ngFor="let column of direction.columnStructure">{{ displayText(column.column) }} {{ column.ratio }}%</span>
+            </div>
+            <p><strong>每周重点:</strong>{{ displayText(direction.weeklyFocus) }}</p>
+            <p><strong>素材方向:</strong>{{ displayText(direction.materialDirections) }}</p>
+            <p><strong>迭代路径:</strong>{{ displayText(direction.iterationPath) }}</p>
+          </article>
+
+          <article class="ip-card ip-section-card" *ngIf="plan.missingInputs.length">
+            <h3>待补充资料</h3>
+            <div class="ip-missing-list">
+              <p *ngFor="let item of plan.missingInputs">{{ displayText(item.type) }}:{{ displayText(item.description) }}</p>
+            </div>
+          </article>
+        </ng-container>
+      </section>
+    </main>
+  </div>
+</section>

+ 312 - 0
src/app/pages/ip-operator/ip-operator.component.spec.ts

@@ -0,0 +1,312 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { AuthCreditService } from '../../services/auth-credit.service';
+import { IpOperatorService } from '../../services/ip-operator.service';
+import { IpOperatorPlan, IpOperatorProfile } from '../../models/ip-operator.model';
+import { IpOperatorComponent } from './ip-operator.component';
+
+describe('IpOperatorComponent', () => {
+  let fixture: ComponentFixture<IpOperatorComponent>;
+  let component: IpOperatorComponent;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [IpOperatorComponent],
+      providers: [
+        {
+          provide: AuthCreditService,
+          useValue: { isLoggedIn: true, currentUser: { objectId: 'user-1' } },
+        },
+        {
+          provide: IpOperatorService,
+          useValue: createIpOperatorStub(),
+        },
+      ],
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(IpOperatorComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('renders an existing IP plan result instead of an empty page', () => {
+    const text = pageText();
+
+    expect(component.step()).toBe(5);
+    expect(text).toContain('IP 起步方案');
+    expect(text).toContain('当前仍是待完善草稿');
+    expect(text).toContain('阻断问题');
+    expect(text).toContain('待补资料');
+  });
+
+  it('disables production entry points while the plan still needs input', () => {
+    const buttons = Array.from(fixture.nativeElement.querySelectorAll('button')) as HTMLButtonElement[];
+    const topicPoolButton = buttons.find((button) => button.textContent?.includes('去选题池'));
+    const syncHighButton = buttons.find((button) => button.textContent?.includes('同步可制作选题'));
+
+    expect(topicPoolButton?.disabled).toBe(true);
+    expect(syncHighButton?.disabled).toBe(true);
+  });
+
+  it('disables the generate button while a generation is already running', () => {
+    component.step.set(4);
+    component.updateProfileDraft({
+      name: '测试 IP',
+      identity: '增长顾问',
+      industry: '本地生活',
+      targetAudience: '传统老板',
+      audiencePainPointsText: '内容像广告',
+      personalStoriesText: '服务过本地商家',
+      expertiseText: '定位和选题',
+      goalsText: '生成测试方案',
+    });
+    component.updateBenchmark(0, {
+      name: '对标账号',
+      reasonToBenchmark: '表达直接',
+      viralTitle: '老板号为什么像广告',
+    });
+    component.isGenerating.set(true);
+    fixture.detectChanges();
+
+    const generateButton = (Array.from(fixture.nativeElement.querySelectorAll('button')) as HTMLButtonElement[])
+      .find((button) => button.textContent?.includes('生成中'));
+
+    expect(component.canGenerate()).toBe(false);
+    expect(generateButton?.disabled).toBe(true);
+  });
+
+  it('renders generation progress while a plan is generating', () => {
+    component.step.set(4);
+    component.activePlan.set({
+      ...planFixture(),
+      status: 'generating',
+      generationProgress: [
+        { stage: 'intake_summary', label: '资料归纳', status: 'completed', durationMs: 120 },
+        { stage: 'diagnosis', label: '定位诊断', status: 'running' },
+        { stage: 'benchmark', label: '对标拆解', status: 'pending' },
+      ],
+    });
+    fixture.detectChanges();
+
+    const progressItems = fixture.nativeElement.querySelectorAll('.ip-progress-item') as NodeListOf<HTMLElement>;
+    const text = pageText();
+
+    expect(progressItems.length).toBe(3);
+    expect(text).toContain('资料归纳');
+    expect(text).toContain('completed');
+    expect(text).toContain('定位诊断');
+    expect(text).toContain('running');
+    expect(text).toContain('对标拆解');
+    expect(text).toContain('pending');
+  });
+
+  it('keeps long full scripts behind an expandable details block', () => {
+    component.setResultView('scripts');
+    fixture.detectChanges();
+
+    const details = fixture.nativeElement.querySelector('.ip-script-fulltext') as HTMLDetailsElement | null;
+    const text = pageText();
+
+    expect(text).toContain('默认先看钩子、观点和可拍摄性');
+    expect(text).toContain('开头钩子');
+    expect(text).toContain('核心观点');
+    expect(details).toBeTruthy();
+    expect(details?.open).toBe(false);
+  });
+
+  it('renders recent regeneration status and structured regeneration records', () => {
+    component.activePlan.set({
+      ...planFixture(),
+      regenerationNotes: [
+        {
+          id: 'note-1',
+          stage: 'topics_scripts_plan',
+          createdAt: '2026-06-08T10:20:00.000Z',
+          attempt: 1,
+          summary: '第 1 次手动重生成完成:选题脚本计划,保留 2 处手动编辑',
+          preservedUserEdits: 2,
+          status: 'completed',
+        },
+        {
+          id: 'note-2',
+          stage: 'quality_check',
+          createdAt: '2026-06-08T10:30:00.000Z',
+          attempt: 1,
+          summary: '第 1 次手动重生成失败:质量检查,失败码:llm_upstream_error',
+          status: 'failed',
+          failureCode: 'llm_upstream_error',
+        },
+      ],
+    });
+    component.setResultView('topics');
+    fixture.detectChanges();
+
+    const text = pageText();
+
+    expect(text).toContain('最近 06-08 10:20 完成,保留 2 处编辑');
+    expect(text).toContain('局部重生成记录');
+    expect(text).toContain('第 1 次 / 失败');
+    expect(text).toContain('llm_upstream_error');
+  });
+
+  it('builds distinct deliverable markdown variants without object placeholders', () => {
+    const plan = {
+      ...planFixture(),
+      supplementInputs: [{
+        id: 'supplement-1',
+        planId: 'plan-1',
+        profileId: 'profile-1',
+        type: '客户案例',
+        title: '客户案例 A',
+        content: '客户从零散咨询提升到每周 3 个有效咨询。',
+        createdAt: '2026-06-08T00:00:00.000Z',
+        updatedAt: '2026-06-08T00:00:00.000Z',
+      }],
+    };
+
+    const full = (component as any).buildMarkdown(plan, 'full') as string;
+    const client = (component as any).buildMarkdown(plan, 'client') as string;
+    const execution = (component as any).buildMarkdown(plan, 'execution') as string;
+
+    expect(full).toContain('待完善草稿');
+    expect(full).toContain('## 2. 对标拆解');
+    expect(full).toContain('## 已补充资料');
+    expect(client).toContain('IP 起步沟通版');
+    expect(client).not.toContain('## 2. 对标拆解');
+    expect(execution).toContain('IP 内容执行版');
+    expect(execution).toContain('3 条完整口播');
+    expect([full, client, execution].join('\n')).not.toContain('[object Object]');
+  });
+
+  function pageText(): string {
+    return (fixture.nativeElement as HTMLElement).textContent || '';
+  }
+});
+
+function createIpOperatorStub(): Partial<IpOperatorService> {
+  return {
+    listProfiles: () => [profileFixture()],
+    listAuditTrail: () => [],
+    getActiveProfileId: () => 'profile-1',
+    setActiveProfileId: () => undefined,
+    getProfile: () => profileFixture(),
+    listBenchmarks: () => [],
+    listPlans: () => [planFixture()],
+    buildPlanQualityAudit: (plan: IpOperatorPlan) => ({
+      id: `quality_audit_${plan.id}`,
+      planId: plan.id,
+      overallScore: 62,
+      status: 'needs_input',
+      dimensions: [
+        {
+          key: 'topics',
+          label: '选题质量',
+          score: 55,
+          conclusion: '选题仍需打磨或补充素材',
+          blockingIssues: ['缺少达到制作门槛的选题'],
+          improvementActions: ['补充真实案例'],
+        },
+      ],
+      productionGate: {
+        canEnterTopicPool: false,
+        canCreateDraft: false,
+        canExportExecution: true,
+        reason: '当前方案仍需补充资料或打磨选题脚本后再进入制作。',
+      },
+      createdAt: '2026-06-08T00:00:00.000Z',
+    }),
+  };
+}
+
+function profileFixture(): IpOperatorProfile {
+  return {
+    id: 'profile-1',
+    userId: 'user-1',
+    name: '林川老板增长笔记',
+    stage: 'new',
+    identity: '传统行业增长顾问',
+    industry: '本地生活',
+    targetAudience: '传统行业老板',
+    audiencePainPoints: ['内容像广告'],
+    productsOrServices: '私域转化陪跑',
+    personalStories: ['服务过本地商家'],
+    expertise: ['增长咨询'],
+    expressionStyle: ['直接', '案例化'],
+    boundaries: ['不夸大收益'],
+    goals: ['建立信任'],
+    questionnaire: [],
+    createdAt: '2026-06-08T00:00:00.000Z',
+    updatedAt: '2026-06-08T00:00:00.000Z',
+  };
+}
+
+function planFixture(): IpOperatorPlan {
+  return {
+    id: 'plan-1',
+    userId: 'user-1',
+    profileId: 'profile-1',
+    status: 'needs_input',
+    generationProgress: [],
+    qualityCheck: {
+      overallScore: 68,
+      positioningClarityScore: 72,
+      audienceClarityScore: 70,
+      evidenceSupportScore: 55,
+      benchmarkTransferScore: 67,
+      topicUsabilityScore: 76,
+      passed: false,
+      blockingIssues: ['缺少真实案例支撑'],
+      improvementSuggestions: ['补充客户反馈截图和服务前后对比'],
+    },
+    diagnosis: {
+      positioningStatement: '帮助传统行业老板做可信任的老板 IP',
+      targetAudience: {
+        summary: '传统行业老板',
+        painPoints: ['不会表达信任'],
+      },
+      marketOpportunity: '传统行业老板需要低成本建立信任',
+      ipType: '老板 IP',
+      personaTags: ['可信任', '案例型'],
+      differentiation: '用服务案例替代泛泛观点',
+      contentTracks: ['信任建设', '案例拆解'],
+      monetizationPath: '私域诊断和陪跑服务',
+      risks: ['素材不足'],
+      alternativeDirections: [],
+      missingInputs: [{ type: '真实案例', description: '补充客户反馈', priority: 'high' }],
+    },
+    benchmarkAnalyses: [],
+    opportunityMap: [],
+    topics: [{
+      id: 'topic-1',
+      title: '老板 IP 如何避免成为广告账号',
+      column: '定位诊断',
+      trafficLayer: 'vertical',
+      contentGoal: '建立信任',
+      priority: 'high',
+      fitReason: '符合目标用户痛点',
+      source: '定位诊断',
+      requiredMaterials: ['客户反馈截图'],
+      scriptStatus: 'full',
+      riskNote: '避免硬广',
+    }],
+    scripts: [{
+      id: 'script-1',
+      topicId: 'topic-1',
+      type: 'full',
+      title: '老板 IP 如何避免成为广告账号',
+      hook: '为什么你的内容越发,用户越不信?',
+      painPoint: '内容像广告',
+      viewpoint: '老板号应该传递信任,而不是产品堆砌。',
+      caseOrMethod: '客户案例拆解',
+      goldenSentence: '信任先于成交',
+      closingCta: '预约诊断',
+      fullScript: '这是一段较长的完整口播,用于验证页面不会默认把长文本直接铺满。',
+      shootingTips: ['正面口播', '插入案例截图'],
+      replaceableSlots: ['客户反馈截图'],
+      riskNote: '避免承诺结果',
+    }],
+    sevenDayTestPlan: [],
+    missingInputs: [{ type: '客户案例', description: '补充 2 个真实客户案例', priority: 'high' }],
+    createdAt: '2026-06-08T00:00:00.000Z',
+    updatedAt: '2026-06-08T00:00:00.000Z',
+  };
+}

+ 1233 - 0
src/app/pages/ip-operator/ip-operator.component.ts

@@ -0,0 +1,1233 @@
+import { CommonModule } from '@angular/common';
+import { Component, EventEmitter, Injector, OnInit, Output, computed, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { CreationBrief } from '../../models/creation-brief.model';
+import { ViralAnalysis } from '../../models/douyin-insight.model';
+import {
+  BenchmarkAccount,
+  IpBenchmarkEvidence,
+  IpEvidenceFetchJob,
+  IpEvidencePolishSuggestion,
+  IpOperatorBridgeRecord,
+  IpOperatorAuditEvent,
+  IpOperatorPlan,
+  IpOperatorProfile,
+  IpOperatorStage,
+  IpPlanQualityAudit,
+  IpScript,
+  IpScriptQualityLevel,
+  IpSupplementInput,
+  IpTopic,
+  IpTopicQualityLevel,
+  IpGenerationFailureCode,
+  IpGenerationSection,
+} from '../../models/ip-operator.model';
+import { AuthCreditService } from '../../services/auth-credit.service';
+import { CreationBriefService } from '../../services/creation-brief.service';
+import { IpOperatorAuthRequiredError, IpOperatorService } from '../../services/ip-operator.service';
+
+interface ProfileDraft {
+  id?: string;
+  name: string;
+  stage: IpOperatorStage;
+  identity: string;
+  industry: string;
+  targetAudience: string;
+  audiencePainPointsText: string;
+  productsOrServices: string;
+  personalStoriesText: string;
+  expertiseText: string;
+  expressionStyleText: string;
+  boundariesText: string;
+  goalsText: string;
+}
+
+interface BenchmarkDraft {
+  id?: string;
+  name: string;
+  url: string;
+  bio: string;
+  reasonToBenchmark: string;
+  viralTitle: string;
+  viralUrl: string;
+  performance: string;
+  perceivedHook: string;
+  notes: string;
+  conversionNotes: string;
+}
+
+interface SupplementDraft {
+  type: string;
+  title: string;
+  content: string;
+  relatedMissingInputId: string;
+}
+
+const QUESTIONNAIRE = [
+  '你现在最希望通过 IP 内容实现什么目标?',
+  '你最想服务哪类人?',
+  '这类人现在最痛苦、最焦虑、最想解决的问题是什么?',
+  '你凭什么能帮助他们?',
+  '你过去有哪些真实经历可以支撑这个定位?',
+  '你目前有什么产品、服务或商业承接方式?',
+  '你最不想被用户误解成什么样的人?',
+  '你喜欢或擅长哪种表达方式?',
+  '你有哪些对标账号?',
+  '你最想借鉴对标账号的什么?',
+  '你觉得自己和对标账号最大的不同是什么?',
+  '你目前最缺的是定位、选题、脚本、拍摄还是运营节奏?',
+];
+
+@Component({
+  selector: 'app-ip-operator',
+  standalone: true,
+  imports: [CommonModule, FormsModule],
+  templateUrl: './ip-operator.component.html',
+  styleUrls: ['./ip-operator.component.css'],
+})
+export class IpOperatorComponent implements OnInit {
+  @Output() navigateToTopicPool = new EventEmitter<void>();
+  @Output() navigateToTopicVideo = new EventEmitter<void>();
+  @Output() navigateToDigitalHuman = new EventEmitter<CreationBrief>();
+  @Output() navigateToLibrary = new EventEmitter<void>();
+
+  readonly auth = inject(AuthCreditService);
+  private readonly ipOperator = inject(IpOperatorService);
+  private readonly creationBriefs = inject(CreationBriefService);
+  private readonly injector = inject(Injector);
+
+  readonly profiles = signal<IpOperatorProfile[]>([]);
+  readonly activePlan = signal<IpOperatorPlan | null>(null);
+  readonly auditTrail = signal<IpOperatorAuditEvent[]>([]);
+  readonly step = signal(1);
+  readonly isGenerating = signal(false);
+  readonly generationError = signal('');
+  readonly storageMode = signal<'local' | 'temporary'>('local');
+  readonly lastSyncedTopicId = signal('');
+  readonly resultView = signal<'overview' | 'topics' | 'scripts' | 'plan'>('overview');
+  readonly exportMenuOpen = signal(false);
+  readonly copyMessage = signal('');
+  readonly regeneratingStage = signal<IpGenerationSection | ''>('');
+  readonly materialSyncMessage = signal('');
+  readonly productionEntryMessage = signal('');
+  readonly supplementMessage = signal('');
+  readonly evidenceUrl = signal('');
+  readonly evidenceBenchmarkId = signal('');
+  readonly evidenceMessage = signal('');
+  readonly evidenceLoading = signal(false);
+  readonly savedAnalyses = signal<ViralAnalysis[]>([]);
+  readonly savedAnalysisId = signal('');
+  readonly savedAnalysisMessage = signal('');
+  readonly savedAnalysisLoading = signal(false);
+  readonly supplementDraft = signal<SupplementDraft>({
+    type: '客户案例',
+    title: '',
+    content: '',
+    relatedMissingInputId: '',
+  });
+  readonly supplementTypes = ['个人故事', '客户案例', '客户问题', '产品服务', '对标数据', '评论私信', '专业证明'];
+
+  readonly profileDraft = signal<ProfileDraft>(this.emptyProfileDraft());
+  readonly questionnaireAnswers = signal<string[]>(QUESTIONNAIRE.map(() => ''));
+  readonly benchmarkDrafts = signal<BenchmarkDraft[]>([this.emptyBenchmarkDraft()]);
+  readonly hasBenchmarkInput = computed(() => this.benchmarkDrafts().some((item) => this.hasBenchmarkSignal(item)));
+
+  readonly canGenerate = computed(() => {
+    const draft = this.profileDraft();
+    const hasRequiredProfile = !!(
+      draft.name.trim()
+      && draft.identity.trim()
+      && draft.industry.trim()
+      && draft.targetAudience.trim()
+      && draft.audiencePainPointsText.trim()
+      && draft.personalStoriesText.trim()
+      && draft.expertiseText.trim()
+      && draft.goalsText.trim()
+    );
+    return hasRequiredProfile && this.hasBenchmarkInput() && !this.isGenerating();
+  });
+
+  readonly highPriorityTopics = computed(() => this.activePlan()?.topics.filter((item) => item.priority === 'high') || []);
+  readonly productionReadyTopics = computed(() => this.activePlan()?.topics.filter((item) => item.qualityLevel === 'make_now') || []);
+  readonly polishFirstTopics = computed(() => this.activePlan()?.topics.filter((item) => item.qualityLevel === 'polish_first') || []);
+  readonly materialNeededTopics = computed(() => this.activePlan()?.topics.filter((item) => item.qualityLevel === 'needs_material') || []);
+  readonly sortedTopics = computed(() => {
+    const qualityOrder: Record<IpTopicQualityLevel, number> = { make_now: 0, polish_first: 1, needs_material: 2, hold: 3 };
+    const priorityOrder: Record<string, number> = { high: 0, medium: 1, low: 2 };
+    return [...(this.activePlan()?.topics || [])].sort((a, b) => {
+      const byQuality = (qualityOrder[a.qualityLevel || 'hold'] ?? 9) - (qualityOrder[b.qualityLevel || 'hold'] ?? 9);
+      if (byQuality) return byQuality;
+      const byPriority = (priorityOrder[a.priority] ?? 9) - (priorityOrder[b.priority] ?? 9);
+      return byPriority || a.title.localeCompare(b.title);
+    });
+  });
+  readonly fullScripts = computed(() => (this.activePlan()?.scripts || []).filter((script) => script.type === 'full'));
+  readonly outlineScripts = computed(() => (this.activePlan()?.scripts || []).filter((script) => script.type !== 'full'));
+  readonly recordableScripts = computed(() => (this.activePlan()?.scripts || []).filter((script) => script.qualityLevel === 'ready_to_record'));
+
+  ngOnInit(): void {
+    this.reloadState();
+  }
+
+  reloadState(): void {
+    const profiles = this.ipOperator.listProfiles();
+    this.profiles.set(profiles);
+    this.auditTrail.set(this.ipOperator.listAuditTrail());
+    const activeProfileId = this.ipOperator.getActiveProfileId() || profiles[0]?.id || '';
+    if (activeProfileId) {
+      const profile = this.ipOperator.getProfile(activeProfileId);
+      if (profile) this.loadProfile(profile);
+      const latestPlan = this.ipOperator.listPlans(activeProfileId)[0] || null;
+      this.activePlan.set(latestPlan);
+    }
+  }
+
+  startNewProfile(): void {
+    this.profileDraft.set(this.emptyProfileDraft());
+    this.questionnaireAnswers.set(QUESTIONNAIRE.map(() => ''));
+    this.benchmarkDrafts.set([this.emptyBenchmarkDraft()]);
+    this.activePlan.set(null);
+    this.step.set(1);
+    this.generationError.set('');
+  }
+
+  loadProfile(profile: IpOperatorProfile): void {
+    this.profileDraft.set({
+      id: profile.id,
+      name: profile.name,
+      stage: profile.stage,
+      identity: profile.identity,
+      industry: profile.industry,
+      targetAudience: profile.targetAudience,
+      audiencePainPointsText: profile.audiencePainPoints.join('\n'),
+      productsOrServices: profile.productsOrServices,
+      personalStoriesText: profile.personalStories.join('\n'),
+      expertiseText: profile.expertise.join('\n'),
+      expressionStyleText: profile.expressionStyle.join('\n'),
+      boundariesText: profile.boundaries.join('\n'),
+      goalsText: profile.goals.join('\n'),
+    });
+    this.questionnaireAnswers.set(QUESTIONNAIRE.map((question) => profile.questionnaire.find((item) => item.question === question)?.answer || ''));
+    const benchmarks = this.ipOperator.listBenchmarks(profile.id);
+    this.benchmarkDrafts.set(benchmarks.length ? benchmarks.map((item) => ({
+      id: item.id,
+      name: item.name,
+      url: item.url || '',
+      bio: item.bio || '',
+      reasonToBenchmark: item.reasonToBenchmark,
+      viralTitle: item.viralExamples[0]?.title || '',
+      viralUrl: item.viralExamples[0]?.url || '',
+      performance: item.viralExamples[0]?.performance || '',
+      perceivedHook: item.viralExamples[0]?.perceivedHook || '',
+      notes: item.viralExamples[0]?.notes || '',
+      conversionNotes: item.conversionNotes || '',
+    })) : [this.emptyBenchmarkDraft()]);
+    this.ipOperator.setActiveProfileId(profile.id, this.storageMode() === 'temporary');
+    const latestPlan = this.ipOperator.listPlans(profile.id)[0] || null;
+    this.activePlan.set(latestPlan);
+    this.step.set(latestPlan ? (latestPlan.status === 'generating' ? 4 : 5) : 1);
+    this.auditTrail.set(this.ipOperator.listAuditTrail());
+  }
+
+  updateProfileDraft(patch: Partial<ProfileDraft>): void {
+    this.profileDraft.set({ ...this.profileDraft(), ...patch });
+  }
+
+  updateQuestionAnswer(index: number, value: string): void {
+    const next = [...this.questionnaireAnswers()];
+    next[index] = value;
+    this.questionnaireAnswers.set(next);
+  }
+
+  updateBenchmark(index: number, patch: Partial<BenchmarkDraft>): void {
+    const next = [...this.benchmarkDrafts()];
+    next[index] = { ...next[index], ...patch };
+    this.benchmarkDrafts.set(next);
+  }
+
+  addBenchmark(): void {
+    this.benchmarkDrafts.set([...this.benchmarkDrafts(), this.emptyBenchmarkDraft()]);
+  }
+
+  removeBenchmark(index: number): void {
+    const next = this.benchmarkDrafts().filter((_, i) => i !== index);
+    this.benchmarkDrafts.set(next.length ? next : [this.emptyBenchmarkDraft()]);
+  }
+
+  saveProfileAndBenchmarks(): IpOperatorProfile {
+    const temporary = this.storageMode() === 'temporary';
+    const profile = this.ipOperator.saveProfile(this.toProfileInput(), { temporary });
+    this.ipOperator.saveBenchmarks(profile.id, this.toBenchmarks(profile.id), { temporary });
+    this.reloadState();
+    return profile;
+  }
+
+  generatePlan(): void {
+    if (!this.canGenerate()) return;
+    this.isGenerating.set(true);
+    this.generationError.set('');
+    let profile: IpOperatorProfile;
+    try {
+      profile = this.saveProfileAndBenchmarks();
+    } catch (error: any) {
+      this.isGenerating.set(false);
+      this.generationError.set(error instanceof IpOperatorAuthRequiredError ? error.message : (error?.message || '保存资料失败'));
+      return;
+    }
+    const benchmarks = this.ipOperator.listBenchmarks(profile.id);
+    this.ipOperator.generateFullPlan({
+      profile,
+      benchmarks,
+      storageMode: this.storageMode(),
+    }).subscribe({
+      next: (event) => {
+        if (event.plan) this.activePlan.set(event.plan);
+        if (event.plan?.status === 'generating') this.step.set(4);
+        this.auditTrail.set(this.ipOperator.listAuditTrail());
+      },
+      error: (error) => {
+        this.isGenerating.set(false);
+        this.generationError.set(error?.message || '生成失败,请稍后重试');
+        this.reloadState();
+      },
+      complete: () => {
+        this.isGenerating.set(false);
+        this.step.set(5);
+        this.reloadState();
+      },
+    });
+  }
+
+  saveTopicField(topic: IpTopic, patch: Partial<IpTopic>): void {
+    const plan = this.activePlan();
+    if (!plan) return;
+    this.activePlan.set(this.ipOperator.updateTopic(plan.id, topic.id, patch));
+    this.auditTrail.set(this.ipOperator.listAuditTrail());
+  }
+
+  saveScriptField(script: IpScript, patch: Partial<IpScript>): void {
+    const plan = this.activePlan();
+    if (!plan) return;
+    this.activePlan.set(this.ipOperator.updateScript(plan.id, script.id, patch));
+    this.auditTrail.set(this.ipOperator.listAuditTrail());
+  }
+
+  syncTopic(topic: IpTopic): void {
+    const plan = this.activePlan();
+    if (!plan || plan.status !== 'ready') return;
+    const synced = this.ipOperator.syncTopicToTopicPool(plan.id, topic.id);
+    this.lastSyncedTopicId.set(synced.id);
+    this.reloadState();
+  }
+
+  syncHighPriorityTopics(): void {
+    const plan = this.activePlan();
+    if (!plan || plan.status !== 'ready') return;
+    const synced = this.ipOperator.syncAllRecommendedTopics(plan.id);
+    this.lastSyncedTopicId.set(synced.map((item) => item.id).join(','));
+    this.reloadState();
+  }
+
+  syncMissingMaterials(): void {
+    const plan = this.activePlan();
+    if (!plan) return;
+    const result = this.ipOperator.syncMissingMaterialsToLibrary(plan.id);
+    this.materialSyncMessage.set(result.total
+      ? `已同步 ${result.total} 条待补充素材到素材库:新增 ${result.created} 条,已存在 ${result.existing} 条`
+      : '当前方案没有可同步的待补充素材');
+    this.auditTrail.set(this.ipOperator.listAuditTrail());
+  }
+
+  importFilledMaterials(): void {
+    const plan = this.activePlan();
+    if (!plan) return;
+    const result = this.ipOperator.importFilledAssetsAsSupplements(plan.id);
+    this.reloadState();
+    this.supplementMessage.set(result.imported
+      ? `已导入 ${result.imported} 条已补素材为补充资料,建议重新质量检查。`
+      : '没有可导入的已补素材。');
+  }
+
+  async createTopicVideoDraft(topic: IpTopic): Promise<void> {
+    const plan = this.activePlan();
+    if (!plan) return;
+    try {
+      const { IpOperatorBridgeService } = await import('../../services/ip-operator-bridge.service');
+      const bridge = new IpOperatorBridgeService(this.creationBriefs);
+      const entry = bridge.enterTopicToVideo(plan, topic, this.topicScript(topic));
+      const record = this.ipOperator.recordBridgeAction(plan.id, {
+        sourceType: 'topic',
+        sourceId: topic.id,
+        target: 'topic_to_video',
+        targetId: entry.brief.id,
+        title: topic.title,
+        status: 'created',
+        message: entry.message,
+      });
+      this.productionEntryMessage.set(record.message || entry.message);
+      this.reloadState();
+      this.navigateToTopicVideo.emit();
+    } catch (error: any) {
+      this.productionEntryMessage.set(error?.message || '生成主题视频草稿失败');
+    }
+  }
+
+  async createDigitalHumanDraft(script: IpScript): Promise<void> {
+    const plan = this.activePlan();
+    if (!plan) return;
+    try {
+      const { IpOperatorBridgeService } = await import('../../services/ip-operator-bridge.service');
+      const bridge = new IpOperatorBridgeService(this.creationBriefs);
+      const entry = bridge.enterDigitalHuman(plan, script);
+      const record = this.ipOperator.recordBridgeAction(plan.id, {
+        sourceType: 'script',
+        sourceId: script.id,
+        target: 'digital_human',
+        targetId: entry.brief.id,
+        title: script.title || script.id,
+        status: 'created',
+        message: entry.message,
+      });
+      this.productionEntryMessage.set(record.message || entry.message);
+      this.reloadState();
+      this.navigateToDigitalHuman.emit(entry.brief);
+    } catch (error: any) {
+      this.productionEntryMessage.set(error?.message || '生成数字人口播草稿失败');
+    }
+  }
+
+  openIpMaterialLibrary(): void {
+    sessionStorage.setItem('videoWorkflow.library.prefilter', JSON.stringify({
+      source: 'ip_operator',
+      materialStatus: 'todo',
+      profileId: this.activePlan()?.profileId || '',
+    }));
+    this.navigateToLibrary.emit();
+  }
+
+  async fetchBenchmarkEvidence(): Promise<void> {
+    const plan = this.activePlan();
+    const url = this.evidenceUrl().trim();
+    if (!plan || !url || this.evidenceLoading()) return;
+    this.evidenceLoading.set(true);
+    this.evidenceMessage.set('');
+    try {
+      const { IpOperatorEvidenceService } = await import('../../services/ip-operator-evidence.service');
+      const evidenceService = this.injector.get(IpOperatorEvidenceService);
+      const result = await new Promise<any>((resolve, reject) => {
+        evidenceService.fetchEvidenceFromDouyinUrl(plan.id, this.evidenceBenchmarkId() || undefined, url).subscribe({
+          next: resolve,
+          error: reject,
+        });
+      });
+      this.evidenceUrl.set('');
+      this.evidenceMessage.set(result.evidence
+        ? `已保存对标证据:${result.evidence.title}`
+        : `证据采集未写入:${result.job.failureMessage || result.job.status}`);
+      this.reloadState();
+    } catch (error: any) {
+      const result = error?.result;
+      this.evidenceMessage.set(result?.job?.failureMessage || error?.message || '对标证据采集失败');
+      this.reloadState();
+    } finally {
+      this.evidenceLoading.set(false);
+    }
+  }
+
+  async refreshSavedAnalyses(): Promise<void> {
+    if (this.savedAnalysisLoading()) return;
+    this.savedAnalysisLoading.set(true);
+    this.savedAnalysisMessage.set('');
+    try {
+      const { ViralAnalysisService } = await import('../../services/viral-analysis.service');
+      const viralAnalysis = this.injector.get(ViralAnalysisService);
+      const analyses = viralAnalysis.listAnalyses();
+      this.savedAnalyses.set(analyses);
+      this.savedAnalysisMessage.set(analyses.length ? `已加载 ${analyses.length} 条已保存爆款分析` : '暂无已保存爆款分析');
+    } catch (error: any) {
+      this.savedAnalysisMessage.set(error?.message || '加载已保存爆款分析失败');
+    } finally {
+      this.savedAnalysisLoading.set(false);
+    }
+  }
+
+  async bindSavedAnalysisEvidence(): Promise<void> {
+    const plan = this.activePlan();
+    const analysisId = this.savedAnalysisId();
+    if (!plan || !analysisId || this.savedAnalysisLoading()) return;
+    this.savedAnalysisLoading.set(true);
+    this.savedAnalysisMessage.set('');
+    try {
+      const { IpOperatorEvidenceService } = await import('../../services/ip-operator-evidence.service');
+      const evidenceService = this.injector.get(IpOperatorEvidenceService);
+      const result = evidenceService.bindSavedAnalysis(plan.id, this.evidenceBenchmarkId() || undefined, analysisId);
+      this.savedAnalysisMessage.set(result.evidence ? `已绑定爆款分析:${result.evidence.title}` : '已绑定分析记录');
+      this.reloadState();
+    } catch (error: any) {
+      this.savedAnalysisMessage.set(error?.message || '绑定爆款分析失败');
+      this.reloadState();
+    } finally {
+      this.savedAnalysisLoading.set(false);
+    }
+  }
+
+  generateEvidenceSuggestions(evidence: IpBenchmarkEvidence): void {
+    const plan = this.activePlan();
+    if (!plan) return;
+    try {
+      const updated = this.ipOperator.generateEvidencePolishSuggestions(plan.id, evidence.id);
+      this.activePlan.set(updated);
+      this.evidenceMessage.set('已生成对标证据打磨建议,需手动确认后才会应用。');
+      this.auditTrail.set(this.ipOperator.listAuditTrail());
+    } catch (error: any) {
+      this.evidenceMessage.set(error?.message || '生成对标证据建议失败');
+    }
+  }
+
+  applyEvidenceSuggestion(suggestion: IpEvidencePolishSuggestion): void {
+    const plan = this.activePlan();
+    if (!plan) return;
+    try {
+      const updated = this.ipOperator.applyEvidencePolishSuggestion(plan.id, suggestion.id);
+      this.activePlan.set(updated);
+      this.evidenceMessage.set('已应用对标证据建议,建议重新检查选题/脚本质量。');
+      this.auditTrail.set(this.ipOperator.listAuditTrail());
+    } catch (error: any) {
+      this.evidenceMessage.set(error?.message || '应用对标证据建议失败');
+    }
+  }
+
+  dismissEvidenceSuggestion(suggestion: IpEvidencePolishSuggestion): void {
+    const plan = this.activePlan();
+    if (!plan) return;
+    try {
+      const updated = this.ipOperator.dismissEvidencePolishSuggestion(plan.id, suggestion.id);
+      this.activePlan.set(updated);
+      this.evidenceMessage.set('已忽略该对标证据建议。');
+      this.auditTrail.set(this.ipOperator.listAuditTrail());
+    } catch (error: any) {
+      this.evidenceMessage.set(error?.message || '忽略对标证据建议失败');
+    }
+  }
+
+  async generateEvidenceBasedSuggestions(): Promise<void> {
+    const plan = this.activePlan();
+    if (!plan || this.evidenceLoading()) return;
+    this.evidenceLoading.set(true);
+    this.evidenceMessage.set('');
+    try {
+      const updated = await new Promise<IpOperatorPlan>((resolve, reject) => {
+        this.ipOperator.generateEvidenceBasedPolishSuggestions(plan.id).subscribe({
+          next: resolve,
+          error: reject,
+        });
+      });
+      this.activePlan.set(updated);
+      this.evidenceMessage.set('已基于对标证据生成增强建议,仍需手动确认后应用。');
+      this.auditTrail.set(this.ipOperator.listAuditTrail());
+    } catch (error: any) {
+      this.evidenceMessage.set(error?.message || '基于对标证据生成增强建议失败');
+    } finally {
+      this.evidenceLoading.set(false);
+    }
+  }
+
+  updateSupplementDraft(patch: Partial<SupplementDraft>): void {
+    this.supplementDraft.set({ ...this.supplementDraft(), ...patch });
+  }
+
+  addSupplementInput(): void {
+    const plan = this.activePlan();
+    const draft = this.supplementDraft();
+    if (!plan || !draft.title.trim() || !draft.content.trim()) return;
+    try {
+      const updated = this.ipOperator.addSupplementInput(plan.id, {
+        type: draft.type,
+        title: draft.title,
+        content: draft.content,
+        relatedMissingInputId: draft.relatedMissingInputId || undefined,
+      });
+      this.activePlan.set(updated);
+      this.supplementDraft.set({ type: draft.type, title: '', content: '', relatedMissingInputId: '' });
+      this.supplementMessage.set('资料已补充,建议重新质量检查或重生成相关模块。');
+      this.auditTrail.set(this.ipOperator.listAuditTrail());
+    } catch (error: any) {
+      this.supplementMessage.set(error?.message || '补充资料保存失败');
+    }
+  }
+
+  saveSupplementInput(input: IpSupplementInput, patch: Partial<IpSupplementInput>): void {
+    const plan = this.activePlan();
+    if (!plan) return;
+    try {
+      const updated = this.ipOperator.updateSupplementInput(plan.id, input.id, patch);
+      this.activePlan.set(updated);
+      this.supplementMessage.set('补充资料已更新,建议重新质量检查。');
+      this.auditTrail.set(this.ipOperator.listAuditTrail());
+    } catch (error: any) {
+      this.supplementMessage.set(error?.message || '补充资料更新失败');
+    }
+  }
+
+  deleteSupplementInput(input: IpSupplementInput): void {
+    const plan = this.activePlan();
+    if (!plan) return;
+    const updated = this.ipOperator.deleteSupplementInput(plan.id, input.id);
+    this.activePlan.set(updated);
+    this.supplementMessage.set('补充资料已删除。');
+    this.auditTrail.set(this.ipOperator.listAuditTrail());
+  }
+
+  regenerateSection(stage: IpGenerationSection): void {
+    const plan = this.activePlan();
+    if (!plan || this.regeneratingStage()) return;
+    const confirmed = confirm(`确认重新生成「${this.stageLabel(stage)}」?\n\n${this.regenerationPrompt(stage)}`);
+    if (!confirmed) return;
+    this.regeneratingStage.set(stage);
+    this.generationError.set('');
+    this.ipOperator.regenerateSection(plan.id, stage).subscribe({
+      next: (event) => {
+        if (event.plan) this.activePlan.set(event.plan);
+        this.auditTrail.set(this.ipOperator.listAuditTrail());
+      },
+      error: (error) => {
+        this.generationError.set(error?.message || '局部重新生成失败');
+        this.regeneratingStage.set('');
+        this.reloadState();
+      },
+      complete: () => {
+        this.regeneratingStage.set('');
+        this.reloadState();
+      },
+    });
+  }
+
+  exportMarkdown(kind: 'full' | 'client' | 'execution' | 'topics'): void {
+    const plan = this.activePlan();
+    if (!plan) return;
+    const profileName = this.profileDraft().name || 'IP操盘方案';
+    const markdown = this.buildMarkdown(plan, kind);
+    const normalizedKind = kind === 'topics' ? 'execution' : kind;
+    const suffix = normalizedKind === 'full' ? '完整方案' : normalizedKind === 'client' ? '客户沟通版' : '执行版';
+    this.downloadText(`IP操盘方案-${profileName}-${suffix}-${this.timestampForFilename()}.md`, markdown);
+    this.exportMenuOpen.set(false);
+  }
+
+  copyPlanDebugInfo(plan: IpOperatorPlan): void {
+    const payload = {
+      planId: plan.id,
+      profileId: plan.profileId,
+      status: plan.status,
+      errorMessage: plan.errorMessage,
+      generationProgress: plan.generationProgress,
+      qualityCheck: plan.qualityCheck,
+      missingInputs: plan.missingInputs,
+    };
+    const text = JSON.stringify(payload, null, 2);
+    if (navigator?.clipboard?.writeText) {
+      navigator.clipboard.writeText(text)
+        .then(() => this.copyMessage.set('调试信息已复制'))
+        .catch(() => this.fallbackCopy(text));
+      return;
+    }
+    this.fallbackCopy(text);
+  }
+
+  clearTemporaryData(): void {
+    this.ipOperator.clearTemporaryData();
+    this.startNewProfile();
+    this.auditTrail.set(this.ipOperator.listAuditTrail());
+  }
+
+  setResultView(view: 'overview' | 'topics' | 'scripts' | 'plan'): void {
+    this.resultView.set(view);
+  }
+
+  planStatusLabel(status: IpOperatorPlan['status']): string {
+    const map: Record<IpOperatorPlan['status'], string> = {
+      draft: '草稿',
+      generating: '生成中',
+      ready: '可进入生产链路',
+      needs_input: '需补充资料',
+      failed: '生成失败',
+    };
+    return map[status] || status;
+  }
+
+  planStatusHint(plan: IpOperatorPlan): string {
+    if (plan.status === 'ready') {
+      return '当前方案已通过质量门槛,可以编辑选题/脚本并同步到选题池。';
+    }
+    if (plan.status === 'needs_input') {
+      return '这是现阶段需要处理的问题:质量门槛未通过。可以先阅读草稿,但建议补充资料后重新生成,暂不进入生产链路。';
+    }
+    if (plan.status === 'generating') {
+      return '方案仍在生成,请等待六阶段完成后再判断内容质量。';
+    }
+    if (plan.status === 'failed') {
+      return '生成中断,已保留完成阶段的内容和错误记录,可补充资料后重试。';
+    }
+    return '方案仍是草稿,请完成生成后查看质量检查。';
+  }
+
+  planFailureCode(plan: IpOperatorPlan): IpGenerationFailureCode | '' {
+    return plan.generationProgress.find((item) => item.failureCode)?.failureCode || '';
+  }
+
+  planFailureStage(plan: IpOperatorPlan): string {
+    const failed = plan.generationProgress.find((item) => item.failureCode || item.status === 'failed');
+    return failed ? failed.label : '生成流程';
+  }
+
+  failureMessage(code: IpGenerationFailureCode | ''): string {
+    const map: Record<IpGenerationFailureCode, string> = {
+      llm_timeout: '上游模型响应超时。本次已保留完成阶段,建议稍后重试当前阶段或减少输入内容。',
+      llm_rate_limited: '上游模型当前限流。本次已保留完成阶段,建议稍后重试。',
+      llm_empty_response: '上游模型没有返回有效内容。建议重试当前阶段。',
+      llm_malformed_json: '模型返回格式异常,系统无法解析为结构化方案。建议重试当前阶段。',
+      llm_refusal: '模型拒绝了本次请求。请调整输入资料后重试。',
+      llm_insufficient_balance: '上游模型账户余额不足。本次生成已保留完成阶段,请充值后重新执行质量检查。',
+      llm_upstream_error: '上游模型服务返回异常。本次生成已保留完成阶段,请稍后重试或复制调试信息排查。',
+      quality_gate_failed: '当前方案没有通过质量门槛。请补充资料后重新质量检查。',
+      storage_quota_exceeded: '本地存储空间不足。请清理旧方案或改用临时模式后重试。',
+      unknown: '生成失败原因暂未分类。请复制调试信息排查,已完成阶段不会被清空。',
+    };
+    return code ? map[code] : '生成失败原因暂未分类。请复制调试信息排查,已完成阶段不会被清空。';
+  }
+
+  failureNextAction(code: IpGenerationFailureCode | ''): string {
+    if (code === 'llm_insufficient_balance') return '充值或恢复上游账户余额后,优先点击“重新质量检查”。';
+    if (code === 'llm_malformed_json' || code === 'llm_empty_response' || code === 'llm_timeout' || code === 'llm_rate_limited') {
+      return '建议重试失败阶段;重试前不需要重新录入已完成资料。';
+    }
+    if (code === 'quality_gate_failed') return '先补充待补资料,再重新质量检查。';
+    if (code === 'storage_quota_exceeded') return '先清理本地旧方案,避免继续写入失败。';
+    return '先复制调试信息确认失败原因,再决定补资料或重试。';
+  }
+
+  priorityLabel(priority: IpTopic['priority']): string {
+    if (priority === 'high') return '高';
+    if (priority === 'medium') return '中';
+    return '低';
+  }
+
+  trafficLayerLabel(layer: IpTopic['trafficLayer']): string {
+    if (layer === 'broad') return '泛流量';
+    if (layer === 'vertical') return '垂直流量';
+    return '转化型';
+  }
+
+  topicQualityLabel(level?: IpTopicQualityLevel): string {
+    const map: Record<IpTopicQualityLevel, string> = {
+      make_now: '可立即制作',
+      polish_first: '先打磨',
+      needs_material: '需补素材',
+      hold: '暂缓',
+    };
+    return map[level || 'hold'];
+  }
+
+  topicQualityClass(level?: IpTopicQualityLevel): string {
+    return `is-${level || 'hold'}`;
+  }
+
+  scriptQualityLabel(level?: IpScriptQualityLevel): string {
+    const map: Record<IpScriptQualityLevel, string> = {
+      ready_to_record: '可拍摄',
+      polish_first: '先打磨',
+      needs_material: '需素材',
+      outline_only: '仅大纲',
+    };
+    return map[level || 'outline_only'];
+  }
+
+  scriptQualityClass(level?: IpScriptQualityLevel): string {
+    return `is-${level || 'outline_only'}`;
+  }
+
+  qualityAudit(plan: IpOperatorPlan): IpPlanQualityAudit {
+    return plan.qualityAudit || this.ipOperator.buildPlanQualityAudit(plan);
+  }
+
+  stageLabel(stage: IpGenerationSection): string {
+    const map: Record<IpGenerationSection, string> = {
+      intake_summary: '资料归纳',
+      diagnosis: '定位诊断',
+      benchmark: '对标拆解',
+      opportunities: '机会地图',
+      topics_scripts_plan: '选题脚本计划',
+      quality_check: '质量检查',
+    };
+    return map[stage];
+  }
+
+  regenerationPrompt(stage: IpGenerationSection): string {
+    const map: Record<IpGenerationSection, string> = {
+      intake_summary: '会基于当前档案、轻问卷和对标账号重新归纳资料缺口。后续定位仍需你手动触发重生成。',
+      diagnosis: '会基于最新档案和问卷重新判断定位,可能影响后续机会地图和选题判断。',
+      benchmark: '会基于当前定位和对标账号重新分析可借鉴点、不可照搬点和迁移方向。',
+      opportunities: '会基于当前定位和对标拆解重新计算内容机会方向,不会自动覆盖选题和脚本。',
+      topics_scripts_plan: '会生成新选题、脚本和测试计划;已手动编辑过的选题和脚本会优先保留。',
+      quality_check: '只重新判断当前方案质量,不改变定位、对标、选题、脚本和测试计划正文。',
+    };
+    return map[stage];
+  }
+
+  lastRegenerationText(plan: IpOperatorPlan, stage: IpGenerationSection): string {
+    const note = [...(plan.regenerationNotes || [])]
+      .filter((item) => item.stage === stage)
+      .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0];
+    if (!note) return '尚未局部重生成';
+    const status = note.status === 'failed' ? '失败' : '完成';
+    const preserved = note.preservedUserEdits ? `,保留 ${note.preservedUserEdits} 处编辑` : '';
+    return `最近 ${note.createdAt.slice(5, 16).replace('T', ' ')} ${status}${preserved}`;
+  }
+
+  recentRegenerationNotes(plan: IpOperatorPlan) {
+    return [...(plan.regenerationNotes || [])]
+      .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
+      .slice(0, 5);
+  }
+
+  recentBridgeRecords(plan: IpOperatorPlan): IpOperatorBridgeRecord[] {
+    return [...(plan.bridgeRecords || [])]
+      .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
+      .slice(0, 6);
+  }
+
+  recentEvidenceJobs(plan: IpOperatorPlan): IpEvidenceFetchJob[] {
+    return [...(plan.evidenceFetchJobs || [])]
+      .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
+      .slice(0, 4);
+  }
+
+  recentBenchmarkEvidence(plan: IpOperatorPlan): IpBenchmarkEvidence[] {
+    return [...(plan.benchmarkEvidence || [])]
+      .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
+      .slice(0, 4);
+  }
+
+  recentEvidenceSuggestions(plan: IpOperatorPlan): IpEvidencePolishSuggestion[] {
+    return [...(plan.evidenceSuggestions || [])]
+      .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
+      .slice(0, 6);
+  }
+
+  suggestionTargetLabel(target: IpEvidencePolishSuggestion['targetType']): string {
+    const map: Record<IpEvidencePolishSuggestion['targetType'], string> = {
+      opportunity: '机会地图',
+      topic: '选题',
+      script: '脚本',
+    };
+    return map[target];
+  }
+
+  benchmarkOptions(plan: IpOperatorPlan): BenchmarkAccount[] {
+    return this.ipOperator.listBenchmarks(plan.profileId);
+  }
+
+  evidenceStrengthLabel(strength: IpEvidenceFetchJob['evidenceStrength']): string {
+    const map: Record<IpEvidenceFetchJob['evidenceStrength'], string> = {
+      none: '无证据',
+      weak: '弱证据',
+      medium: '中证据',
+      strong: '强证据',
+    };
+    return map[strength];
+  }
+
+  evidenceJobStatusLabel(status: IpEvidenceFetchJob['status']): string {
+    const map: Record<IpEvidenceFetchJob['status'], string> = {
+      idle: '待开始',
+      fetching_detail: '抓取详情',
+      fetching_comments: '抓取评论',
+      analyzing: '分析中',
+      completed: '已完成',
+      partial: '部分完成',
+      failed: '失败',
+    };
+    return map[status];
+  }
+
+  bridgeTargetLabel(target: IpOperatorBridgeRecord['target']): string {
+    const map: Record<IpOperatorBridgeRecord['target'], string> = {
+      topic_to_video: '主题视频',
+      digital_human: '数字人口播',
+      supplement_input: '补充资料',
+      topic_pool: '选题池',
+    };
+    return map[target];
+  }
+
+  bridgeStatusLabel(status: IpOperatorBridgeRecord['status']): string {
+    const map: Record<IpOperatorBridgeRecord['status'], string> = {
+      created: '已创建',
+      skipped: '已跳过',
+      failed: '失败',
+    };
+    return map[status];
+  }
+
+  displayText(value: unknown): string {
+    if (value === null || value === undefined || value === '') return '待补充';
+    if (Array.isArray(value)) return value.map((item) => this.displayText(item)).filter(Boolean).join(';') || '待补充';
+    if (typeof value === 'object') {
+      const obj = value as Record<string, unknown>;
+      const preferred = ['description', 'suggestion', 'issue', 'reason', 'content', 'text', 'type', 'priority']
+        .map((key) => obj[key])
+        .filter((item) => item !== undefined && item !== null && item !== '');
+      if (preferred.length) return preferred.map((item) => this.displayText(item)).join(':');
+      return Object.entries(obj)
+        .map(([key, item]) => `${key}:${this.displayText(item)}`)
+        .join(';');
+    }
+    return String(value);
+  }
+
+  firstItems<T>(items: T[] | undefined, count: number): T[] {
+    return (items || []).slice(0, count);
+  }
+
+  private buildMarkdown(plan: IpOperatorPlan, kind: 'full' | 'client' | 'execution' | 'topics'): string {
+    if (kind === 'client') return this.buildClientMarkdown(plan);
+    if (kind === 'execution' || kind === 'topics') return this.buildExecutionMarkdown(plan);
+    return this.buildFullMarkdown(plan);
+  }
+
+  private buildFullMarkdown(plan: IpOperatorPlan): string {
+    const lines: string[] = [];
+    const profileName = this.profileDraft().name || '未命名 IP';
+    lines.push(`# ${profileName} IP 起步方案`);
+    lines.push('');
+    lines.push(this.markdownStatusLine(plan));
+    lines.push(`- 方案状态:${this.planStatusLabel(plan.status)}`);
+    lines.push(`- 更新时间:${this.displayText(plan.updatedAt)}`);
+    if (plan.qualityCheck) lines.push(`- 质量分:${plan.qualityCheck.overallScore}/100`);
+    lines.push('');
+
+    this.appendDiagnosisMarkdown(lines, plan, 1);
+    this.appendBenchmarkMarkdown(lines, plan, 2);
+    this.appendOpportunityMarkdown(lines, plan, 3);
+    this.appendTopicsMarkdown(lines, plan, 4, true);
+    this.appendTestPlanMarkdown(lines, plan);
+    this.appendQualityMarkdown(lines, plan);
+    this.appendSupplementMarkdown(lines, plan);
+    return `${lines.join('\n').trim()}\n`;
+  }
+
+  private buildClientMarkdown(plan: IpOperatorPlan): string {
+    const lines: string[] = [];
+    const profileName = this.profileDraft().name || '未命名 IP';
+    lines.push(`# ${profileName} IP 起步沟通版`);
+    lines.push('');
+    lines.push(this.markdownStatusLine(plan));
+    if (plan.qualityCheck) lines.push(`- 当前质量分:${plan.qualityCheck.overallScore}/100`);
+    lines.push('');
+    this.appendDiagnosisMarkdown(lines, plan, 1);
+    this.appendOpportunityMarkdown(lines, plan, 2);
+    this.appendTopicsMarkdown(lines, plan, 3, false, 6);
+    this.appendTestPlanMarkdown(lines, plan, 4);
+    if (plan.missingInputs.length) {
+      lines.push('## 5. 需要继续补充的资料');
+      lines.push('');
+      for (const item of plan.missingInputs) {
+        lines.push(`- ${this.displayText(item.type)}:${this.displayText(item.description)}`);
+      }
+      lines.push('');
+    }
+    return `${lines.join('\n').trim()}\n`;
+  }
+
+  private buildExecutionMarkdown(plan: IpOperatorPlan): string {
+    const lines: string[] = [];
+    const profileName = this.profileDraft().name || '未命名 IP';
+    lines.push(`# ${profileName} IP 内容执行版`);
+    lines.push('');
+    lines.push(this.markdownStatusLine(plan));
+    lines.push('');
+    this.appendTopicsMarkdown(lines, plan, 1, true);
+    this.appendFullScriptsMarkdown(lines, plan, 2);
+    this.appendOutlinesMarkdown(lines, plan, 3);
+    this.appendTestPlanMarkdown(lines, plan, 4);
+    return `${lines.join('\n').trim()}\n`;
+  }
+
+  private appendDiagnosisMarkdown(lines: string[], plan: IpOperatorPlan, index: number): void {
+    lines.push(`## ${index}. 定位诊断`);
+    lines.push('');
+    lines.push(`定位一句话:${this.displayText(plan.diagnosis?.positioningStatement)}`);
+    lines.push(`目标用户:${this.displayText(plan.diagnosis?.targetAudience?.summary)}`);
+    lines.push(`市场机会:${this.displayText(plan.diagnosis?.marketOpportunity)}`);
+    lines.push(`差异化:${this.displayText(plan.diagnosis?.differentiation)}`);
+    lines.push(`变现路径:${this.displayText(plan.diagnosis?.monetizationPath)}`);
+    lines.push('');
+  }
+
+  private appendBenchmarkMarkdown(lines: string[], plan: IpOperatorPlan, index: number): void {
+    lines.push(`## ${index}. 对标拆解`);
+    lines.push('');
+    for (const item of plan.benchmarkAnalyses) {
+      lines.push(`### ${this.displayText(item.accountName)}(${item.fitScore}分)`);
+      lines.push(`- 定位:${this.displayText(item.positioning)}`);
+      lines.push(`- 可借鉴:${this.displayText(item.borrowablePoints)}`);
+      lines.push(`- 不可照搬:${this.displayText(item.nonCopyablePoints)}`);
+      lines.push(`- 迁移方向:${this.displayText(item.migrationDirection)}`);
+      lines.push('');
+    }
+  }
+
+  private appendOpportunityMarkdown(lines: string[], plan: IpOperatorPlan, index: number): void {
+    lines.push(`## ${index}. 机会地图`);
+    lines.push('');
+    for (const item of plan.opportunityMap) {
+      lines.push(`### ${this.displayText(item.type)}(${item.fitScore}分)`);
+      lines.push(`- 理由:${this.displayText(item.reason)}`);
+      lines.push(`- 痛点:${this.displayText(item.audiencePainPoint)}`);
+      lines.push(`- 所需素材:${this.displayText(item.requiredMaterials)}`);
+      lines.push(`- 风险边界:${this.displayText(item.riskBoundary)}`);
+      lines.push('');
+    }
+  }
+
+  private appendTopicsMarkdown(lines: string[], plan: IpOperatorPlan, index: number, includeScript: boolean, limit = 12): void {
+    lines.push(`## ${index}. 选题与脚本`);
+    lines.push('');
+    for (const topic of this.sortedTopicsForPlan(plan).slice(0, limit)) {
+      const script = plan.scripts.find((item) => item.topicId === topic.id);
+      lines.push(`### ${topic.title}`);
+      lines.push(`- 栏目:${this.displayText(topic.column)}`);
+      lines.push(`- 流量层级:${this.trafficLayerLabel(topic.trafficLayer)}`);
+      lines.push(`- 优先级:${this.priorityLabel(topic.priority)}`);
+      lines.push(`- 内容目标:${this.displayText(topic.contentGoal)}`);
+      lines.push(`- 适配理由:${this.displayText(topic.fitReason)}`);
+      lines.push(`- 所需素材:${this.displayText(topic.requiredMaterials)}`);
+      if (includeScript && script) {
+        lines.push(`- 开头钩子:${this.displayText(script.hook)}`);
+        lines.push(`- 观点:${this.displayText(script.viewpoint)}`);
+        if (script.fullScript) lines.push(`- 完整口播:${this.displayText(script.fullScript)}`);
+      }
+      lines.push('');
+    }
+  }
+
+  private appendFullScriptsMarkdown(lines: string[], plan: IpOperatorPlan, index: number): void {
+    const scripts = plan.scripts.filter((script) => script.type === 'full');
+    lines.push(`## ${index}. 3 条完整口播`);
+    lines.push('');
+    for (const script of scripts) {
+      lines.push(`### ${this.displayText(script.title || script.id)}`);
+      lines.push(`- 开头钩子:${this.displayText(script.hook)}`);
+      lines.push(`- 痛点:${this.displayText(script.painPoint)}`);
+      lines.push(`- 观点:${this.displayText(script.viewpoint)}`);
+      lines.push(`- 完整口播:${this.displayText(script.fullScript)}`);
+      lines.push('');
+    }
+  }
+
+  private appendOutlinesMarkdown(lines: string[], plan: IpOperatorPlan, index: number): void {
+    const scripts = plan.scripts.filter((script) => script.type !== 'full');
+    lines.push(`## ${index}. 9 条脚本大纲`);
+    lines.push('');
+    for (const script of scripts) {
+      lines.push(`### ${this.displayText(script.title || script.id)}`);
+      lines.push(`- 钩子:${this.displayText(script.hook)}`);
+      lines.push(`- 痛点:${this.displayText(script.painPoint)}`);
+      lines.push(`- 观点:${this.displayText(script.viewpoint)}`);
+      lines.push(`- 案例/方法:${this.displayText(script.caseOrMethod)}`);
+      lines.push(`- 结尾:${this.displayText(script.closingCta)}`);
+      lines.push('');
+    }
+  }
+
+  private appendTestPlanMarkdown(lines: string[], plan: IpOperatorPlan, index?: number): void {
+    lines.push(index ? `## ${index}. 测试计划` : '## 测试计划');
+    lines.push('');
+    for (const day of plan.sevenDayTestPlan) {
+      lines.push(`### 第 ${day.day} 天:${this.displayText(day.topicTitle)}`);
+      lines.push(`- 测试目标:${this.displayText(day.testGoal)}`);
+      lines.push(`- 拍摄重点:${this.displayText(day.shootingFocus)}`);
+      lines.push(`- 观察指标:${this.displayText(day.observeMetrics)}`);
+      lines.push(`- 复盘问题:${this.displayText(day.reviewQuestions)}`);
+      lines.push('');
+    }
+  }
+
+  private appendQualityMarkdown(lines: string[], plan: IpOperatorPlan): void {
+    lines.push('## 质量检查与待补充资料');
+    lines.push('');
+    if (plan.qualityCheck) {
+      lines.push(`- 阻断问题:${this.displayText(plan.qualityCheck.blockingIssues)}`);
+      lines.push(`- 改进建议:${this.displayText(plan.qualityCheck.improvementSuggestions)}`);
+    }
+    for (const item of plan.missingInputs) {
+      lines.push(`- ${this.displayText(item.type)}:${this.displayText(item.description)}`);
+    }
+    lines.push('');
+  }
+
+  private appendSupplementMarkdown(lines: string[], plan: IpOperatorPlan): void {
+    if (!plan.supplementInputs?.length) return;
+    lines.push('## 已补充资料');
+    lines.push('');
+    for (const item of plan.supplementInputs) {
+      lines.push(`### ${this.displayText(item.title)}`);
+      lines.push(`- 类型:${this.displayText(item.type)}`);
+      lines.push(this.displayText(item.content));
+      lines.push('');
+    }
+  }
+
+  private markdownStatusLine(plan: IpOperatorPlan): string {
+    if (plan.status === 'failed') return '> 状态标注:生成失败草稿。内容仅供排查和继续打磨,不建议直接交付执行。';
+    if (plan.status === 'needs_input') return '> 状态标注:待完善草稿。需补充资料并重新质量检查后再进入生产链路。';
+    if (plan.status === 'ready') return '> 状态标注:可继续打磨方案。已通过当前质量门槛,可进入选题池继续制作。';
+    return '> 状态标注:草稿。';
+  }
+
+  private sortedTopicsForPlan(plan: IpOperatorPlan): IpTopic[] {
+    const priorityOrder: Record<string, number> = { high: 0, medium: 1, low: 2 };
+    return [...(plan.topics || [])].sort((a, b) => {
+      const byPriority = (priorityOrder[a.priority] ?? 9) - (priorityOrder[b.priority] ?? 9);
+      return byPriority || a.title.localeCompare(b.title);
+    });
+  }
+
+  private timestampForFilename(): string {
+    const d = new Date();
+    const pad = (value: number) => String(value).padStart(2, '0');
+    return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
+  }
+
+  private downloadText(filename: string, text: string): void {
+    const blob = new Blob([text], { type: 'text/markdown;charset=utf-8' });
+    const url = URL.createObjectURL(blob);
+    const link = document.createElement('a');
+    link.href = url;
+    link.download = filename.replace(/[\\/:*?"<>|]/g, '-');
+    link.click();
+    URL.revokeObjectURL(url);
+  }
+
+  private fallbackCopy(text: string): void {
+    const textarea = document.createElement('textarea');
+    textarea.value = text;
+    textarea.style.position = 'fixed';
+    textarea.style.left = '-9999px';
+    document.body.appendChild(textarea);
+    textarea.select();
+    document.execCommand('copy');
+    document.body.removeChild(textarea);
+    this.copyMessage.set('调试信息已复制');
+  }
+
+  topicScript(topic: IpTopic): IpScript | null {
+    return this.activePlan()?.scripts.find((script) => script.topicId === topic.id) || null;
+  }
+
+  trackByIndex(index: number): number {
+    return index;
+  }
+
+  trackById(_: number, item: { id: string }): string {
+    return item.id;
+  }
+
+  trackByStage(_: number, item: { stage: string }): string {
+    return item.stage;
+  }
+
+  private toProfileInput(): Partial<IpOperatorProfile> {
+    const draft = this.profileDraft();
+    return {
+      id: draft.id,
+      name: draft.name,
+      stage: draft.stage,
+      identity: draft.identity,
+      industry: draft.industry,
+      targetAudience: draft.targetAudience,
+      audiencePainPoints: this.toList(draft.audiencePainPointsText),
+      productsOrServices: draft.productsOrServices,
+      personalStories: this.toList(draft.personalStoriesText),
+      expertise: this.toList(draft.expertiseText),
+      expressionStyle: this.toList(draft.expressionStyleText),
+      boundaries: this.toList(draft.boundariesText),
+      goals: this.toList(draft.goalsText),
+      questionnaire: QUESTIONNAIRE.map((question, index) => ({
+        id: `q_${index + 1}`,
+        question,
+        answer: this.questionnaireAnswers()[index] || '',
+      })),
+    };
+  }
+
+  private toBenchmarks(profileId: string): BenchmarkAccount[] {
+    const now = new Date().toISOString();
+    return this.benchmarkDrafts().filter((item) => this.hasBenchmarkSignal(item)).map((item, index) => ({
+      id: item.id || `benchmark_${index + 1}`,
+      profileId,
+      name: item.name,
+      url: item.url,
+      bio: item.bio,
+      reasonToBenchmark: item.reasonToBenchmark,
+      conversionNotes: item.conversionNotes,
+      viralExamples: [{
+        id: `viral_${index + 1}`,
+        title: item.viralTitle,
+        url: item.viralUrl,
+        performance: item.performance,
+        perceivedHook: item.perceivedHook,
+        notes: item.notes,
+      }],
+      createdAt: now,
+      updatedAt: now,
+    }));
+  }
+
+  private emptyProfileDraft(): ProfileDraft {
+    return {
+      name: '',
+      stage: 'new',
+      identity: '',
+      industry: '',
+      targetAudience: '',
+      audiencePainPointsText: '',
+      productsOrServices: '',
+      personalStoriesText: '',
+      expertiseText: '',
+      expressionStyleText: '',
+      boundariesText: '',
+      goalsText: '',
+    };
+  }
+
+  private emptyBenchmarkDraft(): BenchmarkDraft {
+    return {
+      name: '',
+      url: '',
+      bio: '',
+      reasonToBenchmark: '',
+      viralTitle: '',
+      viralUrl: '',
+      performance: '',
+      perceivedHook: '',
+      notes: '',
+      conversionNotes: '',
+    };
+  }
+
+  private hasBenchmarkSignal(item: BenchmarkDraft): boolean {
+    return !!(
+      item.name.trim()
+      || item.url.trim()
+      || item.viralUrl.trim()
+      || item.reasonToBenchmark.trim()
+      || item.viralTitle.trim()
+    );
+  }
+
+  private toList(value: string): string[] {
+    return value.split(/\r?\n|,|、/).map((item) => item.trim()).filter(Boolean);
+  }
+
+  protected readonly questionnaire = QUESTIONNAIRE;
+}

+ 80 - 1
src/app/pages/library/library.component.css

@@ -249,8 +249,35 @@
 .library-card__audio-icon { font-size: 36px; }
 .library-card__audio-icon { font-size: 36px; }
 .library-card__audio audio { width: 100%; }
 .library-card__audio audio { width: 100%; }
 
 
+.library-card__text {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  gap: 8px;
+  padding: 18px;
+  background: #f8fafc;
+  color: #0f172a;
+}
+
+.library-card__text strong {
+  font-size: 15px;
+}
+
+.library-card__text span {
+  display: -webkit-box;
+  overflow: hidden;
+  color: #475569;
+  font-size: 13px;
+  line-height: 1.6;
+  -webkit-line-clamp: 4;
+  -webkit-box-orient: vertical;
+}
+
 .library-card__badge,
 .library-card__badge,
-.library-card__type-badge {
+.library-card__type-badge,
+.library-card__status-badge {
   position: absolute;
   position: absolute;
   top: 8px;
   top: 8px;
   padding: 3px 8px;
   padding: 3px 8px;
@@ -268,6 +295,11 @@
 .library-card__type-badge {
 .library-card__type-badge {
   left: 8px;
   left: 8px;
 }
 }
+.library-card__status-badge {
+  left: 8px;
+  top: 34px;
+  background: #0f172a;
+}
 .library-select {
 .library-select {
   position: absolute;
   position: absolute;
   right: 8px;
   right: 8px;
@@ -451,6 +483,53 @@
   white-space: pre-wrap;
   white-space: pre-wrap;
 }
 }
 
 
+.library-material-editor {
+  display: grid;
+  gap: 10px;
+  margin-top: 14px;
+  padding: 12px;
+  border: 1px solid #bfdbfe;
+  border-radius: 8px;
+  background: #f8fbff;
+}
+
+.library-material-editor header {
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+  align-items: flex-start;
+}
+
+.library-material-editor header span,
+.library-material-editor header small {
+  color: var(--text-secondary);
+  font-size: 12px;
+}
+
+.library-material-editor header strong {
+  display: block;
+  margin-top: 3px;
+  color: var(--text-primary);
+}
+
+.library-material-editor label {
+  display: grid;
+  gap: 6px;
+  color: var(--text-secondary);
+  font-size: 12px;
+}
+
+.library-material-editor textarea {
+  width: 100%;
+  resize: vertical;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--surface-color, #fff);
+  color: var(--text-primary);
+  padding: 10px;
+  line-height: 1.6;
+}
+
 @media (max-width: 720px) {
 @media (max-width: 720px) {
   .library-page { padding-bottom: 20px; }
   .library-page { padding-bottom: 20px; }
 
 

+ 79 - 13
src/app/pages/library/library.component.html

@@ -79,6 +79,12 @@
         🎵 音频 <span class="library-chip__num">{{ typeCounts().audio }}</span>
         🎵 音频 <span class="library-chip__num">{{ typeCounts().audio }}</span>
       </button>
       </button>
 
 
+      <button type="button" class="library-chip"
+              [class.is-active]="typeFilter() === 'text'"
+              (click)="setTypeFilter('text')">
+        文本 <span class="library-chip__num">{{ typeCounts().text }}</span>
+      </button>
+
       <div class="library-search">
       <div class="library-search">
         <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
         <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="11" cy="11" r="8"></circle>
           <circle cx="11" cy="11" r="8"></circle>
@@ -90,6 +96,49 @@
       </div>
       </div>
     </div>
     </div>
 
 
+    <div class="library-filter-row library-filter-row--secondary">
+      <button type="button" class="library-chip"
+              [class.is-active]="sourceFilter() === 'all'"
+              (click)="setSourceFilter('all')">
+        全部来源类型
+      </button>
+      <button type="button" class="library-chip"
+              [class.is-active]="sourceFilter() === 'ip_operator'"
+              (click)="setSourceFilter('ip_operator')">
+        IP操盘
+      </button>
+      <button type="button" class="library-chip"
+              *ngIf="sourceFilter() === 'ip_operator'"
+              [class.is-active]="materialStatusFilter() === 'all'"
+              (click)="setMaterialStatusFilter('all')">
+        全部资料状态
+      </button>
+      <button type="button" class="library-chip"
+              *ngIf="sourceFilter() === 'ip_operator'"
+              [class.is-active]="materialStatusFilter() === 'todo'"
+              (click)="setMaterialStatusFilter('todo')">
+        待补资料
+      </button>
+      <button type="button" class="library-chip"
+              *ngIf="sourceFilter() === 'ip_operator'"
+              [class.is-active]="materialStatusFilter() === 'filled'"
+              (click)="setMaterialStatusFilter('filled')">
+        已补充
+      </button>
+      <button type="button" class="library-chip"
+              *ngIf="sourceFilter() === 'ip_operator'"
+              [class.is-active]="ipProfileFilter() === 'all'"
+              (click)="setIpProfileFilter('all')">
+        全部 IP 档案
+      </button>
+      <button type="button" class="library-chip"
+              *ngFor="let profile of ipProfileOptions()"
+              [class.is-active]="ipProfileFilter() === profile.id"
+              (click)="setIpProfileFilter(profile.id)">
+        {{ profile.name }}
+      </button>
+    </div>
+
     <div class="library-filter-row library-filter-row--secondary">
     <div class="library-filter-row library-filter-row--secondary">
       <button type="button" class="library-chip"
       <button type="button" class="library-chip"
               [class.is-active]="stateFilter() === 'active'"
               [class.is-active]="stateFilter() === 'active'"
@@ -176,6 +225,10 @@
             <audio [src]="it.url" controls preload="metadata"
             <audio [src]="it.url" controls preload="metadata"
                    (click)="$event.stopPropagation()"></audio>
                    (click)="$event.stopPropagation()"></audio>
           </div>
           </div>
+          <div *ngSwitchCase="'text'" class="library-card__text">
+            <strong>待补充资料</strong>
+            <span>{{ it.metadata?.parameters?.['description'] || it.title }}</span>
+          </div>
         </ng-container>
         </ng-container>
 
 
         <div class="library-card__badge" *ngIf="it.isFinal">
         <div class="library-card__badge" *ngIf="it.isFinal">
@@ -187,7 +240,10 @@
           {{ isBulkSelected(it) ? '已选' : '选择' }}
           {{ isBulkSelected(it) ? '已选' : '选择' }}
         </button>
         </button>
         <div class="library-card__type-badge">
         <div class="library-card__type-badge">
-          <span>{{ typeIcon(it.type) }} {{ it.type === 'video' ? '视频' : it.type === 'image' ? '图片' : '音频' }}</span>
+          <span>{{ typeIcon(it.type) }} {{ typeLabel(it.type) }}</span>
+        </div>
+        <div class="library-card__status-badge" *ngIf="materialStatusLabel(it)">
+          <span>{{ materialStatusLabel(it) }}</span>
         </div>
         </div>
       </div>
       </div>
 
 
@@ -198,6 +254,9 @@
         <div class="library-card__sub">
         <div class="library-card__sub">
           <span>来自:{{ it.draftTitle }}</span>
           <span>来自:{{ it.draftTitle }}</span>
         </div>
         </div>
+        <div class="library-card__sub" *ngIf="it.metadata?.source?.ipOperatorPlanId">
+          <span>IP操盘:{{ it.metadata?.parameters?.['missingInputType'] || '待补资料' }}</span>
+        </div>
         <div class="library-card__time">{{ formatRelative(it.createdAt) }}</div>
         <div class="library-card__time">{{ formatRelative(it.createdAt) }}</div>
         <div class="library-card__tags" *ngIf="it.metadata?.tags?.length">
         <div class="library-card__tags" *ngIf="it.metadata?.tags?.length">
           <span *ngFor="let tag of it.metadata?.tags">{{ tag }}</span>
           <span *ngFor="let tag of it.metadata?.tags">{{ tag }}</span>
@@ -259,19 +318,26 @@
     </div>
     </div>
     <button class="library-action library-action--primary" type="button" (click)="saveMetadata()">保存元数据</button>
     <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>
+    <section class="library-material-editor" *ngIf="isIpTextMaterial(selected)">
+      <header>
+        <div>
+          <span>IP 操盘素材回填</span>
+          <strong>{{ materialStatusLabel(selected) || '待补资料' }}</strong>
+        </div>
+        <small>{{ materialEditMessage() || '填写后会标记为已补充,可回到 IP 操盘导入补充资料。' }}</small>
+      </header>
+      <label>
+        已补充内容
+        <textarea rows="5"
+                  [ngModel]="editMaterialContent()"
+                  (ngModelChange)="editMaterialContent.set($event)"
+                  placeholder="填写真实案例、客户反馈、服务前后对比、用户问题等内容"></textarea>
+      </label>
+      <button class="library-action library-action--primary" type="button" (click)="saveFilledMaterial()">
+        保存为已补充
+      </button>
+    </section>
 
 
-    <details class="library-meta__params">
-      <summary>生成参数快照</summary>
-      <pre>{{ formatParams(selected.metadata?.parameters) }}</pre>
-    </details>
   </aside>
   </aside>
 
 
 </section>
 </section>

+ 139 - 18
src/app/pages/library/library.component.ts

@@ -13,7 +13,7 @@ import { FormsModule } from '@angular/forms';
 import { PipelineSessionService } from '../../services/pipeline-session.service';
 import { PipelineSessionService } from '../../services/pipeline-session.service';
 import { PipelineRoute, PIPELINES } from '../../pipelines/pipeline-registry';
 import { PipelineRoute, PIPELINES } from '../../pipelines/pipeline-registry';
 import { Artifact, PipelineDraft } from '../../models/pipeline-draft.model';
 import { Artifact, PipelineDraft } from '../../models/pipeline-draft.model';
-import { ContentAssetMetadata } from '../../models/content-asset.model';
+import { ContentAssetMetadata, ContentAssetType } from '../../models/content-asset.model';
 import { ContentAssetService } from '../../services/content-asset.service';
 import { ContentAssetService } from '../../services/content-asset.service';
 
 
 /** 素材库的扁平条目(来自 drafts 的成片 + artifacts) */
 /** 素材库的扁平条目(来自 drafts 的成片 + artifacts) */
@@ -23,7 +23,7 @@ interface LibraryItem {
   draftId: string;
   draftId: string;
   draftTitle: string;
   draftTitle: string;
   pipelineId: PipelineRoute;
   pipelineId: PipelineRoute;
-  type: 'image' | 'video' | 'audio';
+  type: ContentAssetType;
   url: string;
   url: string;
   /** 显式标题:成片 = draft.title;中间产物 = artifact.title */
   /** 显式标题:成片 = draft.title;中间产物 = artifact.title */
   title: string;
   title: string;
@@ -37,9 +37,12 @@ interface LibraryItem {
   metadata?: ContentAssetMetadata;
   metadata?: ContentAssetMetadata;
 }
 }
 
 
-type LibraryFilterType = 'all' | 'video' | 'image' | 'audio';
+type LibraryFilterType = 'all' | ContentAssetType;
 type LibraryViewMode = 'final' | 'all';
 type LibraryViewMode = 'final' | 'all';
 type AssetStateFilter = 'active' | 'favorite' | 'archived' | 'all';
 type AssetStateFilter = 'active' | 'favorite' | 'archived' | 'all';
+type AssetSourceFilter = 'all' | 'ip_operator';
+type MaterialStatusFilter = 'all' | 'todo' | 'filled';
+const LIBRARY_PREFILTER_KEY = 'videoWorkflow.library.prefilter';
 
 
 /**
 /**
  * 素材库 —— 跨 Pipeline 的 AI 生成成果聚合视图。
  * 素材库 —— 跨 Pipeline 的 AI 生成成果聚合视图。
@@ -77,10 +80,15 @@ export class LibraryComponent implements OnInit {
   readonly projectFilter = signal('all');
   readonly projectFilter = signal('all');
   readonly tagFilter = signal('all');
   readonly tagFilter = signal('all');
   readonly stateFilter = signal<AssetStateFilter>('active');
   readonly stateFilter = signal<AssetStateFilter>('active');
+  readonly sourceFilter = signal<AssetSourceFilter>('all');
+  readonly materialStatusFilter = signal<MaterialStatusFilter>('all');
+  readonly ipProfileFilter = signal('all');
   readonly searchTerm = signal('');
   readonly searchTerm = signal('');
   readonly selectedItemKey = signal('');
   readonly selectedItemKey = signal('');
   readonly editProject = signal('');
   readonly editProject = signal('');
   readonly editTags = signal('');
   readonly editTags = signal('');
+  readonly editMaterialContent = signal('');
+  readonly materialEditMessage = signal('');
   readonly selectedKeys = signal<string[]>([]);
   readonly selectedKeys = signal<string[]>([]);
   readonly bulkProject = signal('');
   readonly bulkProject = signal('');
   readonly bulkTags = signal('');
   readonly bulkTags = signal('');
@@ -88,6 +96,7 @@ export class LibraryComponent implements OnInit {
   /** 扁平后的条目(响应式) */
   /** 扁平后的条目(响应式) */
   readonly items = computed<LibraryItem[]>(() => {
   readonly items = computed<LibraryItem[]>(() => {
     const drafts = this.drafts();
     const drafts = this.drafts();
+    const metadata = this.assetMetadata();
     const mode = this.viewMode();
     const mode = this.viewMode();
     const out: LibraryItem[] = [];
     const out: LibraryItem[] = [];
 
 
@@ -131,6 +140,23 @@ export class LibraryComponent implements OnInit {
         }
         }
       }
       }
     }
     }
+    const existingKeys = new Set(out.map((item) => item.key));
+    for (const asset of metadata) {
+      if (existingKeys.has(asset.assetKey)) continue;
+      if (asset.type !== 'text') continue;
+      out.push({
+        key: asset.assetKey,
+        draftId: asset.draftId,
+        draftTitle: asset.source?.draftTitle || asset.title,
+        pipelineId: asset.pipelineId,
+        type: asset.type,
+        url: asset.url,
+        title: asset.title,
+        isFinal: !!asset.source?.isFinal,
+        createdAt: asset.createdAt,
+        metadata: asset,
+      });
+    }
     out.sort((a, b) => b.createdAt - a.createdAt);
     out.sort((a, b) => b.createdAt - a.createdAt);
     return out;
     return out;
   });
   });
@@ -141,10 +167,13 @@ export class LibraryComponent implements OnInit {
     const tf = this.typeFilter();
     const tf = this.typeFilter();
     const pf = this.pipelineFilter();
     const pf = this.pipelineFilter();
     const project = this.projectFilter();
     const project = this.projectFilter();
-    const tag = this.tagFilter();
-    const state = this.stateFilter();
-    const kw = this.searchTerm().trim().toLowerCase();
-    return items.filter((it) => {
+      const tag = this.tagFilter();
+      const state = this.stateFilter();
+      const source = this.sourceFilter();
+      const materialStatus = this.materialStatusFilter();
+      const ipProfile = this.ipProfileFilter();
+      const kw = this.searchTerm().trim().toLowerCase();
+      return items.filter((it) => {
       if (tf !== 'all' && it.type !== tf) return false;
       if (tf !== 'all' && it.type !== tf) return false;
       if (pf !== 'all' && it.pipelineId !== pf) return false;
       if (pf !== 'all' && it.pipelineId !== pf) return false;
       if (project !== 'all' && (it.metadata?.projectName || '') !== project) return false;
       if (project !== 'all' && (it.metadata?.projectName || '') !== project) return false;
@@ -152,6 +181,9 @@ export class LibraryComponent implements OnInit {
       if (state === 'active' && it.metadata?.archived) return false;
       if (state === 'active' && it.metadata?.archived) return false;
       if (state === 'favorite' && !it.metadata?.favorite) return false;
       if (state === 'favorite' && !it.metadata?.favorite) return false;
       if (state === 'archived' && !it.metadata?.archived) return false;
       if (state === 'archived' && !it.metadata?.archived) return false;
+      if (source === 'ip_operator' && !it.metadata?.source?.ipOperatorPlanId) return false;
+      if (materialStatus !== 'all' && it.metadata?.parameters?.['materialStatus'] !== materialStatus) return false;
+      if (ipProfile !== 'all' && it.metadata?.source?.ipOperatorProfileId !== ipProfile) return false;
       if (kw && !this.searchableText(it).includes(kw)) return false;
       if (kw && !this.searchableText(it).includes(kw)) return false;
       return true;
       return true;
     });
     });
@@ -167,6 +199,19 @@ export class LibraryComponent implements OnInit {
     return Array.from(new Set(tags)).sort();
     return Array.from(new Set(tags)).sort();
   });
   });
 
 
+  readonly ipProfileOptions = computed(() => {
+    const profiles = new Map<string, string>();
+    for (const item of this.assetMetadata()) {
+      const id = item.source?.ipOperatorProfileId;
+      if (!id) continue;
+      const name = this.ipProfileName(item) || id;
+      profiles.set(id, name);
+    }
+    return Array.from(profiles.entries())
+      .map(([id, name]) => ({ id, name }))
+      .sort((a, b) => a.name.localeCompare(b.name));
+  });
+
   readonly selectedItem = computed(() => {
   readonly selectedItem = computed(() => {
     const key = this.selectedItemKey();
     const key = this.selectedItemKey();
     return this.filteredItems().find((item) => item.key === key) || this.filteredItems()[0] || null;
     return this.filteredItems().find((item) => item.key === key) || this.filteredItems()[0] || null;
@@ -182,10 +227,12 @@ export class LibraryComponent implements OnInit {
       video: base.filter((i) => i.type === 'video').length,
       video: base.filter((i) => i.type === 'video').length,
       image: base.filter((i) => i.type === 'image').length,
       image: base.filter((i) => i.type === 'image').length,
       audio: base.filter((i) => i.type === 'audio').length,
       audio: base.filter((i) => i.type === 'audio').length,
+      text: base.filter((i) => i.type === 'text').length,
     };
     };
   });
   });
 
 
   async ngOnInit(): Promise<void> {
   async ngOnInit(): Promise<void> {
+    this.applyPrefilterFromSession();
     await this.refresh();
     await this.refresh();
   }
   }
 
 
@@ -207,6 +254,18 @@ export class LibraryComponent implements OnInit {
   setProjectFilter(p: string): void { this.projectFilter.set(p); }
   setProjectFilter(p: string): void { this.projectFilter.set(p); }
   setTagFilter(t: string): void { this.tagFilter.set(t); }
   setTagFilter(t: string): void { this.tagFilter.set(t); }
   setStateFilter(s: AssetStateFilter): void { this.stateFilter.set(s); }
   setStateFilter(s: AssetStateFilter): void { this.stateFilter.set(s); }
+  setSourceFilter(s: AssetSourceFilter): void {
+    this.sourceFilter.set(s);
+    if (s !== 'ip_operator') {
+      this.materialStatusFilter.set('all');
+      this.ipProfileFilter.set('all');
+    } else {
+      this.typeFilter.set('text');
+      this.viewMode.set('all');
+    }
+  }
+  setMaterialStatusFilter(s: MaterialStatusFilter): void { this.materialStatusFilter.set(s); }
+  setIpProfileFilter(profileId: string): void { this.ipProfileFilter.set(profileId); }
 
 
   // ============== 操作 ==============
   // ============== 操作 ==============
   /** 跳到对应 draft 详情 */
   /** 跳到对应 draft 详情 */
@@ -222,6 +281,8 @@ export class LibraryComponent implements OnInit {
     this.selectedItemKey.set(it.key);
     this.selectedItemKey.set(it.key);
     this.editProject.set(it.metadata?.projectName || '');
     this.editProject.set(it.metadata?.projectName || '');
     this.editTags.set((it.metadata?.tags || []).join(', '));
     this.editTags.set((it.metadata?.tags || []).join(', '));
+    this.editMaterialContent.set(this.filledContent(it));
+    this.materialEditMessage.set('');
   }
   }
 
 
   saveMetadata(): void {
   saveMetadata(): void {
@@ -234,6 +295,31 @@ export class LibraryComponent implements OnInit {
     this.assetMetadata.set(this.assetStore.all());
     this.assetMetadata.set(this.assetStore.all());
   }
   }
 
 
+  saveFilledMaterial(): void {
+    const item = this.selectedItem();
+    const content = this.editMaterialContent().trim();
+    if (!item?.metadata?.source?.ipOperatorPlanId && !item?.metadata?.parameters?.['relatedPlanId']) return;
+    if (item.type !== 'text' || !content) {
+      this.materialEditMessage.set('请先填写已补充内容。');
+      return;
+    }
+    const updated = this.assetStore.updateParameters(item.key, {
+      content,
+      filledContent: content,
+      materialStatus: 'filled',
+      filledAt: new Date().toISOString(),
+    });
+    if (!updated) {
+      this.materialEditMessage.set('素材不存在,保存失败。');
+      return;
+    }
+    this.assetStore.updateUserFields(item.key, {
+      tags: Array.from(new Set([...(updated.tags || []), '已补充'])),
+    });
+    this.assetMetadata.set(this.assetStore.all());
+    this.materialEditMessage.set('已保存为已补充素材,可回到 IP 操盘导入。');
+  }
+
   toggleFavorite(it: LibraryItem, ev?: Event): void {
   toggleFavorite(it: LibraryItem, ev?: Event): void {
     ev?.stopPropagation();
     ev?.stopPropagation();
     this.assetStore.updateUserFields(it.key, { favorite: !it.metadata?.favorite });
     this.assetStore.updateUserFields(it.key, { favorite: !it.metadata?.favorite });
@@ -284,6 +370,7 @@ export class LibraryComponent implements OnInit {
   /** 打开新窗口播放/查看 */
   /** 打开新窗口播放/查看 */
   openExternal(it: LibraryItem, ev: Event): void {
   openExternal(it: LibraryItem, ev: Event): void {
     ev.stopPropagation();
     ev.stopPropagation();
+    if (it.type === 'text') return;
     window.open(it.url, '_blank', 'noopener');
     window.open(it.url, '_blank', 'noopener');
   }
   }
 
 
@@ -318,10 +405,30 @@ export class LibraryComponent implements OnInit {
     return this.pipelines.find((p) => p.route === id)?.displayName || id;
     return this.pipelines.find((p) => p.route === id)?.displayName || id;
   }
   }
 
 
-  typeIcon(t: 'image' | 'video' | 'audio'): string {
+  typeIcon(t: ContentAssetType): string {
+    if (t === 'text') return '文本';
     return t === 'image' ? '🖼️' : t === 'video' ? '🎬' : '🎵';
     return t === 'image' ? '🖼️' : t === 'video' ? '🎬' : '🎵';
   }
   }
 
 
+  typeLabel(t: ContentAssetType): string {
+    return t === 'image' ? '图片' : t === 'video' ? '视频' : t === 'audio' ? '音频' : '文本素材';
+  }
+
+  materialStatusLabel(it: LibraryItem): string {
+    const status = it.metadata?.parameters?.['materialStatus'];
+    if (status === 'todo') return '待补资料';
+    if (status === 'filled') return '已补充';
+    return '';
+  }
+
+  isIpTextMaterial(it: LibraryItem): boolean {
+    return it.type === 'text' && !!(it.metadata?.source?.ipOperatorPlanId || it.metadata?.parameters?.['relatedPlanId']);
+  }
+
+  filledContent(it: LibraryItem): string {
+    return String(it.metadata?.parameters?.['content'] || it.metadata?.parameters?.['filledContent'] || '');
+  }
+
   formatTime(ts: number): string {
   formatTime(ts: number): string {
     if (!ts) return '';
     if (!ts) return '';
     const d = new Date(ts);
     const d = new Date(ts);
@@ -340,15 +447,6 @@ export class LibraryComponent implements OnInit {
   trackByKey(_: number, it: LibraryItem): string { return it.key; }
   trackByKey(_: number, it: LibraryItem): string { return it.key; }
   trackById(_: number, x: { id: string }): string { return x.id; }
   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 {
   private ensureAssetMetadata(item: LibraryItem, draft: PipelineDraft, artifact?: Artifact): ContentAssetMetadata {
     const snapshot = this.safeObject(draft.snapshot);
     const snapshot = this.safeObject(draft.snapshot);
     const extras = this.safeObject(artifact?.extras);
     const extras = this.safeObject(artifact?.extras);
@@ -386,9 +484,32 @@ export class LibraryComponent implements OnInit {
       it.metadata?.source?.templateId,
       it.metadata?.source?.templateId,
       it.metadata?.source?.topicId,
       it.metadata?.source?.topicId,
       it.metadata?.source?.batchJobId,
       it.metadata?.source?.batchJobId,
+      it.metadata?.source?.ipOperatorPlanId,
+      it.metadata?.source?.ipOperatorProfileId,
+      it.metadata?.parameters?.['missingInputDescription'],
+      it.metadata?.parameters?.['missingInputType'],
     ].filter(Boolean).join(' ').toLowerCase();
     ].filter(Boolean).join(' ').toLowerCase();
   }
   }
 
 
+  private applyPrefilterFromSession(): void {
+    try {
+      const raw = sessionStorage.getItem(LIBRARY_PREFILTER_KEY);
+      if (!raw) return;
+      sessionStorage.removeItem(LIBRARY_PREFILTER_KEY);
+      const parsed = JSON.parse(raw) as { source?: string; materialStatus?: string; profileId?: string };
+      if (parsed.source === 'ip_operator') this.setSourceFilter('ip_operator');
+      if (parsed.materialStatus === 'todo' || parsed.materialStatus === 'filled') this.materialStatusFilter.set(parsed.materialStatus);
+      if (parsed.profileId) this.ipProfileFilter.set(parsed.profileId);
+    } catch {}
+  }
+
+  private ipProfileName(asset: ContentAssetMetadata): string {
+    const project = asset.projectName || '';
+    if (project.startsWith('IP操盘-')) return project.replace(/^IP操盘-/, '');
+    const profileTag = (asset.tags || []).find((tag) => tag && tag !== 'IP操盘' && tag !== '待补资料' && tag !== '待补充资料');
+    return profileTag || '';
+  }
+
   private safeObject(value: any): Record<string, any> {
   private safeObject(value: any): Record<string, any> {
     return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
     return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
   }
   }
@@ -399,7 +520,7 @@ export class LibraryComponent implements OnInit {
   }
   }
 
 
   private deriveFilename(it: LibraryItem): string {
   private deriveFilename(it: LibraryItem): string {
-    const ext = it.type === 'video' ? '.mp4' : it.type === 'audio' ? '.mp3' : '.jpg';
+    const ext = it.type === 'video' ? '.mp4' : it.type === 'audio' ? '.mp3' : it.type === 'text' ? '.md' : '.jpg';
     const safe = it.title.replace(/[\\/:*?"<>|]/g, '_');
     const safe = it.title.replace(/[\\/:*?"<>|]/g, '_');
     return `${safe}${ext}`;
     return `${safe}${ext}`;
   }
   }

+ 279 - 0
src/app/pages/pipelines/topic-to-video/services/topic-continuity.service.ts

@@ -0,0 +1,279 @@
+import { Injectable } from '@angular/core';
+
+export type TopicVideoShotType = 'subject' | 'action' | 'detail' | 'environment' | 'transition' | 'ending';
+export type TopicVideoReferenceMode = 'auto' | 'none' | 'previous' | 'current-head';
+
+export interface TopicVideoVisualBible {
+  subject: string;
+  product: string;
+  location: string;
+  palette: string;
+  lighting: string;
+  camera: string;
+  styleNotes: string;
+  constraints: string;
+  subjectImageUrl?: string;
+  productImageUrl?: string;
+  locationImageUrl?: string;
+}
+
+export interface TopicVideoContinuityScene {
+  scenePrompt: string;
+  shotType?: TopicVideoShotType;
+  inheritSubject?: boolean;
+  inheritLocation?: boolean;
+  inheritStyle?: boolean;
+  referenceMode?: TopicVideoReferenceMode;
+  startState?: string;
+  endState?: string;
+  continuity?: string;
+  narration?: string;
+  imageUrl?: string;
+  tailImageUrl?: string;
+}
+
+@Injectable({ providedIn: 'root' })
+export class TopicContinuityService {
+  createEmptyVisualBible(): TopicVideoVisualBible {
+    return {
+      subject: '',
+      product: '',
+      location: '',
+      palette: '',
+      lighting: '',
+      camera: '',
+      styleNotes: '',
+      constraints: '',
+      subjectImageUrl: '',
+      productImageUrl: '',
+      locationImageUrl: '',
+    };
+  }
+
+  sanitizeVisualBible(input?: Partial<TopicVideoVisualBible> | null): TopicVideoVisualBible {
+    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 : '';
+  }
+
+  defaultInheritanceFor(shotType: TopicVideoShotType): { subject: boolean; location: boolean; style: boolean } {
+    return {
+      subject: !['environment', 'transition'].includes(shotType),
+      location: shotType !== 'transition',
+      style: true,
+    };
+  }
+
+  applyShotType<T extends TopicVideoContinuityScene>(scene: T, shotType: TopicVideoShotType): T {
+    const defaults = this.defaultInheritanceFor(shotType);
+    return {
+      ...scene,
+      shotType,
+      inheritSubject: defaults.subject,
+      inheritLocation: defaults.location,
+      inheritStyle: defaults.style,
+      referenceMode: shotType === 'transition' ? 'none' : (shotType === 'environment' ? 'previous' : 'auto'),
+    };
+  }
+
+  resolveInheritance(scene?: TopicVideoContinuityScene): { subject: boolean; location: boolean; style: boolean } {
+    const shotType = scene?.shotType || 'action';
+    const defaults = this.defaultInheritanceFor(shotType);
+    return {
+      subject: scene?.inheritSubject ?? defaults.subject,
+      location: scene?.inheritLocation ?? defaults.location,
+      style: scene?.inheritStyle ?? defaults.style,
+    };
+  }
+
+  visualBibleTextFor(enabled: boolean, bible: TopicVideoVisualBible, scene?: TopicVideoContinuityScene): string {
+    if (!enabled) return '';
+    const inherit = this.resolveInheritance(scene);
+    const parts = [
+      inherit.subject && bible.subject ? `主体:${bible.subject}` : '',
+      inherit.subject && bible.product ? `产品:${bible.product}` : '',
+      inherit.location && bible.location ? `场景:${bible.location}` : '',
+      inherit.style && bible.palette ? `配色:${bible.palette}` : '',
+      inherit.style && bible.lighting ? `光线:${bible.lighting}` : '',
+      inherit.style && bible.camera ? `镜头:${bible.camera}` : '',
+      inherit.style && bible.styleNotes ? `风格:${bible.styleNotes}` : '',
+      bible.constraints ? `必须保持:${bible.constraints}` : '',
+    ].filter(Boolean);
+    return parts.join(', ');
+  }
+
+  buildScenePrompt(
+    enabled: boolean,
+    bible: TopicVideoVisualBible,
+    scene: TopicVideoContinuityScene,
+    frame: 'head' | 'tail' | 'video' = 'head',
+    index = -1,
+  ): string {
+    const bibleText = this.visualBibleTextFor(enabled, bible, scene);
+    const shotType = scene.shotType || 'action';
+    const inherit = this.resolveInheritance(scene);
+    const state = frame === 'tail'
+      ? (scene.endState || scene.continuity || '')
+      : (scene.startState || scene.continuity || '');
+    const isEmptyScene = !inherit.subject;
+    const frameRule = frame === 'tail'
+      ? (isEmptyScene
+          ? '尾帧,纯环境构图,呈现明显更晚的时刻,镜头角度和光线节奏与首帧不同'
+          : '尾帧,呈现首帧之后的明确后续时刻,主体或产品的位置与姿态发生变化,镜头角度不同,不要复制首帧构图')
+      : frame === 'video'
+        ? (isEmptyScene
+            ? '只保留自然环境运动,例如风、光影变化、轻微镜头漂移;不要引入人物或产品'
+            : '自然流畅的运动,保持同一主体身份和同一环境')
+        : (isEmptyScene
+            ? '首帧,纯环境或纯风景构图,画面中不要出现人物或产品'
+            : '首帧,主体或产品在前景清晰可见,本镜头构图需要独立且有辨识度');
+    const shotLabel = index >= 0 ? `第 ${index + 1} 个镜头` : '';
+    const narration = scene.narration ? `故事节拍:${scene.narration}` : '';
+    const emptySceneRule = isEmptyScene
+      ? '严格要求:这是空镜或建立镜头;不要包含任何人物、角色、面部、手、身体、人群、产品或品牌物品;只呈现风景、环境、物体或抽象元素'
+      : '';
+    return [
+      shotLabel,
+      `本镜头画面动作:${scene.scenePrompt}`,
+      `镜头类型:${this.shotTypeLabel(shotType)}`,
+      narration,
+      state ? `连续性状态:${state}` : '',
+      frameRule,
+      bibleText ? `全局一致性参考:${bibleText}` : '',
+      emptySceneRule,
+      '让本镜头与其他镜头在视觉上明显区分,不要复用同一构图,避免泛泛的空镜',
+    ].filter(Boolean).join(', ');
+  }
+
+  referenceImagesFor(
+    enabled: boolean,
+    bible: TopicVideoVisualBible,
+    scenes: TopicVideoContinuityScene[],
+    scene: TopicVideoContinuityScene,
+    index: number,
+    frame: 'head' | 'tail',
+  ): string[] {
+    if (!enabled) return [];
+    const mode = scene.referenceMode || 'auto';
+    if (mode === 'none') return [];
+    const inherit = this.resolveInheritance(scene);
+    const subjImg = bible.subjectImageUrl || '';
+    const prodImg = bible.productImageUrl || '';
+    const locImg = bible.locationImageUrl || '';
+    if (!inherit.subject && mode !== 'current-head') {
+      const refs: string[] = [];
+      if (inherit.location && locImg && mode === 'auto') refs.push(locImg);
+      return refs;
+    }
+    if (mode === 'current-head') return scene.imageUrl ? [scene.imageUrl] : [];
+    const prev = index > 0 ? scenes[index - 1] : undefined;
+    if (mode === 'previous') {
+      if (prev?.tailImageUrl) return [prev.tailImageUrl];
+      if (prev?.imageUrl) return [prev.imageUrl];
+      return [];
+    }
+    const refs: string[] = [];
+    if (inherit.subject && subjImg) refs.push(subjImg);
+    if (inherit.subject && prodImg) refs.push(prodImg);
+    if (inherit.location && locImg) refs.push(locImg);
+    if (frame === 'tail' && scene.imageUrl) refs.push(scene.imageUrl);
+    if (refs.length === 0 && inherit.location) {
+      if (prev?.tailImageUrl) refs.push(prev.tailImageUrl);
+      else if (prev?.imageUrl) refs.push(prev.imageUrl);
+    }
+    return refs;
+  }
+
+  withSceneContinuityDefaults<T extends TopicVideoContinuityScene>(scene: T): T {
+    const shotType = scene.shotType || 'action';
+    const defaults = this.defaultInheritanceFor(shotType);
+    const forceNoSubject = shotType === 'environment' || shotType === 'transition';
+    return {
+      ...scene,
+      shotType,
+      inheritSubject: forceNoSubject ? false : (scene.inheritSubject ?? defaults.subject),
+      inheritLocation: scene.inheritLocation ?? defaults.location,
+      inheritStyle: scene.inheritStyle ?? defaults.style,
+      referenceMode: scene.referenceMode || (shotType === 'transition' ? 'none' : (shotType === 'environment' ? 'previous' : 'auto')),
+    };
+  }
+
+  normalizeShotType(value: unknown): TopicVideoShotType {
+    const v = String(value || '').trim();
+    return ['subject', 'action', 'detail', 'environment', 'transition', 'ending'].includes(v)
+      ? v as TopicVideoShotType
+      : 'action';
+  }
+
+  normalizeReferenceMode(value: unknown): TopicVideoReferenceMode {
+    const v = String(value || '').trim();
+    return ['auto', 'none', 'previous', 'current-head'].includes(v)
+      ? v as TopicVideoReferenceMode
+      : 'auto';
+  }
+
+  narrativeArc(n: number): string {
+    if (n === 3) {
+      return [
+        '段 1(钩子 Hook):抛出悬念或场景痛点,引出主体;不给答案',
+        '段 2(推进 Build):主体行动 / 矛盾发展 / 细节放大',
+        '段 3(收束 Payoff):揭晓结果、升华或反转,回扣开场',
+      ].join('\n');
+    }
+    if (n === 4) {
+      return [
+        '段 1(钩子 Hook):开场抓人,引出主体与悬念',
+        '段 2(铺垫 Setup):交代背景 / 引入矛盾或目标',
+        '段 3(高潮 Climax):冲突或转折达到顶点',
+        '段 4(收束 Payoff):反差 / 答案 / 主题升华,回扣开场',
+      ].join('\n');
+    }
+    if (n === 5) {
+      return [
+        '段 1(钩子 Hook):3 秒内抓住注意力',
+        '段 2(介绍 Intro):交代主体与场景',
+        '段 3(铺垫 Build):层层递进的细节或动作',
+        '段 4(高潮 Climax):最具张力的画面或转折',
+        '段 5(收束 Payoff):余韵 / 升华,回扣开场',
+      ].join('\n');
+    }
+    if (n === 6) {
+      return [
+        '段 1(钩子):开门见山的悬念',
+        '段 2(介绍):明确主体与场景',
+        '段 3-4(推进):两层递进;段 3 引矛盾,段 4 加码',
+        '段 5(高潮):决定性瞬间',
+        '段 6(收束):余韵 / 反差 / 主题点题',
+      ].join('\n');
+    }
+    return [
+      '段 1(钩子):极强的开场画面',
+      '段 2(介绍):主体与世界观',
+      '段 3-4(铺垫):两层细节或人物动机',
+      '段 5-6(推进与高潮):从冲突升级到决定性瞬间',
+      '段 7(转折):反差或结果揭晓',
+      '段 8(收束):升华 / 余韵镜头,回扣开场',
+    ].join('\n');
+  }
+
+  private shotTypeLabel(value: TopicVideoShotType): string {
+    const labels: Record<TopicVideoShotType, string> = {
+      subject: '主体',
+      action: '动作',
+      detail: '细节',
+      environment: '风景',
+      transition: '转场',
+      ending: '收束',
+    };
+    return labels[value] || value;
+  }
+}

+ 57 - 0
src/app/pages/pipelines/topic-to-video/services/topic-jimeng-generation.service.ts

@@ -0,0 +1,57 @@
+import { Injectable } from '@angular/core';
+import { Observable } from 'rxjs';
+import {
+  JimengAspectRatio,
+  JimengService,
+  JimengVideoQuality,
+} from '../../../../services/jimeng.service';
+
+export interface TopicImageGenerationInput {
+  prompt: string;
+  references: string[];
+  size: { width: number; height: number };
+  options?: { scale?: number; forceSingle?: boolean };
+}
+
+export interface TopicVideoGenerationInput {
+  imageUrls: string[];
+  prompt: string;
+  method: '2' | '3';
+  quality: JimengVideoQuality;
+  frames: number;
+  aspectRatio: JimengAspectRatio;
+}
+
+@Injectable({ providedIn: 'root' })
+export class TopicJimengGenerationService {
+  constructor(private jimeng: JimengService) {}
+
+  generateImage(input: TopicImageGenerationInput): Observable<string> {
+    return this.jimeng.generateImgV4(
+      input.prompt,
+      input.references,
+      input.size,
+      undefined,
+      input.options,
+    );
+  }
+
+  uploadImage(file: File, target: 'head' | 'tail'): Observable<string> {
+    return this.jimeng.uploadFileToParse(
+      file,
+      `t2v-${target}-${Date.now()}-${file.name}`,
+      file.type || 'image/jpeg',
+    );
+  }
+
+  generateClip(input: TopicVideoGenerationInput): Observable<{ videoUrl: string; workId: string }> {
+    return this.jimeng.generateImageToVideo({
+      imageUrls: input.imageUrls,
+      prompt: input.prompt,
+      method: input.method,
+      quality: input.quality,
+      frames: input.frames,
+      aspectRatio: input.aspectRatio,
+    });
+  }
+}

+ 255 - 0
src/app/pages/pipelines/topic-to-video/services/topic-storyboard.service.ts

@@ -0,0 +1,255 @@
+import { Injectable } from '@angular/core';
+
+export type TopicStoryboardSceneCount = 3 | 4 | 5 | 6 | 8;
+export type TopicStoryboardMediaMode = 'image' | 'video';
+export type TopicStoryboardShotType = 'subject' | 'action' | 'detail' | 'environment' | 'transition' | 'ending';
+export type TopicStoryboardReferenceMode = 'auto' | 'none' | 'previous' | 'current-head';
+
+export interface TopicStoryboardVisualBible {
+  subject: string;
+  product: string;
+  location: string;
+  palette: string;
+  lighting: string;
+  camera: string;
+  styleNotes: string;
+  constraints: string;
+}
+
+export interface TopicStoryboardPromptInput {
+  topic: string;
+  nScenes: TopicStoryboardSceneCount;
+  mediaMode: TopicStoryboardMediaMode;
+  clipDurationSeconds: number;
+  styleHint: string;
+  existingBible: string;
+  briefContext: string;
+}
+
+export interface TopicStoryboardSceneDraft {
+  scenePrompt: string;
+  shotType?: TopicStoryboardShotType;
+  inheritSubject?: boolean;
+  inheritLocation?: boolean;
+  inheritStyle?: boolean;
+  referenceMode?: TopicStoryboardReferenceMode;
+  startState?: string;
+  endState?: string;
+  continuity?: string;
+  narration?: string;
+  state: 'idle';
+  videoMode: 'first';
+  imageSource: 'ai';
+  tailImageSource: 'ai';
+}
+
+export interface TopicStoryboardResult {
+  visualBible?: Partial<TopicStoryboardVisualBible>;
+  scenes: TopicStoryboardSceneDraft[];
+}
+
+@Injectable({ providedIn: 'root' })
+export class TopicStoryboardService {
+  buildPrompt(input: TopicStoryboardPromptInput): string {
+    const { charLow, charHigh } = this.narrationLength(input.mediaMode, input.clipDurationSeconds);
+    const arc = this.narrativeArc(input.nScenes);
+
+    return `你是一名短视频导演兼分镜师。请根据主题输出 ${input.nScenes} 段连续分镜,适用于真人口播/剧情短片/产品广告。
+
+主题:
+${input.topic.trim()}
+
+结构化创作资料(用于约束分镜,不要把它当作画面标题逐字复述):
+${input.briefContext || '无'}
+
+已有全局设定(为空则你补全):
+${input.existingBible || '无'}
+
+视觉风格:
+${input.styleHint || '保持真实、清晰、有短视频镜头感'}
+
+连续性目标:
+- 不牺牲镜头自由度:每段可以有不同景别、动作、构图和情绪。
+- 按镜头类型决定继承内容:不是所有镜头都要有人物,空景/转场镜头必须不含人物、产品、人脸、人手。
+- 人物/产品镜头保持角色/主体/产品连续;风景镜头只继承场景和风格;转场镜头只继承风格。
+- 每段写清 start_state 和 end_state,让下一段承接上一段。
+- 产品广告固定产品外观、品牌感、使用场景;真人剧情固定人物外貌服装和空间关系。
+- 每个 scene_prompt 必须互不重复:动作、景别、镜头角度、画面焦点至少变化两项。
+- subject/action/detail/ending 镜头的 scene_prompt 必须出现主体/产品正在做什么。
+- environment/transition 镜头是“纯空景 / 纯转场”,scene_prompt 不准出现人物、产品、人脸、人手、品牌标识,只允许风景、环境、物体、抽象元素,同时必须写清前中后景、光线变化与镜头运动。
+- 遇到需要“只拍场景 / 空镜头 / 物件特写但不带人”的叙事节点,你必须主动选 environment,且 inherit_subject 设为 false。
+- scene_prompt 只写本镜头独有内容,不要重复 visual_bible 中的主体/场景/色调长描述。
+
+叙事骨架:
+${arc}
+
+解说规则:
+- narration: 中文备注 ${charLow}-${charHigh} 字,用作字幕/记录,暂不配音。
+- 解说前后承接,不要每段都像独立小故事。
+
+只输出严格 JSON 对象,不要 markdown:
+{
+  "visual_bible": {
+    "subject": "中文填写固定人物/主体外观",
+    "product": "中文填写固定产品外观/卖点,无则留空",
+    "location": "中文填写固定场景和空间布局",
+    "palette": "中文填写固定色彩方案",
+    "lighting": "中文填写固定光线",
+    "camera": "中文填写整体镜头语言",
+    "style_notes": "中文填写视觉风格",
+    "constraints": "中文填写必须保持不变的要点"
+  },
+  "scenes": [
+    {
+      "shot_type": "subject | action | detail | environment | transition | ending",
+      "inherit_subject": true,
+      "inherit_location": true,
+      "inherit_style": true,
+      "reference_mode": "auto | none | previous | current-head",
+      "scene_prompt": "中文,38 字以内,写清具体主体/产品动作、构图和镜头变化",
+      "start_state": "中文填写承接上一镜的起始状态",
+      "end_state": "中文填写留给下一镜承接的结束状态",
+      "continuity": "中文填写与上一镜的衔接关系",
+      "narration": "中文"
+    }
+  ]
+}
+共 ${input.nScenes} 段。`;
+  }
+
+  normalizeResponse(text: string, nScenes: TopicStoryboardSceneCount): TopicStoryboardResult {
+    const payload = this.extractStoryboardPayload(text);
+    const arr = Array.isArray(payload) ? payload : payload?.scenes;
+    if (!Array.isArray(arr) || arr.length === 0) {
+      throw new Error('AI 分镜解析失败:未识别到 JSON scenes');
+    }
+
+    const visualBible = this.normalizeVisualBible(Array.isArray(payload) ? null : payload?.visual_bible || payload?.visualBible);
+    const scenes = arr.slice(0, nScenes).map((item: any, idx: number): TopicStoryboardSceneDraft => ({
+      scenePrompt: String(item.scene_prompt || item.scenePrompt || '').trim(),
+      shotType: this.normalizeShotType(item.shot_type || item.shotType),
+      inheritSubject: typeof item.inherit_subject === 'boolean' ? item.inherit_subject : undefined,
+      inheritLocation: typeof item.inherit_location === 'boolean' ? item.inherit_location : undefined,
+      inheritStyle: typeof item.inherit_style === 'boolean' ? item.inherit_style : undefined,
+      referenceMode: this.normalizeReferenceMode(item.reference_mode || item.referenceMode),
+      startState: String(item.start_state || item.startState || '').trim(),
+      endState: String(item.end_state || item.endState || '').trim(),
+      continuity: String(item.continuity || (idx === 0 ? '开场镜头' : '')).trim(),
+      narration: String(item.narration || '').trim(),
+      state: 'idle',
+      videoMode: 'first',
+      imageSource: 'ai',
+      tailImageSource: 'ai',
+    }));
+
+    if (scenes.some((scene) => !scene.scenePrompt || !scene.narration)) {
+      throw new Error('AI 分镜数据不完整,请重试');
+    }
+
+    return { visualBible, scenes };
+  }
+
+  extractJsonArray(text: string): any {
+    if (!text) return null;
+    const match = text.match(/\[[\s\S]*\]/);
+    if (!match) return null;
+    try {
+      return JSON.parse(match[0]);
+    } catch {
+      return null;
+    }
+  }
+
+  extractStoryboardPayload(text: string): any {
+    if (!text) return null;
+    const objectMatch = text.match(/\{[\s\S]*\}/);
+    if (objectMatch) {
+      try {
+        return JSON.parse(objectMatch[0]);
+      } catch {}
+    }
+    return this.extractJsonArray(text);
+  }
+
+  private narrativeArc(n: TopicStoryboardSceneCount): string {
+    if (n === 3) {
+      return [
+        '段 1(钩子 Hook):抛出悬念或场景痛点,引出主体;不给答案',
+        '段 2(推进 Build):主体行动 / 矛盾发展 / 细节放大',
+        '段 3(收束 Payoff):揭晓结果、升华或反转,回扣开场',
+      ].join('\n');
+    }
+    if (n === 4) {
+      return [
+        '段 1(钩子 Hook):开场抓人,引出主体与悬念',
+        '段 2(铺垫 Setup):交代背景 / 引入矛盾或目标',
+        '段 3(高潮 Climax):冲突或转折达到顶点',
+        '段 4(收束 Payoff):反差 / 答案 / 主题升华,回扣开场',
+      ].join('\n');
+    }
+    if (n === 5) {
+      return [
+        '段 1(钩子 Hook):3 秒内抓住注意力',
+        '段 2(介绍 Intro):交代主体与场景',
+        '段 3(铺垫 Build):层层递进的细节或动作',
+        '段 4(高潮 Climax):最具张力的画面或转折',
+        '段 5(收束 Payoff):余韵 / 升华,回扣开场',
+      ].join('\n');
+    }
+    if (n === 6) {
+      return [
+        '段 1(钩子):开门见山的悬念',
+        '段 2(介绍):明确主体与场景',
+        '段 3-4(推进):两层递进;段 3 引矛盾,段 4 加码',
+        '段 5(高潮):决定性瞬间',
+        '段 6(收束):余韵 / 反差 / 主题点题',
+      ].join('\n');
+    }
+    return [
+      '段 1(钩子):极强的开场画面',
+      '段 2(介绍):主体与世界观',
+      '段 3-4(铺垫):两层细节或人物动机',
+      '段 5-6(推进与高潮):从冲突升级到决定性瞬间',
+      '段 7(转折):反差或结果揭晓',
+      '段 8(收束):升华 / 余韵镜头,回扣开场',
+    ].join('\n');
+  }
+
+  private narrationLength(mediaMode: TopicStoryboardMediaMode, clipDurationSeconds: number): { charLow: number; charHigh: number } {
+    const targetLow = mediaMode === 'video' ? Math.max(1, clipDurationSeconds - 1.5) : 4;
+    const targetHigh = mediaMode === 'video' ? clipDurationSeconds : 7;
+    return {
+      charLow: Math.round(targetLow * 4.5),
+      charHigh: Math.round(targetHigh * 4.5),
+    };
+  }
+
+  private normalizeVisualBible(value: any): Partial<TopicStoryboardVisualBible> | undefined {
+    if (!value || typeof value !== 'object') return undefined;
+    return {
+      subject: String(value.subject || '').trim(),
+      product: String(value.product || '').trim(),
+      location: String(value.location || '').trim(),
+      palette: String(value.palette || '').trim(),
+      lighting: String(value.lighting || '').trim(),
+      camera: String(value.camera || '').trim(),
+      styleNotes: String(value.style_notes || value.styleNotes || '').trim(),
+      constraints: String(value.constraints || '').trim(),
+    };
+  }
+
+  private normalizeShotType(value: any): TopicStoryboardShotType | undefined {
+    const v = String(value || '').trim();
+    return ['subject', 'action', 'detail', 'environment', 'transition', 'ending'].includes(v)
+      ? v as TopicStoryboardShotType
+      : undefined;
+  }
+
+  private normalizeReferenceMode(value: any): TopicStoryboardReferenceMode | undefined {
+    const v = String(value || '').trim();
+    if (v === 'anchor') return 'auto';
+    return ['auto', 'none', 'previous', 'current-head'].includes(v)
+      ? v as TopicStoryboardReferenceMode
+      : undefined;
+  }
+}

+ 41 - 0
src/app/pages/pipelines/topic-to-video/services/topic-template-config.service.ts

@@ -0,0 +1,41 @@
+import { Injectable } from '@angular/core';
+
+export type TopicVideoSceneCount = 3 | 4 | 5 | 6 | 8;
+export type TopicVideoStyle = 'cinematic' | 'anime' | 'realistic' | 'illustration' | 'documentary';
+export type TopicVideoAspect = 'landscape' | 'portrait' | 'square';
+export type TopicVideoMediaMode = 'image' | 'video';
+export type TopicVideoQuality = '720p' | '1080p' | 'pro';
+
+@Injectable({ providedIn: 'root' })
+export class TopicTemplateConfigService {
+  sceneCount(value: unknown): TopicVideoSceneCount | null {
+    return value === 3 || value === 4 || value === 5 || value === 6 || value === 8 ? value : null;
+  }
+
+  style(value: unknown): TopicVideoStyle | null {
+    return this.pick(value, ['cinematic', 'anime', 'realistic', 'illustration', 'documentary']);
+  }
+
+  aspect(value: unknown): TopicVideoAspect | null {
+    return this.pick(value, ['landscape', 'portrait', 'square']);
+  }
+
+  mediaMode(value: unknown): TopicVideoMediaMode | null {
+    return this.pick(value, ['image', 'video']);
+  }
+
+  quality(value: unknown): TopicVideoQuality | null {
+    return this.pick(value, ['720p', '1080p', 'pro']);
+  }
+
+  scriptFrame(quickMode: boolean): string {
+    return quickMode
+      ? '主题输入 -> 连续分镜 -> 首帧生成 -> 可选动态视频 -> 合成成片'
+      : '手动分镜 -> 首尾帧控制 -> 单段图生视频 -> 合成成片';
+  }
+
+  private pick<T extends string>(value: unknown, allowed: T[]): T | null {
+    const normalized = String(value || '').trim();
+    return allowed.includes(normalized as T) ? normalized as T : null;
+  }
+}

+ 56 - 0
src/app/pages/pipelines/topic-to-video/services/topic-video-artifact-store.service.ts

@@ -0,0 +1,56 @@
+import { Injectable } from '@angular/core';
+import { PipelineSessionService } from '../../../../services/pipeline-session.service';
+import {
+  TopicVideoArtifactService,
+  TopicVideoClipArtifactInput,
+  TopicVideoFrameArtifactInput,
+} from './topic-video-artifact.service';
+
+export interface TopicVideoArtifactStoreOptions<TSnapshot> {
+  snapshot?: TSnapshot;
+  title?: string;
+  ensureActive?: boolean;
+  snapshotFactory?: () => TSnapshot;
+}
+
+@Injectable({ providedIn: 'root' })
+export class TopicVideoArtifactStoreService {
+  constructor(
+    private session: PipelineSessionService,
+    private artifacts: TopicVideoArtifactService,
+  ) {}
+
+  saveFrame<TSnapshot>(
+    input: TopicVideoFrameArtifactInput,
+    options: TopicVideoArtifactStoreOptions<TSnapshot> = {},
+  ): void {
+    this.ensureActive(options);
+    this.session.upsertArtifact(
+      this.artifacts.matchFrame(input.sceneIndex, input.frame),
+      this.artifacts.frame(input),
+    );
+    this.patchSnapshot(options);
+  }
+
+  saveClip<TSnapshot>(
+    input: TopicVideoClipArtifactInput,
+    options: TopicVideoArtifactStoreOptions<TSnapshot> = {},
+  ): void {
+    this.ensureActive(options);
+    this.session.upsertArtifact(
+      this.artifacts.matchClip(input.sceneIndex),
+      this.artifacts.clip(input),
+    );
+    this.patchSnapshot(options);
+  }
+
+  private ensureActive<TSnapshot>(options: TopicVideoArtifactStoreOptions<TSnapshot>): void {
+    if (!options.ensureActive || !options.snapshotFactory) return;
+    this.session.ensureActive('topic-to-video', options.snapshotFactory, options.title || '主题生视频');
+  }
+
+  private patchSnapshot<TSnapshot>(options: TopicVideoArtifactStoreOptions<TSnapshot>): void {
+    if (options.snapshot === undefined) return;
+    this.session.patch({ snapshot: options.snapshot });
+  }
+}

+ 66 - 0
src/app/pages/pipelines/topic-to-video/services/topic-video-artifact.service.ts

@@ -0,0 +1,66 @@
+import { Injectable } from '@angular/core';
+import { Artifact } from '../../../../models/pipeline-draft.model';
+
+export type TopicVideoFrame = 'head' | 'tail';
+export type TopicVideoArtifactSource = 'ai' | 'upload';
+export type TopicVideoArtifactInput = Omit<Artifact, 'id' | 'createdAt'> & { id?: string; createdAt?: number };
+
+export interface TopicVideoFrameArtifactInput {
+  url: string;
+  sceneIndex: number;
+  frame: TopicVideoFrame;
+  source: TopicVideoArtifactSource;
+}
+
+export interface TopicVideoClipArtifactInput {
+  url: string;
+  sceneIndex: number;
+  modeTitle: string;
+  method: string | number;
+  frames: number;
+  quality: string;
+  mode: string;
+  fallbackReason?: string;
+}
+
+@Injectable({ providedIn: 'root' })
+export class TopicVideoArtifactService {
+  matchFrame(sceneIndex: number, frame: TopicVideoFrame): (artifact: Artifact) => boolean {
+    return (artifact) => artifact.type === 'image'
+      && artifact.sceneIndex === sceneIndex
+      && artifact.extras?.['frame'] === frame;
+  }
+
+  frame(input: TopicVideoFrameArtifactInput): TopicVideoArtifactInput {
+    return {
+      type: 'image',
+      url: input.url,
+      sceneIndex: input.sceneIndex,
+      title: `分镜 ${input.sceneIndex + 1} - ${input.frame === 'head' ? '首帧' : '尾帧'}(${input.source === 'ai' ? 'AI' : '上传'})`,
+      extras: { frame: input.frame, source: input.source },
+    };
+  }
+
+  matchClip(sceneIndex: number): (artifact: Artifact) => boolean {
+    return (artifact) => artifact.type === 'video'
+      && artifact.sceneIndex === sceneIndex
+      && artifact.extras?.['kind'] === 'i2v';
+  }
+
+  clip(input: TopicVideoClipArtifactInput): TopicVideoArtifactInput {
+    return {
+      type: 'video',
+      url: input.url,
+      sceneIndex: input.sceneIndex,
+      title: `分镜 ${input.sceneIndex + 1} - 图生视频(${input.modeTitle})`,
+      extras: {
+        kind: 'i2v',
+        method: input.method,
+        frames: input.frames,
+        quality: input.quality,
+        mode: input.mode,
+        fallbackReason: input.fallbackReason,
+      },
+    };
+  }
+}

+ 73 - 0
src/app/pages/pipelines/topic-to-video/services/topic-video-clip.service.ts

@@ -0,0 +1,73 @@
+import { Injectable } from '@angular/core';
+import { JimengAspectRatio, JimengVideoQuality } from '../../../../services/jimeng.service';
+
+type ClipMethod = '2' | '3';
+type TopicAspect = 'landscape' | 'portrait' | 'square';
+
+interface ClipSceneLike {
+  imageUrl?: string;
+  tailImageUrl?: string;
+}
+
+export interface TopicVideoClipPlanInput {
+  scene: ClipSceneLike;
+  prompt: string;
+  quality: JimengVideoQuality;
+  frames: number;
+  aspect: TopicAspect;
+  preferFirstLast: boolean;
+  requireFirstLast?: boolean;
+}
+
+export interface TopicVideoClipPlan {
+  imageUrls: string[];
+  prompt: string;
+  method: ClipMethod;
+  quality: JimengVideoQuality;
+  frames: number;
+  aspectRatio: JimengAspectRatio;
+  modeLabel: string;
+  fallbackReason: string;
+}
+
+@Injectable({ providedIn: 'root' })
+export class TopicVideoClipService {
+  buildPlan(input: TopicVideoClipPlanInput): TopicVideoClipPlan {
+    const imageUrl = String(input.scene.imageUrl || '').trim();
+    const tailImageUrl = String(input.scene.tailImageUrl || '').trim();
+    if (!imageUrl) {
+      throw new Error('缺少首帧图片,无法生成动态视频');
+    }
+
+    if (input.requireFirstLast && !tailImageUrl) {
+      throw new Error('首尾帧模式需要先生成或上传尾帧');
+    }
+
+    if (input.requireFirstLast && input.quality === 'pro') {
+      throw new Error('Pro 画质不支持首尾帧模式,请切换 720p/1080p 或改用首帧模式');
+    }
+
+    const canUseFirstLast = input.preferFirstLast && input.quality !== 'pro' && !!tailImageUrl;
+    const method: ClipMethod = canUseFirstLast ? '3' : '2';
+    const fallbackReason = input.preferFirstLast && method === '2'
+      ? (input.quality === 'pro' ? 'Pro 画质仅支持首帧模式' : '缺少尾帧,自动回退首帧模式')
+      : '';
+
+    return {
+      imageUrls: canUseFirstLast ? [imageUrl, tailImageUrl] : [imageUrl],
+      prompt: input.prompt,
+      method,
+      quality: input.quality,
+      frames: input.frames,
+      aspectRatio: this.aspectRatio(input.aspect),
+      modeLabel: canUseFirstLast ? '首尾帧' : '首帧',
+      fallbackReason,
+    };
+  }
+
+  private aspectRatio(aspect: TopicAspect): JimengAspectRatio {
+    if (aspect === 'portrait') return '9:16';
+    if (aspect === 'square') return '1:1';
+    return '16:9';
+  }
+}

+ 27 - 0
src/app/pages/pipelines/topic-to-video/services/topic-video-compose.service.ts

@@ -0,0 +1,27 @@
+import { Injectable } from '@angular/core';
+import { CompositeSegmentInput } from '../../../../services/browser-ffmpeg.service';
+
+interface SceneLike {
+  imageUrl?: string;
+  videoUrl?: string;
+}
+
+@Injectable({ providedIn: 'root' })
+export class TopicVideoComposeService {
+  readonly stillImageDuration = 3;
+
+  buildSegments(scenes: SceneLike[], stillImageDuration = this.stillImageDuration): CompositeSegmentInput[] {
+    return scenes
+      .map((scene, index) => {
+        const segment: CompositeSegmentInput = { id: `t2v-${index}` };
+        if (scene.videoUrl) {
+          segment.videoUrl = scene.videoUrl;
+        } else if (scene.imageUrl) {
+          segment.imageUrl = scene.imageUrl;
+          segment.duration = stillImageDuration;
+        }
+        return segment;
+      })
+      .filter((segment) => !!segment.imageUrl || !!segment.videoUrl);
+  }
+}

+ 56 - 0
src/app/pages/pipelines/topic-to-video/services/topic-video-diagnostics.service.ts

@@ -0,0 +1,56 @@
+import { Injectable } from '@angular/core';
+import { userFriendlyError } from '../../../../services/user-message.util';
+import { TopicVideoClipPlan } from './topic-video-clip.service';
+
+@Injectable({ providedIn: 'root' })
+export class TopicVideoDiagnosticsService {
+  pipelineError(error: unknown): string {
+    return userFriendlyError(error, '视频生成失败,请稍后重试');
+  }
+
+  storyboardError(error: unknown): Error {
+    return new Error(userFriendlyError(error, '分镜生成失败,请稍后重试'));
+  }
+
+  imageFrameError(error: unknown): string {
+    return userFriendlyError(error, '画面生成失败,请稍后重试');
+  }
+
+  allImagesFailed(): Error {
+    return new Error('画面生成失败,请稍后重试或检查积分余额');
+  }
+
+  imagePartialFailure(failed: number, total: number): string {
+    return `[t2v] ${failed}/${total} 张画面失败,跳过失败段`;
+  }
+
+  clipFailureMessage(error: unknown): string {
+    return userFriendlyError(error, '动态片段生成失败,已降级为静态图');
+  }
+
+  allClipsFailed(): Error {
+    return new Error('动态片段生成失败,请稍后重试或检查积分余额');
+  }
+
+  clipPartialFailure(failed: number, total: number): string {
+    return `[t2v] ${failed}/${total} 段图生视频失败,失败片段将使用静态图替代`;
+  }
+
+  clipDiagnostic(plan: TopicVideoClipPlan, sceneIndex: number, error?: unknown): Record<string, unknown> {
+    return {
+      sceneIndex,
+      method: plan.method,
+      modeLabel: plan.modeLabel,
+      quality: plan.quality,
+      frames: plan.frames,
+      aspectRatio: plan.aspectRatio,
+      imageCount: plan.imageUrls.length,
+      fallbackReason: plan.fallbackReason,
+      error,
+    };
+  }
+
+  composeError(error: unknown): Error {
+    return new Error(userFriendlyError(error, '视频合成失败,请稍后重试'));
+  }
+}

+ 29 - 0
src/app/pages/pipelines/topic-to-video/services/topic-video-presets.ts

@@ -0,0 +1,29 @@
+export const TOPIC_VIDEO_STYLE_PRESETS = [
+  { value: 'cinematic', label: '电影感', promptHint: '电影感光影、胶片颗粒、宽银幕镜头质感' },
+  { value: 'realistic', label: '写实', promptHint: '写实照片质感,细节丰富,高清画面' },
+  { value: 'anime', label: '动漫', promptHint: '动画风格,色彩鲜明,柔和手绘质感' },
+  { value: 'illustration', label: '插画', promptHint: '扁平插画风格,现代设计感' },
+  { value: 'documentary', label: '纪录片', promptHint: '纪实摄影风格,自然光,真实现场感' },
+] as const;
+
+export const TOPIC_VIDEO_ASPECT_PRESETS = [
+  { value: 'landscape', label: '横屏 16:9', w: 1920, h: 1080 },
+  { value: 'portrait', label: '竖屏 9:16', w: 1080, h: 1920 },
+  { value: 'square', label: '方屏 1:1', w: 1440, h: 1440 },
+] as const;
+
+export const TOPIC_VIDEO_SHOT_TYPE_PRESETS = [
+  { value: 'subject', label: '主体' },
+  { value: 'action', label: '动作' },
+  { value: 'detail', label: '细节' },
+  { value: 'environment', label: '环境' },
+  { value: 'transition', label: '转场' },
+  { value: 'ending', label: '收束' },
+] as const;
+
+export const TOPIC_VIDEO_REFERENCE_MODE_PRESETS = [
+  { value: 'auto', label: '自动' },
+  { value: 'none', label: '无参考' },
+  { value: 'previous', label: '上一帧' },
+  { value: 'current-head', label: '当前首帧' },
+] as const;

+ 65 - 0
src/app/pages/pipelines/topic-to-video/services/topic-video-progress.service.ts

@@ -0,0 +1,65 @@
+import { Injectable } from '@angular/core';
+
+export type TopicVideoStage = 'idle' | 'scripting' | 'imaging' | 'videoing' | 'composing' | 'done' | 'failed';
+export type TopicVideoMediaMode = 'image' | 'video';
+
+export interface TopicVideoProgressInput {
+  quickMode: boolean;
+  stage: TopicVideoStage;
+  mediaMode: TopicVideoMediaMode;
+  imageProgress: number;
+  videoProgress: number;
+  composeProgress: number;
+}
+
+interface SceneLike {
+  state?: string;
+  imageUrl?: string;
+  videoUrl?: string;
+}
+
+@Injectable({ providedIn: 'root' })
+export class TopicVideoProgressService {
+  calculateTotalProgress(input: TopicVideoProgressInput): number {
+    if (!input.quickMode) {
+      return input.stage === 'composing' ? input.composeProgress : (input.stage === 'done' ? 100 : 0);
+    }
+
+    const SCRIPT_W = 5;
+    const IMAGE_W = 30;
+    const COMPOSE_W = 40;
+    const videoW = input.mediaMode === 'video' ? 25 : 0;
+    const remain = 100 - SCRIPT_W - IMAGE_W - videoW - COMPOSE_W;
+    const composeW = COMPOSE_W + remain;
+
+    const script = input.stage === 'idle' ? 0 : (input.stage === 'scripting' ? 2 : SCRIPT_W);
+    const image = Math.round((input.imageProgress / 100) * IMAGE_W);
+    const video = Math.round((input.videoProgress / 100) * videoW);
+    const compose = Math.round((input.composeProgress / 100) * composeW);
+    return Math.min(script + image + video + compose, 100);
+  }
+
+  readyCount(scenes: SceneLike[]): number {
+    return scenes.filter((scene) => scene.state === 'image-done' || scene.state === 'video-done').length;
+  }
+
+  videoReadyCount(scenes: SceneLike[]): number {
+    return scenes.filter((scene) => !!scene.videoUrl).length;
+  }
+
+  caseSummary(scenes: SceneLike[]): { C: number; D: number; total: number } {
+    const summary = { C: 0, D: 0, total: 0 };
+    for (const scene of scenes) {
+      if (scene.videoUrl) summary.C++;
+      else if (scene.imageUrl) summary.D++;
+    }
+    summary.total = summary.C + summary.D;
+    return summary;
+  }
+
+  formatSize(bytes: number): string {
+    if (bytes < 1024) return `${bytes} B`;
+    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+    return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
+  }
+}

+ 132 - 0
src/app/pages/pipelines/topic-to-video/services/topic-video-scene-state.service.ts

@@ -0,0 +1,132 @@
+import { Injectable } from '@angular/core';
+
+export type TopicVideoFrameTarget = 'head' | 'tail';
+export type TopicVideoPendingTarget = TopicVideoFrameTarget | 'video';
+export type TopicVideoSceneStateValue =
+  | 'idle'
+  | 'image-pending'
+  | 'image-done'
+  | 'image-failed'
+  | 'tail-pending'
+  | 'tail-done'
+  | 'tail-failed'
+  | 'video-pending'
+  | 'video-done'
+  | 'video-failed';
+
+export interface TopicVideoSceneState {
+  imageUrl?: string;
+  imageSource?: 'ai' | 'upload';
+  tailImageUrl?: string;
+  tailImageSource?: 'ai' | 'upload';
+  videoUrl?: string;
+  videoMode?: 'first' | 'first-last';
+  state: TopicVideoSceneStateValue;
+  pendingTarget?: TopicVideoPendingTarget;
+  error?: string;
+}
+
+@Injectable({ providedIn: 'root' })
+export class TopicVideoSceneStateService {
+  isBusy(scene: TopicVideoSceneState): boolean {
+    return scene.state === 'image-pending'
+      || scene.state === 'tail-pending'
+      || scene.state === 'video-pending';
+  }
+
+  setVideoMode(scene: TopicVideoSceneState, mode: 'first' | 'first-last'): boolean {
+    if (scene.videoMode === mode || this.isBusy(scene)) return false;
+    scene.videoMode = mode;
+    if (scene.videoUrl) {
+      scene.videoUrl = undefined;
+      scene.state = scene.imageUrl ? 'image-done' : 'idle';
+    }
+    return true;
+  }
+
+  prepareFrameUpload(scene: TopicVideoSceneState, target: TopicVideoFrameTarget): void {
+    scene.error = undefined;
+    scene.pendingTarget = target;
+    this.clearFrame(scene, target, true);
+    scene.state = target === 'head' ? 'image-pending' : 'tail-pending';
+  }
+
+  applyFrameSuccess(
+    scene: TopicVideoSceneState,
+    target: TopicVideoFrameTarget,
+    url: string,
+    source: 'ai' | 'upload',
+  ): void {
+    scene.pendingTarget = undefined;
+    if (target === 'head') {
+      scene.imageUrl = url;
+      scene.imageSource = source;
+      scene.state = 'image-done';
+    } else {
+      scene.tailImageUrl = url;
+      scene.tailImageSource = source;
+      scene.state = 'tail-done';
+    }
+  }
+
+  applyFrameFailure(scene: TopicVideoSceneState, target: TopicVideoFrameTarget, error: string): void {
+    scene.pendingTarget = undefined;
+    scene.state = target === 'head' ? 'image-failed' : 'tail-failed';
+    scene.error = error;
+  }
+
+  applyTailReferenceSuccess(scene: TopicVideoSceneState, url: string): void {
+    scene.tailImageUrl = url;
+    scene.tailImageSource = 'ai';
+  }
+
+  prepareHeadGeneration(scene: TopicVideoSceneState): void {
+    scene.state = 'image-pending';
+    scene.imageUrl = undefined;
+    scene.videoUrl = undefined;
+    scene.error = undefined;
+  }
+
+  prepareTailGeneration(scene: TopicVideoSceneState): void {
+    scene.state = 'tail-pending';
+    scene.pendingTarget = 'tail';
+    scene.tailImageUrl = undefined;
+    scene.videoUrl = undefined;
+    scene.error = undefined;
+  }
+
+  prepareVideoGeneration(scene: TopicVideoSceneState): void {
+    scene.state = 'video-pending';
+    scene.pendingTarget = 'video';
+    scene.videoUrl = undefined;
+    scene.error = undefined;
+  }
+
+  applyVideoSuccess(scene: TopicVideoSceneState, url: string): void {
+    scene.videoUrl = url;
+    scene.state = 'video-done';
+    scene.pendingTarget = undefined;
+  }
+
+  applyVideoFailure(scene: TopicVideoSceneState, fallbackState: TopicVideoSceneStateValue, error: string): void {
+    scene.state = fallbackState;
+    scene.pendingTarget = undefined;
+    scene.error = error;
+  }
+
+  clearFrame(scene: TopicVideoSceneState, target: TopicVideoFrameTarget, clearVideo = false): void {
+    if (target === 'head') {
+      scene.imageUrl = undefined;
+      scene.imageSource = 'ai';
+      if (clearVideo) scene.videoUrl = undefined;
+      scene.state = scene.tailImageUrl ? 'tail-done' : 'idle';
+    } else {
+      scene.tailImageUrl = undefined;
+      scene.tailImageSource = 'ai';
+      if (clearVideo) scene.videoUrl = undefined;
+      if (scene.imageUrl) scene.state = 'image-done';
+      else scene.state = 'idle';
+    }
+    scene.error = undefined;
+  }
+}

+ 36 - 0
src/app/pages/pipelines/topic-to-video/services/topic-video-session.service.ts

@@ -0,0 +1,36 @@
+import { Injectable } from '@angular/core';
+
+type SceneState =
+  | 'idle'
+  | 'image-pending'
+  | 'image-done'
+  | 'image-failed'
+  | 'tail-pending'
+  | 'tail-done'
+  | 'tail-failed'
+  | 'video-pending'
+  | 'video-done'
+  | 'video-failed';
+
+@Injectable({ providedIn: 'root' })
+export class TopicVideoSessionService {
+  readonly defaultTitle = '未命名创作';
+
+  deriveTitle(topic: string, sceneCount: number): string {
+    const text = String(topic || '').trim();
+    if (text) return text.length > 30 ? `${text.slice(0, 30)}...` : text;
+    if (sceneCount > 0) return `${sceneCount} 段分镜创作`;
+    return this.defaultTitle;
+  }
+
+  shouldUseAutoTitle(currentTitle: string | undefined, derivedTitle: string): boolean {
+    return !currentTitle || currentTitle === this.defaultTitle || currentTitle === derivedTitle;
+  }
+
+  normalizeSceneState(state: SceneState): SceneState {
+    if (state === 'image-pending') return 'idle';
+    if (state === 'tail-pending') return 'tail-failed';
+    if (state === 'video-pending') return 'image-done';
+    return state;
+  }
+}

+ 164 - 0
src/app/pages/pipelines/topic-to-video/services/topic-video-snapshot.service.ts

@@ -0,0 +1,164 @@
+import { Injectable } from '@angular/core';
+import { JimengVideoQuality } from '../../../../services/jimeng.service';
+import { TopicVideoSessionService } from './topic-video-session.service';
+
+type SnapshotStage = 'idle' | 'done' | 'failed';
+type RuntimeStage = SnapshotStage | 'scripting' | 'imaging' | 'videoing' | 'composing';
+type SceneState =
+  | 'idle'
+  | 'image-pending'
+  | 'image-done'
+  | 'image-failed'
+  | 'tail-pending'
+  | 'tail-done'
+  | 'tail-failed'
+  | 'video-pending'
+  | 'video-done'
+  | 'video-failed';
+
+export interface TopicVideoSnapshotScene {
+  scenePrompt?: string;
+  shotType?: string;
+  inheritSubject?: boolean;
+  inheritLocation?: boolean;
+  inheritStyle?: boolean;
+  referenceMode?: string;
+  startState?: string;
+  endState?: string;
+  continuity?: string;
+  narration?: string;
+  imageUrl?: string;
+  imageSource?: 'ai' | 'upload';
+  tailImageUrl?: string;
+  tailImageSource?: 'ai' | 'upload';
+  videoMode?: 'first' | 'first-last';
+  videoUrl?: string;
+  state?: SceneState;
+  error?: string;
+}
+
+export interface TopicVideoSnapshotInput {
+  topic: string;
+  sourceBrief?: unknown;
+  topicAngle?: string;
+  topicHook?: string;
+  topicOutline?: string;
+  topicContext?: string;
+  nScenes: number;
+  style: string;
+  aspect: string;
+  quickMode: boolean;
+  mediaMode: string;
+  clipFrames: number;
+  clipDurationSeconds?: number;
+  clipQuality: JimengVideoQuality;
+  continuityEnabled: boolean;
+  visualBible: unknown;
+  scenes: TopicVideoSnapshotScene[];
+  stage: RuntimeStage;
+  resultVideoUrl: string;
+}
+
+export interface TopicVideoSnapshotRestoreOptions<TScene> {
+  emptyVisualBible: unknown;
+  normalizeDurationSeconds: (seconds: number) => number;
+  framesToSeconds: (frames: number) => number;
+  secondsToFrames: (seconds: number) => number;
+  applySceneDefaults: (scene: TopicVideoSnapshotScene) => TScene;
+}
+
+@Injectable({ providedIn: 'root' })
+export class TopicVideoSnapshotService {
+  constructor(private sessionState: TopicVideoSessionService) {}
+
+  create(input: TopicVideoSnapshotInput): TopicVideoSnapshotInput {
+    return {
+      ...input,
+      visualBible: { ...(input.visualBible as object || {}) },
+      scenes: input.scenes.map((scene) => this.toSnapshotScene(scene)),
+      stage: this.toSnapshotStage(input.stage),
+    };
+  }
+
+  restore<TScene>(
+    snapshot: Partial<TopicVideoSnapshotInput> | null | undefined,
+    options: TopicVideoSnapshotRestoreOptions<TScene>,
+  ): Omit<TopicVideoSnapshotInput, 'scenes'> & { scenes: TScene[] } {
+    const snap = snapshot || {};
+    const durationSeconds = options.normalizeDurationSeconds(
+      Number(snap.clipDurationSeconds ?? options.framesToSeconds(Number(snap.clipFrames || 121))),
+    );
+
+    return {
+      topic: String(snap.topic || ''),
+      sourceBrief: snap.sourceBrief || null,
+      topicAngle: String(snap.topicAngle || ''),
+      topicHook: String(snap.topicHook || ''),
+      topicOutline: String(snap.topicOutline || ''),
+      topicContext: String(snap.topicContext || ''),
+      nScenes: Number(snap.nScenes || 5),
+      style: String(snap.style || 'cinematic'),
+      aspect: String(snap.aspect || 'landscape'),
+      quickMode: !!snap.quickMode,
+      mediaMode: String(snap.mediaMode || 'image'),
+      clipDurationSeconds: durationSeconds,
+      clipFrames: options.secondsToFrames(durationSeconds),
+      clipQuality: snap.clipQuality || '720p',
+      continuityEnabled: snap.continuityEnabled !== false,
+      visualBible: { ...(options.emptyVisualBible as object || {}), ...(snap.visualBible as object || {}) },
+      scenes: (snap.scenes || []).map((scene) => options.applySceneDefaults(this.fromSnapshotScene(scene))),
+      stage: this.toSnapshotStage(snap.stage),
+      resultVideoUrl: String(snap.resultVideoUrl || ''),
+    };
+  }
+
+  private toSnapshotScene(scene: TopicVideoSnapshotScene): TopicVideoSnapshotScene {
+    return {
+      scenePrompt: scene.scenePrompt,
+      shotType: scene.shotType,
+      inheritSubject: scene.inheritSubject,
+      inheritLocation: scene.inheritLocation,
+      inheritStyle: scene.inheritStyle,
+      referenceMode: scene.referenceMode,
+      startState: scene.startState,
+      endState: scene.endState,
+      continuity: scene.continuity,
+      narration: scene.narration,
+      imageUrl: scene.imageUrl,
+      imageSource: scene.imageSource,
+      tailImageUrl: scene.tailImageUrl,
+      tailImageSource: scene.tailImageSource,
+      videoMode: scene.videoMode,
+      videoUrl: scene.videoUrl,
+      state: this.sessionState.normalizeSceneState(scene.state || 'idle'),
+      error: scene.error,
+    };
+  }
+
+  private fromSnapshotScene(scene: TopicVideoSnapshotScene): TopicVideoSnapshotScene {
+    return {
+      scenePrompt: scene.scenePrompt || '',
+      shotType: scene.shotType || 'action',
+      inheritSubject: scene.inheritSubject,
+      inheritLocation: scene.inheritLocation,
+      inheritStyle: scene.inheritStyle,
+      referenceMode: scene.referenceMode || 'auto',
+      startState: scene.startState,
+      endState: scene.endState,
+      continuity: scene.continuity,
+      narration: scene.narration,
+      imageUrl: scene.imageUrl,
+      imageSource: scene.imageSource,
+      tailImageUrl: scene.tailImageUrl,
+      tailImageSource: scene.tailImageSource,
+      videoMode: scene.videoMode,
+      videoUrl: scene.videoUrl,
+      state: scene.state || 'idle',
+      error: scene.error,
+    };
+  }
+
+  private toSnapshotStage(stage?: RuntimeStage | string): SnapshotStage {
+    return stage === 'done' || stage === 'failed' ? stage : 'idle';
+  }
+}

+ 101 - 0
src/app/pages/pipelines/topic-to-video/services/topic-video-stage.service.ts

@@ -0,0 +1,101 @@
+import { Injectable } from '@angular/core';
+
+export type TopicVideoStageValue =
+  | 'idle'
+  | 'scripting'
+  | 'imaging'
+  | 'videoing'
+  | 'composing'
+  | 'done'
+  | 'failed';
+
+export interface TopicVideoStageState {
+  stage: TopicVideoStageValue;
+  stageStatus: string;
+  scriptError: string;
+  errorMsg: string;
+  imageProgress: number;
+  videoProgress: number;
+  composeProgress: number;
+  resultVideoUrl: string;
+}
+
+@Injectable({ providedIn: 'root' })
+export class TopicVideoStageService {
+  beginScripting(state: TopicVideoStageState): void {
+    state.stage = 'scripting';
+    state.stageStatus = '正在让 AI 拆分分镜...';
+    state.scriptError = '';
+    state.errorMsg = '';
+  }
+
+  beginImaging(state: TopicVideoStageState): void {
+    state.stage = 'imaging';
+    state.stageStatus = '正在生成画面...';
+    state.imageProgress = 0;
+  }
+
+  beginVideoing(state: TopicVideoStageState): void {
+    state.stage = 'videoing';
+    state.stageStatus = '正在将画面生成动态视频...';
+    state.videoProgress = 0;
+  }
+
+  beginComposing(state: TopicVideoStageState): void {
+    state.stage = 'composing';
+    state.stageStatus = '正在合成最终视频...';
+    state.composeProgress = 0;
+  }
+
+  prepareManualCompose(state: TopicVideoStageState): void {
+    state.errorMsg = '';
+    state.composeProgress = 0;
+    state.resultVideoUrl = '';
+  }
+
+  restoreSnapshotStage(state: TopicVideoStageState, stage: TopicVideoStageValue, resultVideoUrl = ''): void {
+    state.stage = stage;
+    state.resultVideoUrl = resultVideoUrl;
+    state.stageStatus = '';
+    state.errorMsg = '';
+    state.scriptError = '';
+    state.imageProgress = 0;
+    state.videoProgress = 0;
+    state.composeProgress = 0;
+  }
+
+  markDone(state: TopicVideoStageState, videoUrl: string, status: string): void {
+    state.resultVideoUrl = videoUrl;
+    state.composeProgress = 100;
+    state.stage = 'done';
+    state.stageStatus = status;
+  }
+
+  markFailed(state: TopicVideoStageState, message: string): void {
+    state.stage = 'failed';
+    state.stageStatus = '';
+    state.errorMsg = message;
+  }
+
+  markCanceled(state: TopicVideoStageState): void {
+    state.stage = 'idle';
+    state.stageStatus = '已取消';
+  }
+
+  clearCompletedResult(state: TopicVideoStageState): void {
+    if (state.stage !== 'done') return;
+    state.stage = 'idle';
+    state.resultVideoUrl = '';
+  }
+
+  resetEditable(state: TopicVideoStageState, clearResult = true): void {
+    state.stage = 'idle';
+    state.stageStatus = '';
+    state.scriptError = '';
+    state.errorMsg = '';
+    state.imageProgress = 0;
+    state.videoProgress = 0;
+    state.composeProgress = 0;
+    if (clearResult) state.resultVideoUrl = '';
+  }
+}

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

@@ -25,19 +25,19 @@
 
 
 /* —— CTA 紫蓝彩虹渐变(覆盖 .pl-cta-primary) —— */
 /* —— CTA 紫蓝彩虹渐变(覆盖 .pl-cta-primary) —— */
 .t2v-page .pl-cta-primary:not(:disabled) {
 .t2v-page .pl-cta-primary:not(:disabled) {
-  background: linear-gradient(135deg, #2563eb 0%, #7c3aed 50%, #c026d3 100%);
+  background: linear-gradient(135deg, #2563eb 0%, #06b6d4 100%);
   background-size: 200% 100%;
   background-size: 200% 100%;
-  box-shadow: 0 10px 28px rgba(124, 58, 237, 0.28);
+  box-shadow: 0 10px 24px rgba(37, 99, 235, 0.22);
   transition: transform 0.15s ease, background-position 0.4s ease, box-shadow 0.15s ease;
   transition: transform 0.15s ease, background-position 0.4s ease, box-shadow 0.15s ease;
 }
 }
 .t2v-page .pl-cta-primary:hover:not(:disabled) {
 .t2v-page .pl-cta-primary:hover:not(:disabled) {
   background-position: 100% 0;
   background-position: 100% 0;
-  box-shadow: 0 14px 34px rgba(124, 58, 237, 0.36);
+  box-shadow: 0 14px 30px rgba(37, 99, 235, 0.3);
 }
 }
 
 
 /* —— 进度填充也用彩虹渐变 —— */
 /* —— 进度填充也用彩虹渐变 —— */
 .t2v-page .pl-progress__fill {
 .t2v-page .pl-progress__fill {
-  background: linear-gradient(135deg, #2563eb 0%, #7c3aed 50%, #c026d3 100%);
+  background: linear-gradient(135deg, #2563eb 0%, #06b6d4 100%);
 }
 }
 
 
 .t2v-tip {
 .t2v-tip {
@@ -332,7 +332,7 @@
   gap: 8px;
   gap: 8px;
 }
 }
 .t2v-scene-item__actions .dh-chip-btn.is-primary {
 .t2v-scene-item__actions .dh-chip-btn.is-primary {
-  background: linear-gradient(135deg, #7c3aed, #2563eb);
+  background: linear-gradient(135deg, #2563eb, #06b6d4);
   color: #fff;
   color: #fff;
   border: none;
   border: none;
 }
 }

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

@@ -31,7 +31,7 @@
             {{ template.scope === 'system' ? '系统' : '我的' }} · {{ template.name }}
             {{ template.scope === 'system' ? '系统' : '我的' }} · {{ template.name }}
           </option>
           </option>
         </select>
         </select>
-        <button class="dh-chip-btn" type="button" [disabled]="!selectedTemplateId || isRunning || hasInflightScene" (click)="applySelectedTemplate()">
+        <button class="dh-chip-btn vf-btn" type="button" [disabled]="!selectedTemplateId || isRunning || hasInflightScene" (click)="applySelectedTemplate()">
           应用模板
           应用模板
         </button>
         </button>
       </div>
       </div>
@@ -40,7 +40,7 @@
 
 
     <div class="t2v-template-panel__save">
     <div class="t2v-template-panel__save">
       <input class="dh-input" type="text" placeholder="模板名称(可选)" [(ngModel)]="templateName" [disabled]="isRunning || hasInflightScene">
       <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 class="dh-chip-btn vf-btn" type="button" [disabled]="isRunning || hasInflightScene" (click)="saveCurrentAsTemplate()">
         保存为模板
         保存为模板
       </button>
       </button>
     </div>
     </div>
@@ -470,7 +470,7 @@
 
 
                 <!-- 操作 -->
                 <!-- 操作 -->
                 <div class="t2v-frame-slot__ops">
                 <div class="t2v-frame-slot__ops">
-                  <button class="dh-chip-btn"
+                  <button class="dh-chip-btn vf-btn"
                           [class.is-primary]="!s.imageUrl"
                           [class.is-primary]="!s.imageUrl"
                           [disabled]="!s.scenePrompt.trim() || hasInflightScene"
                           [disabled]="!s.scenePrompt.trim() || hasInflightScene"
                           (click)="regenerateImage(i)">
                           (click)="regenerateImage(i)">
@@ -480,10 +480,10 @@
                   <input #headInput type="file" accept="image/*"
                   <input #headInput type="file" accept="image/*"
                          hidden [disabled]="hasInflightScene"
                          hidden [disabled]="hasInflightScene"
                          (change)="onSceneFileChange(i, 'head', $event)">
                          (change)="onSceneFileChange(i, 'head', $event)">
-                  <button class="dh-chip-btn"
+                  <button class="dh-chip-btn vf-btn"
                           [disabled]="hasInflightScene"
                           [disabled]="hasInflightScene"
                           (click)="headInput.click()">📤 上传</button>
                           (click)="headInput.click()">📤 上传</button>
-                  <button class="dh-chip-btn"
+                  <button class="dh-chip-btn vf-btn vf-btn-danger"
                           *ngIf="s.imageUrl"
                           *ngIf="s.imageUrl"
                           [disabled]="hasInflightScene"
                           [disabled]="hasInflightScene"
                           (click)="clearSceneImage(i, 'head')"
                           (click)="clearSceneImage(i, 'head')"
@@ -519,7 +519,7 @@
                 </div>
                 </div>
 
 
                 <div class="t2v-frame-slot__ops">
                 <div class="t2v-frame-slot__ops">
-                  <button class="dh-chip-btn"
+                  <button class="dh-chip-btn vf-btn"
                           [class.is-primary]="!s.tailImageUrl"
                           [class.is-primary]="!s.tailImageUrl"
                           [disabled]="!s.scenePrompt.trim() || hasInflightScene"
                           [disabled]="!s.scenePrompt.trim() || hasInflightScene"
                           (click)="regenerateTailImage(i)">
                           (click)="regenerateTailImage(i)">
@@ -529,10 +529,10 @@
                   <input #tailInput type="file" accept="image/*"
                   <input #tailInput type="file" accept="image/*"
                          hidden [disabled]="hasInflightScene"
                          hidden [disabled]="hasInflightScene"
                          (change)="onSceneFileChange(i, 'tail', $event)">
                          (change)="onSceneFileChange(i, 'tail', $event)">
-                  <button class="dh-chip-btn"
+                  <button class="dh-chip-btn vf-btn"
                           [disabled]="hasInflightScene"
                           [disabled]="hasInflightScene"
                           (click)="tailInput.click()">📤 上传</button>
                           (click)="tailInput.click()">📤 上传</button>
-                  <button class="dh-chip-btn"
+                  <button class="dh-chip-btn vf-btn vf-btn-danger"
                           *ngIf="s.tailImageUrl"
                           *ngIf="s.tailImageUrl"
                           [disabled]="hasInflightScene"
                           [disabled]="hasInflightScene"
                           (click)="clearSceneImage(i, 'tail')"
                           (click)="clearSceneImage(i, 'tail')"
@@ -552,7 +552,7 @@
             <!-- 6. 视频操作 -->
             <!-- 6. 视频操作 -->
             <div class="t2v-scene-item__actions">
             <div class="t2v-scene-item__actions">
               <ng-container *ngIf="s.imageUrl && (s.videoMode !== 'first-last' || s.tailImageUrl)">
               <ng-container *ngIf="s.imageUrl && (s.videoMode !== 'first-last' || s.tailImageUrl)">
-                <button class="dh-chip-btn is-primary"
+                <button class="dh-chip-btn is-primary vf-btn vf-btn-primary"
                         [disabled]="hasInflightScene"
                         [disabled]="hasInflightScene"
                         (click)="regenerateVideo(i)">
                         (click)="regenerateVideo(i)">
                   <span *ngIf="!s.videoUrl">🎬 生成视频</span>
                   <span *ngIf="!s.videoUrl">🎬 生成视频</span>
@@ -579,7 +579,7 @@
       </div>
       </div>
 
 
       <div class="dh-substep pl-action-area">
       <div class="dh-substep pl-action-area">
-        <button class="pl-cta-primary"
+        <button class="pl-cta-primary vf-btn vf-btn-primary vf-btn-lg"
                 type="button"
                 type="button"
                 [disabled]="!canStart"
                 [disabled]="!canStart"
                 [attr.data-loading]="isRunning ? 'true' : null"
                 [attr.data-loading]="isRunning ? 'true' : null"
@@ -590,7 +590,7 @@
           <span *ngIf="!isRunning && resultVideoUrl">🔄 重新合成</span>
           <span *ngIf="!isRunning && resultVideoUrl">🔄 重新合成</span>
           <span *ngIf="isRunning">{{ totalProgress }}% {{ quickMode ? '生成中' : '合成中' }}</span>
           <span *ngIf="isRunning">{{ totalProgress }}% {{ quickMode ? '生成中' : '合成中' }}</span>
         </button>
         </button>
-        <button *ngIf="isRunning" class="dh-chip-btn" (click)="cancel()">取消</button>
+        <button *ngIf="isRunning" class="dh-chip-btn vf-btn" (click)="cancel()">取消</button>
       </div>
       </div>
 
 
       <!-- 手动模式:当前有几段已就绪 -->
       <!-- 手动模式:当前有几段已就绪 -->
@@ -660,8 +660,8 @@
         </div>
         </div>
 
 
         <div class="pl-result-actions">
         <div class="pl-result-actions">
-          <a class="dh-chip-btn" [href]="resultVideoUrl" target="_blank" rel="noopener">在新窗口打开</a>
-          <a class="dh-chip-btn" [href]="resultVideoUrl" [download]="(topic || '主题生视频') + '.mp4'">下载视频</a>
+          <a class="dh-chip-btn vf-btn" [href]="resultVideoUrl" target="_blank" rel="noopener">在新窗口打开</a>
+          <a class="dh-chip-btn vf-btn vf-btn-primary" [href]="resultVideoUrl" [download]="(topic || '主题生视频') + '.mp4'">下载视频</a>
         </div>
         </div>
       </div>
       </div>
     </div>
     </div>

File diff suppressed because it is too large
+ 181 - 651
src/app/pages/pipelines/topic-to-video/topic-to-video.component.ts


+ 17 - 7
src/app/pages/topic-pool/topic-pool.component.css

@@ -126,6 +126,7 @@
 .topic-list {
 .topic-list {
   display: grid;
   display: grid;
   grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
   grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
+  align-items: stretch;
   gap: 12px;
   gap: 12px;
 }
 }
 
 
@@ -143,6 +144,7 @@
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
   gap: 12px;
   gap: 12px;
+  min-height: 342px;
   border: 1px solid var(--border-color);
   border: 1px solid var(--border-color);
   border-radius: 8px;
   border-radius: 8px;
   background: var(--bg-secondary);
   background: var(--bg-secondary);
@@ -172,7 +174,7 @@
 }
 }
 
 
 .compact-head {
 .compact-head {
-  min-height: 72px;
+  min-height: 78px;
 }
 }
 
 
 .topic-source,
 .topic-source,
@@ -553,17 +555,20 @@
   flex: 0 0 auto;
   flex: 0 0 auto;
   grid-template-columns: repeat(3, minmax(0, 1fr));
   grid-template-columns: repeat(3, minmax(0, 1fr));
   gap: 8px;
   gap: 8px;
-  margin-top: 0;
+  margin-top: auto;
+  padding-top: 10px;
+  border-top: 1px solid rgba(148, 163, 184, 0.22);
 }
 }
 
 
 .topic-card-actions .btn,
 .topic-card-actions .btn,
 .topic-card-actions select {
 .topic-card-actions select {
   width: 100%;
   width: 100%;
-  height: 38px;
-  min-height: 38px;
+  height: 36px;
+  min-height: 36px;
   padding: 0 8px;
   padding: 0 8px;
   font-size: 13px;
   font-size: 13px;
   line-height: 1;
   line-height: 1;
+  border-radius: 8px;
 }
 }
 
 
 .topic-card-actions select {
 .topic-card-actions select {
@@ -602,9 +607,8 @@
   z-index: 5000;
   z-index: 5000;
   display: grid;
   display: grid;
   place-items: center;
   place-items: center;
-  background: rgba(15, 23, 42, 0.72);
+  background: rgba(15, 23, 42, 0.54);
   padding: 28px;
   padding: 28px;
-  backdrop-filter: blur(4px);
 }
 }
 
 
 .topic-modal {
 .topic-modal {
@@ -619,7 +623,7 @@
   border-radius: 8px;
   border-radius: 8px;
   background: #ffffff;
   background: #ffffff;
   color: #0f172a;
   color: #0f172a;
-  box-shadow: 0 28px 80px rgba(15, 23, 42, 0.26);
+  box-shadow: 0 28px 80px rgba(15, 23, 42, 0.34);
 }
 }
 
 
 .topic-modal-header {
 .topic-modal-header {
@@ -671,6 +675,7 @@
 .topic-modal-actions .btn {
 .topic-modal-actions .btn {
   width: 100%;
   width: 100%;
   height: 40px;
   height: 40px;
+  border-radius: 8px;
 }
 }
 
 
 .is-modal-list {
 .is-modal-list {
@@ -690,6 +695,7 @@
 
 
 .topic-modal .topic-direction-card {
 .topic-modal .topic-direction-card {
   border-color: #d7e2f5;
   border-color: #d7e2f5;
+  box-shadow: none;
 }
 }
 
 
 .topic-modal .topic-angle,
 .topic-modal .topic-angle,
@@ -732,6 +738,10 @@
     grid-template-columns: 1fr;
     grid-template-columns: 1fr;
   }
   }
 
 
+  .topic-source-card {
+    min-height: 0;
+  }
+
   .topic-list {
   .topic-list {
     grid-template-columns: 1fr;
     grid-template-columns: 1fr;
   }
   }

+ 19 - 18
src/app/pages/topic-pool/topic-pool.component.html

@@ -3,13 +3,13 @@
     <div>
     <div>
       <span class="topic-pool-eyebrow">内容机会</span>
       <span class="topic-pool-eyebrow">内容机会</span>
       <h1>选题池</h1>
       <h1>选题池</h1>
-      <p>沉淀爆款分析、日报和 AI 助手产生的选题,整理后进入生成流程。</p>
+      <p>沉淀爆款分析、日报、IP操盘和 AI 助手产生的选题,整理后进入生成流程。</p>
     </div>
     </div>
     <div class="topic-header-actions">
     <div class="topic-header-actions">
-      <button class="btn btn-primary" type="button" (click)="sendSelectedToBatch()" [disabled]="selectedCount() === 0">
+      <button class="btn btn-primary vf-btn vf-btn-primary vf-btn-lg" type="button" (click)="sendSelectedToBatch()" [disabled]="selectedCount() === 0">
         加入批量生产 {{ selectedCount() ? '(' + selectedCount() + ')' : '' }}
         加入批量生产 {{ selectedCount() ? '(' + selectedCount() + ')' : '' }}
       </button>
       </button>
-      <button class="btn" type="button" (click)="refresh()">
+      <button class="btn vf-btn vf-btn-lg" type="button" (click)="refresh()">
         <span class="icon">refresh</span>
         <span class="icon">refresh</span>
         刷新
         刷新
       </button>
       </button>
@@ -53,6 +53,7 @@
       <option value="all">全部来源</option>
       <option value="all">全部来源</option>
       <option value="viral_analysis">爆款分析</option>
       <option value="viral_analysis">爆款分析</option>
       <option value="daily_report">监测日报</option>
       <option value="daily_report">监测日报</option>
+      <option value="ip_operator">IP操盘</option>
       <option value="assistant">AI 助手</option>
       <option value="assistant">AI 助手</option>
       <option value="manual">手动创建</option>
       <option value="manual">手动创建</option>
     </select>
     </select>
@@ -115,13 +116,13 @@
       </div>
       </div>
 
 
       <div class="topic-card-actions" (click)="$event.stopPropagation()">
       <div class="topic-card-actions" (click)="$event.stopPropagation()">
-        <button class="btn btn-primary" type="button" (click)="useForTopicVideo(group.primary)">
+        <button class="btn btn-primary vf-btn vf-btn-primary" type="button" (click)="useForTopicVideo(group.primary)">
           生成
           生成
         </button>
         </button>
-        <button class="btn" type="button" (click)="openGroupDetail(group)">
+        <button class="btn vf-btn" type="button" (click)="openGroupDetail(group)">
           查看
           查看
         </button>
         </button>
-        <button class="btn" type="button" (click)="openGroupSourceVideo(group)" [disabled]="!group.sourceVideoId">
+        <button class="btn vf-btn" type="button" (click)="openGroupSourceVideo(group)" [disabled]="!group.sourceVideoId">
           来源
           来源
         </button>
         </button>
         <select [ngModel]="group.primary.status" (ngModelChange)="updateStatus(group.primary, $event)">
         <select [ngModel]="group.primary.status" (ngModelChange)="updateStatus(group.primary, $event)">
@@ -134,8 +135,8 @@
     </article>
     </article>
   </div>
   </div>
 
 
-  <div class="topic-modal-backdrop" *ngIf="activeGroup() as group" (click)="closeGroupDetail()">
-    <section class="topic-modal" (click)="$event.stopPropagation()">
+  <div class="topic-modal-backdrop vf-modal-backdrop" *ngIf="activeGroup() as group" (click)="closeGroupDetail()">
+    <section class="topic-modal vf-modal" (click)="$event.stopPropagation()">
       <header class="topic-modal-header">
       <header class="topic-modal-header">
         <div>
         <div>
           <div class="topic-select-row">
           <div class="topic-select-row">
@@ -150,9 +151,9 @@
       </header>
       </header>
 
 
       <div class="topic-modal-actions">
       <div class="topic-modal-actions">
-        <button class="btn btn-primary" type="button" (click)="useForTopicVideo(group.primary)">用主推生成视频</button>
-        <button class="btn" type="button" (click)="useForDigitalHuman(group.primary)">主推进数字人</button>
-        <button class="btn" type="button" (click)="openGroupSourceVideo(group)" [disabled]="!group.sourceVideoId">查看来源视频</button>
+        <button class="btn btn-primary vf-btn vf-btn-primary vf-btn-lg" type="button" (click)="useForTopicVideo(group.primary)">用主推生成视频</button>
+        <button class="btn vf-btn vf-btn-lg" type="button" (click)="useForDigitalHuman(group.primary)">主推进数字人</button>
+        <button class="btn vf-btn vf-btn-lg" type="button" (click)="openGroupSourceVideo(group)" [disabled]="!group.sourceVideoId">查看来源视频</button>
       </div>
       </div>
 
 
       <div class="topic-direction-list is-modal-list">
       <div class="topic-direction-list is-modal-list">
@@ -163,7 +164,7 @@
               <span class="topic-variant" *ngIf="topicVariantLabel(topic)">{{topicVariantLabel(topic)}}</span>
               <span class="topic-variant" *ngIf="topicVariantLabel(topic)">{{topicVariantLabel(topic)}}</span>
               <span class="topic-recommended" *ngIf="recommendedLabel(topic, group)">主推</span>
               <span class="topic-recommended" *ngIf="recommendedLabel(topic, group)">主推</span>
             </label>
             </label>
-            <button class="btn btn-mini" type="button" (click)="setRecommended(topic)" [disabled]="topic.id === group.primary.id">
+            <button class="btn btn-mini vf-btn vf-btn-sm" type="button" (click)="setRecommended(topic)" [disabled]="topic.id === group.primary.id">
               设为主推
               设为主推
             </button>
             </button>
           </div>
           </div>
@@ -202,8 +203,8 @@
               <textarea rows="5" [ngModel]="editDraft().outline" (ngModelChange)="updateEditDraft({ outline: $event })"></textarea>
               <textarea rows="5" [ngModel]="editDraft().outline" (ngModelChange)="updateEditDraft({ outline: $event })"></textarea>
             </label>
             </label>
             <div class="topic-edit-actions">
             <div class="topic-edit-actions">
-              <button class="btn btn-primary" type="button" (click)="saveEdit(topic)">保存修改</button>
-              <button class="btn" type="button" (click)="cancelEdit()">取消</button>
+              <button class="btn btn-primary vf-btn vf-btn-primary" type="button" (click)="saveEdit(topic)">保存修改</button>
+              <button class="btn vf-btn" type="button" (click)="cancelEdit()">取消</button>
             </div>
             </div>
           </div>
           </div>
 
 
@@ -228,16 +229,16 @@
           </details>
           </details>
 
 
           <div class="topic-direction-actions">
           <div class="topic-direction-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)="startEdit(topic)">编辑</button>
+            <button class="btn btn-primary vf-btn vf-btn-primary" type="button" (click)="useForTopicVideo(topic)">用此方向生成</button>
+            <button class="btn vf-btn" type="button" (click)="useForDigitalHuman(topic)">进入数字人</button>
+            <button class="btn vf-btn" type="button" (click)="startEdit(topic)">编辑</button>
             <select [ngModel]="topic.status" (ngModelChange)="updateStatus(topic, $event)">
             <select [ngModel]="topic.status" (ngModelChange)="updateStatus(topic, $event)">
               <option value="idea">待筛选</option>
               <option value="idea">待筛选</option>
               <option value="script_ready">脚本就绪</option>
               <option value="script_ready">脚本就绪</option>
               <option value="generating">生成中</option>
               <option value="generating">生成中</option>
               <option value="completed">已完成</option>
               <option value="completed">已完成</option>
             </select>
             </select>
-            <button class="btn btn-warn" type="button" (click)="archive(topic)">归档</button>
+            <button class="btn btn-warn vf-btn vf-btn-danger" type="button" (click)="archive(topic)">归档</button>
           </div>
           </div>
         </article>
         </article>
       </div>
       </div>

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

@@ -260,6 +260,7 @@ export class TopicPoolComponent implements OnInit {
       daily_report: '监测日报',
       daily_report: '监测日报',
       assistant: 'AI 助手',
       assistant: 'AI 助手',
       manual: '手动创建',
       manual: '手动创建',
+      ip_operator: 'IP操盘',
     };
     };
     return map[source] || source;
     return map[source] || source;
   }
   }

+ 295 - 61
src/app/services/auth-credit.service.ts

@@ -39,12 +39,30 @@ export interface CreditLedgerItem {
   createdAt: string;
   createdAt: string;
 }
 }
 
 
-export interface RechargeOrder {
-  objectId: string;
-  amountCny: number;
-  creditAmount: number;
-  status: 'pending' | 'paid' | 'cancelled';
-  createdAt: string;
+export interface ApigPriceStep {
+  count: number;
+  price: number;
+}
+
+export interface ApigRechargeContext {
+  authId: string;
+  userId: string;
+  payUserId: string;
+  apig: {
+    objectId: string;
+    title: string;
+    count: number;
+    priceStep: ApigPriceStep[];
+  };
+}
+
+export interface ApigPaymentSession {
+  tradeNo: string;
+  nonceStr: string;
+  codeUrl: string;
+  orderId: string;
+  tier: ApigPriceStep;
+  context: ApigRechargeContext;
 }
 }
 
 
 export interface AdminUserRow extends AppUser {
 export interface AdminUserRow extends AppUser {
@@ -71,10 +89,12 @@ export interface CreditReserveResult {
 const SESSION_STORAGE_KEY = 'videoWorkflow.authSession';
 const SESSION_STORAGE_KEY = 'videoWorkflow.authSession';
 const LOCAL_USERS_KEY = 'videoWorkflow.localAuth.users';
 const LOCAL_USERS_KEY = 'videoWorkflow.localAuth.users';
 const LOCAL_LEDGER_KEY = 'videoWorkflow.localAuth.ledger';
 const LOCAL_LEDGER_KEY = 'videoWorkflow.localAuth.ledger';
-const LOCAL_ORDERS_KEY = 'videoWorkflow.localAuth.orders';
 const PARSE_API_HOST = 'https://server.fmode.cn';
 const PARSE_API_HOST = 'https://server.fmode.cn';
 const PARSE_APP_ID = 'ncloudmaster';
 const PARSE_APP_ID = 'ncloudmaster';
 const LOGIN_SMS_COMPANY = 'E4KpGvTEto';
 const LOGIN_SMS_COMPANY = 'E4KpGvTEto';
+const VIDEO_WORKFLOW_APIG_ID = '6pFf6EAdKT';
+const APIG_PAY_COMPANY = '1AiWpTEDH9';
+const APIG_PAY_FUN_ID = 'HOkkX72PMF';
 const CREDIT_LIMIT_ENABLED = false;
 const CREDIT_LIMIT_ENABLED = false;
 
 
 @Injectable({ providedIn: 'root' })
 @Injectable({ providedIn: 'root' })
@@ -100,7 +120,7 @@ export class AuthCreditService {
   }
   }
 
 
   get isAdmin(): boolean {
   get isAdmin(): boolean {
-    return this.currentUser?.role === 'admin';
+    return false;
   }
   }
 
 
   requestLogin(featureName = '该功能'): void {
   requestLogin(featureName = '该功能'): void {
@@ -164,6 +184,7 @@ export class AuthCreditService {
     }
     }
 
 
     const parseUser = await this.fetchParseMe(token);
     const parseUser = await this.fetchParseMe(token);
+    await this.ensureVideoWorkflowApigAuth(parseUser.objectId, token);
     const session: AuthSession = {
     const session: AuthSession = {
       token,
       token,
       user: this.parseUserToAppUser(parseUser, normalized),
       user: this.parseUserToAppUser(parseUser, normalized),
@@ -173,32 +194,6 @@ export class AuthCreditService {
     return session;
     return session;
   }
   }
 
 
-  async loginWithParsePassword(username: string, password: string): Promise<AuthSession> {
-    const account = String(username || '').trim();
-    const pass = String(password || '');
-    if (!account || !pass) throw new Error('请输入账号和密码');
-
-    const params = new URLSearchParams({ username: account, password: pass });
-    const resp = await fetch(`${PARSE_API_HOST}/parse/login?${params.toString()}`, {
-      method: 'GET',
-      headers: { 'X-Parse-Application-Id': PARSE_APP_ID },
-    });
-    const loginData = await resp.json().catch(() => ({}));
-    const token = loginData.sessionToken;
-    if (!resp.ok || !token) {
-      throw new Error(loginData.error || loginData.message || '账号或密码不正确');
-    }
-
-    const parseUser = await this.fetchParseMe(token);
-    const session: AuthSession = {
-      token,
-      user: this.parseUserToAppUser(parseUser, parseUser.mobilePhoneNumber || ''),
-    };
-    if (this.localMode) this.ensureLocalCreditUser(session.user);
-    this.setSession(session);
-    return session;
-  }
-
   async refreshMe(): Promise<AppUser | null> {
   async refreshMe(): Promise<AppUser | null> {
     if (!this.session) return null;
     if (!this.session) return null;
     if (this.localMode) return this.session.user;
     if (this.localMode) return this.session.user;
@@ -213,6 +208,9 @@ export class AuthCreditService {
   }
   }
 
 
   async getBalance(): Promise<CreditBalance> {
   async getBalance(): Promise<CreditBalance> {
+    if (this.session?.token && this.currentUser?.objectId) {
+      return this.getVideoWorkflowApigBalance(this.currentUser.objectId, this.session.token);
+    }
     if (this.localMode) return this.localBalance();
     if (this.localMode) return this.localBalance();
     return this.call<CreditBalance>('balance', this.authPayload());
     return this.call<CreditBalance>('balance', this.authPayload());
   }
   }
@@ -222,12 +220,6 @@ export class AuthCreditService {
     return this.call<CreditLedgerItem[]>('ledger', { ...this.authPayload(), limit });
     return this.call<CreditLedgerItem[]>('ledger', { ...this.authPayload(), limit });
   }
   }
 
 
-  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> {
   async reserveCredit(operation: string, cost: number, title: string, detail: any = {}): Promise<CreditReserveResult> {
     if (!CREDIT_LIMIT_ENABLED) {
     if (!CREDIT_LIMIT_ENABLED) {
       const balance = this.currentUser ? (await this.getBalance().catch(() => null))?.balance || 0 : 0;
       const balance = this.currentUser ? (await this.getBalance().catch(() => null))?.balance || 0 : 0;
@@ -344,6 +336,178 @@ export class AuthCreditService {
     return data;
     return data;
   }
   }
 
 
+  private async getVideoWorkflowApigBalance(userId: string, sessionToken: string): Promise<CreditBalance> {
+    let auth = await this.findVideoWorkflowApigAuth(userId, sessionToken);
+    if (!auth) {
+      await this.ensureVideoWorkflowApigAuth(userId, sessionToken);
+      auth = await this.findVideoWorkflowApigAuth(userId, sessionToken);
+    }
+    const apig = auth?.objectId ? await this.fetchApigPayInfo(auth.objectId).catch(() => null) : null;
+    const balance = Number(apig?.count ?? auth?.count ?? 0);
+    const used = Number(apig?.used ?? auth?.used ?? 0);
+    return {
+      balance,
+      gifted: 0,
+      totalRecharged: balance + used,
+      totalConsumed: used,
+    };
+  }
+
+  async getVideoWorkflowRechargeContext(): Promise<ApigRechargeContext> {
+    if (!this.session?.token || !this.currentUser?.objectId) throw new Error('请先登录');
+    await this.ensureVideoWorkflowApigAuth(this.currentUser.objectId, this.session.token);
+    const auth = await this.findVideoWorkflowApigAuth(this.currentUser.objectId, this.session.token);
+    if (!auth?.objectId) throw new Error('无法获取 APIGAuth 充值账套');
+
+    const apig = await this.fetchApigPayInfo(auth.objectId);
+    if (!apig) throw new Error('无法通过 APIGAuth 加载充值信息');
+
+    const priceStep = this.normalizePriceSteps(apig.priceStep);
+    if (!priceStep.length) throw new Error('当前 APIG 未配置充值套餐 priceStep');
+
+    return {
+      authId: auth.objectId,
+      userId: this.currentUser.objectId,
+      payUserId: this.currentUser.companyId || auth.objectId,
+      apig: {
+        objectId: apig.objectId || apig.api?.objectId || auth.api?.objectId || VIDEO_WORKFLOW_APIG_ID,
+        title: apig.title || apig.api?.title || auth.api?.title || '短视频 AI 工作流',
+        count: Number(apig.count ?? auth.count ?? 0),
+        priceStep,
+      },
+    };
+  }
+
+  async startVideoWorkflowPayment(context: ApigRechargeContext, tier: ApigPriceStep): Promise<ApigPaymentSession> {
+    if (!this.currentUser?.objectId) throw new Error('请先登录');
+    const tradeNo = this.buildTradeNo(this.currentUser.objectId);
+    const params = {
+      out_trade_no: tradeNo,
+      total_fee: Number(tier.price),
+      body: `${context.apig.title}接口充值`,
+    };
+    const order = await this.createApigOrder(context, tier, params).catch(() => null);
+    const payResp = await this.requestJson(`${PARSE_API_HOST}/parse/functions/pay_code2`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        _ApplicationId: PARSE_APP_ID,
+        company: APIG_PAY_COMPANY,
+        fun_id: APIG_PAY_FUN_ID,
+        ...params,
+      }),
+    }, 20000).catch(() => this.requestJson(`${PARSE_API_HOST}/parse/functions/pay_code2`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        _ApplicationId: PARSE_APP_ID,
+        company: APIG_PAY_COMPANY,
+        ...params,
+      }),
+    }, 20000));
+    const result = payResp.result || payResp;
+    const codeUrl = Array.isArray(result.code_url) ? result.code_url[0] : result.code_url;
+    if (!codeUrl) throw new Error(`获取支付二维码失败:${JSON.stringify(payResp)}`);
+    return {
+      tradeNo,
+      nonceStr: result.nonce_str || '',
+      codeUrl,
+      orderId: order?.objectId || '',
+      tier,
+      context,
+    };
+  }
+
+  async checkApigPayment(session: ApigPaymentSession): Promise<boolean> {
+    const resp = await this.requestJson(`${PARSE_API_HOST}/parse/functions/order_status2`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        _ApplicationId: PARSE_APP_ID,
+        out_trade_no: session.tradeNo,
+        nonce_str: session.nonceStr,
+        company: APIG_PAY_COMPANY,
+      }),
+    }, 15000);
+    const result = resp.result || resp;
+    return Array.isArray(result.status) ? result.status[0] === 'SUCCESS' : result.status === 'SUCCESS';
+  }
+
+  async saveApigRecharge(session: ApigPaymentSession): Promise<void> {
+    const payUserId = session.context.payUserId || session.context.userId || session.context.authId;
+    const resp = await this.requestJson(`${PARSE_API_HOST}/api/apig/saveRecharge`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        user: payUserId,
+        authComp: payUserId,
+        authid: session.context.authId,
+        apigid: session.context.apig.objectId,
+        oldCount: session.context.apig.count || 0,
+        count: session.tier.count,
+        orderid: session.orderId,
+      }),
+    }, 20000);
+    if (resp.code && Number(resp.code) >= 400) {
+      throw new Error(resp.message || resp.error || '充值到账失败');
+    }
+  }
+
+  async completeApigRecharge(session: ApigPaymentSession): Promise<ApigRechargeContext> {
+    const callbackContext = await this.waitForApigBalanceIncrease(session, 8, 3000);
+    if (callbackContext) return callbackContext;
+
+    await this.saveApigRecharge(session);
+    return await this.waitForApigBalanceIncrease(session, 3, 2000)
+      || await this.getVideoWorkflowRechargeContext();
+  }
+
+  private async ensureVideoWorkflowApigAuth(userId: string, sessionToken: string): Promise<void> {
+    if (!userId || !sessionToken) return;
+    const existing = await this.findVideoWorkflowApigAuth(userId, sessionToken);
+    if (existing) return;
+
+    const headers = {
+      'X-Parse-Application-Id': PARSE_APP_ID,
+      'X-Parse-Session-Token': sessionToken,
+    };
+
+    const created = await this.requestJson(`${PARSE_API_HOST}/parse/classes/APIGAuth`, {
+      method: 'POST',
+      headers: {
+        ...headers,
+        'Content-Type': 'application/json',
+      },
+      body: JSON.stringify({
+        api: { __type: 'Pointer', className: 'APIG', objectId: VIDEO_WORKFLOW_APIG_ID },
+        user: { __type: 'Pointer', className: '_User', objectId: userId },
+        count: 0,
+        used: 0,
+      }),
+    }, 15000);
+    if (!created.objectId) {
+      throw new Error(created.error || created.message || '无法创建 APIGAuth 账套');
+    }
+  }
+
+  private async findVideoWorkflowApigAuth(userId: string, sessionToken: string): Promise<any | null> {
+    const where = JSON.stringify({
+      api: { __type: 'Pointer', className: 'APIG', objectId: VIDEO_WORKFLOW_APIG_ID },
+      user: { __type: 'Pointer', className: '_User', objectId: userId },
+    });
+    const query = new URLSearchParams({ where, include: 'api', limit: '1' });
+    const headers = {
+      'X-Parse-Application-Id': PARSE_APP_ID,
+      'X-Parse-Session-Token': sessionToken,
+    };
+
+    const existing = await this.requestJson(`${PARSE_API_HOST}/parse/classes/APIGAuth?${query.toString()}`, {
+      method: 'GET',
+      headers,
+    }, 15000);
+    return Array.isArray(existing.results) ? existing.results[0] || null : null;
+  }
+
   private parseUserToAppUser(parseUser: any, mobile: string): AppUser {
   private parseUserToAppUser(parseUser: any, mobile: string): AppUser {
     const displayName = parseUser.nickname
     const displayName = parseUser.nickname
       || parseUser.name
       || parseUser.name
@@ -358,11 +522,100 @@ export class AuthCreditService {
       phone: parseUser.mobilePhoneNumber || mobile || '',
       phone: parseUser.mobilePhoneNumber || mobile || '',
       displayName,
       displayName,
       companyId: parseUser.company?.objectId || '',
       companyId: parseUser.company?.objectId || '',
-      role: parseUser.role === 'admin' || parseUser.isAdmin === true ? 'admin' : 'user',
+      role: 'user',
       createdAt: parseUser.createdAt,
       createdAt: parseUser.createdAt,
     };
     };
   }
   }
 
 
+  private async fetchApigPayInfo(authId: string): Promise<any | null> {
+    const resp = await this.requestJson(`${PARSE_API_HOST}/api/apig/getApig`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ authid: authId }),
+    }, 15000);
+    return resp.code === 200 && resp.data ? resp.data : null;
+  }
+
+  private normalizePriceSteps(raw: any): ApigPriceStep[] {
+    const source = Array.isArray(raw) ? raw : [];
+    return source
+      .map((item: any) => {
+        const row = item && typeof item === 'object' ? item : {};
+        return { count: Number(row.count || 0), price: Number(row.price || 0) };
+      })
+      .filter((item: ApigPriceStep) => item.count > 0 && item.price > 0);
+  }
+
+  private async createApigOrder(context: ApigRechargeContext, tier: ApigPriceStep, params: any): Promise<any> {
+    const payUserId = context.payUserId || context.userId || context.authId;
+    const resp = await this.requestJson(`${PARSE_API_HOST}/api/apig/created-apigorder`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        user: payUserId,
+        fcompany: payUserId,
+        type: 'wxpay',
+        authid: context.authId,
+        params,
+        apigid: context.apig.objectId,
+        oldCount: context.apig.count || 0,
+        count: tier.count,
+      }),
+    }, 20000);
+    if (resp.code === 200 && resp.data) return resp.data;
+    throw new Error(resp.message || resp.error || '创建 APIGOrder 失败');
+  }
+
+  private async waitForApigBalanceIncrease(
+    session: ApigPaymentSession,
+    attempts: number,
+    intervalMs: number
+  ): Promise<ApigRechargeContext | null> {
+    const oldCount = Number(session.context.apig.count || 0);
+    for (let i = 0; i < attempts; i++) {
+      await this.sleep(intervalMs);
+      const context = await this.getVideoWorkflowRechargeContext().catch(() => null);
+      if (context && Number(context.apig.count || 0) > oldCount) return context;
+    }
+    return null;
+  }
+
+  private sleep(ms: number): Promise<void> {
+    return new Promise((resolve) => setTimeout(resolve, ms));
+  }
+
+  private async requestJson(url: string, init: RequestInit, timeoutMs: number): Promise<any> {
+    const controller = new AbortController();
+    const timer = setTimeout(() => controller.abort(), timeoutMs);
+    try {
+      const resp = await fetch(url, { ...init, signal: controller.signal });
+      const data = await resp.json().catch(() => ({}));
+      if (!resp.ok) throw new Error(data.message || data.error || `HTTP ${resp.status}`);
+      return data;
+    } catch (e: any) {
+      if (e?.name === 'AbortError') throw new Error('请求超时,请稍后重试');
+      throw e;
+    } finally {
+      clearTimeout(timer);
+    }
+  }
+
+  private buildTradeNo(userId: string): string {
+    const now = new Date();
+    const pad = (value: number, size = 2) => String(value).padStart(size, '0');
+    return [
+      'C',
+      userId,
+      now.getFullYear(),
+      pad(now.getMonth() + 1),
+      pad(now.getDate()),
+      pad(now.getHours()),
+      pad(now.getMinutes()),
+      pad(now.getSeconds()),
+      pad(now.getMilliseconds(), 3),
+    ].join('');
+  }
+
   private maskMobile(mobile: string): string {
   private maskMobile(mobile: string): string {
     const value = String(mobile || '');
     const value = String(mobile || '');
     return /^1[3-9]\d{9}$/.test(value) ? value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : value;
     return /^1[3-9]\d{9}$/.test(value) ? value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : value;
@@ -404,7 +657,7 @@ export class AuthCreditService {
       email: input.email || '',
       email: input.email || '',
       phone: input.phone || '',
       phone: input.phone || '',
       displayName: input.displayName || identifier,
       displayName: input.displayName || identifier,
-      role: isFirst ? 'admin' : 'user',
+      role: 'user',
       createdAt: new Date().toISOString(),
       createdAt: new Date().toISOString(),
     };
     };
     users.push({ user, password: input.password, balance: 10 });
     users.push({ user, password: input.password, balance: 10 });
@@ -478,21 +731,6 @@ export class AuthCreditService {
     return user;
     return user;
   }
   }
 
 
-  private localCreateRechargeOrder(amountCny: number): RechargeOrder {
-    if (!this.currentUser) throw new Error('请先登录');
-    const order: RechargeOrder = {
-      objectId: this.localId(),
-      amountCny,
-      creditAmount: Math.round(Number(amountCny || 0) * 10),
-      status: 'pending',
-      createdAt: new Date().toISOString(),
-    };
-    const orders = this.localOrders();
-    orders.unshift({ ...order, userId: this.currentUser.objectId });
-    localStorage.setItem(LOCAL_ORDERS_KEY, JSON.stringify(orders));
-    return order;
-  }
-
   private localReserve(operation: string, cost: number, title: string, detail: any): CreditReserveResult {
   private localReserve(operation: string, cost: number, title: string, detail: any): CreditReserveResult {
     if (!this.currentUser) throw new Error('请先登录');
     if (!this.currentUser) throw new Error('请先登录');
     const users = this.localUsers();
     const users = this.localUsers();
@@ -562,10 +800,6 @@ export class AuthCreditService {
     localStorage.setItem(LOCAL_LEDGER_KEY, JSON.stringify(rows));
     localStorage.setItem(LOCAL_LEDGER_KEY, JSON.stringify(rows));
   }
   }
 
 
-  private localOrders(): any[] {
-    try { return JSON.parse(localStorage.getItem(LOCAL_ORDERS_KEY) || '[]'); } catch { return []; }
-  }
-
   private localId(): string {
   private localId(): string {
     const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
     const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
     let s = '';
     let s = '';

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

@@ -122,6 +122,31 @@ export class BatchJobService {
     this.recalculateJob(item.batchJobId, 'queued');
     this.recalculateJob(item.batchJobId, 'queued');
   }
   }
 
 
+  completeItemFromRecovery(input: {
+    batchJobId?: string;
+    batchItemId?: string;
+    generationTaskId: string;
+    resultUrl: string;
+    externalTaskIds?: Record<string, string>;
+  }): boolean {
+    const item = this.items.find((row) =>
+      (input.batchItemId && row.id === input.batchItemId)
+      || (!!input.batchJobId && row.batchJobId === input.batchJobId && row.generationTaskId === input.generationTaskId),
+    );
+    if (!item || item.status === 'completed' || item.status === 'cancelled') return false;
+
+    this.patchItem(item.id, {
+      status: 'completed',
+      resultUrl: input.resultUrl,
+      generationTaskId: input.generationTaskId,
+      externalTaskIds: { ...(item.externalTaskIds || {}), ...(input.externalTaskIds || {}) },
+      finishedAt: new Date().toISOString(),
+      logs: this.appendItemLog(item, 'info', 'recovery', '任务恢复中心已同步外部结果,子任务自动完成'),
+    });
+    this.recalculateJob(item.batchJobId);
+    return true;
+  }
+
   async run(jobId: string, runner: BatchItemRunner = this.mockRunner): Promise<void> {
   async run(jobId: string, runner: BatchItemRunner = this.mockRunner): Promise<void> {
     const job = this.findJob(jobId);
     const job = this.findJob(jobId);
     if (!job || job.status === 'cancelled') return;
     if (!job || job.status === 'cancelled') return;
@@ -135,6 +160,7 @@ export class BatchJobService {
       status: 'running',
       status: 'running',
       startedAt: job.startedAt || new Date().toISOString(),
       startedAt: job.startedAt || new Date().toISOString(),
       lastRunAt: new Date().toISOString(),
       lastRunAt: new Date().toISOString(),
+      pauseReason: undefined,
     });
     });
 
 
     try {
     try {
@@ -155,6 +181,14 @@ export class BatchJobService {
         try {
         try {
           const result = await runner(nextItem, currentJob, this.createRunContext(nextItem.id));
           const result = await runner(nextItem, currentJob, this.createRunContext(nextItem.id));
           const latestItem = this.items.find((item) => item.id === nextItem.id) || nextItem;
           const latestItem = this.items.find((item) => item.id === nextItem.id) || nextItem;
+          if (latestItem.status === 'waiting_external') {
+            this.patchItem(nextItem.id, {
+              logs: this.appendItemLog(latestItem, 'info', latestItem.lastStepId || 'external', '子任务已进入外部结果等待,刷新后可继续恢复检查'),
+            });
+            this.recalculateJob(jobId);
+            await this.waitBeforeNextItem(jobId);
+            continue;
+          }
           this.patchItem(nextItem.id, {
           this.patchItem(nextItem.id, {
             status: 'completed',
             status: 'completed',
             resultUrl: result?.resultUrl,
             resultUrl: result?.resultUrl,
@@ -174,8 +208,16 @@ export class BatchJobService {
             finishedAt: new Date().toISOString(),
             finishedAt: new Date().toISOString(),
             logs: this.appendItemLog(latestItem, 'error', errorCode, message),
             logs: this.appendItemLog(latestItem, 'error', errorCode, message),
           });
           });
+          if (errorCode === 'rate_limit') {
+            this.stopRequested = true;
+            this.patchJob(jobId, {
+              status: 'paused',
+              pauseReason: '上游限流,已暂停队列,避免后续子任务连续失败。建议稍后手动继续。',
+            });
+          }
         }
         }
         this.recalculateJob(jobId);
         this.recalculateJob(jobId);
+        await this.waitBeforeNextItem(jobId);
       }
       }
     } finally {
     } finally {
       const finalJob = this.findJob(jobId);
       const finalJob = this.findJob(jobId);
@@ -256,6 +298,28 @@ export class BatchJobService {
     };
     };
   }
   }
 
 
+  private async waitBeforeNextItem(jobId: string): Promise<void> {
+    const job = this.findJob(jobId);
+    if (!job || this.stopRequested || job.status === 'cancelled' || job.status === 'paused') return;
+    const hasQueued = this.listItems(jobId).some((item) => item.status === 'queued');
+    if (!hasQueued) {
+      this.patchJob(jobId, { nextRunAt: undefined });
+      return;
+    }
+
+    const intervalMs = Math.max(0, Number(job.intervalMs || 0));
+    if (!intervalMs) {
+      this.patchJob(jobId, { nextRunAt: undefined });
+      return;
+    }
+
+    this.patchJob(jobId, {
+      nextRunAt: new Date(Date.now() + intervalMs).toISOString(),
+    });
+    await new Promise<void>((resolve) => window.setTimeout(resolve, intervalMs));
+    this.patchJob(jobId, { nextRunAt: undefined });
+  }
+
   private findJob(id: string): BatchJob | undefined {
   private findJob(id: string): BatchJob | undefined {
     const userId = this.currentUserId();
     const userId = this.currentUserId();
     return this.jobs.find((job) => job.id === id && job.userId === userId);
     return this.jobs.find((job) => job.id === id && job.userId === userId);

+ 21 - 0
src/app/services/cloud-api.interceptor.spec.ts

@@ -0,0 +1,21 @@
+import { cloudFunctionErrorMessage } from './cloud-api.interceptor';
+
+describe('cloudApiInterceptor helpers', () => {
+  it('preserves nested upstream error details from cloud function responses', () => {
+    const message = cloudFunctionErrorMessage({
+      code: 500,
+      success: false,
+      data: {
+        error: {
+          message: '主账户可用余额不足,请充值后再使用',
+          type: 'upstream_error',
+          code: 'insufficient_balance',
+        },
+      },
+    });
+
+    expect(message).toContain('余额不足');
+    expect(message).toContain('code=insufficient_balance');
+    expect(message).toContain('type=upstream_error');
+  });
+});

+ 41 - 4
src/app/services/cloud-api.interceptor.ts

@@ -9,6 +9,8 @@
  *   - /api/remixes[/:videoId[/:remixId]]
  *   - /api/remixes[/:videoId[/:remixId]]
  *   - /api/voice/auto-speaker-id
  *   - /api/voice/auto-speaker-id
  *   - /api/voice-profiles/sync
  *   - /api/voice-profiles/sync
+ *   - /api/volcengine/jimeng/:endpoint
+ *   - /api/volcengine/jimeng/work/:workId
  *   - /api/llm/chat
  *   - /api/llm/chat
  *   - /api/llm/gemini
  *   - /api/llm/gemini
  *
  *
@@ -38,10 +40,22 @@ function ok(body: any, req: HttpRequest<any>): HttpResponse<any> {
 }
 }
 
 
 /** 把云函数响应({code,success,data,error})解包并校验 */
 /** 把云函数响应({code,success,data,error})解包并校验 */
+export function cloudFunctionErrorMessage(res: any): string {
+  const detail = res?.data?.error || res?.raw?.data?.error || res?.raw?.error || res?.error || res?.message;
+  if (!detail) return '';
+  if (typeof detail === 'string') return detail;
+  const parts = [
+    detail.message,
+    detail.code ? `code=${detail.code}` : '',
+    detail.type ? `type=${detail.type}` : '',
+  ].filter(Boolean);
+  return parts.join(';') || JSON.stringify(detail);
+}
+
 function unwrap(res: any): any {
 function unwrap(res: any): any {
   if (!res || typeof res !== 'object') throw new Error('云函数响应格式异常');
   if (!res || typeof res !== 'object') throw new Error('云函数响应格式异常');
   if (res.code !== 200 || !res.success) {
   if (res.code !== 200 || !res.success) {
-    throw new Error(res.error || res.message || '云函数调用失败');
+    throw new Error(cloudFunctionErrorMessage(res) || '云函数调用失败');
   }
   }
   return res.data;
   return res.data;
 }
 }
@@ -53,12 +67,13 @@ export const cloudApiInterceptor: HttpInterceptorFn = (req, next) => {
   const parse = inject(ParseService);
   const parse = inject(ParseService);
   const auth = inject(AuthCreditService);
   const auth = inject(AuthCreditService);
   const method = req.method.toUpperCase();
   const method = req.method.toUpperCase();
-  const body = req.body && typeof req.body === 'object' ? req.body : {};
-  const [resource, p1, p2] = segs;
-  const scoped = (params: Record<string, any>) => ({
+  const body: Record<string, any> = req.body && typeof req.body === 'object' ? req.body as Record<string, any> : {};
+  const [resource, p1, p2, p3] = segs;
+  const scoped = (params: Record<string, any>, extra: Record<string, any> = {}) => ({
     ...params,
     ...params,
     userId: auth.currentUser?.objectId || '__anonymous__',
     userId: auth.currentUser?.objectId || '__anonymous__',
     sessionToken: auth.session?.token || '',
     sessionToken: auth.session?.token || '',
+    ...extra,
   });
   });
 
 
   // 通用 helper:调云函数并把响应转成 HttpResponse
   // 通用 helper:调云函数并把响应转成 HttpResponse
@@ -120,6 +135,28 @@ export const cloudApiInterceptor: HttpInterceptorFn = (req, next) => {
     return call(CLOUD_FN.voice, { action: 'syncProfile', profile: body }, data => ({ success: true, profile: data }));
     return call(CLOUD_FN.voice, { action: 'syncProfile', profile: body }, data => ({ success: true, profile: data }));
   }
   }
 
 
+  // ==================== jimeng ====================
+  if (resource === 'volcengine' && p1 === 'jimeng') {
+    if (p2 === 'work' && p3 && method === 'GET') {
+      return call(
+        CLOUD_FN.jimeng,
+        scoped({ action: 'getWorkResult', workId: p3 }),
+        data => ({ code: 200, success: true, data })
+      );
+    }
+
+    if (p2 && method === 'POST') {
+      return call(
+        CLOUD_FN.jimeng,
+        scoped(
+          { action: 'call', endpoint: p2, payload: body },
+          { idempotencyKey: body['idempotencyKey'] || body['generationTaskId'] || '' }
+        ),
+        data => data
+      );
+    }
+  }
+
   // ==================== llm ====================
   // ==================== llm ====================
   if (resource === 'llm') {
   if (resource === 'llm') {
     if (p1 === 'chat'   && method === 'POST') return call(CLOUD_FN.proxy, { action: 'llmChat', payload: body }, data => data);
     if (p1 === 'chat'   && method === 'POST') return call(CLOUD_FN.proxy, { action: 'llmChat', payload: body }, data => data);

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

@@ -89,6 +89,21 @@ export class ContentAssetService {
     });
     });
   }
   }
 
 
+  updateParameters(assetKey: string, patch: Record<string, any>): ContentAssetMetadata | undefined {
+    const current = this.getByAssetKey(assetKey);
+    if (!current) return undefined;
+    const next: ContentAssetMetadata = {
+      ...current,
+      parameters: {
+        ...(current.parameters || {}),
+        ...patch,
+      },
+      updatedAt: Date.now(),
+    };
+    this.upsert(next);
+    return next;
+  }
+
   updateMany(assetKeys: string[], patch: { projectName?: string; addTags?: string[]; archived?: boolean }): void {
   updateMany(assetKeys: string[], patch: { projectName?: string; addTags?: string[]; archived?: boolean }): void {
     const keys = new Set(assetKeys);
     const keys = new Set(assetKeys);
     const addTags = this.normalizeTags(patch.addTags || []);
     const addTags = this.normalizeTags(patch.addTags || []);
@@ -126,6 +141,9 @@ export class ContentAssetService {
   }
   }
 
 
   private deriveTags(input: ContentAssetUpsertInput): string[] {
   private deriveTags(input: ContentAssetUpsertInput): string[] {
+    if (input.type === 'text') {
+      return this.normalizeTags(['文本素材', '待补充资料']);
+    }
     const tags = [
     const tags = [
       input.type === 'video' ? '视频' : input.type === 'image' ? '图片' : '音频',
       input.type === 'video' ? '视频' : input.type === 'image' ? '图片' : '音频',
       input.isFinal ? '成片' : '中间产物',
       input.isFinal ? '成片' : '中间产物',

+ 64 - 0
src/app/services/content-production-entry.service.spec.ts

@@ -0,0 +1,64 @@
+import { TopicIdea } from '../models/douyin-insight.model';
+import { ContentProductionEntryService } from './content-production-entry.service';
+import { CreationBriefService } from './creation-brief.service';
+
+describe('ContentProductionEntryService', () => {
+  beforeEach(() => {
+    sessionStorage.clear();
+  });
+
+  it('carries IP operator topic context into topic-to-video pending brief', () => {
+    const creationBriefs = new CreationBriefService();
+    const service = new ContentProductionEntryService(
+      createTopicPoolStub(),
+      creationBriefs,
+      {} as any,
+      {} as any,
+    );
+    const topic: TopicIdea = {
+      id: 'ip-topic-1',
+      userId: 'user-1',
+      title: '老板 IP 如何建立信任',
+      angle: '用真实服务案例证明专业,而不是只讲观点',
+      sourceType: 'ip_operator',
+      sourceVideoIds: [],
+      sourceAuthorIds: [],
+      tags: ['IP操盘', '老板IP', '信任建设'],
+      audience: '传统行业老板',
+      hook: '为什么你讲得越多,用户越不信?',
+      outline: '开头:指出信任问题\n观点:案例比口号更重要\n结尾:引导预约诊断',
+      fullOutline: '开头:指出信任问题\n观点:案例比口号更重要\n案例:服务前后对比\n结尾:引导预约诊断',
+      sourceTitle: 'IP操盘方案:林川老板增长笔记',
+      sourceSummary: '帮助传统行业老板做可信任的老板 IP',
+      sourceEvidence: ['来自定位诊断:缺少真实案例支撑', '来自脚本稿:需要客户反馈截图'],
+      status: 'script_ready',
+      pipelineTarget: 'topic_video',
+      confidence: 'medium',
+      createdAt: '2026-06-08T00:00:00.000Z',
+      updatedAt: '2026-06-08T00:00:00.000Z',
+    };
+
+    const entry = service.enterTopic('topic-to-video', topic);
+    const pending = creationBriefs.consumePendingBrief('topic-to-video');
+    const prefill = creationBriefs.toTopicToVideoPrefill(pending!);
+
+    expect(entry.tab).toBe('topic-to-video');
+    expect(entry.brief.source).toBe('ip_operator');
+    expect(pending?.source).toBe('ip_operator');
+    expect(pending?.topicId).toBe(topic.id);
+    expect(pending?.evidence.map((item) => item.type)).toContain('ip_operator');
+    expect(prefill.topic).toBe(topic.title);
+    expect(prefill.outline).toContain('案例比口号更重要');
+    expect(prefill.context).toContain('来自定位诊断:缺少真实案例支撑');
+    expect(prefill.context).toContain('IP操盘');
+  });
+});
+
+function createTopicPoolStub(): any {
+  return {
+    saveTopic: (topic: TopicIdea) => topic,
+    listTopics: () => [],
+    updateTopic: (_id: string, topic: Partial<TopicIdea>) => topic,
+    archiveTopic: () => undefined,
+  };
+}

+ 134 - 0
src/app/services/content-production-entry.service.ts

@@ -0,0 +1,134 @@
+import { Injectable } from '@angular/core';
+import { AssistantSuggestion } from './assistant.service';
+import { CreationBriefService } from './creation-brief.service';
+import { TopicPoolService } from './topic-pool.service';
+import { ViralAnalysisService } from './viral-analysis.service';
+import { CreationBrief } from '../models/creation-brief.model';
+import { TopicIdea, ViralAnalysis } from '../models/douyin-insight.model';
+import { ViralTopicBridgeService } from './viral-topic-bridge.service';
+
+export type ContentEntryTarget = 'topic-to-video' | 'digital-human';
+
+export interface ContentEntryNavigation {
+  tab: ContentEntryTarget;
+  brief: CreationBrief;
+  message: string;
+}
+
+export interface SavedTopicResult {
+  count: number;
+  title: string;
+  ids: string[];
+}
+
+@Injectable({ providedIn: 'root' })
+export class ContentProductionEntryService {
+  constructor(
+    private topicPool: TopicPoolService,
+    private creationBriefs: CreationBriefService,
+    private viralTopicBridge: ViralTopicBridgeService,
+    private viralAnalysis: ViralAnalysisService,
+  ) {}
+
+  saveAssistantSuggestion(suggestion: AssistantSuggestion): TopicIdea {
+    const payload = suggestion.payload || {};
+    const title = String(payload['title'] || payload['topic'] || suggestion.label || '').trim();
+    const angle = String(payload['angle'] || payload['description'] || payload['topic'] || title).trim();
+    const tags = this.normalizeTags(payload['tags']);
+
+    return 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',
+    });
+  }
+
+  enterTopic(target: ContentEntryTarget, topic: TopicIdea): ContentEntryNavigation {
+    const brief = this.creationBriefs.savePendingTopic(target, topic);
+    return {
+      tab: target,
+      brief,
+      message: `已带入选题:${topic.title}`,
+    };
+  }
+
+  saveManualPrefill(target: ContentEntryTarget, payload: Record<string, unknown>): CreationBrief {
+    return this.creationBriefs.savePendingManualPrefill(target, payload);
+  }
+
+  saveDailyReportTopics(topics: TopicIdea[]): SavedTopicResult {
+    const saved = topics.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,
+    }));
+    return this.savedResult(saved);
+  }
+
+  enterDailyReportTopic(topic: TopicIdea): ContentEntryNavigation {
+    return this.enterTopic('topic-to-video', topic);
+  }
+
+  saveViralAnalysisTopics(analysis: ViralAnalysis): { result: SavedTopicResult; analysis: ViralAnalysis } {
+    const drafts = this.viralTopicBridge.buildTopicDrafts(analysis);
+    const existing = this.topicPool.listTopics()
+      .filter((topic) => topic.sourceType === 'viral_analysis' && topic.sourceVideoIds?.[0] === analysis.awemeId);
+    const saved = drafts.map((draft) => {
+      const matched = existing.find((topic) => topic.variantIndex === draft.variantIndex);
+      return matched
+        ? this.topicPool.updateTopic(matched.id, draft)
+        : this.topicPool.saveTopic(draft);
+    });
+    const savedIds = new Set(saved.map((topic) => topic.id));
+    existing
+      .filter((topic) => !savedIds.has(topic.id) && topic.status !== 'archived')
+      .forEach((topic) => this.topicPool.archiveTopic(topic.id));
+    return {
+      result: this.savedResult(saved),
+      analysis: this.viralAnalysis.markTopicsSaved(analysis.id, saved.map((item) => item.id)),
+    };
+  }
+
+  enterViralAnalysisTopicVideo(analysis: ViralAnalysis): ContentEntryNavigation {
+    const brief = this.viralTopicBridge.buildTopicVideoBrief(analysis);
+    this.creationBriefs.savePendingBrief('topic-to-video', brief);
+    return {
+      tab: 'topic-to-video',
+      brief,
+      message: `已整理选题:${brief.angle || brief.title}`,
+    };
+  }
+
+  private normalizeTags(value: unknown): string[] {
+    if (Array.isArray(value)) {
+      return value.map((tag) => String(tag || '').trim()).filter(Boolean);
+    }
+    return String(value || '').split(/[,,\s]+/).map((tag) => tag.trim()).filter(Boolean);
+  }
+
+  private savedResult(topics: TopicIdea[]): SavedTopicResult {
+    return {
+      count: topics.length,
+      title: topics[0]?.title || '',
+      ids: topics.map((topic) => topic.id),
+    };
+  }
+}

+ 64 - 26
src/app/services/cost-estimator.service.ts

@@ -19,21 +19,28 @@ export interface CostEstimate {
 
 
 @Injectable({ providedIn: 'root' })
 @Injectable({ providedIn: 'root' })
 export class CostEstimatorService {
 export class CostEstimatorService {
+  private readonly creditUnitCny = 0.1;
+
   estimateImageGeneration(count = 1): CostEstimate {
   estimateImageGeneration(count = 1): CostEstimate {
-    const quantity = Math.max(1, Math.ceil(count));
+    const quantity = Math.max(1, Math.ceil(Number(count || 1)));
+    const creditsPerItem = this.cnyToCredits(0.22);
     return this.build('jimeng.imageV4', '图片生成', [
     return this.build('jimeng.imageV4', '图片生成', [
-      { key: 'image', label: '图片生成', credits: 3 * quantity, quantity, unit: '张' },
+      { key: 'image', label: '图片生成', credits: creditsPerItem * quantity, quantity, unit: '张' },
     ]);
     ]);
   }
   }
 
 
-  estimateVideoGeneration(quality: EstimateQuality = '1080p', seconds = 5, title = '视频生成'): CostEstimate {
+  estimateVideoGeneration(
+    quality: EstimateQuality = '1080p',
+    seconds = 5,
+    title = '视频生成'
+  ): CostEstimate {
     const normalizedSeconds = Math.max(1, Math.round(Number(seconds || 5)));
     const normalizedSeconds = Math.max(1, Math.round(Number(seconds || 5)));
-    const cnyPerSecond = quality === 'pro' ? 1 : quality === '1080p' ? 0.63 : 0.28;
+    const cnyPerSecond = this.getVideoUnitPriceCny(quality);
     return this.build('jimeng.video', `${title} ${quality} ${normalizedSeconds} 秒`, [
     return this.build('jimeng.video', `${title} ${quality} ${normalizedSeconds} 秒`, [
       {
       {
         key: 'video',
         key: 'video',
         label: `${quality} 视频生成`,
         label: `${quality} 视频生成`,
-        credits: Math.ceil(cnyPerSecond * normalizedSeconds * 10),
+        credits: this.cnyToCredits(cnyPerSecond * normalizedSeconds),
         quantity: normalizedSeconds,
         quantity: normalizedSeconds,
         unit: '秒',
         unit: '秒',
       },
       },
@@ -44,21 +51,34 @@ export class CostEstimatorService {
     return this.estimateVideoGeneration(quality, seconds, '图生视频');
     return this.estimateVideoGeneration(quality, seconds, '图生视频');
   }
   }
 
 
-  estimateActionTransfer(): CostEstimate {
+  estimateActionTransfer(seconds = 5): CostEstimate {
+    const normalizedSeconds = Math.max(1, Math.round(Number(seconds || 5)));
     return this.build('jimeng.actionTransfer', '动作迁移', [
     return this.build('jimeng.actionTransfer', '动作迁移', [
-      { key: 'action-transfer', label: '动作迁移', credits: 20, quantity: 1, unit: '次' },
+      {
+        key: 'action-transfer',
+        label: '动作迁移',
+        credits: this.cnyToCredits(0.4) * normalizedSeconds,
+        quantity: normalizedSeconds,
+        unit: '秒',
+      },
     ]);
     ]);
   }
   }
 
 
   estimateAssetRemix(imageCount: number, audioSegments = 1, includeComposite = true): CostEstimate {
   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 images = Math.max(1, Math.ceil(Number(imageCount || 1)));
+    const audio = Math.max(1, Math.ceil(Number(audioSegments || 1)));
     const lines: CostEstimateLine[] = [
     const lines: CostEstimateLine[] = [
       { key: 'script', label: '脚本与分镜生成', credits: 1, quantity: 1, unit: '次' },
       { key: 'script', label: '脚本与分镜生成', credits: 1, quantity: 1, unit: '次' },
       { key: 'tts', label: '语音合成', credits: audio, quantity: audio, unit: '段' },
       { key: 'tts', label: '语音合成', credits: audio, quantity: audio, unit: '段' },
     ];
     ];
     if (includeComposite) {
     if (includeComposite) {
-      lines.push({ key: 'composite', label: '视频合成与上传', credits: Math.max(1, Math.ceil(images / 3)), quantity: images, unit: '张素材' });
+      lines.push({
+        key: 'composite',
+        label: '视频合成与上传',
+        credits: Math.max(1, Math.ceil(images / 3)),
+        quantity: images,
+        unit: '张素材',
+      });
     }
     }
     return this.build('pipeline.assetRemix', `素材合成视频 ${images} 张素材`, lines);
     return this.build('pipeline.assetRemix', `素材合成视频 ${images} 张素材`, lines);
   }
   }
@@ -76,30 +96,38 @@ export class CostEstimatorService {
     ]);
     ]);
   }
   }
 
 
-  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);
+  estimateDigitalHuman(secondsOrLegacy: number | boolean = 10, _includeTts = false): CostEstimate {
+    const seconds = typeof secondsOrLegacy === 'boolean' ? 10 : secondsOrLegacy;
+    const normalizedSeconds = Math.max(1, Math.round(Number(seconds || 10)));
+    return this.build('pipeline.digitalHuman', '数字人合成', [
+      {
+        key: 'digital-human',
+        label: 'OmniHuman',
+        credits: this.cnyToCredits(1) * normalizedSeconds,
+        quantity: normalizedSeconds,
+        unit: '秒',
+      },
+    ]);
   }
   }
 
 
   estimateDouyinRead(operation: string, title: string, count = 1): CostEstimate {
   estimateDouyinRead(operation: string, title: string, count = 1): CostEstimate {
-    const quantity = Math.max(1, Math.ceil(count));
+    const quantity = Math.max(1, Math.ceil(Number(count || 1)));
     return this.build(operation, title, [
     return this.build(operation, title, [
       { key: operation, label: title, credits: quantity, quantity, unit: '次' },
       { key: operation, label: title, credits: quantity, quantity, unit: '次' },
     ]);
     ]);
   }
   }
 
 
-  estimateTopicToVideo(sceneCount: number, quality: EstimateQuality = '720p', secondsPerScene = 5, includeTts = true): CostEstimate {
-    const scenes = Math.max(1, Math.ceil(sceneCount || 1));
-    const video = this.estimateVideoGeneration(quality, secondsPerScene, '分镜视频').totalCredits * scenes;
+  estimateTopicToVideo(
+    sceneCount: number,
+    quality: EstimateQuality = '720p',
+    secondsPerScene = 5,
+    includeTts = true
+  ): CostEstimate {
+    const scenes = Math.max(1, Math.ceil(Number(sceneCount || 1)));
+    const videoCredits = this.estimateVideoGeneration(quality, secondsPerScene, '分镜视频').totalCredits * scenes;
     const lines: CostEstimateLine[] = [
     const lines: CostEstimateLine[] = [
       { key: 'storyboard', label: '脚本/分镜生成', credits: 1, quantity: 1, unit: '次' },
       { key: 'storyboard', label: '脚本/分镜生成', credits: 1, quantity: 1, unit: '次' },
-      { key: 'visuals', label: '分镜视频生成', credits: video, quantity: scenes, unit: '段' },
+      { key: 'visuals', label: '分镜视频生成', credits: videoCredits, quantity: scenes, unit: '段' },
     ];
     ];
     if (includeTts) {
     if (includeTts) {
       lines.push({ key: 'tts', label: '语音合成', credits: scenes, quantity: scenes, unit: '段' });
       lines.push({ key: 'tts', label: '语音合成', credits: scenes, quantity: scenes, unit: '段' });
@@ -108,8 +136,18 @@ export class CostEstimatorService {
   }
   }
 
 
   framesToSeconds(frames: number): number {
   framesToSeconds(frames: number): number {
-    const normalizedFrames = Math.max(1, Number(frames || 121));
-    return Math.max(1, Math.round((normalizedFrames - 1) / 24));
+    const normalizedFrames = Math.max(1, Math.round(Number(frames || 121)));
+    return normalizedFrames >= 241 ? 10 : 5;
+  }
+
+  private cnyToCredits(cny: number): number {
+    return Math.max(1, Math.ceil(Math.max(0, Number(cny || 0)) / this.creditUnitCny));
+  }
+
+  private getVideoUnitPriceCny(quality: EstimateQuality): number {
+    if (quality === 'pro') return 1;
+    if (quality === '1080p') return 0.63;
+    return 0.28;
   }
   }
 
 
   private build(operation: string, title: string, lines: CostEstimateLine[]): CostEstimate {
   private build(operation: string, title: string, lines: CostEstimateLine[]): CostEstimate {

+ 106 - 0
src/app/services/creation-brief.service.spec.ts

@@ -0,0 +1,106 @@
+import { CreationBrief } from '../models/creation-brief.model';
+import { CreationBriefService } from './creation-brief.service';
+
+describe('CreationBriefService', () => {
+  const service = new CreationBriefService();
+
+  it('creates clean digital-human fields from a topic planning brief', () => {
+    const brief: CreationBrief = {
+      id: 'brief-1',
+      source: 'viral_analysis',
+      topicId: 'topic-1',
+      title: '方向 3/4:从评论问题反推一条选题',
+      angle: '方向 3/4:跨境电商工具应该怎么选',
+      hook: '方向 3/4:别只看工具名,先看它解决什么问题',
+      audience: '跨境电商从业者',
+      outline: [
+        '选题标题:方向 3/4:从评论问题反推一条选题',
+        '核心角度:跨境电商工具应该怎么选',
+        '脚本框架:',
+        '1. 先用一句具体判断切入',
+        '2. 拆 2-3 个理由',
+      ].join('\n'),
+      tags: ['爆款分析', '方向3/4', '场景共鸣'],
+      sourceVideoIds: ['video-1'],
+      sourceAuthorIds: [],
+      evidence: [],
+      createdAt: '2026-06-04T00:00:00.000Z',
+    };
+
+    const prefill = service.toDigitalHumanPrefill(brief);
+
+    expect(prefill.ttsText).toContain('别只看工具名,先看它解决什么问题');
+    expect(prefill.ttsText).toContain('跨境电商工具应该怎么选');
+    expect(prefill.ttsText).not.toContain('选题标题');
+    expect(prefill.ttsText).not.toContain('核心角度');
+    expect(prefill.ttsText).not.toContain('脚本框架');
+    expect(prefill.ttsText).not.toContain('方向 3/4');
+    expect(prefill.prompt).toBe('自然口播,镜头稳定,表情可信,语速适中,重点句轻微点头强调。');
+    expect(prefill.prompt).not.toContain('方向3/4');
+  });
+  it('preserves IP operator topic context for topic-to-video prefill', () => {
+    const topic = {
+      id: 'ip-topic-1',
+      userId: 'user-1',
+      title: '老板 IP 如何建立信任',
+      angle: '用真实案例证明专业,而不是只讲观点',
+      sourceType: 'ip_operator' as const,
+      sourceVideoIds: [],
+      sourceAuthorIds: [],
+      tags: ['IP操盘', '老板IP', '信任建设'],
+      audience: '传统行业老板',
+      hook: '为什么你讲得越多,用户越不信?',
+      fullOutline: '开头:指出信任问题\n观点:案例比口号更重要\n结尾:引导预约诊断',
+      sourceTitle: 'IP操盘方案:林川老板增长笔记',
+      sourceSummary: '帮助传统行业老板做可信任的老板 IP',
+      sourceEvidence: ['来自定位诊断:缺少真实案例支撑', '来自脚本稿:需要客户反馈截图'],
+      status: 'script_ready' as const,
+      pipelineTarget: 'topic_video' as const,
+      confidence: 'medium' as const,
+      createdAt: '2026-06-08T00:00:00.000Z',
+      updatedAt: '2026-06-08T00:00:00.000Z',
+    };
+
+    const brief = service.fromTopicIdea(topic);
+    const prefill = service.toTopicToVideoPrefill(brief);
+
+    expect(brief.source).toBe('ip_operator');
+    expect(brief.evidence.map((item) => item.text)).toContain('来自定位诊断:缺少真实案例支撑');
+    expect(prefill.topic).toBe('老板 IP 如何建立信任');
+    expect(prefill.angle).toBe('用真实案例证明专业,而不是只讲观点');
+    expect(prefill.hook).toBe('为什么你讲得越多,用户越不信?');
+    expect(prefill.outline).toContain('案例比口号更重要');
+    expect(prefill.context).toContain('来自定位诊断:缺少真实案例支撑');
+    expect(prefill.context).toContain('IP操盘');
+  });
+
+  it('uses IP operator full script as digital-human TTS text', () => {
+    const brief: CreationBrief = {
+      id: 'brief-ip-dh',
+      source: 'ip_operator',
+      topicId: 'topic-1',
+      title: '老板 IP 如何避免成为广告账号',
+      angle: '用真实案例证明专业',
+      hook: '为什么你的内容越发,用户越不信?',
+      audience: '传统行业老板',
+      outline: [
+        '完整口播:',
+        '你知道为什么很多老板号像广告吗?因为它只是在讲产品,没有讲判断。真正有效的老板 IP,应该先讲清楚你如何看问题,再用案例证明你能解决问题。',
+        '拍摄建议:正面口播',
+        '可替换素材位:客户反馈截图',
+      ].join('\n'),
+      tags: ['IP操盘', '数字人'],
+      sourceVideoIds: [],
+      sourceAuthorIds: [],
+      evidence: [],
+      createdAt: '2026-06-09T00:00:00.000Z',
+    };
+
+    const prefill = service.toDigitalHumanPrefill(brief);
+
+    expect(prefill.ttsText).toContain('很多老板号像广告');
+    expect(prefill.ttsText).toContain('应该先讲清楚你如何看问题');
+    expect(prefill.ttsText).not.toContain('拍摄建议');
+    expect(prefill.ttsText).not.toContain('可替换素材位');
+  });
+});

+ 91 - 13
src/app/services/creation-brief.service.ts

@@ -7,6 +7,8 @@ import {
   TopicToVideoPrefill,
   TopicToVideoPrefill,
 } from '../models/creation-brief.model';
 } from '../models/creation-brief.model';
 
 
+type TargetPipeline = 'topic-to-video' | 'digital-human';
+
 @Injectable({ providedIn: 'root' })
 @Injectable({ providedIn: 'root' })
 export class CreationBriefService {
 export class CreationBriefService {
   private readonly storagePrefix = 'videoWorkflow.creationBrief.pending.';
   private readonly storagePrefix = 'videoWorkflow.creationBrief.pending.';
@@ -19,9 +21,15 @@ export class CreationBriefService {
       id,
       id,
       confidence: topic.confidence,
       confidence: topic.confidence,
     }));
     }));
+    evidence.push(...(topic.sourceEvidence || []).map((text, index) => ({
+      type: topic.sourceType === 'ip_operator' ? 'ip_operator' as const : 'analysis' as const,
+      id: `${topic.id || 'topic'}_evidence_${index + 1}`,
+      text: this.clean(text),
+      confidence: topic.confidence,
+    })).filter((item) => !!item.text));
 
 
     return {
     return {
-      id: `brief_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
+      id: this.createId(),
       source: this.sourceFromTopic(topic),
       source: this.sourceFromTopic(topic),
       topicId: topic.id,
       topicId: topic.id,
       title,
       title,
@@ -38,6 +46,39 @@ export class CreationBriefService {
     };
     };
   }
   }
 
 
+  fromManualPayload(payload: Record<string, unknown> | null | undefined): CreationBrief {
+    const data = payload || {};
+    const title = this.clean(data['title'] || data['topic']) || '未命名选题';
+    const angle = this.clean(data['angle'] || data['description'] || data['topic']) || title;
+    return {
+      id: this.createId(),
+      source: 'manual',
+      title,
+      angle,
+      audience: this.clean(data['audience']),
+      hook: this.clean(data['hook'] || title),
+      outline: this.clean(data['outline'] || data['scriptFrame']),
+      tags: this.normalizeTags(data['tags']),
+      sourceVideoIds: [],
+      sourceAuthorIds: [],
+      confidence: 'medium',
+      evidence: [],
+      createdAt: new Date().toISOString(),
+    };
+  }
+
+  savePendingTopic(targetPipeline: TargetPipeline, topic: TopicIdea): CreationBrief {
+    const brief = this.fromTopicIdea(topic);
+    this.savePendingBrief(targetPipeline, brief);
+    return brief;
+  }
+
+  savePendingManualPrefill(targetPipeline: TargetPipeline, payload: Record<string, unknown> | null | undefined): CreationBrief {
+    const brief = this.fromManualPayload(payload);
+    this.savePendingBrief(targetPipeline, brief);
+    return brief;
+  }
+
   toTopicToVideoPrefill(brief: CreationBrief): TopicToVideoPrefill {
   toTopicToVideoPrefill(brief: CreationBrief): TopicToVideoPrefill {
     return {
     return {
       topic: this.clean(brief.title) || this.clean(brief.angle),
       topic: this.clean(brief.title) || this.clean(brief.angle),
@@ -61,13 +102,13 @@ export class CreationBriefService {
     };
     };
   }
   }
 
 
-  savePendingBrief(targetPipeline: 'topic-to-video' | 'digital-human', brief: CreationBrief): void {
+  savePendingBrief(targetPipeline: TargetPipeline, brief: CreationBrief): void {
     try {
     try {
       sessionStorage.setItem(`${this.storagePrefix}${targetPipeline}`, JSON.stringify(brief));
       sessionStorage.setItem(`${this.storagePrefix}${targetPipeline}`, JSON.stringify(brief));
     } catch {}
     } catch {}
   }
   }
 
 
-  consumePendingBrief(targetPipeline: 'topic-to-video' | 'digital-human'): CreationBrief | null {
+  consumePendingBrief(targetPipeline: TargetPipeline): CreationBrief | null {
     const key = `${this.storagePrefix}${targetPipeline}`;
     const key = `${this.storagePrefix}${targetPipeline}`;
     try {
     try {
       const raw = sessionStorage.getItem(key);
       const raw = sessionStorage.getItem(key);
@@ -80,39 +121,59 @@ export class CreationBriefService {
   }
   }
 
 
   private topicContext(brief: CreationBrief): string {
   private topicContext(brief: CreationBrief): string {
+    const evidenceText = (brief.evidence || [])
+      .map((item) => item.text || item.id || '')
+      .filter(Boolean)
+      .slice(0, 4)
+      .join('\n');
     return [
     return [
       `选题:${brief.title}`,
       `选题:${brief.title}`,
       brief.angle && brief.angle !== brief.title ? `角度:${brief.angle}` : '',
       brief.angle && brief.angle !== brief.title ? `角度:${brief.angle}` : '',
       brief.audience ? `目标人群:${brief.audience}` : '',
       brief.audience ? `目标人群:${brief.audience}` : '',
       brief.hook ? `开头钩子:${brief.hook}` : '',
       brief.hook ? `开头钩子:${brief.hook}` : '',
       brief.outline ? `脚本框架:\n${brief.outline}` : '',
       brief.outline ? `脚本框架:\n${brief.outline}` : '',
+      evidenceText ? `证据摘要:\n${evidenceText}` : '',
       brief.tags.length ? `来源标签:${brief.tags.join('、')}` : '',
       brief.tags.length ? `来源标签:${brief.tags.join('、')}` : '',
     ].filter(Boolean).join('\n');
     ].filter(Boolean).join('\n');
   }
   }
 
 
   private digitalHumanScript(brief: CreationBrief): string {
   private digitalHumanScript(brief: CreationBrief): string {
-    const hook = brief.hook || brief.angle || brief.title;
-    const outline = brief.outline ? this.normalizeOutline(brief.outline) : '';
-    if (outline.length >= 80) return outline.slice(0, 600);
-
-    const audience = brief.audience ? `如果你也是${brief.audience},` : '如果你也在关注这个问题,';
+    const ipFullScript = brief.source === 'ip_operator' ? this.extractIpFullScript(brief.outline || '') : '';
+    if (ipFullScript) return ipFullScript.slice(0, 600);
+    const title = this.cleanDigitalHumanTopic(brief.title);
+    const angle = this.cleanDigitalHumanTopic(brief.angle) || title;
+    const hook = this.cleanDigitalHumanTopic(brief.hook) || angle || title;
+    const audience = brief.audience
+      ? `如果你也是${this.cleanDigitalHumanTopic(brief.audience)},`
+      : '如果你也在关注这个问题,';
     return [
     return [
       `${audience}先记住一句话:${hook}`,
       `${audience}先记住一句话:${hook}`,
-      `这个选题真正值得讲的不是表面的「${brief.title}」,而是背后的「${brief.angle}」。`,
+      title && angle && title !== angle
+        ? `这条内容真正值得讲的不是表面的“${title}”,而是背后的“${angle}”。`
+        : `这条内容真正值得讲清楚的是“${angle || title}”。`,
       '我建议你先从三个点判断:第一,看它解决的是不是一个真实高频问题;第二,看评论区有没有持续追问;第三,看这个表达能不能落到自己的场景里。',
       '我建议你先从三个点判断:第一,看它解决的是不是一个真实高频问题;第二,看评论区有没有持续追问;第三,看这个表达能不能落到自己的场景里。',
       '如果你也想把这个方向做成一条内容,可以先从一个具体案例切入,再给出判断标准,最后把问题抛回评论区。',
       '如果你也想把这个方向做成一条内容,可以先从一个具体案例切入,再给出判断标准,最后把问题抛回评论区。',
-    ].join('\n');
+    ].filter(Boolean).join('\n');
+  }
+
+  private extractIpFullScript(outline: string): string {
+    const marker = '完整口播:';
+    const index = outline.indexOf(marker);
+    if (index < 0) return '';
+    const afterMarker = outline.slice(index + marker.length).trim();
+    const nextSection = afterMarker.search(/\n(?:拍摄建议|字幕重点|可替换素材位):/);
+    return this.clean(nextSection >= 0 ? afterMarker.slice(0, nextSection) : afterMarker);
   }
   }
 
 
-  private digitalHumanPrompt(brief: CreationBrief): string {
-    const tags = brief.tags.length ? `,贴合${brief.tags.slice(0, 3).join('、')}内容` : '';
-    return `自然口播,镜头稳定,表情可信,语速适中,重点句轻微点头强调${tags}。`;
+  private digitalHumanPrompt(_brief: CreationBrief): string {
+    return '自然口播,镜头稳定,表情可信,语速适中,重点句轻微点头强调。';
   }
   }
 
 
   private sourceFromTopic(topic: TopicIdea): CreationBrief['source'] {
   private sourceFromTopic(topic: TopicIdea): CreationBrief['source'] {
     if (topic.sourceType === 'viral_analysis') return 'viral_analysis';
     if (topic.sourceType === 'viral_analysis') return 'viral_analysis';
     if (topic.sourceType === 'daily_report') return 'daily_report';
     if (topic.sourceType === 'daily_report') return 'daily_report';
     if (topic.sourceType === 'assistant') return 'assistant';
     if (topic.sourceType === 'assistant') return 'assistant';
+    if (topic.sourceType === 'ip_operator') return 'ip_operator';
     return 'manual';
     return 'manual';
   }
   }
 
 
@@ -124,7 +185,24 @@ export class CreationBriefService {
       .join('\n');
       .join('\n');
   }
   }
 
 
+  private normalizeTags(value: unknown): string[] {
+    const raw = Array.isArray(value)
+      ? value
+      : String(value || '').split(/[,,\s]+/);
+    return Array.from(new Set(raw.map((tag) => this.clean(tag)).filter(Boolean))).slice(0, 12);
+  }
+
   private clean(value: unknown): string {
   private clean(value: unknown): string {
     return String(value || '').replace(/\s+/g, ' ').trim();
     return String(value || '').replace(/\s+/g, ' ').trim();
   }
   }
+
+  private cleanDigitalHumanTopic(value: unknown): string {
+    return this.clean(value)
+      .replace(/^方向\s*\d+\s*\/\s*\d+\s*[::]\s*/u, '')
+      .trim();
+  }
+
+  private createId(): string {
+    return `brief_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+  }
 }
 }

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

@@ -3,6 +3,8 @@ import { CreatorDashboardAction, CreatorDashboardSummary } from '../models/creat
 import { BatchJobService } from './batch-job.service';
 import { BatchJobService } from './batch-job.service';
 import { ContentAssetService } from './content-asset.service';
 import { ContentAssetService } from './content-asset.service';
 import { GenerationTaskService } from './generation-task.service';
 import { GenerationTaskService } from './generation-task.service';
+import { IndexedDbJsonStorage } from './indexed-db-json-storage';
+import { TaskRecoveryService } from './task-recovery.service';
 import { TopicPoolService } from './topic-pool.service';
 import { TopicPoolService } from './topic-pool.service';
 
 
 @Injectable({ providedIn: 'root' })
 @Injectable({ providedIn: 'root' })
@@ -12,6 +14,7 @@ export class CreatorDashboardService {
     private topicPool: TopicPoolService,
     private topicPool: TopicPoolService,
     private contentAssets: ContentAssetService,
     private contentAssets: ContentAssetService,
     private batchJobs: BatchJobService,
     private batchJobs: BatchJobService,
+    private taskRecovery: TaskRecoveryService,
   ) {}
   ) {}
 
 
   getSummary(): CreatorDashboardSummary {
   getSummary(): CreatorDashboardSummary {
@@ -26,6 +29,21 @@ export class CreatorDashboardService {
     const pendingTopics = topics.filter((topic) => topic.status === 'idea' || topic.status === 'script_ready').length;
     const pendingTopics = topics.filter((topic) => topic.status === 'idea' || topic.status === 'script_ready').length;
     const readyAssets = assets.filter((asset) => asset.type === 'video' && asset.source?.isFinal && !asset.archived).length;
     const readyAssets = assets.filter((asset) => asset.type === 'video' && asset.source?.isFinal && !asset.archived).length;
     const activeBatchJobs = jobs.filter((job) => job.status === 'queued' || job.status === 'running' || job.status === 'paused').length;
     const activeBatchJobs = jobs.filter((job) => job.status === 'queued' || job.status === 'running' || job.status === 'paused').length;
+    const storageWarnings = IndexedDbJsonStorage.getAuditTrail()
+      .filter((event) => event.status === 'fallback' || event.status === 'failed')
+      .length;
+    const lastRecoveryRunRecord = this.taskRecovery.getLastRun();
+    const lastRecoveryRun = lastRecoveryRunRecord
+      ? {
+          runId: lastRecoveryRunRecord.runId,
+          finishedAt: lastRecoveryRunRecord.finishedAt,
+          scanned: lastRecoveryRunRecord.summary.scanned,
+          recoverable: lastRecoveryRunRecord.summary.recoverable,
+          completed: lastRecoveryRunRecord.summary.completed,
+          failed: lastRecoveryRunRecord.summary.failed,
+          unrecoverable: lastRecoveryRunRecord.summary.unrecoverable,
+        }
+      : undefined;
 
 
     return {
     return {
       runningTasks,
       runningTasks,
@@ -34,6 +52,8 @@ export class CreatorDashboardService {
       pendingTopics,
       pendingTopics,
       readyAssets,
       readyAssets,
       activeBatchJobs,
       activeBatchJobs,
+      lastRecoveryRun,
+      storageWarnings,
       suggestedActions: this.buildActions({
       suggestedActions: this.buildActions({
         runningTasks,
         runningTasks,
         recoverableTasks,
         recoverableTasks,
@@ -41,6 +61,8 @@ export class CreatorDashboardService {
         pendingTopics,
         pendingTopics,
         readyAssets,
         readyAssets,
         activeBatchJobs,
         activeBatchJobs,
+        lastRecoveryRun,
+        storageWarnings,
       }),
       }),
     };
     };
   }
   }
@@ -58,6 +80,32 @@ export class CreatorDashboardService {
       });
       });
     }
     }
 
 
+    if (input.storageWarnings > 0) {
+      actions.push({
+        id: 'storage-warnings',
+        priority: 'high',
+        title: `检查 ${input.storageWarnings} 条存储回退记录`,
+        description: 'IndexedDB 读写出现过回退或失败,建议先确认任务、批量和素材数据是否完整。',
+        target: 'tasks',
+        payload: { storageAudit: true },
+      });
+    }
+
+    if (input.lastRecoveryRun && (
+      input.lastRecoveryRun.completed > 0
+      || input.lastRecoveryRun.failed > 0
+      || input.lastRecoveryRun.unrecoverable > 0
+    )) {
+      actions.push({
+        id: 'last-recovery-run',
+        priority: input.lastRecoveryRun.failed > 0 || input.lastRecoveryRun.unrecoverable > 0 ? 'high' : 'medium',
+        title: `查看最近恢复扫描`,
+        description: `已扫描 ${input.lastRecoveryRun.scanned} 个任务,完成 ${input.lastRecoveryRun.completed} 个,失败 ${input.lastRecoveryRun.failed} 个,不可恢复 ${input.lastRecoveryRun.unrecoverable} 个。`,
+        target: 'tasks',
+        payload: { recoveryRunId: input.lastRecoveryRun.runId },
+      });
+    }
+
     if (input.recoverableTasks > 0) {
     if (input.recoverableTasks > 0) {
       actions.push({
       actions.push({
         id: 'recoverable-tasks',
         id: 'recoverable-tasks',

Some files were not shown because too many files changed in this diff