feat: allow selecting audience export fields

This commit is contained in:
2026-05-19 16:26:04 +08:00
parent db95a1f565
commit 50933af0a6
11 changed files with 1273 additions and 110 deletions
+126 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, test } from "vitest";
import { buildAudienceProfileCsv } from "../src/content/market/audience-profile-csv";
import {
buildAudienceProfileCsv,
listAudienceProfileSelectableFieldGroups,
listAudienceProfileCsvHeaders
} from "../src/content/market/audience-profile-csv";
import type { AudienceProfileExportRow } from "../src/content/market/audience-profile-types";
describe("audience-profile-csv", () => {
@@ -193,6 +197,83 @@ describe("audience-profile-csv", () => {
expect(readCsvValue(csv, "铁粉画像-Z世代占比")).toBe("0%");
expect(csv.split("\n")[0]).not.toContain("新一线城市占比");
});
test("filters export columns by selected headers", () => {
const row = buildSuccessRow();
const csv = buildAudienceProfileCsv([row], {
selectedHeaders: [
"内容数据-个人视频-播放量中位数",
"观众画像-男性占比"
]
});
const [headerLine, rowLine] = csv.split("\n");
expect(headerLine).toBe(
"达人信息,连接用户数,内容数据-个人视频-播放量中位数,观众画像-男性占比"
);
expect(rowLine).toBe("达人 A,300w,3738.4w,71.7%");
expect(headerLine).not.toContain("秒思api-看后搜数");
expect(headerLine).not.toContain("粉丝画像-女性占比");
});
test("always keeps fixed id export headers when filtering", () => {
const row = buildSuccessRow({
exportFields: {
ID: "123",
: "达人 A",
: "成功",
: ""
}
});
const csv = buildAudienceProfileCsv([row], {
selectedHeaders: ["内容数据-个人视频-播放量中位数"]
});
const [headerLine, rowLine] = csv.split("\n");
expect(headerLine).toBe(
"达人ID,达人名称,导出状态,失败原因,内容数据-个人视频-播放量中位数"
);
expect(rowLine).toBe("123,达人 A,成功,,3738.4w");
});
test("lists headers for field picker defaults", () => {
expect(listAudienceProfileCsvHeaders([buildSuccessRow()])).toEqual(
expect.arrayContaining([
"达人信息",
"连接用户数",
"秒思api-看后搜数",
"内容数据-个人视频-播放量中位数",
"效果预估-20-60s视频-预期CPM",
"观众画像-男性占比",
"铁粉画像-小镇青年占比"
])
);
});
test("groups selectable profile export fields", () => {
expect(listAudienceProfileSelectableFieldGroups()).toEqual(
expect.arrayContaining([
expect.objectContaining({
headers: expect.arrayContaining(["秒思api-看后搜数"]),
label: "秒思api数据"
}),
expect.objectContaining({
headers: expect.arrayContaining(["内容数据-个人视频-播放量中位数"]),
label: "内容数据"
}),
expect.objectContaining({
headers: expect.arrayContaining(["效果预估-20-60s视频-预期CPM"]),
label: "效果预估"
}),
expect.objectContaining({
headers: expect.arrayContaining(["观众画像-男性占比"]),
label: "观众画像"
})
])
);
});
});
function readCsvValue(csv: string, header: string): string {
@@ -204,3 +285,47 @@ function readCsvValue(csv: string, header: string): string {
expect(index).toBeGreaterThanOrEqual(0);
return values[index] ?? "";
}
function buildSuccessRow(
overrides: Partial<AudienceProfileExportRow["record"]> = {}
): AudienceProfileExportRow {
return {
profiles: {
audience: {
age: [{ label: "31-40", value: "50%" }],
cityTier: [{ label: "一线城市", value: "100%" }],
crowd: [{ label: "都市蓝领", value: "100%" }],
gender: [{ label: "男性", value: "71.7%" }],
status: "success"
},
fans: { status: "success" },
longtimeFans: { status: "success" }
},
businessAbility: {
estimates: {
twentyToSixty: {
expectedCpe: "3.7",
expectedCpm: "212.0",
expectedPlay: "250w",
hotRate: "缺失"
}
},
status: "success",
videos: {
personalVideo: {
medianPlay: "3738.4w"
}
}
},
record: {
authorId: "123",
authorName: "达人 A",
exportFields: {
: "达人 A",
: "300w"
},
status: "success",
...overrides
}
};
}
+151 -52
View File
@@ -13,6 +13,7 @@ describe("market-content-entry", () => {
document.documentElement.removeAttribute("data-sces-market-rows");
document.documentElement.removeAttribute("data-sces-market-request-snapshot");
document.documentElement.removeAttribute("data-test-page-index");
window.localStorage.clear();
window.history.replaceState({}, "", "/");
});
@@ -1689,17 +1690,22 @@ describe("market-content-entry", () => {
{ authorType: 1, source: "fansDistribution" },
{ authorType: 5, source: "fansDistribution" }
]);
expect(buildAudienceProfileCsv).toHaveBeenCalledWith([
{
profiles: {
audience: expect.objectContaining({ status: "success" }),
fans: expect.objectContaining({ status: "success" }),
longtimeFans: expect.objectContaining({ status: "success" })
},
businessAbility: expect.objectContaining({ status: "success" }),
record: expect.objectContaining({ authorId: "222" })
}
]);
expect(buildAudienceProfileCsv).toHaveBeenCalledWith(
[
{
profiles: {
audience: expect.objectContaining({ status: "success" }),
fans: expect.objectContaining({ status: "success" }),
longtimeFans: expect.objectContaining({ status: "success" })
},
businessAbility: expect.objectContaining({ status: "success" }),
record: expect.objectContaining({ authorId: "222" })
}
],
expect.objectContaining({
selectedHeaders: expect.arrayContaining(["秒思api-看后搜数"])
})
);
expect(onCsvReady).toHaveBeenCalledWith(
"profile-csv",
expect.stringMatching(/^达人连接用户画像_\d{8}_\d{4}\.csv$/)
@@ -1790,56 +1796,149 @@ describe("market-content-entry", () => {
"6866044569306267651",
"7040323176106033165"
]);
expect(buildAudienceProfileCsv).toHaveBeenCalledWith([
expect.objectContaining({
record: expect.objectContaining({
authorId: "6866044569306267651",
authorName: "小九儿",
backendMetrics: expect.objectContaining({
a3IncreaseCount: "100",
afterViewSearchCount: "300",
afterViewSearchRate: "1.1%",
cpSearch: "10",
cpa3: "30",
newA3Rate: "3.3%"
}),
exportFields: {
ID: "6866044569306267651",
: "小九儿",
: "成功",
: ""
},
rates: {
personalVideoAfterSearchRate: "12.3%",
singleVideoAfterSearchRate: "7.8%"
}
expect(buildAudienceProfileCsv).toHaveBeenCalledWith(
[
expect.objectContaining({
record: expect.objectContaining({
authorId: "6866044569306267651",
authorName: "小九儿",
backendMetrics: expect.objectContaining({
a3IncreaseCount: "100",
afterViewSearchCount: "300",
afterViewSearchRate: "1.1%",
cpSearch: "10",
cpa3: "30",
newA3Rate: "3.3%"
}),
exportFields: {
ID: "6866044569306267651",
: "小九儿",
: "成功",
: ""
},
rates: {
personalVideoAfterSearchRate: "12.3%",
singleVideoAfterSearchRate: "7.8%"
}
})
}),
expect.objectContaining({
record: expect.objectContaining({
authorId: "7040323176106033165",
authorName: "达人 B",
backendMetrics: expect.objectContaining({
a3IncreaseCount: "200",
afterViewSearchCount: "400",
afterViewSearchRate: "2.2%",
cpSearch: "20",
cpa3: "40",
newA3Rate: "4.4%"
}),
rates: {
personalVideoAfterSearchRate: "45.6%",
singleVideoAfterSearchRate: "9.1%"
}
})
})
}),
],
expect.objectContaining({
record: expect.objectContaining({
authorId: "7040323176106033165",
authorName: "达人 B",
backendMetrics: expect.objectContaining({
a3IncreaseCount: "200",
afterViewSearchCount: "400",
afterViewSearchRate: "2.2%",
cpSearch: "20",
cpa3: "40",
newA3Rate: "4.4%"
}),
rates: {
personalVideoAfterSearchRate: "45.6%",
singleVideoAfterSearchRate: "9.1%"
}
})
selectedHeaders: expect.arrayContaining(["秒思api-看后搜数"])
})
]);
);
expect(onCsvReady).toHaveBeenCalledWith(
"profile-csv",
expect.stringMatching(/^达人连接用户画像_按ID导出_\d{8}_\d{4}\.csv$/)
);
});
test("audience profile export uses persisted selected csv fields", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
window.localStorage.setItem(
"sces:audience-profile:selectedHeaders",
JSON.stringify(["内容数据-个人视频-播放量中位数", "秒思api-看后搜数"])
);
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
const loadBusinessAbility = vi.fn(async () => ({
estimates: {},
status: "success" as const,
videos: {}
}));
const loadAudienceProfile = vi.fn(async () => ({
age: [],
crowd: [],
cityTier: [],
gender: [],
status: "success" as const
}));
const onCsvReady = vi.fn();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildAudienceProfileCsv,
document,
loadBusinessAbility,
loadAudienceProfile,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
onCsvReady,
window
}));
await controller.ready;
clickSelectionCheckboxForAuthor("111");
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
click('[data-plugin-export-audience-profile="button"]');
await waitForMockCall(buildAudienceProfileCsv, 40, 50);
expect(buildAudienceProfileCsv.mock.calls[0][1]).toEqual({
selectedHeaders: ["内容数据-个人视频-播放量中位数", "秒思api-看后搜数"]
});
});
test("audience profile field picker persists the next selected fields", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
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-audience-profile-fields="button"]');
const afterSearchCountInput = document.querySelector(
'input[data-audience-profile-field-dialog-field="checkbox"][value="秒思api-看后搜数"]'
) as HTMLInputElement | null;
expect(afterSearchCountInput).not.toBeNull();
afterSearchCountInput!.checked = false;
afterSearchCountInput!.dispatchEvent(new Event("change", { bubbles: true }));
click('[data-audience-profile-field-dialog-save="button"]');
await flush();
const savedHeaders = JSON.parse(
window.localStorage.getItem("sces:audience-profile:selectedHeaders") ?? "[]"
) as string[];
expect(savedHeaders).not.toContain("秒思api-看后搜数");
expect(savedHeaders).toContain("内容数据-个人视频-播放量中位数");
expect(
document.querySelector('[data-plugin-export-status="text"]')?.textContent
).toContain("画像字段已保存");
});
test(
"selected export keeps a generic loading status while exporting the default paged range",
async () => {