|
@@ -0,0 +1,331 @@
|
|
|
|
|
+import { CommonModule } from '@angular/common';
|
|
|
|
|
+import { ChangeDetectorRef, Component, OnDestroy, OnInit, inject } from '@angular/core';
|
|
|
|
|
+import { ActivatedRoute, Router } from '@angular/router';
|
|
|
|
|
+import type { EChartsOption } from 'echarts';
|
|
|
|
|
+import { NgxEchartsModule } from 'ngx-echarts';
|
|
|
|
|
+import { Subject, takeUntil } from 'rxjs';
|
|
|
|
|
+import { DomesticDataset, DomesticProduct, DomesticReview } from '../../app/core/models/domestic.models';
|
|
|
|
|
+import { DomesticDatasetService } from '../../app/core/services/domestic-dataset.service';
|
|
|
|
|
+import { ContentCardComponent } from '../shared/components/content-card/content-card.component';
|
|
|
|
|
+import { DataTableShellComponent } from '../shared/components/data-table-shell/data-table-shell.component';
|
|
|
|
|
+import { EmptyStateComponent } from '../shared/components/empty-state/empty-state.component';
|
|
|
|
|
+import { LoadingSpinnerComponent } from '../shared/components/loading-spinner/loading-spinner.component';
|
|
|
|
|
+import { NeuButtonComponent } from '../shared/components/neu-button/neu-button.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';
|
|
|
|
|
+
|
|
|
|
|
+interface RatingRow {
|
|
|
|
|
+ rating: number;
|
|
|
|
|
+ count: number;
|
|
|
|
|
+ width: number;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+@Component({
|
|
|
|
|
+ selector: 'app-domestic-competitor-detail',
|
|
|
|
|
+ standalone: true,
|
|
|
|
|
+ imports: [
|
|
|
|
|
+ CommonModule,
|
|
|
|
|
+ NgxEchartsModule,
|
|
|
|
|
+ PageHeaderComponent,
|
|
|
|
|
+ SummaryMetricCardComponent,
|
|
|
|
|
+ ContentCardComponent,
|
|
|
|
|
+ DataTableShellComponent,
|
|
|
|
|
+ EmptyStateComponent,
|
|
|
|
|
+ LoadingSpinnerComponent,
|
|
|
|
|
+ NeuButtonComponent,
|
|
|
|
|
+ ProductIdentityComponent,
|
|
|
|
|
+ ],
|
|
|
|
|
+ template: `
|
|
|
|
|
+ <section class="domestic-page competitor-detail-page">
|
|
|
|
|
+ <app-page-header
|
|
|
|
|
+ title="竞品详情"
|
|
|
|
|
+ eyebrow="Competitor Detail"
|
|
|
|
|
+ description="复用原竞品详情框架,展示国内平台商品档案、关联本品与真实评论证据。"
|
|
|
|
|
+ icon="◇">
|
|
|
|
|
+ <app-neu-button pageHeaderActions size="sm" (btnClick)="goBack()">返回竞品关系</app-neu-button>
|
|
|
|
|
+ <app-neu-button pageHeaderActions size="sm" color="primary" (btnClick)="goVoc()">查看竞品 VOC</app-neu-button>
|
|
|
|
|
+ </app-page-header>
|
|
|
|
|
+
|
|
|
|
|
+ @if (product && dataset) {
|
|
|
|
|
+ <section class="competitor-hero" aria-label="竞品商品信息">
|
|
|
|
|
+ <div class="competitor-media">
|
|
|
|
|
+ <img [src]="product.detail?.imageUrl || fallbackImage" [alt]="product.title" (error)="useFallback($event)" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="competitor-hero-copy">
|
|
|
|
|
+ <div class="competitor-kicker"><span>京东竞品</span><span class="status-dot"></span>{{ statusLabel }}</div>
|
|
|
|
|
+ <h2>{{ product.title }}</h2>
|
|
|
|
|
+ <p>{{ product.detail?.sellPoint || '商品详情、规格与评论均来自公司电商数据中转。' }}</p>
|
|
|
|
|
+ <div class="competitor-meta">
|
|
|
|
|
+ <span>品牌 <strong>{{ product.brand || '-' }}</strong></span>
|
|
|
|
|
+ <span>型号 <strong>{{ product.model || '-' }}</strong></span>
|
|
|
|
|
+ <span>商品ID <strong>{{ product.productId }}</strong></span>
|
|
|
|
|
+ <span>类目 <strong>{{ product.category3 || product.category2 || '-' }}</strong></span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="metric-grid">
|
|
|
|
|
+ <app-summary-metric-card label="已采评论" [value]="number(reviews.length)" icon="□" tone="blue" description="当前已入库真实评论样本"></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]="percent(positiveRate)" icon="↑" tone="purple" description="按 4-5 星样本计算"></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 class="detail-grid">
|
|
|
|
|
+ <app-content-card title="商品档案" subtitle="商品身份、类目与规格字段" tone="blue">
|
|
|
|
|
+ <dl class="info-list">
|
|
|
|
|
+ <div><dt>品牌 / 型号</dt><dd>{{ product.brand || '-' }} / {{ product.model || '-' }}</dd></div>
|
|
|
|
|
+ <div><dt>颜色规格</dt><dd>{{ product.detail?.color || product.detail?.specification || '-' }}</dd></div>
|
|
|
|
|
+ <div><dt>商品产地</dt><dd>{{ product.detail?.origin || '-' }}</dd></div>
|
|
|
|
|
+ <div><dt>重量</dt><dd>{{ weightLabel }}</dd></div>
|
|
|
|
|
+ <div><dt>包装尺寸</dt><dd>{{ dimensionsLabel }}</dd></div>
|
|
|
|
|
+ <div><dt>上架时间</dt><dd>{{ date(product.detail?.saleDate) }}</dd></div>
|
|
|
|
|
+ </dl>
|
|
|
|
|
+ </app-content-card>
|
|
|
|
|
+ <app-content-card title="店铺与履约" subtitle="平台商家和配送能力快照" tone="green">
|
|
|
|
|
+ <dl class="info-list">
|
|
|
|
|
+ <div><dt>店铺 ID</dt><dd>{{ product.detail?.shopId || '-' }}</dd></div>
|
|
|
|
|
+ <div><dt>商家 ID</dt><dd>{{ product.detail?.sellerId || '-' }}</dd></div>
|
|
|
|
|
+ <div><dt>商品状态</dt><dd>{{ statusLabel }}</dd></div>
|
|
|
|
|
+ <div><dt>京东配送</dt><dd>{{ product.detail?.jdExpress ? '支持' : '未标记' }}</dd></div>
|
|
|
|
|
+ <div><dt>即时零售</dt><dd>{{ product.detail?.localDelivery ? '支持' : '未标记' }}</dd></div>
|
|
|
|
|
+ <div><dt>详情采集</dt><dd>{{ dateTime(product.detail?.collectedAt) }}</dd></div>
|
|
|
|
|
+ </dl>
|
|
|
|
|
+ </app-content-card>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="detail-grid">
|
|
|
|
|
+ <app-content-card title="评论证据趋势" subtitle="按评论日期聚合当前样本的平均评分和评论数" tone="purple">
|
|
|
|
|
+ @if (hasReviewTrend) {
|
|
|
|
|
+ <div echarts class="chart" [options]="reviewTrendOptions"></div>
|
|
|
|
|
+ } @else {
|
|
|
|
|
+ <app-empty-state size="sm" variant="plain" icon="⌁" title="暂无可绘制的评论趋势" description="评论日期不足时不生成推断曲线。"></app-empty-state>
|
|
|
|
|
+ }
|
|
|
|
|
+ </app-content-card>
|
|
|
|
|
+ <app-content-card title="评分分布" subtitle="仅统计已采评论样本" tone="amber">
|
|
|
|
|
+ @if (ratedReviews.length) {
|
|
|
|
|
+ <div class="rating-list">
|
|
|
|
|
+ @for (row of ratingRows; track row.rating) {
|
|
|
|
|
+ <div class="rating-row">
|
|
|
|
|
+ <span>{{ row.rating }} 星</span>
|
|
|
|
|
+ <span class="rating-track"><span class="rating-fill" [style.width.%]="row.width"></span></span>
|
|
|
|
|
+ <strong>{{ row.count }}</strong>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ }
|
|
|
|
|
+ </div>
|
|
|
|
|
+ } @else {
|
|
|
|
|
+ <app-empty-state size="sm" variant="plain" title="暂无评分样本"></app-empty-state>
|
|
|
|
|
+ }
|
|
|
|
|
+ </app-content-card>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <app-data-table-shell title="关联本品" subtitle="Excel 映射中与当前竞品对应的德玛仕商品" [count]="ownProducts.length" variant="admin" [empty]="ownProducts.length === 0" emptyTitle="暂无关联本品">
|
|
|
|
|
+ <table class="analytics-table">
|
|
|
|
|
+ <thead><tr><th>德玛仕商品</th><th>映射类目</th><th class="numeric">成交金额</th><th class="numeric">成交件数</th></tr></thead>
|
|
|
|
|
+ <tbody>
|
|
|
|
|
+ @for (own of ownProducts; track own.productId) {
|
|
|
|
|
+ <tr>
|
|
|
|
|
+ <td>
|
|
|
|
|
+ <app-product-identity
|
|
|
|
|
+ [showImage]="false"
|
|
|
|
|
+ [title]="own.title"
|
|
|
|
|
+ [productSku]="own.model"
|
|
|
|
|
+ [asin]="own.productId"
|
|
|
|
|
+ size="xs"
|
|
|
|
|
+ [detailRoute]="['/domestic/product']"
|
|
|
|
|
+ [detailQueryParams]="{ productId: own.productId }">
|
|
|
|
|
+ </app-product-identity>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td>{{ relationCategory(own.productId) }}</td>
|
|
|
|
|
+ <td class="numeric">{{ currency(own.summary.gmv) }}</td>
|
|
|
|
|
+ <td class="numeric">{{ number(own.summary.soldUnits) }}</td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ }
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ </app-data-table-shell>
|
|
|
|
|
+
|
|
|
|
|
+ <app-data-table-shell title="评论证据" subtitle="首批评论样本;不展示用户昵称、头像或其他身份字段" [count]="reviews.length" variant="admin" [empty]="reviews.length === 0" emptyTitle="暂无评论证据" emptyDescription="数据源返回评论后会在此展示。">
|
|
|
|
|
+ <table class="analytics-table review-table">
|
|
|
|
|
+ <thead><tr><th>评分</th><th>评论内容</th><th>评论时间</th></tr></thead>
|
|
|
|
|
+ <tbody>
|
|
|
|
|
+ @for (review of reviews; track review.reviewId) {
|
|
|
|
|
+ <tr>
|
|
|
|
|
+ <td><span class="review-rating">{{ review.rating > 0 ? review.rating + ' 星' : '未评分' }}</span></td>
|
|
|
|
|
+ <td class="review-content">{{ review.content }}</td>
|
|
|
|
|
+ <td>{{ date(review.reviewDate) }}</td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ }
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ </app-data-table-shell>
|
|
|
|
|
+
|
|
|
|
|
+ <app-content-card title="趋势与数据边界" subtitle="只展示接口已提供并已落库的数据" tone="amber" variant="dashed">
|
|
|
|
|
+ <ul class="boundary-list">
|
|
|
|
|
+ <li><strong>已有:</strong>商品档案、图片、规格、店铺履约、关联本品和首批评论。</li>
|
|
|
|
|
+ <li><strong>可计算:</strong>评论样本平均分、评分分布和按评论日期聚合的证据趋势。</li>
|
|
|
|
|
+ <li><strong>暂缺:</strong>价格接口当前不可用,销量与价格历史序列尚未入库,因此不展示价格或销量趋势。</li>
|
|
|
|
|
+ </ul>
|
|
|
|
|
+ </app-content-card>
|
|
|
|
|
+ } @else if (loaded) {
|
|
|
|
|
+ <app-empty-state variant="card" icon="◇" title="未找到竞品" description="该商品可能尚未完成竞品详情采集。" actionText="返回竞品关系" (action)="goBack()"></app-empty-state>
|
|
|
|
|
+ } @else {
|
|
|
|
|
+ <app-loading-spinner variant="card" tone="blue" text="正在加载竞品详情"></app-loading-spinner>
|
|
|
|
|
+ }
|
|
|
|
|
+ </section>
|
|
|
|
|
+ `,
|
|
|
|
|
+ styleUrls: ['./domestic-page.shared.scss', './competitor-detail.component.scss'],
|
|
|
|
|
+})
|
|
|
|
|
+export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
|
|
|
|
|
+ private readonly datasetService = inject(DomesticDatasetService);
|
|
|
|
|
+ private readonly route = inject(ActivatedRoute);
|
|
|
|
|
+ private readonly router = inject(Router);
|
|
|
|
|
+ private readonly cdr = inject(ChangeDetectorRef);
|
|
|
|
|
+ private readonly destroy$ = new Subject<void>();
|
|
|
|
|
+
|
|
|
|
|
+ readonly fallbackImage = 'assets/images/placeholder.svg';
|
|
|
|
|
+ dataset?: DomesticDataset;
|
|
|
|
|
+ product?: DomesticProduct;
|
|
|
|
|
+ reviews: DomesticReview[] = [];
|
|
|
|
|
+ ratedReviews: DomesticReview[] = [];
|
|
|
|
|
+ relations: DomesticDataset['relations'] = [];
|
|
|
|
|
+ ownProducts: DomesticProduct[] = [];
|
|
|
|
|
+ ratingRows: RatingRow[] = [];
|
|
|
|
|
+ reviewTrendOptions: EChartsOption = {};
|
|
|
|
|
+ averageRating = 0;
|
|
|
|
|
+ positiveRate = 0;
|
|
|
|
|
+ hasReviewTrend = false;
|
|
|
|
|
+ loaded = false;
|
|
|
|
|
+
|
|
|
|
|
+ ngOnInit(): void {
|
|
|
|
|
+ this.datasetService.dataset$.pipe(takeUntil(this.destroy$)).subscribe((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.loaded = true;
|
|
|
|
|
+ if (!this.product) {
|
|
|
|
|
+ this.cdr.markForCheck();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ this.relations = dataset.relations.filter((relation) => relation.competitorProductId === productId);
|
|
|
|
|
+ const ownIds = new Set(this.relations.map((relation) => relation.ownProductId));
|
|
|
|
|
+ this.ownProducts = dataset.products.filter((product) => product.role === 'own' && ownIds.has(product.productId));
|
|
|
|
|
+ this.reviews = (dataset.reviews ?? [])
|
|
|
|
|
+ .filter((review) => review.productId === productId)
|
|
|
|
|
+ .sort((left, right) => String(right.reviewDate ?? '').localeCompare(String(left.reviewDate ?? '')));
|
|
|
|
|
+ this.ratedReviews = this.reviews.filter((review) => review.rating > 0);
|
|
|
|
|
+ this.averageRating = this.ratedReviews.length
|
|
|
|
|
+ ? this.ratedReviews.reduce((total, review) => total + review.rating, 0) / this.ratedReviews.length
|
|
|
|
|
+ : 0;
|
|
|
|
|
+ this.positiveRate = this.ratedReviews.length
|
|
|
|
|
+ ? this.ratedReviews.filter((review) => review.rating >= 4).length / this.ratedReviews.length
|
|
|
|
|
+ : 0;
|
|
|
|
|
+ this.ratingRows = this.buildRatingRows();
|
|
|
|
|
+ this.reviewTrendOptions = this.buildReviewTrend();
|
|
|
|
|
+ this.cdr.markForCheck();
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ngOnDestroy(): void {
|
|
|
|
|
+ this.destroy$.next();
|
|
|
|
|
+ this.destroy$.complete();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ get statusLabel(): string {
|
|
|
|
|
+ return this.product?.detail?.skuStatus === '1' ? '在售' : this.product?.detail?.skuStatus ? `状态 ${this.product.detail.skuStatus}` : '状态待确认';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ get weightLabel(): string {
|
|
|
|
|
+ return this.product?.detail?.weightKg ? `${this.product.detail.weightKg} kg` : '-';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ get dimensionsLabel(): string {
|
|
|
|
|
+ const dimensions = this.product?.detail?.dimensionsMm;
|
|
|
|
|
+ if (!dimensions || !dimensions.length || !dimensions.width || !dimensions.height) return '-';
|
|
|
|
|
+ return `${dimensions.length} × ${dimensions.width} × ${dimensions.height} mm`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ goBack(): void {
|
|
|
|
|
+ this.router.navigate(['/domestic/competitors']);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ goVoc(): void {
|
|
|
|
|
+ this.router.navigate(['/voc-insight/detail'], {
|
|
|
|
|
+ queryParams: { productId: this.product?.productId || null },
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ useFallback(event: Event): void {
|
|
|
|
|
+ const image = event.target as HTMLImageElement;
|
|
|
|
|
+ if (!image.src.endsWith(this.fallbackImage)) image.src = this.fallbackImage;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ relationCategory(productId: string): string {
|
|
|
|
|
+ return this.relations.find((relation) => relation.ownProductId === productId)?.category || '-';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ number(value: number): string {
|
|
|
|
|
+ return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 }).format(value || 0);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ currency(value: number): string {
|
|
|
|
|
+ return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY', maximumFractionDigits: 0 }).format(value || 0);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ percent(value: number): string {
|
|
|
|
|
+ return value ? `${(value * 100).toFixed(1)}%` : '-';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ date(value?: string): string {
|
|
|
|
|
+ if (!value) return '-';
|
|
|
|
|
+ const parsed = new Date(value);
|
|
|
|
|
+ return Number.isNaN(parsed.getTime()) ? value.slice(0, 10) : parsed.toLocaleDateString('zh-CN');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ dateTime(value?: string): string {
|
|
|
|
|
+ if (!value) return '-';
|
|
|
|
|
+ const parsed = new Date(value);
|
|
|
|
|
+ return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private buildRatingRows(): RatingRow[] {
|
|
|
|
|
+ const counts = new Map<number, number>();
|
|
|
|
|
+ for (let rating = 1; rating <= 5; rating += 1) counts.set(rating, 0);
|
|
|
|
|
+ for (const review of this.ratedReviews) {
|
|
|
|
|
+ const rating = Math.max(1, Math.min(5, Math.round(review.rating)));
|
|
|
|
|
+ counts.set(rating, (counts.get(rating) ?? 0) + 1);
|
|
|
|
|
+ }
|
|
|
|
|
+ const max = Math.max(...counts.values(), 1);
|
|
|
|
|
+ return [5, 4, 3, 2, 1].map((rating) => ({ rating, count: counts.get(rating) ?? 0, width: (counts.get(rating) ?? 0) / max * 100 }));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private buildReviewTrend(): EChartsOption {
|
|
|
|
|
+ const daily = new Map<string, { total: number; count: number }>();
|
|
|
|
|
+ for (const review of this.ratedReviews) {
|
|
|
|
|
+ if (!review.reviewDate) continue;
|
|
|
|
|
+ const date = review.reviewDate.slice(0, 10);
|
|
|
|
|
+ const value = daily.get(date) ?? { total: 0, count: 0 };
|
|
|
|
|
+ value.total += review.rating;
|
|
|
|
|
+ value.count += 1;
|
|
|
|
|
+ daily.set(date, value);
|
|
|
|
|
+ }
|
|
|
|
|
+ const rows = [...daily.entries()].sort(([left], [right]) => left.localeCompare(right));
|
|
|
|
|
+ this.hasReviewTrend = rows.length > 0;
|
|
|
|
|
+ return {
|
|
|
|
|
+ color: ['#4f6bed', '#38a169'],
|
|
|
|
|
+ tooltip: { trigger: 'axis' },
|
|
|
|
|
+ legend: { data: ['平均评分', '评论数'], top: 0 },
|
|
|
|
|
+ grid: { left: 46, right: 44, top: 48, bottom: 34 },
|
|
|
|
|
+ xAxis: { type: 'category', data: rows.map(([date]) => date.slice(5)), axisTick: { show: false } },
|
|
|
|
|
+ yAxis: [
|
|
|
|
|
+ { type: 'value', min: 0, max: 5, name: '评分', splitLine: { lineStyle: { color: '#edf0f4' } } },
|
|
|
|
|
+ { type: 'value', minInterval: 1, name: '评论', splitLine: { show: false } },
|
|
|
|
|
+ ],
|
|
|
|
|
+ series: [
|
|
|
|
|
+ { name: '平均评分', type: 'line', smooth: true, data: rows.map(([, value]) => Number((value.total / value.count).toFixed(2))) },
|
|
|
|
|
+ { name: '评论数', type: 'bar', yAxisIndex: 1, barMaxWidth: 18, data: rows.map(([, value]) => value.count) },
|
|
|
|
|
+ ],
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+}
|