|
@@ -0,0 +1,232 @@
|
|
|
|
|
+import { DestroyRef, Injectable, computed, inject, signal } from '@angular/core';
|
|
|
|
|
+import { ActivatedRoute, ParamMap, Params, Router } from '@angular/router';
|
|
|
|
|
+import { Subscription } from 'rxjs';
|
|
|
|
|
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
|
|
|
+import type {
|
|
|
|
|
+ ListingDimension,
|
|
|
|
|
+ ListingOverviewDirection,
|
|
|
|
|
+ ListingOverviewPage,
|
|
|
|
|
+ ListingOverviewQuery,
|
|
|
|
|
+ ListingOverviewQueryState,
|
|
|
|
|
+ ListingOverviewRow,
|
|
|
|
|
+ ListingOverviewScoreNature,
|
|
|
|
|
+ ListingOverviewSort,
|
|
|
|
|
+ ListingOverviewSummary,
|
|
|
|
|
+} from '../models/listing-ai.models';
|
|
|
|
|
+import { ListingAiApiService } from './listing-ai-api.service';
|
|
|
|
|
+
|
|
|
|
|
+const SORTS: ListingOverviewSort[] = ['productId', 'overallScore', 'title', 'selling_points', 'images', 'description', 'specifications', 'improvementPotential', 'scoredAt', 'syncedAt'];
|
|
|
|
|
+const DIRECTIONS: ListingOverviewDirection[] = ['asc', 'desc'];
|
|
|
|
|
+const SCORE_NATURES: ListingOverviewScoreNature[] = ['simulation', 'formal_ai', 'rule_precheck', 'unscored'];
|
|
|
|
|
+const DIMENSIONS: ListingDimension[] = ['title', 'selling_points', 'images', 'description', 'specifications'];
|
|
|
|
|
+const NUMBER_KEYS = ['minScore', 'maxScore', 'titleMin', 'titleMax', 'sellingPointsMin', 'sellingPointsMax', 'imagesMin', 'imagesMax', 'descriptionMin', 'descriptionMax', 'specificationsMin', 'specificationsMax'] as const;
|
|
|
|
|
+
|
|
|
|
|
+export type ListingOverviewLoadState = 'idle' | 'loading' | 'ready' | 'empty' | 'error';
|
|
|
|
|
+
|
|
|
|
|
+function optionalText(value: string | null | undefined): string | undefined {
|
|
|
|
|
+ const normalized = value?.trim();
|
|
|
|
|
+ return normalized ? normalized : undefined;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function optionalNumber(value: unknown): number | undefined {
|
|
|
|
|
+ if (value === '' || value === null || value === undefined) return undefined;
|
|
|
|
|
+ const parsed = typeof value === 'number' ? value : Number(value);
|
|
|
|
|
+ return Number.isFinite(parsed) ? parsed : 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 normalizeListingOverviewQuery(query: ListingOverviewQuery): ListingOverviewQueryState {
|
|
|
|
|
+ const output: ListingOverviewQueryState = {
|
|
|
|
|
+ sort: enumValue(query.sort, SORTS) ?? 'improvementPotential',
|
|
|
|
|
+ direction: enumValue(query.direction, DIRECTIONS) ?? 'desc',
|
|
|
|
|
+ limit: Math.min(100, Math.max(1, Math.trunc(optionalNumber(query.limit) ?? 25))),
|
|
|
|
|
+ };
|
|
|
|
|
+ const search = optionalText(query.search);
|
|
|
|
|
+ if (search) output.search = search;
|
|
|
|
|
+ const categoryIds = [...new Set((query.categoryIds ?? []).map((value) => value.trim()).filter(Boolean))];
|
|
|
|
|
+ if (categoryIds.length) output.categoryIds = categoryIds;
|
|
|
|
|
+ const scoreNature = enumValue(query.scoreNature, SCORE_NATURES);
|
|
|
|
|
+ if (scoreNature) output.scoreNature = scoreNature;
|
|
|
|
|
+ const weakestDimension = enumValue(query.weakestDimension, DIMENSIONS);
|
|
|
|
|
+ if (weakestDimension) output.weakestDimension = weakestDimension;
|
|
|
|
|
+ for (const key of NUMBER_KEYS) {
|
|
|
|
|
+ const value = optionalNumber(query[key]);
|
|
|
|
|
+ if (value !== undefined) output[key] = value;
|
|
|
|
|
+ }
|
|
|
|
|
+ const cursor = optionalText(query.cursor);
|
|
|
|
|
+ if (cursor) output.cursor = cursor;
|
|
|
|
|
+ return output;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function listingOverviewQueryFromParamMap(params: ParamMap): ListingOverviewQueryState {
|
|
|
|
|
+ const categories = params.getAll('categoryIds').flatMap((value) => value.split(','));
|
|
|
|
|
+ return normalizeListingOverviewQuery({
|
|
|
|
|
+ search: params.get('search') ?? undefined,
|
|
|
|
|
+ categoryIds: categories,
|
|
|
|
|
+ scoreNature: params.get('scoreNature') as ListingOverviewScoreNature | undefined,
|
|
|
|
|
+ minScore: optionalNumber(params.get('minScore')),
|
|
|
|
|
+ maxScore: optionalNumber(params.get('maxScore')),
|
|
|
|
|
+ titleMin: optionalNumber(params.get('titleMin')),
|
|
|
|
|
+ titleMax: optionalNumber(params.get('titleMax')),
|
|
|
|
|
+ sellingPointsMin: optionalNumber(params.get('sellingPointsMin')),
|
|
|
|
|
+ sellingPointsMax: optionalNumber(params.get('sellingPointsMax')),
|
|
|
|
|
+ imagesMin: optionalNumber(params.get('imagesMin')),
|
|
|
|
|
+ imagesMax: optionalNumber(params.get('imagesMax')),
|
|
|
|
|
+ descriptionMin: optionalNumber(params.get('descriptionMin')),
|
|
|
|
|
+ descriptionMax: optionalNumber(params.get('descriptionMax')),
|
|
|
|
|
+ specificationsMin: optionalNumber(params.get('specificationsMin')),
|
|
|
|
|
+ specificationsMax: optionalNumber(params.get('specificationsMax')),
|
|
|
|
|
+ weakestDimension: params.get('weakestDimension') as ListingDimension | undefined,
|
|
|
|
|
+ sort: params.get('sort') as ListingOverviewSort | undefined,
|
|
|
|
|
+ direction: params.get('direction') as ListingOverviewDirection | undefined,
|
|
|
|
|
+ limit: optionalNumber(params.get('limit')),
|
|
|
|
|
+ cursor: params.get('cursor') ?? undefined,
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function listingOverviewQueryToParams(query: ListingOverviewQuery): Params {
|
|
|
|
|
+ const normalized = normalizeListingOverviewQuery(query);
|
|
|
|
|
+ const params: Params = { sort: normalized.sort, direction: normalized.direction, limit: normalized.limit };
|
|
|
|
|
+ for (const key of ['search', 'scoreNature', 'weakestDimension', 'cursor'] as const) {
|
|
|
|
|
+ if (normalized[key] !== undefined) params[key] = normalized[key];
|
|
|
|
|
+ }
|
|
|
|
|
+ if (normalized.categoryIds?.length) params['categoryIds'] = normalized.categoryIds;
|
|
|
|
|
+ for (const key of NUMBER_KEYS) {
|
|
|
|
|
+ if (normalized[key] !== undefined) params[key] = normalized[key];
|
|
|
|
|
+ }
|
|
|
|
|
+ return params;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function queryKey(query: ListingOverviewQuery): string {
|
|
|
|
|
+ const params = listingOverviewQueryToParams(query);
|
|
|
|
|
+ return JSON.stringify(Object.keys(params).sort().map((key) => [key, params[key]]));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function paramMapMatches(params: ParamMap, query: ListingOverviewQuery): boolean {
|
|
|
|
|
+ const desired = listingOverviewQueryToParams(query);
|
|
|
|
|
+ const desiredKeys = Object.keys(desired).sort();
|
|
|
|
|
+ if (params.keys.slice().sort().join('|') !== desiredKeys.join('|')) return false;
|
|
|
|
|
+ return desiredKeys.every((key) => {
|
|
|
|
|
+ const expected = Array.isArray(desired[key]) ? desired[key].map(String) : [String(desired[key])];
|
|
|
|
|
+ return params.getAll(key).join('|') === expected.join('|');
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function isStaleCursorError(error: unknown): boolean {
|
|
|
|
|
+ const value = error as { status?: unknown; error?: { error?: unknown } };
|
|
|
|
|
+ return value?.status === 409 && value.error?.error === 'listing_overview_cursor_stale';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+@Injectable()
|
|
|
|
|
+export class ListingAiOverviewStore {
|
|
|
|
|
+ private readonly api = inject(ListingAiApiService);
|
|
|
|
|
+ private readonly route = inject(ActivatedRoute);
|
|
|
|
|
+ private readonly router = inject(Router);
|
|
|
|
|
+ private readonly destroyRef = inject(DestroyRef);
|
|
|
|
|
+ private workspaceId = '';
|
|
|
|
|
+ private connected = false;
|
|
|
|
|
+ private request?: Subscription;
|
|
|
|
|
+ private requestVersion = 0;
|
|
|
|
|
+ private inFlightKey = '';
|
|
|
|
|
+ private successfulKey = '';
|
|
|
|
|
+
|
|
|
|
|
+ readonly query = signal<ListingOverviewQueryState>(normalizeListingOverviewQuery({}));
|
|
|
|
|
+ readonly items = signal<ListingOverviewRow[]>([]);
|
|
|
|
|
+ readonly summary = signal<ListingOverviewSummary | null>(null);
|
|
|
|
|
+ readonly nextCursor = signal<string | null>(null);
|
|
|
|
|
+ readonly loading = signal(false);
|
|
|
|
|
+ readonly error = signal('');
|
|
|
|
|
+ readonly state = computed<ListingOverviewLoadState>(() => {
|
|
|
|
|
+ if (this.loading()) return 'loading';
|
|
|
|
|
+ if (this.error()) return 'error';
|
|
|
|
|
+ if (!this.summary()) return 'idle';
|
|
|
|
|
+ return this.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 = listingOverviewQueryFromParamMap(params);
|
|
|
|
|
+ this.query.set(next);
|
|
|
|
|
+ if (!paramMapMatches(params, next)) this.syncUrl(next);
|
|
|
|
|
+ this.loadQuery(next);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ patchQuery(patch: Partial<ListingOverviewQuery>): void {
|
|
|
|
|
+ const next = normalizeListingOverviewQuery({ ...this.query(), ...patch, cursor: undefined });
|
|
|
|
|
+ this.query.set(next);
|
|
|
|
|
+ this.syncUrl(next);
|
|
|
|
|
+ this.loadQuery(next);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ resetQuery(): void {
|
|
|
|
|
+ const next = normalizeListingOverviewQuery({});
|
|
|
|
|
+ this.query.set(next);
|
|
|
|
|
+ this.syncUrl(next);
|
|
|
|
|
+ this.loadQuery(next);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ goToCursor(cursor: string | null): void {
|
|
|
|
|
+ const next = normalizeListingOverviewQuery({ ...this.query(), cursor: cursor ?? undefined });
|
|
|
|
|
+ this.query.set(next);
|
|
|
|
|
+ this.syncUrl(next);
|
|
|
|
|
+ this.loadQuery(next);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ nextPage(): void {
|
|
|
|
|
+ if (this.nextCursor()) this.goToCursor(this.nextCursor());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ retry(): void {
|
|
|
|
|
+ this.loadQuery(this.query(), true);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private syncUrl(query: ListingOverviewQuery): void {
|
|
|
|
|
+ void this.router.navigate([], { relativeTo: this.route, queryParams: listingOverviewQueryToParams(query), replaceUrl: true });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private loadQuery(query: ListingOverviewQueryState, force = false, staleRetry = false): void {
|
|
|
|
|
+ if (!this.workspaceId) return;
|
|
|
|
|
+ const key = `${this.workspaceId}|${queryKey(query)}`;
|
|
|
|
|
+ if (!force && (this.inFlightKey === key || this.successfulKey === key)) return;
|
|
|
|
|
+ this.request?.unsubscribe();
|
|
|
|
|
+ const version = ++this.requestVersion;
|
|
|
|
|
+ this.inFlightKey = key;
|
|
|
|
|
+ this.loading.set(true);
|
|
|
|
|
+ this.error.set('');
|
|
|
|
|
+ const request = this.api.overview(this.workspaceId, query).subscribe({
|
|
|
|
|
+ next: (page: ListingOverviewPage) => {
|
|
|
|
|
+ if (version !== this.requestVersion) return;
|
|
|
|
|
+ this.items.set(page.items);
|
|
|
|
|
+ this.summary.set(page.summary);
|
|
|
|
|
+ this.nextCursor.set(page.nextCursor);
|
|
|
|
|
+ this.successfulKey = key;
|
|
|
|
|
+ },
|
|
|
|
|
+ error: (error: unknown) => {
|
|
|
|
|
+ if (version !== this.requestVersion) return;
|
|
|
|
|
+ this.inFlightKey = '';
|
|
|
|
|
+ if (!staleRetry && query.cursor && isStaleCursorError(error)) {
|
|
|
|
|
+ const firstPage = normalizeListingOverviewQuery({ ...query, cursor: undefined });
|
|
|
|
|
+ this.query.set(firstPage);
|
|
|
|
|
+ this.syncUrl(firstPage);
|
|
|
|
|
+ this.loadQuery(firstPage, true, true);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ this.loading.set(false);
|
|
|
|
|
+ this.error.set('Listing 评分看板加载失败,请稍后重试');
|
|
|
|
|
+ },
|
|
|
|
|
+ complete: () => {
|
|
|
|
|
+ if (version !== this.requestVersion) return;
|
|
|
|
|
+ this.inFlightKey = '';
|
|
|
|
|
+ this.loading.set(false);
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+ if (version === this.requestVersion) this.request = request;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|