feat: add independent spread metric filters

This commit is contained in:
wxs
2026-07-10 17:48:02 +08:00
parent b48e244a99
commit 9326d676b7
5 changed files with 767 additions and 356 deletions
+50 -13
View File
@@ -31,7 +31,13 @@ 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 { createSilentExportController } from "./silent-export-controller"; import { createSilentExportController } from "./silent-export-controller";
import { createSpreadInfoClient, matchesSpreadThresholds } from "./spread-info"; import {
buildSpreadInfoConfigKey,
createSpreadInfoClient,
matchesSpreadMetricRule,
normalizeSpreadInfoConfig,
type MappedSpreadInfoResponse
} from "./spread-info";
import { import {
readToolbarExportTarget, readToolbarExportTarget,
readToolbarSpreadFilter, readToolbarSpreadFilter,
@@ -57,6 +63,8 @@ import type {
MarketRecord, MarketRecord,
MarketRowSnapshot, MarketRowSnapshot,
MarketSortState, MarketSortState,
SpreadInfoConfig,
SpreadMetricFilterRule,
SpreadThresholdFilter SpreadThresholdFilter
} from "./types"; } from "./types";
@@ -84,8 +92,8 @@ export interface CreateMarketControllerOptions {
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>; loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
loadSpreadFilterMetrics?: ( loadSpreadFilterMetrics?: (
spreadAuthorId: string, spreadAuthorId: string,
config: SpreadThresholdFilter["config"] config: SpreadInfoConfig
) => Promise<Record<string, string | undefined>>; ) => Promise<MappedSpreadInfoResponse>;
loadSpreadMetrics?: (spreadAuthorId: string) => Promise<Record<string, string>>; loadSpreadMetrics?: (spreadAuthorId: string) => Promise<Record<string, string>>;
searchBackendMetrics?: (starIds: string[]) => Promise< searchBackendMetrics?: (starIds: string[]) => Promise<
Array<BackendMetrics & { starId: string }> Array<BackendMetrics & { starId: string }>
@@ -802,10 +810,19 @@ export function createMarketController(options: CreateMarketControllerOptions) {
records: MarketRecord[], records: MarketRecord[],
filter: SpreadThresholdFilter | undefined filter: SpreadThresholdFilter | undefined
): Promise<MarketRecord[]> { ): Promise<MarketRecord[]> {
if (!filter) { if (!filter || filter.rules.length === 0) {
return records; 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 matchedAuthorIds = new Set<string>(); const matchedAuthorIds = new Set<string>();
await Promise.all( await Promise.all(
records.map(async (record) => { records.map(async (record) => {
@@ -814,21 +831,41 @@ export function createMarketController(options: CreateMarketControllerOptions) {
return; return;
} }
try { const snapshots = new Map<string, MappedSpreadInfoResponse>();
const metrics = await loadSpreadFilterMetrics( await Promise.all(
spreadAuthorId, Array.from(configsByKey.entries()).map(async ([key, config]) => {
filter.config try {
); snapshots.set(
if (matchesSpreadThresholds(metrics, filter.thresholds)) { key,
matchedAuthorIds.add(record.authorId); await loadSpreadFilterMetrics(spreadAuthorId, config)
} );
} catch {} } catch {
snapshots.set(key, {});
}
})
);
if (matchesAllSpreadMetricRules(normalizedRules, snapshots)) {
matchedAuthorIds.add(record.authorId);
}
}) })
); );
return records.filter((record) => matchedAuthorIds.has(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[] {
if (selectedAuthorIds.size === 0) { if (selectedAuthorIds.size === 0) {
return []; return [];
+367 -265
View File
@@ -1,6 +1,9 @@
import type { import type {
MarketExportScope, MarketExportScope,
MarketExportTarget, MarketExportTarget,
SpreadFilterMetric,
SpreadInfoConfig,
SpreadMetricFilterRule,
SpreadThresholdFilter SpreadThresholdFilter
} from "./types"; } from "./types";
@@ -12,12 +15,25 @@ export interface PluginToolbarHandlers {
onSubmitBatch(): Promise<void> | void; onSubmitBatch(): Promise<void> | void;
} }
type VisibleSpreadThresholdKey = Extract< interface SpreadMetricRuleDom {
keyof SpreadThresholdFilter["thresholds"], enabledInput: HTMLInputElement;
"finishRate" | "interactionRate" flowTypeSelect: HTMLSelectElement;
>; onlyAssignSelect: HTMLSelectElement;
rangeSelect: HTMLSelectElement;
root: HTMLElement;
thresholdInput: HTMLInputElement;
typeSelect: HTMLSelectElement;
}
type SpreadThresholdInputMap = Record<VisibleSpreadThresholdKey, HTMLInputElement>; type SpreadMetricRuleDomMap = Record<SpreadFilterMetric, SpreadMetricRuleDom>;
const SPREAD_FILTER_DEFINITIONS: ReadonlyArray<{
label: string;
metric: SpreadFilterMetric;
}> = [
{ label: "完播率", metric: "finishRate" },
{ label: "互动率", metric: "interactionRate" }
];
export interface PluginToolbarDom { export interface PluginToolbarDom {
audienceProfileByIdExportButton: HTMLButtonElement; audienceProfileByIdExportButton: HTMLButtonElement;
@@ -28,11 +44,7 @@ export interface PluginToolbarDom {
exportCustomPagesInput: HTMLInputElement; exportCustomPagesInput: HTMLInputElement;
exportRangeSelect: HTMLSelectElement; exportRangeSelect: HTMLSelectElement;
exportStatusText: HTMLElement; exportStatusText: HTMLElement;
spreadFilterFlowTypeSelect: HTMLSelectElement; spreadMetricRules: SpreadMetricRuleDomMap;
spreadFilterOnlyAssignSelect: HTMLSelectElement;
spreadFilterRangeSelect: HTMLSelectElement;
spreadFilterTypeSelect: HTMLSelectElement;
spreadThresholdInputs: SpreadThresholdInputMap;
root: HTMLElement; root: HTMLElement;
} }
@@ -59,7 +71,8 @@ export function ensurePluginToolbar(
if ( if (
existingRoot.querySelector( existingRoot.querySelector(
'[data-plugin-export-audience-profile-by-id="button"]' '[data-plugin-export-audience-profile-by-id="button"]'
) ) &&
existingRoot.querySelector('[data-plugin-spread-metric="finishRate"]')
) { ) {
ensureToolbarMounted(existingRoot, document); ensureToolbarMounted(existingRoot, document);
return readToolbarDom(existingRoot); return readToolbarDom(existingRoot);
@@ -126,31 +139,7 @@ export function ensurePluginToolbar(
exportStatusText.dataset.pluginExportStatus = "text"; exportStatusText.dataset.pluginExportStatus = "text";
applyStatusStyles(exportStatusText); applyStatusStyles(exportStatusText);
const spreadFilterTypeSelect = document.createElement("select"); const spreadMetricRules = createSpreadMetricRuleDoms(document);
spreadFilterTypeSelect.dataset.pluginSpreadFilter = "type";
appendOption(spreadFilterTypeSelect, "1", "个人视频");
appendOption(spreadFilterTypeSelect, "2", "星图视频");
spreadFilterTypeSelect.value = "1";
const spreadFilterOnlyAssignSelect = document.createElement("select");
spreadFilterOnlyAssignSelect.dataset.pluginSpreadFilter = "onlyAssign";
appendOption(spreadFilterOnlyAssignSelect, "false", "不限指派");
appendOption(spreadFilterOnlyAssignSelect, "true", "只看指派");
spreadFilterOnlyAssignSelect.value = "false";
const spreadFilterFlowTypeSelect = document.createElement("select");
spreadFilterFlowTypeSelect.dataset.pluginSpreadFilter = "flowType";
appendOption(spreadFilterFlowTypeSelect, "0", "不排除营销");
appendOption(spreadFilterFlowTypeSelect, "1", "排除营销");
spreadFilterFlowTypeSelect.value = "0";
const spreadFilterRangeSelect = document.createElement("select");
spreadFilterRangeSelect.dataset.pluginSpreadFilter = "range";
appendOption(spreadFilterRangeSelect, "2", "近30天");
appendOption(spreadFilterRangeSelect, "3", "近90天");
spreadFilterRangeSelect.value = "2";
const spreadThresholdInputs = createSpreadThresholdInputs(document);
const panel = document.createElement("div"); const panel = document.createElement("div");
panel.dataset.pluginToolbarPanel = "root"; panel.dataset.pluginToolbarPanel = "root";
@@ -177,27 +166,20 @@ export function ensurePluginToolbar(
batchSubmitButton batchSubmitButton
); );
const videoGroup = document.createElement("div");
videoGroup.dataset.pluginToolbarGroup = "video";
applyToolbarGroupStyles(videoGroup);
videoGroup.append(
createToolbarGroupTitle(document, "视频口径"),
spreadFilterTypeSelect,
spreadFilterOnlyAssignSelect,
spreadFilterFlowTypeSelect,
spreadFilterRangeSelect
);
const thresholdGroup = document.createElement("div");
thresholdGroup.dataset.pluginToolbarGroup = "thresholds";
applyThresholdGroupStyles(thresholdGroup);
thresholdGroup.append(
...createSpreadThresholdControls(document, spreadThresholdInputs)
);
const thresholdTitle = createToolbarGroupTitle(document, "传播指标筛选"); const thresholdTitle = createToolbarGroupTitle(document, "传播指标筛选");
firstRow.append(dataGroup, videoGroup, exportStatusText); const metricSelector = createSpreadMetricSelector(document, spreadMetricRules);
secondRow.append(thresholdTitle, thresholdGroup); 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);
firstRow.append(dataGroup, exportStatusText);
secondRow.append(thresholdTitle, metricSelector, rulesGroup, filterNote);
panel.append(firstRow, secondRow); panel.append(firstRow, secondRow);
root.append(panel); root.append(panel);
@@ -211,11 +193,7 @@ export function ensurePluginToolbar(
exportButton, exportButton,
exportCustomPagesInput, exportCustomPagesInput,
exportRangeSelect, exportRangeSelect,
spreadFilterFlowTypeSelect, spreadMetricRules
spreadFilterOnlyAssignSelect,
spreadFilterRangeSelect,
spreadFilterTypeSelect,
...spreadThresholdInputs
}); });
ensureToolbarMounted(root, document); ensureToolbarMounted(root, document);
@@ -234,42 +212,6 @@ export function ensurePluginToolbar(
batchSubmitButton.addEventListener("click", () => { batchSubmitButton.addEventListener("click", () => {
void handlers.onSubmitBatch(); void handlers.onSubmitBatch();
}); });
exportRangeSelect.addEventListener("change", () => {
syncCustomPagesInputVisibility({
batchSubmitButton,
audienceProfileFieldButton,
audienceProfileByIdExportButton,
audienceProfileExportButton,
exportButton,
exportCustomPagesInput,
exportRangeSelect,
exportStatusText,
root,
spreadFilterFlowTypeSelect,
spreadFilterOnlyAssignSelect,
spreadFilterRangeSelect,
spreadFilterTypeSelect,
spreadThresholdInputs
});
});
spreadFilterTypeSelect.addEventListener("change", () => {
syncSpreadFilterControlState({
audienceProfileByIdExportButton,
audienceProfileExportButton,
audienceProfileFieldButton,
batchSubmitButton,
exportButton,
exportCustomPagesInput,
exportRangeSelect,
exportStatusText,
root,
spreadFilterFlowTypeSelect,
spreadFilterOnlyAssignSelect,
spreadFilterRangeSelect,
spreadFilterTypeSelect,
spreadThresholdInputs
});
});
const toolbarDom = { const toolbarDom = {
audienceProfileExportButton, audienceProfileExportButton,
@@ -280,15 +222,27 @@ export function ensurePluginToolbar(
exportCustomPagesInput, exportCustomPagesInput,
exportRangeSelect, exportRangeSelect,
exportStatusText, exportStatusText,
spreadFilterFlowTypeSelect, spreadMetricRules,
spreadFilterOnlyAssignSelect,
spreadFilterRangeSelect,
spreadFilterTypeSelect,
spreadThresholdInputs,
root root
} satisfies PluginToolbarDom; } satisfies PluginToolbarDom;
exportRangeSelect.addEventListener("change", () => {
syncCustomPagesInputVisibility(toolbarDom);
});
SPREAD_FILTER_DEFINITIONS.forEach(({ metric }) => {
const rule = spreadMetricRules[metric];
rule.enabledInput.addEventListener("change", () => {
syncSpreadMetricRuleState(rule);
syncSpreadFilterNote(toolbarDom);
});
rule.typeSelect.addEventListener("change", () => {
syncSpreadMetricVideoConstraints(rule);
});
});
syncCustomPagesInputVisibility(toolbarDom); syncCustomPagesInputVisibility(toolbarDom);
syncSpreadFilterControlState(toolbarDom); syncAllSpreadMetricRules(toolbarDom);
return toolbarDom; return toolbarDom;
} }
@@ -304,94 +258,161 @@ function appendOption(
select.appendChild(option); select.appendChild(option);
} }
function createSpreadThresholdInputs( function createSpreadMetricRuleDoms(
document: Document document: Document
): SpreadThresholdInputMap { ): SpreadMetricRuleDomMap {
return Object.fromEntries(
SPREAD_FILTER_DEFINITIONS.map(({ label, metric }) => [
metric,
createSpreadMetricRuleDom(document, metric, label)
])
) as unknown as SpreadMetricRuleDomMap;
}
function createSpreadMetricRuleDom(
document: Document,
metric: SpreadFilterMetric,
label: string
): SpreadMetricRuleDom {
const enabledInput = document.createElement("input");
enabledInput.type = "checkbox";
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";
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);
root.append(
metricLabel,
thresholdControl,
typeSelect,
onlyAssignSelect,
flowTypeSelect,
rangeSelect
);
return { return {
finishRate: createSpreadThresholdInput(document, "finishRate"), enabledInput,
interactionRate: createSpreadThresholdInput(document, "interactionRate") flowTypeSelect,
onlyAssignSelect,
rangeSelect,
root,
thresholdInput,
typeSelect
}; };
} }
function createSpreadThresholdInput( function createSpreadMetricSelector(
document: Document, document: Document,
key: VisibleSpreadThresholdKey rules: SpreadMetricRuleDomMap
): HTMLInputElement { ): HTMLElement {
const input = document.createElement("input"); const selector = document.createElement("div");
input.type = "number"; selector.dataset.pluginSpreadMetrics = "root";
input.min = "0"; applySpreadMetricSelectorStyles(selector);
input.step = getSpreadThresholdStep(key);
input.dataset.pluginSpreadThreshold = key;
return input;
}
function getSpreadThresholdStep(
key: VisibleSpreadThresholdKey
): string {
return key === "finishRate" || key === "interactionRate" ? "0.1" : "1";
}
function createSpreadThresholdControls(
document: Document,
inputs: SpreadThresholdInputMap
): HTMLElement[] {
const controls: HTMLElement[] = [];
const entries: Array<[string, string, HTMLInputElement]> = [
["完播率", "%", inputs.finishRate],
["互动率", "%", inputs.interactionRate]
];
entries.forEach(([label, unit, input], index) => {
if (index > 0) {
controls.push(createSpreadThresholdConjunction(document));
}
const wrapper = document.createElement("label");
wrapper.dataset.pluginSpreadThresholdControl = input.dataset.pluginSpreadThreshold;
applySpreadThresholdControlStyles(wrapper);
SPREAD_FILTER_DEFINITIONS.forEach(({ label, metric }) => {
const option = document.createElement("label");
applySpreadMetricOptionStyles(option);
const labelText = document.createElement("span"); const labelText = document.createElement("span");
labelText.textContent = label; labelText.textContent = label;
option.append(rules[metric].enabledInput, labelText);
const operator = document.createElement("b"); selector.appendChild(option);
operator.dataset.pluginSpreadThresholdOperator = "gte";
operator.textContent = "≥";
const unitText = document.createElement("span");
unitText.dataset.pluginSpreadThresholdUnit = input.dataset.pluginSpreadThreshold;
unitText.textContent = unit;
wrapper.append(labelText, operator, input, unitText);
controls.push(wrapper);
}); });
return controls; return selector;
} }
function createSpreadThresholdConjunction(document: Document): HTMLElement { function createSpreadFilterNote(document: Document): HTMLElement {
const conjunction = document.createElement("span"); const note = document.createElement("span");
conjunction.dataset.pluginSpreadThresholdConjunction = "and"; note.dataset.pluginSpreadFilterNote = "and";
conjunction.textContent = ""; note.textContent = "全部规则都达标才保留达人";
applySpreadThresholdConjunctionStyles(conjunction); note.hidden = true;
return conjunction; applySpreadFilterNoteStyles(note);
return note;
} }
function readSpreadThresholdInputs( function readSpreadMetricRuleDoms(root: HTMLElement): SpreadMetricRuleDomMap {
root: HTMLElement return Object.fromEntries(
): SpreadThresholdInputMap { SPREAD_FILTER_DEFINITIONS.map(({ metric }) => {
return { const ruleRoot = root.querySelector(
finishRate: readSpreadThresholdInput(root, "finishRate"), `[data-plugin-spread-rule="${metric}"]`
interactionRate: readSpreadThresholdInput(root, "interactionRate") ) as HTMLElement;
}; return [
} metric,
{
function readSpreadThresholdInput( enabledInput: root.querySelector(
root: HTMLElement, `[data-plugin-spread-metric="${metric}"]`
key: VisibleSpreadThresholdKey ) as HTMLInputElement,
): HTMLInputElement { flowTypeSelect: ruleRoot.querySelector(
return root.querySelector( '[data-plugin-spread-filter="flowType"]'
`[data-plugin-spread-threshold="${key}"]` ) as HTMLSelectElement,
) as HTMLInputElement; onlyAssignSelect: ruleRoot.querySelector(
'[data-plugin-spread-filter="onlyAssign"]'
) as HTMLSelectElement,
rangeSelect: ruleRoot.querySelector(
'[data-plugin-spread-filter="range"]'
) as HTMLSelectElement,
root: ruleRoot,
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 readToolbarDom(root: HTMLElement): PluginToolbarDom { function readToolbarDom(root: HTMLElement): PluginToolbarDom {
@@ -420,23 +441,11 @@ function readToolbarDom(root: HTMLElement): PluginToolbarDom {
exportStatusText: root.querySelector( exportStatusText: root.querySelector(
'[data-plugin-export-status="text"]' '[data-plugin-export-status="text"]'
) as HTMLElement, ) as HTMLElement,
spreadFilterFlowTypeSelect: root.querySelector( spreadMetricRules: readSpreadMetricRuleDoms(root),
'[data-plugin-spread-filter="flowType"]'
) as HTMLSelectElement,
spreadFilterOnlyAssignSelect: root.querySelector(
'[data-plugin-spread-filter="onlyAssign"]'
) as HTMLSelectElement,
spreadFilterRangeSelect: root.querySelector(
'[data-plugin-spread-filter="range"]'
) as HTMLSelectElement,
spreadFilterTypeSelect: root.querySelector(
'[data-plugin-spread-filter="type"]'
) as HTMLSelectElement,
spreadThresholdInputs: readSpreadThresholdInputs(root),
root root
} satisfies PluginToolbarDom; } satisfies PluginToolbarDom;
syncCustomPagesInputVisibility(toolbarDom); syncCustomPagesInputVisibility(toolbarDom);
syncSpreadFilterControlState(toolbarDom); syncAllSpreadMetricRules(toolbarDom);
return toolbarDom; return toolbarDom;
} }
@@ -498,44 +507,34 @@ export function readToolbarExportTarget(
export function readToolbarSpreadFilter( export function readToolbarSpreadFilter(
toolbar: PluginToolbarDom toolbar: PluginToolbarDom
): { error?: string; filter?: SpreadThresholdFilter } { ): { error?: string; filter?: SpreadThresholdFilter } {
const thresholds: SpreadThresholdFilter["thresholds"] = {}; const rules: SpreadMetricFilterRule[] = [];
for (const [key, input] of Object.entries(toolbar.spreadThresholdInputs)) { for (const { label, metric } of SPREAD_FILTER_DEFINITIONS) {
const trimmedValue = input.value.trim(); const ruleDom = toolbar.spreadMetricRules[metric];
if (!trimmedValue) { clearSpreadMetricRuleValidation(ruleDom);
if (!ruleDom.enabledInput.checked) {
continue; continue;
} }
const numericValue = Number(trimmedValue); const trimmedValue = ruleDom.thresholdInput.value.trim();
if (!Number.isFinite(numericValue) || numericValue < 0) { const threshold = Number(trimmedValue);
if (!trimmedValue || !Number.isFinite(threshold) || threshold < 0) {
markSpreadMetricRuleInvalid(ruleDom);
return { return {
error: "请输入有效筛选阈值" error: `请输入有效${label}筛选阈值`
}; };
} }
thresholds[key as keyof SpreadThresholdFilter["thresholds"]] = numericValue; rules.push({
config: readSpreadMetricConfig(ruleDom),
metric,
threshold
});
} }
if (Object.keys(thresholds).length === 0) {
return {};
}
const type = Number(toolbar.spreadFilterTypeSelect.value) === 2 ? 2 : 1;
return { return {
filter: { filter: {
config: { rules
flowType:
type === 1
? 0
: Number(toolbar.spreadFilterFlowTypeSelect.value) === 1
? 1
: 0,
onlyAssign:
type === 1 ? false : toolbar.spreadFilterOnlyAssignSelect.value === "true",
range: Number(toolbar.spreadFilterRangeSelect.value) === 3 ? 3 : 2,
type
},
thresholds
} }
}; };
} }
@@ -552,16 +551,19 @@ export function setToolbarBusyState(
toolbar.exportButton, toolbar.exportButton,
toolbar.exportRangeSelect, toolbar.exportRangeSelect,
toolbar.exportCustomPagesInput, toolbar.exportCustomPagesInput,
toolbar.spreadFilterTypeSelect, ...Object.values(toolbar.spreadMetricRules).flatMap((rule) => [
toolbar.spreadFilterOnlyAssignSelect, rule.enabledInput,
toolbar.spreadFilterFlowTypeSelect, rule.thresholdInput,
toolbar.spreadFilterRangeSelect, rule.typeSelect,
...Object.values(toolbar.spreadThresholdInputs) rule.onlyAssignSelect,
rule.flowTypeSelect,
rule.rangeSelect
])
].forEach((element) => { ].forEach((element) => {
element.disabled = isBusy; element.disabled = isBusy;
}); });
if (!isBusy) { if (!isBusy) {
syncSpreadFilterControlState(toolbar); syncAllSpreadMetricRules(toolbar);
} }
} }
@@ -577,14 +579,68 @@ function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
toolbar.exportCustomPagesInput.hidden = toolbar.exportRangeSelect.value !== "custom"; toolbar.exportCustomPagesInput.hidden = toolbar.exportRangeSelect.value !== "custom";
} }
function syncSpreadFilterControlState(toolbar: PluginToolbarDom): void { function syncAllSpreadMetricRules(toolbar: PluginToolbarDom): void {
const isPersonalVideo = toolbar.spreadFilterTypeSelect.value !== "2"; Object.values(toolbar.spreadMetricRules).forEach(syncSpreadMetricRuleState);
if (isPersonalVideo) { syncSpreadFilterNote(toolbar);
toolbar.spreadFilterOnlyAssignSelect.value = "false"; }
toolbar.spreadFilterFlowTypeSelect.value = "0";
function syncSpreadMetricRuleState(rule: SpreadMetricRuleDom): void {
rule.root.hidden = !rule.enabledInput.checked;
if (!rule.enabledInput.checked) {
rule.thresholdInput.value = "";
rule.typeSelect.value = "1";
rule.onlyAssignSelect.value = "false";
rule.flowTypeSelect.value = "0";
rule.rangeSelect.value = "2";
clearSpreadMetricRuleValidation(rule);
} }
toolbar.spreadFilterOnlyAssignSelect.disabled = isPersonalVideo; syncSpreadMetricVideoConstraints(rule);
toolbar.spreadFilterFlowTypeSelect.disabled = isPersonalVideo; }
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 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");
} }
function ensureToolbarMounted(root: HTMLElement, document: Document): void { function ensureToolbarMounted(root: HTMLElement, document: Document): void {
@@ -794,17 +850,61 @@ function applyToolbarGroupStyles(group: HTMLElement): void {
group.style.flexWrap = "nowrap"; group.style.flexWrap = "nowrap";
} }
function applyThresholdGroupStyles(group: HTMLElement): void { function applySpreadMetricSelectorStyles(selector: HTMLElement): void {
selector.style.display = "flex";
selector.style.alignItems = "center";
selector.style.gap = "12px";
selector.style.flex = "0 0 auto";
selector.style.whiteSpace = "nowrap";
}
function applySpreadMetricOptionStyles(option: HTMLElement): void {
option.style.display = "inline-flex";
option.style.alignItems = "center";
option.style.gap = "5px";
option.style.height = "32px";
option.style.color = "#344054";
option.style.fontSize = "12px";
option.style.fontWeight = "700";
option.style.whiteSpace = "nowrap";
}
function applySpreadRulesGroupStyles(group: HTMLElement): void {
group.style.display = "flex"; group.style.display = "flex";
group.style.alignItems = "center"; group.style.flexDirection = "column";
group.style.gap = "7px"; group.style.alignItems = "stretch";
group.style.gap = "6px";
group.style.minWidth = "0"; group.style.minWidth = "0";
group.style.flex = "1 1 auto"; group.style.flex = "1 1 auto";
group.style.flexWrap = "nowrap";
group.style.overflowX = "auto"; group.style.overflowX = "auto";
group.style.overflowY = "hidden"; group.style.overflowY = "hidden";
} }
function applySpreadMetricRuleStyles(rule: HTMLElement): void {
rule.style.display = "flex";
rule.style.alignItems = "center";
rule.style.gap = "7px";
rule.style.minWidth = "max-content";
rule.style.minHeight = "32px";
rule.style.whiteSpace = "nowrap";
}
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 createToolbarGroupTitle(document: Document, label: string): HTMLElement { function createToolbarGroupTitle(document: Document, label: string): HTMLElement {
const title = document.createElement("span"); const title = document.createElement("span");
title.dataset.pluginToolbarTitle = label; title.dataset.pluginToolbarTitle = label;
@@ -834,14 +934,8 @@ function applyNativeControlStyles(
exportButton: HTMLButtonElement; exportButton: HTMLButtonElement;
exportCustomPagesInput: HTMLInputElement; exportCustomPagesInput: HTMLInputElement;
exportRangeSelect: HTMLSelectElement; exportRangeSelect: HTMLSelectElement;
spreadFilterFlowTypeSelect: HTMLSelectElement; spreadMetricRules: SpreadMetricRuleDomMap;
spreadFilterOnlyAssignSelect: HTMLSelectElement; }
spreadFilterRangeSelect: HTMLSelectElement;
spreadFilterTypeSelect: HTMLSelectElement;
} & Record<
VisibleSpreadThresholdKey,
HTMLInputElement
>
): void { ): void {
const primaryButton = const primaryButton =
findButtonContainingText(document, "发布任务") ?? findButtonContainingText(document, "发布任务") ??
@@ -870,13 +964,31 @@ function applyNativeControlStyles(
button.style.whiteSpace = "nowrap"; button.style.whiteSpace = "nowrap";
}); });
const nativeControls = Array.from(Object.values(controls)).filter( const ruleControls = Object.values(controls.spreadMetricRules).flatMap(
(element): element is HTMLInputElement | HTMLSelectElement => (rule) => [
element instanceof document.defaultView!.HTMLInputElement || rule.enabledInput,
element instanceof document.defaultView!.HTMLSelectElement rule.thresholdInput,
rule.typeSelect,
rule.onlyAssignSelect,
rule.flowTypeSelect,
rule.rangeSelect
]
); );
const nativeControls = [
controls.exportCustomPagesInput,
controls.exportRangeSelect,
...ruleControls
];
nativeControls.forEach((element) => { 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.height = "32px";
element.style.border = "1px solid #d0d7de"; element.style.border = "1px solid #d0d7de";
element.style.borderRadius = "6px"; element.style.borderRadius = "6px";
@@ -890,22 +1002,15 @@ function applyNativeControlStyles(
controls.exportRangeSelect.style.minWidth = "104px"; controls.exportRangeSelect.style.minWidth = "104px";
controls.exportCustomPagesInput.style.width = "72px"; controls.exportCustomPagesInput.style.width = "72px";
[ ruleControls.forEach((element) => {
controls.spreadFilterTypeSelect, if (element instanceof document.defaultView!.HTMLSelectElement) {
controls.spreadFilterOnlyAssignSelect, element.style.minWidth = "84px";
controls.spreadFilterFlowTypeSelect, }
controls.spreadFilterRangeSelect
].forEach((select) => {
select.style.minWidth = "84px";
});
Object.values(controls).forEach((element) => {
if ( if (
element instanceof document.defaultView!.HTMLInputElement && element instanceof document.defaultView!.HTMLInputElement &&
element.dataset.pluginSpreadThreshold element.dataset.pluginSpreadThreshold
) { ) {
element.style.width = element.style.width = "58px";
element.dataset.pluginSpreadThreshold === "playMedian" ? "82px" : "58px";
element.style.minWidth = "0"; element.style.minWidth = "0";
element.style.height = "26px"; element.style.height = "26px";
element.style.border = "0"; element.style.border = "0";
@@ -945,14 +1050,6 @@ function applySpreadThresholdControlStyles(control: HTMLElement): void {
control.style.flex = "0 0 auto"; control.style.flex = "0 0 auto";
} }
function applySpreadThresholdConjunctionStyles(conjunction: HTMLElement): void {
conjunction.style.color = "#0f8a5f";
conjunction.style.fontSize = "12px";
conjunction.style.fontWeight = "900";
conjunction.style.whiteSpace = "nowrap";
conjunction.style.flex = "0 0 auto";
}
function applyStatusStyles(statusText: HTMLElement): void { function applyStatusStyles(statusText: HTMLElement): void {
statusText.style.color = "#64748b"; statusText.style.color = "#64748b";
statusText.style.fontSize = "12px"; statusText.style.fontSize = "12px";
@@ -1026,6 +1123,11 @@ function ensurePluginActionButtonTheme(document: Document): void {
color: #0f8a5f !important; color: #0f8a5f !important;
font-weight: 900 !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;
}
`; `;
document.head.appendChild(style); document.head.appendChild(style);
} }
+1 -17
View File
@@ -1,8 +1,7 @@
import type { import type {
SpreadInfoConfig, SpreadInfoConfig,
SpreadInfoMetrics, SpreadInfoMetrics,
SpreadMetricFilterRule, SpreadMetricFilterRule
SpreadMetricThresholds
} from "./types"; } from "./types";
interface FetchResponseLike { interface FetchResponseLike {
@@ -205,21 +204,6 @@ export function mapSpreadInfoResponse(
}; };
} }
export function matchesSpreadThresholds(
metrics: MappedSpreadInfoResponse,
thresholds: SpreadMetricThresholds
): boolean {
return Object.entries(thresholds).every(([key, threshold]) => {
if (typeof threshold !== "number" || !Number.isFinite(threshold)) {
return true;
}
const metricValue = metrics[key as keyof SpreadMetricThresholds];
const numericValue = readDisplayNumber(metricValue);
return numericValue !== null && numericValue >= threshold;
});
}
export function normalizeSpreadInfoConfig( export function normalizeSpreadInfoConfig(
config: SpreadInfoConfig config: SpreadInfoConfig
): SpreadInfoConfig { ): SpreadInfoConfig {
+1 -12
View File
@@ -29,19 +29,8 @@ export interface SpreadMetricFilterRule {
threshold: number; threshold: number;
} }
export interface SpreadMetricThresholds {
averageCommentCount?: number;
averageDuration?: number;
averageLikeCount?: number;
averageShareCount?: number;
finishRate?: number;
interactionRate?: number;
playMedian?: number;
}
export interface SpreadThresholdFilter { export interface SpreadThresholdFilter {
config: SpreadInfoConfig; rules: SpreadMetricFilterRule[];
thresholds: SpreadMetricThresholds;
} }
export type MarketSortField = export type MarketSortField =
+348 -49
View File
@@ -4,6 +4,7 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createMarketResultStore } from "../src/content/market/result-store"; import { createMarketResultStore } from "../src/content/market/result-store";
import type { SpreadInfoConfig } from "../src/content/market/types";
const disposers: Array<() => void> = []; const disposers: Array<() => void> = [];
@@ -415,11 +416,11 @@ describe("market-content-entry", () => {
const dataGroup = document.querySelector( const dataGroup = document.querySelector(
'[data-plugin-toolbar-group="data"]' '[data-plugin-toolbar-group="data"]'
) as HTMLElement | null; ) as HTMLElement | null;
const videoGroup = document.querySelector( const metricSelector = document.querySelector(
'[data-plugin-toolbar-group="video"]' '[data-plugin-spread-metrics="root"]'
) as HTMLElement | null; ) as HTMLElement | null;
const thresholdGroup = document.querySelector( const rulesGroup = document.querySelector(
'[data-plugin-toolbar-group="thresholds"]' '[data-plugin-spread-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"]'
@@ -433,12 +434,12 @@ describe("market-content-entry", () => {
const operators = Array.from( const operators = Array.from(
document.querySelectorAll("[data-plugin-spread-threshold-operator]") document.querySelectorAll("[data-plugin-spread-threshold-operator]")
).map((element) => element.textContent); ).map((element) => element.textContent);
const conjunctions = Array.from( const metricInputs = Array.from(
document.querySelectorAll("[data-plugin-spread-threshold-conjunction]") document.querySelectorAll("[data-plugin-spread-metric]")
).map((element) => element.textContent); ) as HTMLInputElement[];
const thresholdControls = Array.from( const ruleRows = Array.from(
document.querySelectorAll("[data-plugin-spread-threshold-control]") document.querySelectorAll("[data-plugin-spread-rule]")
); ) as HTMLElement[];
const thresholdInputs = Array.from( const thresholdInputs = Array.from(
document.querySelectorAll("[data-plugin-spread-threshold]") document.querySelectorAll("[data-plugin-spread-threshold]")
) as HTMLInputElement[]; ) as HTMLInputElement[];
@@ -450,22 +451,19 @@ describe("market-content-entry", () => {
expect(thresholdRow?.style.flexWrap).toBe("nowrap"); expect(thresholdRow?.style.flexWrap).toBe("nowrap");
expect(thresholdRow?.style.alignItems).toBe("center"); expect(thresholdRow?.style.alignItems).toBe("center");
expect(dataGroup?.parentElement).toBe(primaryRow); expect(dataGroup?.parentElement).toBe(primaryRow);
expect(videoGroup?.parentElement).toBe(primaryRow);
expect(statusText?.parentElement).toBe(primaryRow); expect(statusText?.parentElement).toBe(primaryRow);
expect(thresholdGroup?.parentElement).toBe(thresholdRow); expect(metricSelector?.parentElement).toBe(thresholdRow);
expect(thresholdGroup?.style.flexWrap).toBe("nowrap"); expect(rulesGroup?.parentElement).toBe(thresholdRow);
expect(thresholdGroup?.style.overflowX).toBe("auto"); expect(rulesGroup?.style.flexDirection).toBe("column");
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(titles[1]?.style.background).toBe("rgb(238, 245, 255)");
expect(document.querySelector("[data-plugin-spread-threshold-rule]")).toBeNull();
expect(operators).toEqual(["≥", "≥"]); expect(operators).toEqual(["≥", "≥"]);
expect(conjunctions).toEqual(["且"]); expect(metricInputs.map((input) => input.checked)).toEqual([false, false]);
expect(ruleRows.map((row) => row.hidden)).toEqual([true, true]);
expect(thresholdInputs.map((input) => input.placeholder)).toEqual([ expect(thresholdInputs.map((input) => input.placeholder)).toEqual([
"", "",
"" ""
@@ -474,10 +472,6 @@ describe("market-content-entry", () => {
"0.1", "0.1",
"0.1" "0.1"
]); ]);
expect(thresholdControls.map((control) => control.textContent)).toEqual([
"完播率≥%",
"互动率≥%"
]);
expect(buttons.map((button) => button.textContent)).toEqual([ expect(buttons.map((button) => button.textContent)).toEqual([
"导出CSV", "导出CSV",
"导出选中达人数据", "导出选中达人数据",
@@ -1282,7 +1276,7 @@ describe("market-content-entry", () => {
expect(customPagesInput?.hidden).toBe(false); expect(customPagesInput?.hidden).toBe(false);
}); });
test("toolbar exposes spread threshold filters and disables fixed personal-video controls", async () => { test("toolbar exposes independent metric rules and applies personal-video constraints per 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");
@@ -1297,31 +1291,148 @@ describe("market-content-entry", () => {
await controller.ready; await controller.ready;
const videoTypeSelect = document.querySelector( const finishToggle = document.querySelector(
'[data-plugin-spread-filter="type"]' '[data-plugin-spread-metric="finishRate"]'
) as HTMLSelectElement | null; ) as HTMLInputElement | null;
const assignSelect = document.querySelector( const interactionToggle = document.querySelector(
'[data-plugin-spread-filter="onlyAssign"]' '[data-plugin-spread-metric="interactionRate"]'
) as HTMLSelectElement | null; ) as HTMLInputElement | null;
const flowTypeSelect = document.querySelector( const finishRule = document.querySelector(
'[data-plugin-spread-filter="flowType"]' '[data-plugin-spread-rule="finishRate"]'
) as HTMLSelectElement | null; ) as HTMLElement | null;
const interactionRule = document.querySelector(
'[data-plugin-spread-rule="interactionRate"]'
) as HTMLElement | null;
const finishRateInput = document.querySelector( const finishRateInput = document.querySelector(
'[data-plugin-spread-threshold="finishRate"]' '[data-plugin-spread-threshold="finishRate"]'
) as HTMLInputElement | null; ) as HTMLInputElement | null;
expect(videoTypeSelect?.value).toBe("1"); expect(finishToggle?.checked).toBe(false);
expect(assignSelect?.value).toBe("false"); expect(interactionToggle?.checked).toBe(false);
expect(assignSelect?.disabled).toBe(true); expect(finishRule?.hidden).toBe(true);
expect(flowTypeSelect?.value).toBe("0"); expect(interactionRule?.hidden).toBe(true);
expect(flowTypeSelect?.disabled).toBe(true);
expect(finishRateInput?.placeholder).toBe(""); expect(finishRateInput?.placeholder).toBe("");
setSelectValue('[data-plugin-spread-filter="type"]', "2"); enableSpreadMetric("finishRate");
dispatchChange('[data-plugin-spread-filter="type"]'); enableSpreadMetric("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");
expect(assignSelect?.disabled).toBe(false); const finishAssignSelect = readSpreadRuleSelect(
expect(flowTypeSelect?.disabled).toBe(false); "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(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");
if (!finishToggle) {
throw new Error("Missing finish-rate metric toggle");
}
finishToggle.checked = false;
dispatchChange('[data-plugin-spread-metric="finishRate"]');
finishToggle.checked = true;
dispatchChange('[data-plugin-spread-metric="finishRate"]');
expect(finishRateInput?.value).toBe("");
expectSelectValue(
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="type"]',
"1"
);
expectSelectValue(
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="range"]',
"2"
);
});
test("reads selected spread metrics as independent validated rules", 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());
expect(readToolbarSpreadFilter(toolbar)).toEqual({
filter: { rules: [] }
});
enableSpreadMetric("finishRate");
expect(readToolbarSpreadFilter(toolbar)).toEqual({
error: "请输入有效的完播率筛选阈值"
});
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
enableSpreadMetric("interactionRate");
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
setSpreadRuleSelect("finishRate", "type", "2");
setSpreadRuleSelect("finishRate", "onlyAssign", "true");
setSpreadRuleSelect("finishRate", "flowType", "1");
setSpreadRuleSelect("interactionRate", "range", "3");
expect(readToolbarSpreadFilter(toolbar)).toEqual({
filter: {
rules: [
{
config: {
flowType: 1,
onlyAssign: true,
range: 2,
type: 2
},
metric: "finishRate",
threshold: 30
},
{
config: {
flowType: 0,
onlyAssign: false,
range: 3,
type: 1
},
metric: "interactionRate",
threshold: 5
}
]
}
});
}); });
test("export uses the current page ordering without triggering a full scan", async () => { test("export uses the current page ordering without triggering a full scan", async () => {
@@ -1437,7 +1548,7 @@ describe("market-content-entry", () => {
]); ]);
}); });
test("export keeps only records that match spread threshold filters", async () => { test("export requires independent spread metric configs to all match", 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" }
@@ -1459,9 +1570,79 @@ describe("market-content-entry", () => {
} }
]); ]);
const buildCsv = vi.fn(() => "csv-output"); const buildCsv = vi.fn(() => "csv-output");
const loadSpreadFilterMetrics = vi.fn(async (spreadAuthorId: string) => ({ const loadSpreadFilterMetrics = vi.fn(async (
finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%", spreadAuthorId: string,
interactionRate: "5%" config: SpreadInfoConfig
) => {
if (config.type === 2) {
return { finishRate: "35%" };
}
return {
interactionRate: spreadAuthorId === "spread-a" ? "6%" : "4%"
};
});
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildCsv,
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
loadSpreadFilterMetrics,
loadSpreadMetrics: async () => ({}),
onCsvReady: vi.fn(),
window
}));
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("finishRate", "onlyAssign", "true");
setSpreadRuleSelect("interactionRate", "range", "3");
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 80, 50);
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
flowType: 0,
onlyAssign: true,
range: 2,
type: 2
});
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
flowType: 0,
onlyAssign: false,
range: 3,
type: 1
});
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
"a"
]);
});
test("export reuses one spread snapshot when metric configs match", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" }
]);
attachMarketListState([
{
attribute_datas: {
id: "spread-a",
nickname: "Alpha"
},
star_id: "a"
}
]);
const buildCsv = vi.fn(() => "csv-output");
const loadSpreadFilterMetrics = vi.fn(async () => ({
finishRate: "35%",
interactionRate: "6%"
})); }));
const { createMarketController } = await import("../src/content/market/index"); const { createMarketController } = await import("../src/content/market/index");
@@ -1473,6 +1654,7 @@ describe("market-content-entry", () => {
reason: "request-failed" reason: "request-failed"
}), }),
loadSpreadFilterMetrics, loadSpreadFilterMetrics,
loadSpreadMetrics: async () => ({}),
onCsvReady: vi.fn(), onCsvReady: vi.fn(),
window window
})); }));
@@ -1480,10 +1662,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");
enableSpreadMetric("interactionRate");
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30"); setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
click('[data-plugin-export="button"]'); click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 80, 50); await waitForMockCall(buildCsv, 80, 50);
expect(loadSpreadFilterMetrics).toHaveBeenCalledTimes(1);
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", { expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
flowType: 0, flowType: 0,
onlyAssign: false, onlyAssign: false,
@@ -1495,6 +1681,58 @@ describe("market-content-entry", () => {
]); ]);
}); });
test("export excludes missing-id and failed spread metric records without aborting", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
]);
attachMarketListState([
{ star_id: "a" },
{
attribute_datas: {
id: "spread-b",
nickname: "Beta"
},
star_id: "b"
}
]);
const buildCsv = vi.fn(() => "csv-output");
const loadSpreadFilterMetrics = vi.fn(async () => {
throw new Error("request failed");
});
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildCsv,
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
loadSpreadFilterMetrics,
loadSpreadMetrics: async () => ({}),
onCsvReady: vi.fn(),
window
}));
await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
enableSpreadMetric("finishRate");
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 80, 50);
expect(loadSpreadFilterMetrics).toHaveBeenCalledTimes(1);
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-b", {
flowType: 0,
onlyAssign: false,
range: 2,
type: 1
});
expect(buildCsv.mock.calls[0][0]).toEqual([]);
});
test( test(
"default export captures the first 5 pages and keeps non-empty fields when merging duplicates", "default export captures the first 5 pages and keeps non-empty fields when merging duplicates",
async () => { async () => {
@@ -2433,7 +2671,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 keeps only records that match spread threshold filters", async () => { test("batch submit applies all independent spread metric rules", 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" }
@@ -2455,9 +2693,19 @@ describe("market-content-entry", () => {
} }
]); ]);
const submitBatch = vi.fn(async () => ({ ok: true })); const submitBatch = vi.fn(async () => ({ ok: true }));
const loadSpreadFilterMetrics = vi.fn(async (spreadAuthorId: string) => ({ const loadSpreadFilterMetrics = vi.fn(async (
finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%" spreadAuthorId: string,
})); config: SpreadInfoConfig
) => {
if (config.type === 2) {
return {
finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%"
};
}
return {
interactionRate: spreadAuthorId === "spread-a" ? "6%" : "4%"
};
});
const { createMarketController } = await import("../src/content/market/index"); const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({ const controller = trackController(createMarketController({
@@ -2480,7 +2728,12 @@ 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");
enableSpreadMetric("interactionRate");
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30"); setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
setSpreadRuleSelect("finishRate", "type", "2");
setSpreadRuleSelect("interactionRate", "range", "3");
click('[data-plugin-batch-submit="button"]'); click('[data-plugin-batch-submit="button"]');
await waitForMockCall(submitBatch, 80, 50); await waitForMockCall(submitBatch, 80, 50);
@@ -5089,6 +5342,52 @@ function click(selector: string) {
element.click(); element.click();
} }
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 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 setSpreadRuleSelect(
metric: "finishRate" | "interactionRate",
field: "type" | "onlyAssign" | "flowType" | "range",
value: string
) {
const select = readSpreadRuleSelect(metric, field);
select.value = value;
select.dispatchEvent(new Event("change"));
}
function createNoopToolbarHandlers() {
return {
onConfigureAudienceProfileFields: vi.fn(),
onExport: vi.fn(),
onExportAudienceProfile: vi.fn(),
onExportAudienceProfileByIds: vi.fn(),
onSubmitBatch: vi.fn()
};
}
function clickSelectionCheckboxForAuthor(authorId: string) { function clickSelectionCheckboxForAuthor(authorId: string) {
readSelectionCheckboxForAuthor(authorId).click(); readSelectionCheckboxForAuthor(authorId).click();
} }