feat: streamline export field selection

This commit is contained in:
wxs
2026-07-29 10:51:24 +08:00
parent 53db9b0777
commit d121849d3e
12 changed files with 1157 additions and 1700 deletions
+18 -433
View File
@@ -377,7 +377,7 @@ describe("market-content-entry", () => {
);
expect(audienceProfileFieldButton?.textContent).toBe("选择字段");
expect(audienceProfileFieldButton?.title).toBe(
"勾选本次CSV需要导出的字段,设置会自动保存"
"搜索并勾选本次CSV需要导出的字段,设置会自动保存"
);
expect(batchSubmitButton?.title).toBe("将当前选中的达人提交到后续业务批次");
expect(batchSubmitButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
@@ -426,9 +426,6 @@ describe("market-content-entry", () => {
const primaryRow = document.querySelector(
'[data-plugin-toolbar-row="primary"]'
) as HTMLElement | null;
const thresholdRow = document.querySelector(
'[data-plugin-toolbar-row="thresholds"]'
) as HTMLElement | null;
const dataGroup = document.querySelector(
'[data-plugin-toolbar-group="data"]'
) as HTMLElement | null;
@@ -441,18 +438,9 @@ describe("market-content-entry", () => {
const batchSubmitGroup = document.querySelector(
'[data-plugin-toolbar-action-group="batch-submit"]'
) as HTMLElement | null;
const metricCatalog = document.querySelector(
'[data-plugin-metric-catalog="root"]'
) as HTMLElement | null;
const rulesGroup = document.querySelector(
'[data-plugin-metric-rules="root"]'
) as HTMLElement | null;
const statusText = document.querySelector(
'[data-plugin-export-status="text"]'
) as HTMLElement | null;
const titles = Array.from(
document.querySelectorAll("[data-plugin-toolbar-title]")
) as HTMLElement[];
const exportRangeSelect = document.querySelector(
'[data-plugin-export-range="select"]'
) as HTMLSelectElement | null;
@@ -471,24 +459,10 @@ describe("market-content-entry", () => {
const batchSubmitButton = document.querySelector(
'[data-plugin-batch-submit="button"]'
) as HTMLButtonElement | null;
const operators = Array.from(
document.querySelectorAll("[data-plugin-metric-filter-operator]")
).map((element) => element.textContent);
const ruleRows = Array.from(
document.querySelectorAll("[data-plugin-metric-filter-rule]")
) as HTMLElement[];
const thresholdInputs = Array.from(
document.querySelectorAll("[data-plugin-metric-filter-threshold]")
) as HTMLInputElement[];
expect(toolbar?.style.flexWrap).toBe("nowrap");
expect(panel?.style.flexDirection).toBe("column");
expect(panel?.style.alignItems).toBe("center");
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(panel?.style.overflow).toBe("visible");
expect(dataGroup?.parentElement).toBe(primaryRow);
expect(
document.querySelector('[data-plugin-toolbar-action-group="csv-export"]')
@@ -509,31 +483,11 @@ describe("market-content-entry", () => {
batchSubmitButton
]);
expect(statusText?.parentElement).toBe(primaryRow);
expect(metricCatalog?.parentElement).toBe(thresholdRow);
expect(rulesGroup?.parentElement).toBe(thresholdRow);
expect(rulesGroup?.style.flexDirection).toBe("column");
expect(primaryRow?.style.justifyContent).toBe("flex-start");
expect(thresholdRow?.style.justifyContent).toBe("flex-start");
expect(titles.map((element) => element.textContent)).toEqual([
"指标筛选"
]);
expect(titles[0]?.style.background).toBe("rgb(238, 245, 255)");
expect(operators).toEqual([]);
expect(
document.querySelector('[data-plugin-metric-catalog-trigger="button"]')
?.textContent
).toBe("添加筛选指标");
expect(
document.querySelector('[data-plugin-metric-selected-count="text"]')
?.textContent
).toBe("已选 0 项");
expect(
(document.querySelector(
'[data-plugin-metric-catalog-panel="root"]'
) as HTMLElement | null)?.hidden
).toBe(true);
expect(ruleRows).toEqual([]);
expect(thresholdInputs).toEqual([]);
expect(document.querySelector('[data-plugin-toolbar-row="thresholds"]')).toBeNull();
expect(document.querySelector('[data-plugin-metric-catalog]')).toBeNull();
expect(document.querySelector('[data-plugin-metric-filter-rule]')).toBeNull();
expect(document.body.textContent).not.toContain("指标筛选");
expect([
audienceProfileExportButton?.textContent,
audienceProfileByIdExportButton?.textContent,
@@ -1357,237 +1311,6 @@ describe("market-content-entry", () => {
expect(customPagesInput?.hidden).toBe(false);
});
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 groups numeric fields, supports search, and adds one field rule", 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 field = "内容数据-个人视频-近30天-完播率";
const catalogPanel = document.querySelector(
'[data-plugin-metric-catalog-panel="root"]'
) as HTMLElement | null;
expect(catalogPanel?.hidden).toBe(true);
expect(catalogPanel?.style.zIndex).toBe("100");
expect(document.querySelector('[data-plugin-metric-selected-count="text"]')?.textContent).toBe("已选 0 项");
click('[data-plugin-metric-catalog-trigger="button"]');
const groupLabels = Array.from(
document.querySelectorAll('[data-plugin-metric-catalog-group] > strong')
).map((element) => element.textContent);
expect(groupLabels).toEqual(expect.arrayContaining([
"列表字段", "看后搜率", "秒思api数据", "内容数据", "效果预估", "观众画像", "粉丝画像", "铁粉画像"
]));
const search = document.querySelector(
'[data-plugin-metric-catalog-search="input"]'
) as HTMLInputElement | null;
expect(search).not.toBeNull();
search!.value = "个人视频-近30天-完播率";
search!.dispatchEvent(new Event("input"));
expect(findMetricCatalogItem(field)?.hidden).toBe(false);
expect(findMetricCatalogItem("商单视频看后搜率")?.hidden).toBe(true);
clickMetricCatalogAction(field);
expect(catalogPanel?.hidden).toBe(true);
expect(findMetricRule(field)).not.toBeNull();
expect(document.querySelectorAll("[data-plugin-spread-filter]")).toHaveLength(0);
expect(findMetricCatalogAction(field)?.disabled).toBe(true);
expect(findMetricCatalogAction(field)?.textContent).toBe("已添加");
clickMetricRuleRemove(field);
expect(findMetricRule(field)).toBeNull();
expect(findMetricCatalogAction(field)?.disabled).toBe(false);
expect(findMetricCatalogAction(field)?.textContent).toBe("添加");
});
test("reads field rules with selectable greater-than-or-equal and less-than-or-equal operators", async () => {
document.body.innerHTML = buildMarketFixture();
const { ensurePluginToolbar, readToolbarSpreadFilter } = await import(
"../src/content/market/plugin-toolbar"
);
const field = "内容数据-个人视频-近30天-完播率";
const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers(), [
{ field, group: "内容数据" }
]);
click('[data-plugin-metric-catalog-trigger="button"]');
clickMetricCatalogAction(field);
expect(readToolbarSpreadFilter(toolbar)).toEqual({
error: `请输入有效的${field}筛选数值`
});
setInputValueForMetric(field, "30");
setMetricOperator(field, "lte");
expect(readToolbarSpreadFilter(toolbar)).toEqual({
filter: {
rules: [{ field, operator: "lte", threshold: 30 }]
}
});
});
test("busy state disables metric search, catalog actions, and added rule controls", async () => {
document.body.innerHTML = buildMarketFixture();
const { ensurePluginToolbar, setToolbarBusyState } = await import(
"../src/content/market/plugin-toolbar"
);
const field = "内容数据-个人视频-近30天-互动率";
const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers(), [
{ field, group: "内容数据" }
]);
click('[data-plugin-metric-catalog-trigger="button"]');
clickMetricCatalogAction(field);
setToolbarBusyState(toolbar, true);
expect(
(document.querySelector('[data-plugin-metric-catalog-trigger="button"]') as HTMLButtonElement | null)?.disabled
).toBe(true);
expect(
(document.querySelector('[data-plugin-metric-catalog-search="input"]') as HTMLInputElement | null)?.disabled
).toBe(true);
expect(findMetricRuleRemove(field)?.disabled).toBe(true);
expect(findMetricRuleOperator(field)?.disabled).toBe(true);
});
test("audience profile export requires selected creators outside of the all range", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
@@ -2288,6 +2011,18 @@ describe("market-content-entry", () => {
await controller.ready;
click('[data-plugin-audience-profile-fields="button"]');
const searchInput = document.querySelector(
'[data-audience-profile-field-dialog-search="input"]'
) as HTMLInputElement | null;
expect(searchInput).not.toBeNull();
searchInput!.value = "看后搜数";
searchInput!.dispatchEvent(new Event("input"));
expect(
document.querySelector(
'[data-audience-profile-field-dialog-result-count="text"]'
)?.textContent
).toBe("找到 1 个");
const afterSearchCountInput = document.querySelector(
'input[data-audience-profile-field-dialog-field="checkbox"][value="秒思api-看后搜数"]'
) as HTMLInputElement | null;
@@ -2333,8 +2068,6 @@ describe("market-content-entry", () => {
await controller.ready;
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);
@@ -2348,76 +2081,6 @@ describe("market-content-entry", () => {
expect(submitBatch.mock.calls[0]?.[0]).not.toHaveProperty("batchId");
});
test("batch submit applies selected field rules using their fixed content-data headers", 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 loadSpreadMetrics = vi.fn(async (spreadAuthorId: string) => {
return {
"内容数据-个人视频-近30天-互动率":
spreadAuthorId === "spread-a" ? "6%" : "4%",
"内容数据-个人视频-近30天-完播率":
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"
}),
loadSpreadMetrics,
promptBatchName: () => "筛选批次",
submitBatch,
window
}));
await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
const finishField = "内容数据-个人视频-近30天-完播率";
const interactionField = "内容数据-个人视频-近30天-互动率";
click('[data-plugin-metric-catalog-trigger="button"]');
clickMetricCatalogAction(finishField);
click('[data-plugin-metric-catalog-trigger="button"]');
clickMetricCatalogAction(interactionField);
setInputValueForMetric(finishField, "30");
setInputValueForMetric(interactionField, "5");
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 () => {
document.body.innerHTML = buildMarketFixture();
const submitBatch = vi.fn(async () => ({ ok: true }));
@@ -2439,7 +2102,6 @@ describe("market-content-entry", () => {
}));
await controller.ready;
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]');
expect(submitBatch).not.toHaveBeenCalled();
@@ -2483,7 +2145,6 @@ describe("market-content-entry", () => {
}));
await controller.ready;
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]');
click('[data-plugin-batch-name-confirm="button"]');
await flush();
@@ -2518,7 +2179,6 @@ describe("market-content-entry", () => {
}));
await controller.ready;
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]');
click('[data-plugin-batch-name-cancel="button"]');
await flush();
@@ -2559,8 +2219,6 @@ describe("market-content-entry", () => {
clickSelectionCheckboxForAuthor("222");
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);
@@ -2646,8 +2304,6 @@ 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 (
@@ -2766,8 +2422,6 @@ 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 (
@@ -2848,8 +2502,6 @@ describe("market-content-entry", () => {
await flushWithTimers();
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);
@@ -2906,7 +2558,6 @@ describe("market-content-entry", () => {
clickSelectionCheckboxForAuthor("111");
setSelectValue('[data-plugin-export-range="select"]', "all");
dispatchChange('[data-plugin-export-range="select"]');
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]');
await waitForCondition(() =>
@@ -2994,8 +2645,6 @@ describe("market-content-entry", () => {
setSelectValue('[data-plugin-export-range="select"]', "all");
dispatchChange('[data-plugin-export-range="select"]');
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]');
for (let attempt = 0; attempt < 40; attempt += 1) {
if (
@@ -3054,7 +2703,6 @@ describe("market-content-entry", () => {
}));
await controller.ready;
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]');
await flush();
@@ -3087,7 +2735,6 @@ describe("market-content-entry", () => {
}));
await controller.ready;
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]');
await flush();
@@ -4903,68 +4550,6 @@ function click(selector: string) {
element.click();
}
function findMetricCatalogAction(field: string): HTMLButtonElement | null {
return Array.from(
document.querySelectorAll<HTMLButtonElement>("[data-plugin-metric-catalog-action]")
).find((element) => element.dataset.pluginMetricCatalogAction === field) ?? null;
}
function findMetricCatalogItem(field: string): HTMLElement | null {
return Array.from(
document.querySelectorAll<HTMLElement>("[data-plugin-metric-catalog-item]")
).find((element) => element.dataset.pluginMetricCatalogItem === field) ?? null;
}
function findMetricRule(field: string): HTMLElement | null {
return Array.from(
document.querySelectorAll<HTMLElement>("[data-plugin-metric-filter-rule]")
).find((element) => element.dataset.pluginMetricFilterRule === field) ?? null;
}
function findMetricRuleRemove(field: string): HTMLButtonElement | null {
return findMetricRule(field)?.querySelector(
"[data-plugin-metric-filter-remove]"
) as HTMLButtonElement | null;
}
function findMetricRuleOperator(field: string): HTMLSelectElement | null {
return findMetricRule(field)?.querySelector(
"[data-plugin-metric-filter-operator]"
) as HTMLSelectElement | null;
}
function clickMetricCatalogAction(field: string) {
const action = findMetricCatalogAction(field);
if (!action) throw new Error(`Missing metric catalog action: ${field}`);
action.click();
}
function clickMetricRuleRemove(field: string) {
const removeButton = findMetricRuleRemove(field);
if (!removeButton) throw new Error(`Missing metric rule remove button: ${field}`);
removeButton.click();
}
function setInputValueForMetric(field: string, value: string) {
const input = findMetricRule(field)?.querySelector(
"[data-plugin-metric-filter-threshold]"
) as HTMLInputElement | null;
if (!input) throw new Error(`Missing metric threshold input: ${field}`);
input.value = value;
input.dispatchEvent(new Event("input"));
}
function setMetricOperator(field: string, value: "gte" | "lte") {
const select = findMetricRuleOperator(field);
if (!select) throw new Error(`Missing metric operator: ${field}`);
select.value = value;
select.dispatchEvent(new Event("change"));
}
function removeDefaultSpreadMetricFilter() {
// Metric filters now start empty, so batch-submit tests need no cleanup.
}
function createNoopToolbarHandlers() {
return {
onConfigureAudienceProfileFields: vi.fn(),