feat: filter exports by spread thresholds
This commit is contained in:
parent
121977fd0d
commit
9eb1fe43cc
@ -0,0 +1,33 @@
|
|||||||
|
# 星图达人传播指标阈值筛选 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 在导出 CSV 和提交批次前,按用户选择的 spread-info 参数组合和指标阈值过滤达人。
|
||||||
|
|
||||||
|
**Architecture:** 工具栏负责读取筛选配置;`spread-info.ts` 提供单参数组合加载与阈值比较;`index.ts` 在 export range 收集后、CSV/批次 payload 生成前统一应用筛选。
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, Chrome MV3 content script, Vitest, jsdom.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Toolbar Filter State
|
||||||
|
|
||||||
|
- [ ] 增加视频类别、指派、营销流量、数据范围和 7 个阈值输入控件。
|
||||||
|
- [ ] 增加 `readToolbarSpreadFilter` 读取并校验筛选配置。
|
||||||
|
- [ ] 测试个人视频时固定并禁用指派/营销流量。
|
||||||
|
|
||||||
|
### Task 2: Spread Filter Logic
|
||||||
|
|
||||||
|
- [ ] 在 `spread-info.ts` 增加单配置请求与阈值比较。
|
||||||
|
- [ ] 测试百分比显示值、秒、普通数字比较。
|
||||||
|
|
||||||
|
### Task 3: Export And Batch Integration
|
||||||
|
|
||||||
|
- [ ] 在导出和提交批次流程中调用筛选逻辑。
|
||||||
|
- [ ] 空阈值不触发筛选请求。
|
||||||
|
- [ ] 测试导出和提交批次都只保留满足阈值的达人。
|
||||||
|
|
||||||
|
### Task 4: Verification
|
||||||
|
|
||||||
|
- [ ] 运行 focused tests。
|
||||||
|
- [ ] 运行 `npm run build`。
|
||||||
@ -0,0 +1,101 @@
|
|||||||
|
# 星图达人传播指标阈值筛选需求文档
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
在导出 CSV 或提交批次之前,允许用户按一组视频传播数据参数和指标阈值对达人做二次筛选。
|
||||||
|
|
||||||
|
只有满足筛选条件的达人,才进入最终导出或提交批次。
|
||||||
|
|
||||||
|
## 筛选维度
|
||||||
|
|
||||||
|
筛选维度对应 `get_author_spread_info` 的请求参数:
|
||||||
|
|
||||||
|
| UI 维度 | 接口参数 | 可选值 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 视频类别 | `type` | 个人视频 / 星图视频 |
|
||||||
|
| 是否指派 | `only_assign` | 只看指派 / 不限指派 |
|
||||||
|
| 是否排除营销流量 | `flow_type` | 排除营销流量 / 不排除营销流量 |
|
||||||
|
| 数据范围 | `range` | 近30天 / 近90天 |
|
||||||
|
|
||||||
|
个人视频的参数约束:
|
||||||
|
|
||||||
|
- `type=1`
|
||||||
|
- `only_assign=false`
|
||||||
|
- `flow_type=0`
|
||||||
|
- `range` 可选近30天或近90天
|
||||||
|
|
||||||
|
星图视频的参数约束:
|
||||||
|
|
||||||
|
- `type=2`
|
||||||
|
- `only_assign` 可选
|
||||||
|
- `flow_type` 可选
|
||||||
|
- `range` 可选近30天或近90天
|
||||||
|
|
||||||
|
## 指标阈值
|
||||||
|
|
||||||
|
支持下面 7 个指标阈值:
|
||||||
|
|
||||||
|
- 完播率 >=
|
||||||
|
- 播放量中位数 >=
|
||||||
|
- 互动率 >=
|
||||||
|
- 作品平均时长 >=
|
||||||
|
- 作品平均评论数 >=
|
||||||
|
- 作品平均点赞数 >=
|
||||||
|
- 作品平均转发数 >=
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 没填的阈值不参与筛选。
|
||||||
|
- 填了多个阈值时,必须全部满足才保留达人。
|
||||||
|
- 完播率和互动率使用百分数显示值,例如填 `30` 表示 `30%`。
|
||||||
|
- 作品平均时长使用秒,例如填 `56` 表示 `56秒`。
|
||||||
|
- 播放量、评论、点赞、转发使用普通数字。
|
||||||
|
- 如果某个达人在所选参数组合下接口请求失败或缺少被启用的指标,则视为不满足筛选。
|
||||||
|
|
||||||
|
## 生效范围
|
||||||
|
|
||||||
|
阈值筛选同时作用于:
|
||||||
|
|
||||||
|
- 导出 CSV
|
||||||
|
- 提交批次
|
||||||
|
|
||||||
|
处理顺序:
|
||||||
|
|
||||||
|
1. 先按现有导出范围收集达人,例如当前页、前5页、前10页、全部或自定义页数。
|
||||||
|
2. 如果用户没有填写任何阈值,保持现有导出/提交行为。
|
||||||
|
3. 如果用户填写了阈值,对收集到的每个达人按当前筛选维度调用一次 `get_author_spread_info`。
|
||||||
|
4. 将接口响应映射为显示值。
|
||||||
|
5. 用已填写的阈值过滤达人。
|
||||||
|
6. 过滤后的达人进入导出 CSV 或提交批次。
|
||||||
|
|
||||||
|
## UI 设计
|
||||||
|
|
||||||
|
在现有插件操作区中增加一组紧凑控件:
|
||||||
|
|
||||||
|
- 视频类别下拉框
|
||||||
|
- 指派下拉框
|
||||||
|
- 营销流量下拉框
|
||||||
|
- 数据范围下拉框
|
||||||
|
- 7 个数字输入框
|
||||||
|
|
||||||
|
当视频类别选择“个人视频”时:
|
||||||
|
|
||||||
|
- 指派固定为“不限指派”
|
||||||
|
- 营销流量固定为“不排除营销流量”
|
||||||
|
- 对应控件禁用
|
||||||
|
|
||||||
|
## 失败处理
|
||||||
|
|
||||||
|
- 单个达人筛选请求失败:该达人不满足筛选。
|
||||||
|
- 全部达人都不满足:导出空 CSV 表头;提交批次时按现有空记录处理。
|
||||||
|
- 阈值输入非法:阻止导出/提交,并提示用户修正。
|
||||||
|
|
||||||
|
## 测试要求
|
||||||
|
|
||||||
|
- 读取 toolbar 中的筛选参数和阈值。
|
||||||
|
- 个人视频禁用指派和营销流量控件。
|
||||||
|
- 空阈值时不触发二次筛选。
|
||||||
|
- 有阈值时按所选参数调用 `get_author_spread_info`。
|
||||||
|
- 百分比阈值按显示值比较。
|
||||||
|
- 多个阈值按 AND 关系过滤。
|
||||||
|
- 导出 CSV 和提交批次都应用二次筛选。
|
||||||
@ -31,9 +31,10 @@ 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 } from "./spread-info";
|
import { createSpreadInfoClient, matchesSpreadThresholds } from "./spread-info";
|
||||||
import {
|
import {
|
||||||
readToolbarExportTarget,
|
readToolbarExportTarget,
|
||||||
|
readToolbarSpreadFilter,
|
||||||
setToolbarBusyState,
|
setToolbarBusyState,
|
||||||
setToolbarExportStatus
|
setToolbarExportStatus
|
||||||
} from "./plugin-toolbar";
|
} from "./plugin-toolbar";
|
||||||
@ -55,7 +56,8 @@ import type {
|
|||||||
MarketExportTarget,
|
MarketExportTarget,
|
||||||
MarketRecord,
|
MarketRecord,
|
||||||
MarketRowSnapshot,
|
MarketRowSnapshot,
|
||||||
MarketSortState
|
MarketSortState,
|
||||||
|
SpreadThresholdFilter
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
interface MutationObserverLike {
|
interface MutationObserverLike {
|
||||||
@ -80,6 +82,10 @@ export interface CreateMarketControllerOptions {
|
|||||||
target: AudienceProfileRequestTarget
|
target: AudienceProfileRequestTarget
|
||||||
) => Promise<AudienceProfileResult>;
|
) => Promise<AudienceProfileResult>;
|
||||||
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
||||||
|
loadSpreadFilterMetrics?: (
|
||||||
|
spreadAuthorId: string,
|
||||||
|
config: SpreadThresholdFilter["config"]
|
||||||
|
) => Promise<Record<string, string | undefined>>;
|
||||||
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 }>
|
||||||
@ -108,6 +114,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
const resultStore = options.resultStore ?? createMarketResultStore();
|
const resultStore = options.resultStore ?? createMarketResultStore();
|
||||||
const loadAuthorMetrics =
|
const loadAuthorMetrics =
|
||||||
options.loadAuthorMetrics ?? marketApiClient.loadAuthorAseInfo;
|
options.loadAuthorMetrics ?? marketApiClient.loadAuthorAseInfo;
|
||||||
|
const loadSpreadFilterMetrics =
|
||||||
|
options.loadSpreadFilterMetrics ??
|
||||||
|
spreadInfoClient.loadAuthorSpreadMetricSnapshot;
|
||||||
const loadSpreadMetrics =
|
const loadSpreadMetrics =
|
||||||
options.loadSpreadMetrics ?? spreadInfoClient.loadAuthorSpreadMetrics;
|
options.loadSpreadMetrics ?? spreadInfoClient.loadAuthorSpreadMetrics;
|
||||||
const searchBackendMetrics =
|
const searchBackendMetrics =
|
||||||
@ -211,14 +220,22 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
|
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const spreadFilter = readToolbarSpreadFilter(toolbar);
|
||||||
|
if (spreadFilter.error) {
|
||||||
|
setToolbarExportStatus(toolbar, spreadFilter.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setToolbarBusyState(toolbar, true);
|
setToolbarBusyState(toolbar, true);
|
||||||
try {
|
try {
|
||||||
const records = filterRecordsBySelection(
|
const records = filterRecordsBySelection(
|
||||||
await exportRecords(exportTarget.target, "导出中", {
|
await applySpreadThresholdFilter(
|
||||||
includeSpreadMetrics: true,
|
await exportRecords(exportTarget.target, "导出中", {
|
||||||
showDetailedProgress: selectedAuthorIds.size === 0
|
includeSpreadMetrics: true,
|
||||||
})
|
showDetailedProgress: selectedAuthorIds.size === 0
|
||||||
|
}),
|
||||||
|
spreadFilter.filter
|
||||||
|
)
|
||||||
);
|
);
|
||||||
options.onCsvReady?.(buildCsv(records));
|
options.onCsvReady?.(buildCsv(records));
|
||||||
setToolbarExportStatus(toolbar, "");
|
setToolbarExportStatus(toolbar, "");
|
||||||
@ -375,6 +392,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
|
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const spreadFilter = readToolbarSpreadFilter(toolbar);
|
||||||
|
if (spreadFilter.error) {
|
||||||
|
setToolbarExportStatus(toolbar, spreadFilter.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const batchName = await promptBatchName();
|
const batchName = await promptBatchName();
|
||||||
if (batchName === null) {
|
if (batchName === null) {
|
||||||
@ -390,12 +412,15 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
try {
|
try {
|
||||||
const hasSelectedAuthors = selectedAuthorIds.size > 0;
|
const hasSelectedAuthors = selectedAuthorIds.size > 0;
|
||||||
const records = filterRecordsBySelection(
|
const records = filterRecordsBySelection(
|
||||||
await exportRecords(
|
await applySpreadThresholdFilter(
|
||||||
exportTarget.target,
|
await exportRecords(
|
||||||
hasSelectedAuthors ? "提交已选达人中" : "提交中",
|
exportTarget.target,
|
||||||
{
|
hasSelectedAuthors ? "提交已选达人中" : "提交中",
|
||||||
showDetailedProgress: !hasSelectedAuthors
|
{
|
||||||
}
|
showDetailedProgress: !hasSelectedAuthors
|
||||||
|
}
|
||||||
|
),
|
||||||
|
spreadFilter.filter
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const authState = await getAuthState();
|
const authState = await getAuthState();
|
||||||
@ -772,6 +797,37 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
return selectedRecords.length > 0 ? selectedRecords : records;
|
return selectedRecords.length > 0 ? selectedRecords : records;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function applySpreadThresholdFilter(
|
||||||
|
records: MarketRecord[],
|
||||||
|
filter: SpreadThresholdFilter | undefined
|
||||||
|
): Promise<MarketRecord[]> {
|
||||||
|
if (!filter) {
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedAuthorIds = new Set<string>();
|
||||||
|
await Promise.all(
|
||||||
|
records.map(async (record) => {
|
||||||
|
const spreadAuthorId = record.spreadAuthorId;
|
||||||
|
if (!spreadAuthorId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const metrics = await loadSpreadFilterMetrics(
|
||||||
|
spreadAuthorId,
|
||||||
|
filter.config
|
||||||
|
);
|
||||||
|
if (matchesSpreadThresholds(metrics, filter.thresholds)) {
|
||||||
|
matchedAuthorIds.add(record.authorId);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return records.filter((record) => matchedAuthorIds.has(record.authorId));
|
||||||
|
}
|
||||||
|
|
||||||
function filterRecordsBySelectionStrict(records: MarketRecord[]): MarketRecord[] {
|
function filterRecordsBySelectionStrict(records: MarketRecord[]): MarketRecord[] {
|
||||||
if (selectedAuthorIds.size === 0) {
|
if (selectedAuthorIds.size === 0) {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
MarketExportScope,
|
MarketExportScope,
|
||||||
MarketExportTarget
|
MarketExportTarget,
|
||||||
|
SpreadThresholdFilter
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export interface PluginToolbarHandlers {
|
export interface PluginToolbarHandlers {
|
||||||
@ -20,6 +21,11 @@ export interface PluginToolbarDom {
|
|||||||
exportCustomPagesInput: HTMLInputElement;
|
exportCustomPagesInput: HTMLInputElement;
|
||||||
exportRangeSelect: HTMLSelectElement;
|
exportRangeSelect: HTMLSelectElement;
|
||||||
exportStatusText: HTMLElement;
|
exportStatusText: HTMLElement;
|
||||||
|
spreadFilterFlowTypeSelect: HTMLSelectElement;
|
||||||
|
spreadFilterOnlyAssignSelect: HTMLSelectElement;
|
||||||
|
spreadFilterRangeSelect: HTMLSelectElement;
|
||||||
|
spreadFilterTypeSelect: HTMLSelectElement;
|
||||||
|
spreadThresholdInputs: Record<keyof SpreadThresholdFilter["thresholds"], HTMLInputElement>;
|
||||||
root: HTMLElement;
|
root: HTMLElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,15 +120,91 @@ export function ensurePluginToolbar(
|
|||||||
exportStatusText.dataset.pluginExportStatus = "text";
|
exportStatusText.dataset.pluginExportStatus = "text";
|
||||||
applyStatusStyles(exportStatusText);
|
applyStatusStyles(exportStatusText);
|
||||||
|
|
||||||
|
const spreadFilterTypeSelect = document.createElement("select");
|
||||||
|
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");
|
||||||
|
panel.dataset.pluginToolbarPanel = "root";
|
||||||
|
applyToolbarPanelStyles(panel);
|
||||||
|
|
||||||
|
const firstRow = document.createElement("div");
|
||||||
|
firstRow.dataset.pluginToolbarRow = "primary";
|
||||||
|
applyToolbarRowStyles(firstRow);
|
||||||
|
|
||||||
|
const secondRow = document.createElement("div");
|
||||||
|
secondRow.dataset.pluginToolbarRow = "thresholds";
|
||||||
|
applyToolbarRowStyles(secondRow);
|
||||||
|
|
||||||
|
const dataGroup = document.createElement("div");
|
||||||
|
dataGroup.dataset.pluginToolbarGroup = "data";
|
||||||
|
applyToolbarGroupStyles(dataGroup);
|
||||||
|
dataGroup.append(
|
||||||
|
createToolbarGroupTitle(document, "达人数据"),
|
||||||
|
audienceProfileExportButton,
|
||||||
|
audienceProfileByIdExportButton,
|
||||||
|
audienceProfileFieldButton
|
||||||
|
);
|
||||||
|
|
||||||
|
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";
|
||||||
|
applyToolbarGroupStyles(thresholdGroup);
|
||||||
|
thresholdGroup.append(
|
||||||
|
createToolbarGroupTitle(document, "传播指标"),
|
||||||
|
...Object.values(spreadThresholdInputs)
|
||||||
|
);
|
||||||
|
|
||||||
|
const divider = document.createElement("span");
|
||||||
|
applyToolbarDividerStyles(divider);
|
||||||
|
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.dataset.pluginToolbarActions = "root";
|
||||||
|
applyToolbarActionStyles(actions);
|
||||||
|
actions.append(batchSubmitButton);
|
||||||
|
|
||||||
|
firstRow.append(dataGroup, divider, videoGroup, exportStatusText);
|
||||||
|
secondRow.append(thresholdGroup);
|
||||||
|
panel.append(firstRow, secondRow);
|
||||||
|
|
||||||
root.append(
|
root.append(
|
||||||
exportRangeSelect,
|
exportRangeSelect,
|
||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
exportButton,
|
exportButton,
|
||||||
audienceProfileExportButton,
|
panel,
|
||||||
audienceProfileByIdExportButton,
|
actions
|
||||||
audienceProfileFieldButton,
|
|
||||||
batchSubmitButton,
|
|
||||||
exportStatusText
|
|
||||||
);
|
);
|
||||||
|
|
||||||
document.body.appendChild(root);
|
document.body.appendChild(root);
|
||||||
@ -133,7 +215,12 @@ export function ensurePluginToolbar(
|
|||||||
batchSubmitButton,
|
batchSubmitButton,
|
||||||
exportButton,
|
exportButton,
|
||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
exportRangeSelect
|
exportRangeSelect,
|
||||||
|
spreadFilterFlowTypeSelect,
|
||||||
|
spreadFilterOnlyAssignSelect,
|
||||||
|
spreadFilterRangeSelect,
|
||||||
|
spreadFilterTypeSelect,
|
||||||
|
...Object.values(spreadThresholdInputs)
|
||||||
});
|
});
|
||||||
ensureToolbarMounted(root, document);
|
ensureToolbarMounted(root, document);
|
||||||
|
|
||||||
@ -165,6 +252,21 @@ export function ensurePluginToolbar(
|
|||||||
root
|
root
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
spreadFilterTypeSelect.addEventListener("change", () => {
|
||||||
|
syncSpreadFilterControlState({
|
||||||
|
batchSubmitButton,
|
||||||
|
exportButton,
|
||||||
|
exportCustomPagesInput,
|
||||||
|
exportRangeSelect,
|
||||||
|
exportStatusText,
|
||||||
|
root,
|
||||||
|
spreadFilterFlowTypeSelect,
|
||||||
|
spreadFilterOnlyAssignSelect,
|
||||||
|
spreadFilterRangeSelect,
|
||||||
|
spreadFilterTypeSelect,
|
||||||
|
spreadThresholdInputs
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const toolbarDom = {
|
const toolbarDom = {
|
||||||
audienceProfileExportButton,
|
audienceProfileExportButton,
|
||||||
@ -175,9 +277,15 @@ export function ensurePluginToolbar(
|
|||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
exportRangeSelect,
|
exportRangeSelect,
|
||||||
exportStatusText,
|
exportStatusText,
|
||||||
|
spreadFilterFlowTypeSelect,
|
||||||
|
spreadFilterOnlyAssignSelect,
|
||||||
|
spreadFilterRangeSelect,
|
||||||
|
spreadFilterTypeSelect,
|
||||||
|
spreadThresholdInputs,
|
||||||
root
|
root
|
||||||
} satisfies PluginToolbarDom;
|
} satisfies PluginToolbarDom;
|
||||||
syncCustomPagesInputVisibility(toolbarDom);
|
syncCustomPagesInputVisibility(toolbarDom);
|
||||||
|
syncSpreadFilterControlState(toolbarDom);
|
||||||
|
|
||||||
return toolbarDom;
|
return toolbarDom;
|
||||||
}
|
}
|
||||||
@ -193,6 +301,73 @@ function appendOption(
|
|||||||
select.appendChild(option);
|
select.appendChild(option);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSpreadThresholdInputs(
|
||||||
|
document: Document
|
||||||
|
): Record<keyof SpreadThresholdFilter["thresholds"], HTMLInputElement> {
|
||||||
|
return {
|
||||||
|
averageCommentCount: createSpreadThresholdInput(
|
||||||
|
document,
|
||||||
|
"averageCommentCount",
|
||||||
|
"评论>="
|
||||||
|
),
|
||||||
|
averageDuration: createSpreadThresholdInput(
|
||||||
|
document,
|
||||||
|
"averageDuration",
|
||||||
|
"时长>="
|
||||||
|
),
|
||||||
|
averageLikeCount: createSpreadThresholdInput(
|
||||||
|
document,
|
||||||
|
"averageLikeCount",
|
||||||
|
"点赞>="
|
||||||
|
),
|
||||||
|
averageShareCount: createSpreadThresholdInput(
|
||||||
|
document,
|
||||||
|
"averageShareCount",
|
||||||
|
"转发>="
|
||||||
|
),
|
||||||
|
finishRate: createSpreadThresholdInput(document, "finishRate", "完播率>="),
|
||||||
|
interactionRate: createSpreadThresholdInput(document, "interactionRate", "互动率>="),
|
||||||
|
playMedian: createSpreadThresholdInput(document, "playMedian", "播放中位数>=")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSpreadThresholdInput(
|
||||||
|
document: Document,
|
||||||
|
key: keyof SpreadThresholdFilter["thresholds"],
|
||||||
|
placeholder: string
|
||||||
|
): HTMLInputElement {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "number";
|
||||||
|
input.min = "0";
|
||||||
|
input.step = "0.01";
|
||||||
|
input.placeholder = placeholder;
|
||||||
|
input.dataset.pluginSpreadThreshold = key;
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSpreadThresholdInputs(
|
||||||
|
root: HTMLElement
|
||||||
|
): Record<keyof SpreadThresholdFilter["thresholds"], HTMLInputElement> {
|
||||||
|
return {
|
||||||
|
averageCommentCount: readSpreadThresholdInput(root, "averageCommentCount"),
|
||||||
|
averageDuration: readSpreadThresholdInput(root, "averageDuration"),
|
||||||
|
averageLikeCount: readSpreadThresholdInput(root, "averageLikeCount"),
|
||||||
|
averageShareCount: readSpreadThresholdInput(root, "averageShareCount"),
|
||||||
|
finishRate: readSpreadThresholdInput(root, "finishRate"),
|
||||||
|
interactionRate: readSpreadThresholdInput(root, "interactionRate"),
|
||||||
|
playMedian: readSpreadThresholdInput(root, "playMedian")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSpreadThresholdInput(
|
||||||
|
root: HTMLElement,
|
||||||
|
key: keyof SpreadThresholdFilter["thresholds"]
|
||||||
|
): HTMLInputElement {
|
||||||
|
return root.querySelector(
|
||||||
|
`[data-plugin-spread-threshold="${key}"]`
|
||||||
|
) as HTMLInputElement;
|
||||||
|
}
|
||||||
|
|
||||||
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
||||||
const toolbarDom = {
|
const toolbarDom = {
|
||||||
audienceProfileByIdExportButton: root.querySelector(
|
audienceProfileByIdExportButton: root.querySelector(
|
||||||
@ -219,9 +394,23 @@ 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(
|
||||||
|
'[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);
|
||||||
return toolbarDom;
|
return toolbarDom;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -280,6 +469,51 @@ export function readToolbarExportTarget(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readToolbarSpreadFilter(
|
||||||
|
toolbar: PluginToolbarDom
|
||||||
|
): { error?: string; filter?: SpreadThresholdFilter } {
|
||||||
|
const thresholds: SpreadThresholdFilter["thresholds"] = {};
|
||||||
|
|
||||||
|
for (const [key, input] of Object.entries(toolbar.spreadThresholdInputs)) {
|
||||||
|
const trimmedValue = input.value.trim();
|
||||||
|
if (!trimmedValue) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericValue = Number(trimmedValue);
|
||||||
|
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||||
|
return {
|
||||||
|
error: "请输入有效筛选阈值"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
thresholds[key as keyof SpreadThresholdFilter["thresholds"]] = numericValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(thresholds).length === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = Number(toolbar.spreadFilterTypeSelect.value) === 2 ? 2 : 1;
|
||||||
|
return {
|
||||||
|
filter: {
|
||||||
|
config: {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function setToolbarBusyState(
|
export function setToolbarBusyState(
|
||||||
toolbar: PluginToolbarDom,
|
toolbar: PluginToolbarDom,
|
||||||
isBusy: boolean
|
isBusy: boolean
|
||||||
@ -291,10 +525,18 @@ export function setToolbarBusyState(
|
|||||||
toolbar.audienceProfileExportButton,
|
toolbar.audienceProfileExportButton,
|
||||||
toolbar.exportButton,
|
toolbar.exportButton,
|
||||||
toolbar.exportRangeSelect,
|
toolbar.exportRangeSelect,
|
||||||
toolbar.exportCustomPagesInput
|
toolbar.exportCustomPagesInput,
|
||||||
|
toolbar.spreadFilterTypeSelect,
|
||||||
|
toolbar.spreadFilterOnlyAssignSelect,
|
||||||
|
toolbar.spreadFilterFlowTypeSelect,
|
||||||
|
toolbar.spreadFilterRangeSelect,
|
||||||
|
...Object.values(toolbar.spreadThresholdInputs)
|
||||||
].forEach((element) => {
|
].forEach((element) => {
|
||||||
element.disabled = isBusy;
|
element.disabled = isBusy;
|
||||||
});
|
});
|
||||||
|
if (!isBusy) {
|
||||||
|
syncSpreadFilterControlState(toolbar);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setToolbarExportStatus(
|
export function setToolbarExportStatus(
|
||||||
@ -309,6 +551,16 @@ function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
|
|||||||
toolbar.exportCustomPagesInput.hidden = true;
|
toolbar.exportCustomPagesInput.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncSpreadFilterControlState(toolbar: PluginToolbarDom): void {
|
||||||
|
const isPersonalVideo = toolbar.spreadFilterTypeSelect.value !== "2";
|
||||||
|
if (isPersonalVideo) {
|
||||||
|
toolbar.spreadFilterOnlyAssignSelect.value = "false";
|
||||||
|
toolbar.spreadFilterFlowTypeSelect.value = "0";
|
||||||
|
}
|
||||||
|
toolbar.spreadFilterOnlyAssignSelect.disabled = isPersonalVideo;
|
||||||
|
toolbar.spreadFilterFlowTypeSelect.disabled = isPersonalVideo;
|
||||||
|
}
|
||||||
|
|
||||||
function ensureToolbarMounted(root: HTMLElement, document: Document): void {
|
function ensureToolbarMounted(root: HTMLElement, document: Document): void {
|
||||||
const actionRow = findNativeActionRow(document);
|
const actionRow = findNativeActionRow(document);
|
||||||
if (!actionRow) {
|
if (!actionRow) {
|
||||||
@ -482,6 +734,57 @@ function applyToolbarRootStyles(root: HTMLElement): void {
|
|||||||
root.style.flexWrap = "wrap";
|
root.style.flexWrap = "wrap";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyToolbarPanelStyles(panel: HTMLElement): void {
|
||||||
|
panel.style.display = "flex";
|
||||||
|
panel.style.flexDirection = "column";
|
||||||
|
panel.style.gap = "8px";
|
||||||
|
panel.style.minWidth = "0";
|
||||||
|
panel.style.padding = "4px 0";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyToolbarRowStyles(row: HTMLElement): void {
|
||||||
|
row.style.display = "flex";
|
||||||
|
row.style.alignItems = "center";
|
||||||
|
row.style.gap = "10px";
|
||||||
|
row.style.minHeight = "32px";
|
||||||
|
row.style.minWidth = "0";
|
||||||
|
row.style.flexWrap = "wrap";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyToolbarGroupStyles(group: HTMLElement): void {
|
||||||
|
group.style.display = "flex";
|
||||||
|
group.style.alignItems = "center";
|
||||||
|
group.style.gap = "8px";
|
||||||
|
group.style.minWidth = "0";
|
||||||
|
group.style.flexWrap = "wrap";
|
||||||
|
}
|
||||||
|
|
||||||
|
function createToolbarGroupTitle(document: Document, label: string): HTMLElement {
|
||||||
|
const title = document.createElement("span");
|
||||||
|
title.textContent = label;
|
||||||
|
title.style.color = "#64748b";
|
||||||
|
title.style.fontSize = "12px";
|
||||||
|
title.style.fontWeight = "700";
|
||||||
|
title.style.lineHeight = "32px";
|
||||||
|
title.style.whiteSpace = "nowrap";
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyToolbarDividerStyles(divider: HTMLElement): void {
|
||||||
|
divider.style.width = "1px";
|
||||||
|
divider.style.height = "24px";
|
||||||
|
divider.style.background = "#e5e7eb";
|
||||||
|
divider.style.flex = "0 0 auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyToolbarActionStyles(actions: HTMLElement): void {
|
||||||
|
actions.style.display = "flex";
|
||||||
|
actions.style.alignItems = "center";
|
||||||
|
actions.style.gap = "8px";
|
||||||
|
actions.style.paddingLeft = "12px";
|
||||||
|
actions.style.borderLeft = "1px solid #e5e7eb";
|
||||||
|
}
|
||||||
|
|
||||||
function applyNativeControlStyles(
|
function applyNativeControlStyles(
|
||||||
document: Document,
|
document: Document,
|
||||||
controls: {
|
controls: {
|
||||||
@ -492,7 +795,14 @@ function applyNativeControlStyles(
|
|||||||
exportButton: HTMLButtonElement;
|
exportButton: HTMLButtonElement;
|
||||||
exportCustomPagesInput: HTMLInputElement;
|
exportCustomPagesInput: HTMLInputElement;
|
||||||
exportRangeSelect: HTMLSelectElement;
|
exportRangeSelect: HTMLSelectElement;
|
||||||
}
|
spreadFilterFlowTypeSelect: HTMLSelectElement;
|
||||||
|
spreadFilterOnlyAssignSelect: HTMLSelectElement;
|
||||||
|
spreadFilterRangeSelect: HTMLSelectElement;
|
||||||
|
spreadFilterTypeSelect: HTMLSelectElement;
|
||||||
|
} & Record<
|
||||||
|
keyof SpreadThresholdFilter["thresholds"],
|
||||||
|
HTMLInputElement
|
||||||
|
>
|
||||||
): void {
|
): void {
|
||||||
const primaryButton =
|
const primaryButton =
|
||||||
findButtonContainingText(document, "发布任务") ??
|
findButtonContainingText(document, "发布任务") ??
|
||||||
@ -519,7 +829,13 @@ function applyNativeControlStyles(
|
|||||||
button.style.whiteSpace = "nowrap";
|
button.style.whiteSpace = "nowrap";
|
||||||
});
|
});
|
||||||
|
|
||||||
[controls.exportRangeSelect, controls.exportCustomPagesInput].forEach((element) => {
|
const nativeControls = Array.from(Object.values(controls)).filter(
|
||||||
|
(element): element is HTMLInputElement | HTMLSelectElement =>
|
||||||
|
element instanceof document.defaultView!.HTMLInputElement ||
|
||||||
|
element instanceof document.defaultView!.HTMLSelectElement
|
||||||
|
);
|
||||||
|
|
||||||
|
nativeControls.forEach((element) => {
|
||||||
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";
|
||||||
@ -531,6 +847,25 @@ function applyNativeControlStyles(
|
|||||||
|
|
||||||
controls.exportRangeSelect.style.minWidth = "104px";
|
controls.exportRangeSelect.style.minWidth = "104px";
|
||||||
controls.exportCustomPagesInput.style.width = "72px";
|
controls.exportCustomPagesInput.style.width = "72px";
|
||||||
|
|
||||||
|
[
|
||||||
|
controls.spreadFilterTypeSelect,
|
||||||
|
controls.spreadFilterOnlyAssignSelect,
|
||||||
|
controls.spreadFilterFlowTypeSelect,
|
||||||
|
controls.spreadFilterRangeSelect
|
||||||
|
].forEach((select) => {
|
||||||
|
select.style.minWidth = "92px";
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.values(controls).forEach((element) => {
|
||||||
|
if (
|
||||||
|
element instanceof document.defaultView!.HTMLInputElement &&
|
||||||
|
element.dataset.pluginSpreadThreshold
|
||||||
|
) {
|
||||||
|
element.style.width =
|
||||||
|
element.dataset.pluginSpreadThreshold === "playMedian" ? "112px" : "86px";
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyPrimaryButtonStyles(
|
function applyPrimaryButtonStyles(
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { SpreadInfoMetrics } from "./types";
|
import type { SpreadInfoMetrics, SpreadMetricThresholds } from "./types";
|
||||||
|
|
||||||
interface FetchResponseLike {
|
interface FetchResponseLike {
|
||||||
json(): Promise<unknown>;
|
json(): Promise<unknown>;
|
||||||
@ -29,7 +29,7 @@ interface SpreadInfoMetricDefinition {
|
|||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MappedSpreadInfoResponse {
|
export interface MappedSpreadInfoResponse {
|
||||||
averageCommentCount?: string;
|
averageCommentCount?: string;
|
||||||
averageDuration?: string;
|
averageDuration?: string;
|
||||||
averageLikeCount?: string;
|
averageLikeCount?: string;
|
||||||
@ -108,6 +108,12 @@ export function createSpreadInfoClient(options: SpreadInfoClientOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return metrics;
|
return metrics;
|
||||||
|
},
|
||||||
|
async loadAuthorSpreadMetricSnapshot(
|
||||||
|
authorId: string,
|
||||||
|
config: SpreadInfoConfig
|
||||||
|
): Promise<MappedSpreadInfoResponse> {
|
||||||
|
return loadSpreadInfoFromUrl(buildSpreadInfoUrl(authorId, config, baseUrl));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -201,6 +207,21 @@ 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function buildSpreadInfoColumnHeader(
|
function buildSpreadInfoColumnHeader(
|
||||||
config: SpreadInfoConfig,
|
config: SpreadInfoConfig,
|
||||||
metric: SpreadInfoMetricDefinition
|
metric: SpreadInfoMetricDefinition
|
||||||
@ -278,6 +299,15 @@ function readNumberLike(value: unknown): number | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readDisplayNumber(value: string | undefined): number | null {
|
||||||
|
if (!hasTextValue(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedValue = Number(value.replace(/[% ,]/g, ""));
|
||||||
|
return Number.isFinite(parsedValue) ? parsedValue : null;
|
||||||
|
}
|
||||||
|
|
||||||
function formatBasisPointPercent(value: number | null): string | undefined {
|
function formatBasisPointPercent(value: number | null): string | undefined {
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@ -14,6 +14,26 @@ export interface BackendMetrics {
|
|||||||
|
|
||||||
export type SpreadInfoMetrics = Record<string, string>;
|
export type SpreadInfoMetrics = Record<string, string>;
|
||||||
|
|
||||||
|
export interface SpreadMetricThresholds {
|
||||||
|
averageCommentCount?: number;
|
||||||
|
averageDuration?: number;
|
||||||
|
averageLikeCount?: number;
|
||||||
|
averageShareCount?: number;
|
||||||
|
finishRate?: number;
|
||||||
|
interactionRate?: number;
|
||||||
|
playMedian?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpreadThresholdFilter {
|
||||||
|
config: {
|
||||||
|
flowType: 0 | 1;
|
||||||
|
onlyAssign: boolean;
|
||||||
|
range: 2 | 3;
|
||||||
|
type: 1 | 2;
|
||||||
|
};
|
||||||
|
thresholds: SpreadMetricThresholds;
|
||||||
|
}
|
||||||
|
|
||||||
export type MarketSortField =
|
export type MarketSortField =
|
||||||
| keyof Required<AfterSearchRates>
|
| keyof Required<AfterSearchRates>
|
||||||
| keyof Required<BackendMetrics>;
|
| keyof Required<BackendMetrics>;
|
||||||
|
|||||||
@ -1172,6 +1172,48 @@ describe("market-content-entry", () => {
|
|||||||
expect(customPagesInput?.hidden).toBe(true);
|
expect(customPagesInput?.hidden).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("toolbar exposes spread threshold filters and disables fixed personal-video controls", 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 videoTypeSelect = document.querySelector(
|
||||||
|
'[data-plugin-spread-filter="type"]'
|
||||||
|
) as HTMLSelectElement | null;
|
||||||
|
const assignSelect = document.querySelector(
|
||||||
|
'[data-plugin-spread-filter="onlyAssign"]'
|
||||||
|
) as HTMLSelectElement | null;
|
||||||
|
const flowTypeSelect = document.querySelector(
|
||||||
|
'[data-plugin-spread-filter="flowType"]'
|
||||||
|
) as HTMLSelectElement | null;
|
||||||
|
const finishRateInput = document.querySelector(
|
||||||
|
'[data-plugin-spread-threshold="finishRate"]'
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
|
||||||
|
expect(videoTypeSelect?.value).toBe("1");
|
||||||
|
expect(assignSelect?.value).toBe("false");
|
||||||
|
expect(assignSelect?.disabled).toBe(true);
|
||||||
|
expect(flowTypeSelect?.value).toBe("0");
|
||||||
|
expect(flowTypeSelect?.disabled).toBe(true);
|
||||||
|
expect(finishRateInput?.placeholder).toBe("完播率>=");
|
||||||
|
|
||||||
|
setSelectValue('[data-plugin-spread-filter="type"]', "2");
|
||||||
|
dispatchChange('[data-plugin-spread-filter="type"]');
|
||||||
|
|
||||||
|
expect(assignSelect?.disabled).toBe(false);
|
||||||
|
expect(flowTypeSelect?.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
document.body.innerHTML = buildMarketFixture();
|
document.body.innerHTML = buildMarketFixture();
|
||||||
const resultStore = createMarketResultStore();
|
const resultStore = createMarketResultStore();
|
||||||
@ -1285,6 +1327,64 @@ describe("market-content-entry", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("export keeps only records that match spread threshold filters", async () => {
|
||||||
|
document.body.innerHTML = buildRealMarketFixture([
|
||||||
|
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
|
||||||
|
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
|
||||||
|
]);
|
||||||
|
attachMarketListState([
|
||||||
|
{
|
||||||
|
attribute_datas: {
|
||||||
|
id: "spread-a",
|
||||||
|
nickname: "Alpha"
|
||||||
|
},
|
||||||
|
star_id: "a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute_datas: {
|
||||||
|
id: "spread-b",
|
||||||
|
nickname: "Beta"
|
||||||
|
},
|
||||||
|
star_id: "b"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const buildCsv = vi.fn(() => "csv-output");
|
||||||
|
const loadSpreadFilterMetrics = vi.fn(async (spreadAuthorId: string) => ({
|
||||||
|
finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%",
|
||||||
|
interactionRate: "5%"
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = trackController(createMarketController({
|
||||||
|
buildCsv,
|
||||||
|
document,
|
||||||
|
loadAuthorMetrics: async () => ({
|
||||||
|
success: false,
|
||||||
|
reason: "request-failed"
|
||||||
|
}),
|
||||||
|
loadSpreadFilterMetrics,
|
||||||
|
onCsvReady: vi.fn(),
|
||||||
|
window
|
||||||
|
}));
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||||
|
dispatchChange('[data-plugin-export-range="select"]');
|
||||||
|
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
||||||
|
click('[data-plugin-export="button"]');
|
||||||
|
await waitForMockCall(buildCsv, 80, 50);
|
||||||
|
|
||||||
|
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
|
||||||
|
flowType: 0,
|
||||||
|
onlyAssign: false,
|
||||||
|
range: 2,
|
||||||
|
type: 1
|
||||||
|
});
|
||||||
|
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
|
||||||
|
"a"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
@ -2226,6 +2326,64 @@ 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 () => {
|
||||||
|
document.body.innerHTML = buildRealMarketFixture([
|
||||||
|
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
|
||||||
|
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
|
||||||
|
]);
|
||||||
|
attachMarketListState([
|
||||||
|
{
|
||||||
|
attribute_datas: {
|
||||||
|
id: "spread-a",
|
||||||
|
nickname: "Alpha"
|
||||||
|
},
|
||||||
|
star_id: "a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute_datas: {
|
||||||
|
id: "spread-b",
|
||||||
|
nickname: "Beta"
|
||||||
|
},
|
||||||
|
star_id: "b"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const submitBatch = vi.fn(async () => ({ ok: true }));
|
||||||
|
const loadSpreadFilterMetrics = vi.fn(async (spreadAuthorId: string) => ({
|
||||||
|
finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%"
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = trackController(createMarketController({
|
||||||
|
document,
|
||||||
|
getAuthState: async () => ({
|
||||||
|
isAuthenticated: true,
|
||||||
|
resource: "https://talent-search.intelligrow.cn",
|
||||||
|
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
|
||||||
|
}),
|
||||||
|
loadAuthorMetrics: async () => ({
|
||||||
|
success: false,
|
||||||
|
reason: "request-failed"
|
||||||
|
}),
|
||||||
|
loadSpreadFilterMetrics,
|
||||||
|
promptBatchName: () => "筛选批次",
|
||||||
|
submitBatch,
|
||||||
|
window
|
||||||
|
}));
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||||
|
dispatchChange('[data-plugin-export-range="select"]');
|
||||||
|
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
||||||
|
click('[data-plugin-batch-submit="button"]');
|
||||||
|
await waitForMockCall(submitBatch, 80, 50);
|
||||||
|
|
||||||
|
expect(submitBatch.mock.calls[0]?.[0].authors).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
authorId: "a"
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("opens a custom batch name dialog before submitting", async () => {
|
test("opens a custom batch name dialog before submitting", async () => {
|
||||||
document.body.innerHTML = buildMarketFixture();
|
document.body.innerHTML = buildMarketFixture();
|
||||||
const submitBatch = vi.fn(async () => ({ ok: true }));
|
const submitBatch = vi.fn(async () => ({ ok: true }));
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
buildSpreadInfoUrl,
|
buildSpreadInfoUrl,
|
||||||
createSpreadInfoClient,
|
createSpreadInfoClient,
|
||||||
DEFAULT_SPREAD_INFO_CONFIGS,
|
DEFAULT_SPREAD_INFO_CONFIGS,
|
||||||
|
matchesSpreadThresholds,
|
||||||
mapSpreadInfoResponse
|
mapSpreadInfoResponse
|
||||||
} from "../src/content/market/spread-info";
|
} from "../src/content/market/spread-info";
|
||||||
|
|
||||||
@ -165,4 +166,49 @@ describe("spread-info", () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("matches thresholds using display values and requires every filled threshold", () => {
|
||||||
|
expect(
|
||||||
|
matchesSpreadThresholds(
|
||||||
|
{
|
||||||
|
averageDuration: "56",
|
||||||
|
finishRate: "28.24%",
|
||||||
|
interactionRate: "4.02%",
|
||||||
|
playMedian: "10913233"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
averageDuration: 50,
|
||||||
|
finishRate: 28,
|
||||||
|
interactionRate: 4,
|
||||||
|
playMedian: 10000000
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
matchesSpreadThresholds(
|
||||||
|
{
|
||||||
|
averageDuration: "56",
|
||||||
|
finishRate: "28.24%",
|
||||||
|
interactionRate: "4.02%",
|
||||||
|
playMedian: "10913233"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
averageDuration: 57
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
matchesSpreadThresholds(
|
||||||
|
{
|
||||||
|
finishRate: "28.24%"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
finishRate: 20,
|
||||||
|
interactionRate: 1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user