feat: add batch submit toolbar action

This commit is contained in:
admin123 2026-04-22 13:54:51 +08:00
parent b75755e6a6
commit 3c672f8355
3 changed files with 120 additions and 20 deletions

View File

@ -117,6 +117,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
} finally { } finally {
setToolbarBusyState(toolbar, false); setToolbarBusyState(toolbar, false);
} }
},
onSubmitBatch: async () => {
setToolbarExportStatus(toolbar, "批次提交功能开发中");
} }
}); });
@ -366,56 +369,81 @@ export function createMarketController(options: CreateMarketControllerOptions) {
let previousFingerprint = ""; let previousFingerprint = "";
let stablePassCount = 0; let stablePassCount = 0;
for (let attempt = 0; attempt < 12; attempt += 1) { for (let attempt = 0; attempt < 9; attempt += 1) {
await waitForDomSettled(); await waitForDomSettled();
if (attempt > 0) { if (attempt > 0) {
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
options.window.setTimeout(resolve, 100); options.window.setTimeout(
resolve,
previousFingerprint.includes("|missing:0") ? 25 : 50
);
}); });
await Promise.resolve(); await Promise.resolve();
} }
collectCurrentPageSnapshots(); collectCurrentPageSnapshots();
const nextFingerprint = readVisibleRowHydrationFingerprint(); const hydrationSnapshot = readVisibleRowHydrationSnapshot();
if (!nextFingerprint) { if (!hydrationSnapshot.fingerprint) {
stablePassCount = 0; stablePassCount = 0;
previousFingerprint = ""; previousFingerprint = "";
continue; continue;
} }
if (nextFingerprint === previousFingerprint) { if (hydrationSnapshot.fingerprint === previousFingerprint) {
stablePassCount += 1; stablePassCount += 1;
} else { } else {
previousFingerprint = nextFingerprint; previousFingerprint = hydrationSnapshot.fingerprint;
stablePassCount = 1; stablePassCount = 1;
} }
if (stablePassCount >= 3) { if (hydrationSnapshot.missingDefaultFieldCount === 0 && stablePassCount >= 2) {
return; return;
} }
} }
} }
function readVisibleRowHydrationFingerprint(): string { function readVisibleRowHydrationSnapshot(): {
fingerprint: string;
missingDefaultFieldCount: number;
} {
const table = syncMarketTable(options.document); const table = syncMarketTable(options.document);
if (!table || table.rows.length === 0) { if (!table || table.rows.length === 0) {
return ""; return {
fingerprint: "",
missingDefaultFieldCount: 0
};
} }
return table.rows const parts = table.rows.map((rowDom) => {
.map((rowDom) => {
const rowSnapshot = readRowSnapshot(rowDom); const rowSnapshot = readRowSnapshot(rowDom);
const populatedFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter( const populatedFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter(
(value) => typeof value === "string" && value.trim().length > 0 (value) => typeof value === "string" && value.trim().length > 0
).length; ).length;
const hasRepresentativeVideo = hasTextValue(
rowSnapshot.exportFields?.["代表视频"]
);
const hasPriceField =
hasTextValue(rowSnapshot.price21To60s) ||
hasTextValue(rowSnapshot.exportFields?.["21-60s报价"]);
const missingDefaultFieldCount =
Number(!hasRepresentativeVideo) + Number(!hasPriceField);
return [ return [
rowSnapshot.authorId, rowSnapshot.authorId,
populatedFieldCount, populatedFieldCount,
rowSnapshot.price21To60s?.trim() ? "price" : "no-price" hasRepresentativeVideo ? "video" : "no-video",
hasPriceField ? "price" : "no-price",
`missing:${missingDefaultFieldCount}`
].join(":"); ].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 { function scheduleSync(): void {

View File

@ -4,9 +4,11 @@ export interface PluginToolbarHandlers {
onApplyFilter(): Promise<void> | void; onApplyFilter(): Promise<void> | void;
onApplySort(): Promise<void> | void; onApplySort(): Promise<void> | void;
onExport(): Promise<void> | void; onExport(): Promise<void> | void;
onSubmitBatch(): Promise<void> | void;
} }
export interface PluginToolbarDom { export interface PluginToolbarDom {
batchSubmitButton: HTMLButtonElement;
exportButton: HTMLButtonElement; exportButton: HTMLButtonElement;
exportCustomPagesInput: HTMLInputElement; exportCustomPagesInput: HTMLInputElement;
exportRangeSelect: HTMLSelectElement; exportRangeSelect: HTMLSelectElement;
@ -70,6 +72,11 @@ export function ensurePluginToolbar(
exportButton.dataset.pluginExport = "button"; exportButton.dataset.pluginExport = "button";
exportButton.textContent = "导出CSV"; exportButton.textContent = "导出CSV";
const batchSubmitButton = document.createElement("button");
batchSubmitButton.type = "button";
batchSubmitButton.dataset.pluginBatchSubmit = "button";
batchSubmitButton.textContent = "提交批次";
const exportRangeSelect = document.createElement("select"); const exportRangeSelect = document.createElement("select");
exportRangeSelect.dataset.pluginExportRange = "select"; exportRangeSelect.dataset.pluginExportRange = "select";
appendOption(exportRangeSelect, "current", "当前页"); appendOption(exportRangeSelect, "current", "当前页");
@ -98,7 +105,8 @@ export function ensurePluginToolbar(
sortApplyButton, sortApplyButton,
exportRangeSelect, exportRangeSelect,
exportCustomPagesInput, exportCustomPagesInput,
exportButton exportButton,
batchSubmitButton
); );
root.append(exportStatusText); root.append(exportStatusText);
document.body.prepend(root); document.body.prepend(root);
@ -112,8 +120,12 @@ export function ensurePluginToolbar(
exportButton.addEventListener("click", () => { exportButton.addEventListener("click", () => {
void handlers.onExport(); void handlers.onExport();
}); });
batchSubmitButton.addEventListener("click", () => {
void handlers.onSubmitBatch();
});
exportRangeSelect.addEventListener("change", () => { exportRangeSelect.addEventListener("change", () => {
syncCustomPagesInputVisibility({ syncCustomPagesInputVisibility({
batchSubmitButton,
exportButton, exportButton,
exportCustomPagesInput, exportCustomPagesInput,
exportRangeSelect, exportRangeSelect,
@ -129,6 +141,7 @@ export function ensurePluginToolbar(
}); });
const toolbarDom = { const toolbarDom = {
batchSubmitButton,
exportButton, exportButton,
exportCustomPagesInput, exportCustomPagesInput,
exportRangeSelect, exportRangeSelect,
@ -159,6 +172,9 @@ function appendOption(
function readToolbarDom(root: HTMLElement): PluginToolbarDom { function readToolbarDom(root: HTMLElement): PluginToolbarDom {
const toolbarDom = { const toolbarDom = {
batchSubmitButton: root.querySelector(
'[data-plugin-batch-submit="button"]'
) as HTMLButtonElement,
exportButton: root.querySelector( exportButton: root.querySelector(
'[data-plugin-export="button"]' '[data-plugin-export="button"]'
) as HTMLButtonElement, ) as HTMLButtonElement,
@ -255,6 +271,7 @@ export function setToolbarBusyState(
isBusy: boolean isBusy: boolean
): void { ): void {
[ [
toolbar.batchSubmitButton,
toolbar.exportButton, toolbar.exportButton,
toolbar.filterApplyButton, toolbar.filterApplyButton,
toolbar.sortApplyButton, toolbar.sortApplyButton,

View File

@ -44,14 +44,21 @@ describe("market-content-entry", () => {
const createMarketController = vi.fn(() => ({ const createMarketController = vi.fn(() => ({
ready: Promise.resolve() ready: Promise.resolve()
})); }));
const sendMessage = vi.fn(async () => ({
ok: true,
type: "auth:state",
value: { isAuthenticated: true }
}));
window.history.replaceState({}, "", "/ad/creator/market"); window.history.replaceState({}, "", "/ad/creator/market");
( (
globalThis as typeof globalThis & { globalThis as typeof globalThis & {
chrome?: { runtime?: object }; chrome?: { runtime?: { sendMessage?: (message: unknown) => Promise<unknown> } };
} }
).chrome = { ).chrome = {
runtime: {} runtime: {
sendMessage
}
}; };
vi.doMock("../src/content/market/index", () => ({ vi.doMock("../src/content/market/index", () => ({
@ -72,7 +79,12 @@ describe("market-content-entry", () => {
const { bootContentScript } = await import("../src/content/index"); const { bootContentScript } = await import("../src/content/index");
await bootContentScript({ await bootContentScript({
createMarketController createMarketController,
sendAuthMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: { isAuthenticated: true }
}))
}); });
expect(createMarketController).toHaveBeenCalledTimes(1); expect(createMarketController).toHaveBeenCalledTimes(1);
@ -90,6 +102,11 @@ describe("market-content-entry", () => {
await bootContentScript({ await bootContentScript({
createMarketController, createMarketController,
document, document,
sendAuthMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: { isAuthenticated: true }
})),
window: { window: {
location: { location: {
href: "https://www.xingtu.cn/ad/creator/market" href: "https://www.xingtu.cn/ad/creator/market"
@ -128,7 +145,12 @@ describe("market-content-entry", () => {
const { bootContentScript } = await import("../src/content/index"); const { bootContentScript } = await import("../src/content/index");
await bootContentScript({ await bootContentScript({
createMarketController createMarketController,
sendAuthMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: { isAuthenticated: true }
}))
}); });
const controllerOptions = createMarketController.mock.calls[0]?.[0]; const controllerOptions = createMarketController.mock.calls[0]?.[0];
@ -163,8 +185,14 @@ describe("market-content-entry", () => {
}; };
const { bootContentScript } = await import("../src/content/index"); const { bootContentScript } = await import("../src/content/index");
sendMessage.mockClear();
await bootContentScript({ await bootContentScript({
createMarketController createMarketController,
sendAuthMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: { isAuthenticated: true }
}))
}); });
const controllerOptions = createMarketController.mock.calls[0]?.[0]; const controllerOptions = createMarketController.mock.calls[0]?.[0];
@ -213,6 +241,28 @@ describe("market-content-entry", () => {
).toBe("0.03% - 0.2%"); ).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 () => { test("hydrates the real div-grid market rows on start", async () => {
document.body.innerHTML = buildRealMarketFixture([ document.body.innerHTML = buildRealMarketFixture([
{ {
@ -481,6 +531,9 @@ describe("market-content-entry", () => {
expect(exportRangeSelect?.value).toBe("first-5"); expect(exportRangeSelect?.value).toBe("first-5");
expect(customPagesInput?.hidden).toBe(true); expect(customPagesInput?.hidden).toBe(true);
expect(
document.querySelector('[data-plugin-batch-submit="button"]')
).not.toBeNull();
setSelectValue('[data-plugin-export-range="select"]', "custom"); setSelectValue('[data-plugin-export-range="select"]', "custom");
dispatchChange('[data-plugin-export-range="select"]'); dispatchChange('[data-plugin-export-range="select"]');
@ -731,6 +784,7 @@ describe("market-content-entry", () => {
click('[data-plugin-export="button"]'); click('[data-plugin-export="button"]');
expectButtonDisabled('[data-plugin-batch-submit="button"]', true);
expectButtonDisabled('[data-plugin-export="button"]', true); expectButtonDisabled('[data-plugin-export="button"]', true);
expectButtonDisabled('[data-plugin-filter-apply="button"]', true); expectButtonDisabled('[data-plugin-filter-apply="button"]', true);
expectButtonDisabled('[data-plugin-sort-apply="button"]', true); expectButtonDisabled('[data-plugin-sort-apply="button"]', true);
@ -742,6 +796,7 @@ describe("market-content-entry", () => {
await waitForMockCall(buildCsv, 80, 100); await waitForMockCall(buildCsv, 80, 100);
expect(pagination.getClicks()).toBe(2); expect(pagination.getClicks()).toBe(2);
expectButtonDisabled('[data-plugin-batch-submit="button"]', false);
expectButtonDisabled('[data-plugin-export="button"]', false); expectButtonDisabled('[data-plugin-export="button"]', false);
expectSelectDisabled('[data-plugin-export-range="select"]', false); expectSelectDisabled('[data-plugin-export-range="select"]', false);
expect(buildCsv).toHaveBeenCalledTimes(1); expect(buildCsv).toHaveBeenCalledTimes(1);