import type { MarketExportScope, MarketExportTarget } from "./types"; export interface PluginToolbarHandlers { onExport(): Promise | void; onExportAudienceProfile(): Promise | void; onExportAudienceProfileByIds(): Promise | void; onSubmitBatch(): Promise | void; } export interface PluginToolbarDom { audienceProfileByIdExportButton: HTMLButtonElement; audienceProfileExportButton: HTMLButtonElement; batchSubmitButton: HTMLButtonElement; exportButton: HTMLButtonElement; exportCustomPagesInput: HTMLInputElement; exportRangeSelect: HTMLSelectElement; exportStatusText: HTMLElement; root: HTMLElement; } const PLUGIN_ACTION_BUTTON_STYLE_ID = "sces-plugin-action-button-style"; 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 { ensurePluginActionButtonTheme(document); const existingRoot = document.querySelector( "[data-plugin-toolbar='root']" ) as HTMLElement | null; if (existingRoot) { if ( existingRoot.querySelector( '[data-plugin-export-audience-profile-by-id="button"]' ) ) { ensureToolbarMounted(existingRoot, document); return readToolbarDom(existingRoot); } existingRoot.remove(); } 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 audienceProfileExportButton = document.createElement("button"); audienceProfileExportButton.type = "button"; audienceProfileExportButton.dataset.pluginExportAudienceProfile = "button"; audienceProfileExportButton.textContent = "导出画像CSV"; const audienceProfileByIdExportButton = document.createElement("button"); audienceProfileByIdExportButton.type = "button"; audienceProfileByIdExportButton.dataset.pluginExportAudienceProfileById = "button"; audienceProfileByIdExportButton.textContent = "按ID导出画像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, audienceProfileExportButton, audienceProfileByIdExportButton, batchSubmitButton, exportStatusText ); document.body.appendChild(root); applyNativeControlStyles(document, { audienceProfileExportButton, audienceProfileByIdExportButton, batchSubmitButton, exportButton, exportCustomPagesInput, exportRangeSelect }); ensureToolbarMounted(root, document); exportButton.addEventListener("click", () => { void handlers.onExport(); }); audienceProfileExportButton.addEventListener("click", () => { void handlers.onExportAudienceProfile(); }); audienceProfileByIdExportButton.addEventListener("click", () => { void handlers.onExportAudienceProfileByIds(); }); batchSubmitButton.addEventListener("click", () => { void handlers.onSubmitBatch(); }); exportRangeSelect.addEventListener("change", () => { syncCustomPagesInputVisibility({ batchSubmitButton, audienceProfileByIdExportButton, audienceProfileExportButton, exportButton, exportCustomPagesInput, exportRangeSelect, exportStatusText, root }); }); const toolbarDom = { audienceProfileExportButton, audienceProfileByIdExportButton, 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 = { audienceProfileByIdExportButton: root.querySelector( '[data-plugin-export-audience-profile-by-id="button"]' ) as HTMLButtonElement, audienceProfileExportButton: root.querySelector( '[data-plugin-export-audience-profile="button"]' ) as HTMLButtonElement, 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.audienceProfileByIdExportButton, toolbar.audienceProfileExportButton, 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: { audienceProfileExportButton: HTMLButtonElement; audienceProfileByIdExportButton: HTMLButtonElement; 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.audienceProfileExportButton.className = nativeButton.className; controls.audienceProfileByIdExportButton.className = nativeButton.className; controls.batchSubmitButton.className = nativeButton.className; } [ controls.exportButton, controls.audienceProfileExportButton, controls.audienceProfileByIdExportButton, controls.batchSubmitButton ].forEach((button) => { applyPrimaryButtonStyles(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 applyPrimaryButtonStyles( button: HTMLButtonElement ): void { button.style.backgroundColor = "#7f1d2d"; button.style.border = "1px solid #7f1d2d"; button.style.borderRadius = "8px"; button.style.color = "#ffffff"; button.style.height = "32px"; button.style.padding = "0 15px"; button.style.boxSizing = "border-box"; button.style.fontWeight = "600"; button.style.transition = "background-color 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease"; } 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 ensurePluginActionButtonTheme(document: Document): void { if (document.getElementById(PLUGIN_ACTION_BUTTON_STYLE_ID)) { return; } const style = document.createElement("style"); style.id = PLUGIN_ACTION_BUTTON_STYLE_ID; style.textContent = ` [data-plugin-export="button"]:hover:not(:disabled), [data-plugin-export-audience-profile="button"]:hover:not(:disabled), [data-plugin-export-audience-profile-by-id="button"]:hover:not(:disabled), [data-plugin-batch-submit="button"]:hover:not(:disabled) { background-color: #6d1627 !important; border-color: #6d1627 !important; } [data-plugin-export="button"]:active:not(:disabled), [data-plugin-export-audience-profile="button"]:active:not(:disabled), [data-plugin-export-audience-profile-by-id="button"]:active:not(:disabled), [data-plugin-batch-submit="button"]:active:not(:disabled) { background-color: #58111f !important; border-color: #58111f !important; transform: translateY(1px); } [data-plugin-export="button"]:focus-visible, [data-plugin-export-audience-profile="button"]:focus-visible, [data-plugin-export-audience-profile-by-id="button"]:focus-visible, [data-plugin-batch-submit="button"]:focus-visible { outline: none !important; box-shadow: 0 0 0 3px rgba(127, 29, 45, 0.2) !important; } [data-plugin-export="button"]:disabled, [data-plugin-export-audience-profile="button"]:disabled, [data-plugin-export-audience-profile-by-id="button"]:disabled, [data-plugin-batch-submit="button"]:disabled { background-color: #c89ca4 !important; border-color: #c89ca4 !important; color: rgba(255, 255, 255, 0.95) !important; cursor: not-allowed !important; opacity: 1 !important; transform: none !important; box-shadow: none !important; } `; document.head.appendChild(style); } 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; }