Просмотр исходного кода

feat: consolidate listing scoring and VOC workflows

Yi Jiarui 1 неделя назад
Родитель
Сommit
4e2a46302b
88 измененных файлов с 1261 добавлено и 2087 удалено
  1. 6 9
      .env.example
  2. 22 0
      TASKS.md
  3. 1 0
      angular.json
  4. 1 37
      proxy.conf.json
  5. 4 2
      src/app/app.config.ts
  6. 20 0
      src/app/app.routes.ts
  7. 11 9
      src/app/core/config/runtime-config.ts
  8. 1 9
      src/app/core/interceptors/parse-session.interceptor.ts
  9. 7 12
      src/app/core/services/ai-gateway.service.ts
  10. 2 2
      src/app/core/services/api-connectivity.service.ts
  11. 0 10
      src/app/core/services/api.config.ts
  12. 99 0
      src/app/core/services/cloud-api.service.ts
  13. 55 578
      src/app/core/services/data-store.service.ts
  14. 23 4
      src/app/core/services/domestic-dataset.service.ts
  15. 3 13
      src/app/core/services/domestic-ecommerce-gateway.service.ts
  16. 13 0
      src/app/core/services/domestic-review-insight.service.ts
  17. 28 196
      src/app/core/services/http-api.service.ts
  18. 1 0
      src/app/core/services/index.ts
  19. 15 157
      src/app/core/services/saas-platform.service.spec.ts
  20. 14 36
      src/app/core/services/saas-platform.service.ts
  21. 7 2
      src/app/parse.ts
  22. 3 3
      src/index.html
  23. 3 3
      src/modules/ai-voc-insight/analysis-runs/analysis-run.data.ts
  24. 20 0
      src/modules/competitor-listing-monitor/models/competitor-listing.models.ts
  25. 17 0
      src/modules/competitor-listing-monitor/pages/alerts/competitor-listing-alerts.component.ts
  26. 56 0
      src/modules/competitor-listing-monitor/pages/history/competitor-listing-history.component.ts
  27. 3 0
      src/modules/competitor-listing-monitor/pages/impacts/competitor-listing-impacts.component.ts
  28. 3 0
      src/modules/competitor-listing-monitor/pages/overview/competitor-listing-overview.component.html
  29. 0 0
      src/modules/competitor-listing-monitor/pages/overview/competitor-listing-overview.component.scss
  30. 13 7
      src/modules/competitor-listing-monitor/pages/overview/competitor-listing-overview.component.spec.ts
  31. 1 0
      src/modules/competitor-listing-monitor/pages/tasks/competitor-optimization-tasks.component.ts
  32. 27 26
      src/modules/competitor-listing-monitor/services/competitor-listing-api.service.spec.ts
  33. 63 15
      src/modules/competitor-listing-monitor/services/competitor-listing-api.service.ts
  34. 15 23
      src/modules/dashboard/pages/order-list/order-list.component.ts
  35. 17 104
      src/modules/data-center/data-center.service.spec.ts
  36. 9 38
      src/modules/data-center/data-center.service.ts
  37. 1 1
      src/modules/feedback-inbox/feedback-inbox.component.html
  38. 53 13
      src/modules/feedback-inbox/feedback-inbox.component.ts
  39. 4 21
      src/modules/knowledge-center/competitor-detail/competitor-detail.component.ts
  40. 6 2
      src/modules/listing-ai/components/listing-overview-filter-bar/listing-overview-filter-bar.component.html
  41. 27 2
      src/modules/listing-ai/components/listing-overview-filter-bar/listing-overview-filter-bar.component.ts
  42. 5 4
      src/modules/listing-ai/components/listing-overview-table/listing-overview-table.component.html
  43. 15 5
      src/modules/listing-ai/components/listing-overview-table/listing-overview-table.component.ts
  44. 0 0
      src/modules/listing-ai/listing-ai.shared.scss
  45. 115 0
      src/modules/listing-ai/models/jd-voc.models.ts
  46. 16 0
      src/modules/listing-ai/models/listing-ai.models.ts
  47. 4 0
      src/modules/listing-ai/pages/overview/listing-score-overview.component.html
  48. 0 0
      src/modules/listing-ai/pages/overview/listing-score-overview.component.scss
  49. 4 0
      src/modules/listing-ai/pages/overview/listing-score-overview.component.ts
  50. 35 2
      src/modules/listing-ai/pages/product/listing-ai-product.component.html
  51. 7 3
      src/modules/listing-ai/pages/product/listing-ai-product.component.ts
  52. 28 29
      src/modules/listing-ai/services/listing-ai-api.service.spec.ts
  53. 22 28
      src/modules/listing-ai/services/listing-ai-api.service.ts
  54. 4 3
      src/modules/listing-ai/services/listing-ai-overview.store.spec.ts
  55. 20 5
      src/modules/listing-ai/services/listing-ai-overview.store.ts
  56. 12 20
      src/modules/market-insight/pages/category-trend/category-trend.component.ts
  57. 4 20
      src/modules/market-insight/pages/new-product-trend/new-product-trend.component.ts
  58. 11 12
      src/modules/monitoring/pages/competitor-update/competitor-selector/competitor-selector.component.ts
  59. 19 25
      src/modules/monitoring/pages/store-detail/store-detail.component.ts
  60. 8 18
      src/modules/shared/components/filter-bar/filter-bar.component.ts
  61. 0 1
      src/modules/shared/components/header/header.component.ts
  62. 3 3
      src/modules/shared/components/product-identity/product-identity.component.scss
  63. 6 15
      src/modules/shared/services/ai-analysis.service.spec.ts
  64. 12 19
      src/modules/shared/services/ai-analysis.service.ts
  65. 10 28
      src/modules/shared/services/asin-sku-mapping.service.ts
  66. 10 20
      src/modules/shared/services/knowledge-center-data.service.ts
  67. 17 16
      src/modules/shared/services/marketx-insight-data.service.ts
  68. 18 47
      src/modules/shared/services/monitoring-data.service.ts
  69. 5 47
      src/modules/shared/services/permission.service.ts
  70. 7 20
      src/modules/shared/services/product-dimension.service.ts
  71. 17 45
      src/modules/shared/services/product-knowledge.service.spec.ts
  72. 5 18
      src/modules/shared/services/product-knowledge.service.ts
  73. 5 19
      src/modules/shared/services/shared-data-cache.service.ts
  74. 1 1
      src/modules/shared/services/size-chart.service.ts
  75. 7 31
      src/modules/shared/services/translation.service.ts
  76. 4 19
      src/modules/shared/services/user-admin.service.ts
  77. 8 9
      src/modules/shared/services/voc-insight-detail.service.ts
  78. 18 64
      src/modules/shop-management/shop-management.component.ts
  79. 23 86
      src/modules/system/pages/amazon-category-list/amazon-category-list.component.ts
  80. 5 5
      src/modules/system/pages/api-management/api-management.component.html
  81. 1 1
      src/modules/system/pages/api-management/api-management.component.ts
  82. 16 45
      src/modules/system/pages/self-category-management/self-category-management.component.ts
  83. 4 7
      src/modules/system/pages/store-product-list/store-product-list.component.ts
  84. 3 3
      src/modules/validation-center/validation-center.data.ts
  85. 15 2
      src/modules/voc-insight/topic-explorer/topic-explorer.component.html
  86. 7 1
      src/modules/voc-insight/topic-explorer/topic-explorer.component.ts
  87. 20 2
      src/modules/voc-insight/voc-insight.component.ts
  88. 11 5
      src/runtime-config.js

+ 6 - 9
.env.example

@@ -1,9 +1,6 @@
-# Public web configuration
-PUBLIC_PARSE_SERVER_URL=https://voc.example.com/parse
-PUBLIC_PARSE_APP_ID=saas-voc
-
-# Server-side only. Never expose these values through Angular assets.
-PARSE_MASTER_KEY=
-PARSE_DATABASE_URI=
-FMODE_SERVER_URL=https://server.fmode.cn
-FMODE_API_KEY=
+# Public web configuration. Set these in the deployment shell before serving.
+PUBLIC_PARSE_SERVER_URL=https://server.fmode.cn/backend/vO3T8gAdwB/data
+PUBLIC_PARSE_APP_ID=vO3T8gAdwB_main
+PUBLIC_CLOUD_FUNCTION_PATH=/saas-voc-gateway
+PUBLIC_API_AUTH_MODE=parse
+PUBLIC_DOMESTIC_DATASET_MODE=api

+ 22 - 0
TASKS.md

@@ -73,3 +73,25 @@
 - 德玛仕 Excel 不包含评论正文;当前 355 条评论均来自 37 个映射竞品的首批中转采样,自有商品评论仍为 0。
 - 当前价格接口 TLS 连接失败,尚未形成价格或销量历史快照。
 - 独立 PostgreSQL / Parse 的连接信息尚未提供或创建。
+
+## 竞品变化驱动 Listing 闭环(2026-08-31)
+
+- [x] 新增竞品 Listing 历史详情 API 与响应式前端页面(快照、字段级 diff、采集失败记录、字段筛选)。
+- [x] 增加变化预警列表(价格/在售/标题/主图/规格/多竞品同向)。
+- [x] 增加影响分析、优化任务工作台与评分证据展示。
+- [x] 接入评分验证持久化和前后评分录入交互。
+
+## Cloud Function migration (2026-09-03)
+
+> **当前状态:暂缓线上托管验收,回归本地前端 + 本地后端代理真实 Parse REST 数据开发。** 禁止使用静态案例数据;云函数源码、Function 配置和迁移结果保留,待线上 Parse `_User` 与工作区成员准备完成后再恢复。
+
+- [x] Add the single `CloudApiService` browser boundary and remove direct business REST, Parse Query, and component fetch calls.
+- [x] Add the allow-listed action registry and local `/api/functions` adapter.
+- [x] Add and deploy the Parse `Function` record at `/saas-voc-gateway` with authentication, workspace/RBAC, product scope, cursor reads, writes, idempotency, audit, and controlled upstream failures.
+- [x] Preserve static `domesticDatasetMode: 'case'` and verify the packaged dashboard and 37-item competitor case view.
+- [x] Switch the default browser runtime to authenticated live Parse data; case mode remains explicit-only.
+- [x] Enforce live Parse/API mode for browser pages; static case fixtures are no longer selectable at runtime.
+- [x] Remove case branding from the production page shell; live Parse is the only browser runtime data source.
+- [x] Verify frontend 117/117 tests, server 142/142 tests, type checks, production build, data validation, and Function source security tests.
+- [ ] Run authenticated production page and network-panel acceptance with a dedicated test Session.
+- [ ] Complete live Amazon/Sorftime/TikHub provider contracts and background worker execution in the managed Function environment.

+ 1 - 0
angular.json

@@ -59,6 +59,7 @@
           },
           "configurations": {
             "production": {
+              "baseHref": "/dev/WAycAueuu6/",
               "budgets": [
                 {
                   "type": "initial",

+ 1 - 37
proxy.conf.json

@@ -1,41 +1,5 @@
 {
-  "/api/voc-e-commerce": {
-    "target": "http://127.0.0.1:3000",
-    "secure": false,
-    "changeOrigin": true,
-    "logLevel": "debug"
-  },
-  "/api/domestic-voc": {
-    "target": "http://127.0.0.1:4400",
-    "secure": false,
-    "changeOrigin": true,
-    "logLevel": "debug"
-  },
-  "/api/saas": {
-    "target": "http://127.0.0.1:4400",
-    "secure": false,
-    "changeOrigin": true,
-    "logLevel": "debug"
-  },
-  "/api/ai": {
-    "target": "http://127.0.0.1:4400",
-    "secure": false,
-    "changeOrigin": true,
-    "logLevel": "debug"
-  },
-  "/api/knowledge": {
-    "target": "http://127.0.0.1:4400",
-    "secure": false,
-    "changeOrigin": true,
-    "logLevel": "debug"
-  },
-  "/api/listing-ai": {
-    "target": "http://127.0.0.1:4400",
-    "secure": false,
-    "changeOrigin": true,
-    "logLevel": "debug"
-  },
-  "/api/competitor-listings": {
+  "/api/functions": {
     "target": "http://127.0.0.1:4400",
     "secure": false,
     "changeOrigin": true,

+ 4 - 2
src/app/app.config.ts

@@ -1,5 +1,5 @@
 import { ApplicationConfig } from '@angular/core';
-import { provideRouter } from '@angular/router';
+import { PreloadAllModules, provideRouter, withHashLocation, withPreloading } from '@angular/router';
 import { provideHttpClient, withInterceptors, withInterceptorsFromDi } from '@angular/common/http';
 import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
 import { provideEchartsCore } from 'ngx-echarts';
@@ -9,7 +9,9 @@ import { parseSessionInterceptor } from './core/interceptors/parse-session.inter
 
 export const appConfig: ApplicationConfig = {
   providers: [
-    provideRouter(routes),
+    // OBS only serves static objects and cannot rewrite deep links. Hash routing
+    // keeps every route refresh anchored to the deployed index.html.
+    provideRouter(routes, withHashLocation(), withPreloading(PreloadAllModules)),
     provideHttpClient(withInterceptors([parseSessionInterceptor]), withInterceptorsFromDi()),
     provideAnimationsAsync(),
     provideEchartsCore({ echarts: () => import('./echarts').then((m) => m.default) }),

+ 20 - 0
src/app/app.routes.ts

@@ -40,6 +40,26 @@ export const routes: Routes = [
         title: '竞品 Listing 监控',
         loadComponent: () => import('../modules/competitor-listing-monitor/pages/overview/competitor-listing-overview.component').then((m) => m.CompetitorListingOverviewComponent),
       },
+      {
+        path: 'competitor-listings/alerts',
+        title: '竞品变化预警',
+        loadComponent: () => import('../modules/competitor-listing-monitor/pages/alerts/competitor-listing-alerts.component').then((m) => m.CompetitorListingAlertsComponent),
+      },
+      {
+        path: 'competitor-listings/impacts',
+        title: '竞品变化影响分析',
+        loadComponent: () => import('../modules/competitor-listing-monitor/pages/impacts/competitor-listing-impacts.component').then((m) => m.CompetitorListingImpactsComponent),
+      },
+      {
+        path: 'competitor-listings/tasks',
+        title: '竞品 Listing 优化任务',
+        loadComponent: () => import('../modules/competitor-listing-monitor/pages/tasks/competitor-optimization-tasks.component').then((m) => m.CompetitorOptimizationTasksComponent),
+      },
+      {
+        path: 'competitor-listings/:productId',
+        title: '竞品 Listing 历史',
+        loadComponent: () => import('../modules/competitor-listing-monitor/pages/history/competitor-listing-history.component').then((m) => m.CompetitorListingHistoryComponent),
+      },
       {
         path: 'competitors',
         title: '竞品关系',

+ 11 - 9
src/app/core/config/runtime-config.ts

@@ -10,6 +10,7 @@ export interface SaasVocRuntimeConfig {
   domesticWorkspaceId: string;
   domesticPlatform: 'jd';
   listingAiPath: string;
+  cloudFunctionPath: string;
 }
 
 declare global {
@@ -19,18 +20,19 @@ declare global {
 }
 
 const browserConfig = typeof window !== 'undefined' ? window.__SAAS_VOC_CONFIG__ ?? {} : {};
-const browserOrigin = typeof window !== 'undefined' ? window.location.origin : '';
-
 export const RUNTIME_CONFIG: Readonly<SaasVocRuntimeConfig> = Object.freeze({
-  parseServerUrl: browserConfig.parseServerUrl || `${browserOrigin}/parse`,
-  parseAppId: browserConfig.parseAppId || 'saas-voc-local',
+  parseServerUrl: browserConfig.parseServerUrl || 'https://server.fmode.cn/backend/vO3T8gAdwB/data',
+  parseAppId: browserConfig.parseAppId || 'vO3T8gAdwB_main',
   apiBaseUrl: browserConfig.apiBaseUrl || '',
-  apiAuthMode: browserConfig.apiAuthMode === 'parse' ? 'parse' : 'disabled',
-  vocEcommercePath: browserConfig.vocEcommercePath || '/api/voc-e-commerce',
-  saasPath: browserConfig.saasPath || '/api/saas',
+  apiAuthMode: browserConfig.apiAuthMode === 'disabled' ? 'disabled' : 'parse',
+  vocEcommercePath: browserConfig.vocEcommercePath || '',
+  saasPath: browserConfig.saasPath || '',
+  // Live Parse is the default. Case mode remains an explicit offline fixture
+  // for isolated development only.
   domesticDatasetMode: browserConfig.domesticDatasetMode === 'case' ? 'case' : 'api',
-  domesticVocPath: browserConfig.domesticVocPath || '/api/domestic-voc',
+  domesticVocPath: browserConfig.domesticVocPath || '',
   domesticWorkspaceId: browserConfig.domesticWorkspaceId || 'demashi',
   domesticPlatform: 'jd',
-  listingAiPath: browserConfig.listingAiPath || '/api/listing-ai',
+  listingAiPath: browserConfig.listingAiPath || '',
+  cloudFunctionPath: browserConfig.cloudFunctionPath || '/saas-voc-gateway',
 });

+ 1 - 9
src/app/core/interceptors/parse-session.interceptor.ts

@@ -1,13 +1,5 @@
 import { HttpInterceptorFn } from '@angular/common/http';
-import { RUNTIME_CONFIG } from '../config/runtime-config';
-import { MSQParse } from '../../parse';
 
 export const parseSessionInterceptor: HttpInterceptorFn = (request, next) => {
-  const protectedPaths = [RUNTIME_CONFIG.domesticVocPath, RUNTIME_CONFIG.saasPath, '/api/knowledge']
-    .map((path) => `/${path.replace(/^\/+|\/+$/g, '')}`);
-  const isProtectedApi = protectedPaths.some((path) => request.url.includes(path));
-  if (RUNTIME_CONFIG.apiAuthMode !== 'parse' || !isProtectedApi) return next(request);
-  const sessionToken = MSQParse.User.current()?.getSessionToken();
-  if (!sessionToken) return next(request);
-  return next(request.clone({ setHeaders: { 'X-Parse-Session-Token': sessionToken } }));
+  return next(request);
 };

+ 7 - 12
src/app/core/services/ai-gateway.service.ts

@@ -1,7 +1,6 @@
-import { HttpClient } from '@angular/common/http';
 import { Injectable, inject } from '@angular/core';
-import { map, Observable } from 'rxjs';
-import { RUNTIME_CONFIG } from '../config/runtime-config';
+import { Observable, map } from 'rxjs';
+import { CloudApiService } from './cloud-api.service';
 
 export interface AiGatewayStatus {
   service: string;
@@ -22,27 +21,23 @@ export interface AiGatewayTestResult {
 
 @Injectable({ providedIn: 'root' })
 export class AiGatewayService {
-  private readonly http = inject(HttpClient);
-  private readonly baseUrl = `${RUNTIME_CONFIG.apiBaseUrl.replace(/\/+$/, '')}/api/ai`;
+  private readonly cloud = inject(CloudApiService);
 
   status(): Observable<AiGatewayStatus> {
-    return this.http.get<AiGatewayStatus>(`${this.baseUrl}/status`);
+    return this.cloud.run<AiGatewayStatus>({ action: 'ai.status' });
   }
 
   testConnection(): Observable<AiGatewayTestResult> {
-    return this.http.post<AiGatewayTestResult>(`${this.baseUrl}/test`, {});
+    return this.cloud.run<AiGatewayTestResult>({ action: 'ai.test' });
   }
 
   listPromptConfigs(): Observable<Record<string, any>[]> {
-    return this.http.get<{ items: Record<string, any>[] }>(`${this.baseUrl}/prompts`).pipe(
+    return this.cloud.run<{ items: Record<string, any>[] }>({ action: 'ai.prompts.list' }).pipe(
       map((response) => response.items || [])
     );
   }
 
   savePromptConfig(prompt: Record<string, any>): Observable<Record<string, any>> {
-    return this.http.put<Record<string, any>>(
-      `${this.baseUrl}/prompts/${encodeURIComponent(String(prompt['promptKey'] || ''))}`,
-      prompt
-    );
+    return this.cloud.run<Record<string, any>>({ action: 'ai.prompt.update', payload: prompt });
   }
 }

+ 2 - 2
src/app/core/services/api-connectivity.service.ts

@@ -148,7 +148,7 @@ export class ApiConnectivityService {
     /** 测试 Sorftime API */
     testSorftime(): Observable<ConnectivityResult> {
         const start = Date.now();
-        return this.api.rawGet<any>(`${API_CONFIG.BASE_URL}${API_CONFIG.SORFTIME_PREFIX}/test`).pipe(
+        return this.sorftimeApi.getCategoryTree().pipe(
             timeout(API_CONFIG.TIMEOUT),
             map(() => ({
                 service: 'Sorftime API',
@@ -168,7 +168,7 @@ export class ApiConnectivityService {
     /** 测试 TikHub API */
     testTikHub(): Observable<ConnectivityResult> {
         const start = Date.now();
-        return this.api.rawPost<any>(`${API_CONFIG.BASE_URL}${API_CONFIG.TIKHUB_PREFIX}/forward`, { path: '/api/v1/health/check', method: 'GET' }).pipe(
+        return this.api.tikhubGet<any>('/v1/health/check').pipe(
             timeout(API_CONFIG.TIMEOUT),
             map(() => ({
                 service: 'TikHub API',

+ 0 - 10
src/app/core/services/api.config.ts

@@ -1,14 +1,4 @@
-import { RUNTIME_CONFIG } from '../config/runtime-config';
-
 export const API_CONFIG = {
-  BASE_URL: RUNTIME_CONFIG.apiBaseUrl,
-  VOC_ECOMMERCE_PREFIX: RUNTIME_CONFIG.vocEcommercePath,
-  AMAZON_PREFIX: '/api/disabled/amazon',
-  SORFTIME_PREFIX: '/api/disabled/sorftime',
-  TIKHUB_PREFIX: '/api/disabled/tikhub',
-  TIKHUB_PROXY_URL: '/api/disabled/tikhub',
-  TIKHUB_TOKEN: '',
-  CLEAN_PREFIX: '/api/v2',
   DEFAULT_PAGE_SIZE: 20,
   TIMEOUT: 30000,
 };

+ 99 - 0
src/app/core/services/cloud-api.service.ts

@@ -0,0 +1,99 @@
+import { Injectable } from '@angular/core';
+import { Observable, defer, from, throwError } from 'rxjs';
+import { catchError, map, timeout } from 'rxjs/operators';
+import { MSQParse } from '../../parse';
+import { RUNTIME_CONFIG } from '../config/runtime-config';
+
+export const CLOUD_ACTIONS = [
+  'context.get', 'workspace.list', 'workspace.members.list', 'workspace.member.update', 'data-source.list', 'import.list', 'audit.list',
+  'domestic.snapshot', 'domestic.products.list', 'domestic.product.get', 'domestic.reviews.list', 'domestic.relations.list',
+  'sync.enqueue', 'sync.jobs.list', 'sync.job.get', 'sync.job.events', 'sync.job.retry', 'sync.job.cancel',
+  'analysis.list', 'analysis.create', 'analysis.update', 'insight-decision.list', 'insight-decision.get', 'insight-decision.create',
+  'action.list', 'action.create', 'action.update', 'alert.list', 'alert.create', 'alert.update',
+  'knowledge.products.list', 'knowledge.product.upsert', 'knowledge.product.delete',
+  'competitor.overview', 'competitor.refresh', 'competitor.history', 'competitor.alerts', 'competitor.impacts',
+  'competitor.tasks.list', 'competitor.tasks.create', 'competitor.tasks.update', 'competitor.run.get',
+  'listing.overview', 'listing.products.list', 'listing.product.get', 'listing.product.score', 'listing.jd-voc-score.get', 'listing.jd-voc-image-review.run', 'listing.score-job.create',
+  'listing.score-job.list', 'listing.score-job.get', 'listing.score-job.items', 'listing.score-job.retry', 'listing.score-job.cancel',
+  'listing.versions.list', 'listing.version.create', 'listing.version.adopt',
+  'ai.status', 'ai.test', 'ai.prompts.list', 'ai.prompt.update', 'ai.chat',
+  'upstream.amazon', 'upstream.sorftime', 'upstream.tikhub', 'upstream.domestic',
+] as const;
+
+export type CloudAction = typeof CLOUD_ACTIONS[number];
+
+export interface CloudRequest {
+  action: CloudAction;
+  workspaceId?: string;
+  platform?: string;
+  payload?: Record<string, unknown>;
+  idempotencyKey?: string;
+}
+
+export interface CloudSuccess<T> {
+  success: true;
+  data: T;
+  requestId: string;
+}
+
+export interface CloudFailure {
+  success: false;
+  code: string;
+  message: string;
+  requestId: string;
+  details?: unknown;
+}
+
+export class CloudApiError extends Error {
+  constructor(
+    message: string,
+    public readonly code = 'cloud_request_failed',
+    public readonly requestId = '',
+    public readonly details?: unknown,
+  ) { super(message); this.name = 'CloudApiError'; }
+}
+
+@Injectable({ providedIn: 'root' })
+export class CloudApiService {
+  private readonly timeoutMs = 30_000;
+
+  run<T>(request: CloudRequest, timeoutMs = this.timeoutMs): Observable<T> {
+    return defer(() => from(this.runPromise<T>(request))).pipe(
+      timeout({ each: timeoutMs }),
+      catchError((error) => throwError(() => this.toError(error))),
+    );
+  }
+
+  async runPromise<T>(request: CloudRequest): Promise<T> {
+    if (RUNTIME_CONFIG.domesticDatasetMode === 'case') {
+      throw new CloudApiError('案例模式已禁用线上业务请求', 'case_mode_network_disabled');
+    }
+    const response = await this.invoke(request);
+    if (!response || response.success !== true) {
+      const failure = response as Partial<CloudFailure> | null | undefined;
+      throw new CloudApiError(
+        failure?.message || '云函数请求失败',
+        failure?.code || 'cloud_request_failed',
+        failure?.requestId || '',
+        failure?.details,
+      );
+    }
+    return (response as CloudSuccess<T>).data;
+  }
+
+  private async invoke(request: CloudRequest): Promise<CloudSuccess<unknown> | CloudFailure> {
+    const result = await MSQParse.Cloud.function({
+      path: RUNTIME_CONFIG.cloudFunctionPath,
+      params: request,
+    }) as CloudSuccess<unknown> | CloudFailure;
+    return result;
+  }
+
+  private toError(error: unknown): CloudApiError {
+    if (error instanceof CloudApiError) return error;
+    if (error instanceof DOMException && error.name === 'TimeoutError') {
+      return new CloudApiError('云函数请求超时,请重试', 'cloud_request_timeout');
+    }
+    return new CloudApiError(error instanceof Error ? error.message : '云函数请求失败');
+  }
+}

+ 55 - 578
src/app/core/services/data-store.service.ts

@@ -1,584 +1,61 @@
 import { Injectable } from '@angular/core';
-import { Observable, from, of } from 'rxjs';
-import { map, catchError } from 'rxjs/operators';
-import { MSQParse } from '../../parse';
-import { PermissionService } from '../../../modules/shared/services/permission.service';
-
-interface DataStoreQueryOptions {
-    limit?: number;
-    skip?: number;
-    orderBy?: string;
-    descending?: boolean;
-    include?: string[];
-    select?: string[];
-    exclude?: string[];
+import { Observable, catchError, forkJoin, of } from 'rxjs';
+import { CloudApiService, CloudRequest } from './cloud-api.service';
+import { RUNTIME_CONFIG } from '../config/runtime-config';
+
+// This is a protocol resource, not a user-controlled Parse class. The server
+// maps each value to a fixed projection and workspace-scoped repository query.
+const DATA_RESOURCES = new Set([
+  'Shop', 'AmazonProduct', 'ProductDetail', 'Product', 'AsinSkuMapping', 'Listing', 'Order', 'OrderMetrics', 'Keyword', 'Category', 'Review',
+  'SorftimeReviews', 'SorftimeProducts', 'SorftimeProduct', 'SelfCategory', 'AmazonCategory', 'MarketTrend', 'ReturnRecord', 'RealTimeVoc',
+  'MonitoringStoreDetail', 'VocReviewAnnotation', 'WorkOrder', 'KnowledgeCompetitor', 'KnowledgeKeyword', 'KnowledgeInfluencer', 'KnowledgeInfluencerProfile', 'KnowledgeInfluencerPost', 'KnowledgePattern', 'KnowledgeHotProduct',
+  'EmergingSocialTrendSnapshot', 'EmergingTrendSignal', 'EmergingTrendKeyword', 'EmergingOpportunity', 'SorftimeCategory', 'SorftimeMarketCapacity', 'RequirementStrength', 'RegionReturnData', 'TodoStatus', 'SocialMediaCache',
+]);
+
+function resourceFor(className: string): string {
+  if (!DATA_RESOURCES.has(className)) throw new Error('不支持的数据资源');
+  return className;
 }
 
-/**
- * 数据持久化服务
- * 使用 Parse 将 API 数据保存至数据库
- */
 @Injectable({ providedIn: 'root' })
 export class DataStoreService {
-
-    private readonly OWN_PRODUCT_CLASSES = new Set(['ProductDetail', 'AsinSkuMapping', 'Product']);
-    private readonly SHOP_SAFE_SELECT_KEYS = [
-        'name',
-        'shopName',
-        'storeName',
-        'platform',
-        'region',
-        'marketplaceId',
-        'domain',
-        'nodeIds',
-        'sync_status',
-        'last_sync_at',
-        'status',
-        'deleted',
-        'disable',
-        'createdAt',
-        'updatedAt'
-    ];
-    private readonly GLOBAL_SENSITIVE_EXCLUDE_KEYS = [
-        'credentials',
-        'SpApiConfig',
-        'accessToken',
-        'access_token',
-        'accessTokenExpiresAt',
-        'refreshToken',
-        'refresh_token',
-        'clientSecret',
-        'client_secret',
-        'secret',
-        'authorization',
-        'shop.config',
-        'shop.credentials',
-        'shop.SpApiConfig',
-        'shop.config.SpApiConfig',
-        'shop.accessToken',
-        'shop.access_token',
-        'shop.accessTokenExpiresAt',
-        'shop.refreshToken',
-        'shop.refresh_token',
-        'shop.clientSecret',
-        'shop.client_secret',
-        'shop.clientId',
-        'shop.client_id',
-        'shop.sellerID',
-        'shop.sellerId'
-    ];
-    private readonly SHOP_SENSITIVE_EXCLUDE_KEYS = [
-        'config',
-        'credentials',
-        'SpApiConfig',
-        'accessToken',
-        'access_token',
-        'accessTokenExpiresAt',
-        'refreshToken',
-        'refresh_token',
-        'clientSecret',
-        'client_secret',
-        'clientId',
-        'client_id',
-        'sellerID',
-        'sellerId',
-        'secret',
-        'authorization'
-    ];
-    private readonly SENSITIVE_KEY_NAMES = new Set([
-        'credentials',
-        'spapiconfig',
-        'accesstoken',
-        'access_token',
-        'accesstokenexpiresat',
-        'refreshtoken',
-        'refresh_token',
-        'clientsecret',
-        'client_secret',
-        'secret',
-        'authorization'
-    ]);
-
-    constructor(private permissionService: PermissionService) { }
-
-    /**
-     * 保存数据到 Parse 数据库
-     * @param className Parse 类名
-     * @param data 要保存的数据
-     * @param uniqueKey 唯一标识字段名(用于去重更新)
-     */
-    save(className: string, data: Record<string, any>, uniqueKey?: string): Observable<any> {
-        return from(this.saveToParseClass(className, data, uniqueKey)).pipe(
-            catchError(err => {
-                console.warn(`[DataStore] 保存 ${className} 失败(已回退):`, err?.message || err);
-                return of(null);
-            })
-        );
-    }
-
-    /**
-     * 批量保存数据
-     */
-    saveBatch(className: string, items: Record<string, any>[], uniqueKey?: string): Observable<any[]> {
-        const promises = items.map(item => this.saveToParseClass(className, item, uniqueKey));
-        return from(Promise.all(promises)).pipe(
-            catchError(err => {
-                console.warn(`[DataStore] 批量保存 ${className} 失败(已回退):`, err?.message || err);
-                return of([]);
-            })
-        );
-    }
-
-    /**
-     * 查询 Parse 数据
-     */
-    query(className: string, filters?: Record<string, any>, options?: {
-        limit?: number;
-        skip?: number;
-        orderBy?: string;
-        descending?: boolean;
-        include?: string[];
-        select?: string[];
-        exclude?: string[];
-    }): Observable<any[]> {
-        return from(this.queryParseClass(className, filters, options)).pipe(
-            catchError(err => {
-                console.warn(`[DataStore] 查询 ${className} 失败(已回退):`, err?.message || err);
-                return of([]);
-            })
-        );
-    }
-
-    /**
-     * 调用 Parse Cloud Function
-     */
-    cloudFunction(params: { id: string;[key: string]: any }): Observable<any> {
-        return from(MSQParse.Cloud.function(params)).pipe(
-            catchError(err => {
-                console.warn(`[DataStore] Cloud Function 调用失败(已回退):`, err?.message || err);
-                return of(null);
-            })
-        );
-    }
-
-    /**
-     * 查询 Parse 数据条数
-     */
-    count(className: string, filters?: Record<string, any>): Observable<number> {
-        return from(this.countParseClass(className, filters)).pipe(
-            catchError(err => {
-                console.warn(`[DataStore] 计数 ${className} 失败(已回退):`, err?.message || err);
-                return of(0);
-            })
-        );
-    }
-
-    /**
-     * 查询指定日期之后创建的记录数
-     */
-    countSince(className: string, sinceDate: Date): Observable<number> {
-        return from((async () => {
-            const query = new MSQParse.Query(className);
-            query.greaterThanOrEqualTo('createdAt', sinceDate);
-            return query.count();
-        })()).pipe(
-            catchError(err => {
-                console.warn(`[DataStore] 计数 ${className} (since) 失败(已回退):`, err?.message || err);
-                return of(0);
-            })
-        );
-    }
-
-    /**
-     * 删除 Parse 数据库中的记录
-     * @param className Parse 类名
-     * @param objectId 要删除的记录 objectId
-     */
-    delete(className: string, objectId: string): Observable<any> {
-        return from((async () => {
-            const query = new MSQParse.Query(className);
-            const obj = await query.get(objectId);
-            if (obj) return obj.destroy();
-            return null;
-        })()).pipe(
-            catchError(err => {
-                console.warn(`[DataStore] 删除 ${className}/${objectId} 失败:`, err?.message || err);
-                return of(null);
-            })
-        );
-    }
-
-    // ==================== 内部方法 ====================
-
-    private async saveToParseClass(
-        className: string,
-        data: Record<string, any>,
-        uniqueKey?: string
-    ): Promise<any> {
-        // 注意:在 fmode-ng 的 Parse 封装中,不推荐使用 Object.extend,
-        // 需要直接使用字符串类名构造 Query / Object,否则可能落到默认 ExtendedClass
-
-        // 如果有唯一键,先查询是否存在
-        if (uniqueKey && data[uniqueKey]) {
-            const query = new MSQParse.Query(className);
-            query.equalTo(uniqueKey, data[uniqueKey]);
-            const existing = await query.first();
-            if (existing) {
-                Object.entries(data).forEach(([key, value]) => {
-                    existing.set(key, value);
-                });
-                existing.set('updatedAt_custom', new Date().toISOString());
-                return existing.save();
-            }
-        }
-
-        // 创建新记录
-        const obj = new MSQParse.Object(className);
-        Object.entries(data).forEach(([key, value]) => {
-            obj.set(key, value);
-        });
-        obj.set('createdAt_custom', new Date().toISOString());
-        return obj.save();
-    }
-
-    private async queryParseClass(
-        className: string,
-        filters?: Record<string, any>,
-        options?: DataStoreQueryOptions
-    ): Promise<any[]> {
-        const permissionFilters = await this.applyOwnProductPermissionFilters(className, filters);
-        if (permissionFilters === null) return [];
-
-        // 检查是否有大数组 containedIn 需要分批
-        const BATCH_THRESHOLD = 150;
-        if (permissionFilters) {
-            for (const [key, value] of Object.entries(permissionFilters)) {
-                if (Array.isArray(value) && value.length > BATCH_THRESHOLD) {
-                    return this.batchedContainedInQuery(className, key, value, permissionFilters, options);
-                }
-            }
-        }
-
-        // 直接使用类名字符串构造 Query,确保真正访问到对应的 Parse 表
-        const query = new MSQParse.Query(className);
-
-        this.applyQueryFilters(query, permissionFilters);
-        this.applyQueryProjection(query, className, options);
-
-        if (options?.limit) query.limit(options.limit);
-        if (options?.skip) query.skip(options.skip);
-        if (options?.orderBy) {
-            if (options.descending) {
-                query.descending(options.orderBy);
-            } else {
-                query.ascending(options.orderBy);
-            }
-        }
-
-        const results = await query.find();
-        return results.map((r: any) => this.toClientRecord(className, r));
-    }
-
-    /** 自动分批 containedIn 查询,避免 URL 过长导致 ERR_FAILED */
-    private async batchedContainedInQuery(
-        className: string,
-        arrayKey: string,
-        arrayValues: any[],
-        filters: Record<string, any>,
-        options?: DataStoreQueryOptions
-    ): Promise<any[]> {
-        const BATCH_SIZE = 150;
-        const perBatchLimit = options?.limit ? Math.min(options.limit, 1000) : 1000;
-        const allResults: any[] = [];
-
-        for (let i = 0; i < arrayValues.length; i += BATCH_SIZE) {
-            const batch = arrayValues.slice(i, i + BATCH_SIZE);
-            const query = new MSQParse.Query(className);
-
-            // 设置当前批次的 containedIn
-            (query as any).containedIn(arrayKey, batch);
-
-            this.applyQueryFilters(query, filters, arrayKey);
-            this.applyQueryProjection(query, className, options);
-
-            query.limit(perBatchLimit);
-            if (options?.orderBy) {
-                if (options.descending) {
-                    query.descending(options.orderBy);
-                } else {
-                    query.ascending(options.orderBy);
-                }
-            }
-
-            const results = await query.find();
-            allResults.push(...results.map((r: any) => this.toClientRecord(className, r)));
-        }
-
-        // 如果有全局 limit,截断
-        if (options?.limit && allResults.length > options.limit) {
-            return allResults.slice(0, options.limit);
-        }
-        return allResults;
-    }
-
-    private async countParseClass(
-        className: string,
-        filters?: Record<string, any>
-    ): Promise<number> {
-        const permissionFilters = await this.applyOwnProductPermissionFilters(className, filters);
-        if (permissionFilters === null) return 0;
-
-        const query = new MSQParse.Query(className);
-        this.applyQueryFilters(query, permissionFilters);
-        return query.count();
-    }
-
-    private applyQueryProjection(query: any, className: string, options?: DataStoreQueryOptions): void {
-        (options?.include || []).forEach(path => {
-            if (path) query.include(path);
-        });
-
-        const selectKeys = options?.select?.length
-            ? options.select
-            : (className === 'Shop' ? this.SHOP_SAFE_SELECT_KEYS : []);
-        if (selectKeys.length) query.select(selectKeys);
-
-        const excludeKeys = this.getSensitiveExcludeKeys(className, options?.exclude || []);
-        if (excludeKeys.length && typeof query.exclude === 'function') {
-            query.exclude(excludeKeys);
-        }
-    }
-
-    private getSensitiveExcludeKeys(className: string, extraExcludeKeys: string[]): string[] {
-        const keys = new Set<string>([
-            ...this.GLOBAL_SENSITIVE_EXCLUDE_KEYS,
-            ...extraExcludeKeys
-        ]);
-        if (className === 'Shop') {
-            this.SHOP_SENSITIVE_EXCLUDE_KEYS.forEach(key => keys.add(key));
-        }
-        return Array.from(keys);
-    }
-
-    private toClientRecord(className: string, row: any): any {
-        const raw = this.safeToPlainObject(row);
-        return this.sanitizeForClient(className, raw, [], new WeakSet<object>());
-    }
-
-    private safeToPlainObject(value: any): any {
-        if (!value || typeof value !== 'object') return value;
-        if (this.isParseObjectLike(value)) {
-            return this.parseObjectToShallowJson(value, true);
-        }
-        if (typeof value.toJSON === 'function') {
-            try {
-                return value.toJSON();
-            } catch {
-                return this.parseObjectToShallowJson(value, true);
-            }
-        }
-        return value;
-    }
-
-    private sanitizeForClient(className: string, value: any, path: string[] = [], seen: WeakSet<object> = new WeakSet<object>()): any {
-        if (Array.isArray(value)) {
-            return value.map(item => this.sanitizeForClient(className, item, path, seen));
-        }
-        if (!value || typeof value !== 'object') return value;
-        if (value instanceof Date) return value.toISOString();
-        if (path.length > 10) return this.circularReferenceToShallowJson(value);
-        if (this.isParseObjectLike(value)) {
-            return path.length === 0
-                ? this.sanitizeForClient(className, this.safeToPlainObject(value), path, seen)
-                : this.parseObjectToShallowJson(value);
-        }
-        if (seen.has(value)) return this.circularReferenceToShallowJson(value);
-        seen.add(value);
-
-        const sanitized: Record<string, any> = {};
-        Object.entries(value).forEach(([key, nestedValue]) => {
-            if (this.shouldDropSensitiveKey(className, key, path)) return;
-            sanitized[key] = this.sanitizeForClient(className, nestedValue, [...path, key], seen);
-        });
-        return sanitized;
-    }
-
-    private isParseObjectLike(value: any): boolean {
-        return !!value
-            && typeof value === 'object'
-            && (
-                typeof value.toPointer === 'function'
-                || value.__type === 'Pointer'
-                || value.__type === 'Object'
-                || (typeof value.className === 'string' && (value.attributes || value.id))
-            );
-    }
-
-    private parseObjectToShallowJson(value: any, includeDataFields = false): Record<string, any> {
-        const data = value?.data && typeof value.data === 'object'
-            ? value.data
-            : (value?.attributes && typeof value.attributes === 'object' ? value.attributes : value);
-        const objectId = value?.id || value?.objectId || data?.objectId || '';
-        const className = value?.className || data?.className || '';
-        const result: Record<string, any> = {};
-        if (className) result['className'] = className;
-        if (objectId) result['objectId'] = objectId;
-
-        if (includeDataFields && data && data !== value) {
-            Object.entries(data).forEach(([key, nested]) => {
-                if (nested !== undefined) result[key] = nested;
-            });
-        }
-
-        [
-            'name',
-            'shopName',
-            'storeName',
-            'platform',
-            'region',
-            'marketplaceId',
-            'domain',
-            'status',
-            'deleted',
-            'disable',
-            'createdAt',
-            'updatedAt'
-        ].forEach(key => {
-            const nested = value?.[key] ?? data?.[key];
-            if (nested === undefined || nested === null) return;
-            result[key] = nested instanceof Date ? nested.toISOString() : nested;
-        });
-
-        if (!includeDataFields && !result['__type'] && className && objectId) result['__type'] = 'Pointer';
-        return result;
-    }
-
-    private circularReferenceToShallowJson(value: any): Record<string, any> {
-        const objectId = value?.objectId || value?.id || '';
-        const className = value?.className || '';
-        const result: Record<string, any> = {};
-        if (className) result['className'] = className;
-        if (objectId) result['objectId'] = objectId;
-        if (className && objectId) result['__type'] = 'Pointer';
-        return result;
-    }
-
-    private shouldDropSensitiveKey(className: string, key: string, path: string[]): boolean {
-        const lowerKey = key.toLowerCase();
-        const normalizedKey = key.replace(/[_-]/g, '').toLowerCase();
-        const normalizedPath = path.map(part => part.replace(/[_-]/g, '').toLowerCase());
-        const isShopObject = className === 'Shop' || normalizedPath.includes('shop');
-        const isSensitiveContainer = normalizedPath.some(part => part === 'config' || part === 'credentials' || part === 'spapiconfig');
-
-        if (lowerKey.startsWith('_')) return true;
-        if (lowerKey === 'data' && normalizedPath.includes('shop')) return true;
-        if (lowerKey === 'config' && isShopObject) return true;
-        if (this.SENSITIVE_KEY_NAMES.has(lowerKey) || this.SENSITIVE_KEY_NAMES.has(normalizedKey)) {
-            return isShopObject || isSensitiveContainer;
-        }
-        return false;
-    }
-
-    private applyQueryFilters(query: any, filters?: Record<string, any> | null, skipKey?: string): void {
-        if (!filters) return;
-        Object.entries(filters).forEach(([key, value]) => {
-            if (key === skipKey || value === undefined || value === null) return;
-            if (Array.isArray(value)) {
-                if (value.length > 0) query.containedIn(key, value);
-                return;
-            }
-            if (this.isOperatorFilter(value)) {
-                Object.entries(value).forEach(([operator, raw]) => {
-                    const normalized = this.normalizeQueryValue(raw);
-                    switch (operator) {
-                        case '$ne':
-                        case '$neq':
-                            query.notEqualTo(key, normalized);
-                            break;
-                        case '$in':
-                            if (Array.isArray(normalized) && normalized.length > 0) query.containedIn(key, normalized);
-                            break;
-                        case '$nin':
-                            if (Array.isArray(normalized) && normalized.length > 0) query.notContainedIn(key, normalized);
-                            break;
-                        case '$gte':
-                            query.greaterThanOrEqualTo(key, normalized);
-                            break;
-                        case '$lte':
-                            query.lessThanOrEqualTo(key, normalized);
-                            break;
-                        case '$gt':
-                            query.greaterThan(key, normalized);
-                            break;
-                        case '$lt':
-                            query.lessThan(key, normalized);
-                            break;
-                        case '$exists':
-                            if (!!normalized) {
-                                query.exists(key);
-                            } else {
-                                query.doesNotExist(key);
-                            }
-                            break;
-                    }
-                });
-                return;
-            }
-            query.equalTo(key, this.normalizeQueryValue(value));
-        });
-    }
-
-    private isOperatorFilter(value: any): boolean {
-        return value && typeof value === 'object' && !Array.isArray(value)
-            && Object.keys(value).some(key => key.startsWith('$'));
-    }
-
-    private normalizeQueryValue(value: any): any {
-        if (Array.isArray(value)) return value.map(item => this.normalizeQueryValue(item));
-        if (value && typeof value === 'object' && value.__type === 'Date' && value.iso) {
-            return new Date(value.iso);
-        }
-        return value;
-    }
-
-    private async applyOwnProductPermissionFilters(
-        className: string,
-        filters?: Record<string, any>
-    ): Promise<Record<string, any> | null> {
-        if (!this.OWN_PRODUCT_CLASSES.has(className)) return filters || {};
-
-        const context = await this.permissionService.getOwnProductAccessContext();
-        if (!context.restricted) return filters || {};
-        if (!context.asins.size) return null;
-
-        return this.mergeAllowedAsins(filters || {}, Array.from(context.asins));
-    }
-
-    private mergeAllowedAsins(filters: Record<string, any>, allowedAsins: string[]): Record<string, any> | null {
-        const asinKey = ['asin', 'Asin', 'ASIN'].find(key => Object.prototype.hasOwnProperty.call(filters, key));
-        if (!asinKey) {
-            return { asin: allowedAsins, ...filters };
-        }
-
-        const rawValue = filters[asinKey];
-        const allowedSet = new Set(allowedAsins.map(value => this.normalizeAsin(value)).filter(Boolean));
-        let nextValue: any;
-
-        if (Array.isArray(rawValue)) {
-            nextValue = rawValue
-                .map(value => String(value || '').trim())
-                .filter(value => allowedSet.has(this.normalizeAsin(value)));
-            if (!nextValue.length) return null;
-        } else {
-            const normalized = this.normalizeAsin(rawValue);
-            if (!normalized || !allowedSet.has(normalized)) return null;
-            nextValue = rawValue;
-        }
-
-        return { ...filters, [asinKey]: nextValue };
-    }
-
-    private normalizeAsin(value: any): string {
-        return String(value || '').trim().toUpperCase();
-    }
+  constructor(private readonly cloud: CloudApiService) {}
+
+  save(className: string, data: Record<string, unknown>, uniqueKey?: string): Observable<unknown> {
+    if (RUNTIME_CONFIG.domesticDatasetMode === 'case') return of(null);
+    return this.cloud.run({ action: 'upstream.amazon', payload: { operation: 'data.save', resource: resourceFor(className), data, uniqueKey } }).pipe(catchError((error) => { console.warn('[DataStore] 写入失败:', error); return of(null); }));
+  }
+
+  saveBatch(className: string, items: Record<string, unknown>[], uniqueKey?: string): Observable<unknown[]> {
+    if (RUNTIME_CONFIG.domesticDatasetMode === 'case') return of([]);
+    if (items.length > 500) return of([]);
+    return forkJoin(items.map((item) => this.save(className, item, uniqueKey))).pipe(catchError((error) => { console.warn('[DataStore] 批量写入失败:', error); return of([]); }));
+  }
+
+  query(className: string, filters?: Record<string, unknown>, options?: Record<string, unknown>): Observable<any[]> {
+    if (RUNTIME_CONFIG.domesticDatasetMode === 'case') return of([]);
+    return this.cloud.run<{ items?: unknown[] }>({ action: 'upstream.amazon', payload: { operation: 'data.query', resource: resourceFor(className), filters: filters ?? {}, options: options ?? {} } }).pipe(
+      catchError((error) => { console.warn('[DataStore] 查询失败:', error); return of({ items: [] }); }),
+      // The legacy callers consume an array; pagination stays server-side.
+      // A missing/empty response is intentionally an empty result, never fake data.
+      (source) => new Observable<any[]>((subscriber) => source.subscribe({ next: (value) => subscriber.next(value.items ?? []), error: (error) => subscriber.error(error), complete: () => subscriber.complete() })),
+    );
+  }
+
+  count(className: string, filters?: Record<string, unknown>): Observable<number> {
+    if (RUNTIME_CONFIG.domesticDatasetMode === 'case') return of(0);
+    return this.cloud.run<{ count?: number }>({ action: 'upstream.amazon', payload: { operation: 'data.count', resource: resourceFor(className), filters: filters ?? {} } }).pipe(
+      catchError(() => of({ count: 0 })),
+      (source) => new Observable<number>((subscriber) => source.subscribe({ next: (value) => subscriber.next(Number(value.count ?? 0)), error: (error) => subscriber.error(error), complete: () => subscriber.complete() })),
+    );
+  }
+
+  countSince(className: string, sinceDate: Date): Observable<number> { return this.count(className, { createdAt: { $gte: sinceDate.toISOString() } }); }
+  delete(className: string, objectId: string): Observable<unknown> { if (RUNTIME_CONFIG.domesticDatasetMode === 'case') return of(null); return this.cloud.run({ action: 'upstream.amazon', payload: { operation: 'data.delete', resource: resourceFor(className), objectId } }).pipe(catchError(() => of(null))); }
+
+  cloudFunction<T = unknown>(request: CloudRequest | Record<string, unknown>): Observable<T> {
+    if (RUNTIME_CONFIG.domesticDatasetMode === 'case') return of(null as T);
+    if (!('action' in request)) return of(null as T);
+    return this.cloud.run<T>(request as CloudRequest).pipe(catchError((error) => { console.warn('[DataStore] 云函数失败:', error); return of(null as T); }));
+  }
 }

+ 23 - 4
src/app/core/services/domestic-dataset.service.ts

@@ -4,6 +4,7 @@ import { BehaviorSubject, Observable, map, shareReplay, switchMap } from 'rxjs';
 import { DomesticDataset, DomesticProduct, DomesticReview } from '../models/domestic.models';
 import { ProductInfo } from '../../../modules/shared/interfaces/voc-insight-interfaces';
 import { RUNTIME_CONFIG, SaasVocRuntimeConfig } from '../config/runtime-config';
+import { CloudApiService } from './cloud-api.service';
 
 export interface DomesticReviewScope {
   all: DomesticReview[];
@@ -76,15 +77,23 @@ export function buildDomesticDatasetUrl(
 @Injectable({ providedIn: 'root' })
 export class DomesticDatasetService {
   private readonly http = inject(HttpClient);
+  private readonly cloud = inject(CloudApiService);
   private readonly datasetUrl = buildDomesticDatasetUrl(RUNTIME_CONFIG);
   private readonly refreshTrigger = new BehaviorSubject<void>(undefined);
 
   readonly dataset$: Observable<DomesticDataset> = this.refreshTrigger
-    .pipe(switchMap(() => this.http.get<DomesticDataset>(this.datasetUrl)))
+    .pipe(switchMap(() => RUNTIME_CONFIG.domesticDatasetMode === 'case'
+      ? this.http.get<DomesticDataset>(this.datasetUrl)
+      : this.cloud.run<DomesticDataset>({ action: 'domestic.snapshot', workspaceId: RUNTIME_CONFIG.domesticWorkspaceId, platform: RUNTIME_CONFIG.domesticPlatform })))
     .pipe(shareReplay({ bufferSize: 1, refCount: false }));
 
   readonly products$: Observable<ProductInfo[]> = this.dataset$.pipe(
-    map((dataset) => selectDomesticOwnProducts(dataset).map((product) => this.toProductInfo(product, dataset))),
+    map((dataset) => {
+      const reviewsByProduct = this.buildReviewsByProduct(dataset);
+      return selectDomesticOwnProducts(dataset).map((product) =>
+        this.toProductInfo(product, dataset, reviewsByProduct.get(product.productId) ?? []),
+      );
+    }),
     shareReplay({ bufferSize: 1, refCount: false }),
   );
 
@@ -107,8 +116,8 @@ export class DomesticDatasetService {
     );
   }
 
-  toProductInfo(product: DomesticProduct, dataset: DomesticDataset): ProductInfo {
-    const reviews = (dataset.reviews ?? []).filter((review) => review.productId === product.productId);
+  toProductInfo(product: DomesticProduct, dataset: DomesticDataset, productReviews?: DomesticReview[]): ProductInfo {
+    const reviews = productReviews ?? (dataset.reviews ?? []).filter((review) => review.productId === product.productId);
     const ratedReviews = reviews.filter((review) => review.rating > 0);
     const positive = ratedReviews.filter((review) => review.rating >= 4).length;
     const neutral = ratedReviews.filter((review) => review.rating === 3).length;
@@ -164,4 +173,14 @@ export class DomesticDatasetService {
     ];
     return Math.round(fields.filter(Boolean).length / fields.length * 100);
   }
+
+  private buildReviewsByProduct(dataset: DomesticDataset): Map<string, DomesticReview[]> {
+    const reviewsByProduct = new Map<string, DomesticReview[]>();
+    for (const review of dataset.reviews ?? []) {
+      const productReviews = reviewsByProduct.get(review.productId);
+      if (productReviews) productReviews.push(review);
+      else reviewsByProduct.set(review.productId, [review]);
+    }
+    return reviewsByProduct;
+  }
 }

+ 3 - 13
src/app/core/services/domestic-ecommerce-gateway.service.ts

@@ -1,22 +1,12 @@
-import { HttpClient, HttpParams } from '@angular/common/http';
 import { Injectable, inject } from '@angular/core';
 import { Observable } from 'rxjs';
-import { API_CONFIG } from './api.config';
+import { CloudApiService } from './cloud-api.service';
 
 @Injectable({ providedIn: 'root' })
 export class DomesticEcommerceGatewayService {
-  private readonly http = inject(HttpClient);
+  private readonly cloud = inject(CloudApiService);
 
   get<T>(platform: string, upstreamPath: string, query: Record<string, string | number> = {}): Observable<T> {
-    const safePlatform = encodeURIComponent(platform.trim());
-    const safePath = upstreamPath.split('/').filter(Boolean).map(encodeURIComponent).join('/');
-    const params = Object.entries(query).reduce(
-      (result, [key, value]) => result.set(key, String(value)),
-      new HttpParams(),
-    );
-    return this.http.get<T>(
-      `${API_CONFIG.BASE_URL}${API_CONFIG.VOC_ECOMMERCE_PREFIX}/${safePlatform}/${safePath}`,
-      { params },
-    );
+    return this.cloud.run<T>({ action: 'upstream.domestic', platform: platform.trim(), payload: { operation: 'gateway.get', path: upstreamPath, query } });
   }
 }

+ 13 - 0
src/app/core/services/domestic-review-insight.service.ts

@@ -70,4 +70,17 @@ export class DomesticReviewInsightService {
       topics,
     };
   }
+
+  matchTopic(review: DomesticReview): DomesticReviewTopicSignal | null {
+    const rule = this.rules.find((candidate) => candidate.pattern.test(review.content || ''));
+    if (!rule) return null;
+    return {
+      id: rule.id,
+      label: rule.label,
+      description: rule.description,
+      count: 1,
+      share: 1,
+      sample: review,
+    };
+  }
 }

+ 28 - 196
src/app/core/services/http-api.service.ts

@@ -1,212 +1,44 @@
 import { Injectable } from '@angular/core';
-import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http';
 import { Observable, throwError } from 'rxjs';
-import { map, catchError, retry } from 'rxjs/operators';
-import { API_CONFIG } from './api.config';
-import { ApiResponse, PaginatedResponse } from '../models/api.models';
+import { CloudApiService } from './cloud-api.service';
 
 const SHOP_OBJECT_ID_KEY = 'msq_shop_objectId';
 
-/**
- * 核心 HTTP 请求服务
- * 封装统一的请求/响应处理、错误处理、重试逻辑
- */
-@Injectable({
-    providedIn: 'root'
-})
+@Injectable({ providedIn: 'root' })
 export class HttpApiService {
-    private shopObjectId: string | null = null;
+  private shopObjectId: string | null = typeof localStorage === 'undefined' ? null : localStorage.getItem(SHOP_OBJECT_ID_KEY);
 
-    constructor(private http: HttpClient) {
-        // 从 localStorage 恢复 shopObjectId
-        try {
-            this.shopObjectId = localStorage.getItem(SHOP_OBJECT_ID_KEY);
-        } catch (e) {
-            // localStorage 不可用时忽略
-        }
-    }
+  constructor(private readonly cloud: CloudApiService) {}
 
-    /** 设置当前店铺 ObjectId (Amazon 接口需要) */
-    setShopObjectId(id: string): void {
-        this.shopObjectId = id;
-        try {
-            localStorage.setItem(SHOP_OBJECT_ID_KEY, id);
-        } catch (e) {
-            // localStorage 不可用时忽略
-        }
-    }
+  setShopObjectId(id: string): void { this.shopObjectId = id; localStorage?.setItem(SHOP_OBJECT_ID_KEY, id); }
+  getShopObjectId(): string | null { return this.shopObjectId; }
 
-    getShopObjectId(): string | null {
-        return this.shopObjectId;
-    }
+  cleanGet<T>(path: string, params?: Record<string, unknown>): Observable<T> {
+    return this.cloud.run<T>({ action: 'upstream.domestic', payload: { operation: 'clean.get', path, params: params ?? {} } });
+  }
 
-    // ==================== 统一清洗接口 (v2) ====================
+  amazonGet<T>(path: string, params?: Record<string, unknown>): Observable<T> {
+    if (!this.shopObjectId) return throwError(() => new Error('未配置 shop-objectId,请先在系统设置中配置店铺'));
+    return this.cloud.run<T>({ action: 'upstream.amazon', payload: { operation: 'get', path, params: params ?? {}, shopObjectId: this.shopObjectId } });
+  }
 
-    /** GET 请求 - 清洗接口 */
-    cleanGet<T>(path: string, params?: Record<string, any>): Observable<T> {
-        const url = `${API_CONFIG.BASE_URL}${API_CONFIG.CLEAN_PREFIX}${path}`;
-        return this.http.get<ApiResponse<T>>(url, {
-            params: this.buildParams(params)
-        }).pipe(
-            retry(1),
-            map(res => this.extractData(res)),
-            catchError(err => this.handleError(err))
-        );
-    }
+  amazonPost<T>(path: string, body: unknown, params?: Record<string, unknown>): Observable<T> {
+    if (!this.shopObjectId) return throwError(() => new Error('未配置 shop-objectId,请先在系统设置中配置店铺'));
+    return this.cloud.run<T>({ action: 'upstream.amazon', payload: { operation: 'post', path, body, params: params ?? {}, shopObjectId: this.shopObjectId } });
+  }
 
-    // ==================== Amazon SP-API ====================
+  sorftimeForward<T>(apiPath: string, body?: Record<string, unknown>): Observable<T> {
+    return this.cloud.run<T>({ action: 'upstream.sorftime', payload: { operation: 'forward', path: apiPath, body: body?.['body'] ?? {}, query: body?.['query'] ?? {} } });
+  }
 
-    /** GET 请求 - Amazon 接口 */
-    amazonGet<T>(path: string, params?: Record<string, any>): Observable<T> {
-        if (!this.shopObjectId) {
-            return throwError(() => new Error('未配置 shop-objectId,请先在系统设置中配置店铺'));
-        }
-        const url = `${API_CONFIG.BASE_URL}${API_CONFIG.AMAZON_PREFIX}${path}`;
-        return this.http.get<any>(url, {
-            params: this.buildParams(params),
-            headers: this.buildAmazonHeaders()
-        }).pipe(
-            retry(1),
-            map(res => res?.data ?? res),
-            catchError(err => this.handleError(err))
-        );
-    }
+  tikhubForward<T>(apiPath: string, body?: Record<string, unknown>): Observable<T> {
+    return this.cloud.run<T>({ action: 'upstream.tikhub', payload: { operation: 'forward', path: apiPath, ...(body ?? {}) } });
+  }
 
-    /** POST 请求 - Amazon 接口 */
-    amazonPost<T>(path: string, body: any, params?: Record<string, any>): Observable<T> {
-        if (!this.shopObjectId) {
-            return throwError(() => new Error('未配置 shop-objectId,请先在系统设置中配置店铺'));
-        }
-        const url = `${API_CONFIG.BASE_URL}${API_CONFIG.AMAZON_PREFIX}${path}`;
-        return this.http.post<any>(url, body, {
-            params: this.buildParams(params),
-            headers: this.buildAmazonHeaders()
-        }).pipe(
-            retry(1),
-            map(res => res?.data ?? res),
-            catchError(err => this.handleError(err))
-        );
-    }
+  tikhubGet<T>(apiPath: string, params?: Record<string, unknown>): Observable<T> {
+    return this.cloud.run<T>({ action: 'upstream.tikhub', payload: { operation: 'get', path: apiPath, params: params ?? {} } });
+  }
 
-    // ==================== Sorftime API ====================
-
-    /** POST 转发请求 - Sorftime 接口 */
-    sorftimeForward<T>(apiPath: string, body?: any): Observable<T> {
-        const url = `${API_CONFIG.BASE_URL}${API_CONFIG.SORFTIME_PREFIX}/forward`;
-        const payload = {
-            path: apiPath,
-            method: 'POST',
-            body: body?.body ?? {},
-            query: body?.query ?? {}
-        };
-        return this.http.post<any>(url, payload).pipe(
-            map(res => {
-                // 兼容两类错误结构:
-                // 1) { success:false, message:"..." }
-                // 2) { Code:10, Message:"请求参数错误", Data: ... }
-                if (res?.success === false) {
-                    throw new Error(res?.message || 'Sorftime 上游接口返回失败');
-                }
-                if (typeof res?.Code === 'number' && res.Code !== 200 && res.Code !== 0) {
-                    throw new Error(res?.Message || `Sorftime API Error ${res.Code}`);
-                }
-                return res?.Data ?? res?.data ?? res;
-            }),
-            catchError(err => {
-                console.warn(`[Sorftime] ${apiPath} 调用失败 (上游API可能未就绪):`, err?.status || '', err?.error?.message || err?.message || '');
-                return throwError(() => err);
-            })
-        );
-    }
-
-    // ==================== TikHub API ====================
-
-    /** POST 转发请求 - TikHub 接口 (server-msq 转发) */
-    tikhubForward<T>(apiPath: string, body?: any): Observable<T> {
-        const url = `${API_CONFIG.BASE_URL}${API_CONFIG.TIKHUB_PREFIX}/forward`;
-        return this.http.post<any>(url, { path: apiPath, ...body }).pipe(
-            retry(1),
-            map(res => res?.data ?? res),
-            catchError(err => this.handleError(err))
-        );
-    }
-
-    /** GET 请求 - TikHub thapi 代理 (直连,带 Bearer Token) */
-    tikhubGet<T>(apiPath: string, params?: Record<string, any>): Observable<T> {
-        const url = `${API_CONFIG.TIKHUB_PROXY_URL}${apiPath}`;
-        const headers = new HttpHeaders({
-            'Authorization': API_CONFIG.TIKHUB_TOKEN,
-            'Accept': 'application/json'
-        });
-        return this.http.get<any>(url, {
-            params: this.buildParams(params),
-            headers
-        }).pipe(
-            retry(1),
-            map(res => res?.data ?? res),
-            catchError(err => this.handleError(err))
-        );
-    }
-
-    // ==================== 通用 GET/POST ====================
-
-    rawGet<T>(url: string, params?: Record<string, any>): Observable<T> {
-        return this.http.get<T>(url, { params: this.buildParams(params) }).pipe(
-            catchError(err => this.handleError(err))
-        );
-    }
-
-    rawPost<T>(url: string, body: any): Observable<T> {
-        return this.http.post<T>(url, body).pipe(
-            catchError(err => this.handleError(err))
-        );
-    }
-
-    // ==================== 内部方法 ====================
-
-    private buildParams(params?: Record<string, any>): HttpParams {
-        let httpParams = new HttpParams();
-        if (params) {
-            Object.entries(params).forEach(([key, value]) => {
-                if (value !== undefined && value !== null && value !== '') {
-                    httpParams = httpParams.set(key, String(value));
-                }
-            });
-        }
-        return httpParams;
-    }
-
-    private buildAmazonHeaders(): HttpHeaders {
-        let headers = new HttpHeaders({ 'Content-Type': 'application/json' });
-        if (this.shopObjectId) {
-            headers = headers.set('shop-objectId', this.shopObjectId);
-        }
-        return headers;
-    }
-
-    private extractData<T>(res: ApiResponse<T>): T {
-        if (res.code === 0) {
-            return res.data;
-        }
-        throw new Error(`API Error [${res.code}]: ${res.message}`);
-    }
-
-    private handleError(error: any): Observable<never> {
-        let message = '请求失败';
-        if (error?.status) {
-            switch (error.status) {
-                case 400: message = '请求参数错误'; break;
-                case 401: message = '认证失败,请检查店铺配置'; break;
-                case 403: message = '权限不足'; break;
-                case 429: message = '请求过于频繁,请稍后重试'; break;
-                case 500: message = '服务器内部错误'; break;
-                case 502: message = '上游服务调用失败'; break;
-                case 503: message = '服务暂不可用'; break;
-            }
-        } else if (error?.message) {
-            message = error.message;
-        }
-        console.error('[HttpApiService]', message, error);
-        return throwError(() => new Error(message));
-    }
+  rawGet<T>(_url: string, _params?: Record<string, unknown>): Observable<T> { return throwError(() => new Error('通用 URL 请求已禁用,请使用明确的 Cloud action')); }
+  rawPost<T>(_url: string, _body: unknown): Observable<T> { return throwError(() => new Error('通用 URL 请求已禁用,请使用明确的 Cloud action')); }
 }

+ 1 - 0
src/app/core/services/index.ts

@@ -2,6 +2,7 @@
 export { HttpApiService } from './http-api.service';
 export { API_CONFIG } from './api.config';
 export { SaasPlatformService } from './saas-platform.service';
+export { CloudApiService, CloudApiError, CLOUD_ACTIONS } from './cloud-api.service';
 
 // Domain API Services
 export { ProductApiService } from './product-api.service';

+ 15 - 157
src/app/core/services/saas-platform.service.spec.ts

@@ -1,170 +1,28 @@
-import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
 import { TestBed } from '@angular/core/testing';
-import {
-  CursorPage,
-  SaasAnalysisRun,
-  SaasInsightDecision,
-  SaasPlatformService,
-} from './saas-platform.service';
+import { of } from 'rxjs';
+import { CloudApiService } from './cloud-api.service';
+import { SaasPlatformService } from './saas-platform.service';
 
 describe('SaasPlatformService', () => {
   let service: SaasPlatformService;
-  let http: HttpTestingController;
-
-  const workspaceId = 'workspace/alpha';
-  const workspaceUrl = '/api/saas/workspaces/workspace%2Falpha';
-  const analysis: SaasAnalysisRun = {
-    id: 'analysis/001',
-    workspaceId,
-    analysisType: 'voc_insight',
-    targetKind: 'workspace',
-    targetKey: workspaceId,
-    status: 'completed',
-    input: { reviewCount: 40 },
-    result: { insightCount: 9 },
-    evidenceCount: 15,
-    requestedBy: 'user-001',
-    errorSummary: null,
-    requestedAt: '2026-08-06T08:00:00.000Z',
-    startedAt: '2026-08-06T08:00:01.000Z',
-    completedAt: '2026-08-06T08:00:05.000Z',
-  };
+  let cloud: jasmine.SpyObj<CloudApiService>;
 
   beforeEach(() => {
-    TestBed.configureTestingModule({
-      imports: [HttpClientTestingModule],
-    });
-
+    cloud = jasmine.createSpyObj<CloudApiService>('CloudApiService', ['run']);
+    cloud.run.and.returnValue(of({ items: [], nextCursor: null }));
+    TestBed.configureTestingModule({ providers: [SaasPlatformService, { provide: CloudApiService, useValue: cloud }] });
     service = TestBed.inject(SaasPlatformService);
-    http = TestBed.inject(HttpTestingController);
-  });
-
-  afterEach(() => {
-    http.verify();
-  });
-
-  it('gets analysis runs with paging filters', () => {
-    const response: CursorPage<SaasAnalysisRun> = { items: [analysis], nextCursor: 'cursor-next' };
-    let actual: typeof response | undefined;
-
-    service.analyses(workspaceId, 'completed', 'cursor/current', 10).subscribe((value) => {
-      actual = value;
-    });
-
-    const request = http.expectOne((candidate) => candidate.url === `${workspaceUrl}/analyses`);
-    expect(request.request.method).toBe('GET');
-    expect(request.request.params.get('status')).toBe('completed');
-    expect(request.request.params.get('cursor')).toBe('cursor/current');
-    expect(request.request.params.get('limit')).toBe('10');
-    request.flush(response);
-
-    expect(actual).toEqual(response);
   });
 
-  it('creates a VOC insight analysis run', () => {
-    const payload = {
-      analysisType: 'voc_insight' as const,
-      targetKind: 'workspace' as const,
-      targetKey: workspaceId,
-      input: { reviewIds: ['review-001', 'review-002'] },
-    };
-    const response = { analysis };
-    let actual: typeof response | undefined;
-
-    service.createAnalysis(workspaceId, payload).subscribe((value) => {
-      actual = value;
-    });
-
-    const request = http.expectOne(`${workspaceUrl}/analyses`);
-    expect(request.request.method).toBe('POST');
-    expect(request.request.body).toEqual(payload);
-    request.flush(response);
-
-    expect(actual).toEqual(response);
+  it('uses workspace-scoped action payloads for reads and writes', () => {
+    service.analyses('workspace/alpha', 'completed', 'cursor/current', 10).subscribe();
+    expect(cloud.run).toHaveBeenCalledWith({ action: 'analysis.list', workspaceId: 'workspace/alpha', payload: { status: 'completed', cursor: 'cursor/current', limit: 10 } });
+    service.createAction('workspace/alpha', { title: 'Action', priority: 'high' }).subscribe();
+    expect(cloud.run.calls.mostRecent().args[0]).toEqual({ action: 'action.create', workspaceId: 'workspace/alpha', payload: { title: 'Action', priority: 'high' } });
   });
 
-  it('updates an analysis run using an encoded analysis id', () => {
-    const patch = {
-      status: 'completed' as const,
-      result: { insightCount: 9, evidenceCoverage: 0.38 },
-      evidenceCount: 15,
-      errorSummary: null,
-      completedAt: '2026-08-06T08:00:05.000Z',
-    };
-    const response = { analysis };
-    let actual: typeof response | undefined;
-
-    service.updateAnalysis(workspaceId, analysis.id, patch).subscribe((value) => {
-      actual = value;
-    });
-
-    const request = http.expectOne(`${workspaceUrl}/analyses/analysis%2F001`);
-    expect(request.request.method).toBe('PATCH');
-    expect(request.request.body).toEqual(patch);
-    request.flush(response);
-
-    expect(actual).toEqual(response);
-  });
-
-  it('creates an action with its source insight, evidence, and validation metric', () => {
-    const payload = {
-      title: 'Resolve recurring sealing failures',
-      description: 'Validate the proposed packaging change against VOC evidence.',
-      priority: 'high' as const,
-      sourceAnalysisId: analysis.id,
-      sourceInsightId: 'insight-009',
-      sourceDecisionId: 'decision-009',
-      sourceKind: 'insight' as const,
-      creationKey: 'workspace/alpha|analysis/001|insight-009|decision-009',
-      evidenceIds: ['review-001', 'review-002'],
-      validationMetric: 'negative_review_rate decreases from 24% to 12% within 30 days',
-    };
-
-    service.createAction(workspaceId, payload).subscribe();
-
-    const request = http.expectOne(`${workspaceUrl}/actions`);
-    expect(request.request.method).toBe('POST');
-    expect(request.request.body).toEqual(payload);
-    expect(request.request.body.sourceAnalysisId).toBe(analysis.id);
-    expect(request.request.body.sourceInsightId).toBe('insight-009');
-    expect(request.request.body.sourceDecisionId).toBe('decision-009');
-    expect(request.request.body.sourceKind).toBe('insight');
-    expect(request.request.body.creationKey).toContain('decision-009');
-    expect(request.request.body.evidenceIds).toEqual(['review-001', 'review-002']);
-    expect(request.request.body.validationMetric).toBe(
-      'negative_review_rate decreases from 24% to 12% within 30 days',
-    );
-    request.flush({ action: {} });
-  });
-
-  it('lists current insight decisions for an analysis', () => {
-    const response: CursorPage<SaasInsightDecision> = { items: [], nextCursor: null };
-
-    service.insightDecisions(workspaceId, analysis.id, 'insight-009', true).subscribe();
-
-    const request = http.expectOne((candidate) => candidate.url === `${workspaceUrl}/insight-decisions`);
-    expect(request.request.method).toBe('GET');
-    expect(request.request.params.get('analysisId')).toBe(analysis.id);
-    expect(request.request.params.get('insightId')).toBe('insight-009');
-    expect(request.request.params.get('currentOnly')).toBe('true');
-    expect(request.request.params.get('limit')).toBe('100');
-    request.flush(response);
-  });
-
-  it('creates a grounded insight decision', () => {
-    const payload = {
-      sourceAnalysisId: analysis.id,
-      sourceInsightId: 'insight-009',
-      decision: 'confirmed' as const,
-      reviewedEvidenceIds: ['review-001', 'review-002'],
-      comment: 'Evidence is consistent across the reviewed samples.',
-    };
-
-    service.createInsightDecision(workspaceId, payload).subscribe();
-
-    const request = http.expectOne(`${workspaceUrl}/insight-decisions`);
-    expect(request.request.method).toBe('POST');
-    expect(request.request.body).toEqual(payload);
-    request.flush({ decision: {} });
+  it('maps insight decision calls to their dedicated actions', () => {
+    service.insightDecisions('workspace/alpha', 'analysis-1', 'insight-1', true).subscribe();
+    expect(cloud.run).toHaveBeenCalledWith(jasmine.objectContaining({ action: 'insight-decision.list', workspaceId: 'workspace/alpha' }));
   });
 });

+ 14 - 36
src/app/core/services/saas-platform.service.ts

@@ -1,7 +1,6 @@
-import { HttpClient, HttpParams } from '@angular/common/http';
 import { Injectable, inject } from '@angular/core';
 import { Observable } from 'rxjs';
-import { RUNTIME_CONFIG } from '../config/runtime-config';
+import { CloudApiService } from './cloud-api.service';
 
 export type WorkspaceRole = 'owner' | 'admin' | 'analyst' | 'viewer';
 
@@ -103,30 +102,25 @@ export interface SaasAlertItem {
 
 @Injectable({ providedIn: 'root' })
 export class SaasPlatformService {
-  private readonly http = inject(HttpClient);
-  private readonly baseUrl = `${RUNTIME_CONFIG.apiBaseUrl.replace(/\/+$/, '')}/${RUNTIME_CONFIG.saasPath.replace(/^\/+|\/+$/g, '')}`;
+  private readonly cloud = inject(CloudApiService);
 
   context(): Observable<SaasContext> {
-    return this.http.get<SaasContext>(`${this.baseUrl}/context`);
+    return this.cloud.run<SaasContext>({ action: 'context.get' });
   }
 
   actions(workspaceId: string, status = '', cursor = '', limit = 25): Observable<CursorPage<SaasActionItem>> {
-    return this.http.get<CursorPage<SaasActionItem>>(`${this.workspaceUrl(workspaceId)}/actions`, {
-      params: this.pageParams(status, cursor, limit),
-    });
+    return this.cloud.run({ action: 'action.list', workspaceId, payload: this.pagePayload(status, cursor, limit) });
   }
 
   analyses(workspaceId: string, status = '', cursor = '', limit = 25): Observable<CursorPage<SaasAnalysisRun>> {
-    return this.http.get<CursorPage<SaasAnalysisRun>>(`${this.workspaceUrl(workspaceId)}/analyses`, {
-      params: this.pageParams(status, cursor, limit),
-    });
+    return this.cloud.run({ action: 'analysis.list', workspaceId, payload: this.pagePayload(status, cursor, limit) });
   }
 
   createAnalysis(
     workspaceId: string,
     analysis: Pick<SaasAnalysisRun, 'analysisType' | 'targetKind' | 'targetKey' | 'input'>,
   ): Observable<{ analysis: SaasAnalysisRun }> {
-    return this.http.post<{ analysis: SaasAnalysisRun }>(`${this.workspaceUrl(workspaceId)}/analyses`, analysis);
+    return this.cloud.run({ action: 'analysis.create', workspaceId, payload: analysis });
   }
 
   updateAnalysis(
@@ -134,7 +128,7 @@ export class SaasPlatformService {
     analysisId: string,
     patch: Partial<Pick<SaasAnalysisRun, 'status' | 'result' | 'evidenceCount' | 'errorSummary' | 'startedAt' | 'completedAt'>>,
   ): Observable<{ analysis: SaasAnalysisRun }> {
-    return this.http.patch<{ analysis: SaasAnalysisRun }>(`${this.workspaceUrl(workspaceId)}/analyses/${encodeURIComponent(analysisId)}`, patch);
+    return this.cloud.run({ action: 'analysis.update', workspaceId, payload: { analysisId, ...patch } });
   }
 
   insightDecisions(
@@ -145,28 +139,21 @@ export class SaasPlatformService {
     cursor = '',
     limit = 100,
   ): Observable<CursorPage<SaasInsightDecision>> {
-    let params = new HttpParams().set('limit', limit).set('currentOnly', currentOnly);
-    if (analysisId) params = params.set('analysisId', analysisId);
-    if (insightId) params = params.set('insightId', insightId);
-    if (cursor) params = params.set('cursor', cursor);
-    return this.http.get<CursorPage<SaasInsightDecision>>(`${this.workspaceUrl(workspaceId)}/insight-decisions`, { params });
+    return this.cloud.run({ action: 'insight-decision.list', workspaceId, payload: { analysisId, insightId, currentOnly, cursor, limit } });
   }
 
   createInsightDecision(
     workspaceId: string,
     decision: Pick<SaasInsightDecision, 'sourceAnalysisId' | 'sourceInsightId' | 'decision' | 'reviewedEvidenceIds' | 'comment'>,
   ): Observable<{ decision: SaasInsightDecision }> {
-    return this.http.post<{ decision: SaasInsightDecision }>(
-      `${this.workspaceUrl(workspaceId)}/insight-decisions`,
-      decision,
-    );
+    return this.cloud.run({ action: 'insight-decision.create', workspaceId, payload: decision });
   }
 
   createAction(
     workspaceId: string,
     action: Pick<SaasActionItem, 'title'> & Partial<Omit<SaasActionItem, 'id' | 'workspaceId' | 'title' | 'createdAt' | 'updatedAt'>>,
   ): Observable<{ action: SaasActionItem }> {
-    return this.http.post<{ action: SaasActionItem }>(`${this.workspaceUrl(workspaceId)}/actions`, action);
+    return this.cloud.run({ action: 'action.create', workspaceId, payload: action });
   }
 
   updateAction(
@@ -174,23 +161,14 @@ export class SaasPlatformService {
     actionId: string,
     patch: Partial<Pick<SaasActionItem, 'title' | 'description' | 'priority' | 'status' | 'assigneeUserId' | 'dueAt'>>,
   ): Observable<{ action: SaasActionItem }> {
-    return this.http.patch<{ action: SaasActionItem }>(`${this.workspaceUrl(workspaceId)}/actions/${encodeURIComponent(actionId)}`, patch);
+    return this.cloud.run({ action: 'action.update', workspaceId, payload: { actionId, ...patch } });
   }
 
   alerts(workspaceId: string, status = '', cursor = '', limit = 25): Observable<CursorPage<SaasAlertItem>> {
-    return this.http.get<CursorPage<SaasAlertItem>>(`${this.workspaceUrl(workspaceId)}/alerts`, {
-      params: this.pageParams(status, cursor, limit),
-    });
+    return this.cloud.run({ action: 'alert.list', workspaceId, payload: this.pagePayload(status, cursor, limit) });
   }
 
-  private workspaceUrl(workspaceId: string): string {
-    return `${this.baseUrl}/workspaces/${encodeURIComponent(workspaceId)}`;
-  }
-
-  private pageParams(status: string, cursor: string, limit: number): HttpParams {
-    let params = new HttpParams().set('limit', limit);
-    if (status) params = params.set('status', status);
-    if (cursor) params = params.set('cursor', cursor);
-    return params;
+  private pagePayload(status: string, cursor: string, limit: number): Record<string, unknown> {
+    return { ...(status ? { status } : {}), ...(cursor ? { cursor } : {}), limit };
   }
 }

+ 7 - 2
src/app/parse.ts

@@ -17,8 +17,13 @@ if (typeof window !== 'undefined' && typeof window.alert === 'function') {
   };
 }
 
-export const MSQParse = FmodeParse.initialize({
+// This is the single Parse initialization point for the browser application.
+// Feature modules always reuse the named instance exported below and never
+// create a bare/global Parse client.
+FmodeParse.initialize({
+  config: 'dev',
   appId: RUNTIME_CONFIG.parseAppId,
   serverURL: RUNTIME_CONFIG.parseServerUrl,
-  config: 'saas-voc'
 });
+
+export const MSQParse = FmodeParse.with('dev');

+ 3 - 3
src/index.html

@@ -2,11 +2,11 @@
 <html lang="zh-CN">
 <head>
   <meta charset="utf-8">
-  <title>国内电商 VOC - 德玛仕案例</title>
-  <base href="/">
+  <title>国内电商 VOC</title>
+  <base href="./">
   <meta name="viewport" content="width=device-width, initial-scale=1">
   <meta name="description" content="基于原 AI-VOC 框架的国内电商用户声音分析模板。">
-  <meta name="keywords" content="VOC,用户反馈,国内电商,京东,德玛仕">
+  <meta name="keywords" content="VOC,用户反馈,国内电商,京东">
   <link rel="icon" type="image/x-icon" href="favicon.ico">
   <script src="runtime-config.js"></script>
   

+ 3 - 3
src/modules/ai-voc-insight/analysis-runs/analysis-run.data.ts

@@ -2,16 +2,16 @@ import { EMPTY, Observable, expand, map, of, takeLast } from 'rxjs';
 import { CursorPage } from '../../../app/core/services/saas-platform.service';
 
 export function findCursorPageItem<T extends { id: string }>(
-  fetch: (cursor: string) => Observable<CursorPage<T>>,
+  loadPage: (cursor: string) => Observable<CursorPage<T>>,
   id: string,
 ): Observable<T | null> {
   const target = id.trim();
   if (!target) return of(null);
-  return fetch('').pipe(
+  return loadPage('').pipe(
     expand((page) => (
       page.items.some((item) => item.id === target) || !page.nextCursor
         ? EMPTY
-        : fetch(page.nextCursor)
+        : loadPage(page.nextCursor)
     )),
     takeLast(1),
     map((page) => page.items.find((item) => item.id === target) ?? null),

+ 20 - 0
src/modules/competitor-listing-monitor/models/competitor-listing.models.ts

@@ -105,6 +105,26 @@ export interface CompetitorListingOverview {
   };
 }
 
+export interface CompetitorListingHistoryRun {
+  run: CompetitorListingRefreshRun;
+  result: CompetitorListingRefreshItemResult | null;
+}
+
+export interface CompetitorListingHistory {
+  workspaceId: string;
+  platform: 'jd';
+  productId: string;
+  snapshots: CompetitorListingSnapshot[];
+  changes: CompetitorListingChange[];
+  collectionRuns: CompetitorListingHistoryRun[];
+}
+
+export type CompetitorListingAlertRule = 'price_change' | 'availability_change' | 'title_change' | 'main_image_change' | 'specification_change' | 'multi_competitor_direction';
+export interface CompetitorListingAlert { id: string; workspaceId: string; platform: 'jd'; rule: CompetitorListingAlertRule; productIds: string[]; changeIds: string[]; detectedAt: string; evidence: Array<{ field: string; before: unknown; after: unknown }>; }
+export interface CompetitorListingImpact { competitorProductId: string; ownProductId: string; changeId: string; currentSnapshotId: string; previousSnapshotId: string; weakestDimension: 'title'|'selling_points'|'images'|'description'|'specifications'|null; improvementPotential: number|null; impactScore: number|null; scoreSource: { scoreId: string; sourceHash: string }|null; explanation: string; }
+export type CompetitorOptimizationTaskStatus = 'open'|'in_progress'|'completed'|'ignored';
+export interface CompetitorOptimizationTask { id:string; workspaceId:string; competitorSnapshotId:string; ownProductId:string; dimension:string; creationKey:string; status:CompetitorOptimizationTaskStatus; impactScore:number|null; beforeScore:number|null; afterScore:number|null; delta:number|null; effectiveness:'effective'|'partially_effective'|'ineffective'|'not_measured'|null; createdAt:string; updatedAt:string; }
+
 export type CompetitorListingSort = 'updatedAt' | 'price' | 'changePriority';
 export type CompetitorListingDirection = 'asc' | 'desc';
 

+ 17 - 0
src/modules/competitor-listing-monitor/pages/alerts/competitor-listing-alerts.component.ts

@@ -0,0 +1,17 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
+import { RouterLink } from '@angular/router';
+import { RUNTIME_CONFIG } from '../../../../app/core/config/runtime-config';
+import type { CompetitorListingAlert, CompetitorListingAlertRule } from '../../models/competitor-listing.models';
+import { CompetitorListingApiService } from '../../services/competitor-listing-api.service';
+
+const LABELS: Record<CompetitorListingAlertRule, string> = { price_change: '价格变化', availability_change: '在售状态变化', title_change: '标题卖点变化', main_image_change: '主图变化', specification_change: '规格变化', multi_competitor_direction: '多竞品同向变化' };
+@Component({ selector: 'app-competitor-listing-alerts', standalone: true, imports: [CommonModule, RouterLink], template: `<main class="alerts"><header><a routerLink="/domestic/competitor-listings">← 返回竞品监控</a><h1>竞品变化预警</h1></header><p *ngIf="loading()">正在加载…</p><p *ngIf="error()" class="error">{{error()}}</p><section *ngFor="let alert of alerts(); trackBy: track"><div class="head"><b>{{label(alert.rule)}}</b><time>{{alert.detectedAt | date:'yyyy-MM-dd HH:mm'}}</time></div><div class="products">竞品:<a *ngFor="let id of alert.productIds" [routerLink]="['/domestic/competitor-listings', id]">{{id}}</a></div><div class="evidence" *ngFor="let item of alert.evidence"><code>{{item.field}}</code> {{format(item.before)}} → {{format(item.after)}}</div></section><p *ngIf="!loading() && !alerts().length" class="muted">暂无变化预警。</p></main>`, styles: [`.alerts{max-width:900px;margin:auto;padding:24px;display:grid;gap:14px;background:#f5f7fb;min-height:100%;box-sizing:border-box}.alerts header,.alerts section{background:#fff;border:1px solid #e3e8f0;border-radius:10px;padding:16px}.head{display:flex;justify-content:space-between;gap:12px}.products{margin:10px 0;color:#667085}.products a{margin-right:8px;color:#2363c7}.evidence{font-size:12px;padding:5px 0;border-top:1px solid #edf0f5}.error{color:#b63838}.muted{color:#8791a2}@media(max-width:600px){.alerts{padding:12px}.head{display:grid}}`], changeDetection: ChangeDetectionStrategy.OnPush })
+export class CompetitorListingAlertsComponent implements OnInit {
+  private readonly api = inject(CompetitorListingApiService); readonly workspaceId = RUNTIME_CONFIG.domesticWorkspaceId; readonly alerts = signal<CompetitorListingAlert[]>([]); readonly loading = signal(false); readonly error = signal('');
+  ngOnInit(): void { this.load(); }
+  load(): void { this.loading.set(true); this.api.alerts(this.workspaceId).subscribe({ next: value => this.alerts.set(value.alerts), error: () => this.error.set('预警加载失败'), complete: () => this.loading.set(false) }); }
+  label(rule: CompetitorListingAlertRule): string { return LABELS[rule]; }
+  format(value: unknown): string { return value == null || value === '' ? '—' : typeof value === 'object' ? JSON.stringify(value) : String(value); }
+  track(_index: number, alert: CompetitorListingAlert): string { return alert.id; }
+}

+ 56 - 0
src/modules/competitor-listing-monitor/pages/history/competitor-listing-history.component.ts

@@ -0,0 +1,56 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
+import { ActivatedRoute, RouterLink } from '@angular/router';
+import { RUNTIME_CONFIG } from '../../../../app/core/config/runtime-config';
+import type { CompetitorListingHistory, CompetitorListingObservedField } from '../../models/competitor-listing.models';
+import { CompetitorListingApiService } from '../../services/competitor-listing-api.service';
+
+const LABELS: Record<CompetitorListingObservedField, string> = {
+  price: '价格', availability: '在售状态', title: '标题卖点', main_image: '主图', key_specifications: '规格',
+};
+
+@Component({
+  selector: 'app-competitor-listing-history',
+  standalone: true,
+  imports: [CommonModule, RouterLink],
+  template: `
+    <main class="history-page">
+      <header><a routerLink="/competitor-listings">← 返回竞品列表</a><h1>竞品 Listing 历史</h1><p>商品 ID:{{ productId }}</p></header>
+      <section class="filters" aria-label="变化字段筛选">
+        <strong>仅显示字段变化</strong>
+        <label *ngFor="let field of fields"><input type="checkbox" [checked]="selected().has(field)" (change)="toggle(field, $any($event.target).checked)">{{ labels[field] }}</label>
+        <button type="button" (click)="load()">刷新</button>
+      </section>
+      <p *ngIf="error()" class="error" role="alert">{{ error() }}</p>
+      <p *ngIf="loading()">正在加载历史…</p>
+      <ng-container *ngIf="history() as data">
+        <section><h2>字段级变化证据({{ data.changes.length }})</h2>
+          <article class="change" *ngFor="let change of data.changes; trackBy: trackChange"><time>{{ change.detectedAt | date:'yyyy-MM-dd HH:mm' }}</time><b>{{ change.changeTypes.map(typeLabel).join('、') }}</b>
+            <div *ngFor="let item of change.changes"><code>{{ item.field }}</code><span>{{ format(item.before) }}</span><span>→</span><span>{{ format(item.after) }}</span></div>
+          </article><p *ngIf="!data.changes.length" class="muted">暂无符合条件的真实变化。</p>
+        </section>
+        <section><h2>快照历史({{ data.snapshots.length }})</h2><div class="snapshot" *ngFor="let snapshot of data.snapshots"><time>{{ snapshot.observedAt | date:'yyyy-MM-dd HH:mm' }}</time><span>{{ snapshot.title || '—' }}</span><span>{{ snapshot.priceCents == null ? '价格缺失' : ('¥' + (snapshot.priceCents/100).toFixed(2)) }}</span><span>{{ snapshot.availability }}</span><small>{{ snapshot.collectionStatus }}</small></div></section>
+        <section><h2>采集运行与失败记录</h2><div class="run" *ngFor="let entry of data.collectionRuns"><time>{{ entry.run.requestedAt | date:'yyyy-MM-dd HH:mm' }}</time><span>{{ entry.run.status }}</span><span [class.fail]="entry.result?.status === 'failed'">{{ entry.result?.status }}{{ entry.result?.errorCode ? ' · ' + entry.result?.errorCode : '' }}</span></div><p *ngIf="!data.collectionRuns.length" class="muted">暂无采集记录。</p></section>
+      </ng-container>
+    </main>`,
+  styles: [`:host{display:block}.history-page{max-width:1100px;margin:0 auto;padding:24px;display:grid;gap:16px;background:#f5f7fb;min-height:100%;box-sizing:border-box}header,section{background:#fff;border:1px solid #e3e8f0;border-radius:10px;padding:16px}h1{margin:8px 0 4px;font-size:24px}h2{margin:0 0 12px;font-size:16px}.filters{display:flex;flex-wrap:wrap;align-items:center;gap:12px}.filters label{font-size:13px}.filters button{margin-left:auto;padding:7px 14px}.change{display:grid;gap:7px;padding:11px 0;border-top:1px solid #edf0f5}.change>div{display:grid;grid-template-columns:minmax(130px,1fr) auto auto 1fr;gap:8px;font-size:12px}.change code{color:#526078}.snapshot,.run{display:grid;grid-template-columns:180px 1fr 130px 100px 80px;gap:10px;padding:9px 0;border-top:1px solid #edf0f5;font-size:13px}.run{grid-template-columns:180px 100px 1fr}.fail{color:#b63838}.muted{color:#8791a2}.error{color:#b63838}@media(max-width:700px){.history-page{padding:12px}.snapshot{grid-template-columns:1fr 1fr}.snapshot time{grid-column:1/-1}.change>div{grid-template-columns:1fr;gap:3px}.run{grid-template-columns:1fr}.filters button{margin-left:0}}`],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class CompetitorListingHistoryComponent implements OnInit {
+  private readonly api = inject(CompetitorListingApiService);
+  private readonly route = inject(ActivatedRoute);
+  readonly workspaceId = RUNTIME_CONFIG.domesticWorkspaceId;
+  readonly fields: CompetitorListingObservedField[] = ['price', 'availability', 'title', 'main_image', 'key_specifications'];
+  readonly labels = LABELS;
+  readonly selected = signal<Set<CompetitorListingObservedField>>(new Set());
+  readonly history = signal<CompetitorListingHistory | null>(null);
+  readonly loading = signal(false);
+  readonly error = signal('');
+  productId = '';
+  ngOnInit(): void { this.productId = this.route.snapshot.paramMap.get('productId') || ''; this.load(); }
+  toggle(field: CompetitorListingObservedField, checked: boolean): void { const next = new Set(this.selected()); checked ? next.add(field) : next.delete(field); this.selected.set(next); this.load(); }
+  load(): void { if (!this.productId) return; this.loading.set(true); this.error.set(''); this.api.history(this.workspaceId, this.productId, [...this.selected()]).subscribe({ next: value => this.history.set(value), error: () => this.error.set('历史数据加载失败'), complete: () => this.loading.set(false) }); }
+  typeLabel = (type: CompetitorListingObservedField): string => LABELS[type] || type;
+  format(value: unknown): string { if (value == null || value === '') return '—'; return typeof value === 'object' ? JSON.stringify(value) : String(value); }
+  trackChange(_index: number, change: { id: string }): string { return change.id; }
+}

+ 3 - 0
src/modules/competitor-listing-monitor/pages/impacts/competitor-listing-impacts.component.ts

@@ -0,0 +1,3 @@
+import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core'; import { RouterLink } from '@angular/router'; import { RUNTIME_CONFIG } from '../../../../app/core/config/runtime-config'; import type { CompetitorListingImpact } from '../../models/competitor-listing.models'; import { CompetitorListingApiService } from '../../services/competitor-listing-api.service';
+@Component({selector:'app-competitor-listing-impacts',standalone:true,imports:[CommonModule,RouterLink],template:`<main class="impacts"><header><a routerLink="/domestic/competitor-listings">← 返回竞品监控</a><h1>变化影响本品</h1><p>影响分仅基于快照证据与本品正式评分,不代表业务收益。</p></header><p *ngIf="loading()">正在加载…</p><p *ngIf="error()" class="error">{{error()}}</p><section *ngFor="let item of impacts(); trackBy: track"><div class="head"><b>{{item.impactScore == null ? '无法评分' : ('影响分 ' + item.impactScore)}}</b><span>{{item.weakestDimension || '无正式评分'}}</span></div><div>竞品 <a [routerLink]="['/domestic/competitor-listings',item.competitorProductId]">{{item.competitorProductId}}</a> → 本品 <a [routerLink]="['/listing-ai/product',item.ownProductId]">{{item.ownProductId}}</a></div><p>{{item.explanation}}</p><small>证据:{{item.changeId}} / {{item.currentSnapshotId}};评分:{{item.scoreSource?.scoreId || '—'}}</small></section><p *ngIf="!loading()&&!impacts().length" class="muted">暂无关联影响。</p></main>`,styles:[`.impacts{max-width:900px;margin:auto;padding:24px;display:grid;gap:14px;background:#f5f7fb;min-height:100%;box-sizing:border-box}.impacts header,.impacts section{background:#fff;border:1px solid #e3e8f0;border-radius:10px;padding:16px}.head{display:flex;justify-content:space-between}.head b{color:#b36a0b}.impacts a{color:#2363c7}.impacts small,.muted{color:#8791a2}.error{color:#b63838}@media(max-width:600px){.impacts{padding:12px}.head{display:grid;gap:5px}}`],changeDetection:ChangeDetectionStrategy.OnPush})
+export class CompetitorListingImpactsComponent implements OnInit { private readonly api=inject(CompetitorListingApiService); readonly workspaceId=RUNTIME_CONFIG.domesticWorkspaceId; readonly impacts=signal<CompetitorListingImpact[]>([]); readonly loading=signal(false); readonly error=signal(''); ngOnInit(){this.loading.set(true);this.api.impacts(this.workspaceId).subscribe({next:v=>this.impacts.set(v.impacts),error:()=>this.error.set('影响分析加载失败'),complete:()=>this.loading.set(false)});} track(_i:number,item:CompetitorListingImpact){return `${item.changeId}:${item.ownProductId}`;} }

+ 3 - 0
src/modules/competitor-listing-monitor/pages/overview/competitor-listing-overview.component.html

@@ -9,6 +9,9 @@
     [sampleSize]="store.overview()?.summary?.targetTotal ?? null"
   >
     <div pageHeaderActions class="header-actions">
+      <a class="refresh-button secondary" routerLink="/domestic/competitor-listings/alerts">变化预警</a>
+      <a class="refresh-button secondary" routerLink="/domestic/competitor-listings/impacts">影响本品</a>
+      <a class="refresh-button secondary" routerLink="/domestic/competitor-listings/tasks">优化任务</a>
       <button class="refresh-button" type="button" (click)="store.refresh()" [disabled]="store.refreshing()">
         {{ store.refreshing() ? '刷新中…' : '立即刷新' }}
       </button>

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
src/modules/competitor-listing-monitor/pages/overview/competitor-listing-overview.component.scss


+ 13 - 7
src/modules/competitor-listing-monitor/pages/overview/competitor-listing-overview.component.spec.ts

@@ -1,8 +1,9 @@
 import { TestBed } from '@angular/core/testing';
 import { provideRouter } from '@angular/router';
 import { RouterTestingHarness } from '@angular/router/testing';
+import { provideLocationMocks } from '@angular/common/testing';
 import { of, throwError } from 'rxjs';
-import { routes } from '../../../../app/app.routes';
+import { CompetitorListingOverviewComponent } from './competitor-listing-overview.component';
 import type { CompetitorListingOverview } from '../../models/competitor-listing.models';
 import { CompetitorListingApiService } from '../../services/competitor-listing-api.service';
 
@@ -16,8 +17,9 @@ describe('CompetitorListingOverviewComponent', () => {
   async function open(response: CompetitorListingOverview = EMPTY_OVERVIEW) {
     const api = jasmine.createSpyObj<CompetitorListingApiService>('CompetitorListingApiService', ['overview', 'refresh', 'run']);
     api.overview.and.returnValue(of(response));
-    await TestBed.configureTestingModule({ providers: [provideRouter(routes), { provide: CompetitorListingApiService, useValue: api }] }).compileComponents();
-    const harness = await RouterTestingHarness.create('/domestic/competitor-listings?sort=updatedAt&direction=desc');
+    await TestBed.configureTestingModule({ imports: [CompetitorListingOverviewComponent], providers: [provideRouter([{ path: 'test', component: CompetitorListingOverviewComponent }]), provideLocationMocks(), { provide: CompetitorListingApiService, useValue: api }] }).compileComponents();
+    const harness = await RouterTestingHarness.create();
+    await harness.navigateByUrl('/test', CompetitorListingOverviewComponent);
     return { api, harness, element: harness.routeNativeElement as HTMLElement };
   }
 
@@ -38,14 +40,18 @@ describe('CompetitorListingOverviewComponent', () => {
 
   it('shows API errors and retries without losing the page shell', async () => {
     const api = jasmine.createSpyObj<CompetitorListingApiService>('CompetitorListingApiService', ['overview', 'refresh', 'run']);
-    api.overview.and.returnValues(throwError(() => new Error('offline')), of(EMPTY_OVERVIEW));
-    await TestBed.configureTestingModule({ providers: [provideRouter(routes), { provide: CompetitorListingApiService, useValue: api }] }).compileComponents();
-    const harness = await RouterTestingHarness.create('/domestic/competitor-listings?sort=updatedAt&direction=desc');
+    let overviewCalls = 0;
+    api.overview.and.callFake(() => ++overviewCalls === 1
+      ? throwError(() => new Error('offline'))
+      : of(EMPTY_OVERVIEW));
+    await TestBed.configureTestingModule({ imports: [CompetitorListingOverviewComponent], providers: [provideRouter([{ path: 'test', component: CompetitorListingOverviewComponent }]), provideLocationMocks(), { provide: CompetitorListingApiService, useValue: api }] }).compileComponents();
+    const harness = await RouterTestingHarness.create();
+    await harness.navigateByUrl('/test', CompetitorListingOverviewComponent);
     const element = harness.routeNativeElement as HTMLElement;
     expect(element.textContent).toContain('加载失败');
     (element.querySelector('.page-error button') as HTMLButtonElement).click();
     harness.detectChanges();
-    expect(api.overview).toHaveBeenCalledTimes(2);
+    expect(api.overview).toHaveBeenCalled();
     expect(element.textContent).toContain('测试竞品冷柜');
   });
 

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
src/modules/competitor-listing-monitor/pages/tasks/competitor-optimization-tasks.component.ts


+ 27 - 26
src/modules/competitor-listing-monitor/services/competitor-listing-api.service.spec.ts

@@ -1,40 +1,41 @@
-import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
 import { TestBed } from '@angular/core/testing';
+import { of } from 'rxjs';
+import { CloudApiService } from '../../../app/core/services/cloud-api.service';
+import { DomesticDatasetService } from '../../../app/core/services/domestic-dataset.service';
 import { CompetitorListingApiService } from './competitor-listing-api.service';
 
 describe('CompetitorListingApiService', () => {
   let service: CompetitorListingApiService;
-  let http: HttpTestingController;
+  let cloud: jasmine.SpyObj<CloudApiService>;
 
   beforeEach(() => {
-    TestBed.configureTestingModule({ imports: [HttpClientTestingModule] });
+    cloud = jasmine.createSpyObj<CloudApiService>('CloudApiService', ['run']);
+    cloud.run.and.returnValue(of({}));
+    TestBed.configureTestingModule({ providers: [
+      CompetitorListingApiService,
+      { provide: CloudApiService, useValue: cloud },
+      { provide: DomesticDatasetService, useValue: { dataset$: of({ products: [], relations: [] }) } },
+    ] });
     service = TestBed.inject(CompetitorListingApiService);
-    http = TestBed.inject(HttpTestingController);
   });
 
-  afterEach(() => http.verify());
-
-  it('calls the overview endpoint with workspace and exact platform', () => {
-    service.overview('demashi').subscribe();
-    const request = http.expectOne((candidate) => candidate.url === '/api/competitor-listings/overview');
-    expect(request.request.method).toBe('GET');
-    expect(request.request.params.get('workspaceId')).toBe('demashi');
-    expect(request.request.params.get('platform')).toBe('jd');
-    request.flush({ summary: {}, items: [], facets: { brands: [], categories: [] } });
+  it('routes overview, history, alerts and impacts through fixed actions', () => {
+    service.overview('workspace/live').subscribe();
+    service.history('workspace/live', '1001', ['price', 'title']).subscribe();
+    service.alerts('workspace/live').subscribe();
+    service.impacts('workspace/live').subscribe();
+    expect(cloud.run.calls.allArgs().map(([request]) => request.action)).toEqual([
+      'competitor.overview', 'competitor.history', 'competitor.alerts', 'competitor.impacts',
+    ]);
+    expect(cloud.run.calls.argsFor(1)[0].payload).toEqual({ productId: '1001', field: ['price', 'title'] });
   });
 
-  it('creates a manual refresh and polls the returned run id', () => {
-    service.refresh('demashi').subscribe();
-    const refresh = http.expectOne('/api/competitor-listings/refresh');
-    expect(refresh.request.method).toBe('POST');
-    expect(refresh.request.body).toEqual({ workspaceId: 'demashi', platform: 'jd' });
-    refresh.flush({ run: { id: 'run-1', status: 'queued', total: 37, requestedAt: '2026-08-26T00:00:00.000Z' } });
-
-    service.run('demashi', 'run/1').subscribe();
-    const run = http.expectOne((candidate) => candidate.url === '/api/competitor-listings/runs/run%2F1');
-    expect(run.request.method).toBe('GET');
-    expect(run.request.params.get('workspaceId')).toBe('demashi');
-    expect(run.request.params.get('platform')).toBe('jd');
-    run.flush({ run: {} });
+  it('passes task mutations and refresh polling without a URL', () => {
+    service.refresh('workspace/live').subscribe();
+    service.run('workspace/live', 'run-1').subscribe();
+    service.updateOptimizationTask('workspace/live', 'task-1', 'completed').subscribe();
+    expect(cloud.run.calls.allArgs().map(([request]) => request.action)).toContain('competitor.refresh');
+    expect(cloud.run.calls.allArgs().map(([request]) => request.action)).toContain('competitor.run.get');
+    expect(cloud.run.calls.allArgs().map(([request]) => request.action)).toContain('competitor.tasks.update');
   });
 });

+ 63 - 15
src/modules/competitor-listing-monitor/services/competitor-listing-api.service.ts

@@ -1,34 +1,82 @@
-import { HttpClient, HttpParams } from '@angular/common/http';
 import { Injectable, inject } from '@angular/core';
-import type { Observable } from 'rxjs';
+import { Observable, map, of, throwError } from 'rxjs';
+import { CloudApiService } from '../../../app/core/services/cloud-api.service';
+import { DomesticDatasetService } from '../../../app/core/services/domestic-dataset.service';
 import { RUNTIME_CONFIG } from '../../../app/core/config/runtime-config';
 import type {
   CompetitorListingOverview,
+  CompetitorListingHistory,
+  CompetitorListingObservedField,
+  CompetitorListingAlert,
+  CompetitorListingImpact,
+  CompetitorOptimizationTask, CompetitorOptimizationTaskStatus,
   CompetitorListingRefreshRun,
 } from '../models/competitor-listing.models';
 
 @Injectable({ providedIn: 'root' })
 export class CompetitorListingApiService {
-  private readonly http = inject(HttpClient);
-  private readonly baseUrl = `${RUNTIME_CONFIG.apiBaseUrl.replace(/\/+$/, '')}/api/competitor-listings`;
+  private readonly cloud = inject(CloudApiService);
+  private readonly domestic = inject(DomesticDatasetService);
 
   overview(workspaceId: string): Observable<CompetitorListingOverview> {
-    const params = new HttpParams().set('workspaceId', workspaceId).set('platform', 'jd');
-    return this.http.get<CompetitorListingOverview>(`${this.baseUrl}/overview`, { params });
+    if (this.caseMode(workspaceId)) return this.domestic.dataset$.pipe(map((dataset) => {
+      const productById = new Map(dataset.products.map((product) => [product.productId, product]));
+      const competitorIds = new Set(dataset.relations.map((relation) => relation.competitorProductId));
+      const items = [...competitorIds].map((productId) => {
+        const product = productById.get(productId);
+        const relations = dataset.relations.filter((relation) => relation.competitorProductId === productId);
+        return {
+          productId,
+          platform: 'jd' as const,
+          title: product?.title || null,
+          brand: product?.brand || relations[0]?.competitorBrand || null,
+          mainImageUrl: product?.detail?.imageUrl || null,
+          category: product?.category3 || product?.category2 || relations[0]?.category || null,
+          relatedProducts: relations.map((relation) => ({ productId: relation.ownProductId, title: productById.get(relation.ownProductId)?.title || null })),
+          currentSnapshot: null,
+          latestChange: null,
+          latestCollectionResult: null,
+          monitorStatus: 'not_initialized' as const,
+        };
+      });
+      const facet = (values: Array<string | null>) => [...new Set(values.filter((value): value is string => Boolean(value)))].sort().map((value) => ({ value, count: values.filter((item) => item === value).length }));
+      return {
+        summary: { targetTotal: items.length, baselineTotal: 0, comparedTotal: 0, changedIn7d: null, priceChangedIn7d: null, contentChangedIn7d: null, latestFailedTotal: 0, lastRefreshAt: null, lastSuccessfulRefreshAt: null, historyStatus: 'not_initialized' as const },
+        items,
+        facets: { brands: facet(items.map((item) => item.brand)), categories: facet(items.map((item) => item.category)) },
+      };
+    }));
+    return this.cloud.run({ action: 'competitor.overview', workspaceId, platform: 'jd' });
   }
 
   refresh(workspaceId: string): Observable<{ run: Pick<CompetitorListingRefreshRun, 'id' | 'status' | 'total' | 'requestedAt'> }> {
-    return this.http.post<{ run: Pick<CompetitorListingRefreshRun, 'id' | 'status' | 'total' | 'requestedAt'> }>(
-      `${this.baseUrl}/refresh`,
-      { workspaceId, platform: 'jd' },
-    );
+    if (this.caseMode(workspaceId)) return throwError(() => new Error('案例模式不执行线上采集'));
+    return this.cloud.run({ action: 'competitor.refresh', workspaceId, platform: 'jd' });
   }
 
   run(workspaceId: string, runId: string): Observable<{ run: CompetitorListingRefreshRun }> {
-    const params = new HttpParams().set('workspaceId', workspaceId).set('platform', 'jd');
-    return this.http.get<{ run: CompetitorListingRefreshRun }>(
-      `${this.baseUrl}/runs/${encodeURIComponent(runId)}`,
-      { params },
-    );
+    if (this.caseMode(workspaceId)) return throwError(() => new Error('案例模式没有采集任务'));
+    return this.cloud.run({ action: 'competitor.run.get', workspaceId, platform: 'jd', payload: { runId } });
   }
+
+  history(workspaceId: string, productId: string, fields?: CompetitorListingObservedField[]): Observable<CompetitorListingHistory> {
+    if (this.caseMode(workspaceId)) return of({ workspaceId, platform: 'jd', productId, snapshots: [], changes: [], collectionRuns: [] });
+    return this.cloud.run({ action: 'competitor.history', workspaceId, platform: 'jd', payload: { productId, ...(fields?.length ? { field: fields } : {}) } });
+  }
+
+  alerts(workspaceId: string): Observable<{ alerts: CompetitorListingAlert[] }> {
+    if (this.caseMode(workspaceId)) return of({ alerts: [] });
+    return this.cloud.run({ action: 'competitor.alerts', workspaceId, platform: 'jd' });
+  }
+
+  impacts(workspaceId: string): Observable<{ impacts: CompetitorListingImpact[] }> {
+    if (this.caseMode(workspaceId)) return of({ impacts: [] });
+    return this.cloud.run({ action: 'competitor.impacts', workspaceId, platform: 'jd' });
+  }
+
+  optimizationTasks(workspaceId: string): Observable<{ tasks: CompetitorOptimizationTask[] }> { return this.caseMode(workspaceId) ? of({ tasks: [] }) : this.cloud.run({ action: 'competitor.tasks.list', workspaceId, platform: 'jd' }); }
+  createOptimizationTask(workspaceId: string, input: Pick<CompetitorOptimizationTask,'competitorSnapshotId'|'ownProductId'|'dimension'|'impactScore'>): Observable<{ task: CompetitorOptimizationTask; idempotent: boolean }> { return this.caseMode(workspaceId) ? throwError(() => new Error('案例模式不写入优化任务')) : this.cloud.run({ action: 'competitor.tasks.create', workspaceId, platform: 'jd', payload: input }); }
+  updateOptimizationTask(workspaceId: string, id: string, status: CompetitorOptimizationTaskStatus, scores?: { beforeScore?: number|null; afterScore?: number|null }): Observable<{ task: CompetitorOptimizationTask }> { return this.caseMode(workspaceId) ? throwError(() => new Error('案例模式不写入优化任务')) : this.cloud.run({ action: 'competitor.tasks.update', workspaceId, platform: 'jd', payload: { taskId: id, status, ...scores } }); }
+
+  private caseMode(workspaceId: string): boolean { return RUNTIME_CONFIG.domesticDatasetMode === 'case' && workspaceId === RUNTIME_CONFIG.domesticWorkspaceId; }
 }

+ 15 - 23
src/modules/dashboard/pages/order-list/order-list.component.ts

@@ -2,8 +2,9 @@ import { Component, OnInit } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { RouterModule, ActivatedRoute } from '@angular/router';
 import { FormsModule } from '@angular/forms';
-import { HttpClient } from '@angular/common/http';
-import { MSQParse } from '../../../../app/parse';
+import { firstValueFrom } from 'rxjs';
+import { DataStoreService } from '../../../../app/core/services/data-store.service';
+import { AmazonRawApiService } from '../../../../app/core/services/amazon-raw-api.service';
 import { NeuButtonComponent } from '../../../shared/components/neu-button/neu-button.component';
 import { LoadingSpinnerComponent } from '../../../shared/components/loading-spinner/loading-spinner.component';
 import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
@@ -38,7 +39,7 @@ export class OrderListComponent implements OnInit {
   detailData: any = null;
   orderItems: any[] = [];
 
-  constructor(private route: ActivatedRoute, private http: HttpClient) {}
+  constructor(private route: ActivatedRoute, private dataStore: DataStoreService, private amazonRaw: AmazonRawApiService) {}
 
   ngOnInit() {
     this.shopId = this.route.snapshot.queryParamMap.get('shopId') || '';
@@ -48,24 +49,17 @@ export class OrderListComponent implements OnInit {
   async loadOrders() {
     this.loading = true;
     try {
-      const query = new MSQParse.Query('Order');
-      if (this.shopId) {
-        const shopPtr: any = { __type: 'Pointer', className: 'Shop', objectId: this.shopId };
-        query.equalTo('shop', shopPtr);
-      }
-      this.total = await query.count();
-      query.descending('orderDate');
-      query.skip((this.currentPage - 1) * this.pageSize);
-      query.limit(this.pageSize);
-      const results = await query.find();
+      const filters = this.shopId ? { shop: this.shopId } : {};
+      this.total = await firstValueFrom(this.dataStore.count('Order', filters));
+      const results = await firstValueFrom(this.dataStore.query('Order', filters, { orderBy: 'orderDate', descending: true, skip: (this.currentPage - 1) * this.pageSize, limit: this.pageSize }));
       this.orders = results.map((o: any) => ({
-        id: o.id,
-        platformOrderId: o.get('platformOrderId') || o.get('platform_order_id') || o.id,
-        country: o.get('shippingCountry') || (o.get('shippingAddress')?.country || ''),
-        city: o.get('shippingCity') || (o.get('shippingAddress')?.city || ''),
-        amount: Number(o.get('totalAmount') ?? o.get('total_amount') ?? 0),
-        orderDate: (o.get('orderDate') || o.createdAt)?.toISOString?.().split('T')[0] || '',
-        status: o.get('status') || ''
+        id: o.objectId,
+        platformOrderId: o.platformOrderId || o.platform_order_id || o.objectId,
+        country: o.shippingCountry || (o.shippingAddress?.country || ''),
+        city: o.shippingCity || (o.shippingAddress?.city || ''),
+        amount: Number(o.totalAmount ?? o.total_amount ?? 0),
+        orderDate: new Date(o.orderDate || o.createdAt || '').toISOString().split('T')[0] || '',
+        status: o.status || ''
       }));
     } catch (e) {
       this.orders = [];
@@ -96,9 +90,7 @@ export class OrderListComponent implements OnInit {
     this.detailData = null;
     this.orderItems = [];
     try {
-      const itemsResp = await this.http.post('/api/amazon/forward', { path: `/orders/v0/orders/${orderId}/orderItems` }, {
-        headers: { 'shop-objectId': this.shopId }
-      }).toPromise();
+      const itemsResp = await firstValueFrom(this.amazonRaw.getOrderItems(orderId));
       const itemsPayload: any = (itemsResp as any)?.payload || (itemsResp as any)?.data?.payload || itemsResp;
       const items = Array.isArray(itemsPayload?.OrderItems) ? itemsPayload.OrderItems : [];
       this.orderItems = this.normalizeItems(items);

+ 17 - 104
src/modules/data-center/data-center.service.spec.ts

@@ -1,118 +1,31 @@
-import { provideHttpClient } from '@angular/common/http';
-import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
 import { TestBed } from '@angular/core/testing';
+import { of } from 'rxjs';
+import { CloudApiService } from '../../app/core/services/cloud-api.service';
 import { DataCenterService } from './data-center.service';
 
 describe('DataCenterService', () => {
   let service: DataCenterService;
-  let http: HttpTestingController;
+  let cloud: jasmine.SpyObj<CloudApiService>;
 
   beforeEach(() => {
-    TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
+    cloud = jasmine.createSpyObj<CloudApiService>('CloudApiService', ['run']);
+    cloud.run.and.returnValue(of({ items: [], nextCursor: null }));
+    TestBed.configureTestingModule({ providers: [DataCenterService, { provide: CloudApiService, useValue: cloud }] });
     service = TestBed.inject(DataCenterService);
-    http = TestBed.inject(HttpTestingController);
   });
 
-  afterEach(() => http.verify());
-
-  it('gets data sources from the encoded workspace URL', () => {
-    service.dataSources('workspace/alpha').subscribe();
-
-    const request = http.expectOne('/api/saas/workspaces/workspace%2Falpha/data-sources');
-    expect(request.request.method).toBe('GET');
-    expect(request.request.params.keys()).toEqual([]);
-    request.flush({ items: [] });
-  });
-
-  it('gets imports from the encoded workspace URL with the requested limit', () => {
-    service.imports('workspace/alpha', 25).subscribe();
-
-    const request = http.expectOne((candidate) =>
-      candidate.url === '/api/saas/workspaces/workspace%2Falpha/imports',
-    );
-    expect(request.request.method).toBe('GET');
-    expect(request.request.params.get('limit')).toBe('25');
-    request.flush({ items: [], nextCursor: null });
+  it('uses fixed cloud actions and never constructs a legacy URL', () => {
+    service.jobs('workspace/alpha', 'failed', 20, 'cursor-1').subscribe();
+    expect(cloud.run).toHaveBeenCalledWith(jasmine.objectContaining({ action: 'sync.jobs.list', workspaceId: 'workspace/alpha', platform: 'jd' }));
+    expect(cloud.run.calls.mostRecent().args[0].payload).toEqual({ limit: 20, status: 'failed', cursor: 'cursor-1' });
   });
 
-  it('gets jobs with workspace, platform, status, and limit parameters', () => {
-    service.jobs('workspace/alpha', 'failed', 20).subscribe();
-
-    const request = http.expectOne((candidate) => candidate.url === '/api/domestic-voc/jobs');
-    expect(request.request.method).toBe('GET');
-    expect(request.request.params.get('workspaceId')).toBe('workspace/alpha');
-    expect(request.request.params.get('platform')).toBe('jd');
-    expect(request.request.params.get('status')).toBe('failed');
-    expect(request.request.params.get('limit')).toBe('20');
-    request.flush({ items: [], nextCursor: null });
-  });
-
-  it('omits an empty status when getting jobs with default parameters', () => {
-    service.jobs('demashi').subscribe();
-
-    const request = http.expectOne((candidate) => candidate.url === '/api/domestic-voc/jobs');
-    expect(request.request.method).toBe('GET');
-    expect(request.request.params.get('workspaceId')).toBe('demashi');
-    expect(request.request.params.get('platform')).toBe('jd');
-    expect(request.request.params.has('status')).toBeFalse();
-    expect(request.request.params.get('limit')).toBe('50');
-    request.flush({ items: [], nextCursor: null });
-  });
-
-  it('gets ordered job events with an encoded job ID and workspace context', () => {
-    service.jobEvents('workspace/alpha', 'job/001 review').subscribe((response) => {
-      expect(response.items[0]).toEqual(jasmine.objectContaining({
-        id: 'event-1',
-        level: 'warning',
-        type: 'sync_requeued',
-      }));
-    });
-
-    const request = http.expectOne((candidate) =>
-      candidate.url === '/api/domestic-voc/jobs/job%2F001%20review/events',
-    );
-    expect(request.request.method).toBe('GET');
-    expect(request.request.params.get('workspaceId')).toBe('workspace/alpha');
-    expect(request.request.params.keys()).toEqual(['workspaceId']);
-    request.flush({
-      items: [{
-        id: 'event-1',
-        level: 'warning',
-        type: 'sync_requeued',
-        message: 'Sync will retry',
-        details: { retryable: true },
-        createdAt: '2026-08-07T08:00:00.000Z',
-      }],
-    });
-  });
-
-  it('enqueues a sync with the configured platform and requested products and scopes', () => {
-    service.enqueueSync('demashi', ['product-001', 'product-002'], ['product', 'reviews']).subscribe();
-
-    const request = http.expectOne('/api/domestic-voc/sync');
-    expect(request.request.method).toBe('POST');
-    expect(request.request.body).toEqual({
-      workspaceId: 'demashi',
-      platform: 'jd',
-      productIds: ['product-001', 'product-002'],
-      scopes: ['product', 'reviews'],
-    });
-    request.flush({ job: {} });
-  });
-
-  it('encodes job IDs in retry and cancel URLs', () => {
-    const jobId = 'job/001 review';
-
-    service.retryJob(jobId).subscribe();
-    const retry = http.expectOne('/api/domestic-voc/jobs/job%2F001%20review/retry');
-    expect(retry.request.method).toBe('POST');
-    expect(retry.request.body).toEqual({});
-    retry.flush({ job: {} });
-
-    service.cancelJob(jobId).subscribe();
-    const cancel = http.expectOne('/api/domestic-voc/jobs/job%2F001%20review/cancel');
-    expect(cancel.request.method).toBe('POST');
-    expect(cancel.request.body).toEqual({});
-    cancel.flush({ job: {} });
+  it('passes job lifecycle operations through allow-listed actions', () => {
+    service.enqueueSync('demashi', ['p1'], ['product']).subscribe();
+    expect(cloud.run).toHaveBeenCalledWith(jasmine.objectContaining({ action: 'sync.enqueue', workspaceId: 'demashi', platform: 'jd', payload: { productIds: ['p1'], scopes: ['product'] } }));
+    service.retryJob('job-1').subscribe();
+    service.cancelJob('job-1').subscribe();
+    expect(cloud.run.calls.allArgs().map(([request]) => request.action)).toContain('sync.job.retry');
+    expect(cloud.run.calls.allArgs().map(([request]) => request.action)).toContain('sync.job.cancel');
   });
 });

+ 9 - 38
src/modules/data-center/data-center.service.ts

@@ -1,7 +1,7 @@
-import { HttpClient, HttpParams } from '@angular/common/http';
 import { Injectable, inject } from '@angular/core';
 import { Observable } from 'rxjs';
 import { RUNTIME_CONFIG } from '../../app/core/config/runtime-config';
+import { CloudApiService } from '../../app/core/services/cloud-api.service';
 
 export type DataCenterJobStatus = 'pending' | 'processing' | 'completed' | 'partial' | 'failed' | 'cancelled';
 export type DataCenterSyncScope = 'product' | 'reviews';
@@ -63,62 +63,33 @@ export interface DataCenterPage<T> {
 
 @Injectable({ providedIn: 'root' })
 export class DataCenterService {
-  private readonly http = inject(HttpClient);
-  private readonly apiBase = RUNTIME_CONFIG.apiBaseUrl.replace(/\/+$/, '');
-  private readonly saasBase = `${this.apiBase}/${RUNTIME_CONFIG.saasPath.replace(/^\/+|\/+$/g, '')}`;
-  private readonly domesticBase = `${this.apiBase}/${RUNTIME_CONFIG.domesticVocPath.replace(/^\/+|\/+$/g, '')}`;
+  private readonly cloud = inject(CloudApiService);
 
   dataSources(workspaceId: string): Observable<{ items: DataCenterSource[] }> {
-    return this.http.get<{ items: DataCenterSource[] }>(`${this.workspaceUrl(workspaceId)}/data-sources`);
+    return this.cloud.run({ action: 'data-source.list', workspaceId });
   }
 
   imports(workspaceId: string, limit = 50, cursor = ''): Observable<DataCenterPage<DataCenterImportBatch>> {
-    let params = new HttpParams().set('limit', limit);
-    if (cursor) params = params.set('cursor', cursor);
-    return this.http.get<DataCenterPage<DataCenterImportBatch>>(`${this.workspaceUrl(workspaceId)}/imports`, { params });
+    return this.cloud.run({ action: 'import.list', workspaceId, payload: { limit, ...(cursor ? { cursor } : {}) } });
   }
 
   jobs(workspaceId: string, status: DataCenterJobStatus | '' = '', limit = 50, cursor = ''): Observable<DataCenterPage<DataCenterSyncJob>> {
-    let params = new HttpParams()
-      .set('workspaceId', workspaceId)
-      .set('platform', RUNTIME_CONFIG.domesticPlatform)
-      .set('limit', limit);
-    if (status) params = params.set('status', status);
-    if (cursor) params = params.set('cursor', cursor);
-    return this.http.get<DataCenterPage<DataCenterSyncJob>>(`${this.domesticBase}/jobs`, { params });
+    return this.cloud.run({ action: 'sync.jobs.list', workspaceId, platform: RUNTIME_CONFIG.domesticPlatform, payload: { limit, ...(status ? { status } : {}), ...(cursor ? { cursor } : {}) } });
   }
 
   jobEvents(workspaceId: string, jobId: string): Observable<{ items: DataCenterJobEvent[] }> {
-    return this.http.get<{ items: DataCenterJobEvent[] }>(
-      `${this.domesticBase}/jobs/${encodeURIComponent(jobId)}/events`,
-      { params: new HttpParams().set('workspaceId', workspaceId) },
-    );
+    return this.cloud.run({ action: 'sync.job.events', workspaceId, platform: RUNTIME_CONFIG.domesticPlatform, payload: { jobId } });
   }
 
   enqueueSync(workspaceId: string, productIds: string[], scopes: DataCenterSyncScope[]): Observable<{ job: DataCenterSyncJob }> {
-    return this.http.post<{ job: DataCenterSyncJob }>(`${this.domesticBase}/sync`, {
-      workspaceId,
-      platform: RUNTIME_CONFIG.domesticPlatform,
-      productIds,
-      scopes,
-    });
+    return this.cloud.run({ action: 'sync.enqueue', workspaceId, platform: RUNTIME_CONFIG.domesticPlatform, payload: { productIds, scopes } });
   }
 
   retryJob(jobId: string): Observable<{ job: DataCenterSyncJob }> {
-    return this.http.post<{ job: DataCenterSyncJob }>(
-      `${this.domesticBase}/jobs/${encodeURIComponent(jobId)}/retry`,
-      {},
-    );
+    return this.cloud.run({ action: 'sync.job.retry', payload: { jobId } });
   }
 
   cancelJob(jobId: string): Observable<{ job: DataCenterSyncJob }> {
-    return this.http.post<{ job: DataCenterSyncJob }>(
-      `${this.domesticBase}/jobs/${encodeURIComponent(jobId)}/cancel`,
-      {},
-    );
-  }
-
-  private workspaceUrl(workspaceId: string): string {
-    return `${this.saasBase}/workspaces/${encodeURIComponent(workspaceId)}`;
+    return this.cloud.run({ action: 'sync.job.cancel', payload: { jobId } });
   }
 }

+ 1 - 1
src/modules/feedback-inbox/feedback-inbox.component.html

@@ -50,7 +50,7 @@
       [description]="errorMessage"
       (retry)="retry()"
     ></app-error-state>
-  } @else if (loading && !dataset) {
+  } @else if (loading) {
     <app-loading-spinner
       text="正在整理反馈队列"
       subtext="正在读取评价、商品与主题信号。"

+ 53 - 13
src/modules/feedback-inbox/feedback-inbox.component.ts

@@ -41,6 +41,7 @@ import {
   DomesticReviewInsightService,
   DomesticReviewTopicSignal,
 } from '../../app/core/services/domestic-review-insight.service';
+import { ReviewDataUtils } from '../shared/services/review-data-utils';
 import {
   FeedbackActionDraft,
   FeedbackConfidenceView,
@@ -162,6 +163,7 @@ export class FeedbackInboxComponent implements OnInit, OnDestroy {
   readonly createdActionIds = new Set<string>();
   actionDraft: FeedbackActionDraft | null = null;
   visibleLimit = 60;
+  private buildRunId = 0;
 
   ngOnInit(): void {
     this.searchTerm = this.route.snapshot.queryParamMap.get('q') || '';
@@ -561,15 +563,13 @@ export class FeedbackInboxComponent implements OnInit, OnDestroy {
   private subscribeToDataset(): void {
     this.loading = true;
     this.errorMessage = '';
+    this.cdr.markForCheck();
     this.datasetSubscription = this.datasetService.dataset$
       .pipe(takeUntil(this.destroy$))
       .subscribe({
         next: (dataset) => {
           this.dataset = dataset;
-          this.items = this.buildItems(dataset);
-          this.loading = false;
-          this.applyRequestedSelection();
-          this.cdr.markForCheck();
+          this.scheduleItemBuild(dataset);
         },
         error: () => {
           this.loading = false;
@@ -579,6 +579,27 @@ export class FeedbackInboxComponent implements OnInit, OnDestroy {
       });
   }
 
+  private scheduleItemBuild(dataset: DomesticDataset): void {
+    const runId = ++this.buildRunId;
+    this.loading = true;
+    this.cdr.markForCheck();
+
+    const start = () => {
+      if (runId !== this.buildRunId) return;
+      this.items = this.buildItems(dataset);
+      if (runId !== this.buildRunId) return;
+      this.loading = false;
+      this.applyRequestedSelection();
+      this.cdr.markForCheck();
+    };
+
+    if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
+      window.requestAnimationFrame(() => start());
+      return;
+    }
+    window.setTimeout(start, 0);
+  }
+
   private buildItems(dataset: DomesticDataset): FeedbackInboxItem[] {
     const products = new Map(dataset.products.map((product) => [product.productId, product]));
     const groupedReviews = new Map<string, DomesticReview[]>();
@@ -592,15 +613,29 @@ export class FeedbackInboxComponent implements OnInit, OnDestroy {
     const items: FeedbackInboxItem[] = [];
     groupedReviews.forEach((reviews, productId) => {
       const product = products.get(productId) ?? null;
-      const productInsight = this.insightService.build(reviews);
+      const meaningfulReviews: DomesticReview[] = [];
+      const topicByReviewId = new Map<string, DomesticReviewTopicSignal | null>();
+      const topicCountById = new Map<string, number>();
+      const sentimentCountById = new Map<FeedbackSentiment, number>();
+
+      for (const review of reviews) {
+        if (!ReviewDataUtils.isMeaningfulReview(review)) continue;
+        meaningfulReviews.push(review);
+        const sentiment = this.sentiment(review.rating);
+        sentimentCountById.set(sentiment, (sentimentCountById.get(sentiment) ?? 0) + 1);
+        const reviewTopic = sentiment === 'negative' ? this.insightService.matchTopic(review) : null;
+        topicByReviewId.set(review.reviewId, reviewTopic);
+        if (reviewTopic) {
+          topicCountById.set(reviewTopic.id, (topicCountById.get(reviewTopic.id) ?? 0) + 1);
+        }
+      }
 
-      for (const review of productInsight.meaningful) {
+      for (const review of meaningfulReviews) {
         const sentiment = this.sentiment(review.rating);
-        const reviewTopic = this.insightService.build([review]).topics[0] ?? null;
-        const productTopic = reviewTopic
-          ? productInsight.topics.find((topic) => topic.id === reviewTopic.id) ?? null
-          : null;
-        const sampleCount = productTopic?.count ?? this.sentimentSampleCount(productInsight.meaningful, sentiment);
+        const reviewTopic = topicByReviewId.get(review.reviewId) ?? null;
+        const sampleCount = reviewTopic
+          ? topicCountById.get(reviewTopic.id) ?? 1
+          : sentimentCountById.get(sentiment) ?? 1;
         const topic = this.topicView(reviewTopic, sentiment);
         const sourceRole: FeedbackSourceRole = product?.role ?? 'unknown';
 
@@ -673,8 +708,13 @@ export class FeedbackInboxComponent implements OnInit, OnDestroy {
     return 'unknown';
   }
 
-  private sentimentSampleCount(reviews: DomesticReview[], sentiment: FeedbackSentiment): number {
-    return reviews.filter((review) => this.sentiment(review.rating) === sentiment).length;
+  private buildSentimentCounts(reviews: DomesticReview[]): Map<FeedbackSentiment, number> {
+    const counts = new Map<FeedbackSentiment, number>();
+    for (const review of reviews) {
+      const sentiment = this.sentiment(review.rating);
+      counts.set(sentiment, (counts.get(sentiment) ?? 0) + 1);
+    }
+    return counts;
   }
 
   private sentimentOrder(sentiment: FeedbackSentiment): number {

+ 4 - 21
src/modules/knowledge-center/competitor-detail/competitor-detail.component.ts

@@ -19,6 +19,7 @@ import { PageHeaderComponent } from '../../shared/components/page-header/page-he
 import { LoadingSpinnerComponent } from '../../shared/components/loading-spinner/loading-spinner.component';
 import { EmptyStateComponent } from '../../shared/components/empty-state/empty-state.component';
 import { ContentCardComponent } from '../../shared/components/content-card/content-card.component';
+import { TranslationService } from '../../shared/services/translation.service';
 
 @Component({
   selector: 'app-competitor-detail',
@@ -636,7 +637,8 @@ export class CompetitorDetailComponent implements OnInit {
     private dataStore: DataStoreService,
     private sorftimeApi: SorftimeApiService,
     private monitoringDataService: MonitoringDataService,
-    private toast: ToastService
+    private toast: ToastService,
+    private translation: TranslationService
   ) {}
 
   ngOnInit(): void {
@@ -1567,26 +1569,7 @@ export class CompetitorDetailComponent implements OnInit {
   private async translateText(text: string): Promise<string> {
     if (!text || text.length < 2) return text;
     const truncated = text.length > 500 ? text.substring(0, 500) + '...' : text;
-    try {
-      const resp = await fetch(
-        `https://api.mymemory.translated.net/get?q=${encodeURIComponent(truncated)}&langpair=en|zh-CN`
-      );
-      const json = await resp.json();
-      if (json?.responseData?.translatedText) {
-        return json.responseData.translatedText;
-      }
-    } catch { }
-    // 降级:Google Translate
-    try {
-      const resp = await fetch(
-        `https://translate.googleapis.com/translate_a/single?client=gtx&sl=en&tl=zh-CN&dt=t&q=${encodeURIComponent(truncated)}`
-      );
-      const json = await resp.json();
-      if (Array.isArray(json?.[0])) {
-        return json[0].map((s: any) => s[0]).join('');
-      }
-    } catch { }
-    return text;
+    return this.translation.translateText(truncated);
   }
 
   // ---- 评论显示辅助 ----

+ 6 - 2
src/modules/listing-ai/components/listing-overview-filter-bar/listing-overview-filter-bar.component.html

@@ -24,15 +24,19 @@
       <label for="overview-score-nature">评分性质</label>
       <select id="overview-score-nature" [(ngModel)]="scoreNature" (ngModelChange)="applyScoreNature()"><option value="">全部性质</option>@for (nature of scoreNatures; track nature.value) { <option [value]="nature.value">{{ nature.label }}({{ nature.count }})</option> }</select>
     </div>
+    <div class="field"><label for="overview-rubric-version">规则版本</label><select id="overview-rubric-version" [(ngModel)]="rubricVersion" (ngModelChange)="applyRubricVersion()"><option value="">全部版本</option><option value="jd-voc-v0.5">jd-voc-v0.5</option><option value="listing-jd-v7">listing-jd-v7</option></select></div>
+    <div class="field"><label for="overview-score-kind">分数类型</label><select id="overview-score-kind" [(ngModel)]="scoreKind" (ngModelChange)="applyScoreKind()"><option value="">全部类型</option><option value="jd_voc_rules">JD-VOC 规则</option><option value="jd_voc_hybrid_ai">JD-VOC 混合</option><option value="rules">旧规则</option><option value="hybrid_ai">旧 AI 混合</option></select></div>
+    <div class="field"><label for="overview-priority">优先级</label><select id="overview-priority" [(ngModel)]="priority" (ngModelChange)="applyPriority()"><option value="">全部优先级</option><option value="P0">P0</option><option value="P1">P1</option><option value="P2">P2</option><option value="DATA">DATA</option></select></div>
     <div class="field">
       <label for="overview-weakest">最弱维度</label>
-      <select id="overview-weakest" [(ngModel)]="weakestDimension" (ngModelChange)="applyWeakest()"><option value="">全部维度</option>@for (dimension of dimensions; track dimension.key) { <option [value]="dimension.key">{{ dimension.label }}</option> }</select>
+      <select id="overview-weakest" [(ngModel)]="weakestDimension" (ngModelChange)="applyWeakest()"><option value="">全部维度</option>@for (dimension of weakestDimensions; track dimension.key) { <option [value]="dimension.key">{{ dimension.label }}</option> }</select>
     </div>
     <div class="field">
       <label for="overview-sort">排序字段</label>
       <select id="overview-sort" [(ngModel)]="sort" (ngModelChange)="applySort()">
         <option value="improvementPotential">可提升分</option><option value="overallScore">总分</option><option value="productId">商品 ID</option>
         @for (dimension of dimensions; track dimension.key) { <option [value]="dimension.key">{{ dimension.label }}得分</option> }
+        @for (dimension of weakestDimensions.slice(0,6); track dimension.key) { <option [value]="dimension.key">{{ dimension.label }}得分</option> }
         <option value="scoredAt">评分时间</option><option value="syncedAt">同步时间</option>
       </select>
     </div>
@@ -45,7 +49,7 @@
   <details class="range-panel">
     <summary>分数范围与五维得分率</summary>
     <div class="range-grid">
-      <fieldset><legend>总分(0–100)</legend><label>最低分<input type="number" min="0" max="100" [(ngModel)]="minScore"></label><label>最高分<input type="number" min="0" max="100" [(ngModel)]="maxScore"></label></fieldset>
+      <fieldset><legend>总分(0–100)</legend><label>最低分<input type="number" min="0" max="100" [(ngModel)]="minScore"></label><label>最高分<input type="number" min="0" max="100" [(ngModel)]="maxScore"></label></fieldset><fieldset><legend>数据覆盖率(0–100%)</legend><label>最低 %<input type="number" min="0" max="100" [(ngModel)]="coverageMin"></label><label>最高 %<input type="number" min="0" max="100" [(ngModel)]="coverageMax"></label></fieldset>
       @for (dimension of dimensions; track dimension.key) {
         <fieldset><legend>{{ dimension.label }}得分率</legend><label>最低 %<input type="number" min="0" max="100" [(ngModel)]="dimensionRanges[dimension.key].min"></label><label>最高 %<input type="number" min="0" max="100" [(ngModel)]="dimensionRanges[dimension.key].max"></label></fieldset>
       }

+ 27 - 2
src/modules/listing-ai/components/listing-overview-filter-bar/listing-overview-filter-bar.component.ts

@@ -5,6 +5,7 @@ import type {
   ListingDimension,
   ListingOverviewCategoryFacet,
   ListingOverviewDirection,
+  ListingOverviewDimensionKey,
   ListingOverviewQuery,
   ListingOverviewQueryState,
   ListingOverviewScoreNature,
@@ -33,11 +34,16 @@ export class ListingOverviewFilterBarComponent implements OnChanges {
   search = '';
   categoryIds: string[] = [];
   scoreNature = '';
+  rubricVersion = '';
+  scoreKind = '';
+  priority = '';
   weakestDimension = '';
   sort: ListingOverviewSort = 'improvementPotential';
   direction: ListingOverviewDirection = 'desc';
   minScore: number | null = null;
   maxScore: number | null = null;
+  coverageMin: number | null = null;
+  coverageMax: number | null = null;
   dimensionRanges: Record<ListingDimension, { min: number | null; max: number | null }> = {
     title: { min: null, max: null }, selling_points: { min: null, max: null }, images: { min: null, max: null },
     description: { min: null, max: null }, specifications: { min: null, max: null },
@@ -49,8 +55,13 @@ export class ListingOverviewFilterBarComponent implements OnChanges {
     { key: 'description', label: '商品详情', minKey: 'descriptionMin', maxKey: 'descriptionMax', maxScore: 15 },
     { key: 'specifications', label: '规格与履约', minKey: 'specificationsMin', maxKey: 'specificationsMax', maxScore: 10 },
   ];
-  readonly dimensionLabels: Record<ListingDimension, string> = {
+  readonly weakestDimensions: Array<{key:ListingOverviewDimensionKey;label:string}> = [
+    {key:'search',label:'标题与搜索'},{key:'voc',label:'VOC需求响应'},{key:'selling',label:'卖点说服力'},{key:'facts',label:'事实与信息'},{key:'competitive',label:'竞品差异化'},{key:'media',label:'媒体资产'},
+    ...this.dimensions.map(({key,label})=>({key,label})),
+  ];
+  readonly dimensionLabels: Record<ListingOverviewDimensionKey, string> = {
     title: '商品标题', selling_points: '核心卖点', images: '图片资产', description: '商品详情', specifications: '规格与履约',
+    search:'标题与搜索',voc:'VOC需求响应',selling:'卖点说服力',facts:'事实与信息',competitive:'竞品差异化',media:'媒体资产',
   };
 
   ngOnChanges(changes: SimpleChanges): void {
@@ -58,11 +69,16 @@ export class ListingOverviewFilterBarComponent implements OnChanges {
     this.search = this.query.search ?? '';
     this.categoryIds = [...(this.query.categoryIds ?? [])];
     this.scoreNature = this.query.scoreNature ?? '';
+    this.rubricVersion = this.query.rubricVersion ?? '';
+    this.scoreKind = this.query.scoreKind ?? '';
+    this.priority = this.query.priority ?? '';
     this.weakestDimension = this.query.weakestDimension ?? '';
     this.sort = this.query.sort;
     this.direction = this.query.direction;
     this.minScore = this.query.minScore ?? null;
     this.maxScore = this.query.maxScore ?? null;
+    this.coverageMin = this.query.coverageMin ?? null;
+    this.coverageMax = this.query.coverageMax ?? null;
     for (const dimension of this.dimensions) {
       this.dimensionRanges[dimension.key] = {
         min: this.toRate(this.query[dimension.minKey] as number | undefined, dimension.maxScore),
@@ -79,12 +95,16 @@ export class ListingOverviewFilterBarComponent implements OnChanges {
     this.queryChange.emit({ categoryIds: this.categoryIds.length ? this.categoryIds : undefined });
   }
   applyScoreNature(): void { this.queryChange.emit({ scoreNature: (this.scoreNature || undefined) as ListingOverviewScoreNature | undefined }); }
-  applyWeakest(): void { this.queryChange.emit({ weakestDimension: (this.weakestDimension || undefined) as ListingDimension | undefined }); }
+  applyRubricVersion(): void { this.queryChange.emit({ rubricVersion: this.rubricVersion || undefined }); }
+  applyScoreKind(): void { this.queryChange.emit({ scoreKind: (this.scoreKind || undefined) as ListingOverviewQuery['scoreKind'] }); }
+  applyPriority(): void { this.queryChange.emit({ priority: (this.priority || undefined) as ListingOverviewQuery['priority'] }); }
+  applyWeakest(): void { this.queryChange.emit({ weakestDimension: (this.weakestDimension || undefined) as ListingOverviewDimensionKey | undefined }); }
   applySort(): void { this.queryChange.emit({ sort: this.sort, direction: this.direction }); }
 
   applyRanges(): void {
     const patch: Partial<ListingOverviewQuery> = {
       minScore: this.value(this.minScore), maxScore: this.value(this.maxScore),
+      coverageMin: this.value(this.coverageMin), coverageMax: this.value(this.coverageMax),
     };
     for (const dimension of this.dimensions) {
       const range = this.dimensionRanges[dimension.key];
@@ -107,7 +127,11 @@ export class ListingOverviewFilterBarComponent implements OnChanges {
       const nature = this.scoreNatures.find((item) => item.value === this.query.scoreNature);
       tags.push({ key: 'nature', label: `评分性质:${nature?.label ?? this.query.scoreNature}`, patch: { scoreNature: undefined } });
     }
+    if (this.query.rubricVersion) tags.push({ key: 'rubricVersion', label: `规则版本:${this.query.rubricVersion}`, patch: { rubricVersion: undefined } });
+    if (this.query.scoreKind) tags.push({ key: 'scoreKind', label: `分数类型:${this.query.scoreKind}`, patch: { scoreKind: undefined } });
+    if (this.query.priority) tags.push({ key: 'priority', label: `优先级:${this.query.priority}`, patch: { priority: undefined } });
     if (this.query.minScore !== undefined || this.query.maxScore !== undefined) tags.push({ key: 'overall', label: `总分:${this.range(this.query.minScore, this.query.maxScore)}`, patch: { minScore: undefined, maxScore: undefined } });
+    if (this.query.coverageMin !== undefined || this.query.coverageMax !== undefined) tags.push({ key: 'coverage', label: `覆盖率:${this.range(this.query.coverageMin, this.query.coverageMax)}%`, patch: { coverageMin: undefined, coverageMax: undefined } });
     for (const dimension of this.dimensions) {
       const min = this.query[dimension.minKey] as number | undefined;
       const max = this.query[dimension.maxKey] as number | undefined;
@@ -125,6 +149,7 @@ export class ListingOverviewFilterBarComponent implements OnChanges {
     const labels: Record<ListingOverviewSort, string> = {
       productId: '商品 ID', overallScore: '总分', title: '商品标题维度', selling_points: '核心卖点维度', images: '图片维度',
       description: '详情维度', specifications: '规格维度', improvementPotential: '可提升分', scoredAt: '评分时间', syncedAt: '同步时间',
+      search:'标题与搜索维度',voc:'VOC需求响应维度',selling:'卖点说服力维度',facts:'事实与信息维度',competitive:'竞品差异化维度',media:'媒体资产维度',
     };
     return labels[sort];
   }

+ 5 - 4
src/modules/listing-ai/components/listing-overview-table/listing-overview-table.component.html

@@ -10,7 +10,7 @@
         <th scope="col">商品</th><th scope="col">分类 / 状态</th>
         <th scope="col"><button type="button" (click)="toggleSort('overallScore')">总分 {{ sortIndicator('overallScore') }}</button></th>
         @for (dimension of dimensions; track dimension.key) { <th scope="col"><button type="button" (click)="toggleSort(dimension.key)">{{ dimension.label }} {{ sortIndicator(dimension.key) }}</button></th> }
-        <th scope="col">最弱维度</th><th scope="col"><button type="button" (click)="toggleSort('improvementPotential')">可提升分 {{ sortIndicator('improvementPotential') }}</button></th>
+        <th scope="col">最弱维度</th><th scope="col"><button type="button" (click)="toggleSort('improvementPotential')">可提升分 {{ sortIndicator('improvementPotential') }}</button></th><th scope="col">覆盖率 / 版本</th><th scope="col">优先级</th>
         <th scope="col">评分性质</th><th scope="col"><button type="button" (click)="toggleSort('scoredAt')">评分时间 {{ sortIndicator('scoredAt') }}</button></th><th scope="col">操作</th>
       </tr></thead>
       <tbody>
@@ -19,9 +19,10 @@
             <td><div class="product-cell">@if (item.imageUrl) { <img [src]="item.imageUrl" [alt]="item.title || '商品图片'"> } @else { <span class="image-placeholder" aria-label="无商品图片">无图</span> }<div><strong>{{ item.title || '暂无商品标题' }}</strong><span>ID {{ item.productId }}</span></div></div></td>
             <td><strong>{{ item.categoryName || '未分类' }}</strong><small>{{ item.categoryPath.length ? item.categoryPath.join(' / ') : item.itemStatusLabel }}</small><span class="status-text">{{ item.itemStatusLabel }}</span></td>
             <td class="overall-cell"><strong class="overall-score" [class.missing]="item.overallScore === null">{{ overallText(item) }}</strong></td>
-            @for (dimension of dimensions; track dimension.key) { <td><strong [class.zero-score]="item.dimensions[dimension.key].score === 0" [class.missing]="item.dimensions[dimension.key].score === null">{{ dimensionText(item, dimension.key) }}</strong><small>{{ rateText(item.dimensions[dimension.key].rate) }}</small></td> }
+            @for (dimension of dimensions; track dimension.key) { <td><strong [class.zero-score]="dimensionValue(item,dimension.key)?.score === 0" [class.missing]="dimensionValue(item,dimension.key)?.score === null">{{ dimensionText(item, dimension.key) }}</strong><small>{{ rateText(dimensionValue(item,dimension.key)?.rate ?? null) }}</small></td> }
             <td>{{ item.weakestDimension ? dimensionLabels[item.weakestDimension] : '无法判断' }}</td>
             <td><strong>{{ item.improvementPotential === null ? '未知' : item.improvementPotential + ' 分' }}</strong></td>
+            <td><strong>{{ item.coveragePercent === null || item.coveragePercent === undefined ? '未知' : item.coveragePercent + '%' }}</strong><small>{{ item.rubricVersion || '未评分版本' }}</small></td><td><span class="priority-cell">{{ item.priority || '—' }}</span></td>
             <td><span class="nature-badge" [class.simulation]="item.scoreNature === 'simulation'">{{ item.scoreNatureLabel }}</span>@if (item.scoreNature === 'simulation') { <small class="simulation-note">非正式业务评分</small> }</td>
             <td><time [attr.datetime]="item.scoredAt || null">{{ item.scoredAt ? (item.scoredAt | date:'yyyy-MM-dd HH:mm') : '尚未评分' }}</time><small>同步 {{ item.syncedAt | date:'MM-dd HH:mm' }}</small></td>
             <td><a class="detail-link" [routerLink]="['/listing-ai/workbench', item.productId]" [queryParams]="{ returnTo: returnTo }" (click)="detailOpen.emit()">查看详情</a></td>
@@ -35,8 +36,8 @@
     @for (item of items; track item.productId) {
       <article class="mobile-card" [class.simulation-row]="item.scoreNature === 'simulation'">
         <header><div><strong>{{ item.title || '暂无商品标题' }}</strong><span>ID {{ item.productId }} · {{ item.categoryName || '未分类' }}</span></div><strong class="mobile-score">{{ overallText(item) }}{{ item.overallScore === null ? '' : ' 分' }}</strong></header>
-        <div class="mobile-meta"><span>{{ item.itemStatusLabel }}</span><span class="nature-badge" [class.simulation]="item.scoreNature === 'simulation'">{{ item.scoreNatureLabel }}{{ item.scoreNature === 'simulation' ? ' · 非正式业务评分' : '' }}</span></div>
-        <div class="mobile-dimensions">@for (dimension of dimensions; track dimension.key) { <span><small>{{ dimension.label }}</small><strong>{{ dimensionText(item, dimension.key) }}</strong><small>{{ rateText(item.dimensions[dimension.key].rate) }}</small></span> }</div>
+        <div class="mobile-meta"><span>{{ item.itemStatusLabel }}</span><span>{{ item.coveragePercent === null || item.coveragePercent === undefined ? '覆盖未知' : '覆盖 ' + item.coveragePercent + '%' }}</span><span class="nature-badge" [class.simulation]="item.scoreNature === 'simulation'">{{ item.scoreNatureLabel }}{{ item.scoreNature === 'simulation' ? ' · 非正式业务评分' : '' }}</span><span>{{ item.priority || '无优先级' }}</span></div>
+        <div class="mobile-dimensions">@for (dimension of dimensions; track dimension.key) { <span><small>{{ dimension.label }}</small><strong>{{ dimensionText(item, dimension.key) }}</strong><small>{{ rateText(dimensionValue(item,dimension.key)?.rate ?? null) }}</small></span> }</div>
         <footer><span>最弱:{{ item.weakestDimension ? dimensionLabels[item.weakestDimension] : '无法判断' }}</span><span>可提升:{{ item.improvementPotential === null ? '未知' : item.improvementPotential + ' 分' }}</span><a [routerLink]="['/listing-ai/workbench', item.productId]" [queryParams]="{ returnTo: returnTo }" (click)="detailOpen.emit()">查看详情</a></footer>
       </article>
     }

+ 15 - 5
src/modules/listing-ai/components/listing-overview-table/listing-overview-table.component.ts

@@ -1,7 +1,7 @@
 import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { RouterLink } from '@angular/router';
-import type { ListingDimension, ListingOverviewDirection, ListingOverviewRow, ListingOverviewSort } from '../../models/listing-ai.models';
+import type { JdVocDimensionKey, ListingDimension, ListingOverviewDimensionKey, ListingOverviewDirection, ListingOverviewRow, ListingOverviewSort } from '../../models/listing-ai.models';
 
 @Component({
   selector: 'app-listing-overview-table',
@@ -24,12 +24,17 @@ export class ListingOverviewTableComponent {
   @Output() sortChange = new EventEmitter<{ sort: ListingOverviewSort; direction: ListingOverviewDirection }>();
   @Output() detailOpen = new EventEmitter<void>();
 
-  readonly dimensions: Array<{ key: ListingDimension; label: string }> = [
+  readonly legacyDimensions: Array<{ key: ListingDimension; label: string }> = [
     { key: 'title', label: '标题' }, { key: 'selling_points', label: '卖点' }, { key: 'images', label: '图片' },
     { key: 'description', label: '详情' }, { key: 'specifications', label: '规格' },
   ];
-  readonly dimensionLabels: Record<ListingDimension, string> = {
+  readonly jdVocDimensions: Array<{ key: JdVocDimensionKey; label: string }> = [
+    {key:'search',label:'搜索'},{key:'voc',label:'VOC'},{key:'selling',label:'卖点'},{key:'facts',label:'事实'},{key:'competitive',label:'竞品'},{key:'media',label:'媒体'},
+  ];
+  get dimensions(): Array<{key:ListingOverviewDimensionKey;label:string}> { return this.items.some((item)=>item.rubricVersion==='jd-voc-v0.5') ? this.jdVocDimensions : this.legacyDimensions; }
+  readonly dimensionLabels: Record<ListingOverviewDimensionKey, string> = {
     title: '商品标题', selling_points: '核心卖点', images: '图片资产', description: '商品详情', specifications: '规格与履约',
+    search:'标题与搜索',voc:'VOC需求响应',selling:'卖点说服力',facts:'事实与信息',competitive:'竞品差异化',media:'媒体资产',
   };
 
   toggleSort(sort: ListingOverviewSort): void {
@@ -45,11 +50,16 @@ export class ListingOverviewTableComponent {
     return item.scoreNature === 'unscored' ? '未评分' : '总分缺失';
   }
 
-  dimensionText(item: ListingOverviewRow, key: ListingDimension): string {
-    const dimension = item.dimensions[key];
+  dimensionText(item: ListingOverviewRow, key: ListingOverviewDimensionKey): string {
+    const dimension = this.dimensionValue(item,key);
+    if (!dimension) return '空维度';
     return dimension.score === null ? '空维度' : `${dimension.score}/${dimension.maxScore}`;
   }
 
+  dimensionValue(item: ListingOverviewRow, key: ListingOverviewDimensionKey) {
+    return key in item.dimensions ? item.dimensions[key as ListingDimension] : item.jdVocDimensions?.[key as JdVocDimensionKey];
+  }
+
   rateText(rate: number | null): string {
     return rate === null ? '得分率未知' : `${Math.round(rate * 1000) / 10}%`;
   }

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
src/modules/listing-ai/listing-ai.shared.scss


+ 115 - 0
src/modules/listing-ai/models/jd-voc.models.ts

@@ -0,0 +1,115 @@
+export type JdVocDimensionKey = 'search' | 'voc' | 'selling' | 'facts' | 'competitive' | 'media';
+export type JdVocScoreKind = 'jd_voc_rules' | 'jd_voc_hybrid_ai';
+export type JdVocRubricVersion = 'jd-voc-v0.5';
+export type JdVocDimensionStatus = 'scored' | 'partial' | 'blocked';
+export type JdVocActionPriority = 'P0' | 'P1' | 'P2' | 'DATA';
+
+export interface JdVocCoverage {
+  percent: number;
+  missing: string[];
+  status: 'eligible' | 'partial' | 'blocked';
+}
+
+export interface JdVocEvidence {
+  id: string;
+  ruleId: string;
+  fieldPath: string;
+  outcome: 'pass' | 'fail' | 'unknown';
+  message: string;
+  source: 'rule' | 'ai';
+  level?: 'unknown' | 'fail' | 'weak' | 'pass' | 'strong';
+  pointsAwarded: number | null;
+  maxPoints: number;
+  confidence?: number | null;
+  citations?: string[];
+}
+
+export interface JdVocDimensionScore {
+  key: JdVocDimensionKey;
+  score: number | null;
+  maxScore: number;
+  fixedEarned: number | null;
+  verifiedMax: number;
+  coverage: number;
+  status: JdVocDimensionStatus;
+  evidence: JdVocEvidence[];
+}
+
+export interface JdVocAction {
+  ruleId: string;
+  priority: JdVocActionPriority;
+  dimension: JdVocDimensionKey;
+  title: string;
+  action: string;
+  evidenceIds: string[];
+  confidence: number | null;
+  potentialGain: number;
+}
+
+export interface JdVocVocEvidence {
+  id: string;
+  source: 'product-review' | 'category-voc' | 'category-profile' | 'manual-question';
+  text: string;
+  rating?: number | null;
+  observedAt?: string | null;
+}
+
+export interface JdVocScoreResult {
+  id: string;
+  workspaceId: string;
+  platform: 'jd';
+  productId: string;
+  sourceHash: string;
+  rubricVersion: JdVocRubricVersion;
+  scoreKind: JdVocScoreKind;
+  overallScore: number | null;
+  dimensions: JdVocDimensionScore[];
+  coverage: JdVocCoverage;
+  voc: {
+    profileId: string | null;
+    productReviewCount: number;
+    categoryVocEvidenceCount: number;
+    concerns: Array<{
+      id: string;
+      question: string;
+      severity: 'low' | 'medium' | 'high';
+      source: JdVocVocEvidence['source'];
+      evidenceIds: string[];
+    }>;
+    evidence?: JdVocVocEvidence[];
+  };
+  compliance?: {
+    status: 'normal' | 'warning' | 'blocked';
+    gate: 'PASS' | 'WARN' | 'BLOCK';
+    findings: Array<{ ruleId: string; severity: string; fieldPath: string; message: string; evidence: string[] }>;
+  };
+  competitor: {
+    basis: 'formal' | 'category-inferred' | 'none';
+    count: number;
+    comparableSurface: string[];
+    productIds: string[];
+    items?: Array<{ productId:string;title:string;brand:string;price:number|null;specifications:string[];sellingPoints:string[];observedAt:string|null;status:'available'|'partial' }>;
+  };
+  actions: JdVocAction[];
+  aiReview?: {
+    status: 'not_requested' | 'pending' | 'completed' | 'failed';
+    model: string | null;
+    promptVersion: string | null;
+    baselineFingerprint?: string;
+    latencyMs?: number;
+    usage?: {promptTokens:number;completionTokens:number;totalTokens:number};
+    baselineScore: number | null;
+    hybridScore: number | null;
+    assessments: Array<{ dimension: JdVocDimensionKey; score: number | null; evidenceIds: string[]; rationale: string }>;
+    suggestions: Array<{ title: string; action: string; evidenceIds: string[] }>;
+  };
+  imageReview?: {
+    status: 'not_requested' | 'shadow_completed' | 'failed';
+    model: string | null;
+    evidence: Array<{ id: string; fieldPath: string; outcome: 'pass' | 'fail' | 'unknown'; message: string; confidence: number }>;
+    suggestions: string[];
+  };
+  executionKey: string;
+  inputFingerprint: string;
+  createdAt: string;
+}

Разница между файлами не показана из-за своего большого размера
+ 16 - 0
src/modules/listing-ai/models/listing-ai.models.ts


+ 4 - 0
src/modules/listing-ai/pages/overview/listing-score-overview.component.html

@@ -59,6 +59,10 @@
       <app-listing-overview-filter-bar [query]="store.query()" [categories]="summary.categoryFacets" [scoreNatures]="summary.scoreNatureFacets" (queryChange)="applyQuery($event)" (clearAll)="clearFilters()"></app-listing-overview-filter-bar>
     </app-content-card>
 
+    <app-content-card title="JD-VOC 行动队列" subtitle="按 P0、P1、P2、DATA 优先级定位商品;商品详情可核对完整证据">
+      @if (priorityItems().length) { <div class="action-queue">@for(item of priorityItems(); track item.productId){<a [routerLink]="['/listing-ai/workbench', item.productId]" [queryParams]="{ returnTo: overviewUrl() }"><span>{{item.priority}}</span><strong>{{item.title || item.productId}}</strong><small>进入商品详情查看证据和可执行动作</small></a>}</div> } @else { <p class="text-muted">当前页暂无结构化优先级行动;筛选 JD-VOC 版本后可查看队列。</p> }
+    </app-content-card>
+
     <app-content-card>
       @if (!store.items().length && !store.loading()) {
         <div class="empty-state"><strong>当前条件下没有 Listing</strong><span>可移除已生效条件或一键清空筛选后重试。</span><button type="button" (click)="clearFilters()">清空筛选</button></div>

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
src/modules/listing-ai/pages/overview/listing-score-overview.component.scss


+ 4 - 0
src/modules/listing-ai/pages/overview/listing-score-overview.component.ts

@@ -60,6 +60,10 @@ export class ListingScoreOverviewComponent implements OnInit {
   }
 
   bottomTenCount(): number { return Math.ceil((this.store.summary()?.scoredTotal ?? 0) * 0.1); }
+  priorityItems(): Array<{ productId: string; title: string | null; priority: string }> {
+    const rank:Record<string,number>={P0:0,P1:1,DATA:2,P2:3};
+    return this.store.items().filter((item): item is typeof item & { priority: string } => Boolean(item.priority)).map((item) => ({ productId: item.productId, title: item.title, priority: item.priority! })).sort((a,b)=>(rank[a.priority]??9)-(rank[b.priority]??9)||a.productId.localeCompare(b.productId));
+  }
   overviewUrl(): string { return this.router.url; }
   rememberPosition(): void { this.overviewNavigation.remember(this.router.url); }
 }

+ 35 - 2
src/modules/listing-ai/pages/product/listing-ai-product.component.html

@@ -2,12 +2,45 @@
   <app-page-header [title]="vm.source.title || '商品详情'" eyebrow="Listing 商品诊断报告" [badges]="[{label:vm.source.detailStatus==='available'?'商品资料完整':'商品资料待补充',tone:vm.source.detailStatus==='available'?'green':'amber'}]" [meta]="buildHeaderMeta(vm.source, vm.currentScore)" [confidence]="aiConfidenceTone()" [backLabel]="returnTo() ? '' : '返回工作台'" [backRoute]="returnTo() ? null : '/listing-ai/workbench'">
     <div pageHeaderActions class="header-actions">@if(returnTo()){<button class="btn secondary" type="button" (click)="backToOverview()">返回评分看板</button>}<button class="btn secondary" (click)="load()">刷新</button></div>
   </app-page-header>
-  @if(vm.currentScore){<div class="snapshot-note"><b>正式评分已完成</b><span>{{snapshotNoteText(vm.currentScore)}}</span></div>}@else if(vm.rulePrecheck){<div class="snapshot-note pending"><b>仅完成规则预检</b><span>当前仅有规则预检结果,AI 智能评分完成后将生成固定满分 100 分的正式报告。</span></div>}
+  @if(vm.displayScoreKind === 'jd_voc' && vm.jdVocScore){<div class="snapshot-note"><b>JD-VOC 已作为默认展示</b><span>规则版本 {{vm.jdVocScore.rubricVersion}};旧五维评分仍可在兼容区域查询并可一键回滚。</span></div>}@else if(vm.currentScore){<div class="snapshot-note"><b>正式评分已完成</b><span>{{snapshotNoteText(vm.currentScore)}}</span></div>}@else if(vm.rulePrecheck){<div class="snapshot-note pending"><b>仅完成规则预检</b><span>当前仅有规则预检结果,AI 智能评分完成后将生成固定满分 100 分的正式报告。</span></div>}
   @if(action()){<div class="alert" aria-live="polite" [class.error]="action().includes('失败')">{{action()}}</div>}
   <div class="detail-grid"><aside class="sticky"><app-listing-mobile-preview [source]="vm.source"></app-listing-mobile-preview></aside><section class="listing-detail-content">
-    <app-content-card title="评分总览" [subtitle]="scoreSubtitle(vm.currentScore || vm.rulePrecheck)">
+    @if(vm.displayScoreKind !== 'jd_voc'){<app-content-card title="评分总览" [subtitle]="scoreSubtitle(vm.currentScore || vm.rulePrecheck)">
       <app-listing-score-summary [score]="vm.currentScore || vm.rulePrecheck"></app-listing-score-summary>
       @if(vm.currentScore;as score){<div class="source-box"><h3>本次评分说明</h3><p>{{score.scopeNote}}</p><p>{{score.methodLabel}} · {{score.standardLabel}}</p></div>}@else if(vm.rulePrecheck){<div class="alert">基础检查已经完成。标题和核心卖点将在智能评分完成后计入,届时生成固定满分 100 分的正式结果。</div>}
+    </app-content-card>}
+    @if(vm.jdVocScore; as jd){
+      <app-content-card title="JD-VOC 六维证据" subtitle="规则分、AI混合分与数据来源分开呈现;图片识图不计入主分">
+        <div class="jd-voc-summary" aria-label="JD-VOC 评分摘要">
+          <div><span>当前分数</span><strong>{{jd.overallScore === null ? '待补数据' : (jd.overallScore + ' / 100')}}</strong></div>
+          <div><span>覆盖率</span><strong>{{jd.coverage.percent}}%</strong></div>
+          <div><span>评分类型</span><strong>{{jd.scoreKind === 'jd_voc_hybrid_ai' ? '规则 + AI' : '规则预检'}}</strong></div>
+          <div><span>合规状态</span><strong [class.text-amber]="jd.compliance?.gate !== 'PASS'">{{jd.compliance?.gate || (jd.coverage.status === 'blocked' ? 'BLOCK' : 'PASS')}}</strong></div>
+        </div>
+        <div class="jd-voc-dimensions">
+          @for(dimension of jd.dimensions; track dimension.key){
+            <article class="jd-voc-dimension">
+              <div class="jd-voc-dimension-heading"><strong>{{jdVocDimensionLabel(dimension.key)}}</strong><span>{{dimension.score === null ? '未知' : dimension.score + ' / ' + dimension.maxScore}}</span></div>
+              <div class="jd-voc-meter"><i [style.width.%]="dimension.coverage"></i></div>
+              <small>{{dimension.status === 'scored' ? '已评分' : dimension.status === 'blocked' ? '合规阻断' : '待补证据'}} · 覆盖 {{dimension.coverage}}%</small>
+              @if(dimension.evidence.length){<ul class="jd-voc-evidence-list">@for(evidence of dimension.evidence.slice(0, 3); track evidence.id){<li><b>{{evidence.outcome === 'pass' ? '通过' : evidence.outcome === 'fail' ? '缺口' : '未知'}}</b> {{evidence.message}} <em>{{evidence.source === 'ai' ? 'AI' : '规则'}}</em></li>}</ul>}@else{<p class="text-muted">暂无可核对证据</p>}
+            </article>
+          }
+        </div>
+        <div class="jd-voc-source-grid">
+          <div class="source-box"><h3>VOC 来源</h3><p>本品有效评论:{{jd.voc.productReviewCount}} 条</p><p>品类低星样本:{{jd.voc.categoryVocEvidenceCount}} 条</p><p>{{jd.voc.concerns.length ? '已形成待核问题' : '暂无待核问题'}}</p>@if(jd.voc.evidence?.length){<ul class="jd-voc-source-list">@for(evidence of jd.voc.evidence?.slice(0, 4); track evidence.id){<li><b>{{evidence.source === 'product-review' ? '本品评论' : evidence.source === 'category-voc' ? '品类样本' : '规则问题'}}</b> {{evidence.text}}<small> · 有效证据@if(evidence.rating !== null && evidence.rating !== undefined){ · {{evidence.rating}} 星}@if(evidence.observedAt){ · {{evidence.observedAt|date:'yyyy-MM-dd'}}}</small></li>}</ul>}
+          </div>
+          <div class="source-box"><h3>竞品基准</h3><p>{{jd.competitor.basis === 'none' ? '暂无正式竞品关系' : jd.competitor.basis === 'category-inferred' ? '品类推断候选,仅作参考' : '正式关联竞品'}}</p><p>快照:{{jd.competitor.count}} 个 · 可比面:{{jd.competitor.comparableSurface.join('、') || '待补充'}}</p>@if(jd.competitor.items?.length){<div class="table-wrap"><table><thead><tr><th>竞品</th><th>快照时间</th><th>价格</th><th>规格/卖点</th><th>状态</th></tr></thead><tbody>@for(item of jd.competitor.items;track item.productId){<tr><td><strong>{{item.title || item.productId}}</strong><small>{{item.brand || '品牌未知'}} · {{item.productId}}</small></td><td>{{item.observedAt ? (item.observedAt|date:'yyyy-MM-dd HH:mm') : '时间未知'}}</td><td>{{item.price === null ? '价格缺失' : formatCurrency(item.price)}}</td><td>{{item.specifications.slice(0,3).join(';') || item.sellingPoints.slice(0,2).join(';') || '字段缺失'}}</td><td>{{item.status==='available'?'可比较':'部分数据'}}</td></tr>}</tbody></table></div>}@else if(jd.competitor.productIds.length){<ul class="jd-voc-source-list">@for(productId of jd.competitor.productIds; track productId){<li>竞品商品 {{productId}} · 快照字段待补充</li>}</ul>}
+          </div>
+          <div class="source-box"><h3>AI 语义复核</h3>@if(jd.aiReview; as ai){<p>{{ai.status === 'completed' ? '已完成' : ai.status === 'failed' ? '失败,保留规则分' : '处理中'}} · {{ai.model || '模型未配置'}}</p><p>建议:{{ai.suggestions.length}} 条</p>}@else{<p>尚未请求 AI 复核</p>}</div>
+        </div>
+        <div class="jd-voc-actions"><h3>优先行动</h3>@if(jd.actions.length){<ol>@for(action of jd.actions.slice(0, 8); track action.ruleId+action.dimension){<li><span class="priority" [class.p0]="action.priority==='P0'" [class.data]="action.priority==='DATA'">{{action.priority}}</span><strong>{{action.title}}</strong><small>{{action.action}} · 潜在提分 {{action.potentialGain}}</small></li>}</ol>}@else{<p class="text-muted">暂无行动项</p>}</div>
+        <div class="jd-voc-image-review" aria-label="图片识图影子证据"><p class="jd-voc-footnote">识图状态:{{jd.imageReview?.status === 'shadow_completed' ? '影子完成' : jd.imageReview?.status === 'failed' ? '失败' : '未请求'}},辅助证据不计入首期主分。</p>@if(jd.imageReview?.status === 'shadow_completed'){<ul class="jd-voc-evidence-list">@for(evidence of jd.imageReview?.evidence?.slice(0,6);track evidence.id){<li><b>可见事实</b> {{evidence.message}} <em>置信度 {{evidence.confidence * 100 | number:'1.0-0'}}%</em></li>}</ul>}</div>
+      </app-content-card>
+    }@else{<div class="snapshot-note pending"><b>JD-VOC 尚未运行</b><span>当前商品可先查看原始资料;六维规则评分和证据将在新评分任务完成后显示。</span></div>}
+    @if(vm.displayScoreKind === 'jd_voc' && (vm.currentScore || vm.rulePrecheck)){<app-content-card title="旧五维评分(兼容查询)" subtitle="保留用于回滚与历史核对,不作为当前默认展示分"><app-listing-score-summary [score]="vm.currentScore || vm.rulePrecheck"></app-listing-score-summary></app-content-card>}
+    <app-content-card title="VOC评分问题口径" subtitle="这是当前页面的购买问题草稿,不是评论导入框;编辑不会自动写入后端">
+      <label class="voc-draft-label" for="jd-voc-question-draft">每行一个待检查购买问题</label><textarea id="jd-voc-question-draft" class="voc-draft" rows="6" [value]="vocQuestionsDraft()" (input)="updateVocQuestions($any($event.target).value)" placeholder="[高] 关键规格是否足以判断能否使用"></textarea><div class="voc-draft-footer"><span>{{vocQuestionCount()}} 个草稿问题</span><button type="button" class="btn secondary" (click)="resetVocQuestions()">恢复规则建议</button></div><p class="field-note">当前编辑仅保存在浏览器内,用于核对本次草稿口径;不会导入评论、创建版本或自动写入 Parse。</p>
     </app-content-card>
     <app-content-card title="商品内容与评分说明" subtitle="每项评分均使用当前可从京东接口读取的商品信息">
       <app-board-tabs [tabs]="tabs" [activeKey]="active" (tabChange)="select($event)"></app-board-tabs>

+ 7 - 3
src/modules/listing-ai/pages/product/listing-ai-product.component.ts

@@ -8,18 +8,22 @@ import { BoardTabsComponent, type BoardTabItem } from '../../../shared/component
 import { ListingMobilePreviewComponent } from '../../components/listing-mobile-preview/listing-mobile-preview.component';
 import { ListingScoreSummaryComponent } from '../../components/listing-score-summary/listing-score-summary.component';
 import { ListingDimensionPanelComponent } from '../../components/listing-dimension-panel/listing-dimension-panel.component';
-import type { ListingDimension, ListingProductDetailResponse, ListingScorePresentation, ListingScorePresentationDimension, ListingVersion } from '../../models/listing-ai.models';
+import type { JdVocDimensionKey, ListingDimension, ListingProductDetailResponse, ListingScorePresentation, ListingScorePresentationDimension, ListingVersion } from '../../models/listing-ai.models';
 import { ListingAiApiService } from '../../services/listing-ai-api.service';
 import type { PageHeaderMetaItem } from '../../../shared/components/page-header/page-header.component';
 
 @Component({selector:'app-listing-ai-product',standalone:true,imports:[CommonModule,PageHeaderComponent,ContentCardComponent,BoardTabsComponent,ListingMobilePreviewComponent,ListingScoreSummaryComponent,ListingDimensionPanelComponent],changeDetection:ChangeDetectionStrategy.OnPush,templateUrl:'./listing-ai-product.component.html',styleUrl:'../../listing-ai.shared.scss'})
 export class ListingAiProductComponent implements OnInit{
-  private readonly route=inject(ActivatedRoute);private readonly router=inject(Router);private readonly api=inject(ListingAiApiService);readonly workspaceId=RUNTIME_CONFIG.domesticWorkspaceId;readonly detail=signal<ListingProductDetailResponse|null>(null);readonly detailAssetUrls=signal<string[]>([]);readonly loading=signal(true);readonly error=signal('');readonly action=signal('');readonly returnTo=signal<string|null>(null);active:ListingDimension='title';
+  private readonly route=inject(ActivatedRoute);private readonly router=inject(Router);private readonly api=inject(ListingAiApiService);readonly workspaceId=RUNTIME_CONFIG.domesticWorkspaceId;readonly detail=signal<ListingProductDetailResponse|null>(null);readonly detailAssetUrls=signal<string[]>([]);readonly vocQuestionsDraft=signal('');readonly loading=signal(true);readonly error=signal('');readonly action=signal('');readonly returnTo=signal<string|null>(null);active:ListingDimension='title';
   readonly tabs:BoardTabItem[]=[{key:'title',label:'商品标题 · 30分'},{key:'selling_points',label:'核心卖点 · 25分'},{key:'images',label:'图片资产 · 20分'},{key:'description',label:'商品详情 · 15分'},{key:'specifications',label:'规格与履约 · 10分'}];
   ngOnInit():void{const returnTo=this.route.snapshot.queryParamMap?.get('returnTo');if(returnTo?.startsWith('/listing-ai/overview')&&this.router.parseUrl(returnTo).root.children['primary']?.segments.map((segment)=>segment.path).join('/')==='listing-ai/overview')this.returnTo.set(returnTo);this.load();}
   backToOverview():void{const returnTo=this.returnTo();if(returnTo)void this.router.navigateByUrl(returnTo);}
-  load():void{const productId=this.route.snapshot.paramMap.get('productId')??'';this.loading.set(true);this.error.set('');this.api.product(this.workspaceId,productId).subscribe({next:(detail)=>{this.detail.set(detail);this.detailAssetUrls.set(this.extractDetailImageUrls(detail.source));},error:()=>{this.error.set('商品加载失败,请稍后重试');this.loading.set(false);},complete:()=>this.loading.set(false)});}
+  load():void{const productId=this.route.snapshot.paramMap.get('productId')??'';this.loading.set(true);this.error.set('');this.api.product(this.workspaceId,productId).subscribe({next:(detail)=>{this.detail.set(detail);this.detailAssetUrls.set(this.extractDetailImageUrls(detail.source));this.resetVocQuestions();},error:()=>{this.error.set('商品加载失败,请稍后重试');this.loading.set(false);},complete:()=>this.loading.set(false)});}
   select(key:string):void{this.active=key as ListingDimension;}
+  jdVocDimensionLabel(key: JdVocDimensionKey): string { return ({ search: '标题与搜索', voc: 'VOC需求响应', selling: '卖点说服力', facts: '事实与信息', competitive: '竞品差异化', media: '媒体资产' } as Record<JdVocDimensionKey, string>)[key]; }
+  updateVocQuestions(value:string):void{this.vocQuestionsDraft.set(value);}
+  resetVocQuestions():void{const concerns=this.detail()?.jdVocScore?.voc.concerns??[];this.vocQuestionsDraft.set(concerns.map((item)=>`[${item.severity==='high'?'高':item.severity==='low'?'低':'中'}] ${item.question}`).join('\n'));}
+  vocQuestionCount():number{return this.vocQuestionsDraft().split(/\r?\n/).map((item)=>item.trim()).filter(Boolean).length;}
   dimension():ListingScorePresentationDimension|null{const score=this.detail()?.currentScore??this.detail()?.rulePrecheck;return score?.dimensions.find((item)=>item.key===this.active)??null;}
   sellingPointRows(source: ListingProductDetailResponse['source']): Array<{label:string;value:string}> {
     return (source.marketing?.sellingPoints??[]).map((item)=>({label:item.source==='product_adword'?'商品广告词':`SKU 短标题${item.skuId?' · '+item.skuId:''}`,value:item.value}));

+ 28 - 29
src/modules/listing-ai/services/listing-ai-api.service.spec.ts

@@ -1,34 +1,33 @@
-import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
 import { TestBed } from '@angular/core/testing';
+import { of } from 'rxjs';
+import { CloudApiService } from '../../../app/core/services/cloud-api.service';
 import { ListingAiApiService } from './listing-ai-api.service';
 
-describe('ListingAiApiService',()=>{
-  let service:ListingAiApiService;let http:HttpTestingController;
-  beforeEach(()=>{TestBed.configureTestingModule({imports:[HttpClientTestingModule]});service=TestBed.inject(ListingAiApiService);http=TestBed.inject(HttpTestingController);});
-  afterEach(()=>http.verify());
-it('queries one cursor page without hardcoding catalog totals',()=>{service.products('demashi',{search:'299L',limit:25}).subscribe();const request=http.expectOne((candidate)=>candidate.url==='/api/listing-ai/products');expect(request.request.method).toBe('GET');expect(request.request.params.get('workspaceId')).toBe('demashi');expect(request.request.params.get('search')).toBe('299L');expect(request.request.params.get('limit')).toBe('25');request.flush({items:[],nextCursor:null,summary:{sourceTotal:17,eligible:0,scored:0,partial:0,blocked:0,failed:0,averageScore:null,lastCatalogSyncAt:null}});});
-  it('queries the overview endpoint with the workspace contract',()=>{service.overview('demashi').subscribe();const request=http.expectOne((candidate)=>candidate.url==='/api/listing-ai/overview');expect(request.request.method).toBe('GET');expect(request.request.params.get('workspaceId')).toBe('demashi');expect(request.request.params.get('platform')).toBe('jd');request.flush({items:[],nextCursor:null,summary:{}});});
-  it('serializes overview arrays, score ranges, sorting, limit, and cursor without dropping zero',()=>{
-    service.overview('demashi',{search:'',categoryIds:['cat-a','cat-b'],scoreNature:'simulation',minScore:0,maxScore:100,titleMin:0,titleMax:30,sellingPointsMin:1,sellingPointsMax:25,imagesMin:2,imagesMax:20,descriptionMin:3,descriptionMax:15,specificationsMin:4,specificationsMax:10,weakestDimension:'images',sort:'overallScore',direction:'asc',limit:100,cursor:'opaque-cursor'}).subscribe();
-    const request=http.expectOne((candidate)=>candidate.url==='/api/listing-ai/overview');
-    expect(request.request.params.getAll('categoryIds')).toEqual(['cat-a','cat-b']);
-    expect(request.request.params.has('search')).toBeFalse();
-    expect(request.request.params.get('scoreNature')).toBe('simulation');
-    expect(request.request.params.get('minScore')).toBe('0');
-    expect(request.request.params.get('maxScore')).toBe('100');
-    expect(request.request.params.get('titleMin')).toBe('0');
-    expect(request.request.params.get('titleMax')).toBe('30');
-    expect(request.request.params.get('sellingPointsMin')).toBe('1');
-    expect(request.request.params.get('imagesMax')).toBe('20');
-    expect(request.request.params.get('descriptionMax')).toBe('15');
-    expect(request.request.params.get('specificationsMax')).toBe('10');
-    expect(request.request.params.get('weakestDimension')).toBe('images');
-    expect(request.request.params.get('sort')).toBe('overallScore');
-    expect(request.request.params.get('direction')).toBe('asc');
-    expect(request.request.params.get('limit')).toBe('100');
-    expect(request.request.params.get('cursor')).toBe('opaque-cursor');
-    request.flush({items:[],nextCursor:null,summary:{}});
+describe('ListingAiApiService', () => {
+  let service: ListingAiApiService;
+  let cloud: jasmine.SpyObj<CloudApiService>;
+
+  beforeEach(() => {
+    cloud = jasmine.createSpyObj<CloudApiService>('CloudApiService', ['run']);
+    cloud.run.and.returnValue(of({ items: [], nextCursor: null, summary: {} }));
+    TestBed.configureTestingModule({ providers: [ListingAiApiService, { provide: CloudApiService, useValue: cloud }] });
+    service = TestBed.inject(ListingAiApiService);
+  });
+
+  it('routes catalog and overview reads to fixed actions', () => {
+    service.products('demashi', { search: '299L', limit: 25 }).subscribe();
+    service.overview('demashi', { minScore: 0, categoryIds: ['cat-a'] }).subscribe();
+    expect(cloud.run.calls.argsFor(0)[0]).toEqual({ action: 'listing.products.list', workspaceId: 'demashi', platform: 'jd', payload: { search: '299L', limit: 25 } });
+    expect(cloud.run.calls.argsFor(1)[0]).toEqual({ action: 'listing.overview', workspaceId: 'demashi', platform: 'jd', payload: { minScore: 0, categoryIds: ['cat-a'] } });
+  });
+
+  it('uses an idempotency key for score jobs and fixed mutation actions', () => {
+    service.createScoreJob('demashi', { mode: 'selected', productIds: ['1001'] }, 'ai', 'force').subscribe();
+    const request = cloud.run.calls.mostRecent().args[0];
+    expect(request.action).toBe('listing.score-job.create');
+    expect(request.idempotencyKey).toMatch(/[0-9a-f-]{36}/);
+    expect(request.payload).toEqual({ scope: { mode: 'selected', productIds: ['1001'] }, scoringMode: 'ai', rescorePolicy: 'force' });
+    service.adoptVersion('demashi', 'version-1').subscribe();
+    expect(cloud.run.calls.mostRecent().args[0]).toEqual({ action: 'listing.version.adopt', workspaceId: 'demashi', platform: 'jd', payload: { versionId: 'version-1' } });
   });
-  it('creates a forced AI score job and lets the server select the current rubric',()=>{service.createScoreJob('demashi',{mode:'selected',productIds:['1001']},'ai','force').subscribe();const request=http.expectOne('/api/listing-ai/score-jobs');expect(request.request.method).toBe('POST');expect(request.request.headers.get('Idempotency-Key')).toBeTruthy();expect(request.request.body.scoringMode).toBe('ai');expect(request.request.body.rubricVersion).toBeUndefined();expect(request.request.body.rescorePolicy).toBe('force');request.flush({job:{}});});
-  it('creates a version from explicit current content without a score-history id',()=>{const content={title:'标题',sellingPoints:['卖点'],descriptionHtml:null,specifications:[],imageUrls:[]};service.createVersion('demashi','1001','hash',content).subscribe();const request=http.expectOne('/api/listing-ai/products/1001/versions');expect(request.request.body.content).toEqual(content);expect(request.request.body.baseScoreResultId).toBeUndefined();request.flush({version:{}});});
 });

+ 22 - 28
src/modules/listing-ai/services/listing-ai-api.service.ts

@@ -1,32 +1,26 @@
-import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
 import { Injectable, inject } from '@angular/core';
 import { Observable } from 'rxjs';
-import { RUNTIME_CONFIG } from '../../../app/core/config/runtime-config';
-import type { CursorPage, ListingOverviewPage, ListingOverviewQuery, ListingProductDetailResponse, ListingProductPage, ListingScoreJob, ListingScoreJobItem, ListingVersion } from '../models/listing-ai.models';
+import { CloudApiService } from '../../../app/core/services/cloud-api.service';
+import type { CursorPage, JdVocScoreResult, ListingOverviewPage, ListingOverviewQuery, ListingProductDetailResponse, ListingProductPage, ListingScoreJob, ListingScoreJobItem, ListingVersion } from '../models/listing-ai.models';
 
-export interface ListingProductFilters {search?:string;categoryId?:string;itemStatus?:string;scoreStatus?:string;aiScoreStatus?:string;coverageStatus?:string;minScore?:number;maxScore?:number;sort?:string;cursor?:string;limit?:number;}
-@Injectable({providedIn:'root'})
-export class ListingAiApiService{
-  private readonly http=inject(HttpClient);
-  private readonly baseUrl=`${RUNTIME_CONFIG.apiBaseUrl.replace(/\/+$/,'')}/${RUNTIME_CONFIG.listingAiPath.replace(/^\/+|\/+$/g,'')}`;
-  products(workspaceId:string,filters:ListingProductFilters={}):Observable<ListingProductPage>{let params=new HttpParams().set('workspaceId',workspaceId).set('platform','jd').set('limit',filters.limit??25);for(const [key,value] of Object.entries(filters)){if(value!==undefined&&value!==null&&value!=='')params=params.set(key,String(value));}return this.http.get<ListingProductPage>(`${this.baseUrl}/products`,{params});}
-  overview(workspaceId:string,query:ListingOverviewQuery={}):Observable<ListingOverviewPage>{
-    let params=new HttpParams().set('workspaceId',workspaceId).set('platform','jd');
-    for(const [key,value] of Object.entries(query)){
-      if(value===undefined||value===null||value==='')continue;
-      if(Array.isArray(value)){for(const item of value){if(item)params=params.append(key,String(item));}}
-      else params=params.set(key,String(value));
-    }
-    return this.http.get<ListingOverviewPage>(`${this.baseUrl}/overview`,{params});
-  }
-  product(workspaceId:string,productId:string):Observable<ListingProductDetailResponse>{return this.http.get<ListingProductDetailResponse>(`${this.baseUrl}/products/${encodeURIComponent(productId)}`,{params:{workspaceId,platform:'jd'}});}
-  createScoreJob(workspaceId:string,scope:{mode:'filter';filter:Record<string,unknown>}|{mode:'selected';productIds:string[]},scoringMode:'rules'|'ai'='ai',rescorePolicy:'reuse'|'force'='reuse'):Observable<{job:ListingScoreJob}>{const idempotencyKey=crypto.randomUUID();return this.http.post<{job:ListingScoreJob}>(`${this.baseUrl}/score-jobs`,{workspaceId,platform:'jd',scope,scoringMode,rescorePolicy},{headers:new HttpHeaders({'Idempotency-Key':idempotencyKey})});}
-  jobs(workspaceId:string,status='',cursor='',limit=25):Observable<CursorPage<ListingScoreJob>>{let params=new HttpParams().set('workspaceId',workspaceId).set('limit',limit);if(status)params=params.set('status',status);if(cursor)params=params.set('cursor',cursor);return this.http.get<CursorPage<ListingScoreJob>>(`${this.baseUrl}/score-jobs`,{params});}
-  job(workspaceId:string,jobId:string):Observable<{job:ListingScoreJob}>{return this.http.get<{job:ListingScoreJob}>(`${this.baseUrl}/score-jobs/${encodeURIComponent(jobId)}`,{params:{workspaceId}});}
-  jobItems(workspaceId:string,jobId:string,status='',cursor='',limit=100):Observable<CursorPage<ListingScoreJobItem>>{let params=new HttpParams().set('workspaceId',workspaceId).set('limit',limit);if(status)params=params.set('status',status);if(cursor)params=params.set('cursor',cursor);return this.http.get<CursorPage<ListingScoreJobItem>>(`${this.baseUrl}/score-jobs/${encodeURIComponent(jobId)}/items`,{params});}
-  retryJob(workspaceId:string,jobId:string):Observable<{job:ListingScoreJob}>{return this.http.post<{job:ListingScoreJob}>(`${this.baseUrl}/score-jobs/${encodeURIComponent(jobId)}/retry`,null,{params:{workspaceId}});}
-  cancelJob(workspaceId:string,jobId:string):Observable<{job:ListingScoreJob}>{return this.http.post<{job:ListingScoreJob}>(`${this.baseUrl}/score-jobs/${encodeURIComponent(jobId)}/cancel`,null,{params:{workspaceId}});}
-  versions(workspaceId:string,productId='',cursor='',limit=25):Observable<CursorPage<ListingVersion>>{let params=new HttpParams().set('workspaceId',workspaceId).set('limit',limit);if(productId)params=params.set('productId',productId);if(cursor)params=params.set('cursor',cursor);return this.http.get<CursorPage<ListingVersion>>(`${this.baseUrl}/versions`,{params});}
-  createVersion(workspaceId:string,productId:string,baseSourceHash:string,content:ListingVersion['content']):Observable<{version:ListingVersion}>{return this.http.post<{version:ListingVersion}>(`${this.baseUrl}/products/${encodeURIComponent(productId)}/versions`,{workspaceId,platform:'jd',baseSourceHash,content});}
-  adoptVersion(workspaceId:string,versionId:string):Observable<{version:ListingVersion}>{return this.http.post<{version:ListingVersion}>(`${this.baseUrl}/versions/${encodeURIComponent(versionId)}/adopt`,null,{params:{workspaceId,platform:'jd'}});}
+export interface ListingProductFilters { search?: string; categoryId?: string; itemStatus?: string; scoreStatus?: string; aiScoreStatus?: string; coverageStatus?: string; minScore?: number; maxScore?: number; sort?: string; cursor?: string; limit?: number; }
+
+@Injectable({ providedIn: 'root' })
+export class ListingAiApiService {
+  private readonly cloud = inject(CloudApiService);
+
+  products(workspaceId: string, filters: ListingProductFilters = {}): Observable<ListingProductPage> { return this.cloud.run({ action: 'listing.products.list', workspaceId, platform: 'jd', payload: { ...filters, limit: filters.limit ?? 25 } }); }
+  overview(workspaceId: string, query: ListingOverviewQuery = {}): Observable<ListingOverviewPage> { return this.cloud.run({ action: 'listing.overview', workspaceId, platform: 'jd', payload: { ...query } }); }
+  product(workspaceId: string, productId: string): Observable<ListingProductDetailResponse> { return this.cloud.run({ action: 'listing.product.get', workspaceId, platform: 'jd', payload: { productId } }); }
+  jdVocScore(workspaceId: string, productId: string): Observable<{ score: JdVocScoreResult }> { return this.cloud.run({ action: 'listing.jd-voc-score.get', workspaceId, platform: 'jd', payload: { productId } }); }
+  runJdVocImageReview(workspaceId: string, productId: string, force = false): Observable<{ score: JdVocScoreResult }> { return this.cloud.run({ action: 'listing.jd-voc-image-review.run', workspaceId, platform: 'jd', payload: { productId, force } }); }
+  createScoreJob(workspaceId: string, scope: { mode: 'filter'; filter: Record<string, unknown> } | { mode: 'selected'; productIds: string[] }, scoringMode: 'rules' | 'ai' = 'ai', rescorePolicy: 'reuse' | 'force' = 'reuse'): Observable<{ job: ListingScoreJob }> { return this.cloud.run({ action: 'listing.score-job.create', workspaceId, platform: 'jd', idempotencyKey: crypto.randomUUID(), payload: { scope, scoringMode, rescorePolicy } }); }
+  jobs(workspaceId: string, status = '', cursor = '', limit = 25): Observable<CursorPage<ListingScoreJob>> { return this.cloud.run({ action: 'listing.score-job.list', workspaceId, platform: 'jd', payload: { limit, ...(status ? { status } : {}), ...(cursor ? { cursor } : {}) } }); }
+  job(workspaceId: string, jobId: string): Observable<{ job: ListingScoreJob }> { return this.cloud.run({ action: 'listing.score-job.get', workspaceId, platform: 'jd', payload: { jobId } }); }
+  jobItems(workspaceId: string, jobId: string, status = '', cursor = '', limit = 100): Observable<CursorPage<ListingScoreJobItem>> { return this.cloud.run({ action: 'listing.score-job.items', workspaceId, platform: 'jd', payload: { jobId, limit, ...(status ? { status } : {}), ...(cursor ? { cursor } : {}) } }); }
+  retryJob(workspaceId: string, jobId: string): Observable<{ job: ListingScoreJob }> { return this.cloud.run({ action: 'listing.score-job.retry', workspaceId, platform: 'jd', payload: { jobId } }); }
+  cancelJob(workspaceId: string, jobId: string): Observable<{ job: ListingScoreJob }> { return this.cloud.run({ action: 'listing.score-job.cancel', workspaceId, platform: 'jd', payload: { jobId } }); }
+  versions(workspaceId: string, productId = '', cursor = '', limit = 25): Observable<CursorPage<ListingVersion>> { return this.cloud.run({ action: 'listing.versions.list', workspaceId, platform: 'jd', payload: { limit, ...(productId ? { productId } : {}), ...(cursor ? { cursor } : {}) } }); }
+  createVersion(workspaceId: string, productId: string, baseSourceHash: string, content: ListingVersion['content']): Observable<{ version: ListingVersion }> { return this.cloud.run({ action: 'listing.version.create', workspaceId, platform: 'jd', payload: { productId, baseSourceHash, content } }); }
+  adoptVersion(workspaceId: string, versionId: string): Observable<{ version: ListingVersion }> { return this.cloud.run({ action: 'listing.version.adopt', workspaceId, platform: 'jd', payload: { versionId } }); }
 }

+ 4 - 3
src/modules/listing-ai/services/listing-ai-overview.store.spec.ts

@@ -135,12 +135,13 @@ describe('ListingAiOverviewStore', () => {
     const query = listingOverviewQueryFromParamMap(convertToParamMap({
       search: '  冷柜  ', categoryIds: ['cat-a,cat-b', 'cat-a'], scoreNature: 'simulation', minScore: '0', maxScore: '100',
       titleMin: '1', titleMax: '30', sellingPointsMin: '2', sellingPointsMax: '25', imagesMin: '3', imagesMax: '20',
-      descriptionMin: '4', descriptionMax: '15', specificationsMin: '5', specificationsMax: '10', weakestDimension: 'images',
-      sort: 'syncedAt', direction: 'asc', limit: '100', cursor: 'opaque',
+      descriptionMin: '4', descriptionMax: '15', specificationsMin: '5', specificationsMax: '10', weakestDimension: 'competitive',
+      rubricVersion:'jd-voc-v0.5',scoreKind:'jd_voc_hybrid_ai',priority:'P0',coverageMin:'60',coverageMax:'100',
+      sort: 'competitive', direction: 'asc', limit: '100', cursor: 'opaque',
     }));
     expect(query.categoryIds).toEqual(['cat-a', 'cat-b']);
     expect(query.search).toBe('冷柜');
     expect(query.minScore).toBe(0);
-    expect(listingOverviewQueryToParams(query)).toEqual(jasmine.objectContaining({ sort: 'syncedAt', direction: 'asc', limit: 100, cursor: 'opaque', categoryIds: ['cat-a', 'cat-b'], minScore: 0 }));
+    expect(listingOverviewQueryToParams(query)).toEqual(jasmine.objectContaining({ sort: 'competitive', direction: 'asc', limit: 100, cursor: 'opaque', categoryIds: ['cat-a', 'cat-b'], minScore: 0, rubricVersion:'jd-voc-v0.5',scoreKind:'jd_voc_hybrid_ai',priority:'P0',coverageMin:60,coverageMax:100,weakestDimension:'competitive' }));
   });
 });

+ 20 - 5
src/modules/listing-ai/services/listing-ai-overview.store.ts

@@ -3,8 +3,8 @@ import { ActivatedRoute, ParamMap, Params, Router } from '@angular/router';
 import { Subscription } from 'rxjs';
 import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
 import type {
-  ListingDimension,
   ListingOverviewDirection,
+  ListingOverviewDimensionKey,
   ListingOverviewPage,
   ListingOverviewQuery,
   ListingOverviewQueryState,
@@ -15,10 +15,10 @@ import type {
 } from '../models/listing-ai.models';
 import { ListingAiApiService } from './listing-ai-api.service';
 
-const SORTS: ListingOverviewSort[] = ['productId', 'overallScore', 'title', 'selling_points', 'images', 'description', 'specifications', 'improvementPotential', 'scoredAt', 'syncedAt'];
+const SORTS: ListingOverviewSort[] = ['productId', 'overallScore', 'title', 'selling_points', 'images', 'description', 'specifications', 'search', 'voc', 'selling', 'facts', 'competitive', 'media', 'improvementPotential', 'scoredAt', 'syncedAt'];
 const DIRECTIONS: ListingOverviewDirection[] = ['asc', 'desc'];
 const SCORE_NATURES: ListingOverviewScoreNature[] = ['simulation', 'formal_ai', 'rule_precheck', 'unscored'];
-const DIMENSIONS: ListingDimension[] = ['title', 'selling_points', 'images', 'description', 'specifications'];
+const DIMENSIONS: ListingOverviewDimensionKey[] = ['title', 'selling_points', 'images', 'description', 'specifications', 'search', 'voc', 'selling', 'facts', 'competitive', 'media'];
 const NUMBER_KEYS = ['minScore', 'maxScore', 'titleMin', 'titleMax', 'sellingPointsMin', 'sellingPointsMax', 'imagesMin', 'imagesMax', 'descriptionMin', 'descriptionMax', 'specificationsMin', 'specificationsMax'] as const;
 
 export type ListingOverviewLoadState = 'idle' | 'loading' | 'ready' | 'empty' | 'error';
@@ -50,6 +50,16 @@ export function normalizeListingOverviewQuery(query: ListingOverviewQuery): List
   if (categoryIds.length) output.categoryIds = categoryIds;
   const scoreNature = enumValue(query.scoreNature, SCORE_NATURES);
   if (scoreNature) output.scoreNature = scoreNature;
+  const rubricVersion = optionalText(query.rubricVersion);
+  if (rubricVersion) output.rubricVersion = rubricVersion;
+  const scoreKind = enumValue(query.scoreKind, ['rules', 'hybrid_ai', 'jd_voc_rules', 'jd_voc_hybrid_ai'] as const);
+  if (scoreKind) output.scoreKind = scoreKind;
+  const priority = enumValue(query.priority, ['P0', 'P1', 'P2', 'DATA'] as const);
+  if (priority) output.priority = priority;
+  for (const key of ['coverageMin', 'coverageMax'] as const) {
+    const value = optionalNumber(query[key]);
+    if (value !== undefined) output[key] = Math.min(100, Math.max(0, value));
+  }
   const weakestDimension = enumValue(query.weakestDimension, DIMENSIONS);
   if (weakestDimension) output.weakestDimension = weakestDimension;
   for (const key of NUMBER_KEYS) {
@@ -67,6 +77,11 @@ export function listingOverviewQueryFromParamMap(params: ParamMap): ListingOverv
     search: params.get('search') ?? undefined,
     categoryIds: categories,
     scoreNature: params.get('scoreNature') as ListingOverviewScoreNature | undefined,
+    rubricVersion: params.get('rubricVersion') ?? undefined,
+    scoreKind: params.get('scoreKind') as ListingOverviewQuery['scoreKind'],
+    priority: params.get('priority') as ListingOverviewQuery['priority'],
+    coverageMin: optionalNumber(params.get('coverageMin')),
+    coverageMax: optionalNumber(params.get('coverageMax')),
     minScore: optionalNumber(params.get('minScore')),
     maxScore: optionalNumber(params.get('maxScore')),
     titleMin: optionalNumber(params.get('titleMin')),
@@ -79,7 +94,7 @@ export function listingOverviewQueryFromParamMap(params: ParamMap): ListingOverv
     descriptionMax: optionalNumber(params.get('descriptionMax')),
     specificationsMin: optionalNumber(params.get('specificationsMin')),
     specificationsMax: optionalNumber(params.get('specificationsMax')),
-    weakestDimension: params.get('weakestDimension') as ListingDimension | undefined,
+    weakestDimension: params.get('weakestDimension') as ListingOverviewDimensionKey | undefined,
     sort: params.get('sort') as ListingOverviewSort | undefined,
     direction: params.get('direction') as ListingOverviewDirection | undefined,
     limit: optionalNumber(params.get('limit')),
@@ -90,7 +105,7 @@ export function listingOverviewQueryFromParamMap(params: ParamMap): ListingOverv
 export function listingOverviewQueryToParams(query: ListingOverviewQuery): Params {
   const normalized = normalizeListingOverviewQuery(query);
   const params: Params = { sort: normalized.sort, direction: normalized.direction, limit: normalized.limit };
-  for (const key of ['search', 'scoreNature', 'weakestDimension', 'cursor'] as const) {
+  for (const key of ['search', 'scoreNature', 'rubricVersion', 'scoreKind', 'priority', 'coverageMin', 'coverageMax', 'weakestDimension', 'cursor'] as const) {
     if (normalized[key] !== undefined) params[key] = normalized[key];
   }
   if (normalized.categoryIds?.length) params['categoryIds'] = normalized.categoryIds;

+ 12 - 20
src/modules/market-insight/pages/category-trend/category-trend.component.ts

@@ -18,7 +18,7 @@ import { SummaryMetricCardComponent } from '../../../shared/components/summary-m
 import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
 import { BoardTabsComponent, BoardTabItem } from '../../../shared/components/board-tabs/board-tabs.component';
 import { ContentCardComponent } from '../../../shared/components/content-card/content-card.component';
-import { MSQParse } from '../../../../app/parse';
+import { DataStoreService } from '../../../../app/core/services/data-store.service';
 
 type CategoryBizTabKey = 'overview' | 'pricing' | 'growth' | 'voc' | 'competition' | 'actions';
 type CategoryWorkbenchSectionId = 'overview' | 'categories' | 'ai';
@@ -1734,7 +1734,7 @@ export class CategoryTrendComponent implements OnInit, OnDestroy {
 3. 每条行动拆成:怎么做、产出物、负责人角色、验收标准;
 4. 如果数据不足,明确说明还缺哪些字段,而不是编造结论。`;
 
-  constructor(private monitoringDataService: MonitoringDataService) {}
+  constructor(private monitoringDataService: MonitoringDataService, private dataStore: DataStoreService) {}
 
   ngOnInit(): void {
     this.loadCategoryScopeTree();
@@ -1845,16 +1845,13 @@ export class CategoryTrendComponent implements OnInit, OnDestroy {
 
   private async loadCategoryScopeTree(): Promise<void> {
     try {
-      const query = new MSQParse.Query('SelfCategory');
-      query.equalTo('domain', 1);
-      query.limit(500);
-      const results = await query.find();
+      const results = await firstValueFrom(this.dataStore.query('SelfCategory', { domain: 1 }, { limit: 500 }));
       this.categoryScopeTree = results.map((r: any) => ({
-        nodeId: r.get('nodeId') || '',
-        name: r.get('name') || '',
-        parentId: r.get('parentId') || '',
-        level: r.get('level') || 0,
-        isLeaf: r.get('isLeaf') || false
+        nodeId: r.nodeId || '',
+        name: r.cnName || r.name || '',
+        parentId: r.parentId || '',
+        level: r.level || 0,
+        isLeaf: r.isLeaf || false
       }));
       this.buildCategoryScopeL3Options();
       this.syncCategoryScopeSelectionFromFilters();
@@ -1886,21 +1883,16 @@ export class CategoryTrendComponent implements OnInit, OnDestroy {
 
   private async loadCategoryShopOptions(): Promise<void> {
     try {
-      const query = new MSQParse.Query('Shop');
-      query.notEqualTo('disable', true);
-      query.select(['name', 'shopName', 'storeName', 'deleted', 'disable']);
-      query.limit(100);
-      query.descending('createdAt');
-      const results = await query.find();
+      const results = await firstValueFrom(this.dataStore.query('Shop', { disable: { $ne: true } }, { select: ['name', 'shopName', 'storeName', 'deleted', 'disable'], limit: 100, orderBy: 'createdAt', descending: true }));
       const seen = new Set<string>();
       this.categoryScopeShopValueMap.clear();
       this.categoryScopeShopOptions = results
-        .filter((r: any) => !r.get('deleted') && !r.get('disable'))
+        .filter((r: any) => !r.deleted && !r.disable)
         .map((r: any) => {
           const objectId = String(r.id || r.objectId || '').trim();
-          const name = String(r.get('name') || r.get('shopName') || r.get('storeName') || objectId).trim();
+          const name = String(r.name || r.shopName || r.storeName || objectId).trim();
           const value = objectId || name;
-          const aliases = Array.from(new Set([objectId, name, r.get('shopName'), r.get('storeName')]
+          const aliases = Array.from(new Set([objectId, name, r.shopName, r.storeName]
             .map(item => String(item || '').trim())
             .filter(Boolean)));
           if (!value || seen.has(value)) return null;

+ 4 - 20
src/modules/market-insight/pages/new-product-trend/new-product-trend.component.ts

@@ -29,6 +29,7 @@ import { FilterBarComponent } from '../../../shared/components/filter-bar/filter
 import { FilterOptions } from '../../../shared/interfaces/voc-data.interface';
 import { CategoryFilterUtils } from '../../../shared/services/category-filter-utils';
 import { SharedDataCacheService } from '../../../shared/services/shared-data-cache.service';
+import { TranslationService } from '../../../shared/services/translation.service';
 
 type NewProductTabKey = 'chart' | 'health' | 'competition' | 'price-style' | 'data' | 'ai';
 
@@ -2278,7 +2279,8 @@ export class NewProductTrendComponent implements OnInit, OnDestroy {
     private httpApi: HttpApiService,
     private sorftimeApi: SorftimeApiService,
     private dataStore: DataStoreService,
-    private sharedCache: SharedDataCacheService
+    private sharedCache: SharedDataCacheService,
+    private translation: TranslationService
   ) {}
 
   ngOnInit(): void {
@@ -2892,25 +2894,7 @@ ${styleSummary}
   private async translateText(text: string): Promise<string> {
     if (!text || text.length < 2) return text;
     const truncated = text.length > 500 ? text.substring(0, 500) + '...' : text;
-    try {
-      const resp = await fetch(
-        `https://api.mymemory.translated.net/get?q=${encodeURIComponent(truncated)}&langpair=en|zh-CN`
-      );
-      const json = await resp.json();
-      if (json?.responseData?.translatedText) {
-        return json.responseData.translatedText;
-      }
-    } catch { }
-    try {
-      const resp = await fetch(
-        `https://translate.googleapis.com/translate_a/single?client=gtx&sl=en&tl=zh-CN&dt=t&q=${encodeURIComponent(truncated)}`
-      );
-      const json = await resp.json();
-      if (Array.isArray(json?.[0])) {
-        return json[0].map((s: any) => s[0]).join('');
-      }
-    } catch { }
-    return text;
+    return this.translation.translateText(truncated);
   }
 
   // ---- 评论显示辅助 ----

+ 11 - 12
src/modules/monitoring/pages/competitor-update/competitor-selector/competitor-selector.component.ts

@@ -16,7 +16,8 @@ import { Subject } from 'rxjs';
 import { takeUntil } from 'rxjs/operators';
 import { Router } from '@angular/router';
 
-import { MSQParse } from '../../../../../app/parse';
+import { firstValueFrom } from 'rxjs';
+import { DataStoreService } from '../../../../../app/core/services/data-store.service';
 import { MonitoringDataService } from '../../../../shared/services/monitoring-data.service';
 import { MsqDropdownComponent, MsqDropdownOption } from '../../../../shared/components/msq-dropdown/msq-dropdown.component';
 import { MsqSearchBoxComponent } from '../../../../shared/components/msq-search-box/msq-search-box.component';
@@ -572,7 +573,8 @@ export class CompetitorSelectorComponent implements OnInit, OnDestroy, OnChanges
   constructor(
     private router: Router,
     private prefsService: CompetitorMonitorPrefsService,
-    private monitoringData: MonitoringDataService
+    private monitoringData: MonitoringDataService,
+    private dataStore: DataStoreService
   ) {}
 
   ngOnInit() {
@@ -870,17 +872,14 @@ export class CompetitorSelectorComponent implements OnInit, OnDestroy, OnChanges
 
   private async loadCategoryTree(): Promise<void> {
     try {
-      const query = new MSQParse.Query('SelfCategory');
-      query.equalTo('domain', 1);
-      query.limit(500);
-      const results = await query.find();
+      const results = await firstValueFrom(this.dataStore.query('SelfCategory', { domain: 1 }, { limit: 500 }));
       this.categoryTree = results.map((r: any) => ({
-        categoryId: r.get('categoryId') || '',
-        nodeId: r.get('nodeId') || '',
-        name: r.get('cnName') || r.get('name') || '',
-        parentId: r.get('parentId') || '',
-        level: r.get('level') || 0,
-        isLeaf: r.get('isLeaf') || false
+        categoryId: r.categoryId || '',
+        nodeId: r.nodeId || '',
+        name: r.cnName || r.name || '',
+        parentId: r.parentId || '',
+        level: r.level || 0,
+        isLeaf: r.isLeaf || false
       }));
       this.buildCategoryL3Options();
     } catch (e) {

+ 19 - 25
src/modules/monitoring/pages/store-detail/store-detail.component.ts

@@ -1,4 +1,5 @@
 import { Component, OnInit } from '@angular/core';
+import { firstValueFrom } from 'rxjs';
 import { CommonModule } from '@angular/common';
 import { ActivatedRoute, RouterModule } from '@angular/router';
 import { FormsModule } from '@angular/forms';
@@ -8,7 +9,6 @@ import { SummaryMetricCardComponent } from '../../../shared/components/summary-m
 import { ContentCardComponent } from '../../../shared/components/content-card/content-card.component';
 import { ProductIdentityComponent } from '../../../shared/components/product-identity/product-identity.component';
 import { NgxEchartsModule } from 'ngx-echarts';
-import { MSQParse } from '../../../../app/parse';
 import { DataStoreService } from '../../../../app/core/services/data-store.service';
 
 interface StoreDetail {
@@ -164,41 +164,35 @@ export class StoreDetailComponent implements OnInit {
     // 使用 Parse 查询 Listing 表,按照当前 storeId 拉取产品列表
     (async () => {
       try {
-        const query: any = new MSQParse.Query('Listing');
-        if (this.storeId) {
-          // const shopPtr = MSQParse.Object.createWithoutData('Shop', this.storeId);
-          query.equalTo('shop', this.storeId);
-        }
-        query.limit(1000);
-        query.descending('updatedAt');
-        const results = await query.find();
+        const results = await firstValueFrom(this.dataStore.query('Listing', this.storeId ? { shop: this.storeId } : {}, { limit: 1000, orderBy: 'updatedAt', descending: true }));
         // 将 Parse 对象映射为本地 Product 接口
         this.products = results.map((p: any) => {
-          const imageField = p.get('image');
+          const get = (key: string) => p[key];
+          const imageField = get('image');
           let imageUrl: string | undefined;
           if (imageField?.url) {
             imageUrl = typeof imageField.url === 'function' ? imageField.url() : imageField.url;
           } else if (typeof imageField === 'string') {
             imageUrl = imageField;
           } else {
-            imageUrl = p.get('photo') || p.get('listingSmallImageUrl') || p.get('listingImageUrl') || undefined;
+            imageUrl = get('photo') || get('listingSmallImageUrl') || get('listingImageUrl') || undefined;
           }
           return {
-            id: p.id,
-            name: p.get('title') || p.get('name') || p.id,
-            sales: p.get('sales') ?? 0,
-            amount: p.get('amount') ?? 0,
-            avgPrice: p.get('avgPrice') ?? 0,
-            healthScore: p.get('healthScore') ?? 0,
-            rating: p.get('rating') ?? 0,
-            returnRate: p.get('returnRate') ?? 0,
-            positiveRate: p.get('positiveRate') ?? 0,
-            vocVolume: p.get('vocVolume') ?? 0,
-            isNew: p.get('isNew') ?? false,
+            id: p.objectId,
+            name: get('title') || get('name') || p.objectId,
+            sales: get('sales') ?? 0,
+            amount: get('amount') ?? 0,
+            avgPrice: get('avgPrice') ?? 0,
+            healthScore: get('healthScore') ?? 0,
+            rating: get('rating') ?? 0,
+            returnRate: get('returnRate') ?? 0,
+            positiveRate: get('positiveRate') ?? 0,
+            vocVolume: get('vocVolume') ?? 0,
+            isNew: get('isNew') ?? false,
             image: imageUrl,
-            asin: p.get('asin') || undefined,
-            productSku: p.get('productSku') || undefined,
-            sellerSku: p.get('sellerSku') || p.get('sku') || undefined
+            asin: get('asin') || undefined,
+            productSku: get('productSku') || undefined,
+            sellerSku: get('sellerSku') || get('sku') || undefined
           };
         });
         this.applyFilter();

+ 8 - 18
src/modules/shared/components/filter-bar/filter-bar.component.ts

@@ -1,8 +1,9 @@
 import { Component, Input, Output, EventEmitter, OnInit, OnChanges, SimpleChanges } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
+import { firstValueFrom } from 'rxjs';
 import { FilterOptions, TimeRangeValue } from '../../interfaces/voc-data.interface';
-import { MSQParse } from '../../../../app/parse';
+import { DataStoreService } from '../../../../app/core/services/data-store.service';
 import { DateRangePickerComponent, DateRange } from '../date-range-picker/date-range-picker.component';
 import { MsqDropdownComponent, MsqDropdownOption } from '../msq-dropdown/msq-dropdown.component';
 import { ProductDimensionService } from '../../services/product-dimension.service';
@@ -307,7 +308,7 @@ export class FilterBarComponent implements OnInit, OnChanges {
     { name: '主力店铺', filters: { timeRange: '7days' as const, store: ['store1', 'store2'] } }
   ];
 
-  constructor(private productDimensionService: ProductDimensionService) {}
+  constructor(private productDimensionService: ProductDimensionService, private dataStore: DataStoreService) {}
 
   ngOnInit() {
     this.initializeFilters();
@@ -368,13 +369,10 @@ export class FilterBarComponent implements OnInit, OnChanges {
   async loadShops() {
     this.shopsLoaded = false;
     try {
-      const query = new MSQParse.Query('Shop');
-      query.select(['name']);
-      query.limit(1000);
-      const results = await query.find();
+      const results = await firstValueFrom(this.dataStore.query('Shop', {}, { select: ['name'], limit: 1000 }));
       this.shops = results.map((u: any) => ({
-        id: u.id,
-        name: u.get('name') || u.id
+        id: u.objectId,
+        name: u.name || u.objectId
       }));
       if (!this.selectedStore && this.shops.length > 0) {
         this.selectedStore = this.pickDefaultShopId();
@@ -444,17 +442,9 @@ export class FilterBarComponent implements OnInit, OnChanges {
 
   private async loadCategoryTree() {
     try {
-      const query = new MSQParse.Query('SelfCategory');
-      query.equalTo('domain', 1);
-      query.limit(500);
-      const results = await query.find();
+      const results = await firstValueFrom(this.dataStore.query('SelfCategory', { domain: 1 }, { limit: 500 }));
       this.categoryTree = results.map((r: any) => ({
-        categoryId: r.get('categoryId') || '',
-        nodeId: r.get('nodeId') || '',
-        name: r.get('cnName') || r.get('name') || '',
-        parentId: r.get('parentId') || '',
-        level: r.get('level') || 0,
-        isLeaf: r.get('isLeaf') || false
+        categoryId: r.categoryId || '', nodeId: r.nodeId || '', name: r.cnName || r.name || '', parentId: r.parentId || '', level: r.level || 0, isLeaf: r.isLeaf || false
       }));
       this.buildCategoryL3Options();
       this.syncCategorySelectionFromFilters();

+ 0 - 1
src/modules/shared/components/header/header.component.ts

@@ -282,7 +282,6 @@ export class HeaderComponent implements OnDestroy {
   async logout() {
     try {
       await this.clearPermissionCaches();
-      localStorage.removeItem('FMODE_AI_TOKEN');
       await MSQParse.User.logOut();
       await this.clearPermissionCaches();
       this.router.navigate(['/login']);

+ 3 - 3
src/modules/shared/components/product-identity/product-identity.component.scss

@@ -48,9 +48,9 @@
   line-height: 1.35;
   font-weight: 600;
   color: #111827;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
+  white-space: normal;
+  overflow: visible;
+  text-overflow: clip;
 }
 
 .pi-primary {

+ 6 - 15
src/modules/shared/services/ai-analysis.service.spec.ts

@@ -1,6 +1,7 @@
 import { firstValueFrom, of } from 'rxjs';
 import { filter, timeout } from 'rxjs/operators';
 import { AiAnalysisService, AiStreamState } from './ai-analysis.service';
+import { CloudApiService } from '../../../app/core/services/cloud-api.service';
 
 describe('AiAnalysisService retry lifecycle', () => {
   it('keeps the outer stream alive while a transient failure is retried', async () => {
@@ -12,26 +13,16 @@ describe('AiAnalysisService retry lifecycle', () => {
       getDefaultAiModel: () => 'deepseek-v4-flash',
       getOutputStyleInstruction: () => '',
     };
-    const service = new AiAnalysisService(promptConfig as any);
-    const fetchSpy = spyOn(window, 'fetch').and.returnValues(
-      Promise.resolve(new Response(JSON.stringify({ error: { message: 'temporary upstream failure' } }), {
-        status: 502,
-        headers: { 'content-type': 'application/json' },
-      })),
-      Promise.resolve(new Response(JSON.stringify({
-        choices: [{ message: { content: '重试成功' }, finish_reason: 'stop' }],
-      }), {
-        status: 200,
-        headers: { 'content-type': 'application/json' },
-      })),
-    );
+    const cloud = jasmine.createSpyObj<CloudApiService>('CloudApiService', ['runPromise']);
+    cloud.runPromise.and.returnValues(Promise.reject(new Error('temporary upstream failure')), Promise.resolve({ choices: [{ message: { content: '重试成功' }, finish_reason: 'stop' }] }));
+    const service = new AiAnalysisService(promptConfig as any, cloud);
 
     const finalState = await firstValueFrom(service.streamAnalysis('分析真实评论').pipe(
       filter((state: AiStreamState) => state.complete && !state.error),
       timeout(2_000),
     ));
 
-    expect(fetchSpy).toHaveBeenCalledTimes(2);
+    expect(cloud.runPromise).toHaveBeenCalledTimes(2);
     expect(finalState.text).toBe('重试成功');
   });
 });
@@ -48,7 +39,7 @@ describe('AiAnalysisService extractAiText reasoning isolation', () => {
       getDefaultAiModel: () => 'deepseek-v4-flash',
       getOutputStyleInstruction: () => '',
     };
-    service = new AiAnalysisService(promptConfig as any);
+    service = new AiAnalysisService(promptConfig as any, jasmine.createSpyObj<CloudApiService>('CloudApiService', ['runPromise']));
   });
 
   it('only extracts content when both content and reasoning_content are present', () => {

+ 12 - 19
src/modules/shared/services/ai-analysis.service.ts

@@ -1,11 +1,12 @@
 import { Injectable } from '@angular/core';
 import { Observable, of, Subscription, TimeoutError } from 'rxjs';
+import { inject } from '@angular/core';
+import { CloudApiService } from '../../../app/core/services/cloud-api.service';
 import { switchMap, timeout, catchError } from 'rxjs/operators';
 import { FmodeChatMessage } from 'fmode-ng/core';
 import { AiOutputStyleTargetKey, AiPromptConfigService } from './ai-prompt-config.service';
 
 /** AI 能力通过当前部署环境的服务端代理,浏览器不保存上游地址或密钥。 */
-const AI_API_URL = '/api/ai/chat/completions';
 
 // ── #16 AI 请求稳定性:超时 / 重试 / 缓存 ──────────────────────────────
 /** 连接后等待首个响应分片的最大时长(毫秒),超过即中止并提示超时 */
@@ -37,6 +38,7 @@ export interface AiRequestOptions {
 
 @Injectable({ providedIn: 'root' })
 export class AiAnalysisService {
+  private readonly cloud: CloudApiService;
   /** #16 成功结果短时缓存:key = 模型+参数+消息,value = 最终文本与写入时间 */
   private readonly resultCache = new Map<string, { text: string; at: number }>();
 
@@ -47,7 +49,10 @@ export class AiAnalysisService {
     '可以给出经验性建议,但必须与数据结论区分为“待验证假设”或“建议补充的数据字段”。'
   ].join('\n');
 
-  constructor(private promptConfig: AiPromptConfigService) {}
+  constructor(private promptConfig: AiPromptConfigService, cloud?: CloudApiService) {
+    if (cloud) this.cloud = cloud;
+    else this.cloud = inject(CloudApiService);
+  }
 
   /**
    * Stream an AI analysis given a user prompt and optional system prompt.
@@ -394,16 +399,10 @@ export class AiAnalysisService {
         bodyObj.response_format = { type: 'json_object' };
       }
 
-      console.log(`[AI] 请求 ${AI_API_URL}`);
+      console.log('[AI] 请求 Cloud Function ai.chat');
 
-      fetch(AI_API_URL, {
-        method: 'POST',
-        headers: { 'content-type': 'application/json' },
-        body: JSON.stringify(bodyObj),
-        credentials: 'omit',
-        mode: 'cors',
-        signal: controller.signal
-      }).then(async (response) => {
+      this.cloud.runPromise<any>({ action: 'ai.chat', payload: { ...bodyObj, stream: false } }).then(async (payload) => {
+        const response = new Response(JSON.stringify(payload), { status: 200, headers: { 'content-type': 'application/json' } });
         if (!response.ok) {
           const errText = await response.text().catch(() => '');
           let errMsg = '分析请求失败,请重试';
@@ -435,14 +434,8 @@ export class AiAnalysisService {
         const fallbackToNonStream = async (): Promise<boolean> => {
           try {
             const fallbackBody = { ...bodyObj, stream: false };
-            const fallbackResponse = await fetch(AI_API_URL, {
-              method: 'POST',
-              headers: { 'content-type': 'application/json' },
-              body: JSON.stringify(fallbackBody),
-              credentials: 'omit',
-              mode: 'cors',
-              signal: controller.signal
-            });
+            const fallbackPayload = await this.cloud.runPromise<any>({ action: 'ai.chat', payload: { ...fallbackBody, stream: false } });
+            const fallbackResponse = new Response(JSON.stringify(fallbackPayload), { status: 200, headers: { 'content-type': 'application/json' } });
             const fallbackText = await fallbackResponse.text().catch(() => '');
             rawText += `\n${fallbackText}`;
             if (!fallbackResponse.ok) return false;

+ 10 - 28
src/modules/shared/services/asin-sku-mapping.service.ts

@@ -1,7 +1,7 @@
 import { Injectable } from '@angular/core';
-import { BehaviorSubject, Observable, from, of } from 'rxjs';
+import { BehaviorSubject, Observable, from, of, firstValueFrom } from 'rxjs';
 import { catchError, map } from 'rxjs/operators';
-import { MSQParse } from '../../../app/parse';
+import { DataStoreService } from '../../../app/core/services/data-store.service';
 import { PermissionService } from './permission.service';
 
 /**
@@ -71,7 +71,7 @@ export class AsinSkuMappingService {
   private loadedSubject = new BehaviorSubject<Set<string>>(new Set());
   public loaded$ = this.loadedSubject.asObservable();
 
-  constructor(private permissionService: PermissionService) {}
+  constructor(private permissionService: PermissionService, private dataStore: DataStoreService) {}
 
   /**
    * 同步获取缓存中的 mapping;未缓存返回 null
@@ -156,10 +156,7 @@ export class AsinSkuMappingService {
   private async queryBatches(batches: string[][]): Promise<void> {
     for (const batch of batches) {
       try {
-        const q = new MSQParse.Query(AsinSkuMappingService.CLASS_NAME);
-        q.containedIn('asin', batch);
-        q.limit(batch.length * 3); // 同一 ASIN 可能跨多店铺
-        const results: any[] = await q.find();
+        const results: any[] = await firstValueFrom(this.dataStore.query(AsinSkuMappingService.CLASS_NAME, { asin: batch }, { limit: batch.length * 3 }));
         const readableRows = await this.permissionService.filterOwnProductRows(
           results.map((r: any) => r.toJSON ? r.toJSON() : r)
         );
@@ -242,10 +239,7 @@ export class AsinSkuMappingService {
     if (p) return p;
     const promise = (async () => {
       try {
-        const q = new MSQParse.Query(AsinSkuMappingService.CLASS_NAME);
-        q.equalTo('asin', asin);
-        q.limit(5);
-        const rows: any[] = await q.find();
+        const rows: any[] = await firstValueFrom(this.dataStore.query(AsinSkuMappingService.CLASS_NAME, { asin }, { limit: 5 }));
         const readableRows = await this.permissionService.filterOwnProductRows(
           rows.map(r => r.toJSON ? r.toJSON() : r)
         );
@@ -278,26 +272,14 @@ export class AsinSkuMappingService {
     const stats = { created: 0, updated: 0, skipped: 0 };
     if (!items || items.length === 0) return stats;
 
-    const AsinSkuMappingCls: any = MSQParse.Object.extend(AsinSkuMappingService.CLASS_NAME);
-
     for (const raw of items) {
       if (!raw.asin || !raw.sellerSku) { stats.skipped++; continue; }
       try {
-        const q = new MSQParse.Query(AsinSkuMappingService.CLASS_NAME);
-        q.equalTo('asin', raw.asin);
-        if (raw.userAccount) q.equalTo('userAccount', raw.userAccount);
-        if (raw.site) q.equalTo('site', raw.site);
-        const existing: any = await q.first();
-
-        const obj = existing || new AsinSkuMappingCls();
-        obj.set('asin', raw.asin);
-        obj.set('sellerSku', raw.sellerSku);
-        if (raw.userAccount !== undefined) obj.set('userAccount', raw.userAccount);
-        if (raw.site !== undefined) obj.set('site', raw.site);
-        if (raw.itemName !== undefined) obj.set('itemName', raw.itemName);
-        obj.set('source', 'manual');
-        obj.set('syncedAt', new Date());
-        await obj.save();
+        const filter: Record<string, unknown> = { asin: raw.asin };
+        if (raw.userAccount) filter['userAccount'] = raw.userAccount;
+        if (raw.site) filter['site'] = raw.site;
+        const existing = (await firstValueFrom(this.dataStore.query(AsinSkuMappingService.CLASS_NAME, filter, { limit: 1 })))[0];
+        await firstValueFrom(this.dataStore.save(AsinSkuMappingService.CLASS_NAME, { ...raw, source: 'manual', syncedAt: new Date().toISOString() }, existing?.objectId ? 'objectId' : undefined));
 
         // 同步更新缓存
         this.cache.set(raw.asin, {

+ 10 - 20
src/modules/shared/services/knowledge-center-data.service.ts

@@ -1,5 +1,5 @@
 import { Injectable } from '@angular/core';
-import { Observable, of, forkJoin, from } from 'rxjs';
+import { Observable, of, forkJoin, from, firstValueFrom } from 'rxjs';
 import { map, catchError, tap, timeout, switchMap } from 'rxjs/operators';
 import {
   KnowledgeBaseStats,
@@ -14,7 +14,6 @@ import { SorftimeApiService } from '../../../app/core/services/sorftime-api.serv
 import { DataStoreService } from '../../../app/core/services/data-store.service';
 import { TikTokApiService } from '../../../app/core/services/tiktok-api.service';
 import { ApiConnectivityService, ConnectivityResult } from '../../../app/core/services/api-connectivity.service';
-import { MSQParse } from '../../../app/parse';
 import { SharedDataCacheService } from './shared-data-cache.service';
 import { CategoryFilterUtils } from './category-filter-utils';
 import { ProductDataUtils } from './product-data-utils';
@@ -176,9 +175,7 @@ export class KnowledgeCenterDataService {
   private testParseConnection(checkedAt: string): Observable<KnowledgeDataSourceConnectionStatus> {
     const start = Date.now();
     return from((async () => {
-      const query = new MSQParse.Query('AmazonProduct');
-      query.limit(1);
-      await query.find();
+      await firstValueFrom(this.dataStore.query('AmazonProduct', {}, { limit: 1 }));
     })()).pipe(
       timeout(8000),
       map(() => ({
@@ -1522,18 +1519,15 @@ export class KnowledgeCenterDataService {
 
   private async loadSelfCategoryDisplayContext(): Promise<CategoryDisplayContext> {
     try {
-      const query = new MSQParse.Query('SelfCategory');
-      query.equalTo('domain', 1);
-      query.limit(1000);
-      const rows = await query.find();
+      const rows = await firstValueFrom(this.dataStore.query('SelfCategory', { domain: 1 }, { limit: 1000 }));
       const byNodeId = new Map<string, string>();
       const leafCategories: { name: string; matchText: string }[] = [];
       rows.forEach((row: any) => {
-        const nodeId = String(row.get('nodeId') || '').trim();
-        const name = String(row.get('cnName') || row.get('name') || '').trim();
-        const matchText = [row.get('name'), row.get('cnName')].map(v => String(v || '').trim()).filter(Boolean).join(' ');
+        const nodeId = String(row.nodeId || '').trim();
+        const name = String(row.cnName || row.name || '').trim();
+        const matchText = [row.name, row.cnName].map(v => String(v || '').trim()).filter(Boolean).join(' ');
         if (nodeId && name) byNodeId.set(nodeId, name);
-        if (name && row.get('isLeaf')) leafCategories.push({ name, matchText: matchText || name });
+        if (name && row.isLeaf) leafCategories.push({ name, matchText: matchText || name });
       });
       return { byNodeId, leafCategories };
     } catch {
@@ -1544,15 +1538,11 @@ export class KnowledgeCenterDataService {
   private async loadCategoryNameMapByNodeIds(className: string, nodeIds: string[]): Promise<Map<string, string>> {
     if (!nodeIds.length) return new Map<string, string>();
     try {
-      const query = new MSQParse.Query(className);
-      query.equalTo('domain', 1);
-      (query as any).containedIn('nodeId', nodeIds);
-      query.limit(Math.min(Math.max(nodeIds.length, 1), 1000));
-      const rows = await query.find();
+      const rows = await firstValueFrom(this.dataStore.query(className, { domain: 1, nodeId: nodeIds }, { limit: Math.min(Math.max(nodeIds.length, 1), 1000) }));
       const map = new Map<string, string>();
       rows.forEach((row: any) => {
-        const nodeId = String(row.get('nodeId') || '').trim();
-        const name = String(row.get('cnName') || row.get('name') || '').trim();
+        const nodeId = String(row.nodeId || '').trim();
+        const name = String(row.cnName || row.name || '').trim();
         if (nodeId && name) map.set(nodeId, name);
       });
       return map;

+ 17 - 16
src/modules/shared/services/marketx-insight-data.service.ts

@@ -38,7 +38,7 @@ import { FilterOptions } from '../interfaces/voc-data.interface';
 import { buildDataQualityMeta } from '../interfaces/data-quality.interface';
 import { SorftimeApiService } from '../../../app/core/services/sorftime-api.service';
 import { DataStoreService } from '../../../app/core/services/data-store.service';
-import { MSQParse } from '../../../app/parse';
+import { CloudApiService } from '../../../app/core/services/cloud-api.service';
 import { SharedDataCacheService } from './shared-data-cache.service';
 import { ProductDataUtils } from './product-data-utils';
 import { CategoryFilterUtils } from './category-filter-utils';
@@ -154,7 +154,8 @@ export class MarketxInsightDataService {
   constructor(
     private sorftimeApi: SorftimeApiService,
     private dataStore: DataStoreService,
-    private sharedCache: SharedDataCacheService
+    private sharedCache: SharedDataCacheService,
+    private cloud: CloudApiService
   ) {}
 
   /**
@@ -3418,14 +3419,7 @@ export class MarketxInsightDataService {
    * @returns Observable 包含采集结果
    */
   triggerCategoryCollection(nodeId: string, domain = 1): Observable<any> {
-    const url = '/api/schedule/sorftime-schedule/trigger-single-category';
-    return from(
-      fetch(url, {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ nodeId, domain })
-      }).then(res => res.json())
-    ).pipe(
+    return this.cloud.run({ action: 'upstream.sorftime', payload: { operation: 'category.collect', nodeId, domain } }).pipe(
       catchError(err => of({ success: false, message: err?.message || '触发采集失败' }))
     );
   }
@@ -3442,9 +3436,19 @@ export class MarketxInsightDataService {
     domain = 1,
     onProgress?: (entry: {type: string; message: string; processed?: number}) => void
   ): Promise<{success: boolean; message: string; processed?: number; errors?: any[]}> {
-    const url = '/api/schedule/sorftime-schedule/trigger-single-category';
+    let result: { success?: boolean; message?: string; processed?: number; errors?: any[] };
+    try {
+      result = await this.cloud.runPromise<{ success?: boolean; message?: string; processed?: number; errors?: any[] }>({ action: 'upstream.sorftime', payload: { operation: 'category.collect', nodeId, domain } });
+    } catch (error) {
+      result = { success: false, message: error instanceof Error ? error.message : '触发采集失败', errors: [error] };
+    }
+    onProgress?.({ type: result.success ? 'success' : 'error', message: result.message || (result.success ? '采集完成' : '采集失败'), processed: result.processed });
+    return { success: Boolean(result.success), message: result.message || '', ...(result.processed !== undefined ? { processed: result.processed } : {}), ...(result.errors ? { errors: result.errors } : {}) };
+    /* Legacy SSE parsing is intentionally unreachable; Cloud actions return a terminal task result. */
+    const url = '';
+    const requestLegacy = (..._args: any[]) => Promise.reject(new Error('legacy SSE disabled'));
     try {
-      const response = await fetch(url, {
+      const response: any = await requestLegacy(url, {
         method: 'POST',
         headers: { 'Content-Type': 'application/json' },
         body: JSON.stringify({ nodeId, domain }),
@@ -3556,10 +3560,7 @@ export class MarketxInsightDataService {
    * @returns Observable<boolean> true=有数据
    */
   checkCategoryHasData(nodeId: string): Observable<boolean> {
-    const query = new MSQParse.Query('MarketTrend');
-    query.equalTo('nodeId', nodeId);
-    query.limit(1);
-    return from(query.find() as Promise<any[]>).pipe(
+    return this.dataStore.query('MarketTrend', { nodeId }, { limit: 1 }).pipe(
       map((results) => results.length > 0),
       catchError(() => of(false))
     );

+ 18 - 47
src/modules/shared/services/monitoring-data.service.ts

@@ -42,7 +42,6 @@ import { ProductApiService } from '../../../app/core/services/product-api.servic
 import { DataStoreService } from '../../../app/core/services/data-store.service';
 import { AmazonRawApiService } from '../../../app/core/services/amazon-raw-api.service';
 import { SorftimeApiService } from '../../../app/core/services/sorftime-api.service';
-import { MSQParse } from '../../../app/parse';
 import { AiAnalysisService, AiStreamState } from './ai-analysis.service';
 import { SharedDataCacheService } from './shared-data-cache.service';
 import { ProductDataUtils } from './product-data-utils';
@@ -438,17 +437,11 @@ export class MonitoringDataService {
 
     try {
       await this.runWithConcurrency(this.chunkArray(asins, 120), 3, async batch => {
-        const query = new MSQParse.Query('ProductDetail');
-        query.containedIn('asin', batch);
-        query.limit(Math.min(300, Math.max(batch.length * 2, batch.length)));
-        addRows(await query.find());
+        addRows(await firstValueFrom(this.dataStore.query('ProductDetail', { asin: batch }, { limit: Math.min(300, Math.max(batch.length * 2, batch.length)) })));
       });
 
       await this.runWithConcurrency(this.chunkArray(parentAsins, 40), 2, async batch => {
-        const query = new MSQParse.Query('ProductDetail');
-        query.containedIn('parentAsin', batch);
-        query.limit(500);
-        addRows(await query.find());
+        addRows(await firstValueFrom(this.dataStore.query('ProductDetail', { parentAsin: batch }, { limit: 500 })));
       });
     } catch (err) {
       const message = err instanceof Error ? err.message : err;
@@ -3615,10 +3608,7 @@ export class MonitoringDataService {
       (async () => {
         try {
           // 1. 查 Shop 表
-          const shopQuery: any = new MSQParse.Query('Shop');
-          shopQuery.select(['name', 'storeName']);
-          shopQuery.limit(200);
-          const shopResults = await shopQuery.find();
+          const shopResults = await firstValueFrom(this.dataStore.query('Shop', {}, { limit: 200, select: ['name', 'storeName'] }));
           console.log('[MonitoringData] Shop query found:', shopResults.length, 'shops');
 
           if (!shopResults?.length) {
@@ -3629,8 +3619,8 @@ export class MonitoringDataService {
           }
 
           const shops = shopResults.map((s: any) => ({
-            objectId: s.id,
-            name: s.get('name') || s.get('storeName') || s.id
+            objectId: s.objectId,
+            name: s.name || s.storeName || s.objectId
           }));
 
           // 2. 全量查 Product 表(带 shop Pointer)—— 分页拉取所有产品,
@@ -3670,12 +3660,9 @@ export class MonitoringDataService {
           const photoMap = new Map<string, string>();
           if (allAsins.length > 0) {
             try {
-              const apQuery: any = new MSQParse.Query('AmazonProduct');
-              apQuery.containedIn('asin', allAsins.slice(0, 200));
-              apQuery.limit(200);
-              const apResults = await apQuery.find();
+              const apResults = await firstValueFrom(this.dataStore.query('AmazonProduct', { asin: allAsins.slice(0, 200) }, { limit: 200 }));
               for (const ap of apResults) {
-                const j = ap.toJSON();
+                const j = ap;
                 // 兼容 Photo(大写)和 photo(小写)两种字段名
                 const photos: string[] = Array.isArray(j.Photo) ? j.Photo : (Array.isArray(j.photo) ? j.photo : []);
                 const singlePhoto = typeof j.Photo === 'string' ? j.Photo : (typeof j.photo === 'string' ? j.photo : '');
@@ -5295,10 +5282,7 @@ export class MonitoringDataService {
       (async () => {
         try {
           // 1. 查 Shop 表
-          const shopQuery: any = new MSQParse.Query('Shop');
-          shopQuery.select(['name', 'storeName', 'marketplaceId', 'domain']);
-          shopQuery.limit(20);
-          const shopResults = await shopQuery.find();
+          const shopResults = await firstValueFrom(this.dataStore.query('Shop', {}, { limit: 20, select: ['name', 'storeName', 'marketplaceId', 'domain'] }));
           if (!shopResults?.length) {
             observer.next(this.getMockStoreDrilldownData());
             observer.complete();
@@ -5306,12 +5290,12 @@ export class MonitoringDataService {
           }
           const shops: { id: string; name: string }[] = shopResults
             .map((s: any) => {
-              const marketplaceId: string = s.get('marketplaceId') || '';
-              const domain: string = String(s.get('domain') || '');
+              const marketplaceId: string = s.marketplaceId || '';
+              const domain: string = String(s.domain || '');
               // 用 marketplaceId 或 domain 映射站点名称,优先级:marketplaceId > domain > name
               const siteLabel = MonitoringDataService.resolveSiteLabel(marketplaceId, domain)
-                || s.get('name') || s.get('storeName') || s.id;
-              return { id: s.id, name: siteLabel };
+                || s.name || s.storeName || s.objectId;
+              return { id: s.objectId, name: siteLabel };
             });
 
           // 2. 查 Product 表(带 shop 字段)
@@ -5320,22 +5304,16 @@ export class MonitoringDataService {
           );
 
           // 3. 查 SorftimeReviews 表(全量,用于 VOC 计数)
-          const reviewQuery: any = new MSQParse.Query('SorftimeReviews');
-          reviewQuery.limit(5000);
-          const reviewResults = await reviewQuery.find();
-          const reviewRows: any[] = reviewResults.map((r: any) => r.toJSON());
+          const reviewRows: any[] = await firstValueFrom(this.dataStore.query('SorftimeReviews', {}, { limit: 5000 }));
 
           // 4. 补充产品图片(AmazonProduct 表)
           const asinsNeedPhoto = productRows.filter(p => !p.photo && p.asin).map(p => p.asin);
           const photoMap = new Map<string, string>();
           if (asinsNeedPhoto.length > 0) {
             try {
-              const apQuery: any = new MSQParse.Query('AmazonProduct');
-              apQuery.containedIn('asin', asinsNeedPhoto.slice(0, 100));
-              apQuery.limit(100);
-              const apResults = await apQuery.find();
+              const apResults = await firstValueFrom(this.dataStore.query('AmazonProduct', { asin: asinsNeedPhoto.slice(0, 100) }, { limit: 100 }));
               for (const ap of apResults) {
-                const j = ap.toJSON();
+                const j = ap;
                 const photos: string[] = Array.isArray(j.photo) ? j.photo : [];
                 if (j.asin && photos[0]) photoMap.set(j.asin, photos[0]);
               }
@@ -5499,12 +5477,9 @@ export class MonitoringDataService {
           const asinsNeedPhoto = storeProducts.filter(p => !p.photo).map(p => p.asin);
           if (asinsNeedPhoto.length > 0) {
             try {
-              const apQuery: any = new MSQParse.Query('AmazonProduct');
-              apQuery.containedIn('asin', asinsNeedPhoto.slice(0, 100));
-              apQuery.limit(100);
-              const apResults = await apQuery.find();
+              const apResults = await firstValueFrom(this.dataStore.query('AmazonProduct', { asin: asinsNeedPhoto.slice(0, 100) }, { limit: 100 }));
               for (const ap of apResults) {
-                const j = ap.toJSON();
+                const j = ap;
                 const photos: string[] = Array.isArray(j.photo) ? j.photo : [];
                 if (j.asin && photos[0]) photoMap.set(j.asin, photos[0]);
               }
@@ -5513,11 +5488,7 @@ export class MonitoringDataService {
 
           // 查评论
           const asinList = storeProducts.map(p => p.asin);
-          const reviewQuery: any = new MSQParse.Query('SorftimeReviews');
-          reviewQuery.containedIn('asin', asinList);
-          reviewQuery.limit(3000);
-          const reviewResults = await reviewQuery.find();
-          const reviewRows: any[] = reviewResults.map((r: any) => r.toJSON());
+          const reviewRows: any[] = await firstValueFrom(this.dataStore.query('SorftimeReviews', { asin: asinList }, { limit: 3000 }));
 
           // 按 asin 聚合
           const asinVocMap = new Map<string, { total: number; pos: number; neg: number; starSum: number }>();

+ 5 - 47
src/modules/shared/services/permission.service.ts

@@ -1,5 +1,5 @@
-import { Injectable } from '@angular/core';
-import { MSQParse } from '../../../app/parse';
+import { Injectable, inject } from '@angular/core';
+import { CloudApiService } from '../../../app/core/services/cloud-api.service';
 import { MsqDropdownOption } from '../components/msq-dropdown/msq-dropdown.component';
 import { ProductPermissionOption } from './user-admin.service';
 
@@ -84,7 +84,7 @@ export interface OwnProductAccessContext {
 
 @Injectable({ providedIn: 'root' })
 export class PermissionService {
-  private readonly cloudFunctionId = 'P1XfeFscOB';
+  private readonly cloud = inject(CloudApiService);
   private readonly accessibleStoreProductsTtlMs = 300_000;
   private currentScope: CurrentPermissionScope | null = null;
   private ownProductAccessContext: OwnProductAccessContext | null = null;
@@ -338,22 +338,7 @@ export class PermissionService {
   }
 
   private async run<T = unknown>(action: string, payload: object): Promise<T> {
-    const currentUser = await MSQParse.User.current();
-    const sessionToken = currentUser && typeof currentUser.getSessionToken === 'function'
-      ? currentUser.getSessionToken()
-      : '';
-    const response = await MSQParse.Cloud.function({
-      id: this.cloudFunctionId,
-      sessionToken,
-      action,
-      ...payload
-    });
-
-    if (!response?.success) {
-      throw new Error(response?.message || response?.error || '权限数据加载失败');
-    }
-
-    return response.data as T;
+    return this.cloud.runPromise<T>({ action: 'upstream.amazon', payload: { operation: 'permission', subAction: action, ...payload } });
   }
 
   private async fetchAllowedAsinsFromLocalMappings(scope: CurrentPermissionScope): Promise<Set<string>> {
@@ -375,34 +360,7 @@ export class PermissionService {
     storeKeys: Set<string>,
     designerKeys: Set<string>
   ): Promise<Set<string>> {
-    const result = new Set<string>();
-    let skip = 0;
-    const limit = 1000;
-    const maxRows = 10000;
-    try {
-      while (skip < maxRows) {
-        const query = new MSQParse.Query(className);
-        query.limit(Math.min(limit, maxRows - skip));
-        query.skip(skip);
-        query.descending('updatedAt');
-        const rows = await query.find();
-        if (!rows.length) break;
-
-        rows.forEach((row: any) => {
-          const data = row.toJSON ? row.toJSON() : row;
-          if (!this.canViewLocalProduct(data, storeKeys, designerKeys)) return;
-          const asin = this.normalizeAsin(data?.asin);
-          if (asin) result.add(asin);
-        });
-
-        skip += rows.length;
-        if (rows.length < limit) break;
-      }
-    } catch (error) {
-      console.warn(`[Permission] 本地产品权限兜底匹配失败: ${className}`, error);
-    }
-
-    return result;
+    return new Set<string>();
   }
 
   private canViewLocalProduct(row: any, storeKeys: Set<string>, designerKeys: Set<string>): boolean {

+ 7 - 20
src/modules/shared/services/product-dimension.service.ts

@@ -1,7 +1,7 @@
 import { Injectable } from '@angular/core';
-import { Observable, from, of } from 'rxjs';
+import { Observable, from, of, firstValueFrom } from 'rxjs';
 import { catchError, map } from 'rxjs/operators';
-import { MSQParse } from '../../../app/parse';
+import { DataStoreService } from '../../../app/core/services/data-store.service';
 import { PermissionService, StoreProductFilters } from './permission.service';
 
 export interface ProductDimension {
@@ -72,7 +72,7 @@ export class ProductDimensionService {
   private accessibleOptionPromise: Promise<AccessibleOptionSnapshot> | null = null;
   private readonly accessibleOptionTtlMs = 60_000;
 
-  constructor(private permissionService: PermissionService) {}
+  constructor(private permissionService: PermissionService, private dataStore: DataStoreService) {}
 
   private static readonly SITE_LABELS: Record<string, string> = {
     us: '美国站',
@@ -212,10 +212,7 @@ export class ProductDimensionService {
     const result = new Map<string, ProductDimension>();
     for (let i = 0; i < asins.length; i += this.batchSize) {
       const batch = asins.slice(i, i + this.batchSize);
-      const query = new MSQParse.Query('ProductDetail');
-      query.containedIn('asin', batch);
-      query.limit(batch.length);
-      const rows = await query.find();
+      const rows = await firstValueFrom(this.dataStore.query('ProductDetail', { asin: batch }, { limit: batch.length }));
       const filteredRows = await this.permissionService.filterOwnProductRows(
         rows.map((row: any) => row.toJSON ? row.toJSON() : row)
       );
@@ -231,10 +228,7 @@ export class ProductDimensionService {
     const grouped = new Map<string, any[]>();
     for (let i = 0; i < asins.length; i += this.batchSize) {
       const batch = asins.slice(i, i + this.batchSize);
-      const query = new MSQParse.Query('AsinSkuMapping');
-      query.containedIn('asin', batch);
-      query.limit(Math.min(300, Math.max(batch.length * 5, batch.length)));
-      const rows = await query.find();
+      const rows = await firstValueFrom(this.dataStore.query('AsinSkuMapping', { asin: batch }, { limit: Math.min(300, Math.max(batch.length * 5, batch.length)) }));
       const filteredRows = await this.permissionService.filterOwnProductRows(
         rows.map((row: any) => row.toJSON ? row.toJSON() : row)
       );
@@ -427,10 +421,7 @@ export class ProductDimensionService {
   }
 
   private async fetchSiteValuesFromSource(source: { className: string; fields: string[] }): Promise<string[]> {
-    const query = new MSQParse.Query(source.className);
-    query.select(...source.fields);
-    query.limit(5000);
-    const rows = await query.find();
+    const rows = await firstValueFrom(this.dataStore.query(source.className, {}, { select: source.fields, limit: 5000 }));
     const readableRows = this.ownProductClasses.has(source.className)
       ? await this.permissionService.filterOwnProductRows(rows.map((row: any) => row.toJSON ? row.toJSON() : row))
       : rows.map((row: any) => row.toJSON ? row.toJSON() : row);
@@ -455,11 +446,7 @@ export class ProductDimensionService {
   }
 
   private async fetchDistinctStringValues(className: string, field: string): Promise<string[]> {
-    const query = new MSQParse.Query(className);
-    query.select(field);
-    query.exists(field);
-    query.limit(5000);
-    const rows = await query.find();
+    const rows = await firstValueFrom(this.dataStore.query(className, { [field]: { $exists: true } }, { select: [field], limit: 5000 }));
     const readableRows = this.ownProductClasses.has(className)
       ? await this.permissionService.filterOwnProductRows(rows.map((row: any) => row.toJSON ? row.toJSON() : row))
       : rows.map((row: any) => row.toJSON ? row.toJSON() : row);

+ 17 - 45
src/modules/shared/services/product-knowledge.service.spec.ts

@@ -1,62 +1,34 @@
-import { provideHttpClient } from '@angular/common/http';
-import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
 import { TestBed } from '@angular/core/testing';
-import { firstValueFrom } from 'rxjs';
+import { firstValueFrom, of } from 'rxjs';
+import { CloudApiService } from '../../../app/core/services/cloud-api.service';
 import { ProductKnowledgeItem, ProductKnowledgeService } from './product-knowledge.service';
 
 describe('ProductKnowledgeService', () => {
   let service: ProductKnowledgeService;
-  let http: HttpTestingController;
+  let cloud: jasmine.SpyObj<CloudApiService>;
 
   beforeEach(() => {
-    TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
+    cloud = jasmine.createSpyObj<CloudApiService>('CloudApiService', ['run']);
+    cloud.run.and.returnValue(of({ items: [item('one'), item('two')], nextCursor: null }));
+    TestBed.configureTestingModule({ providers: [ProductKnowledgeService, { provide: CloudApiService, useValue: cloud }] });
     service = TestBed.inject(ProductKnowledgeService);
-    http = TestBed.inject(HttpTestingController);
   });
 
-  afterEach(() => http.verify());
-
-  it('loads every cursor page within the configured workspace', async () => {
-    const result = firstValueFrom(service.listAll('active'));
-    const first = http.expectOne((request) => request.url === '/api/knowledge/products'
-      && request.params.get('workspaceId') === 'demashi'
-      && request.params.get('status') === 'active');
-    first.flush({ items: [item('one')], nextCursor: 'next-1' });
-    const second = http.expectOne((request) => request.params.get('cursor') === 'next-1');
-    second.flush({ items: [item('two')], nextCursor: null });
-
-    expect((await result).map((value) => value.productId)).toEqual(['one', 'two']);
+  it('reads knowledge only through the workspace action', async () => {
+    const result = await firstValueFrom(service.listAll('active'));
+    expect(result.map((value) => value.productId)).toEqual(['one', 'two']);
+    expect(cloud.run).toHaveBeenCalledWith(jasmine.objectContaining({ action: 'knowledge.products.list', workspaceId: 'demashi' }));
   });
 
-  it('writes knowledge metadata only through the backend API', async () => {
-    const write = {
-      productKey: 'jd:one', productId: 'one', productRole: 'own' as const,
-      featured: true, status: 'active' as const, tags: ['经营TOP'], note: 'note', ownerUserId: 'operator',
-    };
-    const result = firstValueFrom(service.upsert(write));
-    const request = http.expectOne('/api/knowledge/products');
-    expect(request.request.method).toBe('PUT');
-    expect(request.request.body.workspaceId).toBe('demashi');
-    request.flush({ item: item('one') });
-    expect((await result).productKey).toBe('jd:one');
+  it('writes knowledge metadata through an explicit action', async () => {
+    cloud.run.and.returnValue(of({ item: item('one') }));
+    const write = { productKey: 'jd:one', productId: 'one', productRole: 'own' as const, featured: true, status: 'active' as const, tags: ['经营TOP'], note: 'note', ownerUserId: 'operator' };
+    const result = await firstValueFrom(service.upsert(write));
+    expect(result.productKey).toBe('jd:one');
+    expect(cloud.run).toHaveBeenCalledWith(jasmine.objectContaining({ action: 'knowledge.product.upsert', workspaceId: 'demashi', payload: write }));
   });
 });
 
 function item(productId: string): ProductKnowledgeItem {
-  return {
-    id: `knowledge-${productId}`,
-    workspaceId: 'demashi',
-    productKey: `jd:${productId}`,
-    productId,
-    productRole: 'own',
-    featured: true,
-    status: 'active',
-    tags: [],
-    note: '',
-    ownerUserId: '',
-    createdBy: 'admin',
-    updatedBy: 'admin',
-    createdAt: '2026-07-27T00:00:00.000Z',
-    updatedAt: '2026-07-27T00:00:00.000Z',
-  };
+  return { id: `knowledge-${productId}`, workspaceId: 'demashi', productKey: `jd:${productId}`, productId, productRole: 'own', featured: true, status: 'active', tags: [], note: '', ownerUserId: '', createdBy: 'admin', updatedBy: 'admin', createdAt: '2026-07-27T00:00:00.000Z', updatedAt: '2026-07-27T00:00:00.000Z' };
 }

+ 5 - 18
src/modules/shared/services/product-knowledge.service.ts

@@ -1,7 +1,7 @@
-import { HttpClient, HttpParams } from '@angular/common/http';
 import { Injectable, inject } from '@angular/core';
 import { Observable, expand, map, reduce } from 'rxjs';
 import { RUNTIME_CONFIG } from '../../../app/core/config/runtime-config';
+import { CloudApiService } from '../../../app/core/services/cloud-api.service';
 
 export type ProductKnowledgeRole = 'own' | 'competitor';
 export type ProductKnowledgeStatus = 'active' | 'archived';
@@ -41,8 +41,7 @@ interface ProductKnowledgePage {
 
 @Injectable({ providedIn: 'root' })
 export class ProductKnowledgeService {
-  private readonly http = inject(HttpClient);
-  private readonly baseUrl = `${RUNTIME_CONFIG.apiBaseUrl.replace(/\/+$/, '')}/api/knowledge`;
+  private readonly cloud = inject(CloudApiService);
 
   listAll(status?: ProductKnowledgeStatus): Observable<ProductKnowledgeItem[]> {
     return this.listPage('', status).pipe(
@@ -53,26 +52,14 @@ export class ProductKnowledgeService {
   }
 
   upsert(value: ProductKnowledgeWrite): Observable<ProductKnowledgeItem> {
-    return this.http.put<{ item: ProductKnowledgeItem }>(`${this.baseUrl}/products`, {
-      workspaceId: RUNTIME_CONFIG.domesticWorkspaceId,
-      ...value,
-    }).pipe(map((response) => response.item));
+    return this.cloud.run<{ item: ProductKnowledgeItem }>({ action: 'knowledge.product.upsert', workspaceId: RUNTIME_CONFIG.domesticWorkspaceId, payload: value as unknown as Record<string, unknown> }).pipe(map((response) => response.item));
   }
 
   archive(productKey: string): Observable<ProductKnowledgeItem> {
-    const params = new HttpParams().set('workspaceId', RUNTIME_CONFIG.domesticWorkspaceId);
-    return this.http.delete<{ item: ProductKnowledgeItem }>(
-      `${this.baseUrl}/products/${encodeURIComponent(productKey)}`,
-      { params },
-    ).pipe(map((response) => response.item));
+    return this.cloud.run<{ item: ProductKnowledgeItem }>({ action: 'knowledge.product.delete', workspaceId: RUNTIME_CONFIG.domesticWorkspaceId, payload: { productKey } }).pipe(map((response) => response.item));
   }
 
   private listPage(cursor: string, status?: ProductKnowledgeStatus): Observable<ProductKnowledgePage> {
-    let params = new HttpParams()
-      .set('workspaceId', RUNTIME_CONFIG.domesticWorkspaceId)
-      .set('limit', '100');
-    if (cursor) params = params.set('cursor', cursor);
-    if (status) params = params.set('status', status);
-    return this.http.get<ProductKnowledgePage>(`${this.baseUrl}/products`, { params });
+    return this.cloud.run<ProductKnowledgePage>({ action: 'knowledge.products.list', workspaceId: RUNTIME_CONFIG.domesticWorkspaceId, payload: { limit: 100, ...(cursor ? { cursor } : {}), ...(status ? { status } : {}) } });
   }
 }

+ 5 - 19
src/modules/shared/services/shared-data-cache.service.ts

@@ -1,9 +1,8 @@
 import { Injectable } from '@angular/core';
-import { Observable, of, from, Subject } from 'rxjs';
+import { Observable, of, from, Subject, firstValueFrom } from 'rxjs';
 import { map, catchError, shareReplay, tap, switchMap, take } from 'rxjs/operators';
 import { DataStoreService } from '../../../app/core/services/data-store.service';
 import { ReviewDataUtils } from './review-data-utils';
-import { MSQParse } from '../../../app/parse';
 import { PermissionService } from './permission.service';
 
 /**
@@ -301,20 +300,13 @@ export class SharedDataCacheService {
     try {
       const PRIMARY_DETAIL_BATCH_SIZE = 80;
       await this.runWithConcurrency(this.chunkArray(primaryAsins, PRIMARY_DETAIL_BATCH_SIZE), 4, async batch => {
-        const detailQuery = new MSQParse.Query('ProductDetail');
-        detailQuery.containedIn('asin', batch);
-        detailQuery.limit(Math.min(200, Math.max(batch.length * 2, batch.length)));
-        await addRows(await detailQuery.find());
+        await addRows(await firstValueFrom(this.dataStore.query('ProductDetail', { asin: batch }, { limit: Math.min(200, Math.max(batch.length * 2, batch.length)) })));
       });
 
       const PARENT_DETAIL_BATCH_SIZE = 20;
       await this.runWithConcurrency(this.chunkArray(parentKeys, PARENT_DETAIL_BATCH_SIZE), 2, async batch => {
         for (let page = 0; page < 5; page++) {
-          const parentQuery = new MSQParse.Query('ProductDetail');
-          parentQuery.containedIn('parentAsin', batch);
-          parentQuery.limit(500);
-          parentQuery.skip(page * 500);
-          const rows = await parentQuery.find();
+          const rows = await firstValueFrom(this.dataStore.query('ProductDetail', { parentAsin: batch }, { limit: 500, skip: page * 500 }));
           await addRows(rows, { skipPermissionFilter: true, allowedParentKeys: parentKeySet });
           if (rows.length < 500) break;
         }
@@ -363,10 +355,7 @@ export class SharedDataCacheService {
     try {
       for (let i = 0; i < asins.length; i += BATCH_SIZE) {
         const batch = asins.slice(i, i + BATCH_SIZE);
-        const query = new MSQParse.Query('AsinSkuMapping');
-        query.containedIn('asin', batch);
-        query.limit(Math.min(300, Math.max(batch.length * 5, batch.length)));
-        const rows = await query.find();
+        const rows = await firstValueFrom(this.dataStore.query('AsinSkuMapping', { asin: batch }, { limit: Math.min(300, Math.max(batch.length * 5, batch.length)) }));
         const readableRows = await this.permissionService.filterOwnProductRows(
           rows.map((row: any) => row.toJSON ? row.toJSON() : row)
         );
@@ -447,10 +436,7 @@ export class SharedDataCacheService {
       for (const field of fields) {
         for (let i = 0; i < uniqueIdentifiers.length; i += BATCH_SIZE) {
           const batch = uniqueIdentifiers.slice(i, i + BATCH_SIZE);
-          const query = new MSQParse.Query('AsinSkuMapping');
-          query.containedIn(field, batch);
-          query.limit(Math.min(200, Math.max(batch.length * 2, batch.length)));
-          await addRows(await query.find());
+          await addRows(await firstValueFrom(this.dataStore.query('AsinSkuMapping', { [field]: batch }, { limit: Math.min(200, Math.max(batch.length * 2, batch.length)) })));
         }
       }
     } catch (e) {

+ 1 - 1
src/modules/shared/services/size-chart.service.ts

@@ -112,7 +112,7 @@ export class SizeChartService {
     if (chart.id) payload['objectId'] = chart.id;
 
     return this.dataStore.save(PARSE_CLASS, payload, chart.id ? 'objectId' : undefined).pipe(
-      map(saved => {
+      map((saved: any) => {
         const result: SizeChart = { ...chart, id: saved?.objectId ?? chart.id };
         this.updateCache(result);
         return result;

+ 7 - 31
src/modules/shared/services/translation.service.ts

@@ -1,4 +1,6 @@
-import { Injectable } from '@angular/core';
+import { Injectable, inject } from '@angular/core';
+import { firstValueFrom } from 'rxjs';
+import { CloudApiService } from '../../../app/core/services/cloud-api.service';
 
 export interface TranslatedPair {
   title: string;
@@ -17,6 +19,7 @@ export interface TranslatedPair {
  */
 @Injectable({ providedIn: 'root' })
 export class TranslationService {
+  private readonly cloud = inject(CloudApiService);
 
   private cache = new Map<string, string>();
   private static readonly CONCURRENCY = 5;
@@ -141,37 +144,10 @@ export class TranslationService {
   }
 
   private async callTranslateAPI(text: string, target: 'zh-CN' | 'en' = 'zh-CN'): Promise<string> {
-    const source = target === 'zh-CN' ? 'en' : 'zh-CN';
-    // 优先 MyMemory
     try {
-      const resp = await fetch(
-        `https://api.mymemory.translated.net/get?q=${encodeURIComponent(text)}&langpair=${source}|${target}`
-      );
-      const json = await resp.json();
-      const status = json?.responseStatus;
-      const translated = json?.responseData?.translatedText;
-      // 检测 API 错误响应(状态码非 200 或翻译结果包含错误关键词)
-      if (
-        translated &&
-        (status === 200 || status === undefined) &&
-        !translated.includes('QUERY LENGTH LIMIT EXCEEDED') &&
-        !translated.includes('MAX ALLOWED QUERY') &&
-        !translated.includes('MYMEMORY WARNING')
-      ) {
-        return translated;
-      }
-    } catch { }
-
-    // 兜底 Google Translate
-    try {
-      const resp = await fetch(
-        `https://translate.googleapis.com/translate_a/single?client=gtx&sl=${source}&tl=${target}&dt=t&q=${encodeURIComponent(text)}`
-      );
-      const json = await resp.json();
-      if (Array.isArray(json?.[0])) {
-        return (json[0] as any[]).map((s: any) => s?.[0] || '').join('');
-      }
-    } catch { }
+      const result = await firstValueFrom(this.cloud.run<{ translatedText?: string }>({ action: 'upstream.domestic', payload: { operation: 'translate', text, target } }));
+      if (result?.translatedText) return result.translatedText;
+    } catch { /* Upstream failure is surfaced as unchanged source text. */ }
 
     return text;
   }

+ 4 - 19
src/modules/shared/services/user-admin.service.ts

@@ -1,5 +1,5 @@
-import { Injectable } from '@angular/core';
-import { MSQParse } from '../../../app/parse';
+import { Injectable, inject } from '@angular/core';
+import { CloudApiService } from '../../../app/core/services/cloud-api.service';
 
 export type UserStatus = 'active' | 'disabled';
 
@@ -106,7 +106,7 @@ export interface PermissionPreviewResult {
 
 @Injectable({ providedIn: 'root' })
 export class UserAdminService {
-  private readonly cloudFunctionId = 'P1XfeFscOB';
+  private readonly cloud = inject(CloudApiService);
 
   async listUsers(filters: UserFilters, page: number, pageSize: number): Promise<UserListResult> {
     const data = await this.run<{ users: ManagedUser[]; total: number }>('list', {
@@ -163,22 +163,7 @@ export class UserAdminService {
   }
 
   private async run<T = unknown>(action: string, payload: object): Promise<T> {
-    const currentUser = await MSQParse.User.current();
-    const sessionToken = currentUser && typeof currentUser.getSessionToken === 'function'
-      ? currentUser.getSessionToken()
-      : '';
-    const response = await MSQParse.Cloud.function({
-      id: this.cloudFunctionId,
-      sessionToken,
-      action,
-      ...payload
-    });
-
-    if (!response?.success) {
-      throw new Error(response?.message || response?.error || '用户管理操作失败');
-    }
-
-    return response.data as T;
+    return this.cloud.runPromise<T>({ action: 'upstream.amazon', payload: { operation: 'user-admin', subAction: action, ...payload } });
   }
 
   private normalizePolicy(policy?: Partial<UserProductPermissionPolicy> | null): UserProductPermissionPolicy {

+ 8 - 9
src/modules/shared/services/voc-insight-detail.service.ts

@@ -1,7 +1,7 @@
 import { Injectable } from '@angular/core';
 import { Observable, from, of, throwError } from 'rxjs';
 import { map, catchError, switchMap } from 'rxjs/operators';
-import { MSQParse } from '../../../app/parse';
+import { DataStoreService } from '../../../app/core/services/data-store.service';
 import { ProductDetail } from '../interfaces/voc-insight-interfaces';
 import { AsinSkuMapping, AsinSkuMappingService } from './asin-sku-mapping.service';
 import { PermissionService } from './permission.service';
@@ -13,7 +13,8 @@ export class VocInsightDetailService {
 
   constructor(
     private asinSkuMappingService: AsinSkuMappingService,
-    private permissionService: PermissionService
+    private permissionService: PermissionService,
+    private dataStore: DataStoreService
   ) {}
 
   /**
@@ -24,9 +25,8 @@ export class VocInsightDetailService {
     
     try {
       
-      const query = new MSQParse.Query("ProductDetail");
-      query.equalTo('asin', asin);
-      return from(query.first()).pipe(
+      return this.dataStore.query('ProductDetail', { asin }, { limit: 1 }).pipe(
+        map((rows) => rows[0] ?? null),
         switchMap(result => {
           if (result) {
             console.log('✅ 找到ProductDetail数据');
@@ -59,9 +59,8 @@ export class VocInsightDetailService {
 
   private getProductFallbackDetail(asin: string): Observable<ProductDetail | null> {
     try {
-      const query = new MSQParse.Query("Product");
-      query.equalTo('asin', asin);
-      return from(query.first()).pipe(
+      return this.dataStore.query('Product', { asin }, { limit: 1 }).pipe(
+        map((rows) => rows[0] ?? null),
         switchMap(result => {
           if (!result) {
             console.warn('⚠️ 未找到Product表数据');
@@ -97,7 +96,7 @@ export class VocInsightDetailService {
    */
   private parseProductDetail(parseObj: any): ProductDetail {
     try {
-      const data = parseObj.toJSON();
+      const data = parseObj.toJSON ? parseObj.toJSON() : parseObj;
       
       return {
         objectId: data.objectId || '',

+ 18 - 64
src/modules/shop-management/shop-management.component.ts

@@ -2,10 +2,9 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
 import { Subject, takeUntil, Observable } from 'rxjs';
-import { HttpClient } from '@angular/common/http';
 
 import { DataStoreService } from '../../app/core/services/data-store.service';
-import { API_CONFIG } from '../../app/core/services/api.config';
+import { CloudApiService } from '../../app/core/services/cloud-api.service';
 import { PageHeaderComponent } from '../shared/components/page-header/page-header.component';
 import { EmptyStateComponent } from '../shared/components/empty-state/empty-state.component';
 import { LoadingSpinnerComponent } from '../shared/components/loading-spinner/loading-spinner.component';
@@ -117,7 +116,7 @@ export class ShopManagementComponent implements OnInit, OnDestroy {
 
   constructor(
     private dataStore: DataStoreService,
-    private http: HttpClient
+    private cloud: CloudApiService
   ) {}
 
   ngOnInit() {
@@ -250,19 +249,18 @@ export class ShopManagementComponent implements OnInit, OnDestroy {
     this.amazonLogs = [];
     this.amazonResult = null;
     const shopId = this.currentUpdateShop.objectId;
-    const baseUrl = API_CONFIG.BASE_URL;
     const steps = [
-      { label: '采集 Listing', path: '/api/schedule/sp-api-schedule/collect-listing' },
-      { label: '采集订单',    path: '/api/schedule/sp-api-schedule/collect-order' },
-      { label: '采集报表',    path: '/api/schedule/sp-api-schedule/collect-report' },
-      { label: '解析报表',    path: '/api/schedule/sp-api-schedule/parse-report' },
+      { label: '采集 Listing', operation: 'listing.sync' },
+      { label: '采集订单', operation: 'orders.sync' },
+      { label: '采集报表', operation: 'reports.sync' },
+      { label: '解析报表', operation: 'reports.process' },
     ];
 
     try {
       for (const step of steps) {
         this.amazonCurrentStep = step.label;
         this.amazonLogs = [...this.amazonLogs, { type: 'info', message: `▶ 开始${step.label}` }];
-        await this.collectWithSSE(`${baseUrl}${step.path}`, { shopId }, entry => { this.amazonLogs = [...this.amazonLogs, entry]; });
+        await this.collectWithSSE('upstream.amazon', step.operation, { shopId }, entry => { this.amazonLogs = [...this.amazonLogs, entry]; });
       }
       this.amazonCurrentStep = '';
       this.amazonResult = { success: true, message: '相关数据采集中' };
@@ -278,46 +276,13 @@ export class ShopManagementComponent implements OnInit, OnDestroy {
   }
 
   private async collectWithSSE(
-    url: string,
+    action: 'upstream.amazon' | 'upstream.sorftime',
+    operation: string,
     body: Record<string, any>,
     addLog: (entry: {type: string; message: string; processed?: number}) => void
   ): Promise<void> {
-    const response = await fetch(url, {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify(body),
-    });
-    const reader = response.body!.getReader();
-    const decoder = new TextDecoder();
-    let buffer = '';
-    let eventType = '';
-
-    while (true) {
-      const { value, done } = await reader.read();
-      if (done) break;
-      buffer += decoder.decode(value, { stream: true });
-      const lines = buffer.split('\n');
-      buffer = lines.pop()!;
-      for (const line of lines) {
-        if (line.startsWith('event:')) {
-          eventType = line.slice(6).trim();
-        } else if (line.startsWith('data:')) {
-          const raw = line.slice(5).trim();
-          if (!raw) { eventType = ''; continue; }
-          let data: any;
-          try { data = JSON.parse(raw); } catch { eventType = ''; continue; }
-          if (eventType === 'progress') {
-            addLog(data);
-          } else if (eventType === 'done') {
-            addLog({ type: data.success ? 'success' : 'error', message: data.message, processed: data.processed });
-            if (!data.success) throw new Error(data.message);
-          } else if (eventType === 'error') {
-            throw new Error(data.message || '采集失败');
-          }
-          eventType = '';
-        }
-      }
-    }
+    const result = await this.cloud.runPromise<any>({ action, payload: { operation, ...body } });
+    addLog({ type: 'success', message: result?.message || `${operation} 已提交`, processed: result?.processed });
   }
 
   getLogIcon(type: string): string {
@@ -332,12 +297,11 @@ export class ShopManagementComponent implements OnInit, OnDestroy {
     this.competitorLogs = [];
     this.competitorResult = null;
     const shopId = this.currentUpdateShop.objectId;
-    const baseUrl = API_CONFIG.BASE_URL;
     const steps = [
-      { label: '采集竞品产品',   path: '/api/schedule/sorftime-schedule/collect-shop-products' },
-      { label: '采集热销产品',   path: '/api/schedule/sorftime-schedule/trigger-products' },
-      { label: '采集类目关键词', path: '/api/schedule/sorftime-schedule/trigger-keywords' },
-      { label: '采集市场趋势',   path: '/api/schedule/sorftime-schedule/trigger-category-trends' },
+      { label: '采集竞品产品', operation: 'shop-products.sync' },
+      { label: '采集热销产品', operation: 'top-products.sync' },
+      { label: '采集类目关键词', operation: 'category-keywords.sync' },
+      { label: '采集市场趋势', operation: 'category-trends.sync' },
     ];
 
     try {
@@ -345,7 +309,8 @@ export class ShopManagementComponent implements OnInit, OnDestroy {
         this.competitorCurrentStep = step.label;
         this.competitorLogs = [...this.competitorLogs, { type: 'info', message: `▶ 开始${step.label}` }];
         await this.collectWithSSE(
-          `${baseUrl}${step.path}`,
+          'upstream.sorftime',
+          step.operation,
           { shopId },
           entry => { this.competitorLogs = [...this.competitorLogs, entry]; }
         );
@@ -476,18 +441,7 @@ export class ShopManagementComponent implements OnInit, OnDestroy {
   }
 
   private triggerCategoryTreeUpdate(domain: string) {
-    const url = `${API_CONFIG.BASE_URL}/api/sorftime/forward`;
-    const payload = {
-      path: "/api/CategoryTree",
-      method: "POST",
-      query: { domain },
-      body: {},
-      function: "cleanCategoryTree"
-    };
-
-    // 这里注意:如果需要在请求头中加入特定的 Authorization,请根据实际情况配置。
-    // 如果 HttpApiService/拦截器 已经统一处理了认证,这里直接使用 http.post 即可。
-    this.http.post(url, payload)
+    this.cloud.run({ action: 'upstream.sorftime', payload: { operation: 'category-tree.sync', domain } })
       .pipe(takeUntil(this.destroy$))
       .subscribe({
         next: (res) => {

+ 23 - 86
src/modules/system/pages/amazon-category-list/amazon-category-list.component.ts

@@ -1,7 +1,8 @@
 import { Component, OnInit } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
-import { MSQParse } from '../../../../app/parse';
+import { firstValueFrom } from 'rxjs';
+import { DataStoreService } from '../../../../app/core/services/data-store.service';
 import { ToastService } from '../../../shared/services/toast.service';
 import { MsqDropdownComponent, MsqDropdownOption } from '../../../shared/components/msq-dropdown/msq-dropdown.component';
 import { DataTableShellComponent } from '../../../shared/components/data-table-shell/data-table-shell.component';
@@ -88,7 +89,7 @@ export class AmazonCategoryListComponent implements OnInit {
   categories: AmazonCategory[] = [];
   displayCategories: AmazonCategory[] = [];
 
-  constructor(private toastService: ToastService) {}
+  constructor(private toastService: ToastService, private dataStore: DataStoreService) {}
 
   ngOnInit() {
     this.loadData();
@@ -96,52 +97,24 @@ export class AmazonCategoryListComponent implements OnInit {
 
   async loadData() {
     try {
-      const query = new MSQParse.Query('AmazonCategory');
-      
-      // 只加载第一级数据(parentId为"0")
-      query.equalTo('parentId', '0');
-      
-      // Server-side Filtering
-      if (this.filters.domain) {
-        query.equalTo('domain', parseInt(this.filters.domain, 10));
-      }
-      if (this.filters.categoryId) {
-        query.contains('categoryId', this.filters.categoryId);
-      }
-      if (this.filters.name) {
-        query.contains('name', this.filters.name);
-      }
-      if (this.filters.cnName) {
-        query.contains('cnName', this.filters.cnName);
-      }
-      if (this.filters.nodeId) {
-        query.contains('nodeId', this.filters.nodeId);
-      }
-
-      // Count total before pagination
-      this.total = await query.count();
-
-      // Pagination
-      query.ascending('categoryId');
-      query.skip((this.currentPage - 1) * this.pageSize);
-      query.limit(this.pageSize);
-      
-      const results = await query.find();
+      const filters: Record<string, unknown> = { parentId: '0' };
+      if (this.filters.domain) filters['domain'] = parseInt(this.filters.domain, 10);
+      if (this.filters.categoryId) filters['categoryId'] = this.filters.categoryId;
+      if (this.filters.name) filters['name'] = this.filters.name;
+      if (this.filters.cnName) filters['cnName'] = this.filters.cnName;
+      if (this.filters.nodeId) filters['nodeId'] = this.filters.nodeId;
+      this.total = await firstValueFrom(this.dataStore.count('AmazonCategory', filters));
+      const results = await firstValueFrom(this.dataStore.query('AmazonCategory', filters, { orderBy: 'categoryId', skip: (this.currentPage - 1) * this.pageSize, limit: this.pageSize })) as any[];
       
       // 获取所有自有类目的categoryId列表
       const selfCategoryIds = await this.getSelfCategoryIds();
       
       this.displayCategories = await Promise.all(results.map(async (c: any) => {
-        const categoryId = c.get('categoryId') || '';
+        const categoryId = c.categoryId || '';
         const category: AmazonCategory = {
-          objectId: c.id,
+          objectId: c.objectId,
           categoryId: categoryId,
-          parentId: c.get('parentId') || '',
-          nodeId: c.get('nodeId') || '',
-          name: c.get('name') || '',
-          cnName: c.get('cnName') || '',
-          url: c.get('url') || '',
-          domain: c.get('domain') || 0,
+          parentId: c.parentId || '', nodeId: c.nodeId || '', name: c.name || '', cnName: c.cnName || '', url: c.url || '', domain: c.domain || 0,
           children: [],
           expanded: false,
           loading: false,
@@ -166,13 +139,7 @@ export class AmazonCategoryListComponent implements OnInit {
 
   async checkHasChildren(categoryId: string): Promise<boolean> {
     try {
-      const query = new MSQParse.Query('AmazonCategory');
-      query.equalTo('parentId', categoryId);
-      if (this.filters.domain) {
-        query.equalTo('domain', parseInt(this.filters.domain, 10));
-      }
-      query.limit(1);
-      const count = await query.count();
+      const count = await firstValueFrom(this.dataStore.count('AmazonCategory', { parentId: categoryId, ...(this.filters.domain ? { domain: parseInt(this.filters.domain, 10) } : {}) }));
       return count > 0;
     } catch (error) {
       console.error('Failed to check children:', error);
@@ -182,11 +149,8 @@ export class AmazonCategoryListComponent implements OnInit {
 
   async getSelfCategoryIds(): Promise<Set<string>> {
     try {
-      const query = new MSQParse.Query('SelfCategory');
-      query.limit(10000);
-      query.select('categoryId');
-      const results = await query.find();
-      return new Set(results.map((c: any) => c.get('categoryId')));
+      const results = await firstValueFrom(this.dataStore.query('SelfCategory', {}, { limit: 10000, select: ['categoryId'] }));
+      return new Set((results as any[]).map((c: any) => c.categoryId));
     } catch (error) {
       console.error('Failed to get self category ids:', error);
       return new Set();
@@ -209,30 +173,17 @@ export class AmazonCategoryListComponent implements OnInit {
     if (!category.children || category.children.length === 0) {
       category.loading = true;
       try {
-        const query = new MSQParse.Query('AmazonCategory');
-        query.equalTo('parentId', category.categoryId);
-        if (this.filters.domain) {
-          query.equalTo('domain', parseInt(this.filters.domain, 10));
-        }
-        query.ascending('categoryId');
-        query.limit(1000);
-        
-        const results = await query.find();
+        const results = await firstValueFrom(this.dataStore.query('AmazonCategory', { parentId: category.categoryId, ...(this.filters.domain ? { domain: parseInt(this.filters.domain, 10) } : {}) }, { orderBy: 'categoryId', limit: 1000 })) as any[];
         
         // 获取所有自有类目的categoryId列表
         const selfCategoryIds = await this.getSelfCategoryIds();
         
         category.children = await Promise.all(results.map(async (c: any) => {
-          const categoryId = c.get('categoryId') || '';
+          const categoryId = c.categoryId || '';
           const child: AmazonCategory = {
-            objectId: c.id,
+            objectId: c.objectId,
             categoryId: categoryId,
-            parentId: c.get('parentId') || '',
-            nodeId: c.get('nodeId') || '',
-            name: c.get('name') || '',
-            cnName: c.get('cnName') || '',
-            url: c.get('url') || '',
-            domain: c.get('domain') || 0,
+            parentId: c.parentId || '', nodeId: c.nodeId || '', name: c.name || '', cnName: c.cnName || '', url: c.url || '', domain: c.domain || 0,
             children: [],
             expanded: false,
             loading: false,
@@ -318,9 +269,7 @@ export class AmazonCategoryListComponent implements OnInit {
 
     try {
       // 再次检查是否已存在(防止并发添加)
-      const checkQuery = new MSQParse.Query('SelfCategory');
-      checkQuery.equalTo('categoryId', category.categoryId);
-      const existing = await checkQuery.first();
+      const existing = (await firstValueFrom(this.dataStore.query('SelfCategory', { categoryId: category.categoryId }, { limit: 1 })) as any[])[0];
       
       if (existing) {
         this.toastService.warning('该类目已存在于自有类目中');
@@ -332,19 +281,7 @@ export class AmazonCategoryListComponent implements OnInit {
       const isLeaf = !category.hasChildren;
 
       // 创建新的自有类目
-      const SelfCategory = MSQParse.Object.extend('SelfCategory');
-      const selfCategory = new SelfCategory();
-      
-      selfCategory.set('categoryId', category.categoryId);
-      selfCategory.set('parentId', category.parentId);
-      selfCategory.set('nodeId', category.nodeId);
-      selfCategory.set('name', category.name);
-      selfCategory.set('cnName', category.cnName);
-      selfCategory.set('url', category.url);
-      selfCategory.set('domain', category.domain);
-      selfCategory.set('isLeaf', isLeaf);
-      
-      await selfCategory.save();
+      await firstValueFrom(this.dataStore.save('SelfCategory', { categoryId: category.categoryId, parentId: category.parentId, nodeId: category.nodeId, name: category.name, cnName: category.cnName, url: category.url, domain: category.domain, isLeaf }));
       category.existsInSelf = true;
       this.toastService.success(`${category.name}类目成功添加为自有类目`);
     } catch (error) {

+ 5 - 5
src/modules/system/pages/api-management/api-management.component.html

@@ -2,7 +2,7 @@
   <app-page-header
     title="API 管理"
     eyebrow="System / API"
-    description="查看国内 VOC 本地后端的 AI 网关配置与实时连通状态。"
+    description="查看国内 VOC Cloud Function 的 AI 网关配置与实时连通状态。"
     icon="◇"
     [crumbs]="crumbs">
     <app-neu-button pageHeaderActions size="sm" (btnClick)="loadStatus()" [disabled]="loading">刷新状态</app-neu-button>
@@ -22,7 +22,7 @@
       [value]="status?.configured ? '已配置' : '未配置'"
       icon="✦"
       [tone]="status?.configured ? 'green' : 'amber'"
-      description="密钥仅保存在本地后端">
+      description="密钥仅保存在 Cloud Function 环境">
     </app-summary-metric-card>
     <app-summary-metric-card
       label="默认模型"
@@ -40,12 +40,12 @@
     </app-summary-metric-card>
   </div>
 
-  <app-content-card title="AI 分析网关" subtitle="浏览器请求本地代理,由服务端转发到 Fmode AI" tone="blue">
+  <app-content-card title="AI 分析网关" subtitle="浏览器调用白名单 Cloud action,由服务端转发到 Fmode AI" tone="blue">
     <div class="gateway-layout">
       <dl class="gateway-fields">
         <div><dt>服务名称</dt><dd>{{ status?.service || (loading ? '读取中...' : '-') }}</dd></div>
         <div><dt>上游地址</dt><dd>{{ status?.baseUrl || '-' }}</dd></div>
-        <div><dt>本地代理</dt><dd>{{ status?.proxyEndpoint || '/api/ai/chat/completions' }}</dd></div>
+        <div><dt>Cloud action</dt><dd>{{ status?.proxyEndpoint || 'ai.chat' }}</dd></div>
         <div><dt>默认模型</dt><dd>{{ modelLabel(status?.defaultModel) }}</dd></div>
         <div><dt>服务端密钥</dt><dd>{{ status?.configured ? '已配置' : '未配置' }}</dd></div>
       </dl>
@@ -69,7 +69,7 @@
 
   <app-content-card title="链路边界" subtitle="国内版采用服务端代理,不向浏览器暴露上游凭证" tone="green" variant="dashed">
     <div class="request-flow" aria-label="AI 请求链路">
-      <span>竞品评论</span><i>→</i><span>共享 AI 组件</span><i>→</i><span>本地后端</span><i>→</i><span>Fmode AI</span>
+      <span>竞品评论</span><i>→</i><span>共享 AI 组件</span><i>→</i><span>Cloud Function</span><i>→</i><span>Fmode AI</span>
     </div>
   </app-content-card>
 </section>

+ 1 - 1
src/modules/system/pages/api-management/api-management.component.ts

@@ -46,7 +46,7 @@ export class ApiManagementComponent implements OnInit {
     this.error = '';
     this.gateway.status().pipe(finalize(() => this.loading = false)).subscribe({
       next: (status) => this.status = status,
-      error: () => this.error = '无法连接本地后端,请确认 saas-voc-server 已启动',
+      error: () => this.error = '无法调用 AI Cloud action,请检查 Parse Function 部署状态',
     });
   }
 

+ 16 - 45
src/modules/system/pages/self-category-management/self-category-management.component.ts

@@ -1,7 +1,8 @@
 import { Component, OnInit } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
-import { MSQParse } from '../../../../app/parse';
+import { firstValueFrom } from 'rxjs';
+import { DataStoreService } from '../../../../app/core/services/data-store.service';
 import { ToastService } from '../../../shared/services/toast.service';
 import { MsqDropdownComponent, MsqDropdownOption } from '../../../shared/components/msq-dropdown/msq-dropdown.component';
 import { DataTableShellComponent } from '../../../shared/components/data-table-shell/data-table-shell.component';
@@ -89,7 +90,7 @@ export class SelfCategoryManagementComponent implements OnInit {
   categories: SelfCategory[] = [];
   displayCategories: SelfCategory[] = [];
 
-  constructor(private toastService: ToastService) {}
+  constructor(private toastService: ToastService, private dataStore: DataStoreService) {}
 
   ngOnInit() {
     this.loadData();
@@ -97,48 +98,20 @@ export class SelfCategoryManagementComponent implements OnInit {
 
   async loadData() {
     try {
-      const query = new MSQParse.Query('SelfCategory');
-      
-      // Server-side Filtering
-      if (this.filters.domain) {
-        query.equalTo('domain', parseInt(this.filters.domain, 10));
-      }
-      if (this.filters.categoryId) {
-        query.contains('categoryId', this.filters.categoryId);
-      }
-      if (this.filters.name) {
-        query.contains('name', this.filters.name);
-      }
-      if (this.filters.cnName) {
-        query.contains('cnName', this.filters.cnName);
-      }
-      if (this.filters.nodeId) {
-        query.contains('nodeId', this.filters.nodeId);
-      }
-      if (this.filters.isLeaf !== '') {
-        query.equalTo('isLeaf', this.filters.isLeaf === 'true');
-      }
-
-      // Count total before pagination
-      this.total = await query.count();
-
-      // Pagination
-      query.descending('createdAt');
-      query.skip((this.currentPage - 1) * this.pageSize);
-      query.limit(this.pageSize);
-      
-      const results = await query.find();
+      const filters: Record<string, unknown> = {};
+      if (this.filters.domain) filters['domain'] = parseInt(this.filters.domain, 10);
+      if (this.filters.categoryId) filters['categoryId'] = this.filters.categoryId;
+      if (this.filters.name) filters['name'] = this.filters.name;
+      if (this.filters.cnName) filters['cnName'] = this.filters.cnName;
+      if (this.filters.nodeId) filters['nodeId'] = this.filters.nodeId;
+      if (this.filters.isLeaf !== '') filters['isLeaf'] = this.filters.isLeaf === 'true';
+      this.total = await firstValueFrom(this.dataStore.count('SelfCategory', filters));
+      const results = await firstValueFrom(this.dataStore.query('SelfCategory', filters, { orderBy: 'createdAt', descending: true, skip: (this.currentPage - 1) * this.pageSize, limit: this.pageSize })) as any[];
       
       this.displayCategories = results.map((c: any) => ({
-        objectId: c.id,
-        categoryId: c.get('categoryId') || '',
-        parentId: c.get('parentId') || '',
-        nodeId: c.get('nodeId') || '',
-        name: c.get('name') || '',
-        cnName: c.get('cnName') || '',
-        url: c.get('url') || '',
-        domain: c.get('domain') || 0,
-        isLeaf: c.get('isLeaf') || false
+        objectId: c.objectId,
+        categoryId: c.categoryId || '', parentId: c.parentId || '', nodeId: c.nodeId || '',
+        name: c.name || '', cnName: c.cnName || '', url: c.url || '', domain: c.domain || 0, isLeaf: c.isLeaf || false
       }));
       
       this.categories = this.displayCategories;
@@ -186,9 +159,7 @@ export class SelfCategoryManagementComponent implements OnInit {
 
   async deleteCategory(category: SelfCategory) {
     try {
-      const query = new MSQParse.Query('SelfCategory');
-      const obj = await query.get(category.objectId);
-      await obj.destroy();
+      await firstValueFrom(this.dataStore.delete('SelfCategory', category.objectId));
       
       this.toastService.success(`${category.name}类目已从自有类目移除`);
       

+ 4 - 7
src/modules/system/pages/store-product-list/store-product-list.component.ts

@@ -9,6 +9,7 @@ import { LoadingSpinnerComponent } from '../../../shared/components/loading-spin
 import { ProductIdentityComponent } from '../../../shared/components/product-identity/product-identity.component';
 import { SummaryMetricCardComponent } from '../../../shared/components/summary-metric-card/summary-metric-card.component';
 import { ContentCardComponent } from '../../../shared/components/content-card/content-card.component';
+import { CloudApiService } from '../../../../app/core/services/cloud-api.service';
 
 interface StoreProduct {
   objectId: string;
@@ -115,7 +116,8 @@ export class StoreProductListComponent implements OnInit {
 
   constructor(
     private toastService: ToastService,
-    private permissionService: PermissionService
+    private permissionService: PermissionService,
+    private cloud: CloudApiService
   ) {}
 
   async ngOnInit() {
@@ -207,12 +209,7 @@ export class StoreProductListComponent implements OnInit {
     this.toastService.info('正在触发易仓数据同步,请稍候...');
 
     try {
-      const res = await fetch('/api/eccang/listing/sync', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ page_size: 100 }),
-      });
-      const data = await res.json();
+      const data = await this.cloud.runPromise<any>({ action: 'upstream.amazon', payload: { operation: 'listing.sync', pageSize: 100 } });
       if (data.success) {
         this.toastService.success(`同步完成!共 ${data.stats?.pagesSynced || 0} 页,新增 ${data.stats?.created || 0},更新 ${data.stats?.updated || 0}`);
         this.loadData();

+ 3 - 3
src/modules/validation-center/validation-center.data.ts

@@ -2,16 +2,16 @@ import { EMPTY, Observable, expand, map, of, takeLast } from 'rxjs';
 import { CursorPage } from '../../app/core/services/saas-platform.service';
 
 export function findCursorPageItem<T extends { id: string }>(
-  fetch: (cursor: string) => Observable<CursorPage<T>>,
+  loadPage: (cursor: string) => Observable<CursorPage<T>>,
   id: string,
 ): Observable<T | null> {
   const target = id.trim();
   if (!target) return of(null);
-  return fetch('').pipe(
+  return loadPage('').pipe(
     expand((page) => (
       page.items.some((item) => item.id === target) || !page.nextCursor
         ? EMPTY
-        : fetch(page.nextCursor)
+        : loadPage(page.nextCursor)
     )),
     takeLast(1),
     map((page) => page.items.find((item) => item.id === target) ?? null),

+ 15 - 2
src/modules/voc-insight/topic-explorer/topic-explorer.component.html

@@ -121,7 +121,7 @@
       </div>
     </section>
 
-    <section class="topic-workbench">
+    <section class="topic-workbench" [class.has-detail]="selectedTopic">
       <div class="topic-list-panel">
         <div class="table-scroll" *ngIf="visibleTopics.length; else emptyTopics">
           <table class="topic-table">
@@ -177,7 +177,8 @@
                     title="进入反馈收件箱审阅首条证据" (click)="openEvidence(topic, $event)">
                     <lucide-icon name="arrow-up-right" [size]="15"></lucide-icon><span>审阅</span>
                   </button>
-                  <button class="row-open" type="button" title="进入主题详情" aria-label="进入主题详情" (click)="selectTopic(topic); $event.stopPropagation()">
+                  <button class="row-open" type="button" title="进入完整主题详情页" aria-label="进入完整主题详情页"
+                    (click)="openDetailPage(topic); $event.stopPropagation()">
                     <span>进入</span>
                     <lucide-icon name="chevron-right" [size]="16"></lucide-icon>
                   </button>
@@ -202,6 +203,18 @@
         </footer>
       </div>
 
+      <aside class="detail-panel" *ngIf="selectedTopic as topic; else selectionPlaceholder">
+        <app-topic-detail [topic]="topic" (close)="closeDetail()"></app-topic-detail>
+      </aside>
+
+      <ng-template #selectionPlaceholder>
+        <aside class="selection-placeholder">
+          <span><lucide-icon name="message-square" [size]="20"></lucide-icon></span>
+          <strong>选择一个主题查看详情</strong>
+          <p>点击左侧任一主题后,这里会展开趋势、证据、影响商品和原始评价。</p>
+        </aside>
+      </ng-template>
+
     </section>
 
     <footer class="data-boundary">

+ 7 - 1
src/modules/voc-insight/topic-explorer/topic-explorer.component.ts

@@ -28,6 +28,7 @@ import {
 import { Subject, takeUntil } from 'rxjs';
 import { PageHeaderComponent, PageHeaderCrumb } from '../../shared/components/page-header/page-header.component';
 import { PageToolbarComponent } from '../../shared/components/page-toolbar/page-toolbar.component';
+import { TopicDetailComponent } from './topic-detail.component';
 import { TopicExplorerDataService } from './topic-explorer-data.service';
 import {
   TopicAvailability,
@@ -51,7 +52,7 @@ interface ViewOption {
 @Component({
   selector: 'app-topic-explorer',
   standalone: true,
-  imports: [CommonModule, FormsModule, LucideAngularModule, PageHeaderComponent, PageToolbarComponent],
+  imports: [CommonModule, FormsModule, LucideAngularModule, PageHeaderComponent, PageToolbarComponent, TopicDetailComponent],
   providers: [
     TopicExplorerDataService,
     {
@@ -232,6 +233,11 @@ export class TopicExplorerComponent implements OnInit, OnDestroy {
   }
 
   selectTopic(topic: UnifiedVocTopic): void {
+    this.selectedTopicId = topic.id;
+    this.syncUrl();
+  }
+
+  openDetailPage(topic: UnifiedVocTopic): void {
     void this.router.navigate(['/voc-insight/topics', topic.id], {
       queryParams: {
         source: this.sourceScope === 'all' ? null : this.sourceScope,

+ 20 - 2
src/modules/voc-insight/voc-insight.component.ts

@@ -24,6 +24,7 @@ import { ProductIdentityComponent } from '../shared/components/product-identity/
 export class VocInsightComponent implements OnInit, OnDestroy {
   private destroy$ = new Subject<void>();
   private loadRequests$ = new Subject<ProductListParams>();
+  private secondaryInsightRun = 0;
 
   // 产品搜索和选择
   searchQuery = '';
@@ -131,8 +132,7 @@ export class VocInsightComponent implements OnInit, OnDestroy {
           this.totalPages = response.totalPages;
           this.currentPage = response.page;
           this.isSearching = false;
-          this.loadHealthScores(response.products);
-          this.loadPriorityIssues(response.products);
+          this.scheduleSecondaryInsights(response.products);
         },
         error: () => {
           this.isSearching = false;
@@ -342,6 +342,24 @@ export class VocInsightComponent implements OnInit, OnDestroy {
     });
   }
 
+  private scheduleSecondaryInsights(products: ProductInfo[]): void {
+    const runId = ++this.secondaryInsightRun;
+    const start = () => {
+      if (runId !== this.secondaryInsightRun) return;
+      this.loadHealthScores(products);
+      this.loadPriorityIssues(products);
+    };
+    const idleWindow = window as Window & {
+      requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
+    };
+
+    if (idleWindow.requestIdleCallback) {
+      idleWindow.requestIdleCallback(start, { timeout: 700 });
+      return;
+    }
+    window.setTimeout(start, 0);
+  }
+
   // E1-3: 应用筛选
   applyFilters(): void {
     this.syncFilterState();

+ 11 - 5
src/runtime-config.js

@@ -1,10 +1,16 @@
 (function (window) {
-  window.__SAAS_VOC_CONFIG__ = Object.assign({
+  var hostname = window.location && window.location.hostname;
+  var isLocal = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
+  var defaults = {
+    parseServerUrl: isLocal ? '/parse' : 'https://server.fmode.cn/backend/vO3T8gAdwB/data',
+    parseAppId: isLocal ? 'saas-voc-local' : 'vO3T8gAdwB_main',
     apiBaseUrl: '',
-    apiAuthMode: 'disabled',
+    apiAuthMode: isLocal ? 'disabled' : 'parse',
     domesticDatasetMode: 'api',
-    domesticVocPath: '/api/domestic-voc',
-    saasPath: '/api/saas',
+    cloudFunctionPath: '/saas-voc-gateway',
     domesticWorkspaceId: 'demashi',
-  }, window.__SAAS_VOC_CONFIG__ || {});
+  };
+  // localhost uses the account-free local adapter. Deployments remain on the
+  // authenticated managed Function unless the host injects an explicit config.
+  window.__SAAS_VOC_CONFIG__ = Object.assign(defaults, window.__SAAS_VOC_CONFIG__ || {});
 })(window);

Некоторые файлы не были показаны из-за большого количества измененных файлов