diff --git a/src/content/market/plugin-toolbar.ts b/src/content/market/plugin-toolbar.ts index 7fcda6b..48c5765 100644 --- a/src/content/market/plugin-toolbar.ts +++ b/src/content/market/plugin-toolbar.ts @@ -16,17 +16,35 @@ export interface PluginToolbarHandlers { } interface SpreadMetricRuleDom { + detailsButton: HTMLButtonElement; enabledInput: HTMLInputElement; flowTypeSelect: HTMLSelectElement; onlyAssignSelect: HTMLSelectElement; rangeSelect: HTMLSelectElement; + removeButton: HTMLButtonElement; root: HTMLElement; + secondaryControls: HTMLElement; thresholdInput: HTMLInputElement; typeSelect: HTMLSelectElement; } type SpreadMetricRuleDomMap = Record; +interface MetricCatalogDom { + addButton: HTMLButtonElement; + closeButton: HTMLButtonElement; + metricActions: Record; + panel: HTMLElement; + root: HTMLElement; + searchInput: HTMLInputElement; + selectedCount: HTMLElement; +} + +interface ToolbarResizeListener { + handler: EventListener; + window: Window; +} + const SPREAD_FILTER_DEFINITIONS: ReadonlyArray<{ label: string; metric: SpreadFilterMetric; @@ -35,6 +53,14 @@ const SPREAD_FILTER_DEFINITIONS: ReadonlyArray<{ { label: "互动率", metric: "interactionRate" } ]; +const DEFAULT_ENABLED_SPREAD_METRIC: SpreadFilterMetric = "finishRate"; +const DEFAULT_SPREAD_METRIC_CONFIG: Readonly = { + flowType: 0, + onlyAssign: true, + range: 2, + type: 2 +}; + export interface PluginToolbarDom { audienceProfileByIdExportButton: HTMLButtonElement; audienceProfileExportButton: HTMLButtonElement; @@ -44,18 +70,30 @@ export interface PluginToolbarDom { exportCustomPagesInput: HTMLInputElement; exportRangeSelect: HTMLSelectElement; exportStatusText: HTMLElement; + metricCatalog: MetricCatalogDom; spreadMetricRules: SpreadMetricRuleDomMap; root: HTMLElement; } const PLUGIN_ACTION_BUTTON_STYLE_ID = "sces-plugin-action-button-style"; +const TOOLBAR_RESIZE_LISTENER = Symbol("sces-toolbar-resize-listener"); + +type ToolbarRootWithResizeListener = HTMLElement & { + [TOOLBAR_RESIZE_LISTENER]?: ToolbarResizeListener; +}; export function isPluginToolbarMounted( root: HTMLElement, document: Document ): boolean { const actionRow = findNativeActionRow(document); - return Boolean(actionRow && root.parentElement === actionRow && !root.hidden); + const isMounted = Boolean( + actionRow && root.parentElement === actionRow && !root.hidden + ); + if (!isMounted) { + cleanupToolbarResizeListener(root); + } + return isMounted; } export function ensurePluginToolbar( @@ -72,12 +110,18 @@ export function ensurePluginToolbar( existingRoot.querySelector( '[data-plugin-export-audience-profile-by-id="button"]' ) && - existingRoot.querySelector('[data-plugin-spread-metric="finishRate"]') + existingRoot.querySelector( + '[data-plugin-toolbar-action-group="csv-export"]' + ) && + existingRoot.querySelector('[data-plugin-spread-metric-catalog-trigger="button"]') ) { ensureToolbarMounted(existingRoot, document); - return readToolbarDom(existingRoot); + const toolbarDom = readToolbarDom(existingRoot); + ensureToolbarResizeListener(toolbarDom); + return toolbarDom; } + cleanupToolbarResizeListener(existingRoot); existingRoot.remove(); } @@ -140,6 +184,11 @@ export function ensurePluginToolbar( applyStatusStyles(exportStatusText); const spreadMetricRules = createSpreadMetricRuleDoms(document); + const defaultSpreadMetricRule = + spreadMetricRules[DEFAULT_ENABLED_SPREAD_METRIC]; + defaultSpreadMetricRule.enabledInput.checked = true; + applyDefaultSpreadMetricRuleState(defaultSpreadMetricRule); + const metricCatalog = createMetricCatalog(document); const panel = document.createElement("div"); panel.dataset.pluginToolbarPanel = "root"; @@ -156,18 +205,33 @@ export function ensurePluginToolbar( const dataGroup = document.createElement("div"); dataGroup.dataset.pluginToolbarGroup = "data"; applyToolbarGroupStyles(dataGroup); - dataGroup.append( + const csvExportGroup = createToolbarActionGroup(document, "csv-export", [ exportButton, exportRangeSelect, - exportCustomPagesInput, - audienceProfileExportButton, - audienceProfileByIdExportButton, - audienceProfileFieldButton, + exportCustomPagesInput + ]); + const selectedAudienceExportGroup = createToolbarActionGroup( + document, + "selected-audience-export", + [audienceProfileExportButton, audienceProfileFieldButton] + ); + const idExportGroup = createToolbarActionGroup(document, "id-export", [ + audienceProfileByIdExportButton + ]); + const batchSubmitGroup = createToolbarActionGroup(document, "batch-submit", [ batchSubmitButton + ]); + dataGroup.append( + csvExportGroup, + createToolbarActionDivider(document), + selectedAudienceExportGroup, + createToolbarActionDivider(document), + idExportGroup, + createToolbarActionDivider(document), + batchSubmitGroup ); const thresholdTitle = createToolbarGroupTitle(document, "传播指标筛选"); - const metricSelector = createSpreadMetricSelector(document, spreadMetricRules); const rulesGroup = document.createElement("div"); rulesGroup.dataset.pluginSpreadRules = "root"; applySpreadRulesGroupStyles(rulesGroup); @@ -179,7 +243,7 @@ export function ensurePluginToolbar( const filterNote = createSpreadFilterNote(document); firstRow.append(dataGroup, exportStatusText); - secondRow.append(thresholdTitle, metricSelector, rulesGroup, filterNote); + secondRow.append(thresholdTitle, metricCatalog.root, rulesGroup, filterNote); panel.append(firstRow, secondRow); root.append(panel); @@ -222,6 +286,7 @@ export function ensurePluginToolbar( exportCustomPagesInput, exportRangeSelect, exportStatusText, + metricCatalog, spreadMetricRules, root } satisfies PluginToolbarDom; @@ -230,6 +295,16 @@ export function ensurePluginToolbar( syncCustomPagesInputVisibility(toolbarDom); }); + metricCatalog.addButton.addEventListener("click", () => { + setMetricCatalogOpen(metricCatalog, metricCatalog.panel.hidden); + }); + metricCatalog.closeButton.addEventListener("click", () => { + setMetricCatalogOpen(metricCatalog, false); + }); + metricCatalog.searchInput.addEventListener("input", () => { + syncMetricCatalog(toolbarDom); + }); + SPREAD_FILTER_DEFINITIONS.forEach(({ metric }) => { const rule = spreadMetricRules[metric]; rule.enabledInput.addEventListener("change", () => { @@ -239,8 +314,27 @@ export function ensurePluginToolbar( rule.typeSelect.addEventListener("change", () => { syncSpreadMetricVideoConstraints(rule); }); + rule.removeButton.addEventListener("click", () => { + rule.enabledInput.checked = false; + syncAllSpreadMetricRules(toolbarDom); + }); + rule.detailsButton.addEventListener("click", () => { + rule.root.dataset.pluginSpreadRuleDetails = + rule.root.dataset.pluginSpreadRuleDetails === "open" ? "closed" : "open"; + syncSpreadMetricRuleSecondaryControls(rule); + }); + metricCatalog.metricActions[metric].addEventListener("click", () => { + if (rule.enabledInput.checked) { + return; + } + rule.enabledInput.checked = true; + syncAllSpreadMetricRules(toolbarDom); + setMetricCatalogOpen(metricCatalog, false); + }); }); + ensureToolbarResizeListener(toolbarDom); + syncCustomPagesInputVisibility(toolbarDom); syncAllSpreadMetricRules(toolbarDom); @@ -258,6 +352,30 @@ function appendOption( select.appendChild(option); } +function createToolbarActionGroup( + document: Document, + groupName: + | "batch-submit" + | "csv-export" + | "id-export" + | "selected-audience-export", + controls: HTMLElement[] +): HTMLElement { + const group = document.createElement("div"); + group.dataset.pluginToolbarActionGroup = groupName; + applyToolbarActionGroupStyles(group); + group.append(...controls); + return group; +} + +function createToolbarActionDivider(document: Document): HTMLElement { + const divider = document.createElement("span"); + divider.dataset.pluginToolbarActionDivider = "divider"; + divider.setAttribute("aria-hidden", "true"); + applyToolbarActionDividerStyles(divider); + return divider; +} + function createSpreadMetricRuleDoms( document: Document ): SpreadMetricRuleDomMap { @@ -276,6 +394,7 @@ function createSpreadMetricRuleDom( ): SpreadMetricRuleDom { const enabledInput = document.createElement("input"); enabledInput.type = "checkbox"; + enabledInput.hidden = true; enabledInput.dataset.pluginSpreadMetric = metric; enabledInput.setAttribute("aria-label", `启用${label}筛选`); @@ -332,44 +451,131 @@ function createSpreadMetricRuleDom( unitText.textContent = "%"; thresholdControl.append(operator, thresholdInput, unitText); - root.append( - metricLabel, - thresholdControl, - typeSelect, + const primaryControls = document.createElement("div"); + primaryControls.dataset.pluginSpreadRulePrimary = metric; + applySpreadMetricRulePrimaryStyles(primaryControls); + + const detailsButton = document.createElement("button"); + detailsButton.type = "button"; + detailsButton.dataset.pluginSpreadRuleDetails = metric; + detailsButton.textContent = "更多选项"; + detailsButton.setAttribute("aria-expanded", "false"); + applySpreadMetricDetailsButtonStyles(detailsButton); + primaryControls.append(metricLabel, thresholdControl, typeSelect, detailsButton); + + const secondaryControls = document.createElement("div"); + secondaryControls.dataset.pluginSpreadRuleSecondary = metric; + applySpreadMetricRuleSecondaryStyles(secondaryControls); + + const removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.dataset.pluginSpreadRuleRemove = metric; + removeButton.textContent = "×"; + removeButton.title = `删除${label}筛选`; + removeButton.setAttribute("aria-label", `删除${label}筛选`); + applySpreadMetricRemoveButtonStyles(removeButton); + secondaryControls.append( onlyAssignSelect, flowTypeSelect, - rangeSelect + rangeSelect, + removeButton ); + root.append(enabledInput, primaryControls, secondaryControls); + return { + detailsButton, enabledInput, flowTypeSelect, onlyAssignSelect, rangeSelect, + removeButton, root, + secondaryControls, thresholdInput, typeSelect }; } -function createSpreadMetricSelector( - document: Document, - rules: SpreadMetricRuleDomMap -): HTMLElement { - const selector = document.createElement("div"); - selector.dataset.pluginSpreadMetrics = "root"; - applySpreadMetricSelectorStyles(selector); +function createMetricCatalog(document: Document): MetricCatalogDom { + const root = document.createElement("div"); + root.dataset.pluginSpreadMetricCatalog = "root"; + applyMetricCatalogStyles(root); - SPREAD_FILTER_DEFINITIONS.forEach(({ label, metric }) => { - const option = document.createElement("label"); - applySpreadMetricOptionStyles(option); - const labelText = document.createElement("span"); - labelText.textContent = label; - option.append(rules[metric].enabledInput, labelText); - selector.appendChild(option); - }); + const addButton = document.createElement("button"); + addButton.type = "button"; + addButton.dataset.pluginSpreadMetricCatalogTrigger = "button"; + addButton.textContent = "添加筛选指标"; + addButton.setAttribute("aria-expanded", "false"); + applyMetricCatalogTriggerStyles(addButton); - return selector; + const selectedCount = document.createElement("span"); + selectedCount.dataset.pluginSpreadMetricSelectedCount = "text"; + selectedCount.setAttribute("aria-live", "polite"); + applyMetricCatalogCountStyles(selectedCount); + + const panel = document.createElement("div"); + panel.dataset.pluginSpreadMetricCatalogPanel = "root"; + panel.hidden = true; + applyMetricCatalogPanelStyles(panel); + + const searchInput = document.createElement("input"); + searchInput.type = "search"; + searchInput.placeholder = "搜索指标"; + searchInput.dataset.pluginSpreadMetricCatalogSearch = "input"; + searchInput.setAttribute("aria-label", "搜索筛选指标"); + applyMetricCatalogSearchStyles(searchInput); + + const closeButton = document.createElement("button"); + closeButton.type = "button"; + closeButton.dataset.pluginSpreadMetricCatalogClose = "button"; + closeButton.textContent = "×"; + closeButton.title = "关闭指标目录"; + closeButton.setAttribute("aria-label", "关闭指标目录"); + applyMetricCatalogCloseButtonStyles(closeButton); + + const panelHeader = document.createElement("div"); + panelHeader.dataset.pluginSpreadMetricCatalogHeader = "root"; + applyMetricCatalogHeaderStyles(panelHeader); + panelHeader.append(searchInput, closeButton); + + const group = document.createElement("div"); + group.dataset.pluginSpreadMetricCatalogGroup = "传播表现"; + applyMetricCatalogGroupStyles(group); + const groupTitle = document.createElement("strong"); + groupTitle.textContent = "传播表现"; + applyMetricCatalogGroupTitleStyles(groupTitle); + group.appendChild(groupTitle); + + const metricActions = Object.fromEntries( + SPREAD_FILTER_DEFINITIONS.map(({ label, metric }) => { + const item = document.createElement("div"); + item.dataset.pluginSpreadMetricCatalogItem = metric; + applyMetricCatalogItemStyles(item); + const name = document.createElement("span"); + name.textContent = label; + const action = document.createElement("button"); + action.type = "button"; + action.dataset.pluginSpreadMetricCatalogAction = metric; + applyMetricCatalogActionStyles(action); + item.append(name, action); + group.appendChild(item); + return [metric, action]; + }) + ) as unknown as Record; + + panel.append(panelHeader, group); + root.append(addButton, selectedCount, panel); + + return { + addButton, + closeButton, + metricActions, + panel, + root, + searchInput, + selectedCount + }; } function createSpreadFilterNote(document: Document): HTMLElement { @@ -390,6 +596,9 @@ function readSpreadMetricRuleDoms(root: HTMLElement): SpreadMetricRuleDomMap { return [ metric, { + detailsButton: ruleRoot.querySelector( + `[data-plugin-spread-rule-details="${metric}"]` + ) as HTMLButtonElement, enabledInput: root.querySelector( `[data-plugin-spread-metric="${metric}"]` ) as HTMLInputElement, @@ -402,7 +611,13 @@ function readSpreadMetricRuleDoms(root: HTMLElement): SpreadMetricRuleDomMap { rangeSelect: ruleRoot.querySelector( '[data-plugin-spread-filter="range"]' ) as HTMLSelectElement, + removeButton: ruleRoot.querySelector( + `[data-plugin-spread-rule-remove="${metric}"]` + ) as HTMLButtonElement, root: ruleRoot, + secondaryControls: ruleRoot.querySelector( + `[data-plugin-spread-rule-secondary="${metric}"]` + ) as HTMLElement, thresholdInput: ruleRoot.querySelector( `[data-plugin-spread-threshold="${metric}"]` ) as HTMLInputElement, @@ -415,6 +630,37 @@ function readSpreadMetricRuleDoms(root: HTMLElement): SpreadMetricRuleDomMap { ) as unknown as SpreadMetricRuleDomMap; } +function readMetricCatalogDom(root: HTMLElement): MetricCatalogDom { + return { + addButton: root.querySelector( + '[data-plugin-spread-metric-catalog-trigger="button"]' + ) as HTMLButtonElement, + closeButton: root.querySelector( + '[data-plugin-spread-metric-catalog-close="button"]' + ) as HTMLButtonElement, + metricActions: Object.fromEntries( + SPREAD_FILTER_DEFINITIONS.map(({ metric }) => [ + metric, + root.querySelector( + `[data-plugin-spread-metric-catalog-action="${metric}"]` + ) as HTMLButtonElement + ]) + ) as unknown as Record, + panel: root.querySelector( + '[data-plugin-spread-metric-catalog-panel="root"]' + ) as HTMLElement, + root: root.querySelector( + '[data-plugin-spread-metric-catalog="root"]' + ) as HTMLElement, + searchInput: root.querySelector( + '[data-plugin-spread-metric-catalog-search="input"]' + ) as HTMLInputElement, + selectedCount: root.querySelector( + '[data-plugin-spread-metric-selected-count="text"]' + ) as HTMLElement + }; +} + function readToolbarDom(root: HTMLElement): PluginToolbarDom { const toolbarDom = { audienceProfileByIdExportButton: root.querySelector( @@ -441,6 +687,7 @@ function readToolbarDom(root: HTMLElement): PluginToolbarDom { exportStatusText: root.querySelector( '[data-plugin-export-status="text"]' ) as HTMLElement, + metricCatalog: readMetricCatalogDom(root), spreadMetricRules: readSpreadMetricRuleDoms(root), root } satisfies PluginToolbarDom; @@ -551,13 +798,19 @@ export function setToolbarBusyState( toolbar.exportButton, toolbar.exportRangeSelect, toolbar.exportCustomPagesInput, + toolbar.metricCatalog.addButton, + toolbar.metricCatalog.closeButton, + toolbar.metricCatalog.searchInput, + ...Object.values(toolbar.metricCatalog.metricActions), ...Object.values(toolbar.spreadMetricRules).flatMap((rule) => [ + rule.detailsButton, rule.enabledInput, rule.thresholdInput, rule.typeSelect, rule.onlyAssignSelect, rule.flowTypeSelect, - rule.rangeSelect + rule.rangeSelect, + rule.removeButton ]) ].forEach((element) => { element.disabled = isBusy; @@ -579,22 +832,67 @@ function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void { toolbar.exportCustomPagesInput.hidden = toolbar.exportRangeSelect.value !== "custom"; } +function ensureToolbarResizeListener(toolbar: PluginToolbarDom): void { + const root = toolbar.root as ToolbarRootWithResizeListener; + const currentWindow = root.ownerDocument.defaultView; + const existingListener = root[TOOLBAR_RESIZE_LISTENER]; + if (!currentWindow) { + cleanupToolbarResizeListener(root); + return; + } + + if (existingListener?.window === currentWindow) { + return; + } + + if (existingListener) { + existingListener.window.removeEventListener( + "resize", + existingListener.handler + ); + delete root[TOOLBAR_RESIZE_LISTENER]; + } + + const handler = existingListener?.handler ?? (() => { + syncAllSpreadMetricRules(toolbar); + }); + root[TOOLBAR_RESIZE_LISTENER] = { handler, window: currentWindow }; + currentWindow.addEventListener("resize", handler); +} + +function cleanupToolbarResizeListener(root: HTMLElement): void { + const toolbarRoot = root as ToolbarRootWithResizeListener; + const listener = toolbarRoot[TOOLBAR_RESIZE_LISTENER]; + if (!listener) { + return; + } + + listener.window.removeEventListener("resize", listener.handler); + delete toolbarRoot[TOOLBAR_RESIZE_LISTENER]; +} + function syncAllSpreadMetricRules(toolbar: PluginToolbarDom): void { Object.values(toolbar.spreadMetricRules).forEach(syncSpreadMetricRuleState); + syncMetricCatalog(toolbar); syncSpreadFilterNote(toolbar); } function syncSpreadMetricRuleState(rule: SpreadMetricRuleDom): void { rule.root.hidden = !rule.enabledInput.checked; if (!rule.enabledInput.checked) { - rule.thresholdInput.value = ""; - rule.typeSelect.value = "1"; - rule.onlyAssignSelect.value = "false"; - rule.flowTypeSelect.value = "0"; - rule.rangeSelect.value = "2"; + applyDefaultSpreadMetricRuleState(rule); clearSpreadMetricRuleValidation(rule); } syncSpreadMetricVideoConstraints(rule); + syncSpreadMetricRuleSecondaryControls(rule); +} + +function applyDefaultSpreadMetricRuleState(rule: SpreadMetricRuleDom): void { + rule.thresholdInput.value = ""; + rule.typeSelect.value = String(DEFAULT_SPREAD_METRIC_CONFIG.type); + rule.onlyAssignSelect.value = String(DEFAULT_SPREAD_METRIC_CONFIG.onlyAssign); + rule.flowTypeSelect.value = String(DEFAULT_SPREAD_METRIC_CONFIG.flowType); + rule.rangeSelect.value = String(DEFAULT_SPREAD_METRIC_CONFIG.range); } function syncSpreadMetricVideoConstraints(rule: SpreadMetricRuleDom): void { @@ -621,6 +919,52 @@ function syncSpreadFilterNote(toolbar: PluginToolbarDom): void { note.hidden = enabledCount < 2; } +function syncMetricCatalog(toolbar: PluginToolbarDom): void { + const searchTerm = toolbar.metricCatalog.searchInput.value.trim().toLowerCase(); + const enabledCount = Object.values(toolbar.spreadMetricRules).filter( + (rule) => rule.enabledInput.checked + ).length; + toolbar.metricCatalog.selectedCount.textContent = `已选 ${enabledCount} 项`; + + SPREAD_FILTER_DEFINITIONS.forEach(({ label, metric }) => { + const action = toolbar.metricCatalog.metricActions[metric]; + const isSelected = toolbar.spreadMetricRules[metric].enabledInput.checked; + const item = action.closest( + `[data-plugin-spread-metric-catalog-item="${metric}"]` + ) as HTMLElement | null; + action.disabled = isSelected; + action.textContent = isSelected ? "已添加" : "添加"; + if (item) { + item.hidden = Boolean(searchTerm) && !label.toLowerCase().includes(searchTerm); + } + }); +} + +function setMetricCatalogOpen(catalog: MetricCatalogDom, isOpen: boolean): void { + catalog.panel.hidden = !isOpen; + catalog.addButton.setAttribute("aria-expanded", String(isOpen)); + if (!isOpen) { + catalog.searchInput.value = ""; + Object.values(catalog.metricActions).forEach((action) => { + const item = action.closest("[data-plugin-spread-metric-catalog-item]") as + | HTMLElement + | null; + if (item) { + item.hidden = false; + } + }); + } +} + +function syncSpreadMetricRuleSecondaryControls(rule: SpreadMetricRuleDom): void { + const isNarrowViewport = + (rule.root.ownerDocument.defaultView?.innerWidth ?? Number.POSITIVE_INFINITY) <= 720; + const isOpen = rule.root.dataset.pluginSpreadRuleDetails === "open"; + rule.secondaryControls.hidden = isNarrowViewport && !isOpen; + rule.detailsButton.hidden = !isNarrowViewport; + rule.detailsButton.setAttribute("aria-expanded", String(isOpen)); +} + function readSpreadMetricConfig(rule: SpreadMetricRuleDom): SpreadInfoConfig { const type = rule.typeSelect.value === "2" ? 2 : 1; return { @@ -850,23 +1194,115 @@ function applyToolbarGroupStyles(group: HTMLElement): void { group.style.flexWrap = "nowrap"; } -function applySpreadMetricSelectorStyles(selector: HTMLElement): void { - selector.style.display = "flex"; - selector.style.alignItems = "center"; - selector.style.gap = "12px"; - selector.style.flex = "0 0 auto"; - selector.style.whiteSpace = "nowrap"; +function applyToolbarActionGroupStyles(group: HTMLElement): void { + group.style.display = "flex"; + group.style.alignItems = "center"; + group.style.gap = "8px"; + group.style.flex = "0 0 auto"; + group.style.flexWrap = "nowrap"; } -function applySpreadMetricOptionStyles(option: HTMLElement): void { - option.style.display = "inline-flex"; - option.style.alignItems = "center"; - option.style.gap = "5px"; - option.style.height = "32px"; - option.style.color = "#344054"; - option.style.fontSize = "12px"; - option.style.fontWeight = "700"; - option.style.whiteSpace = "nowrap"; +function applyToolbarActionDividerStyles(divider: HTMLElement): void { + divider.style.width = "1px"; + divider.style.height = "20px"; + divider.style.background = "#d0d5dd"; + divider.style.flex = "0 0 auto"; +} + +function applyMetricCatalogStyles(catalog: HTMLElement): void { + catalog.style.position = "relative"; + catalog.style.display = "inline-flex"; + catalog.style.alignItems = "center"; + catalog.style.gap = "8px"; + catalog.style.flex = "0 0 auto"; +} + +function applyMetricCatalogTriggerStyles(button: HTMLButtonElement): void { + button.style.height = "32px"; + button.style.padding = "0 10px"; + button.style.border = "1px solid #94a3b8"; + button.style.borderRadius = "6px"; + button.style.background = "#ffffff"; + button.style.color = "#334155"; + button.style.fontSize = "12px"; + button.style.fontWeight = "700"; + button.style.whiteSpace = "nowrap"; +} + +function applyMetricCatalogCountStyles(count: HTMLElement): void { + count.style.color = "#64748b"; + count.style.fontSize = "12px"; + count.style.fontWeight = "700"; + count.style.whiteSpace = "nowrap"; +} + +function applyMetricCatalogPanelStyles(panel: HTMLElement): void { + panel.style.position = "absolute"; + panel.style.top = "calc(100% + 6px)"; + panel.style.left = "0"; + panel.style.zIndex = "4"; + panel.style.width = "260px"; + panel.style.padding = "10px"; + panel.style.border = "1px solid #cbd5e1"; + panel.style.borderRadius = "6px"; + panel.style.background = "#ffffff"; + panel.style.boxShadow = "0 10px 20px rgba(15, 23, 42, 0.14)"; +} + +function applyMetricCatalogHeaderStyles(header: HTMLElement): void { + header.style.display = "flex"; + header.style.alignItems = "center"; + header.style.gap = "6px"; +} + +function applyMetricCatalogSearchStyles(input: HTMLInputElement): void { + input.style.width = "100%"; + input.style.height = "30px"; + input.style.padding = "0 8px"; + input.style.border = "1px solid #cbd5e1"; + input.style.borderRadius = "5px"; + input.style.boxSizing = "border-box"; +} + +function applyMetricCatalogCloseButtonStyles(button: HTMLButtonElement): void { + button.style.width = "30px"; + button.style.height = "30px"; + button.style.padding = "0"; + button.style.border = "0"; + button.style.background = "transparent"; + button.style.color = "#64748b"; + button.style.fontSize = "20px"; +} + +function applyMetricCatalogGroupStyles(group: HTMLElement): void { + group.style.display = "flex"; + group.style.flexDirection = "column"; + group.style.gap = "4px"; + group.style.marginTop = "10px"; +} + +function applyMetricCatalogGroupTitleStyles(title: HTMLElement): void { + title.style.color = "#64748b"; + title.style.fontSize = "12px"; +} + +function applyMetricCatalogItemStyles(item: HTMLElement): void { + item.style.display = "flex"; + item.style.alignItems = "center"; + item.style.justifyContent = "space-between"; + item.style.minHeight = "32px"; + item.style.color = "#334155"; + item.style.fontSize = "13px"; +} + +function applyMetricCatalogActionStyles(button: HTMLButtonElement): void { + button.style.minWidth = "56px"; + button.style.height = "28px"; + button.style.border = "1px solid #94a3b8"; + button.style.borderRadius = "5px"; + button.style.background = "#ffffff"; + button.style.color = "#334155"; + button.style.fontSize = "12px"; } function applySpreadRulesGroupStyles(group: HTMLElement): void { @@ -884,9 +1320,46 @@ function applySpreadMetricRuleStyles(rule: HTMLElement): void { rule.style.display = "flex"; rule.style.alignItems = "center"; rule.style.gap = "7px"; - rule.style.minWidth = "max-content"; + rule.style.flexWrap = "wrap"; + rule.style.minWidth = "0"; rule.style.minHeight = "32px"; - rule.style.whiteSpace = "nowrap"; +} + +function applySpreadMetricRulePrimaryStyles(primary: HTMLElement): void { + primary.style.display = "flex"; + primary.style.alignItems = "center"; + primary.style.gap = "7px"; + primary.style.minWidth = "0"; + primary.style.whiteSpace = "nowrap"; +} + +function applySpreadMetricRuleSecondaryStyles(secondary: HTMLElement): void { + secondary.style.display = "flex"; + secondary.style.alignItems = "center"; + secondary.style.gap = "7px"; + secondary.style.minWidth = "0"; + secondary.style.whiteSpace = "nowrap"; +} + +function applySpreadMetricDetailsButtonStyles(button: HTMLButtonElement): void { + button.style.height = "28px"; + button.style.padding = "0 8px"; + button.style.border = "1px solid #cbd5e1"; + button.style.borderRadius = "5px"; + button.style.background = "#ffffff"; + button.style.color = "#475569"; + button.style.fontSize = "12px"; +} + +function applySpreadMetricRemoveButtonStyles(button: HTMLButtonElement): void { + button.style.width = "28px"; + button.style.height = "28px"; + button.style.padding = "0"; + button.style.border = "0"; + button.style.borderRadius = "4px"; + button.style.background = "transparent"; + button.style.color = "#b91c1c"; + button.style.fontSize = "20px"; } function applySpreadMetricRuleLabelStyles(label: HTMLElement): void { @@ -953,16 +1426,18 @@ function applyNativeControlStyles( controls.batchSubmitButton.className = nativeButton.className; } - [ + const secondaryButtons = [ controls.exportButton, controls.audienceProfileExportButton, controls.audienceProfileByIdExportButton, - controls.audienceProfileFieldButton, - controls.batchSubmitButton - ].forEach((button) => { - applyPrimaryButtonStyles(button); + controls.audienceProfileFieldButton + ]; + secondaryButtons.forEach((button) => { + applySecondaryButtonStyles(button); button.style.whiteSpace = "nowrap"; }); + applyPrimaryButtonStyles(controls.batchSubmitButton); + controls.batchSubmitButton.style.whiteSpace = "nowrap"; const ruleControls = Object.values(controls.spreadMetricRules).flatMap( (rule) => [ @@ -1037,6 +1512,19 @@ function applyPrimaryButtonStyles( "background-color 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease"; } +function applySecondaryButtonStyles(button: HTMLButtonElement): void { + button.style.backgroundColor = "#ffffff"; + button.style.border = "1px solid #cbd5e1"; + button.style.borderRadius = "8px"; + button.style.color = "#344054"; + 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 applySpreadThresholdControlStyles(control: HTMLElement): void { control.style.display = "grid"; control.style.gridTemplateColumns = "auto auto 1fr auto"; @@ -1072,7 +1560,11 @@ function ensurePluginActionButtonTheme(document: Document): void { [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-audience-profile-fields="button"]:hover:not(:disabled), + [data-plugin-audience-profile-fields="button"]:hover:not(:disabled) { + background-color: #f8fafc !important; + border-color: #94a3b8 !important; + } + [data-plugin-batch-submit="button"]:hover:not(:disabled) { background-color: #6d1627 !important; border-color: #6d1627 !important; @@ -1081,7 +1573,12 @@ function ensurePluginActionButtonTheme(document: Document): void { [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-audience-profile-fields="button"]:active:not(:disabled), + [data-plugin-audience-profile-fields="button"]:active:not(:disabled) { + background-color: #f1f5f9 !important; + border-color: #94a3b8 !important; + transform: translateY(1px); + } + [data-plugin-batch-submit="button"]:active:not(:disabled) { background-color: #58111f !important; border-color: #58111f !important; @@ -1091,7 +1588,11 @@ function ensurePluginActionButtonTheme(document: Document): void { [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-audience-profile-fields="button"]:focus-visible, + [data-plugin-audience-profile-fields="button"]:focus-visible { + outline: none !important; + box-shadow: 0 0 0 3px rgba(52, 64, 84, 0.18) !important; + } + [data-plugin-batch-submit="button"]:focus-visible { outline: none !important; box-shadow: 0 0 0 3px rgba(127, 29, 45, 0.2) !important; @@ -1100,7 +1601,16 @@ function ensurePluginActionButtonTheme(document: Document): void { [data-plugin-export="button"]:disabled, [data-plugin-export-audience-profile="button"]:disabled, [data-plugin-export-audience-profile-by-id="button"]:disabled, - [data-plugin-audience-profile-fields="button"]:disabled, + [data-plugin-audience-profile-fields="button"]:disabled { + background-color: #f8fafc !important; + border-color: #cbd5e1 !important; + color: #98a2b3 !important; + cursor: not-allowed !important; + opacity: 1 !important; + transform: none !important; + box-shadow: none !important; + } + [data-plugin-batch-submit="button"]:disabled { background-color: #c89ca4 !important; border-color: #c89ca4 !important; @@ -1128,6 +1638,54 @@ function ensurePluginActionButtonTheme(document: Document): void { border-color: #dc2626 !important; box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.12) !important; } + + @media (max-width: 720px) { + [data-plugin-toolbar='root'], + [data-plugin-toolbar-row='thresholds'] { + width: 100% !important; + } + + [data-plugin-toolbar-row='thresholds'] { + flex-wrap: wrap !important; + align-items: flex-start !important; + } + + [data-plugin-toolbar-group='data'], + [data-plugin-toolbar-action-group] { + flex-wrap: wrap !important; + } + + [data-plugin-toolbar-action-divider] { + display: none !important; + } + + [data-plugin-spread-metric-catalog='root'] { + width: 100% !important; + } + + [data-plugin-spread-metric-catalog-panel='root'] { + position: static !important; + width: 100% !important; + margin-top: 6px !important; + box-sizing: border-box !important; + } + + [data-plugin-spread-rules='root'] { + width: 100% !important; + overflow: visible !important; + } + + [data-plugin-spread-rule] { + align-items: stretch !important; + flex-direction: column !important; + } + + [data-plugin-spread-rule-primary], + [data-plugin-spread-rule-secondary] { + width: 100% !important; + flex-wrap: wrap !important; + } + } `; document.head.appendChild(style); } diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index a48ee2a..5e9082e 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -365,6 +365,9 @@ describe("market-content-entry", () => { const audienceProfileFieldButton = document.querySelector( '[data-plugin-audience-profile-fields="button"]' ) as HTMLButtonElement | null; + const exportButton = document.querySelector( + '[data-plugin-export="button"]' + ) as HTMLButtonElement | null; expect(audienceProfileExportButton?.textContent).toBe("导出选中达人数据"); expect(audienceProfileExportButton?.title).toBe( "仅导出已勾选达人,包含内容数据、效果预估、画像等维度" @@ -379,11 +382,29 @@ describe("market-content-entry", () => { ); expect(batchSubmitButton?.title).toBe("将当前选中的达人提交到后续业务批次"); expect(batchSubmitButton?.style.backgroundColor).toBe("rgb(127, 29, 45)"); - expect(audienceProfileExportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)"); - expect(audienceProfileByIdExportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)"); expect(batchSubmitButton?.style.color).toBe("rgb(255, 255, 255)"); - expect(audienceProfileExportButton?.style.color).toBe("rgb(255, 255, 255)"); - expect(audienceProfileByIdExportButton?.style.color).toBe("rgb(255, 255, 255)"); + expect([ + exportButton?.style.backgroundColor, + audienceProfileExportButton?.style.backgroundColor, + audienceProfileByIdExportButton?.style.backgroundColor, + audienceProfileFieldButton?.style.backgroundColor + ]).toEqual([ + "rgb(255, 255, 255)", + "rgb(255, 255, 255)", + "rgb(255, 255, 255)", + "rgb(255, 255, 255)" + ]); + expect([ + exportButton?.style.color, + audienceProfileExportButton?.style.color, + audienceProfileByIdExportButton?.style.color, + audienceProfileFieldButton?.style.color + ]).toEqual([ + "rgb(52, 64, 84)", + "rgb(52, 64, 84)", + "rgb(52, 64, 84)", + "rgb(52, 64, 84)" + ]); }); test("renders the approved compact toolbar layout from the preview", async () => { @@ -416,8 +437,20 @@ describe("market-content-entry", () => { const dataGroup = document.querySelector( '[data-plugin-toolbar-group="data"]' ) as HTMLElement | null; - const metricSelector = document.querySelector( - '[data-plugin-spread-metrics="root"]' + const csvExportGroup = document.querySelector( + '[data-plugin-toolbar-action-group="csv-export"]' + ) as HTMLElement | null; + const selectedAudienceExportGroup = document.querySelector( + '[data-plugin-toolbar-action-group="selected-audience-export"]' + ) as HTMLElement | null; + const idExportGroup = document.querySelector( + '[data-plugin-toolbar-action-group="id-export"]' + ) as HTMLElement | null; + const batchSubmitGroup = document.querySelector( + '[data-plugin-toolbar-action-group="batch-submit"]' + ) as HTMLElement | null; + const metricCatalog = document.querySelector( + '[data-plugin-spread-metric-catalog="root"]' ) as HTMLElement | null; const rulesGroup = document.querySelector( '[data-plugin-spread-rules="root"]' @@ -428,15 +461,30 @@ describe("market-content-entry", () => { const titles = Array.from( document.querySelectorAll("[data-plugin-toolbar-title]") ) as HTMLElement[]; - const buttons = Array.from( - document.querySelectorAll("[data-plugin-toolbar-group='data'] button") - ) as HTMLButtonElement[]; + const exportButton = document.querySelector( + '[data-plugin-export="button"]' + ) as HTMLButtonElement | null; + const exportRangeSelect = document.querySelector( + '[data-plugin-export-range="select"]' + ) as HTMLSelectElement | null; + const exportCustomPagesInput = document.querySelector( + '[data-plugin-export-custom-pages="input"]' + ) as HTMLInputElement | null; + const audienceProfileExportButton = document.querySelector( + '[data-plugin-export-audience-profile="button"]' + ) as HTMLButtonElement | null; + const audienceProfileByIdExportButton = document.querySelector( + '[data-plugin-export-audience-profile-by-id="button"]' + ) as HTMLButtonElement | null; + const audienceProfileFieldButton = document.querySelector( + '[data-plugin-audience-profile-fields="button"]' + ) as HTMLButtonElement | null; + const batchSubmitButton = document.querySelector( + '[data-plugin-batch-submit="button"]' + ) as HTMLButtonElement | null; const operators = Array.from( document.querySelectorAll("[data-plugin-spread-threshold-operator]") ).map((element) => element.textContent); - const metricInputs = Array.from( - document.querySelectorAll("[data-plugin-spread-metric]") - ) as HTMLInputElement[]; const ruleRows = Array.from( document.querySelectorAll("[data-plugin-spread-rule]") ) as HTMLElement[]; @@ -451,8 +499,27 @@ describe("market-content-entry", () => { expect(thresholdRow?.style.flexWrap).toBe("nowrap"); expect(thresholdRow?.style.alignItems).toBe("center"); expect(dataGroup?.parentElement).toBe(primaryRow); + expect(csvExportGroup?.parentElement).toBe(dataGroup); + expect(selectedAudienceExportGroup?.parentElement).toBe(dataGroup); + expect(idExportGroup?.parentElement).toBe(dataGroup); + expect(batchSubmitGroup?.parentElement).toBe(dataGroup); + expect(Array.from(csvExportGroup?.children ?? [])).toEqual([ + exportButton, + exportRangeSelect, + exportCustomPagesInput + ]); + expect(Array.from(selectedAudienceExportGroup?.children ?? [])).toEqual([ + audienceProfileExportButton, + audienceProfileFieldButton + ]); + expect(Array.from(idExportGroup?.children ?? [])).toEqual([ + audienceProfileByIdExportButton + ]); + expect(Array.from(batchSubmitGroup?.children ?? [])).toEqual([ + batchSubmitButton + ]); expect(statusText?.parentElement).toBe(primaryRow); - expect(metricSelector?.parentElement).toBe(thresholdRow); + expect(metricCatalog?.parentElement).toBe(thresholdRow); expect(rulesGroup?.parentElement).toBe(thresholdRow); expect(rulesGroup?.style.flexDirection).toBe("column"); expect(primaryRow?.style.justifyContent).toBe("flex-start"); @@ -462,8 +529,20 @@ describe("market-content-entry", () => { ]); expect(titles[0]?.style.background).toBe("rgb(238, 245, 255)"); expect(operators).toEqual(["≥", "≥"]); - expect(metricInputs.map((input) => input.checked)).toEqual([false, false]); - expect(ruleRows.map((row) => row.hidden)).toEqual([true, true]); + expect( + document.querySelector('[data-plugin-spread-metric-catalog-trigger="button"]') + ?.textContent + ).toBe("添加筛选指标"); + expect( + document.querySelector('[data-plugin-spread-metric-selected-count="text"]') + ?.textContent + ).toBe("已选 1 项"); + expect( + (document.querySelector( + '[data-plugin-spread-metric-catalog-panel="root"]' + ) as HTMLElement | null)?.hidden + ).toBe(true); + expect(ruleRows.map((row) => row.hidden)).toEqual([false, true]); expect(thresholdInputs.map((input) => input.placeholder)).toEqual([ "", "" @@ -472,20 +551,45 @@ describe("market-content-entry", () => { "0.1", "0.1" ]); - expect(buttons.map((button) => button.textContent)).toEqual([ + expect([ + exportButton?.textContent, + audienceProfileExportButton?.textContent, + audienceProfileByIdExportButton?.textContent, + audienceProfileFieldButton?.textContent, + batchSubmitButton?.textContent + ]).toEqual([ "导出CSV", "导出选中达人数据", "按星图ID导出", "选择字段", "提交批次" ]); - expect(buttons.map((button) => button.style.backgroundColor)).toEqual([ - "rgb(127, 29, 45)", - "rgb(127, 29, 45)", - "rgb(127, 29, 45)", - "rgb(127, 29, 45)", + expect([ + exportButton?.style.backgroundColor, + audienceProfileExportButton?.style.backgroundColor, + audienceProfileByIdExportButton?.style.backgroundColor, + audienceProfileFieldButton?.style.backgroundColor, + batchSubmitButton?.style.backgroundColor + ]).toEqual([ + "rgb(255, 255, 255)", + "rgb(255, 255, 255)", + "rgb(255, 255, 255)", + "rgb(255, 255, 255)", "rgb(127, 29, 45)" ]); + expect([ + exportButton?.style.color, + audienceProfileExportButton?.style.color, + audienceProfileByIdExportButton?.style.color, + audienceProfileFieldButton?.style.color, + batchSubmitButton?.style.color + ]).toEqual([ + "rgb(52, 64, 84)", + "rgb(52, 64, 84)", + "rgb(52, 64, 84)", + "rgb(52, 64, 84)", + "rgb(255, 255, 255)" + ]); }); test("remounts the plugin action bar when the native market action row appears later", async () => { @@ -1276,7 +1380,143 @@ describe("market-content-entry", () => { expect(customPagesInput?.hidden).toBe(false); }); - test("toolbar exposes independent metric rules and applies personal-video constraints per rule", async () => { + test("removes the toolbar resize listener after its root is detached", async () => { + document.body.innerHTML = buildMarketFixture(); + const addEventListener = vi.spyOn(window, "addEventListener"); + const removeEventListener = vi.spyOn(window, "removeEventListener"); + + try { + const { ensurePluginToolbar, isPluginToolbarMounted } = await import( + "../src/content/market/plugin-toolbar" + ); + const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers()); + const resizeHandler = addEventListener.mock.calls.find( + ([eventName]) => eventName === "resize" + )?.[1]; + + toolbar.root.remove(); + + expect(isPluginToolbarMounted(toolbar.root, document)).toBe(false); + expect(removeEventListener).toHaveBeenCalledWith("resize", resizeHandler); + } finally { + addEventListener.mockRestore(); + removeEventListener.mockRestore(); + } + }); + + test("moves the toolbar resize listener when its root is adopted by another window", async () => { + document.body.innerHTML = buildMarketFixture(); + const addEventListener = vi.spyOn(window, "addEventListener"); + const removeEventListener = vi.spyOn(window, "removeEventListener"); + const alternateAddEventListener = vi.fn(); + const alternateRemoveEventListener = vi.fn(); + const alternateWindow = { + addEventListener: alternateAddEventListener, + removeEventListener: alternateRemoveEventListener + } as unknown as Window; + const alternateDocument = { + createTreeWalker: document.createTreeWalker.bind(document), + defaultView: alternateWindow + } as unknown as Document; + + try { + const { ensurePluginToolbar, isPluginToolbarMounted } = await import( + "../src/content/market/plugin-toolbar" + ); + const handlers = createNoopToolbarHandlers(); + const toolbar = ensurePluginToolbar(document, handlers); + const resizeHandler = addEventListener.mock.calls.find( + ([eventName]) => eventName === "resize" + )?.[1]; + const originalOwnerDocument = toolbar.root.ownerDocument; + + Object.defineProperty(toolbar.root, "ownerDocument", { + configurable: true, + value: alternateDocument + }); + const reusedToolbar = ensurePluginToolbar(document, handlers); + + expect(reusedToolbar.root).toBe(toolbar.root); + expect(removeEventListener).toHaveBeenCalledWith("resize", resizeHandler); + expect(alternateAddEventListener).toHaveBeenCalledWith("resize", resizeHandler); + + Object.defineProperty(toolbar.root, "ownerDocument", { + configurable: true, + value: originalOwnerDocument + }); + toolbar.root.remove(); + isPluginToolbarMounted(toolbar.root, document); + } finally { + addEventListener.mockRestore(); + removeEventListener.mockRestore(); + } + }); + + test("removes the toolbar resize listener when its root has no owner window", async () => { + document.body.innerHTML = buildMarketFixture(); + const addEventListener = vi.spyOn(window, "addEventListener"); + const removeEventListener = vi.spyOn(window, "removeEventListener"); + let root: HTMLElement | null = null; + let originalOwnerDocument: Document | null = null; + let isPluginToolbarMounted: + | ((toolbarRoot: HTMLElement, toolbarDocument: Document) => boolean) + | undefined; + + try { + const toolbarModule = await import("../src/content/market/plugin-toolbar"); + isPluginToolbarMounted = toolbarModule.isPluginToolbarMounted; + const handlers = createNoopToolbarHandlers(); + const toolbar = toolbarModule.ensurePluginToolbar(document, handlers); + root = toolbar.root; + originalOwnerDocument = root.ownerDocument; + const resizeHandler = addEventListener.mock.calls.find( + ([eventName]) => eventName === "resize" + )?.[1]; + + Object.defineProperty(root, "ownerDocument", { + configurable: true, + value: { + createTreeWalker: document.createTreeWalker.bind(document), + defaultView: null + } as unknown as Document + }); + toolbarModule.ensurePluginToolbar(document, handlers); + + expect(removeEventListener).toHaveBeenCalledWith("resize", resizeHandler); + expect(addEventListener).toHaveBeenCalledTimes(1); + + Object.defineProperty(root, "ownerDocument", { + configurable: true, + value: originalOwnerDocument + }); + toolbarModule.ensurePluginToolbar(document, handlers); + const resizeCalls = addEventListener.mock.calls.filter( + ([eventName]) => eventName === "resize" + ); + expect(resizeCalls).toHaveLength(2); + expect(resizeCalls[1]).toEqual(["resize", expect.any(Function)]); + + toolbarModule.ensurePluginToolbar(document, handlers); + expect( + addEventListener.mock.calls.filter( + ([eventName]) => eventName === "resize" + ) + ).toHaveLength(2); + } finally { + if (root && originalOwnerDocument) { + Object.defineProperty(root, "ownerDocument", { + configurable: true, + value: originalOwnerDocument + }); + root.remove(); + isPluginToolbarMounted?.(root, document); + } + addEventListener.mockRestore(); + removeEventListener.mockRestore(); + } + }); + + test("catalog starts with the default finish-rate rule and adds independent metrics", async () => { document.body.innerHTML = buildMarketFixture(); const { createMarketController } = await import("../src/content/market/index"); @@ -1291,12 +1531,12 @@ describe("market-content-entry", () => { await controller.ready; - const finishToggle = document.querySelector( - '[data-plugin-spread-metric="finishRate"]' - ) as HTMLInputElement | null; - const interactionToggle = document.querySelector( - '[data-plugin-spread-metric="interactionRate"]' - ) as HTMLInputElement | null; + const catalogTrigger = document.querySelector( + '[data-plugin-spread-metric-catalog-trigger="button"]' + ) as HTMLButtonElement | null; + const catalogPanel = document.querySelector( + '[data-plugin-spread-metric-catalog-panel="root"]' + ) as HTMLElement | null; const finishRule = document.querySelector( '[data-plugin-spread-rule="finishRate"]' ) as HTMLElement | null; @@ -1307,14 +1547,22 @@ describe("market-content-entry", () => { '[data-plugin-spread-threshold="finishRate"]' ) as HTMLInputElement | null; - expect(finishToggle?.checked).toBe(false); - expect(interactionToggle?.checked).toBe(false); - expect(finishRule?.hidden).toBe(true); + expect(catalogPanel?.hidden).toBe(true); + expect(finishRule?.hidden).toBe(false); expect(interactionRule?.hidden).toBe(true); expect(finishRateInput?.placeholder).toBe(""); + expect( + document.querySelector('[data-plugin-spread-metric-selected-count="text"]') + ?.textContent + ).toBe("已选 1 项"); + expect(readSpreadRuleSelect("finishRate", "type").value).toBe("2"); + expect(readSpreadRuleSelect("finishRate", "onlyAssign").value).toBe("true"); + expect(readSpreadRuleSelect("finishRate", "flowType").value).toBe("0"); + expect(readSpreadRuleSelect("finishRate", "range").value).toBe("2"); - enableSpreadMetric("finishRate"); - enableSpreadMetric("interactionRate"); + catalogTrigger?.click(); + expect(catalogPanel?.hidden).toBe(false); + click('[data-plugin-spread-metric-catalog-action="interactionRate"]'); setSpreadRuleSelect("finishRate", "type", "2"); setSpreadRuleSelect("finishRate", "onlyAssign", "true"); setSpreadRuleSelect("finishRate", "flowType", "1"); @@ -1352,18 +1600,23 @@ describe("market-content-entry", () => { expect(interactionFlowTypeSelect.disabled).toBe(true); setInputValue('[data-plugin-spread-threshold="finishRate"]', "30"); - if (!finishToggle) { - throw new Error("Missing finish-rate metric toggle"); - } - finishToggle.checked = false; - dispatchChange('[data-plugin-spread-metric="finishRate"]'); - finishToggle.checked = true; - dispatchChange('[data-plugin-spread-metric="finishRate"]'); + click('[data-plugin-spread-rule-remove="finishRate"]'); + expect(finishRule?.hidden).toBe(true); + catalogTrigger?.click(); + click('[data-plugin-spread-metric-catalog-action="finishRate"]'); expect(finishRateInput?.value).toBe(""); expectSelectValue( '[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="type"]', - "1" + "2" + ); + expectSelectValue( + '[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="onlyAssign"]', + "true" + ); + expectSelectValue( + '[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="flowType"]', + "0" ); expectSelectValue( '[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="range"]', @@ -1371,6 +1624,128 @@ describe("market-content-entry", () => { ); }); + test("catalog disables duplicate metrics and restores them after deletion", async () => { + document.body.innerHTML = buildMarketFixture(); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), + window + })); + await controller.ready; + + const finishAction = document.querySelector( + '[data-plugin-spread-metric-catalog-action="finishRate"]' + ) as HTMLButtonElement | null; + expect(finishAction?.disabled).toBe(true); + expect(finishAction?.textContent).toBe("已添加"); + + click('[data-plugin-spread-rule-remove="finishRate"]'); + expect(finishAction?.disabled).toBe(false); + expect(finishAction?.textContent).toBe("添加"); + }); + + test("catalog search resets its visible metric list after closing", async () => { + document.body.innerHTML = buildMarketFixture(); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), + window + })); + await controller.ready; + + click('[data-plugin-spread-metric-catalog-trigger="button"]'); + const searchInput = document.querySelector( + '[data-plugin-spread-metric-catalog-search="input"]' + ) as HTMLInputElement | null; + const interactionItem = document.querySelector( + '[data-plugin-spread-metric-catalog-item="interactionRate"]' + ) as HTMLElement | null; + if (!searchInput) { + throw new Error("Missing metric catalog search input"); + } + searchInput.value = "完播"; + searchInput.dispatchEvent(new Event("input")); + expect(interactionItem?.hidden).toBe(true); + + click('[data-plugin-spread-metric-catalog-close="button"]'); + click('[data-plugin-spread-metric-catalog-trigger="button"]'); + expect(interactionItem?.hidden).toBe(false); + }); + + test("busy toolbar disables catalog and selected rule actions", async () => { + document.body.innerHTML = buildMarketFixture(); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), + window + })); + await controller.ready; + + const { ensurePluginToolbar, setToolbarBusyState } = await import( + "../src/content/market/plugin-toolbar" + ); + const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers()); + setToolbarBusyState(toolbar, true); + + expect( + (document.querySelector( + '[data-plugin-spread-metric-catalog-trigger="button"]' + ) as HTMLButtonElement | null)?.disabled + ).toBe(true); + expect( + (document.querySelector( + '[data-plugin-spread-metric-catalog-action="interactionRate"]' + ) as HTMLButtonElement | null)?.disabled + ).toBe(true); + expect( + (document.querySelector( + '[data-plugin-spread-rule-remove="finishRate"]' + ) as HTMLButtonElement | null)?.disabled + ).toBe(true); + }); + + test("uses a collapsible catalog drawer and secondary rule controls on narrow screens", async () => { + const originalInnerWidth = window.innerWidth; + Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 }); + document.body.innerHTML = buildMarketFixture(); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), + window + })); + await controller.ready; + window.dispatchEvent(new Event("resize")); + + click('[data-plugin-spread-metric-catalog-trigger="button"]'); + const catalogPanel = document.querySelector( + '[data-plugin-spread-metric-catalog-panel="root"]' + ) as HTMLElement | null; + expect(catalogPanel?.hidden).toBe(false); + const secondaryControls = document.querySelector( + '[data-plugin-spread-rule-secondary="finishRate"]' + ) as HTMLElement | null; + expect(secondaryControls?.hidden).toBe(true); + click('[data-plugin-spread-rule-details="finishRate"]'); + expect(secondaryControls?.hidden).toBe(false); + click('[data-plugin-spread-metric-catalog-trigger="button"]'); + click('[data-plugin-spread-metric-catalog-close="button"]'); + expect(catalogPanel?.hidden).toBe(true); + + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: originalInnerWidth + }); + window.dispatchEvent(new Event("resize")); + }); + test("reads selected spread metrics as independent validated rules", async () => { document.body.innerHTML = buildMarketFixture(); @@ -1390,29 +1765,34 @@ describe("market-content-entry", () => { ); const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers()); - expect(readToolbarSpreadFilter(toolbar)).toEqual({ - filter: { rules: [] } - }); - - enableSpreadMetric("finishRate"); expect(readToolbarSpreadFilter(toolbar)).toEqual({ error: "请输入有效的完播率筛选阈值" }); setInputValue('[data-plugin-spread-threshold="finishRate"]', "30"); - enableSpreadMetric("interactionRate"); + click('[data-plugin-spread-metric-catalog-trigger="button"]'); + click('[data-plugin-spread-metric-catalog-action="interactionRate"]'); + expect(readSpreadRuleSelect("interactionRate", "type").value).toBe("2"); + expect(readSpreadRuleSelect("interactionRate", "onlyAssign").value).toBe("true"); + expect(readSpreadRuleSelect("interactionRate", "flowType").value).toBe("0"); + expect(readSpreadRuleSelect("interactionRate", "range").value).toBe("2"); + expect( + (document.querySelector( + '[data-plugin-spread-threshold="interactionRate"]' + ) as HTMLInputElement | null)?.value + ).toBe(""); + expect(readToolbarSpreadFilter(toolbar)).toEqual({ + error: "请输入有效的互动率筛选阈值" + }); + setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5"); - setSpreadRuleSelect("finishRate", "type", "2"); - setSpreadRuleSelect("finishRate", "onlyAssign", "true"); - setSpreadRuleSelect("finishRate", "flowType", "1"); - setSpreadRuleSelect("interactionRate", "range", "3"); expect(readToolbarSpreadFilter(toolbar)).toEqual({ filter: { rules: [ { config: { - flowType: 1, + flowType: 0, onlyAssign: true, range: 2, type: 2 @@ -1423,9 +1803,9 @@ describe("market-content-entry", () => { { config: { flowType: 0, - onlyAssign: false, - range: 3, - type: 1 + onlyAssign: true, + range: 2, + type: 2 }, metric: "interactionRate", threshold: 5 @@ -1467,6 +1847,7 @@ describe("market-content-entry", () => { await flush(); setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); + removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 80, 50); @@ -1525,6 +1906,7 @@ describe("market-content-entry", () => { await controller.ready; setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); + removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 80, 50); @@ -1605,6 +1987,7 @@ describe("market-content-entry", () => { setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5"); setSpreadRuleSelect("finishRate", "type", "2"); setSpreadRuleSelect("finishRate", "onlyAssign", "true"); + setSpreadRuleSelect("interactionRate", "type", "1"); setSpreadRuleSelect("interactionRate", "range", "3"); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 80, 50); @@ -1672,9 +2055,9 @@ describe("market-content-entry", () => { expect(loadSpreadFilterMetrics).toHaveBeenCalledTimes(1); expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", { flowType: 0, - onlyAssign: false, + onlyAssign: true, range: 2, - type: 1 + type: 2 }); expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ "a" @@ -1726,9 +2109,9 @@ describe("market-content-entry", () => { expect(loadSpreadFilterMetrics).toHaveBeenCalledTimes(1); expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-b", { flowType: 0, - onlyAssign: false, + onlyAssign: true, range: 2, - type: 1 + type: 2 }); expect(buildCsv.mock.calls[0][0]).toEqual([]); }); @@ -1763,6 +2146,7 @@ describe("market-content-entry", () => { })); await controller.ready; + removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 120, 100); @@ -1846,6 +2230,7 @@ describe("market-content-entry", () => { })); await controller.ready; + removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 120, 100); @@ -1893,6 +2278,7 @@ describe("market-content-entry", () => { })); await controller.ready; + removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 120, 100); @@ -1938,6 +2324,7 @@ describe("market-content-entry", () => { })); await controller.ready; + removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 120, 100); @@ -1998,6 +2385,7 @@ describe("market-content-entry", () => { await controller.ready; setSelectValue('[data-plugin-export-range="select"]', "all"); dispatchChange('[data-plugin-export-range="select"]'); + removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 120, 100); @@ -2047,6 +2435,7 @@ describe("market-content-entry", () => { setSelectValue('[data-plugin-export-range="select"]', "all"); dispatchChange('[data-plugin-export-range="select"]'); + removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); expectButtonDisabled('[data-plugin-batch-submit="button"]', true); @@ -2126,6 +2515,7 @@ describe("market-content-entry", () => { setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); + removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 40, 50); @@ -2549,6 +2939,7 @@ describe("market-content-entry", () => { await controller.ready; clickSelectionCheckboxForAuthor("111"); + removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); for (let attempt = 0; attempt < 40; attempt += 1) { if ( @@ -2623,6 +3014,7 @@ describe("market-content-entry", () => { setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); + removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 40, 50); @@ -2658,6 +3050,7 @@ describe("market-content-entry", () => { setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); + removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); await waitForMockCall(submitBatch, 40, 50); @@ -2733,6 +3126,7 @@ describe("market-content-entry", () => { setInputValue('[data-plugin-spread-threshold="finishRate"]', "30"); setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5"); setSpreadRuleSelect("finishRate", "type", "2"); + setSpreadRuleSelect("interactionRate", "type", "1"); setSpreadRuleSelect("interactionRate", "range", "3"); click('[data-plugin-batch-submit="button"]'); await waitForMockCall(submitBatch, 80, 50); @@ -2765,6 +3159,7 @@ describe("market-content-entry", () => { })); await controller.ready; + removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); expect(submitBatch).not.toHaveBeenCalled(); @@ -2808,6 +3203,7 @@ describe("market-content-entry", () => { })); await controller.ready; + removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); click('[data-plugin-batch-name-confirm="button"]'); await flush(); @@ -2842,6 +3238,7 @@ describe("market-content-entry", () => { })); await controller.ready; + removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); click('[data-plugin-batch-name-cancel="button"]'); await flush(); @@ -2883,6 +3280,7 @@ describe("market-content-entry", () => { setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); + removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); await waitForMockCall(submitBatch, 40, 50); @@ -2969,6 +3367,7 @@ describe("market-content-entry", () => { await controller.ready; clickSelectionCheckboxForAuthor("111"); + removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); for (let attempt = 0; attempt < 40; attempt += 1) { if ( @@ -3088,6 +3487,7 @@ describe("market-content-entry", () => { const rowSelectionCheckbox = readSelectionCheckboxForAuthor("111"); rowSelectionCheckbox.checked = true; + removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); for (let attempt = 0; attempt < 40; attempt += 1) { if ( @@ -3169,6 +3569,7 @@ describe("market-content-entry", () => { setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); + removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); await waitForMockCall(submitBatch, 40, 50); @@ -3257,6 +3658,7 @@ describe("market-content-entry", () => { await controller.ready; + removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); for (let attempt = 0; attempt < 40; attempt += 1) { if ( @@ -3315,6 +3717,7 @@ describe("market-content-entry", () => { })); await controller.ready; + removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); await flush(); @@ -3347,6 +3750,7 @@ describe("market-content-entry", () => { })); await controller.ready; + removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); await flush(); @@ -3391,6 +3795,7 @@ describe("market-content-entry", () => { personalVideoAfterSearchRate: "8% - 9%" }); + removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 40, 50); @@ -3438,6 +3843,7 @@ describe("market-content-entry", () => { personalVideoAfterSearchRate: "0.03% - 0.2%" }); + removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 40, 50); @@ -3516,6 +3922,7 @@ describe("market-content-entry", () => { setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); + removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 120, 50); @@ -3627,6 +4034,7 @@ describe("market-content-entry", () => { setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); + removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 120, 50); @@ -3717,6 +4125,7 @@ describe("market-content-entry", () => { await controller.ready; setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); + removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 120, 50); @@ -3768,6 +4177,7 @@ describe("market-content-entry", () => { await controller.ready; setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); + removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 120, 50); @@ -3846,6 +4256,7 @@ describe("market-content-entry", () => { setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); + removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 120, 50); @@ -3929,6 +4340,7 @@ describe("market-content-entry", () => { await controller.ready; setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); + removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 120, 50); @@ -4045,6 +4457,7 @@ describe("market-content-entry", () => { await controller.ready; setSelectValue('[data-plugin-export-range="select"]', "first-5"); dispatchChange('[data-plugin-export-range="select"]'); + removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 160, 100); @@ -4089,7 +4502,7 @@ describe("market-content-entry", () => { ]) ); }, - 20000 + 30_000 ); test("rehydrates rows when the market list DOM changes", async () => { @@ -5342,6 +5755,10 @@ function click(selector: string) { element.click(); } +function removeDefaultSpreadMetricFilter() { + click('[data-plugin-spread-rule-remove="finishRate"]'); +} + function enableSpreadMetric(metric: "finishRate" | "interactionRate") { const selector = `[data-plugin-spread-metric="${metric}"]`; const input = document.querySelector(selector) as HTMLInputElement | null;