feat: allow selecting audience export fields
This commit is contained in:
parent
db95a1f565
commit
50933af0a6
@ -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();
|
||||
|
||||
@ -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. 看不到插件按钮
|
||||
|
||||
|
||||
@ -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的画像数据时使用:
|
||||
|
||||
Binary file not shown.
@ -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}`,
|
||||
|
||||
295
src/content/market/audience-profile-field-dialog.ts
Normal file
295
src/content/market/audience-profile-field-dialog.ts
Normal 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";
|
||||
}
|
||||
@ -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(",");
|
||||
|
||||
@ -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<AuthStateValue>;
|
||||
@ -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<void> {
|
||||
await runSyncCycle();
|
||||
await harvestCurrentPageForExport();
|
||||
|
||||
@ -7,12 +7,14 @@ export interface PluginToolbarHandlers {
|
||||
onExport(): Promise<void> | void;
|
||||
onExportAudienceProfile(): Promise<void> | void;
|
||||
onExportAudienceProfileByIds(): Promise<void> | void;
|
||||
onConfigureAudienceProfileFields(): Promise<void> | void;
|
||||
onSubmitBatch(): Promise<void> | 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;
|
||||
|
||||
@ -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["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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user