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

feat: display persisted competitor products

gangvy 1 месяц назад
Родитель
Сommit
02318551a2

+ 5 - 3
README.md

@@ -2,7 +2,7 @@
 
 基于现有 `msq-voc-web` 前端框架打包出的国内电商 VOC 通用模板,首个案例为德玛仕京东商品。项目保留原系统的 Angular App Shell、顶部搜索、侧边导航、共享组件、VOC 页面结构和 ECharts 能力,只替换最小闭环所需的数据入口与国内商品语义。
 
-> 当前状态(2026-07-23):国内 VOC 前端框架已经收口。原项目 App Shell、共享组件体系、经营分析、市场与竞品、VOC 监测、退款分析、新品开发、生命周期复盘和行动建议均已安全迁入;已启用页面统一使用国内商品语义和真实数据状态
+> 当前状态(2026-07-24):国内 VOC 前端框架已经收口。原项目 App Shell、共享组件体系、经营分析、市场与竞品、VOC 监测、退款分析、新品开发、生命周期复盘和行动建议均已安全迁入;默认从国内 VOC 后端读取 Parse 中的真实数据,静态案例仅作为显式回退模式
 
 ## 迁入基线
 
@@ -70,6 +70,8 @@ E:\xwechat_files\wxid_ay08t6ugo2h922_df4f\msg\file\2026-07\德玛仕产品及竟
 
 规范化结果位于 `src/assets/data/demashi-summary.json`。
 
+当前真实数据链为 `德玛仕 Excel -> Parse REST -> saas-voc-server -> 前端`。Parse 中已保存 2,817 个自有商品、37 个竞品商品、9,717 条日指标和 40 条竞品关系;37 个竞品商品详情均已通过公司电商中转完成回填。评论正文仍为真实的 0 条,因此 VOC 评论分析继续展示可解释空状态。
+
 ## 复用策略
 
 国内商品的标准身份是 `platform + productId`,唯一键为 `platform:productId`。原框架大量组件使用 `asin` 表示商品唯一字符串,为控制首版改动量,适配层暂时提供 `asin = productId`:
@@ -146,7 +148,7 @@ npm run import:demashi -- "E:\xwechat_files\wxid_ay08t6ugo2h922_df4f\msg\file\20
 - 移动端:29 个启用入口无页面级横向溢出,导航抽屉展开/收起、标题和控件布局通过
 - 浏览器控制台:启用入口无 Angular 运行时错误、警告或失败请求
 - 运行时请求:当前激活闭环请求同源 `/api/domestic-voc`,本地后端返回 2,817 个商品、9,717 条日指标和 40 条竞品关系,没有访问旧 Parse 或旧跨境域名
-- `npm audit --omit=dev`:17 项原框架依赖公告(1 critical、11 high、5 moderate),正式公网部署前必须升级处理
+- `npm audit --omit=dev`:13 项原框架依赖公告(0 critical、10 high、3 moderate),正式公网部署前必须升级 Angular、ECharts 并替换无修复版本的 `xlsx`
 
 ## 尚未完成
 
@@ -155,7 +157,7 @@ npm run import:demashi -- "E:\xwechat_files\wxid_ay08t6ugo2h922_df4f\msg\file\20
 - 将商品、日指标、竞品关系和评论幂等写入新 Parse 应用
 - 将 `/api/domestic-voc` 从本地快照实现切换为新 PostgreSQL / Parse 持久化实现
 - 部署独立环境并完成端到端验收
-- 升级原框架的 Angular 17、`xlsx`、ECharts、Parse 依赖基线并处理安全公告
+- 升级原框架的 Angular 17、ECharts、Parse 依赖基线,并替换无修复版本的 `xlsx`
 
 完整任务顺序见 [TASKS.md](TASKS.md)。
 

+ 3 - 1
TASKS.md

@@ -32,6 +32,8 @@
 - [x] 完成 29 个启用入口的桌面与移动端响应式验收
 - [x] 修复类目 VOC 首次加载的 Angular 变更检测错误
 - [x] 完成构建、单测和数据完整性校验
+- [x] 将前端默认数据模式切换为国内 VOC 后端 API,静态案例仅保留为显式回退
+- [x] 从 Parse 快照读取 2,817 个自有商品和 37 个竞品商品,并展示真实竞品标题、型号及入库状态
 
 ## 周六前 P0
 
@@ -58,7 +60,7 @@
 ## 后续 P1
 
 - [ ] 升级到受支持的 Angular 版本并完成全量回归
-- [ ] 处理 `npm audit` 中原框架遗留的 78 项公告;其中生产依赖 17 项(1 critical、11 high、5 moderate)
+- [ ] 处理生产依赖剩余的 13 项公告(0 critical、10 high、3 moderate),包括 Angular/ECharts 大版本升级及 `xlsx` 替换
 - [ ] 按实际国内功能边界裁剪未挂路由的跨境模块
 - [ ] 将 `asin` 兼容字段逐步替换为 `productId`
 - [ ] 增加评论采集任务状态、失败重试和导入批次页面

+ 1 - 1
src/app/core/config/runtime-config.ts

@@ -27,7 +27,7 @@ export const RUNTIME_CONFIG: Readonly<SaasVocRuntimeConfig> = Object.freeze({
   apiAuthMode: browserConfig.apiAuthMode === 'parse' ? 'parse' : 'disabled',
   vocEcommercePath: browserConfig.vocEcommercePath || '/api/voc-e-commerce',
   saasPath: browserConfig.saasPath || '/api/saas',
-  domesticDatasetMode: browserConfig.domesticDatasetMode === 'api' ? 'api' : 'case',
+  domesticDatasetMode: browserConfig.domesticDatasetMode === 'case' ? 'case' : 'api',
   domesticVocPath: browserConfig.domesticVocPath || '/api/domestic-voc',
   domesticWorkspaceId: browserConfig.domesticWorkspaceId || 'demashi',
   domesticPlatform: 'jd',

+ 14 - 4
src/app/core/services/domestic-analytics.service.ts

@@ -26,6 +26,10 @@ export interface DomesticCompetitorRelationRow extends DomesticProductRelation {
   ownModel: string;
   ownTitle: string;
   ownProductAvailable: boolean;
+  competitorProduct?: DomesticProduct;
+  competitorModel: string;
+  competitorTitle: string;
+  competitorProductAvailable: boolean;
 }
 
 export interface DomesticOverview {
@@ -107,16 +111,17 @@ export class DomesticAnalyticsService {
 
   readonly overview$ = this.dataset.dataset$.pipe(
     map((dataset): DomesticOverview => {
+      const ownProducts = dataset.products.filter((product) => product.role === 'own');
       const first = dataset.dailyTotals[0]?.gmv || 0;
       const last = dataset.dailyTotals.at(-1)?.gmv || 0;
       return {
         dataset,
         totals: aggregateDomesticMetrics(dataset.dailyTotals),
         gmvTrend: first ? (last - first) / first : 0,
-        topProducts: [...dataset.products]
+        topProducts: [...ownProducts]
           .sort((a, b) => b.summary.gmv - a.summary.gmv || b.summary.soldUnits - a.summary.soldUnits)
           .slice(0, 10),
-        topCategories: buildDomesticCategorySummaries(dataset.products, 'category2').slice(0, 8),
+        topCategories: buildDomesticCategorySummaries(ownProducts, 'category2').slice(0, 8),
       };
     }),
     shareReplay({ bufferSize: 1, refCount: false }),
@@ -124,8 +129,8 @@ export class DomesticAnalyticsService {
 
   readonly categoryAnalysis$ = this.dataset.dataset$.pipe(
     map((dataset): DomesticCategoryAnalysis => ({
-      category2: buildDomesticCategorySummaries(dataset.products, 'category2'),
-      category3: buildDomesticCategorySummaries(dataset.products, 'category3'),
+      category2: buildDomesticCategorySummaries(dataset.products.filter((product) => product.role === 'own'), 'category2'),
+      category3: buildDomesticCategorySummaries(dataset.products.filter((product) => product.role === 'own'), 'category3'),
       relationCount: dataset.relations.length,
     })),
     shareReplay({ bufferSize: 1, refCount: false }),
@@ -147,6 +152,7 @@ export class DomesticAnalyticsService {
       const mappingGroups = new Map(dataset.mappingGroups.map((group) => [group.ownProductId, group]));
       return dataset.relations.map((relation) => {
         const ownProduct = products.get(relation.ownProductId);
+        const competitorProduct = products.get(relation.competitorProductId);
         const mapping = mappingGroups.get(relation.ownProductId);
         return {
           ...relation,
@@ -154,6 +160,10 @@ export class DomesticAnalyticsService {
           ownModel: ownProduct?.model || mapping?.model || relation.ownProductId,
           ownTitle: ownProduct?.title || mapping?.model || relation.ownProductId,
           ownProductAvailable: !!ownProduct,
+          competitorProduct,
+          competitorModel: competitorProduct?.model || relation.competitorProductId,
+          competitorTitle: competitorProduct?.title || relation.competitorBrand || relation.competitorProductId,
+          competitorProductAvailable: !!competitorProduct && competitorProduct.source === 'fmode_gateway' && Boolean(competitorProduct.title),
         };
       });
     }),

+ 14 - 2
src/modules/domestic-voc/competitor-relations.component.ts

@@ -53,9 +53,19 @@ import { VocInsightStateService } from '../shared/services/voc-insight-state.ser
                     </app-product-identity>
                   </td>
                   <td>{{ row.category }}</td>
-                  <td>{{ row.competitorBrand }}</td>
+                  <td>
+                    <div class="competitor-cell">
+                      <strong>{{ row.competitorBrand || row.competitorProduct?.brand || '未知品牌' }}</strong>
+                      <span>{{ row.competitorTitle }}</span>
+                      <small>{{ row.competitorModel }}</small>
+                    </div>
+                  </td>
                   <td>{{ row.competitorProductId }}</td>
-                  <td><span class="status-pill">待采集商品与评论</span></td>
+                  <td>
+                    <span class="status-pill" [class.success]="row.competitorProductAvailable">
+                      {{ row.competitorProductAvailable ? '商品详情已入库' : '待采集商品详情' }}
+                    </span>
+                  </td>
                 </tr>
               }
             </tbody>
@@ -108,6 +118,8 @@ export class DomesticCompetitorRelationsComponent implements OnInit, OnDestroy {
       row.ownTitle,
       row.competitorProductId,
       row.competitorBrand,
+      row.competitorTitle,
+      row.competitorModel,
       row.category,
     ].join(' ').toLowerCase().includes(query));
     this.cdr.markForCheck();

+ 25 - 0
src/modules/domestic-voc/domestic-page.shared.scss

@@ -237,6 +237,31 @@
   color: #1d4ed8;
 }
 
+.competitor-cell {
+  display: grid;
+  min-width: 220px;
+  gap: 3px;
+}
+
+.competitor-cell strong {
+  color: #111827;
+  font-size: 12px;
+}
+
+.competitor-cell span {
+  overflow: hidden;
+  max-width: 360px;
+  color: #374151;
+  font-size: 12px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.competitor-cell small {
+  color: #94a3b8;
+  font-size: 11px;
+}
+
 .dependency-list {
   display: grid;
   gap: 12px;