feat: refine market action bar and metrics sync
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user