|
|
@@ -0,0 +1,258 @@
|
|
|
+import { DestroyRef, Injectable, computed, inject, signal } from '@angular/core';
|
|
|
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
|
+import { ActivatedRoute, ParamMap, Params, Router } from '@angular/router';
|
|
|
+import { Subscription, switchMap, timer } from 'rxjs';
|
|
|
+import type {
|
|
|
+ CompetitorListingDirection,
|
|
|
+ CompetitorListingMonitorStatus,
|
|
|
+ CompetitorListingOverview,
|
|
|
+ CompetitorListingOverviewRow,
|
|
|
+ CompetitorListingQuery,
|
|
|
+ CompetitorListingQueryState,
|
|
|
+ CompetitorListingRefreshRun,
|
|
|
+ CompetitorListingSort,
|
|
|
+} from '../models/competitor-listing.models';
|
|
|
+import { CompetitorListingApiService } from './competitor-listing-api.service';
|
|
|
+
|
|
|
+const SORTS: CompetitorListingSort[] = ['updatedAt', 'price', 'changePriority'];
|
|
|
+const DIRECTIONS: CompetitorListingDirection[] = ['asc', 'desc'];
|
|
|
+const STATUSES: CompetitorListingMonitorStatus[] = ['not_initialized', 'awaiting_comparison', 'changed', 'unchanged', 'failed'];
|
|
|
+const TERMINAL_RUN_STATUSES = new Set(['completed', 'partial', 'failed']);
|
|
|
+
|
|
|
+export type CompetitorListingLoadState = 'idle' | 'loading' | 'ready' | 'empty' | 'error';
|
|
|
+
|
|
|
+function optionalText(value: string | null | undefined): string | undefined {
|
|
|
+ const normalized = value?.normalize('NFKC').trim();
|
|
|
+ return normalized || undefined;
|
|
|
+}
|
|
|
+
|
|
|
+function enumValue<T extends string>(value: string | null | undefined, allowed: readonly T[]): T | undefined {
|
|
|
+ return value && allowed.includes(value as T) ? value as T : undefined;
|
|
|
+}
|
|
|
+
|
|
|
+export function normalizeCompetitorListingQuery(query: CompetitorListingQuery): CompetitorListingQueryState {
|
|
|
+ const output: CompetitorListingQueryState = {
|
|
|
+ sort: enumValue(query.sort, SORTS) ?? 'updatedAt',
|
|
|
+ direction: enumValue(query.direction, DIRECTIONS) ?? 'desc',
|
|
|
+ };
|
|
|
+ const search = optionalText(query.search);
|
|
|
+ const brand = optionalText(query.brand);
|
|
|
+ const category = optionalText(query.category);
|
|
|
+ const status = enumValue(query.status, STATUSES);
|
|
|
+ if (search) output.search = search;
|
|
|
+ if (brand) output.brand = brand;
|
|
|
+ if (category) output.category = category;
|
|
|
+ if (status) output.status = status;
|
|
|
+ return output;
|
|
|
+}
|
|
|
+
|
|
|
+export function competitorListingQueryFromParamMap(params: ParamMap): CompetitorListingQueryState {
|
|
|
+ return normalizeCompetitorListingQuery({
|
|
|
+ search: params.get('search') ?? undefined,
|
|
|
+ brand: params.get('brand') ?? undefined,
|
|
|
+ category: params.get('category') ?? undefined,
|
|
|
+ status: params.get('status') as CompetitorListingMonitorStatus | undefined,
|
|
|
+ sort: params.get('sort') as CompetitorListingSort | undefined,
|
|
|
+ direction: params.get('direction') as CompetitorListingDirection | undefined,
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+export function competitorListingQueryToParams(query: CompetitorListingQuery): Params {
|
|
|
+ const normalized = normalizeCompetitorListingQuery(query);
|
|
|
+ const params: Params = { sort: normalized.sort, direction: normalized.direction };
|
|
|
+ for (const key of ['search', 'brand', 'category', 'status'] as const) {
|
|
|
+ if (normalized[key]) params[key] = normalized[key];
|
|
|
+ }
|
|
|
+ return params;
|
|
|
+}
|
|
|
+
|
|
|
+@Injectable()
|
|
|
+export class CompetitorListingOverviewStore {
|
|
|
+ private readonly api = inject(CompetitorListingApiService);
|
|
|
+ private readonly route = inject(ActivatedRoute);
|
|
|
+ private readonly router = inject(Router);
|
|
|
+ private readonly destroyRef = inject(DestroyRef);
|
|
|
+ private workspaceId = '';
|
|
|
+ private connected = false;
|
|
|
+ private overviewRequest?: Subscription;
|
|
|
+ private pollRequest?: Subscription;
|
|
|
+ private requestVersion = 0;
|
|
|
+
|
|
|
+ readonly query = signal<CompetitorListingQueryState>(normalizeCompetitorListingQuery({}));
|
|
|
+ readonly overview = signal<CompetitorListingOverview | null>(null);
|
|
|
+ readonly loading = signal(false);
|
|
|
+ readonly error = signal('');
|
|
|
+ readonly refreshing = signal(false);
|
|
|
+ readonly refreshMessage = signal('');
|
|
|
+ readonly currentRun = signal<CompetitorListingRefreshRun | null>(null);
|
|
|
+ readonly items = computed(() => filterAndSort(this.overview()?.items ?? [], this.query()));
|
|
|
+ readonly state = computed<CompetitorListingLoadState>(() => {
|
|
|
+ if (this.loading() && !this.overview()) return 'loading';
|
|
|
+ if (this.error() && !this.overview()) return 'error';
|
|
|
+ if (!this.overview()) return 'idle';
|
|
|
+ return this.overview()!.items.length ? 'ready' : 'empty';
|
|
|
+ });
|
|
|
+
|
|
|
+ connect(workspaceId: string): void {
|
|
|
+ if (this.connected) return;
|
|
|
+ this.connected = true;
|
|
|
+ this.workspaceId = workspaceId;
|
|
|
+ this.route.queryParamMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
|
|
|
+ const next = competitorListingQueryFromParamMap(params);
|
|
|
+ this.query.set(next);
|
|
|
+ if (!paramMapMatches(params, next)) this.syncUrl(next);
|
|
|
+ if (!this.overview() && !this.loading()) this.loadOverview();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ patchQuery(patch: Partial<CompetitorListingQuery>): void {
|
|
|
+ const next = normalizeCompetitorListingQuery({ ...this.query(), ...patch });
|
|
|
+ this.query.set(next);
|
|
|
+ this.syncUrl(next);
|
|
|
+ }
|
|
|
+
|
|
|
+ resetQuery(): void {
|
|
|
+ const next = normalizeCompetitorListingQuery({});
|
|
|
+ this.query.set(next);
|
|
|
+ this.syncUrl(next);
|
|
|
+ }
|
|
|
+
|
|
|
+ retry(): void { this.loadOverview(true); }
|
|
|
+
|
|
|
+ refresh(): void {
|
|
|
+ if (!this.workspaceId || this.refreshing()) return;
|
|
|
+ this.pollRequest?.unsubscribe();
|
|
|
+ this.refreshing.set(true);
|
|
|
+ this.refreshMessage.set('正在创建刷新任务…');
|
|
|
+ this.api.refresh(this.workspaceId).subscribe({
|
|
|
+ next: ({ run }) => {
|
|
|
+ this.refreshMessage.set(`正在刷新 0 / ${run.total}`);
|
|
|
+ this.pollRun(run.id);
|
|
|
+ },
|
|
|
+ error: (error: unknown) => {
|
|
|
+ this.refreshing.set(false);
|
|
|
+ if (isRefreshConflict(error)) {
|
|
|
+ this.refreshMessage.set('已有刷新任务正在运行,请稍后查看最新状态');
|
|
|
+ this.loadOverview(true);
|
|
|
+ } else {
|
|
|
+ this.refreshMessage.set('刷新任务创建失败,请稍后重试');
|
|
|
+ }
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private pollRun(runId: string): void {
|
|
|
+ this.pollRequest = timer(0, 2_000).pipe(
|
|
|
+ switchMap(() => this.api.run(this.workspaceId, runId)),
|
|
|
+ takeUntilDestroyed(this.destroyRef),
|
|
|
+ ).subscribe({
|
|
|
+ next: ({ run }) => {
|
|
|
+ this.currentRun.set(run);
|
|
|
+ this.refreshMessage.set(runMessage(run));
|
|
|
+ if (TERMINAL_RUN_STATUSES.has(run.status)) {
|
|
|
+ this.pollRequest?.unsubscribe();
|
|
|
+ this.refreshing.set(false);
|
|
|
+ this.loadOverview(true);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ error: () => {
|
|
|
+ this.pollRequest?.unsubscribe();
|
|
|
+ this.refreshing.set(false);
|
|
|
+ this.refreshMessage.set('刷新状态查询失败,请重新加载页面');
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private loadOverview(force = false): void {
|
|
|
+ if (!this.workspaceId || (this.loading() && !force)) return;
|
|
|
+ this.overviewRequest?.unsubscribe();
|
|
|
+ const version = ++this.requestVersion;
|
|
|
+ this.loading.set(true);
|
|
|
+ this.error.set('');
|
|
|
+ this.overviewRequest = this.api.overview(this.workspaceId).subscribe({
|
|
|
+ next: (overview) => {
|
|
|
+ if (version === this.requestVersion) this.overview.set(overview);
|
|
|
+ },
|
|
|
+ error: () => {
|
|
|
+ if (version !== this.requestVersion) return;
|
|
|
+ this.loading.set(false);
|
|
|
+ this.error.set('竞品 Listing 监控数据加载失败,请稍后重试');
|
|
|
+ },
|
|
|
+ complete: () => {
|
|
|
+ if (version === this.requestVersion) this.loading.set(false);
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private syncUrl(query: CompetitorListingQuery): void {
|
|
|
+ void this.router.navigate([], {
|
|
|
+ relativeTo: this.route,
|
|
|
+ queryParams: competitorListingQueryToParams(query),
|
|
|
+ replaceUrl: true,
|
|
|
+ });
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function filterAndSort(items: CompetitorListingOverviewRow[], query: CompetitorListingQueryState): CompetitorListingOverviewRow[] {
|
|
|
+ const search = query.search?.toLocaleLowerCase('zh-CN');
|
|
|
+ const filtered = items.filter((item) => {
|
|
|
+ if (search && !`${item.title ?? ''} ${item.productId}`.normalize('NFKC').toLocaleLowerCase('zh-CN').includes(search)) return false;
|
|
|
+ if (query.brand && item.brand !== query.brand) return false;
|
|
|
+ if (query.category && item.category !== query.category) return false;
|
|
|
+ if (query.status && item.monitorStatus !== query.status) return false;
|
|
|
+ return true;
|
|
|
+ });
|
|
|
+ return [...filtered].sort((left, right) => {
|
|
|
+ let compared = 0;
|
|
|
+ if (query.sort === 'price') compared = nullableNumber(left.currentSnapshot?.priceCents, right.currentSnapshot?.priceCents);
|
|
|
+ else if (query.sort === 'changePriority') compared = changePriority(left) - changePriority(right);
|
|
|
+ else compared = nullableText(updatedAt(left), updatedAt(right));
|
|
|
+ if (compared === 0) compared = left.productId.localeCompare(right.productId);
|
|
|
+ return query.direction === 'asc' ? compared : -compared;
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function updatedAt(item: CompetitorListingOverviewRow): string | null {
|
|
|
+ return item.latestCollectionResult?.observedAt ?? item.currentSnapshot?.observedAt ?? null;
|
|
|
+}
|
|
|
+
|
|
|
+function changePriority(item: CompetitorListingOverviewRow): number {
|
|
|
+ if (item.monitorStatus === 'failed') return 5;
|
|
|
+ if (!item.latestChange) return item.monitorStatus === 'not_initialized' ? 0 : 1;
|
|
|
+ if (item.latestChange.changeTypes.includes('availability')) return 4;
|
|
|
+ if (item.latestChange.changeTypes.includes('price')) return 3;
|
|
|
+ return 2;
|
|
|
+}
|
|
|
+
|
|
|
+function nullableNumber(left: number | null | undefined, right: number | null | undefined): number {
|
|
|
+ if (left == null && right == null) return 0;
|
|
|
+ if (left == null) return -1;
|
|
|
+ if (right == null) return 1;
|
|
|
+ return left - right;
|
|
|
+}
|
|
|
+
|
|
|
+function nullableText(left: string | null, right: string | null): number {
|
|
|
+ if (!left && !right) return 0;
|
|
|
+ if (!left) return -1;
|
|
|
+ if (!right) return 1;
|
|
|
+ return left.localeCompare(right);
|
|
|
+}
|
|
|
+
|
|
|
+function paramMapMatches(params: ParamMap, query: CompetitorListingQuery): boolean {
|
|
|
+ const desired = competitorListingQueryToParams(query);
|
|
|
+ const keys = Object.keys(desired).sort();
|
|
|
+ if (params.keys.slice().sort().join('|') !== keys.join('|')) return false;
|
|
|
+ return keys.every((key) => params.get(key) === String(desired[key]));
|
|
|
+}
|
|
|
+
|
|
|
+function isRefreshConflict(error: unknown): boolean {
|
|
|
+ const value = error as { status?: unknown; error?: { error?: unknown } };
|
|
|
+ return value?.status === 409 && value.error?.error === 'competitor_listing_refresh_running';
|
|
|
+}
|
|
|
+
|
|
|
+function runMessage(run: CompetitorListingRefreshRun): string {
|
|
|
+ if (run.status === 'completed') return `刷新完成:${run.completed} 个竞品已处理`;
|
|
|
+ if (run.status === 'partial') return `刷新部分完成:${run.failed} 个竞品失败`;
|
|
|
+ if (run.status === 'failed') return `刷新失败:${run.failed || run.total} 个竞品未完成`;
|
|
|
+ return `正在刷新 ${run.completed} / ${run.total}`;
|
|
|
+}
|