feat(frontend): complete public search visual review
No files matched your search
@@ -5,6 +5,7 @@ credentials.json
|
||||
__pycache__/
|
||||
.venv/
|
||||
*.py[cod]
|
||||
tools/frontend-visual-review/node_modules/
|
||||
|
||||
# Local environment files
|
||||
.env
|
||||
|
||||
@@ -27,6 +27,8 @@ CODE = re.compile(r"^[0-9]+$")
|
||||
MAX_PAGE_SIZE = 100
|
||||
MAX_RESULT_WINDOW = 10_000
|
||||
STATIC_ASSETS = {
|
||||
"/static/favicon.svg": ("favicon.svg", "image/svg+xml"),
|
||||
"/static/favicon-32.png": ("favicon-32.png", "image/png"),
|
||||
"/static/fonts/noto-sans-cyrillic-ext.woff2": ("fonts/noto-sans-cyrillic-ext.woff2", "font/woff2"),
|
||||
"/static/fonts/noto-sans-cyrillic.woff2": ("fonts/noto-sans-cyrillic.woff2", "font/woff2"),
|
||||
"/static/fonts/noto-sans-latin.woff2": ("fonts/noto-sans-latin.woff2", "font/woff2"),
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#F7F2E8">
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Ccircle cx='24' cy='24' r='20' fill='none' stroke='%239D2633' stroke-width='3'/%3E%3Cpath d='M24 5v38M5 24h38M11 11l26 26M37 11 11 37' fill='none' stroke='%239D2633' stroke-width='3'/%3E%3C/svg%3E">
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png">
|
||||
<title>Оценка поисковой выдачи · Акылдаш</title>
|
||||
<style>
|
||||
@font-face { font-family: "Noto Sans"; font-style: normal; font-weight: 100 900; font-display: swap; src: url("/static/fonts/noto-sans-cyrillic-ext.woff2") format("woff2"); unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; }
|
||||
@@ -282,7 +283,7 @@
|
||||
.search__filters .review__panel-heading button, .search__document-toolbar button, .search__document-toolbar select, .search__document-language select, .search__find input, .search__find button, .search__expand { min-height: 44px; }
|
||||
.search__mobile-filters { display: inline-flex; }
|
||||
.search__filters { display: none; }
|
||||
.search__filters.is-open { position: fixed; z-index: 21; inset: 0 0 0 auto; display: block; width: min(360px, calc(100vw - 32px)); overflow: auto; background: var(--surface-muted); box-shadow: -12px 0 32px rgb(56 38 42 / .18); }
|
||||
.search__filters.is-open { position: fixed; z-index: 21; inset: 0 0 0 auto; display: block; width: min(360px, calc(100vw - 32px)); overflow: auto; overscroll-behavior: contain; background: var(--surface-muted); box-shadow: -12px 0 32px rgb(56 38 42 / .18); }
|
||||
.search__filter-overlay.is-open { position: fixed; z-index: 20; inset: 0; display: block; background: rgb(56 38 42 / .42); }
|
||||
.search__filter-close { display: inline-flex; }
|
||||
.search__mobile-back { display: inline-flex; }
|
||||
@@ -390,13 +391,24 @@
|
||||
const state = { results: [], token: '', selected: null, query: '' };
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let account = null;
|
||||
let uiLanguage = 'ru';
|
||||
const searchCopy = {
|
||||
ru: {brand: 'Правовая база Кыргызстана', queryLabel: 'Поиск по правовой базе', queryPlaceholder: 'Введите правовой вопрос или название документа', clearQuery: 'Очистить запрос', search: 'Поиск', sources: 'Об источниках', searchLanguage: 'Язык поиска', russian: 'Русский', kyrgyz: 'Кыргызский', filters: 'Фильтры', clear: 'Сбросить', close: 'Закрыть фильтры', date: 'Дата принятия', from: 'От', to: 'До', results: 'Результаты', enterQuery: 'Введите запрос', sort: 'Сортировка', relevance: 'По релевантности', dateSort: 'По дате', noResults: 'Документы не найдены', noResultsHint: 'Измените запрос или сбросьте фильтры.', noTitle: 'Название не указано', noSnippet: 'Для документа нет выдержки из текста.', oneResult: 'результат на странице', manyResults: 'результатов на странице', noResultsCount: 'Поиск недоступен', page: 'Страница', previous: 'Назад', next: 'Далее', document: 'Документ', chooseResult: 'Выберите результат', chooseDocument: 'Выберите результат, чтобы прочитать доступную редакцию.', documentLanguage: 'Язык', toc: 'Содержание', noSections: 'В тексте нет разделов', editionMissing: 'В этой редакции нет доступного текста на выбранном языке.', loadingEdition: 'Загрузка редакции…', editionUnavailable: 'Редакция недоступна.', noEditions: 'Для документа нет доступных редакций с текстом.', find: 'Найти в документе', findPlaceholder: 'Фраза в документе', noMatches: 'Совпадений нет', print: 'Печать', source: 'Официальный источник', expand: 'Развернуть', back: 'К результатам', opened: 'Открыт', queryRequired: 'Введите запрос, чтобы начать поиск.', searching: 'Выполняется поиск…', searchError: 'Не удалось выполнить поиск. Повторите попытку.', noDocuments: 'документов нет. Измените запрос или фильтры.', resultsFor: 'Результаты для запроса', allAuthorities: 'Все органы'},
|
||||
ky: {brand: 'Кыргызстандын укуктук базасы', queryLabel: 'Укуктук база боюнча издөө', queryPlaceholder: 'Укуктук суроону же документтин аталышын киргизиңиз', clearQuery: 'Суроону тазалоо', search: 'Издөө', sources: 'Булактар жөнүндө', searchLanguage: 'Издөө тили', russian: 'Орусча', kyrgyz: 'Кыргызча', filters: 'Чыпкалар', clear: 'Тазалоо', close: 'Чыпкаларды жабуу', date: 'Кабыл алынган күнү', from: 'Баштап', to: 'Чейин', results: 'Натыйжалар', enterQuery: 'Суроо киргизиңиз', sort: 'Иреттөө', relevance: 'Маанилүүлүгү боюнча', dateSort: 'Датасы боюнча', noResults: 'Документтер табылган жок', noResultsHint: 'Суроону өзгөртүңүз же чыпкаларды тазалаңыз.', noTitle: 'Аталышы көрсөтүлгөн эмес', noSnippet: 'Документтин үзүндүсү жок.', oneResult: 'натыйжа бетте', manyResults: 'натыйжа бетте', noResultsCount: 'Издөө жеткиликсиз', page: 'Бет', previous: 'Артка', next: 'Кийинки', document: 'Документ', chooseResult: 'Натыйжаны тандаңыз', chooseDocument: 'Текстин окуу үчүн тизмеден натыйжаны тандаңыз.', documentLanguage: 'Тили', toc: 'Мазмуну', noSections: 'Текстте бөлүмдөр жок', editionMissing: 'Бул редакцияда тандалган тилдеги текст жок.', loadingEdition: 'Редакция жүктөлүүдө…', editionUnavailable: 'Редакция жеткиликсиз.', noEditions: 'Документтин жеткиликтүү редакциялары жок.', find: 'Документтен издөө', findPlaceholder: 'Документтен сөз айкашын издөө', noMatches: 'Дал келген нерсе табылган жок', print: 'Басып чыгаруу', source: 'Расмий булак', expand: 'Кеңейтүү', back: 'Натыйжаларга кайтуу', opened: 'Ачылды', queryRequired: 'Издөө үчүн суроо киргизиңиз.', searching: 'Издөө аткарылууда…', searchError: 'Издөө аткарылган жок. Кайра аракет кылыңыз.', noDocuments: 'документ табылган жок. Суроону же чыпкаларды өзгөртүңүз.', resultsFor: 'Суроо боюнча натыйжалар', allAuthorities: 'Бардык органдар'}
|
||||
};
|
||||
const copy = (key) => searchCopy[uiLanguage][key];
|
||||
const modalCopy = {
|
||||
ru: {account: 'Личный кабинет', lab: 'Лаборатория', users: 'Пользователи', logout: 'Выйти', role: 'Роль', admin: 'Администратор', created: 'Дата создания', changePassword: 'Изменить пароль', passwordRequirement: 'Новый пароль должен содержать не менее 12 символов.', currentPassword: 'Текущий пароль', newPassword: 'Новый пароль', confirmPassword: 'Повторите новый пароль', change: 'Изменить пароль', close: 'Закрыть', usersHeading: 'Управление пользователями', temporaryPassword: 'Временный пароль', temporaryPasswordHelp: 'Пароль — от 12 символов. Новый пользователь сменит его при первом входе. Передайте временный пароль отдельно.', createUser: 'Создать пользователя', sourcesHeading: 'Источники документов', sourceIntro: 'Поиск выполняется по документам, подготовленным из правовых источников Кыргызской Республики. Для каждого результата доступная редакция и ссылка на официальный источник показываются в панели документа.', officialSource: 'Официальный источник:', sourceName: 'Централизованный банк данных правовой информации Министерства юстиции Кыргызской Республики', loginHeading: 'Вход в Акылдаш', loginHelp: 'Введите учетные данные, выданные администратором.', username: 'Логин', password: 'Пароль', login: 'Войти', newPasswordHeading: 'Создайте новый пароль', newPasswordHelp: 'Для продолжения замените временный пароль. Используйте не менее 12 символов.', loginError: 'Не удалось войти. Проверьте логин и пароль.', passwordMismatch: 'Новые пароли не совпадают.', passwordChangeError: 'Не удалось изменить пароль.', usersLoadError: 'Не удалось загрузить список пользователей.', passwordResetError: 'Не удалось сбросить пароль.', userDeleteError: 'Не удалось удалить учетную запись.', userCreateError: 'Не удалось создать учетную запись.'},
|
||||
ky: {account: 'Жеке кабинет', lab: 'Лаборатория', users: 'Колдонуучулар', logout: 'Чыгуу', role: 'Ролу', admin: 'Администратор', created: 'Түзүлгөн күнү', changePassword: 'Сырсөздү өзгөртүү', passwordRequirement: 'Жаңы сырсөз кеминде 12 белгиден турушу керек.', currentPassword: 'Учурдагы сырсөз', newPassword: 'Жаңы сырсөз', confirmPassword: 'Жаңы сырсөздү кайталаңыз', change: 'Сырсөздү өзгөртүү', close: 'Жабуу', usersHeading: 'Колдонуучуларды башкаруу', temporaryPassword: 'Убактылуу сырсөз', temporaryPasswordHelp: 'Сырсөз кеминде 12 белгиден турушу керек. Жаңы колдонуучу биринчи киргенде аны өзгөртөт. Убактылуу сырсөздү өзүнчө жөнөтүңүз.', createUser: 'Колдонуучу түзүү', sourcesHeading: 'Документтердин булактары', sourceIntro: 'Издөө Кыргыз Республикасынын укуктук булактарынан даярдалган документтер боюнча жүргүзүлөт. Ар бир документ үчүн жеткиликтүү редакция жана расмий булактын шилтемеси көрсөтүлөт.', officialSource: 'Расмий булак:', sourceName: 'Кыргыз Республикасынын Юстиция министрлигинин укуктук маалыматтарынын борборлоштурулган банкы', loginHeading: 'Акылдашка кирүү', loginHelp: 'Администратор берген эсептик дайындарды киргизиңиз.', username: 'Колдонуучунун аты', password: 'Сырсөз', login: 'Кирүү', newPasswordHeading: 'Жаңы сырсөз түзүңүз', newPasswordHelp: 'Улантуу үчүн убактылуу сырсөздү алмаштырыңыз. Кеминде 12 белги колдонуңуз.', loginError: 'Кирүү мүмкүн болгон жок. Колдонуучунун атын жана сырсөздү текшериңиз.', passwordMismatch: 'Жаңы сырсөздөр дал келген жок.', passwordChangeError: 'Сырсөздү өзгөртүү мүмкүн болгон жок.', usersLoadError: 'Колдонуучулардын тизмесин жүктөө мүмкүн болгон жок.', passwordResetError: 'Сырсөздү баштапкы абалга келтирүү мүмкүн болгон жок.', userDeleteError: 'Колдонуучуну өчүрүү мүмкүн болгон жок.', userCreateError: 'Колдонуучуну түзүү мүмкүн болгон жок.'}
|
||||
};
|
||||
const modalMessage = (key) => modalCopy[uiLanguage][key];
|
||||
const reviewRoute = location.pathname === '/review';
|
||||
$('review').hidden = !reviewRoute; $('search-page').hidden = reviewRoute; $('review-review-nav').classList.toggle('is-active', reviewRoute); $('review-search-nav').classList.toggle('is-active', !reviewRoute); $('review-nav').classList.toggle('is-active', reviewRoute); $('search-nav').classList.toggle('is-active', !reviewRoute);
|
||||
if (!reviewRoute) { document.title = 'Поиск по правовой базе · Акылдаш'; document.querySelector('.review__header-content').hidden = true; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#public-results'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.2.0 · Backend v0.13.0'; }
|
||||
async function apiFetch(path, options = {}) { const response = await fetch(path, {credentials: 'same-origin', ...options}); if (response.status === 401 && path !== '/auth/login') { document.body.classList.add('review__locked'); if (!$('login-dialog').open) $('login-dialog').showModal(); $('login-username').focus(); } return response; }
|
||||
const showLogin = () => { document.body.classList.add('review__locked'); if (!$('login-dialog').open) $('login-dialog').showModal(); $('login-username').focus(); };
|
||||
const enterWorkspace = (user) => { account = user; $('signed-in-user').textContent = `${user.username} · ${user.role}`; $('review-signed-in-user').textContent = `${user.username} · ${user.role}`; $('reviewer').value = user.username; $('profile-username').textContent = user.username; $('profile-role').textContent = user.role; $('profile-created').textContent = new Date(user.created_at * 1000).toLocaleDateString('ru-RU'); $('search-users-open').hidden = $('review-users-open').hidden = user.username.toLowerCase() !== 'admin'; document.body.classList.remove('review__locked'); if (!reviewRoute) initSearchPage(); else if (!hasSeenTour()) setTimeout(openTutorial, 350); };
|
||||
const filterFields = [['statuses', 'Статус', 'status'], ['document_types', 'Вид документа', 'document_type'], ['authorities', 'Орган принятия', 'authority']];
|
||||
const filterFields = [['statuses', {ru: 'Статус', ky: 'Статус'}, 'status'], ['document_types', {ru: 'Вид документа', ky: 'Документтин түрү'}, 'document_type'], ['authorities', {ru: 'Орган принятия', ky: 'Кабыл алган орган'}, 'authority']];
|
||||
let filterCatalogs = {};
|
||||
let filterCatalogsLoaded = false;
|
||||
let publicPage = 1;
|
||||
@@ -413,7 +425,8 @@
|
||||
filterCatalogsLoaded = true;
|
||||
const root = $('filter-fields');
|
||||
root.replaceChildren();
|
||||
filterFields.forEach(([key, title, parameter]) => {
|
||||
filterFields.forEach(([key, titles, parameter]) => {
|
||||
const title = titles[uiLanguage];
|
||||
const items = payload[key] || [];
|
||||
if (!items.length) return;
|
||||
const group = document.createElement('section');
|
||||
@@ -426,9 +439,9 @@
|
||||
select.id = 'filter-authority';
|
||||
const all = document.createElement('option');
|
||||
all.value = '';
|
||||
all.textContent = 'Все органы';
|
||||
all.textContent = copy('allAuthorities');
|
||||
select.append(all);
|
||||
items.forEach((item) => { const option = document.createElement('option'); option.value = item.code; option.textContent = `${item.labels.ru || item.labels.ky || item.code} (${item.count})`; select.append(option); });
|
||||
items.forEach((item) => { const option = document.createElement('option'); option.value = item.code; option.textContent = `${item.labels[uiLanguage] || item.labels.ru || item.labels.ky || item.code} (${item.count})`; select.append(option); });
|
||||
select.addEventListener('change', () => runSearch(1));
|
||||
label.append(select);
|
||||
group.append(label);
|
||||
@@ -453,17 +466,17 @@
|
||||
const root = $('active-filters');
|
||||
root.replaceChildren();
|
||||
const filters = selectedFilters();
|
||||
const labels = Object.fromEntries(filterFields.map(([key, title, parameter]) => [parameter, {title, items: filterCatalogs[key] || []}]));
|
||||
const labels = Object.fromEntries(filterFields.map(([key, titles, parameter]) => [parameter, {title: titles[uiLanguage], items: filterCatalogs[key] || []}]));
|
||||
Object.entries(filters).forEach(([parameter, value]) => {
|
||||
if (!value) return;
|
||||
const item = labels[parameter].items.find((candidate) => candidate.code === value);
|
||||
const chip = document.createElement('span'); chip.className = 'search__chip';
|
||||
chip.append(document.createTextNode(item?.labels.ru || item?.labels.ky || value));
|
||||
chip.append(document.createTextNode(item?.labels[uiLanguage] || item?.labels.ru || item?.labels.ky || value));
|
||||
const remove = document.createElement('button'); remove.type = 'button'; remove.innerHTML = '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="m5 5 10 10m0-10L5 15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>'; remove.setAttribute('aria-label', `Убрать фильтр «${labels[parameter].title}»`);
|
||||
remove.addEventListener('click', () => { if (parameter === 'authority') $('filter-authority').value = ''; else { const input = document.querySelector(`input[name="filter-${parameter}"]:checked`); if (input) input.checked = false; } runSearch(1); });
|
||||
chip.append(remove); root.append(chip);
|
||||
});
|
||||
[['date-from', 'date_from', 'От'], ['date-to', 'date_to', 'До']].forEach(([id, key, label]) => {
|
||||
[['date-from', 'date_from', copy('from')], ['date-to', 'date_to', copy('to')]].forEach(([id, key, label]) => {
|
||||
if (!$(`${id}`).value) return;
|
||||
const value = $(`${id}`).value;
|
||||
const chip = document.createElement('span'); chip.className = 'search__chip'; chip.append(document.createTextNode(`${label}: ${value}`));
|
||||
@@ -489,24 +502,24 @@
|
||||
list.replaceChildren();
|
||||
if (!results.length) {
|
||||
const empty = document.createElement('li'); empty.className = 'review__empty';
|
||||
const heading = document.createElement('strong'); heading.textContent = 'Документы не найдены';
|
||||
empty.append(heading, document.createTextNode('Измените запрос или сбросьте фильтры.')); list.append(empty); return;
|
||||
const heading = document.createElement('strong'); heading.textContent = copy('noResults');
|
||||
empty.append(heading, document.createTextNode(copy('noResultsHint'))); list.append(empty); return;
|
||||
}
|
||||
results.forEach((result) => {
|
||||
const row = document.createElement('li'); row.className = 'search__result'; row.dataset.code = result.code;
|
||||
const title = document.createElement('button'); title.type = 'button'; title.className = 'search__result-title'; title.textContent = result.name || 'Название не указано';
|
||||
const title = document.createElement('button'); title.type = 'button'; title.className = 'search__result-title'; title.textContent = result.name || copy('noTitle');
|
||||
title.addEventListener('click', () => openPublicDocument(result, row));
|
||||
const meta = document.createElement('p'); meta.className = 'search__result-meta'; meta.textContent = [result.type, result.status, $('public-language').value.toUpperCase(), result.date_adopted, result.number, result.legal_force].filter(Boolean).join(' · ');
|
||||
const snippet = document.createElement('p'); snippet.className = 'search__result-snippet'; appendHighlighted(snippet, result.snippet || 'Для документа нет выдержки из текста.', $('public-query').value.trim());
|
||||
const snippet = document.createElement('p'); snippet.className = 'search__result-snippet'; appendHighlighted(snippet, result.snippet || copy('noSnippet'), $('public-query').value.trim());
|
||||
row.append(title, meta, snippet); list.append(row);
|
||||
});
|
||||
}
|
||||
async function runSearch(page = 1, updateUrl = true) {
|
||||
const query = $('public-query').value.trim();
|
||||
if (!query) { $('public-status').textContent = 'Введите запрос, чтобы начать поиск.'; syncFilterChips(); return; }
|
||||
if (!query) { $('public-status').textContent = copy('queryRequired'); syncFilterChips(); return; }
|
||||
publicPage = Math.max(1, page);
|
||||
const requestId = ++searchRequest;
|
||||
$('public-status').textContent = 'Выполняется поиск…';
|
||||
$('public-status').textContent = copy('searching');
|
||||
const params = new URLSearchParams({q: query, language: $('public-language').value, page: String(publicPage), page_size: '20', sort: $('sort-order').value});
|
||||
for (const [key, value] of Object.entries(selectedFilters())) if (value) params.set(key, value);
|
||||
if ($('date-from').value) params.set('date_from', $('date-from').value);
|
||||
@@ -517,25 +530,25 @@
|
||||
const response = await apiFetch(`/search?${params}`);
|
||||
const payload = await response.json();
|
||||
if (requestId !== searchRequest) return;
|
||||
if (!response.ok) throw new Error(payload.error || 'Не удалось выполнить поиск.');
|
||||
if (!response.ok) throw new Error(payload.error || copy('searchError'));
|
||||
renderPublicResults(payload.results || []);
|
||||
const count = (payload.results || []).length;
|
||||
$('result-count').textContent = `${count} ${count === 1 ? 'результат' : 'результата'} на странице`;
|
||||
$('public-status').textContent = count ? `Результаты для запроса «${query}».` : `По запросу «${query}» документов нет. Измените запрос или фильтры.`;
|
||||
$('page-label').textContent = `Страница ${publicPage}`;
|
||||
$('result-count').textContent = `${count} ${count === 1 ? copy('oneResult') : copy('manyResults')}`;
|
||||
$('public-status').textContent = count ? `${copy('resultsFor')} «${query}».` : `«${query}»: ${copy('noDocuments')}`;
|
||||
$('page-label').textContent = `${copy('page')} ${publicPage}`;
|
||||
$('page-prev').disabled = publicPage <= 1;
|
||||
$('page-next').disabled = !payload.has_next;
|
||||
} catch (error) {
|
||||
if (requestId !== searchRequest) return;
|
||||
$('public-status').textContent = error.message || 'Не удалось выполнить поиск. Повторите попытку.';
|
||||
$('result-count').textContent = 'Поиск недоступен';
|
||||
$('public-status').textContent = error.message || copy('searchError');
|
||||
$('result-count').textContent = copy('noResultsCount');
|
||||
}
|
||||
}
|
||||
function buildDocumentToc() {
|
||||
const body = $('public-document-body');
|
||||
const headings = [...body.querySelectorAll('h1, h2, h3')];
|
||||
const root = $('document-toc-list'); root.replaceChildren();
|
||||
if (!headings.length) { const item = document.createElement('li'); item.textContent = 'В тексте нет разделов'; root.append(item); return; }
|
||||
if (!headings.length) { const item = document.createElement('li'); item.textContent = copy('noSections'); root.append(item); return; }
|
||||
headings.forEach((heading, index) => {
|
||||
if (!heading.id) heading.id = `document-section-${index + 1}`;
|
||||
const item = document.createElement('li'); const link = document.createElement('a'); link.href = `#${heading.id}`; link.textContent = heading.textContent.trim();
|
||||
@@ -561,7 +574,7 @@
|
||||
});
|
||||
}
|
||||
const count = documentMatches.length;
|
||||
$('document-find-count').textContent = count ? `1 из ${count}` : query ? 'Совпадений нет' : '';
|
||||
$('document-find-count').textContent = count ? `1 ${uiLanguage === 'ru' ? 'из' : 'ичинен'} ${count}` : query ? copy('noMatches') : '';
|
||||
$('document-find-prev').disabled = $('document-find-next').disabled = count === 0;
|
||||
if (count) moveDocumentMatch(1);
|
||||
buildDocumentToc();
|
||||
@@ -570,19 +583,23 @@
|
||||
if (!documentMatches.length) return;
|
||||
activeDocumentMatch = (activeDocumentMatch + step + documentMatches.length) % documentMatches.length;
|
||||
documentMatches.forEach((mark, index) => { mark.classList.toggle('is-current', index === activeDocumentMatch); });
|
||||
$('document-find-count').textContent = `${activeDocumentMatch + 1} из ${documentMatches.length}`;
|
||||
$('document-find-count').textContent = `${activeDocumentMatch + 1} ${uiLanguage === 'ru' ? 'из' : 'ичинен'} ${documentMatches.length}`;
|
||||
documentMatches[activeDocumentMatch].scrollIntoView({block: 'center', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth'});
|
||||
}
|
||||
let filterReturnFocus = null;
|
||||
function closeMobileFilters() {
|
||||
$('search-filters').classList.remove('is-open'); $('filter-overlay').classList.remove('is-open');
|
||||
$('search-filters').removeAttribute('aria-modal'); $('search-filters').removeAttribute('role'); $('search-filters').setAttribute('aria-hidden', 'true'); $('mobile-filters-open').setAttribute('aria-expanded', 'false');
|
||||
$('search-filters').removeAttribute('aria-modal'); $('search-filters').removeAttribute('role');
|
||||
if (matchMedia('(max-width: 900px)').matches) $('search-filters').setAttribute('aria-hidden', 'true'); else $('search-filters').removeAttribute('aria-hidden');
|
||||
$('mobile-filters-open').setAttribute('aria-expanded', 'false');
|
||||
['search__chrome', 'search__results', 'search__document', 'search__footer'].forEach((selector) => document.querySelector(`.${selector}`).inert = false);
|
||||
filterReturnFocus?.focus();
|
||||
}
|
||||
function openMobileFilters() {
|
||||
filterReturnFocus = document.activeElement;
|
||||
$('search-filters').classList.add('is-open'); $('filter-overlay').classList.add('is-open');
|
||||
$('search-filters').setAttribute('role', 'dialog'); $('search-filters').setAttribute('aria-modal', 'true'); $('search-filters').setAttribute('aria-hidden', 'false'); $('mobile-filters-open').setAttribute('aria-expanded', 'true');
|
||||
['search__chrome', 'search__results', 'search__document', 'search__footer'].forEach((selector) => document.querySelector(`.${selector}`).inert = true);
|
||||
$('mobile-filters-close').focus();
|
||||
}
|
||||
function setMobileView(view) {
|
||||
@@ -597,17 +614,17 @@
|
||||
const language = $('document-language').value;
|
||||
const editionInfo = selectedPublicDocument.editions.find((item) => String(item.source_code) === String(edition));
|
||||
const languages = editionInfo?.available_languages || [];
|
||||
if (!languages.includes(language)) { $('document-facts').textContent = 'В этой редакции нет доступного текста на выбранном языке.'; $('public-document-body').replaceChildren(); return; }
|
||||
if (!languages.includes(language)) { $('document-facts').textContent = copy('editionMissing'); $('public-document-body').replaceChildren(); return; }
|
||||
const requestId = ++editionRequest;
|
||||
$('document-facts').textContent = 'Загрузка редакции…';
|
||||
$('document-facts').textContent = copy('loadingEdition');
|
||||
try {
|
||||
const response = await apiFetch(`/documents/${encodeURIComponent(code)}/editions/${encodeURIComponent(edition)}?language=${encodeURIComponent(language)}`);
|
||||
const payload = await response.json();
|
||||
if (requestId !== editionRequest || selectedPublicDocument?.code !== code || $('document-edition').value !== edition || $('document-language').value !== language) return;
|
||||
if (!response.ok) throw new Error(payload.error || 'Редакция недоступна.');
|
||||
if (!response.ok) throw new Error(payload.error || copy('editionUnavailable'));
|
||||
const content = payload.content[language];
|
||||
const meta = $('document-facts'); meta.replaceChildren();
|
||||
const type = document.createElement('span'); type.textContent = selectedPublicDocument.type || 'Документ';
|
||||
const type = document.createElement('span'); type.textContent = selectedPublicDocument.type || copy('document');
|
||||
const status = document.createElement('span'); status.textContent = selectedPublicDocument.status || '';
|
||||
const date = document.createElement('span'); date.textContent = selectedPublicDocument.date_adopted || '';
|
||||
meta.append(type, document.createTextNode(' · '), status, ...(date.textContent ? [document.createTextNode(' · '), date] : []));
|
||||
@@ -619,7 +636,7 @@
|
||||
highlightDocumentMatches();
|
||||
} catch (error) {
|
||||
if (requestId !== editionRequest) return;
|
||||
$('document-facts').textContent = error.message || 'Не удалось загрузить документ.';
|
||||
$('document-facts').textContent = error.message || copy('editionUnavailable');
|
||||
$('public-document-body').replaceChildren();
|
||||
}
|
||||
}
|
||||
@@ -627,9 +644,9 @@
|
||||
document.querySelectorAll('.search__result--selected').forEach((item) => item.classList.remove('search__result--selected'));
|
||||
row.classList.add('search__result--selected'); selectedPublicDocument = result; ++editionRequest;
|
||||
row.querySelector('.search__opened')?.remove();
|
||||
const opened = document.createElement('span'); opened.className = 'search__opened'; opened.textContent = 'Открыт'; row.append(opened);
|
||||
$('document-breadcrumb').textContent = result.name || 'Документ';
|
||||
$('public-document-heading').textContent = result.name || 'Документ'; $('public-document-body').replaceChildren();
|
||||
const opened = document.createElement('span'); opened.className = 'search__opened'; opened.textContent = copy('opened'); row.append(opened);
|
||||
$('document-breadcrumb').textContent = result.name || copy('document');
|
||||
$('public-document-heading').textContent = result.name || copy('document'); $('public-document-body').replaceChildren();
|
||||
setMobileView('document');
|
||||
$('document-find').value = ''; $('document-find').disabled = true; $('document-note').hidden = true;
|
||||
$('document-source').hidden = true; $('document-edition').replaceChildren(); $('document-language').replaceChildren();
|
||||
@@ -637,7 +654,7 @@
|
||||
const response = await apiFetch(`/documents/${encodeURIComponent(result.code)}/editions`);
|
||||
const payload = await response.json();
|
||||
if (selectedPublicDocument !== result) return;
|
||||
if (!response.ok) throw new Error(payload.error || 'Не удалось получить список редакций.');
|
||||
if (!response.ok) throw new Error(payload.error || copy('editionUnavailable'));
|
||||
selectedPublicDocument.editions = payload.editions || [];
|
||||
const editionSelect = $('document-edition');
|
||||
(payload.editions || []).forEach((edition) => { const option = document.createElement('option'); option.value = edition.source_code; const name = edition.name?.[$('public-language').value] || edition.name?.ru || edition.name?.ky; option.textContent = [name, edition.source_code].filter(Boolean).join(' · '); editionSelect.append(option); });
|
||||
@@ -646,9 +663,9 @@
|
||||
editionSelect.disabled = !editionSelect.options.length;
|
||||
const languages = payload.available_languages || [];
|
||||
const languageSelect = $('document-language');
|
||||
languages.forEach((language) => { const option = document.createElement('option'); option.value = language; option.textContent = language === 'ru' ? 'Русский' : 'Кыргызский'; languageSelect.append(option); });
|
||||
languages.forEach((language) => { const option = document.createElement('option'); option.value = language; option.textContent = language === 'ru' ? copy('russian') : copy('kyrgyz'); languageSelect.append(option); });
|
||||
languageSelect.disabled = !languages.length;
|
||||
if (!languages.length || !editionSelect.options.length) throw new Error('Для документа нет доступных редакций с текстом.');
|
||||
if (!languages.length || !editionSelect.options.length) throw new Error(copy('noEditions'));
|
||||
languageSelect.value = languages.includes($('public-language').value) ? $('public-language').value : languages[0];
|
||||
await loadPublicEdition();
|
||||
} catch (error) { if (selectedPublicDocument === result) $('document-facts').textContent = error.message || 'Документ недоступен.'; }
|
||||
@@ -677,11 +694,83 @@
|
||||
if (params.get('q')) await runSearch(Number(params.get('page') || 1), false);
|
||||
}
|
||||
function setUiLanguage(language) {
|
||||
const ky = language === 'ky';
|
||||
uiLanguage = language === 'ky' ? 'ky' : 'ru';
|
||||
const ky = uiLanguage === 'ky';
|
||||
$('ui-ru').setAttribute('aria-pressed', String(!ky)); $('ui-ky').setAttribute('aria-pressed', String(ky));
|
||||
document.documentElement.lang = ky ? 'ky' : 'ru';
|
||||
const words = ky ? {search: 'Издөө', sources: 'Булактар жөнүндө', filters: 'Чыпкалар', clear: 'Тазалоо', document: 'Документ', results: 'Натыйжалар', all: 'Бардык органдар', find: 'Документтен издөө', toc: 'Мазмуну', print: 'Басып чыгаруу', account: 'Жеке кабинет', result: 'Табуу'} : {search: 'Поиск', sources: 'Об источниках', filters: 'Фильтры', clear: 'Сбросить', document: 'Документ', results: 'Результаты', all: 'Все органы', find: 'Найти в документе', toc: 'Содержание', print: 'Печать', account: 'Личный кабинет', result: 'Найти'};
|
||||
$('search-nav').textContent = words.search; $('sources-open').textContent = words.sources; $('filters-heading').textContent = words.filters; $('clear-filters').textContent = words.clear; $('mobile-filters-open').textContent = words.filters; $('mobile-filters-close').textContent = words.clear; $('search-results-heading').textContent = words.results; $('public-document-heading').textContent = selectedPublicDocument?.name || words.document; $('document-find').previousElementSibling.textContent = words.find; $('document-toc').querySelector('summary').textContent = words.toc; $('document-print').textContent = words.print; $('search-account-open').setAttribute('aria-label', words.account); $('search-account-open').title = words.account; $('public-search-form').querySelector('button[type="submit"]').textContent = words.result;
|
||||
const setLabel = (selector, value) => { const label = document.querySelector(selector); if (label?.firstChild?.nodeType === Node.TEXT_NODE) label.firstChild.textContent = `${value} `; };
|
||||
const setLabels = (selector, values) => document.querySelectorAll(selector).forEach((label, index) => { if (label.firstChild?.nodeType === Node.TEXT_NODE) label.firstChild.textContent = `${values[index]} `; });
|
||||
const setText = (id, key) => { if ($(id)) $(id).textContent = copy(key); };
|
||||
setText('search-nav', 'search'); setText('sources-open', 'sources'); setText('filters-heading', 'filters');
|
||||
setText('clear-filters', 'clear'); setText('mobile-filters-open', 'filters'); setText('mobile-filters-close', 'close');
|
||||
setText('search-results-heading', 'results'); setText('document-print', 'print');
|
||||
$('search-account-open').setAttribute('aria-label', ky ? 'Жеке кабинет' : 'Личный кабинет'); $('search-account-open').title = $('search-account-open').getAttribute('aria-label');
|
||||
$('public-search-form').querySelector('button[type="submit"]').textContent = ky ? 'Издөө' : 'Найти';
|
||||
$('public-search-form').querySelector('label[for="public-query"]').textContent = copy('queryLabel');
|
||||
$('public-query').placeholder = copy('queryPlaceholder'); $('clear-query').setAttribute('aria-label', copy('clearQuery'));
|
||||
$('search-page').querySelector('h1').textContent = copy('queryLabel');
|
||||
document.querySelector('.search__brand-description').textContent = copy('brand');
|
||||
document.querySelector('.search__ui-language').setAttribute('aria-label', ky ? 'Туюнтма тили' : 'Язык интерфейса');
|
||||
$('public-language').setAttribute('aria-label', copy('searchLanguage'));
|
||||
$('public-language').options[0].textContent = copy('russian'); $('public-language').options[1].textContent = copy('kyrgyz');
|
||||
$('sort-order').options[0].textContent = copy('relevance'); $('sort-order').options[1].textContent = copy('dateSort');
|
||||
setLabel('.search__sort-label', copy('sort')); setText('result-count', 'enterQuery');
|
||||
document.querySelector('.search__date-label').textContent = copy('date');
|
||||
document.querySelectorAll('.search__date-placeholder')[0].textContent = copy('from'); document.querySelectorAll('.search__date-placeholder')[1].textContent = copy('to');
|
||||
$('date-from').setAttribute('aria-label', ky ? 'Кабыл алынган күндөн' : 'Дата принятия от'); $('date-to').setAttribute('aria-label', ky ? 'Кабыл алынган күнгө чейин' : 'Дата принятия до');
|
||||
$('page-prev').textContent = copy('previous'); $('page-next').textContent = copy('next');
|
||||
setText('document-back', 'back'); setText('document-expand', 'expand'); $('document-expand').setAttribute('aria-label', copy('expand'));
|
||||
const crumbs = document.querySelector('.search__breadcrumbs').children; crumbs[0].textContent = copy('document');
|
||||
if (!selectedPublicDocument) { setText('document-breadcrumb', 'chooseResult'); setText('document-facts', 'chooseDocument'); setText('public-document-heading', 'document'); }
|
||||
setLabel('.search__document-language', ky ? 'Тили' : 'Язык'); $('document-language').setAttribute('aria-label', ky ? 'Документтин тили' : 'Язык документа');
|
||||
$('document-toc').querySelector('summary').textContent = ky ? 'Мазмуну' : 'Содержание';
|
||||
$('document-edition').setAttribute('aria-label', ky ? 'Редакциясы' : 'Редакция документа');
|
||||
$('document-find').previousElementSibling.textContent = copy('find'); $('document-find').placeholder = copy('findPlaceholder');
|
||||
$('document-find-prev').setAttribute('aria-label', ky ? 'Мурунку дал келүү' : 'Предыдущее совпадение'); $('document-find-next').setAttribute('aria-label', ky ? 'Кийинки дал келүү' : 'Следующее совпадение');
|
||||
$('document-source').textContent = copy('source');
|
||||
const modal = modalCopy[uiLanguage];
|
||||
setText('account-heading', 'account'); $('review-nav').textContent = modal.lab; $('search-users-open').textContent = modal.users; $('search-logout').textContent = modal.logout;
|
||||
$('profile-role').textContent = account?.role === 'admin' ? modal.admin : account?.role || '';
|
||||
const accountLabels = document.querySelectorAll('#account-dialog dt'); accountLabels[1].textContent = modal.role; accountLabels[2].textContent = modal.created;
|
||||
$('profile-created').textContent = account?.created_at ? new Intl.DateTimeFormat(ky ? 'ky-KG' : 'ru-RU').format(new Date(account.created_at * 1000)) : '';
|
||||
document.querySelector('#profile-password-form h3').textContent = modal.changePassword; setLabels('#profile-password-form label', [modal.currentPassword, modal.newPassword]);
|
||||
document.querySelector('#profile-password-form p').textContent = modal.passwordRequirement;
|
||||
document.querySelector('#profile-password-form button[type="submit"]').textContent = modal.change;
|
||||
document.querySelector('[data-close="account-dialog"]').textContent = modal.close;
|
||||
$('users-heading').textContent = modal.usersHeading; setLabels('#create-user-form label', [modal.username, modal.temporaryPassword]);
|
||||
document.querySelector('#create-user-form p').textContent = modal.temporaryPasswordHelp;
|
||||
document.querySelector('#create-user-form button').textContent = modal.createUser;
|
||||
document.querySelector('#users-dialog [data-close="users-dialog"]').textContent = modal.close;
|
||||
$('login-heading').textContent = modal.loginHeading; document.querySelector('#login-form > p').textContent = modal.loginHelp;
|
||||
setLabels('#login-form label', [modal.username, modal.password]); document.querySelector('#login-form button').textContent = modal.login;
|
||||
$('password-heading').textContent = modal.newPasswordHeading; document.querySelector('#password-form .review__notice').textContent = modal.newPasswordHelp;
|
||||
setLabels('#password-form label', [modal.currentPassword, modal.newPassword, modal.confirmPassword]); document.querySelector('#password-form button').textContent = modal.change;
|
||||
$('dialog-close').textContent = $('sources-close').textContent = modal.close;
|
||||
$('sources-heading').textContent = modal.sourcesHeading;
|
||||
document.querySelector('#sources-dialog p:first-of-type').textContent = modal.sourceIntro;
|
||||
document.querySelector('#sources-dialog p:nth-of-type(2)').firstChild.textContent = `${modal.officialSource} `;
|
||||
document.querySelector('#sources-dialog a').textContent = modal.sourceName;
|
||||
document.querySelectorAll('.search__opened').forEach((item) => { item.textContent = copy('opened'); });
|
||||
if (documentMatches.length) $('document-find-count').textContent = `${activeDocumentMatch + 1} ${ky ? 'ичинен' : 'из'} ${documentMatches.length}`;
|
||||
else if ($('document-find').value.trim()) $('document-find-count').textContent = copy('noMatches');
|
||||
const resultTotal = $('public-results').querySelector('.review__empty') ? 0 : $('public-results').children.length;
|
||||
if ($('public-query').value.trim()) {
|
||||
$('result-count').textContent = `${resultTotal} ${resultTotal === 1 ? copy('oneResult') : copy('manyResults')}`;
|
||||
$('public-status').textContent = resultTotal ? `${copy('resultsFor')} «${$('public-query').value.trim()}».` : `«${$('public-query').value.trim()}»: ${copy('noDocuments')}`;
|
||||
} else {
|
||||
setText('result-count', 'enterQuery'); setText('public-status', 'queryRequired');
|
||||
}
|
||||
const empty = $('public-results').querySelector('.review__empty');
|
||||
if (empty) { empty.querySelector('strong').textContent = copy('noResults'); empty.lastChild.textContent = copy('noResultsHint'); }
|
||||
const pageMatch = $('page-label').textContent.match(/\d+/); if (pageMatch) $('page-label').textContent = `${copy('page')} ${pageMatch[0]}`;
|
||||
if (filterCatalogsLoaded) {
|
||||
const selected = selectedFilters(); renderFilterCatalogs(filterCatalogs);
|
||||
for (const [key, , parameter] of filterFields) {
|
||||
if (parameter === 'authority') { if ($('filter-authority')) $('filter-authority').value = selected[parameter]; }
|
||||
else { const input = [...document.querySelectorAll(`input[name="filter-${parameter}"]`)].find((item) => item.value === selected[parameter]); if (input) input.checked = true; }
|
||||
}
|
||||
syncFilterChips();
|
||||
}
|
||||
try { localStorage.setItem('akyldash-ui-language', ky ? 'ky' : 'ru'); } catch (_) {}
|
||||
}
|
||||
$('ui-ru').addEventListener('click', () => setUiLanguage('ru'));
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||
<circle cx="24" cy="24" r="20" fill="#F7F2E8" stroke="#9D2633" stroke-width="3"/>
|
||||
<path d="M24 5v38M5 24h38M11 11l26 26M37 11 11 37" fill="none" stroke="#9D2633" stroke-width="3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 276 B |
@@ -28,6 +28,12 @@ class SearchApiTest(unittest.TestCase):
|
||||
content_type, body = Api.static_asset("/static/fonts/noto-sans-cyrillic-ext.woff2")
|
||||
self.assertEqual(content_type, "font/woff2")
|
||||
self.assertTrue(body.startswith(b"wOF2"))
|
||||
content_type, body = Api.static_asset("/static/favicon.svg")
|
||||
self.assertEqual(content_type, "image/svg+xml")
|
||||
self.assertIn(b"<svg", body)
|
||||
content_type, body = Api.static_asset("/static/favicon-32.png")
|
||||
self.assertEqual(content_type, "image/png")
|
||||
self.assertTrue(body.startswith(b"\x89PNG\r\n\x1a\n"))
|
||||
with self.assertRaisesRegex(ApiError, "static asset not found"):
|
||||
Api.static_asset("/static/fonts/../../DESIGN.md")
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
# План точного воспроизведения поискового интерфейса
|
||||
|
||||
Дата: 22 октября 2026 года.
|
||||
Дата: 23 октября 2026 года.
|
||||
|
||||
Статус: реализация и локальная визуальная приёмка выполнены в
|
||||
`feature/frontend-pixel-perfect`; CI и ручная проверка PR ещё ожидаются.
|
||||
Статус: основная реализация и локальная визуальная приёмка выполнены в
|
||||
`feature/frontend-pixel-perfect`. Playwright-сценарий и снимки находятся в
|
||||
репозитории; см. [отчёт](visual-review/README.md). Отдельные состояния и
|
||||
доступность перечислены как непроверенные в отчёте; CI следует сверять по
|
||||
последнему коммиту ветки.
|
||||
|
||||
## 1. Цель, границы и источники истины
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
Проверка выполнена 23 октября 2026 года в Chromium 156.0.8078.4, Playwright
|
||||
1.64.1, headless, device scale factor 1. Макеты проверены при 1280×800,
|
||||
1440×900, 1024×900 и 390×844 CSS px.
|
||||
1440×900, 1024×900 и 390×844 CSS px; reflow дополнительно проверен при
|
||||
320×800 CSS px.
|
||||
|
||||
## Снимки
|
||||
|
||||
@@ -18,10 +19,17 @@
|
||||
- При 1280×800 шапка занимает 127 px, рабочая область — y=127–744,
|
||||
footer — y=744–800; границы колонок x=0/226/648/1280.
|
||||
- Горизонтального переполнения при 1440, 1024 и 390 px нет.
|
||||
- При 320 px также нет горизонтального переполнения.
|
||||
- Загружены Noto Sans и Noto Serif, включая кириллические наборы; повторяющихся
|
||||
HTML id нет, ошибок JavaScript в сценарии не зарегистрировано.
|
||||
- В fixture проверены ответы 200 для SVG и PNG favicon; локализация проверена
|
||||
на фильтрах, результатах, счётчике совпадений и открытом статусе.
|
||||
- Мобильная панель фильтров удерживает фокус в цикле, делает фон inert,
|
||||
показывает 3 px focus ring, закрывается по Escape и возвращает фокус на
|
||||
кнопку открытия.
|
||||
- Playwright проверил фильтры и URL, открытие редакции, поиск по документу
|
||||
(1-е и 2-е совпадения), независимость языка текста от языка интерфейса,
|
||||
отображение поисковых подписей и выбранного статуса на кыргызском,
|
||||
разворачивание документа, открытие кабинета с действиями профиля,
|
||||
пользователей и выхода, мобильную панель фильтров и возврат к результатам,
|
||||
а также изоляцию маршрутов поиска и `/review`.
|
||||
@@ -29,15 +37,25 @@
|
||||
Для снимков Playwright перехватывал API и отдавал локальные детерминированные
|
||||
fixture-ответы. Подпись «Демонстрационные данные» добавлялась только в тестовой
|
||||
странице перед снимком. Эти юридические тексты и результаты не входят в код
|
||||
продукта. Overlay растягивает исходный растр 1586×992 до 1280×800; разница
|
||||
пропорций менее 0,1%.
|
||||
продукта. Для overlay и difference исходный растр 1586×992 вписывается
|
||||
в 1280×800 с сохранением пропорций и полем около 1 px по бокам.
|
||||
|
||||
## Повторный запуск
|
||||
|
||||
Из корня репозитория выполните `cd tools/frontend-visual-review`, затем
|
||||
`npm ci`, `npx playwright install chromium` и `npm test`. Скрипт читает текущий
|
||||
`backend/search/review.html`, перехватывает API детерминированными fixture-
|
||||
ответами и обновляет перечисленные выше снимки. Overlay и pixel-difference
|
||||
создаются, если найден исходный файл по стандартному пути или передан путь в
|
||||
переменной `REFERENCE_IMAGE`.
|
||||
|
||||
## Оставшиеся визуальные отличия
|
||||
|
||||
Сглаживание текста и содержимое документа отличаются от растрового макета.
|
||||
Поля даты показывают системный формат Chromium. Переключатель языка интерфейса
|
||||
и поле языка поиска остаются разными состояниями. Не проверялись печать в
|
||||
браузере, полный набор ошибок и пустых состояний, клавиатурный обход всех
|
||||
диалогов и длинные таблицы в реальных редакциях. Автоматические изображения
|
||||
браузере, полный набор ошибок, клавиатурный обход остальных диалогов,
|
||||
скринридер, видимый фокус на каждом шаге и длинные таблицы в реальных редакциях.
|
||||
Автоматические изображения
|
||||
показывают рабочую композицию, но не подтверждают юридическое содержимое или
|
||||
доступность во всех браузерах.
|
||||
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 718 KiB After Width: | Height: | Size: 718 KiB |
@@ -0,0 +1,72 @@
|
||||
const { chromium } = require('playwright');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const repo = path.resolve(__dirname, '../..');
|
||||
const artifacts = path.join(repo, 'docs/product/visual-review');
|
||||
const html = fs.readFileSync(path.join(repo, 'backend/search/review.html'), 'utf8');
|
||||
const root = path.join(repo, 'backend/search/static/fonts');
|
||||
fs.mkdirSync(artifacts, {recursive: true});
|
||||
const docs = [
|
||||
{code:'doc-1',name:'Налоговый кодекс Кыргызской Республики',type:'Кодекс',status:'Действует',snippet:'Налог на прибыль организаций и порядок исчисления налога.',edition:'2024',editions:[{source_code:'2024',name:{ru:'Редакция от 12 января 2024 года'},available_languages:['ru','ky']} ]},
|
||||
{code:'doc-2',name:'О введении в действие Налогового кодекса Кыргызской Республики',type:'Закон',status:'Действует',snippet:'Устанавливается порядок введения в действие Налогового кодекса Кыргызской Республики.'},
|
||||
{code:'doc-3',name:'Порядок заполнения налоговой отчётности',type:'Постановление',snippet:'Рекомендации по заполнению налоговой отчётности, включая показатели по налогу на прибыль.'}
|
||||
];
|
||||
const catalogs = {document_types:[{code:'code',labels:{ru:'Кодекс',ky:'Кодекс'},count:12},{code:'law',labels:{ru:'Закон',ky:'Мыйзам'},count:10},{code:'decree',labels:{ru:'Постановление',ky:'Токтом'},count:8}],statuses:[{code:'active',labels:{ru:'Действует',ky:'Күчүндө'},count:24},{code:'repealed',labels:{ru:'Утратил силу',ky:'Күчүн жоготту'},count:5}],authorities:[{code:'ministries_and_committees',labels:{ru:'Министерства и комитеты',ky:'Министрликтер'},count:5}],legal_forces:[]};
|
||||
const article = '<h2>Налог на прибыль</h2><p>Текст документа приведён для примера оформления. Здесь будут отображаться положения выбранной редакции. По запросу налог на прибыль читатель найдёт нужный фрагмент.</p><p>Ссылка на официальный источник позволяет проверить содержание документа. Доступные редакции и языки указаны в панели выше.</p><h2>Налоговая отчётность</h2><p>В этом разделе отображается текст выбранной редакции. Поиск внутри документа помогает перейти к нужному фрагменту: налог на прибыль.</p>';
|
||||
(async()=>{
|
||||
const browser = await chromium.launch({headless:true});
|
||||
const page = await browser.newPage({viewport:{width:1280,height:800},deviceScaleFactor:1});
|
||||
const errors=[]; page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.route('**/*', async route=>{
|
||||
const req=route.request(), url=new URL(req.url());
|
||||
if (req.isNavigationRequest()) return route.fulfill({status:200,contentType:'text/html; charset=utf-8',body:html});
|
||||
const p=url.pathname;
|
||||
if(p==='/auth/me') return route.fulfill({json:{account:{username:'admin',role:'admin',created_at:1780000000,must_change_password:false}}});
|
||||
if(p==='/search/filters') return route.fulfill({json:catalogs});
|
||||
if(p==='/search') return route.fulfill({json:{results:url.searchParams.get('q')==='not-found'?[]:docs,has_next:false}});
|
||||
if(p==='/documents/doc-1/editions') return route.fulfill({json:{available_languages:['ru','ky'],editions:[{source_code:'2024',name:{ru:'Редакция от 12 января 2024 года'},available_languages:['ru','ky']}]}});
|
||||
if(p==='/documents/doc-1/editions/2024') return route.fulfill({json:{content:{ru:{html:article},ky:{html:article}}}});
|
||||
if(p==='/static/favicon.svg') return route.fulfill({status:200,contentType:'image/svg+xml',body:fs.readFileSync(path.join(root,'../favicon.svg'))});
|
||||
if(p==='/static/favicon-32.png') return route.fulfill({status:200,contentType:'image/png',body:fs.readFileSync(path.join(root,'../favicon-32.png'))});
|
||||
if(p.startsWith('/static/fonts/')) { const f=path.join(root,path.basename(p)); return route.fulfill({status:200,contentType:'font/woff2',body:fs.readFileSync(f)}); }
|
||||
return route.fulfill({status:404,contentType:'application/json',body:'{}'});
|
||||
});
|
||||
await page.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3+%D0%BD%D0%B0+%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&document_type=code&status=active');
|
||||
const favicons=await page.evaluate(async()=>Promise.all([...document.querySelectorAll('link[rel="icon"]')].map(async link=>({type:link.type,status:(await fetch(link.href)).status}))));
|
||||
await page.evaluate(()=>document.fonts.ready);
|
||||
await page.locator('.search__result-title').first().click();
|
||||
await page.locator('#public-document-body h2').first().waitFor();
|
||||
await page.evaluate(()=>document.fonts.ready);
|
||||
await page.locator('#document-find').fill('налог на прибыль');
|
||||
await page.waitForTimeout(200);
|
||||
await page.addStyleTag({content:'.search__fixture-label{display:inline-block;margin-inline-start:12px;padding:5px 10px;border:1px solid #A37B32;border-radius:6px;color:#786469;font-size:14px}' });
|
||||
await page.evaluate(()=>{const n=document.createElement('span');n.className='search__fixture-label';n.textContent='Демонстрационные данные';document.querySelector('.search__subnav').append(n);const note=document.querySelector('#document-note');note.textContent='Текст приведён для примера оформления';note.hidden=false});
|
||||
const openedCount=await page.locator('.search__opened').count(); await page.locator('#public-document-panel').evaluate(el=>el.scrollTop=0); await page.evaluate(()=>document.activeElement.blur()); console.log('desktopBoxes', await page.evaluate(()=>Object.fromEntries(['.search__chrome','.search__layout','.search__footer','.search__document'].map(s=>[s,(()=>{const r=document.querySelector(s).getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height}})()])))); console.log('docElements',await page.evaluate(()=>Object.fromEntries(['.search__document-head','#public-document-heading','#public-document-meta','.search__document-toolbar','.search__find','#document-note','#public-document-body h2'].map(s=>[s,(()=>{const r=document.querySelector(s).getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height}})()])))); await page.screenshot({path:path.join(artifacts,'desktop-1280x800.png'),fullPage:false});
|
||||
const chipsBefore=await page.locator('#active-filters .search__chip').count(); await page.locator('#active-filters button').first().click(); const urlFilters=await page.evaluate(()=>({status:new URL(location.href).searchParams.get('status'),documentType:new URL(location.href).searchParams.get('document_type')})); const firstFindCount=await page.locator('#document-find-count').textContent(); await page.locator('#document-find-next').click(); const nextFindCount=await page.locator('#document-find-count').textContent(); await page.locator('input[name="filter-status"][value="active"]').check(); await page.locator('.search__result-title').first().click(); await page.locator('#document-find').fill('налог на прибыль'); await page.locator('#document-find-next').click(); await page.locator('#ui-ky').click(); const documentLanguage=await page.locator('#document-language').inputValue(); const kyrgyzUi=await page.evaluate(()=>({htmlLang:document.documentElement.lang,queryLabel:document.querySelector('label[for=public-query]').textContent,filterHeading:document.querySelectorAll('#filter-fields h3')[1].textContent,statusHeading:document.querySelectorAll('#filter-fields h3')[0].textContent,authority:document.querySelector('#filter-authority option').textContent,activeStatus:document.querySelector('#active-filters .search__chip').textContent.trim(),queryPlaceholder:document.querySelector('#public-query').placeholder,resultCount:document.querySelector('#result-count').textContent,findCount:document.querySelector('#document-find-count').textContent,opened:document.querySelector('.search__opened').textContent})); await page.locator('#ui-ru').click(); await page.locator('#document-expand').click(); const expanded=await page.locator('#document-dialog').evaluate(e=>e.open); await page.locator('#dialog-close').click(); await page.locator('#search-account-open').click(); const accountActions=await page.locator('#account-dialog').evaluate(d=>({open:d.open,reviewVisible:!d.querySelector('#review-nav').hidden,usersVisible:!d.querySelector('#search-users-open').hidden,logoutVisible:!d.querySelector('#search-logout').hidden})); await page.locator('#account-dialog').evaluate(d=>d.close()); await page.locator('#public-query').fill('not-found'); await page.locator('#public-search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('#result-count').textContent.startsWith('0 ')); await page.locator('#ui-ky').click(); const kyrgyzEmpty=await page.evaluate(()=>({count:document.querySelector('#result-count').textContent,heading:document.querySelector('#public-results .review__empty strong').textContent})); await page.locator('#ui-ru').click(); await page.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3+%D0%BD%D0%B0+%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&document_type=code&status=active'); await page.locator('.search__result-title').first().waitFor(); await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); await page.evaluate(()=>document.fonts.ready); await page.locator('#document-find').fill('налог на прибыль'); await page.addStyleTag({content:'.search__fixture-label{display:inline-block;margin-inline-start:12px;padding:5px 10px;border:1px solid #A37B32;border-radius:6px;color:#786469;font-size:14px}'}); await page.evaluate(()=>{const n=document.createElement('span');n.className='search__fixture-label';n.textContent='Демонстрационные данные';document.querySelector('.search__subnav').append(n);const note=document.querySelector('#document-note');note.textContent='Текст приведён для примера оформления';note.hidden=false;document.querySelector('#public-document-panel').scrollTop=0});
|
||||
const fonts = await page.evaluate(async()=>({sans:document.fonts.check('400 16px "Noto Sans"'),serif:document.fonts.check('600 24px "Noto Serif"'),kyrgyzSans:(await document.fonts.load('400 16px "Noto Sans"','ӨөҮүҢң')).length>0,kyrgyzSerif:(await document.fonts.load('600 24px "Noto Serif"','ӨөҮүҢң')).length>0,ids:[...document.querySelectorAll('[id]')].map(x=>x.id).filter((id,i,a)=>a.indexOf(id)!==i)}));
|
||||
const widths={}; for(const width of [1440,1024]){await page.setViewportSize({width,height:900});widths[width]=await page.evaluate(()=>({scrollWidth:document.documentElement.scrollWidth,viewport:innerWidth,layout:document.querySelector('#search-layout').getBoundingClientRect().width}));await page.screenshot({path:path.join(artifacts,`desktop-${width}px.png`)});} await page.setViewportSize({width:320,height:800}); widths[320]=await page.evaluate(()=>({scrollWidth:document.documentElement.scrollWidth,viewport:innerWidth}));
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await page.reload(); await page.evaluate(()=>document.fonts.ready);
|
||||
const view=await page.evaluate(()=>({mobileView:document.querySelector('#search-layout').dataset.mobileView,scrollWidth:document.documentElement.scrollWidth,width:innerWidth}));
|
||||
await page.locator('#mobile-filters-open').click();
|
||||
const drawer=await page.locator('#search-filters').getAttribute('aria-modal'); const modalBackground=await page.evaluate(()=>({chrome:document.querySelector('.search__chrome').inert,results:document.querySelector('.search__results').inert,document:document.querySelector('.search__document').inert,footer:document.querySelector('.search__footer').inert})); await page.locator('#search-filters').evaluate(el=>{const c=[...el.querySelectorAll('button:not([disabled]),input:not([disabled]),select:not([disabled]),[href]')].filter(n=>n.offsetParent!==null);c.at(-1).focus()}); await page.keyboard.press('Tab'); const focusWrap=await page.evaluate(()=>document.activeElement.id); const focusVisible=await page.evaluate(()=>getComputedStyle(document.activeElement).outlineWidth); await page.keyboard.press('Shift+Tab'); const focusReverseWrap=await page.evaluate(()=>document.activeElement.id);
|
||||
await page.keyboard.press('Escape'); const focusRestored=await page.evaluate(()=>document.activeElement.id);
|
||||
await page.locator('.search__result-title').first().click();
|
||||
await page.locator('#public-document-body h2').first().waitFor();
|
||||
await page.screenshot({path:path.join(artifacts,'mobile-390px.png'),fullPage:true});
|
||||
await page.locator('#document-back').click();
|
||||
const back=await page.locator('#search-layout').getAttribute('data-mobile-view'); await page.setViewportSize({width:1024,height:900}); await page.waitForFunction(()=>document.querySelector('#search-filters').getAttribute('aria-hidden')===null); const desktopFilters=await page.evaluate(()=>({visible:getComputedStyle(document.querySelector('#search-filters')).display!=='none',ariaHidden:document.querySelector('#search-filters').getAttribute('aria-hidden')}));
|
||||
await page.goto('https://fixture.invalid/review'); await page.locator('#review').waitFor(); const routes={searchHidden:await page.locator('#search-page').isHidden(),reviewVisible:await page.locator('#review').isVisible()}; console.log(JSON.stringify({fonts,favicons,widths,view,drawer,modalBackground,focusWrap,focusVisible,focusReverseWrap,focusRestored,back,chipsBefore,urlFilters,openedCount,firstFindCount,nextFindCount,documentLanguage,kyrgyzUi,kyrgyzEmpty,expanded,accountActions,desktopFilters,routes,errors},null,2));
|
||||
const referencePath=process.env.REFERENCE_IMAGE || '/home/vaka/.codex/attachments/662a46bf-0f7f-4bba-a63a-bb823f4470dc/image-1.png';
|
||||
if(fs.existsSync(referencePath)) {
|
||||
const comparePage=await browser.newPage();
|
||||
const reference=fs.readFileSync(referencePath).toString('base64'), actual=fs.readFileSync(path.join(artifacts,'desktop-1280x800.png')).toString('base64');
|
||||
await comparePage.setContent(`<canvas id="overlay" width="1280" height="800"></canvas><canvas id="difference" width="1280" height="800"></canvas><img id="reference" src="data:image/png;base64,${reference}"><img id="actual" src="data:image/png;base64,${actual}">`);
|
||||
await comparePage.evaluate(()=>Promise.all([...document.images].map((image)=>image.decode())));
|
||||
const images=await comparePage.evaluate(()=>{const ref=document.querySelector('#reference'),act=document.querySelector('#actual'),place=(context,image)=>{const scale=Math.min(1280/image.naturalWidth,800/image.naturalHeight),width=image.naturalWidth*scale,height=image.naturalHeight*scale;context.fillStyle='#F7F2E8';context.fillRect(0,0,1280,800);context.drawImage(image,(1280-width)/2,(800-height)/2,width,height)};const overlay=document.querySelector('#overlay'),o=overlay.getContext('2d');place(o,ref);o.globalAlpha=.5;o.drawImage(act,0,0,1280,800);o.globalAlpha=1;const difference=document.querySelector('#difference'),d=difference.getContext('2d');place(d,ref);const a=d.getImageData(0,0,1280,800),b=(()=>{const c=document.createElement('canvas');c.width=1280;c.height=800;const x=c.getContext('2d');x.drawImage(act,0,0,1280,800);return x.getImageData(0,0,1280,800)})();for(let i=0;i<a.data.length;i+=4){const v=Math.max(Math.abs(a.data[i]-b.data[i]),Math.abs(a.data[i+1]-b.data[i+1]),Math.abs(a.data[i+2]-b.data[i+2]));a.data[i]=v;a.data[i+1]=0;a.data[i+2]=0;a.data[i+3]=255}d.putImageData(a,0,0);return [overlay.toDataURL('image/png'),difference.toDataURL('image/png')]});
|
||||
['overlay-50.png','pixel-difference.png'].forEach((name,index)=>fs.writeFileSync(path.join(artifacts,name),Buffer.from(images[index].split(',')[1],'base64')));
|
||||
await comparePage.close();
|
||||
}
|
||||
await browser.close();
|
||||
if(errors.length || !fonts.sans || !fonts.serif || !fonts.kyrgyzSans || !fonts.kyrgyzSerif || fonts.ids.length || favicons.length!==2 || favicons.some(item=>item.status!==200) || Object.values(widths).some(x=>x.scrollWidth>x.viewport) || view.scrollWidth>view.width || drawer!=='true' || Object.values(modalBackground).some(value=>value!==true) || focusWrap!=='clear-filters' || focusVisible!=='3px' || focusReverseWrap==='clear-filters' || focusRestored!=='mobile-filters-open' || back!=='results' || openedCount!==1 || chipsBefore!==2 || urlFilters.status!==null || urlFilters.documentType!=='code' || firstFindCount!=='1 из 3' || nextFindCount!=='2 из 3' || documentLanguage!=='ru' || kyrgyzUi.htmlLang!=='ky' || kyrgyzUi.queryLabel!=='Укуктук база боюнча издөө' || kyrgyzUi.statusHeading!=='Статус' || kyrgyzUi.filterHeading!=='Документтин түрү' || kyrgyzUi.authority!=='Бардык органдар' || kyrgyzUi.activeStatus!=='Күчүндө' || kyrgyzUi.resultCount!=='3 натыйжа бетте' || kyrgyzUi.findCount!=='2 ичинен 3' || kyrgyzUi.opened!=='Ачылды' || kyrgyzEmpty.count!=='0 натыйжа бетте' || kyrgyzEmpty.heading!=='Документтер табылган жок' || !expanded || !accountActions.open || !accountActions.reviewVisible || !accountActions.usersVisible || !accountActions.logoutVisible || desktopFilters.visible!==true || desktopFilters.ariaHidden!==null || routes.searchHidden!==true || routes.reviewVisible!==true) process.exit(1);
|
||||
})();
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "akyldash-frontend-visual-review",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"playwright": "1.64.1"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.64.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "akyldash-frontend-visual-review",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "node check.cjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"playwright": "1.64.1"
|
||||
}
|
||||
}
|
||||