feat: refine market action bar and metrics sync
This commit is contained in:
@@ -40,7 +40,7 @@ export function createBatchPayload(options: {
|
||||
authorId: record.authorId,
|
||||
authorName: record.authorName
|
||||
})),
|
||||
batchId: `${batchName}-${options.createdAt}`,
|
||||
batchId: `${logtoUserId}-${options.createdAt}`,
|
||||
batchName,
|
||||
createdAt: options.createdAt,
|
||||
creatorName:
|
||||
|
||||
+146
-56
@@ -535,8 +535,8 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
||||
) as Record<BackendMetricField, HTMLElement[]>;
|
||||
const priceColumn = findPreviousColumn(actionColumn);
|
||||
const priceCells = priceColumn ? getDirectContentCells(priceColumn) : [];
|
||||
const vueMarketRows = readVueMarketRows(root);
|
||||
const serializedMarketRows = readSerializedMarketRows(root.ownerDocument);
|
||||
const remainingVueMarketRows = [...readVueMarketRows(root)];
|
||||
const remainingSerializedMarketRows = [...readSerializedMarketRows(root.ownerDocument)];
|
||||
|
||||
const rows = authorCells.flatMap((authorCell, index) => {
|
||||
const singleCell = singleCells[index] ?? null;
|
||||
@@ -561,23 +561,27 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
||||
const rowCells = alignedRowCells.filter(
|
||||
(cell): cell is HTMLElement => cell !== null
|
||||
);
|
||||
const vueMarketRow = vueMarketRows[index] ?? null;
|
||||
const serializedMarketRow = serializedMarketRows[index] ?? null;
|
||||
const directAuthorId = extractAuthorId(authorCell) || "";
|
||||
const directAuthorName = extractAuthorName(authorCell) || "";
|
||||
const vueMarketRow = takeMatchedMarketDataRow(
|
||||
remainingVueMarketRows,
|
||||
directAuthorId,
|
||||
directAuthorName
|
||||
);
|
||||
const serializedMarketRow = takeMatchedMarketDataRow(
|
||||
remainingSerializedMarketRows,
|
||||
directAuthorId,
|
||||
directAuthorName
|
||||
);
|
||||
const fallbackMarketRow = mergeMarketDataRows(serializedMarketRow, vueMarketRow);
|
||||
const exportFields = mergeExportFieldMaps(
|
||||
readExportFieldsForDivGridRow(allHeaderCells, alignedRowCells),
|
||||
fallbackMarketRow?.exportFields
|
||||
);
|
||||
const authorId =
|
||||
extractAuthorId(authorCell) ||
|
||||
fallbackMarketRow?.authorId ||
|
||||
"";
|
||||
const authorName =
|
||||
extractAuthorName(authorCell) ||
|
||||
fallbackMarketRow?.authorName ||
|
||||
"";
|
||||
const authorId = directAuthorId || fallbackMarketRow?.authorId || "";
|
||||
const authorName = directAuthorName || fallbackMarketRow?.authorName || "";
|
||||
const price21To60s = mergeNonEmptyString(
|
||||
priceCells[index]?.textContent?.trim() ?? "",
|
||||
readDivGridPriceDisplay(priceCells[index]?.textContent),
|
||||
fallbackMarketRow?.price21To60s
|
||||
);
|
||||
|
||||
@@ -757,7 +761,7 @@ function getOwnerDocument(root: ParentNode): Document | null {
|
||||
return root.ownerDocument;
|
||||
}
|
||||
|
||||
return root instanceof Document ? root : null;
|
||||
return "nodeType" in root && root.nodeType === 9 ? (root as Document) : null;
|
||||
}
|
||||
|
||||
function readSyntheticHeaderLabels(header: HTMLElement): Record<string, string> {
|
||||
@@ -812,7 +816,10 @@ function readExportFieldsForDivGridRow(
|
||||
return;
|
||||
}
|
||||
|
||||
exportFields[headerLabel] = normalizeExportCellText(cell?.textContent);
|
||||
exportFields[headerLabel] =
|
||||
headerLabel === "21-60s报价"
|
||||
? readDivGridPriceDisplay(cell?.textContent) ?? ""
|
||||
: normalizeExportCellText(cell?.textContent);
|
||||
});
|
||||
|
||||
return exportFields;
|
||||
@@ -1069,60 +1076,92 @@ function readVueMarketRows(
|
||||
const vueRoot = (
|
||||
marketRoot as HTMLElement & {
|
||||
__vue__?: {
|
||||
$children?: unknown[];
|
||||
_setupState?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
).__vue__;
|
||||
const setupState = vueRoot?._setupState;
|
||||
if (!setupState) {
|
||||
return [];
|
||||
}
|
||||
const setupStates = collectVueSetupStates(vueRoot);
|
||||
|
||||
for (const value of Object.values(setupState)) {
|
||||
const candidate = unwrapVueRef(value);
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
continue;
|
||||
}
|
||||
for (const setupState of setupStates) {
|
||||
for (const value of Object.values(setupState)) {
|
||||
const candidate = unwrapVueRef(value);
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const marketList = unwrapVueRef(
|
||||
(candidate as Record<string, unknown>).marketList
|
||||
);
|
||||
if (!Array.isArray(marketList)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return marketList.map((row) => {
|
||||
const record = isRecord(row) ? row : {};
|
||||
const attributeDatas = readMarketAttributeDatas(record);
|
||||
const singleVideoAfterSearchRate = normalizeMarketListRate(
|
||||
readMarketFieldValue(record, attributeDatas, "avg_search_after_view_rate_30d")
|
||||
const marketList = unwrapVueRef(
|
||||
(candidate as Record<string, unknown>).marketList
|
||||
);
|
||||
if (!Array.isArray(marketList)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
authorId:
|
||||
readString(readMarketFieldValue(record, attributeDatas, "star_id")) ??
|
||||
readString(readMarketFieldValue(record, attributeDatas, "id")) ??
|
||||
"",
|
||||
authorName:
|
||||
readString(readMarketFieldValue(record, attributeDatas, "nickname")) ??
|
||||
readString(readMarketFieldValue(record, attributeDatas, "nick_name")) ??
|
||||
"",
|
||||
exportFields: buildMarketExportFieldFallbacks(record, attributeDatas),
|
||||
hasDirectRatesSource: true,
|
||||
location: readMarketLocation(record, attributeDatas),
|
||||
price21To60s: readMarketPrice21To60s(record, attributeDatas),
|
||||
rates: singleVideoAfterSearchRate
|
||||
? {
|
||||
singleVideoAfterSearchRate
|
||||
}
|
||||
: undefined
|
||||
};
|
||||
});
|
||||
return marketList.map((row) => {
|
||||
const record = isRecord(row) ? row : {};
|
||||
const attributeDatas = readMarketAttributeDatas(record);
|
||||
const singleVideoAfterSearchRate = normalizeMarketListRate(
|
||||
readMarketFieldValue(record, attributeDatas, "avg_search_after_view_rate_30d")
|
||||
);
|
||||
|
||||
return {
|
||||
authorId:
|
||||
readString(readMarketFieldValue(record, attributeDatas, "star_id")) ??
|
||||
readString(readMarketFieldValue(record, attributeDatas, "id")) ??
|
||||
"",
|
||||
authorName:
|
||||
readString(readMarketFieldValue(record, attributeDatas, "nickname")) ??
|
||||
readString(readMarketFieldValue(record, attributeDatas, "nick_name")) ??
|
||||
"",
|
||||
exportFields: buildMarketExportFieldFallbacks(record, attributeDatas),
|
||||
hasDirectRatesSource: true,
|
||||
location: readMarketLocation(record, attributeDatas),
|
||||
price21To60s: readMarketPrice21To60s(record, attributeDatas),
|
||||
rates: singleVideoAfterSearchRate
|
||||
? {
|
||||
singleVideoAfterSearchRate
|
||||
}
|
||||
: undefined
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function collectVueSetupStates(
|
||||
vueRoot:
|
||||
| {
|
||||
$children?: unknown[];
|
||||
_setupState?: Record<string, unknown>;
|
||||
}
|
||||
| undefined
|
||||
): Array<Record<string, unknown>> {
|
||||
if (!vueRoot) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const queue: unknown[] = [vueRoot];
|
||||
const setupStates: Array<Record<string, unknown>> = [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (!isRecord(current)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isRecord(current._setupState)) {
|
||||
setupStates.push(current._setupState);
|
||||
}
|
||||
|
||||
const children = Array.isArray(current.$children) ? current.$children : [];
|
||||
queue.push(...children);
|
||||
}
|
||||
|
||||
return setupStates;
|
||||
}
|
||||
|
||||
function readSerializedMarketRows(
|
||||
document: Document
|
||||
): MarketDataRow[] {
|
||||
@@ -1207,6 +1246,25 @@ function normalizeExportCellText(value: string | null | undefined): string {
|
||||
return value?.replace(/\s+/g, " ").trim() ?? "";
|
||||
}
|
||||
|
||||
function readDivGridPriceDisplay(value: string | null | undefined): string | undefined {
|
||||
const normalizedValue = normalizeExportCellText(value);
|
||||
if (!normalizedValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const match = normalizedValue.match(/^¥?\s*([\d,]+(?:\.\d+)?)$/);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const numericValue = Number(match[1].replace(/,/g, ""));
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return formatCurrencyValue(numericValue);
|
||||
}
|
||||
|
||||
function shouldExportColumn(label: string): boolean {
|
||||
const excludedBackendLabels = new Set(BACKEND_METRIC_COLUMNS.map((column) => column.label));
|
||||
return Boolean(
|
||||
@@ -1457,6 +1515,38 @@ function mergeMarketDataRows(
|
||||
};
|
||||
}
|
||||
|
||||
function takeMatchedMarketDataRow(
|
||||
remainingRows: MarketDataRow[],
|
||||
authorId: string,
|
||||
authorName: string
|
||||
): MarketDataRow | null {
|
||||
if (remainingRows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matchedIndex = remainingRows.findIndex((row) => {
|
||||
if (authorId && row.authorId === authorId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (authorName && row.authorName === authorName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (matchedIndex >= 0) {
|
||||
return remainingRows.splice(matchedIndex, 1)[0] ?? null;
|
||||
}
|
||||
|
||||
if (!authorId && !authorName) {
|
||||
return remainingRows.shift() ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function mergeExportFieldMaps(
|
||||
current: Record<string, string> | undefined,
|
||||
fallback: Record<string, string> | undefined
|
||||
|
||||
+50
-51
@@ -12,12 +12,11 @@ import {
|
||||
import { applyFilterAndSort } from "./filter-sort-controller";
|
||||
import { createMarketApiClient } from "./api-client";
|
||||
import { createExportRangeController } from "./export-range-controller";
|
||||
import { ensurePluginToolbar } from "./plugin-toolbar";
|
||||
import { ensurePluginToolbar, isPluginToolbarMounted } from "./plugin-toolbar";
|
||||
import {
|
||||
readToolbarExportTarget,
|
||||
setToolbarBusyState,
|
||||
setToolbarExportStatus,
|
||||
setToolbarSortState
|
||||
setToolbarExportStatus
|
||||
} from "./plugin-toolbar";
|
||||
import { createMarketResultStore } from "./result-store";
|
||||
import {
|
||||
@@ -28,7 +27,6 @@ import { isBackendMetricsResponseMessage } from "../../shared/backend-metrics-me
|
||||
import type {
|
||||
BackendMetrics,
|
||||
MarketApiResult,
|
||||
MarketFilterState,
|
||||
MarketExportTarget,
|
||||
MarketRecord,
|
||||
MarketRowSnapshot,
|
||||
@@ -94,15 +92,29 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
readCurrentPageRowCount: () => countCurrentPageRows(options.document),
|
||||
window: options.window
|
||||
});
|
||||
let activeFilters: MarketFilterState = {};
|
||||
let activeSort: MarketSortState | undefined;
|
||||
let isDisposed = false;
|
||||
let isSyncRunning = false;
|
||||
let isSyncScheduled = false;
|
||||
let lastKnownPageSignature = "";
|
||||
let needsResync = false;
|
||||
let scheduledSyncTimeoutId: number | null = null;
|
||||
let toolbar: ReturnType<typeof ensurePluginToolbar> | undefined;
|
||||
const observer = mutationObserverFactory(() => {
|
||||
const nextPageSignature = readMarketPageSignature(options.document);
|
||||
if (nextPageSignature === lastKnownPageSignature) {
|
||||
if (isDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nextPageSignature = lastKnownPageSignature;
|
||||
try {
|
||||
nextPageSignature = readMarketPageSignature(options.document);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const toolbarNeedsRemount =
|
||||
!toolbar || !isPluginToolbarMounted(toolbar.root, options.document);
|
||||
if (nextPageSignature === lastKnownPageSignature && !toolbarNeedsRemount) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -111,22 +123,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
const observationRoot = options.document.body ?? options.document.documentElement;
|
||||
startObserving();
|
||||
|
||||
const toolbar = ensurePluginToolbar(options.document, {
|
||||
onApplyFilter: async () => {
|
||||
activeFilters = {
|
||||
personalVideoAfterSearchRateMin: parseNumberValue(
|
||||
toolbar.personalFilterInput.value
|
||||
),
|
||||
singleVideoAfterSearchRateMin: parseNumberValue(
|
||||
toolbar.singleFilterInput.value
|
||||
)
|
||||
};
|
||||
applyCurrentView();
|
||||
},
|
||||
onApplySort: async () => {
|
||||
activeSort = readSortState(toolbar.sortFieldSelect, toolbar.sortDirectionSelect);
|
||||
applyCurrentView();
|
||||
},
|
||||
const toolbarHandlers = {
|
||||
onExport: async () => {
|
||||
const exportTarget = readToolbarExportTarget(toolbar);
|
||||
if (!exportTarget.target) {
|
||||
@@ -190,13 +187,19 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
setToolbarBusyState(toolbar, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
|
||||
|
||||
const ready = runSyncCycle();
|
||||
|
||||
return {
|
||||
dispose() {
|
||||
isDisposed = true;
|
||||
observer.disconnect();
|
||||
if (scheduledSyncTimeoutId !== null) {
|
||||
options.window.clearTimeout(scheduledSyncTimeoutId);
|
||||
scheduledSyncTimeoutId = null;
|
||||
}
|
||||
},
|
||||
ready
|
||||
};
|
||||
@@ -368,6 +371,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
|
||||
function applyCurrentView(): void {
|
||||
runWithoutMutationSync(() => {
|
||||
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
|
||||
const table = syncMarketTable(options.document);
|
||||
if (!table) {
|
||||
return;
|
||||
@@ -387,7 +391,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
|
||||
function toggleSortFromHeader(field: MarketSortState["field"]): void {
|
||||
activeSort = getNextSortState(activeSort, field);
|
||||
setToolbarSortState(toolbar, activeSort);
|
||||
applyCurrentView();
|
||||
}
|
||||
|
||||
@@ -395,7 +398,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
const currentPageRecords = readCurrentPageRecords(table);
|
||||
|
||||
return applyFilterAndSort(currentPageRecords, {
|
||||
filters: activeFilters,
|
||||
sort: activeSort
|
||||
});
|
||||
}
|
||||
@@ -681,6 +683,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
}
|
||||
|
||||
function scheduleSync(): void {
|
||||
if (isDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSyncRunning) {
|
||||
needsResync = true;
|
||||
return;
|
||||
@@ -691,13 +697,21 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
}
|
||||
|
||||
isSyncScheduled = true;
|
||||
options.window.setTimeout(() => {
|
||||
scheduledSyncTimeoutId = options.window.setTimeout(() => {
|
||||
scheduledSyncTimeoutId = null;
|
||||
isSyncScheduled = false;
|
||||
if (isDisposed) {
|
||||
return;
|
||||
}
|
||||
void runSyncCycle();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function runWithoutMutationSync(callback: () => void): void {
|
||||
if (isDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
observer.disconnect();
|
||||
try {
|
||||
callback();
|
||||
@@ -707,7 +721,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
}
|
||||
|
||||
function startObserving(): void {
|
||||
if (!observationRoot) {
|
||||
if (isDisposed || !observationRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -718,6 +732,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
}
|
||||
|
||||
async function runSyncCycle(): Promise<void> {
|
||||
if (isDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSyncRunning) {
|
||||
needsResync = true;
|
||||
return;
|
||||
@@ -725,11 +743,15 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
|
||||
isSyncRunning = true;
|
||||
try {
|
||||
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
|
||||
await hydrateCurrentPage();
|
||||
applyCurrentView();
|
||||
lastKnownPageSignature = readMarketPageSignature(options.document);
|
||||
} finally {
|
||||
isSyncRunning = false;
|
||||
if (isDisposed) {
|
||||
return;
|
||||
}
|
||||
if (needsResync) {
|
||||
needsResync = false;
|
||||
scheduleSync();
|
||||
@@ -779,29 +801,6 @@ function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
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"]
|
||||
};
|
||||
}
|
||||
|
||||
function getNextSortState(
|
||||
currentSort: MarketSortState | undefined,
|
||||
field: MarketSortState["field"]
|
||||
|
||||
@@ -1,50 +1,9 @@
|
||||
import type {
|
||||
MarketExportScope,
|
||||
MarketExportTarget,
|
||||
MarketSortState
|
||||
MarketExportTarget
|
||||
} 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;
|
||||
onApplySort(): Promise<void> | void;
|
||||
onExport(): Promise<void> | void;
|
||||
onSubmitBatch(): Promise<void> | void;
|
||||
}
|
||||
@@ -55,13 +14,15 @@ export interface PluginToolbarDom {
|
||||
exportCustomPagesInput: HTMLInputElement;
|
||||
exportRangeSelect: HTMLSelectElement;
|
||||
exportStatusText: HTMLElement;
|
||||
filterApplyButton: HTMLButtonElement;
|
||||
personalFilterInput: HTMLInputElement;
|
||||
root: HTMLElement;
|
||||
singleFilterInput: HTMLInputElement;
|
||||
sortApplyButton: HTMLButtonElement;
|
||||
sortDirectionSelect: HTMLSelectElement;
|
||||
sortFieldSelect: HTMLSelectElement;
|
||||
}
|
||||
|
||||
export function isPluginToolbarMounted(
|
||||
root: HTMLElement,
|
||||
document: Document
|
||||
): boolean {
|
||||
const actionRow = findNativeActionRow(document);
|
||||
return Boolean(actionRow && root.parentElement === actionRow && !root.hidden);
|
||||
}
|
||||
|
||||
export function ensurePluginToolbar(
|
||||
@@ -72,53 +33,13 @@ export function ensurePluginToolbar(
|
||||
"[data-plugin-toolbar='root']"
|
||||
) as HTMLElement | null;
|
||||
if (existingRoot) {
|
||||
ensureToolbarMounted(existingRoot, document);
|
||||
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, "", "不排序");
|
||||
SORT_FIELD_OPTIONS.forEach(({ label, value }) => {
|
||||
appendOption(sortFieldSelect, value, label);
|
||||
});
|
||||
|
||||
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";
|
||||
|
||||
const batchSubmitButton = document.createElement("button");
|
||||
batchSubmitButton.type = "button";
|
||||
batchSubmitButton.dataset.pluginBatchSubmit = "button";
|
||||
batchSubmitButton.textContent = "提交批次";
|
||||
applyToolbarRootStyles(root);
|
||||
|
||||
const exportRangeSelect = document.createElement("select");
|
||||
exportRangeSelect.dataset.pluginExportRange = "select";
|
||||
@@ -134,32 +55,40 @@ export function ensurePluginToolbar(
|
||||
exportCustomPagesInput.min = "1";
|
||||
exportCustomPagesInput.step = "1";
|
||||
exportCustomPagesInput.hidden = true;
|
||||
exportCustomPagesInput.placeholder = "页数";
|
||||
exportCustomPagesInput.dataset.pluginExportCustomPages = "input";
|
||||
|
||||
const exportButton = document.createElement("button");
|
||||
exportButton.type = "button";
|
||||
exportButton.dataset.pluginExport = "button";
|
||||
exportButton.textContent = "导出CSV";
|
||||
|
||||
const batchSubmitButton = document.createElement("button");
|
||||
batchSubmitButton.type = "button";
|
||||
batchSubmitButton.dataset.pluginBatchSubmit = "button";
|
||||
batchSubmitButton.textContent = "提交批次";
|
||||
|
||||
const exportStatusText = document.createElement("span");
|
||||
exportStatusText.dataset.pluginExportStatus = "text";
|
||||
applyStatusStyles(exportStatusText);
|
||||
|
||||
root.append(
|
||||
singleFilterInput,
|
||||
personalFilterInput,
|
||||
filterApplyButton,
|
||||
sortFieldSelect,
|
||||
sortDirectionSelect,
|
||||
sortApplyButton,
|
||||
exportRangeSelect,
|
||||
exportCustomPagesInput,
|
||||
exportButton,
|
||||
batchSubmitButton
|
||||
batchSubmitButton,
|
||||
exportStatusText
|
||||
);
|
||||
root.append(exportStatusText);
|
||||
document.body.prepend(root);
|
||||
|
||||
filterApplyButton.addEventListener("click", () => {
|
||||
void handlers.onApplyFilter();
|
||||
});
|
||||
sortApplyButton.addEventListener("click", () => {
|
||||
void handlers.onApplySort();
|
||||
document.body.appendChild(root);
|
||||
applyNativeControlStyles(document, {
|
||||
batchSubmitButton,
|
||||
exportButton,
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect
|
||||
});
|
||||
ensureToolbarMounted(root, document);
|
||||
|
||||
exportButton.addEventListener("click", () => {
|
||||
void handlers.onExport();
|
||||
});
|
||||
@@ -173,13 +102,7 @@ export function ensurePluginToolbar(
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect,
|
||||
exportStatusText,
|
||||
filterApplyButton,
|
||||
personalFilterInput,
|
||||
root,
|
||||
singleFilterInput,
|
||||
sortApplyButton,
|
||||
sortDirectionSelect,
|
||||
sortFieldSelect
|
||||
root
|
||||
});
|
||||
});
|
||||
|
||||
@@ -189,13 +112,7 @@ export function ensurePluginToolbar(
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect,
|
||||
exportStatusText,
|
||||
filterApplyButton,
|
||||
personalFilterInput,
|
||||
root,
|
||||
singleFilterInput,
|
||||
sortApplyButton,
|
||||
sortDirectionSelect,
|
||||
sortFieldSelect
|
||||
root
|
||||
} satisfies PluginToolbarDom;
|
||||
syncCustomPagesInputVisibility(toolbarDom);
|
||||
|
||||
@@ -230,25 +147,7 @@ function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
||||
exportStatusText: root.querySelector(
|
||||
'[data-plugin-export-status="text"]'
|
||||
) as HTMLElement,
|
||||
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
|
||||
root
|
||||
} satisfies PluginToolbarDom;
|
||||
syncCustomPagesInputVisibility(toolbarDom);
|
||||
return toolbarDom;
|
||||
@@ -316,12 +215,6 @@ export function setToolbarBusyState(
|
||||
[
|
||||
toolbar.batchSubmitButton,
|
||||
toolbar.exportButton,
|
||||
toolbar.filterApplyButton,
|
||||
toolbar.sortApplyButton,
|
||||
toolbar.singleFilterInput,
|
||||
toolbar.personalFilterInput,
|
||||
toolbar.sortFieldSelect,
|
||||
toolbar.sortDirectionSelect,
|
||||
toolbar.exportRangeSelect,
|
||||
toolbar.exportCustomPagesInput
|
||||
].forEach((element) => {
|
||||
@@ -336,15 +229,243 @@ 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";
|
||||
}
|
||||
|
||||
function ensureToolbarMounted(root: HTMLElement, document: Document): void {
|
||||
const actionRow = findNativeActionRow(document);
|
||||
if (!actionRow) {
|
||||
root.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const customizeButton = findNativeActionButton(actionRow, "自定义指标");
|
||||
const insertionAnchor = customizeButton
|
||||
? findDirectChildAnchor(actionRow, customizeButton)
|
||||
: null;
|
||||
if (insertionAnchor) {
|
||||
actionRow.insertBefore(root, insertionAnchor);
|
||||
} else if (root.parentElement !== actionRow) {
|
||||
actionRow.prepend(root);
|
||||
}
|
||||
|
||||
root.hidden = false;
|
||||
}
|
||||
|
||||
function findNativeActionRow(document: Document): HTMLElement | null {
|
||||
const customizeButton = findNativeActionButton(document, "自定义指标");
|
||||
const exportButton = findNativeActionButton(document, "导出");
|
||||
const header = findHeaderContainer(customizeButton, exportButton);
|
||||
|
||||
const sharedActionRow =
|
||||
customizeButton && exportButton
|
||||
? findSmallestSharedActionRow(customizeButton, exportButton, header)
|
||||
: null;
|
||||
if (sharedActionRow) {
|
||||
return sharedActionRow;
|
||||
}
|
||||
|
||||
const scope = header ?? document;
|
||||
const candidates = Array.from(
|
||||
scope.querySelectorAll(".xt-space.xt-space--medium, .search-content--header")
|
||||
).filter((element): element is HTMLElement =>
|
||||
element instanceof document.defaultView!.HTMLElement
|
||||
);
|
||||
|
||||
const rankedCandidates = candidates
|
||||
.filter((candidate) =>
|
||||
isNativeActionRowCandidate(candidate, customizeButton, exportButton)
|
||||
)
|
||||
.sort((left, right) => {
|
||||
const depthDelta = getDepthWithinAncestor(right, header) - getDepthWithinAncestor(left, header);
|
||||
if (depthDelta !== 0) {
|
||||
return depthDelta;
|
||||
}
|
||||
|
||||
return normalizeText(left.textContent).length - normalizeText(right.textContent).length;
|
||||
});
|
||||
|
||||
return rankedCandidates[0] ?? null;
|
||||
}
|
||||
|
||||
function findHeaderContainer(
|
||||
customizeButton: HTMLElement | null,
|
||||
exportButton: HTMLElement | null
|
||||
): HTMLElement | null {
|
||||
return (
|
||||
(customizeButton?.closest(".search-content--header") as HTMLElement | null) ??
|
||||
(exportButton?.closest(".search-content--header") as HTMLElement | null)
|
||||
);
|
||||
}
|
||||
|
||||
function findSmallestSharedActionRow(
|
||||
customizeButton: HTMLElement,
|
||||
exportButton: HTMLElement,
|
||||
boundary: HTMLElement | null
|
||||
): HTMLElement | null {
|
||||
const exportAncestors = new Set(collectAncestorChain(exportButton, boundary));
|
||||
|
||||
for (const candidate of collectAncestorChain(customizeButton, boundary)) {
|
||||
if (
|
||||
exportAncestors.has(candidate) &&
|
||||
isNativeActionRowCandidate(candidate, customizeButton, exportButton)
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectAncestorChain(
|
||||
element: HTMLElement,
|
||||
boundary: HTMLElement | null
|
||||
): HTMLElement[] {
|
||||
const ancestors: HTMLElement[] = [];
|
||||
let current: HTMLElement | null = element.parentElement;
|
||||
|
||||
while (current) {
|
||||
ancestors.push(current);
|
||||
if (current === boundary) {
|
||||
break;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
function isNativeActionRowCandidate(
|
||||
candidate: HTMLElement,
|
||||
customizeButton: HTMLElement | null,
|
||||
exportButton: HTMLElement | null
|
||||
): boolean {
|
||||
if (customizeButton && !candidate.contains(customizeButton)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (exportButton && !candidate.contains(exportButton)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const directChildLabels = Array.from(candidate.children)
|
||||
.flatMap((child) => {
|
||||
const buttons: Element[] = [];
|
||||
if (child instanceof candidate.ownerDocument.defaultView!.HTMLButtonElement) {
|
||||
buttons.push(child);
|
||||
}
|
||||
|
||||
buttons.push(...Array.from(child.querySelectorAll("button")));
|
||||
return buttons;
|
||||
})
|
||||
.map((button) => normalizeText(button.textContent));
|
||||
return (
|
||||
directChildLabels.includes("导出") &&
|
||||
(directChildLabels.includes("自定义指标") || Boolean(customizeButton))
|
||||
);
|
||||
}
|
||||
|
||||
function getDepthWithinAncestor(
|
||||
element: HTMLElement,
|
||||
boundary: HTMLElement | null
|
||||
): number {
|
||||
let depth = 0;
|
||||
let current: HTMLElement | null = element.parentElement;
|
||||
|
||||
while (current && current !== boundary) {
|
||||
depth += 1;
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return depth;
|
||||
}
|
||||
|
||||
function findNativeActionButton(
|
||||
root: ParentNode,
|
||||
text: string
|
||||
): HTMLElement | null {
|
||||
const document = root instanceof Document ? root : root.ownerDocument;
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = Array.from(root.querySelectorAll("button")).filter(
|
||||
(element): element is HTMLElement =>
|
||||
element instanceof document.defaultView!.HTMLElement
|
||||
);
|
||||
return (
|
||||
candidates.find((element) => normalizeText(element.textContent) === text) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function applyToolbarRootStyles(root: HTMLElement): void {
|
||||
root.style.display = "inline-flex";
|
||||
root.style.alignItems = "center";
|
||||
root.style.columnGap = "8px";
|
||||
root.style.flexWrap = "wrap";
|
||||
}
|
||||
|
||||
function applyNativeControlStyles(
|
||||
document: Document,
|
||||
controls: {
|
||||
batchSubmitButton: HTMLButtonElement;
|
||||
exportButton: HTMLButtonElement;
|
||||
exportCustomPagesInput: HTMLInputElement;
|
||||
exportRangeSelect: HTMLSelectElement;
|
||||
}
|
||||
): void {
|
||||
const nativeButton =
|
||||
findNativeActionButton(document, "自定义指标") ??
|
||||
findNativeActionButton(document, "导出");
|
||||
|
||||
if (nativeButton) {
|
||||
controls.exportButton.className = nativeButton.className;
|
||||
controls.batchSubmitButton.className = nativeButton.className;
|
||||
}
|
||||
|
||||
[controls.exportButton, controls.batchSubmitButton].forEach((button) => {
|
||||
button.style.whiteSpace = "nowrap";
|
||||
});
|
||||
|
||||
[controls.exportRangeSelect, controls.exportCustomPagesInput].forEach((element) => {
|
||||
element.style.height = "32px";
|
||||
element.style.border = "1px solid #d0d7de";
|
||||
element.style.borderRadius = "6px";
|
||||
element.style.padding = "0 10px";
|
||||
element.style.background = "#fff";
|
||||
element.style.color = "#1f2329";
|
||||
element.style.boxSizing = "border-box";
|
||||
});
|
||||
|
||||
controls.exportRangeSelect.style.minWidth = "104px";
|
||||
controls.exportCustomPagesInput.style.width = "72px";
|
||||
}
|
||||
|
||||
function applyStatusStyles(statusText: HTMLElement): void {
|
||||
statusText.style.color = "#64748b";
|
||||
statusText.style.fontSize = "12px";
|
||||
statusText.style.lineHeight = "20px";
|
||||
statusText.style.marginLeft = "4px";
|
||||
statusText.style.whiteSpace = "nowrap";
|
||||
}
|
||||
|
||||
function normalizeText(value: string | null | undefined): string {
|
||||
return value?.replace(/\s+/g, " ").trim() ?? "";
|
||||
}
|
||||
|
||||
function findDirectChildAnchor(
|
||||
ancestor: HTMLElement,
|
||||
descendant: HTMLElement
|
||||
): HTMLElement | null {
|
||||
let current: HTMLElement | null = descendant;
|
||||
let previous: HTMLElement | null = null;
|
||||
|
||||
while (current && current !== ancestor) {
|
||||
previous = current;
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return current === ancestor ? previous : null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user