feat: add market dom sync
This commit is contained in:
parent
c846ab9f4a
commit
86f776ad79
131
src/content/market/dom-sync.ts
Normal file
131
src/content/market/dom-sync.ts
Normal 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;
|
||||||
|
}
|
||||||
113
tests/market-dom-sync.test.ts
Normal file
113
tests/market-dom-sync.test.ts
Normal 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user