2074 lines
59 KiB
TypeScript
2074 lines
59 KiB
TypeScript
import {
|
|
normalizeFractionRateDisplay,
|
|
normalizeRateDisplay
|
|
} from "../../shared/rate-normalizer";
|
|
import { mapMarketListRow } from "./market-list-row";
|
|
import type {
|
|
AfterSearchRates,
|
|
BackendMetrics,
|
|
MarketRecord,
|
|
MarketSortField,
|
|
MarketSortState
|
|
} from "./types";
|
|
|
|
const BACKEND_COLUMN_KEY = "backendMetrics";
|
|
const SELECTION_COLUMN_KEY = "selection";
|
|
const SINGLE_COLUMN_KEY = "singleVideoAfterSearchRate";
|
|
const PERSONAL_COLUMN_KEY = "personalVideoAfterSearchRate";
|
|
const ACTION_HEADER_TEXT = "操作";
|
|
const AUTHOR_HEADER_TEXT = "达人信息";
|
|
const BACKEND_HEADER_TEXT = "秒探指标";
|
|
const MARKET_SCROLL_HINT_TEXT = "横向滚动可查看看后搜率、秒探指标";
|
|
const MARKET_SCROLLBAR_STYLE_ID = "sces-market-scrollbar-style";
|
|
const UNAVAILABLE_RATE_TEXT = "暂无来源";
|
|
const UNAVAILABLE_BACKEND_METRICS_TEXT = "暂无数据";
|
|
const SERIALIZED_MARKET_ROWS_ATTRIBUTE = "data-sces-market-rows";
|
|
const SORTABLE_RATE_FIELDS = [SINGLE_COLUMN_KEY, PERSONAL_COLUMN_KEY] as const;
|
|
|
|
const BACKEND_METRIC_COLUMNS = [
|
|
{
|
|
field: "afterViewSearchRate",
|
|
label: "看后搜率"
|
|
},
|
|
{
|
|
field: "afterViewSearchCount",
|
|
label: "看后搜数"
|
|
},
|
|
{
|
|
field: "a3IncreaseCount",
|
|
label: "新增A3数"
|
|
},
|
|
{
|
|
field: "newA3Rate",
|
|
label: "新增A3率"
|
|
},
|
|
{
|
|
field: "cpa3",
|
|
label: "CPA3"
|
|
},
|
|
{
|
|
field: "cpSearch",
|
|
label: "cp_search"
|
|
}
|
|
] as const satisfies Array<{
|
|
field: keyof BackendMetrics;
|
|
label: string;
|
|
}>;
|
|
|
|
type BackendMetricField = (typeof BACKEND_METRIC_COLUMNS)[number]["field"];
|
|
const SORTABLE_MARKET_FIELDS = [
|
|
...SORTABLE_RATE_FIELDS,
|
|
...BACKEND_METRIC_COLUMNS.map((column) => column.field)
|
|
] as const satisfies readonly MarketSortField[];
|
|
|
|
type RowOrderTarget = {
|
|
container: HTMLElement;
|
|
mode: "css" | "dom";
|
|
node: HTMLElement;
|
|
};
|
|
|
|
type MarketDataRow = {
|
|
authorId: string;
|
|
authorName: string;
|
|
exportFields?: Record<string, string>;
|
|
hasDirectRatesSource: boolean;
|
|
location?: string;
|
|
price21To60s?: string;
|
|
rates?: AfterSearchRates;
|
|
};
|
|
|
|
export interface MarketRowDom {
|
|
authorId: string;
|
|
authorName: string;
|
|
backendMetricsCells: Record<BackendMetricField, HTMLElement>;
|
|
exportFields?: Record<string, string>;
|
|
hasDirectRatesSource?: boolean;
|
|
location?: string;
|
|
personalCell: HTMLElement;
|
|
price21To60s?: string;
|
|
rates?: AfterSearchRates;
|
|
row: HTMLElement;
|
|
selectionCheckbox: HTMLInputElement;
|
|
singleCell: HTMLElement;
|
|
visibilityTargets: HTMLElement[];
|
|
orderTargets: RowOrderTarget[];
|
|
}
|
|
|
|
export interface MarketTableDom {
|
|
headerSelectionCheckbox: HTMLInputElement | null;
|
|
rows: MarketRowDom[];
|
|
}
|
|
|
|
export function syncMarketTable(root: ParentNode): MarketTableDom | null {
|
|
return syncSyntheticMarketTable(root) ?? syncDivGridMarketTable(root);
|
|
}
|
|
|
|
export function readMarketPageSignature(root: ParentNode): string {
|
|
const document = getOwnerDocument(root);
|
|
const explicitPageIndex =
|
|
document?.documentElement.getAttribute("data-test-page-index") ?? "";
|
|
const activePageIndex =
|
|
document
|
|
?.querySelector(".el-pagination .number.active, .xt-pagination .number.active")
|
|
?.textContent?.trim() ?? "";
|
|
const authorIds = readRawAuthorIds(root).join("|");
|
|
|
|
return `${explicitPageIndex || activePageIndex}::${authorIds}`;
|
|
}
|
|
|
|
export function findNextPageControl(root: ParentNode): HTMLElement | null {
|
|
const document = getOwnerDocument(root);
|
|
if (!document) {
|
|
return null;
|
|
}
|
|
|
|
const explicitControl = document.querySelector('[data-testid="next-page"]');
|
|
if (explicitControl instanceof document.defaultView!.HTMLElement) {
|
|
return explicitControl;
|
|
}
|
|
|
|
const paginationNextControl = document.querySelector(
|
|
".el-pagination .btn-next, .xt-pagination .btn-next"
|
|
);
|
|
if (paginationNextControl instanceof document.defaultView!.HTMLElement) {
|
|
return paginationNextControl;
|
|
}
|
|
|
|
const candidates = Array.from(
|
|
document.querySelectorAll("button, a, [role='button']")
|
|
).filter(
|
|
(element): element is HTMLElement =>
|
|
element instanceof document.defaultView!.HTMLElement
|
|
);
|
|
|
|
return (
|
|
candidates.find((element) =>
|
|
/下一页|next/i.test(normalizeExportCellText(element.textContent))
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
export function isPageControlDisabled(control: HTMLElement | null): boolean {
|
|
if (!control) {
|
|
return true;
|
|
}
|
|
|
|
if (control instanceof HTMLButtonElement) {
|
|
return control.disabled;
|
|
}
|
|
|
|
return control.getAttribute("aria-disabled") === "true";
|
|
}
|
|
|
|
export function renderMarketRowState(
|
|
rowDom: MarketRowDom,
|
|
record: MarketRecord
|
|
): void {
|
|
renderBackendMetricsCells(rowDom.backendMetricsCells, record);
|
|
|
|
if (record.status === "success" && record.rates) {
|
|
rowDom.singleCell.textContent = readRateCellText(
|
|
record.rates.singleVideoAfterSearchRate
|
|
);
|
|
rowDom.personalCell.textContent = readRateCellText(
|
|
record.rates.personalVideoAfterSearchRate
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (record.status === "loading") {
|
|
rowDom.singleCell.textContent = "加载中...";
|
|
rowDom.personalCell.textContent = "加载中...";
|
|
return;
|
|
}
|
|
|
|
if (record.status === "failed") {
|
|
rowDom.singleCell.textContent = "加载失败";
|
|
rowDom.personalCell.textContent = "加载失败";
|
|
return;
|
|
}
|
|
|
|
rowDom.singleCell.textContent = "";
|
|
rowDom.personalCell.textContent = "";
|
|
}
|
|
|
|
export function applyRowVisibility(
|
|
table: MarketTableDom,
|
|
visibleAuthorIds: Set<string>
|
|
): void {
|
|
table.rows.forEach((rowDom) => {
|
|
const isVisible = visibleAuthorIds.has(rowDom.authorId);
|
|
rowDom.visibilityTargets.forEach((target) => {
|
|
target.hidden = !isVisible;
|
|
});
|
|
});
|
|
}
|
|
|
|
export function applyRowOrder(
|
|
table: MarketTableDom,
|
|
orderedAuthorIds: string[]
|
|
): void {
|
|
const rowById = new Map(table.rows.map((rowDom) => [rowDom.authorId, rowDom]));
|
|
const orderByAuthorId = new Map(
|
|
orderedAuthorIds.map((authorId, index) => [authorId, index])
|
|
);
|
|
|
|
orderedAuthorIds.forEach((authorId) => {
|
|
const rowDom = rowById.get(authorId);
|
|
if (!rowDom) {
|
|
return;
|
|
}
|
|
|
|
rowDom.orderTargets.forEach(({ container, mode, node }) => {
|
|
const visualOrder = orderByAuthorId.get(authorId) ?? orderedAuthorIds.length;
|
|
if (mode === "css") {
|
|
container.dataset.marketOrderMode = "css";
|
|
container.style.display = "flex";
|
|
container.style.flexDirection = "column";
|
|
node.style.order = String(visualOrder);
|
|
return;
|
|
}
|
|
|
|
container.dataset.marketOrderMode = "dom";
|
|
container.appendChild(node);
|
|
});
|
|
});
|
|
}
|
|
|
|
export function syncPluginSortHeaders(
|
|
root: ParentNode,
|
|
options: {
|
|
activeSort?: MarketSortState;
|
|
onToggleSort: (field: MarketSortField) => void;
|
|
}
|
|
): void {
|
|
SORTABLE_MARKET_FIELDS.forEach((field) => {
|
|
const cell = root.querySelector(
|
|
`[data-market-header-cell="${field}"]`
|
|
) as HTMLElement | null;
|
|
if (!cell) {
|
|
return;
|
|
}
|
|
|
|
syncSortableHeaderCell(cell, {
|
|
direction:
|
|
options.activeSort?.field === field ? options.activeSort.direction : "none",
|
|
field,
|
|
onToggleSort: options.onToggleSort
|
|
});
|
|
});
|
|
}
|
|
|
|
export function syncMarketSelectionState(
|
|
table: MarketTableDom,
|
|
selectedAuthorIds: Set<string>
|
|
): void {
|
|
table.rows.forEach((rowDom) => {
|
|
rowDom.selectionCheckbox.dataset.marketSelectionAuthorId = rowDom.authorId;
|
|
rowDom.selectionCheckbox.checked = selectedAuthorIds.has(rowDom.authorId);
|
|
});
|
|
|
|
if (!table.headerSelectionCheckbox) {
|
|
return;
|
|
}
|
|
|
|
const visibleRows = table.rows.filter((rowDom) =>
|
|
rowDom.visibilityTargets.some((target) => !target.hidden)
|
|
);
|
|
const scopedRows = visibleRows.length > 0 ? visibleRows : table.rows;
|
|
const selectedCount = scopedRows.filter((rowDom) =>
|
|
selectedAuthorIds.has(rowDom.authorId)
|
|
).length;
|
|
|
|
table.headerSelectionCheckbox.indeterminate =
|
|
selectedCount > 0 && selectedCount < scopedRows.length;
|
|
table.headerSelectionCheckbox.checked =
|
|
scopedRows.length > 0 && selectedCount === scopedRows.length;
|
|
table.headerSelectionCheckbox.disabled = scopedRows.length === 0;
|
|
}
|
|
|
|
function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null {
|
|
const header = root.querySelector("[data-market-header]") as HTMLElement | null;
|
|
const body = root.querySelector("[data-market-body]") as HTMLElement | null;
|
|
|
|
if (!header || !body) {
|
|
return null;
|
|
}
|
|
|
|
const selectionHeader = ensureSyntheticHeaderCell(header, SELECTION_COLUMN_KEY, "");
|
|
const headerSelectionCheckbox = ensureSelectionHeaderControl(selectionHeader);
|
|
ensureSyntheticHeaderCell(header, SINGLE_COLUMN_KEY, "单视频看后搜率");
|
|
ensureSyntheticHeaderCell(header, PERSONAL_COLUMN_KEY, "个人视频看后搜率");
|
|
BACKEND_METRIC_COLUMNS.forEach(({ field, label }) => {
|
|
ensureSyntheticHeaderCell(header, field, label);
|
|
});
|
|
|
|
const headerLabelsByField = readSyntheticHeaderLabels(header);
|
|
const rows = Array.from(body.querySelectorAll("[data-market-row]")).map(
|
|
(rowElement) => {
|
|
const row = rowElement as HTMLElement;
|
|
const selectionCell = ensureSyntheticRowCell(row, SELECTION_COLUMN_KEY);
|
|
const selectionCheckbox = ensureSelectionRowControl(selectionCell);
|
|
const singleCell = ensureSyntheticRowCell(row, SINGLE_COLUMN_KEY);
|
|
const personalCell = ensureSyntheticRowCell(row, PERSONAL_COLUMN_KEY);
|
|
const backendMetricsCells = Object.fromEntries(
|
|
BACKEND_METRIC_COLUMNS.map(({ field }) => [field, ensureSyntheticRowCell(row, field)])
|
|
) as Record<BackendMetricField, HTMLElement>;
|
|
const authorId = row.dataset.authorId ?? "";
|
|
selectionCheckbox.dataset.marketSelectionAuthorId = authorId;
|
|
|
|
return {
|
|
authorId,
|
|
authorName:
|
|
row.querySelector('[data-market-field="authorName"]')?.textContent?.trim() ??
|
|
"",
|
|
backendMetricsCells,
|
|
exportFields: readSyntheticExportFields(row, headerLabelsByField),
|
|
hasDirectRatesSource: false,
|
|
location:
|
|
row.querySelector('[data-market-field="location"]')?.textContent?.trim() ?? "",
|
|
orderTargets: [
|
|
{
|
|
container: body,
|
|
mode: "dom",
|
|
node: row
|
|
}
|
|
],
|
|
personalCell,
|
|
price21To60s:
|
|
row
|
|
.querySelector('[data-market-field="price21To60s"]')
|
|
?.textContent?.trim() ?? "",
|
|
rates: undefined,
|
|
row,
|
|
selectionCheckbox,
|
|
singleCell,
|
|
visibilityTargets: [row]
|
|
} satisfies MarketRowDom;
|
|
}
|
|
);
|
|
|
|
return {
|
|
headerSelectionCheckbox,
|
|
rows
|
|
};
|
|
}
|
|
|
|
function syncDivGridMarketTable(root: ParentNode): MarketTableDom | null {
|
|
const document = getOwnerDocument(root);
|
|
if (!document) {
|
|
return null;
|
|
}
|
|
|
|
for (const marketRoot of document.querySelectorAll(".base-author-list")) {
|
|
if (!(marketRoot instanceof document.defaultView!.HTMLElement)) {
|
|
continue;
|
|
}
|
|
|
|
const syncedTable = syncDivGridRoot(marketRoot);
|
|
if (syncedTable) {
|
|
return syncedTable;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function readRawAuthorIds(root: ParentNode): string[] {
|
|
const document = getOwnerDocument(root);
|
|
const syntheticAuthorIds = readSyntheticAuthorIds(root);
|
|
if (syntheticAuthorIds && syntheticAuthorIds.length > 0) {
|
|
return syntheticAuthorIds;
|
|
}
|
|
|
|
const divGridAuthorIds = readDivGridAuthorIds(root);
|
|
if (divGridAuthorIds && divGridAuthorIds.length > 0) {
|
|
return divGridAuthorIds;
|
|
}
|
|
|
|
if (!document) {
|
|
return [];
|
|
}
|
|
|
|
return readSerializedMarketRows(document)
|
|
.map((row) => row.authorId)
|
|
.filter((authorId) => Boolean(authorId));
|
|
}
|
|
|
|
function readSyntheticAuthorIds(root: ParentNode): string[] | null {
|
|
const body = root.querySelector("[data-market-body]") as HTMLElement | null;
|
|
if (!body) {
|
|
return null;
|
|
}
|
|
|
|
return Array.from(body.querySelectorAll("[data-market-row]"))
|
|
.map((row) =>
|
|
row instanceof HTMLElement ? row.dataset.authorId ?? "" : ""
|
|
)
|
|
.filter((authorId) => Boolean(authorId));
|
|
}
|
|
|
|
function readDivGridAuthorIds(root: ParentNode): string[] | null {
|
|
const document = getOwnerDocument(root);
|
|
if (!document) {
|
|
return null;
|
|
}
|
|
|
|
const marketRoot = document.querySelector(".base-author-list");
|
|
if (!(marketRoot instanceof document.defaultView!.HTMLElement)) {
|
|
return null;
|
|
}
|
|
|
|
const bodySection = Array.from(marketRoot.querySelectorAll(".section-wrapper")).find(
|
|
(section): section is HTMLElement =>
|
|
section instanceof document.defaultView!.HTMLElement &&
|
|
!section.classList.contains("sticky-header")
|
|
);
|
|
const authorSection = bodySection
|
|
? Array.from(bodySection.children).find(
|
|
(child): child is HTMLElement =>
|
|
child instanceof document.defaultView!.HTMLElement &&
|
|
child.querySelector(".content-column .content-cell")
|
|
) ?? null
|
|
: null;
|
|
const authorColumn = authorSection
|
|
? getNativeAuthorColumn(authorSection)
|
|
: null;
|
|
if (!authorColumn) {
|
|
return null;
|
|
}
|
|
|
|
return getDirectContentCells(authorColumn)
|
|
.map((cell) => extractAuthorId(cell))
|
|
.filter((authorId) => Boolean(authorId));
|
|
}
|
|
|
|
function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
|
const headerSection = root.querySelector(
|
|
".section-wrapper.sticky-header"
|
|
) as HTMLElement | null;
|
|
const bodySection = Array.from(root.querySelectorAll(".section-wrapper")).find(
|
|
(section): section is HTMLElement =>
|
|
section instanceof root.ownerDocument.defaultView!.HTMLElement &&
|
|
!section.classList.contains("sticky-header")
|
|
);
|
|
|
|
if (!headerSection || !bodySection) {
|
|
return null;
|
|
}
|
|
|
|
const authorHeader = findCellByText(getDirectHeaderCells(headerSection), AUTHOR_HEADER_TEXT);
|
|
const actionHeader = findCellByText(getDirectHeaderCells(headerSection), ACTION_HEADER_TEXT);
|
|
|
|
if (!authorHeader || !actionHeader) {
|
|
return null;
|
|
}
|
|
|
|
const rightHeaderSection = actionHeader.parentElement;
|
|
if (!(rightHeaderSection instanceof root.ownerDocument.defaultView!.HTMLElement)) {
|
|
return null;
|
|
}
|
|
const middleHeaderSection =
|
|
findPreviousNativeSection(rightHeaderSection) ?? rightHeaderSection;
|
|
|
|
const authorSection = getIndexedChild(
|
|
bodySection,
|
|
getDirectChildIndex(headerSection, authorHeader)
|
|
);
|
|
const authorHeaderSection = getIndexedChild(
|
|
headerSection,
|
|
getDirectChildIndex(headerSection, authorHeader)
|
|
);
|
|
const rightSection = getIndexedChild(
|
|
bodySection,
|
|
getDirectChildIndex(headerSection, actionHeader)
|
|
);
|
|
|
|
if (!authorSection || !authorHeaderSection || !rightSection) {
|
|
return null;
|
|
}
|
|
const middleBodySection = findPreviousNativeSection(rightSection) ?? rightSection;
|
|
|
|
const pluginHeaderSection = ensurePluginSection(headerSection, rightHeaderSection, {
|
|
testId: "plugin-header",
|
|
type: "header"
|
|
});
|
|
const pluginBodySection = ensurePluginSection(bodySection, rightSection, {
|
|
testId: "plugin-section",
|
|
type: "body"
|
|
});
|
|
|
|
const authorColumn = getNativeAuthorColumn(authorSection);
|
|
const actionColumn = getActionColumn(rightSection);
|
|
|
|
if (!authorColumn || !actionColumn) {
|
|
return null;
|
|
}
|
|
|
|
const rowCount = getDirectContentCells(authorColumn).length;
|
|
const selectionHeaderCell = ensureDivHeaderCell(
|
|
authorHeaderSection,
|
|
authorHeader,
|
|
SELECTION_COLUMN_KEY,
|
|
""
|
|
);
|
|
const headerSelectionCheckbox = ensureSelectionHeaderControl(selectionHeaderCell);
|
|
const selectionColumn = ensureDivBodyColumn(
|
|
authorSection,
|
|
authorColumn,
|
|
SELECTION_COLUMN_KEY,
|
|
rowCount
|
|
);
|
|
const headerTemplateCell =
|
|
getDirectHeaderCells(middleHeaderSection).at(-1) ??
|
|
findPreviousHeaderCell(actionHeader) ??
|
|
actionHeader;
|
|
const bodyTemplateColumn =
|
|
getDirectContentColumns(middleBodySection).at(-1) ??
|
|
findPreviousColumn(actionColumn) ??
|
|
actionColumn;
|
|
ensureDivHeaderCell(
|
|
pluginHeaderSection,
|
|
headerTemplateCell,
|
|
SINGLE_COLUMN_KEY,
|
|
"单视频看后搜率"
|
|
);
|
|
ensureDivHeaderCell(
|
|
pluginHeaderSection,
|
|
headerTemplateCell,
|
|
PERSONAL_COLUMN_KEY,
|
|
"个人视频看后搜率"
|
|
);
|
|
const singleColumn = ensureDivBodyColumn(
|
|
pluginBodySection,
|
|
bodyTemplateColumn,
|
|
SINGLE_COLUMN_KEY,
|
|
rowCount
|
|
);
|
|
const personalColumn = ensureDivBodyColumn(
|
|
pluginBodySection,
|
|
bodyTemplateColumn,
|
|
PERSONAL_COLUMN_KEY,
|
|
rowCount
|
|
);
|
|
const backendMetricColumns = Object.fromEntries(
|
|
BACKEND_METRIC_COLUMNS.map(({ field, label }) => {
|
|
ensureDivHeaderCell(pluginHeaderSection, headerTemplateCell, field, label);
|
|
return [
|
|
field,
|
|
ensureDivBodyColumn(
|
|
pluginBodySection,
|
|
bodyTemplateColumn,
|
|
field,
|
|
rowCount
|
|
)
|
|
];
|
|
})
|
|
) as Record<BackendMetricField, HTMLElement>;
|
|
syncContainerWidth(pluginHeaderSection);
|
|
syncContainerWidth(pluginBodySection);
|
|
syncContainerWidth(authorHeaderSection);
|
|
syncContainerWidth(authorSection);
|
|
ensureVisibleHorizontalScroll(headerSection);
|
|
ensureVisibleHorizontalScroll(bodySection);
|
|
ensureScrollHint(root, headerSection);
|
|
|
|
const allBodyColumns = Array.from(bodySection.children).flatMap((section) =>
|
|
section instanceof root.ownerDocument.defaultView!.HTMLElement
|
|
? getDirectContentColumns(section)
|
|
: []
|
|
);
|
|
const allHeaderCells = Array.from(headerSection.children).flatMap((section) =>
|
|
section instanceof root.ownerDocument.defaultView!.HTMLElement
|
|
? getDirectHeaderCells(section)
|
|
: []
|
|
);
|
|
const authorCells = getDirectContentCells(authorColumn);
|
|
const selectionCells = getDirectContentCells(selectionColumn);
|
|
const singleCells = getDirectContentCells(singleColumn);
|
|
const personalCells = getDirectContentCells(personalColumn);
|
|
const backendMetricCellsByField = Object.fromEntries(
|
|
BACKEND_METRIC_COLUMNS.map(({ field }) => [
|
|
field,
|
|
getDirectContentCells(backendMetricColumns[field])
|
|
])
|
|
) as Record<BackendMetricField, HTMLElement[]>;
|
|
const priceColumn = findPreviousColumn(actionColumn);
|
|
const priceCells = priceColumn ? getDirectContentCells(priceColumn) : [];
|
|
const remainingVueMarketRows = [...readVueMarketRows(root)];
|
|
const remainingSerializedMarketRows = [...readSerializedMarketRows(root.ownerDocument)];
|
|
|
|
const rows = authorCells.flatMap((authorCell, index) => {
|
|
const selectionCell = selectionCells[index] ?? null;
|
|
const singleCell = singleCells[index] ?? null;
|
|
const personalCell = personalCells[index] ?? null;
|
|
const backendMetricsCells = Object.fromEntries(
|
|
BACKEND_METRIC_COLUMNS.map(({ field }) => [
|
|
field,
|
|
backendMetricCellsByField[field][index] ?? null
|
|
])
|
|
) as Record<BackendMetricField, HTMLElement | null>;
|
|
if (
|
|
!selectionCell ||
|
|
!singleCell ||
|
|
!personalCell ||
|
|
Object.values(backendMetricsCells).some((cell) => cell === null)
|
|
) {
|
|
return [];
|
|
}
|
|
const selectionCheckbox = ensureSelectionRowControl(selectionCell);
|
|
|
|
const alignedRowCells = allBodyColumns.map(
|
|
(column) => getDirectContentCells(column)[index] ?? null
|
|
);
|
|
const rowCells = alignedRowCells.filter(
|
|
(cell): cell is HTMLElement => cell !== null
|
|
);
|
|
const directAuthorId = extractAuthorId(authorCell) || "";
|
|
const directAuthorName = extractAuthorName(authorCell) || "";
|
|
const vueMarketRow = takeMatchedMarketDataRow(
|
|
remainingVueMarketRows,
|
|
directAuthorId,
|
|
directAuthorName
|
|
);
|
|
const serializedMarketRow = takeMatchedMarketDataRow(
|
|
remainingSerializedMarketRows,
|
|
directAuthorId,
|
|
directAuthorName
|
|
);
|
|
const fallbackMarketRow = mergeMarketDataRows(serializedMarketRow, vueMarketRow);
|
|
const exportFields = mergeExportFieldMaps(
|
|
readExportFieldsForDivGridRow(allHeaderCells, alignedRowCells),
|
|
fallbackMarketRow?.exportFields
|
|
);
|
|
const authorId = directAuthorId || fallbackMarketRow?.authorId || "";
|
|
const authorName = directAuthorName || fallbackMarketRow?.authorName || "";
|
|
const price21To60s = mergeNonEmptyString(
|
|
readDivGridPriceDisplay(priceCells[index]?.textContent),
|
|
fallbackMarketRow?.price21To60s
|
|
);
|
|
selectionCheckbox.dataset.marketSelectionAuthorId = authorId;
|
|
|
|
return [
|
|
{
|
|
authorId,
|
|
authorName,
|
|
backendMetricsCells: backendMetricsCells as Record<BackendMetricField, HTMLElement>,
|
|
exportFields,
|
|
hasDirectRatesSource:
|
|
fallbackMarketRow?.hasDirectRatesSource ?? false,
|
|
location: fallbackMarketRow?.location,
|
|
orderTargets: rowCells
|
|
.map((cell) => {
|
|
const container = cell.parentElement;
|
|
if (!(container instanceof root.ownerDocument.defaultView!.HTMLElement)) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
container,
|
|
mode: "css",
|
|
node: cell
|
|
};
|
|
})
|
|
.filter((target): target is RowOrderTarget => target !== null),
|
|
personalCell,
|
|
price21To60s,
|
|
rates: fallbackMarketRow?.rates,
|
|
row: authorCell,
|
|
selectionCheckbox,
|
|
singleCell,
|
|
visibilityTargets: rowCells
|
|
} satisfies MarketRowDom
|
|
];
|
|
});
|
|
|
|
return {
|
|
headerSelectionCheckbox,
|
|
rows
|
|
};
|
|
}
|
|
|
|
function ensureSyntheticHeaderCell(
|
|
header: HTMLElement,
|
|
field: string,
|
|
label: string
|
|
): HTMLElement {
|
|
const existingCell = header.querySelector(
|
|
`[data-market-header-cell="${field}"]`
|
|
) as HTMLElement | null;
|
|
|
|
if (existingCell) {
|
|
existingCell.textContent = label;
|
|
return existingCell;
|
|
}
|
|
|
|
const nextCell = header.ownerDocument.createElement("div");
|
|
nextCell.dataset.marketHeaderCell = field;
|
|
nextCell.textContent = label;
|
|
if (field === SELECTION_COLUMN_KEY) {
|
|
header.insertBefore(nextCell, header.firstChild);
|
|
} else {
|
|
header.appendChild(nextCell);
|
|
}
|
|
return nextCell;
|
|
}
|
|
|
|
function ensureSyntheticRowCell(row: HTMLElement, field: string): HTMLElement {
|
|
const existingCell = row.querySelector(
|
|
`[data-market-row-cell="${field}"]`
|
|
) as HTMLElement | null;
|
|
|
|
if (existingCell) {
|
|
return existingCell;
|
|
}
|
|
|
|
const nextCell = row.ownerDocument.createElement(field === BACKEND_COLUMN_KEY ? "div" : "span");
|
|
nextCell.dataset.marketRowCell = field;
|
|
if (field === SELECTION_COLUMN_KEY) {
|
|
row.insertBefore(nextCell, row.firstChild);
|
|
} else {
|
|
row.appendChild(nextCell);
|
|
}
|
|
return nextCell;
|
|
}
|
|
|
|
function ensureDivHeaderCell(
|
|
container: HTMLElement,
|
|
templateCell: HTMLElement,
|
|
field: string,
|
|
label: string
|
|
): HTMLElement {
|
|
const existingCell = container.querySelector(
|
|
`[data-market-header-cell="${field}"]`
|
|
) as HTMLElement | null;
|
|
if (existingCell) {
|
|
existingCell.textContent = label;
|
|
applyPluginHeaderCellStyles(existingCell);
|
|
return existingCell;
|
|
}
|
|
|
|
const nextCell = cloneElementShallow(templateCell);
|
|
nextCell.dataset.marketHeaderCell = field;
|
|
nextCell.textContent = label;
|
|
applyColumnWidth(nextCell, field);
|
|
applyPluginHeaderCellStyles(nextCell);
|
|
if (field === SELECTION_COLUMN_KEY) {
|
|
container.insertBefore(nextCell, templateCell);
|
|
} else {
|
|
container.appendChild(nextCell);
|
|
}
|
|
return nextCell;
|
|
}
|
|
|
|
function ensureDivBodyColumn(
|
|
container: HTMLElement,
|
|
templateColumn: HTMLElement,
|
|
field: string,
|
|
rowCount: number
|
|
): HTMLElement {
|
|
const existingColumn = container.querySelector(
|
|
`[data-market-column-group="${field}"]`
|
|
) as HTMLElement | null;
|
|
if (existingColumn) {
|
|
syncDivColumnCells(existingColumn, templateColumn, field, rowCount);
|
|
return existingColumn;
|
|
}
|
|
|
|
const nextColumn = cloneElementShallow(templateColumn);
|
|
nextColumn.dataset.marketColumnGroup = field;
|
|
applyColumnWidth(nextColumn, field);
|
|
syncDivColumnCells(nextColumn, templateColumn, field, rowCount);
|
|
if (field === SELECTION_COLUMN_KEY) {
|
|
container.insertBefore(nextColumn, templateColumn);
|
|
} else {
|
|
container.appendChild(nextColumn);
|
|
}
|
|
return nextColumn;
|
|
}
|
|
|
|
function syncDivColumnCells(
|
|
column: HTMLElement,
|
|
templateColumn: HTMLElement,
|
|
field: string,
|
|
rowCount: number
|
|
): void {
|
|
const currentCells = getDirectContentCells(column);
|
|
while (currentCells.length > rowCount) {
|
|
currentCells.pop()?.remove();
|
|
}
|
|
|
|
const templateCells = getDirectContentCells(templateColumn);
|
|
for (let index = 0; index < rowCount; index += 1) {
|
|
const existingCell = getDirectContentCells(column)[index] ?? null;
|
|
if (existingCell) {
|
|
existingCell.dataset.marketRowCell = field;
|
|
applyPluginContentCellStyles(existingCell);
|
|
continue;
|
|
}
|
|
|
|
const templateCell =
|
|
templateCells[index] ?? templateCells[templateCells.length - 1] ?? null;
|
|
const nextCell =
|
|
field === SELECTION_COLUMN_KEY
|
|
? templateCell
|
|
? createSelectionContentCell(templateCell)
|
|
: createBareContentCell(column.ownerDocument)
|
|
: templateCell
|
|
? cloneElementShallow(templateCell)
|
|
: createBareContentCell(column.ownerDocument);
|
|
nextCell.dataset.marketRowCell = field;
|
|
applyColumnWidth(nextCell, field);
|
|
applyPluginContentCellStyles(nextCell);
|
|
nextCell.textContent = "";
|
|
column.appendChild(nextCell);
|
|
}
|
|
}
|
|
|
|
function applyPluginHeaderCellStyles(cell: HTMLElement): void {
|
|
cell.style.display = "flex";
|
|
cell.style.alignItems = "center";
|
|
cell.style.justifyContent = "normal";
|
|
cell.style.cursor = "pointer";
|
|
cell.style.whiteSpace = "nowrap";
|
|
}
|
|
|
|
function applyPluginContentCellStyles(cell: HTMLElement): void {
|
|
cell.style.display = "flex";
|
|
cell.style.alignItems = "center";
|
|
cell.style.justifyContent = "normal";
|
|
cell.style.paddingTop = "12px";
|
|
cell.style.paddingBottom = "12px";
|
|
cell.style.boxSizing = "border-box";
|
|
cell.style.whiteSpace = "nowrap";
|
|
}
|
|
|
|
function ensureSelectionHeaderControl(cell: HTMLElement): HTMLInputElement {
|
|
cell.textContent = "";
|
|
cell.style.gap = "6px";
|
|
cell.style.justifyContent = "center";
|
|
|
|
const checkbox = ensureSelectionCheckbox(cell, "header");
|
|
const label = cell.querySelector(
|
|
'[data-market-selection-label="header"]'
|
|
) as HTMLElement | null;
|
|
if (label) {
|
|
label.textContent = "全选";
|
|
return checkbox;
|
|
}
|
|
|
|
const nextLabel = cell.ownerDocument.createElement("span");
|
|
nextLabel.dataset.marketSelectionLabel = "header";
|
|
nextLabel.textContent = "全选";
|
|
nextLabel.style.fontSize = "12px";
|
|
cell.appendChild(nextLabel);
|
|
return checkbox;
|
|
}
|
|
|
|
function ensureSelectionRowControl(cell: HTMLElement): HTMLInputElement {
|
|
cell.textContent = "";
|
|
cell.style.justifyContent = "center";
|
|
return ensureSelectionCheckbox(cell, "row");
|
|
}
|
|
|
|
function ensureSelectionCheckbox(
|
|
container: HTMLElement,
|
|
kind: "header" | "row"
|
|
): HTMLInputElement {
|
|
const existingCheckbox = container.querySelector(
|
|
`[data-market-selection-checkbox="${kind}"]`
|
|
) as HTMLInputElement | null;
|
|
if (existingCheckbox) {
|
|
existingCheckbox.type = "checkbox";
|
|
return existingCheckbox;
|
|
}
|
|
|
|
const checkbox = container.ownerDocument.createElement("input");
|
|
checkbox.type = "checkbox";
|
|
checkbox.dataset.marketSelectionCheckbox = kind;
|
|
checkbox.style.cursor = "pointer";
|
|
container.appendChild(checkbox);
|
|
return checkbox;
|
|
}
|
|
|
|
function getOwnerDocument(root: ParentNode): Document | null {
|
|
if ("ownerDocument" in root && root.ownerDocument) {
|
|
return root.ownerDocument;
|
|
}
|
|
|
|
return "nodeType" in root && root.nodeType === 9 ? (root as Document) : null;
|
|
}
|
|
|
|
function readSyntheticHeaderLabels(header: HTMLElement): Record<string, string> {
|
|
return Array.from(header.querySelectorAll("[data-market-header-cell]")).reduce<
|
|
Record<string, string>
|
|
>((labels, cell) => {
|
|
if (!(cell instanceof header.ownerDocument.defaultView!.HTMLElement)) {
|
|
return labels;
|
|
}
|
|
|
|
const field = cell.dataset.marketHeaderCell;
|
|
if (!field) {
|
|
return labels;
|
|
}
|
|
|
|
labels[field] = normalizeExportCellText(cell.textContent);
|
|
return labels;
|
|
}, {});
|
|
}
|
|
|
|
function readSyntheticExportFields(
|
|
row: HTMLElement,
|
|
headerLabelsByField: Record<string, string>
|
|
): Record<string, string> {
|
|
const exportFields: Record<string, string> = {};
|
|
for (const cell of row.querySelectorAll("[data-market-field]")) {
|
|
if (!(cell instanceof row.ownerDocument.defaultView!.HTMLElement)) {
|
|
continue;
|
|
}
|
|
|
|
const field = cell.dataset.marketField;
|
|
const headerLabel = field ? headerLabelsByField[field] : "";
|
|
if (!shouldExportColumn(headerLabel)) {
|
|
continue;
|
|
}
|
|
|
|
exportFields[headerLabel] = normalizeExportCellText(cell.textContent);
|
|
}
|
|
|
|
return exportFields;
|
|
}
|
|
|
|
function readExportFieldsForDivGridRow(
|
|
headerCells: HTMLElement[],
|
|
rowCells: Array<HTMLElement | null>
|
|
): Record<string, string> {
|
|
const exportFields: Record<string, string> = {};
|
|
|
|
rowCells.forEach((cell, index) => {
|
|
const headerLabel = normalizeExportCellText(headerCells[index]?.textContent);
|
|
if (!shouldExportColumn(headerLabel)) {
|
|
return;
|
|
}
|
|
|
|
exportFields[headerLabel] =
|
|
headerLabel === "21-60s报价"
|
|
? readDivGridPriceDisplay(cell?.textContent) ?? ""
|
|
: normalizeExportCellText(cell?.textContent);
|
|
});
|
|
|
|
return exportFields;
|
|
}
|
|
|
|
function findPreviousHeaderCell(cell: HTMLElement): HTMLElement | null {
|
|
let current = cell.previousElementSibling;
|
|
while (current) {
|
|
if (
|
|
current instanceof cell.ownerDocument.defaultView!.HTMLElement &&
|
|
current.classList.contains("header-cell")
|
|
) {
|
|
return current;
|
|
}
|
|
current = current.previousElementSibling;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function findPreviousColumn(column: HTMLElement): HTMLElement | null {
|
|
let current = column.previousElementSibling;
|
|
while (current) {
|
|
if (
|
|
current instanceof column.ownerDocument.defaultView!.HTMLElement &&
|
|
current.classList.contains("content-column")
|
|
) {
|
|
return current;
|
|
}
|
|
current = current.previousElementSibling;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function ensurePluginSection(
|
|
rootSection: HTMLElement,
|
|
referenceSection: HTMLElement,
|
|
options: {
|
|
testId: string;
|
|
type: "header" | "body";
|
|
}
|
|
): HTMLElement {
|
|
const existingSection = rootSection.querySelector(
|
|
`[data-market-plugin-section="${options.type}"]`
|
|
) as HTMLElement | null;
|
|
if (existingSection) {
|
|
existingSection.dataset.testid = options.testId;
|
|
existingSection.setAttribute("data-testid", options.testId);
|
|
return existingSection;
|
|
}
|
|
|
|
const templateSection =
|
|
findPreviousSection(referenceSection) ?? referenceSection;
|
|
const nextSection = cloneElementShallow(templateSection);
|
|
nextSection.dataset.marketPluginSection = options.type;
|
|
nextSection.dataset.testid = options.testId;
|
|
nextSection.setAttribute("data-testid", options.testId);
|
|
resetStickySectionStyles(nextSection);
|
|
rootSection.insertBefore(nextSection, referenceSection);
|
|
return nextSection;
|
|
}
|
|
|
|
function ensureVisibleHorizontalScroll(section: HTMLElement): void {
|
|
ensureVisibleScrollbarStyles(section.ownerDocument);
|
|
section.classList.remove("hide-scrollbar");
|
|
section.dataset.marketScrollbar = "visible";
|
|
section.style.overflowX = "auto";
|
|
section.style.scrollbarWidth = "thin";
|
|
section.style.scrollbarColor = "rgba(148, 163, 184, 0.95) rgba(226, 232, 240, 0.9)";
|
|
}
|
|
|
|
function ensureVisibleScrollbarStyles(document: Document): void {
|
|
if (document.getElementById(MARKET_SCROLLBAR_STYLE_ID)) {
|
|
return;
|
|
}
|
|
|
|
const style = document.createElement("style");
|
|
style.id = MARKET_SCROLLBAR_STYLE_ID;
|
|
style.textContent = `
|
|
[data-market-scrollbar="visible"]::-webkit-scrollbar {
|
|
display: block !important;
|
|
height: 10px !important;
|
|
}
|
|
|
|
[data-market-scrollbar="visible"]::-webkit-scrollbar-track {
|
|
background: rgba(226, 232, 240, 0.9) !important;
|
|
border-radius: 999px;
|
|
}
|
|
|
|
[data-market-scrollbar="visible"]::-webkit-scrollbar-thumb {
|
|
background: rgba(148, 163, 184, 0.95) !important;
|
|
border: 2px solid rgba(226, 232, 240, 0.9);
|
|
border-radius: 999px;
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
function ensureScrollHint(root: HTMLElement, headerSection: HTMLElement): void {
|
|
const existingHint = root.querySelector(
|
|
'[data-testid="market-scroll-hint"]'
|
|
) as HTMLElement | null;
|
|
if (existingHint) {
|
|
existingHint.textContent = MARKET_SCROLL_HINT_TEXT;
|
|
return;
|
|
}
|
|
|
|
const hint = root.ownerDocument.createElement("div");
|
|
hint.dataset.testid = "market-scroll-hint";
|
|
hint.setAttribute("data-testid", "market-scroll-hint");
|
|
hint.textContent = MARKET_SCROLL_HINT_TEXT;
|
|
hint.style.color = "#64748b";
|
|
hint.style.display = "flex";
|
|
hint.style.fontSize = "12px";
|
|
hint.style.justifyContent = "flex-end";
|
|
hint.style.lineHeight = "18px";
|
|
hint.style.padding = "0 12px 8px";
|
|
root.insertBefore(hint, headerSection);
|
|
}
|
|
|
|
function findPreviousSection(section: HTMLElement): HTMLElement | null {
|
|
let current = section.previousElementSibling;
|
|
while (current) {
|
|
if (current instanceof section.ownerDocument.defaultView!.HTMLElement) {
|
|
return current;
|
|
}
|
|
current = current.previousElementSibling;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function findPreviousNativeSection(section: HTMLElement): HTMLElement | null {
|
|
let current = section.previousElementSibling;
|
|
while (current) {
|
|
if (
|
|
current instanceof section.ownerDocument.defaultView!.HTMLElement &&
|
|
!current.hasAttribute("data-market-plugin-section")
|
|
) {
|
|
return current;
|
|
}
|
|
current = current.previousElementSibling;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function resetStickySectionStyles(section: HTMLElement): void {
|
|
section.style.position = "";
|
|
section.style.left = "";
|
|
section.style.right = "";
|
|
section.style.zIndex = "";
|
|
section.style.width = "";
|
|
section.style.minWidth = "";
|
|
}
|
|
|
|
function getActionColumn(bodySection: HTMLElement): HTMLElement | null {
|
|
const columns = getDirectContentColumns(bodySection);
|
|
return columns[columns.length - 1] ?? null;
|
|
}
|
|
|
|
function getNativeAuthorColumn(authorSection: HTMLElement): HTMLElement | null {
|
|
return (
|
|
getDirectContentColumns(authorSection).find(
|
|
(column) =>
|
|
!column.dataset.marketColumnGroup &&
|
|
getDirectContentCells(column).some(
|
|
(cell) =>
|
|
cell.querySelector("a") ||
|
|
cell.querySelector(".author-nickname") ||
|
|
Boolean(cell.dataset.authorId)
|
|
)
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
function getDirectHeaderCells(section: Element): HTMLElement[] {
|
|
return Array.from(section.querySelectorAll(".header-cell")).filter(
|
|
(cell): cell is HTMLElement =>
|
|
cell instanceof section.ownerDocument.defaultView!.HTMLElement
|
|
);
|
|
}
|
|
|
|
function getDirectContentColumns(section: Element): HTMLElement[] {
|
|
return Array.from(section.children).filter(
|
|
(child): child is HTMLElement =>
|
|
child instanceof section.ownerDocument.defaultView!.HTMLElement &&
|
|
child.classList.contains("content-column")
|
|
);
|
|
}
|
|
|
|
function getDirectContentCells(column: Element): HTMLElement[] {
|
|
return Array.from(column.children).filter(
|
|
(child): child is HTMLElement =>
|
|
child instanceof column.ownerDocument.defaultView!.HTMLElement &&
|
|
child.classList.contains("content-cell")
|
|
);
|
|
}
|
|
|
|
function getDirectChildIndex(root: HTMLElement, descendant: HTMLElement): number {
|
|
const directChild = Array.from(root.children).find((child) => child.contains(descendant));
|
|
return directChild ? Array.from(root.children).indexOf(directChild) : -1;
|
|
}
|
|
|
|
function getIndexedChild(root: HTMLElement, index: number): HTMLElement | null {
|
|
if (index < 0) {
|
|
return null;
|
|
}
|
|
|
|
const child = root.children[index] ?? null;
|
|
return child instanceof root.ownerDocument.defaultView!.HTMLElement ? child : null;
|
|
}
|
|
|
|
function findCellByText(cells: HTMLElement[], text: string): HTMLElement | null {
|
|
return cells.find((cell) => cell.textContent?.trim() === text) ?? null;
|
|
}
|
|
|
|
function cloneElementShallow(reference: HTMLElement): HTMLElement {
|
|
const clone = reference.ownerDocument.createElement(reference.tagName);
|
|
Array.from(reference.attributes).forEach((attribute) => {
|
|
clone.setAttribute(attribute.name, attribute.value);
|
|
});
|
|
|
|
return clone;
|
|
}
|
|
|
|
function createBareContentCell(document: Document): HTMLElement {
|
|
const cell = document.createElement("div");
|
|
cell.className = "content-cell";
|
|
return cell;
|
|
}
|
|
|
|
function createSelectionContentCell(templateCell: HTMLElement): HTMLElement {
|
|
const cell = cloneElementShallow(templateCell);
|
|
cell.removeAttribute("data-testid");
|
|
cell.removeAttribute("data-author-id");
|
|
return cell;
|
|
}
|
|
|
|
function extractAuthorId(authorCell: HTMLElement): string {
|
|
const explicitAuthorId = authorCell.dataset.authorId;
|
|
if (explicitAuthorId) {
|
|
return explicitAuthorId;
|
|
}
|
|
|
|
const linkedAuthorId = Array.from(authorCell.querySelectorAll("a"))
|
|
.map((link) => extractAuthorIdFromHref((link as HTMLAnchorElement).href))
|
|
.find((value): value is string => Boolean(value));
|
|
if (linkedAuthorId) {
|
|
return linkedAuthorId;
|
|
}
|
|
|
|
const fallbackAuthorId = authorCell
|
|
.querySelector("[data-author-id]")
|
|
?.getAttribute("data-author-id");
|
|
return fallbackAuthorId ?? "";
|
|
}
|
|
|
|
function extractAuthorName(authorCell: HTMLElement): string {
|
|
return (
|
|
authorCell.querySelector(".author-nickname")?.textContent?.trim() ??
|
|
authorCell.textContent?.trim() ??
|
|
""
|
|
);
|
|
}
|
|
|
|
function extractAuthorIdFromHref(href: string): string | null {
|
|
const match = href.match(/\/author-homepage\/[^/]+\/(\d+)/);
|
|
return match?.[1] ?? null;
|
|
}
|
|
|
|
function readVueMarketRows(
|
|
marketRoot: HTMLElement
|
|
): MarketDataRow[] {
|
|
const vueRoot = (
|
|
marketRoot as HTMLElement & {
|
|
__vue__?: {
|
|
$children?: unknown[];
|
|
_setupState?: Record<string, unknown>;
|
|
};
|
|
}
|
|
).__vue__;
|
|
const setupStates = collectVueSetupStates(vueRoot);
|
|
|
|
for (const setupState of setupStates) {
|
|
for (const value of Object.values(setupState)) {
|
|
const candidate = unwrapVueRef(value);
|
|
if (!candidate || typeof candidate !== "object") {
|
|
continue;
|
|
}
|
|
|
|
const marketList = unwrapVueRef(
|
|
(candidate as Record<string, unknown>).marketList
|
|
);
|
|
if (!Array.isArray(marketList)) {
|
|
continue;
|
|
}
|
|
|
|
return marketList
|
|
.map((row) => (isRecord(row) ? mapMarketListRow(row) : null))
|
|
.filter((row): row is MarketDataRow => row !== null);
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
function collectVueSetupStates(
|
|
vueRoot:
|
|
| {
|
|
$children?: unknown[];
|
|
_setupState?: Record<string, unknown>;
|
|
}
|
|
| undefined
|
|
): Array<Record<string, unknown>> {
|
|
if (!vueRoot) {
|
|
return [];
|
|
}
|
|
|
|
const queue: unknown[] = [vueRoot];
|
|
const setupStates: Array<Record<string, unknown>> = [];
|
|
|
|
while (queue.length > 0) {
|
|
const current = queue.shift();
|
|
if (!isRecord(current)) {
|
|
continue;
|
|
}
|
|
|
|
if (isRecord(current._setupState)) {
|
|
setupStates.push(current._setupState);
|
|
}
|
|
|
|
const children = Array.isArray(current.$children) ? current.$children : [];
|
|
queue.push(...children);
|
|
}
|
|
|
|
return setupStates;
|
|
}
|
|
|
|
function readSerializedMarketRows(
|
|
document: Document
|
|
): MarketDataRow[] {
|
|
const serializedRows = document.documentElement.getAttribute(
|
|
SERIALIZED_MARKET_ROWS_ATTRIBUTE
|
|
);
|
|
if (!serializedRows) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
const parsedRows = JSON.parse(serializedRows);
|
|
if (!Array.isArray(parsedRows)) {
|
|
return [];
|
|
}
|
|
|
|
return parsedRows
|
|
.map((row) => {
|
|
const record = isRecord(row) ? row : {};
|
|
const singleVideoAfterSearchRate = readString(
|
|
record.singleVideoAfterSearchRate
|
|
);
|
|
return {
|
|
authorId: readString(record.authorId) ?? "",
|
|
authorName: readString(record.authorName) ?? "",
|
|
exportFields: readSerializedExportFields(record),
|
|
hasDirectRatesSource: Boolean(singleVideoAfterSearchRate),
|
|
location: readString(record.location) ?? undefined,
|
|
price21To60s: readString(record.price21To60s) ?? undefined,
|
|
rates: singleVideoAfterSearchRate
|
|
? {
|
|
singleVideoAfterSearchRate
|
|
}
|
|
: undefined
|
|
};
|
|
})
|
|
.filter((row) => Boolean(row.authorId || row.authorName));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function unwrapVueRef(value: unknown): unknown {
|
|
if (isRecord(value) && "value" in value) {
|
|
return value.value;
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
function readMarketAttributeDatas(
|
|
record: Record<string, unknown>
|
|
): Record<string, unknown> {
|
|
return isRecord(record.attribute_datas) ? record.attribute_datas : {};
|
|
}
|
|
|
|
function readMarketFieldValue(
|
|
record: Record<string, unknown>,
|
|
attributeDatas: Record<string, unknown>,
|
|
field: string
|
|
): unknown {
|
|
return record[field] ?? attributeDatas[field];
|
|
}
|
|
|
|
function readString(value: unknown): string | null {
|
|
return typeof value === "string" ? value : null;
|
|
}
|
|
|
|
function normalizeMarketListRate(value: unknown): string | null {
|
|
if (typeof value === "number") {
|
|
return normalizeFractionRateDisplay(String(value));
|
|
}
|
|
|
|
return typeof value === "string" ? normalizeFractionRateDisplay(value) : null;
|
|
}
|
|
|
|
function normalizeExportCellText(value: string | null | undefined): string {
|
|
return value?.replace(/\s+/g, " ").trim() ?? "";
|
|
}
|
|
|
|
function readDivGridPriceDisplay(value: string | null | undefined): string | undefined {
|
|
const normalizedValue = normalizeExportCellText(value);
|
|
if (!normalizedValue) {
|
|
return undefined;
|
|
}
|
|
|
|
const match = normalizedValue.match(/^¥?\s*([\d,]+(?:\.\d+)?)$/);
|
|
if (!match) {
|
|
return undefined;
|
|
}
|
|
|
|
const numericValue = Number(match[1].replace(/,/g, ""));
|
|
if (!Number.isFinite(numericValue)) {
|
|
return undefined;
|
|
}
|
|
|
|
return formatCurrencyValue(numericValue);
|
|
}
|
|
|
|
function shouldExportColumn(label: string): boolean {
|
|
const excludedBackendLabels = new Set(BACKEND_METRIC_COLUMNS.map((column) => column.label));
|
|
return Boolean(
|
|
label &&
|
|
label !== ACTION_HEADER_TEXT &&
|
|
label !== BACKEND_HEADER_TEXT &&
|
|
!excludedBackendLabels.has(label) &&
|
|
label !== "单视频看后搜率" &&
|
|
label !== "个人视频看后搜率"
|
|
);
|
|
}
|
|
|
|
function syncSortableHeaderCell(
|
|
cell: HTMLElement,
|
|
options: {
|
|
direction: MarketSortState["direction"] | "none";
|
|
field: MarketSortField;
|
|
onToggleSort: (field: MarketSortField) => void;
|
|
}
|
|
): void {
|
|
const label = readSortableHeaderLabel(cell);
|
|
const sorterRoot = ensureHeaderSorterRoot(cell);
|
|
const text = ensureHeaderSorterText(sorterRoot);
|
|
const icon = ensureHeaderSorterIcon(sorterRoot);
|
|
const upTriangle = ensureHeaderTriangle(icon, "up");
|
|
const downTriangle = ensureHeaderTriangle(icon, "down");
|
|
|
|
text.textContent = label;
|
|
cell.dataset.marketSortField = options.field;
|
|
cell.dataset.marketSortDirection = options.direction;
|
|
cell.setAttribute("role", "button");
|
|
cell.tabIndex = 0;
|
|
cell.onclick = () => {
|
|
options.onToggleSort(options.field);
|
|
};
|
|
cell.onkeydown = (event) => {
|
|
if (event.key !== "Enter" && event.key !== " ") {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
options.onToggleSort(options.field);
|
|
};
|
|
|
|
syncTriangleStyles(upTriangle, {
|
|
active: options.direction === "asc",
|
|
direction: "up"
|
|
});
|
|
syncTriangleStyles(downTriangle, {
|
|
active: options.direction === "desc",
|
|
direction: "down"
|
|
});
|
|
}
|
|
|
|
function readSortableHeaderLabel(cell: HTMLElement): string {
|
|
return (
|
|
cell.dataset.marketHeaderLabel ??
|
|
normalizeExportCellText(cell.textContent) ??
|
|
""
|
|
);
|
|
}
|
|
|
|
function ensureHeaderSorterRoot(cell: HTMLElement): HTMLElement {
|
|
const existingRoot = cell.querySelector(
|
|
'[data-market-sorter="root"]'
|
|
) as HTMLElement | null;
|
|
if (existingRoot) {
|
|
return existingRoot;
|
|
}
|
|
|
|
cell.dataset.marketHeaderLabel = normalizeExportCellText(cell.textContent);
|
|
cell.replaceChildren();
|
|
|
|
const root = cell.ownerDocument.createElement("span");
|
|
root.dataset.marketSorter = "root";
|
|
root.style.alignItems = "center";
|
|
root.style.display = "inline-flex";
|
|
root.style.gap = "4px";
|
|
root.style.maxWidth = "100%";
|
|
cell.appendChild(root);
|
|
return root;
|
|
}
|
|
|
|
function ensureHeaderSorterText(sorterRoot: HTMLElement): HTMLElement {
|
|
const existingText = sorterRoot.querySelector(
|
|
'[data-market-sorter="text"]'
|
|
) as HTMLElement | null;
|
|
if (existingText) {
|
|
return existingText;
|
|
}
|
|
|
|
const text = sorterRoot.ownerDocument.createElement("span");
|
|
text.dataset.marketSorter = "text";
|
|
text.style.display = "inline-block";
|
|
text.style.lineHeight = "20px";
|
|
text.style.whiteSpace = "nowrap";
|
|
sorterRoot.appendChild(text);
|
|
return text;
|
|
}
|
|
|
|
function ensureHeaderSorterIcon(sorterRoot: HTMLElement): HTMLElement {
|
|
const existingIcon = sorterRoot.querySelector(
|
|
'[data-market-sorter="icon"]'
|
|
) as HTMLElement | null;
|
|
if (existingIcon) {
|
|
return existingIcon;
|
|
}
|
|
|
|
const icon = sorterRoot.ownerDocument.createElement("span");
|
|
icon.dataset.marketSorter = "icon";
|
|
icon.style.display = "inline-flex";
|
|
icon.style.flexDirection = "column";
|
|
icon.style.gap = "2px";
|
|
icon.style.justifyContent = "center";
|
|
icon.style.minWidth = "8px";
|
|
sorterRoot.appendChild(icon);
|
|
return icon;
|
|
}
|
|
|
|
function ensureHeaderTriangle(
|
|
iconRoot: HTMLElement,
|
|
direction: "up" | "down"
|
|
): HTMLElement {
|
|
const existingTriangle = iconRoot.querySelector(
|
|
`[data-market-sorter-triangle="${direction}"]`
|
|
) as HTMLElement | null;
|
|
if (existingTriangle) {
|
|
return existingTriangle;
|
|
}
|
|
|
|
const triangle = iconRoot.ownerDocument.createElement("span");
|
|
triangle.dataset.marketSorterTriangle = direction;
|
|
triangle.style.display = "block";
|
|
triangle.style.height = "0";
|
|
triangle.style.width = "0";
|
|
triangle.style.borderLeft = "4px solid transparent";
|
|
triangle.style.borderRight = "4px solid transparent";
|
|
iconRoot.appendChild(triangle);
|
|
return triangle;
|
|
}
|
|
|
|
function syncTriangleStyles(
|
|
triangle: HTMLElement,
|
|
options: {
|
|
active: boolean;
|
|
direction: "up" | "down";
|
|
}
|
|
): void {
|
|
const activeColor = "#1f2329";
|
|
const inactiveColor = "#c9cdd4";
|
|
|
|
if (options.direction === "up") {
|
|
triangle.style.borderBottom = `5px solid ${
|
|
options.active ? activeColor : inactiveColor
|
|
}`;
|
|
triangle.style.borderTop = "0 solid transparent";
|
|
} else {
|
|
triangle.style.borderTop = `5px solid ${
|
|
options.active ? activeColor : inactiveColor
|
|
}`;
|
|
triangle.style.borderBottom = "0 solid transparent";
|
|
}
|
|
}
|
|
|
|
function readRateCellText(value: string | undefined): string {
|
|
return value ? normalizeRateDisplay(value) : UNAVAILABLE_RATE_TEXT;
|
|
}
|
|
|
|
function applyColumnWidth(element: HTMLElement, field: string): void {
|
|
if (field === SELECTION_COLUMN_KEY) {
|
|
element.style.minWidth = "56px";
|
|
element.style.width = "56px";
|
|
}
|
|
|
|
if (field === BACKEND_COLUMN_KEY) {
|
|
element.style.minWidth = "240px";
|
|
element.style.width = "240px";
|
|
}
|
|
|
|
if (field === SINGLE_COLUMN_KEY || field === PERSONAL_COLUMN_KEY) {
|
|
element.style.minWidth = "160px";
|
|
element.style.width = "160px";
|
|
}
|
|
|
|
if (BACKEND_METRIC_COLUMNS.some((column) => column.field === field)) {
|
|
element.style.minWidth = "120px";
|
|
element.style.width = "120px";
|
|
}
|
|
}
|
|
|
|
function syncContainerWidth(container: Element | null): void {
|
|
if (!(container instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
const directChildren = Array.from(container.children).filter(
|
|
(child): child is HTMLElement => child instanceof HTMLElement
|
|
);
|
|
const totalWidth = directChildren.reduce((sum, child) => {
|
|
return sum + readElementWidth(child);
|
|
}, 0);
|
|
|
|
if (totalWidth <= 0) {
|
|
return;
|
|
}
|
|
|
|
container.style.width = `${totalWidth}px`;
|
|
container.style.minWidth = `${totalWidth}px`;
|
|
}
|
|
|
|
function readElementWidth(element: HTMLElement): number {
|
|
const styleWidth = Number.parseFloat(element.style.width || "");
|
|
if (Number.isFinite(styleWidth) && styleWidth > 0) {
|
|
return styleWidth;
|
|
}
|
|
|
|
const minWidth = Number.parseFloat(element.style.minWidth || "");
|
|
if (Number.isFinite(minWidth) && minWidth > 0) {
|
|
return minWidth;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
function mergeMarketDataRows(
|
|
baseRow: MarketDataRow | null,
|
|
preferredRow: MarketDataRow | null
|
|
): MarketDataRow | null {
|
|
if (!baseRow && !preferredRow) {
|
|
return null;
|
|
}
|
|
|
|
if (!baseRow) {
|
|
return preferredRow;
|
|
}
|
|
|
|
if (!preferredRow) {
|
|
return baseRow;
|
|
}
|
|
|
|
return {
|
|
authorId: preferredRow.authorId || baseRow.authorId,
|
|
authorName: preferredRow.authorName || baseRow.authorName,
|
|
exportFields: mergeExportFieldMaps(baseRow.exportFields, preferredRow.exportFields),
|
|
hasDirectRatesSource:
|
|
preferredRow.hasDirectRatesSource || baseRow.hasDirectRatesSource,
|
|
location: mergeNonEmptyString(baseRow.location, preferredRow.location),
|
|
price21To60s: mergeNonEmptyString(
|
|
baseRow.price21To60s,
|
|
preferredRow.price21To60s
|
|
),
|
|
rates: mergeRates(baseRow.rates, preferredRow.rates)
|
|
};
|
|
}
|
|
|
|
function takeMatchedMarketDataRow(
|
|
remainingRows: MarketDataRow[],
|
|
authorId: string,
|
|
authorName: string
|
|
): MarketDataRow | null {
|
|
if (remainingRows.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const matchedIndex = remainingRows.findIndex((row) => {
|
|
if (authorId && row.authorId === authorId) {
|
|
return true;
|
|
}
|
|
|
|
if (authorName && row.authorName === authorName) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
if (matchedIndex >= 0) {
|
|
return remainingRows.splice(matchedIndex, 1)[0] ?? null;
|
|
}
|
|
|
|
if (!authorId && !authorName) {
|
|
return remainingRows.shift() ?? null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function mergeExportFieldMaps(
|
|
current: Record<string, string> | undefined,
|
|
fallback: Record<string, string> | undefined
|
|
): Record<string, string> | undefined {
|
|
if (!current && !fallback) {
|
|
return undefined;
|
|
}
|
|
|
|
const nextFields: Record<string, string> = {
|
|
...(current ?? {})
|
|
};
|
|
Object.entries(fallback ?? {}).forEach(([key, value]) => {
|
|
if (!hasTextValue(nextFields[key]) && hasTextValue(value)) {
|
|
nextFields[key] = value;
|
|
}
|
|
});
|
|
return nextFields;
|
|
}
|
|
|
|
function mergeRates(
|
|
current: AfterSearchRates | undefined,
|
|
fallback: AfterSearchRates | undefined
|
|
): AfterSearchRates | undefined {
|
|
if (!current && !fallback) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
singleVideoAfterSearchRate:
|
|
current?.singleVideoAfterSearchRate ?? fallback?.singleVideoAfterSearchRate,
|
|
personalVideoAfterSearchRate:
|
|
current?.personalVideoAfterSearchRate ??
|
|
fallback?.personalVideoAfterSearchRate
|
|
};
|
|
}
|
|
|
|
function mergeNonEmptyString(
|
|
current: string | undefined,
|
|
fallback: string | undefined
|
|
): string | undefined {
|
|
return hasTextValue(current) ? current : fallback;
|
|
}
|
|
|
|
function buildMarketExportFieldFallbacks(
|
|
record: Record<string, unknown>,
|
|
attributeDatas: Record<string, unknown>
|
|
): Record<string, string> | undefined {
|
|
const exportFields: Record<string, string> = {};
|
|
const authorInfo = buildMarketAuthorInfo(record, attributeDatas);
|
|
const authorType = buildMarketAuthorType(record, attributeDatas);
|
|
const contentTheme = buildMarketContentTheme(record, attributeDatas);
|
|
const connectedUsers = formatWanValue(
|
|
readNumericValue(readMarketFieldValue(record, attributeDatas, "link_link_cnt_by_industry"))
|
|
);
|
|
const followerCount = formatWanValue(
|
|
readNumericValue(readMarketFieldValue(record, attributeDatas, "follower"))
|
|
);
|
|
const expectedCpm = formatDecimalDisplay(
|
|
readNumericValue(readMarketFieldValue(record, attributeDatas, "prospective_20_60_cpm"))
|
|
);
|
|
const expectedPlayCount = formatWanValue(
|
|
readNumericValue(readMarketFieldValue(record, attributeDatas, "expected_play_num"))
|
|
);
|
|
const interactionRate = formatFractionPercent(
|
|
readNumericValue(readMarketFieldValue(record, attributeDatas, "interact_rate_within_30d"))
|
|
);
|
|
const finishRate = formatFractionPercent(
|
|
readNumericValue(readMarketFieldValue(record, attributeDatas, "play_over_rate_within_30d"))
|
|
);
|
|
const burstRate = readBurstRateDisplay(
|
|
readNumericValue(readMarketFieldValue(record, attributeDatas, "burst_text_rate"))
|
|
);
|
|
const price21To60s = readMarketPrice21To60s(record, attributeDatas);
|
|
const representativeVideo = readMarketRepresentativeVideo(record, attributeDatas);
|
|
|
|
assignExportField(exportFields, "达人信息", authorInfo);
|
|
assignExportField(exportFields, "代表视频", representativeVideo);
|
|
assignExportField(exportFields, "达人类型", authorType);
|
|
assignExportField(exportFields, "内容主题", contentTheme);
|
|
assignExportField(exportFields, "连接用户数", connectedUsers);
|
|
assignExportField(exportFields, "粉丝数", followerCount);
|
|
assignExportField(exportFields, "预期CPM", expectedCpm);
|
|
assignExportField(exportFields, "预期播放量", expectedPlayCount);
|
|
assignExportField(exportFields, "互动率", interactionRate);
|
|
assignExportField(exportFields, "完播率", finishRate);
|
|
assignExportField(exportFields, "爆文率", burstRate);
|
|
assignExportField(exportFields, "21-60s报价", price21To60s);
|
|
|
|
return Object.keys(exportFields).length > 0 ? exportFields : undefined;
|
|
}
|
|
|
|
function assignExportField(
|
|
exportFields: Record<string, string>,
|
|
key: string,
|
|
value: string | undefined
|
|
): void {
|
|
if (hasTextValue(value)) {
|
|
exportFields[key] = value;
|
|
}
|
|
}
|
|
|
|
function buildMarketAuthorInfo(
|
|
record: Record<string, unknown>,
|
|
attributeDatas: Record<string, unknown>
|
|
): string | undefined {
|
|
const nickname =
|
|
readString(readMarketFieldValue(record, attributeDatas, "nickname")) ??
|
|
readString(readMarketFieldValue(record, attributeDatas, "nick_name")) ??
|
|
"";
|
|
const parts = [
|
|
nickname,
|
|
readMarketGenderLabel(readMarketFieldValue(record, attributeDatas, "gender")),
|
|
readString(readMarketFieldValue(record, attributeDatas, "city")) ?? ""
|
|
].filter((value) => Boolean(value));
|
|
|
|
return parts.length > 0 ? parts.join(" ") : undefined;
|
|
}
|
|
|
|
function buildMarketAuthorType(
|
|
record: Record<string, unknown>,
|
|
attributeDatas: Record<string, unknown>
|
|
): string | undefined {
|
|
const tagsRelation = readRecordLike(
|
|
readMarketFieldValue(record, attributeDatas, "tags_relation")
|
|
);
|
|
if (tagsRelation) {
|
|
const primaryTag = Object.keys(tagsRelation)[0];
|
|
if (hasTextValue(primaryTag)) {
|
|
return primaryTag;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function buildMarketContentTheme(
|
|
record: Record<string, unknown>,
|
|
attributeDatas: Record<string, unknown>
|
|
): string | undefined {
|
|
const themes = readStringArray(
|
|
readMarketFieldValue(record, attributeDatas, "content_theme_labels_180d")
|
|
);
|
|
if (themes.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
if (themes.length <= 2) {
|
|
return themes.join(" ");
|
|
}
|
|
|
|
return `${themes.slice(0, 2).join(" ")} ${themes.length - 2}+`;
|
|
}
|
|
|
|
function readMarketLocation(
|
|
record: Record<string, unknown>,
|
|
attributeDatas: Record<string, unknown>
|
|
): string | undefined {
|
|
return readString(readMarketFieldValue(record, attributeDatas, "city")) ?? undefined;
|
|
}
|
|
|
|
function readMarketPrice21To60s(
|
|
record: Record<string, unknown>,
|
|
attributeDatas: Record<string, unknown>
|
|
): string | undefined {
|
|
return formatCurrencyValue(
|
|
readNumericValue(readMarketFieldValue(record, attributeDatas, "price_20_60"))
|
|
);
|
|
}
|
|
|
|
function readMarketRepresentativeVideo(
|
|
record: Record<string, unknown>,
|
|
attributeDatas: Record<string, unknown>
|
|
): string | undefined {
|
|
const items = readArrayLike(readMarketFieldValue(record, attributeDatas, "items"));
|
|
for (const item of items) {
|
|
if (!isRecord(item)) {
|
|
continue;
|
|
}
|
|
|
|
const title = readString(item.title);
|
|
if (hasTextValue(title)) {
|
|
return normalizeExportCellText(title);
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function readMarketGenderLabel(value: unknown): string | undefined {
|
|
const rawValue = typeof value === "number" ? String(value) : readString(value);
|
|
if (rawValue === "1") {
|
|
return "男";
|
|
}
|
|
|
|
if (rawValue === "2") {
|
|
return "女";
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function readBurstRateDisplay(value: number | null): string | undefined {
|
|
if (value === null) {
|
|
return undefined;
|
|
}
|
|
|
|
if (value <= 0) {
|
|
return "-";
|
|
}
|
|
|
|
return formatFractionPercent(value);
|
|
}
|
|
|
|
function formatCurrencyValue(value: number | null): string | undefined {
|
|
if (value === null) {
|
|
return undefined;
|
|
}
|
|
|
|
return `¥${value.toLocaleString("en-US", {
|
|
maximumFractionDigits: 0
|
|
})}`;
|
|
}
|
|
|
|
function formatWanValue(value: number | null): string | undefined {
|
|
if (value === null) {
|
|
return undefined;
|
|
}
|
|
|
|
return `${formatDecimalWithGrouping(value / 10000)}w`;
|
|
}
|
|
|
|
function formatFractionPercent(value: number | null): string | undefined {
|
|
if (value === null) {
|
|
return undefined;
|
|
}
|
|
|
|
return `${formatDecimalDisplay(value * 100)}%`;
|
|
}
|
|
|
|
function formatDecimalDisplay(value: number | null): string | undefined {
|
|
if (value === null) {
|
|
return undefined;
|
|
}
|
|
|
|
return value.toLocaleString("en-US", {
|
|
maximumFractionDigits: 1,
|
|
minimumFractionDigits: 0,
|
|
useGrouping: false
|
|
});
|
|
}
|
|
|
|
function formatDecimalWithGrouping(value: number): string {
|
|
return value.toLocaleString("en-US", {
|
|
maximumFractionDigits: 1,
|
|
minimumFractionDigits: 0
|
|
});
|
|
}
|
|
|
|
function readNumericValue(value: unknown): number | null {
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
return value;
|
|
}
|
|
|
|
if (typeof value === "string") {
|
|
const trimmedValue = value.trim();
|
|
if (!trimmedValue) {
|
|
return null;
|
|
}
|
|
|
|
const parsedValue = Number(trimmedValue);
|
|
return Number.isFinite(parsedValue) ? parsedValue : null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function readStringArray(value: unknown): string[] {
|
|
if (Array.isArray(value)) {
|
|
return value.filter((item): item is string => typeof item === "string");
|
|
}
|
|
|
|
if (typeof value === "string") {
|
|
try {
|
|
const parsedValue = JSON.parse(value);
|
|
return Array.isArray(parsedValue)
|
|
? parsedValue.filter((item): item is string => typeof item === "string")
|
|
: [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
function readArrayLike(value: unknown): unknown[] {
|
|
if (Array.isArray(value)) {
|
|
return value;
|
|
}
|
|
|
|
if (typeof value === "string") {
|
|
try {
|
|
const parsedValue = JSON.parse(value);
|
|
return Array.isArray(parsedValue) ? parsedValue : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
function readRecordLike(value: unknown): Record<string, unknown> | null {
|
|
if (isRecord(value)) {
|
|
return value;
|
|
}
|
|
|
|
if (typeof value === "string") {
|
|
try {
|
|
const parsedValue = JSON.parse(value);
|
|
return isRecord(parsedValue) ? parsedValue : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function readSerializedExportFields(
|
|
record: Record<string, unknown>
|
|
): Record<string, string> | undefined {
|
|
if (!isRecord(record.exportFields)) {
|
|
return undefined;
|
|
}
|
|
|
|
const entries = Object.entries(record.exportFields).flatMap(([key, value]) =>
|
|
typeof value === "string" ? [[key, value]] : []
|
|
);
|
|
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
}
|
|
|
|
function hasTextValue(value: string | undefined): boolean {
|
|
return typeof value === "string" && value.trim().length > 0;
|
|
}
|
|
|
|
function renderBackendMetricsCells(
|
|
cells: Record<BackendMetricField, HTMLElement>,
|
|
record: MarketRecord
|
|
): void {
|
|
if (
|
|
record.backendMetricsStatus === "loading" ||
|
|
(record.status === "loading" && !record.backendMetricsStatus)
|
|
) {
|
|
fillBackendMetricCells(cells, "加载中...");
|
|
return;
|
|
}
|
|
|
|
if (record.backendMetricsStatus === "failed") {
|
|
fillBackendMetricCells(cells, "加载失败");
|
|
return;
|
|
}
|
|
|
|
if (record.backendMetricsStatus === "missing") {
|
|
fillBackendMetricCells(cells, UNAVAILABLE_BACKEND_METRICS_TEXT);
|
|
return;
|
|
}
|
|
|
|
if (record.backendMetricsStatus !== "success" || !record.backendMetrics) {
|
|
fillBackendMetricCells(cells, "");
|
|
return;
|
|
}
|
|
|
|
BACKEND_METRIC_COLUMNS.forEach(({ field }) => {
|
|
cells[field].textContent = record.backendMetrics?.[field] ?? "";
|
|
});
|
|
}
|
|
|
|
function fillBackendMetricCells(
|
|
cells: Record<BackendMetricField, HTMLElement>,
|
|
value: string
|
|
): void {
|
|
BACKEND_METRIC_COLUMNS.forEach(({ field }) => {
|
|
cells[field].textContent = value;
|
|
});
|
|
}
|