add regional converter

This commit is contained in:
2026-04-10 18:10:38 +04:00
parent dec08d5809
commit de3b5854c8
7 changed files with 628 additions and 181 deletions

168
www/app_openapi.json Normal file
View File

@@ -0,0 +1,168 @@
{
"openapi": "3.1.0",
"info": { "title": "Product Matcher UI", "version": "1.0.0" },
"servers": [{ "url": "/app" }],
"paths": {
"/health": {
"get": {
"summary": "Health",
"operationId": "health_health_get",
"responses": {
"200": {
"description": "Successful Response",
"content": { "application/json": { "schema": {} } }
}
}
}
},
"/": {
"get": {
"summary": "Home",
"operationId": "home__get",
"responses": {
"200": {
"description": "Successful Response",
"content": { "text/html": { "schema": { "type": "string" } } }
}
}
}
},
"/search": {
"post": {
"summary": "Search Form",
"operationId": "search_form_search_post",
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/Body_search_form_search_post"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": { "text/html": { "schema": { "type": "string" } } }
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/search": {
"post": {
"summary": "Search Api",
"operationId": "search_api_api_search_post",
"requestBody": {
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SearchRequest" }
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": { "application/json": { "schema": {} } }
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
}
},
"components": {
"schemas": {
"Body_search_form_search_post": {
"properties": {
"query_text": {
"type": "string",
"title": "Query Text",
"default": "песчано-гравийная смесь фракция 0-40"
},
"top_k": { "type": "integer", "title": "Top K", "default": 20 },
"use_rerank": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Use Rerank"
},
"rerank_strategy": {
"type": "string",
"title": "Rerank Strategy",
"default": "lexical"
}
},
"type": "object",
"title": "Body_search_form_search_post"
},
"HTTPValidationError": {
"properties": {
"detail": {
"items": { "$ref": "#/components/schemas/ValidationError" },
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
},
"SearchRequest": {
"properties": {
"query_text": {
"type": "string",
"title": "Query Text",
"default": "песчано-гравийная смесь фракция 0-40"
},
"top_k": {
"type": "integer",
"maximum": 1000.0,
"minimum": 1.0,
"title": "Top K",
"default": 20
},
"use_rerank": {
"type": "boolean",
"title": "Use Rerank",
"default": false
},
"rerank_strategy": {
"type": "string",
"title": "Rerank Strategy",
"default": "lexical"
}
},
"type": "object",
"title": "SearchRequest"
},
"ValidationError": {
"properties": {
"loc": {
"items": { "anyOf": [{ "type": "string" }, { "type": "integer" }] },
"type": "array",
"title": "Location"
},
"msg": { "type": "string", "title": "Message" },
"type": { "type": "string", "title": "Error Type" },
"input": { "title": "Input" },
"ctx": { "type": "object", "title": "Context" }
},
"type": "object",
"required": ["loc", "msg", "type"],
"title": "ValidationError"
}
}
}
}

794
www/interface.html Normal file
View File

@@ -0,0 +1,794 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Поиск товаров/услуг и конвертер ЕИ</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Commissioner:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root {
--blue-950: oklch(0.22 0.08 255);
--blue-900: oklch(0.28 0.10 255);
--blue-800: oklch(0.35 0.12 252);
--blue-700: oklch(0.42 0.14 250);
--blue-600: oklch(0.50 0.15 248);
--blue-500: oklch(0.58 0.14 248);
--blue-400: oklch(0.66 0.12 248);
--blue-300: oklch(0.75 0.08 250);
--blue-200: oklch(0.84 0.05 250);
--blue-100: oklch(0.92 0.025 250);
--blue-50: oklch(0.97 0.01 250);
--slate-900: oklch(0.25 0.02 255);
--slate-700: oklch(0.38 0.02 255);
--slate-600: oklch(0.48 0.02 255);
--slate-500: oklch(0.55 0.015 255);
--slate-400: oklch(0.65 0.01 255);
--slate-300: oklch(0.78 0.008 255);
--slate-200: oklch(0.88 0.005 255);
--slate-100: oklch(0.94 0.003 255);
--slate-50: oklch(0.98 0.002 255);
--green-600: oklch(0.55 0.16 155);
--green-100: oklch(0.93 0.04 155);
--amber-600: oklch(0.55 0.14 75);
--amber-100: oklch(0.93 0.04 75);
--red-600: oklch(0.55 0.18 25);
--red-100: oklch(0.93 0.05 25);
--surface: oklch(0.985 0.003 250);
--surface-raised: oklch(1 0 0);
--surface-sunken: oklch(0.96 0.006 250);
--text-primary: var(--slate-900);
--text-secondary: var(--slate-600);
--text-tertiary: var(--slate-500);
--text-on-accent: oklch(0.99 0.005 250);
--accent: var(--blue-600);
--accent-hover: var(--blue-700);
--accent-subtle: var(--blue-100);
--accent-border: var(--blue-200);
--border: var(--slate-200);
--border-focus: var(--blue-400);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--shadow-sm: 0 1px 2px oklch(0.22 0.08 255 / 0.04);
--shadow-md: 0 2px 8px oklch(0.22 0.08 255 / 0.06), 0 1px 3px oklch(0.22 0.08 255 / 0.04);
--shadow-lg: 0 8px 32px oklch(0.22 0.08 255 / 0.08), 0 2px 8px oklch(0.22 0.08 255 / 0.04);
--shadow-focus: 0 0 0 3px oklch(0.58 0.14 248 / 0.18);
--font-body: 'Commissioner', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
html {
font-family: var(--font-body);
color: var(--text-primary);
background: var(--surface);
-webkit-font-smoothing: antialiased;
}
body { min-height: 100vh; display: flex; flex-direction: column; }
/* ── Header ── */
.app-header {
background: var(--blue-950);
color: var(--text-on-accent);
padding: 0 clamp(1.25rem, 4vw, 3rem);
height: 56px;
display: flex;
align-items: center;
gap: 12px;
position: sticky;
top: 0;
z-index: 100;
}
.app-header svg { width: 22px; height: 22px; opacity: 0.7; flex-shrink: 0; }
.app-header h1 { font-size: 0.9375rem; font-weight: 500; letter-spacing: 0.01em; opacity: 0.92; }
/* ── Tabs ── */
.tabs-nav {
display: flex;
gap: 0;
background: var(--surface-raised);
border-bottom: 1.5px solid var(--border);
padding: 0 clamp(1.25rem, 4vw, 3rem);
position: sticky;
top: 56px;
z-index: 90;
}
.tab-btn {
font-family: var(--font-body);
font-size: 0.875rem;
font-weight: 500;
color: var(--text-tertiary);
background: none;
border: none;
padding: 14px 20px;
cursor: pointer;
position: relative;
transition: color 0.15s;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.tab-btn svg { width: 16px; height: 16px; }
.tab-btn:hover { color: var(--text-primary); }
.tab-btn.active { color: var(--accent); }
.tab-btn.active::after {
content: '';
position: absolute;
bottom: -1.5px;
left: 0;
right: 0;
height: 2px;
background: var(--accent);
border-radius: 2px 2px 0 0;
}
/* ── Main ── */
.app-main {
flex: 1;
padding: clamp(1.5rem, 4vw, 2.5rem) clamp(1.25rem, 4vw, 3rem);
max-width: 1180px;
width: 100%;
margin: 0 auto;
}
.tab-panel { display: none; }
.tab-panel.active { display: block; animation: fadeUp 0.3s cubic-bezier(0.16, 1, 0.3, 1) both; }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Form Elements ── */
.section-label {
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--blue-600);
margin-bottom: 0.75rem;
}
.form-card {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: clamp(1.25rem, 3vw, 2rem);
box-shadow: var(--shadow-sm);
}
.field { display: flex; flex-direction: column; gap: 6px; }
.field-label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 4px;
}
.field-label .req { color: var(--blue-500); font-size: 0.75rem; }
input[type="text"],
input[type="number"],
select,
textarea {
font-family: var(--font-body);
font-size: 0.9375rem;
color: var(--text-primary);
background: var(--surface-sunken);
border: 1.5px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 14px;
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
width: 100%;
outline: none;
}
input::placeholder, textarea::placeholder { color: var(--slate-400); font-weight: 300; }
input:hover, select:hover, textarea:hover { border-color: var(--slate-300); background: var(--surface-raised); }
input:focus, select:focus, textarea:focus { border-color: var(--border-focus); background: var(--surface-raised); box-shadow: var(--shadow-focus); }
select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5l5 5 5-5' stroke='%23788599' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
cursor: pointer;
}
/* ── Buttons ── */
.btn {
font-family: var(--font-body);
font-size: 0.875rem;
font-weight: 600;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 20px;
transition: all 0.15s;
white-space: nowrap;
outline: none;
}
.btn:focus-visible { box-shadow: var(--shadow-focus); }
.btn svg { width: 16px; height: 16px; flex-shrink: 0; }
.btn-primary { background: var(--accent); color: var(--text-on-accent); }
.btn-primary:hover { background: var(--accent-hover); }
.btn-primary:active { transform: scale(0.98); }
.btn-primary:disabled { background: var(--slate-300); color: var(--slate-500); cursor: not-allowed; transform: none; }
.btn-secondary { background: transparent; color: var(--accent); border: 1.5px solid var(--accent-border); }
.btn-secondary:hover { background: var(--accent-subtle); border-color: var(--blue-300); }
.btn-ghost { background: transparent; color: var(--text-secondary); padding: 8px 12px; }
.btn-ghost:hover { color: var(--accent); background: var(--accent-subtle); }
.btn-sm { font-size: 0.8125rem; padding: 7px 14px; }
/* ── Grid helpers ── */
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem; }
.grid-search { display: grid; grid-template-columns: 1fr 180px; gap: 1rem; align-items: end; }
@media (max-width: 640px) {
.grid-2, .grid-3, .grid-search { grid-template-columns: 1fr; }
}
/* ── Advanced toggle ── */
.advanced-toggle {
font-size: 0.8125rem; font-weight: 500; color: var(--text-secondary);
background: none; border: none; cursor: pointer;
display: flex; align-items: center; gap: 6px;
padding: 8px 0; font-family: var(--font-body); transition: color 0.15s;
}
.advanced-toggle:hover { color: var(--accent); }
.advanced-toggle svg { width: 14px; height: 14px; transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1); }
.advanced-toggle[aria-expanded="true"] svg { transform: rotate(180deg); }
.collapse-panel { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.35s cubic-bezier(0.16, 1, 0.3, 1); }
.collapse-panel[data-open="true"] { grid-template-rows: 1fr; }
.collapse-inner { overflow: hidden; }
.advanced-fields {
padding-top: 1.25rem; margin-top: 1.25rem;
border-top: 1px solid var(--border);
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;
}
.form-actions { display: flex; align-items: center; gap: 12px; margin-top: 1.25rem; flex-wrap: wrap; }
/* ── Filters bar ── */
.filters-bar {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: clamp(1rem, 2vw, 1.5rem);
margin-bottom: 1rem;
box-shadow: var(--shadow-sm);
}
.filters-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 0.875rem; align-items: end; }
.filters-actions { display: flex; gap: 8px; align-items: end; padding-top: 6px; }
.date-chips { display: flex; gap: 6px; flex-wrap: wrap; }
.chip {
font-family: var(--font-body); font-size: 0.75rem; font-weight: 500;
padding: 5px 12px; border-radius: 100px;
border: 1.5px solid var(--border); background: var(--surface-raised);
color: var(--text-secondary); cursor: pointer; transition: all 0.15s; white-space: nowrap;
}
.chip:hover { border-color: var(--blue-300); color: var(--accent); }
.chip.active { background: var(--accent-subtle); border-color: var(--blue-300); color: var(--blue-700); }
/* ── Table ── */
.table-wrap {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.table-scroll { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
thead { position: sticky; top: 0; z-index: 2; }
th {
background: var(--blue-50); font-size: 0.75rem; font-weight: 600;
letter-spacing: 0.03em; color: var(--text-secondary);
text-align: left; padding: 12px 16px;
border-bottom: 1.5px solid var(--blue-200); white-space: nowrap;
}
th:first-child, td:first-child { padding-left: 20px; }
td { padding: 12px 16px; border-bottom: 1px solid var(--slate-100); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: oklch(0.97 0.008 250); }
.cell-name { font-weight: 500; max-width: 360px; }
.cell-num { font-family: var(--font-mono); font-size: 0.8125rem; color: var(--text-secondary); text-align: center; }
.cell-region { color: var(--text-secondary); }
.cell-unit { font-size: 0.8125rem; }
.cell-qty { font-family: var(--font-mono); font-size: 0.8125rem; text-align: right; }
.cell-date { font-family: var(--font-mono); font-size: 0.8125rem; color: var(--text-secondary); white-space: nowrap; }
.table-footer {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 20px; border-top: 1px solid var(--border);
font-size: 0.8125rem; color: var(--text-tertiary);
}
/* ── Result card (converter) ── */
.result-card {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: clamp(1.5rem, 3vw, 2rem);
box-shadow: var(--shadow-sm);
margin-top: 1.5rem;
}
.coeff-display {
display: flex;
align-items: baseline;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 0.5rem;
}
.coeff-value {
font-family: var(--font-mono);
font-size: clamp(2rem, 5vw, 3rem);
font-weight: 700;
color: var(--blue-700);
line-height: 1;
}
.coeff-label {
font-size: 0.9375rem;
color: var(--text-secondary);
line-height: 1.5;
}
.coeff-formula {
font-size: 0.8125rem;
color: var(--text-tertiary);
font-family: var(--font-mono);
margin-top: 0.5rem;
padding: 8px 12px;
background: var(--surface-sunken);
border-radius: var(--radius-sm);
display: inline-block;
}
/* ── Logs panel ── */
.logs-section { margin-top: 1.5rem; }
.logs-toggle {
font-family: var(--font-body); font-size: 0.8125rem; font-weight: 500;
color: var(--text-secondary); background: none; border: none;
cursor: pointer; display: flex; align-items: center; gap: 8px;
padding: 8px 0; transition: color 0.15s;
}
.logs-toggle:hover { color: var(--accent); }
.logs-toggle svg { width: 14px; height: 14px; transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1); }
.logs-toggle[aria-expanded="true"] svg { transform: rotate(180deg); }
.logs-badge {
font-size: 0.6875rem; font-weight: 600;
background: var(--slate-100); color: var(--text-tertiary);
padding: 1px 7px; border-radius: 100px;
}
.logs-container {
margin-top: 0.75rem;
background: var(--slate-900);
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-md);
}
.logs-header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 16px;
border-bottom: 1px solid oklch(0.35 0.02 255);
}
.logs-header span {
font-size: 0.75rem; font-weight: 600;
letter-spacing: 0.04em; text-transform: uppercase;
color: var(--slate-400);
}
.logs-body {
padding: 12px 16px;
max-height: 320px;
overflow-y: auto;
font-family: var(--font-mono);
font-size: 0.75rem;
line-height: 1.8;
color: var(--slate-300);
}
.logs-body::-webkit-scrollbar { width: 6px; }
.logs-body::-webkit-scrollbar-track { background: transparent; }
.logs-body::-webkit-scrollbar-thumb { background: oklch(0.4 0.02 255); border-radius: 3px; }
.log-line { display: flex; gap: 10px; }
.log-time { color: var(--slate-500); flex-shrink: 0; }
.log-level { font-weight: 500; flex-shrink: 0; min-width: 44px; }
.log-level.info { color: var(--blue-400); }
.log-level.ok { color: var(--green-600); }
.log-level.warn { color: var(--amber-600); }
.log-level.err { color: var(--red-600); }
.log-msg { color: oklch(0.82 0.01 255); }
/* ── Empty state ── */
.empty-state { text-align: center; padding: 4rem 2rem; }
.empty-state svg { width: 48px; height: 48px; color: var(--slate-300); margin-bottom: 1rem; }
.empty-state p { color: var(--text-tertiary); font-size: 0.9375rem; max-width: 380px; margin: 0 auto; line-height: 1.6; }
.empty-state p strong { color: var(--text-secondary); font-weight: 500; }
.hidden { display: none !important; }
/* ── Responsive ── */
@media (max-width: 768px) {
.filters-grid { grid-template-columns: 1fr 1fr; }
.form-actions { flex-direction: column; align-items: stretch; }
.form-actions .btn { width: 100%; }
}
@media (max-width: 480px) {
.filters-grid { grid-template-columns: 1fr; }
.tabs-nav { gap: 0; overflow-x: auto; }
.tab-btn { padding: 12px 14px; font-size: 0.8125rem; }
}
</style>
</head>
<body>
<!-- ═══ Header ═══ -->
<header class="app-header">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
</svg>
<h1>Поиск товаров/услуг и конвертер ЕИ</h1>
</header>
<!-- ═══ Tabs ═══ -->
<nav class="tabs-nav">
<button class="tab-btn active" data-tab="search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
</svg>
Поиск товаров/услуг
</button>
<button class="tab-btn" data-tab="convert">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>
<polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>
</svg>
Конвертер ЕИ
</button>
</nav>
<!-- ═══ Main ═══ -->
<main class="app-main">
<!-- ════════════════════════════════════ -->
<!-- TAB 1: ПОИСК ТОВАРОВ/УСЛУГ -->
<!-- ════════════════════════════════════ -->
<div class="tab-panel active" id="panel-search">
<div class="section-label">Параметры поиска</div>
<form class="form-card" id="searchForm" autocomplete="off">
<div class="field">
<label class="field-label" for="s_product">Наименование товара/услуги <span class="req">*</span></label>
<input type="text" id="s_product" placeholder="Введите наименование товара или услуги" required>
</div>
<!-- Advanced -->
<div class="collapse-panel" id="s_advPanel" data-open="false">
<div class="collapse-inner">
<div class="advanced-fields">
<div class="field">
<label class="field-label" for="s_region">Регион</label>
<select id="s_region">
<option value="">Все регионы</option>
<option>Москва</option><option>Санкт-Петербург</option>
<option>Новосибирская область</option><option>Свердловская область</option>
<option>Краснодарский край</option><option>Республика Татарстан</option>
</select>
</div>
<div class="field">
<label class="field-label" for="s_unit">Единица измерения</label>
<select id="s_unit">
<option value="">Любая</option>
<option>шт</option><option>кг</option><option>л</option>
<option>м</option><option>упак</option><option>компл</option>
</select>
</div>
<div class="field">
<label class="field-label" for="s_qty">Количество</label>
<input type="number" id="s_qty" placeholder="Например, 100" min="0">
</div>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" id="searchBtn" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
</svg>
Найти похожие товары/услуги
</button>
<button type="button" class="advanced-toggle" id="s_advToggle" aria-expanded="false">
<span>Дополнительные параметры</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
</div>
</form>
<!-- Search results -->
<div id="s_empty" class="empty-state" style="margin-top:2rem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/><path d="M11 8v6"/><path d="M8 11h6"/>
</svg>
<p><strong>Введите наименование товара или услуги</strong> и нажмите «Найти» — система подберёт похожие позиции из реестра закупок</p>
</div>
<div class="hidden" id="s_results" style="margin-top:1.5rem">
<div style="display:flex;align-items:baseline;gap:12px;margin-bottom:1.25rem;flex-wrap:wrap">
<div class="section-label" style="margin-bottom:0">Результаты</div>
<span style="font-size:0.8125rem;color:var(--text-tertiary)">Найдено <strong id="s_totalCount" style="font-family:var(--font-mono);font-size:0.8rem;color:var(--text-primary);font-weight:600">0</strong> записей</span>
</div>
<!-- Filters -->
<div class="filters-bar">
<div class="filters-grid">
<div class="field">
<label class="field-label" for="f_region">Регион</label>
<select id="f_region"><option value="">Все</option></select>
</div>
<div class="field">
<label class="field-label" for="f_unit">Ед. измерения</label>
<select id="f_unit"><option value="">Все</option></select>
</div>
<div class="filters-actions">
<button type="button" class="btn btn-primary btn-sm" id="applyFilters">Применить</button>
<button type="button" class="btn btn-ghost btn-sm" id="resetFilters">Сбросить</button>
</div>
</div>
</div>
<!-- Table -->
<div class="table-wrap">
<div class="table-scroll">
<table>
<thead>
<tr>
<th style="width:52px">#</th>
<th>Наименование (raw)</th>
<th>Норм. наименование</th>
<th>Регион</th>
<th>Ед. изм.</th>
<th>ID контракта</th>
</tr>
</thead>
<tbody id="s_tbody"></tbody>
</table>
</div>
<div class="table-footer">
<span id="s_shownInfo">Показано 0 из 0</span>
</div>
</div>
</div>
</div>
<!-- ════════════════════════════════════ -->
<!-- TAB 2: КОНВЕРТЕР ЕИ -->
<!-- ════════════════════════════════════ -->
<div class="tab-panel" id="panel-convert">
<div class="section-label">Конвертер единиц измерения</div>
<form class="form-card" id="convertForm" autocomplete="off">
<div class="field" style="margin-bottom:1rem">
<label class="field-label" for="c_product">Наименование товара/услуги <span class="req">*</span></label>
<input type="text" id="c_product" placeholder="Введите наименование товара или услуги" required>
</div>
<div class="grid-3">
<div class="field">
<label class="field-label" for="c_sourceUnit">Исходная ЕИ <span class="req">*</span></label>
<input type="text" id="c_sourceUnit" placeholder="Например: кг" required>
</div>
<div class="field">
<label class="field-label" for="c_targetUnit">Целевая ЕИ <span class="req">*</span></label>
<input type="text" id="c_targetUnit" placeholder="Например: шт" required>
</div>
<div class="field">
<label class="field-label" for="c_attributes">Характеристики</label>
<input type="text" id="c_attributes" placeholder="Например: фракция 0-40, объём 500мл">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" id="convertBtn" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>
<polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>
</svg>
Конвертировать
</button>
</div>
</form>
<!-- Converter empty state -->
<div id="c_empty" class="empty-state" style="margin-top:2rem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>
<polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>
</svg>
<p><strong>Укажите товар/услугу и единицы измерения</strong> — система рассчитает коэффициент пересчёта на основе данных из реестра закупок</p>
</div>
<!-- Converter result (будет подключён когда появится API) -->
<div class="hidden" id="c_result">
<div class="result-card">
<div style="font-size:0.75rem;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:var(--text-tertiary);margin-bottom:1rem">Коэффициент пересчёта</div>
<div class="coeff-display">
<span class="coeff-value" id="c_coeffValue"></span>
<span class="coeff-label" id="c_coeffLabel"></span>
</div>
<div class="coeff-formula" id="c_formula"></div>
</div>
</div>
</div>
</main>
<script>
// ═══ Tabs ═══
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('panel-' + btn.dataset.tab).classList.add('active');
});
});
// ═══ TAB 1: Search ═══
let allResults = [], filteredResults = [];
const searchForm = document.getElementById('searchForm');
const searchBtn = document.getElementById('searchBtn');
const s_product = document.getElementById('s_product');
// Validate
function validateSearch() { searchBtn.disabled = !s_product.value.trim(); }
s_product.addEventListener('input', validateSearch);
// Advanced toggle
document.getElementById('s_advToggle').addEventListener('click', function() {
const panel = document.getElementById('s_advPanel');
const open = panel.dataset.open === 'true';
panel.dataset.open = String(!open);
this.setAttribute('aria-expanded', String(!open));
});
// Submit search
searchForm.addEventListener('submit', async (e) => {
e.preventDefault();
const queryText = s_product.value.trim();
const topK = 20;
searchBtn.disabled = true;
searchBtn.textContent = 'Поиск…';
try {
const resp = await fetch('/app/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query_text: queryText, top_k: topK, use_rerank: false, rerank_strategy: 'lexical' })
});
if (!resp.ok) throw new Error('Ошибка сервера: ' + resp.status);
const data = await resp.json();
allResults = data.results || [];
} catch (err) {
alert('Не удалось выполнить поиск: ' + err.message);
searchBtn.disabled = false;
searchBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg> Найти похожие товары/услуги';
return;
}
searchBtn.disabled = false;
searchBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg> Найти похожие товары/услуги';
const regions = [...new Set(allResults.map(r => r.region).filter(Boolean))];
const units = [...new Set(allResults.map(r => r.unit).filter(Boolean))];
document.getElementById('f_region').innerHTML = '<option value="">Все</option>' + regions.map(r => `<option>${r}</option>`).join('');
document.getElementById('f_unit').innerHTML = '<option value="">Все</option>' + units.map(u => `<option>${u}</option>`).join('');
applySearchFilters();
document.getElementById('s_empty').classList.add('hidden');
document.getElementById('s_results').classList.remove('hidden');
document.getElementById('s_results').style.animation = 'none';
requestAnimationFrame(() => { document.getElementById('s_results').style.animation = 'fadeUp 0.3s cubic-bezier(0.16,1,0.3,1) both'; });
});
function applySearchFilters() {
const rVal = document.getElementById('f_region').value;
const uVal = document.getElementById('f_unit').value;
filteredResults = allResults.filter(r => {
if (rVal && r.region !== rVal) return false;
if (uVal && r.unit !== uVal) return false;
return true;
});
renderSearchTable();
}
document.getElementById('applyFilters').addEventListener('click', applySearchFilters);
document.getElementById('resetFilters').addEventListener('click', () => {
document.getElementById('f_region').value = '';
document.getElementById('f_unit').value = '';
applySearchFilters();
});
function renderSearchTable() {
document.getElementById('s_totalCount').textContent = filteredResults.length;
document.getElementById('s_shownInfo').textContent = `Показано ${filteredResults.length} из ${allResults.length}`;
const tbody = document.getElementById('s_tbody');
if (!filteredResults.length) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:2.5rem;color:var(--text-tertiary)">Нет записей</td></tr>';
return;
}
tbody.innerHTML = filteredResults.map((r, i) => `
<tr>
<td class="cell-num">${i + 1}</td>
<td class="cell-name">${r.raw_name || ''}</td>
<td class="cell-name">${r.norm_name || ''}</td>
<td class="cell-region">${r.region || '—'}</td>
<td class="cell-unit">${r.unit || ''}</td>
<td class="cell-num">${r.contract_id || ''}</td>
</tr>
`).join('');
}
// ═══ TAB 2: Converter ═══
const convertForm = document.getElementById('convertForm');
const convertBtn = document.getElementById('convertBtn');
const c_product = document.getElementById('c_product');
const c_src = document.getElementById('c_sourceUnit');
const c_tgt = document.getElementById('c_targetUnit');
function validateConvert() {
convertBtn.disabled = !(c_product.value.trim() && c_src.value.trim() && c_tgt.value.trim());
}
[c_product, c_src, c_tgt].forEach(el => el.addEventListener('input', validateConvert));
convertForm.addEventListener('submit', (e) => {
e.preventDefault();
// TODO: подключить API конвертера, когда будет готов endpoint
});
</script>
</body>
</html>

178
www/rc_openapi.json Normal file
View File

@@ -0,0 +1,178 @@
{
"openapi": "3.1.0",
"info": { "title": "Product Matcher Region UI", "version": "1.0.0" },
"servers": [{ "url": "/region_search" }],
"paths": {
"/health": {
"get": {
"summary": "Health",
"operationId": "health_health_get",
"responses": {
"200": {
"description": "Successful Response",
"content": { "application/json": { "schema": {} } }
}
}
}
},
"/": {
"get": {
"summary": "Home",
"operationId": "home__get",
"responses": {
"200": {
"description": "Successful Response",
"content": { "text/html": { "schema": { "type": "string" } } }
}
}
}
},
"/search": {
"post": {
"summary": "Search Form",
"operationId": "search_form_search_post",
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/Body_search_form_search_post"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": { "text/html": { "schema": { "type": "string" } } }
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/search": {
"post": {
"summary": "Search Api",
"operationId": "search_api_api_search_post",
"requestBody": {
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SearchRegionRequest" }
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": { "application/json": { "schema": {} } }
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
}
},
"components": {
"schemas": {
"Body_search_form_search_post": {
"properties": {
"query_text": {
"type": "string",
"title": "Query Text",
"default": "песчано-гравийная смесь фракция 0-40"
},
"region": {
"type": "string",
"title": "Region",
"default": "москва"
},
"top_k": { "type": "integer", "title": "Top K", "default": 20 },
"use_rerank": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Use Rerank"
},
"rerank_strategy": {
"type": "string",
"title": "Rerank Strategy",
"default": "lexical"
}
},
"type": "object",
"title": "Body_search_form_search_post"
},
"HTTPValidationError": {
"properties": {
"detail": {
"items": { "$ref": "#/components/schemas/ValidationError" },
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
},
"SearchRegionRequest": {
"properties": {
"query_text": {
"type": "string",
"title": "Query Text",
"default": "песчано-гравийная смесь фракция 0-40"
},
"region": {
"type": "string",
"title": "Region",
"default": "москва"
},
"top_k": {
"type": "integer",
"maximum": 1000.0,
"minimum": 1.0,
"title": "Top K",
"default": 20
},
"use_rerank": {
"type": "boolean",
"title": "Use Rerank",
"default": false
},
"rerank_strategy": {
"type": "string",
"title": "Rerank Strategy",
"default": "lexical"
}
},
"type": "object",
"title": "SearchRegionRequest"
},
"ValidationError": {
"properties": {
"loc": {
"items": { "anyOf": [{ "type": "string" }, { "type": "integer" }] },
"type": "array",
"title": "Location"
},
"msg": { "type": "string", "title": "Message" },
"type": { "type": "string", "title": "Error Type" },
"input": { "title": "Input" },
"ctx": { "type": "object", "title": "Context" }
},
"type": "object",
"required": ["loc", "msg", "type"],
"title": "ValidationError"
}
}
}
}

178
www/uc_openapi.json Normal file
View File

@@ -0,0 +1,178 @@
{
"openapi": "3.1.0",
"info": {
"title": "Конвертер ЕИ (simple_frontend)",
"description": "Обёртка над `unit_converter/pipeline.py`: приём параметров конвертации, запуск пайплайна, возврат логов. Интерфейс: статическая страница `/`.",
"version": "1.0.0"
},
"servers": [{ "url": "/uc" }],
"paths": {
"/api/units": {
"get": {
"tags": ["Справочники"],
"summary": "Подсказки по единицам измерения",
"operationId": "api_units_api_units_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/UnitsResponse" }
}
}
}
}
}
},
"/api/convert": {
"post": {
"tags": ["Конвертация"],
"summary": "Запуск конвертации (pipeline.py)",
"operationId": "api_convert_api_convert_post",
"requestBody": {
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ConvertRequest" }
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ConvertResponse" }
}
}
},
"400": { "description": "Не заполнены обязательные поля" },
"500": {
"description": "Ошибка запуска pipeline (например нет config/скрипта)"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
}
},
"components": {
"schemas": {
"ConvertRequest": {
"properties": {
"product_name": {
"type": "string",
"title": "Product Name",
"description": "Наименование товара или услуги"
},
"source_unit": {
"type": "string",
"title": "Source Unit",
"description": "Исходная единица измерения"
},
"target_unit": {
"type": "string",
"title": "Target Unit",
"description": "Целевая единица измерения"
},
"supplier_name": {
"type": "string",
"title": "Supplier Name",
"description": "Поставщик (необязательно)",
"default": ""
},
"product_characteristics": {
"type": "string",
"title": "Product Characteristics",
"description": "Текст характеристик (необязательно)",
"default": ""
},
"quantity": {
"type": "number",
"minimum": 0.0,
"title": "Quantity",
"description": "Количество в исходных ЕИ",
"default": 1.0
}
},
"type": "object",
"required": ["product_name", "source_unit", "target_unit"],
"title": "ConvertRequest"
},
"ConvertResponse": {
"properties": {
"process_ok": {
"type": "boolean",
"title": "Process Ok",
"description": "Процесс pipeline завершился с кодом 0"
},
"conversion_success": {
"type": "boolean",
"title": "Conversion Success",
"description": "True, если в выводе нет «перевод невозможен»"
},
"message": {
"type": "string",
"title": "Message",
"description": "Краткий итог для UI"
},
"pipeline_output": {
"type": "string",
"title": "Pipeline Output",
"description": "Полный stdout+stderr pipeline.py (логи)",
"default": ""
}
},
"type": "object",
"required": ["process_ok", "conversion_success", "message"],
"title": "ConvertResponse"
},
"HTTPValidationError": {
"properties": {
"detail": {
"items": { "$ref": "#/components/schemas/ValidationError" },
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
},
"UnitsResponse": {
"properties": {
"units": {
"items": { "type": "string" },
"type": "array",
"title": "Units",
"description": "Список наименований ЕИ из exist_units.csv"
}
},
"type": "object",
"required": ["units"],
"title": "UnitsResponse"
},
"ValidationError": {
"properties": {
"loc": {
"items": { "anyOf": [{ "type": "string" }, { "type": "integer" }] },
"type": "array",
"title": "Location"
},
"msg": { "type": "string", "title": "Message" },
"type": { "type": "string", "title": "Error Type" },
"input": { "title": "Input" },
"ctx": { "type": "object", "title": "Context" }
},
"type": "object",
"required": ["loc", "msg", "type"],
"title": "ValidationError"
}
}
}
}