From 3c672f83556a5d479fb6468fcf1478c96e6e899d Mon Sep 17 00:00:00 2001 From: admin123 Date: Wed, 22 Apr 2026 13:54:51 +0800 Subject: [PATCH] feat: add batch submit toolbar action --- src/content/market/index.ts | 56 ++++++++++++++++++------ src/content/market/plugin-toolbar.ts | 19 +++++++- tests/market-content-entry.test.ts | 65 +++++++++++++++++++++++++--- 3 files changed, 120 insertions(+), 20 deletions(-) diff --git a/src/content/market/index.ts b/src/content/market/index.ts index ab789a6..73a108f 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -117,6 +117,9 @@ export function createMarketController(options: CreateMarketControllerOptions) { } finally { setToolbarBusyState(toolbar, false); } + }, + onSubmitBatch: async () => { + setToolbarExportStatus(toolbar, "批次提交功能开发中"); } }); @@ -366,56 +369,81 @@ export function createMarketController(options: CreateMarketControllerOptions) { let previousFingerprint = ""; let stablePassCount = 0; - for (let attempt = 0; attempt < 12; attempt += 1) { + for (let attempt = 0; attempt < 9; attempt += 1) { await waitForDomSettled(); if (attempt > 0) { await new Promise((resolve) => { - options.window.setTimeout(resolve, 100); + options.window.setTimeout( + resolve, + previousFingerprint.includes("|missing:0") ? 25 : 50 + ); }); await Promise.resolve(); } collectCurrentPageSnapshots(); - const nextFingerprint = readVisibleRowHydrationFingerprint(); - if (!nextFingerprint) { + const hydrationSnapshot = readVisibleRowHydrationSnapshot(); + if (!hydrationSnapshot.fingerprint) { stablePassCount = 0; previousFingerprint = ""; continue; } - if (nextFingerprint === previousFingerprint) { + if (hydrationSnapshot.fingerprint === previousFingerprint) { stablePassCount += 1; } else { - previousFingerprint = nextFingerprint; + previousFingerprint = hydrationSnapshot.fingerprint; stablePassCount = 1; } - if (stablePassCount >= 3) { + if (hydrationSnapshot.missingDefaultFieldCount === 0 && stablePassCount >= 2) { return; } } } - function readVisibleRowHydrationFingerprint(): string { + function readVisibleRowHydrationSnapshot(): { + fingerprint: string; + missingDefaultFieldCount: number; + } { const table = syncMarketTable(options.document); if (!table || table.rows.length === 0) { - return ""; + return { + fingerprint: "", + missingDefaultFieldCount: 0 + }; } - return table.rows - .map((rowDom) => { + const parts = table.rows.map((rowDom) => { const rowSnapshot = readRowSnapshot(rowDom); const populatedFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter( (value) => typeof value === "string" && value.trim().length > 0 ).length; + const hasRepresentativeVideo = hasTextValue( + rowSnapshot.exportFields?.["代表视频"] + ); + const hasPriceField = + hasTextValue(rowSnapshot.price21To60s) || + hasTextValue(rowSnapshot.exportFields?.["21-60s报价"]); + const missingDefaultFieldCount = + Number(!hasRepresentativeVideo) + Number(!hasPriceField); return [ rowSnapshot.authorId, populatedFieldCount, - rowSnapshot.price21To60s?.trim() ? "price" : "no-price" + hasRepresentativeVideo ? "video" : "no-video", + hasPriceField ? "price" : "no-price", + `missing:${missingDefaultFieldCount}` ].join(":"); - }) - .join("|"); + }); + + return { + fingerprint: parts.join("|"), + missingDefaultFieldCount: parts.reduce((count, part) => { + const match = part.match(/missing:(\d+)$/); + return count + Number(match?.[1] ?? 0); + }, 0) + }; } function scheduleSync(): void { diff --git a/src/content/market/plugin-toolbar.ts b/src/content/market/plugin-toolbar.ts index ba504b3..44a1243 100644 --- a/src/content/market/plugin-toolbar.ts +++ b/src/content/market/plugin-toolbar.ts @@ -4,9 +4,11 @@ export interface PluginToolbarHandlers { onApplyFilter(): Promise | void; onApplySort(): Promise | void; onExport(): Promise | void; + onSubmitBatch(): Promise | void; } export interface PluginToolbarDom { + batchSubmitButton: HTMLButtonElement; exportButton: HTMLButtonElement; exportCustomPagesInput: HTMLInputElement; exportRangeSelect: HTMLSelectElement; @@ -70,6 +72,11 @@ export function ensurePluginToolbar( exportButton.dataset.pluginExport = "button"; exportButton.textContent = "导出CSV"; + const batchSubmitButton = document.createElement("button"); + batchSubmitButton.type = "button"; + batchSubmitButton.dataset.pluginBatchSubmit = "button"; + batchSubmitButton.textContent = "提交批次"; + const exportRangeSelect = document.createElement("select"); exportRangeSelect.dataset.pluginExportRange = "select"; appendOption(exportRangeSelect, "current", "当前页"); @@ -98,7 +105,8 @@ export function ensurePluginToolbar( sortApplyButton, exportRangeSelect, exportCustomPagesInput, - exportButton + exportButton, + batchSubmitButton ); root.append(exportStatusText); document.body.prepend(root); @@ -112,8 +120,12 @@ export function ensurePluginToolbar( exportButton.addEventListener("click", () => { void handlers.onExport(); }); + batchSubmitButton.addEventListener("click", () => { + void handlers.onSubmitBatch(); + }); exportRangeSelect.addEventListener("change", () => { syncCustomPagesInputVisibility({ + batchSubmitButton, exportButton, exportCustomPagesInput, exportRangeSelect, @@ -129,6 +141,7 @@ export function ensurePluginToolbar( }); const toolbarDom = { + batchSubmitButton, exportButton, exportCustomPagesInput, exportRangeSelect, @@ -159,6 +172,9 @@ function appendOption( function readToolbarDom(root: HTMLElement): PluginToolbarDom { const toolbarDom = { + batchSubmitButton: root.querySelector( + '[data-plugin-batch-submit="button"]' + ) as HTMLButtonElement, exportButton: root.querySelector( '[data-plugin-export="button"]' ) as HTMLButtonElement, @@ -255,6 +271,7 @@ export function setToolbarBusyState( isBusy: boolean ): void { [ + toolbar.batchSubmitButton, toolbar.exportButton, toolbar.filterApplyButton, toolbar.sortApplyButton, diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index bc57328..17d626e 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -44,14 +44,21 @@ describe("market-content-entry", () => { const createMarketController = vi.fn(() => ({ ready: Promise.resolve() })); + const sendMessage = vi.fn(async () => ({ + ok: true, + type: "auth:state", + value: { isAuthenticated: true } + })); window.history.replaceState({}, "", "/ad/creator/market"); ( globalThis as typeof globalThis & { - chrome?: { runtime?: object }; + chrome?: { runtime?: { sendMessage?: (message: unknown) => Promise } }; } ).chrome = { - runtime: {} + runtime: { + sendMessage + } }; vi.doMock("../src/content/market/index", () => ({ @@ -72,7 +79,12 @@ describe("market-content-entry", () => { const { bootContentScript } = await import("../src/content/index"); await bootContentScript({ - createMarketController + createMarketController, + sendAuthMessage: vi.fn(async () => ({ + ok: true, + type: "auth:state", + value: { isAuthenticated: true } + })) }); expect(createMarketController).toHaveBeenCalledTimes(1); @@ -90,6 +102,11 @@ describe("market-content-entry", () => { await bootContentScript({ createMarketController, document, + sendAuthMessage: vi.fn(async () => ({ + ok: true, + type: "auth:state", + value: { isAuthenticated: true } + })), window: { location: { href: "https://www.xingtu.cn/ad/creator/market" @@ -128,7 +145,12 @@ describe("market-content-entry", () => { const { bootContentScript } = await import("../src/content/index"); await bootContentScript({ - createMarketController + createMarketController, + sendAuthMessage: vi.fn(async () => ({ + ok: true, + type: "auth:state", + value: { isAuthenticated: true } + })) }); const controllerOptions = createMarketController.mock.calls[0]?.[0]; @@ -163,8 +185,14 @@ describe("market-content-entry", () => { }; const { bootContentScript } = await import("../src/content/index"); + sendMessage.mockClear(); await bootContentScript({ - createMarketController + createMarketController, + sendAuthMessage: vi.fn(async () => ({ + ok: true, + type: "auth:state", + value: { isAuthenticated: true } + })) }); const controllerOptions = createMarketController.mock.calls[0]?.[0]; @@ -213,6 +241,28 @@ describe("market-content-entry", () => { ).toBe("0.03% - 0.2%"); }); + test("boots the controller only after auth succeeds", async () => { + const createMarketController = vi.fn(() => ({ + ready: Promise.resolve() + })); + + window.history.replaceState({}, "", "/ad/creator/market"); + + const { bootContentScript } = await import("../src/content/index"); + await bootContentScript({ + createMarketController, + document, + sendAuthMessage: vi.fn(async () => ({ + ok: true, + type: "auth:state", + value: { isAuthenticated: true } + })), + window + }); + + expect(createMarketController).toHaveBeenCalledTimes(1); + }); + test("hydrates the real div-grid market rows on start", async () => { document.body.innerHTML = buildRealMarketFixture([ { @@ -481,6 +531,9 @@ describe("market-content-entry", () => { expect(exportRangeSelect?.value).toBe("first-5"); expect(customPagesInput?.hidden).toBe(true); + expect( + document.querySelector('[data-plugin-batch-submit="button"]') + ).not.toBeNull(); setSelectValue('[data-plugin-export-range="select"]', "custom"); dispatchChange('[data-plugin-export-range="select"]'); @@ -731,6 +784,7 @@ describe("market-content-entry", () => { click('[data-plugin-export="button"]'); + expectButtonDisabled('[data-plugin-batch-submit="button"]', true); expectButtonDisabled('[data-plugin-export="button"]', true); expectButtonDisabled('[data-plugin-filter-apply="button"]', true); expectButtonDisabled('[data-plugin-sort-apply="button"]', true); @@ -742,6 +796,7 @@ describe("market-content-entry", () => { await waitForMockCall(buildCsv, 80, 100); expect(pagination.getClicks()).toBe(2); + expectButtonDisabled('[data-plugin-batch-submit="button"]', false); expectButtonDisabled('[data-plugin-export="button"]', false); expectSelectDisabled('[data-plugin-export-range="select"]', false); expect(buildCsv).toHaveBeenCalledTimes(1);