615 lines
18 KiB
TypeScript

import type {
MarketExportScope,
MarketExportTarget
} from "./types";
export interface PluginToolbarHandlers {
onExport(): Promise<void> | void;
onExportAudienceProfile(): Promise<void> | void;
onExportAudienceProfileByIds(): Promise<void> | void;
onSubmitBatch(): Promise<void> | 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;
}