|
|
@@ -0,0 +1,1771 @@
|
|
|
+import { Component, OnInit, ViewChild, signal, computed } from '@angular/core';
|
|
|
+import { CommonModule, Location } from '@angular/common';
|
|
|
+import { ActivatedRoute } from '@angular/router';
|
|
|
+import { FormsModule } from '@angular/forms';
|
|
|
+import { DataStoreService } from '../../../app/core/services/data-store.service';
|
|
|
+import { SorftimeApiService } from '../../../app/core/services/sorftime-api.service';
|
|
|
+import { catchError, of, forkJoin } from 'rxjs';
|
|
|
+import { ProductVariationsComponent } from './product-variations/product-variations.component';
|
|
|
+import { ProductVariationHistoryComponent } from './product-variation-history/product-variation-history.component';
|
|
|
+import { ProductFeatureAnalysisComponent } from './product-feature-analysis/product-feature-analysis.component';
|
|
|
+import { ProductSpecsComponent } from './product-specs/product-specs.component';
|
|
|
+import { AiAnalysisPanelComponent } from '../../shared/components/ai-analysis-panel/ai-analysis-panel.component';
|
|
|
+import { ReviewCardComponent } from '../../shared/components/review-card/review-card.component';
|
|
|
+import { ReviewDetailModalComponent } from '../../shared/components/review-detail-modal/review-detail-modal.component';
|
|
|
+import { AiChatModalComponent } from '../../shared/components/ai-chat-modal/ai-chat-modal.component';
|
|
|
+import { MonitoringDataService } from '../../shared/services/monitoring-data.service';
|
|
|
+import { ToastService } from '../../shared/services/toast.service';
|
|
|
+import { PageHeaderComponent } from '../../shared/components/page-header/page-header.component';
|
|
|
+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';
|
|
|
+
|
|
|
+@Component({
|
|
|
+ selector: 'app-competitor-detail',
|
|
|
+ standalone: true,
|
|
|
+ imports: [CommonModule, FormsModule, ProductVariationsComponent, ProductVariationHistoryComponent, ProductFeatureAnalysisComponent, ProductSpecsComponent, AiAnalysisPanelComponent, ReviewCardComponent, ReviewDetailModalComponent, AiChatModalComponent, PageHeaderComponent, LoadingSpinnerComponent, EmptyStateComponent, ContentCardComponent],
|
|
|
+ template: `
|
|
|
+ <div class="competitor-detail-container">
|
|
|
+ <!-- Header -->
|
|
|
+ <app-page-header
|
|
|
+ title="竞品详情分析"
|
|
|
+ eyebrow="Knowledge Center"
|
|
|
+ description="竞品产品详细数据、变体、特征与评论分析"
|
|
|
+ icon="🔍">
|
|
|
+ <div pageHeaderActions class="header-actions">
|
|
|
+ <button class="back-btn" (click)="goBack()">
|
|
|
+ <span class="back-icon">←</span> 返回库
|
|
|
+ </button>
|
|
|
+ <button class="btn-refresh" (click)="refreshData()" [disabled]="isRefreshing">
|
|
|
+ <span class="refresh-icon" [class.spinning]="isRefreshing">↻</span>
|
|
|
+ {{ isRefreshing ? '刷新中...' : '刷新数据' }}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </app-page-header>
|
|
|
+
|
|
|
+ @if (loading) {
|
|
|
+ <app-loading-spinner text="正在加载竞品数据..."></app-loading-spinner>
|
|
|
+ }
|
|
|
+
|
|
|
+ @if (!loading && product) {
|
|
|
+ <div class="detail-layout">
|
|
|
+ <!-- Left: Product Basic Info -->
|
|
|
+ <div class="info-card basic-info product-rail">
|
|
|
+ <div class="product-image rail-image">
|
|
|
+ @if (product.photo && product.photo.length) {
|
|
|
+ <img [src]="product.photo[0]" [alt]="product.title" class="main-image">
|
|
|
+ } @else {
|
|
|
+ <div class="image-placeholder">📷</div>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ <div class="product-title">{{ product.title || '未知产品' }}</div>
|
|
|
+ <div class="product-asin">ASIN: {{ product.asin }}</div>
|
|
|
+ <div class="product-brand">{{ product.brand || '未知品牌' }}</div>
|
|
|
+ <div class="product-badges">
|
|
|
+ @for (badge of product.productBadge; track badge) {
|
|
|
+ <span class="badge">{{ badge }}</span>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ <div class="rail-meta-list">
|
|
|
+ <div class="rail-meta-row">
|
|
|
+ <span>BSR类目</span>
|
|
|
+ <strong>{{ getBsrCategoryName(product.bsrCategory) }}</strong>
|
|
|
+ </div>
|
|
|
+ <div class="rail-meta-row">
|
|
|
+ <span>BuyBox</span>
|
|
|
+ <strong>{{ product.buyboxSeller || '-' }}</strong>
|
|
|
+ </div>
|
|
|
+ <div class="rail-meta-row">
|
|
|
+ <span>发货地</span>
|
|
|
+ <strong>{{ product.shipsFrom || '-' }}</strong>
|
|
|
+ </div>
|
|
|
+ <div class="rail-meta-row">
|
|
|
+ <span>最后更新</span>
|
|
|
+ <strong>{{ formatDateTime(product.dataUpdatedAt) || product.updateDate || '-' }}</strong>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="rail-actions">
|
|
|
+ <button type="button" class="rail-action primary" (click)="openReviewsModal()">查看评论</button>
|
|
|
+ <button type="button" class="rail-action" (click)="refreshData()" [disabled]="isRefreshing">
|
|
|
+ {{ isRefreshing ? '刷新中' : '刷新数据' }}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <a class="rail-external" [href]="'https://www.amazon.com/dp/' + asin" target="_blank" rel="noopener">
|
|
|
+ 打开 Amazon 商品页 ↗
|
|
|
+ </a>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Right: Product Details -->
|
|
|
+ <div class="details-section">
|
|
|
+ <div class="detail-overview-card" id="detail-overview">
|
|
|
+ <div class="overview-copy">
|
|
|
+ <span class="overview-eyebrow">Competitor Overview</span>
|
|
|
+ <h2>{{ product.title || '未知产品' }}</h2>
|
|
|
+ <div class="overview-meta">
|
|
|
+ <span>{{ product.brand || '未知品牌' }}</span>
|
|
|
+ <span>ASIN: {{ product.asin }}</span>
|
|
|
+ <span>{{ getBsrCategoryName(product.bsrCategory) }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <nav class="detail-tabs" aria-label="竞品详情导航">
|
|
|
+ <button type="button" [class.active]="activeDetailSection() === 'detail-overview'" (click)="selectDetailSection('detail-overview')">概览</button>
|
|
|
+ <button type="button" [class.active]="activeDetailSection() === 'detail-profit'" (click)="selectDetailSection('detail-profit')">利润</button>
|
|
|
+ <button type="button" [class.active]="activeDetailSection() === 'detail-reviews'" (click)="selectDetailSection('detail-reviews')">评价</button>
|
|
|
+ <button type="button" [class.active]="activeDetailSection() === 'detail-features'" (click)="selectDetailSection('detail-features')">特征</button>
|
|
|
+ <button type="button" [class.active]="activeDetailSection() === 'detail-variants'" (click)="selectDetailSection('detail-variants')">变体</button>
|
|
|
+ <button type="button" [class.active]="activeDetailSection() === 'detail-specs'" (click)="selectDetailSection('detail-specs')">规格</button>
|
|
|
+ </nav>
|
|
|
+ </div>
|
|
|
+ <div class="detail-tab-panels">
|
|
|
+ <section class="detail-tab-panel" [hidden]="activeDetailSection() !== 'detail-overview'">
|
|
|
+ <!-- Key Metrics -->
|
|
|
+ <app-content-card class="metrics-card" title="关键指标">
|
|
|
+ <div class="metrics-grid">
|
|
|
+ <div class="metric-item">
|
|
|
+ <span class="metric-label">价格</span>
|
|
|
+ <span class="metric-value price">\${{ formatPrice(product.salesPrice || product.price) }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="metric-item clickable" (click)="openReviewsModal()">
|
|
|
+ <span class="metric-label">评分</span>
|
|
|
+ <span class="metric-value rating">★ {{ product.ratings || 0 }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="metric-item clickable" (click)="openReviewsModal()">
|
|
|
+ <span class="metric-label">评价数</span>
|
|
|
+ <span class="metric-value">{{ formatNumber(product.ratingsCount || 0) }}</span>
|
|
|
+ <span class="metric-hint">点击查看评论</span>
|
|
|
+ </div>
|
|
|
+ <div class="metric-item">
|
|
|
+ <span class="metric-label">月销量</span>
|
|
|
+ <span class="metric-value">{{ formatNumber(product.listingSalesVolumeOfMonth || 0) }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="metric-item">
|
|
|
+ <span class="metric-label">BSR排名</span>
|
|
|
+ <span class="metric-value">{{ product.rank > 0 ? '#' + product.rank : '-' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="metric-item">
|
|
|
+ <span class="metric-label">上架日期</span>
|
|
|
+ <span class="metric-value">{{ product.onlineDate || '-' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="metric-item">
|
|
|
+ <span class="metric-label">FBA</span>
|
|
|
+ <span class="metric-value">{{ product.isFBA ? '是' : '否' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="metric-item">
|
|
|
+ <span class="metric-label">卖家数</span>
|
|
|
+ <span class="metric-value">{{ product.sellerCount || 0 }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </app-content-card>
|
|
|
+
|
|
|
+ <div class="detail-card-grid overview-info-grid">
|
|
|
+ <!-- Category Info -->
|
|
|
+ <app-content-card class="category-card" title="类目信息" density="compact">
|
|
|
+ <div class="category-list">
|
|
|
+ @if (product.category && product.category.length) {
|
|
|
+ <div class="category-item">
|
|
|
+ <span class="category-label">产品类目</span>
|
|
|
+ <span class="category-value">{{ getCategoryName(product.category) }}</span>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ @if (product.bsrCategory && product.bsrCategory.length) {
|
|
|
+ <div class="category-item">
|
|
|
+ <span class="category-label">BSR类目</span>
|
|
|
+ <span class="category-value">{{ getBsrCategoryName(product.bsrCategory) }}</span>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ </app-content-card>
|
|
|
+
|
|
|
+ <!-- Seller Info -->
|
|
|
+ <app-content-card class="seller-card" title="卖家信息" density="compact">
|
|
|
+ <div class="seller-info">
|
|
|
+ <div class="seller-item">
|
|
|
+ <span class="seller-label">BuyBox卖家</span>
|
|
|
+ <span class="seller-value">{{ product.buyboxSeller || '-' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="seller-item">
|
|
|
+ <span class="seller-label">发货地</span>
|
|
|
+ <span class="seller-value">{{ product.shipsFrom || '-' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="seller-item">
|
|
|
+ <span class="seller-label">店铺名称</span>
|
|
|
+ <span class="seller-value">{{ product.storeName || '-' }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </app-content-card>
|
|
|
+
|
|
|
+ <!-- Last Update -->
|
|
|
+ <app-content-card class="update-card" title="数据更新" density="compact">
|
|
|
+ <div class="update-info">
|
|
|
+ <span class="update-label">最后更新:</span>
|
|
|
+ <span class="update-value">{{ formatDateTime(product.dataUpdatedAt) || product.updateDate || '-' }}</span>
|
|
|
+ </div>
|
|
|
+ </app-content-card>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="detail-tab-panel" [hidden]="activeDetailSection() !== 'detail-profit'">
|
|
|
+ <!-- Sales & Profit -->
|
|
|
+ <app-content-card class="profit-card" title="销售利润分析">
|
|
|
+ <div class="profit-grid">
|
|
|
+ <div class="profit-item">
|
|
|
+ <span class="profit-label">售价</span>
|
|
|
+ <span class="profit-value">\${{ formatPrice(product.salesPrice) }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="profit-item">
|
|
|
+ <span class="profit-label">平台费</span>
|
|
|
+ <span class="profit-value">\${{ formatPrice(product.platformFee) }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="profit-item">
|
|
|
+ <span class="profit-label">FBA费用</span>
|
|
|
+ <span class="profit-value">\${{ formatPrice(product.fbaFee) }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="profit-item">
|
|
|
+ <span class="profit-label">物流成本</span>
|
|
|
+ <span class="profit-value">\${{ formatPrice(product.shipCost) }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="profit-item highlight">
|
|
|
+ <span class="profit-label">预估利润</span>
|
|
|
+ <span class="profit-value">\${{ formatPrice(product.profit) }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="profit-item highlight">
|
|
|
+ <span class="profit-label">利润率</span>
|
|
|
+ <span class="profit-value">{{ (product.profitRate || 0).toFixed(1) }}%</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </app-content-card>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="detail-tab-panel" [hidden]="activeDetailSection() !== 'detail-reviews'">
|
|
|
+ <div class="review-dashboard">
|
|
|
+ <div class="review-summary-strip">
|
|
|
+ <div class="review-summary-copy">
|
|
|
+ <span class="review-section-eyebrow">Review Intelligence</span>
|
|
|
+ <h3>评价健康度</h3>
|
|
|
+ <p>聚合评分分布、好差评主题与变体风险,快速定位 VOC 优先级。</p>
|
|
|
+ </div>
|
|
|
+ <div class="review-summary-metrics">
|
|
|
+ <button type="button" class="review-summary-metric clickable" (click)="openReviewsModal()">
|
|
|
+ <span>评分</span>
|
|
|
+ <strong class="rating">★ {{ product.ratings || 0 }}</strong>
|
|
|
+ </button>
|
|
|
+ <button type="button" class="review-summary-metric clickable" (click)="openReviewsModal()">
|
|
|
+ <span>评价数</span>
|
|
|
+ <strong>{{ formatNumber(product.ratingsCount || reviewInsight()?.totalReviews || 0) }}</strong>
|
|
|
+ </button>
|
|
|
+ @if (reviewInsight()) {
|
|
|
+ <div class="review-summary-metric">
|
|
|
+ <span>正向率</span>
|
|
|
+ <strong class="positive">{{ reviewInsight()!.positiveRate.toFixed(1) }}%</strong>
|
|
|
+ </div>
|
|
|
+ <div class="review-summary-metric">
|
|
|
+ <span>负向率</span>
|
|
|
+ <strong class="negative">{{ reviewInsight()!.negativeRate.toFixed(1) }}%</strong>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="detail-card-grid review-overview-grid">
|
|
|
+ <!-- Rating Distribution -->
|
|
|
+ @if (hasRatingDistribution()) {
|
|
|
+ <app-content-card class="rating-dist-card review-rating-card" title="评分分布" density="compact">
|
|
|
+ @if (hasLowStarAnomaly()) {
|
|
|
+ <p class="rating-insight">低星评价集中(1星多于2星),建议结合「产品VOC深度洞察」排查原因。</p>
|
|
|
+ }
|
|
|
+ <div class="rating-dist">
|
|
|
+ <div class="rating-bar">
|
|
|
+ <span class="rating-label">5星</span>
|
|
|
+ <div class="bar-container">
|
|
|
+ <div class="bar-fill" [style.width.%]="getRatingPercent(5)"></div>
|
|
|
+ </div>
|
|
|
+ <span class="rating-count">{{ product.fiveStartRatings || 0 }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="rating-bar">
|
|
|
+ <span class="rating-label">4星</span>
|
|
|
+ <div class="bar-container">
|
|
|
+ <div class="bar-fill" [style.width.%]="getRatingPercent(4)"></div>
|
|
|
+ </div>
|
|
|
+ <span class="rating-count">{{ product.fourStartRatings || 0 }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="rating-bar">
|
|
|
+ <span class="rating-label">3星</span>
|
|
|
+ <div class="bar-container">
|
|
|
+ <div class="bar-fill" [style.width.%]="getRatingPercent(3)"></div>
|
|
|
+ </div>
|
|
|
+ <span class="rating-count">{{ product.threeStartRatings || 0 }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="rating-bar">
|
|
|
+ <span class="rating-label">2星</span>
|
|
|
+ <div class="bar-container">
|
|
|
+ <div class="bar-fill" [style.width.%]="getRatingPercent(2)"></div>
|
|
|
+ </div>
|
|
|
+ <span class="rating-count">{{ product.twoStartRatings || 0 }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="rating-bar">
|
|
|
+ <span class="rating-label">1星</span>
|
|
|
+ <div class="bar-container">
|
|
|
+ <div class="bar-fill" [style.width.%]="getRatingPercent(1)"></div>
|
|
|
+ </div>
|
|
|
+ <span class="rating-count">{{ product.oneStartRatings || 0 }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </app-content-card>
|
|
|
+ }
|
|
|
+
|
|
|
+ @if (reviewInsight()) {
|
|
|
+ <div class="info-card review-insight-card review-theme-card">
|
|
|
+ <div class="review-card-title-row">
|
|
|
+ <h3>好差评主题</h3>
|
|
|
+ <span>复用竞品分析活动</span>
|
|
|
+ </div>
|
|
|
+ <div class="review-theme-columns">
|
|
|
+ @if (reviewInsight()!.positiveThemes.length > 0) {
|
|
|
+ <div class="theme-block positive-theme-block">
|
|
|
+ <span class="theme-title">好评主题</span>
|
|
|
+ <div class="theme-list">
|
|
|
+ @for (t of reviewInsight()!.positiveThemes; track t) {
|
|
|
+ <span class="theme-tag positive">{{ t }}</span>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+
|
|
|
+ @if (reviewInsight()!.negativeThemes.length > 0) {
|
|
|
+ <div class="theme-block negative-theme-block">
|
|
|
+ <span class="theme-title">差评主题</span>
|
|
|
+ <div class="theme-list">
|
|
|
+ @for (t of reviewInsight()!.negativeThemes; track t) {
|
|
|
+ <span class="theme-tag negative">{{ t }}</span>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+
|
|
|
+ @if (reviewInsight()) {
|
|
|
+ <div class="detail-card-grid review-detail-grid">
|
|
|
+ @if (reviewInsight()!.negativeDetails.length > 0) {
|
|
|
+ <div class="info-card review-insight-card review-phrase-card">
|
|
|
+ <div class="review-card-title-row">
|
|
|
+ <h3>典型差评短句</h3>
|
|
|
+ <span>点击短句查看原始评论</span>
|
|
|
+ </div>
|
|
|
+ <div class="phrase-list">
|
|
|
+ @for (d of reviewInsight()!.negativeDetails.slice(0, 10); track $index) {
|
|
|
+ <button class="phrase-link" (click)="openReviewsModalWithKeyword(d.snippet || d.issue)">
|
|
|
+ @if (d.variant) {
|
|
|
+ <span class="phrase-variant">{{ formatVariant(d.variant) }}</span>
|
|
|
+ }
|
|
|
+ 「{{ (d.snippet || d.issue).slice(0, 42) }}{{ (d.snippet || d.issue).length > 42 ? '...' : '' }}」
|
|
|
+ </button>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+
|
|
|
+ @if (variantBreakdown().length > 0) {
|
|
|
+ <div class="info-card review-insight-card review-variant-card">
|
|
|
+ <div class="review-card-title-row">
|
|
|
+ <h3>变体维度差评分布</h3>
|
|
|
+ <span>SKU / 颜色 / 尺码</span>
|
|
|
+ </div>
|
|
|
+ <div class="variant-breakdown-list">
|
|
|
+ @for (v of variantBreakdown(); track v.variant) {
|
|
|
+ <div class="variant-breakdown-item">
|
|
|
+ <span class="variant-name">{{ formatVariant(v.variant) }}</span>
|
|
|
+ <span class="variant-count">{{ v.count }}条差评</span>
|
|
|
+ <span class="variant-issues">{{ v.issues.join(' / ') }}</span>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+
|
|
|
+ @if (aiReviewPrompt) {
|
|
|
+ <div class="review-ai-panel">
|
|
|
+ <app-ai-analysis-panel
|
|
|
+ [prompt]="aiReviewPrompt"
|
|
|
+ promptKey="shared.analysisPanel.defaultSystem"
|
|
|
+ [title]="'AI 评论分析报告'"
|
|
|
+ [autoGenerate]="true"
|
|
|
+ [enablePhraseLinks]="true"
|
|
|
+ (phraseClick)="openReviewsModalWithKeyword($event)"
|
|
|
+ (reviewClick)="onReviewNameClick($event)"
|
|
|
+ (openChat)="onOpenAiChat($event)"
|
|
|
+ ></app-ai-analysis-panel>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="detail-tab-panel" [hidden]="activeDetailSection() !== 'detail-features'">
|
|
|
+ <div class="detail-card-grid feature-tab-grid">
|
|
|
+ <!-- Features -->
|
|
|
+ @if (product.feature && product.feature.length) {
|
|
|
+ <app-content-card class="features-card" title="产品特点">
|
|
|
+ <ul class="feature-list">
|
|
|
+ @for (feature of product.feature; track feature) {
|
|
|
+ <li>{{ feature }}</li>
|
|
|
+ }
|
|
|
+ </ul>
|
|
|
+ </app-content-card>
|
|
|
+ }
|
|
|
+
|
|
|
+ <!-- 产品特征结构化分析 -->
|
|
|
+ <app-product-feature-analysis
|
|
|
+ [feature]="product.feature"
|
|
|
+ [description]="product.description"
|
|
|
+ [title]="product.title">
|
|
|
+ </app-product-feature-analysis>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="detail-tab-panel" [hidden]="activeDetailSection() !== 'detail-variants'">
|
|
|
+ <!-- 变体概要(数据直接来自 ProductRequest 结果,无额外请求) -->
|
|
|
+ <app-product-variations
|
|
|
+ [asinList]="getVariationASINs()"
|
|
|
+ [currentAsin]="asin"
|
|
|
+ [variationCount]="product.variationASINCount"
|
|
|
+ [attributes]="product.attribute"
|
|
|
+ [sizes]="product.size"
|
|
|
+ [parentAsin]="product.parentAsin">
|
|
|
+ </app-product-variations>
|
|
|
+
|
|
|
+ <!-- 变体历史(调用 ProductVariationHistory API,点刷新按钮触发) -->
|
|
|
+ <app-product-variation-history #variationHistory
|
|
|
+ [asin]="asin"
|
|
|
+ [currentAsin]="asin">
|
|
|
+ </app-product-variation-history>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="detail-tab-panel" [hidden]="activeDetailSection() !== 'detail-specs'">
|
|
|
+ <!-- 产品结构化规格(Property / ProductInfo) -->
|
|
|
+ <app-product-specs
|
|
|
+ [property]="product.property"
|
|
|
+ [productInfo]="product.productInfo"
|
|
|
+ [offSale]="product.offSale">
|
|
|
+ </app-product-specs>
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+
|
|
|
+ @if (!loading && !product) {
|
|
|
+ <app-empty-state title="未找到该竞品数据" icon="🔍" actionText="点击刷新数据" (action)="refreshData()"></app-empty-state>
|
|
|
+ }
|
|
|
+
|
|
|
+ <!-- 评论弹窗 -->
|
|
|
+ @if (showReviewsModal()) {
|
|
|
+ <div class="modal-overlay" (click)="closeReviewsModal()">
|
|
|
+ <div class="modal-container reviews-modal" (click)="$event.stopPropagation()">
|
|
|
+ <div class="modal-header">
|
|
|
+ <h2 class="modal-title">产品评论 <span class="modal-subtitle">{{ asin }}</span></h2>
|
|
|
+ <div class="modal-header-actions">
|
|
|
+ <button class="ai-analysis-btn" (click)="openAiReviewAnalysis()" [disabled]="aiAnalysisLoading()">
|
|
|
+ @if (aiAnalysisLoading()) { 分析中... }
|
|
|
+ @else { 🤖 AI分析 }
|
|
|
+ </button>
|
|
|
+ <button class="translate-btn" [class.active]="isTranslated()"
|
|
|
+ [disabled]="isTranslating()" (click)="toggleTranslation()">
|
|
|
+ @if (isTranslating()) { 翻译中... }
|
|
|
+ @else if (isTranslated()) { 显示原文 }
|
|
|
+ @else { 翻译为中文 }
|
|
|
+ </button>
|
|
|
+ <button class="modal-close" (click)="closeReviewsModal()">×</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="reviews-filters">
|
|
|
+ <div class="star-filters">
|
|
|
+ <button [class.active]="reviewsStarFilter() === ''" (click)="onStarFilterChange('')">全部</button>
|
|
|
+ @for (s of starOptions; track s) {
|
|
|
+ <button [class.active]="reviewsStarFilter() === s" (click)="onStarFilterChange(s)">{{ s }}星</button>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ <div class="extra-filters">
|
|
|
+ <button [class.active]="reviewsOnlyPurchase() === 1" (click)="togglePurchaseFilter()">仅已购买</button>
|
|
|
+ @if (reviewsFromDb()) {
|
|
|
+ <span class="data-source-tag db">缓存 ({{ reviewDbCount() }}条)</span>
|
|
|
+ }
|
|
|
+ <button (click)="refreshReviewsFromApi()" [disabled]="reviewsLoading()">从API获取最新</button>
|
|
|
+ </div>
|
|
|
+ @if (reviewSearchKeyword()) {
|
|
|
+ <div class="keyword-indicator">
|
|
|
+ <span class="keyword-label">关键词匹配:</span>
|
|
|
+ <span class="keyword-text">{{ reviewSearchKeyword() }}</span>
|
|
|
+ <button class="keyword-clear" (click)="clearReviewKeyword()">×</button>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="reviews-list">
|
|
|
+ @if (reviewsLoading()) {
|
|
|
+ <app-loading-spinner text="加载评论中..." size="sm"></app-loading-spinner>
|
|
|
+ } @else if (reviews().length === 0) {
|
|
|
+ <app-empty-state title="暂无评论数据" size="sm" icon="💬"></app-empty-state>
|
|
|
+ } @else {
|
|
|
+ @for (review of reviews(); track $index) {
|
|
|
+ <app-review-card
|
|
|
+ [review]="review"
|
|
|
+ [translatedTitle]="translatedMap().get($index)?.title || ''"
|
|
|
+ [translatedContent]="translatedMap().get($index)?.content || ''"
|
|
|
+ [showTranslation]="isTranslated()"
|
|
|
+ (cardClick)="openReviewDetail($event)">
|
|
|
+ </app-review-card>
|
|
|
+ }
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="reviews-pagination" *ngIf="!reviewSearchKeyword()">
|
|
|
+ <button [disabled]="reviewsPageIndex() <= 1" (click)="reviewsGoPage(-1)">上一页</button>
|
|
|
+ <span>第 {{ reviewsPageIndex() }} 页</span>
|
|
|
+ <button [disabled]="!reviewsHasMore()" (click)="reviewsGoPage(1)">下一页</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ <!-- AI 聊天弹窗 -->
|
|
|
+ <app-ai-chat-modal
|
|
|
+ [visible]="showAiChat()"
|
|
|
+ [contextPrompt]="aiChatContext()"
|
|
|
+ [reportText]="aiChatReportText()"
|
|
|
+ [autoSend]="aiAutoSend()"
|
|
|
+ [title]="'AI 评论深度分析 - ' + asin"
|
|
|
+ (reviewClick)="onReviewNameClick($event)"
|
|
|
+ (close)="showAiChat.set(false)">
|
|
|
+ </app-ai-chat-modal>
|
|
|
+
|
|
|
+ <!-- 评论详情弹窗 -->
|
|
|
+ <app-review-detail-modal
|
|
|
+ [visible]="showReviewDetail()"
|
|
|
+ [review]="reviewDetailData()"
|
|
|
+ (close)="showReviewDetail.set(false)">
|
|
|
+ </app-review-detail-modal>
|
|
|
+ </div>
|
|
|
+ `,
|
|
|
+ styleUrls: ['./competitor-detail.component.scss']
|
|
|
+})
|
|
|
+export class CompetitorDetailComponent implements OnInit {
|
|
|
+ asin = '';
|
|
|
+ loading = true;
|
|
|
+ isRefreshing = false;
|
|
|
+ product: any = null;
|
|
|
+
|
|
|
+ // ---- AI 聊天弹窗 ----
|
|
|
+ showAiChat = signal<boolean>(false);
|
|
|
+ aiChatContext = signal<string>('');
|
|
|
+ aiChatReportText = signal<string>('');
|
|
|
+ aiAutoSend = signal<string>('');
|
|
|
+
|
|
|
+ // ---- 评论详情弹窗 ----
|
|
|
+ showReviewDetail = signal<boolean>(false);
|
|
|
+ reviewDetailData = signal<any>(null);
|
|
|
+
|
|
|
+ // ---- 评论相关信号 ----
|
|
|
+ showReviewsModal = signal<boolean>(false);
|
|
|
+ reviews = signal<any[]>([]);
|
|
|
+ reviewsLoading = signal<boolean>(false);
|
|
|
+ reviewsPageIndex = signal<number>(1);
|
|
|
+ reviewsStarFilter = signal<string>('');
|
|
|
+ reviewsOnlyPurchase = signal<number>(0);
|
|
|
+ reviewsTotal = signal<number>(0);
|
|
|
+ reviewsHasMore = signal<boolean>(false);
|
|
|
+ reviewsFromDb = signal<boolean>(false);
|
|
|
+ reviewDbCount = signal<number>(0);
|
|
|
+ isTranslated = signal<boolean>(false);
|
|
|
+ isTranslating = signal<boolean>(false);
|
|
|
+ translatedMap = signal<Map<number, { title: string; content: string }>>(new Map());
|
|
|
+ starOptions = ['5', '4', '3', '2', '1'];
|
|
|
+ reviewSearchKeyword = signal<string>('');
|
|
|
+ reviewMatchTerms = signal<string[]>([]);
|
|
|
+ aiAnalysisLoading = signal<boolean>(false);
|
|
|
+ activeDetailSection = signal<string>('detail-overview');
|
|
|
+
|
|
|
+ reviewInsight = signal<{
|
|
|
+ positiveRate: number;
|
|
|
+ negativeRate: number;
|
|
|
+ totalReviews: number;
|
|
|
+ positiveThemes: string[];
|
|
|
+ negativeThemes: string[];
|
|
|
+ negativeDetails: Array<{ issue: string; variant: string; star: number; snippet: string }>;
|
|
|
+ } | null>(null);
|
|
|
+ aiReviewPrompt = '';
|
|
|
+
|
|
|
+ /** 将 variant JSON 字符串转为可读格式,如 "Color: Black, Size: M" */
|
|
|
+ formatVariant(raw: string): string {
|
|
|
+ if (!raw) return '';
|
|
|
+ try {
|
|
|
+ const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
|
+ if (Array.isArray(arr) && arr.length > 0 && arr[0].name) {
|
|
|
+ return arr.map((v: any) => `${v.name}: ${v.detail}`).join(', ');
|
|
|
+ }
|
|
|
+ } catch { /* not JSON, return as-is */ }
|
|
|
+ return raw;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- 变体维度差评分布 ----
|
|
|
+ variantBreakdown = computed(() => {
|
|
|
+ const insight = this.reviewInsight();
|
|
|
+ if (!insight?.negativeDetails?.length) return [];
|
|
|
+ const map = new Map<string, { count: number; issues: string[] }>();
|
|
|
+ insight.negativeDetails.forEach(d => {
|
|
|
+ const v = this.formatVariant((d.variant || '').trim());
|
|
|
+ if (!v) return;
|
|
|
+ const entry = map.get(v) || { count: 0, issues: [] };
|
|
|
+ entry.count++;
|
|
|
+ if (entry.issues.length < 3) entry.issues.push(d.issue || d.snippet);
|
|
|
+ map.set(v, entry);
|
|
|
+ });
|
|
|
+ return Array.from(map.entries())
|
|
|
+ .map(([variant, data]) => ({ variant, ...data }))
|
|
|
+ .sort((a, b) => b.count - a.count)
|
|
|
+ .slice(0, 10);
|
|
|
+ });
|
|
|
+
|
|
|
+ constructor(
|
|
|
+ private route: ActivatedRoute,
|
|
|
+ private location: Location,
|
|
|
+ private dataStore: DataStoreService,
|
|
|
+ private sorftimeApi: SorftimeApiService,
|
|
|
+ private monitoringDataService: MonitoringDataService,
|
|
|
+ private toast: ToastService
|
|
|
+ ) {}
|
|
|
+
|
|
|
+ ngOnInit(): void {
|
|
|
+ // 订阅 paramMap 而非使用 snapshot,确保同一组件不同 ASIN 之间跳转时页面能正确刷新
|
|
|
+ this.route.paramMap.subscribe(params => {
|
|
|
+ const newAsin = params.get('asin') || '';
|
|
|
+ if (newAsin && newAsin !== this.asin) {
|
|
|
+ this.asin = newAsin;
|
|
|
+ this.product = null;
|
|
|
+ this.loading = true;
|
|
|
+ this.isRefreshing = false;
|
|
|
+ this.activeDetailSection.set('detail-overview');
|
|
|
+ this.loadProductFromDB();
|
|
|
+ this.loadReviewInsightData();
|
|
|
+ } else if (!newAsin) {
|
|
|
+ this.loading = false;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private loadProductFromDB(): void {
|
|
|
+ // 从 AmazonProduct 表查询数据
|
|
|
+ this.dataStore.query('AmazonProduct', { asin: this.asin }, { limit: 1 }).subscribe({
|
|
|
+ next: (rows) => {
|
|
|
+ if (rows?.length) {
|
|
|
+ const vm = this.toViewModel(rows[0]);
|
|
|
+ // 避免“刷新后先展示接口数据,随后 DB 记录把字段覆盖回空/0”
|
|
|
+ this.product = this.mergePreferMeaningful(this.product, vm);
|
|
|
+ }
|
|
|
+ this.loading = false;
|
|
|
+ },
|
|
|
+ error: (err) => {
|
|
|
+ console.warn('[CompetitorDetail] Load from DB failed:', err?.message);
|
|
|
+ this.loading = false;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ @ViewChild('variationHistory') variationHistoryRef?: ProductVariationHistoryComponent;
|
|
|
+
|
|
|
+ refreshData(): void {
|
|
|
+ if (!this.asin || this.isRefreshing) return;
|
|
|
+
|
|
|
+ this.isRefreshing = true;
|
|
|
+
|
|
|
+ // 同步刷新变体历史数据
|
|
|
+ this.variationHistoryRef?.load();
|
|
|
+
|
|
|
+ // 调用 Sorftime API 获取最新数据
|
|
|
+ this.sorftimeApi.getProductByAsin(this.asin).subscribe({
|
|
|
+ next: (data) => {
|
|
|
+ const productData = data && data.length ? data[0] : null;
|
|
|
+ if (productData && this.isMeaningfulProduct(productData)) {
|
|
|
+ // 先用接口数据更新展示(避免等 DB 回写)
|
|
|
+ this.product = this.toViewModel(productData);
|
|
|
+
|
|
|
+ // 调用云函数保存到 AmazonProduct 表
|
|
|
+ this.saveToCloudFunction(productData);
|
|
|
+ } else {
|
|
|
+ // Sorftime 未收录该 ASIN 时会返回空壳(Asin 形如 "#xxx"、字段全空),
|
|
|
+ // 直接保存会抹掉已有数据,这里保留现有数据并提示
|
|
|
+ this.isRefreshing = false;
|
|
|
+ console.warn('[CompetitorDetail] Sorftime returned empty/placeholder data for', this.asin);
|
|
|
+ this.toast.warning('Sorftime 暂无该商品的最新数据(可能尚未采集或正在采集中),已保留现有数据');
|
|
|
+ }
|
|
|
+ },
|
|
|
+ error: (err) => {
|
|
|
+ this.isRefreshing = false;
|
|
|
+ const msg = err?.message || '';
|
|
|
+ if (/694|请求次数不足|配额/.test(msg)) {
|
|
|
+ this.toast.error('Sorftime 数据源请求次数不足(配额已耗尽,错误码 694),请联系管理员充值/续费后再重试');
|
|
|
+ } else {
|
|
|
+ this.toast.error('刷新失败:' + (msg || 'Sorftime 上游接口暂时不可用'));
|
|
|
+ }
|
|
|
+ console.warn('[CompetitorDetail] Refresh failed:', msg);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 判断 Sorftime 返回的产品数据是否有效(非空壳/未收录占位) */
|
|
|
+ private isMeaningfulProduct(p: any): boolean {
|
|
|
+ if (!p || typeof p !== 'object') return false;
|
|
|
+ // Sorftime 对未收录的 ASIN 会返回形如 "#B0XXXX" 的占位,且字段全空
|
|
|
+ const asin = String(p.Asin ?? p.asin ?? '');
|
|
|
+ if (asin.startsWith('#')) return false;
|
|
|
+ const hasTitle = !!(p.Title || p.title);
|
|
|
+ const hasPrice = Number(p.Price ?? p.SalesPrice ?? p.price ?? 0) > 0;
|
|
|
+ const hasRatings = Number(p.RatingsCount ?? p.ratingsCount ?? 0) > 0;
|
|
|
+ return hasTitle || hasPrice || hasRatings;
|
|
|
+ }
|
|
|
+
|
|
|
+ private saveToCloudFunction(productData: any): void {
|
|
|
+ // 标准化处理:确保字段类型符合云函数 BdZWIv7cqu 的 AmazonProduct schema
|
|
|
+ // Object 字段(Feature/Property 等)→ 包装为 Object;String 字段 → 强制 String
|
|
|
+ const normalizedData = this.normalizeProductData(productData);
|
|
|
+
|
|
|
+ // 调用云函数保存(sorftime-ProductRequest)
|
|
|
+ this.dataStore.cloudFunction({
|
|
|
+ id: 'BdZWIv7cqu', // 云函数ID: sorftime-ProductRequest
|
|
|
+ data: {
|
|
|
+ Code: 200,
|
|
|
+ Data: normalizedData
|
|
|
+ },
|
|
|
+ context: {
|
|
|
+ query: { domain: 1 },
|
|
|
+ domain: 1
|
|
|
+ }
|
|
|
+ }).subscribe({
|
|
|
+ next: (result) => {
|
|
|
+ console.log('[CompetitorDetail] Cloud function saved:', result);
|
|
|
+ // 重新从数据库加载
|
|
|
+ this.loadProductFromDB();
|
|
|
+ this.isRefreshing = false;
|
|
|
+ this.toast.success('已从 Sorftime 刷新并保存最新数据');
|
|
|
+ },
|
|
|
+ error: (err) => {
|
|
|
+ console.warn('[CompetitorDetail] Cloud function save failed:', err);
|
|
|
+ this.isRefreshing = false;
|
|
|
+ this.toast.error('已获取 Sorftime 数据,但保存到数据库失败:' + (err?.message || '请稍后重试'));
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 竞品详情页展示用:兼容不同字段命名/趋势数组 */
|
|
|
+ private toViewModel(raw: any): any {
|
|
|
+ const src = raw || {};
|
|
|
+
|
|
|
+ // 兼容 PascalCase(API)/ camelCase(DB)/ rawData 嵌套
|
|
|
+ const get = (pascal: string, ...alts: string[]): any => {
|
|
|
+ const camel = pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
|
+ for (const key of [pascal, camel, ...alts]) {
|
|
|
+ if (src[key] !== undefined && src[key] !== null) return src[key];
|
|
|
+ }
|
|
|
+ for (const key of [pascal, camel, ...alts]) {
|
|
|
+ const v = src.rawData?.[key] ?? src.data?.[key];
|
|
|
+ if (v !== undefined && v !== null) return v;
|
|
|
+ }
|
|
|
+ return undefined;
|
|
|
+ };
|
|
|
+
|
|
|
+ // API 价格单位为"分",转为"元"
|
|
|
+ const centsToDollars = (v: any): number => {
|
|
|
+ const n = this.toNumber(v);
|
|
|
+ return n != null ? n / 100 : 0;
|
|
|
+ };
|
|
|
+
|
|
|
+ // ---- 月销量 ----
|
|
|
+ const monthlySalesExplicit = this.toNumber(get('ListingSalesVolumeOfMonth'));
|
|
|
+ const monthlySalesFromTrend = this.pickLatestTrendValue(
|
|
|
+ get('ListingSalesVolumeOfMonthTrend', 'salesVolumeMonthTrend')
|
|
|
+ );
|
|
|
+ const monthlySalesFromAsin = this.toNumber(get('AsinSalesCount'));
|
|
|
+ const monthlySales =
|
|
|
+ monthlySalesExplicit ??
|
|
|
+ monthlySalesFromTrend ??
|
|
|
+ (monthlySalesFromAsin && monthlySalesFromAsin > 0 ? monthlySalesFromAsin : null);
|
|
|
+
|
|
|
+ // ---- BSR 排名:优先从 BsrCategory[0][2] 取子类目排名 ----
|
|
|
+ const rawRank = this.toNumber(get('Rank'));
|
|
|
+ const bsrCategory = get('BsrCategory') || [];
|
|
|
+ let bsrRank = rawRank;
|
|
|
+ if (Array.isArray(bsrCategory) && bsrCategory.length) {
|
|
|
+ const firstCat = bsrCategory[0];
|
|
|
+ if (Array.isArray(firstCat) && firstCat.length >= 3) {
|
|
|
+ const catRank = this.toNumber(firstCat[2]);
|
|
|
+ if (catRank != null && catRank > 0) {
|
|
|
+ bsrRank = catRank;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- 上架日期:兼容 Parse Date 对象 / 字符串 ----
|
|
|
+ const rawOnlineDate = get('OnlineDate');
|
|
|
+ let onlineDate = '';
|
|
|
+ if (rawOnlineDate) {
|
|
|
+ if (typeof rawOnlineDate === 'string') {
|
|
|
+ onlineDate = rawOnlineDate;
|
|
|
+ } else if (rawOnlineDate?.iso) {
|
|
|
+ onlineDate = rawOnlineDate.iso.substring(0, 10);
|
|
|
+ } else if (rawOnlineDate instanceof Date) {
|
|
|
+ onlineDate = rawOnlineDate.toISOString().substring(0, 10);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ ...src,
|
|
|
+ // 基本信息
|
|
|
+ title: get('Title') || '',
|
|
|
+ asin: get('Asin') || '',
|
|
|
+ brand: get('Brand') || '',
|
|
|
+ photo: get('Photo') || [],
|
|
|
+ productBadge: get('ProductBadge') || [],
|
|
|
+ description: get('Description') || '',
|
|
|
+
|
|
|
+ // 价格(分→元)
|
|
|
+ salesPrice: centsToDollars(get('SalesPrice')),
|
|
|
+ price: centsToDollars(get('Price')),
|
|
|
+ listPrice: centsToDollars(get('ListPrice')),
|
|
|
+ platformFee: centsToDollars(get('PlatformFee')),
|
|
|
+ fbaFee: centsToDollars(get('FbaFee')),
|
|
|
+ shipCost: centsToDollars(get('ShipCost')),
|
|
|
+ profit: centsToDollars(get('Profit')),
|
|
|
+ profitRate: this.toNumber(get('ProfitRate')) ?? 0,
|
|
|
+ coupon: centsToDollars(get('Coupon')),
|
|
|
+
|
|
|
+ // 核心指标
|
|
|
+ ratings: this.toNumber(get('Ratings')) ?? 0,
|
|
|
+ ratingsCount: this.toNumber(get('RatingsCount')) ?? 0,
|
|
|
+ listingSalesVolumeOfMonth: monthlySales ?? 0,
|
|
|
+ __hasMonthlySales: monthlySales != null,
|
|
|
+ rank: bsrRank ?? -1,
|
|
|
+ onlineDate: onlineDate || '-',
|
|
|
+ onlineDays: this.toNumber(get('OnlineDays')) ?? 0,
|
|
|
+ isFBA: get('IsFBA') ?? false,
|
|
|
+ sellerCount: this.toNumber(get('SellerCount')) ?? 0,
|
|
|
+
|
|
|
+ // 分类
|
|
|
+ category: get('Category') || [],
|
|
|
+ bsrCategory,
|
|
|
+ productType: get('ProductType') || '',
|
|
|
+
|
|
|
+ // 卖家
|
|
|
+ buyboxSeller: get('BuyboxSeller') || '',
|
|
|
+ shipsFrom: get('ShipsFrom') || '',
|
|
|
+ storeName: get('StoreName') || '',
|
|
|
+ buyboxSellerAddress: get('BuyboxSellerAddress') || '',
|
|
|
+
|
|
|
+ // 评价分布(百分比)
|
|
|
+ fiveStartRatings: this.toNumber(get('FiveStartRatings')) ?? 0,
|
|
|
+ fourStartRatings: this.toNumber(get('FourStartRatings')) ?? 0,
|
|
|
+ threeStartRatings: this.toNumber(get('ThreeStartRatings')) ?? 0,
|
|
|
+ twoStartRatings: this.toNumber(get('TwoStartRatings')) ?? 0,
|
|
|
+ oneStartRatings: this.toNumber(get('OneStartRatings')) ?? 0,
|
|
|
+
|
|
|
+ // 其他
|
|
|
+ feature: get('Feature') || [],
|
|
|
+ updateDate: get('UpdateDate') || '',
|
|
|
+ dataUpdatedAt: new Date().toISOString(),
|
|
|
+ hasVideo: get('HasVideo') ?? false,
|
|
|
+ aPlus: get('APlus') ?? false,
|
|
|
+ hasBrandStore: get('HasBrandStore') ?? false,
|
|
|
+ dealType: get('DealType') || '',
|
|
|
+
|
|
|
+ // 结构化规格(来自 Property / ProductInfo)
|
|
|
+ property: get('Property') || [],
|
|
|
+ productInfo: get('ProductInfo') || [],
|
|
|
+ offSale: get('OffSale') ?? 0,
|
|
|
+
|
|
|
+ // 变体相关
|
|
|
+ variationASIN: get('VariationASIN') || [],
|
|
|
+ variationASINCount: this.toNumber(get('VariationASINCount')) ?? 0,
|
|
|
+ parentAsin: get('ParentAsin') || '',
|
|
|
+ attribute: get('Attribute') || [],
|
|
|
+ size: get('Size') || [],
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 合并两个视图模型:当 incoming(通常来自 DB)缺少关键字段时,
|
|
|
+ * 保留 current(通常来自接口实时返回)中更“有意义”的值。
|
|
|
+ */
|
|
|
+ private mergePreferMeaningful(current: any, incoming: any): any {
|
|
|
+ if (!current) return incoming;
|
|
|
+ if (!incoming) return current;
|
|
|
+
|
|
|
+ const merged = { ...current, ...incoming };
|
|
|
+
|
|
|
+ // 月销量:DB 回来如果没有有效来源(__hasMonthlySales=false),则保留当前值
|
|
|
+ if (incoming.__hasMonthlySales === false && current.listingSalesVolumeOfMonth > 0) {
|
|
|
+ merged.listingSalesVolumeOfMonth = current.listingSalesVolumeOfMonth;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 清理内部标记,避免污染模板/保存
|
|
|
+ delete merged.__hasMonthlySales;
|
|
|
+ return merged;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 从趋势数组中取最新有效值:格式 [date, value, date, value, ...],末尾可能 -1 */
|
|
|
+ private pickLatestTrendValue(trend: any): number | null {
|
|
|
+ if (!Array.isArray(trend) || trend.length < 2) return null;
|
|
|
+ // 以 (date,value) 为对,取最后一个 value != -1 的值
|
|
|
+ for (let i = trend.length - 1; i >= 1; i -= 2) {
|
|
|
+ const v = this.toNumber(trend[i]);
|
|
|
+ if (v == null) continue;
|
|
|
+ if (v === -1) continue;
|
|
|
+ return v;
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private toNumber(v: any): number | null {
|
|
|
+ if (v == null) return null;
|
|
|
+ const n = typeof v === 'number' ? v : Number(v);
|
|
|
+ return Number.isFinite(n) ? n : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 标准化 Sorftime 产品数据,使其符合云函数 AmazonProduct schema。
|
|
|
+ * 参照《竞品获取接口规范》:
|
|
|
+ * - Array 字段:强制转 Array(null → [],非数组 → [value])
|
|
|
+ * - String 字段:强制转 String(Array/Object → JSON.stringify)
|
|
|
+ * - 其余字段按原值复制
|
|
|
+ */
|
|
|
+ private normalizeProductData(data: any): any {
|
|
|
+ const pick = (key: string): any => {
|
|
|
+ const lower = key.charAt(0).toLowerCase() + key.slice(1);
|
|
|
+ return data[key] ?? data[lower];
|
|
|
+ };
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // 字段分类基于云函数 BdZWIv7cqu 源码 + 实际 Parse DB 列类型
|
|
|
+ // 4 个字段的 DB 类型与云函数默认值不匹配(标 ⚠️):
|
|
|
+ // feature: DB=Object, 云函数 || [] ⚠️
|
|
|
+ // attribute: DB=Array, 云函数 || '' ⚠️
|
|
|
+ // property: DB=String, 云函数 || [] ⚠️
|
|
|
+ // productInfo: DB=String, 云函数 || [] ⚠️
|
|
|
+ // ============================================================
|
|
|
+
|
|
|
+ // ---- 1. Array 字段(DB=Array)----
|
|
|
+ const arrayFields = [
|
|
|
+ 'Photo', 'EBCPhoto', 'ExtraSavings',
|
|
|
+ 'Category', 'BsrCategory', 'Size',
|
|
|
+ 'VariationASIN', 'FbaDetail',
|
|
|
+ 'ProductBadge', 'Attribute'
|
|
|
+ ];
|
|
|
+
|
|
|
+ // ---- 2. Object 字段(DB=Object,仅 Feature)----
|
|
|
+ const objectFields = ['Feature'];
|
|
|
+
|
|
|
+ // ---- 3a. String 字段-安全(DB=String,云函数默认 '',匹配)----
|
|
|
+ const safeStringFields = [
|
|
|
+ 'Description', 'BrandPromotion', 'DealType',
|
|
|
+ 'ProductType', 'StoreName', 'UpdateDate', 'ShipsFrom'
|
|
|
+ ];
|
|
|
+
|
|
|
+ // ---- 3b. String 字段-不安全(DB=String,云函数默认 [] 不匹配)----
|
|
|
+ const unsafeStringFields = ['Property', 'ProductInfo'];
|
|
|
+
|
|
|
+ // ---- 4. 直接复制字段 ----
|
|
|
+ const passthroughFields = [
|
|
|
+ 'Asin', 'ParentAsin', 'Title', 'Brand',
|
|
|
+ 'Price', 'ListPrice', 'SalesPrice', 'Coupon',
|
|
|
+ 'Rank', 'RatingsCount', 'Ratings',
|
|
|
+ 'FiveStartRatings', 'FourStartRatings', 'ThreeStartRatings',
|
|
|
+ 'TwoStartRatings', 'OneStartRatings',
|
|
|
+ 'OnlineDate', 'OnlineDays', 'OffSale',
|
|
|
+ 'BuyboxSeller', 'BuyboxSellerId', 'BuyboxSellerAddress',
|
|
|
+ 'IsFBA', 'FbaFee', 'PlatformFee', 'Profit', 'ProfitRate',
|
|
|
+ 'ShipCost', 'Weight',
|
|
|
+ 'SellerCount', 'HasVideo', 'APlus', 'HasBrandStore',
|
|
|
+ 'VariationASINCount',
|
|
|
+ 'ListingSalesVolumeOfDaily', 'ListingSalesVolumeOfMonth',
|
|
|
+ 'ListingSalesOfDaily', 'ListingSalesOfMonth',
|
|
|
+ 'AsinSalesCount',
|
|
|
+ 'PriceTrend', 'ListPriceTrend',
|
|
|
+ 'SalesVolumeDailyTrend', 'SalesDailyTrend',
|
|
|
+ 'SalesVolumeMonthTrend', 'SalesMonthTrend',
|
|
|
+ 'RankTrend', 'BsrRankTrend', 'DealTrend'
|
|
|
+ ];
|
|
|
+
|
|
|
+ const normalized: any = {};
|
|
|
+
|
|
|
+ // 1. 直接复制
|
|
|
+ passthroughFields.forEach(key => {
|
|
|
+ const val = pick(key);
|
|
|
+ if (val !== undefined && val !== null) {
|
|
|
+ normalized[key] = val;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 2. Array 字段:强制 Array(null → [])
|
|
|
+ arrayFields.forEach(key => {
|
|
|
+ const val = pick(key);
|
|
|
+ if (Array.isArray(val)) {
|
|
|
+ normalized[key] = val;
|
|
|
+ } else if (val !== undefined && val !== null) {
|
|
|
+ normalized[key] = [val];
|
|
|
+ } else {
|
|
|
+ normalized[key] = [];
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 3. Object 字段:强制 Object(null → {})
|
|
|
+ objectFields.forEach(key => {
|
|
|
+ const val = pick(key);
|
|
|
+ if (val === undefined || val === null) {
|
|
|
+ normalized[key] = {};
|
|
|
+ } else if (Array.isArray(val)) {
|
|
|
+ normalized[key] = { items: val };
|
|
|
+ } else if (typeof val === 'object') {
|
|
|
+ normalized[key] = val;
|
|
|
+ } else {
|
|
|
+ normalized[key] = { value: val };
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 4a. 安全 String 字段(null OK,云函数 || '' 正确兜底)
|
|
|
+ safeStringFields.forEach(key => {
|
|
|
+ const val = pick(key);
|
|
|
+ if (val === undefined || val === null) {
|
|
|
+ // 不设置,让云函数默认为 ''
|
|
|
+ } else if (Array.isArray(val) || (typeof val === 'object')) {
|
|
|
+ normalized[key] = JSON.stringify(val);
|
|
|
+ } else {
|
|
|
+ normalized[key] = String(val);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 4b. 不安全 String 字段(null → "[]",防止云函数 || [] 变成 Array)
|
|
|
+ unsafeStringFields.forEach(key => {
|
|
|
+ const val = pick(key);
|
|
|
+ if (val === undefined || val === null) {
|
|
|
+ normalized[key] = '[]';
|
|
|
+ } else if (Array.isArray(val) || (typeof val === 'object')) {
|
|
|
+ normalized[key] = JSON.stringify(val);
|
|
|
+ } else {
|
|
|
+ normalized[key] = String(val) || '[]';
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ return normalized;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 评论功能 ====================
|
|
|
+
|
|
|
+ private loadReviewInsightData(): void {
|
|
|
+ if (!this.asin) return;
|
|
|
+ this.monitoringDataService.fetchAndAnalyzeReviewsForAsins([this.asin]).subscribe({
|
|
|
+ next: (map) => {
|
|
|
+ const insight = map?.[this.asin];
|
|
|
+ if (!insight) return;
|
|
|
+ this.reviewInsight.set(insight);
|
|
|
+ this.aiReviewPrompt = this.buildAiReviewPrompt(insight);
|
|
|
+ },
|
|
|
+ error: () => {
|
|
|
+ this.reviewInsight.set(null);
|
|
|
+ this.aiReviewPrompt = '';
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private buildAiReviewPrompt(insight: {
|
|
|
+ positiveRate: number;
|
|
|
+ negativeRate: number;
|
|
|
+ totalReviews: number;
|
|
|
+ positiveThemes: string[];
|
|
|
+ negativeThemes: string[];
|
|
|
+ negativeDetails: Array<{ issue: string; variant: string; star: number; snippet: string }>;
|
|
|
+ fullReviews?: Array<{ star: number; title: string; content: string; asin: string; variant: string; consumerName: string; date: string; helpful: number }>;
|
|
|
+ }): string {
|
|
|
+ // 按变体聚合差评分布
|
|
|
+ const variantMap = new Map<string, { count: number; issues: string[] }>();
|
|
|
+ insight.negativeDetails.forEach(d => {
|
|
|
+ const v = this.formatVariant((d.variant || '').trim());
|
|
|
+ if (!v) return;
|
|
|
+ const entry = variantMap.get(v) || { count: 0, issues: [] };
|
|
|
+ entry.count++;
|
|
|
+ if (entry.issues.length < 3) entry.issues.push(d.issue || d.snippet);
|
|
|
+ variantMap.set(v, entry);
|
|
|
+ });
|
|
|
+ const variantLines = Array.from(variantMap.entries())
|
|
|
+ .sort((a, b) => b[1].count - a[1].count)
|
|
|
+ .map(([v, data]) => `- ${v}: ${data.count}条差评,问题:${data.issues.join(' / ')}`)
|
|
|
+ .join('\n');
|
|
|
+
|
|
|
+ // 完整评论数据(差评+好评+中评样本)— 用评论者+变体作为标识,不用数字编号
|
|
|
+ const fullReviewLines = (insight.fullReviews || []).map((r) => {
|
|
|
+ const label = r.consumerName || '匿名用户';
|
|
|
+ const variantTag = r.variant ? `[${this.formatVariant(r.variant)}]` : '';
|
|
|
+ const starTag = '★'.repeat(Math.max(1, r.star)) + '☆'.repeat(Math.max(0, 5 - r.star));
|
|
|
+ const meta: string[] = [];
|
|
|
+ if (r.asin) meta.push(`ASIN:${r.asin}`);
|
|
|
+ if (r.date) meta.push(`日期:${r.date}`);
|
|
|
+ if (r.helpful > 0) meta.push(`${r.helpful}人觉得有用`);
|
|
|
+ const metaStr = meta.length ? ` (${meta.join(', ')})` : '';
|
|
|
+ return `- 【${label}${variantTag}】${starTag}${metaStr}\n 标题: ${r.title || '(无标题)'}\n 内容: ${r.content || '(无内容)'}`;
|
|
|
+ }).join('\n');
|
|
|
+
|
|
|
+ return [
|
|
|
+ `【注意:以下是一款竞品的完整评论数据,非本店产品。请从竞品分析视角出发,基于每条评论的具体ASIN、变体(尺码/颜色)信息进行细致分析。】`,
|
|
|
+ `竞品主ASIN: ${this.asin}`,
|
|
|
+ `总评论: ${insight.totalReviews}`,
|
|
|
+ `正向率: ${insight.positiveRate.toFixed(1)}%`,
|
|
|
+ `负向率: ${insight.negativeRate.toFixed(1)}%`,
|
|
|
+ `好评主题: ${insight.positiveThemes.join(' | ') || '无'}`,
|
|
|
+ `差评主题: ${insight.negativeThemes.join(' | ') || '无'}`,
|
|
|
+ '',
|
|
|
+ '### 变体/SKU维度差评分布:',
|
|
|
+ variantLines || '(无变体信息)',
|
|
|
+ '',
|
|
|
+ '### 完整评论样本(含ASIN、变体、完整原文):',
|
|
|
+ fullReviewLines || '无',
|
|
|
+ '',
|
|
|
+ '【重要格式要求】引用评论时,必须使用【评论者姓名[变体信息]】格式标注评论者,例如【John[Size:M/Color:Black]】。禁止使用"差评1""差评9"等数字编号。引用原文时用「」括起来。',
|
|
|
+ '',
|
|
|
+ '请基于以上完整评论数据,从竞品分析角度输出,分析颗粒度需细致到具体尺码/颜色/SKU:',
|
|
|
+ '1) 竞品各变体/尺码的痛点分布——哪些尺码/颜色差评集中?具体是什么问题?引用具体评论者和原文佐证。这些是我方可以差异化的切入点;',
|
|
|
+ '2) 竞品好评亮点——消费者具体认可什么?按变体维度分析,引用具体评论原文,我方产品需对标或超越的点;',
|
|
|
+ '3) 我方产品的差异化切入建议——基于竞品各变体的弱点,给出具体到尺码/颜色的产品开发和改良建议;',
|
|
|
+ '4) Listing优化参考——从评论原文中提炼消费者在不同变体上关注的卖点关键词,用于优化标题、五点和A+页面。'
|
|
|
+ ].join('\n');
|
|
|
+ }
|
|
|
+
|
|
|
+ openReviewsModal(): void {
|
|
|
+ this.showReviewsModal.set(true);
|
|
|
+ this.reviewsPageIndex.set(1);
|
|
|
+ this.reviewsStarFilter.set('');
|
|
|
+ this.reviewsOnlyPurchase.set(0);
|
|
|
+ this.reviewSearchKeyword.set('');
|
|
|
+ this.reviewMatchTerms.set([]);
|
|
|
+ this.isTranslated.set(false);
|
|
|
+ this.translatedMap.set(new Map());
|
|
|
+ this.loadReviews();
|
|
|
+ }
|
|
|
+
|
|
|
+ onReviewNameClick(reviewer: string): void {
|
|
|
+ // 从【用户名[变体]】格式中提取用户名
|
|
|
+ const name = reviewer.replace(/\[.*?\]/g, '').trim();
|
|
|
+ if (name) {
|
|
|
+ this.openReviewsModalWithKeyword(name);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ openReviewsModalWithKeyword(keyword: string): void {
|
|
|
+ this.showReviewsModal.set(true);
|
|
|
+ this.reviewsPageIndex.set(1);
|
|
|
+ this.reviewsStarFilter.set('');
|
|
|
+ this.reviewsOnlyPurchase.set(0);
|
|
|
+ this.reviewSearchKeyword.set((keyword || '').trim());
|
|
|
+ this.reviewMatchTerms.set(this.extractKeywordTerms(this.reviewSearchKeyword()));
|
|
|
+ this.isTranslated.set(false);
|
|
|
+ this.translatedMap.set(new Map());
|
|
|
+ this.loadReviews();
|
|
|
+ }
|
|
|
+
|
|
|
+ clearReviewKeyword(): void {
|
|
|
+ this.reviewSearchKeyword.set('');
|
|
|
+ this.reviewMatchTerms.set([]);
|
|
|
+ this.reviewsPageIndex.set(1);
|
|
|
+ this.loadReviews();
|
|
|
+ }
|
|
|
+
|
|
|
+ closeReviewsModal(): void {
|
|
|
+ this.showReviewsModal.set(false);
|
|
|
+ this.reviewSearchKeyword.set('');
|
|
|
+ this.reviewMatchTerms.set([]);
|
|
|
+ }
|
|
|
+
|
|
|
+ onStarFilterChange(star: string): void {
|
|
|
+ this.reviewsStarFilter.set(this.reviewsStarFilter() === star ? '' : star);
|
|
|
+ this.reviewsPageIndex.set(1);
|
|
|
+ this.loadReviews();
|
|
|
+ }
|
|
|
+
|
|
|
+ togglePurchaseFilter(): void {
|
|
|
+ this.reviewsOnlyPurchase.set(this.reviewsOnlyPurchase() ? 0 : 1);
|
|
|
+ this.reviewsPageIndex.set(1);
|
|
|
+ this.loadReviews();
|
|
|
+ }
|
|
|
+
|
|
|
+ reviewsGoPage(delta: number): void {
|
|
|
+ const newPage = this.reviewsPageIndex() + delta;
|
|
|
+ if (newPage < 1 || newPage > 100) return;
|
|
|
+ this.reviewsPageIndex.set(newPage);
|
|
|
+ this.loadReviews();
|
|
|
+ }
|
|
|
+
|
|
|
+ refreshReviewsFromApi(): void {
|
|
|
+ this.fetchAndSaveFromApi();
|
|
|
+ }
|
|
|
+
|
|
|
+ private loadReviews(): void {
|
|
|
+ this.reviewsLoading.set(true);
|
|
|
+ this.isTranslated.set(false);
|
|
|
+ this.translatedMap.set(new Map());
|
|
|
+
|
|
|
+ const star = this.reviewsStarFilter();
|
|
|
+ const page = this.reviewsPageIndex();
|
|
|
+ const onlyPurchase = this.reviewsOnlyPurchase();
|
|
|
+ const limit = 20;
|
|
|
+ const skip = (page - 1) * limit;
|
|
|
+
|
|
|
+ // 1. 查 DB 缓存数量
|
|
|
+ const filters: any = { asin: this.asin };
|
|
|
+ if (star) filters.star = Number(star);
|
|
|
+ if (onlyPurchase) filters.ivp = true;
|
|
|
+
|
|
|
+ this.dataStore.query('SorftimeReviews', { asin: this.asin }, { limit: 1 }).subscribe({
|
|
|
+ next: (countCheck) => {
|
|
|
+ if (countCheck?.length) {
|
|
|
+ // DB 有数据,从 DB 加载
|
|
|
+ this.loadReviewsFromDb(filters, limit, skip);
|
|
|
+ } else {
|
|
|
+ // DB 无数据,从 API 获取
|
|
|
+ this.fetchAndSaveFromApi();
|
|
|
+ }
|
|
|
+ },
|
|
|
+ error: () => {
|
|
|
+ this.fetchAndSaveFromApi();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private loadReviewsFromDb(filters: any, limit: number, skip: number): void {
|
|
|
+ const hasKeyword = !!this.reviewSearchKeyword();
|
|
|
+ this.dataStore.query('SorftimeReviews', filters, {
|
|
|
+ limit: hasKeyword ? 200 : limit,
|
|
|
+ skip: hasKeyword ? 0 : skip,
|
|
|
+ orderBy: 'reviewDate',
|
|
|
+ descending: true
|
|
|
+ }).subscribe({
|
|
|
+ next: (rows) => {
|
|
|
+ let mapped = (rows || []).map((r: any) => ({
|
|
|
+ Star: r.star,
|
|
|
+ Title: r.title,
|
|
|
+ Content: r.content,
|
|
|
+ ConsumerName: r.consumerName,
|
|
|
+ ReviewDate: r.reviewDate,
|
|
|
+ UpdateTime: r.updateTime,
|
|
|
+ Helpful: r.helpful,
|
|
|
+ Ivp: r.ivp,
|
|
|
+ AsinProperty: r.asinProperty || r.rawData?.AsinProperty || '',
|
|
|
+ ReviewsCountry: r.reviewsCountry,
|
|
|
+ ReviewLink: r.reviewLink,
|
|
|
+ Resource: r.resource || '',
|
|
|
+ Videos: r.videos || '',
|
|
|
+ rawData: r.rawData,
|
|
|
+ _objectId: r.objectId,
|
|
|
+ _translatedTitle: r.translatedTitle,
|
|
|
+ _translatedContent: r.translatedContent
|
|
|
+ }));
|
|
|
+
|
|
|
+ mapped = this.applyKeywordSort(mapped);
|
|
|
+
|
|
|
+ this.reviews.set(mapped);
|
|
|
+ this.reviewsFromDb.set(true);
|
|
|
+ this.reviewsHasMore.set(!hasKeyword && mapped.length >= limit);
|
|
|
+ this.reviewsLoading.set(false);
|
|
|
+
|
|
|
+ // 加载已保存的翻译
|
|
|
+ const tMap = new Map<number, { title: string; content: string }>();
|
|
|
+ mapped.forEach((r: any, i: number) => {
|
|
|
+ if (r._translatedTitle || r._translatedContent) {
|
|
|
+ tMap.set(i, { title: r._translatedTitle || '', content: r._translatedContent || '' });
|
|
|
+ }
|
|
|
+ });
|
|
|
+ this.translatedMap.set(tMap);
|
|
|
+
|
|
|
+ // 获取 DB 总数
|
|
|
+ this.dataStore.query('SorftimeReviews', { asin: this.asin }).subscribe({
|
|
|
+ next: (all) => this.reviewDbCount.set(all?.length || 0)
|
|
|
+ });
|
|
|
+ },
|
|
|
+ error: () => {
|
|
|
+ this.reviewsLoading.set(false);
|
|
|
+ this.reviews.set([]);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private fetchAndSaveFromApi(): void {
|
|
|
+ this.reviewsLoading.set(true);
|
|
|
+ this.reviewsFromDb.set(false);
|
|
|
+ const hasKeyword = !!this.reviewSearchKeyword();
|
|
|
+
|
|
|
+ this.sorftimeApi.getProductReviews(this.asin, {
|
|
|
+ star: this.reviewsStarFilter() || undefined,
|
|
|
+ pageIndex: this.reviewsPageIndex(),
|
|
|
+ onlyPurchase: this.reviewsOnlyPurchase() || undefined
|
|
|
+ }).subscribe({
|
|
|
+ next: (resp) => {
|
|
|
+ const data = resp?.Data || resp?.Reviews || resp?.data || resp;
|
|
|
+ const reviewList = Array.isArray(data) ? data : [];
|
|
|
+
|
|
|
+ // 提取总数(从 ItemIndex "14/122")
|
|
|
+ if (reviewList.length && reviewList[0]?.ItemIndex) {
|
|
|
+ const parts = String(reviewList[0].ItemIndex).split('/');
|
|
|
+ if (parts.length === 2) {
|
|
|
+ this.reviewsTotal.set(Number(parts[1]) || 0);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Debug: 打印第一条评论的字段名
|
|
|
+ if (reviewList.length) {
|
|
|
+ console.log('[Reviews] 第一条评论字段:', Object.keys(reviewList[0]));
|
|
|
+ console.log('[Reviews] ReviewsDate:', reviewList[0].ReviewsDate, 'UpdateTime:', reviewList[0].UpdateTime);
|
|
|
+ }
|
|
|
+
|
|
|
+ const sorted = this.applyKeywordSort(reviewList);
|
|
|
+ this.reviews.set(sorted);
|
|
|
+ this.reviewsHasMore.set(!hasKeyword && reviewList.length >= 20);
|
|
|
+ this.reviewsLoading.set(false);
|
|
|
+
|
|
|
+ // 后台保存到 DB(不阻塞 UI)
|
|
|
+ if (reviewList.length) {
|
|
|
+ this.saveReviewsToDb(reviewList);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ error: (err) => {
|
|
|
+ console.warn('[Reviews] API fetch failed:', err?.message);
|
|
|
+ this.reviewsLoading.set(false);
|
|
|
+ this.reviews.set([]);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private saveReviewsToDb(reviewList: any[]): void {
|
|
|
+ const items = reviewList.map(r => ({
|
|
|
+ reviewId: r.ReviewsLink || r.ReviewLink || `${this.asin}_${r.ConsumerName}_${r.ReviewsDate || r.ReviewDate}`,
|
|
|
+ asin: this.asin,
|
|
|
+ star: Number(r.Star) || 0,
|
|
|
+ title: r.Title || '',
|
|
|
+ content: r.Content || '',
|
|
|
+ consumerName: r.ConsumerName || '',
|
|
|
+ reviewDate: r.ReviewsDate || r.ReviewDate || r.CreateTime || '',
|
|
|
+ updateTime: r.UpdateTime || '',
|
|
|
+ helpful: Number(r.Helpful) || 0,
|
|
|
+ ivp: !!(r.IsVP || r.Ivp),
|
|
|
+ asinProperty: r.AsinProperty || '',
|
|
|
+ reviewsCountry: r.ReviewedCountry || r.ReviewsCountry || '',
|
|
|
+ reviewLink: r.ReviewsLink || r.ReviewLink || '',
|
|
|
+ consumerUrl: r.ConsumerURL || r.ConsumerUrl || '',
|
|
|
+ itemIndex: r.ItemIndex || '',
|
|
|
+ resource: r.Resource || '',
|
|
|
+ videos: r.Videos || '',
|
|
|
+ rawData: r
|
|
|
+ }));
|
|
|
+
|
|
|
+ this.dataStore.saveBatch('SorftimeReviews', items, 'reviewId').subscribe({
|
|
|
+ next: () => console.log('[Reviews] Saved', items.length, 'reviews to DB'),
|
|
|
+ error: (err: any) => console.warn('[Reviews] DB save failed:', err?.message)
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ openReviewDetail(review: any): void {
|
|
|
+ this.reviewDetailData.set(review);
|
|
|
+ this.showReviewDetail.set(true);
|
|
|
+ }
|
|
|
+
|
|
|
+ onOpenAiChat(event: { prompt: string; report: string }): void {
|
|
|
+ this.aiChatContext.set(event.prompt);
|
|
|
+ this.aiChatReportText.set(event.report);
|
|
|
+ this.aiAutoSend.set('');
|
|
|
+ this.showAiChat.set(true);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 从评论弹窗打开 AI 全量评论分析 */
|
|
|
+ openAiReviewAnalysis(): void {
|
|
|
+ this.aiAnalysisLoading.set(true);
|
|
|
+
|
|
|
+ // 从 DB 加载全量评论用于 AI 分析
|
|
|
+ this.dataStore.query('SorftimeReviews', { asin: this.asin }, {
|
|
|
+ limit: 500, orderBy: 'reviewDate', descending: true
|
|
|
+ }).subscribe({
|
|
|
+ next: (rows: any[]) => {
|
|
|
+ const allReviews = (rows || []).map((r: any) => {
|
|
|
+ // variant: 优先从 asinProperty 字段取,降级从 rawData 取
|
|
|
+ const variant = r.asinProperty || r.rawData?.AsinProperty || '';
|
|
|
+ return {
|
|
|
+ star: Number(r.star) || 0,
|
|
|
+ title: r.title || '',
|
|
|
+ content: r.content || '',
|
|
|
+ consumerName: r.consumerName || '',
|
|
|
+ variant,
|
|
|
+ date: r.reviewDate || '',
|
|
|
+ helpful: Number(r.helpful) || 0,
|
|
|
+ ivp: !!r.ivp
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ if (allReviews.length === 0 && this.aiReviewPrompt) {
|
|
|
+ // 无 DB 数据但有 insight 数据,直接用已有 prompt
|
|
|
+ this.aiChatContext.set(this.aiReviewPrompt);
|
|
|
+ this.aiChatReportText.set('');
|
|
|
+ this.aiAutoSend.set('请基于以上评论数据,进行完整的竞品评论分析');
|
|
|
+ this.closeReviewsModal();
|
|
|
+ this.showAiChat.set(true);
|
|
|
+ this.aiAnalysisLoading.set(false);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 构建全量评论分析 prompt
|
|
|
+ const fullPrompt = this.buildFullReviewAiPrompt(allReviews);
|
|
|
+ this.aiReviewPrompt = fullPrompt;
|
|
|
+ this.aiChatContext.set(fullPrompt);
|
|
|
+ this.aiChatReportText.set('');
|
|
|
+ this.aiAutoSend.set('请基于以上全量评论数据,进行完整的竞品评论分析');
|
|
|
+ this.closeReviewsModal();
|
|
|
+ this.showAiChat.set(true);
|
|
|
+ this.aiAnalysisLoading.set(false);
|
|
|
+ },
|
|
|
+ error: () => {
|
|
|
+ // 降级:使用已有的 aiReviewPrompt
|
|
|
+ if (this.aiReviewPrompt) {
|
|
|
+ this.aiChatContext.set(this.aiReviewPrompt);
|
|
|
+ this.aiChatReportText.set('');
|
|
|
+ this.aiAutoSend.set('请基于以上评论数据,进行竞品评论分析');
|
|
|
+ this.closeReviewsModal();
|
|
|
+ this.showAiChat.set(true);
|
|
|
+ }
|
|
|
+ this.aiAnalysisLoading.set(false);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 构建全量评论 AI 分析 prompt */
|
|
|
+ private buildFullReviewAiPrompt(reviews: Array<{
|
|
|
+ star: number; title: string; content: string;
|
|
|
+ consumerName: string; variant: string; date: string;
|
|
|
+ helpful: number; ivp: boolean;
|
|
|
+ }>): string {
|
|
|
+ const total = reviews.length;
|
|
|
+ const starDist: Record<number, number> = { 5: 0, 4: 0, 3: 0, 2: 0, 1: 0 };
|
|
|
+ reviews.forEach(r => { if (r.star >= 1 && r.star <= 5) starDist[r.star]++; });
|
|
|
+ const negCount = starDist[1] + starDist[2];
|
|
|
+ const posCount = starDist[4] + starDist[5];
|
|
|
+ const negRate = total > 0 ? ((negCount / total) * 100).toFixed(1) : '0';
|
|
|
+ const posRate = total > 0 ? ((posCount / total) * 100).toFixed(1) : '0';
|
|
|
+
|
|
|
+ // 按变体聚合
|
|
|
+ const variantMap = new Map<string, { pos: number; neg: number; issues: string[] }>();
|
|
|
+ reviews.forEach(r => {
|
|
|
+ const v = (r.variant || '').trim();
|
|
|
+ if (!v) return;
|
|
|
+ const entry = variantMap.get(v) || { pos: 0, neg: 0, issues: [] };
|
|
|
+ if (r.star <= 2) {
|
|
|
+ entry.neg++;
|
|
|
+ if (entry.issues.length < 3) entry.issues.push(r.title || r.content.slice(0, 60));
|
|
|
+ } else if (r.star >= 4) {
|
|
|
+ entry.pos++;
|
|
|
+ }
|
|
|
+ variantMap.set(v, entry);
|
|
|
+ });
|
|
|
+ const variantLines = Array.from(variantMap.entries())
|
|
|
+ .sort((a, b) => b[1].neg - a[1].neg)
|
|
|
+ .slice(0, 15)
|
|
|
+ .map(([v, d]) => `- ${v}: 好评${d.pos}条, 差评${d.neg}条${d.issues.length ? ', 问题: ' + d.issues.join(' / ') : ''}`)
|
|
|
+ .join('\n');
|
|
|
+
|
|
|
+ // 完整评论样本(最多100条,优先差评)
|
|
|
+ const sorted = [...reviews].sort((a, b) => a.star - b.star);
|
|
|
+ const sampleReviews = sorted.slice(0, 100);
|
|
|
+ const reviewLines = sampleReviews.map(r => {
|
|
|
+ const label = r.consumerName || '匿名用户';
|
|
|
+ const variantTag = r.variant ? `[${r.variant}]` : '';
|
|
|
+ const starTag = '★'.repeat(Math.max(1, r.star)) + '☆'.repeat(Math.max(0, 5 - r.star));
|
|
|
+ const meta: string[] = [];
|
|
|
+ if (r.date) meta.push(`日期:${r.date}`);
|
|
|
+ if (r.helpful > 0) meta.push(`${r.helpful}人觉得有用`);
|
|
|
+ if (r.ivp) meta.push('已验证购买');
|
|
|
+ const metaStr = meta.length ? ` (${meta.join(', ')})` : '';
|
|
|
+ return `- 【${label}${variantTag}】${starTag}${metaStr}\n 标题: ${r.title || '(无标题)'}\n 内容: ${(r.content || '(无内容)').slice(0, 300)}`;
|
|
|
+ }).join('\n');
|
|
|
+
|
|
|
+ return [
|
|
|
+ `【竞品全量评论AI分析】`,
|
|
|
+ `竞品ASIN: ${this.asin}`,
|
|
|
+ `产品: ${this.product?.title || ''}`,
|
|
|
+ `品牌: ${this.product?.brand || ''}`,
|
|
|
+ `价格: $${this.product?.salesPrice || this.product?.price || 0}`,
|
|
|
+ `评分: ${this.product?.ratings || 0} (${this.product?.ratingsCount || 0}条评价)`,
|
|
|
+ '',
|
|
|
+ `## 评论概览`,
|
|
|
+ `- 总评论数: ${total}`,
|
|
|
+ `- 星级分布: 5★=${starDist[5]}, 4★=${starDist[4]}, 3★=${starDist[3]}, 2★=${starDist[2]}, 1★=${starDist[1]}`,
|
|
|
+ `- 好评率: ${posRate}% | 差评率: ${negRate}%`,
|
|
|
+ '',
|
|
|
+ `## 变体/SKU维度分布:`,
|
|
|
+ variantLines || '(无变体信息)',
|
|
|
+ '',
|
|
|
+ `## 评论样本(${sampleReviews.length}条,优先展示差评):`,
|
|
|
+ reviewLines || '无',
|
|
|
+ '',
|
|
|
+ '【重要格式要求】引用评论时,必须使用【评论者姓名[变体信息]】格式标注评论者。引用原文时用「」括起来。',
|
|
|
+ '',
|
|
|
+ '请基于以上全量评论数据,进行完整的竞品评论分析,输出以下内容:',
|
|
|
+ '1) **差评痛点深度分析** - 按问题类型分类(质量/尺码/材质/物流/描述不符等),标注严重程度和出现频率,引用具体评论佐证;',
|
|
|
+ '2) **好评亮点提炼** - 消费者最认可的产品优势,按变体维度分析差异,引用具体评论;',
|
|
|
+ '3) **变体/SKU差异分析** - 哪些尺码/颜色差评集中?哪些变体口碑最好?给出变体优化建议;',
|
|
|
+ '4) **我方差异化切入点** - 基于竞品痛点,给出具体可落地的产品开发和改良建议;',
|
|
|
+ '5) **Listing优化参考** - 从评论中提炼高频消费者语言和卖点关键词,用于优化标题、五点描述和A+页面;',
|
|
|
+ '6) **风险预警** - 评估该竞品的整体风险等级,识别可能导致退货/差评的关键问题。'
|
|
|
+ ].join('\n');
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- 翻译 ----
|
|
|
+
|
|
|
+ toggleTranslation(): void {
|
|
|
+ if (this.isTranslated()) {
|
|
|
+ this.isTranslated.set(false);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // 检查是否已有翻译缓存
|
|
|
+ const hasContent = Array.from(this.translatedMap().values()).some(t => t.title || t.content);
|
|
|
+ if (hasContent) {
|
|
|
+ this.isTranslated.set(true);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.translateReviews();
|
|
|
+ }
|
|
|
+
|
|
|
+ private async translateReviews(): Promise<void> {
|
|
|
+ this.isTranslating.set(true);
|
|
|
+ const currentReviews = this.reviews();
|
|
|
+ const tMap = new Map<number, { title: string; content: string }>();
|
|
|
+
|
|
|
+ for (let i = 0; i < currentReviews.length; i++) {
|
|
|
+ const r = currentReviews[i];
|
|
|
+ const tTitle = await this.translateText(r.Title || '');
|
|
|
+ const tContent = await this.translateText(r.Content || '');
|
|
|
+ tMap.set(i, { title: tTitle, content: tContent });
|
|
|
+
|
|
|
+ // 保存翻译到 DB
|
|
|
+ if (r._objectId && (tTitle || tContent)) {
|
|
|
+ this.dataStore.save('SorftimeReviews', {
|
|
|
+ objectId: r._objectId,
|
|
|
+ translatedTitle: tTitle,
|
|
|
+ translatedContent: tContent
|
|
|
+ }).subscribe();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ this.translatedMap.set(tMap);
|
|
|
+ this.isTranslated.set(true);
|
|
|
+ this.isTranslating.set(false);
|
|
|
+ }
|
|
|
+
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- 评论显示辅助 ----
|
|
|
+
|
|
|
+ getStars(rating: number): string {
|
|
|
+ const r = Math.round(rating);
|
|
|
+ return '★'.repeat(r) + '☆'.repeat(5 - r);
|
|
|
+ }
|
|
|
+
|
|
|
+ getReviewDate(review: any): string {
|
|
|
+ // 使用 ReviewsDate(实际评论日期),UpdateTime 是系统更新时间不准确
|
|
|
+ const direct = review.ReviewsDate || review.ReviewDate || review.reviewDate || '';
|
|
|
+ if (direct) return direct;
|
|
|
+
|
|
|
+ // 从 rawData 提取(DB 缓存的原始 Sorftime 数据)
|
|
|
+ const raw = review.rawData || review._rawData;
|
|
|
+ if (raw) {
|
|
|
+ const rawDate = raw.ReviewsDate || raw.ReviewDate || '';
|
|
|
+ if (rawDate) return rawDate;
|
|
|
+ }
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+
|
|
|
+ formatReviewDate(dateStr: string): string {
|
|
|
+ if (!dateStr) return '-';
|
|
|
+ const s = String(dateStr).trim();
|
|
|
+
|
|
|
+ // "2026-02-06 17:39" 格式 — 已精确到分钟,直接返回
|
|
|
+ if (/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}/.test(s)) {
|
|
|
+ return s;
|
|
|
+ }
|
|
|
+
|
|
|
+ // "20260204" 格式 — 转为 YYYY-MM-DD
|
|
|
+ if (s.length === 8 && /^\d{8}$/.test(s)) {
|
|
|
+ return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const d = new Date(s);
|
|
|
+ if (!isNaN(d.getTime())) {
|
|
|
+ return d.toLocaleString('zh-CN', {
|
|
|
+ year: 'numeric', month: '2-digit', day: '2-digit',
|
|
|
+ hour: '2-digit', minute: '2-digit'
|
|
|
+ });
|
|
|
+ }
|
|
|
+ } catch { }
|
|
|
+ return s;
|
|
|
+ }
|
|
|
+
|
|
|
+ getReviewTitle(review: any, index: number): string {
|
|
|
+ if (this.isTranslated()) {
|
|
|
+ const t = this.translatedMap().get(index);
|
|
|
+ if (t?.title) return t.title;
|
|
|
+ }
|
|
|
+ return review.Title || '';
|
|
|
+ }
|
|
|
+
|
|
|
+ getReviewContent(review: any, index: number): string {
|
|
|
+ if (this.isTranslated()) {
|
|
|
+ const t = this.translatedMap().get(index);
|
|
|
+ if (t?.content) return t.content;
|
|
|
+ }
|
|
|
+ return review.Content || '';
|
|
|
+ }
|
|
|
+
|
|
|
+ isReviewMatchingKeyword(review: any): boolean {
|
|
|
+ const terms = this.reviewMatchTerms();
|
|
|
+ if (!terms.length) return false;
|
|
|
+ const text = `${review.Title || ''} ${review.Content || ''}`.toLowerCase();
|
|
|
+ return terms.some(t => text.includes(t));
|
|
|
+ }
|
|
|
+
|
|
|
+ private applyKeywordSort(reviews: any[]): any[] {
|
|
|
+ const terms = this.reviewMatchTerms();
|
|
|
+ if (!terms.length) return reviews;
|
|
|
+ return [...reviews].sort((a, b) => {
|
|
|
+ const aText = `${a.Title || a.title || ''} ${a.Content || a.content || ''}`.toLowerCase();
|
|
|
+ const bText = `${b.Title || b.title || ''} ${b.Content || b.content || ''}`.toLowerCase();
|
|
|
+ const aScore = terms.reduce((s, t) => s + (aText.includes(t) ? 1 : 0), 0);
|
|
|
+ const bScore = terms.reduce((s, t) => s + (bText.includes(t) ? 1 : 0), 0);
|
|
|
+ if (aScore !== bScore) return bScore - aScore;
|
|
|
+ return (a.Star || 5) - (b.Star || 5);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private extractKeywordTerms(keyword: string): string[] {
|
|
|
+ const details = this.reviewInsight()?.negativeDetails;
|
|
|
+ return MonitoringDataService.buildSearchTerms(keyword, details);
|
|
|
+ }
|
|
|
+
|
|
|
+ goBack(): void {
|
|
|
+ this.location.back();
|
|
|
+ }
|
|
|
+
|
|
|
+ selectDetailSection(sectionId: string): void {
|
|
|
+ this.activeDetailSection.set(sectionId);
|
|
|
+ }
|
|
|
+
|
|
|
+ formatNumber(num: number): string {
|
|
|
+ if (!num) return '0';
|
|
|
+ if (num >= 10000) return (num / 10000).toFixed(1) + 'w';
|
|
|
+ if (num >= 1000) return (num / 1000).toFixed(1) + 'k';
|
|
|
+ return num.toLocaleString();
|
|
|
+ }
|
|
|
+
|
|
|
+ formatPrice(val: number): string {
|
|
|
+ return (val || 0).toFixed(2);
|
|
|
+ }
|
|
|
+
|
|
|
+ formatDateTime(isoStr: string): string {
|
|
|
+ if (!isoStr) return '';
|
|
|
+ try {
|
|
|
+ const d = new Date(isoStr);
|
|
|
+ if (isNaN(d.getTime())) return '';
|
|
|
+ const pad = (n: number) => n.toString().padStart(2, '0');
|
|
|
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
|
+ } catch {
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ getCategoryName(category: any[]): string {
|
|
|
+ if (!category || !category.length) return '-';
|
|
|
+ const cat = category[0];
|
|
|
+ if (Array.isArray(cat)) return cat[0] || '-';
|
|
|
+ return String(cat);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 从 product 对象中提取 VariationASIN 数组 */
|
|
|
+ getVariationASINs(): string[] {
|
|
|
+ const raw = this.product?.variationASIN ?? [];
|
|
|
+ if (Array.isArray(raw)) return raw.filter((v: any) => typeof v === 'string' && v.length > 0);
|
|
|
+ if (typeof raw === 'string') {
|
|
|
+ try { return JSON.parse(raw); } catch { return []; }
|
|
|
+ }
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+
|
|
|
+ getBsrCategoryName(bsrCategory: any[]): string {
|
|
|
+ if (!bsrCategory || !bsrCategory.length) return '-';
|
|
|
+ const cat = bsrCategory[0];
|
|
|
+ if (Array.isArray(cat)) {
|
|
|
+ const name = cat[0] || '-';
|
|
|
+ const rank = cat[2] ? `#${cat[2]}` : '';
|
|
|
+ return rank ? `${name} ${rank}` : name;
|
|
|
+ }
|
|
|
+ return String(cat);
|
|
|
+ }
|
|
|
+
|
|
|
+ hasRatingDistribution(): boolean {
|
|
|
+ return !!(this.product?.fiveStartRatings || this.product?.fourStartRatings ||
|
|
|
+ this.product?.threeStartRatings || this.product?.twoStartRatings ||
|
|
|
+ this.product?.oneStartRatings);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 1星数量多于2星时提示关注 VOC */
|
|
|
+ hasLowStarAnomaly(): boolean {
|
|
|
+ const one = this.product?.oneStartRatings ?? 0;
|
|
|
+ const two = this.product?.twoStartRatings ?? 0;
|
|
|
+ return one > two && one > 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ getRatingPercent(stars: number): number {
|
|
|
+ const total = (this.product?.fiveStartRatings || 0) +
|
|
|
+ (this.product?.fourStartRatings || 0) +
|
|
|
+ (this.product?.threeStartRatings || 0) +
|
|
|
+ (this.product?.twoStartRatings || 0) +
|
|
|
+ (this.product?.oneStartRatings || 0);
|
|
|
+
|
|
|
+ if (!total) return 0;
|
|
|
+
|
|
|
+ const counts: Record<number, number> = {
|
|
|
+ 5: this.product?.fiveStartRatings || 0,
|
|
|
+ 4: this.product?.fourStartRatings || 0,
|
|
|
+ 3: this.product?.threeStartRatings || 0,
|
|
|
+ 2: this.product?.twoStartRatings || 0,
|
|
|
+ 1: this.product?.oneStartRatings || 0
|
|
|
+ };
|
|
|
+
|
|
|
+ return ((counts[stars] || 0) / total) * 100;
|
|
|
+ }
|
|
|
+}
|