feat: add batch submit toolbar action
This commit is contained in:
parent
b75755e6a6
commit
3c672f8355
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user