feat: wire market plugin controls

This commit is contained in:
2026-04-20 20:17:48 +08:00
parent f6b2bdc862
commit 9b3c6756bb
4 changed files with 608 additions and 1 deletions
+176
View File
@@ -0,0 +1,176 @@
import { buildMarketCsv } from "./csv-exporter";
import {
applyRowOrder,
applyRowVisibility,
renderMarketRowState,
syncMarketTable
} from "./dom-sync";
import { applyFilterAndSort } from "./filter-sort-controller";
import { createFullScanController } from "./full-scan-controller";
import { createMarketApiClient } from "./api-client";
import { ensurePluginToolbar } from "./plugin-toolbar";
import { createMarketResultStore } from "./result-store";
import type {
MarketApiResult,
MarketFilterState,
MarketRecord,
MarketRowSnapshot,
MarketSortState
} from "./types";
interface FullScanControllerLike {
ensureScanForExport(): Promise<void>;
ensureScanForFilter(): Promise<void>;
ensureScanForSort(): Promise<void>;
}
export interface CreateMarketControllerOptions {
buildCsv?: (records: MarketRecord[]) => string;
document: Document;
fullScanController?: FullScanControllerLike;
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
onCsvReady?: (csv: string) => void;
resultStore?: ReturnType<typeof createMarketResultStore>;
window: Window;
}
export function createMarketController(options: CreateMarketControllerOptions) {
const table = syncMarketTable(options.document);
const resultStore = options.resultStore ?? createMarketResultStore();
const loadAuthorMetrics =
options.loadAuthorMetrics ?? createMarketApiClient().loadAuthorAseInfo;
const buildCsv = options.buildCsv ?? buildMarketCsv;
let activeFilters: MarketFilterState = {};
let activeSort: MarketSortState | undefined;
const fullScanController =
options.fullScanController ??
createFullScanController({
goToNextPage: async () => false,
hasNextPage: () => false,
loadAuthorMetrics,
readCurrentPageRows: () =>
table ? table.rows.map((rowDom) => readRowSnapshot(rowDom.row)) : [],
resultStore
});
const toolbar = ensurePluginToolbar(options.document, {
onApplyFilter: async () => {
activeFilters = {
personalVideoAfterSearchRateMin: parseNumberValue(
toolbar.personalFilterInput.value
),
singleVideoAfterSearchRateMin: parseNumberValue(
toolbar.singleFilterInput.value
)
};
await fullScanController.ensureScanForFilter();
applyCurrentView();
},
onApplySort: async () => {
activeSort = readSortState(toolbar.sortFieldSelect, toolbar.sortDirectionSelect);
await fullScanController.ensureScanForSort();
applyCurrentView();
},
onExport: async () => {
await fullScanController.ensureScanForExport();
const records = getVisibleOrderedRecords();
options.onCsvReady?.(buildCsv(records));
}
});
const ready = hydrateCurrentPage().then(() => {
applyCurrentView();
});
return {
ready
};
async function hydrateCurrentPage(): Promise<void> {
if (!table) {
return;
}
for (const rowDom of table.rows) {
const rowSnapshot = readRowSnapshot(rowDom.row);
resultStore.upsertMarketRow(rowSnapshot);
resultStore.setAuthorLoading(rowSnapshot.authorId);
renderMarketRowState(rowDom, {
...rowSnapshot,
status: "loading"
});
const metricsResult = await loadAuthorMetrics(rowSnapshot.authorId);
if (metricsResult.success) {
resultStore.setAuthorSuccess(rowSnapshot.authorId, metricsResult.rates);
renderMarketRowState(rowDom, {
...rowSnapshot,
status: "success",
rates: metricsResult.rates
});
continue;
}
resultStore.setAuthorFailed(rowSnapshot.authorId, metricsResult.reason);
renderMarketRowState(rowDom, {
...rowSnapshot,
failureReason: metricsResult.reason,
status: "failed"
});
}
}
function applyCurrentView(): void {
if (!table) {
return;
}
const records = getVisibleOrderedRecords();
applyRowVisibility(table, new Set(records.map((record) => record.authorId)));
applyRowOrder(table, records.map((record) => record.authorId));
}
function getVisibleOrderedRecords(): MarketRecord[] {
return applyFilterAndSort(resultStore.listRecords(), {
filters: activeFilters,
sort: activeSort
});
}
}
function readRowSnapshot(row: HTMLElement): MarketRowSnapshot {
return {
authorId: row.dataset.authorId ?? "",
authorName:
row.querySelector('[data-market-field="authorName"]')?.textContent?.trim() ??
"",
price21To60s:
row
.querySelector('[data-market-field="price21To60s"]')
?.textContent?.trim() ?? ""
};
}
function parseNumberValue(value: string): number | undefined {
if (!value) {
return undefined;
}
const parsedValue = Number(value);
return Number.isFinite(parsedValue) ? parsedValue : undefined;
}
function readSortState(
fieldSelect: HTMLSelectElement,
directionSelect: HTMLSelectElement
): MarketSortState | undefined {
if (!fieldSelect.value) {
return undefined;
}
return {
direction: directionSelect.value === "asc" ? "asc" : "desc",
field: fieldSelect.value as MarketSortState["field"]
};
}
+137
View File
@@ -0,0 +1,137 @@
export interface PluginToolbarHandlers {
onApplyFilter(): Promise<void> | void;
onApplySort(): Promise<void> | void;
onExport(): Promise<void> | void;
}
export interface PluginToolbarDom {
exportButton: HTMLButtonElement;
filterApplyButton: HTMLButtonElement;
personalFilterInput: HTMLInputElement;
root: HTMLElement;
singleFilterInput: HTMLInputElement;
sortApplyButton: HTMLButtonElement;
sortDirectionSelect: HTMLSelectElement;
sortFieldSelect: HTMLSelectElement;
}
export function ensurePluginToolbar(
document: Document,
handlers: PluginToolbarHandlers
): PluginToolbarDom {
const existingRoot = document.querySelector(
"[data-plugin-toolbar='root']"
) as HTMLElement | null;
if (existingRoot) {
return readToolbarDom(existingRoot);
}
const root = document.createElement("section");
root.dataset.pluginToolbar = "root";
const singleFilterInput = document.createElement("input");
singleFilterInput.type = "number";
singleFilterInput.step = "0.01";
singleFilterInput.dataset.pluginFilterSingle = "input";
const personalFilterInput = document.createElement("input");
personalFilterInput.type = "number";
personalFilterInput.step = "0.01";
personalFilterInput.dataset.pluginFilterPersonal = "input";
const filterApplyButton = document.createElement("button");
filterApplyButton.type = "button";
filterApplyButton.dataset.pluginFilterApply = "button";
filterApplyButton.textContent = "应用筛选";
const sortFieldSelect = document.createElement("select");
sortFieldSelect.dataset.pluginSortField = "select";
appendOption(sortFieldSelect, "", "不排序");
appendOption(sortFieldSelect, "singleVideoAfterSearchRate", "单视频看后搜率");
appendOption(sortFieldSelect, "personalVideoAfterSearchRate", "个人视频看后搜率");
const sortDirectionSelect = document.createElement("select");
sortDirectionSelect.dataset.pluginSortDirection = "select";
appendOption(sortDirectionSelect, "desc", "降序");
appendOption(sortDirectionSelect, "asc", "升序");
const sortApplyButton = document.createElement("button");
sortApplyButton.type = "button";
sortApplyButton.dataset.pluginSortApply = "button";
sortApplyButton.textContent = "应用排序";
const exportButton = document.createElement("button");
exportButton.type = "button";
exportButton.dataset.pluginExport = "button";
exportButton.textContent = "导出CSV";
root.append(
singleFilterInput,
personalFilterInput,
filterApplyButton,
sortFieldSelect,
sortDirectionSelect,
sortApplyButton,
exportButton
);
document.body.prepend(root);
filterApplyButton.addEventListener("click", () => {
void handlers.onApplyFilter();
});
sortApplyButton.addEventListener("click", () => {
void handlers.onApplySort();
});
exportButton.addEventListener("click", () => {
void handlers.onExport();
});
return {
exportButton,
filterApplyButton,
personalFilterInput,
root,
singleFilterInput,
sortApplyButton,
sortDirectionSelect,
sortFieldSelect
};
}
function appendOption(
select: HTMLSelectElement,
value: string,
label: string
): void {
const option = select.ownerDocument.createElement("option");
option.value = value;
option.textContent = label;
select.appendChild(option);
}
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
return {
exportButton: root.querySelector(
'[data-plugin-export="button"]'
) as HTMLButtonElement,
filterApplyButton: root.querySelector(
'[data-plugin-filter-apply="button"]'
) as HTMLButtonElement,
personalFilterInput: root.querySelector(
'[data-plugin-filter-personal="input"]'
) as HTMLInputElement,
root,
singleFilterInput: root.querySelector(
'[data-plugin-filter-single="input"]'
) as HTMLInputElement,
sortApplyButton: root.querySelector(
'[data-plugin-sort-apply="button"]'
) as HTMLButtonElement,
sortDirectionSelect: root.querySelector(
'[data-plugin-sort-direction="select"]'
) as HTMLSelectElement,
sortFieldSelect: root.querySelector(
'[data-plugin-sort-field="select"]'
) as HTMLSelectElement
};
}