4 Commits
Author SHA1 Message Date
wxs 70c1de3a7e fix: restore metric filter interactions 2026-07-16 21:23:45 +08:00
wxs 2e248fa9a3 feat: redesign market propagation toolbar 2026-07-16 18:21:12 +08:00
wxs ca365b485a docs: specify market toolbar redesign 2026-07-16 14:50:09 +08:00
wxs 961fb33e9e 完善传播指标筛选布局设计 2026-07-13 12:18:40 +08:00
4 changed files with 1284 additions and 125 deletions
@@ -0,0 +1,102 @@
# 传播指标筛选布局重设计
## 定位
本文件是 [传播指标独立视频口径筛选设计](2026-07-10-independent-spread-metric-filter-design.md)
的界面补充。它只重做星图插件市场页工具栏中“传播指标筛选”的展示和选取方式,
不改变已实现的独立指标口径、规则数组、筛选执行、CSV 导出或批次提交。
当前 `plugin-toolbar.ts` 已通过 `SPREAD_FILTER_DEFINITIONS` 提供完播率和互动率,
并让 `SpreadMetricRuleDom` 为每个已选指标保留独立阈值和视频口径。本次继续使用这
套状态和 DOM 规则,不重建筛选数据模型。
## 目标
避免在工具栏初始状态平铺全部可筛选指标和完整条件。用户只在需要时选择指标,主区
只展示已经生效的指标规则。
即使未来 `SPREAD_FILTER_DEFINITIONS` 增加更多指标,工具栏高度也只随已选规则增加,
不会随可选指标总数线性增长。
## 桌面端交互
“传播指标筛选”标题行由以下内容组成:
1. “添加筛选指标”按钮。
2. 已选规则数量,例如“已选 2 项”。
3. 仅在同时有两条及以上规则时显示“全部规则都达标才保留达人”。
点击“添加筛选指标”后,打开锚定按钮的指标目录。目录提供搜索框和分组列表;当前
完播率、互动率归在“传播表现”分组。未选择的指标显示添加操作,已选择的指标显示
“已添加”且不可重复选取。
选择一个指标后关闭目录,并在下方追加该指标现有的完整规则行。规则行继续包含:
1. 指标名称。
2. `>=` 阈值输入和 `%` 单位。
3. 视频类型。
4. 是否只看指派。
5. 是否排除营销流量。
6. 时间范围。
7. 删除按钮。
删除按钮取消该指标的现有 `enabledInput` 状态、移除规则行,并使指标在目录中重新
可选。初始无选项时不显示任何规则行,只保留添加入口和简短空状态。
## 窄屏交互
窄屏时,指标目录不占用固定侧栏空间。点击“添加筛选指标”后,目录作为标题行下方
的可收起抽屉展开;选择、搜索或关闭后恢复收起状态。规则行保留指标、阈值和视频
类型,指派、营销和时间范围进入同一行的二级展开区,避免横向挤压或遮挡。
桌面端使用按钮锚定浮层,窄屏使用抽屉;两种视图读取同一组 `enabledInput`
`SpreadMetricRuleDom`,不能分别保存选择状态。
## 代码边界
### 修改范围
- `src/content/market/plugin-toolbar.ts`
-`MetricCatalog` 替代常驻复选框式 `createSpreadMetricSelector()`
- 保留 `createSpreadMetricRuleDom()``syncSpreadMetricRuleState()`
`readToolbarSpreadFilter()``SpreadMetricRuleDomMap` 的规则读写职责。
- 在添加、删除、初始化和工具栏重挂载时,同步目录中的已选状态和规则数量。
- `tests/market-content-entry.test.ts`
- 覆盖初始空状态、从目录添加、重复项不可添加、删除后恢复可选、窄屏抽屉开关。
### 不修改范围
- `src/content/market/types.ts` 中的 `SpreadFilterMetric``SpreadMetricFilterRule`
`SpreadThresholdFilter``SpreadInfoConfig`
- `src/content/market/index.ts` 中的 `applySpreadThresholdFilter()`、规则 AND 判定、
相同口径请求复用、CSV 导出和批次提交流程。
- `src/content/market/spread-info.ts` 的星图接口参数、字段映射和百分比比较。
## 状态约束
1. `SPREAD_FILTER_DEFINITIONS` 是可选指标目录的唯一来源;目录不得再维护一份独立
的指标清单。
2. 一个 `SpreadFilterMetric` 至多有一条规则,目录已选状态由对应
`enabledInput.checked` 推导。
3. 从目录添加指标时,沿用既有默认口径:个人视频、不限指派、不排除营销、近 30 天;
阈值为空,必须由用户填写。
4. 删除后重新添加同一指标,按上述默认值创建,不恢复已删除的阈值或口径。
5. 未选指标不参与 `readToolbarSpreadFilter()` 校验,也不能触发传播指标请求。
## 验收标准
1. 初始工具栏不再显示一排常驻指标复选框和空规则行,只显示“添加筛选指标”入口。
2. 选中完播率后,页面只增加一条完播率规则;选中互动率后才增加第二条规则。
3. 指标目录内已添加项不可再次添加;删除后立即恢复可选。
4. 已选数量、规则行、`enabledInput``readToolbarSpreadFilter()` 返回的规则数组
始终一致。
5. 已选规则的阈值校验、个人视频联动、不同口径请求、规则 AND 筛选、CSV 导出和
批次提交结果与改版前保持一致。
6. 窄屏打开和关闭指标抽屉时不发生内容重叠、横向溢出或大块无效留白。
## 验证
实现阶段先新增工具栏交互测试并观察其在旧布局下失败,再完成最小 UI 改造。之后运行
相关市场页测试、项目类型检查和构建,并在桌面与窄屏实测添加、删除、重复选择和导出
前校验。现有独立口径筛选的回归用例必须全部继续通过。
@@ -0,0 +1,76 @@
# 传播指标筛选与批量操作重设计
## 目标
重设计星图达人市场页插件注入的工具栏红框区域。保留全部现有导出和批量能力,将它们按任务边界排列;将传播指标筛选改成独立的条件构建区,减少操作与筛选条件混排造成的阅读负担。
本设计仅覆盖 `src/content/market/plugin-toolbar.ts` 生成的插件工具栏,不改变星图原生筛选区、后端接口、导出数据结构或批次提交协议。
## 页面结构
工具栏始终分为两行。
### 第一行:导出与批量操作
左侧展示结果计数,右侧可继续展示已有的导出状态。中间操作按以下顺序分成四组,组间使用细分隔线:
1. **列表 CSV 导出**`导出CSV` 紧邻导出范围下拉框。范围选项保持 `当前页``前5页``前10页``全部``自定义`,默认仍为 `前5页`;选择 `自定义` 后显示页数输入框。
2. **已选达人数据导出**`导出选中达人数据``选择字段` 相邻。字段配置自动保存,且仅影响该类达人数据导出。
3. **星图 ID 导出**:保留 `按星图ID导出` 独立入口,打开既有的 ID 输入流程,不依赖当前列表勾选。
4. **批次操作**`提交批次` 是该行唯一的高强调主按钮;其余按钮维持次级强调。
`导出CSV` 的范围只影响当前筛选结果的分页导出,不能影响“导出选中达人数据”或“按星图ID导出”。
### 第二行:传播表现筛选
第二行标题为“传播表现筛选”,标题旁展示已启用指标数。右侧提供“添加筛选指标”入口,打开既有指标目录;未启用的指标不显示空规则行。
每条已启用规则包含指标名、`≥`、阈值输入、视频类型、更多选项和删除按钮。桌面端直接显示视频类型、指派范围与时间范围;窄屏端沿用“更多选项”收起次级控件的模式。
当存在两条及以上规则时,显示 `AND` 关系说明:所有规则都达标才保留达人。
## 默认状态
首次加载时默认启用且仅启用一条规则:
| 项目 | 默认值 |
| --- | --- |
| 指标 | 完播率 (`finishRate`) |
| 阈值 | 留空,用户必须填写 |
| 视频类型 | 星图视频 (`type: 2`) |
| 指派范围 | 只看指派 (`onlyAssign: true`) |
| 时间范围 | 近30天 (`range: 2`) |
| 营销过滤 | 不排除营销 (`flowType: 0`) |
互动率 (`interactionRate`) 初始时只出现在“添加筛选指标”目录中。添加后使用同一套独立规则结构与默认上下文;其阈值同样留空。
启用规则但未填写阈值时,执行依赖传播指标筛选的导出或批次操作必须显示现有的阈值校验错误,不能静默移除该规则或改用未筛选结果。
## 交互与状态
- 点击“添加筛选指标”后,已添加指标显示“已添加”且不可重复添加。
- 删除一条规则后,清空其阈值并恢复该指标在目录中的“添加”状态。
- 规则新增或删除后,已选计数和 `AND` 说明立即同步。
- 每次规则重新添加时,应用本设计规定的默认上下文,但不预填阈值。
- 工具栏忙碌时,现有导出、批次、范围、字段和筛选控件均不可操作;操作完成后恢复。
## 视觉原则
- 使用克制的白底、灰色边界与星图现有的粉色强调色;不增加新的侧栏或浮层任务区。
- 第一行优先服务导出和批量执行,第二行优先服务条件配置,避免两个工作流争夺同一行空间。
- `提交批次` 与“添加筛选指标”可使用强调色;删除是低强调的图标操作;其他按钮为中性描边按钮。
- 通过文本标签与图标共同表达“选择字段”和“按星图ID导出”,不以图标替代功能名称。
## 验收标准
1. 第一行完整保留 `导出CSV`、范围选择、`导出选中达人数据``选择字段``按星图ID导出``提交批次`,且导出范围仅与 `导出CSV` 相邻。
2. 初始页面只显示一条完播率规则,选中计数为“已选 1 项”。
3. 初始规则上下文为星图视频、只看指派、近30天;完播率阈值为空。
4. 未填写阈值时,现有校验能阻止将该启用规则作为有效过滤条件继续执行。
5. 互动率不会在默认界面占位,只有通过目录添加后才显示。
6. 删除和重新添加指标后,规则默认上下文、目录状态和已选计数均正确同步。
7. 窄屏下操作组可换行,筛选规则的次级条件不会溢出或与其他控件重叠。
## 验证范围
实现时更新工具栏 DOM 测试,覆盖默认完播率规则、默认 `SpreadInfoConfig`、空阈值校验、互动率目录添加/删除、导出范围与五个既有操作入口的保留。运行聚焦的市场工具栏测试、传播指标测试和项目构建。
+624 -65
View File
@@ -16,17 +16,35 @@ export interface PluginToolbarHandlers {
}
interface SpreadMetricRuleDom {
detailsButton: HTMLButtonElement;
enabledInput: HTMLInputElement;
flowTypeSelect: HTMLSelectElement;
onlyAssignSelect: HTMLSelectElement;
rangeSelect: HTMLSelectElement;
removeButton: HTMLButtonElement;
root: HTMLElement;
secondaryControls: HTMLElement;
thresholdInput: HTMLInputElement;
typeSelect: HTMLSelectElement;
}
type SpreadMetricRuleDomMap = Record<SpreadFilterMetric, SpreadMetricRuleDom>;
interface MetricCatalogDom {
addButton: HTMLButtonElement;
closeButton: HTMLButtonElement;
metricActions: Record<SpreadFilterMetric, HTMLButtonElement>;
panel: HTMLElement;
root: HTMLElement;
searchInput: HTMLInputElement;
selectedCount: HTMLElement;
}
interface ToolbarResizeListener {
handler: EventListener;
window: Window;
}
const SPREAD_FILTER_DEFINITIONS: ReadonlyArray<{
label: string;
metric: SpreadFilterMetric;
@@ -35,6 +53,14 @@ const SPREAD_FILTER_DEFINITIONS: ReadonlyArray<{
{ label: "互动率", metric: "interactionRate" }
];
const DEFAULT_ENABLED_SPREAD_METRIC: SpreadFilterMetric = "finishRate";
const DEFAULT_SPREAD_METRIC_CONFIG: Readonly<SpreadInfoConfig> = {
flowType: 0,
onlyAssign: true,
range: 2,
type: 2
};
export interface PluginToolbarDom {
audienceProfileByIdExportButton: HTMLButtonElement;
audienceProfileExportButton: HTMLButtonElement;
@@ -44,18 +70,30 @@ export interface PluginToolbarDom {
exportCustomPagesInput: HTMLInputElement;
exportRangeSelect: HTMLSelectElement;
exportStatusText: HTMLElement;
metricCatalog: MetricCatalogDom;
spreadMetricRules: SpreadMetricRuleDomMap;
root: HTMLElement;
}
const PLUGIN_ACTION_BUTTON_STYLE_ID = "sces-plugin-action-button-style";
const TOOLBAR_RESIZE_LISTENER = Symbol("sces-toolbar-resize-listener");
type ToolbarRootWithResizeListener = HTMLElement & {
[TOOLBAR_RESIZE_LISTENER]?: ToolbarResizeListener;
};
export function isPluginToolbarMounted(
root: HTMLElement,
document: Document
): boolean {
const actionRow = findNativeActionRow(document);
return Boolean(actionRow && root.parentElement === actionRow && !root.hidden);
const isMounted = Boolean(
actionRow && root.parentElement === actionRow && !root.hidden
);
if (!isMounted) {
cleanupToolbarResizeListener(root);
}
return isMounted;
}
export function ensurePluginToolbar(
@@ -72,12 +110,18 @@ export function ensurePluginToolbar(
existingRoot.querySelector(
'[data-plugin-export-audience-profile-by-id="button"]'
) &&
existingRoot.querySelector('[data-plugin-spread-metric="finishRate"]')
existingRoot.querySelector(
'[data-plugin-toolbar-action-group="csv-export"]'
) &&
existingRoot.querySelector('[data-plugin-spread-metric-catalog-trigger="button"]')
) {
ensureToolbarMounted(existingRoot, document);
return readToolbarDom(existingRoot);
const toolbarDom = readToolbarDom(existingRoot);
ensureToolbarResizeListener(toolbarDom);
return toolbarDom;
}
cleanupToolbarResizeListener(existingRoot);
existingRoot.remove();
}
@@ -140,6 +184,11 @@ export function ensurePluginToolbar(
applyStatusStyles(exportStatusText);
const spreadMetricRules = createSpreadMetricRuleDoms(document);
const defaultSpreadMetricRule =
spreadMetricRules[DEFAULT_ENABLED_SPREAD_METRIC];
defaultSpreadMetricRule.enabledInput.checked = true;
applyDefaultSpreadMetricRuleState(defaultSpreadMetricRule);
const metricCatalog = createMetricCatalog(document);
const panel = document.createElement("div");
panel.dataset.pluginToolbarPanel = "root";
@@ -156,18 +205,33 @@ export function ensurePluginToolbar(
const dataGroup = document.createElement("div");
dataGroup.dataset.pluginToolbarGroup = "data";
applyToolbarGroupStyles(dataGroup);
dataGroup.append(
const csvExportGroup = createToolbarActionGroup(document, "csv-export", [
exportButton,
exportRangeSelect,
exportCustomPagesInput,
audienceProfileExportButton,
audienceProfileByIdExportButton,
audienceProfileFieldButton,
exportCustomPagesInput
]);
const selectedAudienceExportGroup = createToolbarActionGroup(
document,
"selected-audience-export",
[audienceProfileExportButton, audienceProfileFieldButton]
);
const idExportGroup = createToolbarActionGroup(document, "id-export", [
audienceProfileByIdExportButton
]);
const batchSubmitGroup = createToolbarActionGroup(document, "batch-submit", [
batchSubmitButton
]);
dataGroup.append(
csvExportGroup,
createToolbarActionDivider(document),
selectedAudienceExportGroup,
createToolbarActionDivider(document),
idExportGroup,
createToolbarActionDivider(document),
batchSubmitGroup
);
const thresholdTitle = createToolbarGroupTitle(document, "传播指标筛选");
const metricSelector = createSpreadMetricSelector(document, spreadMetricRules);
const rulesGroup = document.createElement("div");
rulesGroup.dataset.pluginSpreadRules = "root";
applySpreadRulesGroupStyles(rulesGroup);
@@ -179,7 +243,7 @@ export function ensurePluginToolbar(
const filterNote = createSpreadFilterNote(document);
firstRow.append(dataGroup, exportStatusText);
secondRow.append(thresholdTitle, metricSelector, rulesGroup, filterNote);
secondRow.append(thresholdTitle, metricCatalog.root, rulesGroup, filterNote);
panel.append(firstRow, secondRow);
root.append(panel);
@@ -222,6 +286,7 @@ export function ensurePluginToolbar(
exportCustomPagesInput,
exportRangeSelect,
exportStatusText,
metricCatalog,
spreadMetricRules,
root
} satisfies PluginToolbarDom;
@@ -230,6 +295,16 @@ export function ensurePluginToolbar(
syncCustomPagesInputVisibility(toolbarDom);
});
metricCatalog.addButton.addEventListener("click", () => {
setMetricCatalogOpen(metricCatalog, metricCatalog.panel.hidden);
});
metricCatalog.closeButton.addEventListener("click", () => {
setMetricCatalogOpen(metricCatalog, false);
});
metricCatalog.searchInput.addEventListener("input", () => {
syncMetricCatalog(toolbarDom);
});
SPREAD_FILTER_DEFINITIONS.forEach(({ metric }) => {
const rule = spreadMetricRules[metric];
rule.enabledInput.addEventListener("change", () => {
@@ -239,8 +314,27 @@ export function ensurePluginToolbar(
rule.typeSelect.addEventListener("change", () => {
syncSpreadMetricVideoConstraints(rule);
});
rule.removeButton.addEventListener("click", () => {
rule.enabledInput.checked = false;
syncAllSpreadMetricRules(toolbarDom);
});
rule.detailsButton.addEventListener("click", () => {
rule.root.dataset.pluginSpreadRuleDetails =
rule.root.dataset.pluginSpreadRuleDetails === "open" ? "closed" : "open";
syncSpreadMetricRuleSecondaryControls(rule);
});
metricCatalog.metricActions[metric].addEventListener("click", () => {
if (rule.enabledInput.checked) {
return;
}
rule.enabledInput.checked = true;
syncAllSpreadMetricRules(toolbarDom);
setMetricCatalogOpen(metricCatalog, false);
});
});
ensureToolbarResizeListener(toolbarDom);
syncCustomPagesInputVisibility(toolbarDom);
syncAllSpreadMetricRules(toolbarDom);
@@ -258,6 +352,30 @@ function appendOption(
select.appendChild(option);
}
function createToolbarActionGroup(
document: Document,
groupName:
| "batch-submit"
| "csv-export"
| "id-export"
| "selected-audience-export",
controls: HTMLElement[]
): HTMLElement {
const group = document.createElement("div");
group.dataset.pluginToolbarActionGroup = groupName;
applyToolbarActionGroupStyles(group);
group.append(...controls);
return group;
}
function createToolbarActionDivider(document: Document): HTMLElement {
const divider = document.createElement("span");
divider.dataset.pluginToolbarActionDivider = "divider";
divider.setAttribute("aria-hidden", "true");
applyToolbarActionDividerStyles(divider);
return divider;
}
function createSpreadMetricRuleDoms(
document: Document
): SpreadMetricRuleDomMap {
@@ -276,6 +394,7 @@ function createSpreadMetricRuleDom(
): SpreadMetricRuleDom {
const enabledInput = document.createElement("input");
enabledInput.type = "checkbox";
enabledInput.hidden = true;
enabledInput.dataset.pluginSpreadMetric = metric;
enabledInput.setAttribute("aria-label", `启用${label}筛选`);
@@ -332,44 +451,131 @@ function createSpreadMetricRuleDom(
unitText.textContent = "%";
thresholdControl.append(operator, thresholdInput, unitText);
root.append(
metricLabel,
thresholdControl,
typeSelect,
const primaryControls = document.createElement("div");
primaryControls.dataset.pluginSpreadRulePrimary = metric;
applySpreadMetricRulePrimaryStyles(primaryControls);
const detailsButton = document.createElement("button");
detailsButton.type = "button";
detailsButton.dataset.pluginSpreadRuleDetails = metric;
detailsButton.textContent = "更多选项";
detailsButton.setAttribute("aria-expanded", "false");
applySpreadMetricDetailsButtonStyles(detailsButton);
primaryControls.append(metricLabel, thresholdControl, typeSelect, detailsButton);
const secondaryControls = document.createElement("div");
secondaryControls.dataset.pluginSpreadRuleSecondary = metric;
applySpreadMetricRuleSecondaryStyles(secondaryControls);
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.dataset.pluginSpreadRuleRemove = metric;
removeButton.textContent = "×";
removeButton.title = `删除${label}筛选`;
removeButton.setAttribute("aria-label", `删除${label}筛选`);
applySpreadMetricRemoveButtonStyles(removeButton);
secondaryControls.append(
onlyAssignSelect,
flowTypeSelect,
rangeSelect
rangeSelect,
removeButton
);
root.append(enabledInput, primaryControls, secondaryControls);
return {
detailsButton,
enabledInput,
flowTypeSelect,
onlyAssignSelect,
rangeSelect,
removeButton,
root,
secondaryControls,
thresholdInput,
typeSelect
};
}
function createSpreadMetricSelector(
document: Document,
rules: SpreadMetricRuleDomMap
): HTMLElement {
const selector = document.createElement("div");
selector.dataset.pluginSpreadMetrics = "root";
applySpreadMetricSelectorStyles(selector);
function createMetricCatalog(document: Document): MetricCatalogDom {
const root = document.createElement("div");
root.dataset.pluginSpreadMetricCatalog = "root";
applyMetricCatalogStyles(root);
SPREAD_FILTER_DEFINITIONS.forEach(({ label, metric }) => {
const option = document.createElement("label");
applySpreadMetricOptionStyles(option);
const labelText = document.createElement("span");
labelText.textContent = label;
option.append(rules[metric].enabledInput, labelText);
selector.appendChild(option);
});
const addButton = document.createElement("button");
addButton.type = "button";
addButton.dataset.pluginSpreadMetricCatalogTrigger = "button";
addButton.textContent = "添加筛选指标";
addButton.setAttribute("aria-expanded", "false");
applyMetricCatalogTriggerStyles(addButton);
return selector;
const selectedCount = document.createElement("span");
selectedCount.dataset.pluginSpreadMetricSelectedCount = "text";
selectedCount.setAttribute("aria-live", "polite");
applyMetricCatalogCountStyles(selectedCount);
const panel = document.createElement("div");
panel.dataset.pluginSpreadMetricCatalogPanel = "root";
panel.hidden = true;
applyMetricCatalogPanelStyles(panel);
const searchInput = document.createElement("input");
searchInput.type = "search";
searchInput.placeholder = "搜索指标";
searchInput.dataset.pluginSpreadMetricCatalogSearch = "input";
searchInput.setAttribute("aria-label", "搜索筛选指标");
applyMetricCatalogSearchStyles(searchInput);
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.dataset.pluginSpreadMetricCatalogClose = "button";
closeButton.textContent = "×";
closeButton.title = "关闭指标目录";
closeButton.setAttribute("aria-label", "关闭指标目录");
applyMetricCatalogCloseButtonStyles(closeButton);
const panelHeader = document.createElement("div");
panelHeader.dataset.pluginSpreadMetricCatalogHeader = "root";
applyMetricCatalogHeaderStyles(panelHeader);
panelHeader.append(searchInput, closeButton);
const group = document.createElement("div");
group.dataset.pluginSpreadMetricCatalogGroup = "传播表现";
applyMetricCatalogGroupStyles(group);
const groupTitle = document.createElement("strong");
groupTitle.textContent = "传播表现";
applyMetricCatalogGroupTitleStyles(groupTitle);
group.appendChild(groupTitle);
const metricActions = Object.fromEntries(
SPREAD_FILTER_DEFINITIONS.map(({ label, metric }) => {
const item = document.createElement("div");
item.dataset.pluginSpreadMetricCatalogItem = metric;
applyMetricCatalogItemStyles(item);
const name = document.createElement("span");
name.textContent = label;
const action = document.createElement("button");
action.type = "button";
action.dataset.pluginSpreadMetricCatalogAction = metric;
applyMetricCatalogActionStyles(action);
item.append(name, action);
group.appendChild(item);
return [metric, action];
})
) as unknown as Record<SpreadFilterMetric, HTMLButtonElement>;
panel.append(panelHeader, group);
root.append(addButton, selectedCount, panel);
return {
addButton,
closeButton,
metricActions,
panel,
root,
searchInput,
selectedCount
};
}
function createSpreadFilterNote(document: Document): HTMLElement {
@@ -390,6 +596,9 @@ function readSpreadMetricRuleDoms(root: HTMLElement): SpreadMetricRuleDomMap {
return [
metric,
{
detailsButton: ruleRoot.querySelector(
`[data-plugin-spread-rule-details="${metric}"]`
) as HTMLButtonElement,
enabledInput: root.querySelector(
`[data-plugin-spread-metric="${metric}"]`
) as HTMLInputElement,
@@ -402,7 +611,13 @@ function readSpreadMetricRuleDoms(root: HTMLElement): SpreadMetricRuleDomMap {
rangeSelect: ruleRoot.querySelector(
'[data-plugin-spread-filter="range"]'
) as HTMLSelectElement,
removeButton: ruleRoot.querySelector(
`[data-plugin-spread-rule-remove="${metric}"]`
) as HTMLButtonElement,
root: ruleRoot,
secondaryControls: ruleRoot.querySelector(
`[data-plugin-spread-rule-secondary="${metric}"]`
) as HTMLElement,
thresholdInput: ruleRoot.querySelector(
`[data-plugin-spread-threshold="${metric}"]`
) as HTMLInputElement,
@@ -415,6 +630,37 @@ function readSpreadMetricRuleDoms(root: HTMLElement): SpreadMetricRuleDomMap {
) as unknown as SpreadMetricRuleDomMap;
}
function readMetricCatalogDom(root: HTMLElement): MetricCatalogDom {
return {
addButton: root.querySelector(
'[data-plugin-spread-metric-catalog-trigger="button"]'
) as HTMLButtonElement,
closeButton: root.querySelector(
'[data-plugin-spread-metric-catalog-close="button"]'
) as HTMLButtonElement,
metricActions: Object.fromEntries(
SPREAD_FILTER_DEFINITIONS.map(({ metric }) => [
metric,
root.querySelector(
`[data-plugin-spread-metric-catalog-action="${metric}"]`
) as HTMLButtonElement
])
) as unknown as Record<SpreadFilterMetric, HTMLButtonElement>,
panel: root.querySelector(
'[data-plugin-spread-metric-catalog-panel="root"]'
) as HTMLElement,
root: root.querySelector(
'[data-plugin-spread-metric-catalog="root"]'
) as HTMLElement,
searchInput: root.querySelector(
'[data-plugin-spread-metric-catalog-search="input"]'
) as HTMLInputElement,
selectedCount: root.querySelector(
'[data-plugin-spread-metric-selected-count="text"]'
) as HTMLElement
};
}
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
const toolbarDom = {
audienceProfileByIdExportButton: root.querySelector(
@@ -441,6 +687,7 @@ function readToolbarDom(root: HTMLElement): PluginToolbarDom {
exportStatusText: root.querySelector(
'[data-plugin-export-status="text"]'
) as HTMLElement,
metricCatalog: readMetricCatalogDom(root),
spreadMetricRules: readSpreadMetricRuleDoms(root),
root
} satisfies PluginToolbarDom;
@@ -551,13 +798,19 @@ export function setToolbarBusyState(
toolbar.exportButton,
toolbar.exportRangeSelect,
toolbar.exportCustomPagesInput,
toolbar.metricCatalog.addButton,
toolbar.metricCatalog.closeButton,
toolbar.metricCatalog.searchInput,
...Object.values(toolbar.metricCatalog.metricActions),
...Object.values(toolbar.spreadMetricRules).flatMap((rule) => [
rule.detailsButton,
rule.enabledInput,
rule.thresholdInput,
rule.typeSelect,
rule.onlyAssignSelect,
rule.flowTypeSelect,
rule.rangeSelect
rule.rangeSelect,
rule.removeButton
])
].forEach((element) => {
element.disabled = isBusy;
@@ -579,22 +832,68 @@ function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
toolbar.exportCustomPagesInput.hidden = toolbar.exportRangeSelect.value !== "custom";
}
function ensureToolbarResizeListener(toolbar: PluginToolbarDom): void {
const root = toolbar.root as ToolbarRootWithResizeListener;
const currentWindow = root.ownerDocument.defaultView;
const existingListener = root[TOOLBAR_RESIZE_LISTENER];
if (!currentWindow) {
cleanupToolbarResizeListener(root);
return;
}
if (existingListener?.window === currentWindow) {
return;
}
if (existingListener) {
existingListener.window.removeEventListener(
"resize",
existingListener.handler
);
delete root[TOOLBAR_RESIZE_LISTENER];
}
const handler = existingListener?.handler ?? (() => {
syncAllSpreadMetricRules(toolbar);
});
root[TOOLBAR_RESIZE_LISTENER] = { handler, window: currentWindow };
currentWindow.addEventListener("resize", handler);
}
function cleanupToolbarResizeListener(root: HTMLElement): void {
const toolbarRoot = root as ToolbarRootWithResizeListener;
const listener = toolbarRoot[TOOLBAR_RESIZE_LISTENER];
if (!listener) {
return;
}
listener.window.removeEventListener("resize", listener.handler);
delete toolbarRoot[TOOLBAR_RESIZE_LISTENER];
}
function syncAllSpreadMetricRules(toolbar: PluginToolbarDom): void {
Object.values(toolbar.spreadMetricRules).forEach(syncSpreadMetricRuleState);
syncMetricCatalog(toolbar);
syncSpreadFilterNote(toolbar);
}
function syncSpreadMetricRuleState(rule: SpreadMetricRuleDom): void {
rule.root.hidden = !rule.enabledInput.checked;
rule.root.style.display = rule.enabledInput.checked ? "flex" : "none";
if (!rule.enabledInput.checked) {
rule.thresholdInput.value = "";
rule.typeSelect.value = "1";
rule.onlyAssignSelect.value = "false";
rule.flowTypeSelect.value = "0";
rule.rangeSelect.value = "2";
applyDefaultSpreadMetricRuleState(rule);
clearSpreadMetricRuleValidation(rule);
}
syncSpreadMetricVideoConstraints(rule);
syncSpreadMetricRuleSecondaryControls(rule);
}
function applyDefaultSpreadMetricRuleState(rule: SpreadMetricRuleDom): void {
rule.thresholdInput.value = "";
rule.typeSelect.value = String(DEFAULT_SPREAD_METRIC_CONFIG.type);
rule.onlyAssignSelect.value = String(DEFAULT_SPREAD_METRIC_CONFIG.onlyAssign);
rule.flowTypeSelect.value = String(DEFAULT_SPREAD_METRIC_CONFIG.flowType);
rule.rangeSelect.value = String(DEFAULT_SPREAD_METRIC_CONFIG.range);
}
function syncSpreadMetricVideoConstraints(rule: SpreadMetricRuleDom): void {
@@ -621,6 +920,52 @@ function syncSpreadFilterNote(toolbar: PluginToolbarDom): void {
note.hidden = enabledCount < 2;
}
function syncMetricCatalog(toolbar: PluginToolbarDom): void {
const searchTerm = toolbar.metricCatalog.searchInput.value.trim().toLowerCase();
const enabledCount = Object.values(toolbar.spreadMetricRules).filter(
(rule) => rule.enabledInput.checked
).length;
toolbar.metricCatalog.selectedCount.textContent = `已选 ${enabledCount}`;
SPREAD_FILTER_DEFINITIONS.forEach(({ label, metric }) => {
const action = toolbar.metricCatalog.metricActions[metric];
const isSelected = toolbar.spreadMetricRules[metric].enabledInput.checked;
const item = action.closest(
`[data-plugin-spread-metric-catalog-item="${metric}"]`
) as HTMLElement | null;
action.disabled = isSelected;
action.textContent = isSelected ? "已添加" : "添加";
if (item) {
item.hidden = Boolean(searchTerm) && !label.toLowerCase().includes(searchTerm);
}
});
}
function setMetricCatalogOpen(catalog: MetricCatalogDom, isOpen: boolean): void {
catalog.panel.hidden = !isOpen;
catalog.addButton.setAttribute("aria-expanded", String(isOpen));
if (!isOpen) {
catalog.searchInput.value = "";
Object.values(catalog.metricActions).forEach((action) => {
const item = action.closest("[data-plugin-spread-metric-catalog-item]") as
| HTMLElement
| null;
if (item) {
item.hidden = false;
}
});
}
}
function syncSpreadMetricRuleSecondaryControls(rule: SpreadMetricRuleDom): void {
const isNarrowViewport =
(rule.root.ownerDocument.defaultView?.innerWidth ?? Number.POSITIVE_INFINITY) <= 720;
const isOpen = rule.root.dataset.pluginSpreadRuleDetails === "open";
rule.secondaryControls.hidden = isNarrowViewport && !isOpen;
rule.detailsButton.hidden = !isNarrowViewport;
rule.detailsButton.setAttribute("aria-expanded", String(isOpen));
}
function readSpreadMetricConfig(rule: SpreadMetricRuleDom): SpreadInfoConfig {
const type = rule.typeSelect.value === "2" ? 2 : 1;
return {
@@ -826,8 +1171,8 @@ function applyToolbarPanelStyles(panel: HTMLElement): void {
panel.style.flex = "1 1 auto";
panel.style.minWidth = "0";
panel.style.padding = "0";
panel.style.overflowX = "hidden";
panel.style.overflowY = "hidden";
panel.style.overflowX = "visible";
panel.style.overflowY = "visible";
}
function applyToolbarRowStyles(row: HTMLElement): void {
@@ -850,23 +1195,115 @@ function applyToolbarGroupStyles(group: HTMLElement): void {
group.style.flexWrap = "nowrap";
}
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 applyToolbarActionGroupStyles(group: HTMLElement): void {
group.style.display = "flex";
group.style.alignItems = "center";
group.style.gap = "8px";
group.style.flex = "0 0 auto";
group.style.flexWrap = "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 applyToolbarActionDividerStyles(divider: HTMLElement): void {
divider.style.width = "1px";
divider.style.height = "20px";
divider.style.background = "#d0d5dd";
divider.style.flex = "0 0 auto";
}
function applyMetricCatalogStyles(catalog: HTMLElement): void {
catalog.style.position = "relative";
catalog.style.display = "inline-flex";
catalog.style.alignItems = "center";
catalog.style.gap = "8px";
catalog.style.flex = "0 0 auto";
}
function applyMetricCatalogTriggerStyles(button: HTMLButtonElement): void {
button.style.height = "32px";
button.style.padding = "0 10px";
button.style.border = "1px solid #94a3b8";
button.style.borderRadius = "6px";
button.style.background = "#ffffff";
button.style.color = "#334155";
button.style.fontSize = "12px";
button.style.fontWeight = "700";
button.style.whiteSpace = "nowrap";
}
function applyMetricCatalogCountStyles(count: HTMLElement): void {
count.style.color = "#64748b";
count.style.fontSize = "12px";
count.style.fontWeight = "700";
count.style.whiteSpace = "nowrap";
}
function applyMetricCatalogPanelStyles(panel: HTMLElement): void {
panel.style.position = "absolute";
panel.style.top = "calc(100% + 6px)";
panel.style.left = "0";
panel.style.zIndex = "4";
panel.style.width = "260px";
panel.style.padding = "10px";
panel.style.border = "1px solid #cbd5e1";
panel.style.borderRadius = "6px";
panel.style.background = "#ffffff";
panel.style.boxShadow = "0 10px 20px rgba(15, 23, 42, 0.14)";
}
function applyMetricCatalogHeaderStyles(header: HTMLElement): void {
header.style.display = "flex";
header.style.alignItems = "center";
header.style.gap = "6px";
}
function applyMetricCatalogSearchStyles(input: HTMLInputElement): void {
input.style.width = "100%";
input.style.height = "30px";
input.style.padding = "0 8px";
input.style.border = "1px solid #cbd5e1";
input.style.borderRadius = "5px";
input.style.boxSizing = "border-box";
}
function applyMetricCatalogCloseButtonStyles(button: HTMLButtonElement): void {
button.style.width = "30px";
button.style.height = "30px";
button.style.padding = "0";
button.style.border = "0";
button.style.background = "transparent";
button.style.color = "#64748b";
button.style.fontSize = "20px";
}
function applyMetricCatalogGroupStyles(group: HTMLElement): void {
group.style.display = "flex";
group.style.flexDirection = "column";
group.style.gap = "4px";
group.style.marginTop = "10px";
}
function applyMetricCatalogGroupTitleStyles(title: HTMLElement): void {
title.style.color = "#64748b";
title.style.fontSize = "12px";
}
function applyMetricCatalogItemStyles(item: HTMLElement): void {
item.style.display = "flex";
item.style.alignItems = "center";
item.style.justifyContent = "space-between";
item.style.minHeight = "32px";
item.style.color = "#334155";
item.style.fontSize = "13px";
}
function applyMetricCatalogActionStyles(button: HTMLButtonElement): void {
button.style.minWidth = "56px";
button.style.height = "28px";
button.style.border = "1px solid #94a3b8";
button.style.borderRadius = "5px";
button.style.background = "#ffffff";
button.style.color = "#334155";
button.style.fontSize = "12px";
}
function applySpreadRulesGroupStyles(group: HTMLElement): void {
@@ -884,9 +1321,46 @@ function applySpreadMetricRuleStyles(rule: HTMLElement): void {
rule.style.display = "flex";
rule.style.alignItems = "center";
rule.style.gap = "7px";
rule.style.minWidth = "max-content";
rule.style.flexWrap = "wrap";
rule.style.minWidth = "0";
rule.style.minHeight = "32px";
rule.style.whiteSpace = "nowrap";
}
function applySpreadMetricRulePrimaryStyles(primary: HTMLElement): void {
primary.style.display = "flex";
primary.style.alignItems = "center";
primary.style.gap = "7px";
primary.style.minWidth = "0";
primary.style.whiteSpace = "nowrap";
}
function applySpreadMetricRuleSecondaryStyles(secondary: HTMLElement): void {
secondary.style.display = "flex";
secondary.style.alignItems = "center";
secondary.style.gap = "7px";
secondary.style.minWidth = "0";
secondary.style.whiteSpace = "nowrap";
}
function applySpreadMetricDetailsButtonStyles(button: HTMLButtonElement): void {
button.style.height = "28px";
button.style.padding = "0 8px";
button.style.border = "1px solid #cbd5e1";
button.style.borderRadius = "5px";
button.style.background = "#ffffff";
button.style.color = "#475569";
button.style.fontSize = "12px";
}
function applySpreadMetricRemoveButtonStyles(button: HTMLButtonElement): void {
button.style.width = "28px";
button.style.height = "28px";
button.style.padding = "0";
button.style.border = "0";
button.style.borderRadius = "4px";
button.style.background = "transparent";
button.style.color = "#b91c1c";
button.style.fontSize = "20px";
}
function applySpreadMetricRuleLabelStyles(label: HTMLElement): void {
@@ -953,16 +1427,18 @@ function applyNativeControlStyles(
controls.batchSubmitButton.className = nativeButton.className;
}
[
const secondaryButtons = [
controls.exportButton,
controls.audienceProfileExportButton,
controls.audienceProfileByIdExportButton,
controls.audienceProfileFieldButton,
controls.batchSubmitButton
].forEach((button) => {
applyPrimaryButtonStyles(button);
controls.audienceProfileFieldButton
];
secondaryButtons.forEach((button) => {
applySecondaryButtonStyles(button);
button.style.whiteSpace = "nowrap";
});
applyPrimaryButtonStyles(controls.batchSubmitButton);
controls.batchSubmitButton.style.whiteSpace = "nowrap";
const ruleControls = Object.values(controls.spreadMetricRules).flatMap(
(rule) => [
@@ -1037,6 +1513,19 @@ function applyPrimaryButtonStyles(
"background-color 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease";
}
function applySecondaryButtonStyles(button: HTMLButtonElement): void {
button.style.backgroundColor = "#ffffff";
button.style.border = "1px solid #cbd5e1";
button.style.borderRadius = "8px";
button.style.color = "#344054";
button.style.height = "32px";
button.style.padding = "0 15px";
button.style.boxSizing = "border-box";
button.style.fontWeight = "600";
button.style.transition =
"background-color 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease";
}
function applySpreadThresholdControlStyles(control: HTMLElement): void {
control.style.display = "grid";
control.style.gridTemplateColumns = "auto auto 1fr auto";
@@ -1072,7 +1561,11 @@ function ensurePluginActionButtonTheme(document: Document): void {
[data-plugin-export="button"]:hover:not(:disabled),
[data-plugin-export-audience-profile="button"]:hover:not(:disabled),
[data-plugin-export-audience-profile-by-id="button"]:hover:not(:disabled),
[data-plugin-audience-profile-fields="button"]:hover:not(:disabled),
[data-plugin-audience-profile-fields="button"]:hover:not(:disabled) {
background-color: #f8fafc !important;
border-color: #94a3b8 !important;
}
[data-plugin-batch-submit="button"]:hover:not(:disabled) {
background-color: #6d1627 !important;
border-color: #6d1627 !important;
@@ -1081,7 +1574,12 @@ function ensurePluginActionButtonTheme(document: Document): void {
[data-plugin-export="button"]:active:not(:disabled),
[data-plugin-export-audience-profile="button"]:active:not(:disabled),
[data-plugin-export-audience-profile-by-id="button"]:active:not(:disabled),
[data-plugin-audience-profile-fields="button"]:active:not(:disabled),
[data-plugin-audience-profile-fields="button"]:active:not(:disabled) {
background-color: #f1f5f9 !important;
border-color: #94a3b8 !important;
transform: translateY(1px);
}
[data-plugin-batch-submit="button"]:active:not(:disabled) {
background-color: #58111f !important;
border-color: #58111f !important;
@@ -1091,7 +1589,11 @@ function ensurePluginActionButtonTheme(document: Document): void {
[data-plugin-export="button"]:focus-visible,
[data-plugin-export-audience-profile="button"]:focus-visible,
[data-plugin-export-audience-profile-by-id="button"]:focus-visible,
[data-plugin-audience-profile-fields="button"]:focus-visible,
[data-plugin-audience-profile-fields="button"]:focus-visible {
outline: none !important;
box-shadow: 0 0 0 3px rgba(52, 64, 84, 0.18) !important;
}
[data-plugin-batch-submit="button"]:focus-visible {
outline: none !important;
box-shadow: 0 0 0 3px rgba(127, 29, 45, 0.2) !important;
@@ -1100,7 +1602,16 @@ function ensurePluginActionButtonTheme(document: Document): void {
[data-plugin-export="button"]:disabled,
[data-plugin-export-audience-profile="button"]:disabled,
[data-plugin-export-audience-profile-by-id="button"]:disabled,
[data-plugin-audience-profile-fields="button"]:disabled,
[data-plugin-audience-profile-fields="button"]:disabled {
background-color: #f8fafc !important;
border-color: #cbd5e1 !important;
color: #98a2b3 !important;
cursor: not-allowed !important;
opacity: 1 !important;
transform: none !important;
box-shadow: none !important;
}
[data-plugin-batch-submit="button"]:disabled {
background-color: #c89ca4 !important;
border-color: #c89ca4 !important;
@@ -1128,6 +1639,54 @@ function ensurePluginActionButtonTheme(document: Document): void {
border-color: #dc2626 !important;
box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.12) !important;
}
@media (max-width: 720px) {
[data-plugin-toolbar='root'],
[data-plugin-toolbar-row='thresholds'] {
width: 100% !important;
}
[data-plugin-toolbar-row='thresholds'] {
flex-wrap: wrap !important;
align-items: flex-start !important;
}
[data-plugin-toolbar-group='data'],
[data-plugin-toolbar-action-group] {
flex-wrap: wrap !important;
}
[data-plugin-toolbar-action-divider] {
display: none !important;
}
[data-plugin-spread-metric-catalog='root'] {
width: 100% !important;
}
[data-plugin-spread-metric-catalog-panel='root'] {
position: static !important;
width: 100% !important;
margin-top: 6px !important;
box-sizing: border-box !important;
}
[data-plugin-spread-rules='root'] {
width: 100% !important;
overflow: visible !important;
}
[data-plugin-spread-rule] {
align-items: stretch !important;
flex-direction: column !important;
}
[data-plugin-spread-rule-primary],
[data-plugin-spread-rule-secondary] {
width: 100% !important;
flex-wrap: wrap !important;
}
}
`;
document.head.appendChild(style);
}
+482 -60
View File
@@ -365,6 +365,9 @@ describe("market-content-entry", () => {
const audienceProfileFieldButton = document.querySelector(
'[data-plugin-audience-profile-fields="button"]'
) as HTMLButtonElement | null;
const exportButton = document.querySelector(
'[data-plugin-export="button"]'
) as HTMLButtonElement | null;
expect(audienceProfileExportButton?.textContent).toBe("导出选中达人数据");
expect(audienceProfileExportButton?.title).toBe(
"仅导出已勾选达人,包含内容数据、效果预估、画像等维度"
@@ -379,11 +382,29 @@ describe("market-content-entry", () => {
);
expect(batchSubmitButton?.title).toBe("将当前选中的达人提交到后续业务批次");
expect(batchSubmitButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
expect(audienceProfileExportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
expect(audienceProfileByIdExportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
expect(batchSubmitButton?.style.color).toBe("rgb(255, 255, 255)");
expect(audienceProfileExportButton?.style.color).toBe("rgb(255, 255, 255)");
expect(audienceProfileByIdExportButton?.style.color).toBe("rgb(255, 255, 255)");
expect([
exportButton?.style.backgroundColor,
audienceProfileExportButton?.style.backgroundColor,
audienceProfileByIdExportButton?.style.backgroundColor,
audienceProfileFieldButton?.style.backgroundColor
]).toEqual([
"rgb(255, 255, 255)",
"rgb(255, 255, 255)",
"rgb(255, 255, 255)",
"rgb(255, 255, 255)"
]);
expect([
exportButton?.style.color,
audienceProfileExportButton?.style.color,
audienceProfileByIdExportButton?.style.color,
audienceProfileFieldButton?.style.color
]).toEqual([
"rgb(52, 64, 84)",
"rgb(52, 64, 84)",
"rgb(52, 64, 84)",
"rgb(52, 64, 84)"
]);
});
test("renders the approved compact toolbar layout from the preview", async () => {
@@ -416,8 +437,20 @@ describe("market-content-entry", () => {
const dataGroup = document.querySelector(
'[data-plugin-toolbar-group="data"]'
) as HTMLElement | null;
const metricSelector = document.querySelector(
'[data-plugin-spread-metrics="root"]'
const csvExportGroup = document.querySelector(
'[data-plugin-toolbar-action-group="csv-export"]'
) as HTMLElement | null;
const selectedAudienceExportGroup = document.querySelector(
'[data-plugin-toolbar-action-group="selected-audience-export"]'
) as HTMLElement | null;
const idExportGroup = document.querySelector(
'[data-plugin-toolbar-action-group="id-export"]'
) as HTMLElement | null;
const batchSubmitGroup = document.querySelector(
'[data-plugin-toolbar-action-group="batch-submit"]'
) as HTMLElement | null;
const metricCatalog = document.querySelector(
'[data-plugin-spread-metric-catalog="root"]'
) as HTMLElement | null;
const rulesGroup = document.querySelector(
'[data-plugin-spread-rules="root"]'
@@ -428,15 +461,30 @@ describe("market-content-entry", () => {
const titles = Array.from(
document.querySelectorAll("[data-plugin-toolbar-title]")
) as HTMLElement[];
const buttons = Array.from(
document.querySelectorAll("[data-plugin-toolbar-group='data'] button")
) as HTMLButtonElement[];
const exportButton = document.querySelector(
'[data-plugin-export="button"]'
) as HTMLButtonElement | null;
const exportRangeSelect = document.querySelector(
'[data-plugin-export-range="select"]'
) as HTMLSelectElement | null;
const exportCustomPagesInput = document.querySelector(
'[data-plugin-export-custom-pages="input"]'
) as HTMLInputElement | null;
const audienceProfileExportButton = document.querySelector(
'[data-plugin-export-audience-profile="button"]'
) as HTMLButtonElement | null;
const audienceProfileByIdExportButton = document.querySelector(
'[data-plugin-export-audience-profile-by-id="button"]'
) as HTMLButtonElement | null;
const audienceProfileFieldButton = document.querySelector(
'[data-plugin-audience-profile-fields="button"]'
) as HTMLButtonElement | null;
const batchSubmitButton = document.querySelector(
'[data-plugin-batch-submit="button"]'
) as HTMLButtonElement | null;
const operators = Array.from(
document.querySelectorAll("[data-plugin-spread-threshold-operator]")
).map((element) => element.textContent);
const metricInputs = Array.from(
document.querySelectorAll("[data-plugin-spread-metric]")
) as HTMLInputElement[];
const ruleRows = Array.from(
document.querySelectorAll("[data-plugin-spread-rule]")
) as HTMLElement[];
@@ -450,9 +498,30 @@ describe("market-content-entry", () => {
expect(primaryRow?.style.flexWrap).toBe("nowrap");
expect(thresholdRow?.style.flexWrap).toBe("nowrap");
expect(thresholdRow?.style.alignItems).toBe("center");
expect(panel?.style.overflowX).toBe("visible");
expect(panel?.style.overflowY).toBe("visible");
expect(dataGroup?.parentElement).toBe(primaryRow);
expect(csvExportGroup?.parentElement).toBe(dataGroup);
expect(selectedAudienceExportGroup?.parentElement).toBe(dataGroup);
expect(idExportGroup?.parentElement).toBe(dataGroup);
expect(batchSubmitGroup?.parentElement).toBe(dataGroup);
expect(Array.from(csvExportGroup?.children ?? [])).toEqual([
exportButton,
exportRangeSelect,
exportCustomPagesInput
]);
expect(Array.from(selectedAudienceExportGroup?.children ?? [])).toEqual([
audienceProfileExportButton,
audienceProfileFieldButton
]);
expect(Array.from(idExportGroup?.children ?? [])).toEqual([
audienceProfileByIdExportButton
]);
expect(Array.from(batchSubmitGroup?.children ?? [])).toEqual([
batchSubmitButton
]);
expect(statusText?.parentElement).toBe(primaryRow);
expect(metricSelector?.parentElement).toBe(thresholdRow);
expect(metricCatalog?.parentElement).toBe(thresholdRow);
expect(rulesGroup?.parentElement).toBe(thresholdRow);
expect(rulesGroup?.style.flexDirection).toBe("column");
expect(primaryRow?.style.justifyContent).toBe("flex-start");
@@ -462,8 +531,20 @@ describe("market-content-entry", () => {
]);
expect(titles[0]?.style.background).toBe("rgb(238, 245, 255)");
expect(operators).toEqual(["≥", "≥"]);
expect(metricInputs.map((input) => input.checked)).toEqual([false, false]);
expect(ruleRows.map((row) => row.hidden)).toEqual([true, true]);
expect(
document.querySelector('[data-plugin-spread-metric-catalog-trigger="button"]')
?.textContent
).toBe("添加筛选指标");
expect(
document.querySelector('[data-plugin-spread-metric-selected-count="text"]')
?.textContent
).toBe("已选 1 项");
expect(
(document.querySelector(
'[data-plugin-spread-metric-catalog-panel="root"]'
) as HTMLElement | null)?.hidden
).toBe(true);
expect(ruleRows.map((row) => row.hidden)).toEqual([false, true]);
expect(thresholdInputs.map((input) => input.placeholder)).toEqual([
"",
""
@@ -472,20 +553,45 @@ describe("market-content-entry", () => {
"0.1",
"0.1"
]);
expect(buttons.map((button) => button.textContent)).toEqual([
expect([
exportButton?.textContent,
audienceProfileExportButton?.textContent,
audienceProfileByIdExportButton?.textContent,
audienceProfileFieldButton?.textContent,
batchSubmitButton?.textContent
]).toEqual([
"导出CSV",
"导出选中达人数据",
"按星图ID导出",
"选择字段",
"提交批次"
]);
expect(buttons.map((button) => button.style.backgroundColor)).toEqual([
"rgb(127, 29, 45)",
"rgb(127, 29, 45)",
"rgb(127, 29, 45)",
"rgb(127, 29, 45)",
expect([
exportButton?.style.backgroundColor,
audienceProfileExportButton?.style.backgroundColor,
audienceProfileByIdExportButton?.style.backgroundColor,
audienceProfileFieldButton?.style.backgroundColor,
batchSubmitButton?.style.backgroundColor
]).toEqual([
"rgb(255, 255, 255)",
"rgb(255, 255, 255)",
"rgb(255, 255, 255)",
"rgb(255, 255, 255)",
"rgb(127, 29, 45)"
]);
expect([
exportButton?.style.color,
audienceProfileExportButton?.style.color,
audienceProfileByIdExportButton?.style.color,
audienceProfileFieldButton?.style.color,
batchSubmitButton?.style.color
]).toEqual([
"rgb(52, 64, 84)",
"rgb(52, 64, 84)",
"rgb(52, 64, 84)",
"rgb(52, 64, 84)",
"rgb(255, 255, 255)"
]);
});
test("remounts the plugin action bar when the native market action row appears later", async () => {
@@ -1276,7 +1382,143 @@ describe("market-content-entry", () => {
expect(customPagesInput?.hidden).toBe(false);
});
test("toolbar exposes independent metric rules and applies personal-video constraints per rule", async () => {
test("removes the toolbar resize listener after its root is detached", async () => {
document.body.innerHTML = buildMarketFixture();
const addEventListener = vi.spyOn(window, "addEventListener");
const removeEventListener = vi.spyOn(window, "removeEventListener");
try {
const { ensurePluginToolbar, isPluginToolbarMounted } = await import(
"../src/content/market/plugin-toolbar"
);
const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers());
const resizeHandler = addEventListener.mock.calls.find(
([eventName]) => eventName === "resize"
)?.[1];
toolbar.root.remove();
expect(isPluginToolbarMounted(toolbar.root, document)).toBe(false);
expect(removeEventListener).toHaveBeenCalledWith("resize", resizeHandler);
} finally {
addEventListener.mockRestore();
removeEventListener.mockRestore();
}
});
test("moves the toolbar resize listener when its root is adopted by another window", async () => {
document.body.innerHTML = buildMarketFixture();
const addEventListener = vi.spyOn(window, "addEventListener");
const removeEventListener = vi.spyOn(window, "removeEventListener");
const alternateAddEventListener = vi.fn();
const alternateRemoveEventListener = vi.fn();
const alternateWindow = {
addEventListener: alternateAddEventListener,
removeEventListener: alternateRemoveEventListener
} as unknown as Window;
const alternateDocument = {
createTreeWalker: document.createTreeWalker.bind(document),
defaultView: alternateWindow
} as unknown as Document;
try {
const { ensurePluginToolbar, isPluginToolbarMounted } = await import(
"../src/content/market/plugin-toolbar"
);
const handlers = createNoopToolbarHandlers();
const toolbar = ensurePluginToolbar(document, handlers);
const resizeHandler = addEventListener.mock.calls.find(
([eventName]) => eventName === "resize"
)?.[1];
const originalOwnerDocument = toolbar.root.ownerDocument;
Object.defineProperty(toolbar.root, "ownerDocument", {
configurable: true,
value: alternateDocument
});
const reusedToolbar = ensurePluginToolbar(document, handlers);
expect(reusedToolbar.root).toBe(toolbar.root);
expect(removeEventListener).toHaveBeenCalledWith("resize", resizeHandler);
expect(alternateAddEventListener).toHaveBeenCalledWith("resize", resizeHandler);
Object.defineProperty(toolbar.root, "ownerDocument", {
configurable: true,
value: originalOwnerDocument
});
toolbar.root.remove();
isPluginToolbarMounted(toolbar.root, document);
} finally {
addEventListener.mockRestore();
removeEventListener.mockRestore();
}
});
test("removes the toolbar resize listener when its root has no owner window", async () => {
document.body.innerHTML = buildMarketFixture();
const addEventListener = vi.spyOn(window, "addEventListener");
const removeEventListener = vi.spyOn(window, "removeEventListener");
let root: HTMLElement | null = null;
let originalOwnerDocument: Document | null = null;
let isPluginToolbarMounted:
| ((toolbarRoot: HTMLElement, toolbarDocument: Document) => boolean)
| undefined;
try {
const toolbarModule = await import("../src/content/market/plugin-toolbar");
isPluginToolbarMounted = toolbarModule.isPluginToolbarMounted;
const handlers = createNoopToolbarHandlers();
const toolbar = toolbarModule.ensurePluginToolbar(document, handlers);
root = toolbar.root;
originalOwnerDocument = root.ownerDocument;
const resizeHandler = addEventListener.mock.calls.find(
([eventName]) => eventName === "resize"
)?.[1];
Object.defineProperty(root, "ownerDocument", {
configurable: true,
value: {
createTreeWalker: document.createTreeWalker.bind(document),
defaultView: null
} as unknown as Document
});
toolbarModule.ensurePluginToolbar(document, handlers);
expect(removeEventListener).toHaveBeenCalledWith("resize", resizeHandler);
expect(addEventListener).toHaveBeenCalledTimes(1);
Object.defineProperty(root, "ownerDocument", {
configurable: true,
value: originalOwnerDocument
});
toolbarModule.ensurePluginToolbar(document, handlers);
const resizeCalls = addEventListener.mock.calls.filter(
([eventName]) => eventName === "resize"
);
expect(resizeCalls).toHaveLength(2);
expect(resizeCalls[1]).toEqual(["resize", expect.any(Function)]);
toolbarModule.ensurePluginToolbar(document, handlers);
expect(
addEventListener.mock.calls.filter(
([eventName]) => eventName === "resize"
)
).toHaveLength(2);
} finally {
if (root && originalOwnerDocument) {
Object.defineProperty(root, "ownerDocument", {
configurable: true,
value: originalOwnerDocument
});
root.remove();
isPluginToolbarMounted?.(root, document);
}
addEventListener.mockRestore();
removeEventListener.mockRestore();
}
});
test("catalog starts with the default finish-rate rule and adds independent metrics", async () => {
document.body.innerHTML = buildMarketFixture();
const { createMarketController } = await import("../src/content/market/index");
@@ -1291,12 +1533,12 @@ describe("market-content-entry", () => {
await controller.ready;
const finishToggle = document.querySelector(
'[data-plugin-spread-metric="finishRate"]'
) as HTMLInputElement | null;
const interactionToggle = document.querySelector(
'[data-plugin-spread-metric="interactionRate"]'
) as HTMLInputElement | null;
const catalogTrigger = document.querySelector(
'[data-plugin-spread-metric-catalog-trigger="button"]'
) as HTMLButtonElement | null;
const catalogPanel = document.querySelector(
'[data-plugin-spread-metric-catalog-panel="root"]'
) as HTMLElement | null;
const finishRule = document.querySelector(
'[data-plugin-spread-rule="finishRate"]'
) as HTMLElement | null;
@@ -1307,14 +1549,23 @@ describe("market-content-entry", () => {
'[data-plugin-spread-threshold="finishRate"]'
) as HTMLInputElement | null;
expect(finishToggle?.checked).toBe(false);
expect(interactionToggle?.checked).toBe(false);
expect(finishRule?.hidden).toBe(true);
expect(catalogPanel?.hidden).toBe(true);
expect(finishRule?.hidden).toBe(false);
expect(interactionRule?.hidden).toBe(true);
expect(interactionRule?.style.display).toBe("none");
expect(finishRateInput?.placeholder).toBe("");
expect(
document.querySelector('[data-plugin-spread-metric-selected-count="text"]')
?.textContent
).toBe("已选 1 项");
expect(readSpreadRuleSelect("finishRate", "type").value).toBe("2");
expect(readSpreadRuleSelect("finishRate", "onlyAssign").value).toBe("true");
expect(readSpreadRuleSelect("finishRate", "flowType").value).toBe("0");
expect(readSpreadRuleSelect("finishRate", "range").value).toBe("2");
enableSpreadMetric("finishRate");
enableSpreadMetric("interactionRate");
catalogTrigger?.click();
expect(catalogPanel?.hidden).toBe(false);
click('[data-plugin-spread-metric-catalog-action="interactionRate"]');
setSpreadRuleSelect("finishRate", "type", "2");
setSpreadRuleSelect("finishRate", "onlyAssign", "true");
setSpreadRuleSelect("finishRate", "flowType", "1");
@@ -1342,6 +1593,7 @@ describe("market-content-entry", () => {
expect(finishRule?.hidden).toBe(false);
expect(interactionRule?.hidden).toBe(false);
expect(interactionRule?.style.display).toBe("flex");
expect(finishAssignSelect.value).toBe("true");
expect(finishAssignSelect.disabled).toBe(false);
expect(finishFlowTypeSelect.value).toBe("1");
@@ -1352,18 +1604,24 @@ describe("market-content-entry", () => {
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"]');
click('[data-plugin-spread-rule-remove="finishRate"]');
expect(finishRule?.hidden).toBe(true);
expect(finishRule?.style.display).toBe("none");
catalogTrigger?.click();
click('[data-plugin-spread-metric-catalog-action="finishRate"]');
expect(finishRateInput?.value).toBe("");
expectSelectValue(
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="type"]',
"1"
"2"
);
expectSelectValue(
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="onlyAssign"]',
"true"
);
expectSelectValue(
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="flowType"]',
"0"
);
expectSelectValue(
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="range"]',
@@ -1371,6 +1629,128 @@ describe("market-content-entry", () => {
);
});
test("catalog disables duplicate metrics and restores them after deletion", async () => {
document.body.innerHTML = buildMarketFixture();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
window
}));
await controller.ready;
const finishAction = document.querySelector(
'[data-plugin-spread-metric-catalog-action="finishRate"]'
) as HTMLButtonElement | null;
expect(finishAction?.disabled).toBe(true);
expect(finishAction?.textContent).toBe("已添加");
click('[data-plugin-spread-rule-remove="finishRate"]');
expect(finishAction?.disabled).toBe(false);
expect(finishAction?.textContent).toBe("添加");
});
test("catalog search resets its visible metric list after closing", async () => {
document.body.innerHTML = buildMarketFixture();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
window
}));
await controller.ready;
click('[data-plugin-spread-metric-catalog-trigger="button"]');
const searchInput = document.querySelector(
'[data-plugin-spread-metric-catalog-search="input"]'
) as HTMLInputElement | null;
const interactionItem = document.querySelector(
'[data-plugin-spread-metric-catalog-item="interactionRate"]'
) as HTMLElement | null;
if (!searchInput) {
throw new Error("Missing metric catalog search input");
}
searchInput.value = "完播";
searchInput.dispatchEvent(new Event("input"));
expect(interactionItem?.hidden).toBe(true);
click('[data-plugin-spread-metric-catalog-close="button"]');
click('[data-plugin-spread-metric-catalog-trigger="button"]');
expect(interactionItem?.hidden).toBe(false);
});
test("busy toolbar disables catalog and selected rule actions", async () => {
document.body.innerHTML = buildMarketFixture();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
window
}));
await controller.ready;
const { ensurePluginToolbar, setToolbarBusyState } = await import(
"../src/content/market/plugin-toolbar"
);
const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers());
setToolbarBusyState(toolbar, true);
expect(
(document.querySelector(
'[data-plugin-spread-metric-catalog-trigger="button"]'
) as HTMLButtonElement | null)?.disabled
).toBe(true);
expect(
(document.querySelector(
'[data-plugin-spread-metric-catalog-action="interactionRate"]'
) as HTMLButtonElement | null)?.disabled
).toBe(true);
expect(
(document.querySelector(
'[data-plugin-spread-rule-remove="finishRate"]'
) as HTMLButtonElement | null)?.disabled
).toBe(true);
});
test("uses a collapsible catalog drawer and secondary rule controls on narrow screens", async () => {
const originalInnerWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 });
document.body.innerHTML = buildMarketFixture();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
window
}));
await controller.ready;
window.dispatchEvent(new Event("resize"));
click('[data-plugin-spread-metric-catalog-trigger="button"]');
const catalogPanel = document.querySelector(
'[data-plugin-spread-metric-catalog-panel="root"]'
) as HTMLElement | null;
expect(catalogPanel?.hidden).toBe(false);
const secondaryControls = document.querySelector(
'[data-plugin-spread-rule-secondary="finishRate"]'
) as HTMLElement | null;
expect(secondaryControls?.hidden).toBe(true);
click('[data-plugin-spread-rule-details="finishRate"]');
expect(secondaryControls?.hidden).toBe(false);
click('[data-plugin-spread-metric-catalog-trigger="button"]');
click('[data-plugin-spread-metric-catalog-close="button"]');
expect(catalogPanel?.hidden).toBe(true);
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: originalInnerWidth
});
window.dispatchEvent(new Event("resize"));
});
test("reads selected spread metrics as independent validated rules", async () => {
document.body.innerHTML = buildMarketFixture();
@@ -1390,29 +1770,34 @@ describe("market-content-entry", () => {
);
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");
click('[data-plugin-spread-metric-catalog-trigger="button"]');
click('[data-plugin-spread-metric-catalog-action="interactionRate"]');
expect(readSpreadRuleSelect("interactionRate", "type").value).toBe("2");
expect(readSpreadRuleSelect("interactionRate", "onlyAssign").value).toBe("true");
expect(readSpreadRuleSelect("interactionRate", "flowType").value).toBe("0");
expect(readSpreadRuleSelect("interactionRate", "range").value).toBe("2");
expect(
(document.querySelector(
'[data-plugin-spread-threshold="interactionRate"]'
) as HTMLInputElement | null)?.value
).toBe("");
expect(readToolbarSpreadFilter(toolbar)).toEqual({
error: "请输入有效的互动率筛选阈值"
});
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
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,
flowType: 0,
onlyAssign: true,
range: 2,
type: 2
@@ -1423,9 +1808,9 @@ describe("market-content-entry", () => {
{
config: {
flowType: 0,
onlyAssign: false,
range: 3,
type: 1
onlyAssign: true,
range: 2,
type: 2
},
metric: "interactionRate",
threshold: 5
@@ -1467,6 +1852,7 @@ describe("market-content-entry", () => {
await flush();
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
removeDefaultSpreadMetricFilter();
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 80, 50);
@@ -1525,6 +1911,7 @@ describe("market-content-entry", () => {
await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
removeDefaultSpreadMetricFilter();
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 80, 50);
@@ -1605,6 +1992,7 @@ describe("market-content-entry", () => {
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
setSpreadRuleSelect("finishRate", "type", "2");
setSpreadRuleSelect("finishRate", "onlyAssign", "true");
setSpreadRuleSelect("interactionRate", "type", "1");
setSpreadRuleSelect("interactionRate", "range", "3");
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 80, 50);
@@ -1672,9 +2060,9 @@ describe("market-content-entry", () => {
expect(loadSpreadFilterMetrics).toHaveBeenCalledTimes(1);
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
flowType: 0,
onlyAssign: false,
onlyAssign: true,
range: 2,
type: 1
type: 2
});
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
"a"
@@ -1726,9 +2114,9 @@ describe("market-content-entry", () => {
expect(loadSpreadFilterMetrics).toHaveBeenCalledTimes(1);
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-b", {
flowType: 0,
onlyAssign: false,
onlyAssign: true,
range: 2,
type: 1
type: 2
});
expect(buildCsv.mock.calls[0][0]).toEqual([]);
});
@@ -1763,6 +2151,7 @@ describe("market-content-entry", () => {
}));
await controller.ready;
removeDefaultSpreadMetricFilter();
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 120, 100);
@@ -1846,6 +2235,7 @@ describe("market-content-entry", () => {
}));
await controller.ready;
removeDefaultSpreadMetricFilter();
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 120, 100);
@@ -1893,6 +2283,7 @@ describe("market-content-entry", () => {
}));
await controller.ready;
removeDefaultSpreadMetricFilter();
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 120, 100);
@@ -1938,6 +2329,7 @@ describe("market-content-entry", () => {
}));
await controller.ready;
removeDefaultSpreadMetricFilter();
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 120, 100);
@@ -1998,6 +2390,7 @@ describe("market-content-entry", () => {
await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "all");
dispatchChange('[data-plugin-export-range="select"]');
removeDefaultSpreadMetricFilter();
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 120, 100);
@@ -2047,6 +2440,7 @@ describe("market-content-entry", () => {
setSelectValue('[data-plugin-export-range="select"]', "all");
dispatchChange('[data-plugin-export-range="select"]');
removeDefaultSpreadMetricFilter();
click('[data-plugin-export="button"]');
expectButtonDisabled('[data-plugin-batch-submit="button"]', true);
@@ -2126,6 +2520,7 @@ describe("market-content-entry", () => {
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
removeDefaultSpreadMetricFilter();
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 40, 50);
@@ -2549,6 +2944,7 @@ describe("market-content-entry", () => {
await controller.ready;
clickSelectionCheckboxForAuthor("111");
removeDefaultSpreadMetricFilter();
click('[data-plugin-export="button"]');
for (let attempt = 0; attempt < 40; attempt += 1) {
if (
@@ -2623,6 +3019,7 @@ describe("market-content-entry", () => {
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
removeDefaultSpreadMetricFilter();
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 40, 50);
@@ -2658,6 +3055,7 @@ describe("market-content-entry", () => {
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]');
await waitForMockCall(submitBatch, 40, 50);
@@ -2733,6 +3131,7 @@ describe("market-content-entry", () => {
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
setSpreadRuleSelect("finishRate", "type", "2");
setSpreadRuleSelect("interactionRate", "type", "1");
setSpreadRuleSelect("interactionRate", "range", "3");
click('[data-plugin-batch-submit="button"]');
await waitForMockCall(submitBatch, 80, 50);
@@ -2765,6 +3164,7 @@ describe("market-content-entry", () => {
}));
await controller.ready;
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]');
expect(submitBatch).not.toHaveBeenCalled();
@@ -2808,6 +3208,7 @@ describe("market-content-entry", () => {
}));
await controller.ready;
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]');
click('[data-plugin-batch-name-confirm="button"]');
await flush();
@@ -2842,6 +3243,7 @@ describe("market-content-entry", () => {
}));
await controller.ready;
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]');
click('[data-plugin-batch-name-cancel="button"]');
await flush();
@@ -2883,6 +3285,7 @@ describe("market-content-entry", () => {
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]');
await waitForMockCall(submitBatch, 40, 50);
@@ -2969,6 +3372,7 @@ describe("market-content-entry", () => {
await controller.ready;
clickSelectionCheckboxForAuthor("111");
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]');
for (let attempt = 0; attempt < 40; attempt += 1) {
if (
@@ -3088,6 +3492,7 @@ describe("market-content-entry", () => {
const rowSelectionCheckbox = readSelectionCheckboxForAuthor("111");
rowSelectionCheckbox.checked = true;
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]');
for (let attempt = 0; attempt < 40; attempt += 1) {
if (
@@ -3169,6 +3574,7 @@ describe("market-content-entry", () => {
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]');
await waitForMockCall(submitBatch, 40, 50);
@@ -3257,6 +3663,7 @@ describe("market-content-entry", () => {
await controller.ready;
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]');
for (let attempt = 0; attempt < 40; attempt += 1) {
if (
@@ -3315,6 +3722,7 @@ describe("market-content-entry", () => {
}));
await controller.ready;
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]');
await flush();
@@ -3347,6 +3755,7 @@ describe("market-content-entry", () => {
}));
await controller.ready;
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]');
await flush();
@@ -3391,6 +3800,7 @@ describe("market-content-entry", () => {
personalVideoAfterSearchRate: "8% - 9%"
});
removeDefaultSpreadMetricFilter();
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 40, 50);
@@ -3438,6 +3848,7 @@ describe("market-content-entry", () => {
personalVideoAfterSearchRate: "0.03% - 0.2%"
});
removeDefaultSpreadMetricFilter();
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 40, 50);
@@ -3516,6 +3927,7 @@ describe("market-content-entry", () => {
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
removeDefaultSpreadMetricFilter();
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 120, 50);
@@ -3627,6 +4039,7 @@ describe("market-content-entry", () => {
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
removeDefaultSpreadMetricFilter();
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 120, 50);
@@ -3717,6 +4130,7 @@ describe("market-content-entry", () => {
await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
removeDefaultSpreadMetricFilter();
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 120, 50);
@@ -3768,6 +4182,7 @@ describe("market-content-entry", () => {
await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
removeDefaultSpreadMetricFilter();
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 120, 50);
@@ -3846,6 +4261,7 @@ describe("market-content-entry", () => {
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
removeDefaultSpreadMetricFilter();
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 120, 50);
@@ -3929,6 +4345,7 @@ describe("market-content-entry", () => {
await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
removeDefaultSpreadMetricFilter();
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 120, 50);
@@ -4045,6 +4462,7 @@ describe("market-content-entry", () => {
await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "first-5");
dispatchChange('[data-plugin-export-range="select"]');
removeDefaultSpreadMetricFilter();
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 160, 100);
@@ -4089,7 +4507,7 @@ describe("market-content-entry", () => {
])
);
},
20000
30_000
);
test("rehydrates rows when the market list DOM changes", async () => {
@@ -5342,6 +5760,10 @@ function click(selector: string) {
element.click();
}
function removeDefaultSpreadMetricFilter() {
click('[data-plugin-spread-rule-remove="finishRate"]');
}
function enableSpreadMetric(metric: "finishRate" | "interactionRate") {
const selector = `[data-plugin-spread-metric="${metric}"]`;
const input = document.querySelector(selector) as HTMLInputElement | null;