feat: make metric filters field-driven
This commit is contained in:
@@ -127,6 +127,20 @@ export function listAudienceProfileCsvHeaders(
|
||||
];
|
||||
}
|
||||
|
||||
export function buildAudienceProfileFieldValues(
|
||||
row: AudienceProfileExportRow
|
||||
): Record<string, string> {
|
||||
const columns = deduplicateCsvColumns([
|
||||
...buildMarketCsvColumns([row.record]).map(toMarketColumn),
|
||||
...buildBusinessEstimateColumns(),
|
||||
...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout))
|
||||
]);
|
||||
|
||||
return Object.fromEntries(
|
||||
columns.map((column) => [column.header, column.readValue(row)])
|
||||
);
|
||||
}
|
||||
|
||||
export function listAudienceProfileSelectableFieldGroups(
|
||||
marketListHeaders: string[] = []
|
||||
): AudienceProfileCsvFieldGroup[] {
|
||||
|
||||
+64
-63
@@ -4,6 +4,7 @@ import {
|
||||
listRateCsvHeaders
|
||||
} from "./csv-exporter";
|
||||
import {
|
||||
buildAudienceProfileFieldValues,
|
||||
buildAudienceProfileCsv,
|
||||
listAudienceProfileSelectableFieldGroups,
|
||||
type AudienceProfileCsvOptions
|
||||
@@ -52,16 +53,16 @@ import { applyFilterAndSort } from "./filter-sort-controller";
|
||||
import { createMarketApiClient } from "./api-client";
|
||||
import { createExportRangeController } from "./export-range-controller";
|
||||
import { ensurePluginToolbar, isPluginToolbarMounted } from "./plugin-toolbar";
|
||||
import {
|
||||
listNumericMetricFilterDefinitions,
|
||||
matchesMetricFilterRule
|
||||
} from "./metric-filter";
|
||||
import { createSilentExportController } from "./silent-export-controller";
|
||||
import {
|
||||
buildSpreadInfoConfigKey,
|
||||
createSpreadInfoClient,
|
||||
DEFAULT_SPREAD_INFO_CONFIGS,
|
||||
filterUnloadedSpreadInfoConfigs,
|
||||
matchesSpreadMetricRule,
|
||||
normalizeSpreadInfoConfig,
|
||||
selectSpreadInfoConfigsForHeaders,
|
||||
type MappedSpreadInfoResponse
|
||||
selectSpreadInfoConfigsForHeaders
|
||||
} from "./spread-info";
|
||||
import {
|
||||
readToolbarExportTarget,
|
||||
@@ -88,9 +89,8 @@ import type {
|
||||
MarketRecord,
|
||||
MarketRowSnapshot,
|
||||
MarketSortState,
|
||||
MetricThresholdFilter,
|
||||
SpreadInfoConfig,
|
||||
SpreadMetricFilterRule,
|
||||
SpreadThresholdFilter
|
||||
} from "./types";
|
||||
|
||||
interface MutationObserverLike {
|
||||
@@ -127,10 +127,6 @@ export interface CreateMarketControllerOptions {
|
||||
target: AudienceProfileRequestTarget
|
||||
) => Promise<AudienceProfileResult>;
|
||||
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
||||
loadSpreadFilterMetrics?: (
|
||||
spreadAuthorId: string,
|
||||
config: SpreadInfoConfig
|
||||
) => Promise<MappedSpreadInfoResponse>;
|
||||
loadSpreadMetrics?: (
|
||||
spreadAuthorId: string,
|
||||
configs?: SpreadInfoConfig[]
|
||||
@@ -169,9 +165,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
const resultStore = options.resultStore ?? createMarketResultStore();
|
||||
const loadAuthorMetrics =
|
||||
options.loadAuthorMetrics ?? marketApiClient.loadAuthorAseInfo;
|
||||
const loadSpreadFilterMetrics =
|
||||
options.loadSpreadFilterMetrics ??
|
||||
spreadInfoClient.loadAuthorSpreadMetricSnapshot;
|
||||
const loadSpreadMetrics =
|
||||
options.loadSpreadMetrics ?? spreadInfoClient.loadAuthorSpreadMetrics;
|
||||
const searchBackendMetrics =
|
||||
@@ -520,7 +513,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
setToolbarBusyState(toolbar, true);
|
||||
try {
|
||||
const hasSelectedAuthors = selectedAuthorIds.size > 0;
|
||||
const resolvedRecords = await applySpreadThresholdFilter(
|
||||
const resolvedRecords = await applyMetricThresholdFilter(
|
||||
await exportRecords(
|
||||
exportTarget.target,
|
||||
hasSelectedAuthors ? "提交已选达人中" : "提交中",
|
||||
@@ -566,7 +559,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
}
|
||||
}
|
||||
};
|
||||
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
|
||||
toolbar = ensurePluginToolbar(
|
||||
options.document,
|
||||
toolbarHandlers,
|
||||
readMetricFilterDefinitions()
|
||||
);
|
||||
|
||||
const ready = (async () => {
|
||||
await runSyncCycle();
|
||||
@@ -739,7 +736,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
|
||||
function applyCurrentView(): void {
|
||||
runWithoutMutationSync(() => {
|
||||
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
|
||||
toolbar = ensurePluginToolbar(
|
||||
options.document,
|
||||
toolbarHandlers,
|
||||
readMetricFilterDefinitions()
|
||||
);
|
||||
const table = syncMarketTable(options.document);
|
||||
if (!table) {
|
||||
return;
|
||||
@@ -1125,60 +1126,49 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
return selectedRecords.length > 0 ? selectedRecords : records;
|
||||
}
|
||||
|
||||
async function applySpreadThresholdFilter(
|
||||
async function applyMetricThresholdFilter(
|
||||
records: MarketRecord[],
|
||||
filter: SpreadThresholdFilter | undefined
|
||||
filter: MetricThresholdFilter | undefined
|
||||
): Promise<MarketRecord[]> {
|
||||
if (!filter || filter.rules.length === 0) {
|
||||
return records;
|
||||
}
|
||||
|
||||
const normalizedRules = filter.rules.map((rule) => ({
|
||||
...rule,
|
||||
config: normalizeSpreadInfoConfig(rule.config)
|
||||
}));
|
||||
const configsByKey = new Map<string, SpreadInfoConfig>();
|
||||
normalizedRules.forEach((rule) => {
|
||||
configsByKey.set(buildSpreadInfoConfigKey(rule.config), rule.config);
|
||||
const filterSelection = resolveAudienceProfileExportSelection(
|
||||
filter.rules.map((rule) => rule.field)
|
||||
);
|
||||
const hydratedRecords = await hydrateExportRecords(records, {
|
||||
includeBackendMetrics: filterSelection.includeBackendMetrics,
|
||||
includeRates: filterSelection.includeRates,
|
||||
spreadInfoConfigs: filterSelection.spreadInfoConfigs
|
||||
});
|
||||
const matchedRecords: MarketRecord[] = [];
|
||||
|
||||
const matchedAuthorIds = new Set<string>();
|
||||
await Promise.all(
|
||||
records.map(async (record) => {
|
||||
const spreadAuthorId = record.spreadAuthorId;
|
||||
if (!spreadAuthorId) {
|
||||
return;
|
||||
}
|
||||
for (let index = 0; index < hydratedRecords.length; index += 1) {
|
||||
const record = hydratedRecords[index];
|
||||
setToolbarExportStatus(
|
||||
toolbar,
|
||||
`指标筛选 ${index + 1}/${hydratedRecords.length}...`
|
||||
);
|
||||
const [profiles, businessAbility] = await Promise.all([
|
||||
loadAudienceProfileSet(record, filterSelection),
|
||||
filterSelection.businessAbility
|
||||
? loadBusinessAbilitySafe(record)
|
||||
: Promise.resolve(undefined)
|
||||
]);
|
||||
const values = buildAudienceProfileFieldValues(
|
||||
finalizeAudienceProfileExportRow({
|
||||
businessAbility,
|
||||
profiles,
|
||||
record
|
||||
})
|
||||
);
|
||||
if (filter.rules.every((rule) => matchesMetricFilterRule(values, rule))) {
|
||||
matchedRecords.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
const snapshots = new Map<string, MappedSpreadInfoResponse>();
|
||||
await Promise.all(
|
||||
Array.from(configsByKey.entries()).map(async ([key, config]) => {
|
||||
snapshots.set(
|
||||
key,
|
||||
await loadSpreadFilterMetrics(spreadAuthorId, config)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
if (matchesAllSpreadMetricRules(normalizedRules, snapshots)) {
|
||||
matchedAuthorIds.add(record.authorId);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return records.filter((record) => matchedAuthorIds.has(record.authorId));
|
||||
}
|
||||
|
||||
function matchesAllSpreadMetricRules(
|
||||
rules: SpreadMetricFilterRule[],
|
||||
snapshots: Map<string, MappedSpreadInfoResponse>
|
||||
): boolean {
|
||||
return rules.every((rule) =>
|
||||
matchesSpreadMetricRule(
|
||||
snapshots.get(buildSpreadInfoConfigKey(rule.config)) ?? {},
|
||||
rule
|
||||
)
|
||||
);
|
||||
return matchedRecords;
|
||||
}
|
||||
|
||||
function filterRecordsBySelectionStrict(records: MarketRecord[]): MarketRecord[] {
|
||||
@@ -1461,6 +1451,13 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
);
|
||||
}
|
||||
|
||||
function readMetricFilterDefinitions() {
|
||||
const marketListHeaders = buildBaseColumns(
|
||||
readCurrentPageRecords(syncMarketTable(options.document))
|
||||
).map((column) => column.header);
|
||||
return listNumericMetricFilterDefinitions(marketListHeaders);
|
||||
}
|
||||
|
||||
function readAudienceProfileSelectedHeaders(): string[] | undefined {
|
||||
try {
|
||||
const rawValue = options.window.localStorage?.getItem(
|
||||
@@ -2049,7 +2046,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
}
|
||||
|
||||
async function runSingleSyncCycle(): Promise<void> {
|
||||
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
|
||||
toolbar = ensurePluginToolbar(
|
||||
options.document,
|
||||
toolbarHandlers,
|
||||
readMetricFilterDefinitions()
|
||||
);
|
||||
if (isPluginToolbarMounted(toolbar.root, options.document)) {
|
||||
toolbarRemountScheduled = false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
listAudienceProfileSelectableFieldGroups,
|
||||
type AudienceProfileCsvFieldGroup
|
||||
} from "./audience-profile-csv";
|
||||
import type { MetricFilterRule } from "./types";
|
||||
|
||||
export interface MetricFilterDefinition {
|
||||
field: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
const NUMERIC_MARKET_LIST_FIELDS = new Set([
|
||||
"连接用户数",
|
||||
"粉丝数",
|
||||
"预期CPM",
|
||||
"预期播放量",
|
||||
"互动率",
|
||||
"完播率",
|
||||
"爆文率",
|
||||
"21-60s报价"
|
||||
]);
|
||||
|
||||
export function listNumericMetricFilterGroups(
|
||||
marketListHeaders: string[] = []
|
||||
): AudienceProfileCsvFieldGroup[] {
|
||||
return listAudienceProfileSelectableFieldGroups(marketListHeaders)
|
||||
.map((group) => ({
|
||||
...group,
|
||||
headers: group.headers.filter((header) =>
|
||||
group.label === "列表字段"
|
||||
? NUMERIC_MARKET_LIST_FIELDS.has(header)
|
||||
: true
|
||||
)
|
||||
}))
|
||||
.filter((group) => group.headers.length > 0);
|
||||
}
|
||||
|
||||
export function listNumericMetricFilterDefinitions(
|
||||
marketListHeaders: string[] = []
|
||||
): MetricFilterDefinition[] {
|
||||
return listNumericMetricFilterGroups(marketListHeaders).flatMap((group) =>
|
||||
group.headers.map((field) => ({ field, group: group.label }))
|
||||
);
|
||||
}
|
||||
|
||||
export function matchesMetricFilterRule(
|
||||
values: Record<string, string>,
|
||||
rule: MetricFilterRule
|
||||
): boolean {
|
||||
const value = parseDisplayNumber(values[rule.field]);
|
||||
if (value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return rule.operator === "gte"
|
||||
? value >= rule.threshold
|
||||
: value <= rule.threshold;
|
||||
}
|
||||
|
||||
export function parseDisplayNumber(value: string | undefined): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value.trim().replace(/[\s,¥¥]/g, "");
|
||||
if (!normalized || normalized === "缺失") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const suffix = normalized.endsWith("w") || normalized.endsWith("万")
|
||||
? 10_000
|
||||
: normalized.endsWith("亿")
|
||||
? 100_000_000
|
||||
: 1;
|
||||
const numericText = normalized
|
||||
.replace(/[%w万亿]/g, "")
|
||||
.replace(/^\+/, "");
|
||||
const numericValue = Number(numericText);
|
||||
return Number.isFinite(numericValue) ? numericValue * suffix : null;
|
||||
}
|
||||
+419
-1185
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,18 @@ export interface SpreadThresholdFilter {
|
||||
rules: SpreadMetricFilterRule[];
|
||||
}
|
||||
|
||||
export type MetricFilterOperator = "gte" | "lte";
|
||||
|
||||
export interface MetricFilterRule {
|
||||
field: string;
|
||||
operator: MetricFilterOperator;
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
export interface MetricThresholdFilter {
|
||||
rules: MetricFilterRule[];
|
||||
}
|
||||
|
||||
export type MarketSortField =
|
||||
| keyof Required<AfterSearchRates>
|
||||
| keyof Required<BackendMetrics>;
|
||||
|
||||
Reference in New Issue
Block a user