feat: add independent spread metric filters
This commit is contained in:
+50
-13
@@ -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 [];
|
||||||
|
|||||||
@@ -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,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 {
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user