diff --git a/src/content/market/index.ts b/src/content/market/index.ts index 6848e42..cbb0340 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -139,6 +139,7 @@ export function createMarketController(options: CreateMarketControllerOptions) { const toolbarHandlers = { onExport: async () => { + syncSelectionStateFromDom(); const exportTarget = readToolbarExportTarget(toolbar); if (!exportTarget.target) { setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效"); @@ -164,6 +165,7 @@ export function createMarketController(options: CreateMarketControllerOptions) { } }, onSubmitBatch: async () => { + syncSelectionStateFromDom(); const exportTarget = readToolbarExportTarget(toolbar); if (!exportTarget.target) { setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效"); @@ -182,8 +184,15 @@ export function createMarketController(options: CreateMarketControllerOptions) { setToolbarBusyState(toolbar, true); try { + const hasSelectedAuthors = selectedAuthorIds.size > 0; const records = filterRecordsBySelection( - await exportRecords(exportTarget.target, "提交中") + await exportRecords( + exportTarget.target, + hasSelectedAuthors ? "提交已选达人中" : "提交中", + { + showDetailedProgress: !hasSelectedAuthors + } + ) ); const authState = await getAuthState(); if (!authState.isAuthenticated) { @@ -457,6 +466,32 @@ export function createMarketController(options: CreateMarketControllerOptions) { syncMarketSelectionState(table, selectedAuthorIds); } + function syncSelectionStateFromDom(): void { + const rowSelectionCheckboxes = Array.from( + options.document.querySelectorAll('[data-market-selection-checkbox="row"]') + ).filter( + (element): element is HTMLInputElement => element instanceof HTMLInputElement + ); + if (rowSelectionCheckboxes.length === 0) { + return; + } + + rowSelectionCheckboxes.forEach((checkbox) => { + const authorId = checkbox.dataset.marketSelectionAuthorId?.trim(); + if (!authorId) { + return; + } + + if (checkbox.checked) { + selectedAuthorIds.add(authorId); + } else { + selectedAuthorIds.delete(authorId); + } + }); + + refreshSelectionControls(); + } + function toggleSortFromHeader(field: MarketSortState["field"]): void { activeSort = getNextSortState(activeSort, field); applyCurrentView(); diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index a599251..0d3110f 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -1877,6 +1877,242 @@ describe("market-content-entry", () => { ); }); + test( + "selected batch submit keeps a generic loading status while submitting the default paged range", + async () => { + const pages = [ + [ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, + { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } + ], + [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }], + [{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }], + [{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }], + [{ authorId: "666", authorName: "达人 F", price21To60s: "¥66,000" }] + ]; + const secondPageDeferred = createDeferred<{ + json(): Promise; + ok: boolean; + }>(); + const submitBatch = vi.fn(async () => ({ ok: true })); + + document.body.innerHTML = buildRealMarketFixture(pages[0]); + const fetchMock = vi.fn(async (_input: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { page?: number }; + const pageNumber = body.page ?? 1; + const response = { + json: async () => ({ + data: { + marketList: buildMarketListResponseRows(pages[pageNumber - 1] ?? []), + totalPages: 5 + } + }), + ok: true + }; + + if (pageNumber === 2) { + return secondPageDeferred.promise; + } + + return response; + }); + + ( + globalThis as typeof globalThis & { + fetch?: typeof fetchMock; + } + ).fetch = fetchMock; + document.documentElement.setAttribute( + "data-sces-market-request-snapshot", + JSON.stringify({ + body: JSON.stringify({ + page: 1 + }), + method: "POST", + url: "https://xingtu.cn/api/mock-market-search" + }) + ); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + getAuthState: async () => ({ + isAuthenticated: true, + resource: "https://talent-search.intelligrow.cn", + userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" } + }), + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + promptBatchName: vi.fn(() => "自动选择批次"), + submitBatch, + window + })); + + await controller.ready; + clickSelectionCheckboxForAuthor("111"); + + click('[data-plugin-batch-submit="button"]'); + for (let attempt = 0; attempt < 40; attempt += 1) { + if ( + fetchMock.mock.calls.some(([, init]) => { + const body = JSON.parse( + String((init as RequestInit | undefined)?.body ?? "{}") + ) as { page?: number }; + return body.page === 2; + }) + ) { + break; + } + + await new Promise((resolve) => setTimeout(resolve, 50)); + await Promise.resolve(); + } + + expect( + document.querySelector('[data-plugin-export-status="text"]')?.textContent + ).toBe("提交已选达人中..."); + + secondPageDeferred.resolve({ + json: async () => ({ + data: { + marketList: buildMarketListResponseRows(pages[1]), + totalPages: 5 + } + }), + ok: true + }); + + await waitForMockCall(submitBatch, 120, 50); + + expect(submitBatch).toHaveBeenCalledWith( + expect.objectContaining({ + authors: [{ authorId: "111", authorName: "达人 A" }] + }) + ); + } + ); + + test( + "batch submit respects checked row selection even when the selection change event was missed", + async () => { + const pages = [ + [ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, + { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } + ], + [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }], + [{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }], + [{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }], + [{ authorId: "666", authorName: "达人 F", price21To60s: "¥66,000" }] + ]; + const secondPageDeferred = createDeferred<{ + json(): Promise; + ok: boolean; + }>(); + const submitBatch = vi.fn(async () => ({ ok: true })); + + document.body.innerHTML = buildRealMarketFixture(pages[0]); + const fetchMock = vi.fn(async (_input: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { page?: number }; + const pageNumber = body.page ?? 1; + const response = { + json: async () => ({ + data: { + marketList: buildMarketListResponseRows(pages[pageNumber - 1] ?? []), + totalPages: 5 + } + }), + ok: true + }; + + if (pageNumber === 2) { + return secondPageDeferred.promise; + } + + return response; + }); + + ( + globalThis as typeof globalThis & { + fetch?: typeof fetchMock; + } + ).fetch = fetchMock; + document.documentElement.setAttribute( + "data-sces-market-request-snapshot", + JSON.stringify({ + body: JSON.stringify({ + page: 1 + }), + method: "POST", + url: "https://xingtu.cn/api/mock-market-search" + }) + ); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + getAuthState: async () => ({ + isAuthenticated: true, + resource: "https://talent-search.intelligrow.cn", + userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" } + }), + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + promptBatchName: vi.fn(() => "自动选择批次"), + submitBatch, + window + })); + + await controller.ready; + + const rowSelectionCheckbox = readSelectionCheckboxForAuthor("111"); + rowSelectionCheckbox.checked = true; + + click('[data-plugin-batch-submit="button"]'); + for (let attempt = 0; attempt < 40; attempt += 1) { + if ( + fetchMock.mock.calls.some(([, init]) => { + const body = JSON.parse( + String((init as RequestInit | undefined)?.body ?? "{}") + ) as { page?: number }; + return body.page === 2; + }) + ) { + break; + } + + await new Promise((resolve) => setTimeout(resolve, 50)); + await Promise.resolve(); + } + + expect( + document.querySelector('[data-plugin-export-status="text"]')?.textContent + ).toBe("提交已选达人中..."); + + secondPageDeferred.resolve({ + json: async () => ({ + data: { + marketList: buildMarketListResponseRows(pages[1]), + totalPages: 5 + } + }), + ok: true + }); + + await waitForMockCall(submitBatch, 120, 50); + + expect(submitBatch).toHaveBeenCalledWith( + expect.objectContaining({ + authors: [{ authorId: "111", authorName: "达人 A" }] + }) + ); + } + ); + test("selected batch submit falls back to all creators in the current range when no selection matches", async () => { const pages = [ [ @@ -1930,6 +2166,116 @@ describe("market-content-entry", () => { ); }); + test( + "default paged batch submit keeps detailed progress when no creators are selected", + async () => { + const pages = [ + [ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, + { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } + ], + [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }], + [{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }], + [{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }], + [{ authorId: "666", authorName: "达人 F", price21To60s: "¥66,000" }] + ]; + const secondPageDeferred = createDeferred<{ + json(): Promise; + ok: boolean; + }>(); + const submitBatch = vi.fn(async () => ({ ok: true })); + + document.body.innerHTML = buildRealMarketFixture(pages[0]); + const fetchMock = vi.fn(async (_input: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { page?: number }; + const pageNumber = body.page ?? 1; + const response = { + json: async () => ({ + data: { + marketList: buildMarketListResponseRows(pages[pageNumber - 1] ?? []), + totalPages: 5 + } + }), + ok: true + }; + + if (pageNumber === 2) { + return secondPageDeferred.promise; + } + + return response; + }); + + ( + globalThis as typeof globalThis & { + fetch?: typeof fetchMock; + } + ).fetch = fetchMock; + document.documentElement.setAttribute( + "data-sces-market-request-snapshot", + JSON.stringify({ + body: JSON.stringify({ + page: 1 + }), + method: "POST", + url: "https://xingtu.cn/api/mock-market-search" + }) + ); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + getAuthState: async () => ({ + isAuthenticated: true, + resource: "https://talent-search.intelligrow.cn", + userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" } + }), + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + promptBatchName: vi.fn(() => "默认批次"), + submitBatch, + window + })); + + await controller.ready; + + click('[data-plugin-batch-submit="button"]'); + for (let attempt = 0; attempt < 40; attempt += 1) { + if ( + fetchMock.mock.calls.some(([, init]) => { + const body = JSON.parse( + String((init as RequestInit | undefined)?.body ?? "{}") + ) as { page?: number }; + return body.page === 2; + }) + ) { + break; + } + + await new Promise((resolve) => setTimeout(resolve, 50)); + await Promise.resolve(); + } + + expect( + document.querySelector('[data-plugin-export-status="text"]')?.textContent + ).toBe("提交中 2/5 页..."); + + secondPageDeferred.resolve({ + json: async () => ({ + data: { + marketList: buildMarketListResponseRows(pages[1]), + totalPages: 5 + } + }), + ok: true + }); + + await waitForMockCall(submitBatch, 120, 50); + } + ); + test("shows an error when the batch name is blank", async () => { document.body.innerHTML = buildMarketFixture(); const promptBatchName = vi.fn(() => " ");