fix: respect selected audience export fields
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
|||||||
listRateCsvHeaders,
|
listRateCsvHeaders,
|
||||||
type CsvColumn
|
type CsvColumn
|
||||||
} from "./csv-exporter";
|
} from "./csv-exporter";
|
||||||
|
import type { MarketRecord } from "./types";
|
||||||
import type {
|
import type {
|
||||||
AudienceProfileDistributionItem,
|
AudienceProfileDistributionItem,
|
||||||
AudienceProfileExportRow,
|
AudienceProfileExportRow,
|
||||||
@@ -60,6 +61,12 @@ const CROWD_LABELS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const BUSINESS_ESTIMATE_SECTION_LABEL = "效果预估";
|
const BUSINESS_ESTIMATE_SECTION_LABEL = "效果预估";
|
||||||
|
const FIXED_AUDIENCE_PROFILE_HEADERS = new Set([
|
||||||
|
"达人ID",
|
||||||
|
"达人名称",
|
||||||
|
"导出状态",
|
||||||
|
"失败原因"
|
||||||
|
]);
|
||||||
|
|
||||||
const BUSINESS_ESTIMATE_LAYOUTS: Array<{
|
const BUSINESS_ESTIMATE_LAYOUTS: Array<{
|
||||||
key: BusinessAbilityDurationKind;
|
key: BusinessAbilityDurationKind;
|
||||||
@@ -85,11 +92,20 @@ export function buildAudienceProfileCsv(
|
|||||||
options: AudienceProfileCsvOptions = {}
|
options: AudienceProfileCsvOptions = {}
|
||||||
): string {
|
): string {
|
||||||
const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record));
|
const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record));
|
||||||
const csvColumns = filterAudienceProfileCsvColumns([
|
const optionalColumns = [
|
||||||
...marketColumns.map(toMarketColumn),
|
...marketColumns.map(toMarketColumn),
|
||||||
...buildBusinessAbilityColumns(),
|
...buildBusinessAbilityColumns(),
|
||||||
...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout))
|
...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout))
|
||||||
], options.selectedHeaders);
|
];
|
||||||
|
const csvColumns = filterAudienceProfileCsvColumns(
|
||||||
|
options.selectedHeaders === undefined
|
||||||
|
? optionalColumns
|
||||||
|
: deduplicateCsvColumns([
|
||||||
|
...buildFixedAudienceProfileColumns(),
|
||||||
|
...optionalColumns
|
||||||
|
]),
|
||||||
|
options.selectedHeaders
|
||||||
|
);
|
||||||
const headerLine = csvColumns.map((column) => column.header).join(",");
|
const headerLine = csvColumns.map((column) => column.header).join(",");
|
||||||
const rowLines = rows.map((row) =>
|
const rowLines = rows.map((row) =>
|
||||||
csvColumns.map((column) => escapeCsvCell(column.readValue(row))).join(",")
|
csvColumns.map((column) => escapeCsvCell(column.readValue(row))).join(",")
|
||||||
@@ -111,8 +127,22 @@ export function listAudienceProfileCsvHeaders(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listAudienceProfileSelectableFieldGroups(): AudienceProfileCsvFieldGroup[] {
|
export function listAudienceProfileSelectableFieldGroups(
|
||||||
|
marketListHeaders: string[] = []
|
||||||
|
): AudienceProfileCsvFieldGroup[] {
|
||||||
|
const selectableMarketListHeaders = marketListHeaders.filter(
|
||||||
|
(header) => !FIXED_AUDIENCE_PROFILE_HEADERS.has(header)
|
||||||
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
...(selectableMarketListHeaders.length > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
headers: selectableMarketListHeaders,
|
||||||
|
label: "列表字段"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
headers: listRateCsvHeaders(),
|
headers: listRateCsvHeaders(),
|
||||||
label: "看后搜率"
|
label: "看后搜率"
|
||||||
@@ -144,19 +174,63 @@ function filterAudienceProfileCsvColumns(
|
|||||||
return columns;
|
return columns;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectableHeaderSet = new Set(listAudienceProfileSelectableHeaders());
|
|
||||||
const selectedHeaderSet = new Set(selectedHeaders);
|
const selectedHeaderSet = new Set(selectedHeaders);
|
||||||
return columns.filter(
|
return columns.filter(
|
||||||
(column) =>
|
(column) =>
|
||||||
!selectableHeaderSet.has(column.header) ||
|
FIXED_AUDIENCE_PROFILE_HEADERS.has(column.header) ||
|
||||||
selectedHeaderSet.has(column.header)
|
selectedHeaderSet.has(column.header)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function listAudienceProfileSelectableHeaders(): string[] {
|
function buildFixedAudienceProfileColumns(): AudienceProfileCsvColumn[] {
|
||||||
return listAudienceProfileSelectableFieldGroups().flatMap(
|
return [
|
||||||
(group) => group.headers
|
{
|
||||||
);
|
header: "达人ID",
|
||||||
|
readValue: (row) => row.record.exportFields?.达人ID ?? row.record.authorId
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "达人名称",
|
||||||
|
readValue: (row) => row.record.exportFields?.达人名称 ?? row.record.authorName
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "导出状态",
|
||||||
|
readValue: (row) => row.record.exportFields?.导出状态 ?? readExportStatus(row.record)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "失败原因",
|
||||||
|
readValue: (row) => row.record.exportFields?.失败原因 ?? row.record.failureReason ?? ""
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readExportStatus(record: MarketRecord): string {
|
||||||
|
if (record.status === "failed") {
|
||||||
|
return "失败";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.status === "loading") {
|
||||||
|
return "加载中";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.status === "idle") {
|
||||||
|
return "待处理";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "成功";
|
||||||
|
}
|
||||||
|
|
||||||
|
function deduplicateCsvColumns(
|
||||||
|
columns: AudienceProfileCsvColumn[]
|
||||||
|
): AudienceProfileCsvColumn[] {
|
||||||
|
const seenHeaders = new Set<string>();
|
||||||
|
return columns.filter((column) => {
|
||||||
|
if (seenHeaders.has(column.header)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenHeaders.add(column.header);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBusinessAbilityColumns(): AudienceProfileCsvColumn[] {
|
function buildBusinessAbilityColumns(): AudienceProfileCsvColumn[] {
|
||||||
|
|||||||
@@ -3,16 +3,15 @@ import type { AudienceProfileCsvFieldGroup } from "./audience-profile-csv";
|
|||||||
export function promptForAudienceProfileFields(
|
export function promptForAudienceProfileFields(
|
||||||
document: Document,
|
document: Document,
|
||||||
groups: AudienceProfileCsvFieldGroup[],
|
groups: AudienceProfileCsvFieldGroup[],
|
||||||
selectedHeaders: string[]
|
selectedHeaders: string[] | undefined
|
||||||
): Promise<string[] | null> {
|
): Promise<string[] | null> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const selectableHeaders = groups.flatMap((group) => group.headers);
|
const selectableHeaders = groups.flatMap((group) => group.headers);
|
||||||
const selectedHeaderSet = new Set(
|
const selectedHeaderSet = new Set(
|
||||||
selectedHeaders.filter((header) => selectableHeaders.includes(header))
|
selectedHeaders === undefined
|
||||||
|
? selectableHeaders
|
||||||
|
: selectedHeaders.filter((header) => selectableHeaders.includes(header))
|
||||||
);
|
);
|
||||||
if (selectedHeaderSet.size === 0) {
|
|
||||||
selectableHeaders.forEach((header) => selectedHeaderSet.add(header));
|
|
||||||
}
|
|
||||||
|
|
||||||
const overlay = document.createElement("div");
|
const overlay = document.createElement("div");
|
||||||
overlay.dataset.audienceProfileFieldDialog = "overlay";
|
overlay.dataset.audienceProfileFieldDialog = "overlay";
|
||||||
|
|||||||
+15
-20
@@ -1,4 +1,4 @@
|
|||||||
import { buildMarketCsv } from "./csv-exporter";
|
import { buildBaseColumns, buildMarketCsv } from "./csv-exporter";
|
||||||
import {
|
import {
|
||||||
buildAudienceProfileCsv,
|
buildAudienceProfileCsv,
|
||||||
listAudienceProfileSelectableFieldGroups,
|
listAudienceProfileSelectableFieldGroups,
|
||||||
@@ -475,7 +475,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onConfigureAudienceProfileFields: async () => {
|
onConfigureAudienceProfileFields: async () => {
|
||||||
const groups = listAudienceProfileSelectableFieldGroups();
|
const marketListHeaders = buildBaseColumns(
|
||||||
|
readCurrentPageRecords(syncMarketTable(options.document))
|
||||||
|
).map((column) => column.header);
|
||||||
|
const groups = listAudienceProfileSelectableFieldGroups(marketListHeaders);
|
||||||
const selectedHeaders = readAudienceProfileSelectedHeaders();
|
const selectedHeaders = readAudienceProfileSelectedHeaders();
|
||||||
const nextHeaders = await promptForAudienceProfileFields(
|
const nextHeaders = await promptForAudienceProfileFields(
|
||||||
options.document,
|
options.document,
|
||||||
@@ -489,7 +492,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
saveAudienceProfileSelectedHeaders(nextHeaders);
|
saveAudienceProfileSelectedHeaders(nextHeaders);
|
||||||
setToolbarExportStatus(
|
setToolbarExportStatus(
|
||||||
toolbar,
|
toolbar,
|
||||||
`字段已保存(已选 ${nextHeaders.length}/${readAudienceProfileSelectableHeaders().length} 个字段)`
|
`字段已保存(已选 ${nextHeaders.length}/${readAudienceProfileSelectableHeaders(marketListHeaders).length} 个字段)`
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onSubmitBatch: async () => {
|
onSubmitBatch: async () => {
|
||||||
@@ -1355,44 +1358,36 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
return "铁粉画像";
|
return "铁粉画像";
|
||||||
}
|
}
|
||||||
|
|
||||||
function readAudienceProfileSelectableHeaders(): string[] {
|
function readAudienceProfileSelectableHeaders(marketListHeaders: string[] = []): string[] {
|
||||||
return listAudienceProfileSelectableFieldGroups().flatMap(
|
return listAudienceProfileSelectableFieldGroups(marketListHeaders).flatMap(
|
||||||
(group) => group.headers
|
(group) => group.headers
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readAudienceProfileSelectedHeaders(): string[] {
|
function readAudienceProfileSelectedHeaders(): string[] | undefined {
|
||||||
const selectableHeaders = readAudienceProfileSelectableHeaders();
|
|
||||||
const selectableHeaderSet = new Set(selectableHeaders);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rawValue = options.window.localStorage?.getItem(
|
const rawValue = options.window.localStorage?.getItem(
|
||||||
AUDIENCE_PROFILE_FIELD_SELECTION_STORAGE_KEY
|
AUDIENCE_PROFILE_FIELD_SELECTION_STORAGE_KEY
|
||||||
);
|
);
|
||||||
if (!rawValue) {
|
if (!rawValue) {
|
||||||
return selectableHeaders;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedValue = JSON.parse(rawValue) as unknown;
|
const parsedValue = JSON.parse(rawValue) as unknown;
|
||||||
if (!Array.isArray(parsedValue)) {
|
if (!Array.isArray(parsedValue)) {
|
||||||
return selectableHeaders;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedHeaders = parsedValue.filter(
|
return parsedValue.filter(
|
||||||
(header): header is string =>
|
(header): header is string => typeof header === "string"
|
||||||
typeof header === "string" && selectableHeaderSet.has(header)
|
|
||||||
);
|
);
|
||||||
return selectedHeaders.length > 0 ? selectedHeaders : selectableHeaders;
|
|
||||||
} catch {
|
} catch {
|
||||||
return selectableHeaders;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveAudienceProfileSelectedHeaders(headers: string[]): void {
|
function saveAudienceProfileSelectedHeaders(headers: string[]): void {
|
||||||
const selectableHeaderSet = new Set(readAudienceProfileSelectableHeaders());
|
const selectedHeaders = Array.from(new Set(headers));
|
||||||
const selectedHeaders = headers.filter((header) =>
|
|
||||||
selectableHeaderSet.has(header)
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
options.window.localStorage?.setItem(
|
options.window.localStorage?.setItem(
|
||||||
|
|||||||
@@ -201,13 +201,37 @@ describe("audience-profile-csv", () => {
|
|||||||
const [headerLine, rowLine] = csv.split("\n");
|
const [headerLine, rowLine] = csv.split("\n");
|
||||||
|
|
||||||
expect(headerLine).toBe(
|
expect(headerLine).toBe(
|
||||||
"达人信息,连接用户数,内容数据-个人视频-近30天-播放量中位数,观众画像-男性占比"
|
"达人ID,达人名称,导出状态,失败原因,内容数据-个人视频-近30天-播放量中位数,观众画像-男性占比"
|
||||||
);
|
);
|
||||||
expect(rowLine).toBe("达人 A,300w,10913233,71.7%");
|
expect(rowLine).toBe("123,达人 A,成功,,10913233,71.7%");
|
||||||
expect(headerLine).not.toContain("秒思api-看后搜数");
|
expect(headerLine).not.toContain("秒思api-看后搜数");
|
||||||
expect(headerLine).not.toContain("粉丝画像-女性占比");
|
expect(headerLine).not.toContain("粉丝画像-女性占比");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("omits unselected market-list columns after field settings are saved", () => {
|
||||||
|
const row = buildSuccessRow({
|
||||||
|
exportFields: {
|
||||||
|
达人信息: "达人 A",
|
||||||
|
粉丝数: "300w",
|
||||||
|
预期CPM: "120"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const csv = buildAudienceProfileCsv([row], {
|
||||||
|
selectedHeaders: ["内容数据-个人视频-近30天-播放量中位数"]
|
||||||
|
});
|
||||||
|
|
||||||
|
const [headerLine, rowLine] = csv.split("\n");
|
||||||
|
|
||||||
|
expect(headerLine).toBe(
|
||||||
|
"达人ID,达人名称,导出状态,失败原因,内容数据-个人视频-近30天-播放量中位数"
|
||||||
|
);
|
||||||
|
expect(rowLine).toBe("123,达人 A,成功,,10913233");
|
||||||
|
expect(headerLine).not.toContain("达人信息");
|
||||||
|
expect(headerLine).not.toContain("粉丝数");
|
||||||
|
expect(headerLine).not.toContain("预期CPM");
|
||||||
|
});
|
||||||
|
|
||||||
test("always keeps fixed id export headers when filtering", () => {
|
test("always keeps fixed id export headers when filtering", () => {
|
||||||
const row = buildSuccessRow({
|
const row = buildSuccessRow({
|
||||||
exportFields: {
|
exportFields: {
|
||||||
@@ -267,6 +291,19 @@ describe("audience-profile-csv", () => {
|
|||||||
])
|
])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("includes current market-list columns in the field picker", () => {
|
||||||
|
expect(
|
||||||
|
listAudienceProfileSelectableFieldGroups(["达人信息", "粉丝数"])
|
||||||
|
).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{
|
||||||
|
headers: ["达人信息", "粉丝数"],
|
||||||
|
label: "列表字段"
|
||||||
|
}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function readCsvValue(csv: string, header: string): string {
|
function readCsvValue(csv: string, header: string): string {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/** @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { promptForAudienceProfileFields } from "../src/content/market/audience-profile-field-dialog";
|
||||||
|
|
||||||
|
describe("audience-profile-field-dialog", () => {
|
||||||
|
test("keeps an explicitly empty field selection empty", async () => {
|
||||||
|
const result = promptForAudienceProfileFields(
|
||||||
|
document,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
headers: ["粉丝数", "观众画像-男性占比"],
|
||||||
|
label: "测试字段"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fields = Array.from(
|
||||||
|
document.querySelectorAll('[data-audience-profile-field-dialog-field="checkbox"]')
|
||||||
|
) as HTMLInputElement[];
|
||||||
|
expect(fields).toHaveLength(2);
|
||||||
|
expect(fields.every((field) => !field.checked)).toBe(true);
|
||||||
|
|
||||||
|
const saveButton = document.querySelector(
|
||||||
|
'[data-audience-profile-field-dialog-save="button"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
saveButton.click();
|
||||||
|
|
||||||
|
await expect(result).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2633,7 +2633,7 @@ describe("market-content-entry", () => {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
selectedHeaders: expect.arrayContaining(["秒思api-看后搜数"])
|
selectedHeaders: undefined
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(onCsvReady).toHaveBeenCalledWith(
|
expect(onCsvReady).toHaveBeenCalledWith(
|
||||||
@@ -2771,7 +2771,7 @@ describe("market-content-entry", () => {
|
|||||||
})
|
})
|
||||||
],
|
],
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
selectedHeaders: expect.arrayContaining(["秒思api-看后搜数"])
|
selectedHeaders: undefined
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(onCsvReady).toHaveBeenCalledWith(
|
expect(onCsvReady).toHaveBeenCalledWith(
|
||||||
|
|||||||
Reference in New Issue
Block a user