fix: respect selected audience export fields

This commit is contained in:
wxs
2026-07-20 11:16:40 +08:00
parent c683e1c0c2
commit e062be6431
6 changed files with 176 additions and 38 deletions
+83 -9
View File
@@ -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
View File
@@ -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(
+39 -2
View File
@@ -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([]);
});
});
+2 -2
View File
@@ -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(