feat: refine market action bar and metrics sync

This commit is contained in:
2026-04-23 16:20:14 +08:00
parent 24e8a3ba9a
commit bee8cb0207
10 changed files with 892 additions and 447 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+272 -151
View File
@@ -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;
}