Yi Jiarui 3 ヶ月 前
コミット
c3894106ad
63 ファイル変更4930 行追加660 行削除
  1. 5 0
      .gitignore
  2. 22 11
      README.md
  3. 11 5
      cloud-functions/01-manifestManager.js
  4. 32 0
      cloud-functions/02-taskManager.js
  5. 3 1
      cloud-functions/03-historyManager.js
  6. 11 5
      cloud-functions/04-resultManager.js
  7. 10 9
      cloud-functions/05-remixManager.js
  8. 66 0
      cloud-functions/06-voiceManager.js
  9. 580 0
      cloud-functions/10-authCreditManager.js
  10. 116 0
      cloud-functions/11-jimengManager.js
  11. 107 0
      cloud-functions/12-douyinManager.js
  12. 52 0
      cloud-functions/auth-credit-deploy.md
  13. 46 0
      src/app/app.css
  14. 89 135
      src/app/app.html
  15. 249 7
      src/app/app.ts
  16. 0 9
      src/app/components/app-sidebar/app-sidebar.component.html
  17. 30 12
      src/app/components/app-sidebar/app-sidebar.component.ts
  18. 93 0
      src/app/components/auth-required-modal/auth-required-modal.component.css
  19. 12 0
      src/app/components/auth-required-modal/auth-required-modal.component.html
  20. 17 0
      src/app/components/auth-required-modal/auth-required-modal.component.ts
  21. 2 24
      src/app/components/debug-panel/debug-panel.component.ts
  22. 6 10
      src/app/components/voice-picker/voice-picker.component.ts
  23. 75 0
      src/app/models/generation-task.model.ts
  24. 192 0
      src/app/pages/account/admin-users.component.css
  25. 65 0
      src/app/pages/account/admin-users.component.html
  26. 83 0
      src/app/pages/account/admin-users.component.ts
  27. 216 0
      src/app/pages/account/auth-page.component.css
  28. 53 0
      src/app/pages/account/auth-page.component.html
  29. 128 0
      src/app/pages/account/auth-page.component.ts
  30. 203 0
      src/app/pages/account/user-center.component.css
  31. 68 0
      src/app/pages/account/user-center.component.html
  32. 78 0
      src/app/pages/account/user-center.component.ts
  33. 1 3
      src/app/pages/drafts/drafts.component.css
  34. 0 2
      src/app/pages/library/library.component.css
  35. 1 1
      src/app/pages/pipelines/action-transfer/action-transfer.component.html
  36. 4 3
      src/app/pages/pipelines/action-transfer/action-transfer.component.ts
  37. 7 7
      src/app/pages/pipelines/asset-remix/asset-remix.component.html
  38. 50 34
      src/app/pages/pipelines/asset-remix/asset-remix.component.ts
  39. 3 2
      src/app/pages/pipelines/image-generation/image-generation.component.ts
  40. 3 2
      src/app/pages/pipelines/image-to-video/image-to-video.component.ts
  41. 6 6
      src/app/pages/pipelines/topic-to-video/topic-to-video.component.html
  42. 31 22
      src/app/pages/pipelines/topic-to-video/topic-to-video.component.ts
  43. 9 9
      src/app/pipelines/pipeline-registry.ts
  44. 565 0
      src/app/services/auth-credit.service.ts
  45. 38 18
      src/app/services/browser-ffmpeg.service.ts
  46. 27 20
      src/app/services/cloud-api.interceptor.ts
  47. 6 0
      src/app/services/cloud-functions.ts
  48. 81 0
      src/app/services/cost-estimator.service.ts
  49. 3 8
      src/app/services/digital-human.service.ts
  50. 20 49
      src/app/services/douyin-api.service.ts
  51. 52 48
      src/app/services/douyin.service.ts
  52. 25 11
      src/app/services/draft-storage.ts
  53. 275 0
      src/app/services/generation-task.service.ts
  54. 24 63
      src/app/services/jimeng-api.service.ts
  55. 21 8
      src/app/services/jimeng-workflow.service.ts
  56. 165 62
      src/app/services/jimeng.service.ts
  57. 9 0
      src/app/services/pipeline-session.service.ts
  58. 80 40
      src/app/services/qiniu-upload.service.ts
  59. 93 0
      src/app/services/user-message.util.ts
  60. 67 0
      src/app/services/voice-api.service.ts
  61. 1 7
      src/environments/environment.prod.ts
  62. 1 7
      src/environments/environment.ts
  63. 542 0
      高频用户视角改进路线图.md

+ 5 - 0
.gitignore

@@ -63,3 +63,8 @@ deploy.ps1
 # 日志
 *.log
 
+# 本地子项目(不入库)
+/openclaw-voc-skill/
+/apig-pay/
+/video-workflow-skills/
+/douyin-speaking-daily/

+ 22 - 11
README.md

@@ -4,6 +4,8 @@
 
 > 项目名:`tik-tok`(package.json)/ 仓库名:`video-workflow`
 
+当前试运营版本已接入账号系统与积分框架:用户登录后可查看个人创作、素材库、视频管理、博主监测和积分流水;生成、合成、训练等任务在提交前进行登录与积分校验。
+
 ---
 
 ## ✨ 功能总览
@@ -24,6 +26,15 @@
 - 抖音视频搜索 / 监控 / 任务管理 / 历史 / 结果库
 - 语音合成(音色复刻 + 官方音色库选择弹窗 + 试听)
 - 数字人形象准备(normalize / benchmark)
+- 账号系统(Parse `_User` 登录、用户中心、管理员账号页、积分流水、充值框架)
+
+**账号与隔离**
+
+- 试运营阶段关闭公开注册,账号由管理员统一发放。
+- 管理员身份读取 `_User.role === 'admin'` 或 `_User.isAdmin === true`。
+- 积分规则:`1 元 = 10 积分`;新账号赠送 10 积分;任务提交前预扣,明确失败时退回。
+- 个人内容页包括「我的创作」「素材库」「视频管理」「博主监测」,未登录时跳转登录页。
+- 草稿/素材库和博主监测使用当前用户 `objectId` 做本地存储隔离,避免同一浏览器多账号串数据。
 
 ---
 
@@ -103,9 +114,11 @@ video-workflow/
 │       │   ├── portrait-prepare/
 │       │   ├── recent-task-pill/
 │       │   ├── app-assistant/
-│       │   └── voice-picker/    # 音色选择弹窗(音色库 + 我的音色 + 试听)
+│       │   ├── auth-required-modal/ # 登录保护提示弹窗
+│       │   └── voice-picker/    # 声音模型选择弹窗(声音库 + 我的声音 + 试听)
 │       ├── pages/
 │       │   ├── home/
+│       │   ├── account/         # 登录、用户中心、管理员账号页
 │       │   └── pipelines/       # 各 Pipeline 独立页面
 │       │       ├── digital-human/
 │       │       ├── image-to-video/
@@ -115,7 +128,7 @@ video-workflow/
 │       ├── pipelines/
 │       │   ├── pipeline-registry.ts   # Pipeline 注册中心(添加新模式只需改这里)
 │       │   └── composite-sse.ts
-│       ├── services/            # HTTP / 业务服务
+│       ├── services/            # HTTP / 业务服务(含账号积分、云函数拦截、草稿存储)
 │       └── pipes/               # 时间、文件大小等格式化管道
 └── public/                      # 静态资源
 ```
@@ -136,14 +149,14 @@ video-workflow/
 
 ## 🎙️ 语音合成模块说明
 
-- **音色复刻**:上传一段参考音频 → 调火山 voice_clone → 获得 `timbreId`
-- **音色选择弹窗**(`components/voice-picker`):
-  - **音库 Tab**:豆包 2.0 模型 25 个精选官方音,按场景分组(通用/角色扮演/有声阅读/视频配音/客服/多语种),支持关键词搜索
-  - **我的音色 Tab**:用户复刻成功的音色
+- **声音训练**:上传一段参考音频 → 调火山 voice_clone → 获得可复用的声音模型
+- **声音模型选择弹窗**(`components/voice-picker`):
+  - **音库 Tab**:豆包 2.0 模型 25 个精选官方音,按场景分组(通用/角色扮演/有声阅读/视频配音/客服/多语种),支持关键词搜索
+  - **我的声音 Tab**:用户训练成功的声音模型
   - **试听**:点 ▶ 实时调 TTS 合成固定文本,内存缓存避免重复消耗
-- **合成接口**:根据音来源自动切换参数
-  - 复刻音色:`timbreId`
-  - 官方音:`volcengine_voice_type` + `x_api_resource_id: seed-tts-2.0`(`_uranus_bigtts` 后缀必须用 2.0 资源)
+- **合成接口**:根据音来源自动切换参数
+  - 训练声音:`timbreId`
+  - 官方音:`volcengine_voice_type` + `x_api_resource_id: seed-tts-2.0`(`_uranus_bigtts` 后缀必须用 2.0 资源)
 
 参考文档:`docs/语音合成/tts.md`、`docs/语音合成/音色列表.md`(本地参考,已加入 `.gitignore` 不入库)
 
@@ -160,5 +173,3 @@ video-workflow/
 | `npm run build` | 生产构建 → `dist/` |
 | `npm run watch` | 开发模式 watch 构建 |
 | `npm test` | 运行测试(vitest) |
-
-

+ 11 - 5
cloud-functions/01-manifestManager.js

@@ -42,7 +42,9 @@ async function handler(request, response) {
     if (action === 'get') {
       const bizId = pickParam(request, 'videoId', 'id', 'bizId');
       if (!bizId) return response.json({ code: 400, success: false, error: '缺少 videoId' });
-      const rows = await Psql.query(`SELECT * FROM "Manifest" WHERE "bizId" = $1 LIMIT 1`, [bizId]);
+      const rows = userId
+        ? await Psql.query(`SELECT * FROM "Manifest" WHERE "bizId" = $1 AND "userId" = $2 LIMIT 1`, [bizId, userId])
+        : await Psql.query(`SELECT * FROM "Manifest" WHERE "bizId" = $1 LIMIT 1`, [bizId]);
       if (!rows.length) return response.json({ code: 404, success: false, error: '未找到视频' });
       response.json({ code: 200, success: true, data: rowToVideo(rows[0]) });
       return;
@@ -71,12 +73,14 @@ async function handler(request, response) {
       const bizId = pickParam(request, 'videoId', 'id', 'bizId');
       if (!bizId) return response.json({ code: 400, success: false, error: '缺少 videoId' });
       const patch = pickParam(request, 'video', 'data', 'patch') || request.body || {};
-      const cur = await Psql.query(`SELECT * FROM "Manifest" WHERE "bizId" = $1 LIMIT 1`, [bizId]);
+      const cur = userId
+        ? await Psql.query(`SELECT * FROM "Manifest" WHERE "bizId" = $1 AND "userId" = $2 LIMIT 1`, [bizId, userId])
+        : await Psql.query(`SELECT * FROM "Manifest" WHERE "bizId" = $1 LIMIT 1`, [bizId]);
       if (!cur.length) return response.json({ code: 404, success: false, error: '未找到视频' });
       const merged = { ...rowToVideo(cur[0]), ...patch };
       await Psql.query(
-        `UPDATE "Manifest" SET "data"=$1, "category"=$2, "source"=$3, "updatedAt"=NOW() WHERE "bizId"=$4`,
-        [JSON.stringify(merged), merged.category || '', merged.source || '', bizId]
+        userId ? `UPDATE "Manifest" SET "data"=$1, "category"=$2, "source"=$3, "updatedAt"=NOW() WHERE "bizId"=$4 AND "userId"=$5` : `UPDATE "Manifest" SET "data"=$1, "category"=$2, "source"=$3, "updatedAt"=NOW() WHERE "bizId"=$4`,
+        userId ? [JSON.stringify(merged), merged.category || '', merged.source || '', bizId, userId] : [JSON.stringify(merged), merged.category || '', merged.source || '', bizId]
       );
       response.json({ code: 200, success: true, data: merged });
       return;
@@ -85,7 +89,9 @@ async function handler(request, response) {
     if (action === 'delete') {
       const bizId = pickParam(request, 'videoId', 'id', 'bizId');
       if (!bizId) return response.json({ code: 400, success: false, error: '缺少 videoId' });
-      const result = await Psql.query(`DELETE FROM "Manifest" WHERE "bizId" = $1 RETURNING "bizId"`, [bizId]);
+      const result = userId
+        ? await Psql.query(`DELETE FROM "Manifest" WHERE "bizId" = $1 AND "userId" = $2 RETURNING "bizId"`, [bizId, userId])
+        : await Psql.query(`DELETE FROM "Manifest" WHERE "bizId" = $1 RETURNING "bizId"`, [bizId]);
       response.json({ code: 200, success: true, data: { videoId: bizId, deleted: result.length > 0 } });
       return;
     }

+ 32 - 0
cloud-functions/02-taskManager.js

@@ -66,6 +66,38 @@ async function handler(request, response) {
       return;
     }
 
+    if (action === 'update') {
+      const id = pickParam(request, 'id', 'taskId', 'bizId');
+      if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
+      const patch = pickParam(request, 'task', 'data', 'patch') || request.body || {};
+      const cur = await Psql.query(`SELECT * FROM "VideoflowTask" WHERE "bizId" = $1 LIMIT 1`, [id]);
+      const now = new Date().toISOString();
+      const previous = cur.length ? rowToObj(cur[0]) : {};
+      const merged = {
+        ...previous,
+        ...patch,
+        id,
+        bizId: patch.bizId || patch.id || id,
+        created_at: previous.created_at || patch.created_at || now,
+        updated_at: now,
+      };
+      if (cur.length) {
+        await Psql.query(
+          `UPDATE "VideoflowTask" SET "data"=$1, "type"=$2, "status"=$3, "updatedAt"=NOW() WHERE "bizId"=$4`,
+          [JSON.stringify(merged), merged.type || '', merged.status || '', id]
+        );
+      } else {
+        await Psql.query(
+          `INSERT INTO "VideoflowTask" ("objectId","bizId","data","userId","type","status")
+           VALUES ($1,$2,$3,$4,$5,$6)
+           ON CONFLICT ("bizId") DO UPDATE SET "data"=$3, "type"=$5, "status"=$6, "updatedAt"=NOW()`,
+          [generateId(), id, JSON.stringify(merged), userId || merged.userId || '', merged.type || '', merged.status || '']
+        );
+      }
+      response.json({ code: 200, success: true, data: merged });
+      return;
+    }
+
     if (action === 'update') {
       const id = pickParam(request, 'id', 'taskId', 'bizId');
       if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });

+ 3 - 1
cloud-functions/03-historyManager.js

@@ -51,7 +51,9 @@ async function handler(request, response) {
     if (action === 'delete') {
       const id = pickParam(request, 'id', 'bizId');
       if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
-      const r = await Psql.query(`DELETE FROM "History" WHERE "bizId" = $1 RETURNING "bizId"`, [id]);
+      const r = userId
+        ? await Psql.query(`DELETE FROM "History" WHERE "bizId" = $1 AND "userId" = $2 RETURNING "bizId"`, [id, userId])
+        : await Psql.query(`DELETE FROM "History" WHERE "bizId" = $1 RETURNING "bizId"`, [id]);
       response.json({ code: 200, success: true, data: { id, deleted: r.length > 0 } });
       return;
     }

+ 11 - 5
cloud-functions/04-resultManager.js

@@ -45,7 +45,9 @@ async function handler(request, response) {
     if (action === 'get') {
       const id = pickParam(request, 'id', 'bizId');
       if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
-      const rows = await Psql.query(`SELECT * FROM "VideoflowResult" WHERE "bizId" = $1 LIMIT 1`, [id]);
+      const rows = userId
+        ? await Psql.query(`SELECT * FROM "VideoflowResult" WHERE "bizId" = $1 AND "userId" = $2 LIMIT 1`, [id, userId])
+        : await Psql.query(`SELECT * FROM "VideoflowResult" WHERE "bizId" = $1 LIMIT 1`, [id]);
       if (!rows.length) return response.json({ code: 404, success: false, error: '未找到结果' });
       response.json({ code: 200, success: true, data: rowToObj(rows[0]) });
       return;
@@ -70,12 +72,14 @@ async function handler(request, response) {
       const id = pickParam(request, 'id', 'bizId');
       if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
       const patch = pickParam(request, 'result', 'data', 'patch') || request.body || {};
-      const cur = await Psql.query(`SELECT * FROM "VideoflowResult" WHERE "bizId" = $1 LIMIT 1`, [id]);
+      const cur = userId
+        ? await Psql.query(`SELECT * FROM "VideoflowResult" WHERE "bizId" = $1 AND "userId" = $2 LIMIT 1`, [id, userId])
+        : await Psql.query(`SELECT * FROM "VideoflowResult" WHERE "bizId" = $1 LIMIT 1`, [id]);
       if (!cur.length) return response.json({ code: 404, success: false, error: '未找到结果' });
       const merged = { ...rowToObj(cur[0]), ...patch, updated_at: new Date().toISOString() };
       await Psql.query(
-        `UPDATE "VideoflowResult" SET "data"=$1, "type"=$2, "status"=$3, "updatedAt"=NOW() WHERE "bizId"=$4`,
-        [JSON.stringify(merged), merged.type || '', merged.status || '', id]
+        userId ? `UPDATE "VideoflowResult" SET "data"=$1, "type"=$2, "status"=$3, "updatedAt"=NOW() WHERE "bizId"=$4 AND "userId"=$5` : `UPDATE "VideoflowResult" SET "data"=$1, "type"=$2, "status"=$3, "updatedAt"=NOW() WHERE "bizId"=$4`,
+        userId ? [JSON.stringify(merged), merged.type || '', merged.status || '', id, userId] : [JSON.stringify(merged), merged.type || '', merged.status || '', id]
       );
       response.json({ code: 200, success: true, data: merged });
       return;
@@ -84,7 +88,9 @@ async function handler(request, response) {
     if (action === 'delete') {
       const id = pickParam(request, 'id', 'bizId');
       if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
-      const r = await Psql.query(`DELETE FROM "VideoflowResult" WHERE "bizId" = $1 RETURNING "bizId"`, [id]);
+      const r = userId
+        ? await Psql.query(`DELETE FROM "VideoflowResult" WHERE "bizId" = $1 AND "userId" = $2 RETURNING "bizId"`, [id, userId])
+        : await Psql.query(`DELETE FROM "VideoflowResult" WHERE "bizId" = $1 RETURNING "bizId"`, [id]);
       response.json({ code: 200, success: true, data: { id, deleted: r.length > 0 } });
       return;
     }

+ 10 - 9
cloud-functions/05-remixManager.js

@@ -47,10 +47,9 @@ async function handler(request, response) {
     if (action === 'listByVideo') {
       const videoId = pickParam(request, 'videoId');
       if (!videoId) return response.json({ code: 400, success: false, error: '缺少 videoId' });
-      const rows = await Psql.query(
-        `SELECT * FROM "Remix" WHERE "videoId" = $1 ORDER BY "createdAt" DESC`,
-        [videoId]
-      );
+      const rows = userId
+        ? await Psql.query(`SELECT * FROM "Remix" WHERE "videoId" = $1 AND "userId" = $2 ORDER BY "createdAt" DESC`, [videoId, userId])
+        : await Psql.query(`SELECT * FROM "Remix" WHERE "videoId" = $1 ORDER BY "createdAt" DESC`, [videoId]);
       response.json({ code: 200, success: true, data: rows.map(rowToObj) });
       return;
     }
@@ -64,11 +63,13 @@ async function handler(request, response) {
       }
       const now = new Date().toISOString();
       const merged = { ...body, remixId, videoId, updated_at: now };
-      const exists = await Psql.query(`SELECT "objectId" FROM "Remix" WHERE "videoId"=$1 AND "remixId"=$2`, [videoId, remixId]);
+      const exists = userId
+        ? await Psql.query(`SELECT "objectId" FROM "Remix" WHERE "videoId"=$1 AND "remixId"=$2 AND "userId"=$3`, [videoId, remixId, userId])
+        : await Psql.query(`SELECT "objectId" FROM "Remix" WHERE "videoId"=$1 AND "remixId"=$2`, [videoId, remixId]);
       if (exists.length) {
         await Psql.query(
-          `UPDATE "Remix" SET "data"=$1, "updatedAt"=NOW() WHERE "videoId"=$2 AND "remixId"=$3`,
-          [JSON.stringify(merged), videoId, remixId]
+          userId ? `UPDATE "Remix" SET "data"=$1, "updatedAt"=NOW() WHERE "videoId"=$2 AND "remixId"=$3 AND "userId"=$4` : `UPDATE "Remix" SET "data"=$1, "updatedAt"=NOW() WHERE "videoId"=$2 AND "remixId"=$3`,
+          userId ? [JSON.stringify(merged), videoId, remixId, userId] : [JSON.stringify(merged), videoId, remixId]
         );
       } else {
         merged.created_at = now;
@@ -89,8 +90,8 @@ async function handler(request, response) {
         return response.json({ code: 400, success: false, error: '缺少 videoId 或 remixId' });
       }
       const r = await Psql.query(
-        `DELETE FROM "Remix" WHERE "videoId"=$1 AND "remixId"=$2 RETURNING "objectId"`,
-        [videoId, remixId]
+        userId ? `DELETE FROM "Remix" WHERE "videoId"=$1 AND "remixId"=$2 AND "userId"=$3 RETURNING "objectId"` : `DELETE FROM "Remix" WHERE "videoId"=$1 AND "remixId"=$2 RETURNING "objectId"`,
+        userId ? [videoId, remixId, userId] : [videoId, remixId]
       );
       response.json({ code: 200, success: true, data: { videoId, remixId, deleted: r.length > 0 } });
       return;

+ 66 - 0
cloud-functions/06-voiceManager.js

@@ -11,6 +11,9 @@
  * 迁移到云函数后改为存储在 "VoiceSpeakerPool" 表。
  * 首次部署后请用 action=initPool 把现有池一次性灌入。
  */
+const VOICE_TOKEN = 'Bearer r:f0333969e312a40e4703e8fe4ed1c600';
+const VOICE_TTS_BASE_URL = 'https://server.fmode.cn/api/volcengine/tts';
+
 async function handler(request, response) {
   try {
     await Psql.query(`
@@ -124,6 +127,35 @@ async function handler(request, response) {
       return;
     }
 
+    if (action === 'synthesize') {
+      const payload = pickParam(request, 'payload', 'data') || {};
+      if (!payload.text && !payload.ssml) {
+        return response.json({ code: 400, success: false, error: '缺少合成文本' });
+      }
+      const upstreamPayload = stripEmpty({
+        ...payload,
+        token: VOICE_TOKEN,
+        isStream: payload.isStream === true,
+      });
+      const res = await postJson(`${VOICE_TTS_BASE_URL}/unidirectional`, upstreamPayload);
+      response.json(res);
+      return;
+    }
+
+    if (action === 'cloneVoice') {
+      const payload = pickParam(request, 'payload', 'data') || {};
+      if (!payload.name || !payload.speaker_id || !payload.audioData) {
+        return response.json({ code: 400, success: false, error: '缺少声音训练参数' });
+      }
+      const upstreamPayload = stripEmpty({
+        ...payload,
+        token: VOICE_TOKEN,
+      });
+      const res = await postJson(`${VOICE_TTS_BASE_URL}/voice_clone`, upstreamPayload);
+      response.json(res);
+      return;
+    }
+
     if (action === 'deleteProfile') {
       const objectId = pickParam(request, 'objectId');
       const timbreId = pickParam(request, 'timbreId');
@@ -167,3 +199,37 @@ function generateId() {
   for (let i = 0; i < 10; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
   return s;
 }
+
+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;
+}
+
+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}` };
+  }
+  return data || { code: 500, success: false, error: '服务返回异常' };
+}
+
+function readError(data, fallback) {
+  return data?.error?.message || data?.error || data?.message || data?.msg || fallback || '';
+}

+ 580 - 0
cloud-functions/10-authCreditManager.js

@@ -0,0 +1,580 @@
+/**
+ * 云函数:authCreditManager(账号、总积分、充值订单、管理员调账)
+ *
+ * 部署后把函数 objectId 填入 src/app/services/cloud-functions.ts 的 authCredit。
+ * 管理员策略:
+ *   1. 第一位注册用户自动为 admin;
+ *   2. 也可在 ADMIN_IDENTIFIERS 中配置用户名/邮箱/手机号。
+ */
+const ADMIN_IDENTIFIERS = ['admin'];
+const REGISTER_GIFT_CREDITS = 10;
+const CREDIT_PER_CNY = 10;
+const PARSE_API_HOST = 'https://server.fmode.cn';
+const PARSE_APP_ID = 'ncloudmaster';
+
+async function handler(request, response) {
+  try {
+    await ensureTables();
+    const action = pickParam(request, 'action') || '';
+
+    if (action === 'register') return register(request, response);
+    if (action === 'login') return login(request, response);
+    if (action === 'me') return me(request, response);
+    if (action === 'changePassword') return changePassword(request, response);
+    if (action === 'balance') return balance(request, response);
+    if (action === 'ledger') return ledger(request, response);
+    if (action === 'reserve') return reserve(request, response);
+    if (action === 'commitReservation') return commitReservation(request, response);
+    if (action === 'refundReservation') return refundReservation(request, response);
+    if (action === 'createRechargeOrder') return createRechargeOrder(request, response);
+    if (action === 'adminListUsers') return adminListUsers(request, response);
+    if (action === 'adminCreateUser') return adminCreateUser(request, response);
+    if (action === 'adminAdjustCredit') return adminAdjustCredit(request, response);
+
+    response.json({ code: 400, success: false, error: `未知 action: ${action}` });
+  } catch (error) {
+    console.error('authCreditManager failed:', error.message, error.stack);
+    response.json({ code: 500, success: false, error: error.message });
+  }
+}
+
+async function ensureTables() {
+  await Psql.query(`
+    CREATE TABLE IF NOT EXISTS "AppUser" (
+      "objectId" VARCHAR(50) PRIMARY KEY,
+      "username" VARCHAR(120) UNIQUE NOT NULL,
+      "email" VARCHAR(180) UNIQUE,
+      "phone" VARCHAR(60) UNIQUE,
+      "displayName" VARCHAR(180) DEFAULT '',
+      "passwordHash" VARCHAR(255) NOT NULL,
+      "passwordSalt" VARCHAR(120) NOT NULL,
+      "role" VARCHAR(30) DEFAULT 'user',
+      "status" VARCHAR(30) DEFAULT 'active',
+      "createdAt" TIMESTAMPTZ DEFAULT NOW(),
+      "updatedAt" TIMESTAMPTZ DEFAULT NOW()
+    )
+  `);
+  await Psql.query(`CREATE INDEX IF NOT EXISTS idx_app_user_email ON "AppUser" ("email")`);
+  await Psql.query(`CREATE INDEX IF NOT EXISTS idx_app_user_phone ON "AppUser" ("phone")`);
+
+  await Psql.query(`
+    CREATE TABLE IF NOT EXISTS "AppSession" (
+      "token" VARCHAR(120) PRIMARY KEY,
+      "userId" VARCHAR(50) NOT NULL,
+      "expiresAt" TIMESTAMPTZ NOT NULL,
+      "createdAt" TIMESTAMPTZ DEFAULT NOW()
+    )
+  `);
+
+  await Psql.query(`
+    CREATE TABLE IF NOT EXISTS "UserCreditAccount" (
+      "userId" VARCHAR(50) PRIMARY KEY,
+      "balance" NUMERIC(12,2) DEFAULT 0,
+      "gifted" NUMERIC(12,2) DEFAULT 0,
+      "totalRecharged" NUMERIC(12,2) DEFAULT 0,
+      "totalConsumed" NUMERIC(12,2) DEFAULT 0,
+      "createdAt" TIMESTAMPTZ DEFAULT NOW(),
+      "updatedAt" TIMESTAMPTZ DEFAULT NOW()
+    )
+  `);
+
+  await Psql.query(`
+    CREATE TABLE IF NOT EXISTS "CreditLedger" (
+      "objectId" VARCHAR(50) PRIMARY KEY,
+      "userId" VARCHAR(50) NOT NULL,
+      "type" VARCHAR(40) NOT NULL,
+      "amount" NUMERIC(12,2) NOT NULL,
+      "balanceAfter" NUMERIC(12,2) NOT NULL,
+      "title" VARCHAR(255) DEFAULT '',
+      "detail" JSONB DEFAULT '{}',
+      "createdAt" TIMESTAMPTZ DEFAULT NOW()
+    )
+  `);
+  await Psql.query(`CREATE INDEX IF NOT EXISTS idx_credit_ledger_user_time ON "CreditLedger" ("userId", "createdAt" DESC)`);
+
+  await Psql.query(`
+    CREATE TABLE IF NOT EXISTS "CreditReservation" (
+      "objectId" VARCHAR(50) PRIMARY KEY,
+      "userId" VARCHAR(50) NOT NULL,
+      "operation" VARCHAR(120) NOT NULL,
+      "cost" NUMERIC(12,2) NOT NULL,
+      "status" VARCHAR(30) DEFAULT 'reserved',
+      "ledgerId" VARCHAR(50),
+      "detail" JSONB DEFAULT '{}',
+      "createdAt" TIMESTAMPTZ DEFAULT NOW(),
+      "updatedAt" TIMESTAMPTZ DEFAULT NOW()
+    )
+  `);
+
+  await Psql.query(`
+    CREATE TABLE IF NOT EXISTS "RechargeOrder" (
+      "objectId" VARCHAR(50) PRIMARY KEY,
+      "userId" VARCHAR(50) NOT NULL,
+      "amountCny" NUMERIC(12,2) NOT NULL,
+      "creditAmount" NUMERIC(12,2) NOT NULL,
+      "status" VARCHAR(30) DEFAULT 'pending',
+      "detail" JSONB DEFAULT '{}',
+      "createdAt" TIMESTAMPTZ DEFAULT NOW(),
+      "updatedAt" TIMESTAMPTZ DEFAULT NOW()
+    )
+  `);
+}
+
+async function register(request, response) {
+  const username = clean(pickParam(request, 'username'));
+  const email = clean(pickParam(request, 'email'));
+  const phone = clean(pickParam(request, 'phone'));
+  const displayName = clean(pickParam(request, 'displayName')) || username;
+  const password = String(pickParam(request, 'password') || '');
+  if (!username || !password) return response.json({ code: 400, success: false, error: '缺少用户名或密码' });
+
+  const exists = await Psql.query(
+    `SELECT "objectId" FROM "AppUser" WHERE "username"=$1 OR ($2 <> '' AND "email"=$2) OR ($3 <> '' AND "phone"=$3) LIMIT 1`,
+    [username, email, phone]
+  );
+  if (exists.length) return response.json({ code: 409, success: false, error: '账号已存在' });
+
+  const countRows = await Psql.query(`SELECT COUNT(*)::int AS count FROM "AppUser"`);
+  const isFirstUser = Number(countRows[0].count || 0) === 0;
+  const role = isFirstUser || ADMIN_IDENTIFIERS.includes(username) || ADMIN_IDENTIFIERS.includes(email) || ADMIN_IDENTIFIERS.includes(phone)
+    ? 'admin'
+    : 'user';
+  const salt = generateId(16);
+  const userId = generateId();
+  const hash = passwordHash(password, salt);
+
+  await Psql.query(
+    `INSERT INTO "AppUser" ("objectId","username","email","phone","displayName","passwordHash","passwordSalt","role")
+     VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,$6,$7,$8)`,
+    [userId, username, email, phone, displayName, hash, salt, role]
+  );
+  await Psql.query(
+    `INSERT INTO "UserCreditAccount" ("userId","balance","gifted") VALUES ($1,$2,$2)`,
+    [userId, REGISTER_GIFT_CREDITS]
+  );
+  await insertLedger(userId, 'gift', REGISTER_GIFT_CREDITS, REGISTER_GIFT_CREDITS, '注册赠送积分', {});
+  const session = await createSession(userId);
+  response.json({ code: 200, success: true, data: { token: session.token, user: await publicUser(userId) } });
+}
+
+async function login(request, response) {
+  const identifier = clean(pickParam(request, 'identifier'));
+  const password = String(pickParam(request, 'password') || '');
+  const rows = await Psql.query(
+    `SELECT * FROM "AppUser" WHERE "username"=$1 OR "email"=$1 OR "phone"=$1 LIMIT 1`,
+    [identifier]
+  );
+  if (!rows.length) return response.json({ code: 401, success: false, error: '账号或密码不正确' });
+  const row = rows[0];
+  if (row.status !== 'active') return response.json({ code: 403, success: false, error: '账号已停用' });
+  if (passwordHash(password, row.passwordSalt) !== row.passwordHash) {
+    return response.json({ code: 401, success: false, error: '账号或密码不正确' });
+  }
+  const session = await createSession(row.objectId);
+  response.json({ code: 200, success: true, data: { token: session.token, user: rowToUser(row) } });
+}
+
+async function me(request, response) {
+  const session = await requireSession(request);
+  response.json({ code: 200, success: true, data: session.user || await publicUser(session.userId) });
+}
+
+async function changePassword(request, response) {
+  const session = await requireSession(request);
+  const oldPassword = String(pickParam(request, 'oldPassword') || '');
+  const newPassword = String(pickParam(request, 'newPassword') || '');
+  if (!newPassword) return response.json({ code: 400, success: false, error: '缺少新密码' });
+  const rows = await Psql.query(`SELECT * FROM "AppUser" WHERE "objectId"=$1 LIMIT 1`, [session.userId]);
+  const row = rows[0];
+  if (passwordHash(oldPassword, row.passwordSalt) !== row.passwordHash) {
+    return response.json({ code: 400, success: false, error: '原密码不正确' });
+  }
+  const salt = generateId(16);
+  await Psql.query(`UPDATE "AppUser" SET "passwordHash"=$1,"passwordSalt"=$2,"updatedAt"=NOW() WHERE "objectId"=$3`, [
+    passwordHash(newPassword, salt), salt, session.userId
+  ]);
+  response.json({ code: 200, success: true, data: true });
+}
+
+async function balance(request, response) {
+  const session = await requireSession(request);
+  response.json({ code: 200, success: true, data: await getBalance(session.userId) });
+}
+
+async function ledger(request, response) {
+  const session = await requireSession(request);
+  const limit = Math.max(1, Math.min(200, Number(pickParam(request, 'limit') || 50)));
+  const rows = await Psql.query(
+    `SELECT * FROM "CreditLedger" WHERE "userId"=$1 ORDER BY "createdAt" DESC LIMIT ${limit}`,
+    [session.userId]
+  );
+  response.json({ code: 200, success: true, data: rows.map(rowToLedger) });
+}
+
+async function reserve(request, response) {
+  const session = await requireSession(request);
+  const operation = clean(pickParam(request, 'operation')) || 'unknown';
+  const cost = Number(pickParam(request, 'cost') || 0);
+  const title = clean(pickParam(request, 'title')) || operation;
+  const detail = pickParam(request, 'detail') || {};
+  if (cost <= 0) return response.json({ code: 400, success: false, error: '积分消耗必须大于 0' });
+
+  await Psql.query('BEGIN');
+  try {
+    const accounts = await Psql.query(`SELECT * FROM "UserCreditAccount" WHERE "userId"=$1 FOR UPDATE`, [session.userId]);
+    if (!accounts.length || Number(accounts[0].balance || 0) < cost) {
+      await Psql.query('ROLLBACK');
+      return response.json({ code: 402, success: false, error: '积分不足,请先充值' });
+    }
+    const newBalance = Number(accounts[0].balance || 0) - cost;
+    await Psql.query(
+      `UPDATE "UserCreditAccount" SET "balance"=$1,"totalConsumed"="totalConsumed"+$2,"updatedAt"=NOW() WHERE "userId"=$3`,
+      [newBalance, cost, session.userId]
+    );
+    const ledgerId = await insertLedger(session.userId, 'consume', -cost, newBalance, title, { operation, ...detail, status: 'reserved' });
+    const reservationId = generateId();
+    await Psql.query(
+      `INSERT INTO "CreditReservation" ("objectId","userId","operation","cost","ledgerId","detail") VALUES ($1,$2,$3,$4,$5,$6)`,
+      [reservationId, session.userId, operation, cost, ledgerId, JSON.stringify(detail || {})]
+    );
+    await Psql.query('COMMIT');
+    response.json({ code: 200, success: true, data: { reservationId, balance: newBalance, cost } });
+  } catch (e) {
+    await Psql.query('ROLLBACK');
+    throw e;
+  }
+}
+
+async function commitReservation(request, response) {
+  const session = await requireSession(request);
+  const reservationId = clean(pickParam(request, 'reservationId'));
+  await Psql.query(
+    `UPDATE "CreditReservation" SET "status"='committed',"detail"=COALESCE("detail",'{}'::jsonb) || $1::jsonb,"updatedAt"=NOW()
+     WHERE "objectId"=$2 AND "userId"=$3 AND "status"='reserved'`,
+    [JSON.stringify(pickParam(request, 'detail') || {}), reservationId, session.userId]
+  );
+  response.json({ code: 200, success: true, data: true });
+}
+
+async function refundReservation(request, response) {
+  const session = await requireSession(request);
+  const reservationId = clean(pickParam(request, 'reservationId'));
+  const reason = clean(pickParam(request, 'reason')) || '任务失败退回积分';
+  await refundByReservation(session.userId, reservationId, reason);
+  response.json({ code: 200, success: true, data: true });
+}
+
+async function createRechargeOrder(request, response) {
+  const session = await requireSession(request);
+  const amountCny = Number(pickParam(request, 'amountCny') || 0);
+  if (amountCny <= 0) return response.json({ code: 400, success: false, error: '充值金额必须大于 0' });
+  const order = {
+    objectId: generateId(),
+    amountCny,
+    creditAmount: Math.round(amountCny * CREDIT_PER_CNY),
+    status: 'pending',
+    createdAt: new Date().toISOString()
+  };
+  await Psql.query(
+    `INSERT INTO "RechargeOrder" ("objectId","userId","amountCny","creditAmount","status","detail") VALUES ($1,$2,$3,$4,'pending',$5)`,
+    [order.objectId, session.userId, order.amountCny, order.creditAmount, JSON.stringify({ creditPerCny: CREDIT_PER_CNY })]
+  );
+  response.json({ code: 200, success: true, data: order });
+}
+
+async function adminListUsers(request, response) {
+  const session = await requireSession(request);
+  await requireAdmin(session.userId);
+  const rows = await Psql.query(`
+    SELECT u."objectId",u."username",u."email",u."phone",u."displayName",u."role",u."status",u."createdAt",
+           COALESCE(c."balance",0) AS "creditBalance"
+    FROM "AppUser" u
+    LEFT JOIN "UserCreditAccount" c ON c."userId"=u."objectId"
+    ORDER BY u."createdAt" DESC
+    LIMIT 1000
+  `);
+  response.json({ code: 200, success: true, data: rows });
+}
+
+async function adminCreateUser(request, response) {
+  const session = await requireSession(request);
+  await requireAdmin(session.userId);
+
+  const username = clean(pickParam(request, 'username'));
+  const email = clean(pickParam(request, 'email'));
+  const phone = clean(pickParam(request, 'phone'));
+  const displayName = clean(pickParam(request, 'displayName')) || username;
+  const password = String(pickParam(request, 'password') || '');
+  const roleInput = clean(pickParam(request, 'role')) || 'user';
+  const role = roleInput === 'admin' ? 'admin' : 'user';
+  const initialCredits = Math.max(0, Number(pickParam(request, 'initialCredits') ?? REGISTER_GIFT_CREDITS));
+  if (!username || !password) return response.json({ code: 400, success: false, error: '缺少用户名或初始密码' });
+
+  const exists = await Psql.query(
+    `SELECT "objectId" FROM "AppUser" WHERE "username"=$1 OR ($2 <> '' AND "email"=$2) OR ($3 <> '' AND "phone"=$3) LIMIT 1`,
+    [username, email, phone]
+  );
+  if (exists.length) return response.json({ code: 409, success: false, error: '账号已存在' });
+
+  await Psql.query('BEGIN');
+  try {
+    const salt = generateId(16);
+    const userId = generateId();
+    await Psql.query(
+      `INSERT INTO "AppUser" ("objectId","username","email","phone","displayName","passwordHash","passwordSalt","role")
+       VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,$6,$7,$8)`,
+      [userId, username, email, phone, displayName, passwordHash(password, salt), salt, role]
+    );
+    await Psql.query(
+      `INSERT INTO "UserCreditAccount" ("userId","balance","gifted") VALUES ($1,$2,$2)`,
+      [userId, initialCredits]
+    );
+    if (initialCredits > 0) {
+      await insertLedger(userId, 'admin_adjust', initialCredits, initialCredits, '管理员发放初始积分', { adminUserId: session.userId });
+    }
+    await Psql.query('COMMIT');
+    response.json({ code: 200, success: true, data: await publicUser(userId) });
+  } catch (e) {
+    await Psql.query('ROLLBACK');
+    throw e;
+  }
+}
+
+async function adminAdjustCredit(request, response) {
+  const session = await requireSession(request);
+  await requireAdmin(session.userId);
+  const userId = clean(pickParam(request, 'userId'));
+  const amount = Number(pickParam(request, 'amount') || 0);
+  const note = clean(pickParam(request, 'note')) || '管理员调整';
+  if (!userId || !amount) return response.json({ code: 400, success: false, error: '缺少用户或调整积分' });
+
+  await Psql.query('BEGIN');
+  try {
+    await Psql.query(`INSERT INTO "UserCreditAccount" ("userId","balance") VALUES ($1,0) ON CONFLICT ("userId") DO NOTHING`, [userId]);
+    const rows = await Psql.query(`SELECT * FROM "UserCreditAccount" WHERE "userId"=$1 FOR UPDATE`, [userId]);
+    const next = Math.max(0, Number(rows[0].balance || 0) + amount);
+    await Psql.query(`UPDATE "UserCreditAccount" SET "balance"=$1,"updatedAt"=NOW() WHERE "userId"=$2`, [next, userId]);
+    await insertLedger(userId, 'admin_adjust', amount, next, note, { adminUserId: session.userId });
+    await Psql.query('COMMIT');
+    response.json({ code: 200, success: true, data: true });
+  } catch (e) {
+    await Psql.query('ROLLBACK');
+    throw e;
+  }
+}
+
+async function refundByReservation(userId, reservationId, reason) {
+  await Psql.query('BEGIN');
+  try {
+    const rows = await Psql.query(
+      `SELECT * FROM "CreditReservation" WHERE "objectId"=$1 AND "userId"=$2 FOR UPDATE`,
+      [reservationId, userId]
+    );
+    if (!rows.length || rows[0].status !== 'reserved') {
+      await Psql.query('COMMIT');
+      return;
+    }
+    const cost = Number(rows[0].cost || 0);
+    const accounts = await Psql.query(`SELECT * FROM "UserCreditAccount" WHERE "userId"=$1 FOR UPDATE`, [userId]);
+    const next = Number(accounts[0].balance || 0) + cost;
+    await Psql.query(
+      `UPDATE "UserCreditAccount" SET "balance"=$1,"totalConsumed"=GREATEST(0,"totalConsumed"-$2),"updatedAt"=NOW() WHERE "userId"=$3`,
+      [next, cost, userId]
+    );
+    await Psql.query(`UPDATE "CreditReservation" SET "status"='refunded',"updatedAt"=NOW() WHERE "objectId"=$1`, [reservationId]);
+    await insertLedger(userId, 'refund', cost, next, reason, { reservationId });
+    await Psql.query('COMMIT');
+  } catch (e) {
+    await Psql.query('ROLLBACK');
+    throw e;
+  }
+}
+
+async function requireLegacySession(request) {
+  const token = clean(pickParam(request, 'sessionToken'));
+  if (!token) throw new Error('请先登录');
+  const rows = await Psql.query(
+    `SELECT * FROM "AppSession" WHERE "token"=$1 AND "expiresAt" > NOW() LIMIT 1`,
+    [token]
+  );
+  if (!rows.length) throw new Error('登录已过期,请重新登录');
+  return rows[0];
+}
+
+async function requireAdmin(userId) {
+  const user = await publicUser(userId);
+  if (user.role !== 'admin') throw new Error('没有管理员权限');
+}
+
+async function createSession(userId) {
+  const token = generateId(32);
+  await Psql.query(
+    `INSERT INTO "AppSession" ("token","userId","expiresAt") VALUES ($1,$2,NOW()+INTERVAL '30 days')`,
+    [token, userId]
+  );
+  return { token };
+}
+
+async function legacyPublicUser(userId) {
+  const rows = await Psql.query(`SELECT * FROM "AppUser" WHERE "objectId"=$1 LIMIT 1`, [userId]);
+  if (!rows.length) throw new Error('用户不存在');
+  return rowToUser(rows[0]);
+}
+
+async function requireSession(request) {
+  const token = clean(pickParam(request, 'sessionToken'));
+  if (!token) throw new Error('请先登录');
+  const rows = await Psql.query(
+    `SELECT * FROM "AppSession" WHERE "token"=$1 AND "expiresAt" > NOW() LIMIT 1`,
+    [token]
+  );
+  if (rows.length) return rows[0];
+
+  const parseUser = await verifyParseSession(token);
+  if (!parseUser?.objectId) throw new Error('登录已过期,请重新登录');
+  await ensureCreditAccount(parseUser.objectId, REGISTER_GIFT_CREDITS);
+  return {
+    token,
+    userId: parseUser.objectId,
+    user: parseUserToPublicUser(parseUser),
+    source: 'parse',
+  };
+}
+
+async function publicUser(userId) {
+  const rows = await Psql.query(`SELECT * FROM "AppUser" WHERE "objectId"=$1 LIMIT 1`, [userId]);
+  if (rows.length) return rowToUser(rows[0]);
+  return {
+    objectId: userId,
+    username: userId,
+    email: '',
+    phone: '',
+    displayName: userId,
+    role: ADMIN_IDENTIFIERS.includes(userId) ? 'admin' : 'user'
+  };
+}
+
+async function getBalance(userId) {
+  await Psql.query(`INSERT INTO "UserCreditAccount" ("userId","balance") VALUES ($1,0) ON CONFLICT ("userId") DO NOTHING`, [userId]);
+  const rows = await Psql.query(`SELECT * FROM "UserCreditAccount" WHERE "userId"=$1 LIMIT 1`, [userId]);
+  const row = rows[0] || {};
+  return {
+    balance: Number(row.balance || 0),
+    gifted: Number(row.gifted || 0),
+    totalRecharged: Number(row.totalRecharged || 0),
+    totalConsumed: Number(row.totalConsumed || 0)
+  };
+}
+
+async function insertLedger(userId, type, amount, balanceAfter, title, detail) {
+  const objectId = generateId();
+  await Psql.query(
+    `INSERT INTO "CreditLedger" ("objectId","userId","type","amount","balanceAfter","title","detail") VALUES ($1,$2,$3,$4,$5,$6,$7)`,
+    [objectId, userId, type, amount, balanceAfter, title, JSON.stringify(detail || {})]
+  );
+  return objectId;
+}
+
+async function verifyParseSession(sessionToken) {
+  if (typeof fetch !== 'function') return null;
+  const resp = await fetch(`${PARSE_API_HOST}/parse/users/me?include=company`, {
+    method: 'GET',
+    headers: {
+      'X-Parse-Application-Id': PARSE_APP_ID,
+      'X-Parse-Session-Token': sessionToken,
+    },
+  });
+  const data = await resp.json().catch(() => ({}));
+  return resp.ok && data.objectId ? data : null;
+}
+
+async function ensureCreditAccount(userId, giftAmount) {
+  const amount = Math.max(0, Number(giftAmount || 0));
+  const rows = await Psql.query(`SELECT * FROM "UserCreditAccount" WHERE "userId"=$1 LIMIT 1`, [userId]);
+  if (rows.length) return;
+  await Psql.query(
+    `INSERT INTO "UserCreditAccount" ("userId","balance","gifted") VALUES ($1,$2,$2)`,
+    [userId, amount]
+  );
+  if (amount > 0) {
+    await insertLedger(userId, 'gift', amount, amount, '初始赠送积分', { source: 'parse_user_login' });
+  }
+}
+
+function parseUserToPublicUser(user) {
+  const mobile = user.mobilePhoneNumber || user.mobile || '';
+  return {
+    objectId: user.objectId,
+    username: user.username || mobile || user.objectId,
+    email: user.email || '',
+    phone: mobile,
+    displayName: user.nickname || user.name || user.displayName || maskMobile(mobile) || user.username || user.objectId,
+    role: user.role === 'admin' || user.isAdmin === true ? 'admin' : 'user',
+    createdAt: user.createdAt
+  };
+}
+
+function maskMobile(mobile) {
+  const value = String(mobile || '');
+  return /^1[3-9]\d{9}$/.test(value) ? value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : value;
+}
+
+function rowToUser(row) {
+  return {
+    objectId: row.objectId,
+    username: row.username,
+    email: row.email || '',
+    phone: row.phone || '',
+    displayName: row.displayName || row.username,
+    role: row.role || 'user',
+    createdAt: row.createdAt
+  };
+}
+
+function rowToLedger(row) {
+  return {
+    objectId: row.objectId,
+    type: row.type,
+    amount: Number(row.amount || 0),
+    balanceAfter: Number(row.balanceAfter || 0),
+    title: row.title || '',
+    detail: typeof row.detail === 'string' ? JSON.parse(row.detail || '{}') : (row.detail || {}),
+    createdAt: row.createdAt
+  };
+}
+
+function passwordHash(password, salt) {
+  // fmode 云函数环境不保证可用 crypto;这里使用轻量确定性哈希。
+  // 后续若平台支持 crypto,应替换为 PBKDF2/bcrypt。
+  let h = 2166136261;
+  const input = `${salt}:${password}`;
+  for (let i = 0; i < input.length; i++) {
+    h ^= input.charCodeAt(i);
+    h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24);
+  }
+  return (h >>> 0).toString(16);
+}
+
+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;
+    }
+  }
+  return null;
+}
+
+function clean(v) {
+  return String(v || '').trim();
+}
+
+function generateId(len) {
+  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+  let s = '';
+  const n = len || 10;
+  for (let i = 0; i < n; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
+  return s;
+}

+ 116 - 0
cloud-functions/11-jimengManager.js

@@ -0,0 +1,116 @@
+/**
+ * 云函数:jimengManager
+ * 代理即梦图片、视频、数字人、动作迁移等任务接口,避免前端暴露上游地址和 token。
+ *
+ * 部署后把函数 objectId 填入 src/app/services/cloud-functions.ts 的 jimeng。
+ */
+const JIMENG_TOKEN = 'Bearer r:f0333969e312a40e4703e8fe4ed1c600';
+const JIMENG_BASE_URL = 'https://server.fmode.cn/api/volcengine/jimeng';
+const PARSE_BASE_URL = 'https://server.fmode.cn/parse';
+const PARSE_APP_ID = 'ncloudmaster';
+
+const ALLOWED_ENDPOINTS = new Set([
+  'getVideoV3_720p',
+  'getVideoV3_1080p',
+  'getVideoV3_Pro',
+  'getText2ImgV31',
+  'getImgV4',
+  'getDataByTask02',
+  'getOhIdentifyMain',
+  'getOhDateByTask',
+  'getOmniHuman',
+  'getActorV2',
+]);
+
+async function handler(request, response) {
+  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 upstreamPayload = stripEmpty({ ...payload, token: JIMENG_TOKEN });
+      const data = await postJson(`${JIMENG_BASE_URL}/${endpoint}`, upstreamPayload);
+      response.json(data);
+      return;
+    }
+
+    if (action === 'getWorkResult') {
+      const workId = String(pickParam(request, 'workId', 'objectId') || '').trim();
+      if (!workId) {
+        return response.json({ code: 400, success: false, error: '缺少作品 ID' });
+      }
+      const data = await getJson(`${PARSE_BASE_URL}/classes/ImagineWork/${encodeURIComponent(workId)}`, {
+        'X-Parse-Application-Id': PARSE_APP_ID,
+      });
+      response.json({ code: 200, success: true, data });
+      return;
+    }
+
+    response.json({ code: 400, success: false, error: `未知 action: ${action}` });
+  } catch (error) {
+    console.error('jimengManager failed:', error && error.message ? error.message : error);
+    response.json({ code: 500, success: false, error: error && error.message ? error.message : '生成服务调用失败' });
+  }
+}
+
+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;
+    }
+  }
+  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;
+}
+
+async function postJson(url, body) {
+  const r = await fetch(url, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body),
+  });
+  return readResponse(r);
+}
+
+async function getJson(url, headers) {
+  const r = await fetch(url, { method: 'GET', headers });
+  const data = await readResponse(r);
+  if (data && data.success === false) throw new Error(data.error || '查询结果失败');
+  return data;
+}
+
+async function readResponse(r) {
+  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}` };
+  }
+  return data || { code: 500, success: false, error: '服务返回异常' };
+}
+
+function readError(data, fallback) {
+  return data?.error?.message || data?.error || data?.message || data?.msg || fallback || '';
+}

+ 107 - 0
cloud-functions/12-douyinManager.js

@@ -0,0 +1,107 @@
+/**
+ * 云函数:douyinManager
+ * 代理抖音/TikHub 相关接口,避免前端暴露 TikHub 地址和 token。
+ *
+ * 部署后把函数 objectId 填入 src/app/services/cloud-functions.ts 的 douyin。
+ */
+const TIKHUB_BASE_URL = 'https://api.tikhub.io';
+const TIKHUB_TOKEN = 'gqsZHfMWgAiMwV+ITbmZy0qALADWBZVS7QnV7kKJe9CwzgWgJG+7bwK+GQ==';
+
+const ROUTES = {
+  searchVideos: { method: 'POST', path: '/api/v1/douyin/search/fetch_general_search_v2' },
+  challengeSearch: { method: 'POST', path: '/api/v1/douyin/search/fetch_challenge_search_v2' },
+  videoDetail: { method: 'GET', path: '/api/v1/douyin/app/v3/fetch_one_video_v3' },
+  userProfileWeb: { method: 'GET', path: '/api/v1/douyin/web/handler_user_profile_v2' },
+  userProfileApp: { method: 'GET', path: '/api/v1/douyin/app/v3/handler_user_profile' },
+  userPosts: { method: 'GET', path: '/api/v1/douyin/web/fetch_user_post_videos' },
+  comments: { method: 'GET', path: '/api/v1/douyin/app/v3/fetch_video_comments' },
+  replies: { method: 'POST', path: '/api/v1/douyin/comment/reply/list' },
+};
+
+async function handler(request, response) {
+  try {
+    const action = pickParam(request, 'action') || 'call';
+    if (action !== 'call') {
+      return response.json({ code: 400, success: false, error: `未知 action: ${action}` });
+    }
+
+    const routeName = String(pickParam(request, 'route') || '').trim();
+    const route = ROUTES[routeName];
+    if (!route) {
+      return response.json({ code: 400, success: false, error: '不支持的抖音接口' });
+    }
+
+    const params = pickParam(request, 'params') || {};
+    const body = pickParam(request, 'payload', 'data') || {};
+    const data = route.method === 'GET'
+      ? await requestJson('GET', route.path, params)
+      : await requestJson('POST', route.path, body);
+
+    response.json(data);
+  } catch (error) {
+    console.error('douyinManager failed:', error && error.message ? error.message : error);
+    response.json({ code: 500, success: false, error: error && error.message ? error.message : '抖音服务调用失败' });
+  }
+}
+
+async function requestJson(method, path, payload) {
+  const url = new URL(`${TIKHUB_BASE_URL}${path}`);
+  const init = {
+    method,
+    headers: {
+      'Content-Type': 'application/json',
+      'Accept': 'application/json',
+      'Authorization': `Bearer ${TIKHUB_TOKEN}`,
+    },
+  };
+
+  if (method === 'GET') {
+    for (const [key, value] of Object.entries(payload || {})) {
+      if (value !== undefined && value !== null && value !== '') {
+        url.searchParams.set(key, String(value));
+      }
+    }
+  } else {
+    init.body = JSON.stringify(stripEmpty(payload || {}));
+  }
+
+  const r = await fetch(url.toString(), init);
+  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}` };
+  }
+  return data || { code: 500, success: false, error: '服务返回异常' };
+}
+
+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;
+    }
+  }
+  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 readError(data, fallback) {
+  return data?.error?.message || data?.error || data?.message || data?.msg || fallback || '';
+}

+ 52 - 0
cloud-functions/auth-credit-deploy.md

@@ -0,0 +1,52 @@
+# 账号积分云函数部署说明
+
+## 文件
+
+上传 `cloud-functions/10-authCreditManager.js` 到 fmode 云函数平台。
+
+## 前端配置
+
+部署成功后会得到云函数 objectId,把它填入:
+
+```ts
+// src/app/services/cloud-functions.ts
+authCredit: '你的云函数ID'
+```
+
+未填写时,前端会进入本地测试模式:账号、积分、流水保存在浏览器 localStorage,只用于看界面和流程。
+
+## 试运营规则
+
+- 注册赠送 10 积分。
+- 1 元 = 10 积分。
+- 第一位注册用户自动成为管理员。
+- `ADMIN_IDENTIFIERS` 中配置的用户名 / 邮箱 / 手机号也会成为管理员。
+- 前台自助注册已关闭,用户账号由管理员在「账号明细」页发放。
+- 管理员发放账号时可设置用户名、初始密码、邮箱、手机号、角色和初始积分。
+- 充值当前只创建待支付订单,真实微信支付后续再接。
+
+## 已接入后端预扣的生成链路
+
+- 图片生成:3 积分 / 张。
+- 图生视频:
+  - 720p:按 0.28 元/秒换算积分。
+  - 1080p:按 0.63 元/秒换算积分。
+  - Pro:按 1 元/秒换算积分。
+- 标准视频生成 / AI 重塑:同视频规则。
+- 动作迁移:暂按 20 积分 / 次。
+
+## 已接入登录拦截但尚未完整后端扣费的入口
+
+- 声音训练。
+- 语音合成。
+- 数字人合成。
+- 标准视频语音配音。
+
+## 已接入积分预扣但尚未迁到云函数代理的链路
+
+- 抖音视频发现:1 积分 / 次。
+- 抖音视频详情:1 积分 / 次。
+- 博主资料:1 积分 / 次。
+- 博主作品:1 积分 / 次。
+
+这些链路已经会在后端积分表预扣、成功确认、失败退款;但上游 API 仍由前端发起。下一步需要迁到云函数代理中,才能做到和即梦链路同等级的接口隐藏与强校验。

+ 46 - 0
src/app/app.css

@@ -6334,6 +6334,52 @@ code {
   transition: transform 0.4s ease;
 }
 
+.account-chip {
+  height: 34px;
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  margin-right: 10px;
+  padding: 0 10px 0 6px;
+  border-radius: 999px;
+  border: 1px solid rgba(96, 165, 250, .28);
+  background: rgba(255, 255, 255, .72);
+  color: #1e3a8a;
+  font-weight: 800;
+  cursor: pointer;
+}
+
+.account-chip__dot {
+  width: 24px;
+  height: 24px;
+  border-radius: 50%;
+  display: grid;
+  place-items: center;
+  color: #fff;
+  background: linear-gradient(135deg, #2563eb, #06b6d4);
+  font-size: 12px;
+}
+
+.account-chip__text {
+  max-width: 120px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.account-chip__mode {
+  padding: 2px 6px;
+  border-radius: 999px;
+  background: #fef3c7;
+  color: #92400e;
+  font-size: 11px;
+}
+
+.theme-night .account-chip {
+  background: rgba(15, 23, 42, .72);
+  color: #bfdbfe;
+}
+
 /* ============================================================
  * 主题切换全局平滑过渡
  * 给会随主题改变颜色/背景的元素统一加 transition

+ 89 - 135
src/app/app.html

@@ -1,4 +1,4 @@
-<div class="app-container" [class.theme-day]="themeMode === 'day'" [class.theme-night]="themeMode === 'night'">
+<div class="app-container theme-day">
 
   <!-- 头部导航 -->
 
@@ -8,42 +8,10 @@
 
     <span class="spacer"></span>
 
-    <button class="theme-toggle" type="button" (click)="toggleTheme()"
-
-            [attr.aria-label]="themeMode === 'day' ? '切换到夜间模式' : '切换到日间模式'"
-
-            [title]="themeMode === 'day' ? '切换到夜间模式' : '切换到日间模式'">
-
-      <!-- 日间模式显示月亮(点击切夜间),夜间模式显示太阳(点击切日间) -->
-
-      <svg *ngIf="themeMode === 'day'" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-
-        <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
-
-      </svg>
-
-      <svg *ngIf="themeMode === 'night'" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-
-        <circle cx="12" cy="12" r="5"></circle>
-
-        <line x1="12" y1="1" x2="12" y2="3"></line>
-
-        <line x1="12" y1="21" x2="12" y2="23"></line>
-
-        <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
-
-        <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
-
-        <line x1="1" y1="12" x2="3" y2="12"></line>
-
-        <line x1="21" y1="12" x2="23" y2="12"></line>
-
-        <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
-
-        <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
-
-      </svg>
-
+    <button class="account-chip" type="button" (click)="setCurrentTab(auth.isLoggedIn ? 'user-center' : 'login')">
+      <span class="account-chip__dot">{{ auth.isLoggedIn ? (auth.currentUser?.displayName || auth.currentUser?.username || 'U').charAt(0) : '未' }}</span>
+      <span class="account-chip__text">{{ auth.isLoggedIn ? (auth.currentUser?.displayName || auth.currentUser?.username) : '登录' }}</span>
+      <span class="account-chip__mode" *ngIf="auth.localMode">测试</span>
     </button>
 
   </header>
@@ -57,9 +25,7 @@
     <!-- 侧边栏导航(P1 重构:3 大分区纯字排版,参考 components/app-sidebar) -->
     <app-sidebar
       [currentTab]="currentTab"
-      [themeMode]="themeMode"
-      (tabChange)="setCurrentTab($any($event))"
-      (themeToggle)="toggleTheme()">
+      (tabChange)="setCurrentTab($any($event))">
     </app-sidebar>
 
 
@@ -109,7 +75,7 @@
                     [class.badge-stable]="p.status === 'stable'"
                     [class.badge-beta]="p.status === 'beta'"
                     [class.badge-planned]="p.status === 'planned'">
-                {{ p.status === 'stable' ? '已上线' : p.status === 'beta' ? 'Beta' : '即将推出' }}
+                {{ p.status === 'planned' ? '即将推出' : '可使用' }}
               </span>
             </div>
 
@@ -147,15 +113,35 @@
       <app-topic-to-video *ngIf="currentTab === 'topic-to-video'"></app-topic-to-video>
 
       <!-- 我的创作(跨 Pipeline 草稿与 artifact 时间线) -->
-      <app-drafts *ngIf="currentTab === 'drafts'"
+      <app-drafts *ngIf="currentTab === 'drafts' && auth.isLoggedIn"
                   (navigateToPipeline)="onDraftsNavigate($event)">
       </app-drafts>
 
       <!-- 素材库(跨 Pipeline AI 生成成果聚合) -->
-      <app-library *ngIf="currentTab === 'library'"
+      <app-library *ngIf="currentTab === 'library' && auth.isLoggedIn"
                    (navigateToDraft)="onLibraryNavigate($event)">
       </app-library>
 
+      <app-auth-page *ngIf="currentTab === 'login'"
+                     mode="login"
+                     [allowRegister]="false"
+                     (modeChange)="setCurrentTab($event)"
+                     (signedIn)="onAuthSignedIn()">
+      </app-auth-page>
+
+      <app-auth-page *ngIf="currentTab === 'register'"
+                     mode="register"
+                     [allowRegister]="false"
+                     (modeChange)="setCurrentTab($event)"
+                     (signedIn)="onAuthSignedIn()">
+      </app-auth-page>
+
+      <app-user-center *ngIf="currentTab === 'user-center'"
+                       (navigate)="setCurrentTab($any($event))">
+      </app-user-center>
+
+      <app-admin-users *ngIf="currentTab === 'admin-users'"></app-admin-users>
+
       <!-- 视频发现模块 -->
 
       <section class="dh-page search-page" *ngIf="currentTab === 'search'">
@@ -440,7 +426,7 @@
 
 
 
-      <section class="dh-page monitor-page monitor-section" *ngIf="currentTab === 'monitor'">
+      <section class="dh-page monitor-page monitor-section" *ngIf="currentTab === 'monitor' && auth.isLoggedIn">
 
         <header class="dh-page-header">
 
@@ -814,7 +800,7 @@
 
                 <h3>暂无可展示作品</h3>
 
-                <p>这个博主暂时没有返回主页作品,或接口当前未取到数据</p>
+                <p>暂时没有获取到这个博主的作品,请稍后刷新或更换账号</p>
 
               </div>
 
@@ -1120,89 +1106,62 @@
 
               <!-- 元信息 chips -->
 
-              <div class="task-row__meta" *ngIf="task.duration || task.keyword || task.vgData">
+              <div class="task-row__meta" *ngIf="task.duration || task.keyword || task.vgData || task.estimatedCredits || task.cost">
 
                 <span class="task-row__chip" *ngIf="task.duration">
-
                   <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-
                     <polygon points="23 7 16 12 23 17 23 7"></polygon>
-
                     <rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
-
                   </svg>
-
                   {{task.duration}}
-
                 </span>
 
                 <span class="task-row__chip" *ngIf="task.keyword && (task.original_video_title && task.original_video_title !== task.keyword)">
-
                   <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-
                     <circle cx="12" cy="12" r="10"></circle>
-
                     <circle cx="12" cy="12" r="4"></circle>
-
                   </svg>
-
                   {{task.keyword}}
-
                 </span>
 
                 <span class="task-row__chip" *ngIf="task.type === 'video-generation' && task.vgData">
-
                   <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-
                     <polygon points="12 2 2 7 12 12 22 7 12 2"></polygon>
-
                     <polyline points="2 17 12 22 22 17"></polyline>
-
                     <polyline points="2 12 12 17 22 12"></polyline>
-
                   </svg>
-
                   步骤 {{task.vgData.step}}/6
-
                 </span>
 
                 <span class="task-row__chip" *ngIf="task.type === 'video-generation' && task.vgData?.scriptSegments?.length">
-
                   <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-
                     <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
-
                     <polyline points="14 2 14 8 20 8"></polyline>
-
                   </svg>
-
                   {{task.vgData?.scriptSegments?.length}} 个片段
+                </span>
 
+                <span class="task-row__chip" *ngIf="task.estimatedCredits || task.cost">
+                  {{getTaskCreditText(task)}}
                 </span>
 
               </div>
 
 
-
               <!-- 错误信息 -->
 
               <div class="task-row__error" *ngIf="task.status === 'failed' && task.error_message">
-
                 <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-
                   <circle cx="12" cy="12" r="10"></circle>
-
                   <line x1="12" y1="8" x2="12" y2="12"></line>
-
                   <line x1="12" y1="16" x2="12.01" y2="16"></line>
-
                 </svg>
-
                 <span>{{task.error_message}}</span>
-
               </div>
 
-
+              <div class="task-row__error" *ngIf="task.status === 'failed' && (task.failureSuggestion || task.recoverable)">
+                <span>{{getTaskRecoveryText(task)}}</span>
+              </div>
 
               <!-- 操作按钮 -->
 
@@ -1256,6 +1215,9 @@
 
                 </button>
 
+                <button type="button" class="task-btn task-btn--primary" *ngIf="task.status === 'failed' && (task.retryable || task.recoverable)" (click)="retryTask(task)">
+                  重试/恢复
+                </button>
                 <button type="button" class="task-btn task-btn--ghost" *ngIf="task.status === 'processing'" (click)="cancelTask(task)">
 
                   取消
@@ -1782,7 +1744,7 @@
 
       <!-- 视频管理模块 -->
 
-      <section class="dh-page videos-page" *ngIf="currentTab === 'videos'">
+      <section class="dh-page videos-page" *ngIf="currentTab === 'videos' && auth.isLoggedIn">
 
         <header class="dh-page-header">
 
@@ -2114,11 +2076,11 @@
 
             <h1>声音定制</h1>
 
-            <p>上传音频样本训练专属音色,或选择已有音色合成个性化语音</p>
+            <p>上传音频样本训练专属声音模型,或选择已有声音模型生成语音</p>
 
           </div>
 
-          <button type="button" class="dh-help-btn" (click)="showToast('💡 先在左侧「声音训练」上传样本完成复刻,再到右侧「声音合成」选择音色生成语音', 'info', 5000)">
+          <button type="button" class="dh-help-btn" (click)="showToast('先在左侧「声音训练」上传样本完成训练,再到右侧「声音合成」选择声音模型生成语音。', 'info', 5000)">
 
             <span class="dh-help-icon">?</span>
 
@@ -2134,7 +2096,7 @@
 
 
 
-          <!-- ========== 左:声音训练(音色复刻) ========== -->
+          <!-- ========== 左:声音训练 ========== -->
 
           <article class="dh-card vs-subcard">
 
@@ -2290,7 +2252,7 @@
 
                 </select>
 
-                <p class="vs-hint">音色 ID 由后端自动从未训练池中分配,无需手动选择</p>
+                <p class="vs-hint">系统会自动准备声音训练所需信息,无需手动配置</p>
 
               </div>
 
@@ -2304,7 +2266,7 @@
 
               <input class="dh-input" type="text" [(ngModel)]="vsCloneForm.audioText"
 
-                     placeholder="音频中说的文本内容,有助于提升复刻质量"
+                     placeholder="音频中说的文本内容,有助于提升训练质量"
 
                      [disabled]="vsCloneLoading || vsCloneUploading">
 
@@ -2318,7 +2280,7 @@
 
               <input class="dh-input" type="text" [(ngModel)]="vsCloneForm.demoText"
 
-                     placeholder="复刻成功后用于生成试听音频的文本"
+                     placeholder="训练成功后用于生成试听音频的文本"
 
                      [disabled]="vsCloneLoading || vsCloneUploading">
 
@@ -2338,7 +2300,7 @@
 
               </svg>
 
-              <span>{{vsCloneUploading ? '上传音频中...' : vsCloneLoading ? '复刻中...' : '开始训练'}}</span>
+              <span>{{vsCloneUploading ? '上传音频中...' : vsCloneLoading ? '训练中...' : '开始训练'}}</span>
 
             </button>
 
@@ -2354,7 +2316,7 @@
 
 
 
-            <!-- 复刻结果 -->
+            <!-- 训练结果 -->
 
             <div class="vs-result" *ngIf="vsCloneResult?.code === 200 && vsCloneResult?.data?.timbre">
 
@@ -2362,17 +2324,13 @@
 
                 <span class="dh-status-banner__check">✓</span>
 
-                <span>复刻成功</span>
+                <span>训练成功</span>
 
               </div>
 
               <div class="vs-result-info">
 
-                <div><strong>音色 ID:</strong><code style="user-select:all">{{vsCloneResult.data.timbre.objectId}}</code></div>
-
-                <div><strong>音色名称:</strong>{{vsCloneResult.data.timbre.name}}</div>
-
-                <div><strong>Speaker ID:</strong>{{vsCloneResult.data.timbre.speaker_id}}</div>
+                <div><strong>声音名称:</strong>{{vsCloneResult.data.timbre.name || vsCloneForm.name}}</div>
 
                 <div *ngIf="vsCloneResult.data.timbre.models?.[0]?.demo_audio">
 
@@ -2388,7 +2346,7 @@
 
                       (click)="vsSelectClonedTimbre(vsCloneResult.data.timbre.objectId)">
 
-                使用此音色合成
+                使用此声音生成语音
 
               </button>
 
@@ -2396,11 +2354,11 @@
 
 
 
-            <!-- 已复刻快捷列表 -->
+            <!-- 已训练快捷列表 -->
 
             <div class="vs-cloned-list" *ngIf="vsClonedTimbreList.length > 0">
 
-              <h4 class="vs-subtle-title">已复刻音色 ({{vsClonedTimbreList.length}})</h4>
+              <h4 class="vs-subtle-title">我的声音模型 ({{vsClonedTimbreList.length}})</h4>
 
               <div class="dh-chips">
 
@@ -2472,7 +2430,7 @@
                   <span class="vs-voice-picker-info">
                     <span class="vs-voice-picker-name">{{currentVoiceSelection.name}}</span>
                     <span class="vs-voice-picker-src">
-                      {{currentVoiceSelection.source === 'official' ? '音库' : '我的音'}}
+                      {{currentVoiceSelection.source === 'official' ? '音库' : '我的音'}}
                     </span>
                   </span>
                 </ng-container>
@@ -2480,7 +2438,7 @@
                   <span class="vs-voice-picker-avatar vs-voice-picker-avatar--empty">+</span>
                   <span class="vs-voice-picker-info">
                     <span class="vs-voice-picker-name">请选择声音模型</span>
-                    <span class="vs-voice-picker-src">点击打开音色库 / 我的音色</span>
+                    <span class="vs-voice-picker-src">点击选择声音库 / 我的声音</span>
                   </span>
                 </ng-template>
                 <span class="vs-voice-picker-chevron">›</span>
@@ -2638,7 +2596,7 @@
 
               <div class="vs-result-info">
 
-                <div *ngIf="vsSynthWorkId"><strong>作品 ID:</strong><code>{{vsSynthWorkId}}</code></div>
+                <div *ngIf="vsSynthWorkId"><strong>生成记录:</strong>已保存</div>
 
               </div>
 
@@ -2676,7 +2634,7 @@
 
           </h2>
 
-          <p class="subtitle">基于火山引擎API,支持音色复刻与文本语音合成</p>
+          <p class="subtitle">训练自己的声音模型,或选择已有声音生成自然语音</p>
 
         </div>
 
@@ -2694,7 +2652,7 @@
 
             <div class="remix-mode-title">音色复刻</div>
 
-            <div class="remix-mode-desc">上传音频URL复刻音色,获取timbreId后可用于语音合成。</div>
+            <div class="remix-mode-desc">上传音频样本,训练可重复使用的声音模型。</div>
 
           </div>
 
@@ -2706,7 +2664,7 @@
 
             <div class="remix-mode-title">语音合成</div>
 
-            <div class="remix-mode-desc">使用已有音色ID将文本内容转为语音音频。</div>
+            <div class="remix-mode-desc">选择声音模型,将文本内容转为语音音频。</div>
 
           </div>
 
@@ -2728,7 +2686,7 @@
 
           <div class="form-field">
 
-            <label>Speaker ID *</label>
+            <label>训练通道 *</label>
 
             <select [(ngModel)]="vsCloneForm.speakerId" [disabled]="vsCloneLoading || vsCloneUploading">
 
@@ -2890,7 +2848,7 @@
 
             <span class="icon">{{vsCloneLoading || vsCloneUploading ? 'hourglass_empty' : 'content_copy'}}</span>
 
-            {{vsCloneUploading ? '上传音频中...' : vsCloneLoading ? '复刻中...' : '开始音色复刻'}}
+            {{vsCloneUploading ? '上传音频中...' : vsCloneLoading ? '训练中...' : '开始训练' }}
 
           </button>
 
@@ -2910,19 +2868,7 @@
 
             <div style="display:flex;flex-direction:column;gap:6px;font-size:13px">
 
-              <div><strong>音色ID(timbreId):</strong><code style="user-select:all">{{vsCloneResult.data.timbre.objectId}}</code></div>
-
-              <div><strong>音色名称:</strong>{{vsCloneResult.data.timbre.name}}</div>
-
-              <div><strong>Speaker ID:</strong>{{vsCloneResult.data.timbre.speaker_id}}</div>
-
-              <div *ngIf="vsCloneResult.data.timbre.models?.length > 0">
-
-                <strong>可用模型:</strong>{{vsCloneResult.data.timbre.models.length}} 个
-
-                <span *ngIf="vsCloneResult.data.timbre.models[0]?.version">({{vsCloneResult.data.timbre.models[0].version}})</span>
-
-              </div>
+              <div><strong>声音名称:</strong>{{vsCloneResult.data.timbre.name || vsCloneForm.name}}</div>
 
               <div *ngIf="vsCloneResult.data.timbre.models?.[0]?.demo_audio">
 
@@ -2938,7 +2884,7 @@
 
                     (click)="vsSelectClonedTimbre(vsCloneResult.data.timbre.objectId)">
 
-              <span class="icon">arrow_forward</span> 使用此音色进行语音合成
+              <span class="icon">arrow_forward</span> 使用此声音生成语音
 
             </button>
 
@@ -2950,7 +2896,7 @@
 
           <div *ngIf="vsClonedTimbreList.length > 0" style="margin-top:8px">
 
-            <h4 style="margin:0 0 8px;font-size:14px;color:var(--text-secondary)">已复刻音色</h4>
+            <h4 style="margin:0 0 8px;font-size:14px;color:var(--text-secondary)">我的声音模型</h4>
 
             <div style="display:flex;flex-wrap:wrap;gap:8px">
 
@@ -2962,7 +2908,7 @@
 
                 <span class="icon" style="font-size:14px">record_voice_over</span>
 
-                {{t.name}} <small style="opacity:0.6">({{t.objectId}})</small>
+                {{t.name}}
 
               </button>
 
@@ -2980,11 +2926,11 @@
 
           <div class="form-field">
 
-            <label>音色ID(timbreId)*</label>
+            <label>声音模型 *</label>
 
             <input type="text" [(ngModel)]="vsSynthForm.timbreId"
 
-                   placeholder="复刻成功后获得的 objectId,例如:W4WlyF4mKT"
+                   placeholder="请选择已训练的声音模型"
 
                    [disabled]="vsSynthLoading">
 
@@ -3168,7 +3114,7 @@
 
           </div>
 
-          <button type="button" class="dh-help-btn" (click)="showToast('💡 在「语音合成」标签页复刻音色后,可在此处选择 timbreId 直接生成数字人视频', 'info', 5000)">
+          <button type="button" class="dh-help-btn" (click)="showToast('可以先在「语音合成」中训练声音模型,再用于数字人视频。', 'info', 5000)">
 
             <span class="dh-help-icon">?</span>
 
@@ -3275,7 +3221,7 @@
                   <span class="dh-field__counter">{{(dhTtsText || '').length}} / 600</span>
                 </label>
                 <textarea class="dh-textarea" rows="4" maxlength="600" [(ngModel)]="dhTtsText"
-                          placeholder="输入数字人要朗读的文本,将使用所选音色合成语音"></textarea>
+                          placeholder="输入数字人要朗读的文本,将使用所选声音模型生成语音"></textarea>
               </div>
 
               <div class="dh-field">
@@ -3473,7 +3419,7 @@
                         <span class="vs-voice-picker-info">
                           <span class="vs-voice-picker-name">{{dhCurrentVoiceSelection.name}}</span>
                           <span class="vs-voice-picker-src">
-                            {{dhCurrentVoiceSelection.source === 'official' ? '音库' : '我的音'}}
+                            {{dhCurrentVoiceSelection.source === 'official' ? '音库' : '我的音'}}
                           </span>
                         </span>
                       </ng-container>
@@ -3481,7 +3427,7 @@
                         <span class="vs-voice-picker-avatar vs-voice-picker-avatar--empty">+</span>
                         <span class="vs-voice-picker-info">
                           <span class="vs-voice-picker-name">请选择声音模型</span>
-                          <span class="vs-voice-picker-src">点击打开音色库 / 我的音色</span>
+                          <span class="vs-voice-picker-src">点击选择声音库 / 我的声音</span>
                         </span>
                       </ng-template>
                       <span class="vs-voice-picker-chevron">›</span>
@@ -4369,7 +4315,7 @@
 
 
 
-          <!-- 音色选择 -->
+          <!-- 声音模型选择 -->
 
           <div style="margin-bottom:20px;padding:16px;background:var(--bg-secondary);border-radius:10px;border:1px solid var(--border-color)">
 
@@ -4389,7 +4335,7 @@
                   <span class="vs-voice-picker-info">
                     <span class="vs-voice-picker-name">{{vgCurrentVoiceSelection.name}}</span>
                     <span class="vs-voice-picker-src">
-                      {{vgCurrentVoiceSelection.source === 'official' ? '音库' : '我的音'}}
+                      {{vgCurrentVoiceSelection.source === 'official' ? '音库' : '我的音'}}
                     </span>
                   </span>
                 </ng-container>
@@ -4397,7 +4343,7 @@
                   <span class="vs-voice-picker-avatar vs-voice-picker-avatar--empty">+</span>
                   <span class="vs-voice-picker-info">
                     <span class="vs-voice-picker-name">请选择声音模型</span>
-                    <span class="vs-voice-picker-src">点击打开音色库 / 我的音色</span>
+                    <span class="vs-voice-picker-src">点击选择声音库 / 我的声音</span>
                   </span>
                 </ng-template>
                 <span class="vs-voice-picker-chevron">›</span>
@@ -4773,15 +4719,15 @@
 
             <div><strong>步骤1:</strong>上传需要拆解的视频源文件(支持常见视频格式)</div>
 
-            <div><strong>步骤2:</strong>系统自动调用 Whisper 进行语音识别,并通过 AI 分析视频内容</div>
+            <div><strong>步骤2:</strong>系统自动识别视频内容并整理文字稿</div>
 
-            <div><strong>步骤3:</strong>AI 根据转录文字稿自动生成分镜脚本,可手动编辑调整</div>
+            <div><strong>步骤3:</strong>根据文字稿自动生成分镜脚本,可手动编辑调整</div>
 
-            <div><strong>步骤4:</strong>使用即梦 AI 为每个分镜自动生成配图</div>
+            <div><strong>步骤4:</strong>为每个分镜自动生成画面</div>
 
-            <div><strong>步骤5:</strong>选择系统音色或克隆音色,为旁白自动合成语音</div>
+            <div><strong>步骤5:</strong>选择声音模型,为旁白自动合成语音</div>
 
-            <div><strong>步骤6:</strong>将配图与配音合成为最终视频(需要 ffmpeg)</div>
+            <div><strong>步骤6:</strong>将画面与配音合成为最终视频</div>
 
           </div>
 
@@ -6186,6 +6132,14 @@
     (expandedChange)="assistantExpanded = $event">
   </app-assistant>
 
+  <app-auth-required-modal
+    [open]="authModalOpen"
+    [featureName]="authModalFeature"
+    (close)="authModalOpen = false"
+    (login)="openAuth('login')"
+    (register)="openAuth('register')">
+  </app-auth-required-modal>
+
   <!-- Toast 通知 -->
 
   <div class="toast-container" *ngIf="toastVisible" [ngClass]="['toast-' + toastType]" (click)="toastVisible = false">

+ 249 - 7
src/app/app.ts

@@ -59,6 +59,17 @@ import { RecentTaskPillComponent, RecentTaskView } from './components/recent-tas
 import { AppAssistantComponent } from './components/app-assistant/app-assistant.component';
 import { AssistantSuggestion } from './services/assistant.service';
 import { OFFICIAL_VOICE_LIBRARY, VoicePickerComponent, VoiceSelection } from './components/voice-picker/voice-picker.component';
+import { AuthCreditService } from './services/auth-credit.service';
+import { AuthPageComponent } from './pages/account/auth-page.component';
+import { UserCenterComponent } from './pages/account/user-center.component';
+import { AdminUsersComponent } from './pages/account/admin-users.component';
+import { AuthRequiredModalComponent } from './components/auth-required-modal/auth-required-modal.component';
+import { VoiceApiService } from './services/voice-api.service';
+import { LocalStorageDraftStorage } from './services/draft-storage';
+import { CostEstimatorService } from './services/cost-estimator.service';
+import { GenerationTaskService } from './services/generation-task.service';
+import { GenerationTaskCost, GenerationTaskStatus, GenerationTaskStep } from './models/generation-task.model';
+import { userFailureSuggestion, userFriendlyError } from './services/user-message.util';
 
 
 
@@ -138,6 +149,36 @@ interface Task {
 
   error_message?: string;
 
+  failureSuggestion?: string;
+
+  retryable?: boolean;
+
+  recoverable?: boolean;
+
+  retryCount?: number;
+
+  lastErrorAt?: string;
+
+  generationStatus?: GenerationTaskStatus;
+
+  estimatedCredits?: number;
+
+  reservedCredits?: number;
+
+  creditReservationId?: string;
+
+  creditStatus?: GenerationTaskCost['status'];
+
+  operation?: string;
+
+  pipelineId?: string;
+
+  externalTaskIds?: Record<string, string>;
+
+  steps?: GenerationTaskStep[];
+
+  snapshot?: any;
+
   original_video_title?: string;
 
   duration?: string;
@@ -684,7 +725,15 @@ interface MonitorAuthor {
 
     AppAssistantComponent,
 
-    VoicePickerComponent
+    VoicePickerComponent,
+
+    AuthPageComponent,
+
+    UserCenterComponent,
+
+    AdminUsersComponent,
+
+    AuthRequiredModalComponent
 
   ],
 
@@ -703,6 +752,10 @@ interface MonitorAuthor {
 export class App {
 
   private pipelineSession = inject(PipelineSessionService);
+  readonly auth = inject(AuthCreditService);
+  private readonly voiceApi = inject(VoiceApiService);
+  private readonly costEstimator = inject(CostEstimatorService);
+  private readonly generationTasks = inject(GenerationTaskService);
 
 
   protected readonly title = signal('抖音AI视频生成系统');
@@ -711,13 +764,18 @@ export class App {
 
   // 当前激活的标签页
 
-  currentTab: 'home' | 'search' | 'monitor' | 'tasks' | 'history' | 'results' | 'videos' | 'voice-synthesis' | 'pipelines' | 'digital-human' | 'image-generation' | 'video-generation' | 'image-to-video' | 'action-transfer' | 'asset-remix' | 'topic-to-video' | 'drafts' | 'library' = 'home';
+  currentTab: 'home' | 'search' | 'monitor' | 'tasks' | 'history' | 'results' | 'videos' | 'voice-synthesis' | 'pipelines' | 'digital-human' | 'image-generation' | 'video-generation' | 'image-to-video' | 'action-transfer' | 'asset-remix' | 'topic-to-video' | 'drafts' | 'library' | 'login' | 'register' | 'user-center' | 'admin-users' = 'home';
+
+  authModalOpen = false;
+  authModalFeature = '该功能';
+  authReturnTab: typeof this.currentTab = 'home';
 
   /** Pipeline 注册中心数据,模板中通过 *ngFor 动态渲染 */
   readonly pipelines = PIPELINES;
   readonly sidebarPipelines = getSidebarPipelines();
 
   private readonly TAB_STORAGE_KEY = 'tiktok.currentTab';
+  private readonly AUTH_REQUIRED_TABS = new Set(['drafts', 'library', 'videos', 'monitor']);
 
 
 
@@ -1561,6 +1619,19 @@ export class App {
 
     void this.pipelineSession.bootstrap();
 
+    this.auth.loginRequired$.subscribe((ev) => {
+      this.authModalFeature = ev.featureName;
+      this.authReturnTab = this.currentTab;
+      this.authModalOpen = true;
+      this.cdr.detectChanges();
+    });
+
+    this.auth.session$.subscribe(() => {
+      void this.applyDraftStorageForCurrentUser();
+      this.restorePersistedMonitorState();
+      this.refreshIncompleteMonitorAuthors();
+    });
+
     this.initializeDefaults();
 
     this.restoreTheme();
@@ -2007,7 +2078,7 @@ export class App {
 
       const savedTab = localStorage.getItem(this.TAB_STORAGE_KEY);
 
-      const allowed = ['home','search','monitor','tasks','history','results','videos','voice-synthesis','pipelines','digital-human','image-generation','video-generation','image-to-video','action-transfer','asset-remix','topic-to-video','drafts','library'];
+      const allowed = ['home','search','monitor','tasks','history','results','videos','voice-synthesis','pipelines','digital-human','image-generation','video-generation','image-to-video','action-transfer','asset-remix','topic-to-video','drafts','library','login','register','user-center','admin-users'];
 
       // 旧 tab → 新页面的迁移映射(M4/M5/M6 重组后)
       // 'history' 完整功能被「我的创作」覆盖;'results' 被「素材库」替代。
@@ -2103,7 +2174,16 @@ export class App {
 
 
 
-  setCurrentTab(tab: 'home' | 'search' | 'monitor' | 'tasks' | 'history' | 'results' | 'videos' | 'voice-synthesis' | 'pipelines' | 'digital-human' | 'image-generation' | 'video-generation' | 'image-to-video' | 'action-transfer' | 'asset-remix' | 'topic-to-video' | 'drafts' | 'library'): void {
+  setCurrentTab(tab: 'home' | 'search' | 'monitor' | 'tasks' | 'history' | 'results' | 'videos' | 'voice-synthesis' | 'pipelines' | 'digital-human' | 'image-generation' | 'video-generation' | 'image-to-video' | 'action-transfer' | 'asset-remix' | 'topic-to-video' | 'drafts' | 'library' | 'login' | 'register' | 'user-center' | 'admin-users'): void {
+
+    if (tab === 'admin-users' && !this.auth.isAdmin) {
+      tab = this.auth.isLoggedIn ? 'user-center' : 'login';
+    }
+
+    if (this.requiresAuthenticatedTab(tab) && !this.auth.isLoggedIn) {
+      this.authReturnTab = tab;
+      tab = 'login';
+    }
 
     this.currentTab = tab;
 
@@ -2115,6 +2195,35 @@ export class App {
 
   }
 
+  private requiresAuthenticatedTab(tab: string): boolean {
+    return this.AUTH_REQUIRED_TABS.has(tab);
+  }
+
+  private async applyDraftStorageForCurrentUser(): Promise<void> {
+    const userId = this.auth.currentUser?.objectId || 'guest';
+    await this.pipelineSession.useStorageAndReload(new LocalStorageDraftStorage(`user.${userId}`));
+  }
+
+  requireLogin(featureName = '该功能'): boolean {
+    if (this.auth.isLoggedIn) return true;
+    this.authModalFeature = featureName;
+    this.authReturnTab = this.currentTab;
+    this.authModalOpen = true;
+    return false;
+  }
+
+  openAuth(mode: 'login' | 'register'): void {
+    this.authModalOpen = false;
+    this.setCurrentTab(mode);
+  }
+
+  onAuthSignedIn(): void {
+    const target = this.authReturnTab === 'login' || this.authReturnTab === 'register'
+      ? 'user-center'
+      : this.authReturnTab;
+    this.setCurrentTab(target as any);
+  }
+
   /** 右下浮动 mini-pill:当前正在跑的任务(取 activeTasks 中第一个 processing/pending) */
   get currentRunningTask(): RecentTaskView | null {
     const running = (this.activeTasks || []).find(
@@ -2250,7 +2359,11 @@ export class App {
 
       next: (tasks) => {
 
-        this.activeTasks = tasks.map(t => ({ ...t, created_at: new Date(t.created_at), updated_at: new Date(t.updated_at) }));
+        this.activeTasks = this.generationTasks.normalizeLegacyTasks(tasks.map(t => ({
+          ...t,
+          created_at: new Date(t.created_at),
+          updated_at: new Date(t.updated_at),
+        })) as Task[]);
 
         this.updateTaskStats();
 
@@ -2268,7 +2381,8 @@ export class App {
 
         console.warn('⚠️ 无法从后端加载任务,使用空列表');
 
-        this.activeTasks = [];
+        this.activeTasks = this.generationTasks.tasks.map(task => this.generationTaskToLegacyTask(task));
+        this.updateTaskStats();
 
         this.refreshView();
 
@@ -2510,6 +2624,101 @@ export class App {
 
 
 
+  private hydrateTaskForGenerationCenter(task: Task): Task {
+    const estimatedCredits = Math.max(0, Math.ceil(Number(task.estimatedCredits ?? task.cost ?? 0)));
+    const normalizedError = task.error_message
+      ? userFriendlyError(task.error_message, task.error_message, this.auth.isAdmin)
+      : undefined;
+    return {
+      ...task,
+      pipelineId: task.pipelineId || task.type || this.inferPipelineIdFromStep(task.step),
+      operation: task.operation || task.type || task.step,
+      generationStatus: task.generationStatus || this.legacyStatusToGenerationStatus(task.status),
+      estimatedCredits,
+      creditStatus: task.creditStatus || (estimatedCredits > 0 ? 'estimated' : 'unknown'),
+      error_message: normalizedError,
+      failureSuggestion: task.failureSuggestion || (task.error_message ? userFailureSuggestion(task.error_message) : undefined),
+      retryable: task.retryable ?? (task.status === 'failed' || task.status === 'timeout'),
+      recoverable: task.recoverable ?? !!task.vgData,
+      steps: task.steps || this.buildTaskSteps(task.step),
+      snapshot: task.snapshot || task.vgData,
+    };
+  }
+
+  private generationTaskToLegacyTask(task: any): Task {
+    return {
+      id: task.id,
+      keyword: task.title,
+      status: this.generationStatusToLegacyStatus(task.status),
+      step: (task.currentStepId || 'generate'),
+      progress: task.progress,
+      cost: task.cost.estimatedCredits || 0,
+      estimatedCredits: task.cost.estimatedCredits || 0,
+      reservedCredits: task.cost.reservedCredits,
+      creditReservationId: task.cost.reservationId,
+      creditStatus: task.cost.status,
+      operation: task.operation,
+      pipelineId: task.pipelineId,
+      generationStatus: task.status,
+      created_at: new Date(task.createdAt),
+      updated_at: new Date(task.updatedAt),
+      result_url: task.resultUrl,
+      error_message: task.errorMessage,
+      failureSuggestion: task.failureSuggestion,
+      retryable: task.retryable,
+      recoverable: task.recoverable,
+      externalTaskIds: task.externalTaskIds,
+      steps: task.steps,
+      snapshot: task.snapshot,
+      type: task.pipelineId === 'video-generation' ? 'video-generation' : task.pipelineId === 'remix' ? 'remix' : undefined,
+    };
+  }
+
+  private legacyStatusToGenerationStatus(status: Task['status']): GenerationTaskStatus {
+    if (status === 'completed') return 'completed';
+    if (status === 'failed' || status === 'timeout') return 'failed';
+    if (status === 'pending') return 'queued';
+    return 'running';
+  }
+
+  private generationStatusToLegacyStatus(status: GenerationTaskStatus): Task['status'] {
+    if (status === 'completed') return 'completed';
+    if (status === 'failed' || status === 'cancelled') return 'failed';
+    if (status === 'queued' || status === 'draft') return 'pending';
+    return 'processing';
+  }
+
+  private inferPipelineIdFromStep(step: string): string {
+    if (String(step).startsWith('vg-')) return 'video-generation';
+    if (step === 'download' || step === 'detail' || step === 'search') return 'douyin';
+    return 'legacy';
+  }
+
+  private buildTaskSteps(currentStep: string): GenerationTaskStep[] {
+    const labels: Record<string, string> = {
+      search: '视频搜索', detail: '详情获取', comments: '评论获取', download: '视频下载',
+      transcribe: '语音转文字', generate: 'AI生成', complete: '完成',
+      'vg-upload': '上传视频', 'vg-analyze': '视频解析', 'vg-script': '脚本生成',
+      'vg-image': '配图生成', 'vg-voice': '语音配音', 'vg-composite': '视频合成',
+    };
+    return Object.entries(labels).map(([id, label]) => ({
+      id, label, status: id === currentStep ? 'running' : 'pending', progress: id === currentStep ? 1 : 0, retryable: false,
+    }));
+  }
+
+  getTaskCreditText(task: Task): string {
+    const estimated = Math.max(0, Math.ceil(Number(task.estimatedCredits ?? task.cost ?? 0)));
+    if (!estimated) return '积分:未估算';
+    const statusText: Record<string, string> = { estimated: '预计', reserved: '已预扣', committed: '已确认', refunded: '已退回', free: '免费', unknown: '预计' };
+    return `${statusText[task.creditStatus || 'estimated'] || '预计'} ${estimated} 积分`;
+  }
+
+  getTaskRecoveryText(task: Task): string {
+    if (task.failureSuggestion) return task.failureSuggestion;
+    if (task.recoverable) return '该任务保留了恢复信息,可从任务管理页继续。';
+    return '该任务未保存完整恢复信息,如需继续建议重新发起。';
+  }
+
   // ==================== 持久化保存方法 ====================
 
 
@@ -2518,6 +2727,9 @@ export class App {
 
   saveTask(task: Task): void {
 
+    task = this.hydrateTaskForGenerationCenter(task);
+    this.generationTasks.upsert(this.generationTasks.legacyToGenerationTask(task));
+
     this.taskSaveReady = new Promise<void>((resolve) => {
 
       this.http.post<any>('/backend/api/tasks', task).subscribe({
@@ -2554,6 +2766,9 @@ export class App {
 
   updateTask(task: Task): void {
 
+    task = this.hydrateTaskForGenerationCenter(task);
+    this.generationTasks.upsert(this.generationTasks.legacyToGenerationTask(task));
+
     this.taskSaveReady.then(() => {
 
       this.http.put<any>(`/backend/api/tasks/${task.id}`, task).subscribe({
@@ -2603,6 +2818,7 @@ export class App {
     // 乐观删除:立即从本地列表移除,UI 立刻更新
 
     this.activeTasks = this.activeTasks.filter(t => t.id !== taskId);
+    this.generationTasks.remove(taskId);
 
     this.canceledTaskIds.add(taskId);
 
@@ -5830,6 +6046,29 @@ export class App {
 
 
 
+  retryTask(task: Task): void {
+
+    if (!task) return;
+
+    if (task.type === 'video-generation' && task.vgData) {
+      task.status = 'processing';
+      task.error_message = undefined;
+      task.failureSuggestion = undefined;
+      task.retryCount = (task.retryCount || 0) + 1;
+      task.updated_at = new Date();
+      this.updateTask(task);
+      this.vgResumeFromTask(task);
+      return;
+    }
+
+    task.retryCount = (task.retryCount || 0) + 1;
+    task.updated_at = new Date();
+    task.failureSuggestion = '当前任务类型暂未接入自动续跑,请从对应生成模式重新发起。';
+    this.updateTask(task);
+    this.showToast(task.failureSuggestion, 'warn');
+  }
+
+
   // 查看任务详情
 
   viewTaskDetail(task: Task): void {
@@ -5858,7 +6097,10 @@ export class App {
 
     task.updated_at = new Date();
 
-    if (task.id) this.canceledTaskIds.add(task.id);
+    if (task.id) {
+      this.canceledTaskIds.add(task.id);
+      this.generationTasks.markCancelled(task.id);
+    }
 
     // 若取消的是当前进行中的视频生成任务,清空 vg 运行状态,防止继续触发新请求
 

+ 0 - 9
src/app/components/app-sidebar/app-sidebar.component.html

@@ -30,13 +30,4 @@
     </li>
   </ol>
 
-  <!-- 底部:主题切换 -->
-  <footer class="t-sidebar__foot">
-    <button type="button" class="t-theme" (click)="themeToggle.emit()"
-            [attr.aria-label]="themeMode === 'day' ? '切换到夜间模式' : '切换到日间模式'">
-      <span class="t-theme__dot" [class.is-night]="themeMode === 'night'"></span>
-      <span class="t-theme__label">{{ themeMode === 'day' ? '日间' : '夜间' }}</span>
-    </button>
-  </footer>
-
 </nav>

+ 30 - 12
src/app/components/app-sidebar/app-sidebar.component.ts

@@ -1,5 +1,6 @@
-import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
 import { CommonModule } from '@angular/common';
+import { AuthCreditService } from '../../services/auth-credit.service';
 
 /**
  * 全局侧栏 — 3 大分区纯字排版
@@ -32,13 +33,13 @@ const ZONES: SidebarZone[] = [
     items: [
       { tab: 'home', label: '首页' },
       { tab: 'pipelines', label: '生成模式中心' },
-      { tab: 'image-generation', label: '图片生成', badge: 'Beta' },
-      { tab: 'topic-to-video', label: '主题生视频', badge: 'Beta' },
-      { tab: 'image-to-video', label: '图生视频', badge: 'Beta' },
+      { tab: 'image-generation', label: '图片生成' },
+      { tab: 'topic-to-video', label: '主题生视频' },
+      { tab: 'image-to-video', label: '图生视频' },
       { tab: 'digital-human', label: '数字人合成' },
-      { tab: 'action-transfer', label: '动作迁移', badge: 'Beta' },
-      { tab: 'asset-remix', label: '素材合成视频', badge: 'Beta' },
-      { tab: 'video-generation', label: '标准视频生成', badge: 'Beta' },
+      { tab: 'action-transfer', label: '动作迁移' },
+      { tab: 'asset-remix', label: '素材合成视频' },
+      { tab: 'video-generation', label: '标准视频生成' },
     ],
   },
   {
@@ -46,8 +47,8 @@ const ZONES: SidebarZone[] = [
     serial: '02',
     label: '资产',
     items: [
-      { tab: 'drafts', label: '我的创作', badge: 'New' },
-      { tab: 'library', label: '素材库', badge: 'New' },
+      { tab: 'drafts', label: '我的创作' },
+      { tab: 'library', label: '素材库' },
       // 老入口保留作为兼容(后续版本可移除)
       { tab: 'videos', label: '视频管理' },
       { tab: 'tasks', label: '任务管理' },
@@ -63,6 +64,15 @@ const ZONES: SidebarZone[] = [
       { tab: 'voice-synthesis', label: '语音合成' },
     ],
   },
+  {
+    key: 'account',
+    serial: '04',
+    label: '账号',
+    items: [
+      { tab: 'user-center', label: '用户中心' },
+      { tab: 'admin-users', label: '账号明细' },
+    ],
+  },
 ];
 
 @Component({
@@ -74,11 +84,19 @@ const ZONES: SidebarZone[] = [
 })
 export class AppSidebarComponent {
   @Input() currentTab: string = '';
-  @Input() themeMode: 'day' | 'night' = 'day';
   @Output() tabChange = new EventEmitter<string>();
-  @Output() themeToggle = new EventEmitter<void>();
 
-  readonly zones = ZONES;
+  private readonly auth = inject(AuthCreditService);
+
+  get zones(): SidebarZone[] {
+    if (this.auth.isAdmin) return ZONES;
+    return ZONES
+      .map((zone) => ({
+        ...zone,
+        items: zone.items.filter((item) => item.tab !== 'admin-users'),
+      }))
+      .filter((zone) => zone.items.length > 0);
+  }
 
   /** trackBy 减少二级渲染时 DOM 重建 */
   trackByZone = (_: number, z: SidebarZone) => z.key;

+ 93 - 0
src/app/components/auth-required-modal/auth-required-modal.component.css

@@ -0,0 +1,93 @@
+.auth-modal-backdrop {
+  position: fixed;
+  inset: 0;
+  z-index: 10000;
+  display: grid;
+  place-items: center;
+  padding: 20px;
+  background: rgba(15, 23, 42, .42);
+  backdrop-filter: blur(8px);
+}
+
+.auth-modal {
+  width: min(420px, 100%);
+  position: relative;
+  border-radius: 8px;
+  background: #fff;
+  border: 1px solid rgba(148, 163, 184, .28);
+  padding: 28px;
+  box-shadow: 0 24px 80px rgba(15, 23, 42, .22);
+}
+
+.auth-modal-close {
+  position: absolute;
+  top: 12px;
+  right: 12px;
+  width: 32px;
+  height: 32px;
+  border: 0;
+  border-radius: 50%;
+  background: #f1f5f9;
+  color: #64748b;
+  cursor: pointer;
+  font-size: 20px;
+}
+
+.auth-modal-icon {
+  width: 44px;
+  height: 44px;
+  border-radius: 8px;
+  display: grid;
+  place-items: center;
+  color: #fff;
+  font-weight: 900;
+  background: linear-gradient(135deg, #2563eb, #06b6d4);
+}
+
+.auth-modal h2 {
+  margin: 16px 0 8px;
+  color: #0f172a;
+  font-size: 22px;
+}
+
+.auth-modal p {
+  margin: 0;
+  color: #64748b;
+  line-height: 1.7;
+}
+
+.auth-modal-actions {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 10px;
+  margin-top: 22px;
+}
+
+.auth-modal-actions button {
+  height: 44px;
+  border-radius: 8px;
+  border: 1px solid #bfdbfe;
+  background: #fff;
+  color: #1d4ed8;
+  font-weight: 800;
+  cursor: pointer;
+}
+
+.auth-modal-actions .auth-modal-primary {
+  border: 0;
+  color: #fff;
+  background: linear-gradient(135deg, #2563eb, #06b6d4);
+}
+
+.theme-night .auth-modal {
+  background: #0f172a;
+  border-color: rgba(148, 163, 184, .22);
+}
+
+.theme-night .auth-modal h2 {
+  color: #e5e7eb;
+}
+
+.theme-night .auth-modal p {
+  color: #94a3b8;
+}

+ 12 - 0
src/app/components/auth-required-modal/auth-required-modal.component.html

@@ -0,0 +1,12 @@
+<div class="auth-modal-backdrop" *ngIf="open" (click)="close.emit()">
+  <section class="auth-modal" role="dialog" aria-modal="true" (click)="$event.stopPropagation()">
+    <button type="button" class="auth-modal-close" aria-label="关闭" (click)="close.emit()">×</button>
+    <div class="auth-modal-icon">AI</div>
+    <h2>需要登录后使用</h2>
+    <p>请先登录账号,再继续使用 {{ featureName }}。查看个人内容不会消耗积分,只有提交生成、合成、训练等任务时才会按规则扣除积分。</p>
+    <div class="auth-modal-actions">
+      <button type="button" class="auth-modal-primary" (click)="login.emit()">登录</button>
+      <button type="button" (click)="close.emit()">稍后再说</button>
+    </div>
+  </section>
+</div>

+ 17 - 0
src/app/components/auth-required-modal/auth-required-modal.component.ts

@@ -0,0 +1,17 @@
+import { CommonModule } from '@angular/common';
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+
+@Component({
+  selector: 'app-auth-required-modal',
+  standalone: true,
+  imports: [CommonModule],
+  templateUrl: './auth-required-modal.component.html',
+  styleUrls: ['./auth-required-modal.component.css'],
+})
+export class AuthRequiredModalComponent {
+  @Input() open = false;
+  @Input() featureName = '该功能';
+  @Output() close = new EventEmitter<void>();
+  @Output() login = new EventEmitter<void>();
+  @Output() register = new EventEmitter<void>();
+}

+ 2 - 24
src/app/components/debug-panel/debug-panel.component.ts

@@ -262,28 +262,6 @@ export class DebugPanelComponent {
   }
 
   async testCORS() {
-    this.corsStatus = '正在测试CORS支持...';
-
-    try {
-      const response = await fetch('https://server.fmode.cn/api/voc-social/douyin/search/fetch_general_search_v2', {
-        method: 'OPTIONS',
-        headers: {
-          'Origin': window.location.origin,
-          'Access-Control-Request-Method': 'POST',
-          'Access-Control-Request-Headers': 'content-type,authorization'
-        }
-      });
-
-      const corsHeaders = {
-        'Access-Control-Allow-Origin': response.headers.get('Access-Control-Allow-Origin'),
-        'Access-Control-Allow-Methods': response.headers.get('Access-Control-Allow-Methods'),
-        'Access-Control-Allow-Headers': response.headers.get('Access-Control-Allow-Headers')
-      };
-
-      this.corsStatus = `CORS预检请求完成\n状态: ${response.status}\n响应头: ${JSON.stringify(corsHeaders, null, 2)}`;
-    } catch (error) {
-      const errorMessage = error instanceof Error ? error.message : '未知错误';
-      this.corsStatus = `CORS测试失败 ❌\n错误: ${errorMessage}\n这可能是导致API调用失败的原因`;
-    }
+    this.corsStatus = '线上版本已通过云函数代理访问抖音服务,无需在浏览器中直接测试上游 CORS。';
   }
-}
+}

+ 6 - 10
src/app/components/voice-picker/voice-picker.component.ts

@@ -1,7 +1,8 @@
 import { Component, EventEmitter, Input, Output, OnChanges, SimpleChanges } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
-import { HttpClient } from '@angular/common/http';
+import { VoiceApiService } from '../../services/voice-api.service';
+import { userFriendlyError } from '../../services/user-message.util';
 
 /** 官方音色库条目 */
 export interface OfficialVoice {
@@ -77,10 +78,6 @@ const VOICE_LIBRARY_SCENES = ['全部', '通用', '角色扮演', '有声阅读'
 /** 用于试听的固定文本 */
 const PREVIEW_TEXT = '青山依旧在,几度夕阳红。';
 
-/** TTS 代理(与 vsStartSynthesize 保持一致) */
-const TTS_API_URL = 'https://server.fmode.cn/api/volcengine/tts/unidirectional';
-const TTS_TOKEN = 'Bearer r:f0333969e312a40e4703e8fe4ed1c600';
-
 @Component({
   selector: 'app-voice-picker',
   standalone: true,
@@ -395,7 +392,7 @@ export class VoicePickerComponent implements OnChanges {
   previewError: string = '';
   private currentAudio: HTMLAudioElement | null = null;
 
-  constructor(private http: HttpClient) {}
+  constructor(private voiceApi: VoiceApiService) {}
 
   ngOnChanges(changes: SimpleChanges): void {
     // 弹窗每次打开时根据已选项回显,并默认聚焦相应 Tab
@@ -483,7 +480,6 @@ export class VoicePickerComponent implements OnChanges {
     this.previewLoadingKey = key;
 
     const body: any = {
-      token: TTS_TOKEN,
       text: PREVIEW_TEXT,
       isStream: false,
       audio_params: { format: 'mp3', sample_rate: 24000 }
@@ -497,19 +493,19 @@ export class VoicePickerComponent implements OnChanges {
       body.timbreId = id;
     }
 
-    this.http.post<any>(TTS_API_URL, body).subscribe({
+    this.voiceApi.synthesize(body).subscribe({
       next: (res) => {
         this.previewLoadingKey = '';
         if (res?.code === 200 && res?.data?.audioUrl) {
           this.previewCache.set(key, res.data.audioUrl);
           this.playAudio(res.data.audioUrl);
         } else {
-          this.previewError = `试听 "${name}" 失败:${res?.error?.message || res?.message || '未知错误'}`;
+          this.previewError = userFriendlyError(res, `试听 "${name}" 失败,请稍后重试。`);
         }
       },
       error: (err) => {
         this.previewLoadingKey = '';
-        this.previewError = `试听 "${name}" 请求失败:${err?.error?.message || err?.message || '网络错误'}`;
+        this.previewError = userFriendlyError(err, `试听 "${name}" 失败,请稍后重试。`);
       }
     });
   }

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

@@ -0,0 +1,75 @@
+export type GenerationTaskStatus = 'draft' | 'queued' | 'running' | 'waiting_external' | 'completed' | 'failed' | 'cancelled';
+
+export type GenerationTaskStepStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
+
+export interface GenerationTaskStep {
+  id: string;
+  label: string;
+  status: GenerationTaskStepStatus;
+  progress: number;
+  errorMessage?: string;
+  startedAt?: number;
+  finishedAt?: number;
+  retryable?: boolean;
+}
+
+export interface GenerationTaskCost {
+  estimatedCredits: number;
+  reservedCredits?: number;
+  reservationId?: string;
+  status: 'estimated' | 'reserved' | 'committed' | 'refunded' | 'free' | 'unknown';
+  refundReason?: string;
+}
+
+export interface GenerationTask {
+  id: string;
+  title: string;
+  pipelineId: string;
+  operation: string;
+  status: GenerationTaskStatus;
+  progress: number;
+  currentStepId: string;
+  steps: GenerationTaskStep[];
+  cost: GenerationTaskCost;
+  createdAt: number;
+  updatedAt: number;
+  resultUrl?: string;
+  errorMessage?: string;
+  failureSuggestion?: string;
+  retryable?: boolean;
+  recoverable?: boolean;
+  externalTaskIds?: Record<string, string>;
+  snapshot?: any;
+}
+
+export interface LegacyTaskLike {
+  id: string;
+  keyword: string;
+  status: string;
+  step: string;
+  progress: number;
+  cost?: number;
+  created_at: Date | string;
+  updated_at: Date | string;
+  result_url?: string;
+  error_message?: string;
+  original_video_title?: string;
+  duration?: string;
+  type?: string;
+  estimatedCredits?: number;
+  reservedCredits?: number;
+  creditReservationId?: string;
+  creditStatus?: GenerationTaskCost['status'];
+  operation?: string;
+  pipelineId?: string;
+  generationStatus?: GenerationTaskStatus;
+  externalTaskIds?: Record<string, string>;
+  failureSuggestion?: string;
+  retryable?: boolean;
+  recoverable?: boolean;
+  lastErrorAt?: string;
+  retryCount?: number;
+  steps?: GenerationTaskStep[];
+  snapshot?: any;
+  vgData?: any;
+}

+ 192 - 0
src/app/pages/account/admin-users.component.css

@@ -0,0 +1,192 @@
+.admin-page {
+  padding: 28px 32px;
+  display: block;
+}
+
+.admin-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 16px;
+  margin-bottom: 18px;
+}
+
+.admin-header h1 {
+  margin: 0;
+  color: #0f172a;
+}
+
+.admin-header p {
+  margin: 6px 0 0;
+  color: #64748b;
+}
+
+.admin-header button,
+.admin-adjust button {
+  height: 36px;
+  border: 1px solid #bfdbfe;
+  border-radius: 8px;
+  background: #fff;
+  color: #1d4ed8;
+  padding: 0 12px;
+  font-weight: 700;
+  cursor: pointer;
+}
+
+.admin-table {
+  background: rgba(255, 255, 255, .94);
+  border: 1px solid rgba(148, 163, 184, .28);
+  border-radius: 8px;
+  overflow: auto;
+}
+
+.admin-create {
+  background: rgba(255, 255, 255, .94);
+  border: 1px solid rgba(148, 163, 184, .28);
+  border-radius: 8px;
+  padding: 18px;
+  margin-bottom: 16px;
+}
+
+.admin-create h2 {
+  margin: 0 0 14px;
+  font-size: 17px;
+  color: #0f172a;
+}
+
+.admin-create-grid {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(120px, 1fr));
+  gap: 10px;
+}
+
+.admin-create-grid input,
+.admin-create-grid select {
+  height: 38px;
+  border: 1px solid #dbeafe;
+  border-radius: 8px;
+  padding: 0 10px;
+  background: #fff;
+}
+
+.admin-create-grid button {
+  height: 38px;
+  border: 0;
+  border-radius: 8px;
+  background: linear-gradient(135deg, #2563eb, #06b6d4);
+  color: #fff;
+  font-weight: 800;
+  cursor: pointer;
+}
+
+.admin-table table {
+  width: 100%;
+  border-collapse: collapse;
+  min-width: 920px;
+}
+
+.admin-table th,
+.admin-table td {
+  padding: 14px 12px;
+  border-bottom: 1px solid #e2e8f0;
+  text-align: left;
+}
+
+.admin-table th {
+  color: #64748b;
+  font-size: 12px;
+}
+
+.admin-table td {
+  color: #334155;
+}
+
+.admin-table strong,
+.admin-table span {
+  display: block;
+}
+
+.admin-table span {
+  margin-top: 3px;
+  color: #94a3b8;
+  font-size: 12px;
+}
+
+.admin-credit {
+  font-weight: 900;
+  color: #2563eb !important;
+}
+
+.admin-adjust {
+  display: grid;
+  grid-template-columns: 90px 1fr auto;
+  gap: 8px;
+}
+
+.admin-adjust input {
+  height: 36px;
+  border: 1px solid #dbeafe;
+  border-radius: 8px;
+  padding: 0 10px;
+}
+
+.admin-error {
+  margin-bottom: 14px;
+  padding: 12px 14px;
+  border-radius: 8px;
+  color: #b91c1c;
+  background: #fef2f2;
+  border: 1px solid #fecaca;
+}
+
+.admin-message {
+  margin-bottom: 14px;
+  padding: 12px 14px;
+  border-radius: 8px;
+  color: #065f46;
+  background: #ecfdf5;
+  border: 1px solid #a7f3d0;
+}
+
+.theme-night .admin-header h1 {
+  color: #e5e7eb;
+}
+
+.theme-night .admin-table {
+  background: rgba(15, 23, 42, .94);
+  border-color: rgba(148, 163, 184, .22);
+}
+
+.theme-night .admin-create {
+  background: rgba(15, 23, 42, .94);
+  border-color: rgba(148, 163, 184, .22);
+}
+
+.theme-night .admin-create h2 {
+  color: #e5e7eb;
+}
+
+.theme-night .admin-create-grid input,
+.theme-night .admin-create-grid select {
+  background: #020617;
+  color: #e5e7eb;
+  border-color: rgba(96, 165, 250, .24);
+}
+
+.theme-night .admin-table td,
+.theme-night .admin-header p {
+  color: #94a3b8;
+}
+
+@media (max-width: 720px) {
+  .admin-page { padding: 18px; }
+  .admin-header { align-items: flex-start; flex-direction: column; }
+}
+
+@media (max-width: 1100px) {
+  .admin-create-grid { grid-template-columns: 1fr 1fr; }
+}
+
+@media (max-width: 640px) {
+  .admin-create-grid { grid-template-columns: 1fr; }
+}

+ 65 - 0
src/app/pages/account/admin-users.component.html

@@ -0,0 +1,65 @@
+<section class="account-page admin-page">
+  <header class="admin-header">
+    <div>
+      <h1>账号明细</h1>
+      <p>创建试运营账号、查看余额并进行隐式积分调整。</p>
+    </div>
+    <button type="button" (click)="reload()" [disabled]="loading">刷新</button>
+  </header>
+
+  <div class="admin-error" *ngIf="error">{{ error }}</div>
+  <div class="admin-message" *ngIf="message">{{ message }}</div>
+  <div class="admin-error" *ngIf="!auth.isAdmin">当前账号没有管理员权限。</div>
+
+  <article class="admin-create" *ngIf="auth.isAdmin">
+    <h2>发放账号</h2>
+    <div class="admin-create-grid">
+      <input placeholder="用户名" [(ngModel)]="createForm.username">
+      <input placeholder="初始密码" [(ngModel)]="createForm.password">
+      <input placeholder="显示名称" [(ngModel)]="createForm.displayName">
+      <input placeholder="邮箱(可选)" [(ngModel)]="createForm.email">
+      <input placeholder="手机号(可选)" [(ngModel)]="createForm.phone">
+      <select [(ngModel)]="createForm.role">
+        <option value="user">普通用户</option>
+        <option value="admin">管理员</option>
+      </select>
+      <input type="number" min="0" placeholder="初始积分" [(ngModel)]="createForm.initialCredits">
+      <button type="button" (click)="createUser()">创建账号</button>
+    </div>
+  </article>
+
+  <article class="admin-table" *ngIf="auth.isAdmin">
+    <table>
+      <thead>
+      <tr>
+        <th>用户</th>
+        <th>邮箱 / 手机</th>
+        <th>角色</th>
+        <th>积分</th>
+        <th>调整</th>
+      </tr>
+      </thead>
+      <tbody>
+      <tr *ngFor="let user of users">
+        <td>
+          <strong>{{ user.displayName || user.username }}</strong>
+          <span *ngIf="auth.isAdmin">{{ user.objectId }}</span>
+        </td>
+        <td>{{ user.email || '-' }} / {{ user.phone || '-' }}</td>
+        <td>{{ user.role }}</td>
+        <td class="admin-credit">{{ user.creditBalance }}</td>
+        <td>
+          <div class="admin-adjust">
+            <input type="number" placeholder="+/- 积分" [(ngModel)]="adjustAmount[user.objectId]">
+            <input placeholder="备注" [(ngModel)]="adjustNote[user.objectId]">
+            <button type="button" (click)="adjust(user)">确认</button>
+          </div>
+        </td>
+      </tr>
+      <tr *ngIf="!users.length">
+        <td colspan="5">暂无用户</td>
+      </tr>
+      </tbody>
+    </table>
+  </article>
+</section>

+ 83 - 0
src/app/pages/account/admin-users.component.ts

@@ -0,0 +1,83 @@
+import { CommonModule } from '@angular/common';
+import { Component, OnInit } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { AdminUserRow, AuthCreditService } from '../../services/auth-credit.service';
+
+@Component({
+  selector: 'app-admin-users',
+  standalone: true,
+  imports: [CommonModule, FormsModule],
+  templateUrl: './admin-users.component.html',
+  styleUrls: ['./admin-users.component.css'],
+})
+export class AdminUsersComponent implements OnInit {
+  users: AdminUserRow[] = [];
+  loading = false;
+  error = '';
+  message = '';
+  adjustAmount: Record<string, number> = {};
+  adjustNote: Record<string, string> = {};
+  createForm = {
+    username: '',
+    password: '',
+    displayName: '',
+    email: '',
+    phone: '',
+    role: 'user' as 'user' | 'admin',
+    initialCredits: 10,
+  };
+
+  constructor(public auth: AuthCreditService) {}
+
+  ngOnInit(): void {
+    this.reload();
+  }
+
+  async reload(): Promise<void> {
+    if (!this.auth.isAdmin) return;
+    this.loading = true;
+    this.error = '';
+    try {
+      this.users = await this.auth.adminListUsers();
+    } catch (e: any) {
+      this.error = e?.message || '加载账号明细失败';
+    } finally {
+      this.loading = false;
+    }
+  }
+
+  async adjust(user: AdminUserRow): Promise<void> {
+    this.error = '';
+    try {
+      const amount = Number(this.adjustAmount[user.objectId] || 0);
+      if (!amount) throw new Error('请输入调整积分');
+      await this.auth.adminAdjustCredit(user.objectId, amount, this.adjustNote[user.objectId] || '管理员调整');
+      this.adjustAmount[user.objectId] = 0;
+      this.adjustNote[user.objectId] = '';
+      await this.reload();
+    } catch (e: any) {
+      this.error = e?.message || '调整失败';
+    }
+  }
+
+  async createUser(): Promise<void> {
+    this.error = '';
+    this.message = '';
+    try {
+      const user = await this.auth.adminCreateUser(this.createForm);
+      this.message = `账号 ${user.username} 已创建`;
+      this.createForm = {
+        username: '',
+        password: '',
+        displayName: '',
+        email: '',
+        phone: '',
+        role: 'user',
+        initialCredits: 10,
+      };
+      await this.reload();
+    } catch (e: any) {
+      this.error = e?.message || '创建账号失败';
+    }
+  }
+}

+ 216 - 0
src/app/pages/account/auth-page.component.css

@@ -0,0 +1,216 @@
+.account-page {
+  min-height: calc(100vh - 64px);
+  padding: 32px;
+  display: grid;
+  place-items: center;
+}
+
+.account-shell {
+  width: min(1040px, 100%);
+  display: grid;
+  grid-template-columns: minmax(280px, .9fr) minmax(320px, 1fr);
+  gap: 24px;
+  align-items: stretch;
+}
+
+.account-side,
+.account-card {
+  background: rgba(255, 255, 255, .94);
+  border: 1px solid rgba(148, 163, 184, .28);
+  border-radius: 8px;
+  box-shadow: 0 20px 60px rgba(15, 23, 42, .08);
+}
+
+.account-side {
+  padding: 32px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  gap: 18px;
+}
+
+.account-mark {
+  width: 48px;
+  height: 48px;
+  border-radius: 8px;
+  display: grid;
+  place-items: center;
+  color: #fff;
+  font-weight: 800;
+  background: linear-gradient(135deg, #2563eb, #06b6d4);
+}
+
+.account-side h1 {
+  margin: 0;
+  font-size: 30px;
+  letter-spacing: 0;
+  color: #0f172a;
+}
+
+.account-side p {
+  margin: 0;
+  line-height: 1.8;
+  color: #64748b;
+}
+
+.account-rate,
+.account-note {
+  border-radius: 8px;
+  padding: 14px 16px;
+  background: #f8fafc;
+  border: 1px solid #e2e8f0;
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+  color: #475569;
+}
+
+.account-note {
+  color: #92400e;
+  background: #fffbeb;
+  border-color: #fde68a;
+}
+
+.account-card {
+  padding: 28px;
+  display: flex;
+  flex-direction: column;
+  gap: 18px;
+}
+
+.account-tabs,
+.account-login-methods {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  padding: 4px;
+  border-radius: 8px;
+  background: #eef2ff;
+}
+
+.account-tabs button,
+.account-login-methods button {
+  border: 0;
+  border-radius: 6px;
+  padding: 12px;
+  background: transparent;
+  color: #64748b;
+  font-weight: 700;
+  cursor: pointer;
+}
+
+.account-tabs button.is-active,
+.account-login-methods button.is-active {
+  background: #fff;
+  color: #1d4ed8;
+  box-shadow: 0 6px 18px rgba(37, 99, 235, .12);
+}
+
+.account-field {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  color: #334155;
+  font-weight: 700;
+  font-size: 13px;
+}
+
+.account-field input {
+  height: 44px;
+  border: 1px solid #dbeafe;
+  border-radius: 8px;
+  padding: 0 14px;
+  font: inherit;
+  color: #0f172a;
+  background: #fff;
+  outline: none;
+}
+
+.account-field input:focus {
+  border-color: #3b82f6;
+  box-shadow: 0 0 0 3px rgba(59, 130, 246, .14);
+}
+
+.account-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 12px;
+}
+
+.account-code-row {
+  display: grid;
+  grid-template-columns: 1fr 132px;
+  gap: 12px;
+  align-items: end;
+}
+
+.account-code-btn {
+  height: 44px;
+  border: 1px solid #bfdbfe;
+  border-radius: 8px;
+  background: #eff6ff;
+  color: #1d4ed8;
+  font-weight: 800;
+  cursor: pointer;
+}
+
+.account-code-btn:disabled {
+  opacity: .55;
+  cursor: not-allowed;
+}
+
+.account-error {
+  padding: 12px 14px;
+  border-radius: 8px;
+  color: #b91c1c;
+  background: #fef2f2;
+  border: 1px solid #fecaca;
+}
+
+.account-submit {
+  height: 48px;
+  border: 0;
+  border-radius: 8px;
+  background: linear-gradient(135deg, #2563eb, #06b6d4);
+  color: #fff;
+  font-weight: 800;
+  cursor: pointer;
+}
+
+.account-submit:disabled {
+  opacity: .6;
+  cursor: not-allowed;
+}
+
+.theme-night .account-side,
+.theme-night .account-card {
+  background: rgba(15, 23, 42, .94);
+  border-color: rgba(148, 163, 184, .22);
+}
+
+.theme-night .account-side h1,
+.theme-night .account-field,
+.theme-night .account-field input {
+  color: #e5e7eb;
+}
+
+.theme-night .account-side p {
+  color: #94a3b8;
+}
+
+.theme-night .account-rate,
+.theme-night .account-tabs {
+  background: rgba(30, 41, 59, .8);
+  border-color: rgba(148, 163, 184, .18);
+}
+
+.theme-night .account-field input {
+  background: #020617;
+  border-color: rgba(96, 165, 250, .24);
+}
+
+@media (max-width: 860px) {
+  .account-page { padding: 18px; }
+  .account-shell { grid-template-columns: 1fr; }
+  .account-grid { grid-template-columns: 1fr; }
+  .account-code-row { grid-template-columns: 1fr; }
+}

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

@@ -0,0 +1,53 @@
+<section class="account-page">
+  <div class="account-shell">
+    <aside class="account-side">
+      <div class="account-mark">AI</div>
+      <h1>登录账号</h1>
+      <p>登录后可查看个人内容,并使用生成、合成、语音和数据功能。积分只会在提交任务前预扣,明确失败时自动退回。</p>
+      <div class="account-rate">
+        <span>积分规则</span>
+        <strong>1 元 = 10 积分</strong>
+      </div>
+    </aside>
+
+    <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">
+            <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>
+
+      <div class="account-error" *ngIf="error">{{ error }}</div>
+
+      <button class="account-submit" type="submit" [disabled]="!canSubmit()">
+        {{ loading ? '处理中...' : '登录' }}
+      </button>
+    </form>
+  </div>
+</section>

+ 128 - 0
src/app/pages/account/auth-page.component.ts

@@ -0,0 +1,128 @@
+import { CommonModule } from '@angular/common';
+import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { AuthCreditService } from '../../services/auth-credit.service';
+
+@Component({
+  selector: 'app-auth-page',
+  standalone: true,
+  imports: [CommonModule, FormsModule],
+  templateUrl: './auth-page.component.html',
+  styleUrls: ['./auth-page.component.css'],
+})
+export class AuthPageComponent implements OnChanges {
+  @Input() mode: 'login' | 'register' = 'login';
+  @Input() allowRegister = false;
+  @Output() modeChange = new EventEmitter<'login' | 'register'>();
+  @Output() signedIn = new EventEmitter<void>();
+
+  loginMethod: 'mobile' | 'password' = 'mobile';
+  mobile = '';
+  code = '';
+  countdown = 0;
+  private countdownTimer: ReturnType<typeof setInterval> | null = null;
+  identifier = '';
+  password = '';
+  username = '';
+  email = '';
+  phone = '';
+  displayName = '';
+  confirmPassword = '';
+  loading = false;
+  error = '';
+
+  constructor(public auth: AuthCreditService) {}
+
+  ngOnDestroy(): void {
+    if (this.countdownTimer) clearInterval(this.countdownTimer);
+  }
+
+  ngOnChanges(changes: SimpleChanges): void {
+    if (!this.allowRegister && this.mode === 'register') {
+      this.mode = 'login';
+    }
+  }
+
+  switchMode(mode: 'login' | 'register'): void {
+    if (mode === 'register' && !this.allowRegister) return;
+    this.mode = mode;
+    this.error = '';
+    this.modeChange.emit(mode);
+  }
+
+  async submit(): Promise<void> {
+    this.error = '';
+    this.loading = true;
+    try {
+      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);
+        }
+      } else {
+        if (!this.allowRegister) throw new Error('试运营阶段暂未开放自助注册,请联系管理员获取账号');
+        if (this.password !== this.confirmPassword) throw new Error('两次输入的密码不一致');
+        await this.auth.register({
+          username: this.username,
+          password: this.password,
+          email: this.email,
+          phone: this.phone,
+          displayName: this.displayName,
+        });
+      }
+      this.signedIn.emit();
+    } catch (e: any) {
+      this.error = e?.message || '操作失败';
+    } finally {
+      this.loading = false;
+    }
+  }
+
+  normalizeMobile(): void {
+    this.mobile = this.mobile.replace(/\D/g, '').slice(0, 11);
+  }
+
+  canSendCode(): boolean {
+    return /^1[3-9]\d{9}$/.test(this.mobile) && !this.loading && this.countdown <= 0;
+  }
+
+  canSubmit(): boolean {
+    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;
+  }
+
+  switchLoginMethod(method: 'mobile' | 'password'): void {
+    this.loginMethod = method;
+    this.error = '';
+  }
+
+  async sendCode(): Promise<void> {
+    if (!this.canSendCode()) return;
+    this.error = '';
+    this.loading = true;
+    try {
+      await this.auth.sendMobileCode(this.mobile);
+      this.startCountdown();
+    } catch (e: any) {
+      this.error = e?.message || '验证码发送失败,请稍后重试';
+    } finally {
+      this.loading = false;
+    }
+  }
+
+  private startCountdown(): void {
+    this.countdown = 60;
+    if (this.countdownTimer) clearInterval(this.countdownTimer);
+    this.countdownTimer = setInterval(() => {
+      this.countdown -= 1;
+      if (this.countdown <= 0 && this.countdownTimer) {
+        clearInterval(this.countdownTimer);
+        this.countdownTimer = null;
+      }
+    }, 1000);
+  }
+}

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

@@ -0,0 +1,203 @@
+.account-dashboard {
+  padding: 28px 32px 40px;
+  display: block;
+}
+
+.uc-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  margin-bottom: 22px;
+  padding: 4px 0;
+}
+
+.uc-header h1 {
+  margin: 0;
+  font-size: 28px;
+  color: #0f172a;
+}
+
+.uc-header p {
+  margin: 6px 0 0;
+  color: #64748b;
+}
+
+.uc-actions {
+  display: flex;
+  gap: 10px;
+  flex-wrap: wrap;
+}
+
+.uc-actions button,
+.uc-card button {
+  border: 1px solid #bfdbfe;
+  border-radius: 8px;
+  background: #fff;
+  color: #1d4ed8;
+  height: 38px;
+  padding: 0 14px;
+  font-weight: 700;
+  cursor: pointer;
+}
+
+.uc-grid {
+  display: grid;
+  grid-template-columns: minmax(280px, .82fr) minmax(420px, 1.18fr);
+  gap: 16px;
+  margin-bottom: 16px;
+  align-items: stretch;
+}
+
+.uc-card {
+  background: rgba(255, 255, 255, .94);
+  border: 1px solid rgba(148, 163, 184, .28);
+  border-radius: 8px;
+  padding: 22px;
+  box-shadow: 0 16px 48px rgba(15, 23, 42, .06);
+}
+
+.uc-grid .uc-card {
+  min-height: 190px;
+}
+
+.uc-card h2 {
+  margin: 0 0 14px;
+  font-size: 17px;
+  color: #0f172a;
+}
+
+.uc-balance span {
+  color: #64748b;
+  font-weight: 700;
+}
+
+.uc-balance strong {
+  display: block;
+  margin-top: 8px;
+  font-size: 46px;
+  color: #2563eb;
+  line-height: 1;
+}
+
+.uc-balance p,
+.uc-order {
+  color: #64748b;
+}
+
+.uc-balance {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+}
+
+.uc-recharge,
+.uc-password {
+  display: grid;
+  gap: 12px;
+}
+
+.uc-recharge label {
+  display: grid;
+  gap: 6px;
+  color: #475569;
+  font-weight: 700;
+  font-size: 13px;
+}
+
+.uc-recharge input,
+.uc-password input {
+  height: 40px;
+  border: 1px solid #dbeafe;
+  border-radius: 8px;
+  padding: 0 12px;
+  font: inherit;
+}
+
+.uc-credit-preview {
+  color: #059669;
+  font-weight: 800;
+  font-size: 16px;
+}
+
+.uc-recharge button {
+  width: 100%;
+}
+
+.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-message,
+.uc-error {
+  margin-bottom: 16px;
+  padding: 12px 14px;
+  border-radius: 8px;
+}
+
+.uc-message {
+  color: #065f46;
+  background: #ecfdf5;
+  border: 1px solid #a7f3d0;
+}
+
+.uc-error {
+  color: #b91c1c;
+  background: #fef2f2;
+  border: 1px solid #fecaca;
+}
+
+.theme-night .uc-header h1,
+.theme-night .uc-card h2 {
+  color: #e5e7eb;
+}
+
+.theme-night .uc-card {
+  background: rgba(15, 23, 42, .94);
+  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-balance p {
+  color: #94a3b8;
+}
+
+@media (max-width: 1100px) {
+  .uc-grid { grid-template-columns: 1fr; }
+}
+
+@media (max-width: 720px) {
+  .account-dashboard { padding: 18px; }
+  .uc-header { align-items: flex-start; flex-direction: column; }
+  .uc-ledger { overflow-x: auto; }
+  .uc-ledger table { min-width: 680px; }
+}

+ 68 - 0
src/app/pages/account/user-center.component.html

@@ -0,0 +1,68 @@
+<section class="account-page account-dashboard">
+  <header class="uc-header">
+    <div>
+      <h1>用户中心</h1>
+      <p>{{ auth.currentUser?.displayName || auth.currentUser?.username }} · {{ auth.currentUser?.role === 'admin' ? '管理员' : '普通用户' }}</p>
+    </div>
+    <div class="uc-actions">
+      <button *ngIf="auth.isAdmin" type="button" (click)="navigate.emit('admin-users')">账号明细</button>
+      <button type="button" (click)="logout()">退出登录</button>
+    </div>
+  </header>
+
+  <div class="uc-grid">
+    <article class="uc-card uc-balance">
+      <span>当前积分</span>
+      <strong>{{ balance?.balance ?? 0 }}</strong>
+      <p>赠送 {{ balance?.gifted ?? 0 }},累计消费 {{ balance?.totalConsumed ?? 0 }}</p>
+    </article>
+
+    <article class="uc-card">
+      <h2>充值</h2>
+      <div class="uc-recharge">
+        <label>
+          <span>充值金额(元)</span>
+          <input type="number" min="1" step="1" [(ngModel)]="rechargeAmount">
+        </label>
+        <div class="uc-credit-preview">到账 {{ (rechargeAmount || 0) * 10 }} 积分</div>
+        <button type="button" (click)="createRecharge()">创建充值订单</button>
+      </div>
+      <div class="uc-order" *ngIf="pendingOrder">
+        订单 {{ pendingOrder.objectId }} · {{ pendingOrder.creditAmount }} 积分 · {{ pendingOrder.status === 'pending' ? '待支付' : pendingOrder.status }}
+      </div>
+    </article>
+  </div>
+
+  <div class="uc-message" *ngIf="message">{{ message }}</div>
+  <div class="uc-error" *ngIf="error">{{ error }}</div>
+
+  <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>

+ 78 - 0
src/app/pages/account/user-center.component.ts

@@ -0,0 +1,78 @@
+import { CommonModule } from '@angular/common';
+import { Component, EventEmitter, OnInit, Output } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { AuthCreditService, CreditBalance, CreditLedgerItem, RechargeOrder } from '../../services/auth-credit.service';
+
+@Component({
+  selector: 'app-user-center',
+  standalone: true,
+  imports: [CommonModule, FormsModule],
+  templateUrl: './user-center.component.html',
+  styleUrls: ['./user-center.component.css'],
+})
+export class UserCenterComponent implements OnInit {
+  @Output() navigate = new EventEmitter<string>();
+
+  balance: CreditBalance | null = null;
+  ledger: CreditLedgerItem[] = [];
+  rechargeAmount = 10;
+  pendingOrder: RechargeOrder | null = null;
+  passwordForm = { oldPassword: '', newPassword: '', confirmPassword: '' };
+  loading = false;
+  message = '';
+  error = '';
+
+  constructor(public auth: AuthCreditService) {}
+
+  ngOnInit(): void {
+    this.reload();
+  }
+
+  async reload(): Promise<void> {
+    if (!this.auth.isLoggedIn) return;
+    this.loading = true;
+    this.error = '';
+    try {
+      this.balance = await this.auth.getBalance();
+      this.ledger = await this.auth.getLedger(80);
+    } catch (e: any) {
+      this.error = e?.message || '加载用户信息失败';
+    } finally {
+      this.loading = false;
+    }
+  }
+
+  async createRecharge(): Promise<void> {
+    this.message = '';
+    this.error = '';
+    try {
+      const amount = Number(this.rechargeAmount || 0);
+      if (amount <= 0) throw new Error('请输入有效充值金额');
+      this.pendingOrder = await this.auth.createRechargeOrder(amount);
+      this.message = '充值订单已创建,支付接口接入后将在这里展示支付状态。';
+      await this.reload();
+    } catch (e: any) {
+      this.error = e?.message || '创建充值订单失败';
+    }
+  }
+
+  async changePassword(): Promise<void> {
+    this.message = '';
+    this.error = '';
+    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 = '密码已更新';
+    } catch (e: any) {
+      this.error = e?.message || '修改密码失败';
+    }
+  }
+
+  logout(): void {
+    this.auth.logout();
+    this.navigate.emit('home');
+  }
+}

+ 1 - 3
src/app/pages/drafts/drafts.component.css

@@ -1,7 +1,5 @@
 /* 我的创作页 */
 
-.drafts-page { padding: 0 0 32px; }
-
 /* ========= 工具栏 ========= */
 .drafts-toolbar {
   display: flex;
@@ -102,7 +100,7 @@
   grid-template-columns: 380px 1fr;
   gap: 16px;
   align-items: stretch;
-  min-height: calc(100vh - 280px);
+  min-height: 520px;
 }
 
 @media (max-width: 1100px) {

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

@@ -1,7 +1,5 @@
 /* 素材库页 */
 
-.library-page { padding: 0 0 32px; }
-
 .library-refresh {
   margin-left: auto;
   display: inline-flex;

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

@@ -17,7 +17,7 @@
       </svg>
     </div>
     <div class="dh-header-text">
-      <h2>动作迁移 <span class="pl-pill">即梦 Actor v2</span></h2>
+      <h2>动作迁移 <span class="pl-pill">角色动作</span></h2>
       <p>上传一张角色图片和一段参考动作视频,让角色按视频中的动作/表情/口型动起来。</p>
     </div>
   </header>

+ 4 - 3
src/app/pages/pipelines/action-transfer/action-transfer.component.ts

@@ -8,6 +8,7 @@ import { PipelineSessionService } from '../../../services/pipeline-session.servi
 import { QiniuUploadService } from '../../../services/qiniu-upload.service';
 import { SessionBarComponent } from '../../../components/session-bar/session-bar.component';
 import { DraftMeta } from '../../../models/pipeline-draft.model';
+import { userFriendlyError } from '../../../services/user-message.util';
 
 interface UploadedAsset {
   url: string;
@@ -193,7 +194,7 @@ export class ActionTransferComponent implements OnInit, OnDestroy {
         }
         const url = event.url || '';
         if (!url) {
-          this.uploadError = '涓婁紶澶辫触锛氭湭杩斿洖 URL';
+          this.uploadError = '上传失败,请检查网络后重试';
           this.uploadingSlot = null;
           this.cdr.detectChanges();
           return;
@@ -229,7 +230,7 @@ export class ActionTransferComponent implements OnInit, OnDestroy {
         this.session.patch({ snapshot: this.toSnapshot() });
       },
       error: (err) => {
-        this.uploadError = err?.message || '上传失败';
+        this.uploadError = userFriendlyError(err, '素材上传失败,请检查网络后重试');
         this.uploadingSlot = null;
         this.cdr.detectChanges();
       },
@@ -300,7 +301,7 @@ export class ActionTransferComponent implements OnInit, OnDestroy {
         error: (err) => {
           this.zone.run(() => {
             console.error('[action-transfer] error', err);
-            this.errorMsg = err?.message || '生成失败';
+            this.errorMsg = userFriendlyError(err, '动作视频生成失败,请稍后重试');
             this.statusText = '';
             this.generating = false;
             this.cdr.detectChanges();

+ 7 - 7
src/app/pages/pipelines/asset-remix/asset-remix.component.html

@@ -17,8 +17,8 @@
       </svg>
     </div>
     <div class="dh-header-text">
-      <h2>素材合成视频 <span class="pl-pill">MVP</span></h2>
-      <p>上传多张图片 → AI 生成口播文案 → 火山 TTS 配音 → ffmpeg 拼接成片。</p>
+      <h2>素材合成视频</h2>
+      <p>上传多张图片和主题,自动生成讲解文案、配音并合成视频。</p>
     </div>
   </header>
 
@@ -78,7 +78,7 @@
       </ul>
     </div>
 
-    <!-- 中:主题 + 文案 + 音色 -->
+    <!-- 中:主题 + 文案 + 声音模型 -->
     <div class="dh-step-col">
       <div class="dh-step-col__head">
         <span class="dh-step-col__step">Step 2</span>
@@ -183,7 +183,7 @@
       </div>
 
       <div class="dh-substep">
-        <div class="dh-substep__head"><h4>音色</h4></div>
+        <div class="dh-substep__head"><h4>声音模型</h4></div>
 
         <button type="button" class="vs-voice-picker-trigger"
                 [class.is-empty]="!currentVoiceSelection"
@@ -197,7 +197,7 @@
             <span class="vs-voice-picker-info">
               <span class="vs-voice-picker-name">{{currentVoiceSelection.name}}</span>
               <span class="vs-voice-picker-src">
-                {{currentVoiceSelection.source === 'official' ? '音库' : '我的音'}}
+                {{currentVoiceSelection.source === 'official' ? '音库' : '我的音'}}
               </span>
             </span>
           </ng-container>
@@ -205,7 +205,7 @@
             <span class="vs-voice-picker-avatar vs-voice-picker-avatar--empty">+</span>
             <span class="vs-voice-picker-info">
               <span class="vs-voice-picker-name">请选择声音模型</span>
-              <span class="vs-voice-picker-src">点击打开音色库 / 我的音色</span>
+              <span class="vs-voice-picker-src">点击选择声音库 / 我的声音</span>
             </span>
           </ng-template>
           <span class="vs-voice-picker-chevron">›</span>
@@ -268,7 +268,7 @@
           </div>
         </div>
 
-        <!-- 每段 TTS 状态明细 -->
+        <!-- 每段语音状态明细 -->
         <ul class="ar-tts-status-list" *ngIf="images.length">
           <li *ngFor="let img of images; let i = index"
               class="ar-tts-status-item"

+ 50 - 34
src/app/pages/pipelines/asset-remix/asset-remix.component.ts

@@ -15,6 +15,8 @@ import { QiniuUploadService } from '../../../services/qiniu-upload.service';
 import { SessionBarComponent } from '../../../components/session-bar/session-bar.component';
 import { DraftMeta } from '../../../models/pipeline-draft.model';
 import { OFFICIAL_VOICE_LIBRARY, VoicePickerComponent, VoiceSelection } from '../../../components/voice-picker/voice-picker.component';
+import { userFriendlyError } from '../../../services/user-message.util';
+import { VoiceApiService } from '../../../services/voice-api.service';
 
 interface UploadedImage {
   url: string;          // 七牛云公网 URL
@@ -129,9 +131,6 @@ export class AssetRemixComponent implements OnInit, OnDestroy {
   errorMsg = '';
 
   // ====== 常量 ======
-  private readonly voiceApiToken = 'Bearer r:f0333969e312a40e4703e8fe4ed1c600';
-  private readonly voiceTtsUrl = 'https://server.fmode.cn/api/volcengine/tts/unidirectional';
-
   readonly stylePresets: { value: AssetRemixComponent['style']; label: string }[] = [
     { value: 'documentary', label: '纪实风' },
     { value: 'energetic', label: '激情风' },
@@ -163,6 +162,7 @@ export class AssetRemixComponent implements OnInit, OnDestroy {
     private results: ResultsService,
     public session: PipelineSessionService,
     private browserFfmpeg: BrowserFfmpegService,
+    private voiceApi: VoiceApiService,
   ) {}
 
   ngOnInit(): void {
@@ -354,7 +354,7 @@ export class AssetRemixComponent implements OnInit, OnDestroy {
         error: (err) => {
           this.zone.run(() => {
             console.error('[asset-remix] upload', err);
-            this.uploadError = `${file.name} 上传失败:${err?.error?.error || err?.message}`;
+            this.uploadError = `${file.name} ${userFriendlyError(err, '上传失败,请检查网络后重试')}`;
             URL.revokeObjectURL(preview);
             delete this.uploadProgress[placeholderKey];
             if (--remaining === 0) this.uploading = false;
@@ -446,7 +446,7 @@ export class AssetRemixComponent implements OnInit, OnDestroy {
         next: (text) => this.zone.run(() => this.applyScriptsFromText(text, '视觉模型')),
         error: (err) => this.zone.run(() => {
           this.generatingScripts = false;
-          this.scriptError = err?.message || 'Gemini 视觉生成失败';
+          this.scriptError = userFriendlyError(err, '文案生成失败,请稍后重试');
           this.cdr.detectChanges();
         }),
       });
@@ -459,7 +459,7 @@ export class AssetRemixComponent implements OnInit, OnDestroy {
         }),
         error: (err) => this.zone.run(() => {
           this.generatingScripts = false;
-          this.scriptError = err?.message || 'AI 生成失败';
+          this.scriptError = userFriendlyError(err, '文案生成失败,请稍后重试');
           this.cdr.detectChanges();
         }),
       });
@@ -471,7 +471,7 @@ export class AssetRemixComponent implements OnInit, OnDestroy {
     this.generatingScripts = false;
     const arr = this.extractJsonArray(text);
     if (!arr || !Array.isArray(arr)) {
-      this.scriptError = `解析 ${pathLabel} 输出失败:未识别到 JSON 数组`;
+      this.scriptError = '文案结果格式异常,请重新生成一次';
       console.warn('[asset-remix] AI 原始返回:', text);
       this.cdr.detectChanges();
       return;
@@ -649,7 +649,7 @@ ${text}
 
   private handleRefineError(err: any): void {
     this.refineLoading = false;
-    const msg = err?.message || err?.error?.message || '精修失败';
+    const msg = userFriendlyError(err, '文案精修失败,请稍后重试');
     this.refineMessages.push({ role: 'assistant', content: '❌ ' + msg, ts: Date.now() });
     this.refineError = msg;
     this.cdr.detectChanges();
@@ -796,7 +796,6 @@ ${text}
     img.ttsAttempts = 0;
     // 关键:系统音色用 speaker_id,克隆音色用 timbreId(上游字段名不同)
     const payload: any = {
-      token: this.voiceApiToken,
       text: img.script.trim(),
       isStream: false,
       audio_params: { format: 'mp3', sample_rate: 24000, speech_rate: 0, loudness_rate: 0 },
@@ -808,8 +807,8 @@ ${text}
     } else {
       payload.timbreId = this.timbreId.trim();
     }
-    return this.http
-      .post<any>(this.voiceTtsUrl, payload)
+    return this.voiceApi
+      .synthesize(payload)
       .pipe(
         map((res) => {
           const url = res?.data?.audioUrl;
@@ -845,17 +844,18 @@ ${text}
           img.audioUrl = url;
           img.ttsState = 'done';
           img.ttsError = undefined;
-          // TTS 产物入会话
-          this.session.ensureActive('asset-remix', () => this.toSnapshot(), this.deriveTitle());
-          this.session.upsertArtifact(
-            (a) => a.type === 'audio' && a.extras?.['imageUrl'] === img.url,
-            {
-              type: 'audio', url, sceneIndex: idx,
-              title: `素材 ${idx + 1} - TTS音频`,
-              extras: { imageUrl: img.url, voiceMode: this.voiceMode, voiceId: this.activeVoiceId },
-            },
-          );
-          this.session.patch({ snapshot: this.toSnapshot() });
+          this.deferSessionWrite(() => {
+            this.session.ensureActive('asset-remix', () => this.toSnapshot(), this.deriveTitle());
+            this.session.upsertArtifact(
+              (a) => a.type === 'audio' && a.extras?.['imageUrl'] === img.url,
+              {
+                type: 'audio', url, sceneIndex: idx,
+                title: `素材 ${idx + 1} - 语音音频`,
+                extras: { imageUrl: img.url, voiceMode: this.voiceMode, voiceId: this.activeVoiceId },
+              },
+            );
+            this.session.patch({ snapshot: this.toSnapshot() });
+          });
           return img;
         }),
         catchError((err) => {
@@ -866,8 +866,7 @@ ${text}
             err?.error?.message ||
             err?.message ||
             'TTS 失败';
-          const status = err?.status ? `HTTP ${err.status} · ` : '';
-          img.ttsError = `${status}${this.readErrorText(err, detail)}(已重试 ${img.ttsAttempts || 0} 次)`;
+          img.ttsError = `${userFriendlyError(err, detail)}(已重试 ${img.ttsAttempts || 0} 次)`;
           console.error(`[asset-remix] TTS 段 ${idx + 1} 失败`, err);
           return of(img);
         }),
@@ -942,6 +941,14 @@ ${text}
       });
   }
 
+  private deferSessionWrite(write: () => void): void {
+    setTimeout(() => {
+      this.zone.run(() => {
+        write();
+      });
+    }, 0);
+  }
+
   // ============== 合成视频 ==============
   get canCompose(): boolean {
     return (
@@ -998,7 +1005,9 @@ ${text}
           // 浏览器端合成后七牛 CDN 返回完整 https URL,直接用
           this.resultVideoUrl = res.videoUrl;
           this.compositeProgress = 100;
-          this.compositeStatus = `合成完成(${res.segments} 段,${this.formatSize(res.size || 0)})`;
+          this.compositeStatus = res.persistent === false
+            ? `合成完成,但云端上传失败:${res.uploadError || '请先下载本地临时视频'}`
+            : `合成完成(${res.segments} 段,${this.formatSize(res.size || 0)})`;
           this.cdr.detectChanges();
           const composeExtras = {
             segments: res.segments,
@@ -1007,22 +1016,29 @@ ${text}
             style: this.style,
             voiceMode: this.voiceMode,
             voiceId: this.activeVoiceId,
+            persistent: res.persistent !== false,
+            uploadError: res.uploadError || '',
           };
           this.session.patch({ snapshot: this.toSnapshot() });
-          this.session.finalize(this.resultVideoUrl, composeExtras);
-          this.results.saveResult({
-            type: 'video',
-            url: this.resultVideoUrl,
-            title: this.topic.trim() || '素材合成视频',
-            pipelineId: 'asset_remix',
-            extras: composeExtras,
-          }).subscribe();
+          if (res.persistent === false) {
+            this.errorMsg = '视频已在本机浏览器合成完成,但上传云端失败。请先使用预览区下载按钮保存本地视频,稍后可重试合成上传。';
+            this.session.fail(this.errorMsg);
+          } else {
+            this.session.finalize(this.resultVideoUrl, composeExtras);
+            this.results.saveResult({
+              type: 'video',
+              url: this.resultVideoUrl,
+              title: this.topic.trim() || '素材合成视频',
+              pipelineId: 'asset_remix',
+              extras: composeExtras,
+            }).subscribe();
+          }
         });
       })
       .catch((err) => {
         this.zone.run(() => {
           this.compositing = false;
-          this.errorMsg = err?.message || '合成失败';
+          this.errorMsg = userFriendlyError(err, '视频合成失败,请稍后重试');
           this.cdr.detectChanges();
           this.session.fail(this.errorMsg);
         });

+ 3 - 2
src/app/pages/pipelines/image-generation/image-generation.component.ts

@@ -8,6 +8,7 @@ import { ResultsService } from '../../../services/results.service';
 import { PipelineSessionService } from '../../../services/pipeline-session.service';
 import { SessionBarComponent } from '../../../components/session-bar/session-bar.component';
 import { DraftMeta } from '../../../models/pipeline-draft.model';
+import { userFriendlyError } from '../../../services/user-message.util';
 
 type ImageAspect = '1:1' | '4:3' | '3:4' | '16:9' | '9:16' | '21:9';
 
@@ -231,7 +232,7 @@ export class ImageGenerationComponent implements OnInit, OnDestroy {
         this.cdr.detectChanges();
       },
       error: (err) => {
-        this.uploadError = err?.error?.error || err?.message || '参考图上传失败';
+        this.uploadError = userFriendlyError(err, '参考图上传失败,请检查网络后重试');
         this.uploading = false;
         this.cdr.detectChanges();
       },
@@ -298,7 +299,7 @@ export class ImageGenerationComponent implements OnInit, OnDestroy {
   private handleGenerateError(err: any): void {
     this.generating = false;
     this.progress = 0;
-    this.errorMsg = err?.error?.error?.message || err?.error?.message || err?.message || '图片生成失败';
+    this.errorMsg = userFriendlyError(err, '图片生成失败,请稍后重试');
     this.session.fail(this.errorMsg);
     this.cdr.detectChanges();
   }

+ 3 - 2
src/app/pages/pipelines/image-to-video/image-to-video.component.ts

@@ -14,6 +14,7 @@ import { PipelineSessionService } from '../../../services/pipeline-session.servi
 import { QiniuUploadService } from '../../../services/qiniu-upload.service';
 import { SessionBarComponent } from '../../../components/session-bar/session-bar.component';
 import { DraftMeta } from '../../../models/pipeline-draft.model';
+import { userFriendlyError } from '../../../services/user-message.util';
 
 interface CameraTemplateOption {
   id: JimengCameraTemplateId;
@@ -268,7 +269,7 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
       },
       error: (err) => {
         console.error('[i2v] upload error', err);
-        this.uploadError = err?.error?.error || err?.message || '上传失败';
+        this.uploadError = userFriendlyError(err, '图片上传失败,请检查网络后重试');
         this.uploadingSlot = null;
         this.cdr.detectChanges();
       },
@@ -391,7 +392,7 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
         error: (err) => {
           this.zone.run(() => {
             console.error('[i2v] generate error', err);
-            this.errorMsg = err?.message || '生成失败';
+            this.errorMsg = userFriendlyError(err, '视频生成失败,请稍后重试');
             this.statusText = '';
             this.generating = false;
             this.cdr.detectChanges();

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

@@ -16,7 +16,7 @@
       </svg>
     </div>
     <div class="dh-header-text">
-      <h2>主题生视频 <span class="pl-pill">快速 / 手动 · LLM + 即梦 + ffmpeg</span></h2>
+      <h2>主题生视频 <span class="pl-pill">快速 / 手动</span></h2>
       <p>快速:一句话主题 → AI 拆分镜 → 配图 → 可选动态视频 → 拼接成片。手动:逐卡填 prompt、上传或 AI 生成首尾帧、单段图生视频,最后一键拼接。</p>
     </div>
   </header>
@@ -124,7 +124,7 @@
         <div class="t2v-bible-refs" *ngIf="continuityEnabled">
           <div class="t2v-bible-refs__head">
             <span class="t2v-bible-refs__title">参考图(可选)</span>
-            <span class="t2v-tip">上传后即梦会作为生成参考;分镜若关闭对应继承则不传</span>
+            <span class="t2v-tip">上传后会作为画面参考;可在分镜中单独调整是否使用</span>
           </div>
           <div class="t2v-bible-refs__grid">
             <ng-container *ngFor="let f of bibleRefSlots">
@@ -162,7 +162,7 @@
       <div class="dh-substep" *ngIf="quickMode">
         <div class="dh-substep__head">
           <h4>画面类型</h4>
-          <span class="t2v-tip" *ngIf="mediaMode === 'video'">每段会额外调用即梦图生视频</span>
+          <span class="t2v-tip" *ngIf="mediaMode === 'video'">每段画面会生成动态视频</span>
         </div>
         <div class="dh-segment">
           <button class="dh-segment__item"
@@ -587,17 +587,17 @@
               [class.is-active]="stage === 'imaging'"
               [class.is-done]="['videoing','composing','done'].includes(stage)">
             <span class="t2v-stage-list__dot"></span>
-            <span>🖼️ 即梦文生图({{ imageProgress }}%)</span>
+            <span>🖼️ 生成画面({{ imageProgress }}%)</span>
           </li>
           <li *ngIf="quickMode && mediaMode === 'video'"
               [class.is-active]="stage === 'videoing'"
               [class.is-done]="['composing','done'].includes(stage)">
             <span class="t2v-stage-list__dot"></span>
-            <span>🎞️ 即梦图生视频({{ videoProgress }}%)</span>
+            <span>🎞️ 生成动态片段({{ videoProgress }}%)</span>
           </li>
           <li [class.is-active]="stage === 'composing'" [class.is-done]="stage === 'done'">
             <span class="t2v-stage-list__dot"></span>
-            <span>🎬 ffmpeg 合成({{ composeProgress }}%)</span>
+            <span>🎬 合成视频({{ composeProgress }}%)</span>
           </li>
         </ul>
       </div>

+ 31 - 22
src/app/pages/pipelines/topic-to-video/topic-to-video.component.ts

@@ -19,6 +19,7 @@ import { PipelineSessionService } from '../../../services/pipeline-session.servi
 import { QiniuUploadService } from '../../../services/qiniu-upload.service';
 import { SessionBarComponent } from '../../../components/session-bar/session-bar.component';
 import { DraftMeta } from '../../../models/pipeline-draft.model';
+import { userFriendlyError } from '../../../services/user-message.util';
 
 interface SceneSegment {
   /** 画面描述(英文 prompt);快速模式由 LLM 生成,手动模式由用户填入 */
@@ -271,7 +272,7 @@ export class TopicToVideoComponent implements OnInit, OnDestroy {
       error: (err) => {
         this.zone.run(() => {
           this.bibleImageUploading[field] = false;
-          this.bibleImageError = err?.message || '参考图上传失败';
+          this.bibleImageError = userFriendlyError(err, '参考图上传失败,请检查网络后重试');
           this.cdr.detectChanges();
         });
       },
@@ -708,7 +709,7 @@ export class TopicToVideoComponent implements OnInit, OnDestroy {
     this.zone.run(() => {
       this.stage = 'failed';
       this.stageStatus = '';
-      this.errorMsg = err?.message || '生成失败';
+      this.errorMsg = userFriendlyError(err, '视频生成失败,请稍后重试');
       this.cdr.detectChanges();
       this.session.fail(this.errorMsg);
     });
@@ -788,7 +789,7 @@ ${arc}
           }
           resolve(scenes);
         },
-        error: (err) => reject(new Error(err?.message || 'AI 分镜失败')),
+        error: (err) => reject(new Error(userFriendlyError(err, '分镜生成失败,请稍后重试'))),
       });
     });
   }
@@ -920,7 +921,7 @@ ${arc}
           this.session.patch({ snapshot: this.toSnapshot(), title: this.deriveTitle() });
           resolve(scenes);
         },
-        error: (err) => reject(new Error(err?.message || 'AI 分镜失败')),
+        error: (err) => reject(new Error(userFriendlyError(err, '分镜生成失败,请稍后重试'))),
       });
     });
   }
@@ -1076,7 +1077,7 @@ ${arc}
               }),
               catchError((err) => {
                 scene.state = 'image-failed';
-                scene.error = err?.message || '图片生成失败';
+                scene.error = userFriendlyError(err, '画面生成失败,请稍后重试');
                 done += 1;
                 this.zone.run(() => {
                   this.imageProgress = Math.round((done / total) * 100);
@@ -1092,7 +1093,7 @@ ${arc}
           next: () => {
             const failed = this.scenes.filter((s) => s.state === 'image-failed').length;
             if (failed === total) {
-              reject(new Error('所有画面生成失败,请检查即梦 token 余额或稍后重试'));
+              reject(new Error('画面生成失败,请稍后重试或检查积分余额'));
               return;
             }
             if (failed > 0) {
@@ -1167,7 +1168,7 @@ ${arc}
                 catchError((err) => {
                   // 降级:该段仍用静态图,不报错
                   scene.state = 'video-failed';
-                  scene.error = err?.message || '图生视频失败';
+                  scene.error = userFriendlyError(err, '动态片段生成失败,请稍后重试');
                   done += 1;
                   console.warn('[t2v] 该段图生视频失败,降级为静态图:', err);
                   this.zone.run(() => {
@@ -1185,7 +1186,7 @@ ${arc}
             const ok = this.scenes.filter((s) => s.state === 'video-done').length;
             const fail = this.scenes.filter((s) => s.state === 'video-failed').length;
             if (ok === 0) {
-              reject(new Error('所有分镜的图生视频均失败,请检查即梦额度或稍后重试'));
+              reject(new Error('动态片段生成失败,请稍后重试或检查积分余额'));
               return;
             }
             if (fail > 0) {
@@ -1257,7 +1258,9 @@ ${arc}
               this.resultVideoUrl = res.videoUrl;
               this.composeProgress = 100;
               this.stage = 'done';
-              this.stageStatus = `生成完成(${res.segments} 段,${this.formatSize(res.size || 0)})`;
+              this.stageStatus = res.persistent === false
+                ? `合成完成,但云端上传失败:${res.uploadError || '请先下载本地临时视频'}`
+                : `生成完成(${res.segments} 段,${this.formatSize(res.size || 0)})`;
               this.cdr.detectChanges();
               // 持久化
               // ★ 成片同时写会话(finalize)与素材库(results)
@@ -1271,16 +1274,22 @@ ${arc}
                 mediaMode: this.quickMode ? this.mediaMode : undefined,
                 clipFrames: this.quickMode && this.mediaMode === 'video' ? this.clipFrames : undefined,
                 clipQuality: this.quickMode && this.mediaMode === 'video' ? this.clipQuality : undefined,
+                persistent: res.persistent !== false,
+                uploadError: res.uploadError || '',
               };
               this.session.patch({ snapshot: this.toSnapshot() });
-              this.session.finalize(this.resultVideoUrl, composeExtras);
-              this.results.saveResult({
-                type: 'video',
-                url: this.resultVideoUrl,
-                title: this.topic.trim() || '主题生视频',
-                pipelineId: 'topic_to_video',
-                extras: composeExtras,
-              }).subscribe();
+              if (res.persistent === false) {
+                this.session.fail('视频已在本机浏览器合成完成,但上传云端失败。请先下载本地临时视频,稍后可重试。');
+              } else {
+                this.session.finalize(this.resultVideoUrl, composeExtras);
+                this.results.saveResult({
+                  type: 'video',
+                  url: this.resultVideoUrl,
+                  title: this.topic.trim() || '主题生视频',
+                  pipelineId: 'topic_to_video',
+                  extras: composeExtras,
+                }).subscribe();
+              }
               resolve();
             } else {
               reject(new Error('合成失败:服务端未返回视频'));
@@ -1288,7 +1297,7 @@ ${arc}
           });
         })
         .catch((err) => {
-          reject(new Error(err?.message || '合成失败'));
+          reject(new Error(userFriendlyError(err, '视频合成失败,请稍后重试')));
         });
     });
   }
@@ -1441,7 +1450,7 @@ ${arc}
         this.zone.run(() => {
           scene.pendingTarget = undefined;
           scene.state = target === 'head' ? 'image-failed' : 'tail-failed';
-          scene.error = err?.message || '上传失败';
+          scene.error = userFriendlyError(err, '图片上传失败,请检查网络后重试');
           this.cdr.detectChanges();
         });
       },
@@ -1507,7 +1516,7 @@ ${arc}
         this.zone.run(() => {
           scene.state = 'tail-failed';
           scene.pendingTarget = undefined;
-          scene.error = err?.message || '尾帧生成失败';
+          scene.error = userFriendlyError(err, '尾帧生成失败,请稍后重试');
           this.cdr.detectChanges();
         });
       },
@@ -1603,7 +1612,7 @@ ${arc}
       error: (err) => {
         this.zone.run(() => {
           scene.state = 'image-failed';
-          scene.error = err?.message || '图片生成失败';
+          scene.error = userFriendlyError(err, '图片生成失败,请稍后重试');
           this.cdr.detectChanges();
         });
       },
@@ -1681,7 +1690,7 @@ ${arc}
             // 失败回退到「已出图」状态,让用户可重试
             scene.state = 'image-done';
             scene.pendingTarget = undefined;
-            scene.error = err?.message || '图生视频失败';
+            scene.error = userFriendlyError(err, '动态片段生成失败,请稍后重试');
             this.cdr.detectChanges();
           });
         },

+ 9 - 9
src/app/pipelines/pipeline-registry.ts

@@ -45,8 +45,8 @@ export const PIPELINES: readonly PipelineDef[] = [
   {
     id: 'digital_human',
     displayName: '数字人合成',
-    description: '上传参考视频或形象图,结合脚本与音色生成数字人口播视频',
-    inputHint: '参考视频 / 形象图 + 脚本 + 音色',
+    description: '上传形象图和口播内容,生成可用于展示、介绍或种草的视频',
+    inputHint: '参考视频 / 形象图 + 脚本 + 声音模型',
     iconSvg:
       '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle>',
     route: 'digital-human',
@@ -56,7 +56,7 @@ export const PIPELINES: readonly PipelineDef[] = [
   {
     id: 'image_generation',
     displayName: '图片生成',
-    description: '输入提示词生成图片资产,也可上传参考图做风格或主体延展',
+    description: '输入画面想法,生成适合用于视频、封面或素材库的图片',
     inputHint: '提示词 + 风格 + 比例',
     iconSvg:
       '<rect x="3" y="3" width="18" height="18" rx="3"></rect><circle cx="9" cy="9" r="1.5"></circle><path d="M21 15l-4.5-4.5L6 21"></path>',
@@ -67,7 +67,7 @@ export const PIPELINES: readonly PipelineDef[] = [
   {
     id: 'video_generation',
     displayName: '标准视频生成',
-    description: '上传视频后通过 AI 进行重塑、字幕与配音改造',
+    description: '上传原视频,重新整理画面、文案和配音,生成新的成片',
     inputHint: '原始视频 + 改造指令',
     iconSvg:
       '<polygon points="23 7 16 12 23 17 23 7"></polygon><rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>',
@@ -78,7 +78,7 @@ export const PIPELINES: readonly PipelineDef[] = [
   {
     id: 'image_to_video',
     displayName: '图生视频',
-    description: '上传一张或两张图片(首帧/首尾帧),配合提示词生成动态短视频',
+    description: '上传图片并描述想要的动作或氛围,生成动态短视频',
     inputHint: '图片 + 提示词 + 时长',
     iconSvg:
       '<rect x="3" y="3" width="18" height="14" rx="2"></rect><circle cx="9" cy="9" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline>',
@@ -89,7 +89,7 @@ export const PIPELINES: readonly PipelineDef[] = [
   {
     id: 'action_transfer',
     displayName: '动作迁移',
-    description: '基于即梦 Actor v2:上传角色图 + 参考视频,让角色按视频动作驱动起来',
+    description: '上传角色图和参考动作视频,让角色自然动起来',
     inputHint: '角色图 + 参考动作视频',
     iconSvg:
       '<circle cx="12" cy="5" r="2"></circle><path d="M12 7v6"></path><path d="M8 21l4-8 4 8"></path><path d="M6 11l6-3 6 3"></path>',
@@ -100,8 +100,8 @@ export const PIPELINES: readonly PipelineDef[] = [
   {
     id: 'asset_remix',
     displayName: '素材合成视频',
-    description: '上传多张图片 + 主题,AI 生成口播文案 + TTS 配音 + ffmpeg 拼接成片',
-    inputHint: '3-10 张图片 + 主题 + 音色',
+    description: '上传多张素材图和主题,自动生成讲解视频',
+    inputHint: '3-10 张图片 + 主题 + 声音模型',
     iconSvg:
       '<rect x="3" y="3" width="7" height="7" rx="1"></rect><rect x="14" y="3" width="7" height="7" rx="1"></rect><rect x="3" y="14" width="7" height="7" rx="1"></rect><rect x="14" y="14" width="7" height="7" rx="1"></rect>',
     route: 'asset-remix',
@@ -111,7 +111,7 @@ export const PIPELINES: readonly PipelineDef[] = [
   {
     id: 'topic_to_video',
     displayName: '主题生视频',
-    description: '一句话主题,LLM 拆分镜 → 即梦文生图 → TTS 配音 → ffmpeg 全自动拼接',
+    description: '输入一个主题,自动生成一条完整视频',
     inputHint: '一句话主题(全自动)',
     iconSvg:
       '<circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path>',

+ 565 - 0
src/app/services/auth-credit.service.ts

@@ -0,0 +1,565 @@
+import { Injectable } from '@angular/core';
+import { BehaviorSubject, Subject } from 'rxjs';
+import { CLOUD_FN } from './cloud-functions';
+import { ParseService } from './parse.service';
+
+export type AppUserRole = 'user' | 'admin';
+
+export interface AppUser {
+  objectId: string;
+  username: string;
+  email?: string;
+  phone?: string;
+  displayName?: string;
+  role: AppUserRole;
+  createdAt?: string;
+}
+
+export interface AuthSession {
+  token: string;
+  user: AppUser;
+}
+
+export interface CreditBalance {
+  balance: number;
+  gifted: number;
+  totalRecharged: number;
+  totalConsumed: number;
+}
+
+export interface CreditLedgerItem {
+  objectId: string;
+  userId?: string;
+  type: 'gift' | 'recharge' | 'consume' | 'refund' | 'admin_adjust' | string;
+  amount: number;
+  balanceAfter: number;
+  title: string;
+  detail?: any;
+  createdAt: string;
+}
+
+export interface RechargeOrder {
+  objectId: string;
+  amountCny: number;
+  creditAmount: number;
+  status: 'pending' | 'paid' | 'cancelled';
+  createdAt: string;
+}
+
+export interface AdminUserRow extends AppUser {
+  creditBalance: number;
+  status?: string;
+}
+
+export interface AdminCreateUserInput {
+  username: string;
+  password: string;
+  email?: string;
+  phone?: string;
+  displayName?: string;
+  role?: AppUserRole;
+  initialCredits?: number;
+}
+
+export interface CreditReserveResult {
+  reservationId: string;
+  balance: number;
+  cost: number;
+}
+
+const SESSION_STORAGE_KEY = 'videoWorkflow.authSession';
+const LOCAL_USERS_KEY = 'videoWorkflow.localAuth.users';
+const LOCAL_LEDGER_KEY = 'videoWorkflow.localAuth.ledger';
+const LOCAL_ORDERS_KEY = 'videoWorkflow.localAuth.orders';
+const PARSE_API_HOST = 'https://server.fmode.cn';
+const PARSE_APP_ID = 'ncloudmaster';
+const LOGIN_SMS_COMPANY = 'E4KpGvTEto';
+
+@Injectable({ providedIn: 'root' })
+export class AuthCreditService {
+  private readonly sessionSubject = new BehaviorSubject<AuthSession | null>(this.readSession());
+  private readonly loginRequiredSubject = new Subject<{ featureName: string }>();
+  readonly session$ = this.sessionSubject.asObservable();
+  readonly loginRequired$ = this.loginRequiredSubject.asObservable();
+  readonly localMode = !CLOUD_FN.authCredit;
+
+  constructor(private parse: ParseService) {}
+
+  get session(): AuthSession | null {
+    return this.sessionSubject.value;
+  }
+
+  get currentUser(): AppUser | null {
+    return this.session?.user || null;
+  }
+
+  get isLoggedIn(): boolean {
+    return !!this.session?.token;
+  }
+
+  get isAdmin(): boolean {
+    return this.currentUser?.role === 'admin';
+  }
+
+  requestLogin(featureName = '该功能'): void {
+    this.loginRequiredSubject.next({ featureName });
+  }
+
+  async register(input: {
+    username: string;
+    password: string;
+    email?: string;
+    phone?: string;
+    displayName?: string;
+  }): Promise<AuthSession> {
+    const session = this.localMode
+      ? this.localRegister(input)
+      : await this.call<AuthSession>('register', input);
+    this.setSession(session);
+    return session;
+  }
+
+  async login(identifier: string, password: string): Promise<AuthSession> {
+    const session = this.localMode
+      ? this.localLogin(identifier, password)
+      : await this.call<AuthSession>('login', { identifier, password });
+    this.setSession(session);
+    return session;
+  }
+
+  async sendMobileCode(mobile: string): Promise<void> {
+    const normalized = this.normalizeMobile(mobile);
+    if (!normalized) throw new Error('请输入正确的手机号');
+    const resp = await fetch(`${PARSE_API_HOST}/api/apig/message`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        'X-Parse-Application-Id': PARSE_APP_ID,
+      },
+      body: JSON.stringify({ mobile: normalized, company: LOGIN_SMS_COMPANY }),
+    });
+    const result = await resp.json().catch(() => ({}));
+    if (!resp.ok || result.code !== 1) {
+      throw new Error(result.data?.msg || result.message || result.error || '验证码发送失败,请稍后重试');
+    }
+  }
+
+  async loginWithMobileCode(mobile: string, code: string): Promise<AuthSession> {
+    const normalized = this.normalizeMobile(mobile);
+    const smsCode = String(code || '').trim();
+    if (!normalized) throw new Error('请输入正确的手机号');
+    if (smsCode.length < 4) throw new Error('请输入验证码');
+
+    const loginUrl = `${PARSE_API_HOST}/api/fmode/mobile?mobile=${encodeURIComponent(normalized)}&code=${encodeURIComponent(smsCode)}`;
+    const resp = await fetch(loginUrl, {
+      method: 'GET',
+      headers: { 'X-Parse-Application-Id': PARSE_APP_ID },
+    });
+    const result = await resp.json().catch(() => ({}));
+    const token = result?.data?.token;
+    if (!resp.ok || result.code !== 200 || !token) {
+      throw new Error(result.mess || result.message || result.error || '验证码错误或已过期');
+    }
+
+    const parseUser = await this.fetchParseMe(token);
+    const session: AuthSession = {
+      token,
+      user: this.parseUserToAppUser(parseUser, normalized),
+    };
+    if (this.localMode) this.ensureLocalCreditUser(session.user);
+    this.setSession(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> {
+    if (!this.session) return null;
+    if (this.localMode) return this.session.user;
+    const user = await this.call<AppUser>('me', this.authPayload());
+    this.setSession({ ...this.session, user });
+    return user;
+  }
+
+  logout(): void {
+    localStorage.removeItem(SESSION_STORAGE_KEY);
+    this.sessionSubject.next(null);
+  }
+
+  async getBalance(): Promise<CreditBalance> {
+    if (this.localMode) return this.localBalance();
+    return this.call<CreditBalance>('balance', this.authPayload());
+  }
+
+  async getLedger(limit = 50): Promise<CreditLedgerItem[]> {
+    if (this.localMode) return this.localLedger().slice(0, limit);
+    return this.call<CreditLedgerItem[]>('ledger', { ...this.authPayload(), limit });
+  }
+
+  async createRechargeOrder(amountCny: number): Promise<RechargeOrder> {
+    if (this.localMode) return this.localCreateRechargeOrder(amountCny);
+    return this.call<RechargeOrder>('createRechargeOrder', { ...this.authPayload(), amountCny });
+  }
+
+  async reserveCredit(operation: string, cost: number, title: string, detail: any = {}): Promise<CreditReserveResult> {
+    if (this.localMode) return this.localReserve(operation, cost, title, detail);
+    return this.call<CreditReserveResult>('reserve', { ...this.authPayload(), operation, cost, title, detail });
+  }
+
+  async commitReservation(reservationId: string, detail: any = {}): Promise<void> {
+    if (this.localMode) return;
+    await this.call<void>('commitReservation', { ...this.authPayload(), reservationId, detail });
+  }
+
+  async refundReservation(reservationId: string, reason: string): Promise<void> {
+    if (this.localMode) {
+      this.localRefund(reservationId, reason);
+      return;
+    }
+    await this.call<void>('refundReservation', { ...this.authPayload(), reservationId, reason });
+  }
+
+  async adminListUsers(): Promise<AdminUserRow[]> {
+    if (this.localMode) return this.localUsers().map((u: any) => ({ ...u.user, creditBalance: u.balance || 0 }));
+    return this.call<AdminUserRow[]>('adminListUsers', this.authPayload());
+  }
+
+  async adminCreateUser(input: AdminCreateUserInput): Promise<AppUser> {
+    if (this.localMode) return this.localAdminCreateUser(input);
+    return this.call<AppUser>('adminCreateUser', { ...this.authPayload(), ...input });
+  }
+
+  async adminAdjustCredit(userId: string, amount: number, note: string): Promise<void> {
+    if (this.localMode) {
+      const users = this.localUsers();
+      const row = users.find((u: any) => u.user.objectId === userId);
+      if (!row) throw new Error('未找到用户');
+      row.balance = Math.max(0, Number(row.balance || 0) + Number(amount || 0));
+      this.writeLocalUsers(users);
+      this.appendLocalLedger({
+        objectId: this.localId(),
+        userId,
+        type: 'admin_adjust',
+        amount,
+        balanceAfter: row.balance,
+        title: note || '管理员调整',
+        createdAt: new Date().toISOString(),
+      });
+      return;
+    }
+    await this.call<void>('adminAdjustCredit', { ...this.authPayload(), userId, amount, note });
+  }
+
+  async changePassword(oldPassword: string, newPassword: string): Promise<void> {
+    if (this.localMode) {
+      const session = this.session;
+      if (!session) throw new Error('请先登录');
+      const users = this.localUsers();
+      const row = users.find((u: any) => u.user.objectId === session.user.objectId);
+      if (!row || row.password !== oldPassword) throw new Error('原密码不正确');
+      row.password = newPassword;
+      this.writeLocalUsers(users);
+      return;
+    }
+    await this.call<void>('changePassword', { ...this.authPayload(), oldPassword, newPassword });
+  }
+
+  private async call<T>(action: string, params: Record<string, any>): Promise<T> {
+    const res = await this.parse.call<T>(CLOUD_FN.authCredit, { action, ...params });
+    if (res.code !== 200 || !res.success) {
+      throw new Error(res.error || '账号服务调用失败');
+    }
+    return res.data as T;
+  }
+
+  private authPayload(): Record<string, any> {
+    if (!this.session) throw new Error('请先登录');
+    return { sessionToken: this.session.token, userId: this.session.user.objectId };
+  }
+
+  private readSession(): AuthSession | null {
+    try {
+      const raw = localStorage.getItem(SESSION_STORAGE_KEY);
+      return raw ? JSON.parse(raw) : null;
+    } catch {
+      return null;
+    }
+  }
+
+  private setSession(session: AuthSession): void {
+    localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
+    this.sessionSubject.next(session);
+  }
+
+  private normalizeMobile(mobile: string): string {
+    const value = String(mobile || '').replace(/\D/g, '').slice(0, 11);
+    return /^1[3-9]\d{9}$/.test(value) ? value : '';
+  }
+
+  private async fetchParseMe(sessionToken: string): Promise<any> {
+    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 || !data.objectId) {
+      throw new Error(data.error || data.message || '登录成功但无法获取用户信息');
+    }
+    return data;
+  }
+
+  private parseUserToAppUser(parseUser: any, mobile: string): AppUser {
+    const displayName = parseUser.nickname
+      || parseUser.name
+      || parseUser.displayName
+      || this.maskMobile(parseUser.mobilePhoneNumber || mobile)
+      || parseUser.username
+      || parseUser.objectId;
+    return {
+      objectId: parseUser.objectId,
+      username: parseUser.username || parseUser.mobilePhoneNumber || mobile || parseUser.objectId,
+      email: parseUser.email || '',
+      phone: parseUser.mobilePhoneNumber || mobile || '',
+      displayName,
+      role: parseUser.role === 'admin' || parseUser.isAdmin === true ? 'admin' : 'user',
+      createdAt: parseUser.createdAt,
+    };
+  }
+
+  private maskMobile(mobile: string): string {
+    const value = String(mobile || '');
+    return /^1[3-9]\d{9}$/.test(value) ? value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : value;
+  }
+
+  private ensureLocalCreditUser(user: AppUser): void {
+    const users = this.localUsers();
+    const row = users.find((u: any) => u.user.objectId === user.objectId);
+    if (row) {
+      row.user = { ...row.user, ...user };
+      this.writeLocalUsers(users);
+      return;
+    }
+    users.push({ user, password: '', balance: 10 });
+    this.writeLocalUsers(users);
+    this.appendLocalLedger({
+      objectId: this.localId(),
+      userId: user.objectId,
+      type: 'gift',
+      amount: 10,
+      balanceAfter: 10,
+      title: '初始赠送积分',
+      createdAt: new Date().toISOString(),
+    });
+  }
+
+  private localRegister(input: any): AuthSession {
+    const users = this.localUsers();
+    const identifier = String(input.username || '').trim();
+    if (!identifier || !input.password) throw new Error('请输入账号和密码');
+    const exists = users.some((u: any) =>
+      u.user.username === identifier || (input.email && u.user.email === input.email) || (input.phone && u.user.phone === input.phone)
+    );
+    if (exists) throw new Error('账号已存在');
+    const isFirst = users.length === 0;
+    const user: AppUser = {
+      objectId: this.localId(),
+      username: identifier,
+      email: input.email || '',
+      phone: input.phone || '',
+      displayName: input.displayName || identifier,
+      role: isFirst ? 'admin' : 'user',
+      createdAt: new Date().toISOString(),
+    };
+    users.push({ user, password: input.password, balance: 10 });
+    this.writeLocalUsers(users);
+    this.appendLocalLedger({
+      objectId: this.localId(),
+      userId: user.objectId,
+      type: 'gift',
+      amount: 10,
+      balanceAfter: 10,
+      title: '注册赠送积分',
+      createdAt: new Date().toISOString(),
+    });
+    return { token: this.localId() + this.localId(), user };
+  }
+
+  private localLogin(identifier: string, password: string): AuthSession {
+    const id = String(identifier || '').trim();
+    const row = this.localUsers().find((u: any) =>
+      (u.user.username === id || u.user.email === id || u.user.phone === id) && u.password === password
+    );
+    if (!row) throw new Error('账号或密码不正确');
+    return { token: this.localId() + this.localId(), user: row.user };
+  }
+
+  private localBalance(): CreditBalance {
+    const userId = this.currentUser?.objectId;
+    const row = this.localUsers().find((u: any) => u.user.objectId === userId);
+    const ledger = this.localLedger().filter((i: any) => i.userId === userId);
+    return {
+      balance: Number(row?.balance || 0),
+      gifted: ledger.filter((i) => i.type === 'gift').reduce((s, i) => s + Number(i.amount || 0), 0),
+      totalRecharged: ledger.filter((i) => i.type === 'recharge').reduce((s, i) => s + Number(i.amount || 0), 0),
+      totalConsumed: Math.abs(ledger.filter((i) => i.type === 'consume').reduce((s, i) => s + Number(i.amount || 0), 0)),
+    };
+  }
+
+  private localAdminCreateUser(input: AdminCreateUserInput): AppUser {
+    if (!this.isAdmin) throw new Error('没有管理员权限');
+    const username = String(input.username || '').trim();
+    const password = String(input.password || '').trim();
+    if (!username || !password) throw new Error('请输入用户名和初始密码');
+    const users = this.localUsers();
+    const exists = users.some((u: any) =>
+      u.user.username === username || (input.email && u.user.email === input.email) || (input.phone && u.user.phone === input.phone)
+    );
+    if (exists) throw new Error('账号已存在');
+    const initialCredits = Math.max(0, Number(input.initialCredits ?? 10));
+    const user: AppUser = {
+      objectId: this.localId(),
+      username,
+      email: input.email || '',
+      phone: input.phone || '',
+      displayName: input.displayName || username,
+      role: input.role || 'user',
+      createdAt: new Date().toISOString(),
+    };
+    users.push({ user, password, balance: initialCredits });
+    this.writeLocalUsers(users);
+    if (initialCredits > 0) {
+      this.appendLocalLedger({
+        objectId: this.localId(),
+        userId: user.objectId,
+        type: 'admin_adjust',
+        amount: initialCredits,
+        balanceAfter: initialCredits,
+        title: '管理员发放初始积分',
+        createdAt: new Date().toISOString(),
+      });
+    }
+    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 {
+    if (!this.currentUser) throw new Error('请先登录');
+    const users = this.localUsers();
+    const row = users.find((u: any) => u.user.objectId === this.currentUser!.objectId);
+    const amount = Math.max(0, Number(cost || 0));
+    if (!row || Number(row.balance || 0) < amount) throw new Error('积分不足,请先充值');
+    row.balance = Number(row.balance || 0) - amount;
+    this.writeLocalUsers(users);
+    const reservationId = this.localId();
+    this.appendLocalLedger({
+      objectId: reservationId,
+      userId: this.currentUser.objectId,
+      type: 'consume',
+      amount: -amount,
+      balanceAfter: row.balance,
+      title,
+      detail: { operation, ...detail, status: 'reserved' },
+      createdAt: new Date().toISOString(),
+    });
+    return { reservationId, balance: row.balance, cost: amount };
+  }
+
+  private localRefund(reservationId: string, reason: string): void {
+    const ledger = this.localLedger();
+    const item = ledger.find((i: any) => i.objectId === reservationId && i.type === 'consume');
+    if (!item || item.detail?.refunded) return;
+    const users = this.localUsers();
+    const row = users.find((u: any) => u.user.objectId === item.userId);
+    const refund = Math.abs(Number(item.amount || 0));
+    if (!row) return;
+    row.balance = Number(row.balance || 0) + refund;
+    item.detail = { ...(item.detail || {}), refunded: true, refundReason: reason };
+    this.writeLocalUsers(users);
+    localStorage.setItem(LOCAL_LEDGER_KEY, JSON.stringify(ledger));
+    this.appendLocalLedger({
+      objectId: this.localId(),
+      userId: item.userId,
+      type: 'refund',
+      amount: refund,
+      balanceAfter: row.balance,
+      title: reason || '任务失败退回积分',
+      createdAt: new Date().toISOString(),
+    });
+  }
+
+  private localUsers(): any[] {
+    try { return JSON.parse(localStorage.getItem(LOCAL_USERS_KEY) || '[]'); } catch { return []; }
+  }
+
+  private writeLocalUsers(users: any[]): void {
+    localStorage.setItem(LOCAL_USERS_KEY, JSON.stringify(users));
+  }
+
+  private localLedger(): CreditLedgerItem[] {
+    const userId = this.currentUser?.objectId;
+    try {
+      const rows = JSON.parse(localStorage.getItem(LOCAL_LEDGER_KEY) || '[]');
+      return rows.filter((i: any) => !userId || i.userId === userId);
+    } catch {
+      return [];
+    }
+  }
+
+  private appendLocalLedger(item: any): void {
+    const rows = JSON.parse(localStorage.getItem(LOCAL_LEDGER_KEY) || '[]');
+    rows.unshift(item);
+    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 {
+    const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+    let s = '';
+    for (let i = 0; i < 10; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
+    return s;
+  }
+}

+ 38 - 18
src/app/services/browser-ffmpeg.service.ts

@@ -40,6 +40,9 @@ export interface CompositeDoneEvent {
   filename: string;
   size: number;
   segments: number;
+  /** false 表示云端上传失败,仅返回当前浏览器可用的 blob 临时地址。 */
+  persistent?: boolean;
+  uploadError?: string;
 }
 
 export interface CompositeCallbacks {
@@ -307,24 +310,39 @@ export class BrowserFfmpegService {
           .substring(0, 50) || 'video';
         const filename = `${safeTitle}-${Date.now()}.mp4`;
 
-        const url = await new Promise<string>((resolve, reject) => {
-          this.qiniu
-            .uploadFileWithProgress(blob, filename, 'video/mp4', 'video')
-            .subscribe({
-              next: (ev) => {
-                if (ev.state === 'progress') {
-                  params.onProgress?.({
-                    stage: 'cleanup',
-                    percent: Math.min(100, 95 + Math.round((ev.progress || 0) * 0.05)),
-                    message: `uploading ${Math.round((ev.progress || 0) * 100)}%`,
-                  });
-                } else if (ev.state === 'done' && ev.url) {
-                  resolve(ev.url);
-                }
-              },
-              error: (err) => reject(err instanceof Error ? err : new Error(String(err))),
-            });
-        });
+        let url = '';
+        let persistent = true;
+        let uploadError = '';
+
+        try {
+          url = await new Promise<string>((resolve, reject) => {
+            this.qiniu
+              .uploadFileWithProgress(blob, filename, 'video/mp4', 'video')
+              .subscribe({
+                next: (ev) => {
+                  if (ev.state === 'progress') {
+                    params.onProgress?.({
+                      stage: 'cleanup',
+                      percent: Math.min(100, 95 + Math.round((ev.progress || 0) * 0.05)),
+                      message: `uploading ${Math.round((ev.progress || 0) * 100)}%`,
+                    });
+                  } else if (ev.state === 'done' && ev.url) {
+                    resolve(ev.url);
+                  }
+                },
+                error: (err) => reject(err instanceof Error ? err : new Error(String(err))),
+              });
+          });
+        } catch (err: any) {
+          persistent = false;
+          uploadError = err?.message || String(err);
+          url = URL.createObjectURL(blob);
+          params.onProgress?.({
+            stage: 'cleanup',
+            percent: 100,
+            message: '云端上传失败,已生成本地临时预览',
+          });
+        }
 
         await Promise.all([...tempFiles].map((n) => this.safeDelete(ffmpeg, n)));
 
@@ -334,6 +352,8 @@ export class BrowserFfmpegService {
           filename,
           size,
           segments: segments.length,
+          persistent,
+          uploadError,
         };
         params.onProgress?.({ stage: 'cleanup', percent: 100 });
         params.onDone?.(done);

+ 27 - 20
src/app/services/cloud-api.interceptor.ts

@@ -21,6 +21,7 @@ import { Observable, from, of } from 'rxjs';
 import { switchMap, map } from 'rxjs/operators';
 import { ParseService } from './parse.service';
 import { CLOUD_FN } from './cloud-functions';
+import { AuthCreditService } from './auth-credit.service';
 
 /** 解析 /backend/api/xxx 或 /api/xxx,返回 path 段数组(不含前缀) */
 function parsePath(url: string): string[] | null {
@@ -50,9 +51,15 @@ export const cloudApiInterceptor: HttpInterceptorFn = (req, next) => {
   if (!segs || segs.length === 0) return next(req);
 
   const parse = inject(ParseService);
+  const auth = inject(AuthCreditService);
   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>) => ({
+    ...params,
+    userId: auth.currentUser?.objectId || '__anonymous__',
+    sessionToken: auth.session?.token || '',
+  });
 
   // 通用 helper:调云函数并把响应转成 HttpResponse
   const call = (fnId: string, params: Record<string, any>, shape: (data: any) => any) => {
@@ -67,42 +74,42 @@ export const cloudApiInterceptor: HttpInterceptorFn = (req, next) => {
 
   // ==================== manifest ====================
   if (resource === 'manifest') {
-    if (method === 'GET' && !p1)  return call(CLOUD_FN.manifest, { action: 'list' }, data => data);
-    if (method === 'POST' && !p1) return call(CLOUD_FN.manifest, { action: 'create', video: body }, data => ({ success: true, video: data }));
-    if (method === 'PUT' && p1)   return call(CLOUD_FN.manifest, { action: 'update', videoId: p1, video: body }, data => ({ success: true, video: data }));
-    if (method === 'DELETE' && p1) return call(CLOUD_FN.manifest, { action: 'delete', videoId: p1 }, data => ({ success: true, videoId: data?.videoId ?? p1 }));
+    if (method === 'GET' && !p1)  return call(CLOUD_FN.manifest, scoped({ action: 'list' }), data => data);
+    if (method === 'POST' && !p1) return call(CLOUD_FN.manifest, scoped({ action: 'create', video: body }), data => ({ success: true, video: data }));
+    if (method === 'PUT' && p1)   return call(CLOUD_FN.manifest, scoped({ action: 'update', videoId: p1, video: body }), data => ({ success: true, video: data }));
+    if (method === 'DELETE' && p1) return call(CLOUD_FN.manifest, scoped({ action: 'delete', videoId: p1 }), data => ({ success: true, videoId: data?.videoId ?? p1 }));
   }
 
   // ==================== tasks ====================
   if (resource === 'tasks') {
-    if (method === 'GET' && !p1)  return call(CLOUD_FN.task, { action: 'list' }, data => data);
-    if (method === 'POST' && !p1) return call(CLOUD_FN.task, { action: 'create', task: body }, data => ({ success: true, task: data }));
-    if (method === 'PUT' && p1)   return call(CLOUD_FN.task, { action: 'update', id: p1, task: body }, data => ({ success: true, task: data }));
-    if (method === 'DELETE' && p1) return call(CLOUD_FN.task, { action: 'delete', id: p1 }, () => ({ success: true }));
+    if (method === 'GET' && !p1)  return call(CLOUD_FN.task, scoped({ action: 'list' }), data => data);
+    if (method === 'POST' && !p1) return call(CLOUD_FN.task, scoped({ action: 'create', task: body }), data => ({ success: true, task: data }));
+    if (method === 'PUT' && p1)   return call(CLOUD_FN.task, scoped({ action: 'update', taskId: p1, task: body }), data => ({ success: true, task: data }));
+    if (method === 'DELETE' && p1) return call(CLOUD_FN.task, scoped({ action: 'delete', taskId: p1 }), () => ({ success: true }));
   }
 
   // ==================== history ====================
   if (resource === 'history') {
-    if (method === 'GET' && !p1)    return call(CLOUD_FN.history, { action: 'list' }, data => data);
-    if (method === 'POST' && !p1)   return call(CLOUD_FN.history, { action: 'create', entry: body }, data => ({ success: true, record: data }));
-    if (method === 'DELETE' && p1)  return call(CLOUD_FN.history, { action: 'delete', id: p1 }, () => ({ success: true }));
-    if (method === 'DELETE' && !p1) return call(CLOUD_FN.history, { action: 'clearAll' }, () => ({ success: true }));
+    if (method === 'GET' && !p1)    return call(CLOUD_FN.history, scoped({ action: 'list' }), data => data);
+    if (method === 'POST' && !p1)   return call(CLOUD_FN.history, scoped({ action: 'create', entry: body }), data => ({ success: true, record: data }));
+    if (method === 'DELETE' && p1)  return call(CLOUD_FN.history, scoped({ action: 'delete', bizId: p1 }), () => ({ success: true }));
+    if (method === 'DELETE' && !p1) return call(CLOUD_FN.history, scoped({ action: 'clearAll' }), () => ({ success: true }));
   }
 
   // ==================== results ====================
   if (resource === 'results') {
-    if (method === 'GET' && !p1)  return call(CLOUD_FN.result, { action: 'list' }, data => data);
-    if (method === 'POST' && !p1) return call(CLOUD_FN.result, { action: 'create', result: body }, data => ({ success: true, result: data }));
-    if (method === 'PUT' && p1)   return call(CLOUD_FN.result, { action: 'update', id: p1, result: body }, data => ({ success: true, result: data }));
-    if (method === 'DELETE' && p1) return call(CLOUD_FN.result, { action: 'delete', id: p1 }, () => ({ success: true }));
+    if (method === 'GET' && !p1)  return call(CLOUD_FN.result, scoped({ action: 'list' }), data => data);
+    if (method === 'POST' && !p1) return call(CLOUD_FN.result, scoped({ action: 'create', result: body }), data => ({ success: true, result: data }));
+    if (method === 'PUT' && p1)   return call(CLOUD_FN.result, scoped({ action: 'update', bizId: p1, result: body }), data => ({ success: true, result: data }));
+    if (method === 'DELETE' && p1) return call(CLOUD_FN.result, scoped({ action: 'delete', bizId: p1 }), () => ({ success: true }));
   }
 
   // ==================== remixes ====================
   if (resource === 'remixes') {
-    if (method === 'GET' && !p1)         return call(CLOUD_FN.remix, { action: 'listAll' }, data => data);
-    if (method === 'GET' && p1 && !p2)   return call(CLOUD_FN.remix, { action: 'listByVideo', videoId: p1 }, data => data);
-    if (method === 'POST' && p1 && !p2)  return call(CLOUD_FN.remix, { action: 'upsert', videoId: p1, remix: body }, () => ({ success: true }));
-    if (method === 'DELETE' && p1 && p2) return call(CLOUD_FN.remix, { action: 'delete', videoId: p1, remixId: p2 }, () => ({ success: true }));
+    if (method === 'GET' && !p1)         return call(CLOUD_FN.remix, scoped({ action: 'listAll' }), data => data);
+    if (method === 'GET' && p1 && !p2)   return call(CLOUD_FN.remix, scoped({ action: 'listByVideo', videoId: p1 }), data => data);
+    if (method === 'POST' && p1 && !p2)  return call(CLOUD_FN.remix, scoped({ action: 'upsert', videoId: p1, remix: body }), () => ({ success: true }));
+    if (method === 'DELETE' && p1 && p2) return call(CLOUD_FN.remix, scoped({ action: 'delete', videoId: p1, remixId: p2 }), () => ({ success: true }));
   }
 
   // ==================== voice ====================

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

@@ -27,8 +27,14 @@ export const CLOUD_FN = {
   quickly: '',
   /** LLM 代理(chat / gemini) */
   proxy: 'wzwnEuAhwb',
+  /** 即梦图片、视频、数字人代理 */
+  jimeng: 'WUkf74lVE5',
+  /** 抖音/TikHub 搜索、详情、博主资料代理 */
+  douyin: 'HArHym0smi',
   /** 七牛云直传凭证 */
   upload: 'VHS0noRP7Q',
+  /** 账号、积分、充值与管理员 */
+  authCredit: '',
 } as const;
 
 export type CloudFnKey = keyof typeof CLOUD_FN;

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

@@ -0,0 +1,81 @@
+import { Injectable } from '@angular/core';
+
+export type EstimateQuality = '720p' | '1080p' | 'pro';
+
+export interface CostEstimateLine {
+  key: string;
+  label: string;
+  credits: number;
+  quantity?: number;
+  unit?: string;
+}
+
+export interface CostEstimate {
+  operation: string;
+  title: string;
+  totalCredits: number;
+  lines: CostEstimateLine[];
+}
+
+@Injectable({ providedIn: 'root' })
+export class CostEstimatorService {
+  estimateImageGeneration(count = 1): CostEstimate {
+    const quantity = Math.max(1, Math.ceil(count));
+    return this.build('jimeng.imageV4', '图片生成', [
+      { key: 'image', label: '图片生成', credits: 3 * quantity, quantity, unit: '张' },
+    ]);
+  }
+
+  estimateVideoGeneration(quality: EstimateQuality = '1080p', seconds = 5, title = '视频生成'): CostEstimate {
+    const normalizedSeconds = Number(seconds || 5) >= 10 ? 10 : 5;
+    const cnyPerSecond = quality === 'pro' ? 1 : quality === '1080p' ? 0.63 : 0.28;
+    return this.build('jimeng.video', `${title} ${quality} ${normalizedSeconds} 秒`, [
+      {
+        key: 'video',
+        label: `${quality} 视频生成`,
+        credits: Math.ceil(cnyPerSecond * normalizedSeconds * 10),
+        quantity: normalizedSeconds,
+        unit: '秒',
+      },
+    ]);
+  }
+
+  estimateImageToVideo(quality: EstimateQuality = '720p', seconds = 5): CostEstimate {
+    return this.estimateVideoGeneration(quality, seconds, '图生视频');
+  }
+
+  estimateActionTransfer(): CostEstimate {
+    return this.build('jimeng.actionTransfer', '动作迁移', [
+      { key: 'action-transfer', label: '动作迁移', credits: 20, quantity: 1, unit: '次' },
+    ]);
+  }
+
+  estimateDouyinRead(operation: string, title: string, count = 1): CostEstimate {
+    const quantity = Math.max(1, Math.ceil(count));
+    return this.build(operation, title, [
+      { 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;
+    const lines: CostEstimateLine[] = [
+      { key: 'storyboard', label: '脚本/分镜生成', credits: 1, quantity: 1, unit: '次' },
+      { key: 'visuals', label: '分镜视频生成', credits: video, quantity: scenes, unit: '段' },
+    ];
+    if (includeTts) {
+      lines.push({ key: 'tts', label: '语音合成', credits: scenes, quantity: scenes, unit: '段' });
+    }
+    return this.build('pipeline.topicToVideo', `主题生视频 ${scenes} 段`, lines);
+  }
+
+  framesToSeconds(frames: number): number {
+    return Number(frames || 121) >= 241 ? 10 : 5;
+  }
+
+  private build(operation: string, title: string, lines: CostEstimateLine[]): CostEstimate {
+    const totalCredits = Math.max(0, Math.ceil(lines.reduce((sum, line) => sum + Number(line.credits || 0), 0)));
+    return { operation, title, totalCredits, lines };
+  }
+}

+ 3 - 8
src/app/services/digital-human.service.ts

@@ -1,10 +1,9 @@
 import { Injectable } from '@angular/core';
-import { HttpClient } from '@angular/common/http';
 import { Observable, Subject, of, throwError, from } from 'rxjs';
 import { switchMap, catchError, map } from 'rxjs/operators';
 import { JimengService } from './jimeng.service';
-import { environment } from '../../environments/environment';
 import { BrowserFfmpegService } from './browser-ffmpeg.service';
+import { VoiceApiService } from './voice-api.service';
 
 // ==================== 类型定义 ====================
 
@@ -80,13 +79,10 @@ export type DigitalHumanEvent = DigitalHumanProgressEvent | DigitalHumanResult;
 @Injectable({ providedIn: 'root' })
 export class DigitalHumanService {
 
-  private readonly ttsEndpoint = 'https://server.fmode.cn/api/volcengine/tts/unidirectional';
-  private readonly ttsToken = environment.jimengApi.token; // Bearer r:xxx
-
   constructor(
-    private http: HttpClient,
     private jimeng: JimengService,
     private browserFfmpeg: BrowserFfmpegService,
+    private voiceApi: VoiceApiService,
   ) {}
 
   /**
@@ -221,7 +217,6 @@ export class DigitalHumanService {
     onProgress('正在合成语音...', 18);
 
     const body: any = {
-      token: this.ttsToken,
       text: audio.ttsText.trim(),
       isStream: false,
       audio_params: {
@@ -238,7 +233,7 @@ export class DigitalHumanService {
       body.timbreId = voiceId;
     }
 
-    return this.http.post<any>(this.ttsEndpoint, body).pipe(
+    return this.voiceApi.synthesize(body).pipe(
       map((res: any) => {
         if (res?.code === 200 && res?.data?.audioUrl) {
           onProgress('语音合成完成', 30);

+ 20 - 49
src/app/services/douyin-api.service.ts

@@ -1,8 +1,9 @@
 import { Injectable } from '@angular/core';
-import { HttpClient, HttpHeaders } from '@angular/common/http';
-import { Observable, throwError } from 'rxjs';
+import { Observable, from, throwError } from 'rxjs';
 import { catchError, retry } from 'rxjs/operators';
 import { environment } from '../../environments/environment';
+import { ParseService } from './parse.service';
+import { CLOUD_FN } from './cloud-functions';
 
 // 抖音API接口定义
 interface DouyinSearchParams {
@@ -29,11 +30,9 @@ interface DouyinCommentsParams {
   providedIn: 'root'
 })
 export class DouyinApiService {
-  private readonly BASE_URL = environment.douyinApi.baseUrl;
-  private readonly TIMEOUT = environment.douyinApi.timeout;
   private readonly MAX_RETRIES = environment.douyinApi.maxRetries;
 
-  constructor(private http: HttpClient) {}
+  constructor(private parse: ParseService) {}
 
   /**
    * 抖音综合搜索
@@ -41,8 +40,6 @@ export class DouyinApiService {
    * @returns 搜索结果
    */
   searchVideos(params: DouyinSearchParams): Observable<any> {
-    const url = `${this.BASE_URL}/api/v1/douyin/search/fetch_challenge_search_v2`;
-
     const defaultParams = {
       cursor: 0,
       sort_type: '0',
@@ -58,10 +55,7 @@ export class DouyinApiService {
       cursor: params.cursor || 0 // 保持cursor为数字,根据API文档
     };
 
-    return this.http.post(url, requestParams, {
-      headers: this.getHeaders(),
-      timeout: this.TIMEOUT
-    }).pipe(
+    return this.callDouyin('challengeSearch', { payload: requestParams }).pipe(
       retry(this.MAX_RETRIES),
       catchError(this.handleError)
     );
@@ -73,13 +67,7 @@ export class DouyinApiService {
    * @returns 视频详情
    */
   getVideoDetail(aweme_id: string): Observable<any> {
-    const url = `${this.BASE_URL}/api/v1/douyin/app/v3/fetch_one_video_v3`;
-
-    return this.http.get(url, {
-      params: { aweme_id },
-      headers: this.getHeaders(),
-      timeout: this.TIMEOUT
-    }).pipe(
+    return this.callDouyin('videoDetail', { params: { aweme_id } }).pipe(
       retry(this.MAX_RETRIES),
       catchError(this.handleError)
     );
@@ -91,8 +79,6 @@ export class DouyinApiService {
    * @returns 评论列表
    */
   getVideoComments(params: DouyinCommentsParams): Observable<any> {
-    const url = `${this.BASE_URL}/app/v3/fetch_video_comments`;
-
     const defaultParams = {
       cursor: 0,
       count: 20
@@ -105,11 +91,7 @@ export class DouyinApiService {
       count: params.count || 20
     };
 
-    return this.http.get(url, {
-      params: requestParams,
-      headers: this.getHeaders(),
-      timeout: this.TIMEOUT
-    }).pipe(
+    return this.callDouyin('comments', { params: requestParams }).pipe(
       retry(this.MAX_RETRIES),
       catchError(this.handleError)
     );
@@ -123,17 +105,12 @@ export class DouyinApiService {
    * @returns 回复列表
    */
   getCommentReplies(item_id: string, comment_id: string, cursor: number = 0): Observable<any> {
-    const url = `${this.BASE_URL}/comment/reply/list`;
-
-    return this.http.post(url, {
+    return this.callDouyin('replies', { payload: {
       item_id,
       comment_id,
       cursor,
       count: 20
-    }, {
-      headers: this.getHeaders(),
-      timeout: this.TIMEOUT
-    }).pipe(
+    } }).pipe(
       retry(this.MAX_RETRIES),
       catchError(this.handleError)
     );
@@ -145,27 +122,21 @@ export class DouyinApiService {
    * @returns 用户信息
    */
   getUserProfile(sec_uid: string): Observable<any> {
-    const url = `${this.BASE_URL}/app/v3/handler_user_profile`;
-
-    return this.http.get(url, {
-      params: { sec_user_id: sec_uid },
-      headers: this.getHeaders(),
-      timeout: this.TIMEOUT
-    }).pipe(
+    return this.callDouyin('userProfileApp', { params: { sec_user_id: sec_uid } }).pipe(
       retry(this.MAX_RETRIES),
       catchError(this.handleError)
     );
   }
 
-  /**
-   * 获取请求头(使用环境配置)
-   */
-  private getHeaders(): HttpHeaders {
-    return new HttpHeaders({
-      'Content-Type': 'application/json',
-      'Accept': 'application/json',
-      'Authorization': `Bearer ${environment.douyinApi.token}`
-    });
+  private callDouyin(route: string, params: { payload?: Record<string, any>; params?: Record<string, any> }): Observable<any> {
+    if (!CLOUD_FN.douyin) {
+      return throwError(() => new Error('抖音服务暂未配置,请先部署抖音云函数。'));
+    }
+    return from(this.parse.callOrThrow<any>(CLOUD_FN.douyin, {
+      action: 'call',
+      route,
+      ...params,
+    }));
   }
 
   /**
@@ -211,4 +182,4 @@ export class DouyinApiService {
     console.error('Douyin API Error:', errorMessage, error);
     return throwError(() => new Error(errorMessage));
   }
-}
+}

+ 52 - 48
src/app/services/douyin.service.ts

@@ -1,17 +1,18 @@
 import { Injectable } from '@angular/core';
-import { HttpClient, HttpHeaders } from '@angular/common/http';
-import { Observable, throwError, timer } from 'rxjs';
-import { catchError, retry } from 'rxjs/operators';
+import { HttpClient } from '@angular/common/http';
+import { from, Observable, throwError, timer } from 'rxjs';
+import { catchError, map, retry, switchMap } from 'rxjs/operators';
 import { DouyinVideo, VideoDownloadTask } from '../models/types';
 import { environment } from '../../environments/environment';
+import { AuthCreditService } from './auth-credit.service';
+import { ParseService } from './parse.service';
+import { CLOUD_FN } from './cloud-functions';
 
 @Injectable({ providedIn: 'root' })
 export class DouyinService {
-  private readonly baseUrl = environment.douyinApi.baseUrl;
-  private readonly TIMEOUT = environment.douyinApi.timeout;
   private readonly MAX_RETRIES = environment.douyinApi.maxRetries;
 
-  constructor(private http: HttpClient) {}
+  constructor(private http: HttpClient, private authCredit: AuthCreditService, private parse: ParseService) {}
 
   // 抖音话题搜索 V2
   searchVideos(
@@ -35,16 +36,7 @@ export class DouyinService {
       backtrace: backtrace || ''
     };
 
-    console.log('🔍 发送搜索请求:', {
-      url: `${this.baseUrl}/api/v1/douyin/search/fetch_general_search_v2`,
-      body: requestBody,
-      headers: this.getHeaders()
-    });
-
-    return this.http.post(`${this.baseUrl}/api/v1/douyin/search/fetch_general_search_v2`, requestBody, {
-      headers: this.getHeaders(),
-      timeout: this.TIMEOUT
-    }).pipe(
+    return this.withCreditGate('douyin.search', 1, '视频发现', () => this.callDouyin('searchVideos', { payload: requestBody }).pipe(
       retry({
         count: this.MAX_RETRIES,
         delay: (error, retryCount) => {
@@ -53,57 +45,43 @@ export class DouyinService {
         }
       }),
       catchError(this.handleError)
-    );
+    ));
   }
 
   // 获取视频详情
   getVideoDetail(awemeId: string): Observable<any> {
-    return this.http.get(`${this.baseUrl}/api/v1/douyin/app/v3/fetch_one_video_v3`, {
-      params: { aweme_id: awemeId },
-      headers: this.getHeaders(),
-      timeout: this.TIMEOUT
-    }).pipe(
+    return this.withCreditGate('douyin.videoDetail', 1, '视频详情', () => this.callDouyin('videoDetail', { params: { aweme_id: awemeId } }).pipe(
       retry(this.MAX_RETRIES),
       catchError(this.handleError)
-    );
+    ));
   }
 
   getUserProfileByUniqueId(uniqueId: string): Observable<any> {
-    return this.http.get(`${this.baseUrl}/api/v1/douyin/web/handler_user_profile_v2`, {
-      params: { unique_id: uniqueId },
-      headers: this.getHeaders(),
-      timeout: this.TIMEOUT
-    }).pipe(
+    return this.withCreditGate('douyin.userProfile', 1, '博主资料', () => this.callDouyin('userProfileWeb', { params: { unique_id: uniqueId } }).pipe(
       retry(this.MAX_RETRIES),
       catchError(this.handleError)
-    );
+    ));
   }
 
   getUserProfileBySecUserId(secUserId: string): Observable<any> {
-    return this.http.get(`${this.baseUrl}/api/v1/douyin/app/v3/handler_user_profile`, {
-      params: { sec_user_id: secUserId },
-      headers: this.getHeaders(),
-      timeout: this.TIMEOUT
-    }).pipe(
+    return this.withCreditGate('douyin.userProfile', 1, '博主资料', () => this.callDouyin('userProfileApp', { params: { sec_user_id: secUserId } }).pipe(
       retry(this.MAX_RETRIES),
       catchError(this.handleError)
-    );
+    ));
   }
 
   getUserPostVideos(secUserId: string, maxCursor: string = '0', count: number = 18, filterType: string = '0'): Observable<any> {
-    return this.http.get(`${this.baseUrl}/api/v1/douyin/web/fetch_user_post_videos`, {
+    return this.withCreditGate('douyin.userPosts', 1, '博主作品', () => this.callDouyin('userPosts', {
       params: {
         sec_user_id: secUserId,
         max_cursor: maxCursor,
         count,
         filter_type: filterType
-      },
-      headers: this.getHeaders(),
-      timeout: this.TIMEOUT
+      }
     }).pipe(
       retry(this.MAX_RETRIES),
       catchError(this.handleError)
-    );
+    ));
   }
 
   extractUserProfileData(payload: any): any {
@@ -411,13 +389,39 @@ export class DouyinService {
     return `${author}_${safeTitle}_${video.aweme_id}.mp4`;
   }
 
-  // 获取请求头
-  private getHeaders(): HttpHeaders {
-    return new HttpHeaders({
-      'Content-Type': 'application/json',
-      'Accept': 'application/json',
-      'Authorization': `Bearer ${environment.douyinApi.token}`
-    });
+  private callDouyin(route: string, params: { payload?: Record<string, any>; params?: Record<string, any> }): Observable<any> {
+    if (!CLOUD_FN.douyin) {
+      return throwError(() => new Error('抖音服务暂未配置,请先部署抖音云函数。'));
+    }
+    return from(this.parse.callOrThrow<any>(CLOUD_FN.douyin, {
+      action: 'call',
+      route,
+      ...params,
+    }));
+  }
+
+  private withCreditGate<T>(
+    operation: string,
+    cost: number,
+    title: string,
+    run: () => Observable<T>
+  ): Observable<T> {
+    if (!this.authCredit.isLoggedIn) {
+      this.authCredit.requestLogin(title);
+      return throwError(() => new Error('请先登录后使用该功能'));
+    }
+    let reservationId = '';
+    return from(this.authCredit.reserveCredit(operation, cost, title)).pipe(
+      switchMap((reserved) => {
+        reservationId = reserved.reservationId;
+        return run().pipe(
+          switchMap((value) => from(this.authCredit.commitReservation(reservationId, { title })).pipe(map(() => value))),
+          catchError((err) => from(this.authCredit.refundReservation(reservationId, err?.message || '任务失败')).pipe(
+            switchMap(() => throwError(() => err))
+          ))
+        );
+      })
+    );
   }
 
   // 错误处理
@@ -474,4 +478,4 @@ export class DouyinService {
     console.error('Douyin API Error:', errorMessage, error);
     return throwError(() => new Error(errorMessage));
   }
-}
+}

+ 25 - 11
src/app/services/draft-storage.ts

@@ -23,9 +23,23 @@ const INDEX_KEY = 'tiktok.drafts.index';
 const DRAFT_KEY_PREFIX = 'tiktok.draft.';
 
 export class LocalStorageDraftStorage implements DraftStorage {
+  constructor(private readonly scope = '') {}
+
+  private key(base: string): string {
+    if (!this.scope) return base;
+    const safeScope = this.scope.replace(/[^a-zA-Z0-9_.:-]/g, '_');
+    return `${base}.${safeScope}`;
+  }
+
+  private draftKey(id: string): string {
+    if (!this.scope) return DRAFT_KEY_PREFIX + id;
+    const safeScope = this.scope.replace(/[^a-zA-Z0-9_.:-]/g, '_');
+    return `${DRAFT_KEY_PREFIX}${safeScope}.${id}`;
+  }
+
   async list(): Promise<DraftMeta[]> {
     try {
-      const raw = localStorage.getItem(INDEX_KEY);
+      const raw = localStorage.getItem(this.key(INDEX_KEY));
       if (!raw) return [];
       const arr = JSON.parse(raw);
       return Array.isArray(arr) ? arr : [];
@@ -37,7 +51,7 @@ export class LocalStorageDraftStorage implements DraftStorage {
 
   async get(id: string): Promise<PipelineDraft | null> {
     try {
-      const raw = localStorage.getItem(DRAFT_KEY_PREFIX + id);
+      const raw = localStorage.getItem(this.draftKey(id));
       if (!raw) return null;
       return JSON.parse(raw) as PipelineDraft;
     } catch (e) {
@@ -48,13 +62,13 @@ export class LocalStorageDraftStorage implements DraftStorage {
 
   async put(draft: PipelineDraft): Promise<void> {
     try {
-      localStorage.setItem(DRAFT_KEY_PREFIX + draft.id, JSON.stringify(draft));
+      localStorage.setItem(this.draftKey(draft.id), JSON.stringify(draft));
     } catch (e) {
       // 配额溢出:尝试清最旧的草稿后重试一次
       console.warn('[DraftStorage] put failed, trying to evict oldest', e);
       await this.evictOldest();
       try {
-        localStorage.setItem(DRAFT_KEY_PREFIX + draft.id, JSON.stringify(draft));
+        localStorage.setItem(this.draftKey(draft.id), JSON.stringify(draft));
       } catch (e2) {
         console.error('[DraftStorage] put failed after evict', e2);
         throw e2;
@@ -68,7 +82,7 @@ export class LocalStorageDraftStorage implements DraftStorage {
     else list.unshift(meta);
     list.sort((a, b) => b.updatedAt - a.updatedAt);
     try {
-      localStorage.setItem(INDEX_KEY, JSON.stringify(list));
+      localStorage.setItem(this.key(INDEX_KEY), JSON.stringify(list));
     } catch (e) {
       console.warn('[DraftStorage] index update failed', e);
     }
@@ -76,12 +90,12 @@ export class LocalStorageDraftStorage implements DraftStorage {
 
   async delete(id: string): Promise<void> {
     try {
-      localStorage.removeItem(DRAFT_KEY_PREFIX + id);
+      localStorage.removeItem(this.draftKey(id));
     } catch {}
     const list = await this.list();
     const next = list.filter((m) => m.id !== id);
     try {
-      localStorage.setItem(INDEX_KEY, JSON.stringify(next));
+      localStorage.setItem(this.key(INDEX_KEY), JSON.stringify(next));
     } catch (e) {
       console.warn('[DraftStorage] index delete failed', e);
     }
@@ -91,11 +105,11 @@ export class LocalStorageDraftStorage implements DraftStorage {
     const list = await this.list();
     for (const m of list) {
       try {
-        localStorage.removeItem(DRAFT_KEY_PREFIX + m.id);
+        localStorage.removeItem(this.draftKey(m.id));
       } catch {}
     }
     try {
-      localStorage.removeItem(INDEX_KEY);
+      localStorage.removeItem(this.key(INDEX_KEY));
     } catch {}
   }
 
@@ -108,9 +122,9 @@ export class LocalStorageDraftStorage implements DraftStorage {
     if (!evictable.length) return;
     const victim = evictable[0];
     try {
-      localStorage.removeItem(DRAFT_KEY_PREFIX + victim.id);
+      localStorage.removeItem(this.draftKey(victim.id));
       const next = list.filter((m) => m.id !== victim.id);
-      localStorage.setItem(INDEX_KEY, JSON.stringify(next));
+      localStorage.setItem(this.key(INDEX_KEY), JSON.stringify(next));
       console.warn('[DraftStorage] evicted oldest draft', victim.id);
     } catch {}
   }

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

@@ -0,0 +1,275 @@
+import { Injectable } from '@angular/core';
+import { BehaviorSubject } from 'rxjs';
+import { GenerationTask, GenerationTaskCost, GenerationTaskStatus, GenerationTaskStep, LegacyTaskLike } from '../models/generation-task.model';
+import { userFriendlyError, userFailureSuggestion } from './user-message.util';
+
+const STORAGE_KEY = 'videoWorkflow.generationTasks.v1';
+
+@Injectable({ providedIn: 'root' })
+export class GenerationTaskService {
+  private readonly tasksSubject = new BehaviorSubject<GenerationTask[]>(this.restore());
+  readonly tasks$ = this.tasksSubject.asObservable();
+
+  get tasks(): GenerationTask[] {
+    return this.tasksSubject.value;
+  }
+
+  create(input: {
+    title: string;
+    pipelineId: string;
+    operation: string;
+    steps?: Array<Pick<GenerationTaskStep, 'id' | 'label'>>;
+    estimatedCredits?: number;
+    snapshot?: any;
+  }): GenerationTask {
+    const now = Date.now();
+    const steps = this.normalizeSteps(input.steps);
+    const task: GenerationTask = {
+      id: `GT-${now}-${Math.random().toString(36).slice(2, 8)}`,
+      title: input.title || '未命名任务',
+      pipelineId: input.pipelineId || 'unknown',
+      operation: input.operation || input.pipelineId || 'unknown',
+      status: 'queued',
+      progress: 0,
+      currentStepId: steps[0]?.id || 'prepare',
+      steps,
+      cost: {
+        estimatedCredits: Math.max(0, Math.ceil(Number(input.estimatedCredits || 0))),
+        status: input.estimatedCredits ? 'estimated' : 'unknown',
+      },
+      createdAt: now,
+      updatedAt: now,
+      snapshot: input.snapshot,
+      retryable: false,
+      recoverable: true,
+      externalTaskIds: {},
+    };
+    this.upsert(task);
+    return task;
+  }
+
+  upsert(task: GenerationTask): void {
+    const list = this.tasks;
+    const idx = list.findIndex((item) => item.id === task.id);
+    const next = idx >= 0 ? list.map((item) => item.id === task.id ? task : item) : [task, ...list];
+    this.setTasks(next);
+  }
+
+  markRunning(id: string, stepId?: string, progress?: number): void {
+    this.patch(id, {
+      status: 'running',
+      currentStepId: stepId,
+      progress,
+      retryable: false,
+      errorMessage: undefined,
+      failureSuggestion: undefined,
+    });
+  }
+
+  markWaitingExternal(id: string, externalTaskIds: Record<string, string>, stepId?: string, progress?: number): void {
+    const task = this.find(id);
+    this.patch(id, {
+      status: 'waiting_external',
+      currentStepId: stepId,
+      progress,
+      externalTaskIds: { ...(task?.externalTaskIds || {}), ...externalTaskIds },
+    });
+  }
+
+  markCompleted(id: string, resultUrl?: string): void {
+    this.patch(id, {
+      status: 'completed',
+      progress: 100,
+      resultUrl,
+      retryable: false,
+      recoverable: false,
+      errorMessage: undefined,
+      failureSuggestion: undefined,
+    });
+  }
+
+  markFailed(id: string, error: any, opts: { retryable?: boolean; recoverable?: boolean } = {}): void {
+    const friendly = userFriendlyError(error, '任务失败,请稍后重试');
+    this.patch(id, {
+      status: 'failed',
+      errorMessage: friendly,
+      failureSuggestion: userFailureSuggestion(error),
+      retryable: opts.retryable ?? true,
+      recoverable: opts.recoverable ?? true,
+    });
+  }
+
+  markCancelled(id: string): void {
+    this.patch(id, {
+      status: 'cancelled',
+      errorMessage: '用户已取消',
+      failureSuggestion: '如需继续,请从草稿或任务详情重新发起。',
+      retryable: false,
+      recoverable: true,
+    });
+  }
+
+  patchCost(id: string, partial: Partial<GenerationTaskCost>): void {
+    const task = this.find(id);
+    if (!task) return;
+    this.patch(id, { cost: { ...task.cost, ...partial } });
+  }
+
+  remove(id: string): void {
+    this.setTasks(this.tasks.filter((task) => task.id !== id));
+  }
+
+  normalizeLegacyTask(raw: LegacyTaskLike): LegacyTaskLike {
+    const errorMessage = raw.error_message ? userFriendlyError(raw.error_message, raw.error_message) : undefined;
+    const generationStatus = raw.generationStatus || this.legacyStatusToGeneration(raw.status);
+    const creditStatus = raw.creditStatus || (raw.creditReservationId ? 'reserved' : Number(raw.cost || raw.estimatedCredits || 0) > 0 ? 'estimated' : 'unknown');
+    return {
+      ...raw,
+      generationStatus,
+      estimatedCredits: Math.max(0, Math.ceil(Number(raw.estimatedCredits ?? raw.cost ?? 0))),
+      reservedCredits: raw.reservedCredits,
+      creditStatus,
+      error_message: errorMessage,
+      failureSuggestion: raw.failureSuggestion || (raw.error_message ? userFailureSuggestion(raw.error_message) : undefined),
+      retryable: raw.retryable ?? (raw.status === 'failed' || raw.status === 'timeout'),
+      recoverable: raw.recoverable ?? !!raw.vgData,
+      steps: raw.steps || this.legacySteps(raw.step),
+    };
+  }
+
+  normalizeLegacyTasks<T extends LegacyTaskLike>(tasks: T[]): T[] {
+    return tasks.map((task) => this.normalizeLegacyTask(task) as T);
+  }
+
+  legacyToGenerationTask(raw: LegacyTaskLike): GenerationTask {
+    const normalized = this.normalizeLegacyTask(raw);
+    return {
+      id: normalized.id,
+      title: normalized.original_video_title || normalized.keyword || '未命名任务',
+      pipelineId: normalized.pipelineId || normalized.type || 'legacy',
+      operation: normalized.operation || normalized.type || normalized.step || 'legacy',
+      status: normalized.generationStatus || this.legacyStatusToGeneration(normalized.status),
+      progress: Math.max(0, Math.min(100, Number(normalized.progress || 0))),
+      currentStepId: normalized.step || 'prepare',
+      steps: normalized.steps || this.legacySteps(normalized.step),
+      cost: {
+        estimatedCredits: Math.max(0, Math.ceil(Number(normalized.estimatedCredits ?? normalized.cost ?? 0))),
+        reservedCredits: normalized.reservedCredits,
+        reservationId: normalized.creditReservationId,
+        status: normalized.creditStatus || 'unknown',
+      },
+      createdAt: new Date(normalized.created_at).getTime() || Date.now(),
+      updatedAt: new Date(normalized.updated_at).getTime() || Date.now(),
+      resultUrl: normalized.result_url,
+      errorMessage: normalized.error_message,
+      failureSuggestion: normalized.failureSuggestion,
+      retryable: normalized.retryable,
+      recoverable: normalized.recoverable,
+      externalTaskIds: normalized.externalTaskIds || {},
+      snapshot: normalized.snapshot || normalized.vgData,
+    };
+  }
+
+  private patch(id: string, partial: Partial<GenerationTask>): void {
+    const task = this.find(id);
+    if (!task) return;
+    const next: GenerationTask = {
+      ...task,
+      ...partial,
+      updatedAt: Date.now(),
+      progress: partial.progress === undefined ? task.progress : Math.max(0, Math.min(100, Number(partial.progress || 0))),
+      steps: partial.steps || this.patchStepState(task.steps, partial.currentStepId, partial.status, partial.progress),
+    };
+    this.upsert(next);
+  }
+
+  private patchStepState(steps: GenerationTaskStep[], stepId?: string, status?: GenerationTaskStatus, progress?: number): GenerationTaskStep[] {
+    if (!stepId) return steps;
+    return steps.map((step) => {
+      if (step.id !== stepId) return step;
+      const stepStatus = status === 'completed' ? 'completed' : status === 'failed' ? 'failed' : 'running';
+      return {
+        ...step,
+        status: stepStatus,
+        progress: progress === undefined ? step.progress : Math.max(0, Math.min(100, Number(progress || 0))),
+        startedAt: step.startedAt || Date.now(),
+        finishedAt: stepStatus === 'completed' || stepStatus === 'failed' ? Date.now() : step.finishedAt,
+        retryable: stepStatus === 'failed',
+      };
+    });
+  }
+
+  private find(id: string): GenerationTask | undefined {
+    return this.tasks.find((task) => task.id === id);
+  }
+
+  private setTasks(tasks: GenerationTask[]): void {
+    const sorted = [...tasks].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 100);
+    this.tasksSubject.next(sorted);
+    this.persist(sorted);
+  }
+
+  private normalizeSteps(steps?: Array<Pick<GenerationTaskStep, 'id' | 'label'>>): GenerationTaskStep[] {
+    const source = steps?.length ? steps : [
+      { id: 'prepare', label: '准备任务' },
+      { id: 'generate', label: '生成内容' },
+      { id: 'archive', label: '归档结果' },
+    ];
+    return source.map((step, index) => ({
+      id: step.id,
+      label: step.label,
+      status: index === 0 ? 'pending' : 'pending',
+      progress: 0,
+      retryable: false,
+    }));
+  }
+
+  private legacySteps(currentStep: string): GenerationTaskStep[] {
+    const labels: Record<string, string> = {
+      search: '视频搜索',
+      detail: '详情获取',
+      comments: '评论获取',
+      download: '视频下载',
+      transcribe: '语音转文字',
+      generate: 'AI 生成',
+      complete: '完成',
+      'vg-upload': '上传视频',
+      'vg-analyze': '视频解析',
+      'vg-script': '脚本生成',
+      'vg-image': '配图生成',
+      'vg-voice': '语音配音',
+      'vg-composite': '视频合成',
+    };
+    return Object.entries(labels).map(([id, label]) => ({
+      id,
+      label,
+      status: id === currentStep ? 'running' : 'pending',
+      progress: id === currentStep ? 1 : 0,
+      retryable: false,
+    }));
+  }
+
+  private legacyStatusToGeneration(status: string): GenerationTaskStatus {
+    if (status === 'completed') return 'completed';
+    if (status === 'failed' || status === 'timeout') return 'failed';
+    if (status === 'pending') return 'queued';
+    if (status === 'processing') return 'running';
+    return 'draft';
+  }
+
+  private restore(): GenerationTask[] {
+    try {
+      const raw = localStorage.getItem(STORAGE_KEY);
+      const parsed = raw ? JSON.parse(raw) : [];
+      return Array.isArray(parsed) ? parsed : [];
+    } catch {
+      return [];
+    }
+  }
+
+  private persist(tasks: GenerationTask[]): void {
+    try {
+      localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
+    } catch {}
+  }
+}

+ 24 - 63
src/app/services/jimeng-api.service.ts

@@ -1,7 +1,8 @@
 import { Injectable } from '@angular/core';
-import { HttpClient, HttpHeaders } from '@angular/common/http';
-import { Observable, throwError, timer } from 'rxjs';
+import { Observable, from, throwError, timer } from 'rxjs';
 import { catchError, retry, switchMap } from 'rxjs/operators';
+import { ParseService } from './parse.service';
+import { CLOUD_FN } from './cloud-functions';
 
 // 即梦AI API接口定义
 interface JimengVideoGenerationParams {
@@ -32,11 +33,9 @@ interface JimengTaskQueryParams {
   providedIn: 'root'
 })
 export class JimengApiService {
-  private readonly BASE_URL = 'https://server.fmode.cn/api/volcengine/jimeng';
-  private readonly TIMEOUT = 60000;
   private readonly MAX_RETRIES = 3;
 
-  constructor(private http: HttpClient) {}
+  constructor(private parse: ParseService) {}
 
   /**
    * 文生视频 (高质量版本)
@@ -44,21 +43,15 @@ export class JimengApiService {
    * @returns 任务ID
    */
   generateVideoPro(params: JimengVideoGenerationParams): Observable<any> {
-    const url = `${this.BASE_URL}/getVideoV3_Pro`;
-
     const defaultParams = {
       frames: 121,
       aspect_ratio: 1, // 16:9
-      method: 'text2video',
-      token: this.getToken()
+      method: 'text2video'
     };
 
     const requestParams = { ...defaultParams, ...params };
 
-    return this.http.post(url, requestParams, {
-      headers: this.getHeaders(),
-      timeout: this.TIMEOUT
-    }).pipe(
+    return this.callJimeng('getVideoV3_Pro', requestParams).pipe(
       retry(this.MAX_RETRIES),
       catchError(this.handleError)
     );
@@ -70,21 +63,15 @@ export class JimengApiService {
    * @returns 任务ID
    */
   generateVideo720p(params: JimengVideoGenerationParams): Observable<any> {
-    const url = `${this.BASE_URL}/getVideoV3_720p`;
-
     const defaultParams = {
       frames: 121,
       aspect_ratio: 1,
-      method: 'text2video',
-      token: this.getToken()
+      method: 'text2video'
     };
 
     const requestParams = { ...defaultParams, ...params };
 
-    return this.http.post(url, requestParams, {
-      headers: this.getHeaders(),
-      timeout: this.TIMEOUT
-    }).pipe(
+    return this.callJimeng('getVideoV3_720p', requestParams).pipe(
       retry(this.MAX_RETRIES),
       catchError(this.handleError)
     );
@@ -96,22 +83,16 @@ export class JimengApiService {
    * @returns 任务ID
    */
   generateImageV31(params: JimengImageGenerationParams): Observable<any> {
-    const url = `${this.BASE_URL}/getText2ImgV31`;
-
     const defaultParams = {
       sizeData: {
         width: 1024,
         height: 1024
-      },
-      token: this.getToken()
+      }
     };
 
     const requestParams = { ...defaultParams, ...params };
 
-    return this.http.post(url, requestParams, {
-      headers: this.getHeaders(),
-      timeout: this.TIMEOUT
-    }).pipe(
+    return this.callJimeng('getText2ImgV31', requestParams).pipe(
       retry(this.MAX_RETRIES),
       catchError(this.handleError)
     );
@@ -123,17 +104,11 @@ export class JimengApiService {
    * @returns 任务状态
    */
   queryTaskStatus(task_id: string): Observable<any> {
-    const url = `${this.BASE_URL}/getDataByTask02`;
-
     const params = {
-      task_id,
-      token: this.getToken()
+      task_id
     };
 
-    return this.http.post(url, params, {
-      headers: this.getHeaders(),
-      timeout: this.TIMEOUT
-    }).pipe(
+    return this.callJimeng('getDataByTask02', params).pipe(
       retry(this.MAX_RETRIES),
       catchError(this.handleError)
     );
@@ -145,17 +120,7 @@ export class JimengApiService {
    * @returns 生成结果
    */
   getWorkResult(task_id: string): Observable<any> {
-    const url = `${this.BASE_URL}/getWorkResult`;
-
-    const params = {
-      task_id,
-      token: this.getToken()
-    };
-
-    return this.http.post(url, params, {
-      headers: this.getHeaders(),
-      timeout: this.TIMEOUT
-    }).pipe(
+    return this.getWorkResultByProxy(task_id).pipe(
       retry(this.MAX_RETRIES),
       catchError(this.handleError)
     );
@@ -226,22 +191,18 @@ export class JimengApiService {
     });
   }
 
-  /**
-   * 获取API认证Token
-   */
-  private getToken(): string {
-    // 这里应该从环境变量或安全存储中获取
-    return 'your-jimeng-api-token';
+  private callJimeng<T = any>(endpoint: string, payload: Record<string, any>): Observable<T> {
+    if (!CLOUD_FN.jimeng) {
+      return throwError(() => new Error('生成服务暂未配置,请先部署即梦云函数。'));
+    }
+    return from(this.parse.callOrThrow<T>(CLOUD_FN.jimeng, { action: 'call', endpoint, payload }));
   }
 
-  /**
-   * 获取请求头
-   */
-  private getHeaders(): HttpHeaders {
-    return new HttpHeaders({
-      'Content-Type': 'application/json',
-      'Accept': 'application/json'
-    });
+  private getWorkResultByProxy<T = any>(workId: string): Observable<T> {
+    if (!CLOUD_FN.jimeng) {
+      return throwError(() => new Error('生成服务暂未配置,请先部署即梦云函数。'));
+    }
+    return from(this.parse.callOrThrow<T>(CLOUD_FN.jimeng, { action: 'getWorkResult', workId }));
   }
 
   /**
@@ -261,4 +222,4 @@ export class JimengApiService {
     console.error('Jimeng API Error:', errorMessage);
     return throwError(() => new Error(errorMessage));
   }
-}
+}

+ 21 - 8
src/app/services/jimeng-workflow.service.ts

@@ -1,14 +1,13 @@
 import { Injectable } from '@angular/core';
-import { HttpClient } from '@angular/common/http';
 import { Observable, from, of, throwError } from 'rxjs';
 import { switchMap, delay, map, catchError, concatMap, toArray } from 'rxjs/operators';
 import { StoryboardSegment } from '../models/types';
+import { ParseService } from './parse.service';
+import { CLOUD_FN } from './cloud-functions';
 
 @Injectable({ providedIn: 'root' })
 export class JimengWorkflowService {
-  private readonly baseUrl = 'https://server.fmode.cn/api/volcengine/jimeng';
-
-  constructor(private http: HttpClient) {}
+  constructor(private parse: ParseService) {}
 
   // Stage 1: AI 分镜脚本生成 (此处通常调用后端LLM,前端做Mock演示)
   generateStoryboard(transcript: string, segmentDuration: number = 15): Observable<StoryboardSegment[]> {
@@ -33,7 +32,7 @@ export class JimengWorkflowService {
       frames: 121 // 5秒默认值
     };
 
-    return this.http.post<any>(`${this.baseUrl}/getVideoV3_720p`, payload).pipe(
+    return this.callJimeng<any>('getVideoV3_720p', payload).pipe(
       switchMap(res => {
         if (!res.workId) return throwError(() => new Error('Task creation failed'));
         return this.pollTaskStatus(res.workId, 'getVideoV3_720p');
@@ -43,11 +42,11 @@ export class JimengWorkflowService {
 
   // Stage 3: 轮询获取结果
   private pollTaskStatus(workId: string, routerName: string): Observable<string> {
-    return this.http.post<any>(`${this.baseUrl}/getDataByTask02`, { workId, routerName }).pipe(
+    return this.callJimeng<any>('getDataByTask02', { workId, routerName }).pipe(
       switchMap(res => {
         if (res.isFinish) {
           // 任务完成,获取结果URL
-          return this.http.post<any>(`${this.baseUrl}/getWorkResult`, { workId }).pipe(
+          return this.getWorkResult(workId).pipe(
             map(result => result.videos[0])
           );
         } else {
@@ -61,6 +60,20 @@ export class JimengWorkflowService {
     );
   }
 
+  private callJimeng<T = any>(endpoint: string, payload: Record<string, any>): Observable<T> {
+    if (!CLOUD_FN.jimeng) {
+      return throwError(() => new Error('生成服务暂未配置,请先部署即梦云函数。'));
+    }
+    return from(this.parse.callOrThrow<T>(CLOUD_FN.jimeng, { action: 'call', endpoint, payload }));
+  }
+
+  private getWorkResult(workId: string): Observable<any> {
+    if (!CLOUD_FN.jimeng) {
+      return throwError(() => new Error('生成服务暂未配置,请先部署即梦云函数。'));
+    }
+    return from(this.parse.callOrThrow<any>(CLOUD_FN.jimeng, { action: 'getWorkResult', workId }));
+  }
+
   // 执行完整链路
   executeFullWorkflow(transcript: string): Observable<any> {
     return this.generateStoryboard(transcript).pipe(
@@ -78,4 +91,4 @@ export class JimengWorkflowService {
       )
     );
   }
-}
+}

+ 165 - 62
src/app/services/jimeng.service.ts

@@ -1,9 +1,12 @@
 import { Injectable } from '@angular/core';
-import { HttpClient, HttpHeaders } from '@angular/common/http';
-import { Observable, Subject, timer, throwError, of } from 'rxjs';
+import { HttpClient } from '@angular/common/http';
+import { Observable, Subject, timer, throwError, of, from } from 'rxjs';
 import { switchMap, takeWhile, tap, catchError, map } from 'rxjs/operators';
 import { environment } from '../../environments/environment';
 import { QiniuUploadService } from './qiniu-upload.service';
+import { AuthCreditService } from './auth-credit.service';
+import { ParseService } from './parse.service';
+import { CLOUD_FN } from './cloud-functions';
 
 // MD5 hash function (lightweight, for signing only)
 function md5(input: string): string {
@@ -164,10 +167,6 @@ export interface RemixTask {
 })
 export class JimengService {
   private readonly digitalHumanSubmitRetryableCodes = new Set([50429, 50430, 50500, 50501]);
-  private readonly baseUrl = environment.jimengApi.baseUrl;
-  private readonly parseBaseUrl = environment.jimengApi.parseBaseUrl;
-  private readonly parseAppId = environment.jimengApi.parseAppId;
-  private readonly token = environment.jimengApi.token;
   private readonly pollInterval = environment.jimengApi.pollIntervalMs;
   private readonly pollMaxAttempts = environment.jimengApi.pollMaxAttempts;
   private readonly digitalHumanPollMaxAttempts = Math.max(environment.jimengApi.pollMaxAttempts, 120);
@@ -176,23 +175,43 @@ export class JimengService {
   constructor(
     private http: HttpClient,
     private qiniuUpload: QiniuUploadService,
+    private authCredit: AuthCreditService,
+    private parse: ParseService,
   ) {}
 
+  private jimengCall<T = any>(endpoint: string, payload: Record<string, any>): Observable<T> {
+    if (!CLOUD_FN.jimeng) {
+      return throwError(() => new Error('生成服务暂未配置,请先部署即梦云函数。'));
+    }
+    return from(this.parse.callOrThrow<T>(CLOUD_FN.jimeng, {
+      action: 'call',
+      endpoint,
+      payload,
+    }));
+  }
+
+  private jimengWorkResult(workId: string): Observable<JimengWorkResult> {
+    if (!CLOUD_FN.jimeng) {
+      return throwError(() => new Error('生成服务暂未配置,请先部署即梦云函数。'));
+    }
+    return from(this.parse.callOrThrow<JimengWorkResult>(CLOUD_FN.jimeng, {
+      action: 'getWorkResult',
+      workId,
+    }));
+  }
+
   // 提交文生视频任务 (720p)
   generateVideo720p(request: JimengVideoRequest): Observable<any> {
     const body = {
       prompt: request.prompt,
       method: request.method,
       frames: request.frames || 121,
-      config: request.config || { aspect_ratio: '16:9' },
-      token: this.token
+      config: request.config || { aspect_ratio: '16:9' }
     };
 
     console.log('🎬 即梦API - 提交视频生成任务:', body);
 
-    return this.http.post(`${this.baseUrl}/getVideoV3_720p`, body, {
-      headers: new HttpHeaders({ 'Content-Type': 'application/json' })
-    }).pipe(
+    return this.jimengCall('getVideoV3_720p', body).pipe(
       tap((res: any) => console.log('🎬 即梦API - 任务提交响应:', res)),
       catchError(this.handleError('generateVideo720p'))
     );
@@ -204,15 +223,12 @@ export class JimengService {
       prompt: request.prompt,
       method: request.method,
       frames: request.frames || 121,
-      config: request.config || { aspect_ratio: '16:9' },
-      token: this.token
+      config: request.config || { aspect_ratio: '16:9' }
     };
 
     console.log('🎬 即梦API - 提交1080p视频生成任务:', body);
 
-    return this.http.post(`${this.baseUrl}/getVideoV3_1080p`, body, {
-      headers: new HttpHeaders({ 'Content-Type': 'application/json' })
-    }).pipe(
+    return this.jimengCall('getVideoV3_1080p', body).pipe(
       tap((res: any) => console.log('🎬 即梦API - 1080p任务提交响应:', res)),
       catchError(this.handleError('generateVideo1080p'))
     );
@@ -244,7 +260,6 @@ export class JimengService {
       prompt: request.prompt,
       frames: request.frames || 121,
       aspect_ratio: aspectCode,
-      token: this.token,
     };
     if (request.method === '2' && imageUrl) {
       body.image_url = imageUrl;
@@ -252,9 +267,7 @@ export class JimengService {
 
     console.log('🎬 即梦API - 提交Pro视频生成任务:', body);
 
-    return this.http.post(`${this.baseUrl}/getVideoV3_Pro`, body, {
-      headers: new HttpHeaders({ 'Content-Type': 'application/json' })
-    }).pipe(
+    return this.jimengCall('getVideoV3_Pro', body).pipe(
       tap((res: any) => console.log('🎬 即梦API - Pro任务提交响应:', res)),
       catchError(this.handleError('generateVideoPro'))
     );
@@ -290,6 +303,30 @@ export class JimengService {
     },
     onProgress?: (status: string, progress: number) => void,
     options?: { scale?: number; forceSingle?: boolean }
+  ): Observable<{ videoUrl: string; workId: string }> {
+    const seconds = this.framesToSeconds(params.frames || 121);
+    const cost = this.videoCreditCost(params.quality, seconds);
+    return this.withCreditGate(
+      'jimeng.imageToVideo',
+      cost,
+      `图生视频 ${params.quality} ${seconds} 秒`,
+      () => this.generateImageToVideoUnlocked(params, onProgress, options)
+    );
+  }
+
+  private generateImageToVideoUnlocked(
+    params: {
+      imageUrls: string[];
+      prompt: string;
+      method: '2' | '3' | '4';
+      quality: JimengVideoQuality;
+      frames?: number;
+      cameraTemplate?: JimengCameraTemplateId;
+      cameraStrength?: JimengCameraStrength;
+      aspectRatio?: JimengAspectRatio;
+    },
+    onProgress?: (status: string, progress: number) => void,
+    options?: { scale?: number; forceSingle?: boolean }
   ): Observable<{ videoUrl: string; workId: string }> {
     // ---- 校验 ----
     if (params.method === '3' && params.quality === 'pro') {
@@ -374,15 +411,12 @@ export class JimengService {
   generateImage(prompt: string, width: number = 1920, height: number = 1080): Observable<any> {
     const body = {
       prompt: prompt,
-      sizeDate: { width, height },
-      token: this.token
+      sizeDate: { width, height }
     };
 
     console.log('🖼️ 即梦API - 提交图片生成任务:', body);
 
-    return this.http.post(`${this.baseUrl}/getText2ImgV31`, body, {
-      headers: new HttpHeaders({ 'Content-Type': 'application/json' })
-    }).pipe(
+    return this.jimengCall('getText2ImgV31', body).pipe(
       tap((res: any) => console.log('🖼️ 即梦API - 图片任务提交响应:', res)),
       catchError(this.handleError('generateImage'))
     );
@@ -401,15 +435,12 @@ export class JimengService {
       image_urls: imageUrls,
       sizeDate,
       scale,
-      force_single: forceSingle,
-      token: this.token
+      force_single: forceSingle
     };
 
     console.log('🖼️ 即梦API v4 - 提交图片生成任务:', body);
 
-    return this.http.post<any>(`${this.baseUrl}/getImgV4`, body, {
-      headers: new HttpHeaders({ 'Content-Type': 'application/json' })
-    }).pipe(
+    return this.jimengCall<any>('getImgV4', body).pipe(
       map((res: any) => {
         const workId = res?.data?.workId || res?.data?.taskId || '';
         if (res?.code !== 200 || !workId) {
@@ -429,6 +460,21 @@ export class JimengService {
     sizeDate: { width: number; height: number } = { width: 2048, height: 2048 },
     onProgress?: (status: string, progress: number) => void,
     options?: { scale?: number; forceSingle?: boolean }
+  ): Observable<string> {
+    return this.withCreditGate(
+      'jimeng.imageV4',
+      3,
+      '图片生成',
+      () => this.generateImgV4Unlocked(prompt, imageUrls, sizeDate, onProgress, options)
+    );
+  }
+
+  private generateImgV4Unlocked(
+    prompt: string,
+    imageUrls: string[] = [],
+    sizeDate: { width: number; height: number } = { width: 2048, height: 2048 },
+    onProgress?: (status: string, progress: number) => void,
+    options?: { scale?: number; forceSingle?: boolean }
   ): Observable<string> {
     if (onProgress) onProgress('正在提交图片生成任务...', 5);
 
@@ -467,27 +513,17 @@ export class JimengService {
   queryTask(workId: string, routerName: string): Observable<any> {
     const body = {
       workId: workId,
-      routerName: routerName,
-      token: this.token
+      routerName: routerName
     };
 
-    return this.http.post(`${this.baseUrl}/getDataByTask02`, body, {
-      headers: new HttpHeaders({ 'Content-Type': 'application/json' })
-    }).pipe(
+    return this.jimengCall('getDataByTask02', body).pipe(
       catchError(this.handleError('queryTask'))
     );
   }
 
   // 获取作品结果(通过 Parse API 查询 ImagineWork 表)
   getWorkResult(workId: string): Observable<JimengWorkResult> {
-    return this.http.get<JimengWorkResult>(
-      `${this.parseBaseUrl}/classes/ImagineWork/${workId}`,
-      {
-        headers: new HttpHeaders({
-          'X-Parse-Application-Id': this.parseAppId
-        })
-      }
-    ).pipe(
+    return this.jimengWorkResult(workId).pipe(
       tap((res: any) => console.log('📦 即梦API - 作品结果:', res)),
       catchError(this.handleError('getWorkResult'))
     );
@@ -502,13 +538,10 @@ export class JimengService {
 
   identifyDigitalHuman(imageUrl: string): Observable<{ workId: string }> {
     const body = {
-      image_url: imageUrl,
-      token: this.token
+      image_url: imageUrl
     };
 
-    return this.http.post<any>(`${this.baseUrl}/getOhIdentifyMain`, body, {
-      headers: new HttpHeaders({ 'Content-Type': 'application/json' })
-    }).pipe(
+    return this.jimengCall<any>('getOhIdentifyMain', body).pipe(
       map((res: any) => {
         const workId = res?.data?.workId || res?.data?.taskId || '';
         if (res?.code !== 200 || !workId) {
@@ -526,13 +559,10 @@ export class JimengService {
     const body = {
       workId,
       taskId: workId,
-      routerName,
-      token: this.token
+      routerName
     };
 
-    return this.http.post<any>(`${this.baseUrl}/getOhDateByTask`, body, {
-      headers: new HttpHeaders({ 'Content-Type': 'application/json' })
-    }).pipe(
+    return this.jimengCall<any>('getOhDateByTask', body).pipe(
       catchError(this.handleError('queryDigitalHumanTask'))
     );
   }
@@ -781,8 +811,7 @@ export class JimengService {
       prompt: options.prompt || '',
       pe_fast_mode: !!options.peFastMode,
       output_resolution: outputResolution,
-      mask_url: options.maskUrl || [],
-      token: this.token
+      mask_url: options.maskUrl || []
     };
 
     if (onProgress) onProgress('正在提交数字人生成任务...', 5);
@@ -830,9 +859,7 @@ export class JimengService {
     onProgress?: (status: string, progress: number) => void,
     attempt: number = 1
   ): Observable<any> {
-    return this.http.post<any>(`${this.baseUrl}/getOmniHuman`, body, {
-      headers: new HttpHeaders({ 'Content-Type': 'application/json' })
-    }).pipe(
+    return this.jimengCall<any>('getOmniHuman', body).pipe(
       tap((res: any) => console.log('🎭 即梦API - 数字人生成提交响应:', res)),
       catchError((err) => {
         const retryCode = this.extractJimengErrorCode(err);
@@ -946,6 +973,30 @@ export class JimengService {
   ): Observable<{ videoUrl: string; workId: string }> {
     const method = options.method || '1';
     const quality = options.quality || '1080p';
+    const seconds = this.framesToSeconds(options.frames || 121);
+    const cost = this.videoCreditCost(quality, seconds);
+
+    return this.withCreditGate(
+      'jimeng.video',
+      cost,
+      `视频生成 ${quality} ${seconds} 秒`,
+      () => this.remixVideoUnlocked(prompt, options, onProgress)
+    );
+  }
+
+  private remixVideoUnlocked(
+    prompt: string,
+    options: {
+      method?: '1' | '2';
+      frames?: number;
+      aspectRatio?: JimengAspectRatio;
+      imageUrl?: string;
+      quality?: JimengVideoQuality;
+    } = {},
+    onProgress?: (status: string, progress: number) => void
+  ): Observable<{ videoUrl: string; workId: string }> {
+    const method = options.method || '1';
+    const quality = options.quality || '1080p';
 
     const request: JimengVideoRequest = {
       prompt,
@@ -1015,14 +1066,11 @@ export class JimengService {
       image_url: imageUrl,
       video_url: videoUrl,
       cut_result_first_second_switch: options.cutFirstSecond !== false,
-      token: this.token,
     };
 
     console.log('💃 即梦API - 提交动作迁移任务:', { imageUrl, videoUrl, ...options });
 
-    return this.http.post<any>(`${this.baseUrl}/getActorV2`, body, {
-      headers: new HttpHeaders({ 'Content-Type': 'application/json' })
-    }).pipe(
+    return this.jimengCall<any>('getActorV2', body).pipe(
       map((res: any) => {
         const workId = res?.data?.workId || res?.data?.taskId || '';
         if (res?.code !== 200 || !workId) {
@@ -1043,6 +1091,20 @@ export class JimengService {
     videoUrl: string,
     options: { cutFirstSecond?: boolean } = {},
     onProgress?: (status: string, progress: number) => void
+  ): Observable<{ videoUrl: string; workId: string }> {
+    return this.withCreditGate(
+      'jimeng.actionTransfer',
+      20,
+      '动作迁移',
+      () => this.actionTransferUnlocked(imageUrl, videoUrl, options, onProgress)
+    );
+  }
+
+  private actionTransferUnlocked(
+    imageUrl: string,
+    videoUrl: string,
+    options: { cutFirstSecond?: boolean } = {},
+    onProgress?: (status: string, progress: number) => void
   ): Observable<{ videoUrl: string; workId: string }> {
     if (onProgress) onProgress('正在提交动作迁移任务...', 5);
 
@@ -1185,6 +1247,47 @@ export class JimengService {
     });
   }
 
+  private withCreditGate<T>(
+    operation: string,
+    cost: number,
+    title: string,
+    run: () => Observable<T>
+  ): Observable<T> {
+    const normalizedCost = Math.max(1, Math.ceil(Number(cost || 0)));
+    if (!this.authCredit.isLoggedIn) {
+      this.authCredit.requestLogin(title);
+      return throwError(() => new Error('请先登录后使用该功能'));
+    }
+
+    let reservationId = '';
+    return from(this.authCredit.reserveCredit(operation, normalizedCost, title)).pipe(
+      switchMap((reserved) => {
+        reservationId = reserved.reservationId;
+        return run().pipe(
+          switchMap((value) =>
+            from(this.authCredit.commitReservation(reservationId, { title })).pipe(
+              map(() => value)
+            )
+          ),
+          catchError((err) =>
+            from(this.authCredit.refundReservation(reservationId, err?.message || '任务失败')).pipe(
+              switchMap(() => throwError(() => err))
+            )
+          )
+        );
+      })
+    );
+  }
+
+  private framesToSeconds(frames: number): number {
+    return Number(frames || 121) >= 241 ? 10 : 5;
+  }
+
+  private videoCreditCost(quality: JimengVideoQuality, seconds: number): number {
+    const cnyPerSecond = quality === 'pro' ? 1 : quality === '1080p' ? 0.63 : 0.28;
+    return Math.ceil(cnyPerSecond * seconds * 10);
+  }
+
   private handleError(operation: string) {
     return (error: any): Observable<never> => {
       console.error(`即梦API [${operation}] 错误:`, error);

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

@@ -73,6 +73,15 @@ export class PipelineSessionService {
     this.storage = s;
   }
 
+  async useStorageAndReload(s: DraftStorage): Promise<void> {
+    this.flushSave();
+    this.storage = s;
+    this.bootstrapped = false;
+    this.active.set(null);
+    this.list.set([]);
+    await this.bootstrap();
+  }
+
   /** 应用启动期一次:把索引拉进 signal */
   async bootstrap(): Promise<void> {
     if (this.bootstrapped) return;

+ 80 - 40
src/app/services/qiniu-upload.service.ts

@@ -62,62 +62,102 @@ export class QiniuUploadService {
       throw new Error('上传云函数 ID 未配置');
     }
 
-    const res = await this.parse.call<QiniuUploadToken>(CLOUD_FN.upload, {
-      action: 'createUploadToken',
-      filename,
-      mimeType,
-      size,
-      kind,
-    });
-
-    if (res.code !== 200 || !res.success || !res.data?.token || !res.data?.url) {
-      throw new Error(res.error || '获取七牛上传凭证失败');
+    let lastError = '获取七牛上传凭证失败';
+    for (let attempt = 1; attempt <= 3; attempt += 1) {
+      const res = await this.parse.call<QiniuUploadToken>(CLOUD_FN.upload, {
+        action: 'createUploadToken',
+        filename,
+        mimeType,
+        size,
+        kind,
+      });
+
+      if (res.code === 200 && res.success && res.data?.token && res.data?.url) {
+        return res.data;
+      }
+
+      lastError = res.error || lastError;
+      if (attempt < 3 && this.isRetryableUploadTokenError(res.code, lastError)) {
+        await this.sleep(800 * attempt);
+        continue;
+      }
+      break;
     }
 
-    return res.data;
+    throw new Error(lastError);
   }
 
   private uploadToQiniu(file: File, token: QiniuUploadToken): Observable<QiniuUploadProgress> {
     return new Observable<QiniuUploadProgress>((observer) => {
-      const formData = new FormData();
-      formData.append('token', token.token);
-      formData.append('key', token.key);
-      formData.append('file', file, file.name);
-
-      const xhr = new XMLHttpRequest();
-      xhr.open('POST', token.uploadUrl, true);
-
-      xhr.upload.onprogress = (event) => {
-        if (!event.lengthComputable) return;
-        const progress = Math.max(0, Math.min(99, Math.round((event.loaded / event.total) * 100)));
-        this.zone.run(() => observer.next({ state: 'progress', progress }));
-      };
-
-      xhr.onload = () => {
-        this.zone.run(() => {
-          if (xhr.status < 200 || xhr.status >= 300) {
-            observer.error(new Error(this.readQiniuError(xhr)));
-            return;
-          }
-          observer.next({ state: 'done', progress: 100, url: token.url, key: token.key });
-          observer.complete();
-        });
-      };
-
-      xhr.onerror = () => {
-        this.zone.run(() => observer.error(new Error('七牛上传网络错误')));
+      let xhr: XMLHttpRequest | null = null;
+      let cancelled = false;
+
+      const startUpload = (attempt: number) => {
+        if (cancelled) return;
+        const formData = new FormData();
+        formData.append('token', token.token);
+        formData.append('key', token.key);
+        formData.append('file', file, file.name);
+
+        xhr = new XMLHttpRequest();
+        xhr.open('POST', token.uploadUrl, true);
+
+        xhr.upload.onprogress = (event) => {
+          if (!event.lengthComputable) return;
+          const progress = Math.max(0, Math.min(99, Math.round((event.loaded / event.total) * 100)));
+          this.zone.run(() => observer.next({ state: 'progress', progress }));
+        };
+
+        xhr.onload = () => {
+          this.zone.run(() => {
+            if (!xhr) return;
+            if (xhr.status < 200 || xhr.status >= 300) {
+              const message = this.readQiniuError(xhr);
+              if (attempt < 3 && xhr.status >= 500) {
+                setTimeout(() => startUpload(attempt + 1), 800 * attempt);
+                return;
+              }
+              observer.error(new Error(message));
+              return;
+            }
+            observer.next({ state: 'done', progress: 100, url: token.url, key: token.key });
+            observer.complete();
+          });
+        };
+
+        xhr.onerror = () => {
+          this.zone.run(() => {
+            if (attempt < 3) {
+              setTimeout(() => startUpload(attempt + 1), 800 * attempt);
+              return;
+            }
+            observer.error(new Error('七牛上传网络错误'));
+          });
+        };
+
+        xhr.send(formData);
       };
 
-      xhr.send(formData);
+      startUpload(1);
 
       return () => {
-        if (xhr.readyState !== XMLHttpRequest.DONE) {
+        cancelled = true;
+        if (xhr && xhr.readyState !== XMLHttpRequest.DONE) {
           xhr.abort();
         }
       };
     });
   }
 
+  private isRetryableUploadTokenError(code: number, message: string): boolean {
+    if (code === 0 || code >= 500 || code === 408 || code === 429) return true;
+    return /fetch|network|timeout|ECONN|连接|网络|Failed/i.test(message || '');
+  }
+
+  private sleep(ms: number): Promise<void> {
+    return new Promise((resolve) => setTimeout(resolve, ms));
+  }
+
   private readQiniuError(xhr: XMLHttpRequest): string {
     try {
       const payload = JSON.parse(xhr.responseText || '{}');

+ 93 - 0
src/app/services/user-message.util.ts

@@ -0,0 +1,93 @@
+export function userFriendlyError(error: any, fallback = '操作失败,请稍后重试', isAdmin = false): string {
+  const raw = readError(error);
+  if (!raw) return fallback;
+  const lower = raw.toLowerCase();
+  let publicText = fallback;
+
+  if (/积分不足|余额不足|insufficient|quota|balance/.test(lower)) {
+    publicText = '积分不足,请前往用户中心充值或联系管理员。';
+  } else if (/请先登录|登录已过期|unauthorized|401|403|auth/.test(lower)) {
+    publicText = '登录状态已失效,请重新登录后再试。';
+  } else if (/failed to fetch|network|err_connection|timeout|timed out|网络|连接|超时/.test(lower)) {
+    publicText = '网络连接不稳定,请稍后重试。';
+  } else if (/上传|upload/.test(lower)) {
+    publicText = '上传失败,请检查文件和网络后重试。';
+  } else if (/下载|download/.test(lower)) {
+    publicText = '下载失败,请稍后重试。';
+  } else if (/token|request_id|workid|objectid|speaker_id|timbreid|routername|http|server\.fmode|api|接口|云函数/.test(lower)) {
+    publicText = fallback || '服务暂时不可用,请稍后重试。';
+  } else {
+    publicText = raw
+      .replace(/即梦|火山引擎|Gemini|Whisper|TTS|ffmpeg|API|接口|token|Token/g, '')
+      .replace(/\s+/g, ' ')
+      .trim() || fallback;
+  }
+
+  return isAdmin && raw !== publicText
+    ? `${publicText}(管理员详情:${raw.slice(0, 180)})`
+    : publicText;
+}
+
+export function userFailureSuggestion(error: any): string {
+  const raw = readError(error);
+  const lower = raw.toLowerCase();
+
+  if (/积分不足|余额不足|insufficient|quota|balance/.test(lower)) {
+    return '请先充值或联系管理员加积分,也可以降低清晰度、缩短时长后重试。';
+  }
+  if (/请先登录|登录已过期|unauthorized|401|403|auth/.test(lower)) {
+    return '请重新登录后回到任务管理页继续。';
+  }
+  if (/429|并发|频繁|rate|limit|too many/.test(lower)) {
+    return '上游服务限流,建议等待 1-3 分钟后重试当前步骤。';
+  }
+  if (/timeout|timed out|超时/.test(lower)) {
+    return '任务可能仍在上游生成中,建议稍后进入任务管理页恢复或重试。';
+  }
+  if (/failed to fetch|network|err_connection|网络|连接/.test(lower)) {
+    return '请检查网络连接,确认素材 URL 可访问后重试。';
+  }
+  if (/上传|upload/.test(lower)) {
+    return '请重新上传素材,确保文件未损坏且大小符合限制。';
+  }
+  if (/下载|download/.test(lower)) {
+    return '请稍后重试下载;如果仍失败,可以先查看视频详情确认资源有效。';
+  }
+  if (/workid|task|任务|result|未返回/.test(lower)) {
+    return '请保留任务记录,稍后尝试恢复轮询;如果仍无结果再重试生成。';
+  }
+  return '建议先重试当前步骤;如果连续失败,请调整输入参数或降低生成规格。';
+}
+
+function readError(error: any): string {
+  const candidates = [
+    error?.error?.error?.message,
+    error?.error?.error,
+    error?.error?.message,
+    error?.error?.msg,
+    error?.message,
+    error,
+  ];
+  for (const item of candidates) {
+    const text = readValue(item);
+    if (text) return text;
+  }
+  return '';
+}
+
+function readValue(value: any): string {
+  if (value == null) return '';
+  if (typeof value === 'string') return value.trim();
+  if (typeof value === 'number' || typeof value === 'boolean') return String(value);
+  if (typeof value !== 'object') return '';
+  for (const key of ['message', 'msg', 'error', 'detail', 'reason']) {
+    const nested = readValue(value[key]);
+    if (nested) return nested;
+  }
+  try {
+    const json = JSON.stringify(value);
+    return json && json !== '{}' ? json : '';
+  } catch {
+    return '';
+  }
+}

+ 67 - 0
src/app/services/voice-api.service.ts

@@ -0,0 +1,67 @@
+import { Injectable } from '@angular/core';
+import { from, Observable } from 'rxjs';
+import { CLOUD_FN } from './cloud-functions';
+import { ParseService } from './parse.service';
+
+export interface VoiceSynthesizeRequest {
+  text: string;
+  ssml?: string;
+  isStream?: boolean;
+  timbreId?: string;
+  speaker_id?: string;
+  volcengine_voice_type?: string;
+  x_api_resource_id?: string;
+  model?: string;
+  audio_params?: Record<string, any>;
+  additions?: Record<string, any>;
+}
+
+export interface VoiceCloneRequest {
+  name: string;
+  speaker_id: string;
+  audioData: Record<string, any>;
+  language?: number | string;
+  extra_params?: Record<string, any>;
+}
+
+@Injectable({ providedIn: 'root' })
+export class VoiceApiService {
+  constructor(private parse: ParseService) {}
+
+  synthesize(payload: VoiceSynthesizeRequest): Observable<any> {
+    return from(this.call('synthesize', { payload: this.stripEmpty(payload) }));
+  }
+
+  cloneVoice(payload: VoiceCloneRequest): Observable<any> {
+    return from(this.call('cloneVoice', { payload: this.stripEmpty(payload) }));
+  }
+
+  autoSpeakerId(): Observable<{ speakerId: string }> {
+    return from(this.call<{ speakerId: string }>('autoSpeakerId'));
+  }
+
+  syncProfile(profile: Record<string, any>): Observable<any> {
+    return from(this.call('syncProfile', { profile: this.stripEmpty(profile) }));
+  }
+
+  private async call<T = any>(action: string, params: Record<string, any> = {}): Promise<T> {
+    if (!CLOUD_FN.voice) {
+      throw new Error('声音服务暂未配置,请先部署语音云函数。');
+    }
+    return this.parse.callOrThrow<T>(CLOUD_FN.voice, { action, ...params });
+  }
+
+  private stripEmpty<T extends Record<string, any>>(value: T): T {
+    const out: Record<string, any> = {};
+    for (const [key, val] of Object.entries(value || {})) {
+      if (val === undefined || val === null || val === '') continue;
+      if (typeof val === 'object' && !Array.isArray(val)) {
+        const nested = this.stripEmpty(val);
+        if (Object.keys(nested).length) out[key] = nested;
+      } else {
+        out[key] = val;
+      }
+    }
+    return out as T;
+  }
+}

+ 1 - 7
src/environments/environment.prod.ts

@@ -1,18 +1,12 @@
 export const environment = {
   production: true,
   douyinApi: {
-    baseUrl: 'https://api.tikhub.io',
-    token: 'gqsZHfMWgAiMwV+ITbmZy0qALADWBZVS7QnV7kKJe9CwzgWgJG+7bwK+GQ==',
     timeout: 45000,
     maxRetries: 2
   },
   jimengApi: {
-    baseUrl: 'https://server.fmode.cn/api/volcengine/jimeng',
-    token: 'Bearer r:f0333969e312a40e4703e8fe4ed1c600',
-    parseBaseUrl: 'https://server.fmode.cn/parse',
-    parseAppId: 'ncloudmaster',
     pollIntervalMs: 4000,
     pollMaxAttempts: 60,
     timeout: 60000
   }
-};
+};

+ 1 - 7
src/environments/environment.ts

@@ -1,18 +1,12 @@
 export const environment = {
   production: false,
   douyinApi: {
-    baseUrl: '/api',
-    token: 'gqsZHfMWgAiMwV+ITbmZy0qALADWBZVS7QnV7kKJe9CwzgWgJG+7bwK+GQ==',
     timeout: 45000,
     maxRetries: 2
   },
   jimengApi: {
-    baseUrl: '/jimeng',
-    token: 'Bearer r:f0333969e312a40e4703e8fe4ed1c600',
-    parseBaseUrl: '/parse',
-    parseAppId: 'ncloudmaster',
     pollIntervalMs: 4000,
     pollMaxAttempts: 60,
     timeout: 60000
   }
-};
+};

+ 542 - 0
高频用户视角改进路线图.md

@@ -0,0 +1,542 @@
+# video-workflow 高频用户视角改进路线图
+
+> 目标:把当前项目从“多功能 AI 视频工具集合”逐步升级为“高频创作者每天可稳定生产、复盘、迭代的内容工作台”。
+
+本文档用于后续一步步实施改进。每个阶段都包含目标、任务清单、建议涉及文件和验收标准。
+
+---
+
+## 当前实施状态(2026-05-29)
+
+> 当前第一阶段的 1.1 / 1.2 / 1.3 已完成底座版落地,继续深入接入所有 Pipeline 的任务续跑与成本明细功能先暂挂。
+
+### 已完成
+
+- [x] 新增统一任务模型:`src/app/models/generation-task.model.ts`。
+- [x] 新增统一任务中心服务:`src/app/services/generation-task.service.ts`。
+- [x] 新增成本预估服务:`src/app/services/cost-estimator.service.ts`。
+- [x] 扩展用户可读错误与恢复建议:`src/app/services/user-message.util.ts`。
+- [x] 任务管理页展示预计积分、失败原因、恢复建议和重试/恢复入口。
+- [x] 现有任务保存/更新/删除与统一任务中心同步。
+- [x] `video-generation` 已支持基于 `vgData` 的恢复入口。
+- [x] 已执行 `npm run build`,构建通过,仅存在既有 CommonJS 优化警告。
+
+### 暂挂 / 后续继续项
+
+- [ ] 将即梦 `workId`、TTS 任务 ID、上传 key、合成任务 ID 等外部任务 ID 深度接入各 Pipeline。
+- [ ] 图生视频、动作迁移、主题生视频、素材合成等 Pipeline 刷新后自动续轮询。
+- [ ] 每个 Pipeline 的提交按钮旁展示独立成本明细与低成本降级选项。
+- [ ] 用户中心增加按任务聚合的积分流水、退款记录和扣费解释。
+- [ ] 任务完成后统一归档到素材库/我的创作的自动闭环。
+
+---
+
+## 0. 当前判断
+
+当前项目已经具备较完整的基础能力:
+
+- **创作 Pipeline**:数字人合成、图片生成、图生视频、动作迁移、素材合成视频、主题生视频、标准视频生成。
+- **发现与运营**:抖音视频发现、博主监测、视频详情、视频下载。
+- **资产沉淀**:我的创作、素材库、视频管理、历史/结果记录。
+- **账号体系**:登录、用户中心、管理员账号、积分流水、预扣/退款框架。
+- **AI 助手**:首页对话、浮窗对话、流式回复、建议跳转 Pipeline。
+- **OpenClaw/skills 方向**:已有抖音、即梦、TTS、主题生视频等 skill 试验基础。
+
+当前最主要的问题不是功能少,而是:
+
+- **任务不可充分恢复**:刷新、断网、长任务中断后,用户很难继续。
+- **生产闭环不够顺**:发现素材、分析爆款、生成脚本、生成视频、沉淀复盘之间仍有断点。
+- **草稿和素材偏本地化**:重度用户跨设备、长期沉淀、批量生产会受限。
+- **成本和失败反馈不够透明**:高频用户会关心每一步花了多少、失败是否退款、如何重试。
+- **主组件过大**:`src/app/app.ts` 超过 1.5 万行,后续维护风险高。
+
+---
+
+## 1. 总体路线
+
+建议按四个阶段推进:
+
+| 阶段 | 主题 | 目标 |
+|---|---|---|
+| 第一阶段 | 稳定生产 | 任务可恢复、失败可重试、成本透明、草稿更可靠 |
+| 第二阶段 | 抖音运营闭环 | 从发现爆款到生成同款脚本/视频形成闭环 |
+| 第三阶段 | 高频效率工具 | 批量生产、模板系统、资产标签、作品复盘 |
+| 第四阶段 | 架构治理 | 拆分 `app.ts`,Pipeline 插件化,统一任务/资产模型 |
+
+推荐先做第一阶段。它能最大幅度提升重度用户的信任感和日常可用性。
+
+---
+
+# 第一阶段:稳定生产
+
+## 1.1 统一任务中心
+
+### 背景
+
+目前各 Pipeline 有自己的状态管理,`pipeline-session.service.ts` 能保存草稿和 artifacts,但长任务刷新后会被降级为失败。对高频用户来说,最痛的是“生成一半没了”。
+
+### 目标
+
+建立一个统一任务模型,让所有长流程任务都能:
+
+- 创建任务记录
+- 保存当前步骤
+- 保存外部任务 ID
+- 刷新后恢复轮询
+- 失败后单步重试
+- 完成后自动归档到素材库/我的创作
+
+### 建议任务清单
+
+- [x] 设计统一任务接口 `GenerationTask`。
+- [x] 增加任务状态:`draft`、`queued`、`running`、`waiting_external`、`completed`、`failed`、`cancelled`。
+- [x] 增加步骤状态:脚本、图片、音频、视频、合成、上传、归档。
+- [ ] 保存外部任务 ID:如即梦 `workId`、TTS 任务、上传 key、合成输出 URL。(暂挂:底座已支持字段,尚未逐个 Pipeline 深度接入)
+- [x] 应用启动时恢复未完成任务。(已支持本地任务中心 fallback;外部任务自动续轮询暂挂)
+- [x] 任务页展示进度、错误、预计积分和恢复建议。(完整步骤耗时明细暂挂)
+- [x] 支持失败步骤重试。(当前 `video-generation` 支持恢复入口;其他 Pipeline 自动续跑暂挂)
+
+### 建议涉及文件
+
+- `src/app/services/pipeline-session.service.ts`
+- `src/app/services/results.service.ts`
+- `src/app/services/jimeng.service.ts`
+- `src/app/services/digital-human.service.ts`
+- `src/app/pages/drafts/drafts.component.ts`
+- `src/app/pages/library/library.component.ts`
+- 后端或 Parse 云函数任务表
+
+### 验收标准
+
+- [ ] 启动一个图生视频任务后刷新页面,任务仍可继续轮询。(暂挂:需接入即梦 `workId` 恢复轮询)
+- [ ] 启动一个主题生视频任务后关闭浏览器,再打开仍能看到任务状态。(底座已具备,主题生视频续跑暂挂)
+- [ ] 某一步失败后,用户可以只重试失败步骤。(当前先支持 `video-generation` 恢复入口,单步重试暂挂)
+- [ ] 任务完成后自动进入素材库或我的创作。(暂挂)
+- [ ] 任务详情中能看到每一步耗时、状态和产物。(状态底座已具备,详情页暂挂)
+
+---
+
+## 1.2 生成失败重试与错误解释
+
+### 背景
+
+当前错误提示有些地方只显示“失败”或 `console.error`。重度用户需要知道失败原因和下一步操作。
+
+### 目标
+
+让所有生成错误都变成可理解、可操作、可恢复的提示。
+
+### 建议任务清单
+
+- [x] 统一错误类型:网络错误、余额不足、服务未配置、上游限流、任务超时、参数错误、素材不可访问。
+- [x] 增加 `UserMessage` 映射,把技术错误转成人话。
+- [x] 任务管理页展示原因 + 解决建议 + 重试按钮。(所有 Pipeline 页面内联展示暂挂)
+- [ ] 对上游限流/超时增加自动退避重试。(暂挂)
+- [ ] 对素材 URL 失效增加重新上传入口。(暂挂)
+
+### 建议涉及文件
+
+- `src/app/services/user-message.util.ts`
+- `src/app/services/jimeng.service.ts`
+- `src/app/services/douyin.service.ts`
+- `src/app/pages/pipelines/*`
+- `src/app/app.ts`
+
+### 验收标准
+
+- [x] 即梦超时时,用户看到任务仍可能在生成,可稍后恢复查看。
+- [ ] 余额不足时,提示当前余额和本次所需积分。(错误映射已具备,提交前余额联动暂挂)
+- [x] 参数错误时,能给出更可读的失败原因与建议。
+- [x] 每个失败任务都至少有一个明确的下一步操作。
+
+---
+
+## 1.3 成本预估与积分透明
+
+### 背景
+
+`auth-credit.service.ts` 已有 `reserveCredit`、`commitReservation`、`refundReservation`,但用户在提交前不一定清楚成本结构。
+
+### 目标
+
+让用户在提交前知道预计消耗,提交后知道每一步实际消耗,失败后知道是否退款。
+
+### 建议任务清单
+
+- [x] 新增统一 `CostEstimatorService`,提供图片、视频、图生视频、动作迁移、主题生视频等成本预估。(逐页面 `estimateCost()` 接入暂挂)
+- [ ] 在提交按钮附近展示预计积分。(任务卡已展示,提交按钮旁展示暂挂)
+- [x] 任务卡展示积分预估/预扣/确认/退款状态字段。(独立任务详情页暂挂)
+- [ ] 余额不足时提供降级选项:降低清晰度、缩短时长、减少分镜。(暂挂)
+- [ ] 用户中心增加按任务分组的积分流水。(暂挂)
+
+### 建议涉及文件
+
+- `src/app/services/auth-credit.service.ts`
+- `src/app/services/jimeng.service.ts`
+- `src/app/pages/account/user-center.component.ts`
+- `src/app/pages/pipelines/*`
+- `src/app/pipelines/pipeline-registry.ts`
+
+### 验收标准
+
+- [ ] 图生视频提交前能看到预计积分。(暂挂:已有预估服务,未接入页面提交区)
+- [ ] 主题生视频能拆分显示脚本、图片、TTS、合成的预计成本。(暂挂:已有预估服务,未接入页面提交区)
+- [ ] 失败任务能看到退款记录。(任务卡已有退款状态字段,真实流水聚合暂挂)
+- [ ] 余额不足时用户能选择低成本方案。(暂挂)
+
+---
+
+## 1.4 草稿从 localStorage 升级到 IndexedDB/云端
+
+### 背景
+
+`draft-storage.ts` 目前使用 `localStorage`。这对轻量草稿可以,但对高频用户不够可靠。
+
+### 目标
+
+先升级容量和可靠性,再实现跨设备同步。
+
+### 建议任务清单
+
+- [ ] 新增 `IndexedDbDraftStorage`。
+- [ ] 保留 `LocalStorageDraftStorage` 作为 fallback。
+- [ ] 启动时自动迁移旧草稿。
+- [ ] 后续增加 Parse/后端版 `RemoteDraftStorage`。
+- [ ] 草稿保存 artifact 元数据,不直接塞大体积内容。
+
+### 建议涉及文件
+
+- `src/app/services/draft-storage.ts`
+- `src/app/services/pipeline-session.service.ts`
+- `src/app/pages/drafts/drafts.component.ts`
+- `src/app/pages/library/library.component.ts`
+
+### 验收标准
+
+- [ ] 大量草稿不会轻易触发 localStorage 配额错误。
+- [ ] 旧草稿能自动迁移。
+- [ ] 每个用户的草稿仍然隔离。
+- [ ] 后续可无痛切换到云端草稿。
+
+---
+
+# 第二阶段:抖音运营闭环
+
+## 2.1 单视频爆款分析
+
+### 目标
+
+从视频发现或博主作品中,一键分析可复用的创作结构。
+
+### 建议任务清单
+
+- [ ] 视频详情页增加“爆款分析”按钮。
+- [ ] 分析维度包括标题、开头钩子、内容节奏、情绪、评论关注点、可复刻角度。
+- [ ] 支持把分析结果保存到选题池。
+- [ ] 支持一键生成同款脚本。
+
+### 建议涉及文件
+
+- `src/app/app.html`
+- `src/app/app.ts`
+- `src/app/services/douyin.service.ts`
+- `src/app/services/llm.service.ts`
+- 新增 `viral-analysis.service.ts`
+
+### 验收标准
+
+- [ ] 任意搜索结果视频可生成分析报告。
+- [ ] 报告中能直接生成 3 个同款选题。
+- [ ] 分析结果能保存并二次打开。
+
+---
+
+## 2.2 博主监测日报
+
+### 目标
+
+让用户每天打开项目时,就知道关注博主有什么新内容、哪些值得跟进。
+
+### 建议任务清单
+
+- [ ] 监测博主记录上次刷新时间。
+- [ ] 识别新增作品。
+- [ ] 汇总新增作品互动数据。
+- [ ] 自动生成日报:新作品、涨粉、爆款、可借鉴选题。
+- [ ] 首页展示“今日监测摘要”。
+
+### 建议涉及文件
+
+- `src/app/app.ts`
+- `src/app/app.html`
+- `src/app/pages/home/home.component.ts`
+- `src/app/services/douyin.service.ts`
+- `douyin-speaking-daily/skills/*` 或后续云函数
+
+### 验收标准
+
+- [ ] 添加博主后,第二天能看到新增作品摘要。
+- [ ] 日报能列出最值得模仿的 3 条内容。
+- [ ] 用户能从日报一键进入脚本生成或主题生视频。
+
+---
+
+## 2.3 选题池
+
+### 目标
+
+把抖音发现、AI 助手、爆款分析产生的灵感沉淀起来,形成可执行队列。
+
+### 建议任务清单
+
+- [ ] 新增选题模型:标题、来源、标签、参考视频、脚本状态、生成状态。
+- [ ] 支持从视频分析结果保存选题。
+- [ ] 支持从 AI 助手保存选题。
+- [ ] 支持选题一键进入主题生视频/数字人。
+- [ ] 支持按状态筛选:待写脚本、待生成、已完成。
+
+### 验收标准
+
+- [ ] 用户可以连续保存多个选题。
+- [ ] 每个选题能关联来源视频。
+- [ ] 选题能进入后续 Pipeline。
+
+---
+
+# 第三阶段:高频效率工具
+
+## 3.1 批量生产模式
+
+### 目标
+
+让重度用户一次性跑多个主题、多个素材、多个视频,而不是一条一条点。
+
+### 建议任务清单
+
+- [ ] 新增批量任务页。
+- [ ] 支持粘贴多行主题。
+- [ ] 支持 CSV 导入。
+- [ ] 支持选择 Pipeline 和模板。
+- [ ] 支持队列执行、暂停、取消、重试。
+- [ ] 支持批量导出结果。
+
+### 验收标准
+
+- [ ] 用户可一次提交 10 个主题生成任务。
+- [ ] 每个任务有独立状态。
+- [ ] 失败任务可以单独重试。
+- [ ] 完成结果能批量下载或归档。
+
+---
+
+## 3.2 模板/预设系统
+
+### 目标
+
+减少重复配置,让用户保存常用生产方式。
+
+### 建议任务清单
+
+- [ ] 增加系统模板和我的模板。
+- [ ] 模板保存:Pipeline、prompt、比例、时长、音色、风格、脚本结构。
+- [ ] 支持从当前配置保存为模板。
+- [ ] 支持从模板创建任务。
+- [ ] 支持模板分类:口播、产品种草、知识科普、影视感、带货。
+
+### 验收标准
+
+- [ ] 用户能保存当前主题生视频配置为模板。
+- [ ] 下次可一键复用该模板。
+- [ ] 批量生产可选择模板。
+
+---
+
+## 3.3 资产标签和项目管理
+
+### 目标
+
+让素材库从“结果列表”升级为“内容资产库”。
+
+### 建议任务清单
+
+- [ ] artifact/result 增加标签。
+- [ ] 增加项目/客户/账号维度。
+- [ ] 支持按 Pipeline、时间、标签、项目筛选。
+- [ ] 支持收藏、归档、删除。
+- [ ] 支持查看生成参数。
+
+### 验收标准
+
+- [ ] 用户可以给作品打标签。
+- [ ] 用户可以按客户或项目查看作品。
+- [ ] 每个作品能查看原始 prompt 和生成参数。
+
+---
+
+## 3.4 作品复盘
+
+### 目标
+
+从“生成完成”继续走到“发布后表现复盘”。
+
+### 建议任务清单
+
+- [ ] 每个作品支持填写发布平台、发布时间、播放量、点赞、评论。
+- [ ] 支持用户评分。
+- [ ] 统计不同模板、音色、主题的表现。
+- [ ] 生成复盘报告。
+
+### 验收标准
+
+- [ ] 用户能记录作品发布表现。
+- [ ] 系统能汇总最有效的主题/模板/音色。
+- [ ] 复盘结果能反向推荐新选题。
+
+---
+
+# 第四阶段:架构治理
+
+## 4.1 拆分 `app.ts`
+
+### 背景
+
+`src/app/app.ts` 已经超过 1.5 万行,继续堆功能会显著增加维护成本。
+
+### 建议拆分顺序
+
+- [ ] 抖音视频发现页:`pages/discovery/search`
+- [ ] 博主监测页:`pages/discovery/monitor`
+- [ ] 语音合成页:`pages/voice-synthesis`
+- [ ] 视频管理页:`pages/videos`
+- [ ] 任务中心页:`pages/tasks`
+- [ ] 标准视频生成页:`pages/pipelines/video-generation`
+
+### 验收标准
+
+- [ ] `app.ts` 只保留全局导航、登录弹窗、全局状态。
+- [ ] 每个业务页面有独立 component。
+- [ ] 共享逻辑进入 service。
+
+---
+
+## 4.2 Pipeline 插件化
+
+### 背景
+
+`pipeline-registry.ts` 已经是好方向,但新增 Pipeline 仍需要同时改多个地方。
+
+### 目标
+
+新增 Pipeline 时尽量只改注册配置和新增组件。
+
+### 建议任务清单
+
+- [ ] registry 中增加 component/route 配置。
+- [ ] Sidebar 完全从 registry 生成。
+- [ ] Pipeline Hub 完全从 registry 生成。
+- [ ] Pipeline 定义中增加权限、预计成本、输入类型、输出类型。
+- [ ] 每个 Pipeline 实现统一接口:`snapshot`、`restore`、`run`、`estimateCost`。
+
+### 验收标准
+
+- [ ] 新增一个 Pipeline 不需要修改 `app.html` 大段模板。
+- [ ] 侧边栏和 Hub 自动出现新模式。
+- [ ] 草稿恢复和素材归档走统一接口。
+
+---
+
+## 4.3 统一数据模型
+
+### 目标
+
+减少历史记录、结果、草稿、任务、artifact 之间的重复和割裂。
+
+### 建议统一模型
+
+- **Task**:一次执行过程。
+- **Draft**:用户可继续编辑的创作状态。
+- **Artifact**:中间或最终产物,如图片、音频、视频。
+- **Result**:用户认可并归档的最终作品。
+- **Topic**:选题池中的创作意图。
+- **Template**:可复用的生成配置。
+
+### 验收标准
+
+- [ ] 我的创作、素材库、任务中心不再各自维护重复状态。
+- [ ] 一个作品可以追溯到原始任务、选题、模板和素材。
+- [ ] 后续接云端数据库时迁移成本低。
+
+---
+
+# 推荐下一步执行清单
+
+如果现在只选 5 件事做,建议按这个顺序:
+
+1. **统一任务模型与任务恢复**
+2. **失败重试和错误解释**
+3. **成本预估与积分透明**
+4. **抖音视频爆款分析**
+5. **博主监测日报/选题池**
+
+---
+
+# 里程碑版本建议
+
+## v1.1:稳定生产版
+
+- [x] 任务可恢复(底座版;外部任务续轮询暂挂)
+- [x] 单步失败可重试(底座版;完整单步重试暂挂)
+- [x] 成本预估(服务层已完成;页面提交区展示暂挂)
+- [ ] 失败退款可见(字段已接入;用户中心流水聚合暂挂)
+- [ ] IndexedDB 草稿
+
+## v1.2:抖音运营闭环版
+
+- [ ] 视频爆款分析
+- [ ] 同款脚本生成
+- [ ] 博主监测日报
+- [ ] 选题池
+- [ ] 从选题进入 Pipeline
+
+## v1.3:批量效率版
+
+- [ ] 批量主题生成
+- [ ] 批量队列
+- [ ] 模板系统
+- [ ] 资产标签
+- [ ] 批量导出
+
+## v1.4:架构治理版
+
+- [ ] 拆分 `app.ts`
+- [ ] Pipeline 插件化
+- [ ] 统一任务/草稿/资产模型
+- [ ] 云端草稿/资产同步
+
+---
+
+# 最终目标
+
+项目最终应形成这样一条高频创作闭环:
+
+```text
+发现素材 / 监测博主
+        ↓
+爆款分析 / 评论洞察
+        ↓
+沉淀选题池
+        ↓
+生成脚本 / 分镜 / Prompt
+        ↓
+批量生成图片 / 音频 / 视频
+        ↓
+合成成片
+        ↓
+素材库归档
+        ↓
+发布复盘
+        ↓
+反向优化选题和模板
+```
+
+这条闭环完成后,`video-workflow` 就不只是一个 AI 视频生成工具,而是一个真正面向高频创作者的内容生产系统。