|
|
@@ -1,1417 +1,616 @@
|
|
|
-import { Component, OnInit } from '@angular/core';
|
|
|
import { CommonModule } from '@angular/common';
|
|
|
+import { Component, OnDestroy, OnInit } from '@angular/core';
|
|
|
import { FormsModule } from '@angular/forms';
|
|
|
-import { Router } from '@angular/router';
|
|
|
+import { forkJoin, Subject, take, takeUntil } from 'rxjs';
|
|
|
+import { DomesticDatasetService } from '../../app/core/services/domestic-dataset.service';
|
|
|
+import { DomesticDataset, DomesticProduct } from '../../app/core/models/domestic.models';
|
|
|
import {
|
|
|
- KnowledgeCenterDataService,
|
|
|
- KnowledgeDataSourceConnectionStatus,
|
|
|
- KnowledgeDataSourceStatus
|
|
|
-} from '../shared/services/knowledge-center-data.service';
|
|
|
-import { MsqDropdownComponent, MsqDropdownOption } from '../shared/components/msq-dropdown/msq-dropdown.component';
|
|
|
-import { MsqSearchBoxComponent } from '../shared/components/msq-search-box/msq-search-box.component';
|
|
|
-import { FetchCountDialogComponent, FetchDialogResult } from './fetch-count-dialog/fetch-count-dialog.component';
|
|
|
-import { CompetitorBackfillDialogComponent, BackfillScope, BackfillPreview, BackfillRunResult } from './competitor-backfill-dialog/competitor-backfill-dialog.component';
|
|
|
-import { ExportReviewsDialogComponent } from './export-reviews-dialog/export-reviews-dialog.component';
|
|
|
-import { PatternEditDialogComponent } from './pattern-edit-dialog/pattern-edit-dialog.component';
|
|
|
-import {
|
|
|
- KnowledgeBaseStats,
|
|
|
- CompetitorKnowledge,
|
|
|
- InfluencerKnowledge,
|
|
|
- KeywordKnowledge,
|
|
|
- PatternKnowledge,
|
|
|
- HotProductKnowledge,
|
|
|
- KnowledgeBaseFilter
|
|
|
-} from '../shared/interfaces/knowledge-center-interfaces';
|
|
|
-import { NeuButtonComponent } from '../shared/components/neu-button/neu-button.component';
|
|
|
-import { EntryCardComponent, EntryCardMetric } from '../shared/components/entry-card/entry-card.component';
|
|
|
-import { EntryCardGridComponent } from '../shared/components/entry-card/entry-card-grid.component';
|
|
|
+ CrudTableBulkAction,
|
|
|
+ CrudTableColumn,
|
|
|
+ CrudTableFilter,
|
|
|
+ CrudTableWorkbenchComponent,
|
|
|
+} from '../shared/components/crud-table-workbench/crud-table-workbench.component';
|
|
|
+import { PageHeaderComponent } from '../shared/components/page-header/page-header.component';
|
|
|
+import { ProductIdentityComponent } from '../shared/components/product-identity/product-identity.component';
|
|
|
import { SummaryMetricCardComponent } from '../shared/components/summary-metric-card/summary-metric-card.component';
|
|
|
-import { DataTableShellComponent } from '../shared/components/data-table-shell/data-table-shell.component';
|
|
|
-import { FilterBarComponent } from '../shared/components/filter-bar/filter-bar.component';
|
|
|
-import { FilterOptions } from '../shared/interfaces/voc-data.interface';
|
|
|
-
|
|
|
-type KnowledgeBaseType = 'competitor' | 'influencer' | 'keyword' | 'pattern' | 'hotproduct';
|
|
|
-
|
|
|
-type DataSourceItem = KnowledgeDataSourceConnectionStatus;
|
|
|
-
|
|
|
-interface DictionaryItem {
|
|
|
- id: string;
|
|
|
- name: string;
|
|
|
- termCount: number;
|
|
|
- lastUpdated: string;
|
|
|
- owner: string;
|
|
|
+import {
|
|
|
+ ProductKnowledgeItem,
|
|
|
+ ProductKnowledgeService,
|
|
|
+ ProductKnowledgeWrite,
|
|
|
+} from '../shared/services/product-knowledge.service';
|
|
|
+
|
|
|
+type KnowledgeTab = 'own' | 'featured' | 'competitor';
|
|
|
+
|
|
|
+interface KnowledgeMetric {
|
|
|
+ label: string;
|
|
|
+ value: string | number;
|
|
|
+ unit: string;
|
|
|
+ description: string;
|
|
|
+ tone: 'default' | 'blue' | 'green' | 'amber' | 'red' | 'purple';
|
|
|
}
|
|
|
|
|
|
@Component({
|
|
|
selector: 'app-knowledge-center',
|
|
|
standalone: true,
|
|
|
- imports: [CommonModule, FormsModule, FetchCountDialogComponent, CompetitorBackfillDialogComponent, ExportReviewsDialogComponent, PatternEditDialogComponent, MsqDropdownComponent, MsqSearchBoxComponent, NeuButtonComponent, EntryCardComponent, EntryCardGridComponent, SummaryMetricCardComponent, DataTableShellComponent, FilterBarComponent],
|
|
|
- template: `
|
|
|
- <div class="knowledge-center-container">
|
|
|
- <!-- Navigation and Search -->
|
|
|
- <div class="navigation-panel">
|
|
|
- <div class="panel-header">
|
|
|
- <h1>数据中心 - 自动化知识库</h1>
|
|
|
- <div class="search-box">
|
|
|
- <msq-search-box placeholder="搜索知识库内容..." [(ngModel)]="searchQuery" (valueChange)="onSearch()" (search)="onSearch()"></msq-search-box>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- Data Sources Connection Status -->
|
|
|
- <section class="data-sources-section">
|
|
|
- <header class="data-sources-header">
|
|
|
- <div>
|
|
|
- <h2>数据源连接状态</h2>
|
|
|
- <p>统一聚合 VOC / 市场 / 监测关键数据源,异常状态一目了然。</p>
|
|
|
- </div>
|
|
|
- <button class="ghost-btn" (click)="refreshDataSourceStatus()" [disabled]="isRefreshingDataSourceStatus">
|
|
|
- {{ isRefreshingDataSourceStatus ? '检测中...' : '刷新状态' }}
|
|
|
- </button>
|
|
|
- </header>
|
|
|
- <app-entry-card-grid columns="4" gap="sm">
|
|
|
- @for (source of dataSources; track source.id) {
|
|
|
- <app-entry-card
|
|
|
- [title]="source.name"
|
|
|
- [subtitle]="source.message"
|
|
|
- [icon]="getDataSourceIcon(source.status)"
|
|
|
- [badge]="getDataSourceStatusLabel(source.status)"
|
|
|
- [tone]="getDataSourceTone(source.status)"
|
|
|
- [compact]="true"
|
|
|
- [metrics]="getDataSourceMetrics(source)">
|
|
|
- </app-entry-card>
|
|
|
- }
|
|
|
- </app-entry-card-grid>
|
|
|
- </section>
|
|
|
-
|
|
|
- <!-- Overview Statistics -->
|
|
|
- <div class="overview-section">
|
|
|
- <div class="stats-grid">
|
|
|
- <app-summary-metric-card
|
|
|
- class="knowledge-stat-card"
|
|
|
- [class.active]="selectedKnowledgeBase === 'competitor'"
|
|
|
- tone="blue" icon="🏢" label="竞品库"
|
|
|
- size="sm"
|
|
|
- [compact]="true"
|
|
|
- [clickable]="true"
|
|
|
- [value]="stats?.competitorCount || 0"
|
|
|
- [description]="'本周新增 ' + (stats?.competitorWeeklyGrowth || 0)"
|
|
|
- (activate)="selectKnowledgeBase('competitor')">
|
|
|
- </app-summary-metric-card>
|
|
|
- <app-summary-metric-card
|
|
|
- class="knowledge-stat-card"
|
|
|
- [class.active]="selectedKnowledgeBase === 'influencer'"
|
|
|
- tone="purple" icon="👤" label="红人库"
|
|
|
- size="sm"
|
|
|
- [compact]="true"
|
|
|
- [clickable]="true"
|
|
|
- [value]="stats?.influencerCount || 0"
|
|
|
- [description]="'本周新增 ' + (stats?.influencerWeeklyGrowth || 0)"
|
|
|
- (activate)="selectKnowledgeBase('influencer')">
|
|
|
- </app-summary-metric-card>
|
|
|
- <app-summary-metric-card
|
|
|
- class="knowledge-stat-card"
|
|
|
- [class.active]="selectedKnowledgeBase === 'keyword'"
|
|
|
- tone="amber" icon="🔑" label="关键词库"
|
|
|
- size="sm"
|
|
|
- [compact]="true"
|
|
|
- [clickable]="true"
|
|
|
- [value]="stats?.keywordCount || 0"
|
|
|
- [description]="'本周新增 ' + (stats?.keywordWeeklyGrowth || 0)"
|
|
|
- (activate)="selectKnowledgeBase('keyword')">
|
|
|
- </app-summary-metric-card>
|
|
|
- <app-summary-metric-card
|
|
|
- class="knowledge-stat-card"
|
|
|
- [class.active]="selectedKnowledgeBase === 'pattern'"
|
|
|
- tone="green" icon="📐" label="版型库"
|
|
|
- size="sm"
|
|
|
- [compact]="true"
|
|
|
- [clickable]="true"
|
|
|
- [value]="stats?.patternCount || 0"
|
|
|
- [description]="'本周新增 ' + (stats?.patternWeeklyGrowth || 0)"
|
|
|
- (activate)="selectKnowledgeBase('pattern')">
|
|
|
- </app-summary-metric-card>
|
|
|
- <app-summary-metric-card
|
|
|
- class="knowledge-stat-card"
|
|
|
- [class.active]="selectedKnowledgeBase === 'hotproduct'"
|
|
|
- tone="red" icon="🔥" label="爆款库(BSR400)"
|
|
|
- size="sm"
|
|
|
- [compact]="true"
|
|
|
- [clickable]="true"
|
|
|
- [value]="stats?.hotProductCount || 0"
|
|
|
- [description]="'本周新增 ' + (stats?.hotProductWeeklyGrowth || 0)"
|
|
|
- (activate)="selectKnowledgeBase('hotproduct')">
|
|
|
- </app-summary-metric-card>
|
|
|
- <app-summary-metric-card
|
|
|
- class="knowledge-stat-card"
|
|
|
- tone="default" icon="📊" label="总数据量"
|
|
|
- size="sm"
|
|
|
- [compact]="true"
|
|
|
- [value]="formatNumber(stats?.totalRecords || 0)"
|
|
|
- [description]="'最后更新: ' + (stats?.lastUpdate || '-')">
|
|
|
- </app-summary-metric-card>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- Data Table -->
|
|
|
- <div class="data-section">
|
|
|
- <div class="data-header">
|
|
|
- <div class="data-title">
|
|
|
- <h2>{{ getKnowledgeBaseTitle() }}</h2>
|
|
|
- @if (selectedKnowledgeBase === 'competitor') {
|
|
|
- <span class="last-updated">
|
|
|
- 最后更新时间:{{ competitorLastUpdated ? formatDateTime(competitorLastUpdated) : '-' }}
|
|
|
- </span>
|
|
|
- }
|
|
|
- @if (selectedKnowledgeBase === 'influencer') {
|
|
|
- <span class="last-updated">
|
|
|
- 最后更新时间:{{ influencerLastUpdated ? formatDateTime(influencerLastUpdated) : '-' }}
|
|
|
- </span>
|
|
|
- }
|
|
|
- </div>
|
|
|
- <div class="data-actions">
|
|
|
- <app-neu-button size="sm" (click)="toggleFilter()">
|
|
|
- 筛选 {{ showFilter ? '▲' : '▼' }}
|
|
|
- </app-neu-button>
|
|
|
- <app-neu-button size="sm" color="primary" (click)="openExportReviewsDialog()">导出评论</app-neu-button>
|
|
|
- @if (selectedKnowledgeBase === 'influencer') {
|
|
|
- <app-neu-button size="sm" (click)="goVideoSearch()">🎬 视频搜索</app-neu-button>
|
|
|
- }
|
|
|
- @if (selectedKnowledgeBase === 'competitor') {
|
|
|
- <app-neu-button size="sm" (click)="onOpenBackfill()">补齐数据</app-neu-button>
|
|
|
- }
|
|
|
- <app-neu-button
|
|
|
- size="sm"
|
|
|
- color="primary"
|
|
|
- (click)="onAddData()"
|
|
|
- [disabled]="!canFetchByAddButton()">
|
|
|
- {{ (isFetchingCompetitor || isFetchingInfluencer) && selectedKnowledgeBase !== 'pattern' ? '拉取中...' : '+ 添加数据' }}
|
|
|
- </app-neu-button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- @if (showFilter) {
|
|
|
- <div class="filter-panel">
|
|
|
- @if (selectedKnowledgeBase === 'competitor') {
|
|
|
- <div class="filter-row">
|
|
|
- <div class="filter-group">
|
|
|
- <label>搜索</label>
|
|
|
- <input
|
|
|
- type="text"
|
|
|
- [(ngModel)]="filters.searchText"
|
|
|
- (input)="applyFilters()"
|
|
|
- placeholder="ASIN / 品牌 / 产品名"
|
|
|
- class="filter-input wide">
|
|
|
- </div>
|
|
|
- <div class="filter-group category-filter-group">
|
|
|
- <app-filter-bar
|
|
|
- class="knowledge-category-filter"
|
|
|
- [filters]="competitorCategoryFilters"
|
|
|
- [enableCategory]="true"
|
|
|
- [showSite]="false"
|
|
|
- [showStore]="false"
|
|
|
- [showTimeRange]="false"
|
|
|
- [showDatePicker]="false"
|
|
|
- [showActions]="false"
|
|
|
- (filtersChange)="onCompetitorCategoryFilterChange($event)">
|
|
|
- </app-filter-bar>
|
|
|
- </div>
|
|
|
- <div class="filter-group">
|
|
|
- <label>评分</label>
|
|
|
- <msq-dropdown [options]="ratingDropdownOptions" placeholder="不限" (valueChange)="filters.minRating = $event; applyFilters()"></msq-dropdown>
|
|
|
- </div>
|
|
|
- <div class="filter-group">
|
|
|
- <label>价格区间</label>
|
|
|
- <div class="range-inputs">
|
|
|
- <input type="number" [(ngModel)]="filters.minPrice" (change)="applyFilters()" placeholder="最低" class="filter-input small">
|
|
|
- <span class="range-sep">-</span>
|
|
|
- <input type="number" [(ngModel)]="filters.maxPrice" (change)="applyFilters()" placeholder="最高" class="filter-input small">
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <div class="filter-group">
|
|
|
- <label>数据完整度</label>
|
|
|
- <msq-dropdown [options]="completenessDropdownOptions" placeholder="全部" (valueChange)="onCompletenessChange($event)"></msq-dropdown>
|
|
|
- </div>
|
|
|
- <button class="btn-reset" (click)="resetFilters()">重置</button>
|
|
|
- </div>
|
|
|
- }
|
|
|
- @if (selectedKnowledgeBase === 'influencer') {
|
|
|
- <div class="filter-row">
|
|
|
- <div class="filter-group">
|
|
|
- <label>平台</label>
|
|
|
- <msq-dropdown [options]="platformDropdownOptions" placeholder="全部" (valueChange)="filters.platform = $event; applyFilters()"></msq-dropdown>
|
|
|
- </div>
|
|
|
- <div class="filter-group">
|
|
|
- <label>最低粉丝</label>
|
|
|
- <input
|
|
|
- type="number"
|
|
|
- [(ngModel)]="filters.minFollowers"
|
|
|
- (change)="applyFilters()"
|
|
|
- placeholder="如 10000"
|
|
|
- class="filter-input">
|
|
|
- </div>
|
|
|
- <button class="btn-reset" (click)="resetFilters()">重置</button>
|
|
|
- </div>
|
|
|
- }
|
|
|
- @if (selectedKnowledgeBase === 'keyword') {
|
|
|
- <div class="filter-row">
|
|
|
- <div class="filter-group">
|
|
|
- <label>关键词</label>
|
|
|
- <input
|
|
|
- type="text"
|
|
|
- [(ngModel)]="filters.searchText"
|
|
|
- (input)="applyFilters()"
|
|
|
- placeholder="搜索关键词"
|
|
|
- class="filter-input wide">
|
|
|
- </div>
|
|
|
- <div class="filter-group">
|
|
|
- <label>最低搜索量</label>
|
|
|
- <input
|
|
|
- type="number"
|
|
|
- [(ngModel)]="filters.minSearchVolume"
|
|
|
- (change)="applyFilters()"
|
|
|
- placeholder="如 1000"
|
|
|
- class="filter-input">
|
|
|
- </div>
|
|
|
- <button class="btn-reset" (click)="resetFilters()">重置</button>
|
|
|
- </div>
|
|
|
- }
|
|
|
- </div>
|
|
|
- }
|
|
|
-
|
|
|
- <div class="data-table-container">
|
|
|
- @if (selectedKnowledgeBase === 'competitor') {
|
|
|
- <app-data-table-shell variant="admin" [count]="displayedCompetitorData.length">
|
|
|
- <table class="data-table">
|
|
|
- <thead>
|
|
|
- <tr>
|
|
|
- <th>产品</th>
|
|
|
- <th>品牌</th>
|
|
|
- <th>类目</th>
|
|
|
- <th>月销量</th>
|
|
|
- <th>评分</th>
|
|
|
- <th>价格</th>
|
|
|
- <th>操作</th>
|
|
|
- </tr>
|
|
|
- </thead>
|
|
|
- <tbody>
|
|
|
- @for (item of displayedCompetitorData; track item.id) {
|
|
|
- <tr (click)="viewCompetitor(item, $event)">
|
|
|
- <td class="product-info-cell">
|
|
|
- <div class="product-info-row">
|
|
|
- @if (item.photo) { <img [src]="proxyImg(item.photo)" class="product-thumb" alt="" /> }
|
|
|
- <div class="product-text">
|
|
|
- <span class="product-asin">{{ item.asin }}</span>
|
|
|
- <span class="product-title" [title]="item.name">{{ item.name || '-' }}</span>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </td>
|
|
|
- <td>{{ item.brandName || '-' }}</td>
|
|
|
- <td [title]="getCompetitorCategoryTitle(item)">{{ item.category || '-' }}</td>
|
|
|
- @if (item.hasMonthlySales === false) {
|
|
|
- <td class="number kc-uncollected" title="数据源未返回月销量(未补齐),不计入均值与占比">未补齐</td>
|
|
|
- } @else {
|
|
|
- <td class="number">{{ formatNumber(item.monthlySales) }}</td>
|
|
|
- }
|
|
|
- <td class="rating">⭐ {{ item.rating }}</td>
|
|
|
- @if (item.hasPrice === false) {
|
|
|
- <td class="price kc-uncollected" title="数据源未返回价格(未补齐 / $0.00),不计入均值">未补齐</td>
|
|
|
- } @else {
|
|
|
- <td class="price">{{ formatPrice(item.price) }}</td>
|
|
|
- }
|
|
|
- <td>
|
|
|
- <button class="btn-action" (click)="viewCompetitor(item, $event)">查看</button>
|
|
|
- </td>
|
|
|
- </tr>
|
|
|
- }
|
|
|
- </tbody>
|
|
|
- </table>
|
|
|
- </app-data-table-shell>
|
|
|
- }
|
|
|
-
|
|
|
- @if (selectedKnowledgeBase === 'influencer') {
|
|
|
- <app-data-table-shell variant="admin" [count]="displayedInfluencerData.length">
|
|
|
- <table class="data-table">
|
|
|
- <thead>
|
|
|
- <tr>
|
|
|
- <th>红人名称</th>
|
|
|
- <th>平台</th>
|
|
|
- <th>粉丝数</th>
|
|
|
- <th>互动率</th>
|
|
|
- <th>主要类目</th>
|
|
|
- <th>合作价格</th>
|
|
|
- <th>操作</th>
|
|
|
- </tr>
|
|
|
- </thead>
|
|
|
- <tbody>
|
|
|
- @for (item of displayedInfluencerData; track item.id) {
|
|
|
- <tr (click)="viewInfluencer(item, $event)">
|
|
|
- <td class="influencer-info-cell">
|
|
|
- <div class="influencer-info-row">
|
|
|
- @if (item.avatar) {
|
|
|
- <img [src]="proxyImg(item.avatar)" class="influencer-avatar" alt="" />
|
|
|
- } @else {
|
|
|
- <div class="influencer-avatar-placeholder">{{ (item.name || '?').charAt(1) || '?' }}</div>
|
|
|
- }
|
|
|
- <span class="influencer-name-text" [title]="item.name">{{ item.name }}</span>
|
|
|
- </div>
|
|
|
- </td>
|
|
|
- <td>{{ item.platform }}</td>
|
|
|
- <td class="number">{{ formatNumber(item.followers) }}</td>
|
|
|
- <td class="engagement">{{ item.engagementRate }}%</td>
|
|
|
- <td>{{ item.mainCategory }}</td>
|
|
|
- <td class="price">¥{{ formatNumber(item.cooperationPrice) }}</td>
|
|
|
- <td>
|
|
|
- <button class="btn-action" (click)="viewInfluencer(item, $event)">查看</button>
|
|
|
- </td>
|
|
|
- </tr>
|
|
|
- }
|
|
|
- </tbody>
|
|
|
- </table>
|
|
|
- </app-data-table-shell>
|
|
|
- }
|
|
|
-
|
|
|
- @if (selectedKnowledgeBase === 'keyword') {
|
|
|
- <app-data-table-shell variant="admin" [count]="displayedKeywordData.length">
|
|
|
- <table class="data-table">
|
|
|
- <thead>
|
|
|
- <tr>
|
|
|
- <th>关键词</th>
|
|
|
- <th>类型</th>
|
|
|
- <th>搜索量</th>
|
|
|
- <th>竞争度</th>
|
|
|
- <th>CPC</th>
|
|
|
- <th>转化率</th>
|
|
|
- <th>趋势</th>
|
|
|
- <th>SEO机会</th>
|
|
|
- <th>操作</th>
|
|
|
- </tr>
|
|
|
- </thead>
|
|
|
- <tbody>
|
|
|
- @for (item of displayedKeywordData; track item.id) {
|
|
|
- <tr (click)="navigateToKeywordResearch(item.keyword)">
|
|
|
- <td class="keyword">
|
|
|
- {{ item.keyword }}
|
|
|
- <div class="keyword-category-tag">{{ item.category }}</div>
|
|
|
- </td>
|
|
|
- <td>
|
|
|
- <span class="type-badge" [class]="'type-' + item.type">
|
|
|
- {{ item.type === 'conversion' ? '转化词' : item.type === 'blueocean' ? '蓝海词' : item.type === 'brand' ? '品牌词' : '流量词' }}
|
|
|
- </span>
|
|
|
- </td>
|
|
|
- <td class="number">{{ formatNumber(item.searchVolume) }}</td>
|
|
|
- <td>
|
|
|
- <span class="competition-badge" [class]="'level-' + item.competition">
|
|
|
- {{ item.competition === 'high' ? '高' : item.competition === 'medium' ? '中' : '低' }}
|
|
|
- </span>
|
|
|
- </td>
|
|
|
- <td class="number">\${{ item.cpc.toFixed(2) }}</td>
|
|
|
- <td class="conversion">{{ item.conversionRate > 0 ? item.conversionRate + '%' : '-' }}</td>
|
|
|
- <td>
|
|
|
- <span class="trend" [class]="item.trend > 0 ? 'up' : item.trend < 0 ? 'down' : ''">
|
|
|
- {{ item.trend > 0 ? '↑' : item.trend < 0 ? '↓' : '-' }}
|
|
|
- @if (item.trend !== 0) { {{ (Math.abs(item.trend) * 100).toFixed(1) }}% }
|
|
|
- </span>
|
|
|
- </td>
|
|
|
- <td>
|
|
|
- <div class="seo-bar">
|
|
|
- <div class="seo-fill" [style.width.%]="item.seoOpportunity" [class.seo-high]="item.seoOpportunity >= 70" [class.seo-mid]="item.seoOpportunity >= 40 && item.seoOpportunity < 70" [class.seo-low]="item.seoOpportunity < 40"></div>
|
|
|
- </div>
|
|
|
- <span class="seo-val">{{ item.seoOpportunity }}</span>
|
|
|
- </td>
|
|
|
- <td>
|
|
|
- <button class="btn-action" (click)="navigateToKeywordResearch(item.keyword); $event.stopPropagation()">查看</button>
|
|
|
- <button class="btn-action" (click)="navigateToKeywordAiAnalysis(item.keyword); $event.stopPropagation()">AI 分析</button>
|
|
|
- </td>
|
|
|
- </tr>
|
|
|
- }
|
|
|
- </tbody>
|
|
|
- </table>
|
|
|
- </app-data-table-shell>
|
|
|
- }
|
|
|
-
|
|
|
- @if (selectedKnowledgeBase === 'pattern') {
|
|
|
- <app-data-table-shell variant="admin" [count]="displayedPatternData.length">
|
|
|
- <table class="data-table">
|
|
|
- <thead>
|
|
|
- <tr>
|
|
|
- <th>版型名称</th>
|
|
|
- <th>类目</th>
|
|
|
- <th>版型</th>
|
|
|
- <th>适用场景</th>
|
|
|
- <th>流行度</th>
|
|
|
- <th>季节</th>
|
|
|
- <th>参考图片</th>
|
|
|
- <th>操作</th>
|
|
|
- </tr>
|
|
|
- </thead>
|
|
|
- <tbody>
|
|
|
- @for (item of displayedPatternData; track item.id) {
|
|
|
- <tr (click)="openPatternEdit(item)">
|
|
|
- <td class="pattern-name">{{ item.name }}</td>
|
|
|
- <td>{{ item.category }}</td>
|
|
|
- <td>
|
|
|
- <span class="fit-badge" [class]="'fit-' + item.fitType">
|
|
|
- {{ item.fitType === 'slim' ? '修身' : item.fitType === 'regular' ? '常规' : item.fitType === 'loose' ? '宽松' : '超大' }}
|
|
|
- </span>
|
|
|
- </td>
|
|
|
- <td>{{ item.applicableScenarios.join(', ') }}</td>
|
|
|
- <td>
|
|
|
- <div class="popularity-bar">
|
|
|
- <div class="bar" [style.width.%]="item.popularity"></div>
|
|
|
- </div>
|
|
|
- <span class="popularity-value">{{ item.popularity }}%</span>
|
|
|
- </td>
|
|
|
- <td>{{ item.season }}</td>
|
|
|
- <td class="image-count">{{ item.referenceImages.length }}张</td>
|
|
|
- <td>
|
|
|
- <button class="btn-action" (click)="openPatternEdit(item); $event.stopPropagation()">编辑</button>
|
|
|
- </td>
|
|
|
- </tr>
|
|
|
- }
|
|
|
- </tbody>
|
|
|
- </table>
|
|
|
- </app-data-table-shell>
|
|
|
- }
|
|
|
-
|
|
|
- @if (selectedKnowledgeBase === 'hotproduct') {
|
|
|
- <app-data-table-shell variant="admin" [count]="displayedHotProductData.length">
|
|
|
- <table class="data-table">
|
|
|
- <thead>
|
|
|
- <tr>
|
|
|
- <th>产品</th>
|
|
|
- <th>BSR排名</th>
|
|
|
- <th>类目</th>
|
|
|
- <th>价格</th>
|
|
|
- <th>评分</th>
|
|
|
- <th>评论数</th>
|
|
|
- <th>月销量</th>
|
|
|
- <th>操作</th>
|
|
|
- </tr>
|
|
|
- </thead>
|
|
|
- <tbody>
|
|
|
- @for (item of displayedHotProductData; track item.id) {
|
|
|
- <tr (click)="viewHotProduct(item, $event)">
|
|
|
- <td class="product-info-cell">
|
|
|
- <div class="product-info-row">
|
|
|
- @if (item.photo) {
|
|
|
- <img [src]="proxyImg(item.photo)" class="product-thumb" alt="" />
|
|
|
- } @else {
|
|
|
- <div class="product-thumb-placeholder">📦</div>
|
|
|
- }
|
|
|
- <div class="product-text">
|
|
|
- <div class="product-name">{{ item.productName || item.title }}</div>
|
|
|
- @if (item.brand) {
|
|
|
- <div class="product-brand">{{ item.brand }}</div>
|
|
|
- }
|
|
|
- <div class="product-asin">{{ item.asin }}</div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </td>
|
|
|
- <td class="bsr-rank">
|
|
|
- <span class="rank-badge">{{ item.bsrRank || '-' }}</span>
|
|
|
- </td>
|
|
|
- <td>{{ item.category }}</td>
|
|
|
- <td class="price">\${{ item.price.toFixed(2) }}</td>
|
|
|
- <td class="rating">⭐ {{ item.rating > 0 ? item.rating.toFixed(1) : '-' }}</td>
|
|
|
- <td class="number">{{ formatNumber(item.reviewCount) }}</td>
|
|
|
- <td class="number">{{ item.monthlySales > 0 ? formatNumber(item.monthlySales) : '-' }}</td>
|
|
|
- <td>
|
|
|
- <button class="btn-action" (click)="viewHotProduct(item, $event)">查看</button>
|
|
|
- </td>
|
|
|
- </tr>
|
|
|
- }
|
|
|
- </tbody>
|
|
|
- </table>
|
|
|
- </app-data-table-shell>
|
|
|
- }
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- Pagination -->
|
|
|
- <div class="pagination">
|
|
|
- <button
|
|
|
- class="page-btn"
|
|
|
- [disabled]="currentPage === 1"
|
|
|
- (click)="previousPage()">
|
|
|
- 上一页
|
|
|
- </button>
|
|
|
- <span class="page-info">
|
|
|
- 第 {{ currentPage }} 页 / 共 {{ totalPages }} 页
|
|
|
- </span>
|
|
|
- <button
|
|
|
- class="page-btn"
|
|
|
- [disabled]="currentPage === totalPages"
|
|
|
- (click)="nextPage()">
|
|
|
- 下一页
|
|
|
- </button>
|
|
|
- <select class="page-size-select" [value]="pageSize" (change)="setPageSize(+$any($event.target).value)" title="每页条数">
|
|
|
- <option value="20">20 条/页</option>
|
|
|
- <option value="50">50 条/页</option>
|
|
|
- <option value="100">100 条/页</option>
|
|
|
- </select>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
-
|
|
|
- <!-- Export Reviews Dialog -->
|
|
|
- @if (showExportReviewsDialog) {
|
|
|
- <app-export-reviews-dialog
|
|
|
- [competitors]="competitorDataForExport"
|
|
|
- (cancelExport)="showExportReviewsDialog = false"
|
|
|
- (exportComplete)="showExportReviewsDialog = false">
|
|
|
- </app-export-reviews-dialog>
|
|
|
- }
|
|
|
-
|
|
|
- <!-- Fetch Count Dialog -->
|
|
|
- @if (showFetchCountDialog) {
|
|
|
- <app-fetch-count-dialog
|
|
|
- [fetchType]="pendingFetchType || 'competitor'"
|
|
|
- (confirmFetch)="onConfirmFetch($event)"
|
|
|
- (cancelFetch)="showFetchCountDialog = false">
|
|
|
- </app-fetch-count-dialog>
|
|
|
- }
|
|
|
-
|
|
|
- <!-- Competitor Backfill Dialog -->
|
|
|
- @if (showBackfillDialog) {
|
|
|
- <app-competitor-backfill-dialog
|
|
|
- [brandOptions]="backfillBrandOptions"
|
|
|
- [categoryOptions]="backfillCategoryOptions"
|
|
|
- [running]="backfillRunning"
|
|
|
- [preview]="backfillPreview"
|
|
|
- [runResult]="backfillRunResult"
|
|
|
- (previewBackfill)="onPreviewBackfill($event)"
|
|
|
- (confirmBackfill)="onConfirmBackfill($event)"
|
|
|
- (cancelBackfill)="onCloseBackfill()">
|
|
|
- </app-competitor-backfill-dialog>
|
|
|
- }
|
|
|
-
|
|
|
- <!-- Pattern Edit Dialog -->
|
|
|
- @if (showPatternEditDialog) {
|
|
|
- <app-pattern-edit-dialog
|
|
|
- [pattern]="editingPattern"
|
|
|
- (save)="onPatternSave($event)"
|
|
|
- (cancel)="showPatternEditDialog = false">
|
|
|
- </app-pattern-edit-dialog>
|
|
|
- }
|
|
|
- </div>
|
|
|
- `,
|
|
|
- styleUrls: ['./knowledge-center.component.scss']
|
|
|
+ imports: [
|
|
|
+ CommonModule,
|
|
|
+ FormsModule,
|
|
|
+ PageHeaderComponent,
|
|
|
+ SummaryMetricCardComponent,
|
|
|
+ CrudTableWorkbenchComponent,
|
|
|
+ ProductIdentityComponent,
|
|
|
+ ],
|
|
|
+ templateUrl: './knowledge-center.component.html',
|
|
|
+ styleUrls: ['./knowledge-center.component.scss'],
|
|
|
})
|
|
|
-export class KnowledgeCenterComponent implements OnInit {
|
|
|
- // Data properties
|
|
|
- stats: {
|
|
|
- competitorCount: number;
|
|
|
- competitorWeeklyGrowth: number;
|
|
|
- influencerCount: number;
|
|
|
- influencerWeeklyGrowth: number;
|
|
|
- keywordCount: number;
|
|
|
- keywordWeeklyGrowth: number;
|
|
|
- patternCount: number;
|
|
|
- patternWeeklyGrowth: number;
|
|
|
- hotProductCount: number;
|
|
|
- hotProductWeeklyGrowth: number;
|
|
|
- totalRecords: number;
|
|
|
- lastUpdate: string;
|
|
|
- } | null = null;
|
|
|
-
|
|
|
- competitorData: CompetitorKnowledge[] = [];
|
|
|
- influencerData: InfluencerKnowledge[] = [];
|
|
|
- keywordData: KeywordKnowledge[] = [];
|
|
|
- patternData: PatternKnowledge[] = [];
|
|
|
- hotProductData: HotProductKnowledge[] = [];
|
|
|
- dataSources: DataSourceItem[] = [
|
|
|
- { id: 'ds-parse', name: 'Parse 主库', status: 'warning', updateFrequency: '实时', lastSync: '-', owner: '数据工程师', message: '等待检测' },
|
|
|
- { id: 'ds-sorftime', name: 'Sorftime API', status: 'warning', updateFrequency: '每 2 小时', lastSync: '-', owner: '分析师', message: '等待检测' },
|
|
|
- { id: 'ds-amazon', name: 'Amazon 数据源', status: 'warning', updateFrequency: '每日', lastSync: '-', owner: '运营经理', message: '等待检测' },
|
|
|
- { id: 'ds-tikhub', name: 'TikHub 社媒源', status: 'warning', updateFrequency: '每 1 小时', lastSync: '-', owner: '内容运营', message: '等待检测' }
|
|
|
- ];
|
|
|
- dictionaries: DictionaryItem[] = [
|
|
|
- { id: 'dic-industry', name: '行业词典', termCount: 1320, lastUpdated: '-', owner: '数据分析师' },
|
|
|
- { id: 'dic-synonym', name: '同义词词典', termCount: 860, lastUpdated: '-', owner: '算法工程师' },
|
|
|
- { id: 'dic-painpoint', name: '痛点词典', termCount: 540, lastUpdated: '-', owner: '产品经理' }
|
|
|
- ];
|
|
|
-
|
|
|
- competitorLastUpdated: Date | null = null;
|
|
|
- isFetchingCompetitor = false;
|
|
|
- influencerLastUpdated: Date | null = null;
|
|
|
- isFetchingInfluencer = false;
|
|
|
- isRefreshingDataSourceStatus = false;
|
|
|
-
|
|
|
- // UI state
|
|
|
- selectedKnowledgeBase: KnowledgeBaseType = 'competitor';
|
|
|
- // 已加载过的知识库类型,切换卡片时复用已有数据,避免重复请求
|
|
|
- private loadedTypes = new Set<KnowledgeBaseType>();
|
|
|
- searchQuery: string = '';
|
|
|
- showFilter: boolean = false;
|
|
|
- showFetchCountDialog: boolean = false;
|
|
|
- showExportReviewsDialog: boolean = false;
|
|
|
- showPatternEditDialog: boolean = false;
|
|
|
- editingPattern: PatternKnowledge | null = null;
|
|
|
- pendingFetchType: 'competitor' | 'influencer' | null = null;
|
|
|
-
|
|
|
- // 竞品「补齐数据」弹窗
|
|
|
- showBackfillDialog = false;
|
|
|
- backfillRunning = false;
|
|
|
- backfillPreview: BackfillPreview | null = null;
|
|
|
- backfillRunResult: BackfillRunResult | null = null;
|
|
|
- backfillBrandOptions: string[] = [];
|
|
|
- backfillCategoryOptions: string[] = [];
|
|
|
-
|
|
|
- // Pagination
|
|
|
- currentPage: number = 1;
|
|
|
- pageSize: number = 50;
|
|
|
- totalPages: number = 1;
|
|
|
-
|
|
|
- // Filters
|
|
|
- filters: KnowledgeBaseFilter = {};
|
|
|
- competitorCategoryFilters: FilterOptions = { timeRange: '7days' };
|
|
|
-
|
|
|
- readonly ratingDropdownOptions: MsqDropdownOption[] = [
|
|
|
- { value: '4.5', label: '4.5+' },
|
|
|
- { value: '4', label: '4.0+' },
|
|
|
- { value: '3.5', label: '3.5+' },
|
|
|
- { value: '3', label: '3.0+' }
|
|
|
- ];
|
|
|
-
|
|
|
- readonly platformDropdownOptions: MsqDropdownOption[] = [
|
|
|
- { value: 'tiktok', label: 'TikTok' },
|
|
|
- { value: 'instagram', label: 'Instagram' }
|
|
|
- ];
|
|
|
-
|
|
|
- // #18 竞品数据完整度筛选
|
|
|
- competitorCompleteness: 'all' | 'hasSales' | 'hasPrice' | 'incomplete' = 'all';
|
|
|
- readonly completenessDropdownOptions: MsqDropdownOption[] = [
|
|
|
- { value: 'all', label: '全部' },
|
|
|
- { value: 'hasSales', label: '已采集销量' },
|
|
|
- { value: 'hasPrice', label: '已采集价格' },
|
|
|
- { value: 'incomplete', label: '存在未补齐项' }
|
|
|
+export class KnowledgeCenterComponent implements OnInit, OnDestroy {
|
|
|
+ private readonly destroy$ = new Subject<void>();
|
|
|
+ dataset: DomesticDataset | null = null;
|
|
|
+ knowledgeItems: ProductKnowledgeItem[] = [];
|
|
|
+ knowledgeByKey = new Map<string, ProductKnowledgeItem>();
|
|
|
+ reviewCounts = new Map<string, number>();
|
|
|
+ ratingByProduct = new Map<string, number>();
|
|
|
+ relationCounts = new Map<string, number>();
|
|
|
+ selectedKeys = new Set<string>();
|
|
|
+ loading = true;
|
|
|
+ saving = false;
|
|
|
+ error = '';
|
|
|
+ notice = '';
|
|
|
+ activeTab: KnowledgeTab = 'own';
|
|
|
+ query = '';
|
|
|
+ page = 1;
|
|
|
+ pageSize = 20;
|
|
|
+ tableDensity: 'default' | 'compact' = 'compact';
|
|
|
+ editorProduct: DomesticProduct | null = null;
|
|
|
+ editorTags = '';
|
|
|
+ editorNote = '';
|
|
|
+ editorOwner = '';
|
|
|
+ editorFeatured = false;
|
|
|
+
|
|
|
+ filters: CrudTableFilter[] = [];
|
|
|
+ columns: CrudTableColumn[] = [
|
|
|
+ { key: 'product', label: '商品', visible: true, locked: true },
|
|
|
+ { key: 'role', label: '类型', visible: true },
|
|
|
+ { key: 'category', label: '品类', visible: true },
|
|
|
+ { key: 'gmv', label: '成交金额', visible: true },
|
|
|
+ { key: 'sales', label: '成交件数', visible: true },
|
|
|
+ { key: 'price', label: '平均单价', visible: true },
|
|
|
+ { key: 'refund', label: '退款率', visible: false },
|
|
|
+ { key: 'relations', label: '关联数', visible: true },
|
|
|
+ { key: 'reviews', label: '评价 / 评分', visible: true },
|
|
|
+ { key: 'tags', label: '知识标签', visible: true },
|
|
|
+ { key: 'owner', label: '负责人', visible: false },
|
|
|
+ { key: 'completeness', label: '资料状态', visible: true },
|
|
|
+ { key: 'updated', label: '维护时间', visible: true },
|
|
|
+ { key: 'actions', label: '操作', visible: true, locked: true },
|
|
|
];
|
|
|
|
|
|
- // Math for template
|
|
|
- Math = Math;
|
|
|
-
|
|
|
constructor(
|
|
|
- private knowledgeCenterService: KnowledgeCenterDataService,
|
|
|
- private router: Router
|
|
|
+ private readonly datasetService: DomesticDatasetService,
|
|
|
+ private readonly knowledgeService: ProductKnowledgeService,
|
|
|
) {}
|
|
|
|
|
|
ngOnInit(): void {
|
|
|
- this.touchConfigUpdateTime();
|
|
|
- this.loadStats();
|
|
|
- this.loadKnowledgeBaseData();
|
|
|
- this.refreshDataSourceStatus();
|
|
|
+ this.rebuildFilters();
|
|
|
+ this.load();
|
|
|
}
|
|
|
|
|
|
- private loadStats(): void {
|
|
|
- this.knowledgeCenterService.getKnowledgeBaseStatsFromDB().subscribe({
|
|
|
- next: (r) => {
|
|
|
- this.stats = {
|
|
|
- competitorCount: r.competitorCount,
|
|
|
- competitorWeeklyGrowth: r.competitorWeekly,
|
|
|
- influencerCount: r.influencerCount,
|
|
|
- influencerWeeklyGrowth: r.influencerWeekly,
|
|
|
- keywordCount: r.keywordCount,
|
|
|
- keywordWeeklyGrowth: r.keywordWeekly,
|
|
|
- patternCount: r.patternCount,
|
|
|
- patternWeeklyGrowth: r.patternWeekly,
|
|
|
- hotProductCount: r.hotProductCount,
|
|
|
- hotProductWeeklyGrowth: r.hotProductWeekly,
|
|
|
- totalRecords: r.competitorCount + r.influencerCount + r.keywordCount + r.patternCount + r.hotProductCount,
|
|
|
- lastUpdate: new Date().toLocaleString('zh-CN', {
|
|
|
- year: 'numeric', month: '2-digit', day: '2-digit',
|
|
|
- hour: '2-digit', minute: '2-digit'
|
|
|
- })
|
|
|
- };
|
|
|
- },
|
|
|
- error: () => {
|
|
|
- this.stats = {
|
|
|
- competitorCount: 0, competitorWeeklyGrowth: 0,
|
|
|
- influencerCount: 0, influencerWeeklyGrowth: 0,
|
|
|
- keywordCount: 0, keywordWeeklyGrowth: 0,
|
|
|
- patternCount: 0, patternWeeklyGrowth: 0,
|
|
|
- hotProductCount: 0, hotProductWeeklyGrowth: 0,
|
|
|
- totalRecords: 0, lastUpdate: '-'
|
|
|
- };
|
|
|
- }
|
|
|
- });
|
|
|
+ ngOnDestroy(): void {
|
|
|
+ this.destroy$.next();
|
|
|
+ this.destroy$.complete();
|
|
|
}
|
|
|
|
|
|
- private loadKnowledgeBaseData(force = false): void {
|
|
|
- const type = this.selectedKnowledgeBase;
|
|
|
- // 已加载过的类型直接复用已有数据(仅重算分页),切换卡片时不再重新请求
|
|
|
- if (!force && this.loadedTypes.has(type)) {
|
|
|
- this.recalcTotalPages();
|
|
|
- return;
|
|
|
- }
|
|
|
- this.loadedTypes.add(type);
|
|
|
- switch (this.selectedKnowledgeBase) {
|
|
|
- case 'competitor':
|
|
|
- // 进入页面优先查数据库(不自动调用 Sorftime 接口)
|
|
|
- this.competitorData = [];
|
|
|
- this.totalPages = 1;
|
|
|
- this.competitorLastUpdated = null;
|
|
|
-
|
|
|
- this.knowledgeCenterService.loadCompetitorFromDB().subscribe(dbData => {
|
|
|
- if (dbData?.length) {
|
|
|
- this.setCompetitorData(dbData);
|
|
|
- return;
|
|
|
- }
|
|
|
- // 数据库为空时回退 mock(不触发接口)
|
|
|
- this.setCompetitorData(this.knowledgeCenterService.getCompetitorKnowledge(this.filters));
|
|
|
- });
|
|
|
- break;
|
|
|
- case 'influencer':
|
|
|
- // 进入页面优先查数据库(不自动调用接口)
|
|
|
- this.influencerData = [];
|
|
|
- this.totalPages = 1;
|
|
|
- this.influencerLastUpdated = null;
|
|
|
-
|
|
|
- this.knowledgeCenterService.loadInfluencerFromDB().subscribe(dbData => {
|
|
|
- if (dbData?.length) {
|
|
|
- this.setInfluencerData(dbData);
|
|
|
- return;
|
|
|
- }
|
|
|
- // 数据库为空时回退 mock(不触发接口)
|
|
|
- this.setInfluencerData(this.knowledgeCenterService.getInfluencerKnowledge(this.filters));
|
|
|
- });
|
|
|
- break;
|
|
|
- case 'keyword':
|
|
|
- // 先显示 mock 作为占位
|
|
|
- this.keywordData = this.knowledgeCenterService.getKeywordKnowledge(this.filters);
|
|
|
- this.totalPages = Math.ceil(this.keywordData.length / this.pageSize);
|
|
|
- // 优先从 KeywordSummary 表加载真实数据(关键词调研模块维护的主数据源)
|
|
|
- this.knowledgeCenterService.loadKeywordFromKeywordSummary().subscribe(realData => {
|
|
|
- if (realData?.length) {
|
|
|
- this.keywordData = realData;
|
|
|
- this.totalPages = Math.ceil(realData.length / this.pageSize);
|
|
|
- return;
|
|
|
- }
|
|
|
- // KeywordSummary 为空时,回退到 KnowledgeKeyword 表
|
|
|
- this.knowledgeCenterService.loadKeywordFromDB().subscribe(dbData => {
|
|
|
- if (dbData?.length) {
|
|
|
- this.keywordData = dbData;
|
|
|
- this.totalPages = Math.ceil(dbData.length / this.pageSize);
|
|
|
- return;
|
|
|
- }
|
|
|
- // 都为空时尝试 Sorftime API
|
|
|
- this.knowledgeCenterService.fetchKeywordData().subscribe(apiData => {
|
|
|
- if (apiData?.length) {
|
|
|
- this.keywordData = apiData;
|
|
|
- this.totalPages = Math.ceil(apiData.length / this.pageSize);
|
|
|
- }
|
|
|
- });
|
|
|
- });
|
|
|
- });
|
|
|
- break;
|
|
|
- case 'pattern':
|
|
|
- this.patternData = this.knowledgeCenterService.getPatternKnowledge(this.filters);
|
|
|
- this.totalPages = Math.ceil(this.patternData.length / this.pageSize);
|
|
|
- // 保存 mock 数据到数据库
|
|
|
- this.knowledgeCenterService.savePatternData(this.patternData);
|
|
|
- // 从数据库加载
|
|
|
- this.knowledgeCenterService.loadPatternFromDB().subscribe(dbData => {
|
|
|
- if (dbData?.length) {
|
|
|
- this.patternData = dbData;
|
|
|
- this.totalPages = Math.ceil(dbData.length / this.pageSize);
|
|
|
- }
|
|
|
- });
|
|
|
- break;
|
|
|
- case 'hotproduct':
|
|
|
- // 先显示 mock 占位
|
|
|
- this.hotProductData = this.knowledgeCenterService.getHotProductKnowledge(this.filters);
|
|
|
- this.totalPages = Math.ceil(this.hotProductData.length / this.pageSize);
|
|
|
- // 优先从 AmazonProduct 表加载真实数据(竞品监控模块维护的主数据源)
|
|
|
- this.knowledgeCenterService.loadHotProductFromAmazonProduct().subscribe(realData => {
|
|
|
- if (realData?.length) {
|
|
|
- this.hotProductData = realData;
|
|
|
- this.totalPages = Math.ceil(realData.length / this.pageSize);
|
|
|
- return;
|
|
|
- }
|
|
|
- // AmazonProduct 为空时,回退到 KnowledgeHotProduct 表
|
|
|
- this.knowledgeCenterService.loadHotProductFromDB().subscribe(dbData => {
|
|
|
- if (dbData?.length) {
|
|
|
- this.hotProductData = dbData;
|
|
|
- this.totalPages = Math.ceil(dbData.length / this.pageSize);
|
|
|
- }
|
|
|
- });
|
|
|
- });
|
|
|
- break;
|
|
|
- }
|
|
|
+ get ownCount(): number {
|
|
|
+ return this.dataset?.products.filter((product) => product.role === 'own').length ?? 0;
|
|
|
}
|
|
|
|
|
|
- selectKnowledgeBase(type: KnowledgeBaseType): void {
|
|
|
- this.selectedKnowledgeBase = type;
|
|
|
- this.currentPage = 1;
|
|
|
- this.filters = {};
|
|
|
- this.competitorCategoryFilters = { timeRange: '7days' };
|
|
|
- this.searchQuery = '';
|
|
|
- this.showFilter = false;
|
|
|
- this.loadKnowledgeBaseData();
|
|
|
+ get competitorCount(): number {
|
|
|
+ return this.dataset?.products.filter((product) => product.role === 'competitor').length ?? 0;
|
|
|
}
|
|
|
|
|
|
- getKnowledgeBaseTitle(): string {
|
|
|
- const titles: { [key in KnowledgeBaseType]: string } = {
|
|
|
- competitor: '竞品库数据',
|
|
|
- influencer: '红人库数据',
|
|
|
- keyword: '关键词库数据',
|
|
|
- pattern: '版型库数据',
|
|
|
- hotproduct: '爆款库数据(BSR400)'
|
|
|
- };
|
|
|
- return titles[this.selectedKnowledgeBase];
|
|
|
+ get featuredCount(): number {
|
|
|
+ return this.knowledgeItems.filter((item) => item.status === 'active' && item.featured).length;
|
|
|
}
|
|
|
|
|
|
- canFetchByAddButton(): boolean {
|
|
|
- if (this.selectedKnowledgeBase === 'competitor') return !this.isFetchingCompetitor;
|
|
|
- if (this.selectedKnowledgeBase === 'influencer') return !this.isFetchingInfluencer;
|
|
|
- if (this.selectedKnowledgeBase === 'pattern') return true;
|
|
|
- return false;
|
|
|
+ get reviewCount(): number {
|
|
|
+ return this.dataset?.reviews?.length ?? 0;
|
|
|
}
|
|
|
|
|
|
- onAddData(): void {
|
|
|
- if (!this.canFetchByAddButton()) return;
|
|
|
-
|
|
|
- if (this.selectedKnowledgeBase === 'pattern') {
|
|
|
- this.editingPattern = null;
|
|
|
- this.showPatternEditDialog = true;
|
|
|
- return;
|
|
|
+ get tabProducts(): DomesticProduct[] {
|
|
|
+ const products = this.dataset?.products ?? [];
|
|
|
+ if (this.activeTab === 'featured') {
|
|
|
+ return products.filter((product) => {
|
|
|
+ const item = this.knowledgeByKey.get(product.productKey);
|
|
|
+ return item?.featured && item.status === 'active';
|
|
|
+ });
|
|
|
}
|
|
|
-
|
|
|
- // 显示请求次数选择对话框
|
|
|
- if (this.selectedKnowledgeBase === 'competitor' || this.selectedKnowledgeBase === 'influencer') {
|
|
|
- this.pendingFetchType = this.selectedKnowledgeBase;
|
|
|
- this.showFetchCountDialog = true;
|
|
|
+ return products.filter((product) => product.role === this.activeTab);
|
|
|
+ }
|
|
|
+
|
|
|
+ get metricCards(): KnowledgeMetric[] {
|
|
|
+ const products = this.tabProducts;
|
|
|
+ const maintained = products.filter((product) => this.knowledgeByKey.has(product.productKey)).length;
|
|
|
+ const categories = new Set(products.map((product) => this.category(product)).filter(Boolean)).size;
|
|
|
+ if (this.activeTab === 'featured') {
|
|
|
+ const items = products.map((product) => this.knowledgeByKey.get(product.productKey)).filter(Boolean) as ProductKnowledgeItem[];
|
|
|
+ return [
|
|
|
+ { label: '重点商品', value: products.length, unit: '款', description: '纳入持续经营跟踪', tone: 'amber' },
|
|
|
+ { label: '本店商品', value: products.filter((product) => product.role === 'own').length, unit: '款', description: '本店重点经营商品', tone: 'blue' },
|
|
|
+ { label: '竞品档案', value: products.filter((product) => product.role === 'competitor').length, unit: '款', description: '重点关注竞品', tone: 'purple' },
|
|
|
+ { label: '知识标签', value: new Set(items.flatMap((item) => item.tags)).size, unit: '个', description: '已沉淀业务标签', tone: 'green' },
|
|
|
+ { label: '负责人已填', value: items.filter((item) => item.ownerUserId.trim()).length, unit: '款', description: '已明确维护责任人', tone: 'default' },
|
|
|
+ { label: '备注已填', value: items.filter((item) => item.note.trim()).length, unit: '款', description: '已记录经营关注事项', tone: 'default' },
|
|
|
+ ];
|
|
|
}
|
|
|
- }
|
|
|
-
|
|
|
- onConfirmFetch(result: FetchDialogResult): void {
|
|
|
- this.showFetchCountDialog = false;
|
|
|
-
|
|
|
- if (this.pendingFetchType === 'competitor') {
|
|
|
- this.isFetchingCompetitor = true;
|
|
|
-
|
|
|
- if (result.mode === 'asin' && result.asins?.length) {
|
|
|
- // 按 ASIN 精确查询
|
|
|
- this.knowledgeCenterService.fetchCompetitorByAsins(result.asins).subscribe({
|
|
|
- next: (data) => {
|
|
|
- if (data?.length) {
|
|
|
- // 合并到现有数据(去重)
|
|
|
- const existingAsins = new Set(this.competitorData.map(d => d.asin));
|
|
|
- const newItems = data.filter(d => !existingAsins.has(d.asin));
|
|
|
- if (newItems.length) {
|
|
|
- this.setCompetitorData([...newItems, ...this.competitorData]);
|
|
|
- } else {
|
|
|
- this.setCompetitorData(data);
|
|
|
- }
|
|
|
- }
|
|
|
- },
|
|
|
- error: () => {},
|
|
|
- complete: () => { this.isFetchingCompetitor = false; }
|
|
|
- });
|
|
|
- } else {
|
|
|
- // 按关键词搜索
|
|
|
- const keyword = result.keyword || 'women fashion';
|
|
|
- this.knowledgeCenterService.fetchCompetitorData(keyword, result.pageCount || 1).subscribe({
|
|
|
- next: (data) => {
|
|
|
- if (data?.length) {
|
|
|
- // 合并到现有数据(去重)
|
|
|
- const existingAsins = new Set(this.competitorData.map(d => d.asin));
|
|
|
- const newItems = data.filter(d => !existingAsins.has(d.asin));
|
|
|
- if (newItems.length) {
|
|
|
- this.setCompetitorData([...newItems, ...this.competitorData]);
|
|
|
- } else if (!this.competitorData.length) {
|
|
|
- this.setCompetitorData(data);
|
|
|
- }
|
|
|
- }
|
|
|
- },
|
|
|
- error: () => {},
|
|
|
- complete: () => { this.isFetchingCompetitor = false; }
|
|
|
- });
|
|
|
- }
|
|
|
- } else if (this.pendingFetchType === 'influencer') {
|
|
|
- this.isFetchingInfluencer = true;
|
|
|
- const keyword = result.keyword || result.asins?.[0] || 'fashion';
|
|
|
- this.knowledgeCenterService.fetchInfluencerData(keyword, result.pageCount || 1).subscribe({
|
|
|
- next: (data) => {
|
|
|
- if (data?.length) this.setInfluencerData(data);
|
|
|
- },
|
|
|
- error: () => {},
|
|
|
- complete: () => { this.isFetchingInfluencer = false; }
|
|
|
- });
|
|
|
+ if (this.activeTab === 'competitor') {
|
|
|
+ const brands = new Set(products.map((product) => product.brand).filter(Boolean)).size;
|
|
|
+ const ownProducts = new Set((this.dataset?.relations ?? []).map((relation) => relation.ownProductId)).size;
|
|
|
+ return [
|
|
|
+ { label: '竞品档案', value: products.length, unit: '款', description: '来自竞品关系表', tone: 'purple' },
|
|
|
+ { label: '关联本店商品', value: ownProducts, unit: '款', description: '已建立竞品映射', tone: 'blue' },
|
|
|
+ { label: '竞品品牌', value: brands, unit: '个', description: '已识别品牌范围', tone: 'amber' },
|
|
|
+ { label: '覆盖品类', value: categories, unit: '类', description: '竞品关系覆盖品类', tone: 'green' },
|
|
|
+ { label: '评价证据', value: this.reviewCount, unit: '条', description: this.reviewCount ? '可用于竞品 VOC 分析' : '待数据源补充采集', tone: this.reviewCount ? 'green' : 'amber' },
|
|
|
+ { label: '市场快照', value: products.filter((product) => !!product.market).length, unit: '款', description: '含价格、店铺和销量信号', tone: 'default' },
|
|
|
+ ];
|
|
|
}
|
|
|
-
|
|
|
- this.pendingFetchType = null;
|
|
|
- }
|
|
|
-
|
|
|
- // ============ 竞品「补齐数据」 ============
|
|
|
- onOpenBackfill(): void {
|
|
|
- this.backfillPreview = null;
|
|
|
- this.backfillRunResult = null;
|
|
|
- this.backfillRunning = false;
|
|
|
- this.backfillBrandOptions = this.collectBackfillBrands();
|
|
|
- this.backfillCategoryOptions = this.collectBackfillCategories();
|
|
|
- this.showBackfillDialog = true;
|
|
|
- }
|
|
|
-
|
|
|
- onCloseBackfill(): void {
|
|
|
- if (this.backfillRunning) return;
|
|
|
- this.showBackfillDialog = false;
|
|
|
+ const totalGmv = products.reduce((sum, product) => sum + product.summary.gmv, 0);
|
|
|
+ const totalSales = products.reduce((sum, product) => sum + product.summary.soldUnits, 0);
|
|
|
+ const relatedCompetitors = new Set((this.dataset?.relations ?? []).map((relation) => relation.competitorProductId)).size;
|
|
|
+ return [
|
|
|
+ { label: '商品主数据', value: products.length, unit: '款', description: '本店经营商品档案', tone: 'blue' },
|
|
|
+ { label: '成交金额', value: this.formatCompactCurrency(totalGmv), unit: '', description: this.dateRangeLabel, tone: 'green' },
|
|
|
+ { label: '成交件数', value: this.formatCompactNumber(totalSales), unit: '件', description: '所选周期累计销量', tone: 'amber' },
|
|
|
+ { label: '覆盖品类', value: categories, unit: '类', description: '三级/二级有效品类', tone: 'purple' },
|
|
|
+ { label: '已关联竞品', value: relatedCompetitors, unit: '款', description: `${this.dataset?.relations.length ?? 0} 条竞品关系`, tone: 'default' },
|
|
|
+ { label: '已维护档案', value: maintained, unit: '款', description: `${this.knowledgeCoveragePercent}% 商品已维护`, tone: 'default' },
|
|
|
+ ];
|
|
|
}
|
|
|
|
|
|
- onPreviewBackfill(scope: BackfillScope): void {
|
|
|
- this.backfillRunning = true;
|
|
|
- this.backfillRunResult = null;
|
|
|
- this.knowledgeCenterService.backfillCompetitor(scope, true).subscribe({
|
|
|
- next: (res) => {
|
|
|
- const r = res?.result;
|
|
|
- this.backfillPreview = r ? {
|
|
|
- scopeTotal: r.scopeTotal ?? 0,
|
|
|
- incomplete: r.incomplete ?? 0,
|
|
|
- willProcess: r.willProcess ?? 0
|
|
|
- } : null;
|
|
|
- },
|
|
|
- error: () => { this.backfillRunning = false; },
|
|
|
- complete: () => { this.backfillRunning = false; }
|
|
|
- });
|
|
|
+ get activeKnowledgeCount(): number {
|
|
|
+ return this.knowledgeItems.filter((item) => item.status === 'active').length;
|
|
|
}
|
|
|
|
|
|
- onConfirmBackfill(scope: BackfillScope): void {
|
|
|
- this.backfillRunning = true;
|
|
|
- this.knowledgeCenterService.backfillCompetitor(scope, false).subscribe({
|
|
|
- next: (res) => {
|
|
|
- const r = res?.result;
|
|
|
- this.backfillRunResult = r ? {
|
|
|
- requested: r.requested ?? 0,
|
|
|
- succeeded: r.succeeded ?? 0,
|
|
|
- failed: r.failed ?? 0,
|
|
|
- filled: r.filled ?? 0,
|
|
|
- noData: r.noData ?? 0
|
|
|
- } : null;
|
|
|
- // 补齐后重新从数据库加载竞品数据以反映更新
|
|
|
- if (r && (r.filled ?? 0) > 0) {
|
|
|
- this.knowledgeCenterService.loadCompetitorFromDB().subscribe(dbData => {
|
|
|
- if (dbData?.length) this.setCompetitorData(dbData);
|
|
|
- });
|
|
|
- }
|
|
|
- },
|
|
|
- error: () => { this.backfillRunning = false; },
|
|
|
- complete: () => { this.backfillRunning = false; }
|
|
|
- });
|
|
|
+ get knowledgeCoveragePercent(): number {
|
|
|
+ return this.ownCount
|
|
|
+ ? Math.round((this.knowledgeItems.filter((item) => item.status === 'active' && item.productRole === 'own').length / this.ownCount) * 1_000) / 10
|
|
|
+ : 0;
|
|
|
}
|
|
|
|
|
|
- private collectBackfillBrands(): string[] {
|
|
|
- const set = new Set<string>();
|
|
|
- for (const d of this.competitorData) {
|
|
|
- const b = (d.brandName || '').trim();
|
|
|
- if (b) set.add(b);
|
|
|
- }
|
|
|
- return Array.from(set).sort();
|
|
|
+ get dateRangeLabel(): string {
|
|
|
+ const range = this.dataset?.source.dateRange;
|
|
|
+ if (!range?.start || !range.end) return '暂无周期信息';
|
|
|
+ return `${range.start.replaceAll('-', '.')} - ${range.end.replaceAll('-', '.')}`;
|
|
|
}
|
|
|
|
|
|
- private collectBackfillCategories(): string[] {
|
|
|
- const set = new Set<string>();
|
|
|
- for (const d of this.competitorData) {
|
|
|
- const c = (d.category || '').trim();
|
|
|
- if (c) set.add(c);
|
|
|
- }
|
|
|
- return Array.from(set).sort();
|
|
|
+ get platformLabel(): string {
|
|
|
+ return this.dataset?.platform === 'jd' ? '京东' : (this.dataset?.platform || '-');
|
|
|
}
|
|
|
|
|
|
- onSearch(): void {
|
|
|
- // 搜索视为主动刷新,强制重新加载当前类型数据
|
|
|
- this.loadKnowledgeBaseData(true);
|
|
|
+ get tableSubtitle(): string {
|
|
|
+ if (this.activeTab === 'competitor') return '竞品关系来自导入表格;评价、价格与趋势仅在采集成功后展示';
|
|
|
+ if (this.activeTab === 'featured') return '集中管理需持续跟踪的本店商品与竞品知识';
|
|
|
+ return '按成交金额排序,统一维护商品标签、责任人与经营备注';
|
|
|
}
|
|
|
|
|
|
- goVideoSearch(): void {
|
|
|
- this.router.navigate(['/knowledge-center/video-search']);
|
|
|
+ get selectedCount(): number {
|
|
|
+ return this.selectedKeys.size;
|
|
|
}
|
|
|
|
|
|
- toggleFilter(): void {
|
|
|
- this.showFilter = !this.showFilter;
|
|
|
+ get bulkActions(): CrudTableBulkAction[] {
|
|
|
+ return this.activeTab === 'featured'
|
|
|
+ ? [{ key: 'remove-featured', label: '移出重点商品库', tone: 'danger' }]
|
|
|
+ : [{ key: 'add-featured', label: '加入重点商品库' }];
|
|
|
}
|
|
|
|
|
|
- applyFilters(): void {
|
|
|
- this.currentPage = 1;
|
|
|
- this.recalcTotalPages();
|
|
|
+ get allPageSelected(): boolean {
|
|
|
+ return this.pagedProducts.length > 0 && this.pagedProducts.every((product) => this.selectedKeys.has(product.productKey));
|
|
|
}
|
|
|
|
|
|
- resetFilters(): void {
|
|
|
- this.filters = {};
|
|
|
- this.competitorCategoryFilters = { timeRange: '7days' };
|
|
|
- this.competitorCompleteness = 'all';
|
|
|
- this.currentPage = 1;
|
|
|
- this.recalcTotalPages();
|
|
|
+ get filteredProducts(): DomesticProduct[] {
|
|
|
+ const dataset = this.dataset;
|
|
|
+ if (!dataset) return [];
|
|
|
+ const category = this.filterValue('category').toLowerCase();
|
|
|
+ const maintenance = this.filterValue('maintenance');
|
|
|
+ const role = this.filterValue('role');
|
|
|
+ const tag = this.filterValue('tag').toLowerCase();
|
|
|
+ const query = this.query.trim().toLowerCase();
|
|
|
+ return dataset.products
|
|
|
+ .filter((product) => {
|
|
|
+ const item = this.knowledgeByKey.get(product.productKey);
|
|
|
+ if (this.activeTab === 'own' && product.role !== 'own') return false;
|
|
|
+ if (this.activeTab === 'competitor' && product.role !== 'competitor') return false;
|
|
|
+ if (this.activeTab === 'featured' && (!item?.featured || item.status !== 'active')) return false;
|
|
|
+ if (role && product.role !== role) return false;
|
|
|
+ const productCategory = this.category(product).toLowerCase();
|
|
|
+ if (category && productCategory !== category) return false;
|
|
|
+ if (maintenance === 'maintained' && !item) return false;
|
|
|
+ if (maintenance === 'unmaintained' && item) return false;
|
|
|
+ if (tag && !item?.tags.some((value) => value.toLowerCase().includes(tag))) return false;
|
|
|
+ if (!query) return true;
|
|
|
+ return [
|
|
|
+ product.productId, product.model, product.title, product.brand, productCategory,
|
|
|
+ item?.tags.join(' '), item?.note, item?.ownerUserId,
|
|
|
+ ].filter(Boolean).join(' ').toLowerCase().includes(query);
|
|
|
+ })
|
|
|
+ .sort((left, right) => {
|
|
|
+ if (this.activeTab === 'featured') {
|
|
|
+ return (this.knowledgeByKey.get(right.productKey)?.updatedAt ?? '')
|
|
|
+ .localeCompare(this.knowledgeByKey.get(left.productKey)?.updatedAt ?? '');
|
|
|
+ }
|
|
|
+ if (this.activeTab === 'competitor') {
|
|
|
+ return (this.reviewCounts.get(right.productId) ?? 0) - (this.reviewCounts.get(left.productId) ?? 0);
|
|
|
+ }
|
|
|
+ return right.summary.gmv - left.summary.gmv || right.summary.soldUnits - left.summary.soldUnits;
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
- onCompletenessChange(value: string): void {
|
|
|
- const allowed: ReadonlyArray<string> = ['all', 'hasSales', 'hasPrice', 'incomplete'];
|
|
|
- this.competitorCompleteness = (allowed.includes(value) ? value : 'all') as 'all' | 'hasSales' | 'hasPrice' | 'incomplete';
|
|
|
- this.applyFilters();
|
|
|
+ get pagedProducts(): DomesticProduct[] {
|
|
|
+ const start = (this.page - 1) * this.pageSize;
|
|
|
+ return this.filteredProducts.slice(start, start + this.pageSize);
|
|
|
}
|
|
|
|
|
|
- onCompetitorCategoryFilterChange(filters: FilterOptions): void {
|
|
|
- const categoryNodeIds = Array.from(new Set((filters.categoryNodeIds || []).filter(Boolean)));
|
|
|
- this.competitorCategoryFilters = { ...filters, categoryNodeIds };
|
|
|
- this.filters = {
|
|
|
- ...this.filters,
|
|
|
- category: undefined,
|
|
|
- categoryNodeIds: categoryNodeIds.length ? categoryNodeIds : undefined
|
|
|
- };
|
|
|
- this.applyFilters();
|
|
|
+ get title(): string {
|
|
|
+ if (this.activeTab === 'featured') return '重点商品库';
|
|
|
+ if (this.activeTab === 'competitor') return '竞品资料库';
|
|
|
+ return '本店商品库';
|
|
|
}
|
|
|
|
|
|
- private recalcTotalPages(): void {
|
|
|
- let len = 0;
|
|
|
- switch (this.selectedKnowledgeBase) {
|
|
|
- case 'competitor':
|
|
|
- len = this.filterCompetitorData(this.competitorData).length;
|
|
|
- break;
|
|
|
- case 'influencer':
|
|
|
- len = this.influencerData.length;
|
|
|
- break;
|
|
|
- case 'keyword':
|
|
|
- len = this.keywordData.length;
|
|
|
- break;
|
|
|
- case 'pattern':
|
|
|
- len = this.patternData.length;
|
|
|
- break;
|
|
|
- case 'hotproduct':
|
|
|
- len = this.hotProductData.length;
|
|
|
- break;
|
|
|
- }
|
|
|
- this.totalPages = Math.max(1, Math.ceil(len / this.pageSize));
|
|
|
+ load(): void {
|
|
|
+ this.loading = true;
|
|
|
+ this.error = '';
|
|
|
+ forkJoin({
|
|
|
+ dataset: this.datasetService.dataset$.pipe(take(1)),
|
|
|
+ knowledge: this.knowledgeService.listAll('active'),
|
|
|
+ }).pipe(takeUntil(this.destroy$)).subscribe({
|
|
|
+ next: ({ dataset, knowledge }) => {
|
|
|
+ this.dataset = dataset;
|
|
|
+ this.knowledgeItems = knowledge;
|
|
|
+ this.rebuildIndexes();
|
|
|
+ this.rebuildFilters();
|
|
|
+ this.loading = false;
|
|
|
+ },
|
|
|
+ error: () => {
|
|
|
+ this.error = '知识库数据加载失败,请检查本地后端连接。';
|
|
|
+ this.loading = false;
|
|
|
+ },
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
- navigateToKeywordResearch(keyword: string): void {
|
|
|
- this.router.navigate(['/market-insight/keyword'], { queryParams: { keyword } });
|
|
|
+ selectTab(tab: KnowledgeTab): void {
|
|
|
+ this.activeTab = tab;
|
|
|
+ this.page = 1;
|
|
|
+ this.query = '';
|
|
|
+ this.clearSelection();
|
|
|
+ this.rebuildFilters();
|
|
|
}
|
|
|
|
|
|
- navigateToKeywordAiAnalysis(keyword: string): void {
|
|
|
- this.router.navigate(['/market-insight/keyword'], { queryParams: { keyword, tab: 'ai' } });
|
|
|
+ onFiltersChange(filters: CrudTableFilter[]): void {
|
|
|
+ this.filters = filters;
|
|
|
+ this.page = 1;
|
|
|
}
|
|
|
|
|
|
- viewHotProduct(item: HotProductKnowledge, event: Event): void {
|
|
|
- event.stopPropagation();
|
|
|
- if (item.asin) {
|
|
|
- this.router.navigate(['/knowledge-center/competitor', item.asin]);
|
|
|
- }
|
|
|
+ resetFilters(): void {
|
|
|
+ this.query = '';
|
|
|
+ this.page = 1;
|
|
|
+ this.clearSelection();
|
|
|
+ this.rebuildFilters();
|
|
|
}
|
|
|
|
|
|
- openPatternEdit(pattern: PatternKnowledge): void {
|
|
|
- this.editingPattern = pattern;
|
|
|
- this.showPatternEditDialog = true;
|
|
|
+ onPageChange(page: number): void {
|
|
|
+ this.page = page;
|
|
|
}
|
|
|
|
|
|
- onPatternSave(updated: PatternKnowledge): void {
|
|
|
- // 持久化到数据库
|
|
|
- this.knowledgeCenterService.saveOnePattern(updated).subscribe(() => {
|
|
|
- // 更新本地列表
|
|
|
- const idx = this.patternData.findIndex(p => p.id === updated.id);
|
|
|
- if (idx >= 0) {
|
|
|
- this.patternData[idx] = updated;
|
|
|
- } else {
|
|
|
- this.patternData.unshift(updated);
|
|
|
- }
|
|
|
- this.showPatternEditDialog = false;
|
|
|
- this.editingPattern = null;
|
|
|
- });
|
|
|
+ onPageSizeChange(pageSize: number): void {
|
|
|
+ this.pageSize = pageSize;
|
|
|
+ this.page = 1;
|
|
|
}
|
|
|
|
|
|
- setPageSize(size: number): void {
|
|
|
- if (!size || size <= 0) return;
|
|
|
- this.pageSize = size;
|
|
|
- this.currentPage = 1;
|
|
|
- this.recalcTotalPages();
|
|
|
+ openEditor(product: DomesticProduct): void {
|
|
|
+ const item = this.knowledgeByKey.get(product.productKey);
|
|
|
+ this.editorProduct = product;
|
|
|
+ this.editorTags = item?.tags.join(',') ?? '';
|
|
|
+ this.editorNote = item?.note ?? '';
|
|
|
+ this.editorOwner = item?.ownerUserId ?? '';
|
|
|
+ this.editorFeatured = item?.featured ?? false;
|
|
|
+ this.error = '';
|
|
|
}
|
|
|
|
|
|
- previousPage(): void {
|
|
|
- if (this.currentPage > 1) {
|
|
|
- this.currentPage--;
|
|
|
- }
|
|
|
+ closeEditor(): void {
|
|
|
+ if (this.saving) return;
|
|
|
+ this.editorProduct = null;
|
|
|
}
|
|
|
|
|
|
- nextPage(): void {
|
|
|
- if (this.currentPage < this.totalPages) {
|
|
|
- this.currentPage++;
|
|
|
- }
|
|
|
+ saveEditor(): void {
|
|
|
+ const product = this.editorProduct;
|
|
|
+ if (!product) return;
|
|
|
+ this.save(product, {
|
|
|
+ featured: this.editorFeatured,
|
|
|
+ tags: this.parseTags(this.editorTags),
|
|
|
+ note: this.editorNote,
|
|
|
+ ownerUserId: this.editorOwner,
|
|
|
+ }, true);
|
|
|
}
|
|
|
|
|
|
- viewInfluencer(item: InfluencerKnowledge, event: Event): void {
|
|
|
- event.stopPropagation();
|
|
|
- // Extract uniqueId from name (remove @ prefix)
|
|
|
- const uniqueId = item.name.replace(/^@/, '');
|
|
|
- const platform = item.platform || 'tiktok';
|
|
|
- this.router.navigate(['/knowledge-center/influencer', platform, uniqueId]);
|
|
|
+ toggleFeatured(product: DomesticProduct, event?: Event): void {
|
|
|
+ event?.stopPropagation();
|
|
|
+ const current = this.knowledgeByKey.get(product.productKey);
|
|
|
+ this.save(product, {
|
|
|
+ featured: !current?.featured,
|
|
|
+ tags: current?.tags ?? [],
|
|
|
+ note: current?.note ?? '',
|
|
|
+ ownerUserId: current?.ownerUserId ?? '',
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
- viewCompetitor(item: CompetitorKnowledge, event: Event): void {
|
|
|
- event.stopPropagation();
|
|
|
- if (!item.asin) return;
|
|
|
- this.router.navigate(['/knowledge-center/competitor', item.asin]);
|
|
|
+ toggleSelection(product: DomesticProduct, checked: boolean): void {
|
|
|
+ const next = new Set(this.selectedKeys);
|
|
|
+ if (checked) next.add(product.productKey);
|
|
|
+ else next.delete(product.productKey);
|
|
|
+ this.selectedKeys = next;
|
|
|
}
|
|
|
|
|
|
- formatNumber(num: number): string {
|
|
|
- if (num >= 10000) {
|
|
|
- return (num / 10000).toFixed(1) + 'w';
|
|
|
+ togglePageSelection(checked: boolean): void {
|
|
|
+ const next = new Set(this.selectedKeys);
|
|
|
+ for (const product of this.pagedProducts) {
|
|
|
+ if (checked) next.add(product.productKey);
|
|
|
+ else next.delete(product.productKey);
|
|
|
}
|
|
|
- return num.toLocaleString();
|
|
|
+ this.selectedKeys = next;
|
|
|
+ }
|
|
|
+
|
|
|
+ clearSelection(): void {
|
|
|
+ this.selectedKeys = new Set<string>();
|
|
|
+ }
|
|
|
+
|
|
|
+ handleBulkAction(action: string): void {
|
|
|
+ const featured = action === 'add-featured';
|
|
|
+ const products = (this.dataset?.products ?? []).filter((product) => this.selectedKeys.has(product.productKey));
|
|
|
+ if (!products.length) return;
|
|
|
+ this.saving = true;
|
|
|
+ this.error = '';
|
|
|
+ const requests = products.map((product) => {
|
|
|
+ const current = this.knowledgeByKey.get(product.productKey);
|
|
|
+ return this.knowledgeService.upsert({
|
|
|
+ productKey: product.productKey,
|
|
|
+ productId: product.productId,
|
|
|
+ productRole: product.role,
|
|
|
+ featured,
|
|
|
+ status: 'active',
|
|
|
+ tags: current?.tags ?? [],
|
|
|
+ note: current?.note ?? '',
|
|
|
+ ownerUserId: current?.ownerUserId ?? '',
|
|
|
+ });
|
|
|
+ });
|
|
|
+ forkJoin(requests).pipe(takeUntil(this.destroy$)).subscribe({
|
|
|
+ next: (items) => {
|
|
|
+ for (const item of items) this.upsertLocal(item);
|
|
|
+ this.notice = featured ? `已将 ${items.length} 款商品加入重点商品库` : `已将 ${items.length} 款商品移出重点商品库`;
|
|
|
+ this.saving = false;
|
|
|
+ this.clearSelection();
|
|
|
+ },
|
|
|
+ error: () => {
|
|
|
+ this.error = '批量维护失败,请检查本地后端连接后重试。';
|
|
|
+ this.saving = false;
|
|
|
+ },
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
- formatDateTime(d: Date): string {
|
|
|
- try {
|
|
|
- return d.toLocaleString('zh-CN', {
|
|
|
- year: 'numeric',
|
|
|
- month: '2-digit',
|
|
|
- day: '2-digit',
|
|
|
- hour: '2-digit',
|
|
|
- minute: '2-digit'
|
|
|
- });
|
|
|
- } catch {
|
|
|
- return String(d);
|
|
|
- }
|
|
|
+ exportProducts(): void {
|
|
|
+ const headers = ['商品ID', '型号', '商品名称', '品牌', '品类', '类型', '成交金额', '成交件数', '平均单价', '退款率', '关联数', '评价数', '知识标签', '负责人', '资料状态'];
|
|
|
+ const rows = this.filteredProducts.map((product) => {
|
|
|
+ const item = this.item(product);
|
|
|
+ return [
|
|
|
+ product.productId, product.model, product.title, product.brand, this.category(product),
|
|
|
+ product.role === 'own' ? '本店商品' : '竞品', product.summary.gmv, product.summary.soldUnits,
|
|
|
+ product.summary.averageUnitPrice, product.summary.refundToGmvRate, this.relationCount(product),
|
|
|
+ this.reviewCounts.get(product.productId) ?? 0, item?.tags.join('|') ?? '', item?.ownerUserId ?? '',
|
|
|
+ this.completenessLabel(product),
|
|
|
+ ];
|
|
|
+ });
|
|
|
+ const csv = [headers, ...rows].map((row) => row.map((value) => `"${String(value ?? '').replaceAll('"', '""')}"`).join(',')).join('\r\n');
|
|
|
+ const url = URL.createObjectURL(new Blob([`\uFEFF${csv}`], { type: 'text/csv;charset=utf-8' }));
|
|
|
+ const anchor = document.createElement('a');
|
|
|
+ anchor.href = url;
|
|
|
+ anchor.download = `${this.title}-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
|
+ anchor.click();
|
|
|
+ URL.revokeObjectURL(url);
|
|
|
+ this.notice = `已导出 ${rows.length} 条当前筛选结果`;
|
|
|
+ }
|
|
|
+
|
|
|
+ archiveEditor(): void {
|
|
|
+ const product = this.editorProduct;
|
|
|
+ if (!product || !this.knowledgeByKey.has(product.productKey)) return;
|
|
|
+ this.saving = true;
|
|
|
+ this.knowledgeService.archive(product.productKey).pipe(takeUntil(this.destroy$)).subscribe({
|
|
|
+ next: (item) => {
|
|
|
+ this.upsertLocal(item);
|
|
|
+ this.notice = '知识条目已归档';
|
|
|
+ this.saving = false;
|
|
|
+ this.editorProduct = null;
|
|
|
+ },
|
|
|
+ error: () => {
|
|
|
+ this.error = '归档失败,请稍后重试。';
|
|
|
+ this.saving = false;
|
|
|
+ },
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
- private setCompetitorData(data: CompetitorKnowledge[]): void {
|
|
|
- this.competitorData = data || [];
|
|
|
- this.totalPages = Math.max(1, Math.ceil(this.competitorData.length / this.pageSize));
|
|
|
- this.competitorLastUpdated = this.pickLatestUpdate(this.competitorData);
|
|
|
+ item(product: DomesticProduct): ProductKnowledgeItem | undefined {
|
|
|
+ return this.knowledgeByKey.get(product.productKey);
|
|
|
}
|
|
|
|
|
|
- private pickLatestUpdate(rows: CompetitorKnowledge[]): Date | null {
|
|
|
- if (!rows?.length) return null;
|
|
|
- let latest = rows[0]?.lastUpdate ? new Date(rows[0].lastUpdate) : null;
|
|
|
- for (const r of rows) {
|
|
|
- const t = r?.lastUpdate ? new Date(r.lastUpdate) : null;
|
|
|
- if (!t || isNaN(t.getTime())) continue;
|
|
|
- if (!latest || t.getTime() > latest.getTime()) latest = t;
|
|
|
- }
|
|
|
- return latest;
|
|
|
+ isColumnVisible(key: string): boolean {
|
|
|
+ return this.columns.find((column) => column.key === key)?.visible ?? false;
|
|
|
}
|
|
|
|
|
|
- private setInfluencerData(data: InfluencerKnowledge[]): void {
|
|
|
- this.influencerData = data || [];
|
|
|
- this.totalPages = Math.max(1, Math.ceil(this.influencerData.length / this.pageSize));
|
|
|
- this.influencerLastUpdated = this.pickLatestUpdateFromInfluencer(this.influencerData);
|
|
|
+ isSelected(product: DomesticProduct): boolean {
|
|
|
+ return this.selectedKeys.has(product.productKey);
|
|
|
}
|
|
|
|
|
|
- // ---- Pagination + Filtering getters ----
|
|
|
- get displayedCompetitorData(): CompetitorKnowledge[] {
|
|
|
- const filtered = this.filterCompetitorData(this.competitorData);
|
|
|
- return this.paginate(filtered);
|
|
|
+ relationCount(product: DomesticProduct): number {
|
|
|
+ return this.relationCounts.get(product.productKey) ?? product.relationCount ?? 0;
|
|
|
}
|
|
|
|
|
|
- get displayedInfluencerData(): InfluencerKnowledge[] {
|
|
|
- let data = this.influencerData;
|
|
|
- if (this.filters.platform) {
|
|
|
- data = data.filter(d => d.platform === this.filters.platform);
|
|
|
- }
|
|
|
- if (this.filters.minFollowers) {
|
|
|
- data = data.filter(d => d.followers >= (this.filters.minFollowers || 0));
|
|
|
- }
|
|
|
- return this.paginate(data);
|
|
|
+ averageRating(product: DomesticProduct): string {
|
|
|
+ const rating = this.ratingByProduct.get(product.productId);
|
|
|
+ return rating ? rating.toFixed(1) : '-';
|
|
|
}
|
|
|
|
|
|
- get displayedKeywordData(): KeywordKnowledge[] {
|
|
|
- let data = this.keywordData;
|
|
|
- if (this.filters.searchText) {
|
|
|
- const q = this.filters.searchText.toLowerCase();
|
|
|
- data = data.filter(d => d.keyword.toLowerCase().includes(q));
|
|
|
- }
|
|
|
- if (this.filters.minSearchVolume) {
|
|
|
- data = data.filter(d => d.searchVolume >= (this.filters.minSearchVolume || 0));
|
|
|
- }
|
|
|
- return this.paginate(data);
|
|
|
+ completenessLabel(product: DomesticProduct): string {
|
|
|
+ const reviews = this.reviewCounts.get(product.productId) ?? 0;
|
|
|
+ if (product.role === 'competitor') return reviews > 0 ? '证据已采集' : '关系已建 · 待采集';
|
|
|
+ if (this.knowledgeByKey.has(product.productKey)) return '知识已维护';
|
|
|
+ if (product.summary.gmv > 0 || product.summary.soldUnits > 0) return '经营数据完整';
|
|
|
+ return '基础档案';
|
|
|
}
|
|
|
|
|
|
- get displayedPatternData(): PatternKnowledge[] {
|
|
|
- return this.paginate(this.patternData);
|
|
|
+ completenessTone(product: DomesticProduct): string {
|
|
|
+ const label = this.completenessLabel(product);
|
|
|
+ if (label === '证据已采集' || label === '知识已维护') return 'complete';
|
|
|
+ if (label.includes('待采集')) return 'pending';
|
|
|
+ return 'ready';
|
|
|
}
|
|
|
|
|
|
- get displayedHotProductData(): HotProductKnowledge[] {
|
|
|
- return this.paginate(this.hotProductData);
|
|
|
+ category(product: DomesticProduct): string {
|
|
|
+ return product.category3 || product.category2 || product.category1 || '未分类';
|
|
|
}
|
|
|
|
|
|
- private filterCompetitorData(data: CompetitorKnowledge[]): CompetitorKnowledge[] {
|
|
|
- let result = data;
|
|
|
- const q = (this.filters.searchText || '').trim().toLowerCase();
|
|
|
- if (q) {
|
|
|
- result = result.filter(d =>
|
|
|
- (d.asin || '').toLowerCase().includes(q) ||
|
|
|
- (d.brandName || '').toLowerCase().includes(q) ||
|
|
|
- (d.name || '').toLowerCase().includes(q)
|
|
|
- );
|
|
|
- }
|
|
|
- const cat = (this.filters.category || '').trim().toLowerCase();
|
|
|
- if (cat) {
|
|
|
- result = result.filter(d => [d.category, ...(d.categoryCandidates || [])]
|
|
|
- .some(category => String(category || '').toLowerCase().includes(cat)));
|
|
|
- }
|
|
|
- const categoryNodeIds = (this.filters.categoryNodeIds || []).filter(Boolean);
|
|
|
- if (categoryNodeIds.length) {
|
|
|
- const selectedIds = new Set(categoryNodeIds);
|
|
|
- result = result.filter(d => (d.categoryNodeIds || []).some(id => selectedIds.has(id)));
|
|
|
- }
|
|
|
- if (this.filters.minRating) {
|
|
|
- const minR = parseFloat(this.filters.minRating);
|
|
|
- if (!isNaN(minR)) result = result.filter(d => d.rating >= minR);
|
|
|
- }
|
|
|
- if (this.filters.minPrice != null && this.filters.minPrice !== undefined) {
|
|
|
- result = result.filter(d => d.price >= (this.filters.minPrice || 0));
|
|
|
- }
|
|
|
- if (this.filters.maxPrice != null && this.filters.maxPrice !== undefined) {
|
|
|
- result = result.filter(d => d.price <= (this.filters.maxPrice || Infinity));
|
|
|
- }
|
|
|
- // #18 数据完整度筛选(未补齐项不与真实值混淆)
|
|
|
- switch (this.competitorCompleteness) {
|
|
|
- case 'hasSales': result = result.filter(d => d.hasMonthlySales !== false); break;
|
|
|
- case 'hasPrice': result = result.filter(d => d.hasPrice !== false); break;
|
|
|
- case 'incomplete': result = result.filter(d => d.hasMonthlySales === false || d.hasPrice === false); break;
|
|
|
- }
|
|
|
- return result;
|
|
|
+ productImage(product: DomesticProduct): string {
|
|
|
+ return product.detail?.imageUrl || 'assets/images/placeholder.svg';
|
|
|
}
|
|
|
|
|
|
- private paginate<T>(data: T[]): T[] {
|
|
|
- const start = (this.currentPage - 1) * this.pageSize;
|
|
|
- return data.slice(start, start + this.pageSize);
|
|
|
+ productRoute(product: DomesticProduct): any[] {
|
|
|
+ return product.role === 'competitor'
|
|
|
+ ? ['/domestic/competitors', product.productId]
|
|
|
+ : ['/voc-insight/detail'];
|
|
|
}
|
|
|
|
|
|
- getCompetitorCategoryTitle(item: CompetitorKnowledge): string {
|
|
|
- const values = [item.category, ...(item.categoryCandidates || [])]
|
|
|
- .map(v => String(v || '').trim())
|
|
|
- .filter(Boolean);
|
|
|
- return Array.from(new Set(values)).join(' / ') || '-';
|
|
|
+ productQuery(product: DomesticProduct): Record<string, string> | null {
|
|
|
+ return product.role === 'own' ? { productId: product.productId } : null;
|
|
|
}
|
|
|
|
|
|
- openExportReviewsDialog(): void {
|
|
|
- this.showExportReviewsDialog = true;
|
|
|
+ formatNumber(value: number): string {
|
|
|
+ return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 }).format(value || 0);
|
|
|
}
|
|
|
|
|
|
- getDataSourceIcon(status: KnowledgeDataSourceStatus): string {
|
|
|
- switch (status) {
|
|
|
- case 'healthy': return '✓';
|
|
|
- case 'warning': return '!';
|
|
|
- case 'offline': return '✕';
|
|
|
- default: return '◎';
|
|
|
- }
|
|
|
+ formatCurrency(value: number): string {
|
|
|
+ return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY', maximumFractionDigits: 0 }).format(value || 0);
|
|
|
}
|
|
|
|
|
|
- getDataSourceStatusLabel(status: KnowledgeDataSourceStatus): string {
|
|
|
- switch (status) {
|
|
|
- case 'healthy': return '健康';
|
|
|
- case 'warning': return '警告';
|
|
|
- case 'offline': return '离线';
|
|
|
- default: return '-';
|
|
|
- }
|
|
|
+ formatCompactCurrency(value: number): string {
|
|
|
+ if (value >= 100_000_000) return `¥${(value / 100_000_000).toFixed(2)}亿`;
|
|
|
+ if (value >= 10_000) return `¥${(value / 10_000).toFixed(1)}万`;
|
|
|
+ return this.formatCurrency(value);
|
|
|
}
|
|
|
|
|
|
- getDataSourceTone(status: KnowledgeDataSourceStatus): 'green' | 'amber' | 'red' | 'default' {
|
|
|
- switch (status) {
|
|
|
- case 'healthy': return 'green';
|
|
|
- case 'warning': return 'amber';
|
|
|
- case 'offline': return 'red';
|
|
|
- default: return 'default';
|
|
|
- }
|
|
|
+ formatCompactNumber(value: number): string {
|
|
|
+ if (value >= 10_000) return `${(value / 10_000).toFixed(1)}万`;
|
|
|
+ return this.formatNumber(value);
|
|
|
}
|
|
|
|
|
|
- getDataSourceMetrics(source: DataSourceItem): EntryCardMetric[] {
|
|
|
- const metrics: EntryCardMetric[] = [
|
|
|
- { label: '更新频率', value: source.updateFrequency },
|
|
|
- { label: '最后检测', value: this.formatDataSourceSyncTime(source.lastSync) }
|
|
|
- ];
|
|
|
- if (source.latency !== undefined) {
|
|
|
- metrics.push({ label: '响应耗时', value: `${source.latency}ms` });
|
|
|
- }
|
|
|
- return metrics;
|
|
|
+ formatPercent(value: number): string {
|
|
|
+ if (!Number.isFinite(value)) return '-';
|
|
|
+ const normalized = value > 1 ? value : value * 100;
|
|
|
+ return `${normalized.toFixed(1)}%`;
|
|
|
}
|
|
|
|
|
|
- formatDataSourceSyncTime(value: string): string {
|
|
|
- if (!value || value === '-') return '-';
|
|
|
- const parts = value.split(' ');
|
|
|
- return parts.length > 1 ? parts[1] : value;
|
|
|
+ formatDate(value: string | undefined): string {
|
|
|
+ if (!value) return '-';
|
|
|
+ return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
|
|
+ .format(new Date(value));
|
|
|
}
|
|
|
|
|
|
- refreshDataSourceStatus(): void {
|
|
|
- if (this.isRefreshingDataSourceStatus) return;
|
|
|
- this.isRefreshingDataSourceStatus = true;
|
|
|
- this.knowledgeCenterService.getDataSourceConnectionStatus().subscribe({
|
|
|
- next: sources => {
|
|
|
- this.dataSources = sources;
|
|
|
- this.isRefreshingDataSourceStatus = false;
|
|
|
+ private save(
|
|
|
+ product: DomesticProduct,
|
|
|
+ patch: Pick<ProductKnowledgeWrite, 'featured' | 'tags' | 'note' | 'ownerUserId'>,
|
|
|
+ closeOnSuccess = false,
|
|
|
+ ): void {
|
|
|
+ const current = this.knowledgeByKey.get(product.productKey);
|
|
|
+ this.saving = true;
|
|
|
+ this.error = '';
|
|
|
+ this.knowledgeService.upsert({
|
|
|
+ productKey: product.productKey,
|
|
|
+ productId: product.productId,
|
|
|
+ productRole: product.role,
|
|
|
+ featured: patch.featured,
|
|
|
+ status: 'active',
|
|
|
+ tags: patch.tags,
|
|
|
+ note: patch.note,
|
|
|
+ ownerUserId: patch.ownerUserId,
|
|
|
+ }).pipe(takeUntil(this.destroy$)).subscribe({
|
|
|
+ next: (item) => {
|
|
|
+ this.upsertLocal(item);
|
|
|
+ this.notice = closeOnSuccess
|
|
|
+ ? '知识信息已保存'
|
|
|
+ : item.featured ? '已加入重点商品库' : current?.featured ? '已移出重点商品库' : '知识信息已保存';
|
|
|
+ this.saving = false;
|
|
|
+ if (closeOnSuccess) this.editorProduct = null;
|
|
|
},
|
|
|
error: () => {
|
|
|
- const checkedAt = this.getNowText();
|
|
|
- this.dataSources = this.dataSources.map(item => ({
|
|
|
- ...item,
|
|
|
- status: 'offline',
|
|
|
- lastSync: checkedAt,
|
|
|
- message: '状态检测失败'
|
|
|
- }));
|
|
|
- this.isRefreshingDataSourceStatus = false;
|
|
|
- }
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- addDictionary(): void {
|
|
|
- const name = window.prompt('请输入词典名称(如:品牌词典)', '');
|
|
|
- if (!name) return;
|
|
|
- const trimmed = name.trim();
|
|
|
- if (!trimmed) return;
|
|
|
- const id = `dic-${Date.now()}`;
|
|
|
- this.dictionaries = [
|
|
|
- {
|
|
|
- id,
|
|
|
- name: trimmed,
|
|
|
- termCount: 0,
|
|
|
- lastUpdated: this.getNowText(),
|
|
|
- owner: '系统管理员'
|
|
|
+ this.error = '保存失败,请检查后端连接后重试。';
|
|
|
+ this.saving = false;
|
|
|
},
|
|
|
- ...this.dictionaries
|
|
|
- ];
|
|
|
- }
|
|
|
-
|
|
|
- get competitorDataForExport(): { asin: string; name: string }[] {
|
|
|
- return this.competitorData
|
|
|
- .filter(d => d.asin)
|
|
|
- .map(d => ({ asin: d.asin, name: d.name || '' }));
|
|
|
- }
|
|
|
-
|
|
|
- formatPrice(val: any): string {
|
|
|
- const n = Number(val || 0);
|
|
|
- return n > 100 ? (n / 100).toFixed(2) : n.toFixed(2);
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
- /** 将 TikTok CDN 图片转为代理 URL,避免跨域/防盗链问题 */
|
|
|
- proxyImg(url: string): string {
|
|
|
- if (!url) return '';
|
|
|
- if (url.includes('tiktokcdn') || url.includes('tiktokcdn-us') || url.includes('tiktokcdn.com')) {
|
|
|
- return 'https://wsrv.nl/?url=' + encodeURIComponent(url);
|
|
|
+ private upsertLocal(item: ProductKnowledgeItem): void {
|
|
|
+ const index = this.knowledgeItems.findIndex((candidate) => candidate.productKey === item.productKey);
|
|
|
+ if (index >= 0) this.knowledgeItems = this.knowledgeItems.map((candidate, i) => i === index ? item : candidate);
|
|
|
+ else this.knowledgeItems = [...this.knowledgeItems, item];
|
|
|
+ this.knowledgeByKey = new Map(this.knowledgeItems
|
|
|
+ .filter((candidate) => candidate.status === 'active')
|
|
|
+ .map((candidate) => [candidate.productKey, candidate]));
|
|
|
+ }
|
|
|
+
|
|
|
+ private rebuildIndexes(): void {
|
|
|
+ this.knowledgeByKey = new Map(this.knowledgeItems
|
|
|
+ .filter((item) => item.status === 'active')
|
|
|
+ .map((item) => [item.productKey, item]));
|
|
|
+ const counts = new Map<string, number>();
|
|
|
+ const ratingTotals = new Map<string, number>();
|
|
|
+ for (const review of this.dataset?.reviews ?? []) {
|
|
|
+ counts.set(review.productId, (counts.get(review.productId) ?? 0) + 1);
|
|
|
+ ratingTotals.set(review.productId, (ratingTotals.get(review.productId) ?? 0) + review.rating);
|
|
|
+ }
|
|
|
+ this.reviewCounts = counts;
|
|
|
+ this.ratingByProduct = new Map([...ratingTotals].map(([productId, total]) => [productId, total / (counts.get(productId) ?? 1)]));
|
|
|
+ const relationCounts = new Map<string, number>();
|
|
|
+ for (const relation of this.dataset?.relations ?? []) {
|
|
|
+ relationCounts.set(relation.ownProductKey, (relationCounts.get(relation.ownProductKey) ?? 0) + 1);
|
|
|
+ relationCounts.set(relation.competitorProductKey, (relationCounts.get(relation.competitorProductKey) ?? 0) + 1);
|
|
|
}
|
|
|
- return url;
|
|
|
+ this.relationCounts = relationCounts;
|
|
|
}
|
|
|
|
|
|
- private pickLatestUpdateFromInfluencer(rows: InfluencerKnowledge[]): Date | null {
|
|
|
- if (!rows?.length) return null;
|
|
|
- let latest = rows[0]?.lastUpdate ? new Date(rows[0].lastUpdate) : null;
|
|
|
- for (const r of rows) {
|
|
|
- const t = (r as any)?.lastUpdate ? new Date((r as any).lastUpdate) : null;
|
|
|
- if (!t || isNaN(t.getTime())) continue;
|
|
|
- if (!latest || t.getTime() > latest.getTime()) latest = t;
|
|
|
- }
|
|
|
- return latest;
|
|
|
+ private rebuildFilters(): void {
|
|
|
+ const categories = [...new Set((this.dataset?.products ?? [])
|
|
|
+ .filter((product) => this.activeTab === 'featured' || product.role === this.activeTab)
|
|
|
+ .map((product) => this.category(product)))]
|
|
|
+ .sort((left, right) => left.localeCompare(right, 'zh-CN'));
|
|
|
+ this.filters = [
|
|
|
+ { key: 'category', label: '品类', value: '', options: categories.map((value) => ({ value, label: value })) },
|
|
|
+ {
|
|
|
+ key: 'maintenance', label: '维护状态', value: '', options: [
|
|
|
+ { value: 'maintained', label: '已维护' },
|
|
|
+ { value: 'unmaintained', label: '待维护' },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ { key: 'tag', label: '知识标签', value: '', type: 'text', placeholder: '输入标签' },
|
|
|
+ ...(this.activeTab === 'featured' ? [{
|
|
|
+ key: 'role', label: '商品类型', value: '', options: [
|
|
|
+ { value: 'own', label: '本店商品' },
|
|
|
+ { value: 'competitor', label: '竞品' },
|
|
|
+ ],
|
|
|
+ } as CrudTableFilter] : []),
|
|
|
+ ];
|
|
|
}
|
|
|
|
|
|
- private touchConfigUpdateTime(): void {
|
|
|
- const now = this.getNowText();
|
|
|
- this.dataSources = this.dataSources.map(item => ({ ...item, lastSync: now }));
|
|
|
- this.dictionaries = this.dictionaries.map(item => ({ ...item, lastUpdated: now }));
|
|
|
+ private filterValue(key: string): string {
|
|
|
+ return this.filters.find((filter) => filter.key === key)?.value ?? '';
|
|
|
}
|
|
|
|
|
|
- private getNowText(): string {
|
|
|
- return new Date().toLocaleString('zh-CN', {
|
|
|
- year: 'numeric',
|
|
|
- month: '2-digit',
|
|
|
- day: '2-digit',
|
|
|
- hour: '2-digit',
|
|
|
- minute: '2-digit'
|
|
|
- });
|
|
|
+ private parseTags(value: string): string[] {
|
|
|
+ return [...new Set(value.split(/[,,;;\n]/).map((tag) => tag.trim()).filter(Boolean))].slice(0, 20);
|
|
|
}
|
|
|
}
|