diff --git a/src/content/market/audience-profile-csv.ts b/src/content/market/audience-profile-csv.ts index 999df22..12d7461 100644 --- a/src/content/market/audience-profile-csv.ts +++ b/src/content/market/audience-profile-csv.ts @@ -5,6 +5,7 @@ import { listRateCsvHeaders, type CsvColumn } from "./csv-exporter"; +import type { MarketRecord } from "./types"; import type { AudienceProfileDistributionItem, AudienceProfileExportRow, @@ -60,6 +61,12 @@ const CROWD_LABELS = [ ]; const BUSINESS_ESTIMATE_SECTION_LABEL = "效果预估"; +const FIXED_AUDIENCE_PROFILE_HEADERS = new Set([ + "达人ID", + "达人名称", + "导出状态", + "失败原因" +]); const BUSINESS_ESTIMATE_LAYOUTS: Array<{ key: BusinessAbilityDurationKind; @@ -85,11 +92,20 @@ export function buildAudienceProfileCsv( options: AudienceProfileCsvOptions = {} ): string { const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record)); - const csvColumns = filterAudienceProfileCsvColumns([ + const optionalColumns = [ ...marketColumns.map(toMarketColumn), ...buildBusinessAbilityColumns(), ...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 rowLines = rows.map((row) => 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 [ + ...(selectableMarketListHeaders.length > 0 + ? [ + { + headers: selectableMarketListHeaders, + label: "列表字段" + } + ] + : []), { headers: listRateCsvHeaders(), label: "看后搜率" @@ -144,19 +174,63 @@ function filterAudienceProfileCsvColumns( return columns; } - const selectableHeaderSet = new Set(listAudienceProfileSelectableHeaders()); const selectedHeaderSet = new Set(selectedHeaders); return columns.filter( (column) => - !selectableHeaderSet.has(column.header) || + FIXED_AUDIENCE_PROFILE_HEADERS.has(column.header) || selectedHeaderSet.has(column.header) ); } -function listAudienceProfileSelectableHeaders(): string[] { - return listAudienceProfileSelectableFieldGroups().flatMap( - (group) => group.headers - ); +function buildFixedAudienceProfileColumns(): AudienceProfileCsvColumn[] { + return [ + { + 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(); + return columns.filter((column) => { + if (seenHeaders.has(column.header)) { + return false; + } + + seenHeaders.add(column.header); + return true; + }); } function buildBusinessAbilityColumns(): AudienceProfileCsvColumn[] { diff --git a/src/content/market/audience-profile-field-dialog.ts b/src/content/market/audience-profile-field-dialog.ts index f885cd2..c339b13 100644 --- a/src/content/market/audience-profile-field-dialog.ts +++ b/src/content/market/audience-profile-field-dialog.ts @@ -3,16 +3,15 @@ import type { AudienceProfileCsvFieldGroup } from "./audience-profile-csv"; export function promptForAudienceProfileFields( document: Document, groups: AudienceProfileCsvFieldGroup[], - selectedHeaders: string[] + selectedHeaders: string[] | undefined ): Promise { return new Promise((resolve) => { const selectableHeaders = groups.flatMap((group) => group.headers); 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"); overlay.dataset.audienceProfileFieldDialog = "overlay"; diff --git a/src/content/market/index.ts b/src/content/market/index.ts index 35c210a..f5f7412 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -1,4 +1,4 @@ -import { buildMarketCsv } from "./csv-exporter"; +import { buildBaseColumns, buildMarketCsv } from "./csv-exporter"; import { buildAudienceProfileCsv, listAudienceProfileSelectableFieldGroups, @@ -475,7 +475,10 @@ export function createMarketController(options: CreateMarketControllerOptions) { } }, onConfigureAudienceProfileFields: async () => { - const groups = listAudienceProfileSelectableFieldGroups(); + const marketListHeaders = buildBaseColumns( + readCurrentPageRecords(syncMarketTable(options.document)) + ).map((column) => column.header); + const groups = listAudienceProfileSelectableFieldGroups(marketListHeaders); const selectedHeaders = readAudienceProfileSelectedHeaders(); const nextHeaders = await promptForAudienceProfileFields( options.document, @@ -489,7 +492,7 @@ export function createMarketController(options: CreateMarketControllerOptions) { saveAudienceProfileSelectedHeaders(nextHeaders); setToolbarExportStatus( toolbar, - `字段已保存(已选 ${nextHeaders.length}/${readAudienceProfileSelectableHeaders().length} 个字段)` + `字段已保存(已选 ${nextHeaders.length}/${readAudienceProfileSelectableHeaders(marketListHeaders).length} 个字段)` ); }, onSubmitBatch: async () => { @@ -1355,44 +1358,36 @@ export function createMarketController(options: CreateMarketControllerOptions) { return "铁粉画像"; } - function readAudienceProfileSelectableHeaders(): string[] { - return listAudienceProfileSelectableFieldGroups().flatMap( + function readAudienceProfileSelectableHeaders(marketListHeaders: string[] = []): string[] { + return listAudienceProfileSelectableFieldGroups(marketListHeaders).flatMap( (group) => group.headers ); } - function readAudienceProfileSelectedHeaders(): string[] { - const selectableHeaders = readAudienceProfileSelectableHeaders(); - const selectableHeaderSet = new Set(selectableHeaders); - + function readAudienceProfileSelectedHeaders(): string[] | undefined { try { const rawValue = options.window.localStorage?.getItem( AUDIENCE_PROFILE_FIELD_SELECTION_STORAGE_KEY ); if (!rawValue) { - return selectableHeaders; + return undefined; } const parsedValue = JSON.parse(rawValue) as unknown; if (!Array.isArray(parsedValue)) { - return selectableHeaders; + return undefined; } - const selectedHeaders = parsedValue.filter( - (header): header is string => - typeof header === "string" && selectableHeaderSet.has(header) + return parsedValue.filter( + (header): header is string => typeof header === "string" ); - return selectedHeaders.length > 0 ? selectedHeaders : selectableHeaders; } catch { - return selectableHeaders; + return undefined; } } function saveAudienceProfileSelectedHeaders(headers: string[]): void { - const selectableHeaderSet = new Set(readAudienceProfileSelectableHeaders()); - const selectedHeaders = headers.filter((header) => - selectableHeaderSet.has(header) - ); + const selectedHeaders = Array.from(new Set(headers)); try { options.window.localStorage?.setItem( diff --git a/tests/audience-profile-csv.test.ts b/tests/audience-profile-csv.test.ts index c714d4b..28cd41b 100644 --- a/tests/audience-profile-csv.test.ts +++ b/tests/audience-profile-csv.test.ts @@ -201,13 +201,37 @@ describe("audience-profile-csv", () => { const [headerLine, rowLine] = csv.split("\n"); 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("粉丝画像-女性占比"); }); + 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", () => { const row = buildSuccessRow({ 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 { diff --git a/tests/audience-profile-field-dialog.test.ts b/tests/audience-profile-field-dialog.test.ts new file mode 100644 index 0000000..9b59042 --- /dev/null +++ b/tests/audience-profile-field-dialog.test.ts @@ -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([]); + }); +}); diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index 6e02444..5d380e1 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -2633,7 +2633,7 @@ describe("market-content-entry", () => { } ], expect.objectContaining({ - selectedHeaders: expect.arrayContaining(["秒思api-看后搜数"]) + selectedHeaders: undefined }) ); expect(onCsvReady).toHaveBeenCalledWith( @@ -2771,7 +2771,7 @@ describe("market-content-entry", () => { }) ], expect.objectContaining({ - selectedHeaders: expect.arrayContaining(["秒思api-看后搜数"]) + selectedHeaders: undefined }) ); expect(onCsvReady).toHaveBeenCalledWith(