import type { MarketExportScope, MarketExportTarget } from "./types"; export interface PluginToolbarHandlers { onExport(): Promise | void; onSubmitBatch(): Promise | void; } export interface PluginToolbarDom { batchSubmitButton: HTMLButtonElement; exportButton: HTMLButtonElement; exportCustomPagesInput: HTMLInputElement; exportRangeSelect: HTMLSelectElement; exportStatusText: HTMLElement; root: HTMLElement; } export function isPluginToolbarMounted( root: HTMLElement, document: Document ): boolean { const actionRow = findNativeActionRow(document); return Boolean(actionRow && root.parentElement === actionRow && !root.hidden); } export function ensurePluginToolbar( document: Document, handlers: PluginToolbarHandlers ): PluginToolbarDom { const existingRoot = document.querySelector( "[data-plugin-toolbar='root']" ) as HTMLElement | null; if (existingRoot) { ensureToolbarMounted(existingRoot, document); return readToolbarDom(existingRoot); } const root = document.createElement("section"); root.dataset.pluginToolbar = "root"; applyToolbarRootStyles(root); const exportRangeSelect = document.createElement("select"); exportRangeSelect.dataset.pluginExportRange = "select"; appendOption(exportRangeSelect, "current", "当前页"); appendOption(exportRangeSelect, "first-5", "前5页"); appendOption(exportRangeSelect, "first-10", "前10页"); appendOption(exportRangeSelect, "all", "全部"); appendOption(exportRangeSelect, "custom", "自定义"); exportRangeSelect.value = "first-5"; const exportCustomPagesInput = document.createElement("input"); exportCustomPagesInput.type = "number"; 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( exportRangeSelect, exportCustomPagesInput, exportButton, batchSubmitButton, exportStatusText ); document.body.appendChild(root); applyNativeControlStyles(document, { batchSubmitButton, exportButton, exportCustomPagesInput, exportRangeSelect }); ensureToolbarMounted(root, document); exportButton.addEventListener("click", () => { void handlers.onExport(); }); batchSubmitButton.addEventListener("click", () => { void handlers.onSubmitBatch(); }); exportRangeSelect.addEventListener("change", () => { syncCustomPagesInputVisibility({ batchSubmitButton, exportButton, exportCustomPagesInput, exportRangeSelect, exportStatusText, root }); }); const toolbarDom = { batchSubmitButton, exportButton, exportCustomPagesInput, exportRangeSelect, exportStatusText, root } satisfies PluginToolbarDom; syncCustomPagesInputVisibility(toolbarDom); return toolbarDom; } 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 { const toolbarDom = { batchSubmitButton: root.querySelector( '[data-plugin-batch-submit="button"]' ) as HTMLButtonElement, exportButton: root.querySelector( '[data-plugin-export="button"]' ) as HTMLButtonElement, exportCustomPagesInput: root.querySelector( '[data-plugin-export-custom-pages="input"]' ) as HTMLInputElement, exportRangeSelect: root.querySelector( '[data-plugin-export-range="select"]' ) as HTMLSelectElement, exportStatusText: root.querySelector( '[data-plugin-export-status="text"]' ) as HTMLElement, root } satisfies PluginToolbarDom; syncCustomPagesInputVisibility(toolbarDom); return toolbarDom; } export function readToolbarExportTarget( toolbar: PluginToolbarDom ): { error?: string; target?: MarketExportTarget } { const scope = toolbar.exportRangeSelect.value as MarketExportScope; if (scope === "all") { return { target: { mode: "all" } }; } if (scope === "current") { return { target: { mode: "count", pageCount: 1 } }; } if (scope === "first-5") { return { target: { mode: "count", pageCount: 5 } }; } if (scope === "first-10") { return { target: { mode: "count", pageCount: 10 } }; } const pageCount = Number(toolbar.exportCustomPagesInput.value); if (!Number.isInteger(pageCount) || pageCount < 1) { return { error: "请输入有效页数" }; } return { target: { mode: "count", pageCount } }; } export function setToolbarBusyState( toolbar: PluginToolbarDom, isBusy: boolean ): void { [ toolbar.batchSubmitButton, toolbar.exportButton, toolbar.exportRangeSelect, toolbar.exportCustomPagesInput ].forEach((element) => { element.disabled = isBusy; }); } export function setToolbarExportStatus( toolbar: PluginToolbarDom, text: string ): void { toolbar.exportStatusText.textContent = text; } 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 primaryButton = findButtonContainingText(document, "发布任务") ?? findButtonContainingText(document, "+发布任务"); const nativeButton = primaryButton ?? findNativeActionButton(document, "自定义指标") ?? findNativeActionButton(document, "导出"); if (nativeButton) { controls.exportButton.className = nativeButton.className; controls.batchSubmitButton.className = nativeButton.className; } [controls.exportButton, controls.batchSubmitButton].forEach((button) => { applyPrimaryButtonStyles(button, Boolean(primaryButton)); 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 applyPrimaryButtonStyles( button: HTMLButtonElement, isUsingNativePrimaryButtonClass: boolean ): void { if (!isUsingNativePrimaryButtonClass) { button.style.backgroundColor = "#fe346e"; button.style.border = "1px solid #fe346e"; button.style.borderRadius = "8px"; button.style.color = "#ffffff"; button.style.height = "32px"; button.style.padding = "0 15px"; } else { button.style.color = "#ffffff"; } button.style.boxSizing = "border-box"; } 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 findButtonContainingText( 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).includes(text)) ?? null; } 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; }