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(
marketListHeaders: string[] = []
): AudienceProfileCsvFieldGroup[] {
+64 -63
View File
@@ -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<AudienceProfileResult>;
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
loadSpreadFilterMetrics?: (
spreadAuthorId: string,
config: SpreadInfoConfig
) => Promise<MappedSpreadInfoResponse>;
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<MarketRecord[]> {
if (!filter || filter.rules.length === 0) {
return records;
}
const normalizedRules = filter.rules.map((rule) => ({
...rule,
config: normalizeSpreadInfoConfig(rule.config)
}));
const configsByKey = new Map<string, SpreadInfoConfig>();
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<string>();
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<string, MappedSpreadInfoResponse>();
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
)
);
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<void> {
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
toolbar = ensurePluginToolbar(
options.document,
toolbarHandlers,
readMetricFilterDefinitions()
);
if (isPluginToolbarMounted(toolbar.root, options.document)) {
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[];
}
export type MetricFilterOperator = "gte" | "lte";
export interface MetricFilterRule {
field: string;
operator: MetricFilterOperator;
threshold: number;
}
export interface MetricThresholdFilter {
rules: MetricFilterRule[];
}
export type MarketSortField =
| keyof Required<AfterSearchRates>
| keyof Required<BackendMetrics>;
+143 -328
View File
@@ -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<HTMLButtonElement>("[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<HTMLElement>("[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<HTMLElement>("[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(),
+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);
});
});