| <!DOCTYPE html><html lang="ru"> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| <title>Подбор запчастей</title> |
| <style> |
| :root { |
| --bg: #0b1020; |
| --panel: rgba(17, 24, 39, 0.86); |
| --panel-2: rgba(15, 23, 42, 0.9); |
| --card: rgba(255, 255, 255, 0.06); |
| --card-2: rgba(255, 255, 255, 0.09); |
| --text: #e5eefb; |
| --muted: #9fb0cc; |
| --line: rgba(255, 255, 255, 0.1); |
| --accent: #6d8dff; |
| --accent-2: #47d7ac; |
| --danger: #ff6b7a; |
| --warn: #ffcb66; |
| --shadow: 0 18px 50px rgba(0, 0, 0, 0.35); |
| --radius: 22px; |
| --radius-sm: 14px; |
| --radius-xs: 10px; |
| }* { box-sizing: border-box; } |
| html, body { height: 100%; } |
| body { |
| margin: 0; |
| font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; |
| color: var(--text); |
| background: |
| radial-gradient(circle at 15% 10%, rgba(109, 141, 255, 0.22), transparent 25%), |
| radial-gradient(circle at 85% 5%, rgba(71, 215, 172, 0.16), transparent 20%), |
| radial-gradient(circle at 50% 100%, rgba(255, 203, 102, 0.09), transparent 28%), |
| linear-gradient(180deg, #060913 0%, #0a1020 45%, #070b15 100%); |
| } |
| |
| .app { |
| max-width: 1440px; |
| margin: 0 auto; |
| padding: 20px; |
| } |
| |
| .topbar { |
| display: flex; |
| gap: 14px; |
| align-items: stretch; |
| justify-content: space-between; |
| margin-bottom: 16px; |
| flex-wrap: wrap; |
| } |
| |
| .brand { |
| flex: 1 1 320px; |
| background: linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.04)); |
| border: 1px solid var(--line); |
| border-radius: var(--radius); |
| padding: 18px 20px; |
| box-shadow: var(--shadow); |
| backdrop-filter: blur(14px); |
| } |
| |
| .brand h1 { |
| margin: 0 0 6px; |
| font-size: clamp(24px, 4vw, 38px); |
| line-height: 1.05; |
| letter-spacing: -0.03em; |
| } |
| |
| .brand p { |
| margin: 0; |
| color: var(--muted); |
| max-width: 80ch; |
| line-height: 1.45; |
| font-size: 14px; |
| } |
| |
| .status { |
| flex: 0 1 360px; |
| background: linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.04)); |
| border: 1px solid var(--line); |
| border-radius: var(--radius); |
| padding: 16px 18px; |
| box-shadow: var(--shadow); |
| display: grid; |
| gap: 10px; |
| backdrop-filter: blur(14px); |
| } |
| |
| .status-grid { |
| display: grid; |
| grid-template-columns: repeat(3, minmax(0, 1fr)); |
| gap: 10px; |
| } |
| |
| .pill { |
| background: rgba(255, 255, 255, 0.06); |
| border: 1px solid var(--line); |
| border-radius: 16px; |
| padding: 10px 12px; |
| min-height: 60px; |
| } |
| |
| .pill .label { |
| font-size: 11px; |
| color: var(--muted); |
| margin-bottom: 5px; |
| text-transform: uppercase; |
| letter-spacing: 0.08em; |
| } |
| |
| .pill .value { |
| font-size: 14px; |
| font-weight: 700; |
| line-height: 1.25; |
| } |
| |
| .layout { |
| display: grid; |
| grid-template-columns: 340px 1fr 360px; |
| gap: 16px; |
| align-items: start; |
| } |
| |
| @media (max-width: 1180px) { |
| .layout { grid-template-columns: 1fr; } |
| .status { flex-basis: 100%; } |
| } |
| |
| .panel { |
| background: linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.04)); |
| border: 1px solid var(--line); |
| border-radius: var(--radius); |
| box-shadow: var(--shadow); |
| backdrop-filter: blur(16px); |
| overflow: hidden; |
| } |
| |
| .panel-head { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| gap: 10px; |
| padding: 16px 18px; |
| border-bottom: 1px solid var(--line); |
| background: rgba(255,255,255,0.03); |
| } |
| |
| .panel-head h2 { |
| margin: 0; |
| font-size: 18px; |
| letter-spacing: -0.02em; |
| } |
| |
| .panel-head small { |
| color: var(--muted); |
| } |
| |
| .panel-body { |
| padding: 16px 18px 18px; |
| } |
| |
| .stack { display: grid; gap: 12px; } |
| .grid-2 { display: grid; gap: 12px; grid-template-columns: 1fr 1fr; } |
| .grid-3 { display: grid; gap: 12px; grid-template-columns: repeat(3, 1fr); } |
| @media (max-width: 720px) { |
| .grid-2, .grid-3 { grid-template-columns: 1fr; } |
| .status-grid { grid-template-columns: 1fr; } |
| } |
| |
| .field { |
| display: grid; |
| gap: 7px; |
| } |
| |
| .field label { |
| font-size: 12px; |
| color: var(--muted); |
| } |
| |
| input, select, textarea, button { |
| font: inherit; |
| } |
| |
| input[type="text"], input[type="search"], input[type="password"], select, textarea { |
| width: 100%; |
| background: rgba(8, 12, 22, 0.75); |
| color: var(--text); |
| border: 1px solid rgba(255,255,255,0.12); |
| border-radius: 14px; |
| padding: 12px 14px; |
| outline: none; |
| transition: 0.18s ease; |
| } |
| |
| textarea { resize: vertical; min-height: 88px; } |
| |
| input:focus, select:focus, textarea:focus { |
| border-color: rgba(109, 141, 255, 0.7); |
| box-shadow: 0 0 0 4px rgba(109, 141, 255, 0.15); |
| } |
| |
| .btn-row { |
| display: flex; |
| gap: 10px; |
| flex-wrap: wrap; |
| } |
| |
| button { |
| border: 0; |
| border-radius: 14px; |
| padding: 12px 14px; |
| color: #fff; |
| background: linear-gradient(135deg, var(--accent), #8a6dff); |
| cursor: pointer; |
| font-weight: 700; |
| letter-spacing: 0.01em; |
| transition: transform 0.16s ease, filter 0.16s ease, opacity 0.16s ease; |
| } |
| |
| button:hover { transform: translateY(-1px); filter: brightness(1.05); } |
| button:active { transform: translateY(0); opacity: 0.92; } |
| button.secondary { background: rgba(255,255,255,0.08); border: 1px solid var(--line); } |
| button.success { background: linear-gradient(135deg, var(--accent-2), #2ea0ff); } |
| button.danger { background: linear-gradient(135deg, var(--danger), #d14be2); } |
| button.ghost { background: transparent; border: 1px solid var(--line); color: var(--text); } |
| |
| .hint { |
| color: var(--muted); |
| font-size: 12px; |
| line-height: 1.45; |
| } |
| |
| .vin-ok { color: #7ff0b0; } |
| .vin-bad { color: #ff8d99; } |
| |
| .car-chip-list { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 8px; |
| } |
| |
| .chip { |
| display: inline-flex; |
| align-items: center; |
| gap: 8px; |
| padding: 9px 12px; |
| border-radius: 999px; |
| background: rgba(255,255,255,0.07); |
| border: 1px solid var(--line); |
| color: var(--text); |
| font-size: 13px; |
| cursor: pointer; |
| user-select: none; |
| } |
| |
| .chip[data-active="true"] { |
| background: rgba(109, 141, 255, 0.2); |
| border-color: rgba(109, 141, 255, 0.7); |
| } |
| |
| .list { |
| display: grid; |
| gap: 10px; |
| } |
| |
| .part-card { |
| background: rgba(255,255,255,0.06); |
| border: 1px solid var(--line); |
| border-radius: 18px; |
| padding: 14px; |
| display: grid; |
| gap: 10px; |
| } |
| |
| .part-top { |
| display: flex; |
| justify-content: space-between; |
| gap: 12px; |
| align-items: start; |
| } |
| |
| .part-name { |
| margin: 0; |
| font-size: 16px; |
| line-height: 1.2; |
| } |
| |
| .part-meta { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 8px; |
| margin-top: 8px; |
| } |
| |
| .tag { |
| display: inline-flex; |
| align-items: center; |
| gap: 6px; |
| font-size: 12px; |
| padding: 6px 9px; |
| border-radius: 999px; |
| background: rgba(255,255,255,0.07); |
| border: 1px solid var(--line); |
| color: var(--muted); |
| } |
| |
| .tag strong { color: var(--text); font-weight: 700; } |
| |
| .price { |
| font-weight: 800; |
| font-size: 18px; |
| white-space: nowrap; |
| } |
| |
| .part-actions { |
| display: flex; |
| gap: 8px; |
| flex-wrap: wrap; |
| } |
| |
| .part-actions button { padding: 9px 11px; font-size: 13px; border-radius: 12px; } |
| |
| .results-head { |
| display: flex; |
| justify-content: space-between; |
| gap: 12px; |
| align-items: center; |
| margin-bottom: 14px; |
| flex-wrap: wrap; |
| } |
| |
| .results-head h3 { |
| margin: 0; |
| font-size: 18px; |
| } |
| |
| .results-head p { |
| margin: 3px 0 0; |
| color: var(--muted); |
| font-size: 13px; |
| } |
| |
| .right-tabs { |
| display: flex; |
| gap: 8px; |
| flex-wrap: wrap; |
| padding: 0 18px 16px; |
| } |
| |
| .tab { |
| border: 1px solid var(--line); |
| background: rgba(255,255,255,0.05); |
| color: var(--text); |
| border-radius: 999px; |
| padding: 8px 12px; |
| cursor: pointer; |
| font-size: 13px; |
| } |
| |
| .tab.active { |
| background: rgba(109, 141, 255, 0.22); |
| border-color: rgba(109, 141, 255, 0.7); |
| } |
| |
| .chat { |
| display: grid; |
| gap: 10px; |
| } |
| |
| .chat-log { |
| display: grid; |
| gap: 10px; |
| max-height: 420px; |
| overflow: auto; |
| padding-right: 4px; |
| } |
| |
| .bubble { |
| max-width: 100%; |
| border-radius: 16px; |
| padding: 12px 13px; |
| border: 1px solid var(--line); |
| background: rgba(255,255,255,0.06); |
| line-height: 1.45; |
| font-size: 14px; |
| white-space: pre-wrap; |
| } |
| |
| .bubble.me { |
| background: rgba(109, 141, 255, 0.15); |
| border-color: rgba(109, 141, 255, 0.35); |
| } |
| |
| .bubble.ai { |
| background: rgba(71, 215, 172, 0.12); |
| border-color: rgba(71, 215, 172, 0.28); |
| } |
| |
| .tiny { |
| font-size: 12px; |
| color: var(--muted); |
| } |
| |
| .divider { |
| height: 1px; |
| background: var(--line); |
| margin: 8px 0; |
| } |
| |
| .modal-backdrop { |
| position: fixed; |
| inset: 0; |
| background: rgba(0,0,0,0.55); |
| display: none; |
| align-items: center; |
| justify-content: center; |
| padding: 20px; |
| z-index: 100; |
| } |
| |
| .modal-backdrop.show { display: flex; } |
| |
| .modal { |
| width: min(920px, 100%); |
| max-height: 90vh; |
| overflow: auto; |
| background: linear-gradient(180deg, rgba(18,25,44,0.98), rgba(10,14,25,0.98)); |
| border: 1px solid var(--line); |
| border-radius: 24px; |
| box-shadow: var(--shadow); |
| } |
| |
| .modal-head, .modal-foot { |
| padding: 16px 18px; |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| gap: 10px; |
| border-bottom: 1px solid var(--line); |
| } |
| |
| .modal-foot { border-top: 1px solid var(--line); border-bottom: 0; } |
| |
| .modal-body { padding: 18px; } |
| |
| .detail-grid { |
| display: grid; |
| grid-template-columns: 1.1fr 0.9fr; |
| gap: 16px; |
| } |
| |
| @media (max-width: 840px) { |
| .detail-grid { grid-template-columns: 1fr; } |
| } |
| |
| .detail-box { |
| background: rgba(255,255,255,0.05); |
| border: 1px solid var(--line); |
| border-radius: 18px; |
| padding: 14px; |
| } |
| |
| .detail-box h4 { |
| margin: 0 0 10px; |
| font-size: 15px; |
| } |
| |
| .detail-list { |
| margin: 0; |
| padding-left: 18px; |
| color: var(--text); |
| line-height: 1.55; |
| } |
| |
| .detail-list li { margin-bottom: 6px; } |
| |
| .callout { |
| background: rgba(109, 141, 255, 0.12); |
| border: 1px solid rgba(109, 141, 255, 0.35); |
| border-radius: 18px; |
| padding: 12px 14px; |
| color: var(--text); |
| line-height: 1.5; |
| } |
| |
| .footer-note { |
| margin-top: 16px; |
| color: var(--muted); |
| font-size: 12px; |
| text-align: center; |
| line-height: 1.5; |
| } |
| |
| .mini-grid { |
| display: grid; |
| grid-template-columns: repeat(4, 1fr); |
| gap: 10px; |
| } |
| |
| @media (max-width: 900px) { |
| .mini-grid { grid-template-columns: 1fr 1fr; } |
| } |
| |
| @media (max-width: 520px) { |
| .mini-grid { grid-template-columns: 1fr; } |
| } |
| |
| .metric { |
| padding: 12px; |
| border-radius: 16px; |
| background: rgba(255,255,255,0.06); |
| border: 1px solid var(--line); |
| } |
| |
| .metric .num { font-size: 22px; font-weight: 900; } |
| .metric .txt { font-size: 12px; color: var(--muted); margin-top: 4px; } |
| |
| .kbd { |
| font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; |
| font-size: 12px; |
| padding: 2px 6px; |
| border-radius: 6px; |
| border: 1px solid var(--line); |
| background: rgba(255,255,255,0.06); |
| } |
| |
| </style> |
| </head> |
| <body> |
| <div class="app"> |
| <div class="topbar"> |
| <div class="brand"> |
| <h1>Подбор запчастей</h1> |
| <p>Один файл. Работает как автономный каталог с поиском по автомобилю, VIN, названию детали и необязательным ИИ-помощником. Можно запускать сразу в браузере без сборки.</p> |
| </div> |
| <div class="status"> |
| <div class="status-grid"> |
| <div class="pill"> |
| <div class="label">Авто</div> |
| <div class="value" id="statusCar">Не выбрано</div> |
| </div> |
| <div class="pill"> |
| <div class="label">Запчастей</div> |
| <div class="value" id="statusCount">0</div> |
| </div> |
| <div class="pill"> |
| <div class="label">Режим</div> |
| <div class="value" id="statusMode">Локальный</div> |
| </div> |
| </div> |
| </div> |
| </div><div class="layout"> |
| |
| <section class="panel"> |
| <div class="panel-head"> |
| <div> |
| <h2>Автомобиль</h2> |
| <small>Подбор по марке, модели, году и двигателю</small> |
| </div> |
| <button class="ghost" id="btnResetCar">Сброс</button> |
| </div> |
| <div class="panel-body stack"> |
| <div class="field"> |
| <label for="vinInput">VIN</label> |
| <input id="vinInput" type="text" placeholder="17 символов, например WVWZZZ3CZ8E123456" maxlength="17" autocomplete="off" /> |
| <div class="hint" id="vinHint">Можно просто ввести VIN, чтобы попытаться определить автомобиль по базе.</div> |
| </div> |
|
|
| <div class="grid-2"> |
| <div class="field"> |
| <label for="brandSelect">Марка</label> |
| <select id="brandSelect"></select> |
| </div> |
| <div class="field"> |
| <label for="modelSelect">Модель</label> |
| <select id="modelSelect"></select> |
| </div> |
| </div> |
|
|
| <div class="grid-2"> |
| <div class="field"> |
| <label for="yearSelect">Год</label> |
| <select id="yearSelect"></select> |
| </div> |
| <div class="field"> |
| <label for="engineSelect">Двигатель</label> |
| <select id="engineSelect"></select> |
| </div> |
| </div> |
|
|
| <div class="grid-2"> |
| <div class="field"> |
| <label for="bodySelect">Кузов</label> |
| <select id="bodySelect"></select> |
| </div> |
| <div class="field"> |
| <label for="transSelect">Коробка</label> |
| <select id="transSelect"></select> |
| </div> |
| </div> |
|
|
| <div class="btn-row"> |
| <button id="btnApplyCar">Применить</button> |
| <button class="secondary" id="btnFindByVin">По VIN</button> |
| </div> |
|
|
| <div class="hint"> |
| Подсказка: сначала выбери автомобиль вручную, потом ищи деталь. Для более точного поиска можно использовать VIN или сразу написать в поиск название детали. |
| </div> |
|
|
| <div> |
| <div class="tiny" style="margin-bottom:8px;">Популярные быстрые шаблоны</div> |
| <div class="car-chip-list" id="quickCars"></div> |
| </div> |
|
|
| <div class="divider"></div> |
|
|
| <div class="mini-grid"> |
| <div class="metric"><div class="num" id="metricBrands">0</div><div class="txt">марок в базе</div></div> |
| <div class="metric"><div class="num" id="metricModels">0</div><div class="txt">моделей</div></div> |
| <div class="metric"><div class="num" id="metricParts">0</div><div class="txt">типов запчастей</div></div> |
| <div class="metric"><div class="num" id="metricFavs">0</div><div class="txt">избранных</div></div> |
| </div> |
| </div> |
| </section> |
|
|
| |
| <section class="panel"> |
| <div class="panel-head"> |
| <div> |
| <h2>Каталог</h2> |
| <small>Поиск по названию, OEM, аналогу или функции</small> |
| </div> |
| <small id="resultsMeta">0 найдено</small> |
| </div> |
| <div class="panel-body"> |
| <div class="stack" style="margin-bottom:14px;"> |
| <div class="field"> |
| <label for="searchInput">Поиск запчасти</label> |
| <input id="searchInput" type="search" placeholder="Например: тормозные колодки, фильтр, насос, левый шрус..." /> |
| </div> |
| <div class="grid-3"> |
| <div class="field"> |
| <label for="categorySelect">Категория</label> |
| <select id="categorySelect"></select> |
| </div> |
| <div class="field"> |
| <label for="priceSelect">Сортировка</label> |
| <select id="priceSelect"> |
| <option value="relevance">По релевантности</option> |
| <option value="priceAsc">Цена: по возрастанию</option> |
| <option value="priceDesc">Цена: по убыванию</option> |
| <option value="nameAsc">Название: A→Z</option> |
| </select> |
| </div> |
| <div class="field"> |
| <label for="stockFilter">Наличие</label> |
| <select id="stockFilter"> |
| <option value="all">Любое</option> |
| <option value="in">В наличии</option> |
| <option value="few">Мало</option> |
| <option value="out">Нет</option> |
| </select> |
| </div> |
| </div> |
| <div class="btn-row"> |
| <button class="success" id="btnSearch">Найти</button> |
| <button class="secondary" id="btnClearFilters">Очистить фильтры</button> |
| </div> |
| </div> |
|
|
| <div class="results-head"> |
| <div> |
| <h3>Результаты</h3> |
| <p id="resultsDescription">Выбери автомобиль и введи запрос, чтобы увидеть совместимые позиции.</p> |
| </div> |
| <div class="tiny">Быстрый фильтр: <span class="kbd">/</span> фокус на поиск</div> |
| </div> |
|
|
| <div class="list" id="resultsList"></div> |
| </div> |
| </section> |
|
|
| |
| <section class="panel"> |
| <div class="panel-head"> |
| <div> |
| <h2>ИИ / Помощник</h2> |
| <small>Локально или через Mistral, если ключ введён вручную</small> |
| </div> |
| <small id="aiState">Локальные подсказки</small> |
| </div> |
| <div class="right-tabs"> |
| <button class="tab active" data-tab="assistant">Помощник</button> |
| <button class="tab" data-tab="favorites">Избранное</button> |
| <button class="tab" data-tab="history">История</button> |
| <button class="tab" data-tab="settings">Настройки</button> |
| </div> |
|
|
| <div class="panel-body"> |
| <div id="tab-assistant" class="tab-page chat"> |
| <div class="chat-log" id="chatLog"></div> |
| <div class="field"> |
| <label for="chatInput">Сообщение</label> |
| <textarea id="chatInput" placeholder="Напиши: нужны колодки на passat b6 2.0 tdi или какие аналоги лучше для фильтра масла"></textarea> |
| </div> |
| <div class="btn-row"> |
| <button id="btnAskAI">Спросить</button> |
| <button class="secondary" id="btnSuggestFromCar">Подобрать по авто</button> |
| </div> |
| <div class="hint"> |
| ИИ здесь работает как помощник по каталогу: он не выдумывает совместимость, а опирается на базу и подсказки. Ключ Mistral можно вставить в настройках — код сам отправит запросы на API. |
| </div> |
| </div> |
|
|
| <div id="tab-favorites" class="tab-page" style="display:none;"> |
| <div class="stack"> |
| <div class="tiny">Сохранённые позиции</div> |
| <div class="list" id="favoritesList"></div> |
| </div> |
| </div> |
|
|
| <div id="tab-history" class="tab-page" style="display:none;"> |
| <div class="stack"> |
| <div class="tiny">Последние запросы</div> |
| <div class="list" id="historyList"></div> |
| </div> |
| </div> |
|
|
| <div id="tab-settings" class="tab-page" style="display:none;"> |
| <div class="stack"> |
| <div class="field"> |
| <label for="apiKeyInput">Mistral API ключ</label> |
| <input id="apiKeyInput" type="password" placeholder="Вставь ключ вручную, если хочешь включить облачный ИИ" /> |
| <div class="hint">Ключ хранится только в браузере в localStorage на этом устройстве. Лучше использовать отдельный тестовый ключ и не публиковать этот файл с ключом внутри.</div> |
| </div> |
| <div class="field"> |
| <label for="modelInput">Модель</label> |
| <input id="modelInput" type="text" value="mistral-small-latest" /> |
| </div> |
| <div class="grid-2"> |
| <button id="btnSaveSettings">Сохранить</button> |
| <button class="secondary" id="btnClearData">Стереть данные</button> |
| </div> |
| <div class="callout"> |
| По умолчанию приложение работает полностью локально. Если ключ не задан, ответы строятся из правил и базы совместимости. Это безопаснее и быстрее для простого подбора. |
| </div> |
| </div> |
| </div> |
| </div> |
| </section> |
| </div> |
|
|
| <div class="footer-note"> |
| Горячие клавиши: <span class="kbd">/</span> — поиск, <span class="kbd">Enter</span> — применить первый подходящий поиск, <span class="kbd">Esc</span> — закрыть окно деталей. |
| </div> |
|
|
| </div> <div class="modal-backdrop" id="modalBackdrop" aria-hidden="true"> |
| <div class="modal"> |
| <div class="modal-head"> |
| <div> |
| <div class="tiny" id="modalCategory">Категория</div> |
| <h3 style="margin:4px 0 0;" id="modalTitle">Название детали</h3> |
| </div> |
| <button class="ghost" id="btnCloseModal">Закрыть</button> |
| </div> |
| <div class="modal-body" id="modalBody"></div> |
| <div class="modal-foot"> |
| <div class="tiny" id="modalNote">Совместимость и аналоги основаны на локальной базе.</div> |
| <div class="btn-row"> |
| <button class="secondary" id="btnModalFav">В избранное</button> |
| <button id="btnModalUse">Подобрать похожие</button> |
| </div> |
| </div> |
| </div> |
| </div> <script> |
| (() => { |
| const STORAGE = { |
| settings: 'parts_picker_settings_v1', |
| favorites: 'parts_picker_favorites_v1', |
| history: 'parts_picker_history_v1', |
| lastVehicle: 'parts_picker_vehicle_v1', |
| }; |
| |
| const DB = { |
| vehicles: [ |
| { |
| brand: 'Volkswagen', |
| models: [ |
| { |
| name: 'Passat B6', |
| years: [2006, 2007, 2008, 2009, 2010], |
| bodies: ['Sedan', 'Variant'], |
| transmissions: ['MT', 'AT', 'DSG'], |
| engines: [ |
| { code: '1.6 BSE', fuel: 'Бензин', hp: 102 }, |
| { code: '1.8 TSI CABB', fuel: 'Бензин', hp: 160 }, |
| { code: '2.0 TDI BMP', fuel: 'Дизель', hp: 140 }, |
| { code: '2.0 TDI CBAA', fuel: 'Дизель', hp: 140 }, |
| ], |
| vins: ['WVWZZZ3CZ8E123456', 'WVWZZZ3CZ9P123456', 'WVWZZZ3CZ0P987654'], |
| }, |
| { |
| name: 'Golf 5', |
| years: [2004, 2005, 2006, 2007, 2008], |
| bodies: ['Hatchback', 'Variant'], |
| transmissions: ['MT', 'AT', 'DSG'], |
| engines: [ |
| { code: '1.4 BUD', fuel: 'Бензин', hp: 80 }, |
| { code: '1.6 BSE', fuel: 'Бензин', hp: 102 }, |
| { code: '2.0 TDI BKD', fuel: 'Дизель', hp: 140 }, |
| ], |
| vins: ['WVWZZZ1KZ6W123456', 'WVWZZZ1KZ7W654321'], |
| }, |
| ], |
| }, |
| { |
| brand: 'Skoda', |
| models: [ |
| { |
| name: 'Octavia A5', |
| years: [2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011], |
| bodies: ['Liftback', 'Combi'], |
| transmissions: ['MT', 'AT', 'DSG'], |
| engines: [ |
| { code: '1.6 BSE', fuel: 'Бензин', hp: 102 }, |
| { code: '1.8 TSI CDAA', fuel: 'Бензин', hp: 160 }, |
| { code: '1.9 TDI BXE', fuel: 'Дизель', hp: 105 }, |
| { code: '2.0 TDI BKD', fuel: 'Дизель', hp: 140 }, |
| ], |
| vins: ['TMBZZZ1Z8A1123456', 'TMBZZZ1Z9B2456789'], |
| }, |
| ], |
| }, |
| { |
| brand: 'Toyota', |
| models: [ |
| { |
| name: 'Camry XV40', |
| years: [2006, 2007, 2008, 2009, 2010], |
| bodies: ['Sedan'], |
| transmissions: ['AT'], |
| engines: [ |
| { code: '2.4 2AZ-FE', fuel: 'Бензин', hp: 167 }, |
| { code: '3.5 2GR-FE', fuel: 'Бензин', hp: 277 }, |
| ], |
| vins: ['JTNBE40K303123456', 'JTNBF30K803654321'], |
| }, |
| { |
| name: 'Corolla E150', |
| years: [2007, 2008, 2009, 2010, 2011, 2012], |
| bodies: ['Sedan', 'Hatchback'], |
| transmissions: ['MT', 'AT'], |
| engines: [ |
| { code: '1.6 1ZR-FE', fuel: 'Бензин', hp: 124 }, |
| { code: '1.4 D-4D 1ND-TV', fuel: 'Дизель', hp: 90 }, |
| ], |
| vins: ['JTNBH56E001234567', 'JTNBA52E001112233'], |
| } |
| ], |
| }, |
| { |
| brand: 'BMW', |
| models: [ |
| { |
| name: '3 Series E90', |
| years: [2005, 2006, 2007, 2008, 2009, 2010, 2011], |
| bodies: ['Sedan', 'Touring'], |
| transmissions: ['MT', 'AT', 'SMG'], |
| engines: [ |
| { code: '320d M47', fuel: 'Дизель', hp: 163 }, |
| { code: '318i N46', fuel: 'Бензин', hp: 129 }, |
| { code: '330i N52', fuel: 'Бензин', hp: 258 }, |
| ], |
| vins: ['WBAWD11070P123456', 'WBAUX31020P987654'], |
| }, |
| ], |
| } |
| ], |
| |
| parts: [ |
| { |
| id: 'brk-pads-front', |
| name: 'Тормозные колодки передние', |
| category: 'Тормозная система', |
| oem: ['1K0698151A', '3C0698151'], |
| synonyms: ['колодки', 'передние колодки', 'brake pads', 'передний тормоз'], |
| fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'BMW 3 Series E90'], |
| engines: ['*'], |
| notes: 'Смотри толщину диска и тип суппорта перед заказом.', |
| price: 42, |
| stock: 'in', |
| brands: ['ATE', 'TRW', 'Brembo', 'Bosch'], |
| compatible: ['Sedan', 'Hatchback', 'Liftback', 'Variant', 'Combi', 'Touring'], |
| }, |
| { |
| id: 'brk-pads-rear', |
| name: 'Тормозные колодки задние', |
| category: 'Тормозная система', |
| oem: ['5Q0698451', '8K0698451'], |
| synonyms: ['задние колодки', 'rear pads'], |
| fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'BMW 3 Series E90'], |
| engines: ['*'], |
| notes: 'Иногда нужен электроручник или другой комплект скоб.', |
| price: 38, |
| stock: 'few', |
| brands: ['ATE', 'Textar', 'TRW'], |
| compatible: ['Sedan', 'Hatchback', 'Liftback', 'Variant', 'Combi', 'Touring'], |
| }, |
| { |
| id: 'oil-filter', |
| name: 'Масляный фильтр', |
| category: 'Двигатель', |
| oem: ['06D115562', '03C115561H'], |
| synonyms: ['фильтр масла', 'oil filter'], |
| fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'BMW 3 Series E90', 'Toyota Corolla E150'], |
| engines: ['*'], |
| notes: 'Подбирай по резьбе, высоте и клапану перепуска.', |
| price: 11, |
| stock: 'in', |
| brands: ['MANN', 'Mahle', 'Bosch', 'Hengst'], |
| compatible: ['*'], |
| }, |
| { |
| id: 'air-filter', |
| name: 'Воздушный фильтр', |
| category: 'Двигатель', |
| oem: ['1K0129620D', '8K0133843'], |
| synonyms: ['фильтр воздуха', 'air filter'], |
| fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'Toyota Corolla E150', 'BMW 3 Series E90'], |
| engines: ['*'], |
| notes: 'Для дизеля и бензина часто разные размеры корпуса.', |
| price: 15, |
| stock: 'in', |
| brands: ['MANN', 'Knecht', 'Bosch'], |
| compatible: ['*'], |
| }, |
| { |
| id: 'cabin-filter', |
| name: 'Салонный фильтр', |
| category: 'Фильтры', |
| oem: ['1K0819644B', '87139-0D010'], |
| synonyms: ['фильтр салона', 'пылевой фильтр'], |
| fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'Toyota Corolla E150', 'BMW 3 Series E90'], |
| engines: ['*'], |
| notes: 'Есть угольные и обычные версии.', |
| price: 14, |
| stock: 'in', |
| brands: ['MANN', 'Mahle', 'WIX'], |
| compatible: ['*'], |
| }, |
| { |
| id: 'fuel-filter-diesel', |
| name: 'Топливный фильтр дизель', |
| category: 'Фильтры', |
| oem: ['1K0127434A', '191127401A'], |
| synonyms: ['diesel filter', 'солярный фильтр'], |
| fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5'], |
| engines: ['Дизель'], |
| notes: 'Проверь наличие подогрева и тип корпуса.', |
| price: 19, |
| stock: 'few', |
| brands: ['MANN', 'Mahle', 'Bosch'], |
| compatible: ['*'], |
| }, |
| { |
| id: 'spark-plugs', |
| name: 'Свечи зажигания', |
| category: 'Двигатель', |
| oem: ['101905611G', '22401-1FDAC'], |
| synonyms: ['свечи', 'spark plugs'], |
| fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'Toyota Corolla E150', 'BMW 3 Series E90'], |
| engines: ['Бензин'], |
| notes: 'Надо сверять калильное число и зазор.', |
| price: 24, |
| stock: 'in', |
| brands: ['NGK', 'Denso', 'Bosch'], |
| compatible: ['*'], |
| }, |
| { |
| id: 'glow-plugs', |
| name: 'Свечи накаливания', |
| category: 'Двигатель', |
| oem: ['N10579805', 'N0252151'], |
| synonyms: ['накал', 'glow plugs'], |
| fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5'], |
| engines: ['Дизель'], |
| notes: 'Часто есть разница по резьбе и длине стержня.', |
| price: 28, |
| stock: 'few', |
| brands: ['Bosch', 'NGK', 'Beru'], |
| compatible: ['*'], |
| }, |
| { |
| id: 'front-shock', |
| name: 'Амортизатор передний', |
| category: 'Подвеска', |
| oem: ['1K0413031BK', '334839'], |
| synonyms: ['стойка', 'shock absorber', 'аморт'], |
| fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'BMW 3 Series E90'], |
| engines: ['*'], |
| notes: 'Смотри тип подвески, диаметр штока и спортивную/обычную версию.', |
| price: 67, |
| stock: 'in', |
| brands: ['KYB', 'Sachs', 'Bilstein'], |
| compatible: ['*'], |
| }, |
| { |
| id: 'rear-shock', |
| name: 'Амортизатор задний', |
| category: 'Подвеска', |
| oem: ['1K0513025CF', '343399'], |
| synonyms: ['задняя стойка', 'rear shock'], |
| fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'BMW 3 Series E90'], |
| engines: ['*'], |
| notes: 'На универсалах и седанах бывают разные длины.', |
| price: 61, |
| stock: 'in', |
| brands: ['KYB', 'Sachs', 'Bilstein'], |
| compatible: ['*'], |
| }, |
| { |
| id: 'air-suspension-note', |
| name: 'Пружина подвески', |
| category: 'Подвеска', |
| oem: ['1K0411105', '481311'], |
| synonyms: ['пружина', 'spring'], |
| fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'BMW 3 Series E90'], |
| engines: ['*'], |
| notes: 'Обычно подбирается по VIN и цветовым меткам.', |
| price: 33, |
| stock: 'few', |
| brands: ['Lesjöfors', 'H&R', 'Sachs'], |
| compatible: ['*'], |
| }, |
| { |
| id: 'wheel-bearing', |
| name: 'Ступичный подшипник', |
| category: 'Подвеска', |
| oem: ['1K0498621', '42450-02050'], |
| synonyms: ['ступица', 'hub bearing'], |
| fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'Toyota Corolla E150', 'BMW 3 Series E90'], |
| engines: ['*'], |
| notes: 'Проверь ABS-датчик и число шлицов.', |
| price: 49, |
| stock: 'in', |
| brands: ['SKF', 'FAG', 'Koyo'], |
| compatible: ['*'], |
| }, |
| { |
| id: 'timing-kit', |
| name: 'Комплект ГРМ', |
| category: 'Двигатель', |
| oem: ['03L198119F', '2AZFE-TIMING'], |
| synonyms: ['грм', 'ремень грм', 'timing kit'], |
| fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'Toyota Corolla E150', 'BMW 3 Series E90'], |
| engines: ['*'], |
| notes: 'Нужно сверять мотор, помпу и ролики. Для некоторых двигателей цепь вместо ремня.', |
| price: 145, |
| stock: 'in', |
| brands: ['INA', 'ContiTech', 'Gates'], |
| compatible: ['*'], |
| }, |
| { |
| id: 'water-pump', |
| name: 'Помпа охлаждения', |
| category: 'Охлаждение', |
| oem: ['06A121011', '16100-09280'], |
| synonyms: ['водяная помпа', 'water pump'], |
| fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'Toyota Corolla E150'], |
| engines: ['*'], |
| notes: 'Часто берут вместе с комплектом ГРМ.', |
| price: 54, |
| stock: 'in', |
| brands: ['HEPU', 'Saleri', 'Graf'], |
| compatible: ['*'], |
| }, |
| { |
| id: 'starter', |
| name: 'Стартер', |
| category: 'Электрика', |
| oem: ['02M911023P', '28100-0D090'], |
| synonyms: ['start', 'стартер двигателя'], |
| fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'Toyota Corolla E150', 'BMW 3 Series E90'], |
| engines: ['*'], |
| notes: 'Важно смотреть мощность, крепление и число зубьев бендикса.', |
| price: 129, |
| stock: 'few', |
| brands: ['Bosch', 'Valeo', 'Denso'], |
| compatible: ['*'], |
| }, |
| { |
| id: 'alternator', |
| name: 'Генератор', |
| category: 'Электрика', |
| oem: ['06F903023D', '27060-0D090'], |
| synonyms: ['alternator', 'генератор двигателя'], |
| fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'Toyota Corolla E150', 'BMW 3 Series E90'], |
| engines: ['*'], |
| notes: 'Проверь ток зарядки и разъем регулятора.', |
| price: 164, |
| stock: 'in', |
| brands: ['Bosch', 'Valeo', 'Denso'], |
| compatible: ['*'], |
| }, |
| { |
| id: 'headlight-bulb', |
| name: 'Лампа фары', |
| category: 'Электрика', |
| oem: ['H7', 'D2S', 'H11'], |
| synonyms: ['лампа', 'bulb', 'лампа ближнего света'], |
| fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'Toyota Corolla E150', 'BMW 3 Series E90'], |
| engines: ['*'], |
| notes: 'Нужно знать тип фары: галоген, ксенон или LED.', |
| price: 9, |
| stock: 'in', |
| brands: ['Philips', 'Osram', 'Bosch'], |
| compatible: ['*'], |
| }, |
| { |
| id: 'drive-shaft-out', |
| name: 'ШРУС наружный', |
| category: 'Трансмиссия', |
| oem: ['1K0498099B', '43460-0D040'], |
| synonyms: ['шрус', 'наружный шрус', 'cv joint'], |
| fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Corolla E150', 'BMW 3 Series E90'], |
| engines: ['*'], |
| notes: 'Сверь число шлицов и ABS-кольцо.', |
| price: 58, |
| stock: 'few', |
| brands: ['SKF', 'GKN', 'SNR'], |
| compatible: ['*'], |
| }, |
| { |
| id: 'injector-seal', |
| name: 'Уплотнение форсунки', |
| category: 'Двигатель', |
| oem: ['03G198051', '23681-0D010'], |
| synonyms: ['прокладка форсунки', 'injector seal'], |
| fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5'], |
| engines: ['Дизель'], |
| notes: 'Лучше менять комплектом с шайбами и болтами.', |
| price: 7, |
| stock: 'in', |
| brands: ['Victor Reinz', 'Elring', 'Bosch'], |
| compatible: ['*'], |
| }, |
| { |
| id: 'belt-tensioner', |
| name: 'Натяжитель ремня', |
| category: 'Двигатель', |
| oem: ['03C145299C', '16620-0H010'], |
| synonyms: ['натяжной ролик', 'tensioner'], |
| fits: ['Volkswagen Passat B6', 'Volkswagen Golf 5', 'Skoda Octavia A5', 'Toyota Camry XV40', 'Toyota Corolla E150'], |
| engines: ['*'], |
| notes: 'Сверяй длину ремня и схему навесного оборудования.', |
| price: 44, |
| stock: 'in', |
| brands: ['INA', 'Gates', 'Dayco'], |
| compatible: ['*'], |
| } |
| ], |
| }; |
| |
| const $ = (sel) => document.querySelector(sel); |
| const $$ = (sel) => Array.from(document.querySelectorAll(sel)); |
| const state = { |
| brand: '', |
| model: '', |
| year: '', |
| engine: '', |
| body: '', |
| trans: '', |
| vin: '', |
| category: 'all', |
| search: '', |
| stock: 'all', |
| sort: 'relevance', |
| activePart: null, |
| activeTab: 'assistant', |
| aiMode: 'local', |
| settings: { |
| apiKey: '', |
| model: 'mistral-small-latest', |
| }, |
| favorites: [], |
| history: [], |
| }; |
| |
| const categoryOptions = ['all', ...new Set(DB.parts.map(p => p.category))]; |
| |
| function loadStorage() { |
| try { |
| const s = JSON.parse(localStorage.getItem(STORAGE.settings) || 'null'); |
| if (s && typeof s === 'object') state.settings = { ...state.settings, ...s }; |
| } catch {} |
| try { |
| state.favorites = JSON.parse(localStorage.getItem(STORAGE.favorites) || '[]'); |
| } catch { state.favorites = []; } |
| try { |
| state.history = JSON.parse(localStorage.getItem(STORAGE.history) || '[]'); |
| } catch { state.history = []; } |
| try { |
| const v = JSON.parse(localStorage.getItem(STORAGE.lastVehicle) || 'null'); |
| if (v) state.brand = v.brand || ''; |
| if (v) state.model = v.model || ''; |
| if (v) state.year = String(v.year || ''); |
| if (v) state.engine = v.engine || ''; |
| if (v) state.body = v.body || ''; |
| if (v) state.trans = v.trans || ''; |
| } catch {} |
| } |
| |
| function saveStorage() { |
| localStorage.setItem(STORAGE.settings, JSON.stringify(state.settings)); |
| localStorage.setItem(STORAGE.favorites, JSON.stringify(state.favorites)); |
| localStorage.setItem(STORAGE.history, JSON.stringify(state.history.slice(0, 20))); |
| localStorage.setItem(STORAGE.lastVehicle, JSON.stringify({ |
| brand: state.brand, |
| model: state.model, |
| year: state.year, |
| engine: state.engine, |
| body: state.body, |
| trans: state.trans, |
| })); |
| } |
| |
| function getModelsByBrand(brand) { |
| const item = DB.vehicles.find(v => v.brand === brand); |
| return item ? item.models : []; |
| } |
| |
| function currentCarKey() { |
| if (!state.brand || !state.model) return ''; |
| return `${state.brand} ${state.model}`; |
| } |
| |
| function currentVehicle() { |
| const brand = DB.vehicles.find(v => v.brand === state.brand); |
| const model = brand?.models.find(m => m.name === state.model); |
| return { brand, model }; |
| } |
| |
| function getAvailableYears() { |
| const { model } = currentVehicle(); |
| return model ? [...model.years] : []; |
| } |
| |
| function getAvailableEngines() { |
| const { model } = currentVehicle(); |
| return model ? [...model.engines] : []; |
| } |
| |
| function fillSelect(select, values, placeholder) { |
| select.innerHTML = ''; |
| const opt = document.createElement('option'); |
| opt.value = ''; |
| opt.textContent = placeholder; |
| select.appendChild(opt); |
| values.forEach(v => { |
| const o = document.createElement('option'); |
| o.value = typeof v === 'string' ? v : String(v); |
| o.textContent = typeof v === 'string' ? v : String(v); |
| select.appendChild(o); |
| }); |
| } |
| |
| function initSelectors() { |
| fillSelect($('#brandSelect'), DB.vehicles.map(v => v.brand), 'Выбери марку'); |
| fillSelect($('#categorySelect'), categoryOptions, 'Все категории'); |
| $('#categorySelect').value = state.category; |
| |
| renderBrandModels(); |
| renderYearsAndEngines(); |
| renderBodiesAndTrans(); |
| |
| $('#brandSelect').value = state.brand; |
| $('#modelSelect').value = state.model; |
| $('#yearSelect').value = state.year; |
| $('#engineSelect').value = state.engine; |
| $('#bodySelect').value = state.body; |
| $('#transSelect').value = state.trans; |
| $('#searchInput').value = state.search; |
| $('#stockFilter').value = state.stock; |
| $('#priceSelect').value = state.sort; |
| $('#vinInput').value = state.vin; |
| $('#apiKeyInput').value = state.settings.apiKey; |
| $('#modelInput').value = state.settings.model; |
| } |
| |
| function renderBrandModels() { |
| const models = getModelsByBrand(state.brand); |
| fillSelect($('#modelSelect'), models.map(m => m.name), 'Выбери модель'); |
| $('#brandSelect').value = state.brand; |
| $('#modelSelect').value = state.model; |
| } |
| |
| function renderYearsAndEngines() { |
| const { model } = currentVehicle(); |
| fillSelect($('#yearSelect'), model ? model.years : [], 'Выбери год'); |
| fillSelect($('#engineSelect'), model ? model.engines.map(e => e.code) : [], 'Выбери двигатель'); |
| $('#yearSelect').value = state.year; |
| $('#engineSelect').value = state.engine; |
| } |
| |
| function renderBodiesAndTrans() { |
| const { model } = currentVehicle(); |
| fillSelect($('#bodySelect'), model ? model.bodies : [], 'Выбери кузов'); |
| fillSelect($('#transSelect'), model ? model.transmissions : [], 'Выбери коробку'); |
| $('#bodySelect').value = state.body; |
| $('#transSelect').value = state.trans; |
| } |
| |
| function fillQuickCars() { |
| const quickCars = [ |
| ['Volkswagen', 'Passat B6'], |
| ['Skoda', 'Octavia A5'], |
| ['Toyota', 'Camry XV40'], |
| ['BMW', '3 Series E90'], |
| ]; |
| const wrap = $('#quickCars'); |
| wrap.innerHTML = ''; |
| quickCars.forEach(([brand, model]) => { |
| const chip = document.createElement('div'); |
| chip.className = 'chip'; |
| chip.textContent = `${brand} ${model}`; |
| chip.onclick = () => setVehicle(brand, model); |
| wrap.appendChild(chip); |
| }); |
| } |
| |
| function setVehicle(brand, model) { |
| state.brand = brand; |
| state.model = model; |
| const cv = currentVehicle().model; |
| state.year = cv?.years?.[0] ? String(cv.years[0]) : ''; |
| state.engine = cv?.engines?.[0]?.code || ''; |
| state.body = cv?.bodies?.[0] || ''; |
| state.trans = cv?.transmissions?.[0] || ''; |
| syncVehicleControls(); |
| saveStorage(); |
| render(); |
| } |
| |
| function syncVehicleControls() { |
| renderBrandModels(); |
| renderYearsAndEngines(); |
| renderBodiesAndTrans(); |
| $('#brandSelect').value = state.brand; |
| $('#modelSelect').value = state.model; |
| $('#yearSelect').value = state.year; |
| $('#engineSelect').value = state.engine; |
| $('#bodySelect').value = state.body; |
| $('#transSelect').value = state.trans; |
| } |
| |
| function normalize(text) { |
| return String(text || '') |
| .toLowerCase() |
| .replace(/ё/g, 'е') |
| .replace(/[^a-zа-я0-9]+/gi, ' ') |
| .trim(); |
| } |
| |
| function vehicleMatchesPart(part) { |
| const key = currentCarKey(); |
| if (!key) return true; |
| return part.fits.includes(key) || part.fits.includes('*'); |
| } |
| |
| function partScore(part, query) { |
| const q = normalize(query); |
| const tokens = q.split(/\s+/).filter(Boolean); |
| if (!tokens.length) return vehicleMatchesPart(part) ? 10 : 0; |
| |
| let score = 0; |
| const hay = normalize([part.name, part.category, part.oem.join(' '), part.synonyms.join(' '), part.notes, part.brands.join(' ')].join(' ')); |
| if (hay.includes(q)) score += 100; |
| |
| for (const t of tokens) { |
| if (hay.includes(t)) score += 12; |
| if (normalize(part.name).includes(t)) score += 10; |
| if (normalize(part.category).includes(t)) score += 8; |
| if (part.oem.some(o => normalize(o).includes(t))) score += 10; |
| } |
| |
| if (vehicleMatchesPart(part)) score += 20; |
| if (state.category !== 'all' && part.category === state.category) score += 15; |
| if (state.stock !== 'all' && part.stock === state.stock) score += 10; |
| if (state.year && part.notes.toLowerCase().includes(state.year)) score += 1; |
| return score; |
| } |
| |
| function filteredParts() { |
| const query = state.search || ''; |
| let arr = DB.parts.filter(part => { |
| if (state.category !== 'all' && part.category !== state.category) return false; |
| if (state.stock !== 'all' && part.stock !== state.stock) return false; |
| if (state.brand || state.model || state.year || state.engine || state.body || state.trans) { |
| if (!vehicleMatchesPart(part)) return false; |
| } |
| if (!query.trim()) return true; |
| const q = normalize(query); |
| const hay = normalize([part.name, part.category, part.oem.join(' '), part.synonyms.join(' '), part.notes, part.brands.join(' ')].join(' ')); |
| return hay.includes(q) || q.split(/\s+/).every(t => hay.includes(t)); |
| }); |
| |
| if (state.sort === 'priceAsc') arr.sort((a, b) => a.price - b.price); |
| else if (state.sort === 'priceDesc') arr.sort((a, b) => b.price - a.price); |
| else if (state.sort === 'nameAsc') arr.sort((a, b) => a.name.localeCompare(b.name, 'ru')); |
| else arr.sort((a, b) => partScore(b, query) - partScore(a, query)); |
| |
| return arr; |
| } |
| |
| function stockText(stock) { |
| if (stock === 'in') return 'В наличии'; |
| if (stock === 'few') return 'Мало'; |
| return 'Нет в наличии'; |
| } |
| |
| function stockColorLabel(stock) { |
| if (stock === 'in') return 'success'; |
| if (stock === 'few') return 'warn'; |
| return 'danger'; |
| } |
| |
| function renderResults() { |
| const list = $('#resultsList'); |
| const parts = filteredParts(); |
| $('#statusCount').textContent = String(parts.length); |
| $('#resultsMeta').textContent = `${parts.length} найдено`; |
| |
| const carLabel = currentCarKey() || 'Не выбрано'; |
| $('#statusCar').textContent = carLabel; |
| |
| const desc = []; |
| if (state.search) desc.push(`запрос: «${state.search}»`); |
| if (state.category !== 'all') desc.push(`категория: ${state.category}`); |
| if (currentCarKey()) desc.push(`авто: ${carLabel}`); |
| $('#resultsDescription').textContent = desc.length ? desc.join(' • ') : 'Выбери автомобиль и введи запрос, чтобы увидеть совместимые позиции.'; |
| |
| if (!parts.length) { |
| list.innerHTML = `<div class="part-card"><strong>Ничего не найдено</strong><div class="hint">Попробуй убрать фильтр категории, изменить двигатель, либо написать синоним: например «фильтр масла», «колодки», «шрус».</div></div>`; |
| return; |
| } |
| |
| list.innerHTML = ''; |
| parts.forEach(part => { |
| const el = document.createElement('div'); |
| el.className = 'part-card'; |
| const fav = state.favorites.some(f => f.id === part.id); |
| el.innerHTML = ` |
| <div class="part-top"> |
| <div> |
| <h4 class="part-name">${escapeHtml(part.name)}</h4> |
| <div class="part-meta"> |
| <span class="tag"><strong>${escapeHtml(part.category)}</strong></span> |
| <span class="tag">OEM: <strong>${escapeHtml(part.oem[0] || '-')}</strong></span> |
| <span class="tag">Бренды: <strong>${escapeHtml(part.brands.slice(0, 2).join(', '))}</strong></span> |
| <span class="tag">Наличие: <strong>${stockText(part.stock)}</strong></span> |
| </div> |
| </div> |
| <div class="price">${part.price} $</div> |
| </div> |
| <div class="hint">${escapeHtml(part.notes)}</div> |
| <div class="part-actions"> |
| <button data-open="${part.id}">Открыть</button> |
| <button class="secondary" data-fav="${part.id}">${fav ? 'Убрать из избранного' : 'В избранное'}</button> |
| <button class="secondary" data-similar="${part.id}">Похожие</button> |
| </div> |
| `; |
| list.appendChild(el); |
| }); |
| |
| list.querySelectorAll('[data-open]').forEach(btn => btn.addEventListener('click', () => openPart(btn.dataset.open))); |
| list.querySelectorAll('[data-fav]').forEach(btn => btn.addEventListener('click', () => toggleFavorite(btn.dataset.fav))); |
| list.querySelectorAll('[data-similar]').forEach(btn => btn.addEventListener('click', () => showSimilar(btn.dataset.similar))); |
| } |
| |
| function escapeHtml(text) { |
| return String(text) |
| .replace(/&/g, '&') |
| .replace(/</g, '<') |
| .replace(/>/g, '>') |
| .replace(/"/g, '"') |
| .replace(/'/g, '''); |
| } |
| |
| function openPart(id) { |
| const part = DB.parts.find(p => p.id === id); |
| if (!part) return; |
| state.activePart = part; |
| recordHistory(`Открыл: ${part.name}`); |
| |
| $('#modalCategory').textContent = part.category; |
| $('#modalTitle').textContent = part.name; |
| $('#modalBody').innerHTML = ` |
| <div class="detail-grid"> |
| <div class="detail-box"> |
| <h4>Описание</h4> |
| <div class="callout" style="margin-bottom:12px;">${escapeHtml(part.notes)}</div> |
| <div class="grid-2"> |
| <div> |
| <div class="tiny">OEM номера</div> |
| <ul class="detail-list">${part.oem.map(o => `<li>${escapeHtml(o)}</li>`).join('')}</ul> |
| </div> |
| <div> |
| <div class="tiny">Бренды-аналоги</div> |
| <ul class="detail-list">${part.brands.map(o => `<li>${escapeHtml(o)}</li>`).join('')}</ul> |
| </div> |
| </div> |
| <div class="divider"></div> |
| <div class="tiny">Совместимые авто</div> |
| <div class="car-chip-list" style="margin-top:8px;">${part.fits.slice(0, 8).map(v => `<span class="chip" data-vehicle-chip="${escapeHtml(v)}">${escapeHtml(v)}</span>`).join('')}</div> |
| </div> |
| <div class="detail-box"> |
| <h4>Советы по подбору</h4> |
| <ul class="detail-list"> |
| <li>Сверь VIN, год и мотор.</li> |
| <li>Проверь фото старой детали и номер на корпусе.</li> |
| <li>Сравни крепления, разъемы и размеры.</li> |
| <li>Если есть сомнение, открой «Похожие» и сравни аналоги.</li> |
| </ul> |
| <div class="divider"></div> |
| <div class="tiny">Быстрые данные</div> |
| <div class="stack" style="margin-top:8px;"> |
| <div class="tag"><strong>Цена:</strong> ${part.price} $</div> |
| <div class="tag"><strong>Наличие:</strong> ${stockText(part.stock)}</div> |
| <div class="tag"><strong>Категория:</strong> ${escapeHtml(part.category)}</div> |
| <div class="tag"><strong>Совместимость:</strong> ${part.compatible.includes('*') ? 'Универсально по кузову' : part.compatible.join(', ')}</div> |
| </div> |
| </div> |
| </div> |
| `; |
| $('#btnModalFav').textContent = state.favorites.some(f => f.id === part.id) ? 'Убрать из избранного' : 'В избранное'; |
| $('#modalBackdrop').classList.add('show'); |
| $('#modalBackdrop').setAttribute('aria-hidden', 'false'); |
| |
| $('#modalBody').querySelectorAll('[data-vehicle-chip]').forEach(chip => { |
| chip.addEventListener('click', () => { |
| const [brand, ...rest] = chip.dataset.vehicleChip.split(' '); |
| const model = rest.join(' '); |
| const exact = DB.vehicles.find(v => v.brand === brand); |
| if (exact) setVehicle(brand, model || exact.models[0].name); |
| }); |
| }); |
| } |
| |
| function closeModal() { |
| $('#modalBackdrop').classList.remove('show'); |
| $('#modalBackdrop').setAttribute('aria-hidden', 'true'); |
| state.activePart = null; |
| } |
| |
| function toggleFavorite(id) { |
| const part = DB.parts.find(p => p.id === id); |
| if (!part) return; |
| const idx = state.favorites.findIndex(f => f.id === id); |
| if (idx >= 0) { |
| state.favorites.splice(idx, 1); |
| addBubble('ai', `Убрано из избранного: ${part.name}`); |
| } else { |
| state.favorites.unshift({ id: part.id, name: part.name, category: part.category, price: part.price }); |
| addBubble('ai', `Добавлено в избранное: ${part.name}`); |
| } |
| saveStorage(); |
| renderSidebarLists(); |
| renderMetrics(); |
| renderResults(); |
| if (state.activePart && state.activePart.id === id) $('#btnModalFav').textContent = state.favorites.some(f => f.id === id) ? 'Убрать из избранного' : 'В избранное'; |
| } |
| |
| function showSimilar(id) { |
| const part = DB.parts.find(p => p.id === id); |
| if (!part) return; |
| const base = normalize(part.name + ' ' + part.category + ' ' + part.synonyms.join(' ')); |
| state.search = part.name; |
| $('#searchInput').value = part.name; |
| state.category = part.category; |
| $('#categorySelect').value = state.category; |
| recordHistory(`Ищу похожие на: ${part.name}`); |
| render(); |
| addBubble('ai', `Показал похожие на «${part.name}». Если хочешь, могу сузить выдачу по OEM или по марке.`); |
| } |
| |
| function renderSidebarLists() { |
| const fav = $('#favoritesList'); |
| if (!state.favorites.length) { |
| fav.innerHTML = '<div class="hint">Пока пусто. Добавляй позиции из каталога.</div>'; |
| } else { |
| fav.innerHTML = state.favorites.map(item => ` |
| <div class="part-card"> |
| <div class="part-top"> |
| <div> |
| <div class="tiny">${escapeHtml(item.category)}</div> |
| <h4 class="part-name">${escapeHtml(item.name)}</h4> |
| </div> |
| <div class="price">${item.price} $</div> |
| </div> |
| <div class="part-actions"> |
| <button data-open-fav="${item.id}">Открыть</button> |
| <button class="secondary" data-remove-fav="${item.id}">Убрать</button> |
| </div> |
| </div> |
| `).join(''); |
| fav.querySelectorAll('[data-open-fav]').forEach(btn => btn.addEventListener('click', () => openPart(btn.dataset.openFav))); |
| fav.querySelectorAll('[data-remove-fav]').forEach(btn => btn.addEventListener('click', () => toggleFavorite(btn.dataset.removeFav))); |
| } |
| |
| const hist = $('#historyList'); |
| if (!state.history.length) { |
| hist.innerHTML = '<div class="hint">История пустая.</div>'; |
| } else { |
| hist.innerHTML = state.history.slice(0, 12).map(x => `<div class="part-card"><div>${escapeHtml(x)}</div></div>`).join(''); |
| } |
| } |
| |
| function renderMetrics() { |
| $('#metricBrands').textContent = String(DB.vehicles.length); |
| $('#metricModels').textContent = String(DB.vehicles.reduce((s, v) => s + v.models.length, 0)); |
| $('#metricParts').textContent = String(DB.parts.length); |
| $('#metricFavs').textContent = String(state.favorites.length); |
| } |
| |
| function recordHistory(text) { |
| state.history.unshift(`${new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })} — ${text}`); |
| state.history = state.history.slice(0, 20); |
| saveStorage(); |
| renderSidebarLists(); |
| } |
| |
| function addBubble(type, text) { |
| const log = $('#chatLog'); |
| const bubble = document.createElement('div'); |
| bubble.className = `bubble ${type}`; |
| bubble.textContent = text; |
| log.appendChild(bubble); |
| log.scrollTop = log.scrollHeight; |
| } |
| |
| function bootChat() { |
| if (!$('#chatLog').children.length) { |
| addBubble('ai', 'Привет. Я помогу подобрать запчасть по марке, модели, мотору, VIN или названию детали. Напиши запрос в обычной форме, например: «нужен масляный фильтр на Passat B6 2.0 TDI».'); |
| } |
| } |
| |
| function extractIntent(query) { |
| const q = normalize(query); |
| const tokens = q.split(/\s+/).filter(Boolean); |
| |
| let brand = DB.vehicles.find(v => q.includes(normalize(v.brand)))?.brand || ''; |
| let model = ''; |
| let year = ''; |
| let engine = ''; |
| let body = ''; |
| let trans = ''; |
| |
| for (const v of DB.vehicles) { |
| for (const m of v.models) { |
| if (q.includes(normalize(m.name))) { |
| brand = v.brand; |
| model = m.name; |
| const y = m.years.find(y => q.includes(String(y))); |
| if (y) year = String(y); |
| const e = m.engines.find(e => q.includes(normalize(e.code)) || q.includes(normalize(e.fuel))); |
| if (e) engine = e.code; |
| const b = m.bodies.find(b => q.includes(normalize(b))); |
| if (b) body = b; |
| const t = m.transmissions.find(t => q.includes(normalize(t))); |
| if (t) trans = t; |
| } |
| } |
| } |
| |
| const part = DB.parts.find(p => { |
| const hay = normalize([p.name, p.category, p.synonyms.join(' '), p.oem.join(' ')].join(' ')); |
| return tokens.some(t => hay.includes(t)); |
| }); |
| |
| const vin = q.match(/[a-hj-npr-z0-9]{17}/i)?.[0]?.toUpperCase() || ''; |
| |
| return { brand, model, year, engine, body, trans, part, vin }; |
| } |
| |
| function localAdvice(query) { |
| const q = normalize(query); |
| const intent = extractIntent(query); |
| const suggestions = []; |
| if (intent.vin) suggestions.push(`VIN похож на: ${intent.vin}. Сначала проверь его по базе, а затем сравни кузов и двигатель.`); |
| if (intent.brand || intent.model) suggestions.push(`Похоже на авто: ${[intent.brand, intent.model].filter(Boolean).join(' ')}${intent.year ? ', ' + intent.year : ''}${intent.engine ? ', ' + intent.engine : ''}.`); |
| if (intent.part) { |
| const matches = DB.parts.filter(p => p.category === intent.part.category && vehicleMatchesPart(p)).slice(0, 4); |
| suggestions.push(`Похоже, ты ищешь: ${intent.part.name}.`); |
| suggestions.push(`Похожие варианты: ${matches.map(m => m.name).join(', ')}.`); |
| } |
| |
| if (/колодк|brake/.test(q)) suggestions.push('Для колодок проверь диаметр диска, тип суппорта и наличие датчика износа.'); |
| if (/фильтр/.test(q)) suggestions.push('Для фильтров сверяй корпус, размеры и клапаны.'); |
| if (/шрус|cv/.test(q)) suggestions.push('Для ШРУСа критичны шлицы, ABS-кольцо и диаметр посадки.'); |
| if (/грм|ремень|timing/.test(q)) suggestions.push('Для ГРМ обязательно сверяй мотор-код и версию помпы.'); |
| if (/аморт|стойк/.test(q)) suggestions.push('Для подвески важны кузов, тип привода и спортивная/обычная подвеска.'); |
| if (/свеч|spark|glow/.test(q)) suggestions.push('Свечи подбираются строго по типу топлива и мотор-коду.'); |
| |
| if (!suggestions.length) { |
| suggestions.push('Напиши марку, модель и деталь. Пример: «Skoda Octavia A5 1.9 TDI, нужен салонный фильтр».'); |
| suggestions.push('Можно также просто назвать OEM-номер или код с детали.'); |
| } |
| |
| return suggestions.join(' '); |
| } |
| |
| async function askAI(query) { |
| recordHistory(`AI: ${query}`); |
| addBubble('me', query); |
| const key = state.settings.apiKey.trim(); |
| const model = state.settings.model.trim() || 'mistral-small-latest'; |
| state.aiMode = key ? 'Mistral' : 'Локальный'; |
| $('#aiState').textContent = state.aiMode === 'Mistral' ? `Mistral: ${model}` : 'Локальные подсказки'; |
| $('#statusMode').textContent = state.aiMode; |
| |
| if (!key) { |
| const reply = localAdvice(query); |
| addBubble('ai', reply); |
| applyIntentToForm(query); |
| return; |
| } |
| |
| addBubble('ai', 'Думаю...'); |
| const log = $('#chatLog'); |
| const thinkingBubble = log.lastElementChild; |
| |
| try { |
| const cars = currentCarKey() || 'Не выбрано'; |
| const context = filteredParts().slice(0, 10).map(p => `${p.name} | ${p.category} | OEM: ${p.oem.join(', ')} | fits: ${p.fits.join(', ')} | notes: ${p.notes}`).join('\n'); |
| const prompt = `Ты помощник по подбору автозапчастей. Не выдумывай совместимость. Опирайся на контекст каталога и объясняй сомнения. Если данных мало, проси уточнить марку/модель/год/двигатель/кузов/VIN.\n\nТекущий автомобиль: ${cars}\n\nКаталог (первые позиции):\n${context}\n\nЗапрос пользователя: ${query}`; |
| |
| const res = await fetch('https://api.mistral.ai/v1/chat/completions', { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| 'Authorization': `Bearer ${key}`, |
| }, |
| body: JSON.stringify({ |
| model, |
| temperature: 0.2, |
| messages: [ |
| { role: 'system', content: 'Ты очень аккуратный помощник по автозапчастям.' }, |
| { role: 'user', content: prompt }, |
| ], |
| }), |
| }); |
| |
| if (!res.ok) { |
| const txt = await res.text(); |
| throw new Error(`API error ${res.status}: ${txt.slice(0, 220)}`); |
| } |
| const data = await res.json(); |
| const text = data?.choices?.[0]?.message?.content?.trim() || 'Нет ответа от модели.'; |
| if (thinkingBubble) thinkingBubble.remove(); |
| addBubble('ai', text); |
| applyIntentToForm(query); |
| } catch (err) { |
| if (thinkingBubble) thinkingBubble.remove(); |
| addBubble('ai', `Не удалось обратиться к Mistral. Переключаюсь на локальный режим.\n${err.message}\n\n${localAdvice(query)}`); |
| applyIntentToForm(query); |
| } |
| } |
| |
| function applyIntentToForm(query) { |
| const intent = extractIntent(query); |
| if (intent.brand) state.brand = intent.brand; |
| if (intent.model) state.model = intent.model; |
| if (intent.year) state.year = intent.year; |
| if (intent.engine) state.engine = intent.engine; |
| if (intent.body) state.body = intent.body; |
| if (intent.trans) state.trans = intent.trans; |
| if (intent.vin) { |
| state.vin = intent.vin; |
| $('#vinInput').value = intent.vin; |
| tryVinLookup(intent.vin); |
| } |
| syncVehicleControls(); |
| saveStorage(); |
| if (intent.part) { |
| state.search = intent.part.name; |
| $('#searchInput').value = intent.part.name; |
| } |
| render(); |
| } |
| |
| function tryVinLookup(vin) { |
| vin = String(vin || '').toUpperCase(); |
| const matchedVehicle = DB.vehicles.find(v => v.models.some(m => m.vins.includes(vin))); |
| if (!matchedVehicle) { |
| $('#vinHint').innerHTML = `<span class="vin-bad">VIN не найден в локальной базе.</span> Можно всё равно выбрать авто вручную.`; |
| return false; |
| } |
| const model = matchedVehicle.models.find(m => m.vins.includes(vin)); |
| if (model) { |
| state.brand = matchedVehicle.brand; |
| state.model = model.name; |
| state.year = String(model.years[0]); |
| state.engine = model.engines[0]?.code || ''; |
| state.body = model.bodies[0] || ''; |
| state.trans = model.transmissions[0] || ''; |
| syncVehicleControls(); |
| $('#vinHint').innerHTML = `<span class="vin-ok">VIN распознан.</span> Найдено: ${matchedVehicle.brand} ${model.name}`; |
| recordHistory(`VIN: ${vin} → ${matchedVehicle.brand} ${model.name}`); |
| return true; |
| } |
| $('#vinHint').innerHTML = `<span class="vin-bad">VIN не совпал с вариантом модели.</span>`; |
| return false; |
| } |
| |
| function render() { |
| renderMetrics(); |
| renderResults(); |
| renderSidebarLists(); |
| saveStorage(); |
| } |
| |
| function bindEvents() { |
| $('#brandSelect').addEventListener('change', e => { |
| state.brand = e.target.value; |
| state.model = ''; |
| state.year = ''; |
| state.engine = ''; |
| state.body = ''; |
| state.trans = ''; |
| syncVehicleControls(); |
| saveStorage(); |
| render(); |
| }); |
| |
| $('#modelSelect').addEventListener('change', e => { |
| state.model = e.target.value; |
| const model = currentVehicle().model; |
| state.year = model?.years[0] ? String(model.years[0]) : ''; |
| state.engine = model?.engines[0]?.code || ''; |
| state.body = model?.bodies[0] || ''; |
| state.trans = model?.transmissions[0] || ''; |
| syncVehicleControls(); |
| saveStorage(); |
| render(); |
| }); |
| |
| $('#yearSelect').addEventListener('change', e => { state.year = e.target.value; saveStorage(); render(); }); |
| $('#engineSelect').addEventListener('change', e => { state.engine = e.target.value; saveStorage(); render(); }); |
| $('#bodySelect').addEventListener('change', e => { state.body = e.target.value; saveStorage(); render(); }); |
| $('#transSelect').addEventListener('change', e => { state.trans = e.target.value; saveStorage(); render(); }); |
| |
| $('#vinInput').addEventListener('input', e => { |
| state.vin = e.target.value.toUpperCase().replace(/\s+/g, ''); |
| $('#vinInput').value = state.vin; |
| if (state.vin.length === 17) { |
| tryVinLookup(state.vin); |
| saveStorage(); |
| render(); |
| } else { |
| $('#vinHint').textContent = 'Можно просто ввести VIN, чтобы попытаться определить автомобиль по базе.'; |
| } |
| }); |
| |
| $('#searchInput').addEventListener('input', e => { |
| state.search = e.target.value; |
| renderResults(); |
| saveStorage(); |
| }); |
| |
| $('#categorySelect').addEventListener('change', e => { state.category = e.target.value; render(); }); |
| $('#stockFilter').addEventListener('change', e => { state.stock = e.target.value; render(); }); |
| $('#priceSelect').addEventListener('change', e => { state.sort = e.target.value; render(); }); |
| |
| $('#btnSearch').addEventListener('click', () => { |
| recordHistory(`Поиск: ${state.search || 'без запроса'}`); |
| render(); |
| }); |
| |
| $('#btnClearFilters').addEventListener('click', () => { |
| state.search = ''; |
| state.category = 'all'; |
| state.stock = 'all'; |
| state.sort = 'relevance'; |
| $('#searchInput').value = ''; |
| $('#categorySelect').value = 'all'; |
| $('#stockFilter').value = 'all'; |
| $('#priceSelect').value = 'relevance'; |
| render(); |
| }); |
| |
| $('#btnApplyCar').addEventListener('click', () => { |
| saveStorage(); |
| recordHistory(`Выбран автомобиль: ${currentCarKey() || 'не выбран'}`); |
| render(); |
| }); |
| |
| $('#btnFindByVin').addEventListener('click', () => { |
| if (!state.vin) { |
| addBubble('ai', 'Сначала введи VIN.'); |
| return; |
| } |
| tryVinLookup(state.vin); |
| render(); |
| }); |
| |
| $('#btnResetCar').addEventListener('click', () => { |
| state.brand = ''; |
| state.model = ''; |
| state.year = ''; |
| state.engine = ''; |
| state.body = ''; |
| state.trans = ''; |
| state.vin = ''; |
| $('#vinInput').value = ''; |
| $('#vinHint').textContent = 'Можно просто ввести VIN, чтобы попытаться определить автомобиль по базе.'; |
| syncVehicleControls(); |
| render(); |
| }); |
| |
| $('#btnAskAI').addEventListener('click', () => { |
| const q = $('#chatInput').value.trim(); |
| if (!q) return; |
| $('#chatInput').value = ''; |
| askAI(q); |
| }); |
| |
| $('#btnSuggestFromCar').addEventListener('click', () => { |
| const q = [state.brand, state.model, state.year, state.engine].filter(Boolean).join(' '); |
| const text = q ? `Подбери запчасти для ${q}` : 'Подбери запчасти по текущему автомобилю'; |
| $('#chatInput').value = text; |
| askAI(text); |
| }); |
| |
| $('#btnSaveSettings').addEventListener('click', () => { |
| state.settings.apiKey = $('#apiKeyInput').value.trim(); |
| state.settings.model = $('#modelInput').value.trim() || 'mistral-small-latest'; |
| saveStorage(); |
| $('#aiState').textContent = state.settings.apiKey ? `Mistral: ${state.settings.model}` : 'Локальные подсказки'; |
| $('#statusMode').textContent = state.settings.apiKey ? 'Mistral' : 'Локальный'; |
| addBubble('ai', state.settings.apiKey ? 'Ключ сохранён. Теперь ответы могут идти через Mistral.' : 'Ключ не задан. Остаёмся в локальном режиме.'); |
| }); |
| |
| $('#btnClearData').addEventListener('click', () => { |
| if (!confirm('Стереть сохранённые настройки, историю и избранное?')) return; |
| localStorage.removeItem(STORAGE.settings); |
| localStorage.removeItem(STORAGE.favorites); |
| localStorage.removeItem(STORAGE.history); |
| localStorage.removeItem(STORAGE.lastVehicle); |
| location.reload(); |
| }); |
| |
| $('#btnCloseModal').addEventListener('click', closeModal); |
| $('#modalBackdrop').addEventListener('click', (e) => { if (e.target === $('#modalBackdrop')) closeModal(); }); |
| $('#btnModalFav').addEventListener('click', () => { if (state.activePart) toggleFavorite(state.activePart.id); }); |
| $('#btnModalUse').addEventListener('click', () => { if (state.activePart) showSimilar(state.activePart.id); closeModal(); }); |
| |
| $$('.tab').forEach(tab => { |
| tab.addEventListener('click', () => switchTab(tab.dataset.tab)); |
| }); |
| |
| document.addEventListener('keydown', (e) => { |
| if (e.key === '/' && document.activeElement !== $('#searchInput')) { |
| e.preventDefault(); |
| $('#searchInput').focus(); |
| } |
| if (e.key === 'Escape') closeModal(); |
| if (e.key === 'Enter' && document.activeElement === $('#searchInput')) { |
| e.preventDefault(); |
| recordHistory(`Поиск: ${state.search || 'без запроса'}`); |
| render(); |
| } |
| }); |
| } |
| |
| function switchTab(tab) { |
| state.activeTab = tab; |
| $$('.tab').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tab)); |
| $$('.tab-page').forEach(page => page.style.display = 'none'); |
| $(`#tab-${tab}`).style.display = ''; |
| } |
| |
| function init() { |
| loadStorage(); |
| initSelectors(); |
| fillQuickCars(); |
| bindEvents(); |
| bootChat(); |
| switchTab('assistant'); |
| $('#aiState').textContent = state.settings.apiKey ? `Mistral: ${state.settings.model}` : 'Локальные подсказки'; |
| $('#statusMode').textContent = state.settings.apiKey ? 'Mistral' : 'Локальный'; |
| if (state.vin) $('#vinInput').value = state.vin; |
| if (state.vin) tryVinLookup(state.vin); |
| if (state.brand && state.model) syncVehicleControls(); |
| render(); |
| } |
| |
| |
| window.addEventListener('click', (e) => { |
| const btn = e.target.closest('button'); |
| if (!btn) return; |
| }); |
| |
| init(); |
| })(); |
| </script></body> |
| </html> |