feat: add sortable market metric columns
This commit is contained in:
+946
-123
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,9 @@ import {
|
||||
parseRateLowerBound
|
||||
} from "../../shared/rate-normalizer";
|
||||
import type {
|
||||
AfterSearchRates,
|
||||
BackendMetrics,
|
||||
MarketSortField,
|
||||
MarketFilterState,
|
||||
MarketRecord,
|
||||
MarketSortState
|
||||
@@ -67,8 +70,21 @@ function compareRecords(
|
||||
rightRecord: MarketRecord,
|
||||
sort: MarketSortState
|
||||
): number {
|
||||
const leftValue = leftRecord.rates?.[sort.field];
|
||||
const rightValue = rightRecord.rates?.[sort.field];
|
||||
if (isRateSortField(sort.field)) {
|
||||
return compareRateSortRecords(leftRecord, rightRecord, sort);
|
||||
}
|
||||
|
||||
return compareBackendMetricRecords(leftRecord, rightRecord, sort);
|
||||
}
|
||||
|
||||
function compareRateSortRecords(
|
||||
leftRecord: MarketRecord,
|
||||
rightRecord: MarketRecord,
|
||||
sort: MarketSortState
|
||||
): number {
|
||||
const field = sort.field as keyof Required<AfterSearchRates>;
|
||||
const leftValue = leftRecord.rates?.[field];
|
||||
const rightValue = rightRecord.rates?.[field];
|
||||
const leftLowerBound = parseRateLowerBound(leftValue ?? null);
|
||||
const rightLowerBound = parseRateLowerBound(rightValue ?? null);
|
||||
|
||||
@@ -93,3 +109,50 @@ function compareRecords(
|
||||
const tieBreak = compareRateValues(leftValue, rightValue);
|
||||
return sort.direction === "asc" ? tieBreak : -tieBreak;
|
||||
}
|
||||
|
||||
function compareBackendMetricRecords(
|
||||
leftRecord: MarketRecord,
|
||||
rightRecord: MarketRecord,
|
||||
sort: MarketSortState
|
||||
): number {
|
||||
const field = sort.field as keyof Required<BackendMetrics>;
|
||||
const leftValue = parseBackendMetricValue(leftRecord.backendMetrics?.[field]);
|
||||
const rightValue = parseBackendMetricValue(rightRecord.backendMetrics?.[field]);
|
||||
|
||||
if (leftValue == null && rightValue == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (leftValue == null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (rightValue == null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return sort.direction === "asc" ? leftValue - rightValue : rightValue - leftValue;
|
||||
}
|
||||
|
||||
function parseBackendMetricValue(value: string | null | undefined): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedValue = value.replace(/,/g, "").replace(/%/g, "").trim();
|
||||
if (!normalizedValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numericValue = Number(normalizedValue);
|
||||
return Number.isFinite(numericValue) ? numericValue : null;
|
||||
}
|
||||
|
||||
function isRateSortField(
|
||||
field: MarketSortField
|
||||
): field is keyof Required<AfterSearchRates> {
|
||||
return (
|
||||
field === "singleVideoAfterSearchRate" ||
|
||||
field === "personalVideoAfterSearchRate"
|
||||
);
|
||||
}
|
||||
|
||||
+196
-37
@@ -4,6 +4,7 @@ import {
|
||||
applyRowOrder,
|
||||
applyRowVisibility,
|
||||
renderMarketRowState,
|
||||
syncPluginSortHeaders,
|
||||
syncMarketTable,
|
||||
type MarketRowDom
|
||||
} from "./dom-sync";
|
||||
@@ -14,7 +15,8 @@ import { ensurePluginToolbar } from "./plugin-toolbar";
|
||||
import {
|
||||
readToolbarExportTarget,
|
||||
setToolbarBusyState,
|
||||
setToolbarExportStatus
|
||||
setToolbarExportStatus,
|
||||
setToolbarSortState
|
||||
} from "./plugin-toolbar";
|
||||
import { createMarketResultStore } from "./result-store";
|
||||
import {
|
||||
@@ -88,6 +90,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
},
|
||||
prepareCurrentPageForExport: prepareCurrentPageForExport,
|
||||
readCurrentPageRecords: () => getVisibleOrderedRecords(),
|
||||
readCurrentPageRowCount: () => countCurrentPageRows(options.document),
|
||||
window: options.window
|
||||
});
|
||||
let activeFilters: MarketFilterState = {};
|
||||
@@ -99,12 +102,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
scheduleSync();
|
||||
});
|
||||
const observationRoot = options.document.body ?? options.document.documentElement;
|
||||
if (observationRoot) {
|
||||
observer.observe(observationRoot, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
startObserving();
|
||||
|
||||
const toolbar = ensurePluginToolbar(options.document, {
|
||||
onApplyFilter: async () => {
|
||||
@@ -209,7 +207,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
|
||||
for (const rowDom of table.rows) {
|
||||
const rowSnapshot = readRowSnapshot(rowDom);
|
||||
if (!rowSnapshot.authorId) {
|
||||
if (!rowSnapshot.authorId || !hasTextValue(rowSnapshot.authorName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -362,14 +360,27 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
}
|
||||
|
||||
function applyCurrentView(): void {
|
||||
const table = syncMarketTable(options.document);
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
runWithoutMutationSync(() => {
|
||||
const table = syncMarketTable(options.document);
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
|
||||
const records = getVisibleOrderedRecords(table);
|
||||
applyRowVisibility(table, new Set(records.map((record) => record.authorId)));
|
||||
applyRowOrder(table, records.map((record) => record.authorId));
|
||||
syncPluginSortHeaders(options.document, {
|
||||
activeSort,
|
||||
onToggleSort: toggleSortFromHeader
|
||||
});
|
||||
|
||||
const records = getVisibleOrderedRecords(table);
|
||||
applyRowVisibility(table, new Set(records.map((record) => record.authorId)));
|
||||
applyRowOrder(table, records.map((record) => record.authorId));
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSortFromHeader(field: MarketSortState["field"]): void {
|
||||
activeSort = getNextSortState(activeSort, field);
|
||||
setToolbarSortState(toolbar, activeSort);
|
||||
applyCurrentView();
|
||||
}
|
||||
|
||||
function getVisibleOrderedRecords(table = syncMarketTable(options.document)): MarketRecord[] {
|
||||
@@ -397,6 +408,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
async function prepareCurrentPageForExport(): Promise<void> {
|
||||
await runSyncCycle();
|
||||
await harvestCurrentPageForExport();
|
||||
await runSyncCycle();
|
||||
}
|
||||
|
||||
async function harvestCurrentPageForExport(): Promise<void> {
|
||||
@@ -445,29 +457,37 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
return table.rows
|
||||
.map((rowDom) => {
|
||||
const rowSnapshot = readRowSnapshot(rowDom);
|
||||
if (!rowSnapshot.authorId) {
|
||||
if (!rowSnapshot.authorId || !hasTextValue(rowSnapshot.authorName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existingRecord = resultStore.getRecord(rowSnapshot.authorId);
|
||||
const authorName =
|
||||
mergeStringValue(existingRecord?.authorName, rowSnapshot.authorName) ?? "";
|
||||
const location = mergeStringValue(existingRecord?.location, rowSnapshot.location);
|
||||
const price21To60s = mergeStringValue(
|
||||
existingRecord?.price21To60s,
|
||||
rowSnapshot.price21To60s
|
||||
);
|
||||
return {
|
||||
...existingRecord,
|
||||
...rowSnapshot,
|
||||
authorName: mergeStringValue(existingRecord?.authorName, rowSnapshot.authorName) ?? "",
|
||||
authorName,
|
||||
backendMetrics: mergeFieldMap(
|
||||
existingRecord?.backendMetrics,
|
||||
rowSnapshot.backendMetrics
|
||||
),
|
||||
backendMetricsStatus: existingRecord?.backendMetricsStatus ?? "idle",
|
||||
exportFields: mergeFieldMap(
|
||||
existingRecord?.exportFields,
|
||||
rowSnapshot.exportFields
|
||||
),
|
||||
location: mergeStringValue(existingRecord?.location, rowSnapshot.location),
|
||||
price21To60s: mergeStringValue(
|
||||
existingRecord?.price21To60s,
|
||||
rowSnapshot.price21To60s
|
||||
exportFields: withExportFieldFallbacks(
|
||||
mergeFieldMap(existingRecord?.exportFields, rowSnapshot.exportFields),
|
||||
{
|
||||
authorName,
|
||||
location,
|
||||
price21To60s
|
||||
}
|
||||
),
|
||||
location,
|
||||
price21To60s,
|
||||
rates: mergeFieldMap(existingRecord?.rates, rowSnapshot.rates),
|
||||
status: existingRecord?.status ?? "idle"
|
||||
} satisfies MarketRecord;
|
||||
@@ -488,27 +508,42 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const seenElements = new Set<HTMLElement>();
|
||||
const candidateScores = new Map<HTMLElement, { depth: number; scrollRange: number }>();
|
||||
const candidateRoots = table.rows
|
||||
.map((rowDom) => rowDom.row)
|
||||
.filter((row): row is HTMLElement => row instanceof options.window.HTMLElement);
|
||||
|
||||
for (const rootElement of candidateRoots) {
|
||||
let currentElement = rootElement.parentElement;
|
||||
let depth = 0;
|
||||
while (currentElement) {
|
||||
if (
|
||||
!seenElements.has(currentElement) &&
|
||||
isScrollableContainer(currentElement)
|
||||
) {
|
||||
return currentElement;
|
||||
if (isScrollableContainer(currentElement)) {
|
||||
const scrollRange = currentElement.scrollHeight - currentElement.clientHeight;
|
||||
const existingScore = candidateScores.get(currentElement);
|
||||
if (!existingScore || depth < existingScore.depth) {
|
||||
candidateScores.set(currentElement, {
|
||||
depth,
|
||||
scrollRange
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
seenElements.add(currentElement);
|
||||
depth += 1;
|
||||
currentElement = currentElement.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
const rankedCandidates = Array.from(candidateScores.entries()).sort((left, right) => {
|
||||
const [, leftScore] = left;
|
||||
const [, rightScore] = right;
|
||||
if (rightScore.scrollRange !== leftScore.scrollRange) {
|
||||
return rightScore.scrollRange - leftScore.scrollRange;
|
||||
}
|
||||
|
||||
return leftScore.depth - rightScore.depth;
|
||||
});
|
||||
|
||||
return rankedCandidates[0]?.[0] ?? null;
|
||||
}
|
||||
|
||||
function isScrollableContainer(element: HTMLElement): boolean {
|
||||
@@ -529,8 +564,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
async function collectCurrentPageSnapshotsUntilSettled(): Promise<void> {
|
||||
let previousFingerprint = "";
|
||||
let stablePassCount = 0;
|
||||
let fingerprintStableSince = 0;
|
||||
|
||||
for (let attempt = 0; attempt < 9; attempt += 1) {
|
||||
for (let attempt = 0; attempt < 16; attempt += 1) {
|
||||
await waitForDomSettled();
|
||||
if (attempt > 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
@@ -555,21 +591,38 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
} else {
|
||||
previousFingerprint = hydrationSnapshot.fingerprint;
|
||||
stablePassCount = 1;
|
||||
fingerprintStableSince = options.window.Date.now();
|
||||
}
|
||||
|
||||
if (hydrationSnapshot.missingDefaultFieldCount === 0 && stablePassCount >= 2) {
|
||||
const stableForMs = options.window.Date.now() - fingerprintStableSince;
|
||||
if (
|
||||
hydrationSnapshot.missingDefaultFieldCount === 0 &&
|
||||
hydrationSnapshot.blankExportFieldCount === 0 &&
|
||||
stablePassCount >= 2
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
hydrationSnapshot.missingDefaultFieldCount === 0 &&
|
||||
hydrationSnapshot.blankExportFieldCount > 0 &&
|
||||
stablePassCount >= 2 &&
|
||||
stableForMs >= 500
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readVisibleRowHydrationSnapshot(): {
|
||||
blankExportFieldCount: number;
|
||||
fingerprint: string;
|
||||
missingDefaultFieldCount: number;
|
||||
} {
|
||||
const table = syncMarketTable(options.document);
|
||||
if (!table || table.rows.length === 0) {
|
||||
return {
|
||||
blankExportFieldCount: 0,
|
||||
fingerprint: "",
|
||||
missingDefaultFieldCount: 0
|
||||
};
|
||||
@@ -580,6 +633,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
const populatedFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter(
|
||||
(value) => typeof value === "string" && value.trim().length > 0
|
||||
).length;
|
||||
const blankExportFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter(
|
||||
(value) => typeof value !== "string" || value.trim().length === 0
|
||||
).length;
|
||||
const hasAuthorField = hasTextValue(rowSnapshot.exportFields?.["达人信息"]);
|
||||
const hasRepresentativeVideo = hasTextValue(
|
||||
rowSnapshot.exportFields?.["代表视频"]
|
||||
);
|
||||
@@ -587,11 +644,15 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
hasTextValue(rowSnapshot.price21To60s) ||
|
||||
hasTextValue(rowSnapshot.exportFields?.["21-60s报价"]);
|
||||
const missingDefaultFieldCount =
|
||||
Number(!hasRepresentativeVideo) + Number(!hasPriceField);
|
||||
Number(!hasAuthorField) +
|
||||
Number(!hasRepresentativeVideo) +
|
||||
Number(!hasPriceField);
|
||||
|
||||
return [
|
||||
rowSnapshot.authorId,
|
||||
populatedFieldCount,
|
||||
`blank:${blankExportFieldCount}`,
|
||||
hasAuthorField ? "author" : "no-author",
|
||||
hasRepresentativeVideo ? "video" : "no-video",
|
||||
hasPriceField ? "price" : "no-price",
|
||||
`missing:${missingDefaultFieldCount}`
|
||||
@@ -599,6 +660,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
});
|
||||
|
||||
return {
|
||||
blankExportFieldCount: parts.reduce((count, part) => {
|
||||
const match = part.match(/:blank:(\d+):/);
|
||||
return count + Number(match?.[1] ?? 0);
|
||||
}, 0),
|
||||
fingerprint: parts.join("|"),
|
||||
missingDefaultFieldCount: parts.reduce((count, part) => {
|
||||
const match = part.match(/missing:(\d+)$/);
|
||||
@@ -624,6 +689,26 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function runWithoutMutationSync(callback: () => void): void {
|
||||
observer.disconnect();
|
||||
try {
|
||||
callback();
|
||||
} finally {
|
||||
startObserving();
|
||||
}
|
||||
}
|
||||
|
||||
function startObserving(): void {
|
||||
if (!observationRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
observer.observe(observationRoot, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
|
||||
async function runSyncCycle(): Promise<void> {
|
||||
if (isSyncRunning) {
|
||||
needsResync = true;
|
||||
@@ -658,7 +743,19 @@ function readCurrentPageRows(document: Document): MarketRowSnapshot[] {
|
||||
|
||||
return table.rows
|
||||
.map((rowDom) => readRowSnapshot(rowDom))
|
||||
.filter((row): row is MarketRowSnapshot => Boolean(row.authorId));
|
||||
.filter(
|
||||
(row): row is MarketRowSnapshot =>
|
||||
Boolean(row.authorId) && hasTextValue(row.authorName)
|
||||
);
|
||||
}
|
||||
|
||||
function countCurrentPageRows(document: Document): number {
|
||||
const table = syncMarketTable(document);
|
||||
if (!table) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return table.rows.filter((rowDom) => Boolean(rowDom.authorId)).length;
|
||||
}
|
||||
|
||||
function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot {
|
||||
@@ -667,6 +764,7 @@ function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot {
|
||||
authorName: rowDom.authorName,
|
||||
exportFields: rowDom.exportFields,
|
||||
hasDirectRatesSource: rowDom.hasDirectRatesSource,
|
||||
location: rowDom.location,
|
||||
price21To60s: rowDom.price21To60s,
|
||||
rates: rowDom.rates
|
||||
};
|
||||
@@ -695,6 +793,27 @@ function readSortState(
|
||||
};
|
||||
}
|
||||
|
||||
function getNextSortState(
|
||||
currentSort: MarketSortState | undefined,
|
||||
field: MarketSortState["field"]
|
||||
): MarketSortState | undefined {
|
||||
if (!currentSort || currentSort.field !== field) {
|
||||
return {
|
||||
direction: "desc",
|
||||
field
|
||||
};
|
||||
}
|
||||
|
||||
if (currentSort.direction === "desc") {
|
||||
return {
|
||||
direction: "asc",
|
||||
field
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function mergeFieldMap<T extends Record<string, string | undefined>>(
|
||||
current: T | undefined,
|
||||
incoming: T | undefined
|
||||
@@ -805,6 +924,46 @@ function mergeStringValue(
|
||||
return current;
|
||||
}
|
||||
|
||||
function withExportFieldFallbacks(
|
||||
exportFields: Record<string, string | undefined> | undefined,
|
||||
fallbackValues: {
|
||||
authorName: string;
|
||||
location: string | undefined;
|
||||
price21To60s: string | undefined;
|
||||
}
|
||||
): Record<string, string | undefined> | undefined {
|
||||
if (!exportFields) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const nextExportFields = {
|
||||
...exportFields
|
||||
};
|
||||
if (
|
||||
"达人信息" in nextExportFields &&
|
||||
!hasTextValue(nextExportFields["达人信息"]) &&
|
||||
hasTextValue(fallbackValues.authorName)
|
||||
) {
|
||||
nextExportFields["达人信息"] = fallbackValues.authorName;
|
||||
}
|
||||
if (
|
||||
"地区" in nextExportFields &&
|
||||
!hasTextValue(nextExportFields["地区"]) &&
|
||||
hasTextValue(fallbackValues.location)
|
||||
) {
|
||||
nextExportFields["地区"] = fallbackValues.location;
|
||||
}
|
||||
if (
|
||||
"21-60s报价" in nextExportFields &&
|
||||
!hasTextValue(nextExportFields["21-60s报价"]) &&
|
||||
hasTextValue(fallbackValues.price21To60s)
|
||||
) {
|
||||
nextExportFields["21-60s报价"] = fallbackValues.price21To60s;
|
||||
}
|
||||
|
||||
return nextExportFields;
|
||||
}
|
||||
|
||||
function hasTextValue(value: string | undefined): boolean {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,46 @@
|
||||
import type { MarketExportScope, MarketExportTarget } from "./types";
|
||||
import type {
|
||||
MarketExportScope,
|
||||
MarketExportTarget,
|
||||
MarketSortState
|
||||
} from "./types";
|
||||
|
||||
const SORT_FIELD_OPTIONS = [
|
||||
{
|
||||
label: "单视频看后搜率",
|
||||
value: "singleVideoAfterSearchRate"
|
||||
},
|
||||
{
|
||||
label: "个人视频看后搜率",
|
||||
value: "personalVideoAfterSearchRate"
|
||||
},
|
||||
{
|
||||
label: "看后搜率",
|
||||
value: "afterViewSearchRate"
|
||||
},
|
||||
{
|
||||
label: "看后搜数",
|
||||
value: "afterViewSearchCount"
|
||||
},
|
||||
{
|
||||
label: "新增A3数",
|
||||
value: "a3IncreaseCount"
|
||||
},
|
||||
{
|
||||
label: "新增A3率",
|
||||
value: "newA3Rate"
|
||||
},
|
||||
{
|
||||
label: "CPA3",
|
||||
value: "cpa3"
|
||||
},
|
||||
{
|
||||
label: "cp_search",
|
||||
value: "cpSearch"
|
||||
}
|
||||
] as const satisfies Array<{
|
||||
label: string;
|
||||
value: NonNullable<MarketSortState["field"]>;
|
||||
}>;
|
||||
|
||||
export interface PluginToolbarHandlers {
|
||||
onApplyFilter(): Promise<void> | void;
|
||||
@@ -54,8 +96,9 @@ export function ensurePluginToolbar(
|
||||
const sortFieldSelect = document.createElement("select");
|
||||
sortFieldSelect.dataset.pluginSortField = "select";
|
||||
appendOption(sortFieldSelect, "", "不排序");
|
||||
appendOption(sortFieldSelect, "singleVideoAfterSearchRate", "单视频看后搜率");
|
||||
appendOption(sortFieldSelect, "personalVideoAfterSearchRate", "个人视频看后搜率");
|
||||
SORT_FIELD_OPTIONS.forEach(({ label, value }) => {
|
||||
appendOption(sortFieldSelect, value, label);
|
||||
});
|
||||
|
||||
const sortDirectionSelect = document.createElement("select");
|
||||
sortDirectionSelect.dataset.pluginSortDirection = "select";
|
||||
@@ -293,6 +336,14 @@ export function setToolbarExportStatus(
|
||||
toolbar.exportStatusText.textContent = text;
|
||||
}
|
||||
|
||||
export function setToolbarSortState(
|
||||
toolbar: PluginToolbarDom,
|
||||
sort: MarketSortState | undefined
|
||||
): void {
|
||||
toolbar.sortFieldSelect.value = sort?.field ?? "";
|
||||
toolbar.sortDirectionSelect.value = sort?.direction ?? "desc";
|
||||
}
|
||||
|
||||
function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
|
||||
toolbar.exportCustomPagesInput.hidden =
|
||||
toolbar.exportRangeSelect.value !== "custom";
|
||||
|
||||
@@ -12,6 +12,10 @@ export interface BackendMetrics {
|
||||
newA3Rate?: string;
|
||||
}
|
||||
|
||||
export type MarketSortField =
|
||||
| keyof Required<AfterSearchRates>
|
||||
| keyof Required<BackendMetrics>;
|
||||
|
||||
export type MarketRecordStatus = "idle" | "loading" | "success" | "failed" | "missing";
|
||||
|
||||
export interface MarketRowSnapshot {
|
||||
@@ -49,7 +53,7 @@ export type MarketExportTarget =
|
||||
|
||||
export interface MarketSortState {
|
||||
direction: "asc" | "desc";
|
||||
field: keyof Required<AfterSearchRates>;
|
||||
field: MarketSortField;
|
||||
}
|
||||
|
||||
export type MarketApiFailureReason =
|
||||
|
||||
Reference in New Issue
Block a user