115 lines
15 KiB
HTML
115 lines
15 KiB
HTML
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Оценка поисковой выдачи · Акылдаш</title>
|
||
<style>
|
||
:root { color-scheme: light; font: 16px/1.5 system-ui, sans-serif; color: #17202a; background: #f5f7fa; }
|
||
* { box-sizing: border-box; }
|
||
body { margin: 0; }
|
||
.review__skip { position: absolute; inset-block-start: 0; inset-inline-start: -10000px; padding: .5rem 1rem; background: #fff; }
|
||
.review__skip:focus { inset-inline-start: 1rem; z-index: 2; }
|
||
.review__header, .review__main { max-width: 1440px; margin: auto; padding-inline: 1rem; }
|
||
.review__header { padding-block: 1.5rem 1rem; }
|
||
.review__main { padding-block-end: 8rem; }
|
||
.review__search { display: flex; flex-wrap: wrap; align-items: end; gap: .75rem; padding: 1rem; background: #fff; border: 1px solid #d9e0e7; border-radius: .75rem; }
|
||
.review__field { display: grid; gap: .25rem; min-width: 12rem; flex: 1; }
|
||
.review__field--small { flex: 0 0 7rem; min-width: 7rem; }
|
||
input, select, textarea, button { font: inherit; }
|
||
input, select, textarea { border: 1px solid #8d9aaa; border-radius: .4rem; padding: .6rem .7rem; background: #fff; }
|
||
input:focus-visible, select:focus-visible, textarea:focus-visible, button:focus-visible { outline: 3px solid #1769aa; outline-offset: 2px; }
|
||
button { cursor: pointer; border: 1px solid #536273; border-radius: .4rem; padding: .6rem .9rem; background: #fff; color: #17202a; }
|
||
button:hover { background: #edf3f8; }
|
||
.review__button--primary { background: #145a86; color: #fff; border-color: #145a86; }
|
||
.review__button--primary:hover { background: #0e4669; }
|
||
.review__status { min-height: 1.7rem; margin-block: .75rem; }
|
||
.review__status--error { color: #9b1c1c; }
|
||
.review__workspace { display: grid; grid-template-columns: minmax(22rem, 1fr) minmax(24rem, 1.1fr); gap: 1rem; align-items: start; }
|
||
.review__results, .review__document { background: #fff; border: 1px solid #d9e0e7; border-radius: .75rem; padding: 1rem; }
|
||
.review__results-list { display: grid; gap: 1rem; margin: 0; padding: 0; list-style: none; }
|
||
.review__result { border-block-start: 1px solid #d9e0e7; padding-block-start: 1rem; }
|
||
.review__result:first-child { border-block-start: 0; padding-block-start: 0; }
|
||
.review__result-title { display: flex; gap: .5rem; align-items: baseline; width: 100%; text-align: start; font-weight: 700; border: 0; padding: 0; color: #124f78; }
|
||
.review__rank { flex: 0 0 auto; color: #526272; font-variant-numeric: tabular-nums; }
|
||
.review__meta, .review__snippet { margin-block: .35rem; color: #526272; }
|
||
.review__snippet { overflow-wrap: anywhere; }
|
||
.review__rating { display: flex; flex-wrap: wrap; gap: .45rem; margin-block: .65rem; padding: 0; border: 0; }
|
||
.review__rating legend { width: 100%; font-weight: 600; }
|
||
.review__rating label { min-width: 3.5rem; text-align: center; }
|
||
.review__rating input { accent-color: #145a86; }
|
||
.review__comment { width: 100%; min-height: 4rem; resize: vertical; }
|
||
.review__document { position: sticky; inset-block-start: 1rem; min-height: 20rem; }
|
||
.review__document-body { max-width: 75ch; overflow-wrap: anywhere; }
|
||
.review__document-body img { max-width: 100%; height: auto; }
|
||
.review__actions { position: fixed; inset-block-end: 0; inset-inline: 0; padding: .75rem 1rem; background: rgb(255 255 255 / .96); border-block-start: 1px solid #d9e0e7; text-align: end; }
|
||
dialog { max-width: min(56rem, calc(100% - 2rem)); max-height: calc(100% - 2rem); border: 1px solid #8d9aaa; border-radius: .75rem; padding: 1rem; }
|
||
dialog::backdrop { background: rgb(10 20 30 / .5); }
|
||
.review__dialog-close { float: inline-end; }
|
||
@media (max-width: 800px) {
|
||
.review__workspace { grid-template-columns: 1fr; }
|
||
.review__document { display: none; }
|
||
.review__search { align-items: stretch; }
|
||
.review__field, .review__field--small { flex-basis: 100%; }
|
||
.review__search button { width: 100%; }
|
||
}
|
||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<a class="review__skip" href="#results">Перейти к результатам</a>
|
||
<header class="review__header"><h1>Оценка поисковой выдачи</h1><p>Проверяйте результаты нашего OpenSearch по практическим юридическим запросам.</p></header>
|
||
<main class="review__main" id="review">
|
||
<form class="review__search" id="search-form">
|
||
<label class="review__field">Запрос<input id="query" name="q" maxlength="500" required autocomplete="off"></label>
|
||
<label class="review__field review__field--small">Язык<select id="language" name="language"><option value="ru">Русский</option><option value="ky">Кыргызский</option></select></label>
|
||
<label class="review__field review__field--small">Результатов<select id="page-size" name="page_size"><option>10</option><option>20</option></select></label>
|
||
<button class="review__button--primary" type="submit">Найти</button>
|
||
</form>
|
||
<div class="review__status" id="status" role="status" aria-live="polite"></div>
|
||
<div class="review__workspace">
|
||
<section class="review__results" aria-labelledby="results-heading"><h2 id="results-heading">Результаты</h2><ol class="review__results-list" id="results"></ol></section>
|
||
<section class="review__document" aria-labelledby="document-heading"><h2 id="document-heading">Документ</h2><div id="document-meta">Выберите результат, чтобы открыть текст.</div><article class="review__document-body" id="document-body"></article></section>
|
||
</div>
|
||
</main>
|
||
<div class="review__actions"><label>Проверяющий <input id="reviewer" maxlength="120" autocomplete="name"></label> <label>Общий комментарий <input id="overall-comment" maxlength="4000"></label> <button class="review__button--primary" id="save" type="button">Сохранить оценку</button></div>
|
||
<dialog id="document-dialog"><button class="review__dialog-close" id="dialog-close" type="button">Закрыть</button><h2 id="dialog-heading">Документ</h2><div class="review__document-body" id="dialog-body"></div></dialog>
|
||
<div class="review__status review__status--error" id="error" role="alert" aria-live="assertive"></div>
|
||
<footer class="review__header">Акылдаш · Backend v0.9.0 · Внутренняя лаборатория релевантности</footer>
|
||
<script>
|
||
const DRAFT_KEY = 'akyldash-search-review-draft';
|
||
const state = { results: [], token: '', selected: null, query: '' };
|
||
const $ = (id) => document.getElementById(id);
|
||
const setStatus = (text) => { $('status').textContent = text; $('error').textContent = ''; };
|
||
const setError = (text) => { $('error').textContent = text; };
|
||
const escapeText = (value) => value == null ? '' : String(value);
|
||
const readDraft = () => { try { return JSON.parse(localStorage.getItem(DRAFT_KEY) || 'null'); } catch (_) { return null; } };
|
||
const saveDraft = () => { if (!state.token) return; const results = [...$('results').children].map((item, index) => ({rank: index + 1, code: item.dataset.code, rating: item.querySelector('input:checked')?.value ?? null, comment: item.querySelector('textarea').value})); try { localStorage.setItem(DRAFT_KEY, JSON.stringify({query: state.query, language: $('language').value, pageSize: $('page-size').value, reviewer: $('reviewer').value, overallComment: $('overall-comment').value, results})); } catch (_) {} };
|
||
const applyDraft = () => { const draft = readDraft(); if (!draft || draft.query !== state.query || draft.language !== $('language').value || draft.pageSize !== $('page-size').value) return; $('reviewer').value = draft.reviewer || ''; $('overall-comment').value = draft.overallComment || ''; (draft.results || []).forEach((saved) => { const item = [...$('results').children].find((candidate) => candidate.dataset.rank === String(saved.rank) && candidate.dataset.code === saved.code); if (!item) return; if (saved.rating != null) { const input = item.querySelector(`input[value="${CSS.escape(String(saved.rating))}"]`); if (input) input.checked = true; } item.querySelector('textarea').value = saved.comment || ''; }); };
|
||
function renderResults() {
|
||
$('results').replaceChildren();
|
||
state.results.forEach((result, index) => {
|
||
const item = document.createElement('li'); item.className = 'review__result'; item.dataset.rank = index + 1; item.dataset.code = result.code;
|
||
const title = document.createElement('button'); title.type = 'button'; title.className = 'review__result-title'; title.innerHTML = `<span class="review__rank">#${index + 1}</span><span></span>`; title.lastElementChild.textContent = escapeText(result.name || 'Название не указано'); title.addEventListener('click', () => openDocument(index, title));
|
||
const meta = document.createElement('div'); meta.className = 'review__meta'; meta.textContent = [result.type, result.status, result.date_adopted, result.number].filter(Boolean).join(' · ');
|
||
const snippet = document.createElement('div'); snippet.className = 'review__snippet'; snippet.textContent = result.snippet || 'Фрагмент не найден.';
|
||
const rating = document.createElement('fieldset'); rating.className = 'review__rating'; rating.innerHTML = `<legend>Оценка результата</legend>`;
|
||
[['0', 'нерелевантен'], ['1', 'косвенно полезен'], ['2', 'частично полезен'], ['3', 'прямо отвечает']].forEach(([value, label]) => { const id = `rating-${index}-${value}`; const wrapper = document.createElement('label'); wrapper.htmlFor = id; wrapper.textContent = `${value} — ${label}`; const input = document.createElement('input'); input.type = 'radio'; input.name = `rating-${index}`; input.id = id; input.value = value; wrapper.prepend(input); rating.append(wrapper); });
|
||
const comment = document.createElement('textarea'); comment.className = 'review__comment'; comment.maxLength = 4000; comment.placeholder = 'Комментарий к результату (необязательно)'; comment.setAttribute('aria-label', `Комментарий к результату #${index + 1}`);
|
||
item.append(title, meta, snippet, rating, comment); $('results').append(item);
|
||
});
|
||
applyDraft();
|
||
}
|
||
async function openDocument(index, trigger) {
|
||
const result = state.results[index]; state.selected = trigger; setStatus('Загрузка документа…');
|
||
try { const response = await fetch(`/documents/${encodeURIComponent(result.code)}/editions/${encodeURIComponent(result.edition)}?language=${encodeURIComponent($('language').value)}`); if (!response.ok) throw new Error('Документ недоступен'); const payload = await response.json(); const content = payload.content[$('language').value]; const meta = $('document-meta'); meta.textContent = [result.name, result.status, result.date_adopted].filter(Boolean).join(' · '); const source = document.createElement('a'); source.href = `https://cbd.minjust.gov.kg/${encodeURIComponent(result.code)}/edition/${encodeURIComponent(result.edition)}/${encodeURIComponent($('language').value)}`; source.target = '_blank'; source.rel = 'noreferrer'; source.textContent = ' Официальный источник'; meta.append(source); $('document-body').innerHTML = content.html; $('dialog-heading').textContent = result.name || 'Документ'; $('dialog-body').innerHTML = content.html; if (matchMedia('(max-width: 800px)').matches) $('document-dialog').showModal(); setStatus('Документ загружен.'); } catch (error) { setError('Не удалось загрузить документ. Повторите попытку.'); }
|
||
}
|
||
$('dialog-close').addEventListener('click', () => { $('document-dialog').close(); if (state.selected) state.selected.focus(); });
|
||
$('search-form').addEventListener('submit', async (event) => { event.preventDefault(); const query = $('query').value.trim(); if (!query) return; state.query = query; setStatus('Поиск выполняется…'); $('save').disabled = true; try { const params = new URLSearchParams({q: query, language: $('language').value, page_size: $('page-size').value}); const response = await fetch(`/search?${params}`); if (!response.ok) throw new Error(); const payload = await response.json(); state.results = payload.results; state.token = payload.review_token; renderResults(); setStatus(state.results.length ? `Найдено результатов: ${state.results.length}.` : `По запросу «${query}» ничего не найдено. Измените запрос.`); } catch (error) { state.results = []; state.token = ''; renderResults(); setError('Не удалось выполнить поиск. Повторите поиск.'); } finally { $('save').disabled = false; } });
|
||
document.addEventListener('input', saveDraft); document.addEventListener('change', saveDraft);
|
||
$('save').addEventListener('click', async () => { if (!state.token) { setError('Сначала выполните поиск.'); return; } const reviewer = $('reviewer').value.trim(); if (!reviewer) { $('reviewer').focus(); setError('Укажите проверяющего.'); return; } const results = [...$('results').children].map((item, index) => ({rank: index + 1, code: item.dataset.code, rating: item.querySelector('input:checked') ? Number(item.querySelector('input:checked').value) : null, comment: item.querySelector('textarea').value})); $('save').disabled = true; setStatus('Сохранение выполняется…'); try { const response = await fetch('/search-reviews', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({review_token: state.token, reviewer, overall_comment: $('overall-comment').value, results})}); if (!response.ok) throw new Error(); const payload = await response.json(); localStorage.removeItem(DRAFT_KEY); setStatus(`Оценка сохранена · № ${payload.id}.`); } catch (error) { setError('Не удалось сохранить. Проверьте подключение и повторите.'); } finally { $('save').disabled = false; } });
|
||
const draft = readDraft(); if (draft) { $('query').value = draft.query || ''; $('language').value = draft.language || 'ru'; $('page-size').value = draft.pageSize || '20'; $('reviewer').value = draft.reviewer || ''; $('overall-comment').value = draft.overallComment || ''; }
|
||
</script>
|
||
</body>
|
||
</html>
|