feat: make metric filters field-driven

This commit is contained in:
wxs
2026-07-27 17:58:10 +08:00
parent e03dedd91e
commit 53db9b0777
7 changed files with 783 additions and 1576 deletions
@@ -127,6 +127,20 @@ export function listAudienceProfileCsvHeaders(
]; ];
} }
export function buildAudienceProfileFieldValues(
row: AudienceProfileExportRow
): Record<string, string> {
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( export function listAudienceProfileSelectableFieldGroups(
marketListHeaders: string[] = [] marketListHeaders: string[] = []
): AudienceProfileCsvFieldGroup[] { ): AudienceProfileCsvFieldGroup[] {
+64 -63
View File
@@ -4,6 +4,7 @@ import {
listRateCsvHeaders listRateCsvHeaders
} from "./csv-exporter"; } from "./csv-exporter";
import { import {
buildAudienceProfileFieldValues,
buildAudienceProfileCsv, buildAudienceProfileCsv,
listAudienceProfileSelectableFieldGroups, listAudienceProfileSelectableFieldGroups,
type AudienceProfileCsvOptions type AudienceProfileCsvOptions
@@ -52,16 +53,16 @@ import { applyFilterAndSort } from "./filter-sort-controller";
import { createMarketApiClient } from "./api-client"; import { createMarketApiClient } from "./api-client";
import { createExportRangeController } from "./export-range-controller"; import { createExportRangeController } from "./export-range-controller";
import { ensurePluginToolbar, isPluginToolbarMounted } from "./plugin-toolbar"; import { ensurePluginToolbar, isPluginToolbarMounted } from "./plugin-toolbar";
import {
listNumericMetricFilterDefinitions,
matchesMetricFilterRule
} from "./metric-filter";
import { createSilentExportController } from "./silent-export-controller"; import { createSilentExportController } from "./silent-export-controller";
import { import {
buildSpreadInfoConfigKey,
createSpreadInfoClient, createSpreadInfoClient,
DEFAULT_SPREAD_INFO_CONFIGS, DEFAULT_SPREAD_INFO_CONFIGS,
filterUnloadedSpreadInfoConfigs, filterUnloadedSpreadInfoConfigs,
matchesSpreadMetricRule, selectSpreadInfoConfigsForHeaders
normalizeSpreadInfoConfig,
selectSpreadInfoConfigsForHeaders,
type MappedSpreadInfoResponse
} from "./spread-info"; } from "./spread-info";
import { import {
readToolbarExportTarget, readToolbarExportTarget,
@@ -88,9 +89,8 @@ import type {
MarketRecord, MarketRecord,
MarketRowSnapshot, MarketRowSnapshot,
MarketSortState, MarketSortState,
MetricThresholdFilter,
SpreadInfoConfig, SpreadInfoConfig,
SpreadMetricFilterRule,
SpreadThresholdFilter
} from "./types"; } from "./types";
interface MutationObserverLike { interface MutationObserverLike {
@@ -127,10 +127,6 @@ export interface CreateMarketControllerOptions {
target: AudienceProfileRequestTarget target: AudienceProfileRequestTarget
) => Promise<AudienceProfileResult>; ) => Promise<AudienceProfileResult>;
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>; loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
loadSpreadFilterMetrics?: (
spreadAuthorId: string,
config: SpreadInfoConfig
) => Promise<MappedSpreadInfoResponse>;
loadSpreadMetrics?: ( loadSpreadMetrics?: (
spreadAuthorId: string, spreadAuthorId: string,
configs?: SpreadInfoConfig[] configs?: SpreadInfoConfig[]
@@ -169,9 +165,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
const resultStore = options.resultStore ?? createMarketResultStore(); const resultStore = options.resultStore ?? createMarketResultStore();
const loadAuthorMetrics = const loadAuthorMetrics =
options.loadAuthorMetrics ?? marketApiClient.loadAuthorAseInfo; options.loadAuthorMetrics ?? marketApiClient.loadAuthorAseInfo;
const loadSpreadFilterMetrics =
options.loadSpreadFilterMetrics ??
spreadInfoClient.loadAuthorSpreadMetricSnapshot;
const loadSpreadMetrics = const loadSpreadMetrics =
options.loadSpreadMetrics ?? spreadInfoClient.loadAuthorSpreadMetrics; options.loadSpreadMetrics ?? spreadInfoClient.loadAuthorSpreadMetrics;
const searchBackendMetrics = const searchBackendMetrics =
@@ -520,7 +513,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
setToolbarBusyState(toolbar, true); setToolbarBusyState(toolbar, true);
try { try {
const hasSelectedAuthors = selectedAuthorIds.size > 0; const hasSelectedAuthors = selectedAuthorIds.size > 0;
const resolvedRecords = await applySpreadThresholdFilter( const resolvedRecords = await applyMetricThresholdFilter(
await exportRecords( await exportRecords(
exportTarget.target, exportTarget.target,
hasSelectedAuthors ? "提交已选达人中" : "提交中", hasSelectedAuthors ? "提交已选达人中" : "提交中",
@@ -566,7 +559,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
} }
} }
}; };
toolbar = ensurePluginToolbar(options.document, toolbarHandlers); toolbar = ensurePluginToolbar(
options.document,
toolbarHandlers,
readMetricFilterDefinitions()
);
const ready = (async () => { const ready = (async () => {
await runSyncCycle(); await runSyncCycle();
@@ -739,7 +736,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
function applyCurrentView(): void { function applyCurrentView(): void {
runWithoutMutationSync(() => { runWithoutMutationSync(() => {
toolbar = ensurePluginToolbar(options.document, toolbarHandlers); toolbar = ensurePluginToolbar(
options.document,
toolbarHandlers,
readMetricFilterDefinitions()
);
const table = syncMarketTable(options.document); const table = syncMarketTable(options.document);
if (!table) { if (!table) {
return; return;
@@ -1125,60 +1126,49 @@ export function createMarketController(options: CreateMarketControllerOptions) {
return selectedRecords.length > 0 ? selectedRecords : records; return selectedRecords.length > 0 ? selectedRecords : records;
} }
async function applySpreadThresholdFilter( async function applyMetricThresholdFilter(
records: MarketRecord[], records: MarketRecord[],
filter: SpreadThresholdFilter | undefined filter: MetricThresholdFilter | undefined
): Promise<MarketRecord[]> { ): Promise<MarketRecord[]> {
if (!filter || filter.rules.length === 0) { if (!filter || filter.rules.length === 0) {
return records; return records;
} }
const normalizedRules = filter.rules.map((rule) => ({ const filterSelection = resolveAudienceProfileExportSelection(
...rule, filter.rules.map((rule) => rule.field)
config: normalizeSpreadInfoConfig(rule.config) );
})); const hydratedRecords = await hydrateExportRecords(records, {
const configsByKey = new Map<string, SpreadInfoConfig>(); includeBackendMetrics: filterSelection.includeBackendMetrics,
normalizedRules.forEach((rule) => { includeRates: filterSelection.includeRates,
configsByKey.set(buildSpreadInfoConfigKey(rule.config), rule.config); spreadInfoConfigs: filterSelection.spreadInfoConfigs
}); });
const matchedRecords: MarketRecord[] = [];
const matchedAuthorIds = new Set<string>(); for (let index = 0; index < hydratedRecords.length; index += 1) {
await Promise.all( const record = hydratedRecords[index];
records.map(async (record) => { setToolbarExportStatus(
const spreadAuthorId = record.spreadAuthorId; toolbar,
if (!spreadAuthorId) { `指标筛选 ${index + 1}/${hydratedRecords.length}...`
return; );
} 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<string, MappedSpreadInfoResponse>(); return matchedRecords;
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<string, MappedSpreadInfoResponse>
): boolean {
return rules.every((rule) =>
matchesSpreadMetricRule(
snapshots.get(buildSpreadInfoConfigKey(rule.config)) ?? {},
rule
)
);
} }
function filterRecordsBySelectionStrict(records: MarketRecord[]): MarketRecord[] { 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 { function readAudienceProfileSelectedHeaders(): string[] | undefined {
try { try {
const rawValue = options.window.localStorage?.getItem( const rawValue = options.window.localStorage?.getItem(
@@ -2049,7 +2046,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
} }
async function runSingleSyncCycle(): Promise<void> { async function runSingleSyncCycle(): Promise<void> {
toolbar = ensurePluginToolbar(options.document, toolbarHandlers); toolbar = ensurePluginToolbar(
options.document,
toolbarHandlers,
readMetricFilterDefinitions()
);
if (isPluginToolbarMounted(toolbar.root, options.document)) { if (isPluginToolbarMounted(toolbar.root, options.document)) {
toolbarRemountScheduled = false; toolbarRemountScheduled = false;
} }
+80
View File
@@ -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<string, string>,
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;
}
File diff suppressed because it is too large Load Diff
+12
View File
@@ -33,6 +33,18 @@ export interface SpreadThresholdFilter {
rules: SpreadMetricFilterRule[]; rules: SpreadMetricFilterRule[];
} }
export type MetricFilterOperator = "gte" | "lte";
export interface MetricFilterRule {
field: string;
operator: MetricFilterOperator;
threshold: number;
}
export interface MetricThresholdFilter {
rules: MetricFilterRule[];
}
export type MarketSortField = export type MarketSortField =
| keyof Required<AfterSearchRates> | keyof Required<AfterSearchRates>
| keyof Required<BackendMetrics>; | keyof Required<BackendMetrics>;
+143 -328
View File
@@ -442,10 +442,10 @@ describe("market-content-entry", () => {
'[data-plugin-toolbar-action-group="batch-submit"]' '[data-plugin-toolbar-action-group="batch-submit"]'
) as HTMLElement | null; ) as HTMLElement | null;
const metricCatalog = document.querySelector( const metricCatalog = document.querySelector(
'[data-plugin-spread-metric-catalog="root"]' '[data-plugin-metric-catalog="root"]'
) as HTMLElement | null; ) as HTMLElement | null;
const rulesGroup = document.querySelector( const rulesGroup = document.querySelector(
'[data-plugin-spread-rules="root"]' '[data-plugin-metric-rules="root"]'
) as HTMLElement | null; ) as HTMLElement | null;
const statusText = document.querySelector( const statusText = document.querySelector(
'[data-plugin-export-status="text"]' '[data-plugin-export-status="text"]'
@@ -472,13 +472,13 @@ describe("market-content-entry", () => {
'[data-plugin-batch-submit="button"]' '[data-plugin-batch-submit="button"]'
) as HTMLButtonElement | null; ) as HTMLButtonElement | null;
const operators = Array.from( const operators = Array.from(
document.querySelectorAll("[data-plugin-spread-threshold-operator]") document.querySelectorAll("[data-plugin-metric-filter-operator]")
).map((element) => element.textContent); ).map((element) => element.textContent);
const ruleRows = Array.from( const ruleRows = Array.from(
document.querySelectorAll("[data-plugin-spread-rule]") document.querySelectorAll("[data-plugin-metric-filter-rule]")
) as HTMLElement[]; ) as HTMLElement[];
const thresholdInputs = Array.from( const thresholdInputs = Array.from(
document.querySelectorAll("[data-plugin-spread-threshold]") document.querySelectorAll("[data-plugin-metric-filter-threshold]")
) as HTMLInputElement[]; ) as HTMLInputElement[];
expect(toolbar?.style.flexWrap).toBe("nowrap"); expect(toolbar?.style.flexWrap).toBe("nowrap");
@@ -515,32 +515,25 @@ describe("market-content-entry", () => {
expect(primaryRow?.style.justifyContent).toBe("flex-start"); expect(primaryRow?.style.justifyContent).toBe("flex-start");
expect(thresholdRow?.style.justifyContent).toBe("flex-start"); expect(thresholdRow?.style.justifyContent).toBe("flex-start");
expect(titles.map((element) => element.textContent)).toEqual([ expect(titles.map((element) => element.textContent)).toEqual([
"传播指标筛选" "指标筛选"
]); ]);
expect(titles[0]?.style.background).toBe("rgb(238, 245, 255)"); expect(titles[0]?.style.background).toBe("rgb(238, 245, 255)");
expect(operators).toEqual(["≥", "≥"]); expect(operators).toEqual([]);
expect( expect(
document.querySelector('[data-plugin-spread-metric-catalog-trigger="button"]') document.querySelector('[data-plugin-metric-catalog-trigger="button"]')
?.textContent ?.textContent
).toBe("添加筛选指标"); ).toBe("添加筛选指标");
expect( expect(
document.querySelector('[data-plugin-spread-metric-selected-count="text"]') document.querySelector('[data-plugin-metric-selected-count="text"]')
?.textContent ?.textContent
).toBe("已选 1 项"); ).toBe("已选 0 项");
expect( expect(
(document.querySelector( (document.querySelector(
'[data-plugin-spread-metric-catalog-panel="root"]' '[data-plugin-metric-catalog-panel="root"]'
) as HTMLElement | null)?.hidden ) as HTMLElement | null)?.hidden
).toBe(true); ).toBe(true);
expect(ruleRows.map((row) => row.hidden)).toEqual([false, true]); expect(ruleRows).toEqual([]);
expect(thresholdInputs.map((input) => input.placeholder)).toEqual([ expect(thresholdInputs).toEqual([]);
"",
""
]);
expect(thresholdInputs.map((input) => input.step)).toEqual([
"0.1",
"0.1"
]);
expect([ expect([
audienceProfileExportButton?.textContent, audienceProfileExportButton?.textContent,
audienceProfileByIdExportButton?.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(); document.body.innerHTML = buildMarketFixture();
const { createMarketController } = await import("../src/content/market/index"); const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({ const controller = trackController(createMarketController({
document, document,
loadAuthorMetrics: async () => ({ loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
success: false,
reason: "request-failed"
}),
window window
})); }));
await controller.ready; await controller.ready;
const catalogTrigger = document.querySelector( const field = "内容数据-个人视频-近30天-完播率";
'[data-plugin-spread-metric-catalog-trigger="button"]'
) as HTMLButtonElement | null;
const catalogPanel = document.querySelector( const catalogPanel = document.querySelector(
'[data-plugin-spread-metric-catalog-panel="root"]' '[data-plugin-metric-catalog-panel="root"]'
) as HTMLElement | null; ) 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?.hidden).toBe(true);
expect(catalogPanel?.style.zIndex).toBe("100"); expect(catalogPanel?.style.zIndex).toBe("100");
expect(finishRule?.hidden).toBe(false); expect(document.querySelector('[data-plugin-metric-selected-count="text"]')?.textContent).toBe("已选 0 项");
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");
catalogTrigger?.click(); click('[data-plugin-metric-catalog-trigger="button"]');
expect(catalogPanel?.hidden).toBe(false); const groupLabels = Array.from(
click('[data-plugin-spread-metric-catalog-action="interactionRate"]'); document.querySelectorAll('[data-plugin-metric-catalog-group] > strong')
setSpreadRuleSelect("finishRate", "type", "2"); ).map((element) => element.textContent);
setSpreadRuleSelect("finishRate", "onlyAssign", "true"); expect(groupLabels).toEqual(expect.arrayContaining([
setSpreadRuleSelect("finishRate", "flowType", "1"); "列表字段", "看后搜率", "秒思api数据", "内容数据", "效果预估", "观众画像", "粉丝画像", "铁粉画像"
setSpreadRuleSelect("interactionRate", "type", "2"); ]));
setSpreadRuleSelect("interactionRate", "onlyAssign", "true"); const search = document.querySelector(
setSpreadRuleSelect("interactionRate", "flowType", "1"); '[data-plugin-metric-catalog-search="input"]'
setSpreadRuleSelect("interactionRate", "type", "1"); ) 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( clickMetricCatalogAction(field);
"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"]');
expect(catalogPanel?.hidden).toBe(true); 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", { clickMetricRuleRemove(field);
configurable: true, expect(findMetricRule(field)).toBeNull();
value: originalInnerWidth expect(findMetricCatalogAction(field)?.disabled).toBe(false);
}); expect(findMetricCatalogAction(field)?.textContent).toBe("添加");
window.dispatchEvent(new Event("resize"));
}); });
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(); 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( const { ensurePluginToolbar, readToolbarSpreadFilter } = await import(
"../src/content/market/plugin-toolbar" "../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({ expect(readToolbarSpreadFilter(toolbar)).toEqual({
error: "请输入有效的完播率筛选阈值" error: `请输入有效的${field}筛选数值`
}); });
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30"); setInputValueForMetric(field, "30");
click('[data-plugin-spread-metric-catalog-trigger="button"]'); setMetricOperator(field, "lte");
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");
expect(readToolbarSpreadFilter(toolbar)).toEqual({ expect(readToolbarSpreadFilter(toolbar)).toEqual({
filter: { filter: {
rules: [ rules: [{ field, operator: "lte", threshold: 30 }]
{
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
}
]
} }
}); });
}); });
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 () => { test("audience profile export requires selected creators outside of the all range", async () => {
document.body.innerHTML = buildRealMarketFixture([ document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
@@ -2551,7 +2348,7 @@ describe("market-content-entry", () => {
expect(submitBatch.mock.calls[0]?.[0]).not.toHaveProperty("batchId"); 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([ document.body.innerHTML = buildRealMarketFixture([
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" }, { authorId: "a", authorName: "Alpha", price21To60s: "450000" },
{ authorId: "b", authorName: "Beta", price21To60s: "70000" } { authorId: "b", authorName: "Beta", price21To60s: "70000" }
@@ -2573,17 +2370,12 @@ describe("market-content-entry", () => {
} }
]); ]);
const submitBatch = vi.fn(async () => ({ ok: true })); const submitBatch = vi.fn(async () => ({ ok: true }));
const loadSpreadFilterMetrics = vi.fn(async ( const loadSpreadMetrics = vi.fn(async (spreadAuthorId: string) => {
spreadAuthorId: string,
config: SpreadInfoConfig
) => {
if (config.type === 2) {
return {
finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%"
};
}
return { 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, success: false,
reason: "request-failed" reason: "request-failed"
}), }),
loadSpreadFilterMetrics, loadSpreadMetrics,
promptBatchName: () => "筛选批次", promptBatchName: () => "筛选批次",
submitBatch, submitBatch,
window window
@@ -2608,13 +2400,14 @@ describe("market-content-entry", () => {
await controller.ready; await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "current"); setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]'); dispatchChange('[data-plugin-export-range="select"]');
enableSpreadMetric("finishRate"); const finishField = "内容数据-个人视频-近30天-完播率";
enableSpreadMetric("interactionRate"); const interactionField = "内容数据-个人视频-近30天-互动率";
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30"); click('[data-plugin-metric-catalog-trigger="button"]');
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5"); clickMetricCatalogAction(finishField);
setSpreadRuleSelect("finishRate", "type", "2"); click('[data-plugin-metric-catalog-trigger="button"]');
setSpreadRuleSelect("interactionRate", "type", "1"); clickMetricCatalogAction(interactionField);
setSpreadRuleSelect("interactionRate", "range", "3"); setInputValueForMetric(finishField, "30");
setInputValueForMetric(interactionField, "5");
click('[data-plugin-batch-submit="button"]'); click('[data-plugin-batch-submit="button"]');
await waitForMockCall(submitBatch, 80, 50); await waitForMockCall(submitBatch, 80, 50);
@@ -5110,46 +4903,68 @@ function click(selector: string) {
element.click(); element.click();
} }
function removeDefaultSpreadMetricFilter() { function findMetricCatalogAction(field: string): HTMLButtonElement | null {
click('[data-plugin-spread-rule-remove="finishRate"]'); return Array.from(
document.querySelectorAll<HTMLButtonElement>("[data-plugin-metric-catalog-action]")
).find((element) => element.dataset.pluginMetricCatalogAction === field) ?? null;
} }
function enableSpreadMetric(metric: "finishRate" | "interactionRate") { function findMetricCatalogItem(field: string): HTMLElement | null {
const selector = `[data-plugin-spread-metric="${metric}"]`; return Array.from(
const input = document.querySelector(selector) as HTMLInputElement | null; document.querySelectorAll<HTMLElement>("[data-plugin-metric-catalog-item]")
if (!input) { ).find((element) => element.dataset.pluginMetricCatalogItem === field) ?? null;
throw new Error(`Missing spread metric toggle: ${metric}`);
}
input.checked = true;
dispatchChange(selector);
} }
function readSpreadRuleSelect( function findMetricRule(field: string): HTMLElement | null {
metric: "finishRate" | "interactionRate", return Array.from(
field: "type" | "onlyAssign" | "flowType" | "range" document.querySelectorAll<HTMLElement>("[data-plugin-metric-filter-rule]")
): HTMLSelectElement { ).find((element) => element.dataset.pluginMetricFilterRule === field) ?? null;
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 setSpreadRuleSelect( function findMetricRuleRemove(field: string): HTMLButtonElement | null {
metric: "finishRate" | "interactionRate", return findMetricRule(field)?.querySelector(
field: "type" | "onlyAssign" | "flowType" | "range", "[data-plugin-metric-filter-remove]"
value: string ) as HTMLButtonElement | null;
) { }
const select = readSpreadRuleSelect(metric, field);
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.value = value;
select.dispatchEvent(new Event("change")); select.dispatchEvent(new Event("change"));
} }
function removeDefaultSpreadMetricFilter() {
// Metric filters now start empty, so batch-submit tests need no cleanup.
}
function createNoopToolbarHandlers() { function createNoopToolbarHandlers() {
return { return {
onConfigureAudienceProfileFields: vi.fn(), onConfigureAudienceProfileFields: vi.fn(),
+51
View File
@@ -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);
});
});