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