diff --git a/dist-release/content/index.js b/dist-release/content/index.js index 194a655..ee2c3ad 100644 --- a/dist-release/content/index.js +++ b/dist-release/content/index.js @@ -141,6 +141,12 @@ readValue: (record) => record.backendMetrics?.cpSearch ?? "" } ]; + function listRateCsvHeaders() { + return RATE_COLUMNS.map((column) => column.header); + } + function listBackendMetricCsvHeaders() { + return BACKEND_METRIC_COLUMNS.map((column) => column.header); + } function buildMarketCsv(records) { const csvColumns = buildMarketCsvColumns(records); const headerLine = csvColumns.map((column) => column.header).join(","); @@ -227,27 +233,73 @@ { key: "expectedPlay", label: "\u9884\u671F\u64AD\u653E\u91CF" }, { key: "hotRate", label: "\u7206\u6587\u7387" } ]; - function buildAudienceProfileCsv(rows) { + function buildAudienceProfileCsv(rows, options = {}) { const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record)); - const csvColumns = [ + const csvColumns = filterAudienceProfileCsvColumns([ ...marketColumns.map(toMarketColumn), ...buildBusinessAbilityColumns(), ...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout)) - ]; + ], options.selectedHeaders); const headerLine = csvColumns.map((column) => column.header).join(","); const rowLines = rows.map( (row) => csvColumns.map((column) => escapeCsvCell(column.readValue(row))).join(",") ); return [headerLine, ...rowLines].join("\n"); } + function listAudienceProfileSelectableFieldGroups() { + return [ + { + headers: listRateCsvHeaders(), + label: "\u770B\u540E\u641C\u7387" + }, + { + headers: listBackendMetricCsvHeaders(), + label: "\u79D2\u601Dapi\u6570\u636E" + }, + { + headers: buildBusinessVideoColumns().map((column) => column.header), + label: "\u5185\u5BB9\u6570\u636E" + }, + { + headers: buildBusinessEstimateColumns().map((column) => column.header), + label: "\u6548\u679C\u9884\u4F30" + }, + ...PROFILE_LAYOUTS.map((layout) => ({ + headers: buildProfileColumns(layout).map((column) => column.header), + label: layout.label + })) + ]; + } + function filterAudienceProfileCsvColumns(columns, selectedHeaders) { + if (!selectedHeaders) { + return columns; + } + const selectableHeaderSet = new Set(listAudienceProfileSelectableHeaders()); + const selectedHeaderSet = new Set(selectedHeaders); + return columns.filter( + (column) => !selectableHeaderSet.has(column.header) || selectedHeaderSet.has(column.header) + ); + } + function listAudienceProfileSelectableHeaders() { + return listAudienceProfileSelectableFieldGroups().flatMap( + (group) => group.headers + ); + } function buildBusinessAbilityColumns() { + return [...buildBusinessVideoColumns(), ...buildBusinessEstimateColumns()]; + } + function buildBusinessVideoColumns() { return [ ...BUSINESS_VIDEO_LAYOUTS.flatMap( (videoLayout) => BUSINESS_VIDEO_METRIC_LAYOUTS.map((metricLayout) => ({ header: `${BUSINESS_VIDEO_SECTION_LABEL}-${videoLayout.label}-${metricLayout.label}`, readValue: (row) => readBusinessVideoValue(row, videoLayout.key, metricLayout.key) })) - ), + ) + ]; + } + function buildBusinessEstimateColumns() { + return [ ...BUSINESS_ESTIMATE_LAYOUTS.flatMap( (durationLayout) => BUSINESS_ESTIMATE_METRIC_LAYOUTS.map((metricLayout) => ({ header: `${BUSINESS_ESTIMATE_SECTION_LABEL}-${durationLayout.label}-${metricLayout.label}`, @@ -813,24 +865,89 @@ return typeof value === "object" && value !== null; } - // src/content/market/author-id-dialog.ts - function promptForAuthorIds(document2) { + // src/content/market/audience-profile-field-dialog.ts + function promptForAudienceProfileFields(document2, groups, selectedHeaders) { return new Promise((resolve) => { + const selectableHeaders = groups.flatMap((group) => group.headers); + const selectedHeaderSet = new Set( + selectedHeaders.filter((header) => selectableHeaders.includes(header)) + ); + if (selectedHeaderSet.size === 0) { + selectableHeaders.forEach((header) => selectedHeaderSet.add(header)); + } const overlay = document2.createElement("div"); - overlay.dataset.authorIdDialog = "overlay"; + overlay.dataset.audienceProfileFieldDialog = "overlay"; applyOverlayStyles(overlay); const dialog = document2.createElement("section"); applyDialogStyles(dialog); const title = document2.createElement("h2"); - title.textContent = "\u6309\u661F\u56FEID\u5BFC\u51FA\u753B\u50CFCSV"; applyTitleStyles(title); - const textarea = document2.createElement("textarea"); - textarea.dataset.authorIdDialogInput = "textarea"; - textarea.placeholder = "\u6BCF\u884C\u4E00\u4E2A\u661F\u56FEID\uFF0C\u4E5F\u652F\u6301\u9017\u53F7\u3001\u7A7A\u683C\u5206\u9694"; - applyTextareaStyles(textarea); const hint = document2.createElement("p"); - hint.textContent = "\u7C98\u8D34\u5BA2\u6237\u63D0\u4F9B\u7684\u8FBE\u4EBA\u661F\u56FEID\uFF0C\u786E\u8BA4\u540E\u5C06\u6279\u91CF\u5BFC\u51FA\u753B\u50CF\u548C\u5546\u4E1A\u80FD\u529B\u6570\u636E\u3002"; + hint.textContent = "\u57FA\u7840\u5B57\u6BB5\u4F1A\u56FA\u5B9A\u4FDD\u7559\u3002\u53D6\u6D88\u52FE\u9009\u540E\uFF0C\u672C\u6B21\u53CA\u540E\u7EED\u753B\u50CFCSV\u5C06\u4E0D\u5305\u542B\u5BF9\u5E94\u5217\u3002"; applyHintStyles(hint); + const toolbar = document2.createElement("div"); + applyToolbarStyles(toolbar); + const selectAllButton = document2.createElement("button"); + selectAllButton.type = "button"; + selectAllButton.textContent = "\u5168\u9009"; + applySecondaryButtonStyles(selectAllButton); + const resetButton = document2.createElement("button"); + resetButton.type = "button"; + resetButton.textContent = "\u6062\u590D\u9ED8\u8BA4"; + applySecondaryButtonStyles(resetButton); + toolbar.append(selectAllButton, resetButton); + const groupContainer = document2.createElement("div"); + applyGroupContainerStyles(groupContainer); + const fieldInputs = []; + groups.forEach((group) => { + const groupSection = document2.createElement("section"); + groupSection.dataset.audienceProfileFieldDialogGroup = "section"; + applyGroupSectionStyles(groupSection); + const groupHeader = document2.createElement("label"); + applyGroupHeaderStyles(groupHeader); + const groupInput = document2.createElement("input"); + groupInput.type = "checkbox"; + const groupTitle = document2.createElement("span"); + groupTitle.textContent = group.label; + groupHeader.append(groupInput, groupTitle); + const fieldList = document2.createElement("div"); + applyFieldListStyles(fieldList); + const groupFieldInputs = group.headers.map((header) => { + const fieldLabel = document2.createElement("label"); + applyFieldLabelStyles(fieldLabel); + const input = document2.createElement("input"); + input.type = "checkbox"; + input.value = header; + input.dataset.audienceProfileFieldDialogField = "checkbox"; + input.checked = selectedHeaderSet.has(header); + const text = document2.createElement("span"); + text.textContent = header; + fieldLabel.append(input, text); + fieldList.append(fieldLabel); + fieldInputs.push(input); + return input; + }); + const syncGroupInput = () => { + const checkedCount = groupFieldInputs.filter((input) => input.checked).length; + groupInput.checked = checkedCount === groupFieldInputs.length; + groupInput.indeterminate = checkedCount > 0 && checkedCount < groupFieldInputs.length; + }; + groupInput.addEventListener("change", () => { + groupFieldInputs.forEach((input) => { + input.checked = groupInput.checked; + }); + syncTitle(); + }); + groupFieldInputs.forEach((input) => { + input.addEventListener("change", () => { + syncGroupInput(); + syncTitle(); + }); + }); + syncGroupInput(); + groupSection.append(groupHeader, fieldList); + groupContainer.append(groupSection); + }); const actions = document2.createElement("div"); applyActionsStyles(actions); const cancelButton = document2.createElement("button"); @@ -839,24 +956,60 @@ applySecondaryButtonStyles(cancelButton); const confirmButton = document2.createElement("button"); confirmButton.type = "button"; - confirmButton.textContent = "\u5F00\u59CB\u5BFC\u51FA"; + confirmButton.dataset.audienceProfileFieldDialogSave = "button"; + confirmButton.textContent = "\u4FDD\u5B58"; applyPrimaryButtonStyles(confirmButton); actions.append(cancelButton, confirmButton); - dialog.append(title, hint, textarea, actions); + dialog.append(title, hint, toolbar, groupContainer, actions); overlay.append(dialog); document2.body.appendChild(overlay); - const close = (value) => { + function syncTitle() { + const checkedCount = fieldInputs.filter((input) => input.checked).length; + title.textContent = `\u53EF\u9009\u5B57\u6BB5\uFF08\u5DF2\u9009 ${checkedCount}/${fieldInputs.length} \u4E2A\u5B57\u6BB5\uFF09`; + } + function close(value) { overlay.remove(); resolve(value); - }; + } + selectAllButton.addEventListener("click", () => { + fieldInputs.forEach((input) => { + input.checked = true; + }); + syncTitle(); + syncAllGroupInputs(dialog); + }); + resetButton.addEventListener("click", () => { + fieldInputs.forEach((input) => { + input.checked = true; + }); + syncTitle(); + syncAllGroupInputs(dialog); + }); cancelButton.addEventListener("click", () => close(null)); - confirmButton.addEventListener("click", () => close(textarea.value)); + confirmButton.addEventListener("click", () => { + const nextHeaders = fieldInputs.filter((input) => input.checked).map((input) => input.value); + close(nextHeaders); + }); overlay.addEventListener("click", (event) => { if (event.target === overlay) { close(null); } }); - textarea.focus(); + syncTitle(); + }); + } + function syncAllGroupInputs(dialog) { + dialog.querySelectorAll('[data-audience-profile-field-dialog-group="section"]').forEach((section) => { + const groupInput = section.querySelector(":scope > label > input"); + const fieldInputs = Array.from( + section.querySelectorAll(":scope > div input") + ); + if (!(groupInput instanceof HTMLInputElement) || fieldInputs.length === 0) { + return; + } + const checkedCount = fieldInputs.filter((input) => input.checked).length; + groupInput.checked = checkedCount === fieldInputs.length; + groupInput.indeterminate = checkedCount > 0 && checkedCount < fieldInputs.length; }); } function applyOverlayStyles(overlay) { @@ -869,8 +1022,11 @@ overlay.style.background = "rgba(15, 23, 42, 0.38)"; } function applyDialogStyles(dialog) { - dialog.style.width = "520px"; + dialog.style.width = "680px"; dialog.style.maxWidth = "calc(100vw - 32px)"; + dialog.style.maxHeight = "calc(100vh - 48px)"; + dialog.style.display = "flex"; + dialog.style.flexDirection = "column"; dialog.style.background = "#ffffff"; dialog.style.borderRadius = "8px"; dialog.style.boxShadow = "0 18px 45px rgba(15, 23, 42, 0.22)"; @@ -889,18 +1045,43 @@ hint.style.lineHeight = "20px"; hint.style.color = "#64748b"; } - function applyTextareaStyles(textarea) { - textarea.style.width = "100%"; - textarea.style.height = "220px"; - textarea.style.resize = "vertical"; - textarea.style.border = "1px solid #d0d7de"; - textarea.style.borderRadius = "6px"; - textarea.style.padding = "10px"; - textarea.style.boxSizing = "border-box"; - textarea.style.fontSize = "13px"; - textarea.style.lineHeight = "20px"; - textarea.style.fontFamily = "ui-monospace, SFMono-Regular, Menlo, monospace"; - textarea.style.color = "#1f2329"; + function applyToolbarStyles(toolbar) { + toolbar.style.display = "flex"; + toolbar.style.gap = "8px"; + toolbar.style.marginBottom = "12px"; + } + function applyGroupContainerStyles(container) { + container.style.display = "flex"; + container.style.flexDirection = "column"; + container.style.gap = "10px"; + container.style.overflow = "auto"; + container.style.paddingRight = "4px"; + } + function applyGroupSectionStyles(section) { + section.style.border = "1px solid #e5e7eb"; + section.style.borderRadius = "8px"; + section.style.padding = "10px"; + } + function applyGroupHeaderStyles(label) { + label.style.display = "flex"; + label.style.alignItems = "center"; + label.style.gap = "8px"; + label.style.fontWeight = "700"; + label.style.color = "#1f2329"; + label.style.marginBottom = "8px"; + } + function applyFieldListStyles(list) { + list.style.display = "grid"; + list.style.gridTemplateColumns = "repeat(auto-fit, minmax(220px, 1fr))"; + list.style.gap = "8px"; + } + function applyFieldLabelStyles(label) { + label.style.display = "flex"; + label.style.alignItems = "center"; + label.style.gap = "6px"; + label.style.fontSize = "13px"; + label.style.lineHeight = "18px"; + label.style.color = "#374151"; } function applyActionsStyles(actions) { actions.style.display = "flex"; @@ -927,6 +1108,120 @@ button.style.fontWeight = "600"; } + // src/content/market/author-id-dialog.ts + function promptForAuthorIds(document2) { + return new Promise((resolve) => { + const overlay = document2.createElement("div"); + overlay.dataset.authorIdDialog = "overlay"; + applyOverlayStyles2(overlay); + const dialog = document2.createElement("section"); + applyDialogStyles2(dialog); + const title = document2.createElement("h2"); + title.textContent = "\u6309\u661F\u56FEID\u5BFC\u51FA\u753B\u50CFCSV"; + applyTitleStyles2(title); + const textarea = document2.createElement("textarea"); + textarea.dataset.authorIdDialogInput = "textarea"; + textarea.placeholder = "\u6BCF\u884C\u4E00\u4E2A\u661F\u56FEID\uFF0C\u4E5F\u652F\u6301\u9017\u53F7\u3001\u7A7A\u683C\u5206\u9694"; + applyTextareaStyles(textarea); + const hint = document2.createElement("p"); + hint.textContent = "\u7C98\u8D34\u5BA2\u6237\u63D0\u4F9B\u7684\u8FBE\u4EBA\u661F\u56FEID\uFF0C\u786E\u8BA4\u540E\u5C06\u6279\u91CF\u5BFC\u51FA\u753B\u50CF\u548C\u5546\u4E1A\u80FD\u529B\u6570\u636E\u3002"; + applyHintStyles2(hint); + const actions = document2.createElement("div"); + applyActionsStyles2(actions); + const cancelButton = document2.createElement("button"); + cancelButton.type = "button"; + cancelButton.textContent = "\u53D6\u6D88"; + applySecondaryButtonStyles2(cancelButton); + const confirmButton = document2.createElement("button"); + confirmButton.type = "button"; + confirmButton.textContent = "\u5F00\u59CB\u5BFC\u51FA"; + applyPrimaryButtonStyles2(confirmButton); + actions.append(cancelButton, confirmButton); + dialog.append(title, hint, textarea, actions); + overlay.append(dialog); + document2.body.appendChild(overlay); + const close = (value) => { + overlay.remove(); + resolve(value); + }; + cancelButton.addEventListener("click", () => close(null)); + confirmButton.addEventListener("click", () => close(textarea.value)); + overlay.addEventListener("click", (event) => { + if (event.target === overlay) { + close(null); + } + }); + textarea.focus(); + }); + } + function applyOverlayStyles2(overlay) { + overlay.style.position = "fixed"; + overlay.style.inset = "0"; + overlay.style.zIndex = "2147483647"; + overlay.style.display = "flex"; + overlay.style.alignItems = "center"; + overlay.style.justifyContent = "center"; + overlay.style.background = "rgba(15, 23, 42, 0.38)"; + } + function applyDialogStyles2(dialog) { + dialog.style.width = "520px"; + dialog.style.maxWidth = "calc(100vw - 32px)"; + dialog.style.background = "#ffffff"; + dialog.style.borderRadius = "8px"; + dialog.style.boxShadow = "0 18px 45px rgba(15, 23, 42, 0.22)"; + dialog.style.padding = "20px"; + dialog.style.boxSizing = "border-box"; + } + function applyTitleStyles2(title) { + title.style.margin = "0 0 8px"; + title.style.fontSize = "18px"; + title.style.fontWeight = "700"; + title.style.color = "#1f2329"; + } + function applyHintStyles2(hint) { + hint.style.margin = "0 0 12px"; + hint.style.fontSize = "13px"; + hint.style.lineHeight = "20px"; + hint.style.color = "#64748b"; + } + function applyTextareaStyles(textarea) { + textarea.style.width = "100%"; + textarea.style.height = "220px"; + textarea.style.resize = "vertical"; + textarea.style.border = "1px solid #d0d7de"; + textarea.style.borderRadius = "6px"; + textarea.style.padding = "10px"; + textarea.style.boxSizing = "border-box"; + textarea.style.fontSize = "13px"; + textarea.style.lineHeight = "20px"; + textarea.style.fontFamily = "ui-monospace, SFMono-Regular, Menlo, monospace"; + textarea.style.color = "#1f2329"; + } + function applyActionsStyles2(actions) { + actions.style.display = "flex"; + actions.style.justifyContent = "flex-end"; + actions.style.columnGap = "8px"; + actions.style.marginTop = "14px"; + } + function applyPrimaryButtonStyles2(button) { + button.style.height = "32px"; + button.style.padding = "0 15px"; + button.style.border = "1px solid #7f1d2d"; + button.style.borderRadius = "8px"; + button.style.background = "#7f1d2d"; + button.style.color = "#ffffff"; + button.style.fontWeight = "600"; + } + function applySecondaryButtonStyles2(button) { + button.style.height = "32px"; + button.style.padding = "0 15px"; + button.style.border = "1px solid #d0d7de"; + button.style.borderRadius = "8px"; + button.style.background = "#ffffff"; + button.style.color = "#1f2329"; + button.style.fontWeight = "600"; + } + // src/content/market/batch-name-dialog.ts var DIALOG_STYLE_ID = "sces-batch-name-dialog-style"; var activeDialogs = /* @__PURE__ */ new WeakMap(); @@ -943,13 +1238,13 @@ dialogRoot.setAttribute("role", "dialog"); dialogRoot.setAttribute("aria-modal", "true"); dialogRoot.setAttribute("aria-labelledby", "sces-batch-name-title"); - applyOverlayStyles2(dialogRoot); + applyOverlayStyles3(dialogRoot); const dialogPanel = document2.createElement("div"); applyPanelStyles(dialogPanel); const title = document2.createElement("h2"); title.id = "sces-batch-name-title"; title.textContent = "\u63D0\u4EA4\u6279\u6B21"; - applyTitleStyles2(title); + applyTitleStyles3(title); const description = document2.createElement("p"); description.textContent = "\u8BF7\u8F93\u5165\u6279\u6B21\u540D\u79F0\uFF0C\u4FBF\u4E8E\u540E\u7EED\u5728\u7CFB\u7EDF\u4E2D\u8BC6\u522B\u548C\u8FFD\u8E2A\u3002"; applyDescriptionStyles(description); @@ -968,12 +1263,12 @@ cancelButton.type = "button"; cancelButton.dataset.pluginBatchNameCancel = "button"; cancelButton.textContent = "\u53D6\u6D88"; - applySecondaryButtonStyles2(cancelButton); + applySecondaryButtonStyles3(cancelButton); const confirmButton = document2.createElement("button"); confirmButton.type = "button"; confirmButton.dataset.pluginBatchNameConfirm = "button"; confirmButton.textContent = "\u786E\u8BA4\u63D0\u4EA4"; - applyPrimaryButtonStyles2(confirmButton); + applyPrimaryButtonStyles3(confirmButton); buttonRow.append(cancelButton, confirmButton); dialogPanel.append(title, description, input, errorText, buttonRow); dialogRoot.appendChild(dialogPanel); @@ -1055,7 +1350,7 @@ `; document2.head.appendChild(style); } - function applyOverlayStyles2(root) { + function applyOverlayStyles3(root) { root.style.position = "fixed"; root.style.inset = "0"; root.style.background = "rgba(15, 23, 42, 0.38)"; @@ -1074,7 +1369,7 @@ panel.style.padding = "24px"; panel.style.boxSizing = "border-box"; } - function applyTitleStyles2(title) { + function applyTitleStyles3(title) { title.style.margin = "0"; title.style.color = "#4c0519"; title.style.fontSize = "20px"; @@ -1113,7 +1408,7 @@ buttonRow.style.gap = "10px"; buttonRow.style.marginTop = "18px"; } - function applySecondaryButtonStyles2(button) { + function applySecondaryButtonStyles3(button) { button.style.height = "36px"; button.style.padding = "0 16px"; button.style.border = "1px solid #d7dde6"; @@ -1123,7 +1418,7 @@ button.style.fontWeight = "600"; button.style.cursor = "pointer"; } - function applyPrimaryButtonStyles2(button) { + function applyPrimaryButtonStyles3(button) { button.style.height = "36px"; button.style.padding = "0 16px"; button.style.border = "1px solid #7f1d2d"; @@ -3291,6 +3586,10 @@ audienceProfileByIdExportButton.type = "button"; audienceProfileByIdExportButton.dataset.pluginExportAudienceProfileById = "button"; audienceProfileByIdExportButton.textContent = "\u6309ID\u5BFC\u51FA\u753B\u50CFCSV"; + const audienceProfileFieldButton = document2.createElement("button"); + audienceProfileFieldButton.type = "button"; + audienceProfileFieldButton.dataset.pluginAudienceProfileFields = "button"; + audienceProfileFieldButton.textContent = "\u753B\u50CF\u5B57\u6BB5"; const batchSubmitButton = document2.createElement("button"); batchSubmitButton.type = "button"; batchSubmitButton.dataset.pluginBatchSubmit = "button"; @@ -3304,6 +3603,7 @@ exportButton, audienceProfileExportButton, audienceProfileByIdExportButton, + audienceProfileFieldButton, batchSubmitButton, exportStatusText ); @@ -3311,6 +3611,7 @@ applyNativeControlStyles(document2, { audienceProfileExportButton, audienceProfileByIdExportButton, + audienceProfileFieldButton, batchSubmitButton, exportButton, exportCustomPagesInput, @@ -3326,12 +3627,16 @@ audienceProfileByIdExportButton.addEventListener("click", () => { void handlers.onExportAudienceProfileByIds(); }); + audienceProfileFieldButton.addEventListener("click", () => { + void handlers.onConfigureAudienceProfileFields(); + }); batchSubmitButton.addEventListener("click", () => { void handlers.onSubmitBatch(); }); exportRangeSelect.addEventListener("change", () => { syncCustomPagesInputVisibility({ batchSubmitButton, + audienceProfileFieldButton, audienceProfileByIdExportButton, audienceProfileExportButton, exportButton, @@ -3344,6 +3649,7 @@ const toolbarDom = { audienceProfileExportButton, audienceProfileByIdExportButton, + audienceProfileFieldButton, batchSubmitButton, exportButton, exportCustomPagesInput, @@ -3368,6 +3674,9 @@ audienceProfileExportButton: root.querySelector( '[data-plugin-export-audience-profile="button"]' ), + audienceProfileFieldButton: root.querySelector( + '[data-plugin-audience-profile-fields="button"]' + ), batchSubmitButton: root.querySelector( '[data-plugin-batch-submit="button"]' ), @@ -3437,6 +3746,7 @@ function setToolbarBusyState(toolbar, isBusy) { [ toolbar.batchSubmitButton, + toolbar.audienceProfileFieldButton, toolbar.audienceProfileByIdExportButton, toolbar.audienceProfileExportButton, toolbar.exportButton, @@ -3565,15 +3875,17 @@ controls.exportButton.className = nativeButton.className; controls.audienceProfileExportButton.className = nativeButton.className; controls.audienceProfileByIdExportButton.className = nativeButton.className; + controls.audienceProfileFieldButton.className = nativeButton.className; controls.batchSubmitButton.className = nativeButton.className; } [ controls.exportButton, controls.audienceProfileExportButton, controls.audienceProfileByIdExportButton, + controls.audienceProfileFieldButton, controls.batchSubmitButton ].forEach((button) => { - applyPrimaryButtonStyles3(button); + applyPrimaryButtonStyles4(button); button.style.whiteSpace = "nowrap"; }); [controls.exportRangeSelect, controls.exportCustomPagesInput].forEach((element) => { @@ -3588,7 +3900,7 @@ controls.exportRangeSelect.style.minWidth = "104px"; controls.exportCustomPagesInput.style.width = "72px"; } - function applyPrimaryButtonStyles3(button) { + function applyPrimaryButtonStyles4(button) { button.style.backgroundColor = "#7f1d2d"; button.style.border = "1px solid #7f1d2d"; button.style.borderRadius = "8px"; @@ -3616,6 +3928,7 @@ [data-plugin-export="button"]:hover:not(:disabled), [data-plugin-export-audience-profile="button"]:hover:not(:disabled), [data-plugin-export-audience-profile-by-id="button"]:hover:not(:disabled), + [data-plugin-audience-profile-fields="button"]:hover:not(:disabled), [data-plugin-batch-submit="button"]:hover:not(:disabled) { background-color: #6d1627 !important; border-color: #6d1627 !important; @@ -3624,6 +3937,7 @@ [data-plugin-export="button"]:active:not(:disabled), [data-plugin-export-audience-profile="button"]:active:not(:disabled), [data-plugin-export-audience-profile-by-id="button"]:active:not(:disabled), + [data-plugin-audience-profile-fields="button"]:active:not(:disabled), [data-plugin-batch-submit="button"]:active:not(:disabled) { background-color: #58111f !important; border-color: #58111f !important; @@ -3633,6 +3947,7 @@ [data-plugin-export="button"]:focus-visible, [data-plugin-export-audience-profile="button"]:focus-visible, [data-plugin-export-audience-profile-by-id="button"]:focus-visible, + [data-plugin-audience-profile-fields="button"]:focus-visible, [data-plugin-batch-submit="button"]:focus-visible { outline: none !important; box-shadow: 0 0 0 3px rgba(127, 29, 45, 0.2) !important; @@ -3641,6 +3956,7 @@ [data-plugin-export="button"]:disabled, [data-plugin-export-audience-profile="button"]:disabled, [data-plugin-export-audience-profile-by-id="button"]:disabled, + [data-plugin-audience-profile-fields="button"]:disabled, [data-plugin-batch-submit="button"]:disabled { background-color: #c89ca4 !important; border-color: #c89ca4 !important; @@ -4235,6 +4551,7 @@ } // src/content/market/index.ts + var AUDIENCE_PROFILE_FIELD_SELECTION_STORAGE_KEY = "sces:audience-profile:selectedHeaders"; function createMarketController(options) { const marketApiClient = createMarketApiClient(); const audienceProfileClient = createAudienceProfileClient(); @@ -4379,7 +4696,12 @@ setToolbarExportStatus(toolbar, "\u753B\u50CF\u5BFC\u51FA\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5"); return; } - options.onCsvReady?.(buildAudienceCsv(rows), buildAudienceProfileFilename()); + options.onCsvReady?.( + buildAudienceCsv(rows, { + selectedHeaders: readAudienceProfileSelectedHeaders() + }), + buildAudienceProfileFilename() + ); setToolbarExportStatus(toolbar, ""); } catch (error) { setToolbarExportStatus( @@ -4422,7 +4744,9 @@ ); } options.onCsvReady?.( - buildAudienceCsv(rows), + buildAudienceCsv(rows, { + selectedHeaders: readAudienceProfileSelectedHeaders() + }), buildAudienceProfileFilename(/* @__PURE__ */ new Date(), "\u6309ID\u5BFC\u51FA") ); setToolbarExportStatus(toolbar, ""); @@ -4435,6 +4759,23 @@ setToolbarBusyState(toolbar, false); } }, + onConfigureAudienceProfileFields: async () => { + const groups = listAudienceProfileSelectableFieldGroups(); + const selectedHeaders = readAudienceProfileSelectedHeaders(); + const nextHeaders = await promptForAudienceProfileFields( + options.document, + groups, + selectedHeaders + ); + if (nextHeaders === null) { + return; + } + saveAudienceProfileSelectedHeaders(nextHeaders); + setToolbarExportStatus( + toolbar, + `\u753B\u50CF\u5B57\u6BB5\u5DF2\u4FDD\u5B58\uFF08\u5DF2\u9009 ${nextHeaders.length}/${readAudienceProfileSelectableHeaders().length} \u4E2A\u5B57\u6BB5\uFF09` + ); + }, onSubmitBatch: async () => { syncSelectionStateFromDom(); const exportTarget = readToolbarExportTarget(toolbar); @@ -4880,6 +5221,46 @@ } return "\u94C1\u7C89\u753B\u50CF"; } + function readAudienceProfileSelectableHeaders() { + return listAudienceProfileSelectableFieldGroups().flatMap( + (group) => group.headers + ); + } + function readAudienceProfileSelectedHeaders() { + const selectableHeaders = readAudienceProfileSelectableHeaders(); + const selectableHeaderSet = new Set(selectableHeaders); + try { + const rawValue = options.window.localStorage?.getItem( + AUDIENCE_PROFILE_FIELD_SELECTION_STORAGE_KEY + ); + if (!rawValue) { + return selectableHeaders; + } + const parsedValue = JSON.parse(rawValue); + if (!Array.isArray(parsedValue)) { + return selectableHeaders; + } + const selectedHeaders = parsedValue.filter( + (header) => typeof header === "string" && selectableHeaderSet.has(header) + ); + return selectedHeaders.length > 0 ? selectedHeaders : selectableHeaders; + } catch { + return selectableHeaders; + } + } + function saveAudienceProfileSelectedHeaders(headers) { + const selectableHeaderSet = new Set(readAudienceProfileSelectableHeaders()); + const selectedHeaders = headers.filter( + (header) => selectableHeaderSet.has(header) + ); + try { + options.window.localStorage?.setItem( + AUDIENCE_PROFILE_FIELD_SELECTION_STORAGE_KEY, + JSON.stringify(selectedHeaders) + ); + } catch { + } + } async function prepareCurrentPageForExport() { await runSyncCycle(); await harvestCurrentPageForExport(); diff --git a/docs/aigc-user-guide.md b/docs/aigc-user-guide.md index 4aabe8d..2d24c25 100644 --- a/docs/aigc-user-guide.md +++ b/docs/aigc-user-guide.md @@ -264,7 +264,59 @@ https://xingtu.cn/ad/creator/market --- -## 十、提交批次的方法 +## 十、导出画像 CSV 的方法 + +画像 CSV 用来导出达人画像、内容数据、效果预估和秒思 api 数据。 + +### 1. 勾选达人 + +画像导出必须先勾选达人。 + +原因: + +- 画像导出会额外读取达人详情页数据 +- 为了避免请求太多,只处理你勾选的达人 + +### 2. 可选:选择需要导出的字段 + +如果只想导出一部分画像字段,点击: + +- `画像字段` + +在弹出的窗口里: + +1. 勾选需要的字段 +2. 取消不需要的字段 +3. 点击 `保存` + +基础字段会固定保留,例如: + +- 达人ID +- 达人名称 +- 导出状态 +- 失败原因 + +保存后,下次再导出画像 CSV,会自动沿用这次勾选结果,不需要重新勾选。 + +### 3. 点击导出 + +勾选达人后,点击: + +- `导出画像CSV` + +如果已经有达人星图 ID 列表,也可以点击: + +- `按ID导出画像CSV` + +然后把达人 ID 粘贴进去,每行一个。 + +### 4. 等待下载 + +导出完成后,浏览器会自动下载 CSV 文件。 + +--- + +## 十一、提交批次的方法 ### 1. 选择范围 @@ -321,7 +373,7 @@ https://xingtu.cn/ad/creator/market --- -## 十一、批次名称填写建议 +## 十二、批次名称填写建议 建议使用容易看懂的命名方式: @@ -348,7 +400,7 @@ https://xingtu.cn/ad/creator/market --- -## 十二、如何更新插件 +## 十三、如何更新插件 当你收到新的插件压缩包时,不需要重新从零安装。 @@ -380,7 +432,7 @@ https://xingtu.cn/ad/creator/market --- -## 十三、常见问题 +## 十四、常见问题 ### 1. 看不到插件按钮 diff --git a/docs/【超简单版】插件安装使用指南.md b/docs/【超简单版】插件安装使用指南.md index e94a4e1..76b0ed7 100644 --- a/docs/【超简单版】插件安装使用指南.md +++ b/docs/【超简单版】插件安装使用指南.md @@ -97,6 +97,14 @@ https://xingtu.cn/ad/creator/market - 效果预估:不同视频时长的预期CPM、预期CPE、预期播放量、爆文率 - 秒思api数据:看后搜率、看后搜数、新增A3数、新增A3率、CPA3、cp_search +**只导出部分字段**: +- 点击 **"画像字段"** +- 勾选你需要的字段,取消不需要的字段 +- 点击 **"保存"** +- 再点击 **"导出画像CSV"** 或 **"按ID导出画像CSV"** + +说明:达人ID、达人名称、导出状态、失败原因等基础字段会固定保留;你保存过一次后,下次导出会自动沿用这次勾选结果,不需要重新勾选。 + ### 3️⃣ 按ID导出画像数据 当你想批量查询特定达人ID的画像数据时使用: diff --git a/release/star-chart-search-enhancer-internal.zip b/release/star-chart-search-enhancer-internal.zip index ede2c1f..dab936d 100644 Binary files a/release/star-chart-search-enhancer-internal.zip and b/release/star-chart-search-enhancer-internal.zip differ diff --git a/src/content/market/audience-profile-csv.ts b/src/content/market/audience-profile-csv.ts index f0b2a58..a544283 100644 --- a/src/content/market/audience-profile-csv.ts +++ b/src/content/market/audience-profile-csv.ts @@ -1,5 +1,10 @@ import { escapeCsvCell } from "../../shared/csv"; -import { buildMarketCsvColumns, type CsvColumn } from "./csv-exporter"; +import { + buildMarketCsvColumns, + listBackendMetricCsvHeaders, + listRateCsvHeaders, + type CsvColumn +} from "./csv-exporter"; import type { AudienceProfileDistributionItem, AudienceProfileExportRow, @@ -16,6 +21,15 @@ type AudienceProfileCsvColumn = { readValue: (row: AudienceProfileExportRow) => string; }; +export interface AudienceProfileCsvOptions { + selectedHeaders?: string[]; +} + +export type AudienceProfileCsvFieldGroup = { + headers: string[]; + label: string; +}; + const PROFILE_LAYOUTS: Array<{ includeGender: boolean; kind: AudienceProfileKind; @@ -91,14 +105,15 @@ const BUSINESS_ESTIMATE_METRIC_LAYOUTS: Array<{ ]; export function buildAudienceProfileCsv( - rows: AudienceProfileExportRow[] + rows: AudienceProfileExportRow[], + options: AudienceProfileCsvOptions = {} ): string { const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record)); - const csvColumns = [ + const csvColumns = filterAudienceProfileCsvColumns([ ...marketColumns.map(toMarketColumn), ...buildBusinessAbilityColumns(), ...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout)) - ]; + ], options.selectedHeaders); const headerLine = csvColumns.map((column) => column.header).join(","); const rowLines = rows.map((row) => csvColumns.map((column) => escapeCsvCell(column.readValue(row))).join(",") @@ -107,7 +122,72 @@ export function buildAudienceProfileCsv( return [headerLine, ...rowLines].join("\n"); } +export function listAudienceProfileCsvHeaders( + rows: AudienceProfileExportRow[] = [] +): string[] { + const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record)); + return [ + ...marketColumns.map((column) => column.header), + ...buildBusinessAbilityColumns().map((column) => column.header), + ...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout)).map( + (column) => column.header + ) + ]; +} + +export function listAudienceProfileSelectableFieldGroups(): AudienceProfileCsvFieldGroup[] { + return [ + { + headers: listRateCsvHeaders(), + label: "看后搜率" + }, + { + headers: listBackendMetricCsvHeaders(), + label: "秒思api数据" + }, + { + headers: buildBusinessVideoColumns().map((column) => column.header), + label: "内容数据" + }, + { + headers: buildBusinessEstimateColumns().map((column) => column.header), + label: "效果预估" + }, + ...PROFILE_LAYOUTS.map((layout) => ({ + headers: buildProfileColumns(layout).map((column) => column.header), + label: layout.label + })) + ]; +} + +function filterAudienceProfileCsvColumns( + columns: AudienceProfileCsvColumn[], + selectedHeaders: string[] | undefined +): AudienceProfileCsvColumn[] { + if (!selectedHeaders) { + return columns; + } + + const selectableHeaderSet = new Set(listAudienceProfileSelectableHeaders()); + const selectedHeaderSet = new Set(selectedHeaders); + return columns.filter( + (column) => + !selectableHeaderSet.has(column.header) || + selectedHeaderSet.has(column.header) + ); +} + +function listAudienceProfileSelectableHeaders(): string[] { + return listAudienceProfileSelectableFieldGroups().flatMap( + (group) => group.headers + ); +} + function buildBusinessAbilityColumns(): AudienceProfileCsvColumn[] { + return [...buildBusinessVideoColumns(), ...buildBusinessEstimateColumns()]; +} + +function buildBusinessVideoColumns(): AudienceProfileCsvColumn[] { return [ ...BUSINESS_VIDEO_LAYOUTS.flatMap((videoLayout) => BUSINESS_VIDEO_METRIC_LAYOUTS.map((metricLayout) => ({ @@ -115,7 +195,12 @@ function buildBusinessAbilityColumns(): AudienceProfileCsvColumn[] { readValue: (row: AudienceProfileExportRow) => readBusinessVideoValue(row, videoLayout.key, metricLayout.key) })) - ), + ) + ]; +} + +function buildBusinessEstimateColumns(): AudienceProfileCsvColumn[] { + return [ ...BUSINESS_ESTIMATE_LAYOUTS.flatMap((durationLayout) => BUSINESS_ESTIMATE_METRIC_LAYOUTS.map((metricLayout) => ({ header: `${BUSINESS_ESTIMATE_SECTION_LABEL}-${durationLayout.label}-${metricLayout.label}`, diff --git a/src/content/market/audience-profile-field-dialog.ts b/src/content/market/audience-profile-field-dialog.ts new file mode 100644 index 0000000..08c0926 --- /dev/null +++ b/src/content/market/audience-profile-field-dialog.ts @@ -0,0 +1,295 @@ +import type { AudienceProfileCsvFieldGroup } from "./audience-profile-csv"; + +export function promptForAudienceProfileFields( + document: Document, + groups: AudienceProfileCsvFieldGroup[], + selectedHeaders: string[] +): Promise { + return new Promise((resolve) => { + const selectableHeaders = groups.flatMap((group) => group.headers); + const selectedHeaderSet = new Set( + 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"; + applyOverlayStyles(overlay); + + const dialog = document.createElement("section"); + applyDialogStyles(dialog); + + const title = document.createElement("h2"); + applyTitleStyles(title); + + const hint = document.createElement("p"); + hint.textContent = "基础字段会固定保留。取消勾选后,本次及后续画像CSV将不包含对应列。"; + applyHintStyles(hint); + + const toolbar = document.createElement("div"); + applyToolbarStyles(toolbar); + + const selectAllButton = document.createElement("button"); + selectAllButton.type = "button"; + selectAllButton.textContent = "全选"; + applySecondaryButtonStyles(selectAllButton); + + const resetButton = document.createElement("button"); + resetButton.type = "button"; + resetButton.textContent = "恢复默认"; + applySecondaryButtonStyles(resetButton); + + toolbar.append(selectAllButton, resetButton); + + const groupContainer = document.createElement("div"); + applyGroupContainerStyles(groupContainer); + + const fieldInputs: HTMLInputElement[] = []; + groups.forEach((group) => { + const groupSection = document.createElement("section"); + groupSection.dataset.audienceProfileFieldDialogGroup = "section"; + applyGroupSectionStyles(groupSection); + + const groupHeader = document.createElement("label"); + applyGroupHeaderStyles(groupHeader); + + const groupInput = document.createElement("input"); + groupInput.type = "checkbox"; + + const groupTitle = document.createElement("span"); + groupTitle.textContent = group.label; + + groupHeader.append(groupInput, groupTitle); + + const fieldList = document.createElement("div"); + applyFieldListStyles(fieldList); + + const groupFieldInputs = group.headers.map((header) => { + const fieldLabel = document.createElement("label"); + applyFieldLabelStyles(fieldLabel); + + const input = document.createElement("input"); + input.type = "checkbox"; + input.value = header; + input.dataset.audienceProfileFieldDialogField = "checkbox"; + input.checked = selectedHeaderSet.has(header); + + const text = document.createElement("span"); + text.textContent = header; + + fieldLabel.append(input, text); + fieldList.append(fieldLabel); + fieldInputs.push(input); + return input; + }); + + const syncGroupInput = () => { + const checkedCount = groupFieldInputs.filter((input) => input.checked).length; + groupInput.checked = checkedCount === groupFieldInputs.length; + groupInput.indeterminate = checkedCount > 0 && checkedCount < groupFieldInputs.length; + }; + + groupInput.addEventListener("change", () => { + groupFieldInputs.forEach((input) => { + input.checked = groupInput.checked; + }); + syncTitle(); + }); + groupFieldInputs.forEach((input) => { + input.addEventListener("change", () => { + syncGroupInput(); + syncTitle(); + }); + }); + syncGroupInput(); + + groupSection.append(groupHeader, fieldList); + groupContainer.append(groupSection); + }); + + const actions = document.createElement("div"); + applyActionsStyles(actions); + + const cancelButton = document.createElement("button"); + cancelButton.type = "button"; + cancelButton.textContent = "取消"; + applySecondaryButtonStyles(cancelButton); + + const confirmButton = document.createElement("button"); + confirmButton.type = "button"; + confirmButton.dataset.audienceProfileFieldDialogSave = "button"; + confirmButton.textContent = "保存"; + applyPrimaryButtonStyles(confirmButton); + + actions.append(cancelButton, confirmButton); + dialog.append(title, hint, toolbar, groupContainer, actions); + overlay.append(dialog); + document.body.appendChild(overlay); + + function syncTitle() { + const checkedCount = fieldInputs.filter((input) => input.checked).length; + title.textContent = `可选字段(已选 ${checkedCount}/${fieldInputs.length} 个字段)`; + } + + function close(value: string[] | null) { + overlay.remove(); + resolve(value); + } + + selectAllButton.addEventListener("click", () => { + fieldInputs.forEach((input) => { + input.checked = true; + }); + syncTitle(); + syncAllGroupInputs(dialog); + }); + resetButton.addEventListener("click", () => { + fieldInputs.forEach((input) => { + input.checked = true; + }); + syncTitle(); + syncAllGroupInputs(dialog); + }); + cancelButton.addEventListener("click", () => close(null)); + confirmButton.addEventListener("click", () => { + const nextHeaders = fieldInputs + .filter((input) => input.checked) + .map((input) => input.value); + close(nextHeaders); + }); + overlay.addEventListener("click", (event) => { + if (event.target === overlay) { + close(null); + } + }); + syncTitle(); + }); +} + +function syncAllGroupInputs(dialog: HTMLElement): void { + dialog + .querySelectorAll('[data-audience-profile-field-dialog-group="section"]') + .forEach((section) => { + const groupInput = section.querySelector(":scope > label > input"); + const fieldInputs = Array.from( + section.querySelectorAll(":scope > div input") + ) as HTMLInputElement[]; + if (!(groupInput instanceof HTMLInputElement) || fieldInputs.length === 0) { + return; + } + + const checkedCount = fieldInputs.filter((input) => input.checked).length; + groupInput.checked = checkedCount === fieldInputs.length; + groupInput.indeterminate = checkedCount > 0 && checkedCount < fieldInputs.length; + }); +} + +function applyOverlayStyles(overlay: HTMLElement): void { + overlay.style.position = "fixed"; + overlay.style.inset = "0"; + overlay.style.zIndex = "2147483647"; + overlay.style.display = "flex"; + overlay.style.alignItems = "center"; + overlay.style.justifyContent = "center"; + overlay.style.background = "rgba(15, 23, 42, 0.38)"; +} + +function applyDialogStyles(dialog: HTMLElement): void { + dialog.style.width = "680px"; + dialog.style.maxWidth = "calc(100vw - 32px)"; + dialog.style.maxHeight = "calc(100vh - 48px)"; + dialog.style.display = "flex"; + dialog.style.flexDirection = "column"; + dialog.style.background = "#ffffff"; + dialog.style.borderRadius = "8px"; + dialog.style.boxShadow = "0 18px 45px rgba(15, 23, 42, 0.22)"; + dialog.style.padding = "20px"; + dialog.style.boxSizing = "border-box"; +} + +function applyTitleStyles(title: HTMLElement): void { + title.style.margin = "0 0 8px"; + title.style.fontSize = "18px"; + title.style.fontWeight = "700"; + title.style.color = "#1f2329"; +} + +function applyHintStyles(hint: HTMLElement): void { + hint.style.margin = "0 0 12px"; + hint.style.fontSize = "13px"; + hint.style.lineHeight = "20px"; + hint.style.color = "#64748b"; +} + +function applyToolbarStyles(toolbar: HTMLElement): void { + toolbar.style.display = "flex"; + toolbar.style.gap = "8px"; + toolbar.style.marginBottom = "12px"; +} + +function applyGroupContainerStyles(container: HTMLElement): void { + container.style.display = "flex"; + container.style.flexDirection = "column"; + container.style.gap = "10px"; + container.style.overflow = "auto"; + container.style.paddingRight = "4px"; +} + +function applyGroupSectionStyles(section: HTMLElement): void { + section.style.border = "1px solid #e5e7eb"; + section.style.borderRadius = "8px"; + section.style.padding = "10px"; +} + +function applyGroupHeaderStyles(label: HTMLElement): void { + label.style.display = "flex"; + label.style.alignItems = "center"; + label.style.gap = "8px"; + label.style.fontWeight = "700"; + label.style.color = "#1f2329"; + label.style.marginBottom = "8px"; +} + +function applyFieldListStyles(list: HTMLElement): void { + list.style.display = "grid"; + list.style.gridTemplateColumns = "repeat(auto-fit, minmax(220px, 1fr))"; + list.style.gap = "8px"; +} + +function applyFieldLabelStyles(label: HTMLElement): void { + label.style.display = "flex"; + label.style.alignItems = "center"; + label.style.gap = "6px"; + label.style.fontSize = "13px"; + label.style.lineHeight = "18px"; + label.style.color = "#374151"; +} + +function applyActionsStyles(actions: HTMLElement): void { + actions.style.display = "flex"; + actions.style.justifyContent = "flex-end"; + actions.style.columnGap = "8px"; + actions.style.marginTop = "14px"; +} + +function applyPrimaryButtonStyles(button: HTMLButtonElement): void { + button.style.height = "32px"; + button.style.padding = "0 15px"; + button.style.border = "1px solid #7f1d2d"; + button.style.borderRadius = "8px"; + button.style.background = "#7f1d2d"; + button.style.color = "#ffffff"; + button.style.fontWeight = "600"; +} + +function applySecondaryButtonStyles(button: HTMLButtonElement): void { + button.style.height = "32px"; + button.style.padding = "0 15px"; + button.style.border = "1px solid #d0d7de"; + button.style.borderRadius = "8px"; + button.style.background = "#ffffff"; + button.style.color = "#1f2329"; + button.style.fontWeight = "600"; +} diff --git a/src/content/market/csv-exporter.ts b/src/content/market/csv-exporter.ts index 471355f..00718b0 100644 --- a/src/content/market/csv-exporter.ts +++ b/src/content/market/csv-exporter.ts @@ -74,6 +74,14 @@ const BACKEND_METRIC_COLUMNS: CsvColumn[] = [ } ]; +export function listRateCsvHeaders(): string[] { + return RATE_COLUMNS.map((column) => column.header); +} + +export function listBackendMetricCsvHeaders(): string[] { + return BACKEND_METRIC_COLUMNS.map((column) => column.header); +} + export function buildMarketCsv(records: MarketRecord[]): string { const csvColumns = buildMarketCsvColumns(records); const headerLine = csvColumns.map((column) => column.header).join(","); diff --git a/src/content/market/index.ts b/src/content/market/index.ts index 964bd35..7058433 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -1,5 +1,9 @@ import { buildMarketCsv } from "./csv-exporter"; -import { buildAudienceProfileCsv } from "./audience-profile-csv"; +import { + buildAudienceProfileCsv, + listAudienceProfileSelectableFieldGroups, + type AudienceProfileCsvOptions +} from "./audience-profile-csv"; import { AUDIENCE_PROFILE_TARGETS, createAudienceProfileClient, @@ -8,6 +12,7 @@ import { import { createAuthorBaseClient } from "./author-base-client"; import { parseAuthorIds } from "./author-id-input"; import { createBusinessAbilityClient } from "./business-ability-client"; +import { promptForAudienceProfileFields } from "./audience-profile-field-dialog"; import { promptForAuthorIds } from "./author-id-dialog"; import { promptForBatchName } from "./batch-name-dialog"; import { createBatchPayload, type BatchPayload } from "./batch-payload"; @@ -58,7 +63,10 @@ interface MutationObserverLike { } export interface CreateMarketControllerOptions { - buildAudienceProfileCsv?: (rows: AudienceProfileExportRow[]) => string; + buildAudienceProfileCsv?: ( + rows: AudienceProfileExportRow[], + options?: AudienceProfileCsvOptions + ) => string; buildCsv?: (records: MarketRecord[]) => string; document: Document; getAuthState?: () => Promise; @@ -85,6 +93,9 @@ export interface CreateMarketControllerOptions { window: Window; } +const AUDIENCE_PROFILE_FIELD_SELECTION_STORAGE_KEY = + "sces:audience-profile:selectedHeaders"; + export function createMarketController(options: CreateMarketControllerOptions) { const marketApiClient = createMarketApiClient(); const audienceProfileClient = createAudienceProfileClient(); @@ -266,7 +277,12 @@ export function createMarketController(options: CreateMarketControllerOptions) { return; } - options.onCsvReady?.(buildAudienceCsv(rows), buildAudienceProfileFilename()); + options.onCsvReady?.( + buildAudienceCsv(rows, { + selectedHeaders: readAudienceProfileSelectedHeaders() + }), + buildAudienceProfileFilename() + ); setToolbarExportStatus(toolbar, ""); } catch (error) { setToolbarExportStatus( @@ -313,7 +329,9 @@ export function createMarketController(options: CreateMarketControllerOptions) { } options.onCsvReady?.( - buildAudienceCsv(rows), + buildAudienceCsv(rows, { + selectedHeaders: readAudienceProfileSelectedHeaders() + }), buildAudienceProfileFilename(new Date(), "按ID导出") ); setToolbarExportStatus(toolbar, ""); @@ -326,6 +344,24 @@ export function createMarketController(options: CreateMarketControllerOptions) { setToolbarBusyState(toolbar, false); } }, + onConfigureAudienceProfileFields: async () => { + const groups = listAudienceProfileSelectableFieldGroups(); + const selectedHeaders = readAudienceProfileSelectedHeaders(); + const nextHeaders = await promptForAudienceProfileFields( + options.document, + groups, + selectedHeaders + ); + if (nextHeaders === null) { + return; + } + + saveAudienceProfileSelectedHeaders(nextHeaders); + setToolbarExportStatus( + toolbar, + `画像字段已保存(已选 ${nextHeaders.length}/${readAudienceProfileSelectableHeaders().length} 个字段)` + ); + }, onSubmitBatch: async () => { syncSelectionStateFromDom(); const exportTarget = readToolbarExportTarget(toolbar); @@ -909,6 +945,55 @@ export function createMarketController(options: CreateMarketControllerOptions) { return "铁粉画像"; } + function readAudienceProfileSelectableHeaders(): string[] { + return listAudienceProfileSelectableFieldGroups().flatMap( + (group) => group.headers + ); + } + + function readAudienceProfileSelectedHeaders(): string[] { + const selectableHeaders = readAudienceProfileSelectableHeaders(); + const selectableHeaderSet = new Set(selectableHeaders); + + try { + const rawValue = options.window.localStorage?.getItem( + AUDIENCE_PROFILE_FIELD_SELECTION_STORAGE_KEY + ); + if (!rawValue) { + return selectableHeaders; + } + + const parsedValue = JSON.parse(rawValue) as unknown; + if (!Array.isArray(parsedValue)) { + return selectableHeaders; + } + + const selectedHeaders = parsedValue.filter( + (header): header is string => + typeof header === "string" && selectableHeaderSet.has(header) + ); + return selectedHeaders.length > 0 ? selectedHeaders : selectableHeaders; + } catch { + return selectableHeaders; + } + } + + function saveAudienceProfileSelectedHeaders(headers: string[]): void { + const selectableHeaderSet = new Set(readAudienceProfileSelectableHeaders()); + const selectedHeaders = headers.filter((header) => + selectableHeaderSet.has(header) + ); + + try { + options.window.localStorage?.setItem( + AUDIENCE_PROFILE_FIELD_SELECTION_STORAGE_KEY, + JSON.stringify(selectedHeaders) + ); + } catch { + // localStorage may be unavailable in hardened browser contexts. + } + } + async function prepareCurrentPageForExport(): Promise { await runSyncCycle(); await harvestCurrentPageForExport(); diff --git a/src/content/market/plugin-toolbar.ts b/src/content/market/plugin-toolbar.ts index 3de6348..97c9e18 100644 --- a/src/content/market/plugin-toolbar.ts +++ b/src/content/market/plugin-toolbar.ts @@ -7,12 +7,14 @@ export interface PluginToolbarHandlers { onExport(): Promise | void; onExportAudienceProfile(): Promise | void; onExportAudienceProfileByIds(): Promise | void; + onConfigureAudienceProfileFields(): Promise | void; onSubmitBatch(): Promise | void; } export interface PluginToolbarDom { audienceProfileByIdExportButton: HTMLButtonElement; audienceProfileExportButton: HTMLButtonElement; + audienceProfileFieldButton: HTMLButtonElement; batchSubmitButton: HTMLButtonElement; exportButton: HTMLButtonElement; exportCustomPagesInput: HTMLInputElement; @@ -89,6 +91,11 @@ export function ensurePluginToolbar( audienceProfileByIdExportButton.dataset.pluginExportAudienceProfileById = "button"; audienceProfileByIdExportButton.textContent = "按ID导出画像CSV"; + const audienceProfileFieldButton = document.createElement("button"); + audienceProfileFieldButton.type = "button"; + audienceProfileFieldButton.dataset.pluginAudienceProfileFields = "button"; + audienceProfileFieldButton.textContent = "画像字段"; + const batchSubmitButton = document.createElement("button"); batchSubmitButton.type = "button"; batchSubmitButton.dataset.pluginBatchSubmit = "button"; @@ -104,6 +111,7 @@ export function ensurePluginToolbar( exportButton, audienceProfileExportButton, audienceProfileByIdExportButton, + audienceProfileFieldButton, batchSubmitButton, exportStatusText ); @@ -112,6 +120,7 @@ export function ensurePluginToolbar( applyNativeControlStyles(document, { audienceProfileExportButton, audienceProfileByIdExportButton, + audienceProfileFieldButton, batchSubmitButton, exportButton, exportCustomPagesInput, @@ -128,12 +137,16 @@ export function ensurePluginToolbar( audienceProfileByIdExportButton.addEventListener("click", () => { void handlers.onExportAudienceProfileByIds(); }); + audienceProfileFieldButton.addEventListener("click", () => { + void handlers.onConfigureAudienceProfileFields(); + }); batchSubmitButton.addEventListener("click", () => { void handlers.onSubmitBatch(); }); exportRangeSelect.addEventListener("change", () => { syncCustomPagesInputVisibility({ batchSubmitButton, + audienceProfileFieldButton, audienceProfileByIdExportButton, audienceProfileExportButton, exportButton, @@ -147,6 +160,7 @@ export function ensurePluginToolbar( const toolbarDom = { audienceProfileExportButton, audienceProfileByIdExportButton, + audienceProfileFieldButton, batchSubmitButton, exportButton, exportCustomPagesInput, @@ -178,6 +192,9 @@ function readToolbarDom(root: HTMLElement): PluginToolbarDom { audienceProfileExportButton: root.querySelector( '[data-plugin-export-audience-profile="button"]' ) as HTMLButtonElement, + audienceProfileFieldButton: root.querySelector( + '[data-plugin-audience-profile-fields="button"]' + ) as HTMLButtonElement, batchSubmitButton: root.querySelector( '[data-plugin-batch-submit="button"]' ) as HTMLButtonElement, @@ -260,6 +277,7 @@ export function setToolbarBusyState( ): void { [ toolbar.batchSubmitButton, + toolbar.audienceProfileFieldButton, toolbar.audienceProfileByIdExportButton, toolbar.audienceProfileExportButton, toolbar.exportButton, @@ -460,6 +478,7 @@ function applyNativeControlStyles( controls: { audienceProfileExportButton: HTMLButtonElement; audienceProfileByIdExportButton: HTMLButtonElement; + audienceProfileFieldButton: HTMLButtonElement; batchSubmitButton: HTMLButtonElement; exportButton: HTMLButtonElement; exportCustomPagesInput: HTMLInputElement; @@ -478,6 +497,7 @@ function applyNativeControlStyles( controls.exportButton.className = nativeButton.className; controls.audienceProfileExportButton.className = nativeButton.className; controls.audienceProfileByIdExportButton.className = nativeButton.className; + controls.audienceProfileFieldButton.className = nativeButton.className; controls.batchSubmitButton.className = nativeButton.className; } @@ -485,6 +505,7 @@ function applyNativeControlStyles( controls.exportButton, controls.audienceProfileExportButton, controls.audienceProfileByIdExportButton, + controls.audienceProfileFieldButton, controls.batchSubmitButton ].forEach((button) => { applyPrimaryButtonStyles(button); @@ -539,6 +560,7 @@ function ensurePluginActionButtonTheme(document: Document): void { [data-plugin-export="button"]:hover:not(:disabled), [data-plugin-export-audience-profile="button"]:hover:not(:disabled), [data-plugin-export-audience-profile-by-id="button"]:hover:not(:disabled), + [data-plugin-audience-profile-fields="button"]:hover:not(:disabled), [data-plugin-batch-submit="button"]:hover:not(:disabled) { background-color: #6d1627 !important; border-color: #6d1627 !important; @@ -547,6 +569,7 @@ function ensurePluginActionButtonTheme(document: Document): void { [data-plugin-export="button"]:active:not(:disabled), [data-plugin-export-audience-profile="button"]:active:not(:disabled), [data-plugin-export-audience-profile-by-id="button"]:active:not(:disabled), + [data-plugin-audience-profile-fields="button"]:active:not(:disabled), [data-plugin-batch-submit="button"]:active:not(:disabled) { background-color: #58111f !important; border-color: #58111f !important; @@ -556,6 +579,7 @@ function ensurePluginActionButtonTheme(document: Document): void { [data-plugin-export="button"]:focus-visible, [data-plugin-export-audience-profile="button"]:focus-visible, [data-plugin-export-audience-profile-by-id="button"]:focus-visible, + [data-plugin-audience-profile-fields="button"]:focus-visible, [data-plugin-batch-submit="button"]:focus-visible { outline: none !important; box-shadow: 0 0 0 3px rgba(127, 29, 45, 0.2) !important; @@ -564,6 +588,7 @@ function ensurePluginActionButtonTheme(document: Document): void { [data-plugin-export="button"]:disabled, [data-plugin-export-audience-profile="button"]:disabled, [data-plugin-export-audience-profile-by-id="button"]:disabled, + [data-plugin-audience-profile-fields="button"]:disabled, [data-plugin-batch-submit="button"]:disabled { background-color: #c89ca4 !important; border-color: #c89ca4 !important; diff --git a/tests/audience-profile-csv.test.ts b/tests/audience-profile-csv.test.ts index a42c370..e99e67e 100644 --- a/tests/audience-profile-csv.test.ts +++ b/tests/audience-profile-csv.test.ts @@ -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 { + 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 + } + }; +} diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index ca3db74..4750a1a 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -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 () => {