615 lines
18 KiB
TypeScript
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;
|
|
}
|