feat: make audience export fields selective

This commit is contained in:
wxs
2026-07-27 15:37:35 +08:00
parent e062be6431
commit e15bc0657a
9 changed files with 858 additions and 1979 deletions
+6 -1
View File
@@ -26,6 +26,10 @@ export interface AudienceProfileFailure {
status: "failed";
}
export interface AudienceProfileSkipped {
status: "skipped";
}
export interface AudienceProfileSet {
audience: AudienceProfileResult;
fans: AudienceProfileResult;
@@ -34,7 +38,8 @@ export interface AudienceProfileSet {
export type AudienceProfileResult =
| AudienceProfileSuccess
| AudienceProfileFailure;
| AudienceProfileFailure
| AudienceProfileSkipped;
export interface AudienceProfileExportRow {
businessAbility?: BusinessAbilityResult;
+90 -2
View File
@@ -8,6 +8,10 @@ export type CsvColumn = {
readValue: (record: MarketRecord) => string;
};
export interface MarketCsvOptions {
selectedHeaders?: string[];
}
const FALLBACK_BASE_COLUMNS: CsvColumn[] = [
{
header: "达人ID",
@@ -80,6 +84,13 @@ const SPREAD_INFO_COLUMNS: CsvColumn[] = buildSpreadInfoColumns().map((header) =
readValue: (record: MarketRecord) => record.spreadMetrics?.[header] ?? ""
}));
const FIXED_MARKET_CSV_HEADERS = new Set([
"达人ID",
"达人名称",
"导出状态",
"失败原因"
]);
export function listRateCsvHeaders(): string[] {
return RATE_COLUMNS.map((column) => column.header);
}
@@ -88,8 +99,20 @@ export function listBackendMetricCsvHeaders(): string[] {
return BACKEND_METRIC_COLUMNS.map((column) => column.header);
}
export function buildMarketCsv(records: MarketRecord[]): string {
const csvColumns = buildMarketCsvColumns(records);
export function buildMarketCsv(
records: MarketRecord[],
options: MarketCsvOptions = {}
): string {
const optionalColumns = buildMarketCsvColumns(records);
const csvColumns = filterMarketCsvColumns(
options.selectedHeaders === undefined
? optionalColumns
: deduplicateCsvColumns([
...buildFixedMarketCsvColumns(),
...optionalColumns
]),
options.selectedHeaders
);
const headerLine = csvColumns.map((column) => column.header).join(",");
const rowLines = records.map((record) =>
csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",")
@@ -133,3 +156,68 @@ export function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
readValue: (record: MarketRecord) => record.exportFields?.[header] ?? ""
}));
}
function filterMarketCsvColumns(
columns: CsvColumn[],
selectedHeaders: string[] | undefined
): CsvColumn[] {
if (!selectedHeaders) {
return columns;
}
const selectedHeaderSet = new Set(selectedHeaders);
return columns.filter(
(column) =>
FIXED_MARKET_CSV_HEADERS.has(column.header) ||
selectedHeaderSet.has(column.header)
);
}
function buildFixedMarketCsvColumns(): CsvColumn[] {
return [
{
header: "达人ID",
readValue: (record) => record.exportFields?.ID ?? record.authorId
},
{
header: "达人名称",
readValue: (record) => record.exportFields?. ?? record.authorName
},
{
header: "导出状态",
readValue: (record) => record.exportFields?. ?? readExportStatus(record)
},
{
header: "失败原因",
readValue: (record) => record.exportFields?. ?? record.failureReason ?? ""
}
];
}
function readExportStatus(record: MarketRecord): string {
if (record.status === "failed") {
return "失败";
}
if (record.status === "loading") {
return "加载中";
}
if (record.status === "idle") {
return "待处理";
}
return "成功";
}
function deduplicateCsvColumns(columns: CsvColumn[]): CsvColumn[] {
const seenHeaders = new Set<string>();
return columns.filter((column) => {
if (seenHeaders.has(column.header)) {
return false;
}
seenHeaders.add(column.header);
return true;
});
}
+217 -138
View File
@@ -1,4 +1,8 @@
import { buildBaseColumns, buildMarketCsv } from "./csv-exporter";
import {
buildBaseColumns,
listBackendMetricCsvHeaders,
listRateCsvHeaders
} from "./csv-exporter";
import {
buildAudienceProfileCsv,
listAudienceProfileSelectableFieldGroups,
@@ -52,8 +56,11 @@ import { createSilentExportController } from "./silent-export-controller";
import {
buildSpreadInfoConfigKey,
createSpreadInfoClient,
DEFAULT_SPREAD_INFO_CONFIGS,
filterUnloadedSpreadInfoConfigs,
matchesSpreadMetricRule,
normalizeSpreadInfoConfig,
selectSpreadInfoConfigsForHeaders,
type MappedSpreadInfoResponse
} from "./spread-info";
import {
@@ -91,12 +98,21 @@ interface MutationObserverLike {
observe(target: Node, options?: MutationObserverInit): void;
}
interface AudienceProfileExportSelection {
audience: boolean;
businessAbility: boolean;
fans: boolean;
includeBackendMetrics: boolean;
includeRates: boolean;
longtimeFans: boolean;
spreadInfoConfigs: SpreadInfoConfig[];
}
export interface CreateMarketControllerOptions {
buildAudienceProfileCsv?: (
rows: AudienceProfileExportRow[],
options?: AudienceProfileCsvOptions
) => string;
buildCsv?: (records: MarketRecord[]) => string;
confirmFavoriteDelete?: (message: string) => boolean;
confirmFavoriteImport?: (message: string) => boolean;
document: Document;
@@ -115,7 +131,10 @@ export interface CreateMarketControllerOptions {
spreadAuthorId: string,
config: SpreadInfoConfig
) => Promise<MappedSpreadInfoResponse>;
loadSpreadMetrics?: (spreadAuthorId: string) => Promise<Record<string, string>>;
loadSpreadMetrics?: (
spreadAuthorId: string,
configs?: SpreadInfoConfig[]
) => Promise<Record<string, string>>;
searchBackendMetrics?: (starIds: string[]) => Promise<
Array<BackendMetrics & { starId: string }>
>;
@@ -158,7 +177,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
const searchBackendMetrics =
options.searchBackendMetrics ??
(hasRuntimeMessageSender() ? (starIds: string[]) => readBackendMetrics(sendRuntimeMessage, starIds) : null);
const buildCsv = options.buildCsv ?? buildMarketCsv;
const buildAudienceCsv = options.buildAudienceProfileCsv ?? buildAudienceProfileCsv;
const loadAudienceProfile =
options.loadAudienceProfile ?? audienceProfileClient.loadAudienceProfile;
@@ -319,41 +337,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
startObserving();
const toolbarHandlers = {
onExport: async () => {
syncSelectionStateFromDom();
const exportTarget = readToolbarExportTarget(toolbar);
if (!exportTarget.target) {
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
return;
}
const spreadFilter = readToolbarSpreadFilter(toolbar);
if (spreadFilter.error) {
setToolbarExportStatus(toolbar, spreadFilter.error);
return;
}
setToolbarBusyState(toolbar, true);
try {
const records = filterRecordsBySelection(
await applySpreadThresholdFilter(
await exportRecords(exportTarget.target, "导出中", {
includeSpreadMetrics: true,
showDetailedProgress: selectedAuthorIds.size === 0
}),
spreadFilter.filter
)
);
options.onCsvReady?.(buildCsv(records));
setToolbarExportStatus(toolbar, "");
} catch (error) {
setToolbarExportStatus(
toolbar,
error instanceof Error ? error.message : "导出失败,请稍后重试"
);
} finally {
setToolbarBusyState(toolbar, false);
}
},
onExportAudienceProfile: async () => {
syncSelectionStateFromDom();
if (selectedAuthorIds.size === 0) {
@@ -369,10 +352,16 @@ export function createMarketController(options: CreateMarketControllerOptions) {
setToolbarBusyState(toolbar, true);
try {
const selectedHeaders = readAudienceProfileSelectedHeaders();
const exportSelection = resolveAudienceProfileExportSelection(
selectedHeaders
);
const selectedRecords = filterRecordsBySelectionStrict(
await exportRecords(exportTarget.target, "画像导出中", {
includeSpreadMetrics: true,
showDetailedProgress: false
includeBackendMetrics: exportSelection.includeBackendMetrics,
includeRates: exportSelection.includeRates,
showDetailedProgress: false,
spreadInfoConfigs: exportSelection.spreadInfoConfigs
})
);
if (selectedRecords.length === 0) {
@@ -388,28 +377,21 @@ export function createMarketController(options: CreateMarketControllerOptions) {
`画像导出中 ${index + 1}/${selectedRecords.length}...`
);
const [profiles, businessAbility] = await Promise.all([
loadAudienceProfileSet(record),
loadBusinessAbilitySafe(record)
loadAudienceProfileSet(record, exportSelection),
exportSelection.businessAbility
? loadBusinessAbilitySafe(record)
: Promise.resolve(undefined)
]);
rows.push({
rows.push(finalizeAudienceProfileExportRow({
businessAbility,
profiles,
record
});
}
if (
rows.every((row) =>
Object.values(row.profiles).every((profile) => profile.status === "failed")
)
) {
setToolbarExportStatus(toolbar, "画像导出失败,请稍后重试");
return;
}));
}
options.onCsvReady?.(
buildAudienceCsv(rows, {
selectedHeaders: readAudienceProfileSelectedHeaders()
selectedHeaders
}),
buildAudienceProfileFilename()
);
@@ -442,7 +424,13 @@ export function createMarketController(options: CreateMarketControllerOptions) {
`识别 ${parsed.ids.length + parsed.duplicates.length + parsed.invalidTokens.length} 个,去重后 ${parsed.ids.length} 个,非法 ${parsed.invalidTokens.length}`
);
const backendMetricsByAuthorId = await loadBackendMetricsMap(parsed.ids);
const selectedHeaders = readAudienceProfileSelectedHeaders();
const exportSelection = resolveAudienceProfileExportSelection(
selectedHeaders
);
const backendMetricsByAuthorId = exportSelection.includeBackendMetrics
? await loadBackendMetricsMap(parsed.ids)
: new Map<string, BackendMetrics>();
const rows: AudienceProfileExportRow[] = [];
for (let index = 0; index < parsed.ids.length; index += 1) {
const authorId = parsed.ids[index];
@@ -453,14 +441,15 @@ export function createMarketController(options: CreateMarketControllerOptions) {
rows.push(
await loadAudienceProfileRowById(
authorId,
backendMetricsByAuthorId.get(authorId)
backendMetricsByAuthorId.get(authorId),
exportSelection
)
);
}
options.onCsvReady?.(
buildAudienceCsv(rows, {
selectedHeaders: readAudienceProfileSelectedHeaders()
selectedHeaders
}),
buildAudienceProfileFilename(new Date(), "按ID导出")
);
@@ -1050,8 +1039,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
target: MarketExportTarget,
inProgressLabel = "导出中",
progressOptions: {
includeSpreadMetrics?: boolean;
includeBackendMetrics?: boolean;
includeRates?: boolean;
showDetailedProgress?: boolean;
spreadInfoConfigs?: SpreadInfoConfig[];
} = {}
): Promise<MarketRecord[]> {
activeProgressLabel = inProgressLabel;
@@ -1061,7 +1052,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
if (target.mode === "count" && target.pageCount <= 1) {
await prepareCurrentPageForExport();
return hydrateExportRecords(getVisibleOrderedRecords(), {
includeSpreadMetrics: progressOptions.includeSpreadMetrics ?? false
includeBackendMetrics: progressOptions.includeBackendMetrics,
includeRates: progressOptions.includeRates,
spreadInfoConfigs: progressOptions.spreadInfoConfigs
});
}
@@ -1073,7 +1066,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
status: record.status ?? "idle"
})),
{
includeSpreadMetrics: progressOptions.includeSpreadMetrics ?? false
includeBackendMetrics: progressOptions.includeBackendMetrics,
includeRates: progressOptions.includeRates,
spreadInfoConfigs: progressOptions.spreadInfoConfigs
}
);
}
@@ -1137,14 +1132,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
const snapshots = new Map<string, MappedSpreadInfoResponse>();
await Promise.all(
Array.from(configsByKey.entries()).map(async ([key, config]) => {
try {
snapshots.set(
key,
await loadSpreadFilterMetrics(spreadAuthorId, config)
);
} catch {
snapshots.set(key, {});
}
snapshots.set(
key,
await loadSpreadFilterMetrics(spreadAuthorId, config)
);
})
);
@@ -1178,11 +1169,17 @@ export function createMarketController(options: CreateMarketControllerOptions) {
}
async function loadAudienceProfileSet(
record: MarketRecord
record: MarketRecord,
exportSelection: AudienceProfileExportSelection
): Promise<AudienceProfileExportRow["profiles"]> {
const profiles = {} as AudienceProfileExportRow["profiles"];
for (const { kind, target } of audienceProfileTargets) {
if (!exportSelection[kind]) {
profiles[kind] = { status: "skipped" };
continue;
}
try {
profiles[kind] = await loadAudienceProfile(record, target);
} catch (error) {
@@ -1213,54 +1210,45 @@ export function createMarketController(options: CreateMarketControllerOptions) {
async function loadAudienceProfileRowById(
authorId: string,
backendMetrics?: BackendMetrics
backendMetrics: BackendMetrics | undefined,
exportSelection: AudienceProfileExportSelection
): Promise<AudienceProfileExportRow> {
const [baseRecord, metricsResult] = await Promise.all([
loadAuthorBaseInfoSafe(authorId),
loadAuthorMetricsSafe(authorId)
exportSelection.includeRates
? loadAuthorMetricsSafe(authorId)
: Promise.resolve(undefined)
]);
const spreadMetrics = baseRecord.spreadAuthorId
? await loadSpreadMetrics(baseRecord.spreadAuthorId)
: {};
const spreadMetrics =
exportSelection.spreadInfoConfigs.length > 0 && baseRecord.spreadAuthorId
? await loadSpreadMetrics(
baseRecord.spreadAuthorId,
exportSelection.spreadInfoConfigs
)
: {};
const recordForRequests = {
...baseRecord,
authorName: baseRecord.authorName || authorId,
...(metricsResult.success ? { rates: metricsResult.rates } : {}),
...(metricsResult?.success ? { rates: metricsResult.rates } : {}),
...(backendMetrics
? { backendMetrics, backendMetricsStatus: "success" as const }
: {}),
...(Object.keys(spreadMetrics).length > 0 ? { spreadMetrics } : {})
};
const [profiles, businessAbility] = await Promise.all([
loadAudienceProfileSet(recordForRequests),
loadBusinessAbilitySafe(recordForRequests)
loadAudienceProfileSet(recordForRequests, exportSelection),
exportSelection.businessAbility
? loadBusinessAbilitySafe(recordForRequests)
: Promise.resolve(undefined)
]);
const failureReasons = collectAudienceProfileRowFailures(
baseRecord,
profiles,
businessAbility
return finalizeAudienceProfileExportRow(
{
businessAbility,
profiles,
record: recordForRequests
},
{ includeBaseRecordFailure: true }
);
const rowStatus =
failureReasons.length === 0
? "成功"
: hasAudienceProfileRowSuccess(baseRecord, profiles, businessAbility)
? "部分成功"
: "失败";
const authorName = baseRecord.authorName || "";
return {
businessAbility,
profiles,
record: {
...recordForRequests,
exportFields: {
达人ID: authorId,
达人名称: authorName,
导出状态: rowStatus,
失败原因: failureReasons.join("; ")
}
}
};
}
async function loadAuthorBaseInfoSafe(authorId: string): Promise<MarketRecord> {
@@ -1314,10 +1302,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
function collectAudienceProfileRowFailures(
baseRecord: MarketRecord,
profiles: AudienceProfileExportRow["profiles"],
businessAbility: BusinessAbilityResult
businessAbility: BusinessAbilityResult | undefined,
includeBaseRecordFailure: boolean
): string[] {
const failures: string[] = [];
if (baseRecord.status === "failed") {
if (includeBaseRecordFailure && baseRecord.status === "failed") {
failures.push(`基础信息:${baseRecord.failureReason ?? "request-failed"}`);
}
@@ -1327,8 +1316,8 @@ export function createMarketController(options: CreateMarketControllerOptions) {
}
});
if (businessAbility.status === "failed") {
failures.push(`商业能力:${businessAbility.failureReason ?? "request-failed"}`);
if (businessAbility?.status === "failed") {
failures.push(`效果预估:${businessAbility.failureReason ?? "request-failed"}`);
}
return failures;
@@ -1337,15 +1326,55 @@ export function createMarketController(options: CreateMarketControllerOptions) {
function hasAudienceProfileRowSuccess(
baseRecord: MarketRecord,
profiles: AudienceProfileExportRow["profiles"],
businessAbility: BusinessAbilityResult
businessAbility: BusinessAbilityResult | undefined,
includeBaseRecordFailure: boolean
): boolean {
return (
!includeBaseRecordFailure ||
baseRecord.status === "success" ||
businessAbility.status === "success" ||
businessAbility?.status === "success" ||
Object.values(profiles).some((profile) => profile.status === "success")
);
}
function finalizeAudienceProfileExportRow(
row: AudienceProfileExportRow,
options: { includeBaseRecordFailure?: boolean } = {}
): AudienceProfileExportRow {
const includeBaseRecordFailure = options.includeBaseRecordFailure ?? false;
const failureReasons = collectAudienceProfileRowFailures(
row.record,
row.profiles,
row.businessAbility,
includeBaseRecordFailure
);
const rowStatus =
failureReasons.length === 0
? "成功"
: hasAudienceProfileRowSuccess(
row.record,
row.profiles,
row.businessAbility,
includeBaseRecordFailure
)
? "部分成功"
: "失败";
return {
...row,
record: {
...row.record,
exportFields: {
...row.record.exportFields,
达人ID: row.record.authorId,
达人名称: row.record.authorName,
导出状态: rowStatus,
失败原因: failureReasons.join("; ")
}
}
};
}
function readAudienceProfileKindLabel(kind: AudienceProfileKind): string {
if (kind === "audience") {
return "观众画像";
@@ -1358,6 +1387,38 @@ export function createMarketController(options: CreateMarketControllerOptions) {
return "铁粉画像";
}
function resolveAudienceProfileExportSelection(
selectedHeaders: string[] | undefined
): AudienceProfileExportSelection {
if (selectedHeaders === undefined) {
return {
audience: true,
businessAbility: true,
fans: true,
includeBackendMetrics: true,
includeRates: true,
longtimeFans: true,
spreadInfoConfigs: DEFAULT_SPREAD_INFO_CONFIGS
};
}
const selectedHeaderSet = new Set(selectedHeaders);
const includesAny = (headers: string[]) =>
headers.some((header) => selectedHeaderSet.has(header));
const includesPrefix = (prefix: string) =>
selectedHeaders.some((header) => header.startsWith(prefix));
return {
audience: includesPrefix("观众画像-"),
businessAbility: includesPrefix("效果预估-"),
fans: includesPrefix("粉丝画像-"),
includeBackendMetrics: includesAny(listBackendMetricCsvHeaders()),
includeRates: includesAny(listRateCsvHeaders()),
longtimeFans: includesPrefix("铁粉画像-"),
spreadInfoConfigs: selectSpreadInfoConfigsForHeaders(selectedHeaders)
};
}
function readAudienceProfileSelectableHeaders(marketListHeaders: string[] = []): string[] {
return listAudienceProfileSelectableFieldGroups(marketListHeaders).flatMap(
(group) => group.headers
@@ -1408,39 +1469,44 @@ export function createMarketController(options: CreateMarketControllerOptions) {
async function hydrateExportRecords(
records: MarketRecord[],
options: {
includeSpreadMetrics?: boolean;
includeBackendMetrics?: boolean;
includeRates?: boolean;
spreadInfoConfigs?: SpreadInfoConfig[];
} = {}
): Promise<MarketRecord[]> {
for (const record of records) {
resultStore.upsertMarketRow(record);
const existingRecord = resultStore.getRecord(record.authorId);
if (existingRecord?.status === "success" && existingRecord.rates) {
continue;
}
records.forEach((record) => resultStore.upsertMarketRow(record));
if (record.hasDirectRatesSource) {
const directRates = record.rates ?? {};
const hasAllRates =
Boolean(directRates.singleVideoAfterSearchRate) &&
Boolean(directRates.personalVideoAfterSearchRate);
resultStore.setAuthorSuccess(record.authorId, directRates);
if (hasAllRates) {
if (options.includeRates ?? true) {
for (const record of records) {
const existingRecord = resultStore.getRecord(record.authorId);
if (existingRecord?.status === "success" && existingRecord.rates) {
continue;
}
} else {
resultStore.setAuthorLoading(record.authorId);
}
const metricsResult = await loadAuthorMetrics(record.authorId);
if (metricsResult.success) {
resultStore.setAuthorSuccess(record.authorId, metricsResult.rates);
} else {
resultStore.setAuthorFailed(record.authorId, metricsResult.reason);
if (record.hasDirectRatesSource) {
const directRates = record.rates ?? {};
const hasAllRates =
Boolean(directRates.singleVideoAfterSearchRate) &&
Boolean(directRates.personalVideoAfterSearchRate);
resultStore.setAuthorSuccess(record.authorId, directRates);
if (hasAllRates) {
continue;
}
} else {
resultStore.setAuthorLoading(record.authorId);
}
const metricsResult = await loadAuthorMetrics(record.authorId);
if (metricsResult.success) {
resultStore.setAuthorSuccess(record.authorId, metricsResult.rates);
} else {
resultStore.setAuthorFailed(record.authorId, metricsResult.reason);
}
}
}
if (searchBackendMetrics) {
if ((options.includeBackendMetrics ?? true) && searchBackendMetrics) {
const backendTargetRecords = records.filter((record) => {
const existingRecord = resultStore.getRecord(record.authorId);
return !(
@@ -1476,25 +1542,38 @@ export function createMarketController(options: CreateMarketControllerOptions) {
}
}
if (options.includeSpreadMetrics) {
await hydrateSpreadMetricsForRecords(records);
if (options.spreadInfoConfigs?.length) {
await hydrateSpreadMetricsForRecords(records, options.spreadInfoConfigs);
}
return records.map((record) => toMarketRecord(record));
}
async function hydrateSpreadMetricsForRecords(
records: MarketRecord[]
records: MarketRecord[],
selectedConfigs: SpreadInfoConfig[]
): Promise<void> {
await Promise.all(
records.map(async (record) => {
const storedRecord = resultStore.getRecord(record.authorId) ?? record;
const spreadAuthorId = storedRecord.spreadAuthorId ?? record.spreadAuthorId;
if (!spreadAuthorId || storedRecord.spreadMetrics) {
if (!spreadAuthorId) {
return;
}
const spreadMetrics = await loadSpreadMetrics(spreadAuthorId);
const existingMetrics = storedRecord.spreadMetrics ?? {};
const missingConfigs = filterUnloadedSpreadInfoConfigs(
selectedConfigs,
existingMetrics
);
if (missingConfigs.length === 0) {
return;
}
const spreadMetrics = await loadSpreadMetrics(
spreadAuthorId,
missingConfigs
);
if (Object.keys(spreadMetrics).length > 0) {
resultStore.setSpreadMetricsSuccess(record.authorId, spreadMetrics);
}
+7 -34
View File
@@ -8,7 +8,6 @@ import type {
} from "./types";
export interface PluginToolbarHandlers {
onExport(): Promise<void> | void;
onExportAudienceProfile(): Promise<void> | void;
onExportAudienceProfileByIds(): Promise<void> | void;
onConfigureAudienceProfileFields(): Promise<void> | void;
@@ -65,7 +64,6 @@ export interface PluginToolbarDom {
audienceProfileExportButton: HTMLButtonElement;
audienceProfileFieldButton: HTMLButtonElement;
batchSubmitButton: HTMLButtonElement;
exportButton: HTMLButtonElement;
exportCustomPagesInput: HTMLInputElement;
exportRangeSelect: HTMLSelectElement;
exportStatusText: HTMLElement;
@@ -110,7 +108,7 @@ export function ensurePluginToolbar(
'[data-plugin-export-audience-profile-by-id="button"]'
) &&
existingRoot.querySelector(
'[data-plugin-toolbar-action-group="csv-export"]'
'[data-plugin-toolbar-action-group="selected-audience-export"]'
) &&
existingRoot.querySelector('[data-plugin-spread-metric-catalog-trigger="button"]')
) {
@@ -145,12 +143,6 @@ export function ensurePluginToolbar(
exportCustomPagesInput.placeholder = "页数";
exportCustomPagesInput.dataset.pluginExportCustomPages = "input";
const exportButton = document.createElement("button");
exportButton.type = "button";
exportButton.dataset.pluginExport = "button";
exportButton.textContent = "导出CSV";
exportButton.title = "按所选页数范围导出当前筛选结果";
const audienceProfileExportButton = document.createElement("button");
audienceProfileExportButton.type = "button";
audienceProfileExportButton.dataset.pluginExportAudienceProfile = "button";
@@ -204,15 +196,15 @@ export function ensurePluginToolbar(
const dataGroup = document.createElement("div");
dataGroup.dataset.pluginToolbarGroup = "data";
applyToolbarGroupStyles(dataGroup);
const csvExportGroup = createToolbarActionGroup(document, "csv-export", [
exportButton,
exportRangeSelect,
exportCustomPagesInput
]);
const selectedAudienceExportGroup = createToolbarActionGroup(
document,
"selected-audience-export",
[audienceProfileExportButton, audienceProfileFieldButton]
[
audienceProfileExportButton,
audienceProfileFieldButton,
exportRangeSelect,
exportCustomPagesInput
]
);
const idExportGroup = createToolbarActionGroup(document, "id-export", [
audienceProfileByIdExportButton
@@ -221,8 +213,6 @@ export function ensurePluginToolbar(
batchSubmitButton
]);
dataGroup.append(
csvExportGroup,
createToolbarActionDivider(document),
selectedAudienceExportGroup,
createToolbarActionDivider(document),
idExportGroup,
@@ -253,16 +243,12 @@ export function ensurePluginToolbar(
audienceProfileByIdExportButton,
audienceProfileFieldButton,
batchSubmitButton,
exportButton,
exportCustomPagesInput,
exportRangeSelect,
spreadMetricRules
});
ensureToolbarMounted(root, document);
exportButton.addEventListener("click", () => {
void handlers.onExport();
});
audienceProfileExportButton.addEventListener("click", () => {
void handlers.onExportAudienceProfile();
});
@@ -281,7 +267,6 @@ export function ensurePluginToolbar(
audienceProfileByIdExportButton,
audienceProfileFieldButton,
batchSubmitButton,
exportButton,
exportCustomPagesInput,
exportRangeSelect,
exportStatusText,
@@ -352,7 +337,6 @@ function createToolbarActionGroup(
document: Document,
groupName:
| "batch-submit"
| "csv-export"
| "id-export"
| "selected-audience-export",
controls: HTMLElement[]
@@ -660,9 +644,6 @@ function readToolbarDom(root: HTMLElement): PluginToolbarDom {
batchSubmitButton: root.querySelector(
'[data-plugin-batch-submit="button"]'
) as HTMLButtonElement,
exportButton: root.querySelector(
'[data-plugin-export="button"]'
) as HTMLButtonElement,
exportCustomPagesInput: root.querySelector(
'[data-plugin-export-custom-pages="input"]'
) as HTMLInputElement,
@@ -780,7 +761,6 @@ export function setToolbarBusyState(
toolbar.audienceProfileFieldButton,
toolbar.audienceProfileByIdExportButton,
toolbar.audienceProfileExportButton,
toolbar.exportButton,
toolbar.exportRangeSelect,
toolbar.exportCustomPagesInput,
toolbar.metricCatalog.addButton,
@@ -1362,7 +1342,6 @@ function applyNativeControlStyles(
audienceProfileByIdExportButton: HTMLButtonElement;
audienceProfileFieldButton: HTMLButtonElement;
batchSubmitButton: HTMLButtonElement;
exportButton: HTMLButtonElement;
exportCustomPagesInput: HTMLInputElement;
exportRangeSelect: HTMLSelectElement;
spreadMetricRules: SpreadMetricRuleDomMap;
@@ -1377,7 +1356,6 @@ function applyNativeControlStyles(
findNativeActionButton(document, "导出");
if (nativeButton) {
controls.exportButton.className = nativeButton.className;
controls.audienceProfileExportButton.className = nativeButton.className;
controls.audienceProfileByIdExportButton.className = nativeButton.className;
controls.audienceProfileFieldButton.className = nativeButton.className;
@@ -1385,7 +1363,6 @@ function applyNativeControlStyles(
}
const secondaryButtons = [
controls.exportButton,
controls.audienceProfileExportButton,
controls.audienceProfileByIdExportButton,
controls.audienceProfileFieldButton
@@ -1515,7 +1492,6 @@ function ensurePluginActionButtonTheme(document: Document): void {
const style = document.createElement("style");
style.id = PLUGIN_ACTION_BUTTON_STYLE_ID;
style.textContent = `
[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) {
@@ -1528,7 +1504,6 @@ function ensurePluginActionButtonTheme(document: Document): void {
border-color: #6d1627 !important;
}
[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) {
@@ -1543,7 +1518,6 @@ function ensurePluginActionButtonTheme(document: Document): void {
transform: translateY(1px);
}
[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 {
@@ -1556,7 +1530,6 @@ function ensurePluginActionButtonTheme(document: Document): void {
box-shadow: 0 0 0 3px rgba(127, 29, 45, 0.2) !important;
}
[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 {
+65 -6
View File
@@ -7,6 +7,8 @@ import type {
interface FetchResponseLike {
json(): Promise<unknown>;
ok: boolean;
status?: number;
statusText?: string;
}
type FetchLike = (
@@ -90,10 +92,13 @@ export function createSpreadInfoClient(options: SpreadInfoClientOptions = {}) {
const timeoutMs = options.timeoutMs ?? 8000;
return {
async loadAuthorSpreadMetrics(authorId: string): Promise<SpreadInfoMetrics> {
async loadAuthorSpreadMetrics(
authorId: string,
selectedConfigs: SpreadInfoConfig[] = configs
): Promise<SpreadInfoMetrics> {
const metrics: SpreadInfoMetrics = {};
for (const config of configs) {
for (const config of selectedConfigs) {
const mappedResponse = await loadSpreadInfoFromUrl(
buildSpreadInfoUrl(authorId, config, baseUrl)
);
@@ -127,12 +132,22 @@ export function createSpreadInfoClient(options: SpreadInfoClientOptions = {}) {
signal: controller.signal
});
if (!response.ok) {
return {};
throw new Error(
`星图传播数据接口请求失败: HTTP ${response.status ?? "unknown"}${
response.statusText ? `: ${response.statusText}` : ""
}`
);
}
return mapSpreadInfoResponse(await response.json());
} catch {
return {};
const payload = await response.json();
const rateLimitMessage = readRateLimitMessage(payload);
if (rateLimitMessage) {
throw new Error(
`星图传播数据接口被限流 (status_code=31157): ${rateLimitMessage}`
);
}
return mapSpreadInfoResponse(payload);
} finally {
clearTimeout(timeoutId);
}
@@ -163,6 +178,30 @@ export function buildSpreadInfoColumns(
);
}
export function selectSpreadInfoConfigsForHeaders(
selectedHeaders: readonly string[],
configs: SpreadInfoConfig[] = DEFAULT_SPREAD_INFO_CONFIGS
): SpreadInfoConfig[] {
const selectedHeaderSet = new Set(selectedHeaders);
return configs.filter((config) =>
buildSpreadInfoColumns([config]).some((header) =>
selectedHeaderSet.has(header)
)
);
}
export function filterUnloadedSpreadInfoConfigs(
configs: SpreadInfoConfig[],
existingMetrics: SpreadInfoMetrics
): SpreadInfoConfig[] {
return configs.filter((config) =>
buildSpreadInfoColumns([config]).every(
(header) => !(header in existingMetrics)
)
);
}
export function buildSpreadInfoMetricMap(
config: SpreadInfoConfig,
metrics: MappedSpreadInfoResponse
@@ -278,6 +317,26 @@ function getPayloadData(payload: unknown): Record<string, unknown> | null {
return isRecord(payload.data) ? payload.data : payload;
}
function readRateLimitMessage(payload: unknown): string | null {
const payloadRecord = isRecord(payload) ? payload : null;
const dataRecord = payloadRecord && isRecord(payloadRecord.data)
? payloadRecord.data
: null;
const baseResponse =
(payloadRecord && isRecord(payloadRecord.base_resp) && payloadRecord.base_resp) ||
(dataRecord && isRecord(dataRecord.base_resp) && dataRecord.base_resp);
if (!baseResponse || readNumberLike(baseResponse.status_code) !== 31157) {
return null;
}
return (
readStringLike(baseResponse.status_message) ??
readStringLike(baseResponse.message) ??
"您访问过于频繁,请稍后重试"
);
}
function readNestedValue(value: unknown, key: string): unknown {
return isRecord(value) ? value[key] : undefined;
}