|
|
import type { Locator, Page } from "playwright";
|
|
|
import { RpaError } from "@/modules/rpa/errors";
|
|
|
|
|
|
const FIELD_VERIFY_MS = 8_000;
|
|
|
const FIELD_VERIFY_POLL_MS = 150;
|
|
|
|
|
|
function dispatchInputCommit(input: HTMLInputElement | HTMLTextAreaElement): void {
|
|
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
input.blur();
|
|
|
}
|
|
|
|
|
|
/** 触发 change/blur,确保 React 表单捕获变更 */
|
|
|
export async function commitLocatorInput(loc: Locator): Promise<void> {
|
|
|
await loc.evaluate((el) => {
|
|
|
if (!(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement)) {
|
|
|
return;
|
|
|
}
|
|
|
dispatchInputCommit(el);
|
|
|
});
|
|
|
}
|
|
|
|
|
|
function valuesEquivalent(expected: string, actual: string): boolean {
|
|
|
if (expected === actual) {
|
|
|
return true;
|
|
|
}
|
|
|
const expNum = Number(expected);
|
|
|
const actNum = Number(actual);
|
|
|
if (Number.isFinite(expNum) && Number.isFinite(actNum)) {
|
|
|
return expNum === actNum;
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
/** 填写后循环校验 value,失败则重填一次 */
|
|
|
export async function fillLocatorWithVerify(
|
|
|
page: Page,
|
|
|
loc: Locator,
|
|
|
expected: string,
|
|
|
options?: { forceClear?: boolean; commit?: boolean },
|
|
|
): Promise<void> {
|
|
|
const deadline = Date.now() + FIELD_VERIFY_MS;
|
|
|
|
|
|
while (Date.now() < deadline) {
|
|
|
await loc.click({ timeout: 3_000 }).catch(() => undefined);
|
|
|
if (options?.forceClear) {
|
|
|
await loc.click({ clickCount: 3, timeout: 3_000 }).catch(() => undefined);
|
|
|
await loc.press("Control+A").catch(() => undefined);
|
|
|
await loc.press("Backspace").catch(() => undefined);
|
|
|
}
|
|
|
await loc.fill(expected, { timeout: 8_000 });
|
|
|
let actual = await loc.inputValue().catch(() => "");
|
|
|
if (actual !== expected) {
|
|
|
await loc.pressSequentially(expected, { delay: 20 });
|
|
|
actual = await loc.inputValue().catch(() => "");
|
|
|
}
|
|
|
if (options?.commit ?? true) {
|
|
|
await commitLocatorInput(loc);
|
|
|
await loc.press("Tab").catch(() => page.keyboard.press("Tab"));
|
|
|
}
|
|
|
actual = await loc.inputValue().catch(() => "");
|
|
|
if (valuesEquivalent(expected, actual)) {
|
|
|
return;
|
|
|
}
|
|
|
await page.waitForTimeout(FIELD_VERIFY_POLL_MS);
|
|
|
}
|
|
|
|
|
|
const finalValue = await loc.inputValue().catch(() => "");
|
|
|
if (valuesEquivalent(expected, finalValue)) {
|
|
|
return;
|
|
|
}
|
|
|
throw new RpaError(
|
|
|
"CARGO_WEIGHT_MISMATCH",
|
|
|
`货物字段填写未生效(expected=${expected} actual=${finalValue})`,
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
export type CargoEditorFieldValues = {
|
|
|
pallet: string | null;
|
|
|
weight: string | null;
|
|
|
length: string | null;
|
|
|
width: string | null;
|
|
|
height: string | null;
|
|
|
weightFieldHint: string | null;
|
|
|
};
|
|
|
|
|
|
/** editor 仍打开时读取各 input value */
|
|
|
export async function readCargoEditorFieldValues(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
): Promise<CargoEditorFieldValues> {
|
|
|
return page.evaluate((sel) => {
|
|
|
const widget = document.querySelector(sel);
|
|
|
if (!widget) {
|
|
|
return {
|
|
|
pallet: null,
|
|
|
weight: null,
|
|
|
length: null,
|
|
|
width: null,
|
|
|
height: null,
|
|
|
weightFieldHint: null,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
const result: CargoEditorFieldValues = {
|
|
|
pallet: null,
|
|
|
weight: null,
|
|
|
length: null,
|
|
|
width: null,
|
|
|
height: null,
|
|
|
weightFieldHint: null,
|
|
|
};
|
|
|
|
|
|
const inputs = widget.querySelectorAll<HTMLInputElement>("input");
|
|
|
for (const inp of inputs) {
|
|
|
const visible = inp.offsetParent !== null;
|
|
|
if (!visible) {
|
|
|
continue;
|
|
|
}
|
|
|
const ph = inp.placeholder ?? "";
|
|
|
const val = inp.value?.trim() ?? "";
|
|
|
if (!val) {
|
|
|
continue;
|
|
|
}
|
|
|
if (/托盘|pallet/i.test(ph)) {
|
|
|
result.pallet = val;
|
|
|
} else if (/weight|重量|lbs|kg|公斤|磅/i.test(ph)) {
|
|
|
result.weight = val;
|
|
|
result.weightFieldHint = ph || inp.getAttribute("aria-label") || "";
|
|
|
} else if (/length|长度/i.test(ph)) {
|
|
|
result.length = val;
|
|
|
} else if (/width|宽度/i.test(ph)) {
|
|
|
result.width = val;
|
|
|
} else if (/height|高度/i.test(ph)) {
|
|
|
result.height = val;
|
|
|
}
|
|
|
}
|
|
|
return result;
|
|
|
}, widgetSelector);
|
|
|
}
|
|
|
|
|
|
export async function waitForCargoEditorFieldValues(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
expected: Record<string, string>,
|
|
|
): Promise<CargoEditorFieldValues> {
|
|
|
const deadline = Date.now() + FIELD_VERIFY_MS;
|
|
|
while (Date.now() < deadline) {
|
|
|
const current = await readCargoEditorFieldValues(page, widgetSelector);
|
|
|
const ok = Object.entries(expected).every(([key, val]) => {
|
|
|
const actual = current[key as keyof CargoEditorFieldValues];
|
|
|
return actual === val;
|
|
|
});
|
|
|
if (ok) {
|
|
|
return current;
|
|
|
}
|
|
|
await page.waitForTimeout(FIELD_VERIFY_POLL_MS);
|
|
|
}
|
|
|
const failed = await readCargoEditorFieldValues(page, widgetSelector);
|
|
|
throw new RpaError(
|
|
|
"CARGO_WEIGHT_MISMATCH",
|
|
|
`editor 内字段未对齐(expected=${JSON.stringify(expected)} actual=${JSON.stringify(failed)})`,
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|