From 53db9b0777a908e540d6e2ea161a7fa1e5e31c14 Mon Sep 17 00:00:00 2001 From: wxs Date: Mon, 27 Jul 2026 17:58:10 +0800 Subject: [PATCH] feat: make metric filters field-driven --- src/content/market/audience-profile-csv.ts | 14 + src/content/market/index.ts | 127 +- src/content/market/metric-filter.ts | 80 + src/content/market/plugin-toolbar.ts | 1604 +++++--------------- src/content/market/types.ts | 12 + tests/market-content-entry.test.ts | 471 ++---- tests/metric-filter.test.ts | 51 + 7 files changed, 783 insertions(+), 1576 deletions(-) create mode 100644 src/content/market/metric-filter.ts create mode 100644 tests/metric-filter.test.ts diff --git a/src/content/market/audience-profile-csv.ts b/src/content/market/audience-profile-csv.ts index 12d7461..cbce62b 100644 --- a/src/content/market/audience-profile-csv.ts +++ b/src/content/market/audience-profile-csv.ts @@ -127,6 +127,20 @@ export function listAudienceProfileCsvHeaders( ]; } +export function buildAudienceProfileFieldValues( + row: AudienceProfileExportRow +): Record { + const columns = deduplicateCsvColumns([ + ...buildMarketCsvColumns([row.record]).map(toMarketColumn), + ...buildBusinessEstimateColumns(), + ...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout)) + ]); + + return Object.fromEntries( + columns.map((column) => [column.header, column.readValue(row)]) + ); +} + export function listAudienceProfileSelectableFieldGroups( marketListHeaders: string[] = [] ): AudienceProfileCsvFieldGroup[] { diff --git a/src/content/market/index.ts b/src/content/market/index.ts index 5e6d67e..4fefa97 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -4,6 +4,7 @@ import { listRateCsvHeaders } from "./csv-exporter"; import { + buildAudienceProfileFieldValues, buildAudienceProfileCsv, listAudienceProfileSelectableFieldGroups, type AudienceProfileCsvOptions @@ -52,16 +53,16 @@ import { applyFilterAndSort } from "./filter-sort-controller"; import { createMarketApiClient } from "./api-client"; import { createExportRangeController } from "./export-range-controller"; import { ensurePluginToolbar, isPluginToolbarMounted } from "./plugin-toolbar"; +import { + listNumericMetricFilterDefinitions, + matchesMetricFilterRule +} from "./metric-filter"; import { createSilentExportController } from "./silent-export-controller"; import { - buildSpreadInfoConfigKey, createSpreadInfoClient, DEFAULT_SPREAD_INFO_CONFIGS, filterUnloadedSpreadInfoConfigs, - matchesSpreadMetricRule, - normalizeSpreadInfoConfig, - selectSpreadInfoConfigsForHeaders, - type MappedSpreadInfoResponse + selectSpreadInfoConfigsForHeaders } from "./spread-info"; import { readToolbarExportTarget, @@ -88,9 +89,8 @@ import type { MarketRecord, MarketRowSnapshot, MarketSortState, + MetricThresholdFilter, SpreadInfoConfig, - SpreadMetricFilterRule, - SpreadThresholdFilter } from "./types"; interface MutationObserverLike { @@ -127,10 +127,6 @@ export interface CreateMarketControllerOptions { target: AudienceProfileRequestTarget ) => Promise; loadAuthorMetrics?: (authorId: string) => Promise; - loadSpreadFilterMetrics?: ( - spreadAuthorId: string, - config: SpreadInfoConfig - ) => Promise; loadSpreadMetrics?: ( spreadAuthorId: string, configs?: SpreadInfoConfig[] @@ -169,9 +165,6 @@ export function createMarketController(options: CreateMarketControllerOptions) { const resultStore = options.resultStore ?? createMarketResultStore(); const loadAuthorMetrics = options.loadAuthorMetrics ?? marketApiClient.loadAuthorAseInfo; - const loadSpreadFilterMetrics = - options.loadSpreadFilterMetrics ?? - spreadInfoClient.loadAuthorSpreadMetricSnapshot; const loadSpreadMetrics = options.loadSpreadMetrics ?? spreadInfoClient.loadAuthorSpreadMetrics; const searchBackendMetrics = @@ -520,7 +513,7 @@ export function createMarketController(options: CreateMarketControllerOptions) { setToolbarBusyState(toolbar, true); try { const hasSelectedAuthors = selectedAuthorIds.size > 0; - const resolvedRecords = await applySpreadThresholdFilter( + const resolvedRecords = await applyMetricThresholdFilter( await exportRecords( exportTarget.target, hasSelectedAuthors ? "提交已选达人中" : "提交中", @@ -566,7 +559,11 @@ export function createMarketController(options: CreateMarketControllerOptions) { } } }; - toolbar = ensurePluginToolbar(options.document, toolbarHandlers); + toolbar = ensurePluginToolbar( + options.document, + toolbarHandlers, + readMetricFilterDefinitions() + ); const ready = (async () => { await runSyncCycle(); @@ -739,7 +736,11 @@ export function createMarketController(options: CreateMarketControllerOptions) { function applyCurrentView(): void { runWithoutMutationSync(() => { - toolbar = ensurePluginToolbar(options.document, toolbarHandlers); + toolbar = ensurePluginToolbar( + options.document, + toolbarHandlers, + readMetricFilterDefinitions() + ); const table = syncMarketTable(options.document); if (!table) { return; @@ -1125,60 +1126,49 @@ export function createMarketController(options: CreateMarketControllerOptions) { return selectedRecords.length > 0 ? selectedRecords : records; } - async function applySpreadThresholdFilter( + async function applyMetricThresholdFilter( records: MarketRecord[], - filter: SpreadThresholdFilter | undefined + filter: MetricThresholdFilter | undefined ): Promise { if (!filter || filter.rules.length === 0) { return records; } - const normalizedRules = filter.rules.map((rule) => ({ - ...rule, - config: normalizeSpreadInfoConfig(rule.config) - })); - const configsByKey = new Map(); - normalizedRules.forEach((rule) => { - configsByKey.set(buildSpreadInfoConfigKey(rule.config), rule.config); + const filterSelection = resolveAudienceProfileExportSelection( + filter.rules.map((rule) => rule.field) + ); + const hydratedRecords = await hydrateExportRecords(records, { + includeBackendMetrics: filterSelection.includeBackendMetrics, + includeRates: filterSelection.includeRates, + spreadInfoConfigs: filterSelection.spreadInfoConfigs }); + const matchedRecords: MarketRecord[] = []; - const matchedAuthorIds = new Set(); - await Promise.all( - records.map(async (record) => { - const spreadAuthorId = record.spreadAuthorId; - if (!spreadAuthorId) { - return; - } + for (let index = 0; index < hydratedRecords.length; index += 1) { + const record = hydratedRecords[index]; + setToolbarExportStatus( + toolbar, + `指标筛选 ${index + 1}/${hydratedRecords.length}...` + ); + const [profiles, businessAbility] = await Promise.all([ + loadAudienceProfileSet(record, filterSelection), + filterSelection.businessAbility + ? loadBusinessAbilitySafe(record) + : Promise.resolve(undefined) + ]); + const values = buildAudienceProfileFieldValues( + finalizeAudienceProfileExportRow({ + businessAbility, + profiles, + record + }) + ); + if (filter.rules.every((rule) => matchesMetricFilterRule(values, rule))) { + matchedRecords.push(record); + } + } - const snapshots = new Map(); - await Promise.all( - Array.from(configsByKey.entries()).map(async ([key, config]) => { - snapshots.set( - key, - await loadSpreadFilterMetrics(spreadAuthorId, config) - ); - }) - ); - - if (matchesAllSpreadMetricRules(normalizedRules, snapshots)) { - matchedAuthorIds.add(record.authorId); - } - }) - ); - - return records.filter((record) => matchedAuthorIds.has(record.authorId)); - } - - function matchesAllSpreadMetricRules( - rules: SpreadMetricFilterRule[], - snapshots: Map - ): boolean { - return rules.every((rule) => - matchesSpreadMetricRule( - snapshots.get(buildSpreadInfoConfigKey(rule.config)) ?? {}, - rule - ) - ); + return matchedRecords; } function filterRecordsBySelectionStrict(records: MarketRecord[]): MarketRecord[] { @@ -1461,6 +1451,13 @@ export function createMarketController(options: CreateMarketControllerOptions) { ); } + function readMetricFilterDefinitions() { + const marketListHeaders = buildBaseColumns( + readCurrentPageRecords(syncMarketTable(options.document)) + ).map((column) => column.header); + return listNumericMetricFilterDefinitions(marketListHeaders); + } + function readAudienceProfileSelectedHeaders(): string[] | undefined { try { const rawValue = options.window.localStorage?.getItem( @@ -2049,7 +2046,11 @@ export function createMarketController(options: CreateMarketControllerOptions) { } async function runSingleSyncCycle(): Promise { - toolbar = ensurePluginToolbar(options.document, toolbarHandlers); + toolbar = ensurePluginToolbar( + options.document, + toolbarHandlers, + readMetricFilterDefinitions() + ); if (isPluginToolbarMounted(toolbar.root, options.document)) { toolbarRemountScheduled = false; } diff --git a/src/content/market/metric-filter.ts b/src/content/market/metric-filter.ts new file mode 100644 index 0000000..1af8e11 --- /dev/null +++ b/src/content/market/metric-filter.ts @@ -0,0 +1,80 @@ +import { + listAudienceProfileSelectableFieldGroups, + type AudienceProfileCsvFieldGroup +} from "./audience-profile-csv"; +import type { MetricFilterRule } from "./types"; + +export interface MetricFilterDefinition { + field: string; + group: string; +} + +const NUMERIC_MARKET_LIST_FIELDS = new Set([ + "连接用户数", + "粉丝数", + "预期CPM", + "预期播放量", + "互动率", + "完播率", + "爆文率", + "21-60s报价" +]); + +export function listNumericMetricFilterGroups( + marketListHeaders: string[] = [] +): AudienceProfileCsvFieldGroup[] { + return listAudienceProfileSelectableFieldGroups(marketListHeaders) + .map((group) => ({ + ...group, + headers: group.headers.filter((header) => + group.label === "列表字段" + ? NUMERIC_MARKET_LIST_FIELDS.has(header) + : true + ) + })) + .filter((group) => group.headers.length > 0); +} + +export function listNumericMetricFilterDefinitions( + marketListHeaders: string[] = [] +): MetricFilterDefinition[] { + return listNumericMetricFilterGroups(marketListHeaders).flatMap((group) => + group.headers.map((field) => ({ field, group: group.label })) + ); +} + +export function matchesMetricFilterRule( + values: Record, + rule: MetricFilterRule +): boolean { + const value = parseDisplayNumber(values[rule.field]); + if (value === null) { + return false; + } + + return rule.operator === "gte" + ? value >= rule.threshold + : value <= rule.threshold; +} + +export function parseDisplayNumber(value: string | undefined): number | null { + if (!value) { + return null; + } + + const normalized = value.trim().replace(/[\s,¥¥]/g, ""); + if (!normalized || normalized === "缺失") { + return null; + } + + const suffix = normalized.endsWith("w") || normalized.endsWith("万") + ? 10_000 + : normalized.endsWith("亿") + ? 100_000_000 + : 1; + const numericText = normalized + .replace(/[%w万亿]/g, "") + .replace(/^\+/, ""); + const numericValue = Number(numericText); + return Number.isFinite(numericValue) ? numericValue * suffix : null; +} diff --git a/src/content/market/plugin-toolbar.ts b/src/content/market/plugin-toolbar.ts index 5eb45f1..a7b5de3 100644 --- a/src/content/market/plugin-toolbar.ts +++ b/src/content/market/plugin-toolbar.ts @@ -1,10 +1,10 @@ +import type { MetricFilterDefinition } from "./metric-filter"; import type { MarketExportScope, MarketExportTarget, - SpreadFilterMetric, - SpreadInfoConfig, - SpreadMetricFilterRule, - SpreadThresholdFilter + MetricFilterOperator, + MetricFilterRule, + MetricThresholdFilter } from "./types"; export interface PluginToolbarHandlers { @@ -14,27 +14,23 @@ export interface PluginToolbarHandlers { onSubmitBatch(): Promise | void; } -interface SpreadMetricRuleDom { - detailsButton: HTMLButtonElement; - enabledInput: HTMLInputElement; - flowTypeSelect: HTMLSelectElement; - onlyAssignSelect: HTMLSelectElement; - rangeSelect: HTMLSelectElement; +interface MetricFilterRuleDom { + definition: MetricFilterDefinition; + operatorSelect: HTMLSelectElement; removeButton: HTMLButtonElement; root: HTMLElement; - secondaryControls: HTMLElement; thresholdInput: HTMLInputElement; - typeSelect: HTMLSelectElement; } -type SpreadMetricRuleDomMap = Record; - interface MetricCatalogDom { + actions: Map; addButton: HTMLButtonElement; closeButton: HTMLButtonElement; - metricActions: Record; + emptyState: HTMLElement; + items: Map; panel: HTMLElement; root: HTMLElement; + searchInput: HTMLInputElement; selectedCount: HTMLElement; } @@ -43,22 +39,6 @@ interface ToolbarResizeListener { window: Window; } -const SPREAD_FILTER_DEFINITIONS: ReadonlyArray<{ - label: string; - metric: SpreadFilterMetric; -}> = [ - { label: "完播率", metric: "finishRate" }, - { 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; @@ -68,14 +48,16 @@ export interface PluginToolbarDom { exportRangeSelect: HTMLSelectElement; exportStatusText: HTMLElement; metricCatalog: MetricCatalogDom; - spreadMetricRules: SpreadMetricRuleDomMap; + metricRules: Map; root: HTMLElement; } const PLUGIN_ACTION_BUTTON_STYLE_ID = "sces-plugin-action-button-style"; const TOOLBAR_RESIZE_LISTENER = Symbol("sces-toolbar-resize-listener"); +const TOOLBAR_DOM = Symbol("sces-toolbar-dom"); type ToolbarRootWithResizeListener = HTMLElement & { + [TOOLBAR_DOM]?: PluginToolbarDom; [TOOLBAR_RESIZE_LISTENER]?: ToolbarResizeListener; }; @@ -95,35 +77,39 @@ export function isPluginToolbarMounted( export function ensurePluginToolbar( document: Document, - handlers: PluginToolbarHandlers + handlers: PluginToolbarHandlers, + metricDefinitions: MetricFilterDefinition[] = [] ): PluginToolbarDom { ensurePluginActionButtonTheme(document); + const catalogVersion = metricDefinitions + .map(({ field, group }) => `${group}\u0001${field}`) + .join("\u0002"); const existingRoot = document.querySelector( "[data-plugin-toolbar='root']" ) as HTMLElement | null; - if (existingRoot) { - if ( - existingRoot.querySelector( - '[data-plugin-export-audience-profile-by-id="button"]' - ) && - existingRoot.querySelector( - '[data-plugin-toolbar-action-group="selected-audience-export"]' - ) && - existingRoot.querySelector('[data-plugin-spread-metric-catalog-trigger="button"]') - ) { - ensureToolbarMounted(existingRoot, document); - const toolbarDom = readToolbarDom(existingRoot); - ensureToolbarResizeListener(toolbarDom); - return toolbarDom; - } + const existingToolbar = (existingRoot as ToolbarRootWithResizeListener | null)?.[ + TOOLBAR_DOM + ]; + if ( + existingRoot && + existingToolbar && + existingRoot.dataset.pluginMetricCatalogVersion === catalogVersion && + existingRoot.querySelector('[data-plugin-metric-catalog-trigger="button"]') + ) { + ensureToolbarMounted(existingRoot, document); + ensureToolbarResizeListener(existingToolbar); + return existingToolbar; + } + if (existingRoot) { cleanupToolbarResizeListener(existingRoot); existingRoot.remove(); } const root = document.createElement("section"); root.dataset.pluginToolbar = "root"; + root.dataset.pluginMetricCatalogVersion = catalogVersion; applyToolbarRootStyles(root); const exportRangeSelect = document.createElement("select"); @@ -143,52 +129,54 @@ export function ensurePluginToolbar( exportCustomPagesInput.placeholder = "页数"; exportCustomPagesInput.dataset.pluginExportCustomPages = "input"; - const audienceProfileExportButton = document.createElement("button"); - audienceProfileExportButton.type = "button"; - audienceProfileExportButton.dataset.pluginExportAudienceProfile = "button"; - audienceProfileExportButton.textContent = "导出选中达人数据"; - audienceProfileExportButton.title = - "仅导出已勾选达人,包含内容数据、效果预估、画像等维度"; + const audienceProfileExportButton = createActionButton( + document, + "导出选中达人数据", + "pluginExportAudienceProfile", + "button" + ); + audienceProfileExportButton.title = "仅导出已勾选达人,包含内容数据、效果预估、画像等维度"; - const audienceProfileByIdExportButton = document.createElement("button"); - audienceProfileByIdExportButton.type = "button"; - audienceProfileByIdExportButton.dataset.pluginExportAudienceProfileById = "button"; - audienceProfileByIdExportButton.textContent = "按星图ID导出"; - audienceProfileByIdExportButton.title = - "粘贴达人星图ID后批量导出达人数据,不依赖当前列表勾选"; + const audienceProfileByIdExportButton = createActionButton( + document, + "按星图ID导出", + "pluginExportAudienceProfileById", + "button" + ); + audienceProfileByIdExportButton.title = "粘贴达人星图ID后批量导出达人数据,不依赖当前列表勾选"; - const audienceProfileFieldButton = document.createElement("button"); - audienceProfileFieldButton.type = "button"; - audienceProfileFieldButton.dataset.pluginAudienceProfileFields = "button"; - audienceProfileFieldButton.textContent = "选择字段"; - audienceProfileFieldButton.title = - "勾选本次CSV需要导出的字段,设置会自动保存"; + const audienceProfileFieldButton = createActionButton( + document, + "选择字段", + "pluginAudienceProfileFields", + "button" + ); + audienceProfileFieldButton.title = "勾选本次CSV需要导出的字段,设置会自动保存"; - const batchSubmitButton = document.createElement("button"); - batchSubmitButton.type = "button"; - batchSubmitButton.dataset.pluginBatchSubmit = "button"; - batchSubmitButton.textContent = "提交批次"; + const batchSubmitButton = createActionButton( + document, + "提交批次", + "pluginBatchSubmit", + "button" + ); batchSubmitButton.title = "将当前选中的达人提交到后续业务批次"; const exportStatusText = document.createElement("span"); exportStatusText.dataset.pluginExportStatus = "text"; applyStatusStyles(exportStatusText); - const spreadMetricRules = createSpreadMetricRuleDoms(document); - const defaultSpreadMetricRule = - spreadMetricRules[DEFAULT_ENABLED_SPREAD_METRIC]; - defaultSpreadMetricRule.enabledInput.checked = true; - applyDefaultSpreadMetricRuleState(defaultSpreadMetricRule); - const metricCatalog = createMetricCatalog(document); + const rulesGroup = document.createElement("div"); + rulesGroup.dataset.pluginMetricRules = "root"; + applyRulesGroupStyles(rulesGroup); + const metricRules = new Map(); + const metricCatalog = createMetricCatalog(document, metricDefinitions); const panel = document.createElement("div"); panel.dataset.pluginToolbarPanel = "root"; applyToolbarPanelStyles(panel); - const firstRow = document.createElement("div"); firstRow.dataset.pluginToolbarRow = "primary"; applyToolbarRowStyles(firstRow); - const secondRow = document.createElement("div"); secondRow.dataset.pluginToolbarRow = "thresholds"; applyToolbarRowStyles(secondRow); @@ -196,16 +184,12 @@ export function ensurePluginToolbar( const dataGroup = document.createElement("div"); dataGroup.dataset.pluginToolbarGroup = "data"; applyToolbarGroupStyles(dataGroup); - const selectedAudienceExportGroup = createToolbarActionGroup( - document, - "selected-audience-export", - [ - audienceProfileExportButton, - audienceProfileFieldButton, - exportRangeSelect, - exportCustomPagesInput - ] - ); + const selectedAudienceExportGroup = createToolbarActionGroup(document, "selected-audience-export", [ + audienceProfileExportButton, + audienceProfileFieldButton, + exportRangeSelect, + exportCustomPagesInput + ]); const idExportGroup = createToolbarActionGroup(document, "id-export", [ audienceProfileByIdExportButton ]); @@ -220,33 +204,29 @@ export function ensurePluginToolbar( batchSubmitGroup ); - const thresholdTitle = createToolbarGroupTitle(document, "传播指标筛选"); - const rulesGroup = document.createElement("div"); - rulesGroup.dataset.pluginSpreadRules = "root"; - applySpreadRulesGroupStyles(rulesGroup); - rulesGroup.append( - ...SPREAD_FILTER_DEFINITIONS.map( - ({ metric }) => spreadMetricRules[metric].root - ) - ); - const filterNote = createSpreadFilterNote(document); - + const filterTitle = createToolbarGroupTitle(document, "指标筛选"); + const filterNote = createFilterNote(document); firstRow.append(dataGroup, exportStatusText); - secondRow.append(thresholdTitle, metricCatalog.root, rulesGroup, filterNote); + secondRow.append(filterTitle, metricCatalog.root, rulesGroup, filterNote); panel.append(firstRow, secondRow); - root.append(panel); - document.body.appendChild(root); - applyNativeControlStyles(document, { - audienceProfileExportButton, + + const toolbarDom = { audienceProfileByIdExportButton, + audienceProfileExportButton, audienceProfileFieldButton, batchSubmitButton, exportCustomPagesInput, exportRangeSelect, - spreadMetricRules - }); + exportStatusText, + metricCatalog, + metricRules, + root + } satisfies PluginToolbarDom; + (root as ToolbarRootWithResizeListener)[TOOLBAR_DOM] = toolbarDom; + + applyNativeControlStyles(document, toolbarDom); ensureToolbarMounted(root, document); audienceProfileExportButton.addEventListener("click", () => { @@ -261,72 +241,45 @@ export function ensurePluginToolbar( batchSubmitButton.addEventListener("click", () => { void handlers.onSubmitBatch(); }); - - const toolbarDom = { - audienceProfileExportButton, - audienceProfileByIdExportButton, - audienceProfileFieldButton, - batchSubmitButton, - exportCustomPagesInput, - exportRangeSelect, - exportStatusText, - metricCatalog, - spreadMetricRules, - root - } satisfies PluginToolbarDom; - exportRangeSelect.addEventListener("change", () => { syncCustomPagesInputVisibility(toolbarDom); }); - metricCatalog.addButton.addEventListener("click", () => { setMetricCatalogOpen(metricCatalog, metricCatalog.panel.hidden); }); metricCatalog.closeButton.addEventListener("click", () => { setMetricCatalogOpen(metricCatalog, false); }); - - SPREAD_FILTER_DEFINITIONS.forEach(({ metric }) => { - const rule = spreadMetricRules[metric]; - rule.enabledInput.addEventListener("change", () => { - syncSpreadMetricRuleState(rule); - syncSpreadFilterNote(toolbarDom); - }); - 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); + metricCatalog.searchInput.addEventListener("input", () => { + syncMetricCatalogSearch(metricCatalog); + }); + metricDefinitions.forEach((definition) => { + const action = metricCatalog.actions.get(definition.field); + action?.addEventListener("click", () => { + addMetricRule(toolbarDom, definition, rulesGroup); }); }); ensureToolbarResizeListener(toolbarDom); - syncCustomPagesInputVisibility(toolbarDom); - syncAllSpreadMetricRules(toolbarDom); - + syncMetricCatalog(toolbarDom); return toolbarDom; } -function appendOption( - select: HTMLSelectElement, - value: string, - label: string -): void { +function createActionButton( + document: Document, + label: string, + datasetKey: string, + datasetValue: string +): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.dataset[datasetKey] = datasetValue; + button.textContent = label; + return button; +} + +function appendOption(select: HTMLSelectElement, value: string, label: string): void { const option = select.ownerDocument.createElement("option"); option.value = value; option.textContent = label; @@ -335,10 +288,7 @@ function appendOption( function createToolbarActionGroup( document: Document, - groupName: - | "batch-submit" - | "id-export" - | "selected-audience-export", + groupName: "batch-submit" | "id-export" | "selected-audience-export", controls: HTMLElement[] ): HTMLElement { const group = document.createElement("div"); @@ -356,406 +306,194 @@ function createToolbarActionDivider(document: Document): HTMLElement { return divider; } -function createSpreadMetricRuleDoms( - document: Document -): SpreadMetricRuleDomMap { - return Object.fromEntries( - SPREAD_FILTER_DEFINITIONS.map(({ label, metric }) => [ - metric, - createSpreadMetricRuleDom(document, metric, label) - ]) - ) as unknown as SpreadMetricRuleDomMap; -} - -function createSpreadMetricRuleDom( +function createMetricCatalog( document: Document, - metric: SpreadFilterMetric, - label: string -): SpreadMetricRuleDom { - const enabledInput = document.createElement("input"); - enabledInput.type = "checkbox"; - enabledInput.hidden = true; - enabledInput.dataset.pluginSpreadMetric = metric; - enabledInput.setAttribute("aria-label", `启用${label}筛选`); - - const thresholdInput = document.createElement("input"); - thresholdInput.type = "number"; - thresholdInput.min = "0"; - thresholdInput.step = "0.1"; - thresholdInput.dataset.pluginSpreadThreshold = metric; - thresholdInput.setAttribute("aria-label", `${label}筛选阈值`); - - const typeSelect = document.createElement("select"); - typeSelect.dataset.pluginSpreadFilter = "type"; - appendOption(typeSelect, "1", "个人视频"); - appendOption(typeSelect, "2", "星图视频"); - typeSelect.value = "1"; - - const onlyAssignSelect = document.createElement("select"); - onlyAssignSelect.dataset.pluginSpreadFilter = "onlyAssign"; - appendOption(onlyAssignSelect, "false", "不限指派"); - appendOption(onlyAssignSelect, "true", "只看指派"); - onlyAssignSelect.value = "false"; - - const flowTypeSelect = document.createElement("select"); - flowTypeSelect.dataset.pluginSpreadFilter = "flowType"; - appendOption(flowTypeSelect, "0", "不排除营销"); - appendOption(flowTypeSelect, "1", "排除营销"); - flowTypeSelect.value = "0"; - - const rangeSelect = document.createElement("select"); - rangeSelect.dataset.pluginSpreadFilter = "range"; - appendOption(rangeSelect, "2", "近30天"); - appendOption(rangeSelect, "3", "近90天"); - rangeSelect.value = "2"; - + definitions: MetricFilterDefinition[] +): MetricCatalogDom { const root = document.createElement("div"); - root.dataset.pluginSpreadRule = metric; - root.hidden = true; - applySpreadMetricRuleStyles(root); - - const metricLabel = document.createElement("strong"); - metricLabel.textContent = label; - applySpreadMetricRuleLabelStyles(metricLabel); - - const thresholdControl = document.createElement("label"); - thresholdControl.dataset.pluginSpreadThresholdControl = metric; - applySpreadThresholdControlStyles(thresholdControl); - - const operator = document.createElement("b"); - operator.dataset.pluginSpreadThresholdOperator = "gte"; - operator.textContent = "≥"; - - const unitText = document.createElement("span"); - unitText.dataset.pluginSpreadThresholdUnit = metric; - unitText.textContent = "%"; - thresholdControl.append(operator, thresholdInput, unitText); - - 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, - removeButton - ); - - root.append(enabledInput, primaryControls, secondaryControls); - - return { - detailsButton, - enabledInput, - flowTypeSelect, - onlyAssignSelect, - rangeSelect, - removeButton, - root, - secondaryControls, - thresholdInput, - typeSelect - }; -} - -function createMetricCatalog(document: Document): MetricCatalogDom { - const root = document.createElement("div"); - root.dataset.pluginSpreadMetricCatalog = "root"; + root.dataset.pluginMetricCatalog = "root"; applyMetricCatalogStyles(root); - const addButton = document.createElement("button"); addButton.type = "button"; - addButton.dataset.pluginSpreadMetricCatalogTrigger = "button"; + addButton.dataset.pluginMetricCatalogTrigger = "button"; addButton.textContent = "添加筛选指标"; addButton.setAttribute("aria-expanded", "false"); applyMetricCatalogTriggerStyles(addButton); - const selectedCount = document.createElement("span"); - selectedCount.dataset.pluginSpreadMetricSelectedCount = "text"; + selectedCount.dataset.pluginMetricSelectedCount = "text"; selectedCount.setAttribute("aria-live", "polite"); applyMetricCatalogCountStyles(selectedCount); - const panel = document.createElement("div"); - panel.dataset.pluginSpreadMetricCatalogPanel = "root"; + panel.dataset.pluginMetricCatalogPanel = "root"; panel.hidden = true; applyMetricCatalogPanelStyles(panel); + const panelHeader = document.createElement("div"); + applyMetricCatalogHeaderStyles(panelHeader); + const searchInput = document.createElement("input"); + searchInput.type = "search"; + searchInput.placeholder = "搜索指标"; + searchInput.dataset.pluginMetricCatalogSearch = "input"; + searchInput.setAttribute("aria-label", "搜索筛选指标"); + applyMetricCatalogSearchStyles(searchInput); const closeButton = document.createElement("button"); closeButton.type = "button"; - closeButton.dataset.pluginSpreadMetricCatalogClose = "button"; + closeButton.dataset.pluginMetricCatalogClose = "button"; closeButton.textContent = "×"; closeButton.title = "关闭指标目录"; closeButton.setAttribute("aria-label", "关闭指标目录"); applyMetricCatalogCloseButtonStyles(closeButton); + panelHeader.append(searchInput, closeButton); - const panelHeader = document.createElement("div"); - panelHeader.dataset.pluginSpreadMetricCatalogHeader = "root"; - applyMetricCatalogHeaderStyles(panelHeader); - panelHeader.append(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 actions = new Map(); + const items = new Map(); + const definitionsByGroup = new Map(); + definitions.forEach((definition) => { + const group = definitionsByGroup.get(definition.group) ?? []; + group.push(definition); + definitionsByGroup.set(definition.group, group); + }); + const groupsRoot = document.createElement("div"); + groupsRoot.dataset.pluginMetricCatalogGroups = "root"; + applyMetricCatalogGroupsStyles(groupsRoot); + definitionsByGroup.forEach((groupDefinitions, groupLabel) => { + const group = document.createElement("section"); + group.dataset.pluginMetricCatalogGroup = groupLabel; + applyMetricCatalogGroupStyles(group); + const groupTitle = document.createElement("strong"); + groupTitle.textContent = groupLabel; + applyMetricCatalogGroupTitleStyles(groupTitle); + group.append(groupTitle); + groupDefinitions.forEach((definition) => { const item = document.createElement("div"); - item.dataset.pluginSpreadMetricCatalogItem = metric; + item.dataset.pluginMetricCatalogItem = definition.field; + item.dataset.pluginMetricCatalogGroupItem = groupLabel; applyMetricCatalogItemStyles(item); const name = document.createElement("span"); - name.textContent = label; + name.textContent = definition.field; const action = document.createElement("button"); action.type = "button"; - action.dataset.pluginSpreadMetricCatalogAction = metric; + action.dataset.pluginMetricCatalogAction = definition.field; applyMetricCatalogActionStyles(action); item.append(name, action); - group.appendChild(item); - return [metric, action]; - }) - ) as unknown as Record; - - panel.append(panelHeader, group); + group.append(item); + actions.set(definition.field, action); + items.set(definition.field, item); + }); + groupsRoot.append(group); + }); + const emptyState = document.createElement("p"); + emptyState.textContent = "没有匹配的数值指标"; + emptyState.hidden = true; + applyMetricCatalogEmptyStateStyles(emptyState); + panel.append(panelHeader, groupsRoot, emptyState); root.append(addButton, selectedCount, panel); - - return { - addButton, - closeButton, - metricActions, - panel, - root, - selectedCount - }; + return { actions, addButton, closeButton, emptyState, items, panel, root, searchInput, selectedCount }; } -function createSpreadFilterNote(document: Document): HTMLElement { +function addMetricRule( + toolbar: PluginToolbarDom, + definition: MetricFilterDefinition, + rulesGroup: HTMLElement +): void { + if (toolbar.metricRules.has(definition.field)) { + return; + } + const rule = createMetricFilterRuleDom(toolbar.root.ownerDocument, definition); + toolbar.metricRules.set(definition.field, rule); + rulesGroup.appendChild(rule.root); + rule.removeButton.addEventListener("click", () => { + toolbar.metricRules.delete(definition.field); + rule.root.remove(); + syncMetricCatalog(toolbar); + }); + applyRuleControlStyles(toolbar.root.ownerDocument, rule); + syncMetricCatalog(toolbar); + setMetricCatalogOpen(toolbar.metricCatalog, false); +} + +function createMetricFilterRuleDom( + document: Document, + definition: MetricFilterDefinition +): MetricFilterRuleDom { + const root = document.createElement("div"); + root.dataset.pluginMetricFilterRule = definition.field; + applyMetricFilterRuleStyles(root); + const label = document.createElement("strong"); + label.textContent = definition.field; + label.title = definition.field; + applyMetricFilterRuleLabelStyles(label); + const thresholdControl = document.createElement("label"); + thresholdControl.dataset.pluginMetricFilterThresholdControl = definition.field; + applyMetricThresholdControlStyles(thresholdControl); + const operatorSelect = document.createElement("select"); + operatorSelect.dataset.pluginMetricFilterOperator = definition.field; + operatorSelect.setAttribute("aria-label", `${definition.field}比较方式`); + appendOption(operatorSelect, "gte", "≥"); + appendOption(operatorSelect, "lte", "≤"); + const thresholdInput = document.createElement("input"); + thresholdInput.type = "number"; + thresholdInput.step = "any"; + thresholdInput.dataset.pluginMetricFilterThreshold = definition.field; + thresholdInput.setAttribute("aria-label", `${definition.field}筛选数值`); + thresholdInput.placeholder = "数值"; + thresholdControl.append(operatorSelect, thresholdInput); + const removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.dataset.pluginMetricFilterRemove = definition.field; + removeButton.textContent = "×"; + removeButton.title = `删除${definition.field}筛选`; + removeButton.setAttribute("aria-label", `删除${definition.field}筛选`); + applyMetricFilterRemoveButtonStyles(removeButton); + root.append(label, thresholdControl, removeButton); + return { definition, operatorSelect, removeButton, root, thresholdInput }; +} + +function createFilterNote(document: Document): HTMLElement { const note = document.createElement("span"); - note.dataset.pluginSpreadFilterNote = "and"; + note.dataset.pluginMetricFilterNote = "and"; note.textContent = "全部规则都达标才保留达人"; note.hidden = true; - applySpreadFilterNoteStyles(note); + applyFilterNoteStyles(note); return note; } -function readSpreadMetricRuleDoms(root: HTMLElement): SpreadMetricRuleDomMap { - return Object.fromEntries( - SPREAD_FILTER_DEFINITIONS.map(({ metric }) => { - const ruleRoot = root.querySelector( - `[data-plugin-spread-rule="${metric}"]` - ) as HTMLElement; - return [ - metric, - { - detailsButton: ruleRoot.querySelector( - `[data-plugin-spread-rule-details="${metric}"]` - ) as HTMLButtonElement, - enabledInput: root.querySelector( - `[data-plugin-spread-metric="${metric}"]` - ) as HTMLInputElement, - flowTypeSelect: ruleRoot.querySelector( - '[data-plugin-spread-filter="flowType"]' - ) as HTMLSelectElement, - onlyAssignSelect: ruleRoot.querySelector( - '[data-plugin-spread-filter="onlyAssign"]' - ) as HTMLSelectElement, - 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, - typeSelect: ruleRoot.querySelector( - '[data-plugin-spread-filter="type"]' - ) as HTMLSelectElement - } satisfies SpreadMetricRuleDom - ]; - }) - ) 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, - selectedCount: root.querySelector( - '[data-plugin-spread-metric-selected-count="text"]' - ) as HTMLElement - }; -} - -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, - audienceProfileFieldButton: root.querySelector( - '[data-plugin-audience-profile-fields="button"]' - ) as HTMLButtonElement, - batchSubmitButton: root.querySelector( - '[data-plugin-batch-submit="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, - metricCatalog: readMetricCatalogDom(root), - spreadMetricRules: readSpreadMetricRuleDoms(root), - root - } satisfies PluginToolbarDom; - syncCustomPagesInputVisibility(toolbarDom); - syncAllSpreadMetricRules(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 - } - }; - } - + 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 - } - }; + return Number.isInteger(pageCount) && pageCount >= 1 + ? { target: { mode: "count", pageCount } } + : { error: "请输入有效页数" }; } export function readToolbarSpreadFilter( toolbar: PluginToolbarDom -): { error?: string; filter?: SpreadThresholdFilter } { - const rules: SpreadMetricFilterRule[] = []; - - for (const { label, metric } of SPREAD_FILTER_DEFINITIONS) { - const ruleDom = toolbar.spreadMetricRules[metric]; - clearSpreadMetricRuleValidation(ruleDom); - if (!ruleDom.enabledInput.checked) { - continue; +): { error?: string; filter?: MetricThresholdFilter } { + const rules: MetricFilterRule[] = []; + for (const rule of toolbar.metricRules.values()) { + clearMetricRuleValidation(rule); + const rawValue = rule.thresholdInput.value.trim(); + const threshold = Number(rawValue); + if (!rawValue || !Number.isFinite(threshold) || threshold < 0) { + markMetricRuleInvalid(rule); + return { error: `请输入有效的${rule.definition.field}筛选数值` }; } - - const trimmedValue = ruleDom.thresholdInput.value.trim(); - const threshold = Number(trimmedValue); - if (!trimmedValue || !Number.isFinite(threshold) || threshold < 0) { - markSpreadMetricRuleInvalid(ruleDom); - return { - error: `请输入有效的${label}筛选阈值` - }; - } - rules.push({ - config: readSpreadMetricConfig(ruleDom), - metric, + field: rule.definition.field, + operator: rule.operatorSelect.value === "lte" ? "lte" : "gte", threshold }); } - - return { - filter: { - rules - } - }; + return { filter: { rules } }; } -export function setToolbarBusyState( - toolbar: PluginToolbarDom, - isBusy: boolean -): void { +export function setToolbarBusyState(toolbar: PluginToolbarDom, isBusy: boolean): void { [ toolbar.batchSubmitButton, toolbar.audienceProfileFieldButton, @@ -765,37 +503,71 @@ export function setToolbarBusyState( toolbar.exportCustomPagesInput, toolbar.metricCatalog.addButton, toolbar.metricCatalog.closeButton, - ...Object.values(toolbar.metricCatalog.metricActions), - ...Object.values(toolbar.spreadMetricRules).flatMap((rule) => [ - rule.detailsButton, - rule.enabledInput, + toolbar.metricCatalog.searchInput, + ...toolbar.metricCatalog.actions.values(), + ...Array.from(toolbar.metricRules.values()).flatMap((rule) => [ + rule.operatorSelect, rule.thresholdInput, - rule.typeSelect, - rule.onlyAssignSelect, - rule.flowTypeSelect, - rule.rangeSelect, rule.removeButton ]) ].forEach((element) => { element.disabled = isBusy; }); - if (!isBusy) { - syncAllSpreadMetricRules(toolbar); - } + if (!isBusy) syncMetricCatalog(toolbar); } -export function setToolbarExportStatus( - toolbar: PluginToolbarDom, - text: string -): void { +export function setToolbarExportStatus(toolbar: PluginToolbarDom, text: string): void { toolbar.exportStatusText.textContent = text; } function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void { - toolbar.exportRangeSelect.hidden = false; toolbar.exportCustomPagesInput.hidden = toolbar.exportRangeSelect.value !== "custom"; } +function syncMetricCatalog(toolbar: PluginToolbarDom): void { + toolbar.metricCatalog.selectedCount.textContent = `已选 ${toolbar.metricRules.size} 项`; + toolbar.metricCatalog.actions.forEach((action, field) => { + const isSelected = toolbar.metricRules.has(field); + action.disabled = isSelected; + action.textContent = isSelected ? "已添加" : "添加"; + }); + syncMetricCatalogSearch(toolbar.metricCatalog); + const note = toolbar.root.querySelector('[data-plugin-metric-filter-note="and"]') as HTMLElement | null; + if (note) note.hidden = toolbar.metricRules.size < 2; +} + +function syncMetricCatalogSearch(catalog: MetricCatalogDom): void { + const query = catalog.searchInput.value.trim().toLocaleLowerCase(); + const visibleGroups = new Set(); + catalog.items.forEach((item, field) => { + const isVisible = !query || field.toLocaleLowerCase().includes(query); + item.hidden = !isVisible; + if (isVisible && item.dataset.pluginMetricCatalogGroupItem) { + visibleGroups.add(item.dataset.pluginMetricCatalogGroupItem); + } + }); + catalog.root.querySelectorAll("[data-plugin-metric-catalog-group]").forEach((group) => { + group.hidden = !visibleGroups.has(group.dataset.pluginMetricCatalogGroup ?? ""); + }); + catalog.emptyState.hidden = visibleGroups.size > 0; +} + +function setMetricCatalogOpen(catalog: MetricCatalogDom, isOpen: boolean): void { + catalog.panel.hidden = !isOpen; + catalog.addButton.setAttribute("aria-expanded", String(isOpen)); + if (isOpen) catalog.searchInput.focus(); +} + +function clearMetricRuleValidation(rule: MetricFilterRuleDom): void { + delete rule.root.dataset.pluginMetricFilterRuleInvalid; + rule.thresholdInput.removeAttribute("aria-invalid"); +} + +function markMetricRuleInvalid(rule: MetricFilterRuleDom): void { + rule.root.dataset.pluginMetricFilterRuleInvalid = "true"; + rule.thresholdInput.setAttribute("aria-invalid", "true"); +} + function ensureToolbarResizeListener(toolbar: PluginToolbarDom): void { const root = toolbar.root as ToolbarRootWithResizeListener; const currentWindow = root.ownerDocument.defaultView; @@ -804,134 +576,18 @@ function ensureToolbarResizeListener(toolbar: PluginToolbarDom): void { 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); - }); + if (existingListener?.window === currentWindow) return; + if (existingListener) existingListener.window.removeEventListener("resize", existingListener.handler); + const handler = existingListener?.handler ?? (() => syncMetricCatalog(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; - } - + const listener = (root as ToolbarRootWithResizeListener)[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; - rule.root.style.display = rule.enabledInput.checked ? "flex" : "none"; - if (!rule.enabledInput.checked) { - 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 { - const isPersonalVideo = rule.typeSelect.value !== "2"; - if (isPersonalVideo) { - rule.onlyAssignSelect.value = "false"; - rule.flowTypeSelect.value = "0"; - } - rule.onlyAssignSelect.disabled = isPersonalVideo; - rule.flowTypeSelect.disabled = isPersonalVideo; -} - -function syncSpreadFilterNote(toolbar: PluginToolbarDom): void { - const note = toolbar.root.querySelector( - '[data-plugin-spread-filter-note="and"]' - ) as HTMLElement | null; - if (!note) { - return; - } - - const enabledCount = Object.values(toolbar.spreadMetricRules).filter( - (rule) => rule.enabledInput.checked - ).length; - note.hidden = enabledCount < 2; -} - -function syncMetricCatalog(toolbar: PluginToolbarDom): void { - const enabledCount = Object.values(toolbar.spreadMetricRules).filter( - (rule) => rule.enabledInput.checked - ).length; - toolbar.metricCatalog.selectedCount.textContent = `已选 ${enabledCount} 项`; - - SPREAD_FILTER_DEFINITIONS.forEach(({ metric }) => { - const action = toolbar.metricCatalog.metricActions[metric]; - const isSelected = toolbar.spreadMetricRules[metric].enabledInput.checked; - action.disabled = isSelected; - action.textContent = isSelected ? "已添加" : "添加"; - }); -} - -function setMetricCatalogOpen(catalog: MetricCatalogDom, isOpen: boolean): void { - catalog.panel.hidden = !isOpen; - catalog.addButton.setAttribute("aria-expanded", String(isOpen)); -} - -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 { - flowType: - type === 1 ? 0 : rule.flowTypeSelect.value === "1" ? 1 : 0, - onlyAssign: - type === 1 ? false : rule.onlyAssignSelect.value === "true", - range: rule.rangeSelect.value === "3" ? 3 : 2, - type - }; -} - -function clearSpreadMetricRuleValidation(rule: SpreadMetricRuleDom): void { - delete rule.root.dataset.pluginSpreadRuleInvalid; - rule.thresholdInput.removeAttribute("aria-invalid"); -} - -function markSpreadMetricRuleInvalid(rule: SpreadMetricRuleDom): void { - rule.root.dataset.pluginSpreadRuleInvalid = "true"; - rule.thresholdInput.setAttribute("aria-invalid", "true"); + delete (root as ToolbarRootWithResizeListener)[TOOLBAR_RESIZE_LISTENER]; } function ensureToolbarMounted(root: HTMLElement, document: Document): void { @@ -940,17 +596,10 @@ function ensureToolbarMounted(root: HTMLElement, document: Document): void { 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); - } - + const anchor = customizeButton ? findDirectChildAnchor(actionRow, customizeButton) : null; + if (anchor) actionRow.insertBefore(root, anchor); + else if (root.parentElement !== actionRow) actionRow.prepend(root); root.hidden = false; } @@ -958,7 +607,6 @@ 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) @@ -966,28 +614,21 @@ function findNativeActionRow(document: Document): HTMLElement | 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 + return 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; + const depthDelta = + getDepthWithinAncestor(right, header) - getDepthWithinAncestor(left, header); + return depthDelta || normalizeText(left.textContent).length - normalizeText(right.textContent).length; + })[0] ?? null; } function findHeaderContainer( @@ -1006,17 +647,13 @@ function findSmallestSharedActionRow( 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; + return ( + collectAncestorChain(customizeButton, boundary).find( + (candidate) => + exportAncestors.has(candidate) && + isNativeActionRowCandidate(candidate, customizeButton, exportButton) + ) ?? null + ); } function collectAncestorChain( @@ -1025,15 +662,11 @@ function collectAncestorChain( ): HTMLElement[] { const ancestors: HTMLElement[] = []; let current: HTMLElement | null = element.parentElement; - while (current) { ancestors.push(current); - if (current === boundary) { - break; - } + if (current === boundary) break; current = current.parentElement; } - return ancestors; } @@ -1042,29 +675,17 @@ function isNativeActionRowCandidate( 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; - }) + if (customizeButton && !candidate.contains(customizeButton)) return false; + if (exportButton && !candidate.contains(exportButton)) return false; + const labels = Array.from(candidate.children) + .flatMap((child) => [ + ...(child instanceof candidate.ownerDocument.defaultView!.HTMLButtonElement + ? [child] + : []), + ...Array.from(child.querySelectorAll("button")) + ]) .map((button) => normalizeText(button.textContent)); - return ( - directChildLabels.includes("导出") && - (directChildLabels.includes("自定义指标") || Boolean(customizeButton)) - ); + return labels.includes("导出") && (labels.includes("自定义指标") || Boolean(customizeButton)); } function getDepthWithinAncestor( @@ -1073,586 +694,199 @@ function getDepthWithinAncestor( ): 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 { +function findNativeActionButton(root: ParentNode, text: string): HTMLButtonElement | null { const document = root instanceof Document ? root : root.ownerDocument; - if (!document) { - return null; - } - - const candidates = Array.from(root.querySelectorAll("button")).filter( + if (!document) return null; + return Array.from(root.querySelectorAll("button")).find( (element): element is HTMLButtonElement => - element instanceof document.defaultView!.HTMLButtonElement - ); - return ( - candidates.find((element) => normalizeText(element.textContent) === text) ?? null - ); + element instanceof document.defaultView!.HTMLButtonElement && normalizeText(element.textContent) === text + ) ?? null; +} + +function findDirectChildAnchor(parent: HTMLElement, target: HTMLElement): Element | null { + let current: Element | null = target; + while (current?.parentElement && current.parentElement !== parent) current = current.parentElement; + return current?.parentElement === parent ? current : null; +} + +function normalizeText(value: string | null): string { + return value?.replace(/\s+/g, "").trim() ?? ""; } function applyToolbarRootStyles(root: HTMLElement): void { - root.style.display = "inline-flex"; - root.style.alignItems = "center"; - root.style.columnGap = "10px"; - root.style.flex = "1 1 auto"; - root.style.minWidth = "0"; - root.style.flexWrap = "nowrap"; + Object.assign(root.style, { alignItems: "center", columnGap: "10px", display: "inline-flex", flex: "1 1 auto", flexWrap: "nowrap", minWidth: "0" }); } function applyToolbarPanelStyles(panel: HTMLElement): void { - panel.style.display = "flex"; - panel.style.flexDirection = "column"; - panel.style.alignItems = "center"; - panel.style.gap = "6px"; - panel.style.flex = "1 1 auto"; - panel.style.minWidth = "0"; - panel.style.padding = "0"; - panel.style.overflowX = "visible"; - panel.style.overflowY = "visible"; + Object.assign(panel.style, { alignItems: "center", display: "flex", flex: "1 1 auto", flexDirection: "column", gap: "6px", minWidth: "0", overflowX: "visible", overflowY: "visible", padding: "0" }); } function applyToolbarRowStyles(row: HTMLElement): void { - row.style.display = "flex"; - row.style.alignItems = "center"; - row.style.justifyContent = "flex-start"; - row.style.gap = "6px"; - row.style.minHeight = "32px"; - row.style.minWidth = "0"; - row.style.width = "100%"; - row.style.flexWrap = "nowrap"; + Object.assign(row.style, { alignItems: "center", display: "flex", flexWrap: "nowrap", gap: "6px", justifyContent: "flex-start", minHeight: "32px", minWidth: "0", width: "100%" }); } function applyToolbarGroupStyles(group: HTMLElement): void { - group.style.display = "flex"; - group.style.alignItems = "center"; - group.style.gap = "8px"; - group.style.minWidth = "0"; - group.style.flex = "0 0 auto"; - group.style.flexWrap = "nowrap"; + Object.assign(group.style, { alignItems: "center", display: "flex", flex: "0 0 auto", flexWrap: "nowrap", gap: "8px", minWidth: "0" }); } 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"; + Object.assign(group.style, { alignItems: "center", display: "flex", flex: "0 0 auto", flexWrap: "nowrap", gap: "8px" }); } function applyToolbarActionDividerStyles(divider: HTMLElement): void { - divider.style.width = "1px"; - divider.style.height = "20px"; - divider.style.background = "#d0d5dd"; - divider.style.flex = "0 0 auto"; + Object.assign(divider.style, { background: "#d0d5dd", flex: "0 0 auto", height: "20px", width: "1px" }); } 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"; + Object.assign(catalog.style, { alignItems: "center", display: "inline-flex", flex: "0 0 auto", gap: "8px", position: "relative" }); } 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"; + applySecondaryButtonStyles(button); + Object.assign(button.style, { fontSize: "12px", height: "32px", padding: "0 10px" }); } function applyMetricCatalogCountStyles(count: HTMLElement): void { - count.style.color = "#64748b"; - count.style.fontSize = "12px"; - count.style.fontWeight = "700"; - count.style.whiteSpace = "nowrap"; + Object.assign(count.style, { color: "#64748b", fontSize: "12px", fontWeight: "700", whiteSpace: "nowrap" }); } function applyMetricCatalogPanelStyles(panel: HTMLElement): void { - panel.style.position = "absolute"; - panel.style.top = "calc(100% + 6px)"; - panel.style.left = "0"; - panel.style.zIndex = "100"; - 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)"; + Object.assign(panel.style, { background: "#ffffff", border: "1px solid #cbd5e1", borderRadius: "6px", boxShadow: "0 10px 20px rgba(15, 23, 42, 0.14)", left: "0", maxHeight: "420px", overflow: "auto", padding: "10px", position: "absolute", top: "calc(100% + 6px)", width: "420px", zIndex: "100" }); } function applyMetricCatalogHeaderStyles(header: HTMLElement): void { - header.style.display = "flex"; - header.style.alignItems = "center"; - header.style.justifyContent = "flex-end"; + Object.assign(header.style, { alignItems: "center", display: "flex", gap: "8px" }); +} + +function applyMetricCatalogSearchStyles(input: HTMLInputElement): void { + Object.assign(input.style, { border: "1px solid #cbd5e1", borderRadius: "5px", flex: "1 1 auto", height: "30px", minWidth: "0", padding: "0 8px" }); } 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"; + Object.assign(button.style, { background: "transparent", border: "0", color: "#64748b", fontSize: "20px", height: "30px", padding: "0", width: "30px" }); +} + +function applyMetricCatalogGroupsStyles(groups: HTMLElement): void { + Object.assign(groups.style, { display: "flex", flexDirection: "column", gap: "10px", marginTop: "10px" }); } function applyMetricCatalogGroupStyles(group: HTMLElement): void { - group.style.display = "flex"; - group.style.flexDirection = "column"; - group.style.gap = "4px"; - group.style.marginTop = "10px"; + Object.assign(group.style, { display: "flex", flexDirection: "column", gap: "4px" }); } function applyMetricCatalogGroupTitleStyles(title: HTMLElement): void { - title.style.color = "#64748b"; - title.style.fontSize = "12px"; + Object.assign(title.style, { color: "#64748b", 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"; + Object.assign(item.style, { alignItems: "center", color: "#334155", display: "flex", fontSize: "13px", gap: "10px", justifyContent: "space-between", minHeight: "32px" }); + const name = item.firstElementChild as HTMLElement | null; + if (name) Object.assign(name.style, { minWidth: "0", overflowWrap: "anywhere" }); } 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"; + Object.assign(button.style, { background: "#ffffff", border: "1px solid #94a3b8", borderRadius: "5px", color: "#334155", flex: "0 0 auto", fontSize: "12px", height: "28px", minWidth: "56px" }); } -function applySpreadRulesGroupStyles(group: HTMLElement): void { - group.style.display = "flex"; - group.style.flexDirection = "column"; - group.style.alignItems = "stretch"; - group.style.gap = "6px"; - group.style.minWidth = "0"; - group.style.flex = "1 1 auto"; - group.style.overflowX = "auto"; - group.style.overflowY = "hidden"; +function applyMetricCatalogEmptyStateStyles(state: HTMLElement): void { + Object.assign(state.style, { color: "#64748b", fontSize: "13px", margin: "14px 0 4px", textAlign: "center" }); } -function applySpreadMetricRuleStyles(rule: HTMLElement): void { - rule.style.display = "flex"; - rule.style.alignItems = "center"; - rule.style.gap = "7px"; - rule.style.flexWrap = "wrap"; - rule.style.minWidth = "0"; - rule.style.minHeight = "32px"; +function applyRulesGroupStyles(group: HTMLElement): void { + Object.assign(group.style, { alignItems: "stretch", display: "flex", flex: "1 1 auto", flexDirection: "column", gap: "6px", minWidth: "0", overflow: "visible" }); } -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 applyMetricFilterRuleStyles(rule: HTMLElement): void { + Object.assign(rule.style, { alignItems: "center", display: "flex", flexWrap: "wrap", gap: "7px", minHeight: "32px", minWidth: "0" }); } -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 applyMetricFilterRuleLabelStyles(label: HTMLElement): void { + Object.assign(label.style, { color: "#344054", flex: "0 1 420px", fontSize: "12px", fontWeight: "800", lineHeight: "18px", minWidth: "180px", overflowWrap: "anywhere" }); } -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 applyMetricThresholdControlStyles(control: HTMLElement): void { + Object.assign(control.style, { alignItems: "center", background: "#ffffff", border: "1px solid #dbe2ec", borderRadius: "6px", display: "flex", flex: "0 0 auto", gap: "6px", height: "32px", padding: "0 8px" }); } -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 applyMetricFilterRemoveButtonStyles(button: HTMLButtonElement): void { + Object.assign(button.style, { background: "transparent", border: "0", borderRadius: "4px", color: "#b91c1c", fontSize: "20px", height: "28px", padding: "0", width: "28px" }); } -function applySpreadMetricRuleLabelStyles(label: HTMLElement): void { - label.style.width = "48px"; - label.style.color = "#344054"; - label.style.fontSize = "12px"; - label.style.fontWeight = "800"; - label.style.flex = "0 0 auto"; -} - -function applySpreadFilterNoteStyles(note: HTMLElement): void { - note.style.color = "#0f8a5f"; - note.style.fontSize = "12px"; - note.style.fontWeight = "800"; - note.style.whiteSpace = "nowrap"; - note.style.flex = "0 0 auto"; +function applyFilterNoteStyles(note: HTMLElement): void { + Object.assign(note.style, { color: "#0f8a5f", flex: "0 0 auto", fontSize: "12px", fontWeight: "800", whiteSpace: "nowrap" }); } function createToolbarGroupTitle(document: Document, label: string): HTMLElement { const title = document.createElement("span"); title.dataset.pluginToolbarTitle = label; title.textContent = label; - title.style.display = "flex"; - title.style.alignItems = "center"; - title.style.height = "32px"; - title.style.padding = "0 10px"; - title.style.border = "1px solid #cfe0ff"; - title.style.borderRadius = "8px"; - title.style.background = "#eef5ff"; - title.style.color = "#2563eb"; - title.style.fontSize = "12px"; - title.style.fontWeight = "900"; - title.style.flex = "0 0 auto"; - title.style.whiteSpace = "nowrap"; + Object.assign(title.style, { alignItems: "center", background: "#eef5ff", border: "1px solid #cfe0ff", borderRadius: "8px", color: "#2563eb", display: "flex", flex: "0 0 auto", fontSize: "12px", fontWeight: "900", height: "32px", padding: "0 10px", whiteSpace: "nowrap" }); return title; } -function applyNativeControlStyles( - document: Document, - controls: { - audienceProfileExportButton: HTMLButtonElement; - audienceProfileByIdExportButton: HTMLButtonElement; - audienceProfileFieldButton: HTMLButtonElement; - batchSubmitButton: HTMLButtonElement; - exportCustomPagesInput: HTMLInputElement; - exportRangeSelect: HTMLSelectElement; - spreadMetricRules: SpreadMetricRuleDomMap; - } -): void { - const primaryButton = - findButtonContainingText(document, "发布任务") ?? - findButtonContainingText(document, "+发布任务"); - const nativeButton = - primaryButton ?? - findNativeActionButton(document, "自定义指标") ?? - findNativeActionButton(document, "导出"); - - if (nativeButton) { - controls.audienceProfileExportButton.className = nativeButton.className; - controls.audienceProfileByIdExportButton.className = nativeButton.className; - controls.audienceProfileFieldButton.className = nativeButton.className; - controls.batchSubmitButton.className = nativeButton.className; - } - - const secondaryButtons = [ - controls.audienceProfileExportButton, - controls.audienceProfileByIdExportButton, - 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) => [ - rule.enabledInput, - rule.thresholdInput, - rule.typeSelect, - rule.onlyAssignSelect, - rule.flowTypeSelect, - rule.rangeSelect - ] - ); - const nativeControls = [ - controls.exportCustomPagesInput, - controls.exportRangeSelect, - ...ruleControls - ]; - - nativeControls.forEach((element) => { - if (element instanceof document.defaultView!.HTMLInputElement && element.type === "checkbox") { - element.style.width = "16px"; - element.style.height = "16px"; - element.style.padding = "0"; - element.style.accentColor = "#7f1d2d"; - element.style.flex = "0 0 auto"; - return; - } - element.style.height = "32px"; - element.style.border = "1px solid #d0d7de"; - element.style.borderRadius = "6px"; - element.style.padding = "0 8px"; - element.style.background = "#fff"; - element.style.color = "#1f2329"; - element.style.boxSizing = "border-box"; - element.style.flex = "0 0 auto"; - }); - - controls.exportRangeSelect.style.minWidth = "104px"; - controls.exportCustomPagesInput.style.width = "72px"; - - ruleControls.forEach((element) => { - if (element instanceof document.defaultView!.HTMLSelectElement) { - element.style.minWidth = "84px"; - } - if ( - element instanceof document.defaultView!.HTMLInputElement && - element.dataset.pluginSpreadThreshold - ) { - element.style.width = "58px"; - element.style.minWidth = "0"; - element.style.height = "26px"; - element.style.border = "0"; - element.style.borderRadius = "0"; - element.style.padding = "0"; - element.style.outline = "0"; - element.style.fontWeight = "700"; - } +function applyNativeControlStyles(document: Document, toolbar: PluginToolbarDom): void { + const nativeButton = findNativeActionButton(document, "自定义指标") ?? findNativeActionButton(document, "导出"); + [toolbar.audienceProfileExportButton, toolbar.audienceProfileByIdExportButton, toolbar.audienceProfileFieldButton, toolbar.batchSubmitButton].forEach((button) => { + if (nativeButton) button.className = nativeButton.className; }); + [toolbar.audienceProfileExportButton, toolbar.audienceProfileByIdExportButton, toolbar.audienceProfileFieldButton].forEach(applySecondaryButtonStyles); + applyPrimaryButtonStyles(toolbar.batchSubmitButton); + [toolbar.exportRangeSelect, toolbar.exportCustomPagesInput].forEach(applyInputStyles); + toolbar.exportRangeSelect.style.minWidth = "104px"; + toolbar.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 applyRuleControlStyles(document: Document, rule: MetricFilterRuleDom): void { + applyInputStyles(rule.operatorSelect); + applyInputStyles(rule.thresholdInput); + rule.operatorSelect.style.height = "26px"; + rule.operatorSelect.style.minWidth = "50px"; + rule.operatorSelect.style.padding = "0 2px"; + rule.thresholdInput.style.border = "0"; + rule.thresholdInput.style.height = "26px"; + rule.thresholdInput.style.minWidth = "76px"; + rule.thresholdInput.style.outline = "0"; + rule.thresholdInput.style.padding = "0"; +} + +function applyPrimaryButtonStyles(button: HTMLButtonElement): void { + Object.assign(button.style, { backgroundColor: "#7f1d2d", border: "1px solid #7f1d2d", borderRadius: "8px", boxSizing: "border-box", color: "#ffffff", fontWeight: "600", height: "32px", padding: "0 15px", whiteSpace: "nowrap" }); } 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"; + Object.assign(button.style, { backgroundColor: "#ffffff", border: "1px solid #cbd5e1", borderRadius: "8px", boxSizing: "border-box", color: "#344054", fontWeight: "600", height: "32px", padding: "0 15px", whiteSpace: "nowrap" }); } -function applySpreadThresholdControlStyles(control: HTMLElement): void { - control.style.display = "grid"; - control.style.gridTemplateColumns = "auto auto 1fr auto"; - control.style.alignItems = "center"; - control.style.gap = "6px"; - control.style.height = "32px"; - control.style.padding = "0 8px"; - control.style.border = "1px solid #dbe2ec"; - control.style.borderRadius = "8px"; - control.style.background = "#ffffff"; - control.style.flex = "0 0 auto"; +function applyInputStyles(element: HTMLInputElement | HTMLSelectElement): void { + Object.assign(element.style, { background: "#ffffff", border: "1px solid #d0d7de", borderRadius: "6px", boxSizing: "border-box", color: "#1f2329", height: "32px", padding: "0 8px" }); } function applyStatusStyles(statusText: HTMLElement): void { - statusText.style.color = "#64748b"; - statusText.style.fontSize = "12px"; - statusText.style.lineHeight = "20px"; - statusText.style.marginLeft = "0"; - statusText.style.flex = "1 1 auto"; - statusText.style.minWidth = "120px"; - statusText.style.textAlign = "center"; - statusText.style.whiteSpace = "nowrap"; + Object.assign(statusText.style, { color: "#64748b", flex: "1 1 auto", fontSize: "12px", lineHeight: "20px", minWidth: "120px", textAlign: "center", whiteSpace: "nowrap" }); } function ensurePluginActionButtonTheme(document: Document): void { - if (document.getElementById(PLUGIN_ACTION_BUTTON_STYLE_ID)) { - return; - } - + 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-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) { - background-color: #f8fafc !important; - border-color: #94a3b8 !important; - } - - [data-plugin-batch-submit="button"]:hover:not(:disabled) { - background-color: #6d1627 !important; - border-color: #6d1627 !important; - } - - [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) { - 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; - transform: translateY(1px); - } - - [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 { - 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; - } - - [data-plugin-export-audience-profile="button"]:disabled, - [data-plugin-export-audience-profile-by-id="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; - color: rgba(255, 255, 255, 0.95) !important; - cursor: not-allowed !important; - opacity: 1 !important; - transform: none !important; - box-shadow: none !important; - } - - [data-plugin-spread-threshold-control] span, - [data-plugin-spread-threshold-control] b { - color: #667085 !important; - font-size: 12px !important; - font-weight: 700 !important; - white-space: nowrap !important; - } - - [data-plugin-spread-threshold-control] b { - color: #0f8a5f !important; - font-weight: 900 !important; - } - - [data-plugin-spread-rule-invalid="true"] [data-plugin-spread-threshold-control] { - border-color: #dc2626 !important; - box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.12) !important; - } - + [data-plugin-metric-filter-rule-invalid="true"] [data-plugin-metric-filter-threshold-control] { border-color: #dc2626 !important; box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.12) !important; } + [data-plugin-metric-catalog-action]:disabled { color: #98a2b3 !important; cursor: not-allowed !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; - } + [data-plugin-toolbar='root'], [data-plugin-toolbar-row='thresholds'], [data-plugin-metric-catalog='root'] { width: 100% !important; } + [data-plugin-toolbar-row='thresholds'], [data-plugin-toolbar-group='data'], [data-plugin-toolbar-action-group] { align-items: flex-start !important; flex-wrap: wrap !important; } + [data-plugin-toolbar-action-divider] { display: none !important; } + [data-plugin-metric-catalog-panel='root'] { margin-top: 6px !important; max-width: none !important; position: static !important; width: 100% !important; } + [data-plugin-metric-filter-rule] { width: 100% !important; } + [data-plugin-metric-filter-rule] strong { flex-basis: 100% !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 HTMLButtonElement => - element instanceof document.defaultView!.HTMLButtonElement - ); - - 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; -} diff --git a/src/content/market/types.ts b/src/content/market/types.ts index 5648214..420962a 100644 --- a/src/content/market/types.ts +++ b/src/content/market/types.ts @@ -33,6 +33,18 @@ export interface SpreadThresholdFilter { rules: SpreadMetricFilterRule[]; } +export type MetricFilterOperator = "gte" | "lte"; + +export interface MetricFilterRule { + field: string; + operator: MetricFilterOperator; + threshold: number; +} + +export interface MetricThresholdFilter { + rules: MetricFilterRule[]; +} + export type MarketSortField = | keyof Required | keyof Required; diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index a2a28e2..87be2c2 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -442,10 +442,10 @@ describe("market-content-entry", () => { '[data-plugin-toolbar-action-group="batch-submit"]' ) as HTMLElement | null; const metricCatalog = document.querySelector( - '[data-plugin-spread-metric-catalog="root"]' + '[data-plugin-metric-catalog="root"]' ) as HTMLElement | null; const rulesGroup = document.querySelector( - '[data-plugin-spread-rules="root"]' + '[data-plugin-metric-rules="root"]' ) as HTMLElement | null; const statusText = document.querySelector( '[data-plugin-export-status="text"]' @@ -472,13 +472,13 @@ describe("market-content-entry", () => { '[data-plugin-batch-submit="button"]' ) as HTMLButtonElement | null; const operators = Array.from( - document.querySelectorAll("[data-plugin-spread-threshold-operator]") + document.querySelectorAll("[data-plugin-metric-filter-operator]") ).map((element) => element.textContent); const ruleRows = Array.from( - document.querySelectorAll("[data-plugin-spread-rule]") + document.querySelectorAll("[data-plugin-metric-filter-rule]") ) as HTMLElement[]; const thresholdInputs = Array.from( - document.querySelectorAll("[data-plugin-spread-threshold]") + document.querySelectorAll("[data-plugin-metric-filter-threshold]") ) as HTMLInputElement[]; expect(toolbar?.style.flexWrap).toBe("nowrap"); @@ -515,32 +515,25 @@ describe("market-content-entry", () => { expect(primaryRow?.style.justifyContent).toBe("flex-start"); expect(thresholdRow?.style.justifyContent).toBe("flex-start"); expect(titles.map((element) => element.textContent)).toEqual([ - "传播指标筛选" + "指标筛选" ]); expect(titles[0]?.style.background).toBe("rgb(238, 245, 255)"); - expect(operators).toEqual(["≥", "≥"]); + expect(operators).toEqual([]); expect( - document.querySelector('[data-plugin-spread-metric-catalog-trigger="button"]') + document.querySelector('[data-plugin-metric-catalog-trigger="button"]') ?.textContent ).toBe("添加筛选指标"); expect( - document.querySelector('[data-plugin-spread-metric-selected-count="text"]') + document.querySelector('[data-plugin-metric-selected-count="text"]') ?.textContent - ).toBe("已选 1 项"); + ).toBe("已选 0 项"); expect( (document.querySelector( - '[data-plugin-spread-metric-catalog-panel="root"]' + '[data-plugin-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([ - "", - "" - ]); - expect(thresholdInputs.map((input) => input.step)).toEqual([ - "0.1", - "0.1" - ]); + expect(ruleRows).toEqual([]); + expect(thresholdInputs).toEqual([]); expect([ audienceProfileExportButton?.textContent, audienceProfileByIdExportButton?.textContent, @@ -1500,297 +1493,101 @@ describe("market-content-entry", () => { } }); - test("catalog starts with the default finish-rate rule and adds independent metrics", async () => { + test("catalog groups numeric fields, supports search, and adds one field rule", 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" - }), + loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), window })); - await controller.ready; - const catalogTrigger = document.querySelector( - '[data-plugin-spread-metric-catalog-trigger="button"]' - ) as HTMLButtonElement | null; + const field = "内容数据-个人视频-近30天-完播率"; const catalogPanel = document.querySelector( - '[data-plugin-spread-metric-catalog-panel="root"]' + '[data-plugin-metric-catalog-panel="root"]' ) as HTMLElement | null; - const finishRule = document.querySelector( - '[data-plugin-spread-rule="finishRate"]' - ) as HTMLElement | null; - const interactionRule = document.querySelector( - '[data-plugin-spread-rule="interactionRate"]' - ) as HTMLElement | null; - const finishRateInput = document.querySelector( - '[data-plugin-spread-threshold="finishRate"]' - ) as HTMLInputElement | null; - expect(catalogPanel?.hidden).toBe(true); expect(catalogPanel?.style.zIndex).toBe("100"); - expect(finishRule?.hidden).toBe(false); - expect(interactionRule?.hidden).toBe(true); - expect(interactionRule?.style.display).toBe("none"); - 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"); + expect(document.querySelector('[data-plugin-metric-selected-count="text"]')?.textContent).toBe("已选 0 项"); - 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"); - setSpreadRuleSelect("interactionRate", "type", "2"); - setSpreadRuleSelect("interactionRate", "onlyAssign", "true"); - setSpreadRuleSelect("interactionRate", "flowType", "1"); - setSpreadRuleSelect("interactionRate", "type", "1"); + click('[data-plugin-metric-catalog-trigger="button"]'); + const groupLabels = Array.from( + document.querySelectorAll('[data-plugin-metric-catalog-group] > strong') + ).map((element) => element.textContent); + expect(groupLabels).toEqual(expect.arrayContaining([ + "列表字段", "看后搜率", "秒思api数据", "内容数据", "效果预估", "观众画像", "粉丝画像", "铁粉画像" + ])); + const search = document.querySelector( + '[data-plugin-metric-catalog-search="input"]' + ) as HTMLInputElement | null; + expect(search).not.toBeNull(); + search!.value = "个人视频-近30天-完播率"; + search!.dispatchEvent(new Event("input")); + expect(findMetricCatalogItem(field)?.hidden).toBe(false); + expect(findMetricCatalogItem("商单视频看后搜率")?.hidden).toBe(true); - const finishAssignSelect = readSpreadRuleSelect( - "finishRate", - "onlyAssign" - ); - const finishFlowTypeSelect = readSpreadRuleSelect( - "finishRate", - "flowType" - ); - const interactionAssignSelect = readSpreadRuleSelect( - "interactionRate", - "onlyAssign" - ); - const interactionFlowTypeSelect = readSpreadRuleSelect( - "interactionRate", - "flowType" - ); - - expect(finishRule?.hidden).toBe(false); - expect(interactionRule?.hidden).toBe(false); - expect(interactionRule?.style.display).toBe("flex"); - expect(finishAssignSelect.value).toBe("true"); - expect(finishAssignSelect.disabled).toBe(false); - expect(finishFlowTypeSelect.value).toBe("1"); - expect(finishFlowTypeSelect.disabled).toBe(false); - expect(interactionAssignSelect.value).toBe("false"); - expect(interactionAssignSelect.disabled).toBe(true); - expect(interactionFlowTypeSelect.value).toBe("0"); - expect(interactionFlowTypeSelect.disabled).toBe(true); - - setInputValue('[data-plugin-spread-threshold="finishRate"]', "30"); - click('[data-plugin-spread-rule-remove="finishRate"]'); - expect(finishRule?.hidden).toBe(true); - expect(finishRule?.style.display).toBe("none"); - 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"]', - "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"]', - "2" - ); - }); - - 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 does not render a metric search input", 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"]'); - - expect( - document.querySelector('[data-plugin-spread-metric-catalog-search="input"]') - ).toBeNull(); - }); - - 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"]'); + clickMetricCatalogAction(field); expect(catalogPanel?.hidden).toBe(true); + expect(findMetricRule(field)).not.toBeNull(); + expect(document.querySelectorAll("[data-plugin-spread-filter]")).toHaveLength(0); + expect(findMetricCatalogAction(field)?.disabled).toBe(true); + expect(findMetricCatalogAction(field)?.textContent).toBe("已添加"); - Object.defineProperty(window, "innerWidth", { - configurable: true, - value: originalInnerWidth - }); - window.dispatchEvent(new Event("resize")); + clickMetricRuleRemove(field); + expect(findMetricRule(field)).toBeNull(); + expect(findMetricCatalogAction(field)?.disabled).toBe(false); + expect(findMetricCatalogAction(field)?.textContent).toBe("添加"); }); - test("reads selected spread metrics as independent validated rules", async () => { + test("reads field rules with selectable greater-than-or-equal and less-than-or-equal operators", 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, readToolbarSpreadFilter } = await import( "../src/content/market/plugin-toolbar" ); - const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers()); + const field = "内容数据-个人视频-近30天-完播率"; + const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers(), [ + { field, group: "内容数据" } + ]); + click('[data-plugin-metric-catalog-trigger="button"]'); + clickMetricCatalogAction(field); expect(readToolbarSpreadFilter(toolbar)).toEqual({ - error: "请输入有效的完播率筛选阈值" + error: `请输入有效的${field}筛选数值` }); - setInputValue('[data-plugin-spread-threshold="finishRate"]', "30"); - 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"); - + setInputValueForMetric(field, "30"); + setMetricOperator(field, "lte"); expect(readToolbarSpreadFilter(toolbar)).toEqual({ filter: { - rules: [ - { - config: { - flowType: 0, - onlyAssign: true, - range: 2, - type: 2 - }, - metric: "finishRate", - threshold: 30 - }, - { - config: { - flowType: 0, - onlyAssign: true, - range: 2, - type: 2 - }, - metric: "interactionRate", - threshold: 5 - } - ] + rules: [{ field, operator: "lte", threshold: 30 }] } }); }); + test("busy state disables metric search, catalog actions, and added rule controls", async () => { + document.body.innerHTML = buildMarketFixture(); + const { ensurePluginToolbar, setToolbarBusyState } = await import( + "../src/content/market/plugin-toolbar" + ); + const field = "内容数据-个人视频-近30天-互动率"; + const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers(), [ + { field, group: "内容数据" } + ]); + click('[data-plugin-metric-catalog-trigger="button"]'); + clickMetricCatalogAction(field); + setToolbarBusyState(toolbar, true); + + expect( + (document.querySelector('[data-plugin-metric-catalog-trigger="button"]') as HTMLButtonElement | null)?.disabled + ).toBe(true); + expect( + (document.querySelector('[data-plugin-metric-catalog-search="input"]') as HTMLInputElement | null)?.disabled + ).toBe(true); + expect(findMetricRuleRemove(field)?.disabled).toBe(true); + expect(findMetricRuleOperator(field)?.disabled).toBe(true); + }); + test("audience profile export requires selected creators outside of the all range", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } @@ -2551,7 +2348,7 @@ describe("market-content-entry", () => { expect(submitBatch.mock.calls[0]?.[0]).not.toHaveProperty("batchId"); }); - test("batch submit applies all independent spread metric rules", async () => { + test("batch submit applies selected field rules using their fixed content-data headers", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "a", authorName: "Alpha", price21To60s: "450000" }, { authorId: "b", authorName: "Beta", price21To60s: "70000" } @@ -2573,17 +2370,12 @@ describe("market-content-entry", () => { } ]); const submitBatch = vi.fn(async () => ({ ok: true })); - const loadSpreadFilterMetrics = vi.fn(async ( - spreadAuthorId: string, - config: SpreadInfoConfig - ) => { - if (config.type === 2) { - return { - finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%" - }; - } + const loadSpreadMetrics = vi.fn(async (spreadAuthorId: string) => { return { - interactionRate: spreadAuthorId === "spread-a" ? "6%" : "4%" + "内容数据-个人视频-近30天-互动率": + spreadAuthorId === "spread-a" ? "6%" : "4%", + "内容数据-个人视频-近30天-完播率": + spreadAuthorId === "spread-a" ? "35%" : "20%" }; }); @@ -2599,7 +2391,7 @@ describe("market-content-entry", () => { success: false, reason: "request-failed" }), - loadSpreadFilterMetrics, + loadSpreadMetrics, promptBatchName: () => "筛选批次", submitBatch, window @@ -2608,13 +2400,14 @@ describe("market-content-entry", () => { await controller.ready; setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); - enableSpreadMetric("finishRate"); - enableSpreadMetric("interactionRate"); - 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"); + const finishField = "内容数据-个人视频-近30天-完播率"; + const interactionField = "内容数据-个人视频-近30天-互动率"; + click('[data-plugin-metric-catalog-trigger="button"]'); + clickMetricCatalogAction(finishField); + click('[data-plugin-metric-catalog-trigger="button"]'); + clickMetricCatalogAction(interactionField); + setInputValueForMetric(finishField, "30"); + setInputValueForMetric(interactionField, "5"); click('[data-plugin-batch-submit="button"]'); await waitForMockCall(submitBatch, 80, 50); @@ -5110,46 +4903,68 @@ function click(selector: string) { element.click(); } -function removeDefaultSpreadMetricFilter() { - click('[data-plugin-spread-rule-remove="finishRate"]'); +function findMetricCatalogAction(field: string): HTMLButtonElement | null { + return Array.from( + document.querySelectorAll("[data-plugin-metric-catalog-action]") + ).find((element) => element.dataset.pluginMetricCatalogAction === field) ?? null; } -function enableSpreadMetric(metric: "finishRate" | "interactionRate") { - const selector = `[data-plugin-spread-metric="${metric}"]`; - const input = document.querySelector(selector) as HTMLInputElement | null; - if (!input) { - throw new Error(`Missing spread metric toggle: ${metric}`); - } - - input.checked = true; - dispatchChange(selector); +function findMetricCatalogItem(field: string): HTMLElement | null { + return Array.from( + document.querySelectorAll("[data-plugin-metric-catalog-item]") + ).find((element) => element.dataset.pluginMetricCatalogItem === field) ?? null; } -function readSpreadRuleSelect( - metric: "finishRate" | "interactionRate", - field: "type" | "onlyAssign" | "flowType" | "range" -): HTMLSelectElement { - const selector = - `[data-plugin-spread-rule="${metric}"] ` + - `[data-plugin-spread-filter="${field}"]`; - const select = document.querySelector(selector) as HTMLSelectElement | null; - if (!select) { - throw new Error(`Missing spread rule select: ${metric}.${field}`); - } - - return select; +function findMetricRule(field: string): HTMLElement | null { + return Array.from( + document.querySelectorAll("[data-plugin-metric-filter-rule]") + ).find((element) => element.dataset.pluginMetricFilterRule === field) ?? null; } -function setSpreadRuleSelect( - metric: "finishRate" | "interactionRate", - field: "type" | "onlyAssign" | "flowType" | "range", - value: string -) { - const select = readSpreadRuleSelect(metric, field); +function findMetricRuleRemove(field: string): HTMLButtonElement | null { + return findMetricRule(field)?.querySelector( + "[data-plugin-metric-filter-remove]" + ) as HTMLButtonElement | null; +} + +function findMetricRuleOperator(field: string): HTMLSelectElement | null { + return findMetricRule(field)?.querySelector( + "[data-plugin-metric-filter-operator]" + ) as HTMLSelectElement | null; +} + +function clickMetricCatalogAction(field: string) { + const action = findMetricCatalogAction(field); + if (!action) throw new Error(`Missing metric catalog action: ${field}`); + action.click(); +} + +function clickMetricRuleRemove(field: string) { + const removeButton = findMetricRuleRemove(field); + if (!removeButton) throw new Error(`Missing metric rule remove button: ${field}`); + removeButton.click(); +} + +function setInputValueForMetric(field: string, value: string) { + const input = findMetricRule(field)?.querySelector( + "[data-plugin-metric-filter-threshold]" + ) as HTMLInputElement | null; + if (!input) throw new Error(`Missing metric threshold input: ${field}`); + input.value = value; + input.dispatchEvent(new Event("input")); +} + +function setMetricOperator(field: string, value: "gte" | "lte") { + const select = findMetricRuleOperator(field); + if (!select) throw new Error(`Missing metric operator: ${field}`); select.value = value; select.dispatchEvent(new Event("change")); } +function removeDefaultSpreadMetricFilter() { + // Metric filters now start empty, so batch-submit tests need no cleanup. +} + function createNoopToolbarHandlers() { return { onConfigureAudienceProfileFields: vi.fn(), diff --git a/tests/metric-filter.test.ts b/tests/metric-filter.test.ts new file mode 100644 index 0000000..96b156e --- /dev/null +++ b/tests/metric-filter.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "vitest"; + +import { + listNumericMetricFilterGroups, + matchesMetricFilterRule, + parseDisplayNumber +} from "../src/content/market/metric-filter"; + +describe("metric-filter", () => { + test("keeps only numeric list fields while retaining numeric API and profile groups", () => { + const groups = listNumericMetricFilterGroups([ + "达人信息", + "粉丝数", + "互动率", + "完播率", + "内容主题", + "21-60s报价" + ]); + const listFields = groups.find((group) => group.label === "列表字段"); + + expect(listFields?.headers).toEqual([ + "粉丝数", + "互动率", + "完播率", + "21-60s报价" + ]); + expect(groups.map((group) => group.label)).toEqual(expect.arrayContaining([ + "内容数据", + "效果预估", + "观众画像", + "粉丝画像", + "铁粉画像" + ])); + }); + + test("compares displayed percentages, prices, and wan-unit values with both operators", () => { + expect(parseDisplayNumber("28.5%")).toBe(28.5); + expect(parseDisplayNumber("¥12,000")).toBe(12000); + expect(parseDisplayNumber("6.2w")).toBe(62000); + expect(parseDisplayNumber("缺失")).toBeNull(); + + expect(matchesMetricFilterRule( + { 完播率: "28.5%" }, + { field: "完播率", operator: "gte", threshold: 28 } + )).toBe(true); + expect(matchesMetricFilterRule( + { 完播率: "28.5%" }, + { field: "完播率", operator: "lte", threshold: 28 } + )).toBe(false); + }); +});