feat: allow selecting audience export fields

This commit is contained in:
admin123 2026-05-19 16:26:04 +08:00
parent db95a1f565
commit 50933af0a6
11 changed files with 1273 additions and 110 deletions

View File

@ -141,6 +141,12 @@
readValue: (record) => record.backendMetrics?.cpSearch ?? "" 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) { function buildMarketCsv(records) {
const csvColumns = buildMarketCsvColumns(records); const csvColumns = buildMarketCsvColumns(records);
const headerLine = csvColumns.map((column) => column.header).join(","); const headerLine = csvColumns.map((column) => column.header).join(",");
@ -227,27 +233,73 @@
{ key: "expectedPlay", label: "\u9884\u671F\u64AD\u653E\u91CF" }, { key: "expectedPlay", label: "\u9884\u671F\u64AD\u653E\u91CF" },
{ key: "hotRate", label: "\u7206\u6587\u7387" } { key: "hotRate", label: "\u7206\u6587\u7387" }
]; ];
function buildAudienceProfileCsv(rows) { function buildAudienceProfileCsv(rows, options = {}) {
const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record)); const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record));
const csvColumns = [ const csvColumns = filterAudienceProfileCsvColumns([
...marketColumns.map(toMarketColumn), ...marketColumns.map(toMarketColumn),
...buildBusinessAbilityColumns(), ...buildBusinessAbilityColumns(),
...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout)) ...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout))
]; ], options.selectedHeaders);
const headerLine = csvColumns.map((column) => column.header).join(","); const headerLine = csvColumns.map((column) => column.header).join(",");
const rowLines = rows.map( const rowLines = rows.map(
(row) => csvColumns.map((column) => escapeCsvCell(column.readValue(row))).join(",") (row) => csvColumns.map((column) => escapeCsvCell(column.readValue(row))).join(",")
); );
return [headerLine, ...rowLines].join("\n"); 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() { function buildBusinessAbilityColumns() {
return [...buildBusinessVideoColumns(), ...buildBusinessEstimateColumns()];
}
function buildBusinessVideoColumns() {
return [ return [
...BUSINESS_VIDEO_LAYOUTS.flatMap( ...BUSINESS_VIDEO_LAYOUTS.flatMap(
(videoLayout) => BUSINESS_VIDEO_METRIC_LAYOUTS.map((metricLayout) => ({ (videoLayout) => BUSINESS_VIDEO_METRIC_LAYOUTS.map((metricLayout) => ({
header: `${BUSINESS_VIDEO_SECTION_LABEL}-${videoLayout.label}-${metricLayout.label}`, header: `${BUSINESS_VIDEO_SECTION_LABEL}-${videoLayout.label}-${metricLayout.label}`,
readValue: (row) => readBusinessVideoValue(row, videoLayout.key, metricLayout.key) readValue: (row) => readBusinessVideoValue(row, videoLayout.key, metricLayout.key)
})) }))
), )
];
}
function buildBusinessEstimateColumns() {
return [
...BUSINESS_ESTIMATE_LAYOUTS.flatMap( ...BUSINESS_ESTIMATE_LAYOUTS.flatMap(
(durationLayout) => BUSINESS_ESTIMATE_METRIC_LAYOUTS.map((metricLayout) => ({ (durationLayout) => BUSINESS_ESTIMATE_METRIC_LAYOUTS.map((metricLayout) => ({
header: `${BUSINESS_ESTIMATE_SECTION_LABEL}-${durationLayout.label}-${metricLayout.label}`, header: `${BUSINESS_ESTIMATE_SECTION_LABEL}-${durationLayout.label}-${metricLayout.label}`,
@ -813,24 +865,89 @@
return typeof value === "object" && value !== null; return typeof value === "object" && value !== null;
} }
// src/content/market/author-id-dialog.ts // src/content/market/audience-profile-field-dialog.ts
function promptForAuthorIds(document2) { function promptForAudienceProfileFields(document2, groups, selectedHeaders) {
return new Promise((resolve) => { 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"); const overlay = document2.createElement("div");
overlay.dataset.authorIdDialog = "overlay"; overlay.dataset.audienceProfileFieldDialog = "overlay";
applyOverlayStyles(overlay); applyOverlayStyles(overlay);
const dialog = document2.createElement("section"); const dialog = document2.createElement("section");
applyDialogStyles(dialog); applyDialogStyles(dialog);
const title = document2.createElement("h2"); const title = document2.createElement("h2");
title.textContent = "\u6309\u661F\u56FEID\u5BFC\u51FA\u753B\u50CFCSV";
applyTitleStyles(title); 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"); 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); 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"); const actions = document2.createElement("div");
applyActionsStyles(actions); applyActionsStyles(actions);
const cancelButton = document2.createElement("button"); const cancelButton = document2.createElement("button");
@ -839,24 +956,60 @@
applySecondaryButtonStyles(cancelButton); applySecondaryButtonStyles(cancelButton);
const confirmButton = document2.createElement("button"); const confirmButton = document2.createElement("button");
confirmButton.type = "button"; confirmButton.type = "button";
confirmButton.textContent = "\u5F00\u59CB\u5BFC\u51FA"; confirmButton.dataset.audienceProfileFieldDialogSave = "button";
confirmButton.textContent = "\u4FDD\u5B58";
applyPrimaryButtonStyles(confirmButton); applyPrimaryButtonStyles(confirmButton);
actions.append(cancelButton, confirmButton); actions.append(cancelButton, confirmButton);
dialog.append(title, hint, textarea, actions); dialog.append(title, hint, toolbar, groupContainer, actions);
overlay.append(dialog); overlay.append(dialog);
document2.body.appendChild(overlay); 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(); overlay.remove();
resolve(value); 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)); 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) => { overlay.addEventListener("click", (event) => {
if (event.target === overlay) { if (event.target === overlay) {
close(null); 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) { function applyOverlayStyles(overlay) {
@ -869,8 +1022,11 @@
overlay.style.background = "rgba(15, 23, 42, 0.38)"; overlay.style.background = "rgba(15, 23, 42, 0.38)";
} }
function applyDialogStyles(dialog) { function applyDialogStyles(dialog) {
dialog.style.width = "520px"; dialog.style.width = "680px";
dialog.style.maxWidth = "calc(100vw - 32px)"; 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.background = "#ffffff";
dialog.style.borderRadius = "8px"; dialog.style.borderRadius = "8px";
dialog.style.boxShadow = "0 18px 45px rgba(15, 23, 42, 0.22)"; dialog.style.boxShadow = "0 18px 45px rgba(15, 23, 42, 0.22)";
@ -889,18 +1045,43 @@
hint.style.lineHeight = "20px"; hint.style.lineHeight = "20px";
hint.style.color = "#64748b"; hint.style.color = "#64748b";
} }
function applyTextareaStyles(textarea) { function applyToolbarStyles(toolbar) {
textarea.style.width = "100%"; toolbar.style.display = "flex";
textarea.style.height = "220px"; toolbar.style.gap = "8px";
textarea.style.resize = "vertical"; toolbar.style.marginBottom = "12px";
textarea.style.border = "1px solid #d0d7de"; }
textarea.style.borderRadius = "6px"; function applyGroupContainerStyles(container) {
textarea.style.padding = "10px"; container.style.display = "flex";
textarea.style.boxSizing = "border-box"; container.style.flexDirection = "column";
textarea.style.fontSize = "13px"; container.style.gap = "10px";
textarea.style.lineHeight = "20px"; container.style.overflow = "auto";
textarea.style.fontFamily = "ui-monospace, SFMono-Regular, Menlo, monospace"; container.style.paddingRight = "4px";
textarea.style.color = "#1f2329"; }
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) { function applyActionsStyles(actions) {
actions.style.display = "flex"; actions.style.display = "flex";
@ -927,6 +1108,120 @@
button.style.fontWeight = "600"; 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 // src/content/market/batch-name-dialog.ts
var DIALOG_STYLE_ID = "sces-batch-name-dialog-style"; var DIALOG_STYLE_ID = "sces-batch-name-dialog-style";
var activeDialogs = /* @__PURE__ */ new WeakMap(); var activeDialogs = /* @__PURE__ */ new WeakMap();
@ -943,13 +1238,13 @@
dialogRoot.setAttribute("role", "dialog"); dialogRoot.setAttribute("role", "dialog");
dialogRoot.setAttribute("aria-modal", "true"); dialogRoot.setAttribute("aria-modal", "true");
dialogRoot.setAttribute("aria-labelledby", "sces-batch-name-title"); dialogRoot.setAttribute("aria-labelledby", "sces-batch-name-title");
applyOverlayStyles2(dialogRoot); applyOverlayStyles3(dialogRoot);
const dialogPanel = document2.createElement("div"); const dialogPanel = document2.createElement("div");
applyPanelStyles(dialogPanel); applyPanelStyles(dialogPanel);
const title = document2.createElement("h2"); const title = document2.createElement("h2");
title.id = "sces-batch-name-title"; title.id = "sces-batch-name-title";
title.textContent = "\u63D0\u4EA4\u6279\u6B21"; title.textContent = "\u63D0\u4EA4\u6279\u6B21";
applyTitleStyles2(title); applyTitleStyles3(title);
const description = document2.createElement("p"); 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"; 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); applyDescriptionStyles(description);
@ -968,12 +1263,12 @@
cancelButton.type = "button"; cancelButton.type = "button";
cancelButton.dataset.pluginBatchNameCancel = "button"; cancelButton.dataset.pluginBatchNameCancel = "button";
cancelButton.textContent = "\u53D6\u6D88"; cancelButton.textContent = "\u53D6\u6D88";
applySecondaryButtonStyles2(cancelButton); applySecondaryButtonStyles3(cancelButton);
const confirmButton = document2.createElement("button"); const confirmButton = document2.createElement("button");
confirmButton.type = "button"; confirmButton.type = "button";
confirmButton.dataset.pluginBatchNameConfirm = "button"; confirmButton.dataset.pluginBatchNameConfirm = "button";
confirmButton.textContent = "\u786E\u8BA4\u63D0\u4EA4"; confirmButton.textContent = "\u786E\u8BA4\u63D0\u4EA4";
applyPrimaryButtonStyles2(confirmButton); applyPrimaryButtonStyles3(confirmButton);
buttonRow.append(cancelButton, confirmButton); buttonRow.append(cancelButton, confirmButton);
dialogPanel.append(title, description, input, errorText, buttonRow); dialogPanel.append(title, description, input, errorText, buttonRow);
dialogRoot.appendChild(dialogPanel); dialogRoot.appendChild(dialogPanel);
@ -1055,7 +1350,7 @@
`; `;
document2.head.appendChild(style); document2.head.appendChild(style);
} }
function applyOverlayStyles2(root) { function applyOverlayStyles3(root) {
root.style.position = "fixed"; root.style.position = "fixed";
root.style.inset = "0"; root.style.inset = "0";
root.style.background = "rgba(15, 23, 42, 0.38)"; root.style.background = "rgba(15, 23, 42, 0.38)";
@ -1074,7 +1369,7 @@
panel.style.padding = "24px"; panel.style.padding = "24px";
panel.style.boxSizing = "border-box"; panel.style.boxSizing = "border-box";
} }
function applyTitleStyles2(title) { function applyTitleStyles3(title) {
title.style.margin = "0"; title.style.margin = "0";
title.style.color = "#4c0519"; title.style.color = "#4c0519";
title.style.fontSize = "20px"; title.style.fontSize = "20px";
@ -1113,7 +1408,7 @@
buttonRow.style.gap = "10px"; buttonRow.style.gap = "10px";
buttonRow.style.marginTop = "18px"; buttonRow.style.marginTop = "18px";
} }
function applySecondaryButtonStyles2(button) { function applySecondaryButtonStyles3(button) {
button.style.height = "36px"; button.style.height = "36px";
button.style.padding = "0 16px"; button.style.padding = "0 16px";
button.style.border = "1px solid #d7dde6"; button.style.border = "1px solid #d7dde6";
@ -1123,7 +1418,7 @@
button.style.fontWeight = "600"; button.style.fontWeight = "600";
button.style.cursor = "pointer"; button.style.cursor = "pointer";
} }
function applyPrimaryButtonStyles2(button) { function applyPrimaryButtonStyles3(button) {
button.style.height = "36px"; button.style.height = "36px";
button.style.padding = "0 16px"; button.style.padding = "0 16px";
button.style.border = "1px solid #7f1d2d"; button.style.border = "1px solid #7f1d2d";
@ -3291,6 +3586,10 @@
audienceProfileByIdExportButton.type = "button"; audienceProfileByIdExportButton.type = "button";
audienceProfileByIdExportButton.dataset.pluginExportAudienceProfileById = "button"; audienceProfileByIdExportButton.dataset.pluginExportAudienceProfileById = "button";
audienceProfileByIdExportButton.textContent = "\u6309ID\u5BFC\u51FA\u753B\u50CFCSV"; 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"); const batchSubmitButton = document2.createElement("button");
batchSubmitButton.type = "button"; batchSubmitButton.type = "button";
batchSubmitButton.dataset.pluginBatchSubmit = "button"; batchSubmitButton.dataset.pluginBatchSubmit = "button";
@ -3304,6 +3603,7 @@
exportButton, exportButton,
audienceProfileExportButton, audienceProfileExportButton,
audienceProfileByIdExportButton, audienceProfileByIdExportButton,
audienceProfileFieldButton,
batchSubmitButton, batchSubmitButton,
exportStatusText exportStatusText
); );
@ -3311,6 +3611,7 @@
applyNativeControlStyles(document2, { applyNativeControlStyles(document2, {
audienceProfileExportButton, audienceProfileExportButton,
audienceProfileByIdExportButton, audienceProfileByIdExportButton,
audienceProfileFieldButton,
batchSubmitButton, batchSubmitButton,
exportButton, exportButton,
exportCustomPagesInput, exportCustomPagesInput,
@ -3326,12 +3627,16 @@
audienceProfileByIdExportButton.addEventListener("click", () => { audienceProfileByIdExportButton.addEventListener("click", () => {
void handlers.onExportAudienceProfileByIds(); void handlers.onExportAudienceProfileByIds();
}); });
audienceProfileFieldButton.addEventListener("click", () => {
void handlers.onConfigureAudienceProfileFields();
});
batchSubmitButton.addEventListener("click", () => { batchSubmitButton.addEventListener("click", () => {
void handlers.onSubmitBatch(); void handlers.onSubmitBatch();
}); });
exportRangeSelect.addEventListener("change", () => { exportRangeSelect.addEventListener("change", () => {
syncCustomPagesInputVisibility({ syncCustomPagesInputVisibility({
batchSubmitButton, batchSubmitButton,
audienceProfileFieldButton,
audienceProfileByIdExportButton, audienceProfileByIdExportButton,
audienceProfileExportButton, audienceProfileExportButton,
exportButton, exportButton,
@ -3344,6 +3649,7 @@
const toolbarDom = { const toolbarDom = {
audienceProfileExportButton, audienceProfileExportButton,
audienceProfileByIdExportButton, audienceProfileByIdExportButton,
audienceProfileFieldButton,
batchSubmitButton, batchSubmitButton,
exportButton, exportButton,
exportCustomPagesInput, exportCustomPagesInput,
@ -3368,6 +3674,9 @@
audienceProfileExportButton: root.querySelector( audienceProfileExportButton: root.querySelector(
'[data-plugin-export-audience-profile="button"]' '[data-plugin-export-audience-profile="button"]'
), ),
audienceProfileFieldButton: root.querySelector(
'[data-plugin-audience-profile-fields="button"]'
),
batchSubmitButton: root.querySelector( batchSubmitButton: root.querySelector(
'[data-plugin-batch-submit="button"]' '[data-plugin-batch-submit="button"]'
), ),
@ -3437,6 +3746,7 @@
function setToolbarBusyState(toolbar, isBusy) { function setToolbarBusyState(toolbar, isBusy) {
[ [
toolbar.batchSubmitButton, toolbar.batchSubmitButton,
toolbar.audienceProfileFieldButton,
toolbar.audienceProfileByIdExportButton, toolbar.audienceProfileByIdExportButton,
toolbar.audienceProfileExportButton, toolbar.audienceProfileExportButton,
toolbar.exportButton, toolbar.exportButton,
@ -3565,15 +3875,17 @@
controls.exportButton.className = nativeButton.className; controls.exportButton.className = nativeButton.className;
controls.audienceProfileExportButton.className = nativeButton.className; controls.audienceProfileExportButton.className = nativeButton.className;
controls.audienceProfileByIdExportButton.className = nativeButton.className; controls.audienceProfileByIdExportButton.className = nativeButton.className;
controls.audienceProfileFieldButton.className = nativeButton.className;
controls.batchSubmitButton.className = nativeButton.className; controls.batchSubmitButton.className = nativeButton.className;
} }
[ [
controls.exportButton, controls.exportButton,
controls.audienceProfileExportButton, controls.audienceProfileExportButton,
controls.audienceProfileByIdExportButton, controls.audienceProfileByIdExportButton,
controls.audienceProfileFieldButton,
controls.batchSubmitButton controls.batchSubmitButton
].forEach((button) => { ].forEach((button) => {
applyPrimaryButtonStyles3(button); applyPrimaryButtonStyles4(button);
button.style.whiteSpace = "nowrap"; button.style.whiteSpace = "nowrap";
}); });
[controls.exportRangeSelect, controls.exportCustomPagesInput].forEach((element) => { [controls.exportRangeSelect, controls.exportCustomPagesInput].forEach((element) => {
@ -3588,7 +3900,7 @@
controls.exportRangeSelect.style.minWidth = "104px"; controls.exportRangeSelect.style.minWidth = "104px";
controls.exportCustomPagesInput.style.width = "72px"; controls.exportCustomPagesInput.style.width = "72px";
} }
function applyPrimaryButtonStyles3(button) { function applyPrimaryButtonStyles4(button) {
button.style.backgroundColor = "#7f1d2d"; button.style.backgroundColor = "#7f1d2d";
button.style.border = "1px solid #7f1d2d"; button.style.border = "1px solid #7f1d2d";
button.style.borderRadius = "8px"; button.style.borderRadius = "8px";
@ -3616,6 +3928,7 @@
[data-plugin-export="button"]:hover:not(:disabled), [data-plugin-export="button"]:hover:not(:disabled),
[data-plugin-export-audience-profile="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-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) { [data-plugin-batch-submit="button"]:hover:not(:disabled) {
background-color: #6d1627 !important; background-color: #6d1627 !important;
border-color: #6d1627 !important; border-color: #6d1627 !important;
@ -3624,6 +3937,7 @@
[data-plugin-export="button"]:active:not(:disabled), [data-plugin-export="button"]:active:not(:disabled),
[data-plugin-export-audience-profile="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-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) { [data-plugin-batch-submit="button"]:active:not(:disabled) {
background-color: #58111f !important; background-color: #58111f !important;
border-color: #58111f !important; border-color: #58111f !important;
@ -3633,6 +3947,7 @@
[data-plugin-export="button"]:focus-visible, [data-plugin-export="button"]:focus-visible,
[data-plugin-export-audience-profile="button"]:focus-visible, [data-plugin-export-audience-profile="button"]:focus-visible,
[data-plugin-export-audience-profile-by-id="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 { [data-plugin-batch-submit="button"]:focus-visible {
outline: none !important; outline: none !important;
box-shadow: 0 0 0 3px rgba(127, 29, 45, 0.2) !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="button"]:disabled,
[data-plugin-export-audience-profile="button"]:disabled, [data-plugin-export-audience-profile="button"]:disabled,
[data-plugin-export-audience-profile-by-id="button"]:disabled, [data-plugin-export-audience-profile-by-id="button"]:disabled,
[data-plugin-audience-profile-fields="button"]:disabled,
[data-plugin-batch-submit="button"]:disabled { [data-plugin-batch-submit="button"]:disabled {
background-color: #c89ca4 !important; background-color: #c89ca4 !important;
border-color: #c89ca4 !important; border-color: #c89ca4 !important;
@ -4235,6 +4551,7 @@
} }
// src/content/market/index.ts // src/content/market/index.ts
var AUDIENCE_PROFILE_FIELD_SELECTION_STORAGE_KEY = "sces:audience-profile:selectedHeaders";
function createMarketController(options) { function createMarketController(options) {
const marketApiClient = createMarketApiClient(); const marketApiClient = createMarketApiClient();
const audienceProfileClient = createAudienceProfileClient(); const audienceProfileClient = createAudienceProfileClient();
@ -4379,7 +4696,12 @@
setToolbarExportStatus(toolbar, "\u753B\u50CF\u5BFC\u51FA\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5"); setToolbarExportStatus(toolbar, "\u753B\u50CF\u5BFC\u51FA\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5");
return; return;
} }
options.onCsvReady?.(buildAudienceCsv(rows), buildAudienceProfileFilename()); options.onCsvReady?.(
buildAudienceCsv(rows, {
selectedHeaders: readAudienceProfileSelectedHeaders()
}),
buildAudienceProfileFilename()
);
setToolbarExportStatus(toolbar, ""); setToolbarExportStatus(toolbar, "");
} catch (error) { } catch (error) {
setToolbarExportStatus( setToolbarExportStatus(
@ -4422,7 +4744,9 @@
); );
} }
options.onCsvReady?.( options.onCsvReady?.(
buildAudienceCsv(rows), buildAudienceCsv(rows, {
selectedHeaders: readAudienceProfileSelectedHeaders()
}),
buildAudienceProfileFilename(/* @__PURE__ */ new Date(), "\u6309ID\u5BFC\u51FA") buildAudienceProfileFilename(/* @__PURE__ */ new Date(), "\u6309ID\u5BFC\u51FA")
); );
setToolbarExportStatus(toolbar, ""); setToolbarExportStatus(toolbar, "");
@ -4435,6 +4759,23 @@
setToolbarBusyState(toolbar, false); 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 () => { onSubmitBatch: async () => {
syncSelectionStateFromDom(); syncSelectionStateFromDom();
const exportTarget = readToolbarExportTarget(toolbar); const exportTarget = readToolbarExportTarget(toolbar);
@ -4880,6 +5221,46 @@
} }
return "\u94C1\u7C89\u753B\u50CF"; 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() { async function prepareCurrentPageForExport() {
await runSyncCycle(); await runSyncCycle();
await harvestCurrentPageForExport(); await harvestCurrentPageForExport();

View File

@ -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. 选择范围 ### 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. 看不到插件按钮 ### 1. 看不到插件按钮

View File

@ -97,6 +97,14 @@ https://xingtu.cn/ad/creator/market
- 效果预估不同视频时长的预期CPM、预期CPE、预期播放量、爆文率 - 效果预估不同视频时长的预期CPM、预期CPE、预期播放量、爆文率
- 秒思api数据看后搜率、看后搜数、新增A3数、新增A3率、CPA3、cp_search - 秒思api数据看后搜率、看后搜数、新增A3数、新增A3率、CPA3、cp_search
**只导出部分字段**
- 点击 **"画像字段"**
- 勾选你需要的字段,取消不需要的字段
- 点击 **"保存"**
- 再点击 **"导出画像CSV"** 或 **"按ID导出画像CSV"**
说明达人ID、达人名称、导出状态、失败原因等基础字段会固定保留你保存过一次后下次导出会自动沿用这次勾选结果不需要重新勾选。
### 3⃣ 按ID导出画像数据 ### 3⃣ 按ID导出画像数据
当你想批量查询特定达人ID的画像数据时使用 当你想批量查询特定达人ID的画像数据时使用

View File

@ -1,5 +1,10 @@
import { escapeCsvCell } from "../../shared/csv"; import { escapeCsvCell } from "../../shared/csv";
import { buildMarketCsvColumns, type CsvColumn } from "./csv-exporter"; import {
buildMarketCsvColumns,
listBackendMetricCsvHeaders,
listRateCsvHeaders,
type CsvColumn
} from "./csv-exporter";
import type { import type {
AudienceProfileDistributionItem, AudienceProfileDistributionItem,
AudienceProfileExportRow, AudienceProfileExportRow,
@ -16,6 +21,15 @@ type AudienceProfileCsvColumn = {
readValue: (row: AudienceProfileExportRow) => string; readValue: (row: AudienceProfileExportRow) => string;
}; };
export interface AudienceProfileCsvOptions {
selectedHeaders?: string[];
}
export type AudienceProfileCsvFieldGroup = {
headers: string[];
label: string;
};
const PROFILE_LAYOUTS: Array<{ const PROFILE_LAYOUTS: Array<{
includeGender: boolean; includeGender: boolean;
kind: AudienceProfileKind; kind: AudienceProfileKind;
@ -91,14 +105,15 @@ const BUSINESS_ESTIMATE_METRIC_LAYOUTS: Array<{
]; ];
export function buildAudienceProfileCsv( export function buildAudienceProfileCsv(
rows: AudienceProfileExportRow[] rows: AudienceProfileExportRow[],
options: AudienceProfileCsvOptions = {}
): string { ): string {
const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record)); const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record));
const csvColumns = [ const csvColumns = filterAudienceProfileCsvColumns([
...marketColumns.map(toMarketColumn), ...marketColumns.map(toMarketColumn),
...buildBusinessAbilityColumns(), ...buildBusinessAbilityColumns(),
...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout)) ...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout))
]; ], 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(",")
@ -107,7 +122,72 @@ export function buildAudienceProfileCsv(
return [headerLine, ...rowLines].join("\n"); 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[] { function buildBusinessAbilityColumns(): AudienceProfileCsvColumn[] {
return [...buildBusinessVideoColumns(), ...buildBusinessEstimateColumns()];
}
function buildBusinessVideoColumns(): AudienceProfileCsvColumn[] {
return [ return [
...BUSINESS_VIDEO_LAYOUTS.flatMap((videoLayout) => ...BUSINESS_VIDEO_LAYOUTS.flatMap((videoLayout) =>
BUSINESS_VIDEO_METRIC_LAYOUTS.map((metricLayout) => ({ BUSINESS_VIDEO_METRIC_LAYOUTS.map((metricLayout) => ({
@ -115,7 +195,12 @@ function buildBusinessAbilityColumns(): AudienceProfileCsvColumn[] {
readValue: (row: AudienceProfileExportRow) => readValue: (row: AudienceProfileExportRow) =>
readBusinessVideoValue(row, videoLayout.key, metricLayout.key) readBusinessVideoValue(row, videoLayout.key, metricLayout.key)
})) }))
), )
];
}
function buildBusinessEstimateColumns(): AudienceProfileCsvColumn[] {
return [
...BUSINESS_ESTIMATE_LAYOUTS.flatMap((durationLayout) => ...BUSINESS_ESTIMATE_LAYOUTS.flatMap((durationLayout) =>
BUSINESS_ESTIMATE_METRIC_LAYOUTS.map((metricLayout) => ({ BUSINESS_ESTIMATE_METRIC_LAYOUTS.map((metricLayout) => ({
header: `${BUSINESS_ESTIMATE_SECTION_LABEL}-${durationLayout.label}-${metricLayout.label}`, header: `${BUSINESS_ESTIMATE_SECTION_LABEL}-${durationLayout.label}-${metricLayout.label}`,

View File

@ -0,0 +1,295 @@
import type { AudienceProfileCsvFieldGroup } from "./audience-profile-csv";
export function promptForAudienceProfileFields(
document: Document,
groups: AudienceProfileCsvFieldGroup[],
selectedHeaders: string[]
): Promise<string[] | null> {
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";
}

View File

@ -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 { export function buildMarketCsv(records: MarketRecord[]): string {
const csvColumns = buildMarketCsvColumns(records); const csvColumns = buildMarketCsvColumns(records);
const headerLine = csvColumns.map((column) => column.header).join(","); const headerLine = csvColumns.map((column) => column.header).join(",");

View File

@ -1,5 +1,9 @@
import { buildMarketCsv } from "./csv-exporter"; import { buildMarketCsv } from "./csv-exporter";
import { buildAudienceProfileCsv } from "./audience-profile-csv"; import {
buildAudienceProfileCsv,
listAudienceProfileSelectableFieldGroups,
type AudienceProfileCsvOptions
} from "./audience-profile-csv";
import { import {
AUDIENCE_PROFILE_TARGETS, AUDIENCE_PROFILE_TARGETS,
createAudienceProfileClient, createAudienceProfileClient,
@ -8,6 +12,7 @@ import {
import { createAuthorBaseClient } from "./author-base-client"; import { createAuthorBaseClient } from "./author-base-client";
import { parseAuthorIds } from "./author-id-input"; import { parseAuthorIds } from "./author-id-input";
import { createBusinessAbilityClient } from "./business-ability-client"; import { createBusinessAbilityClient } from "./business-ability-client";
import { promptForAudienceProfileFields } from "./audience-profile-field-dialog";
import { promptForAuthorIds } from "./author-id-dialog"; import { promptForAuthorIds } from "./author-id-dialog";
import { promptForBatchName } from "./batch-name-dialog"; import { promptForBatchName } from "./batch-name-dialog";
import { createBatchPayload, type BatchPayload } from "./batch-payload"; import { createBatchPayload, type BatchPayload } from "./batch-payload";
@ -58,7 +63,10 @@ interface MutationObserverLike {
} }
export interface CreateMarketControllerOptions { export interface CreateMarketControllerOptions {
buildAudienceProfileCsv?: (rows: AudienceProfileExportRow[]) => string; buildAudienceProfileCsv?: (
rows: AudienceProfileExportRow[],
options?: AudienceProfileCsvOptions
) => string;
buildCsv?: (records: MarketRecord[]) => string; buildCsv?: (records: MarketRecord[]) => string;
document: Document; document: Document;
getAuthState?: () => Promise<AuthStateValue>; getAuthState?: () => Promise<AuthStateValue>;
@ -85,6 +93,9 @@ export interface CreateMarketControllerOptions {
window: Window; window: Window;
} }
const AUDIENCE_PROFILE_FIELD_SELECTION_STORAGE_KEY =
"sces:audience-profile:selectedHeaders";
export function createMarketController(options: CreateMarketControllerOptions) { export function createMarketController(options: CreateMarketControllerOptions) {
const marketApiClient = createMarketApiClient(); const marketApiClient = createMarketApiClient();
const audienceProfileClient = createAudienceProfileClient(); const audienceProfileClient = createAudienceProfileClient();
@ -266,7 +277,12 @@ export function createMarketController(options: CreateMarketControllerOptions) {
return; return;
} }
options.onCsvReady?.(buildAudienceCsv(rows), buildAudienceProfileFilename()); options.onCsvReady?.(
buildAudienceCsv(rows, {
selectedHeaders: readAudienceProfileSelectedHeaders()
}),
buildAudienceProfileFilename()
);
setToolbarExportStatus(toolbar, ""); setToolbarExportStatus(toolbar, "");
} catch (error) { } catch (error) {
setToolbarExportStatus( setToolbarExportStatus(
@ -313,7 +329,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
} }
options.onCsvReady?.( options.onCsvReady?.(
buildAudienceCsv(rows), buildAudienceCsv(rows, {
selectedHeaders: readAudienceProfileSelectedHeaders()
}),
buildAudienceProfileFilename(new Date(), "按ID导出") buildAudienceProfileFilename(new Date(), "按ID导出")
); );
setToolbarExportStatus(toolbar, ""); setToolbarExportStatus(toolbar, "");
@ -326,6 +344,24 @@ export function createMarketController(options: CreateMarketControllerOptions) {
setToolbarBusyState(toolbar, false); 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 () => { onSubmitBatch: async () => {
syncSelectionStateFromDom(); syncSelectionStateFromDom();
const exportTarget = readToolbarExportTarget(toolbar); const exportTarget = readToolbarExportTarget(toolbar);
@ -909,6 +945,55 @@ export function createMarketController(options: CreateMarketControllerOptions) {
return "铁粉画像"; 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<void> { async function prepareCurrentPageForExport(): Promise<void> {
await runSyncCycle(); await runSyncCycle();
await harvestCurrentPageForExport(); await harvestCurrentPageForExport();

View File

@ -7,12 +7,14 @@ export interface PluginToolbarHandlers {
onExport(): Promise<void> | void; onExport(): Promise<void> | void;
onExportAudienceProfile(): Promise<void> | void; onExportAudienceProfile(): Promise<void> | void;
onExportAudienceProfileByIds(): Promise<void> | void; onExportAudienceProfileByIds(): Promise<void> | void;
onConfigureAudienceProfileFields(): Promise<void> | void;
onSubmitBatch(): Promise<void> | void; onSubmitBatch(): Promise<void> | void;
} }
export interface PluginToolbarDom { export interface PluginToolbarDom {
audienceProfileByIdExportButton: HTMLButtonElement; audienceProfileByIdExportButton: HTMLButtonElement;
audienceProfileExportButton: HTMLButtonElement; audienceProfileExportButton: HTMLButtonElement;
audienceProfileFieldButton: HTMLButtonElement;
batchSubmitButton: HTMLButtonElement; batchSubmitButton: HTMLButtonElement;
exportButton: HTMLButtonElement; exportButton: HTMLButtonElement;
exportCustomPagesInput: HTMLInputElement; exportCustomPagesInput: HTMLInputElement;
@ -89,6 +91,11 @@ export function ensurePluginToolbar(
audienceProfileByIdExportButton.dataset.pluginExportAudienceProfileById = "button"; audienceProfileByIdExportButton.dataset.pluginExportAudienceProfileById = "button";
audienceProfileByIdExportButton.textContent = "按ID导出画像CSV"; audienceProfileByIdExportButton.textContent = "按ID导出画像CSV";
const audienceProfileFieldButton = document.createElement("button");
audienceProfileFieldButton.type = "button";
audienceProfileFieldButton.dataset.pluginAudienceProfileFields = "button";
audienceProfileFieldButton.textContent = "画像字段";
const batchSubmitButton = document.createElement("button"); const batchSubmitButton = document.createElement("button");
batchSubmitButton.type = "button"; batchSubmitButton.type = "button";
batchSubmitButton.dataset.pluginBatchSubmit = "button"; batchSubmitButton.dataset.pluginBatchSubmit = "button";
@ -104,6 +111,7 @@ export function ensurePluginToolbar(
exportButton, exportButton,
audienceProfileExportButton, audienceProfileExportButton,
audienceProfileByIdExportButton, audienceProfileByIdExportButton,
audienceProfileFieldButton,
batchSubmitButton, batchSubmitButton,
exportStatusText exportStatusText
); );
@ -112,6 +120,7 @@ export function ensurePluginToolbar(
applyNativeControlStyles(document, { applyNativeControlStyles(document, {
audienceProfileExportButton, audienceProfileExportButton,
audienceProfileByIdExportButton, audienceProfileByIdExportButton,
audienceProfileFieldButton,
batchSubmitButton, batchSubmitButton,
exportButton, exportButton,
exportCustomPagesInput, exportCustomPagesInput,
@ -128,12 +137,16 @@ export function ensurePluginToolbar(
audienceProfileByIdExportButton.addEventListener("click", () => { audienceProfileByIdExportButton.addEventListener("click", () => {
void handlers.onExportAudienceProfileByIds(); void handlers.onExportAudienceProfileByIds();
}); });
audienceProfileFieldButton.addEventListener("click", () => {
void handlers.onConfigureAudienceProfileFields();
});
batchSubmitButton.addEventListener("click", () => { batchSubmitButton.addEventListener("click", () => {
void handlers.onSubmitBatch(); void handlers.onSubmitBatch();
}); });
exportRangeSelect.addEventListener("change", () => { exportRangeSelect.addEventListener("change", () => {
syncCustomPagesInputVisibility({ syncCustomPagesInputVisibility({
batchSubmitButton, batchSubmitButton,
audienceProfileFieldButton,
audienceProfileByIdExportButton, audienceProfileByIdExportButton,
audienceProfileExportButton, audienceProfileExportButton,
exportButton, exportButton,
@ -147,6 +160,7 @@ export function ensurePluginToolbar(
const toolbarDom = { const toolbarDom = {
audienceProfileExportButton, audienceProfileExportButton,
audienceProfileByIdExportButton, audienceProfileByIdExportButton,
audienceProfileFieldButton,
batchSubmitButton, batchSubmitButton,
exportButton, exportButton,
exportCustomPagesInput, exportCustomPagesInput,
@ -178,6 +192,9 @@ function readToolbarDom(root: HTMLElement): PluginToolbarDom {
audienceProfileExportButton: root.querySelector( audienceProfileExportButton: root.querySelector(
'[data-plugin-export-audience-profile="button"]' '[data-plugin-export-audience-profile="button"]'
) as HTMLButtonElement, ) as HTMLButtonElement,
audienceProfileFieldButton: root.querySelector(
'[data-plugin-audience-profile-fields="button"]'
) as HTMLButtonElement,
batchSubmitButton: root.querySelector( batchSubmitButton: root.querySelector(
'[data-plugin-batch-submit="button"]' '[data-plugin-batch-submit="button"]'
) as HTMLButtonElement, ) as HTMLButtonElement,
@ -260,6 +277,7 @@ export function setToolbarBusyState(
): void { ): void {
[ [
toolbar.batchSubmitButton, toolbar.batchSubmitButton,
toolbar.audienceProfileFieldButton,
toolbar.audienceProfileByIdExportButton, toolbar.audienceProfileByIdExportButton,
toolbar.audienceProfileExportButton, toolbar.audienceProfileExportButton,
toolbar.exportButton, toolbar.exportButton,
@ -460,6 +478,7 @@ function applyNativeControlStyles(
controls: { controls: {
audienceProfileExportButton: HTMLButtonElement; audienceProfileExportButton: HTMLButtonElement;
audienceProfileByIdExportButton: HTMLButtonElement; audienceProfileByIdExportButton: HTMLButtonElement;
audienceProfileFieldButton: HTMLButtonElement;
batchSubmitButton: HTMLButtonElement; batchSubmitButton: HTMLButtonElement;
exportButton: HTMLButtonElement; exportButton: HTMLButtonElement;
exportCustomPagesInput: HTMLInputElement; exportCustomPagesInput: HTMLInputElement;
@ -478,6 +497,7 @@ function applyNativeControlStyles(
controls.exportButton.className = nativeButton.className; controls.exportButton.className = nativeButton.className;
controls.audienceProfileExportButton.className = nativeButton.className; controls.audienceProfileExportButton.className = nativeButton.className;
controls.audienceProfileByIdExportButton.className = nativeButton.className; controls.audienceProfileByIdExportButton.className = nativeButton.className;
controls.audienceProfileFieldButton.className = nativeButton.className;
controls.batchSubmitButton.className = nativeButton.className; controls.batchSubmitButton.className = nativeButton.className;
} }
@ -485,6 +505,7 @@ function applyNativeControlStyles(
controls.exportButton, controls.exportButton,
controls.audienceProfileExportButton, controls.audienceProfileExportButton,
controls.audienceProfileByIdExportButton, controls.audienceProfileByIdExportButton,
controls.audienceProfileFieldButton,
controls.batchSubmitButton controls.batchSubmitButton
].forEach((button) => { ].forEach((button) => {
applyPrimaryButtonStyles(button); applyPrimaryButtonStyles(button);
@ -539,6 +560,7 @@ function ensurePluginActionButtonTheme(document: Document): void {
[data-plugin-export="button"]:hover:not(:disabled), [data-plugin-export="button"]:hover:not(:disabled),
[data-plugin-export-audience-profile="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-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) { [data-plugin-batch-submit="button"]:hover:not(:disabled) {
background-color: #6d1627 !important; background-color: #6d1627 !important;
border-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="button"]:active:not(:disabled),
[data-plugin-export-audience-profile="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-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) { [data-plugin-batch-submit="button"]:active:not(:disabled) {
background-color: #58111f !important; background-color: #58111f !important;
border-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="button"]:focus-visible,
[data-plugin-export-audience-profile="button"]:focus-visible, [data-plugin-export-audience-profile="button"]:focus-visible,
[data-plugin-export-audience-profile-by-id="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 { [data-plugin-batch-submit="button"]:focus-visible {
outline: none !important; outline: none !important;
box-shadow: 0 0 0 3px rgba(127, 29, 45, 0.2) !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="button"]:disabled,
[data-plugin-export-audience-profile="button"]:disabled, [data-plugin-export-audience-profile="button"]:disabled,
[data-plugin-export-audience-profile-by-id="button"]:disabled, [data-plugin-export-audience-profile-by-id="button"]:disabled,
[data-plugin-audience-profile-fields="button"]:disabled,
[data-plugin-batch-submit="button"]:disabled { [data-plugin-batch-submit="button"]:disabled {
background-color: #c89ca4 !important; background-color: #c89ca4 !important;
border-color: #c89ca4 !important; border-color: #c89ca4 !important;

View File

@ -1,6 +1,10 @@
import { describe, expect, test } from "vitest"; 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"; import type { AudienceProfileExportRow } from "../src/content/market/audience-profile-types";
describe("audience-profile-csv", () => { describe("audience-profile-csv", () => {
@ -193,6 +197,83 @@ describe("audience-profile-csv", () => {
expect(readCsvValue(csv, "铁粉画像-Z世代占比")).toBe("0%"); expect(readCsvValue(csv, "铁粉画像-Z世代占比")).toBe("0%");
expect(csv.split("\n")[0]).not.toContain("新一线城市占比"); 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 { function readCsvValue(csv: string, header: string): string {
@ -204,3 +285,47 @@ function readCsvValue(csv: string, header: string): string {
expect(index).toBeGreaterThanOrEqual(0); expect(index).toBeGreaterThanOrEqual(0);
return values[index] ?? ""; return values[index] ?? "";
} }
function buildSuccessRow(
overrides: Partial<AudienceProfileExportRow["record"]> = {}
): AudienceProfileExportRow {
return {
profiles: {
audience: {
age: [{ label: "31-40", value: "50%" }],
cityTier: [{ label: "一线城市", value: "100%" }],
crowd: [{ label: "都市蓝领", value: "100%" }],
gender: [{ label: "男性", value: "71.7%" }],
status: "success"
},
fans: { status: "success" },
longtimeFans: { status: "success" }
},
businessAbility: {
estimates: {
twentyToSixty: {
expectedCpe: "3.7",
expectedCpm: "212.0",
expectedPlay: "250w",
hotRate: "缺失"
}
},
status: "success",
videos: {
personalVideo: {
medianPlay: "3738.4w"
}
}
},
record: {
authorId: "123",
authorName: "达人 A",
exportFields: {
: "达人 A",
: "300w"
},
status: "success",
...overrides
}
};
}

View File

@ -13,6 +13,7 @@ describe("market-content-entry", () => {
document.documentElement.removeAttribute("data-sces-market-rows"); document.documentElement.removeAttribute("data-sces-market-rows");
document.documentElement.removeAttribute("data-sces-market-request-snapshot"); document.documentElement.removeAttribute("data-sces-market-request-snapshot");
document.documentElement.removeAttribute("data-test-page-index"); document.documentElement.removeAttribute("data-test-page-index");
window.localStorage.clear();
window.history.replaceState({}, "", "/"); window.history.replaceState({}, "", "/");
}); });
@ -1689,17 +1690,22 @@ describe("market-content-entry", () => {
{ authorType: 1, source: "fansDistribution" }, { authorType: 1, source: "fansDistribution" },
{ authorType: 5, source: "fansDistribution" } { authorType: 5, source: "fansDistribution" }
]); ]);
expect(buildAudienceProfileCsv).toHaveBeenCalledWith([ expect(buildAudienceProfileCsv).toHaveBeenCalledWith(
{ [
profiles: { {
audience: expect.objectContaining({ status: "success" }), profiles: {
fans: expect.objectContaining({ status: "success" }), audience: expect.objectContaining({ status: "success" }),
longtimeFans: expect.objectContaining({ status: "success" }) fans: expect.objectContaining({ status: "success" }),
}, longtimeFans: expect.objectContaining({ status: "success" })
businessAbility: expect.objectContaining({ status: "success" }), },
record: expect.objectContaining({ authorId: "222" }) businessAbility: expect.objectContaining({ status: "success" }),
} record: expect.objectContaining({ authorId: "222" })
]); }
],
expect.objectContaining({
selectedHeaders: expect.arrayContaining(["秒思api-看后搜数"])
})
);
expect(onCsvReady).toHaveBeenCalledWith( expect(onCsvReady).toHaveBeenCalledWith(
"profile-csv", "profile-csv",
expect.stringMatching(/^达人连接用户画像_\d{8}_\d{4}\.csv$/) expect.stringMatching(/^达人连接用户画像_\d{8}_\d{4}\.csv$/)
@ -1790,56 +1796,149 @@ describe("market-content-entry", () => {
"6866044569306267651", "6866044569306267651",
"7040323176106033165" "7040323176106033165"
]); ]);
expect(buildAudienceProfileCsv).toHaveBeenCalledWith([ expect(buildAudienceProfileCsv).toHaveBeenCalledWith(
expect.objectContaining({ [
record: expect.objectContaining({ expect.objectContaining({
authorId: "6866044569306267651", record: expect.objectContaining({
authorName: "小九儿", authorId: "6866044569306267651",
backendMetrics: expect.objectContaining({ authorName: "小九儿",
a3IncreaseCount: "100", backendMetrics: expect.objectContaining({
afterViewSearchCount: "300", a3IncreaseCount: "100",
afterViewSearchRate: "1.1%", afterViewSearchCount: "300",
cpSearch: "10", afterViewSearchRate: "1.1%",
cpa3: "30", cpSearch: "10",
newA3Rate: "3.3%" cpa3: "30",
}), newA3Rate: "3.3%"
exportFields: { }),
ID: "6866044569306267651", exportFields: {
: "小九儿", ID: "6866044569306267651",
: "成功", : "小九儿",
: "" : "成功",
}, : ""
rates: { },
personalVideoAfterSearchRate: "12.3%", rates: {
singleVideoAfterSearchRate: "7.8%" 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({ expect.objectContaining({
record: expect.objectContaining({ selectedHeaders: expect.arrayContaining(["秒思api-看后搜数"])
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(onCsvReady).toHaveBeenCalledWith( expect(onCsvReady).toHaveBeenCalledWith(
"profile-csv", "profile-csv",
expect.stringMatching(/^达人连接用户画像_按ID导出_\d{8}_\d{4}\.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( test(
"selected export keeps a generic loading status while exporting the default paged range", "selected export keeps a generic loading status while exporting the default paged range",
async () => { async () => {