feat: add market dom sync

This commit is contained in:
admin123 2026-04-20 20:11:11 +08:00
parent c846ab9f4a
commit 86f776ad79
2 changed files with 244 additions and 0 deletions

View File

@ -0,0 +1,131 @@
import { normalizeRateDisplay } from "../../shared/rate-normalizer";
import type { MarketRecord } from "./types";
export interface MarketRowDom {
authorId: string;
personalCell: HTMLElement;
row: HTMLElement;
singleCell: HTMLElement;
}
export interface MarketTableDom {
body: HTMLElement;
rows: MarketRowDom[];
}
export function syncMarketTable(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;
}
ensureHeaderCell(header, "singleVideoAfterSearchRate", "单视频看后搜率");
ensureHeaderCell(header, "personalVideoAfterSearchRate", "个人视频看后搜率");
const rows = Array.from(
body.querySelectorAll("[data-market-row]")
).map((rowElement) => {
const row = rowElement as HTMLElement;
return {
authorId: row.dataset.authorId ?? "",
personalCell: ensureRowCell(row, "personalVideoAfterSearchRate"),
row,
singleCell: ensureRowCell(row, "singleVideoAfterSearchRate")
};
});
return {
body,
rows
};
}
export function renderMarketRowState(
rowDom: MarketRowDom,
record: MarketRecord
): void {
if (record.status === "success" && record.rates) {
rowDom.singleCell.textContent = normalizeRateDisplay(
record.rates.singleVideoAfterSearchRate
);
rowDom.personalCell.textContent = normalizeRateDisplay(
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) => {
rowDom.row.hidden = !visibleAuthorIds.has(rowDom.authorId);
});
}
export function applyRowOrder(
table: MarketTableDom,
orderedAuthorIds: string[]
): void {
const rowById = new Map(table.rows.map((rowDom) => [rowDom.authorId, rowDom]));
orderedAuthorIds.forEach((authorId) => {
const rowDom = rowById.get(authorId);
if (rowDom) {
table.body.appendChild(rowDom.row);
}
});
}
function ensureHeaderCell(
header: HTMLElement,
field: string,
label: string
): HTMLElement {
const existingCell = header.querySelector(
`[data-market-header-cell="${field}"]`
) as HTMLElement | null;
if (existingCell) {
return existingCell;
}
const nextCell = header.ownerDocument.createElement("div");
nextCell.dataset.marketHeaderCell = field;
nextCell.textContent = label;
header.appendChild(nextCell);
return nextCell;
}
function ensureRowCell(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("span");
nextCell.dataset.marketRowCell = field;
row.appendChild(nextCell);
return nextCell;
}

View File

@ -0,0 +1,113 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, test } from "vitest";
import {
applyRowOrder,
applyRowVisibility,
renderMarketRowState,
syncMarketTable
} from "../src/content/market/dom-sync";
import type { MarketRecord } from "../src/content/market/types";
describe("market-dom-sync", () => {
beforeEach(() => {
document.body.innerHTML = `
<div data-market-table>
<div data-market-header>
<div data-market-header-cell="authorName"></div>
<div data-market-header-cell="price21To60s">21-60s报价</div>
</div>
<div data-market-body>
<div data-market-row data-author-id="a">
<span data-market-field="authorName">Alpha</span>
<span data-market-field="price21To60s">450000</span>
</div>
<div data-market-row data-author-id="b">
<span data-market-field="authorName">Beta</span>
<span data-market-field="price21To60s">70000</span>
</div>
</div>
</div>
`;
});
test("injects the two header cells and per-row cells", () => {
const table = syncMarketTable(document);
expect(table).not.toBeNull();
expect(
document.querySelector('[data-market-header-cell="singleVideoAfterSearchRate"]')
).not.toBeNull();
expect(
document.querySelector(
'[data-market-header-cell="personalVideoAfterSearchRate"]'
)
).not.toBeNull();
expect(document.querySelectorAll("[data-market-row-cell]").length).toBe(4);
});
test("renders loading, success, and failed states", () => {
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
const alphaRow = table.rows[0];
const betaRow = table.rows[1];
renderMarketRowState(alphaRow, {
authorId: "a",
authorName: "Alpha",
status: "loading"
});
renderMarketRowState(betaRow, {
authorId: "b",
authorName: "Beta",
status: "success",
rates: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.02 - 0.1%"
}
});
expect(alphaRow.singleCell.textContent).toBe("加载中...");
expect(betaRow.singleCell.textContent).toBe("0.5% - 1%");
expect(betaRow.personalCell.textContent).toBe("0.02% - 0.1%");
renderMarketRowState(betaRow, {
authorId: "b",
authorName: "Beta",
status: "failed"
});
expect(betaRow.singleCell.textContent).toBe("加载失败");
expect(betaRow.personalCell.textContent).toBe("加载失败");
});
test("hides rows outside the visible author ids", () => {
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
applyRowVisibility(table, new Set(["b"]));
expect(table.rows[0].row.hidden).toBe(true);
expect(table.rows[1].row.hidden).toBe(false);
});
test("reorders rows based on ordered author ids", () => {
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
applyRowOrder(table, ["b", "a"]);
expect(
Array.from(
document.querySelectorAll("[data-market-row]")
).map((row) => row.getAttribute("data-author-id"))
).toEqual(["b", "a"]);
});
});