Improve market audit UI and capture diagnostics

This commit is contained in:
2026-08-11 19:11:04 +03:00
parent d97305553f
commit 77c597dfd8
8 changed files with 316 additions and 100 deletions
-1
View File
@@ -1,2 +1 @@
L2_MARKET_IMPORT_URL=https://l2.dokops.ru/api/l2/market/import
L2_MARKET_IMPORT_TOKEN=
+1 -2
View File
@@ -9,8 +9,6 @@
```bash
npm install
cp .env.local.example .env.local
# Сгенерируй один случайный токен, сразу сохрани его и в .env.local,
# и как новый Gitea Secret HOME_SERVICE_L2_MARKET_IMPORT_TOKEN.
npm run dev
```
@@ -94,6 +92,7 @@ npm run dev
В результате показывается последний обработанный кадр с рамками и каждый этап отдельно:
- зелёная рамка означает, что шаблон прошёл заданный порог;
- жёлтая показывает найденную область предмета, которую OCR ещё не разобрал;
- красная показывает лучшее совпадение, которое не прошло порог;
- синяя показывает вычисленную область следующего шага;
- «Пропущено» означает, что предыдущий обязательный этап не прошёл;
+14 -4
View File
@@ -4,14 +4,17 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>L2 Market Parser</title>
<title>Аудит рынка · L2</title>
</head>
<body>
<div id="app" class="app-shell">
<header class="app-header">
<div class="audit-brand">
<span class="audit-crest" aria-hidden="true"></span>
<div>
<p class="eyebrow">Локальный прототип</p>
<h1>L2 Market Parser</h1>
<p class="eyebrow">Interlude · рынок</p>
<h1>Аудит рынка</h1>
</div>
</div>
<div class="status-stack">
<div id="global-status" class="status" data-tone="neutral" role="status" aria-live="polite">
@@ -45,6 +48,13 @@
</div>
<button id="add-source" class="button button--primary" type="button">Добавить окно</button>
</div>
<div id="capture-requirement" class="capture-requirement" role="alert" hidden>
<div>
<strong id="capture-requirement-title"></strong>
<p id="capture-requirement-text"></p>
</div>
<a id="capture-secure-link" class="button" href="https://l2.dokops.ru/l2/market-audit/index.html">Открыть через HTTPS</a>
</div>
<div id="source-list" class="source-list"></div>
</section>
@@ -67,7 +77,7 @@
<div id="reference-list" class="reference-strip" aria-label="Снимки калибровки" hidden></div>
<details class="calibration-guide" open>
<details class="calibration-guide">
<summary>Как связаны области калибровки</summary>
<div class="calibration-guide__content">
<ol>
+84 -15
View File
@@ -1,6 +1,7 @@
import "./style.css";
import {
combineRects,
displayCaptureIssue,
isCalibrationReady,
isRectInside,
parseStoreHeaderText,
@@ -17,7 +18,10 @@ import {
import { createMarketOutbox } from "./market-outbox.js";
const STORAGE_KEY = "l2-market-parser.calibration.v4";
const embedded = location.pathname.startsWith("/l2/market-audit/");
const appHosted = location.pathname.startsWith("/l2/market-audit/");
const embedded = new URLSearchParams(location.search).has("embedded");
document.documentElement.dataset.theme =
localStorage.getItem("theme") || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
if (embedded) document.documentElement.classList.add("is-embedded");
const regionNames = {
saleAnchor: "Маркер магазина",
@@ -63,7 +67,7 @@ const state = {
results: [],
collecting: false,
};
const marketOutbox = createMarketOutbox(embedded ? "/api/l2/market/import" : undefined);
const marketOutbox = createMarketOutbox(appHosted ? "/api/l2/market/import" : undefined);
const catalogMatches = new Map();
const elements = {
@@ -72,6 +76,10 @@ const elements = {
nav: [...document.querySelectorAll("[data-view]")],
views: [...document.querySelectorAll(".view")],
addSource: document.querySelector("#add-source"),
captureRequirement: document.querySelector("#capture-requirement"),
captureRequirementTitle: document.querySelector("#capture-requirement-title"),
captureRequirementText: document.querySelector("#capture-requirement-text"),
captureSecureLink: document.querySelector("#capture-secure-link"),
sourceList: document.querySelector("#source-list"),
calibrationSource: document.querySelector("#calibration-source"),
captureReference: document.querySelector("#capture-reference"),
@@ -105,6 +113,24 @@ function setSyncStatus(message, tone = "neutral") {
elements.syncStatus.dataset.tone = tone;
}
function currentDisplayCaptureIssue() {
return displayCaptureIssue({
secure: window.isSecureContext,
supported: Boolean(navigator.mediaDevices?.getDisplayMedia),
topLevel: window.self === window.top,
});
}
function renderCaptureRequirement() {
const issue = currentDisplayCaptureIssue();
elements.addSource.disabled = Boolean(issue);
elements.captureRequirement.hidden = !issue;
if (!issue) return;
elements.captureRequirementTitle.textContent = issue.title;
elements.captureRequirementText.textContent = issue.detail;
elements.captureSecureLink.hidden = window.isSecureContext;
}
function showView(name) {
elements.nav.forEach((button) => {
const active = button.dataset.view === name;
@@ -142,8 +168,10 @@ async function waitForVideo(video) {
}
async function addSource() {
if (!navigator.mediaDevices?.getDisplayMedia) {
setStatus("Браузер не поддерживает захват экрана", "error");
const issue = currentDisplayCaptureIssue();
if (issue) {
renderCaptureRequirement();
setStatus(issue.title, "error");
return;
}
@@ -182,7 +210,7 @@ async function addSource() {
setStatus(`Добавлено: ${source.name}`, "success");
} catch (error) {
if (error.name === "NotAllowedError") {
setStatus("Захват отменён или не разрешён", "error");
setStatus("Захват отменён или запрещён. Проверь разрешение браузера на запись экрана", "error");
} else {
setStatus(error.message || "Не удалось добавить окно", "error");
}
@@ -213,6 +241,28 @@ function sourceEmptyState() {
return empty;
}
function drawSourceOverlay(source, result) {
const canvas = source.overlayCanvas;
const status = source.overlayStatus;
if (!canvas || !status) return;
canvas.width = source.video.videoWidth || 1;
canvas.height = source.video.videoHeight || 1;
const context = canvas.getContext("2d");
context.clearRect(0, 0, canvas.width, canvas.height);
context.lineWidth = Math.max(3, canvas.width / 500);
result?.diagnostics?.boxes.forEach(({ rect, status: boxStatus }) => {
context.strokeStyle = boxColor(boxStatus);
context.strokeRect(rect.x, rect.y, rect.width, rect.height);
});
const itemBox = result?.diagnostics?.boxes.find(({ label }) => label === "Название + цена");
status.innerHTML = `
<span data-tone="${result?.saleFound ? "found" : "pending"}">${result?.saleFound ? "Магазин найден" : "Ищу магазин"}</span>
<span data-tone="${itemBox?.status === "found" ? "found" : "pending"}">${itemBox?.status === "found" ? "Предмет распознан" : "Предмет ожидает"}</span>
`;
}
function renderSources() {
elements.sourceList.replaceChildren();
@@ -232,7 +282,16 @@ function renderSources() {
video.muted = true;
video.playsInline = true;
video.srcObject = source.stream;
preview.append(video);
const overlay = document.createElement("canvas");
overlay.className = "source-row__overlay";
overlay.setAttribute("aria-hidden", "true");
const overlayStatus = document.createElement("div");
overlayStatus.className = "source-row__overlay-status";
overlayStatus.setAttribute("aria-label", "Статус распознавания");
source.overlayCanvas = overlay;
source.overlayStatus = overlayStatus;
preview.append(video, overlay, overlayStatus);
drawSourceOverlay(source, state.results.find((result) => result.sourceId === source.id));
const info = document.createElement("div");
info.className = "source-row__info";
@@ -528,6 +587,13 @@ function cropPreview(frame, rect) {
: null;
}
function boxColor(status) {
if (status === "found") return "oklch(0.72 0.16 155)";
if (status === "pending") return "oklch(0.8 0.16 85)";
if (status === "not-found") return "oklch(0.68 0.18 25)";
return "oklch(0.72 0.13 250)";
}
function annotatedPreview(frame, boxes) {
const scale = Math.min(1, 760 / frame.width);
const preview = document.createElement("canvas");
@@ -538,7 +604,7 @@ function annotatedPreview(frame, boxes) {
context.font = "12px system-ui";
boxes.forEach(({ rect, label, status }) => {
const color = status === "found" ? "#42c884" : status === "not-found" ? "#ef6a67" : "#6d9ff2";
const color = boxColor(status);
const x = rect.x * scale;
const y = rect.y * scale;
const width = rect.width * scale;
@@ -550,7 +616,7 @@ function annotatedPreview(frame, boxes) {
const labelWidth = context.measureText(label).width + 10;
const labelY = Math.max(0, y - 20);
context.fillRect(x, labelY, labelWidth, 20);
context.fillStyle = "#f7f9fc";
context.fillStyle = "oklch(0.98 0.004 250)";
context.fillText(label, x + 5, labelY + 14);
});
@@ -835,7 +901,7 @@ async function saveCalibration() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.calibration));
if (embedded) {
if (appHosted) {
const response = await fetch("/api/l2/market/calibration", {
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -847,12 +913,12 @@ async function saveCalibration() {
setStatus("Калибровка сохранена в браузере", "success");
}
} catch {
setStatus(embedded ? "Сервер не смог сохранить калибровку" : "Браузер не смог сохранить калибровку", "error");
setStatus(appHosted ? "Сервер не смог сохранить калибровку" : "Браузер не смог сохранить калибровку", "error");
}
}
async function loadSharedCalibration() {
if (!embedded) return;
if (!appHosted) return;
try {
const response = await fetch("/api/l2/market/calibration", { cache: "no-store" });
if (!response.ok) throw new Error();
@@ -923,7 +989,7 @@ function appendDiagnostics(section, result) {
preview.append(image);
}
const caption = document.createElement("p");
caption.textContent = "Зелёная рамка найдена, красная не прошла порог, синяя показывает область следующего шага.";
caption.textContent = "Жёлтая рамка ожидает распознавания, зелёная найдена, красная не прошла порог, синяя показывает область следующего шага.";
preview.append(caption);
const stages = document.createElement("div");
@@ -1297,7 +1363,7 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source
const nameRect = resolveFieldRect(tooltipRect, state.calibration.itemNameRegion);
const priceRect = resolveFieldRect(tooltipRect, state.calibration.itemPriceRegion);
const itemRect = combineRects(nameRect, priceRect);
const itemBox = { rect: itemRect, label: "Название + цена", status: "info" };
const itemBox = { rect: itemRect, label: "Название + цена", status: "pending" };
diagnostics.boxes.push(itemBox);
if (
@@ -1319,7 +1385,7 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source
const itemOcr = await recognizeText(frame, itemRect);
let item = parseTooltipText(itemOcr.text);
itemBox.status = item ? "found" : "not-found";
itemBox.status = item ? "found" : "pending";
addDiagnosticStage(
diagnostics,
"itemName",
@@ -1389,7 +1455,7 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source
}
async function resolveCatalogItem(item) {
if (!embedded) return item;
if (!appHosted) return item;
if (!catalogMatches.has(item.name)) {
catalogMatches.set(
item.name,
@@ -1428,6 +1494,7 @@ async function scanSource(source, saleTemplate, tooltipTemplate) {
],
};
}
drawSourceOverlay(source, result);
}
function loadCalibrationTemplates() {
@@ -1555,6 +1622,7 @@ async function syncMarketOutbox() {
function clearResults() {
state.results = [];
state.sources.forEach((source) => drawSourceOverlay(source, null));
renderResults();
setStatus("Результаты очищены", "neutral");
}
@@ -1635,4 +1703,5 @@ renderSources();
renderCalibration();
renderResults();
updateDrawHint();
renderCaptureRequirement();
void loadSharedCalibration();
+29
View File
@@ -31,6 +31,32 @@ export function normalizeOcrText(text) {
return text.replace(/\s+/g, " ").trim();
}
export function cleanItemName(text) {
return normalizeOcrText(text.replace(/[^\p{L}\p{N}]+/gu, " "));
}
export function displayCaptureIssue({ secure, supported, topLevel }) {
if (!topLevel) {
return {
title: "Захват недоступен во встроенном окне",
detail: "Открой аудит рынка отдельным окном и повтори.",
};
}
if (!secure) {
return {
title: "Для захвата нужен защищённый адрес",
detail: "Браузер разрешает выбор окон только через HTTPS или на http://localhost.",
};
}
if (!supported) {
return {
title: "Браузер не поддерживает захват окон",
detail: "Открой аудит в актуальной версии Chrome или Edge.",
};
}
return null;
}
export function parseFieldValue(type, text) {
const normalized = normalizeOcrText(text);
@@ -94,6 +120,9 @@ export function parseTooltipText(text) {
.replace(/\s+/g, " ")
.trim();
}
name = cleanItemName(name);
if (!name) return null;
return {
name,
+167 -71
View File
@@ -1,26 +1,27 @@
:root {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
color: oklch(0.24 0.012 255);
background: oklch(0.965 0.006 250);
color: oklch(0.27 0.035 61);
background: oklch(0.93 0.025 83);
color-scheme: light;
font-synthesis: none;
--background: oklch(0.965 0.006 250);
--surface: oklch(0.99 0.004 250);
--surface-raised: oklch(1 0.003 250);
--surface-muted: oklch(0.935 0.008 250);
--text: oklch(0.24 0.012 255);
--text-muted: oklch(0.52 0.018 255);
--border: oklch(0.86 0.012 250);
--border-strong: oklch(0.74 0.02 250);
--accent: oklch(0.58 0.16 250);
--accent-hover: oklch(0.52 0.17 250);
--accent-soft: oklch(0.93 0.035 250);
--success: oklch(0.54 0.13 155);
--success-soft: oklch(0.93 0.04 155);
--error: oklch(0.56 0.18 25);
--error-soft: oklch(0.94 0.04 25);
--warning: oklch(0.68 0.15 75);
--focus: oklch(0.66 0.18 250);
--shadow: 0 14px 40px oklch(0.32 0.025 250 / 0.08);
--background: oklch(0.93 0.025 83);
--surface: oklch(0.975 0.014 82);
--surface-raised: oklch(0.99 0.008 82);
--surface-muted: oklch(0.945 0.024 80);
--text: oklch(0.27 0.035 61);
--text-muted: oklch(0.49 0.028 69);
--border: oklch(0.74 0.04 77);
--border-strong: oklch(0.64 0.05 75);
--accent: oklch(0.58 0.1 78);
--accent-hover: oklch(0.49 0.09 72);
--accent-soft: oklch(0.9 0.045 80);
--success: oklch(0.45 0.09 145);
--success-soft: oklch(0.91 0.035 145);
--error: oklch(0.49 0.15 28);
--error-soft: oklch(0.92 0.035 28);
--warning: oklch(0.6 0.1 75);
--focus: oklch(0.58 0.1 78);
--shadow: 0 14px 36px oklch(0.27 0.035 61 / 0.09);
}
* {
@@ -70,7 +71,8 @@ p {
h1 {
margin-block-end: 0;
font-size: 1.45rem;
font-family: Georgia, "Times New Roman", serif;
font-size: 1.55rem;
line-height: 1.15;
letter-spacing: -0.025em;
}
@@ -88,18 +90,36 @@ h3 {
}
.app-shell {
width: min(1480px, 100%);
width: 100%;
min-height: 100vh;
margin-inline: auto;
padding: 28px clamp(18px, 3vw, 48px) 56px;
padding: 0 clamp(18px, 2vw, 34px) 48px;
}
.app-header {
display: flex;
min-height: 62px;
min-height: 76px;
align-items: center;
justify-content: space-between;
gap: 24px;
margin-inline: clamp(-34px, -2vw, -18px);
padding-inline: clamp(18px, 2vw, 34px);
border-block-end: 1px solid var(--border);
background: color-mix(in oklch, var(--surface) 93%, var(--accent) 7%);
}
.audit-brand {
display: flex;
align-items: center;
gap: 13px;
}
.audit-crest {
width: 22px;
height: 22px;
border: 1px solid var(--accent);
background: var(--surface-muted);
box-shadow: inset 0 0 0 5px var(--surface);
rotate: 45deg;
}
.eyebrow {
@@ -173,7 +193,7 @@ h3 {
.step-nav {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-block: 30px 42px;
margin-block: 18px 34px;
border-block-end: 1px solid var(--border);
}
@@ -241,6 +261,36 @@ h3 {
align-items: center;
}
.capture-requirement {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
margin-block-end: 18px;
padding: 14px 16px;
border: 1px solid color-mix(in oklch, var(--error), transparent 45%);
border-radius: 8px;
background: var(--error-soft);
}
.capture-requirement[hidden] {
display: none;
}
.capture-requirement p {
max-width: 72ch;
margin: 4px 0 0;
color: var(--text-muted);
font-size: 0.84rem;
line-height: 1.45;
}
.capture-requirement .button {
flex: 0 0 auto;
color: var(--text);
text-decoration: none;
}
.reference-strip {
display: flex;
gap: 10px;
@@ -258,7 +308,7 @@ h3 {
flex: 0 0 220px;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 11px;
border-radius: 7px;
background: var(--surface);
}
@@ -330,7 +380,7 @@ h3 {
.calibration-guide {
margin-block-end: 18px;
border: 1px solid var(--border);
border-radius: 12px;
border-radius: 7px;
background: var(--surface);
}
@@ -407,7 +457,7 @@ select,
input[type="text"] {
min-height: 40px;
border: 1px solid var(--border-strong);
border-radius: 9px;
border-radius: 5px;
background: var(--surface-raised);
color: var(--text);
}
@@ -473,7 +523,7 @@ input[type="text"] {
gap: 22px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 14px;
border-radius: 8px;
background: var(--surface);
transition: border-color 180ms cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 180ms cubic-bezier(0.22, 1, 0.36, 1);
@@ -485,6 +535,7 @@ input[type="text"] {
}
.source-row__preview {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: 8px;
@@ -497,6 +548,42 @@ input[type="text"] {
object-fit: contain;
}
.source-row__overlay {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
}
.source-row__overlay-status {
position: absolute;
inset: 7px auto auto 7px;
display: grid;
gap: 4px;
pointer-events: none;
}
.source-row__overlay-status span {
width: max-content;
padding: 3px 6px;
border: 1px solid currentColor;
border-radius: 5px;
background: oklch(0.18 0.01 255 / 0.88);
color: oklch(0.88 0.02 250);
font-size: 0.62rem;
font-weight: 700;
}
.source-row__overlay-status span[data-tone="pending"] {
color: oklch(0.84 0.16 85);
}
.source-row__overlay-status span[data-tone="found"] {
color: oklch(0.8 0.15 155);
}
.source-row__info span {
color: var(--text-muted);
font-size: 0.72rem;
@@ -539,17 +626,17 @@ input[type="text"] {
.empty-state--wide {
min-height: 300px;
border: 1px dashed var(--border-strong);
border-radius: 14px;
border-radius: 8px;
background: color-mix(in oklch, var(--surface), transparent 30%);
}
.calibration-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 310px;
min-height: 620px;
grid-template-columns: minmax(0, 1fr) 350px;
min-height: calc(100vh - 300px);
overflow: hidden;
border: 1px solid var(--border);
border-radius: 16px;
border-radius: 8px;
background: var(--surface);
box-shadow: var(--shadow);
}
@@ -557,16 +644,16 @@ input[type="text"] {
.canvas-stage {
display: grid;
min-width: 0;
min-height: 620px;
max-height: calc(100vh - 260px);
min-height: calc(100vh - 300px);
max-height: none;
place-items: center;
overflow: auto;
padding: 24px;
background-color: oklch(0.91 0.008 250);
background-image: linear-gradient(45deg, oklch(0.88 0.008 250) 25%, transparent 25%),
linear-gradient(-45deg, oklch(0.88 0.008 250) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, oklch(0.88 0.008 250) 75%),
linear-gradient(-45deg, transparent 75%, oklch(0.88 0.008 250) 75%);
background-color: oklch(0.89 0.018 80);
background-image: linear-gradient(45deg, oklch(0.86 0.02 78) 25%, transparent 25%),
linear-gradient(-45deg, oklch(0.86 0.02 78) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, oklch(0.86 0.02 78) 75%),
linear-gradient(-45deg, transparent 75%, oklch(0.86 0.02 78) 75%);
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
background-size: 16px 16px;
}
@@ -748,7 +835,7 @@ input[type="range"] {
.unique-lines {
overflow: hidden;
border: 1px solid var(--border-strong);
border-radius: 14px;
border-radius: 8px;
background: var(--surface);
}
@@ -797,7 +884,7 @@ input[type="range"] {
.result-group {
overflow: hidden;
border: 1px solid var(--border);
border-radius: 14px;
border-radius: 8px;
background: var(--surface);
}
@@ -1130,6 +1217,15 @@ td:nth-child(5) {
}
@media (max-width: 640px) {
.capture-requirement {
align-items: flex-start;
flex-direction: column;
}
.capture-requirement .button {
width: 100%;
}
.app-shell {
padding-inline: 14px;
}
@@ -1197,36 +1293,36 @@ td:nth-child(5) {
}
}
@media (prefers-color-scheme: dark) {
:root {
color: oklch(0.9 0.009 250);
background: oklch(0.18 0.012 255);
--background: oklch(0.18 0.012 255);
--surface: oklch(0.225 0.014 255);
--surface-raised: oklch(0.255 0.015 255);
--surface-muted: oklch(0.28 0.016 255);
--text: oklch(0.9 0.009 250);
--text-muted: oklch(0.68 0.018 250);
--border: oklch(0.34 0.018 255);
--border-strong: oklch(0.43 0.022 255);
--accent: oklch(0.7 0.15 250);
--accent-hover: oklch(0.75 0.13 250);
--accent-soft: oklch(0.3 0.05 250);
--success: oklch(0.72 0.12 155);
--success-soft: oklch(0.29 0.045 155);
--error: oklch(0.72 0.15 25);
--error-soft: oklch(0.3 0.05 25);
--warning: oklch(0.78 0.13 75);
--shadow: 0 18px 48px oklch(0.08 0.015 255 / 0.28);
html[data-theme="dark"] {
color: oklch(0.9 0.018 78);
background: oklch(0.17 0.014 70);
color-scheme: dark;
--background: oklch(0.17 0.014 70);
--surface: oklch(0.21 0.018 70);
--surface-raised: oklch(0.23 0.02 71);
--surface-muted: oklch(0.25 0.022 72);
--text: oklch(0.9 0.018 78);
--text-muted: oklch(0.7 0.025 75);
--border: oklch(0.41 0.035 72);
--border-strong: oklch(0.5 0.04 74);
--accent: oklch(0.7 0.1 78);
--accent-hover: oklch(0.75 0.09 80);
--accent-soft: oklch(0.28 0.04 76);
--success: oklch(0.72 0.1 145);
--success-soft: oklch(0.26 0.035 145);
--error: oklch(0.72 0.15 28);
--error-soft: oklch(0.27 0.045 28);
--warning: oklch(0.78 0.1 75);
--focus: oklch(0.7 0.1 78);
--shadow: 0 18px 48px oklch(0.06 0.01 70 / 0.55);
}
.canvas-stage {
background-color: oklch(0.16 0.01 255);
background-image: linear-gradient(45deg, oklch(0.2 0.012 255) 25%, transparent 25%),
linear-gradient(-45deg, oklch(0.2 0.012 255) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, oklch(0.2 0.012 255) 75%),
linear-gradient(-45deg, transparent 75%, oklch(0.2 0.012 255) 75%);
}
html[data-theme="dark"] .canvas-stage {
background-color: oklch(0.16 0.012 70);
background-image: linear-gradient(45deg, oklch(0.2 0.016 72) 25%, transparent 25%),
linear-gradient(-45deg, oklch(0.2 0.016 72) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, oklch(0.2 0.016 72) 75%),
linear-gradient(-45deg, transparent 75%, oklch(0.2 0.016 72) 75%);
}
@media (prefers-reduced-motion: reduce) {
+17 -4
View File
@@ -1,7 +1,9 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
cleanItemName,
combineRects,
displayCaptureIssue,
isCalibrationReady,
isRectInside,
parseFieldValue,
@@ -11,6 +13,17 @@ import {
resolveFieldRect,
} from "../src/parser.js";
test("keeps only letters, digits and spaces in OCR item names", () => {
assert.equal(cleanItemName(" ~~ Soulshot: S-grade ` "), "Soulshot S grade");
assert.equal(cleanItemName("Кристалл № 12"), "Кристалл 12");
});
test("explains why display capture is unavailable", () => {
assert.equal(displayCaptureIssue({ secure: true, supported: true, topLevel: true }), null);
assert.match(displayCaptureIssue({ secure: false, supported: false, topLevel: true }).detail, /HTTPS/);
assert.match(displayCaptureIssue({ secure: true, supported: true, topLevel: false }).title, /встроенном/);
});
test("normalizes a reverse drag into a positive rectangle", () => {
assert.deepEqual(rectFromPoints({ x: 140, y: 90 }, { x: 20, y: 30 }), {
x: 20,
@@ -100,17 +113,17 @@ test("parses a hovered item tooltip", () => {
assert.deepEqual(
parseTooltipText("Blessed Spiritshot: D-Grade (D) (5,600)\nFor Each 56 Adena"),
{
name: "Blessed Spiritshot: D-Grade (D)",
name: "Blessed Spiritshot D Grade D",
quantity: 5600,
priceAdena: 56,
rawText: "Blessed Spiritshot: D-Grade (D) (5,600)\nFor Each 56 Adena",
},
);
assert.deepEqual(parseTooltipText("Animal Skin (18),,.\nFor Each 400 Adena"), {
name: "Animal Skin ,,.",
assert.deepEqual(parseTooltipText("~~ Animal Skin (18),,.\nFor Each 400 Adena"), {
name: "Animal Skin",
quantity: 18,
priceAdena: 400,
rawText: "Animal Skin (18),,.\nFor Each 400 Adena",
rawText: "~~ Animal Skin (18),,.\nFor Each 400 Adena",
});
assert.equal(parseTooltipText("Purchase List\nAdena 5,403,160"), null);
});
+2 -1
View File
@@ -1,6 +1,7 @@
import { defineConfig, loadEnv } from "vite";
const MAX_BATCH_BYTES = 96 * 1024;
const L2_MARKET_IMPORT_TOKEN = "ebc212a3735eddf2b3ce7bcaf01522e2d53425483436ff1814e7998a8bde8d0e";
export async function forwardMarketBatch(body, { url, token, fetchImpl = fetch }) {
if (!url || !token) return new Response("Market import is not configured", { status: 503 });
@@ -84,7 +85,7 @@ export default defineConfig(({ mode }) => {
plugins: [
marketImportProxy({
url: environment.L2_MARKET_IMPORT_URL,
token: environment.L2_MARKET_IMPORT_TOKEN,
token: L2_MARKET_IMPORT_TOKEN,
}),
],
};