|
@@ -5,7 +5,10 @@ import type { EChartsOption } from 'echarts';
|
|
|
import { NgxEchartsModule } from 'ngx-echarts';
|
|
import { NgxEchartsModule } from 'ngx-echarts';
|
|
|
import { Subject, takeUntil } from 'rxjs';
|
|
import { Subject, takeUntil } from 'rxjs';
|
|
|
import { DomesticDataset, DomesticProduct, DomesticReview } from '../../app/core/models/domestic.models';
|
|
import { DomesticDataset, DomesticProduct, DomesticReview } from '../../app/core/models/domestic.models';
|
|
|
|
|
+import { RUNTIME_CONFIG } from '../../app/core/config/runtime-config';
|
|
|
import { DomesticDatasetService } from '../../app/core/services/domestic-dataset.service';
|
|
import { DomesticDatasetService } from '../../app/core/services/domestic-dataset.service';
|
|
|
|
|
+import type { CompetitorListingOverviewRow } from '../competitor-listing-monitor/models/competitor-listing.models';
|
|
|
|
|
+import { CompetitorListingApiService } from '../competitor-listing-monitor/services/competitor-listing-api.service';
|
|
|
import { ContentCardComponent } from '../shared/components/content-card/content-card.component';
|
|
import { ContentCardComponent } from '../shared/components/content-card/content-card.component';
|
|
|
import { DataTableShellComponent } from '../shared/components/data-table-shell/data-table-shell.component';
|
|
import { DataTableShellComponent } from '../shared/components/data-table-shell/data-table-shell.component';
|
|
|
import { EmptyStateComponent } from '../shared/components/empty-state/empty-state.component';
|
|
import { EmptyStateComponent } from '../shared/components/empty-state/empty-state.component';
|
|
@@ -114,15 +117,15 @@ interface OwnComparisonRow {
|
|
|
|
|
|
|
|
<section class="detail-tab-panel" [hidden]="activeDetailTab !== 'overview'">
|
|
<section class="detail-tab-panel" [hidden]="activeDetailTab !== 'overview'">
|
|
|
<div class="metric-grid">
|
|
<div class="metric-grid">
|
|
|
- <app-summary-metric-card label="当前价" [value]="currentPriceLabel" icon="元" tone="amber" description="品牌品类检索快照"></app-summary-metric-card>
|
|
|
|
|
- <app-summary-metric-card label="销量信号" [value]="salesSignal" icon="↗" tone="purple" description="平台公开销量文案"></app-summary-metric-card>
|
|
|
|
|
|
|
+ <app-summary-metric-card label="当前价" [value]="currentPriceLabel" icon="元" tone="amber" [description]="competitorPriceDescription"></app-summary-metric-card>
|
|
|
|
|
+ <app-summary-metric-card label="销量信号" [value]="salesSignal" icon="↗" tone="purple" description="平台公开销量文案;未返回时不作推算"></app-summary-metric-card>
|
|
|
<app-summary-metric-card label="有效评论" [value]="number(reviews.length)" icon="□" tone="blue" [description]="reviewQualityDescription"></app-summary-metric-card>
|
|
<app-summary-metric-card label="有效评论" [value]="number(reviews.length)" icon="□" tone="blue" [description]="reviewQualityDescription"></app-summary-metric-card>
|
|
|
<app-summary-metric-card label="平均评分" [value]="averageRating ? averageRating.toFixed(1) : '-'" unit="/ 5" icon="★" tone="green" [sampleSize]="ratedReviews.length"></app-summary-metric-card>
|
|
<app-summary-metric-card label="平均评分" [value]="averageRating ? averageRating.toFixed(1) : '-'" unit="/ 5" icon="★" tone="green" [sampleSize]="ratedReviews.length"></app-summary-metric-card>
|
|
|
<app-summary-metric-card label="4-5 星占比" [value]="percent(positiveRate)" icon="↑" tone="purple" description="评分分组,不等同正文情绪"></app-summary-metric-card>
|
|
<app-summary-metric-card label="4-5 星占比" [value]="percent(positiveRate)" icon="↑" tone="purple" description="评分分组,不等同正文情绪"></app-summary-metric-card>
|
|
|
<app-summary-metric-card label="关联本品" [value]="number(ownProducts.length)" icon="⇄" tone="amber" [description]="number(relations.length) + ' 条映射关系'"></app-summary-metric-card>
|
|
<app-summary-metric-card label="关联本品" [value]="number(ownProducts.length)" icon="⇄" tone="amber" [description]="number(relations.length) + ' 条映射关系'"></app-summary-metric-card>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <app-content-card class="own-vs-card" title="本品关联均值 vs 竞品" subtitle="关联本品的经营汇总与竞品市场快照并排对比;仅价格同量纲可计算差异" tone="blue">
|
|
|
|
|
|
|
+ <app-content-card class="own-vs-card" title="本品关联均值 vs 竞品" subtitle="关联本品的经营汇总与竞品 Listing 快照并排对比;仅价格同量纲可计算差异" tone="blue">
|
|
|
<div class="vs-grid">
|
|
<div class="vs-grid">
|
|
|
<div class="vs-col vs-own">
|
|
<div class="vs-col vs-own">
|
|
|
<div class="vs-col-title"><span class="vs-tag own">本品关联</span><strong>{{ number(ownProducts.length) }} 个德玛仕商品</strong></div>
|
|
<div class="vs-col-title"><span class="vs-tag own">本品关联</span><strong>{{ number(ownProducts.length) }} 个德玛仕商品</strong></div>
|
|
@@ -139,16 +142,16 @@ interface OwnComparisonRow {
|
|
|
<dl class="vs-list">
|
|
<dl class="vs-list">
|
|
|
<div><dt>当前价</dt><dd>{{ competitorPriceLabel }}</dd></div>
|
|
<div><dt>当前价</dt><dd>{{ competitorPriceLabel }}</dd></div>
|
|
|
<div><dt>销量信号</dt><dd [title]="salesSignal">{{ salesSignal }}</dd></div>
|
|
<div><dt>销量信号</dt><dd [title]="salesSignal">{{ salesSignal }}</dd></div>
|
|
|
- <div><dt>成交经营数据</dt><dd>无同量纲数据</dd></div>
|
|
|
|
|
- <div><dt>数据口径</dt><dd>市场搜索快照</dd></div>
|
|
|
|
|
|
|
+ <div><dt>成交经营数据</dt><dd>无公开同量纲数据</dd></div>
|
|
|
|
|
+ <div><dt>数据口径</dt><dd>{{ competitorDataSourceLabel }}</dd></div>
|
|
|
</dl>
|
|
</dl>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
<div class="vs-diff-row">
|
|
<div class="vs-diff-row">
|
|
|
- <span class="vs-diff-label">参考价差异(本品成交均价·内部经营数据 vs 竞品挂牌价·市场快照)</span>
|
|
|
|
|
|
|
+ <span class="vs-diff-label">参考价差异(本品成交均价·内部经营数据 vs 竞品挂牌价·Listing 快照)</span>
|
|
|
<span class="diff-badge" [ngClass]="ownAvgVsCompetitor.cls">{{ ownAvgVsCompetitor.label }}</span>
|
|
<span class="diff-badge" [ngClass]="ownAvgVsCompetitor.cls">{{ ownAvgVsCompetitor.label }}</span>
|
|
|
</div>
|
|
</div>
|
|
|
- <p class="vs-note">仅「参考价」为同量纲可比(均为 元/件,本品为成交均价、竞品为挂牌价快照);竞品无成交金额/件数经营数据,销量为平台文案区间,故其余指标仅并列展示、不计算百分比差异。</p>
|
|
|
|
|
|
|
+ <p class="vs-note">仅「参考价」为同量纲可比(均为 元/件,本品为成交均价、竞品为挂牌价快照);竞品无公开成交金额/件数,销量信号仅展示平台实际返回文案,不作反推,故其余指标不计算百分比差异。</p>
|
|
|
</app-content-card>
|
|
</app-content-card>
|
|
|
|
|
|
|
|
<div class="detail-grid">
|
|
<div class="detail-grid">
|
|
@@ -191,7 +194,7 @@ interface OwnComparisonRow {
|
|
|
</tbody>
|
|
</tbody>
|
|
|
</table>
|
|
</table>
|
|
|
<ng-container dataTableFooter>
|
|
<ng-container dataTableFooter>
|
|
|
- <span class="table-foot-note">对比口径:本品成交均价 = 成交金额 ÷ 成交件数(内部经营数据);竞品当前价来自市场搜索快照。绿色 = 本品均价低于竞品当前价,红色 = 高于,仅反映相对定价、不构成优劣结论。竞品侧无成交金额/件数经营数据,故仅在本列做价格对比。</span>
|
|
|
|
|
|
|
+ <span class="table-foot-note">对比口径:本品成交均价 = 成交金额 ÷ 成交件数(内部经营数据);竞品当前价优先来自 Listing 监控快照。绿色 = 本品均价低于竞品当前价,红色 = 高于,仅反映相对定价、不构成优劣结论。竞品侧无公开成交金额/件数,故仅在本列做价格对比。</span>
|
|
|
</ng-container>
|
|
</ng-container>
|
|
|
</app-data-table-shell>
|
|
</app-data-table-shell>
|
|
|
</section>
|
|
</section>
|
|
@@ -288,6 +291,7 @@ interface OwnComparisonRow {
|
|
|
})
|
|
})
|
|
|
export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
|
|
export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
|
|
|
private readonly datasetService = inject(DomesticDatasetService);
|
|
private readonly datasetService = inject(DomesticDatasetService);
|
|
|
|
|
+ private readonly listingApi = inject(CompetitorListingApiService);
|
|
|
private readonly route = inject(ActivatedRoute);
|
|
private readonly route = inject(ActivatedRoute);
|
|
|
private readonly router = inject(Router);
|
|
private readonly router = inject(Router);
|
|
|
private readonly cdr = inject(ChangeDetectorRef);
|
|
private readonly cdr = inject(ChangeDetectorRef);
|
|
@@ -353,6 +357,7 @@ export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
|
|
|
activeDetailTab = 'overview';
|
|
activeDetailTab = 'overview';
|
|
|
dataset?: DomesticDataset;
|
|
dataset?: DomesticDataset;
|
|
|
product?: DomesticProduct;
|
|
product?: DomesticProduct;
|
|
|
|
|
+ listingRow?: CompetitorListingOverviewRow;
|
|
|
selectedReview?: DomesticReview;
|
|
selectedReview?: DomesticReview;
|
|
|
showAllReviews = false;
|
|
showAllReviews = false;
|
|
|
rawReviews: DomesticReview[] = [];
|
|
rawReviews: DomesticReview[] = [];
|
|
@@ -385,9 +390,23 @@ export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
ngOnInit(): void {
|
|
ngOnInit(): void {
|
|
|
|
|
+ const productId = this.route.snapshot.paramMap.get('productId') || '';
|
|
|
|
|
+ this.listingApi.overview(RUNTIME_CONFIG.domesticWorkspaceId)
|
|
|
|
|
+ .pipe(takeUntil(this.destroy$))
|
|
|
|
|
+ .subscribe({
|
|
|
|
|
+ next: (overview) => {
|
|
|
|
|
+ this.listingRow = overview.items.find((item) => item.productId === productId);
|
|
|
|
|
+ this.refreshPriceComparisons();
|
|
|
|
|
+ if (this.product) this.aiReviewPrompt = this.buildAiReviewPrompt();
|
|
|
|
|
+ this.cdr.markForCheck();
|
|
|
|
|
+ },
|
|
|
|
|
+ error: () => {
|
|
|
|
|
+ this.listingRow = undefined;
|
|
|
|
|
+ this.cdr.markForCheck();
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
this.datasetService.dataset$.pipe(takeUntil(this.destroy$)).subscribe((dataset) => {
|
|
this.datasetService.dataset$.pipe(takeUntil(this.destroy$)).subscribe((dataset) => {
|
|
|
this.dataset = dataset;
|
|
this.dataset = dataset;
|
|
|
- const productId = this.route.snapshot.paramMap.get('productId') || '';
|
|
|
|
|
this.product = dataset.products.find((product) => product.role === 'competitor' && product.productId === productId);
|
|
this.product = dataset.products.find((product) => product.role === 'competitor' && product.productId === productId);
|
|
|
this.loaded = true;
|
|
this.loaded = true;
|
|
|
if (!this.product) {
|
|
if (!this.product) {
|
|
@@ -397,10 +416,7 @@ export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
|
|
|
this.relations = dataset.relations.filter((relation) => relation.competitorProductId === productId);
|
|
this.relations = dataset.relations.filter((relation) => relation.competitorProductId === productId);
|
|
|
const ownIds = new Set(this.relations.map((relation) => relation.ownProductId));
|
|
const ownIds = new Set(this.relations.map((relation) => relation.ownProductId));
|
|
|
this.ownProducts = dataset.products.filter((product) => product.role === 'own' && ownIds.has(product.productId));
|
|
this.ownProducts = dataset.products.filter((product) => product.role === 'own' && ownIds.has(product.productId));
|
|
|
- this.ownComparisonRows = this.ownProducts.map((own) => ({
|
|
|
|
|
- own,
|
|
|
|
|
- diff: this.priceDiffState(this.ownUnitPrice(own)),
|
|
|
|
|
- }));
|
|
|
|
|
|
|
+ this.refreshPriceComparisons();
|
|
|
this.rawReviews = (dataset.reviews ?? [])
|
|
this.rawReviews = (dataset.reviews ?? [])
|
|
|
.filter((review) => review.productId === productId)
|
|
.filter((review) => review.productId === productId)
|
|
|
.sort((left, right) => String(right.reviewDate ?? '').localeCompare(String(left.reviewDate ?? '')));
|
|
.sort((left, right) => String(right.reviewDate ?? '').localeCompare(String(left.reviewDate ?? '')));
|
|
@@ -425,18 +441,35 @@ export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
get statusLabel(): string {
|
|
get statusLabel(): string {
|
|
|
|
|
+ const availability = this.listingRow?.currentSnapshot?.availability;
|
|
|
|
|
+ if (availability === 'online') return '在售';
|
|
|
|
|
+ if (availability === 'offline') return '已下架';
|
|
|
if (this.product?.detail?.skuStatus === '1') return '在售';
|
|
if (this.product?.detail?.skuStatus === '1') return '在售';
|
|
|
if (this.product?.detail?.skuStatus) return `状态 ${this.product.detail.skuStatus}`;
|
|
if (this.product?.detail?.skuStatus) return `状态 ${this.product.detail.skuStatus}`;
|
|
|
return this.product?.market ? '市场快照已采集' : '状态待确认';
|
|
return this.product?.market ? '市场快照已采集' : '状态待确认';
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
get currentPriceLabel(): string {
|
|
get currentPriceLabel(): string {
|
|
|
- const price = this.product?.market?.currentPrice || this.product?.summary.averageUnitPrice || 0;
|
|
|
|
|
- return price > 0 ? this.currency(price) : '-';
|
|
|
|
|
|
|
+ return this.competitorPriceLabel;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
get salesSignal(): string {
|
|
get salesSignal(): string {
|
|
|
- return this.product?.market?.monthSalesText || this.product?.market?.salesText || '-';
|
|
|
|
|
|
|
+ return this.product?.market?.monthSalesText || this.product?.market?.salesText || '平台未返回';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ get competitorDataSourceLabel(): string {
|
|
|
|
|
+ if (this.listingRow?.currentSnapshot) return '竞品 Listing 监控快照';
|
|
|
|
|
+ if (this.product?.market) return '市场搜索快照';
|
|
|
|
|
+ return '暂无可用快照';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ get competitorPriceDescription(): string {
|
|
|
|
|
+ const observedAt = this.listingRow?.currentSnapshot?.observedAt;
|
|
|
|
|
+ return observedAt
|
|
|
|
|
+ ? `Listing 监控快照 · ${this.dateTime(observedAt)}`
|
|
|
|
|
+ : this.product?.market
|
|
|
|
|
+ ? '市场搜索快照'
|
|
|
|
|
+ : '平台未返回有效价格';
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
get weightLabel(): string {
|
|
get weightLabel(): string {
|
|
@@ -512,8 +545,10 @@ export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
|
|
|
|
|
|
|
|
// ── 本品 vs 竞品 对比(组件内基于现有字段派生,不改动数据获取逻辑)──
|
|
// ── 本品 vs 竞品 对比(组件内基于现有字段派生,不改动数据获取逻辑)──
|
|
|
|
|
|
|
|
- /** 竞品参考价:优先市场快照当前价,回退均价字段;无有效值返回 null。 */
|
|
|
|
|
|
|
+ /** 竞品参考价:优先 Listing 监控快照,回退旧市场快照;无有效值返回 null。 */
|
|
|
get competitorPrice(): number | null {
|
|
get competitorPrice(): number | null {
|
|
|
|
|
+ const priceCents = this.listingRow?.currentSnapshot?.priceCents;
|
|
|
|
|
+ if (priceCents != null && Number.isFinite(priceCents) && priceCents > 0) return priceCents / 100;
|
|
|
const price = this.product?.market?.currentPrice || this.product?.summary.averageUnitPrice || 0;
|
|
const price = this.product?.market?.currentPrice || this.product?.summary.averageUnitPrice || 0;
|
|
|
return price > 0 ? price : null;
|
|
return price > 0 ? price : null;
|
|
|
}
|
|
}
|
|
@@ -526,6 +561,13 @@ export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
|
|
|
return this.product?.brand || '竞品';
|
|
return this.product?.brand || '竞品';
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private refreshPriceComparisons(): void {
|
|
|
|
|
+ this.ownComparisonRows = this.ownProducts.map((own) => ({
|
|
|
|
|
+ own,
|
|
|
|
|
+ diff: this.priceDiffState(this.ownUnitPrice(own)),
|
|
|
|
|
+ }));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
get ownLinkedGmv(): number {
|
|
get ownLinkedGmv(): number {
|
|
|
return this.ownProducts.reduce((sum, own) => sum + own.summary.gmv, 0);
|
|
return this.ownProducts.reduce((sum, own) => sum + own.summary.gmv, 0);
|
|
|
}
|
|
}
|
|
@@ -660,7 +702,8 @@ export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
|
|
|
`当前价快照: ${this.currentPriceLabel}`,
|
|
`当前价快照: ${this.currentPriceLabel}`,
|
|
|
`平台销量信号: ${this.salesSignal}`,
|
|
`平台销量信号: ${this.salesSignal}`,
|
|
|
`店铺: ${this.product.market?.shopName || '-'}`,
|
|
`店铺: ${this.product.market?.shopName || '-'}`,
|
|
|
- `市场快照时间: ${this.dateTime(this.product.market?.collectedAt)}`,
|
|
|
|
|
|
|
+ `竞品快照口径: ${this.competitorDataSourceLabel}`,
|
|
|
|
|
+ `竞品快照时间: ${this.dateTime(this.listingRow?.currentSnapshot?.observedAt || this.product.market?.collectedAt)}`,
|
|
|
`在售状态: ${this.statusLabel}`,
|
|
`在售状态: ${this.statusLabel}`,
|
|
|
`原始入库评论: ${this.rawReviews.length}`,
|
|
`原始入库评论: ${this.rawReviews.length}`,
|
|
|
`有效评论: ${this.reviews.length}`,
|
|
`有效评论: ${this.reviews.length}`,
|
|
@@ -677,7 +720,7 @@ export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
|
|
|
'【真实评论证据】',
|
|
'【真实评论证据】',
|
|
|
reviewEvidence,
|
|
reviewEvidence,
|
|
|
'',
|
|
'',
|
|
|
- '【数据边界】当前价和销量文案来自本次市场搜索快照,可用于横向观察;价格历史与销量历史序列尚未入库,不得生成时间趋势,也不得根据德玛仕本品成交数据反推竞品销量。',
|
|
|
|
|
|
|
+ '【数据边界】当前价优先来自竞品 Listing 监控快照;销量文案仅在平台实际返回时展示。竞品无公开成交金额/件数,不得根据德玛仕本品经营数据或挂牌价反推竞品销量、GMV或市场份额。',
|
|
|
'',
|
|
'',
|
|
|
'请生成可视化报告:核心指标只使用上述真实统计;评分分布使用图表;证据卡引用评论原句;单独检查评分与正文情绪是否冲突;行动建议区分产品、详情页和数据采集动作。',
|
|
'请生成可视化报告:核心指标只使用上述真实统计;评分分布使用图表;证据卡引用评论原句;单独检查评分与正文情绪是否冲突;行动建议区分产品、详情页和数据采集动作。',
|
|
|
].join('\n');
|
|
].join('\n');
|