Исправления интерфейса Түндүк: поиск, лаборатория и кабинеты #54
No files matched your search
@@ -2,6 +2,19 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Frontend `0.2.6` / Backend `0.13.1`: двухпанельная компоновка поиска
|
||||
сохраняется до ширины 1175 px, чтобы три панели не обрезались при
|
||||
промежуточных ширинах окна.
|
||||
- Frontend `0.2.5` / Backend `0.13.1`: устранены промежуточные переполнения
|
||||
навигации, старый текст больше не остаётся при ошибке загрузки другого
|
||||
документа; очищаются дополнительные URL-атрибуты юридической разметки,
|
||||
включая `srcset` и `ping`. Добавлены fixture-проверки ошибок и гонок загрузки,
|
||||
изоляции ссылок, reflow и безопасных форм управления пользователями.
|
||||
- Frontend `0.2.4` / Backend `0.13.1`: исправлены состояния и адаптивный поиск,
|
||||
чтение документа и лаборатории, доступные диалоги; управление пользователями
|
||||
вынесено на отдельный защищённый экран. Документная разметка очищается от
|
||||
активного HTML и небезопасных ссылок. Добавлены проверки маршрутов и reflow;
|
||||
правовой текст и API управления пользователями не менялись.
|
||||
- Frontend `0.2.3`: добавлен публичный поиск по правовой базе с фильтрами,
|
||||
адаптивной выдачей, просмотром редакций и поиском по тексту документа;
|
||||
внутренняя лаборатория сохранена, её подсказки стали компактнее и привязаны
|
||||
|
||||
@@ -92,7 +92,7 @@ spacing:
|
||||
до реализации; утверждённые токены не заменяются цветами, взятыми пипеткой
|
||||
из растрового изображения. Изменения постоянных правил вносятся сюда явно.
|
||||
Этапы работ, исходные измерения и критерии визуальной приёмки находятся в
|
||||
[плане точного воспроизведения поискового интерфейса](docs/product/frontend-pixel-perfect-plan.md).
|
||||
[едином плане корректировки UI/UX](docs/product/interface-correction-plan.md).
|
||||
План не является второй дизайн-системой и не означает завершение реализации.
|
||||
|
||||
**Key Characteristics:**
|
||||
|
||||
@@ -395,7 +395,7 @@ def handler(api: Api):
|
||||
session = cookies.get("akyldash_session")
|
||||
token = session.value if session else None
|
||||
user = api.auth_store.user_for_token(token)
|
||||
if method == "GET" and route in {"/", "/review"}:
|
||||
if method == "GET" and route in {"/", "/review", "/users"}:
|
||||
body = api.review_page().encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
@@ -17,7 +17,7 @@ from typing import Iterator
|
||||
|
||||
from search.catalog import authority_codes, legal_force_code, source_code
|
||||
|
||||
APP_VERSION = "0.13.0"
|
||||
APP_VERSION = "0.13.1"
|
||||
LANGUAGES = {"ru", "ky"}
|
||||
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
|
||||
|
||||
@@ -465,7 +465,7 @@ def main() -> int:
|
||||
else:
|
||||
documents, fragments = export_bulk(arguments.input, arguments.output, arguments.index, arguments.limit)
|
||||
destination = arguments.output
|
||||
print(f"documents={documents} fragments={fragments} destination={destination}\nAkyldash · Backend v{APP_VERSION} · Frontend v0.1.2")
|
||||
print(f"documents={documents} fragments={fragments} destination={destination}\nAkyldash · Backend v{APP_VERSION} · Frontend v0.2.6")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -148,6 +148,130 @@
|
||||
@media (max-width: 480px) { .review__search { grid-template-columns: 1fr; } .review__search .review__field:first-child, .review__search button { grid-column: auto; } .review__rating { grid-template-columns: repeat(2, minmax(0, 1fr)); } .search__query-row { grid-template-columns: 1fr 1fr; } .search__query-row > input { grid-column: 1 / -1; } .review__nav { align-items: start; flex-wrap: wrap; } .review__nav-links { order: 3; width: 100%; flex-wrap: wrap; } .review__nav-account { margin-inline-start: auto; } }
|
||||
@media (hover: hover) { button:hover { background: var(--surface-muted); border-color: var(--muted); } .review__button--primary:hover { background: var(--brand-dark); border-color: var(--brand-dark); } .review__result:hover { background: var(--subtle); } .review__result-title:hover, .search__result-title:hover { background: none; color: var(--brand); } .review__rating label:hover { border-color: var(--brand); } }
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } }
|
||||
/* Route-specific layout keeps reading and review controls in view without nested page shells. */
|
||||
body:has(#review:not([hidden])) { min-height: 100dvh; display: flex; flex-direction: column; }
|
||||
body:has(#review:not([hidden])) > .review__header { width: 100%; }
|
||||
body:has(#review:not([hidden])) > #review { width: min(100%, 1600px); flex: 1; }
|
||||
body:has(#review:not([hidden])) > .search__footer { display: none; }
|
||||
body:has(#review:not([hidden])) > .review__actions { width: min(100%, 1600px); margin: auto auto 0; }
|
||||
body:has(#review:not([hidden])) > footer.review__header { width: 100%; margin-block-start: 1rem; }
|
||||
.review__header-content, .review__actions-inner { max-width: 1600px; }
|
||||
.review__header { padding-block-end: .5rem; }
|
||||
.review__header-content h1 { margin-block: .6rem .25rem; }
|
||||
.review__workspace { grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); }
|
||||
.review__document { position: static; max-height: min(68dvh, 860px); overflow: auto; overscroll-behavior: contain; }
|
||||
.review__document-body { max-width: 72ch; margin-inline: auto; }
|
||||
.review__document-body table { display: block; max-width: 100%; overflow-x: auto; }
|
||||
.review__document-body p:empty { display: none; }
|
||||
.review__document-body a { color: var(--brand-dark); text-underline-offset: .18em; }
|
||||
.search__layout[data-filters-hidden="true"] { grid-template-columns: minmax(320px, .8fr) minmax(0, 1.5fr); }
|
||||
.search__layout[data-filters-hidden="true"] .search__filters { display: none; }
|
||||
.search__layout.is-expanded { position: fixed; z-index: 15; inset: 0; display: block; overflow: auto; background: var(--surface); }
|
||||
.search__layout.is-expanded .search__filters, .search__layout.is-expanded .search__results { display: none; }
|
||||
.search__layout.is-expanded .search__document { min-height: 100%; padding: 24px clamp(16px, 6vw, 96px); }
|
||||
.search__layout.is-expanded .search__document-body { max-width: 78ch; margin-inline: auto; }
|
||||
.search__toolbar-actions { display: flex; align-items: center; gap: .5rem; }
|
||||
.search__layout:not([data-has-editions="true"]) .search__document-toolbar { display: none; }
|
||||
.search__layout:not([data-has-document="true"]) .search__find,
|
||||
.search__layout:not([data-has-document="true"]) #document-expand,
|
||||
.search__layout:not([data-has-document="true"]) #document-print,
|
||||
.search__layout:not([data-has-document="true"]) .search__toc,
|
||||
.search__layout:not([data-has-document="true"]) #document-source { display: none; }
|
||||
.search__layout:not([data-has-document="true"]) .search__document-body { display: none; }
|
||||
.search__mobile-filters { display: inline-flex; }
|
||||
.search__account button { display: inline-flex; width: auto; min-height: 44px; gap: 8px; align-items: center; padding: 8px 12px; }
|
||||
.search__account-label { display: inline; }
|
||||
.search__document-body table, .review__document-body table { display: block; max-width: 100%; overflow-x: auto; }
|
||||
.search__table-scroll { max-width: 100%; margin-block: 1rem; overflow-x: auto; overscroll-behavior-inline: contain; }
|
||||
.search__table-scroll table { width: max-content; min-width: 100%; border-collapse: collapse; }
|
||||
.search__filters .review__panel-heading { align-items: center; flex-wrap: wrap; }
|
||||
.search__filters .review__panel-heading h2 { flex: 1 1 auto; }
|
||||
.search__filters .review__panel-heading button { flex: 0 0 auto; }
|
||||
.search__filter label { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; align-items: center; gap: 8px; min-height: 40px; }
|
||||
.search__filter label span { min-width: 0; overflow-wrap: anywhere; }
|
||||
.search__filter-count { color: var(--muted); font-size: .875rem; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.search__filter-more { margin-block-start: .4rem; }
|
||||
.search__filter-more summary { min-height: 40px; display: flex; align-items: center; color: var(--brand-dark); cursor: pointer; }
|
||||
.search__language-chip select { width: auto; min-width: 126px; max-width: none; }
|
||||
.search__document-toolbar select { width: auto; min-width: 160px; max-width: min(280px, 100%); }
|
||||
.search__document-toolbar .search__edition-label select { width: auto; min-width: 220px; max-width: min(360px, 100%); }
|
||||
.search__source-link { max-width: none; white-space: normal; }
|
||||
.review__rating { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.review__rating label { justify-items: start; text-align: start; padding-inline: .6rem; }
|
||||
.review__comment-disclosure { margin-block: .75rem; }
|
||||
.review__comment-disclosure summary { color: var(--brand-dark); cursor: pointer; }
|
||||
.review__actions-inner { align-items: end; }
|
||||
#save:disabled { cursor: not-allowed; }
|
||||
dialog { width: min(640px, calc(100vw - 32px)); max-height: min(88dvh, 900px); overflow: auto; padding: 0; }
|
||||
dialog > .review__page, dialog > section { max-width: none; padding: 24px; }
|
||||
dialog h2 { margin: 0; padding-inline-end: 52px; }
|
||||
.review__dialog-heading { position: sticky; z-index: 1; inset-block-start: -24px; display: flex; justify-content: space-between; align-items: start; gap: 1rem; margin: -24px -24px 16px; padding: 18px 24px; background: var(--surface); border-block-end: 1px solid var(--line); }
|
||||
.review__dialog-heading h2 { margin: 0; font: 600 1.5rem/1.3 var(--serif); }
|
||||
.review__dialog-heading button { flex: 0 0 44px; width: 44px; min-height: 44px; padding: 0; }
|
||||
.review__dialog-heading button svg { width: 20px; height: 20px; }
|
||||
body.is-users-route { min-height: 100dvh; display: flex; flex-direction: column; }
|
||||
body.is-users-route > .review__header { display: block; width: 100%; padding-block-end: 0; }
|
||||
body.is-users-route > .review__header > .review__header-content { display: none; }
|
||||
body.is-users-route > footer.review__header { display: block; margin-block-start: auto; }
|
||||
.review__users-page { width: min(1200px, 100%); flex: 1; margin-inline: auto; padding: 28px clamp(16px, 4vw, 48px); }
|
||||
.review__users-page[hidden] { display: none !important; }
|
||||
.review__users-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-block-end: 24px; }
|
||||
.review__user-table-head, .review__user-row { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, .8fr) minmax(0, 1.2fr) minmax(0, 1.2fr) minmax(180px, 1.5fr); align-items: start; gap: 12px; }
|
||||
.review__user-table-head { padding: 12px; color: var(--muted); font-size: .875rem; border-block-end: 1px solid var(--line); }
|
||||
.review__user-list { list-style: none; margin: 0; padding: 0; }
|
||||
.review__user-row { padding: 16px 12px; border-block-end: 1px solid var(--line); }
|
||||
.review__user-row > * { min-width: 0; overflow-wrap: anywhere; }
|
||||
.review__user-row details { grid-column: 1 / -1; }
|
||||
.review__user-row details summary { min-height: 40px; display: flex; align-items: center; color: var(--brand-dark); cursor: pointer; }
|
||||
.review__user-actions { display: flex; flex-wrap: wrap; gap: .5rem; }
|
||||
.review__account-tabs { display: flex; gap: .5rem; margin-block: 1rem; border-block-end: 1px solid var(--line); }
|
||||
.review__account-tabs [aria-selected="true"] { color: var(--brand-dark); border-color: var(--brand); background: var(--subtle); }
|
||||
@media (max-width: 1100px) {
|
||||
.search__layout { grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); }
|
||||
.search__layout:not([data-filters-hidden="true"]) .search__filters { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 24px; border-inline-end: 0; border-block-end: 1px solid var(--line); }
|
||||
.search__layout:not([data-filters-hidden="true"]) .search__filters > .review__panel-heading,
|
||||
.search__layout:not([data-filters-hidden="true"]) .search__filters > .search__dates,
|
||||
.search__layout:not([data-filters-hidden="true"]) .search__filters > .search__date-label,
|
||||
.search__layout:not([data-filters-hidden="true"]) .search__filters > #filter-fields { grid-column: 1 / -1; }
|
||||
.search__filters .search__ornament { display: none; }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
body:has(.search:not([hidden])) { height: auto; min-height: 100dvh; overflow: visible; }
|
||||
.review__workspace { grid-template-columns: minmax(0, 1fr); }
|
||||
.review__document { max-height: 62dvh; }
|
||||
.search__layout:not([data-filters-hidden="true"]) .search__filters { display: none; }
|
||||
.search__layout:not([data-filters-hidden="true"]) .search__filters.is-open { display: block; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.review__users-heading { align-items: stretch; flex-direction: column; }
|
||||
.review__user-table-head { display: none; }
|
||||
.review__user-row { grid-template-columns: minmax(0, 1fr); gap: 8px; }
|
||||
.review__user-row > [data-label]::before { content: attr(data-label) ": "; color: var(--muted); font-weight: 600; }
|
||||
.review__user-row .review__user-actions { grid-column: 1; }
|
||||
.review__nav { flex-wrap: wrap; }
|
||||
.review__nav-links { flex-wrap: wrap; }
|
||||
.review__nav-links a, .review__nav-links button { min-height: 44px; }
|
||||
.search__topbar { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.search__account { grid-column: auto; grid-row: 1; }
|
||||
.search__account button { width: 48px; min-height: 48px; }
|
||||
.search__account-label { display: none; }
|
||||
.search__subnav { flex-wrap: wrap; }
|
||||
.search__language-chip select { min-width: 126px; }
|
||||
.search__layout { display: block; }
|
||||
.search__filters { overflow: visible; }
|
||||
.search__filter label { grid-template-columns: 24px minmax(0, 1fr) auto; }
|
||||
.search__dates { grid-template-columns: minmax(0, 1fr); }
|
||||
.search__document-toolbar { align-items: stretch; }
|
||||
.search__document-toolbar > *, .search__document-toolbar label { max-width: 100%; }
|
||||
.search__document-toolbar .search__edition-label select { min-width: min(220px, calc(100vw - 48px)); }
|
||||
.search__find { grid-template-columns: minmax(0, 1fr) auto auto; }
|
||||
.search__find input { grid-column: 1 / -1; }
|
||||
.search__find label { grid-column: 1 / -1; }
|
||||
.review__actions-inner { display: grid; grid-template-columns: minmax(0, 1fr); }
|
||||
dialog { max-width: calc(100vw - 24px); max-height: calc(100dvh - 24px); }
|
||||
dialog > .review__page, dialog > section { padding: 16px; }
|
||||
.review__dialog-heading { inset-block-start: -16px; margin: -16px -16px 12px; padding: 12px 16px; }
|
||||
}
|
||||
:root { --color-bg: #F7F2E8; --color-surface: #fff; --color-surface-muted: #FBF8F4; --color-text: #38262A; --color-text-secondary: #786469; --color-border: #DED3CE; --color-primary: #9D2633; --color-primary-hover: #84202B; --color-accent: #A37B32; --color-highlight: #FFF0C7; --color-focus: #245C78; --color-success: #246843; --search-header-height: 76px; --search-nav-height: 50px; }
|
||||
body:has(.search:not([hidden])) { width: 100%; height: 100dvh; min-height: 0; overflow: hidden; display: flex; flex-direction: column; }
|
||||
body:has(.search:not([hidden])) > .review__header { display: none; }
|
||||
@@ -207,6 +331,7 @@
|
||||
.search__filter input[type="radio"]:checked::after { content: ''; position: absolute; width: 6px; height: 11px; inset: 3px 7px; border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg); }
|
||||
.search__filter--authority { display: grid; gap: 5px; margin-block: 0 20px; }
|
||||
.search__filter--authority label { display: grid; min-height: auto; gap: 5px; font-weight: 600; }
|
||||
.search__filter--authority label { grid-template-columns: minmax(0, 1fr); }
|
||||
.search__filter--authority select { min-height: 42px; padding-block: 7px; font-weight: 400; }
|
||||
.search__date-label { display: block; margin-block: 0 7px; font-size: 16px; font-weight: 600; }
|
||||
.search__dates { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
@@ -230,6 +355,7 @@
|
||||
.search__result-snippet { margin: 6px 0 0; color: var(--ink); font-size: 15px; line-height: 1.45; }
|
||||
.search__result-snippet mark, .search__document-body mark { color: inherit; background: var(--color-highlight); padding: 0 2px; }
|
||||
.search__document-body mark.is-current { outline: 2px solid var(--color-accent); outline-offset: 1px; }
|
||||
.search__document-body a { color: var(--brand-dark); text-underline-offset: .18em; }
|
||||
.search__result em { font-style: normal; }
|
||||
.search__pagination { padding-block-start: 12px; }
|
||||
.search__pagination button { min-height: 40px; padding: 6px 12px; }
|
||||
@@ -336,14 +462,38 @@
|
||||
.search, .search__layout, .search__document { display: block; width: auto; min-height: 0; overflow: visible; border: 0; padding: 0; }
|
||||
.search__document-body { max-width: none; }
|
||||
}
|
||||
/* Route visibility must win over broad legacy :has() layout selectors. */
|
||||
body.is-users-route { width: 100%; height: auto; min-height: 100dvh; overflow: visible; display: flex; flex-direction: column; }
|
||||
body.is-users-route > header.review__header { display: block; width: 100%; padding-block-end: 0; }
|
||||
body.is-users-route > header.review__header > .review__header-content { display: none; }
|
||||
body.is-users-route > footer.review__header { display: block; width: 100%; margin-block-start: auto; }
|
||||
.search__layout { grid-template-columns: clamp(256px, 19.45vw, 280px) clamp(360px, 30vw, 408px) minmax(560px, 1fr); }
|
||||
.search__account button { width: auto; min-width: 44px; min-height: 44px; padding: 8px 12px; }
|
||||
@media (min-width: 901px) and (max-width: 1175px) {
|
||||
.search__layout { grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); }
|
||||
.search__layout:not([data-filters-hidden="true"]) .search__filters { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 24px; }
|
||||
.search__layout:not([data-filters-hidden="true"]) .search__filters > .review__panel-heading,
|
||||
.search__layout:not([data-filters-hidden="true"]) .search__filters > .search__dates,
|
||||
.search__layout:not([data-filters-hidden="true"]) .search__filters > .search__date-label,
|
||||
.search__layout:not([data-filters-hidden="true"]) .search__filters > #filter-fields { grid-column: 1 / -1; }
|
||||
}
|
||||
@media (max-width: 600px) { .search__account button { width: 48px; padding: 8px; } }
|
||||
@media (min-width: 601px) and (max-width: 700px) {
|
||||
.search__subnav { flex-wrap: wrap; overflow-x: visible; white-space: normal; }
|
||||
}
|
||||
@media (min-width: 481px) and (max-width: 800px) {
|
||||
.review__nav { flex-wrap: wrap; }
|
||||
.review__nav-links { order: 3; width: 100%; flex-wrap: wrap; }
|
||||
.review__nav-account { margin-inline-start: auto; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="review__skip" href="#results">Перейти к результатам</a>
|
||||
<header class="review__header">
|
||||
<div class="review__nav"><div class="review__eyebrow">Акылдаш</div><nav class="review__nav-links" aria-label="Основная навигация"><a id="review-search-nav" href="/">Поиск</a><a id="review-review-nav" href="/review">Лаборатория</a><button id="review-account-open" type="button">Личный кабинет</button><button id="review-users-open" type="button" hidden>Пользователи</button></nav><div class="review__nav-account"><span id="review-signed-in-user"></span><button id="review-logout" type="button">Выйти</button></div></div>
|
||||
<div class="review__nav"><div class="review__eyebrow">Акылдаш</div><nav class="review__nav-links" aria-label="Основная навигация"><a id="review-search-nav" href="/">Поиск</a><a id="review-review-nav" href="/review">Лаборатория</a><button id="review-account-open" type="button">Личный кабинет</button><a id="review-users-open" href="/users" hidden>Пользователи</a></nav><div class="review__nav-account"><span id="review-signed-in-user"></span><button id="review-logout" type="button">Выйти</button></div></div>
|
||||
<div class="review__header-content"><div class="review__header-top"><button id="tutorial-open" type="button">Как это работает</button></div>
|
||||
<h1>Проверка качества поиска</h1><p>Сопоставляйте запрос с текстом документов и сохраняйте оценку выдачи.</p></div>
|
||||
<h1 id="route-heading">Проверка качества поиска</h1><p id="route-description">Сопоставляйте запрос с текстом документов и сохраняйте оценку выдачи.</p></div>
|
||||
</header>
|
||||
<header class="search__chrome" aria-label="Акылдаш">
|
||||
<div class="search__topbar">
|
||||
@@ -354,7 +504,7 @@
|
||||
<form class="search__query" id="public-search-form"><div><label for="public-query">Поиск по правовой базе</label><div class="search__query-input"><svg class="search__query-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="10.8" cy="10.8" r="7" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="m16 16 5 5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg><input id="public-query" maxlength="500" autocomplete="off" placeholder="Введите правовой вопрос или название документа"><button class="search__query-clear" id="clear-query" type="button" aria-label="Очистить запрос"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m5 5 10 10m0-10L5 15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg></button></div></div><button class="review__button--primary" type="submit">Найти</button></form>
|
||||
<div class="search__ui-language" role="group" aria-label="Язык интерфейса"><button id="ui-ru" type="button" aria-pressed="true">RU</button><span aria-hidden="true">/</span><button id="ui-ky" type="button" aria-pressed="false">KY</button></div>
|
||||
</div>
|
||||
<nav class="search__subnav" aria-label="Основная навигация"><a id="search-nav" class="is-active" href="/">Поиск</a><button id="sources-open" class="search__nav-text" type="button">Об источниках</button><label class="search__chip search__language-chip"><span class="search__sr-only">Язык поиска</span><select id="public-language" aria-label="Язык поиска"><option value="ru">Русский</option><option value="ky">Кыргызский</option></select></label><button class="search__mobile-filters" id="mobile-filters-open" type="button" aria-expanded="false">Фильтры</button><div class="search__chips" id="active-filters" aria-label="Активные фильтры"></div><div class="search__account"><span id="signed-in-user" class="search__sr-only"></span><button id="search-account-open" type="button" aria-label="Личный кабинет" title="Личный кабинет"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><circle cx="12" cy="8" r="3.5" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M5 20c.5-3.5 3-5.5 7-5.5s6.5 2 7 5.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg></button></div></nav>
|
||||
<nav class="search__subnav" aria-label="Основная навигация"><a id="search-nav" class="is-active" href="/">Поиск</a><button id="sources-open" class="search__nav-text" type="button">Об источниках</button><label class="search__chip search__language-chip"><span class="search__sr-only">Язык поиска</span><select id="public-language" aria-label="Язык поиска"><option value="ru">Русский</option><option value="ky">Кыргызский</option></select></label><button class="search__mobile-filters" id="mobile-filters-open" type="button" aria-expanded="false">Фильтры</button><div class="search__chips" id="active-filters" aria-label="Активные фильтры"></div><div class="search__account"><span id="signed-in-user" class="search__sr-only"></span><button id="search-account-open" type="button" aria-label="Личный кабинет" title="Личный кабинет"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><circle cx="12" cy="8" r="3.5" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M5 20c.5-3.5 3-5.5 7-5.5s6.5 2 7 5.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg><span class="search__account-label">Кабинет</span></button></div></nav>
|
||||
</header>
|
||||
<main class="review__main" id="review">
|
||||
<form class="review__search" id="search-form">
|
||||
@@ -377,16 +527,25 @@
|
||||
<section class="search__document" id="public-document-panel" aria-labelledby="public-document-heading"><div class="search__document-head"><div class="search__breadcrumbs"><span>Документ</span><span aria-hidden="true">/</span><span id="document-breadcrumb">Выберите результат</span></div><div><button class="search__mobile-back" id="document-back" type="button">К результатам</button><button class="search__expand" id="document-expand" type="button" aria-label="Развернуть документ">Развернуть</button></div><h2 id="public-document-heading">Документ</h2></div><p id="public-document-meta" class="search__document-meta"><span id="document-facts">Выберите результат, чтобы прочитать доступную редакцию.</span><label class="search__document-language">Язык<select id="document-language" disabled aria-label="Язык документа"></select></label></p><div class="search__document-toolbar"><details class="search__toc" id="document-toc"><summary>Содержание</summary><nav aria-label="Содержание документа"><ol id="document-toc-list"><li>Оглавление появится после загрузки документа</li></ol></nav></details><label class="search__edition-label"><select id="document-edition" disabled aria-label="Редакция документа"></select></label><button id="document-print" type="button">Печать</button><a id="document-source" class="search__source-link" href="#" target="_blank" rel="noreferrer" hidden>Официальный источник</a></div><div class="search__find"><label for="document-find">Найти в документе</label><input id="document-find" type="search" placeholder="Фраза в документе" disabled><span class="search__find-count" id="document-find-count" aria-live="polite"></span><button id="document-find-prev" type="button" disabled aria-label="Предыдущее совпадение"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m12 4-6 6 6 6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg></button><button id="document-find-next" type="button" disabled aria-label="Следующее совпадение"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m8 4 6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg></button></div><p class="search__document-note" id="document-note" hidden></p><article id="public-document-body" class="search__document-body"></article></section>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="search__footer"><span><strong>Акылдаш</strong> <span aria-hidden="true">·</span> Концепция Түндүк</span><span>Frontend v0.2.3 <span aria-hidden="true">·</span> Backend v0.13.0</span></footer>
|
||||
<div class="review__actions"><div class="review__actions-inner"><label>Проверяющий<input id="reviewer" readonly aria-label="Проверяющий"></label><label>Комментарий к выдаче <input id="overall-comment" maxlength="4000" placeholder="Что важно учесть? (необязательно)"></label><button class="review__button--primary" id="save" type="button">Сохранить оценку</button></div></div>
|
||||
<footer class="search__footer"><span><strong>Акылдаш</strong> <span aria-hidden="true">·</span> Правовая база Кыргызстана</span><span>Frontend v0.2.6 <span aria-hidden="true">·</span> Backend v0.13.1</span></footer>
|
||||
<div class="review__actions"><div class="review__actions-inner"><label>Проверяющий<input id="reviewer" readonly aria-label="Проверяющий"></label><label>Комментарий к выдаче <input id="overall-comment" maxlength="4000" placeholder="Что важно учесть? (необязательно)"></label><button class="review__button--primary" id="save" type="button" disabled>Сначала выполните поиск</button><p id="draft-status" class="review__status" role="status" aria-live="polite">Черновик появится после поиска.</p></div></div>
|
||||
<dialog id="login-dialog" aria-labelledby="login-heading"><form class="review__page" id="login-form"><h2 id="login-heading">Вход в Акылдаш</h2><p>Введите учетные данные, выданные администратором.</p><label>Логин<input id="login-username" name="username" autocomplete="username" required minlength="3" maxlength="64"></label><label>Пароль<input id="login-password" name="password" type="password" autocomplete="current-password" required></label><p id="login-error" class="review__status review__status--error" role="alert"></p><button class="review__button--primary" type="submit">Войти</button></form></dialog>
|
||||
<dialog id="password-dialog" aria-labelledby="password-heading"><form class="review__page" id="password-form"><h2 id="password-heading">Создайте новый пароль</h2><p class="review__notice">Для продолжения замените временный пароль. Используйте не менее 12 символов.</p><label>Текущий пароль<input id="current-password" type="password" autocomplete="current-password" required></label><label>Новый пароль<input id="new-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><label>Повторите новый пароль<input id="confirm-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p id="password-error" class="review__status review__status--error" role="alert"></p><button class="review__button--primary" type="submit">Сохранить пароль</button></form></dialog>
|
||||
<dialog id="account-dialog" aria-labelledby="account-heading"><section class="review__page"><h2 id="account-heading">Личный кабинет</h2><nav class="search__account-actions" aria-label="Действия учетной записи"><a id="review-nav" href="/review">Лаборатория</a><button id="search-users-open" type="button" hidden>Пользователи</button><button id="search-logout" type="button">Выйти</button></nav><dl><dt>Логин</dt><dd id="profile-username"></dd><dt>Роль</dt><dd id="profile-role">Администратор</dd><dt>Дата создания</dt><dd id="profile-created"></dd></dl><form id="profile-password-form"><h3>Изменить пароль</h3><p>Новый пароль должен содержать не менее 12 символов.</p><label>Текущий пароль<input id="profile-current-password" type="password" autocomplete="current-password" required></label><label>Новый пароль<input id="profile-new-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p id="profile-error" role="alert" class="review__status review__status--error"></p><div class="review__page-actions"><button class="review__button--primary" type="submit">Изменить пароль</button><button type="button" data-close="account-dialog">Закрыть</button></div></form></section></dialog>
|
||||
<dialog id="users-dialog" aria-labelledby="users-heading"><section class="review__page"><h2 id="users-heading">Управление пользователями</h2><form id="create-user-form"><label>Логин<input id="new-username" autocomplete="off" minlength="3" maxlength="64" required></label><label>Временный пароль<input id="temporary-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p>Пароль — от 12 символов. Новый пользователь сменит его при первом входе. Передайте временный пароль отдельно.</p><p id="users-error" role="alert" class="review__status review__status--error"></p><button class="review__button--primary" type="submit">Создать пользователя</button></form><ul id="user-list" class="review__user-list"></ul><button type="button" data-close="users-dialog">Закрыть</button></section></dialog>
|
||||
<dialog id="account-dialog" aria-labelledby="account-heading"><section class="review__page"><h2 id="account-heading">Личный кабинет</h2><div class="review__account-tabs" role="tablist" aria-label="Разделы личного кабинета"><button id="profile-tab" type="button" role="tab" aria-controls="profile-panel" aria-selected="true">Профиль</button><button id="security-tab" type="button" role="tab" aria-controls="security-panel" aria-selected="false" tabindex="-1">Безопасность</button></div><section id="profile-panel" role="tabpanel" aria-labelledby="profile-tab"><nav class="search__account-actions" aria-label="Действия учетной записи"><a id="review-nav" href="/review">Лаборатория</a><button id="search-users-open" type="button" hidden>Пользователи</button><button id="search-logout" type="button">Выйти</button></nav><dl><dt>Логин</dt><dd id="profile-username"></dd><dt>Роль</dt><dd id="profile-role">Администратор</dd><dt>Дата создания</dt><dd id="profile-created"></dd></dl></section><section id="security-panel" role="tabpanel" aria-labelledby="security-tab" hidden><form id="profile-password-form"><h3>Изменить пароль</h3><p>Новый пароль должен содержать не менее 12 символов.</p><label>Текущий пароль<input id="profile-current-password" type="password" autocomplete="current-password" required></label><label>Новый пароль<input id="profile-new-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><label>Повторите новый пароль<input id="profile-confirm-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p id="profile-error" role="alert" class="review__status review__status--error"></p><div class="review__page-actions"><button class="review__button--primary" type="submit">Изменить пароль</button><button type="button" data-close="account-dialog">Отмена</button></div></form></section></section></dialog>
|
||||
<main class="review__users-page" id="users-page" hidden>
|
||||
<div class="review__users-heading"><div><h2 id="users-heading">Пользователи</h2><p id="users-description">Права на действия определяются сервером.</p></div><div class="review__users-actions"><div class="search__ui-language" role="group" aria-label="Язык интерфейса"><button id="users-ui-ru" type="button" aria-pressed="true">RU</button><span aria-hidden="true">/</span><button id="users-ui-ky" type="button" aria-pressed="false">KY</button></div><button id="create-user-open" class="review__button--primary" type="button">Создать пользователя</button></div></div>
|
||||
<p id="users-list-error" role="alert" class="review__status review__status--error"></p>
|
||||
<section class="review__user-table" role="table" aria-label="Список пользователей">
|
||||
<div class="review__user-table-head" role="row"><span role="columnheader">Логин</span><span role="columnheader">Роль</span><span role="columnheader">Состояние</span><span role="columnheader">Последний вход</span><span role="columnheader">Действия</span></div>
|
||||
<ul id="user-list" class="review__user-list"></ul>
|
||||
</section>
|
||||
</main>
|
||||
<dialog id="create-user-dialog" aria-labelledby="create-user-heading"><section class="review__page"><h2 id="create-user-heading">Новый пользователь</h2><form id="create-user-form"><label>Логин<input id="new-username" autocomplete="off" minlength="3" maxlength="64" required></label><label>Временный пароль<input id="temporary-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p>Не менее 12 символов. При первом входе пользователь должен изменить временный пароль.</p><p id="users-error" role="alert" class="review__status review__status--error"></p><div class="review__page-actions"><button class="review__button--primary" type="submit">Создать</button><button type="button" data-close="create-user-dialog">Отмена</button></div></form></section></dialog>
|
||||
<dialog id="delete-user-dialog" aria-labelledby="delete-user-heading"><section><h2 id="delete-user-heading">Удалить пользователя</h2><p id="delete-user-copy"></p><p id="delete-user-error" role="alert" class="review__status review__status--error"></p><div class="review__page-actions"><button id="delete-user-confirm" class="review__button--primary" type="button">Удалить пользователя</button><button type="button" data-close="delete-user-dialog">Отмена</button></div></section></dialog>
|
||||
<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>
|
||||
<dialog class="search__about" id="sources-dialog" aria-labelledby="sources-heading"><button class="review__dialog-close" id="sources-close" type="button">Закрыть</button><h2 id="sources-heading">Источники документов</h2><p>Поиск выполняется по документам, подготовленным из правовых источников Кыргызской Республики. Для каждого результата доступная редакция и ссылка на официальный источник показываются в панели документа.</p><p>Официальный источник: <a href="https://cbd.minjust.gov.kg/" target="_blank" rel="noreferrer">Централизованный банк данных правовой информации Министерства юстиции Кыргызской Республики</a>.</p></dialog>
|
||||
<div class="review__tour-spotlight" id="tutorial-spotlight" hidden aria-hidden="true"></div><dialog class="review__tour" id="tutorial-dialog" aria-labelledby="tutorial-heading" aria-describedby="tutorial-text"><div class="review__tour-content"><p class="review__tour-kicker">Короткое знакомство</p><h2 id="tutorial-heading"></h2><p id="tutorial-text"></p><div class="review__tour-footer"><span class="review__tour-progress" id="tutorial-progress"></span><div class="review__tour-actions"><button id="tutorial-skip" type="button">Пропустить</button><button class="review__button--primary" id="tutorial-next" type="button">Далее</button></div></div></div></dialog>
|
||||
<footer class="review__header"><div class="review__header-content">Акылдаш · Frontend v0.2.3 · Backend v0.13.0 · Внутренняя лаборатория релевантности</div></footer>
|
||||
<footer class="review__header"><div class="review__header-content">Акылдаш · Frontend v0.2.6 · Backend v0.13.1 · Внутренняя лаборатория релевантности</div></footer>
|
||||
<script>
|
||||
document.body.classList.add('review__locked');
|
||||
const DRAFT_KEY = 'akyldash-search-review-draft';
|
||||
@@ -398,13 +557,13 @@
|
||||
{ target: 'document-panel', title: 'Читайте документ перед оценкой', text: 'Нажмите на название результата. Справа откроется текст документа и ссылка на официальный источник.', label: 'Шаг 3 из 4' },
|
||||
{ target: 'save', title: 'Сохраните свою разметку', text: 'Поставьте оценку каждому просмотренному документу, добавьте комментарии и сохраните результат внизу страницы.', label: 'Шаг 4 из 4' }
|
||||
];
|
||||
const state = { results: [], token: '', selected: null, query: '' };
|
||||
const state = { results: [], token: '', selected: null, query: '', searchRequest: 0, documentRequest: 0 };
|
||||
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: 'Бардык органдар'}
|
||||
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: 'Все органы', allTypes: 'Все виды', allStatuses: 'Все статусы'},
|
||||
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: 'Бардык органдар', allTypes: 'Бардык түрлөр', allStatuses: 'Бардык статустар'}
|
||||
};
|
||||
const copy = (key) => searchCopy[uiLanguage][key];
|
||||
const modalCopy = {
|
||||
@@ -413,11 +572,17 @@
|
||||
};
|
||||
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.3 · Backend v0.13.0'; }
|
||||
const usersRoute = location.pathname === '/users';
|
||||
const searchRoute = !reviewRoute && !usersRoute;
|
||||
$('review').hidden = !reviewRoute; $('search-page').hidden = !searchRoute; $('users-page').hidden = !usersRoute;
|
||||
$('review-review-nav').classList.toggle('is-active', reviewRoute); $('review-search-nav').classList.toggle('is-active', searchRoute); $('review-users-open').classList.toggle('is-active', usersRoute); $('review-nav').classList.toggle('is-active', reviewRoute); $('search-nav').classList.toggle('is-active', searchRoute);
|
||||
document.body.classList.toggle('is-review-route', reviewRoute);
|
||||
document.body.classList.toggle('is-search-route', searchRoute); document.body.classList.toggle('is-users-route', usersRoute);
|
||||
if (usersRoute) { document.title = 'Пользователи · Акылдаш'; $('route-heading').textContent = 'Пользователи'; $('route-description').textContent = 'Управляйте учётными записями. Доступ к действиям проверяется сервером.'; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#users-page'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.2.6 · Backend v0.13.1'; }
|
||||
else 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.6 · Backend v0.13.1'; }
|
||||
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 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'); const canManageUsers = user.role === 'admin'; $('search-users-open').hidden = $('review-users-open').hidden = !canManageUsers; document.body.classList.remove('review__locked'); if (usersRoute) { if (!canManageUsers) { location.replace('/'); return; } document.body.classList.add('is-admin'); const savedLanguage = (() => { try { return localStorage.getItem('akyldash-ui-language') || 'ru'; } catch (_) { return 'ru'; } })(); updateUsersLanguage(savedLanguage); } else if (searchRoute) initSearchPage(); };
|
||||
const filterFields = [['statuses', {ru: 'Статус', ky: 'Статус'}, 'status'], ['document_types', {ru: 'Вид документа', ky: 'Документтин түрү'}, 'document_type'], ['authorities', {ru: 'Орган принятия', ky: 'Кабыл алган орган'}, 'authority']];
|
||||
let filterCatalogs = {};
|
||||
let filterCatalogsLoaded = false;
|
||||
@@ -428,6 +593,45 @@
|
||||
let currentDocumentHtml = '';
|
||||
let documentMatches = [];
|
||||
let activeDocumentMatch = -1;
|
||||
function sanitizeDocumentHtml(html) {
|
||||
const parsed = new DOMParser().parseFromString(String(html || ''), 'text/html');
|
||||
parsed.querySelectorAll('script, style, iframe, frame, object, embed, form, input, button, textarea, select, link, meta, base, svg, math, video, audio, source').forEach((node) => node.remove());
|
||||
const urlAttributes = new Set(['href', 'src', 'srcset', 'imagesrcset', 'ping', 'poster', 'cite', 'data', 'codebase', 'archive', 'action', 'formaction', 'manifest', 'background', 'longdesc', 'usemap', 'dynsrc', 'lowsrc', 'xlink:href']);
|
||||
const officialUrl = (value) => {
|
||||
if (!value.startsWith('https://cbd.minjust.gov.kg/')) return null;
|
||||
try {
|
||||
const target = new URL(value);
|
||||
if (target.protocol !== 'https:' || target.hostname !== 'cbd.minjust.gov.kg' || target.username || target.password) return null;
|
||||
return target.href;
|
||||
} catch (_) { return null; }
|
||||
};
|
||||
parsed.body.querySelectorAll('*').forEach((node) => {
|
||||
[...node.attributes].forEach((attribute) => {
|
||||
const name = attribute.name.toLowerCase();
|
||||
if (name.startsWith('on') || name === 'srcdoc' || name === 'style') { node.removeAttribute(attribute.name); return; }
|
||||
if (!urlAttributes.has(name)) return;
|
||||
if (name === 'href' && node.tagName === 'A') {
|
||||
const href = attribute.value.trim();
|
||||
if (href.startsWith('#')) return;
|
||||
const safeHref = officialUrl(href);
|
||||
if (safeHref) { node.setAttribute('href', safeHref); node.target = '_blank'; node.rel = 'noreferrer'; }
|
||||
else node.removeAttribute(attribute.name);
|
||||
return;
|
||||
}
|
||||
if (name === 'src' && node.tagName === 'IMG' && attribute.value.startsWith('data:image/')) return;
|
||||
if (name === 'src' && node.tagName === 'IMG') {
|
||||
const safeSource = officialUrl(attribute.value.trim());
|
||||
if (safeSource) { node.setAttribute('src', safeSource); return; }
|
||||
}
|
||||
node.removeAttribute(attribute.name);
|
||||
});
|
||||
if (node.tagName === 'TABLE') {
|
||||
const wrapper = parsed.createElement('div'); wrapper.className = 'search__table-scroll'; wrapper.setAttribute('role', 'region'); wrapper.setAttribute('aria-label', 'Таблица документа. Прокручивайте по горизонтали для просмотра всех столбцов.');
|
||||
node.replaceWith(wrapper); wrapper.append(node);
|
||||
}
|
||||
});
|
||||
return parsed.body.innerHTML;
|
||||
}
|
||||
const selectedFilters = () => Object.fromEntries(filterFields.map(([key,, parameter]) => [parameter, parameter === 'authority' ? $('filter-authority')?.value || '' : document.querySelector(`input[name="filter-${parameter}"]:checked`)?.value || '']));
|
||||
const syncDateHint = (id) => $(`${id}`).parentElement.classList.toggle('has-value', Boolean($(`${id}`).value));
|
||||
function renderFilterCatalogs(payload) {
|
||||
@@ -459,15 +663,24 @@
|
||||
const heading = document.createElement('h3');
|
||||
heading.textContent = title;
|
||||
group.append(heading);
|
||||
items.forEach((item) => {
|
||||
const renderItem = (item) => {
|
||||
const label = document.createElement('label');
|
||||
const input = document.createElement('input');
|
||||
input.type = 'radio'; input.name = `filter-${parameter}`; input.value = item.code;
|
||||
input.addEventListener('change', () => runSearch(1));
|
||||
const text = document.createElement('span');
|
||||
text.textContent = `${item.labels[uiLanguage] || item.labels.ru || item.labels.ky || item.code} (${item.count})`;
|
||||
label.append(input, text); group.append(label);
|
||||
});
|
||||
text.textContent = item.labels[uiLanguage] || item.labels.ru || item.labels.ky || item.code;
|
||||
const count = document.createElement('span'); count.className = 'search__filter-count'; count.textContent = new Intl.NumberFormat(uiLanguage === 'ky' ? 'ky-KG' : 'ru-RU').format(item.count);
|
||||
label.append(input, text, count); return label;
|
||||
};
|
||||
const initial = items.slice(0, 6);
|
||||
initial.forEach((item) => group.append(renderItem(item)));
|
||||
if (items.length > initial.length) {
|
||||
const more = document.createElement('details'); more.className = 'search__filter-more';
|
||||
const summary = document.createElement('summary'); summary.textContent = `${copy(parameter === 'status' ? 'allStatuses' : 'allTypes')} (${items.length})`; more.append(summary);
|
||||
items.slice(6).forEach((item) => more.append(renderItem(item)));
|
||||
group.append(more);
|
||||
}
|
||||
}
|
||||
root.append(group);
|
||||
});
|
||||
@@ -510,10 +723,11 @@
|
||||
function renderPublicResults(results) {
|
||||
const list = $('public-results');
|
||||
list.replaceChildren();
|
||||
if (!results.length) {
|
||||
if (results === null || !results.length) {
|
||||
const empty = document.createElement('li'); empty.className = 'review__empty';
|
||||
const heading = document.createElement('strong'); heading.textContent = copy('noResults');
|
||||
empty.append(heading, document.createTextNode(copy('noResultsHint'))); list.append(empty); return;
|
||||
const failed = results === null;
|
||||
const heading = document.createElement('strong'); heading.textContent = failed ? 'Поиск временно недоступен' : copy('noResults');
|
||||
empty.append(heading, document.createTextNode(failed ? 'Проверьте подключение и повторите запрос.' : copy('noResultsHint'))); list.append(empty); return;
|
||||
}
|
||||
results.forEach((result) => {
|
||||
const row = document.createElement('li'); row.className = 'search__result'; row.dataset.code = result.code;
|
||||
@@ -551,6 +765,7 @@
|
||||
} catch (error) {
|
||||
if (requestId !== searchRequest) return;
|
||||
$('public-status').textContent = error.message || copy('searchError');
|
||||
renderPublicResults(null);
|
||||
$('result-count').textContent = copy('noResultsCount');
|
||||
}
|
||||
}
|
||||
@@ -642,9 +857,11 @@
|
||||
const date = document.createElement('span'); date.textContent = selectedPublicDocument.date_adopted || '';
|
||||
meta.append(type, document.createTextNode(' · '), status, ...(date.textContent ? [document.createTextNode(' · '), date] : []));
|
||||
const source = $('document-source'); source.href = `https://cbd.minjust.gov.kg/${encodeURIComponent(code)}/edition/${encodeURIComponent(edition)}/${encodeURIComponent(language)}`; source.hidden = false;
|
||||
currentDocumentHtml = content.html || '';
|
||||
currentDocumentHtml = sanitizeDocumentHtml(content.html);
|
||||
$('public-document-body').innerHTML = currentDocumentHtml;
|
||||
$('public-document-body').lang = language;
|
||||
$('search-layout').dataset.hasDocument = 'true';
|
||||
$('document-expand').setAttribute('aria-expanded', 'false');
|
||||
$('document-find').disabled = false; $('document-note').hidden = true;
|
||||
highlightDocumentMatches();
|
||||
} catch (error) {
|
||||
@@ -658,6 +875,7 @@
|
||||
async function openPublicDocument(result, row) {
|
||||
document.querySelectorAll('.search__result--selected').forEach((item) => item.classList.remove('search__result--selected'));
|
||||
row.classList.add('search__result--selected'); selectedPublicDocument = result; ++editionRequest; currentDocumentHtml = '';
|
||||
$('search-layout').removeAttribute('data-has-document'); $('search-layout').removeAttribute('data-has-editions');
|
||||
row.querySelector('.search__opened')?.remove();
|
||||
const opened = document.createElement('span'); opened.className = 'search__opened'; opened.textContent = copy('opened'); row.append(opened);
|
||||
$('document-breadcrumb').textContent = result.name || copy('document');
|
||||
@@ -681,12 +899,13 @@
|
||||
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(copy('noEditions'));
|
||||
$('search-layout').dataset.hasEditions = 'true';
|
||||
languageSelect.value = languages.includes($('public-language').value) ? $('public-language').value : languages[0];
|
||||
await loadPublicEdition();
|
||||
} catch (error) { if (selectedPublicDocument === result) $('document-facts').textContent = error.message || 'Документ недоступен.'; }
|
||||
}
|
||||
async function initSearchPage() {
|
||||
if (reviewRoute) return;
|
||||
if (!searchRoute) return;
|
||||
const params = new URLSearchParams(location.search);
|
||||
$('public-query').value = params.get('q') || '';
|
||||
$('public-language').value = params.get('language') || 'ru';
|
||||
@@ -746,16 +965,17 @@
|
||||
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 || '';
|
||||
$('profile-tab').textContent = ky ? 'Профиль' : 'Профиль'; $('security-tab').textContent = ky ? 'Коопсуздук' : 'Безопасность';
|
||||
document.querySelectorAll('[data-close]').forEach((button) => button.setAttribute('aria-label', modal.close));
|
||||
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 h3').textContent = modal.changePassword; setLabels('#profile-password-form label', [modal.currentPassword, modal.newPassword, modal.confirmPassword]);
|
||||
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;
|
||||
@@ -797,11 +1017,20 @@
|
||||
$('public-language').addEventListener('change', async () => { const response = await apiFetch(`/search/filters?language=${encodeURIComponent($('public-language').value)}`); if (response.ok) renderFilterCatalogs(await response.json()); if ($('public-query').value.trim()) runSearch(1); });
|
||||
$('page-prev').addEventListener('click', () => runSearch(publicPage - 1)); $('page-next').addEventListener('click', () => runSearch(publicPage + 1));
|
||||
$('clear-filters').addEventListener('click', () => { document.querySelectorAll('#filter-fields input:checked').forEach((input) => { input.checked = false; }); if ($('filter-authority')) $('filter-authority').value = ''; $('date-from').value = ''; $('date-to').value = ''; syncDateHint('date-from'); syncDateHint('date-to'); $('sort-order').value = 'relevance'; runSearch(1); });
|
||||
window.addEventListener('popstate', () => { if (!reviewRoute) initSearchPage(); });
|
||||
window.addEventListener('popstate', () => { if (searchRoute) initSearchPage(); });
|
||||
window.addEventListener('resize', () => { if (!matchMedia('(max-width: 900px)').matches) { closeMobileFilters(); $('search-layout').removeAttribute('data-mobile-view'); } });
|
||||
$('document-find').addEventListener('input', highlightDocumentMatches);
|
||||
$('document-find-prev').addEventListener('click', () => moveDocumentMatch(-1)); $('document-find-next').addEventListener('click', () => moveDocumentMatch(1));
|
||||
$('mobile-filters-open').addEventListener('click', openMobileFilters); $('mobile-filters-close').addEventListener('click', closeMobileFilters); $('filter-overlay').addEventListener('click', closeMobileFilters);
|
||||
$('mobile-filters-open').addEventListener('click', () => {
|
||||
if (matchMedia('(max-width: 900px)').matches) openMobileFilters();
|
||||
else {
|
||||
const hidden = $('search-layout').dataset.filtersHidden !== 'true';
|
||||
$('search-layout').dataset.filtersHidden = String(hidden);
|
||||
$('mobile-filters-open').setAttribute('aria-expanded', String(!hidden));
|
||||
$('mobile-filters-open').textContent = hidden ? 'Показать фильтры' : 'Скрыть фильтры';
|
||||
}
|
||||
});
|
||||
$('mobile-filters-close').addEventListener('click', closeMobileFilters); $('filter-overlay').addEventListener('click', closeMobileFilters);
|
||||
$('search-filters').addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') { closeMobileFilters(); return; }
|
||||
if (event.key !== 'Tab' || !$('search-filters').classList.contains('is-open')) return;
|
||||
@@ -814,7 +1043,18 @@
|
||||
$('document-back').addEventListener('click', () => { setMobileView('results'); document.querySelector('.search__result--selected .search__result-title')?.focus(); });
|
||||
$('document-edition').addEventListener('change', loadPublicEdition); $('document-language').addEventListener('change', loadPublicEdition);
|
||||
$('document-print').addEventListener('click', () => window.print());
|
||||
$('document-expand').addEventListener('click', () => { $('dialog-heading').textContent = $('public-document-heading').textContent; $('dialog-body').innerHTML = currentDocumentHtml; $('document-dialog').classList.add('search__expanded'); $('document-dialog').showModal(); });
|
||||
$('document-expand').addEventListener('click', () => {
|
||||
const expanded = $('search-layout').classList.toggle('is-expanded');
|
||||
$('document-expand').textContent = expanded ? 'Свернуть' : 'Развернуть';
|
||||
$('document-expand').setAttribute('aria-label', expanded ? 'Свернуть документ' : 'Развернуть документ');
|
||||
$('document-expand').setAttribute('aria-expanded', String(expanded));
|
||||
if (expanded) $('public-document-heading').focus({preventScroll: true});
|
||||
});
|
||||
$('search-layout').addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Escape' || !$('search-layout').classList.contains('is-expanded')) return;
|
||||
$('search-layout').classList.remove('is-expanded'); $('document-expand').setAttribute('aria-expanded', 'false');
|
||||
$('document-expand').textContent = 'Развернуть'; $('document-expand').setAttribute('aria-label', 'Развернуть документ'); $('document-expand').focus();
|
||||
});
|
||||
$('dialog-close').addEventListener('click', () => { $('document-dialog').close(); if (state.selected) state.selected.focus(); });
|
||||
$('document-dialog').addEventListener('close', () => $('document-dialog').classList.remove('search__expanded'));
|
||||
$('sources-open').addEventListener('click', () => $('sources-dialog').showModal());
|
||||
@@ -830,37 +1070,99 @@
|
||||
event.preventDefault(); (event.shiftKey ? controls.at(-1) : controls[0]).focus();
|
||||
}
|
||||
});
|
||||
async function loadUsers() {
|
||||
$('users-error').textContent = '';
|
||||
const response = await apiFetch('/admin/users');
|
||||
if (!response.ok) { $('users-error').textContent = 'Не удалось загрузить список пользователей.'; return; }
|
||||
const {users} = await response.json();
|
||||
const formatUserDate = (timestamp) => timestamp ? new Intl.DateTimeFormat('ru-RU', {dateStyle: 'medium', timeStyle: 'short'}).format(new Date(timestamp * 1000)) : '—';
|
||||
const list = $('user-list'); list.replaceChildren();
|
||||
users.forEach((user) => {
|
||||
const row = document.createElement('li'); row.className = 'review__user-row';
|
||||
const label = document.createElement('span'); label.textContent = `${user.username} · Администратор${user.must_change_password ? ' · требуется сменить пароль' : ''}`; row.append(label);
|
||||
const dates = document.createElement('small'); dates.className = 'review__user-meta'; dates.textContent = `Первый вход: ${formatUserDate(user.first_login_at)}\nПоследний вход: ${formatUserDate(user.last_login_at)}\nСброс пароля: ${formatUserDate(user.password_reset_at)}`; row.append(dates);
|
||||
if (user.username.toLowerCase() !== 'admin') {
|
||||
const resetForm = document.createElement('form'); resetForm.className = 'review__user-reset';
|
||||
const password = document.createElement('input'); password.type = 'password'; password.autocomplete = 'new-password'; password.minLength = 12; password.maxLength = 256; password.required = true;
|
||||
const passwordLabel = document.createElement('label'); passwordLabel.append(document.createTextNode('Новый временный пароль (от 12 символов)'), password);
|
||||
const reset = document.createElement('button'); reset.type = 'submit'; reset.textContent = 'Сбросить пароль'; resetForm.append(passwordLabel, reset);
|
||||
resetForm.addEventListener('submit', async (event) => { event.preventDefault(); const result = await apiFetch(`/admin/users/${encodeURIComponent(user.username)}/reset-password`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({password: password.value})}); if (!result.ok) { $('users-error').textContent = (await result.json()).error || 'Не удалось сбросить пароль.'; return; } await loadUsers(); });
|
||||
const remove = document.createElement('button'); remove.type = 'button'; remove.textContent = 'Удалить'; remove.addEventListener('click', async () => { if (!confirm(`Удалить учетную запись ${user.username}? Сохраненная разметка останется в истории.`)) return; const result = await apiFetch(`/admin/users/${encodeURIComponent(user.username)}`, {method: 'DELETE'}); if (!result.ok) { $('users-error').textContent = 'Не удалось удалить учетную запись.'; return; } await loadUsers(); });
|
||||
row.append(resetForm, remove);
|
||||
}
|
||||
list.append(row);
|
||||
});
|
||||
let usersLanguage = 'ru';
|
||||
const userCopy = {
|
||||
ru: {heading:'Пользователи', description:'Права на действия определяются сервером.', create:'Создать пользователя', login:'Логин', role:'Роль', state:'Состояние', lastLogin:'Последний вход', actions:'Действия', statusChange:'Требуется сменить пароль', active:'Активен', history:'История', firstLogin:'Первый вход', resetDate:'Сброс пароля', reset:'Сбросить пароль', resetLabel:'Новый временный пароль (от 12 символов)', delete:'Удалить', deleteHeading:'Удалить пользователя', cancel:'Отмена', username:'Имя пользователя', tempPassword:'Временный пароль', tempHelp:'Не менее 12 символов. При первом входе пользователь должен изменить временный пароль.', createError:'Не удалось создать учетную запись.', loadError:'Не удалось загрузить список пользователей.'},
|
||||
ky: {heading:'Колдонуучулар', description:'Аракеттерге уруксатты сервер текшерет.', create:'Колдонуучу түзүү', login:'Колдонуучунун аты', role:'Ролу', state:'Абалы', lastLogin:'Акыркы кирүү', actions:'Аракеттер', statusChange:'Сырсөздү өзгөртүү талап кылынат', active:'Активдүү', history:'Тарых', firstLogin:'Биринчи кирүү', resetDate:'Сырсөздү калыбына келтирүү', reset:'Сырсөздү калыбына келтирүү', resetLabel:'Жаңы убактылуу сырсөз (кеминде 12 белги)', delete:'Өчүрүү', deleteHeading:'Колдонуучуну өчүрүү', cancel:'Жокко чыгаруу', username:'Колдонуучунун аты', tempPassword:'Убактылуу сырсөз', tempHelp:'Кеминде 12 белги. Биринчи киргенде колдонуучу убактылуу сырсөздү өзгөртүшү керек.', createError:'Колдонуучуну түзүү мүмкүн болгон жок.', loadError:'Колдонуучулардын тизмесин жүктөө мүмкүн болгон жок.'}
|
||||
};
|
||||
const updateUsersLanguage = (language) => {
|
||||
usersLanguage = language === 'ky' ? 'ky' : 'ru';
|
||||
try { localStorage.setItem('akyldash-ui-language', usersLanguage); } catch (_) {}
|
||||
const words = userCopy[usersLanguage]; const ky = usersLanguage === 'ky';
|
||||
$('users-ui-ru').setAttribute('aria-pressed', String(!ky)); $('users-ui-ky').setAttribute('aria-pressed', String(ky));
|
||||
document.querySelector('#users-page [role="group"]').setAttribute('aria-label', ky ? 'Интерфейстин тили' : 'Язык интерфейса');
|
||||
document.documentElement.lang = usersLanguage;
|
||||
$('route-heading').textContent = words.heading; $('route-description').textContent = words.description; $('users-heading').textContent = words.heading; $('users-description').textContent = words.description; $('create-user-open').textContent = words.create;
|
||||
document.querySelectorAll('.review__user-table-head [role="columnheader"]').forEach((cell, index) => { cell.textContent = [words.login, words.role, words.state, words.lastLogin, words.actions][index]; });
|
||||
$('create-user-heading').textContent = words.create; document.querySelectorAll('#create-user-form label').forEach((label, index) => { const input = label.querySelector('input'); label.firstChild.textContent = `${[words.username, words.tempPassword][index]} `; if (input) label.append(input); }); document.querySelector('#create-user-form p').textContent = words.tempHelp; document.querySelector('#create-user-form button[type="submit"]').textContent = words.create;
|
||||
$('delete-user-heading').textContent = words.deleteHeading; $('delete-user-confirm').textContent = words.delete; document.querySelectorAll('#create-user-dialog [data-close], #delete-user-dialog [data-close]').forEach((button) => button.textContent = words.cancel);
|
||||
document.querySelector('footer .review__header-content').textContent = `Акылдаш · Frontend v0.2.6 · Backend v0.13.1`;
|
||||
if (usersRoute && account) loadUsers();
|
||||
};
|
||||
$('users-ui-ru').addEventListener('click', () => updateUsersLanguage('ru'));
|
||||
$('users-ui-ky').addEventListener('click', () => updateUsersLanguage('ky'));
|
||||
async function loadUsers() {
|
||||
$('users-list-error').textContent = '';
|
||||
try {
|
||||
const response = await apiFetch('/admin/users');
|
||||
if (!response.ok) { $('users-list-error').textContent = userCopy[usersLanguage].loadError; return; }
|
||||
const {users} = await response.json();
|
||||
const formatUserDate = (timestamp) => timestamp ? new Intl.DateTimeFormat('ru-RU', {dateStyle: 'medium', timeStyle: 'short'}).format(new Date(timestamp * 1000)) : '—';
|
||||
const list = $('user-list'); list.replaceChildren();
|
||||
users.forEach((user) => {
|
||||
const row = document.createElement('li'); row.className = 'review__user-row'; row.setAttribute('role', 'row');
|
||||
const cell = (label, value, className = '') => { const item = document.createElement('span'); item.setAttribute('role', 'cell'); item.dataset.label = label; item.className = className; item.textContent = value; return item; };
|
||||
const words = userCopy[usersLanguage];
|
||||
row.append(cell(words.login, user.username, 'review__user-name'), cell(words.role, user.role || words.role), cell(words.state, user.must_change_password ? words.statusChange : words.active), cell(words.lastLogin, formatUserDate(user.last_login_at)));
|
||||
const actions = document.createElement('div'); actions.className = 'review__user-actions'; actions.setAttribute('role', 'cell'); actions.dataset.label = 'Действия';
|
||||
const history = document.createElement('details'); history.className = 'review__user-history';
|
||||
const historySummary = document.createElement('summary'); historySummary.textContent = words.history;
|
||||
const historyText = document.createElement('small'); historyText.className = 'review__user-meta'; historyText.textContent = `${words.firstLogin}: ${formatUserDate(user.first_login_at)}\n${words.resetDate}: ${formatUserDate(user.password_reset_at)}`;
|
||||
history.append(historySummary, historyText); actions.append(history);
|
||||
if (user.username.toLowerCase() !== 'admin') {
|
||||
const resetForm = document.createElement('form'); resetForm.className = 'review__user-reset';
|
||||
const password = document.createElement('input'); password.type = 'password'; password.autocomplete = 'new-password'; password.minLength = 12; password.maxLength = 256; password.required = true;
|
||||
const passwordLabel = document.createElement('label'); passwordLabel.append(document.createTextNode(words.resetLabel), password);
|
||||
const reset = document.createElement('button'); reset.type = 'submit'; reset.textContent = words.reset; resetForm.append(passwordLabel, reset);
|
||||
resetForm.addEventListener('submit', async (event) => { event.preventDefault(); $('users-list-error').textContent = ''; const result = await apiFetch(`/admin/users/${encodeURIComponent(user.username)}/reset-password`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({password: password.value})}); if (!result.ok) { $('users-list-error').textContent = (await result.json()).error || userCopy[usersLanguage].loadError; return; } resetForm.reset(); await loadUsers(); });
|
||||
const resetDetails = document.createElement('details'); resetDetails.className = 'review__user-reset-disclosure';
|
||||
const resetSummary = document.createElement('summary'); resetSummary.textContent = words.reset; resetDetails.append(resetSummary, resetForm); actions.append(resetDetails);
|
||||
const remove = document.createElement('button'); remove.type = 'button'; remove.textContent = words.delete; remove.addEventListener('click', () => { pendingDeleteUser = user.username; $('delete-user-copy').textContent = usersLanguage === 'ky' ? `${user.username} аккаунтын өчүрөсүзбү? Сакталган белгилөө тарыхта калат.` : `Удалить учетную запись ${user.username}? Сохранённая разметка останется в истории.`; $('delete-user-error').textContent = ''; $('delete-user-dialog').showModal(); $('delete-user-confirm').focus(); }); actions.append(remove);
|
||||
}
|
||||
row.append(actions); list.append(row);
|
||||
});
|
||||
} catch (_) { $('users-list-error').textContent = userCopy[usersLanguage].loadError; }
|
||||
}
|
||||
let pendingDeleteUser = null;
|
||||
$('delete-user-confirm').addEventListener('click', async () => {
|
||||
if (!pendingDeleteUser) return;
|
||||
$('delete-user-confirm').disabled = true; $('delete-user-error').textContent = '';
|
||||
try {
|
||||
const result = await apiFetch(`/admin/users/${encodeURIComponent(pendingDeleteUser)}`, {method: 'DELETE'});
|
||||
if (!result.ok) { $('delete-user-error').textContent = (await result.json()).error || 'Не удалось удалить учетную запись.'; return; }
|
||||
$('delete-user-dialog').close(); pendingDeleteUser = null; await loadUsers();
|
||||
} catch (_) { $('delete-user-error').textContent = 'Не удалось удалить учетную запись. Проверьте подключение и повторите.'; }
|
||||
finally { $('delete-user-confirm').disabled = false; }
|
||||
});
|
||||
$('login-form').addEventListener('submit', async (event) => { event.preventDefault(); $('login-error').textContent = ''; const response = await fetch('/auth/login', {method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({username: $('login-username').value, password: $('login-password').value})}); const payload = await response.json(); if (!response.ok) { $('login-error').textContent = payload.error || 'Не удалось войти. Проверьте логин и пароль.'; $('login-password').focus(); return; } $('login-dialog').close(); $('login-password').value = ''; if (payload.account.must_change_password) { $('current-password').value = sessionTemporaryPassword; $('password-dialog').showModal(); $('new-password').focus(); } else { enterWorkspace(payload.account); } });
|
||||
let sessionTemporaryPassword = '';
|
||||
$('login-form').addEventListener('submit', () => { sessionTemporaryPassword = $('login-password').value; }, true);
|
||||
$('password-form').addEventListener('submit', async (event) => { event.preventDefault(); $('password-error').textContent = ''; if ($('new-password').value !== $('confirm-password').value) { $('password-error').textContent = 'Новые пароли не совпадают.'; $('confirm-password').focus(); return; } const response = await fetch('/auth/change-password', {method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({current_password: $('current-password').value, new_password: $('new-password').value})}); const payload = await response.json(); if (!response.ok) { $('password-error').textContent = payload.error || 'Не удалось изменить пароль.'; return; } $('password-dialog').close(); sessionTemporaryPassword = ''; $('password-form').reset(); enterWorkspace(payload.account); });
|
||||
$('profile-password-form').addEventListener('submit', async (event) => { event.preventDefault(); $('profile-error').textContent = ''; const response = await apiFetch('/auth/change-password', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({current_password: $('profile-current-password').value, new_password: $('profile-new-password').value})}); const payload = await response.json(); if (!response.ok) { $('profile-error').textContent = payload.error || 'Не удалось изменить пароль.'; return; } $('account-dialog').close(); $('profile-password-form').reset(); enterWorkspace(payload.account); });
|
||||
$('create-user-form').addEventListener('submit', async (event) => { event.preventDefault(); $('users-error').textContent = ''; const response = await apiFetch('/admin/users', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({username: $('new-username').value, password: $('temporary-password').value})}); const payload = await response.json(); if (!response.ok) { $('users-error').textContent = payload.error || 'Не удалось создать учетную запись.'; return; } $('create-user-form').reset(); await loadUsers(); });
|
||||
['review-account-open', 'search-account-open'].forEach((id) => $(id).addEventListener('click', () => $('account-dialog').showModal()));
|
||||
['review-users-open', 'search-users-open'].forEach((id) => $(id).addEventListener('click', async () => { await loadUsers(); $('users-dialog').showModal(); }));
|
||||
$('profile-password-form').addEventListener('submit', async (event) => { event.preventDefault(); $('profile-error').textContent = ''; if ($('profile-new-password').value !== $('profile-confirm-password').value) { $('profile-error').textContent = modalMessage('passwordMismatch'); $('profile-confirm-password').focus(); return; } const response = await apiFetch('/auth/change-password', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({current_password: $('profile-current-password').value, new_password: $('profile-new-password').value})}); const payload = await response.json(); if (!response.ok) { $('profile-error').textContent = payload.error || 'Не удалось изменить пароль.'; return; } $('account-dialog').close(); $('profile-password-form').reset(); enterWorkspace(payload.account); });
|
||||
$('create-user-open').addEventListener('click', () => { $('create-user-dialog').showModal(); $('create-user-heading').focus(); });
|
||||
$('create-user-form').addEventListener('submit', async (event) => { event.preventDefault(); $('users-error').textContent = ''; const response = await apiFetch('/admin/users', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({username: $('new-username').value, password: $('temporary-password').value})}); const payload = await response.json(); if (!response.ok) { $('users-error').textContent = payload.error || 'Не удалось создать учетную запись.'; return; } $('create-user-form').reset(); $('create-user-dialog').close(); await loadUsers(); });
|
||||
['review-account-open', 'search-account-open'].forEach((id) => $(id).addEventListener('click', () => { $('account-dialog').showModal(); $('profile-tab').focus(); }));
|
||||
$('search-users-open').addEventListener('click', () => { $('account-dialog').close(); location.assign('/users'); });
|
||||
[['profile-tab', 'security-tab'], ['security-tab', 'profile-tab']].forEach(([id, other]) => $(id).addEventListener('click', () => {
|
||||
const selected = $(id), target = id === 'profile-tab' ? $('profile-panel') : $('security-panel');
|
||||
const inactive = id === 'profile-tab' ? $('security-panel') : $('profile-panel');
|
||||
selected.setAttribute('aria-selected', 'true'); selected.removeAttribute('tabindex'); $(other).setAttribute('aria-selected', 'false'); $(other).tabIndex = -1;
|
||||
target.hidden = false; inactive.hidden = true;
|
||||
}));
|
||||
document.querySelectorAll('[role="tablist"] [role="tab"]').forEach((tab) => tab.addEventListener('keydown', (event) => {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault(); const tabs = [...tab.parentElement.querySelectorAll('[role="tab"]')];
|
||||
const next = event.key === 'Home' ? tabs[0] : event.key === 'End' ? tabs.at(-1) : tabs[(tabs.indexOf(tab) + (event.key === 'ArrowRight' ? 1 : tabs.length - 1)) % tabs.length];
|
||||
next.click(); next.focus();
|
||||
}));
|
||||
document.querySelectorAll('dialog:not(#login-dialog):not(#password-dialog):not(#tutorial-dialog)').forEach((dialog) => {
|
||||
const headingId = dialog.getAttribute('aria-labelledby'); const heading = headingId ? $(headingId) : dialog.querySelector('h2');
|
||||
if (!heading) return;
|
||||
const chrome = document.createElement('div'); chrome.className = 'review__dialog-heading';
|
||||
const close = document.createElement('button'); close.type = 'button'; close.dataset.close = dialog.id; close.setAttribute('aria-label', 'Закрыть окно');
|
||||
close.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
|
||||
heading.tabIndex = -1; heading.before(chrome); chrome.append(heading, close);
|
||||
});
|
||||
document.querySelectorAll('[data-close]').forEach((button) => button.addEventListener('click', () => $(button.dataset.close).close()));
|
||||
['review-logout', 'search-logout'].forEach((id) => $(id).addEventListener('click', async () => { await fetch('/auth/logout', {method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'}, body: '{}'}); account = null; document.body.classList.add('review__locked'); showLogin(); }));
|
||||
(async () => { const response = await fetch('/auth/me', {credentials: 'same-origin'}); if (!response.ok) { showLogin(); return; } const payload = await response.json(); if (payload.account.must_change_password) { $('password-dialog').showModal(); $('current-password').focus(); return; } enterWorkspace(payload.account); })();
|
||||
@@ -868,10 +1170,9 @@
|
||||
const setError = (text) => setStatus(text, true);
|
||||
const escapeText = (value) => value == null ? '' : String(value);
|
||||
const readDraft = () => { try { return JSON.parse(localStorage.getItem(draftStorageKey()) || 'null'); } catch (_) { return null; } };
|
||||
const hasSeenTour = () => { try { return localStorage.getItem(TOUR_KEY) === '1'; } catch (_) { return false; } };
|
||||
const markTourSeen = () => { try { localStorage.setItem(TOUR_KEY, '1'); } catch (_) {} };
|
||||
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(draftStorageKey(), JSON.stringify({query: state.query, language: $('language').value, pageSize: $('page-size').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 = account?.username || ''; $('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 || ''; }); };
|
||||
const saveDraft = () => { if (!state.token) return; const results = [...$('results').children].filter((item) => item.dataset.code).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(draftStorageKey(), JSON.stringify({query: state.query, language: $('language').value, pageSize: $('page-size').value, overallComment: $('overall-comment').value, results})); $('draft-status').textContent = 'Черновик сохранён на этом устройстве.'; } catch (_) { $('draft-status').textContent = 'Не удалось сохранить черновик на этом устройстве.'; } };
|
||||
const applyDraft = () => { const draft = readDraft(); if (!draft || draft.query !== state.query || draft.language !== $('language').value || draft.pageSize !== $('page-size').value) return; $('reviewer').value = account?.username || ''; $('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; } const comment = item.querySelector('textarea'); comment.value = saved.comment || ''; if (comment.value) item.querySelector('details').open = true; }); if (draft.overallComment || (draft.results || []).some((item) => item.comment)) $('draft-status').textContent = 'Восстановлен черновик с этого устройства.'; };
|
||||
function renderResults() {
|
||||
$('results').replaceChildren();
|
||||
if (!state.results.length) { $('results').innerHTML = '<li class="review__empty"><strong>Результатов пока нет</strong>Введите другой запрос и попробуйте снова.</li>'; return; }
|
||||
@@ -881,14 +1182,20 @@
|
||||
const meta = document.createElement('div'); meta.className = 'review__meta'; meta.textContent = [result.type, result.status, result.date_adopted, result.number].filter(Boolean).join(' · ');
|
||||
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, rating, comment); $('results').append(item);
|
||||
const disclosure = document.createElement('details'); disclosure.className = 'review__comment-disclosure';
|
||||
const summary = document.createElement('summary'); summary.textContent = 'Добавить комментарий';
|
||||
const comment = document.createElement('textarea'); comment.className = 'review__comment'; comment.maxLength = 4000; comment.placeholder = 'Комментарий (необязательно)'; comment.setAttribute('aria-label', `Комментарий к результату #${index + 1}`);
|
||||
disclosure.append(summary, comment);
|
||||
item.append(title, meta, rating, disclosure); $('results').append(item);
|
||||
});
|
||||
applyDraft();
|
||||
}
|
||||
async function openDocument(index, trigger) {
|
||||
const result = state.results[index]; state.selected = trigger; [...$('results').children].forEach((item) => item.classList.remove('review__result--selected')); trigger.closest('.review__result')?.classList.add('review__result--selected'); setStatus('Загрузка документа…');
|
||||
try { const response = await apiFetch(`/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('Не удалось загрузить документ. Повторите попытку.'); }
|
||||
const requestId = ++state.documentRequest;
|
||||
const result = state.results[index]; state.selected = trigger; [...$('results').children].forEach((item) => item.classList.remove('review__result--selected')); trigger.closest('.review__result')?.classList.add('review__result--selected');
|
||||
$('document-meta').replaceChildren(); $('document-body').replaceChildren(); $('dialog-body').replaceChildren(); $('dialog-heading').textContent = result.name || 'Документ';
|
||||
setStatus('Загрузка документа…');
|
||||
try { const response = await apiFetch(`/documents/${encodeURIComponent(result.code)}/editions/${encodeURIComponent(result.edition)}?language=${encodeURIComponent($('language').value)}`); if (!response.ok) throw new Error('Документ недоступен'); const payload = await response.json(); if (requestId !== state.documentRequest || state.results[index] !== result) return; 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 = sanitizeDocumentHtml(content.html); $('dialog-heading').textContent = result.name || 'Документ'; $('dialog-body').innerHTML = $('document-body').innerHTML; if (matchMedia('(max-width: 800px)').matches) $('document-panel').scrollIntoView({block: 'start'}); setStatus('Документ загружен.'); } catch (error) { if (requestId === state.documentRequest) setError('Не удалось загрузить документ. Повторите попытку.'); }
|
||||
}
|
||||
$('dialog-close').addEventListener('click', () => { $('document-dialog').close(); if (state.selected) state.selected.focus(); });
|
||||
let tutorialStep = 0; let tutorialReturnFocus = null;
|
||||
@@ -931,7 +1238,7 @@
|
||||
function openTutorial() { tutorialReturnFocus = document.activeElement; tutorialStep = 0; renderTutorial(); }
|
||||
window.addEventListener('resize', () => { if ($('tutorial-dialog').open) renderTutorial(); });
|
||||
$('tutorial-open').addEventListener('click', openTutorial); $('tutorial-next').addEventListener('click', () => { if (tutorialStep === TOUR_STEPS.length - 1) closeTutorial(); else { tutorialStep += 1; renderTutorial(); } }); $('tutorial-skip').addEventListener('click', closeTutorial); $('tutorial-dialog').addEventListener('cancel', (event) => { event.preventDefault(); closeTutorial(); });
|
||||
$('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 apiFetch(`/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; } });
|
||||
$('search-form').addEventListener('submit', async (event) => { event.preventDefault(); const query = $('query').value.trim(); if (!query) return; const requestId = ++state.searchRequest; state.documentRequest += 1; state.query = query; state.token = ''; setStatus('Поиск выполняется…'); $('save').disabled = true; $('save').textContent = 'Поиск выполняется…'; try { const params = new URLSearchParams({q: query, language: $('language').value, page_size: $('page-size').value}); const response = await apiFetch(`/search?${params}`); if (!response.ok) throw new Error(); const payload = await response.json(); if (requestId !== state.searchRequest) return; state.results = payload.results; state.token = payload.review_token; renderResults(); setStatus(state.results.length ? `Найдено результатов: ${state.results.length}.` : `По запросу «${query}» ничего не найдено. Измените запрос.`); $('save').textContent = state.token && state.results.length ? 'Сохранить оценку' : 'Нет данных для сохранения'; $('save').disabled = !state.token || !state.results.length; $('draft-status').textContent = state.results.length ? 'Оценка ещё не сохранена.' : 'Пустую выдачу сохранить нельзя.'; } catch (error) { if (requestId !== state.searchRequest) return; state.results = []; state.token = ''; renderResults(); setError('Не удалось выполнить поиск. Повторите поиск.'); $('save').textContent = 'Сначала выполните поиск'; $('save').disabled = true; $('draft-status').textContent = 'Поиск не завершился; черновик не создан.'; } });
|
||||
document.addEventListener('input', saveDraft); document.addEventListener('change', saveDraft);
|
||||
$('save').addEventListener('click', async () => { if (!state.token) { setError('Сначала выполните поиск.'); return; } const reviewer = $('reviewer').value.trim(); 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 apiFetch('/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(draftStorageKey()); 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 = account?.username || ''; $('overall-comment').value = draft.overallComment || ''; }
|
||||
|
||||
@@ -4,9 +4,9 @@ import io
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from search.api import Api, ApiError
|
||||
from search.api import Api, ApiError, handler
|
||||
|
||||
|
||||
class SearchApiTest(unittest.TestCase):
|
||||
@@ -37,6 +37,28 @@ class SearchApiTest(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ApiError, "static asset not found"):
|
||||
Api.static_asset("/static/fonts/../../DESIGN.md")
|
||||
|
||||
def test_users_screen_is_served_without_exposing_admin_api(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = self.make_api(Path(temporary))
|
||||
request_handler = handler(api)
|
||||
|
||||
def get(path):
|
||||
request = request_handler.__new__(request_handler)
|
||||
request.path = path
|
||||
request.headers = {}
|
||||
request.wfile = io.BytesIO()
|
||||
request.send_response = Mock()
|
||||
request.send_header = Mock()
|
||||
request.end_headers = Mock()
|
||||
request.do_GET()
|
||||
return request
|
||||
|
||||
users_page = get("/users")
|
||||
users_page.send_response.assert_called_once_with(200)
|
||||
self.assertIn(b'id="users-page"', users_page.wfile.getvalue())
|
||||
admin_api = get("/admin/users")
|
||||
admin_api.send_response.assert_called_once_with(401)
|
||||
|
||||
def test_search_pagination_filters_and_highlight(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = self.make_api(Path(temporary))
|
||||
|
||||
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 157 KiB |
@@ -0,0 +1,314 @@
|
||||
# Единый план корректировки интерфейса «Акылдаш»
|
||||
|
||||
Дата аудита: **23 октября 2026 года**.
|
||||
Статус на 23 октября 2026 года: реализация внесена в ветку
|
||||
`feature/uiux-tunduk-corrections`; локальная браузерная проверка прошла.
|
||||
Ожидаются CI и ручное PR review.
|
||||
|
||||
Этот файл заменяет `frontend-pixel-perfect-plan.md` и является единственным
|
||||
рабочим планом исправления UI/UX поиска, лаборатории и служебных окон.
|
||||
Продуктовый roadmap и требования к юридической готовности остаются отдельными
|
||||
документами: они не являются конкурирующими планами исправления интерфейса.
|
||||
|
||||
## 1. Источники истины и границы
|
||||
|
||||
- [DESIGN.md](../../DESIGN.md) — нормативная система «Түндүк».
|
||||
- [Утверждённая композиция](../design/tunduk-desktop-concept.png) — визуальный
|
||||
ориентир, а не образец юридических данных или текущих функций.
|
||||
- [Целевые редактируемые макеты Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB)
|
||||
— предлагаемая компоновка поиска, лаборатории, кабинета и адаптивных видов.
|
||||
- Этот файл — дефекты, требования к поведению, порядок исправления и приёмка.
|
||||
|
||||
Проверялся работающий `https://akyldash.kg/` в авторизованной сессии,
|
||||
версии в интерфейсе: Frontend **0.2.3**, Backend **0.13.0**.
|
||||
Локальный checkout содержит более ранние версии; код и live-сайт нельзя
|
||||
считать одинаковыми без сверки SHA перед реализацией. Инфраструктурные
|
||||
документы не заменяют результаты этой браузерной проверки.
|
||||
|
||||
В этой задаче исправляются frontend и доступ к статическому экрану `/users`.
|
||||
Backend-проверки ролей и операций управления пользователями не меняются.
|
||||
Production, пользователи, пароли, сохранённые оценки, ранжирование и правовой
|
||||
корпус не меняются. Реальные учётные записи не используются в регрессионном
|
||||
fixture.
|
||||
|
||||
## 2. Что проверено
|
||||
|
||||
Проверка включала клики и чтение DOM, снимки и измерение переполнения.
|
||||
Контрольные viewport: **1440×900, 1280×800, 1024×768, 390×844**;
|
||||
дополнительно исходный viewport браузера 1064×1244. Изображения пользователя
|
||||
с более широким desktop учитываются как дополнительные свидетельства,
|
||||
но не выдаются за собственный браузерный прогон.
|
||||
|
||||
| Поверхность / сценарий | Выполнено | Ограничение |
|
||||
|---|---|---|
|
||||
| Начальный `/` | Пустой запрос, доступные действия, фильтры, footer | Ошибочное empty-состояние подтверждено |
|
||||
| Поиск RU | Запрос «налог на прибыль», загрузка выдачи, открытие кодекса | Не экспертная проверка юридической релевантности |
|
||||
| Фильтры | Выбор статуса, появление chip, снятие фильтра | Все комбинации справочников не перебирались |
|
||||
| Сортировка / страницы | Выбор даты / релевантности, переход на страницу 2, сброс на страницу 1 | Оценивалось состояние UI и URL |
|
||||
| Пустая выдача | Запрос `zzzzqwerty987654321` | Серверный сбой не провоцировался |
|
||||
| Документ | Реальный текст, список редакций, переключение редакции и языка, разворот | Печать на принтер / в PDF не запускалась |
|
||||
| Поиск в документе | Ввод фразы, подсветка, кнопка следующего совпадения | Поиск после смены редакции требует отдельного теста |
|
||||
| RU/KY интерфейс | Переключение и чтение подписей | Найдены непереведённые подписи; полнота всех строк не подтверждена |
|
||||
| Мобильные фильтры | Открытие и закрытие панели | Полный touch-прогон на физическом устройстве не выполнен |
|
||||
| «Об источниках» | Открытие, чтение, закрытие | Внешний сайт Минюста не является частью этого аудита |
|
||||
| Личный кабинет | Открытие, профиль, поля смены пароля, Escape и возврат фокуса | Пароли не вводились и не менялись |
|
||||
| Пользователи | Открытие из кабинета и из лаборатории, чтение структуры | Создание, удаление и сброс пароля не выполнялись |
|
||||
| `/review` | Пустое состояние, поиск, шкала оценок, открытие документа, обучение | Оценки не отправлялись; серверное сохранение не подтверждено |
|
||||
| Авторизация / истёкшая сессия | Установлено наличие в локальной реализации | Live login и принудительная смена пароля не проверялись: действующую сессию не завершали |
|
||||
|
||||
Снимки: [каталог аудита](../design/audit-2026-10-23/).
|
||||
Участки с реальными списками пользователей не переносятся в макеты Figma;
|
||||
там используются явно условные примеры. Часть снимков лаборатории сделана
|
||||
с длинной страницей; снимок не гарантирует корректность всех скрытых участков.
|
||||
|
||||
## 3. Реестр недостатков
|
||||
|
||||
**P1** — исправить до визуальной приёмки / выпуска; **P2** — существенное
|
||||
улучшение понятности и последовательности. Предложения обозначены отдельно
|
||||
от подтверждённых дефектов. Отсутствие функции само по себе не означает баг.
|
||||
|
||||
| ID | Приоритет | Подтверждённый недостаток / свидетельство | Корректировка и условие приёмки |
|
||||
|---|---|---|---|
|
||||
| UI-01 | P1 | В начальном поиске одновременно «Введите запрос» и «Документы не найдены» | Разделить initial/loading/results/empty/error; empty только после завершённого поиска |
|
||||
| UI-02 | P1 | До выбора документа доступны печать/разворот/содержание, видны пустые селекты | До выбора — только понятное приглашение открыть результат; инструменты появляются после загрузки |
|
||||
| UI-03 | P1 | У фильтров горизонтальный и вертикальный скроллы; при 1064 px `clientWidth=194`, `scrollWidth=231` | Горизонтальное переполнение исключено; компактные группы, достаточная ширина, длинные списки раскрываются по запросу |
|
||||
| UI-04 | P1 | «Сбросить» обрезается заголовком панели | Заголовок и действие полностью видны на всех поддерживаемых размерах |
|
||||
| UI-05 | P1 | Счётчики отрываются от названий, частые переносы превращают фильтры в длинную колонку | Отдельные колонки выбора/подписи/числа; числа с разделителями; перенос только длинного названия по словам |
|
||||
| UI-06 | P1 | Все 28 видов и 8 статусов развёрнуты; орган и даты далеко ниже первого экрана | Основные варианты + «Все виды»/«Все статусы», сворачиваемые группы; сохранять доступ ко всем значениям |
|
||||
| UI-07 | P1 | Язык поиска имеет ширину около 78 px; «Русский»/«Кыргызский» обрезаются | Размер по самой длинной локализованной подписи и стрелке; проверка RU/KY без ellipsis |
|
||||
| UI-08 | P1 | На 390 px строка навигации горизонтально прокручивается, кабинет и фильтры уходят за край | Адаптивная перестройка: видимые поиск, фильтры, кабинет; ни одна основная кнопка не требует горизонтального скролла |
|
||||
| UI-09 | P2 | Кабинет — малозаметная иконка в правом конце второй строки; слева большой неинформативный промежуток | На desktop подписанная кнопка «Кабинет» в шапке; на mobile узнаваемая иконка с доступным именем и достаточной областью |
|
||||
| UI-10 | P1 | На 1024/1064 три колонки остаются тесными; название кодекса занимает большую часть панели | Адаптивный двухпанельный режим, скрытие фильтров, ограничения ширин; не сжимать все три колонки бесконечно |
|
||||
| UI-11 | P2 | Длинное название документа повторено в breadcrumb, заголовке и исходном тексте | Breadcrumb обозначает контекст; в заголовке короткое название при наличии отдельного поля, реквизиты ниже; официальный текст не переписывать |
|
||||
| UI-12 | P1 | Source link и toolbar переполняются на промежуточных ширинах; при 1064 px toolbar `412/427` | Действия переносятся группами, без обрезки; размер по содержимому, подписи редакции и языка постоянные |
|
||||
| UI-13 | P2 | В исходном тексте большие пустые абзацы и синие ссылки конфликтуют с оболочкой | Нормализовать отображение пустых блоков без потери структуры; безопасно унифицировать ссылки и отступы в scope документа |
|
||||
| UI-14 | P1 | В тексте есть широкие таблицы: например, `clientWidth=412`, `scrollWidth=834` | Таблицы — самостоятельная обозначенная область прокрутки; документ и весь экран не получают горизонтальный скролл |
|
||||
| UI-15 | P1 | В DOM документа обнаружены относительные ссылки на старые локальные Temp-пути и HTTP URL исходного редактора | Не показывать их как рабочие внутренние переходы; безопасно разрешать/классифицировать ссылки, не удаляя юридический текст; локальные/IP-ссылки не открывались |
|
||||
| UI-16 | P2 | Разворот открывает ещё одно окно с текстом; поиск, выбранное совпадение и toolbar не представлены в нём | Целевой режим чтения сохраняет редакцию, язык, положение и средства навигации; возврат очевиден |
|
||||
| UI-17 | P2 | Часть KY-интерфейса остаётся русской: например заголовки групп и служебные доступные подписи | Инвентаризировать все UI-строки; контент документа переводом интерфейса не подменять |
|
||||
| UI-18 | P1 | На `/review` два видимых footer; общий footer находится до сохранения | Ровно один видимый footer после основного контента и панели сохранения |
|
||||
| UI-19 | P1 | В лаборатории шапка, формы, панели и footer имеют разные границы и ширины | Общая оболочка и единые горизонтальные направляющие, совпадающие поля и ширины контейнеров |
|
||||
| UI-20 | P1 | Шапка лаборатории слишком высокая; на mobile рабочие результаты вытеснены далеко вниз | Компактный заголовок и помощь рядом; поиск и начало работы доступны без большого вступительного блока |
|
||||
| UI-21 | P1 | У кабинета фактически пустой `account-heading`, нет видимого заголовка | «Личный кабинет» в заголовке, корректное имя диалога для скринридера |
|
||||
| UI-22 | P1 | В кабинете нет крестика; «Закрыть» только внизу формы смены пароля | Единый крестик 44×44 справа сверху, доступное имя, Escape, возврат фокуса; нижняя отмена при редактировании |
|
||||
| UI-23 | P1 | Навигация, выход, профиль и смена пароля смешаны в одном неструктурированном окне | Отдельные разделы «Профиль» / «Безопасность»; служебные действия и выход отделены от формы |
|
||||
| UI-24 | P1 | «Пользователи» из кабинета открывает `users-dialog`, когда `account-dialog` остаётся открытым поверх него | Не оставлять конкурирующие модальные окна: закрыть кабинет с сохранением контекста и открыть целевой экран/диалог; фокус соответствует видимому окну |
|
||||
| UI-25 | P1 | Управление пользователями — длинный модальный список, закрытие ниже видимой области | Предпочтительно отдельный экран с таблицей; создать/сбросить — короткие отдельные диалоги с фиксированным заголовком и крестиком |
|
||||
| UI-26 | P2 | Удаление, сброс пароля, создание и данные входа перемешаны без ясной иерархии | Действия на строке пользователя; удаление отдельно и с подтверждением; не раскрывать все формы сброса постоянно |
|
||||
| UI-27 | P2 | Различаются оформление бренда/навигации/шрифтов, есть стандартные синие ссылки в лаборатории и кабинете | Единые header, logo, button, link, dialog и footer на всех поверхностях |
|
||||
| UI-28 | P2 | Диалоги закрываются разными кнопками в разных местах | Одна модель окна и доступного закрытия, описанная ниже |
|
||||
| UI-29 | P2 | Обучение автоматически появляется с задержкой и перехватывает фокус уже начатого ввода | Не перехватывать ввод; запуск по «Как это работает» либо ненавязчивому предложению до начала работы |
|
||||
| UI-30 | P2 | Шкала 0–3 на длинной выдаче создаёт плотный набор переносов; комментарии всегда занимают место | Полные подписи без обрезки, допускается сетка 2×2; комментарий раскрывается, введённый текст не теряется |
|
||||
| UI-31 | P2 | На пустом лабораторном экране «Сохранить оценку» выглядит готовой к отправке | Состояние зависит от наличия снимка; явное объяснение, прогресс и статус черновика; не менять смысл отсутствующей оценки |
|
||||
| UI-32 | P2 | В footer работающего сайта остаётся «Концепция Түндүк» | В рабочем режиме название продукта и реальные версии; подпись концепции только в макете/демо |
|
||||
| UI-33 | P1 | Открытие кодекса в лаборатории растягивает страницу: footer оказался на y≈636935 px, сохранение — внизу после него | Ограничить область чтения по высоте viewport и дать ей собственную вертикальную прокрутку; сохранение доступно независимо от длины документа, без сотен экранов прокрутки |
|
||||
|
||||
Escape и возврат фокуса кабинета на `search-account-open` проверены и работают.
|
||||
Это поведение сохранить. Механизм Tab/Shift+Tab всех окон, ошибки API,
|
||||
контраст всех состояний, печатная вёрстка и поведение на 200–400% zoom
|
||||
требуют отдельного приёмочного прогона; здесь они не объявлены проверенными.
|
||||
|
||||
## 4. Целевая сетка, изменение ширины и фильтры
|
||||
|
||||
Это согласованное направление корректировки, а не уже существующие функции.
|
||||
|
||||
1. Шапка, навигация, main и footer принадлежат одной оболочке. Поисковый
|
||||
и лабораторный контент не вставляются друг в друга и не дублируют footer.
|
||||
2. На широком desktop три колонки с двумя доступными разделителями.
|
||||
Базовый ориентир при 1440: **280 / 408 / 736 px + два захвата по 8 px**.
|
||||
При 1280: **256 / 384 / 624 px + два захвата по 8 px**. Это размеры
|
||||
целевых макетов, а не требование сжимать эти колонки ниже удобных минимумов.
|
||||
3. Предлагаемые пределы: фильтры 256–360 px, выдача от 360 px,
|
||||
документ от 560 px. Если окно не вмещает минимумы, фильтры уходят
|
||||
в отдельную панель, затем документ становится отдельным представлением.
|
||||
Пользовательское растягивание не может сломать соседнюю колонку.
|
||||
4. Разделители: узнаваемый захват, `col-resize`, роль `separator`,
|
||||
доступное имя, текущая/минимальная/максимальная ширина. Перетаскивание
|
||||
и стрелки клавиатуры; отдельные «Скрыть фильтры» / «Показать фильтры»
|
||||
и «Сбросить ширину». Двойной клик может дополнять, но не заменяет
|
||||
видимый и клавиатурный способ сброса. Положение хранится локально;
|
||||
при восстановлении ограничивается фактическим viewport.
|
||||
5. На desktop у фильтров нет отдельной постоянно прокручиваемой узкой
|
||||
колонки: краткие группы и раскрытие длинных справочников. Полный
|
||||
каталог доступен через поиск/раскрытие; нельзя скрыть значения навсегда.
|
||||
Одиночный выбор API не выдаётся за множественный checkbox.
|
||||
6. Длинные названия органов открываются в панели выбора с полными строками;
|
||||
основное поле не становится бессмысленной обрезанной подписью.
|
||||
7. Даты имеют постоянные «От» / «До»; если два поля не помещаются,
|
||||
ставятся друг под другом. Перенос текста внутри input недопустим.
|
||||
8. На desktop самостоятельная вертикальная прокрутка выдачи и длинного
|
||||
документа допустима. Их заголовки/toolbar доступны при чтении.
|
||||
Фильтры и всё приложение не должны требовать горизонтальной прокрутки.
|
||||
На телефоне — одна основная вертикальная прокрутка и отдельные панели.
|
||||
9. Footer внизу короткой страницы через layout с `min-height: 100dvh`
|
||||
и растущим main, а не `position: fixed` поверх содержимого. При длинном
|
||||
контенте footer следует после него. Панель сохранения имеет собственное
|
||||
место; sticky допускается только без перекрытия последней строки.
|
||||
|
||||
## 5. Единые правила всех окон и форм
|
||||
|
||||
- Видимый короткий заголовок и крестик в правом верхнем углу, область 44×44.
|
||||
Закрытие не спрятано в конце длинного содержимого.
|
||||
- Desktop: ширина по задаче, обычно 480–640 px, максимум относительно
|
||||
viewport; mobile: отступ 16 px либо полноэкранная форма для длинного ввода.
|
||||
Заголовок/закрытие остаются доступны при прокрутке содержимого.
|
||||
- `dialog` / `aria-modal`, имя через заголовок; фокус при открытии внутри,
|
||||
Tab/Shift+Tab не уходят в фон, Escape закрывает обычное окно, фокус
|
||||
возвращается на инициатор. Фон не интерактивен. Исключения для
|
||||
обязательной смены пароля объясняются пользователю, не маскируются.
|
||||
- Не наслаивать кабинет и пользователей. Переход между задачами — замена
|
||||
поверхности с понятным возвратом. Подтверждение удаления — короткое
|
||||
целевое окно, а не второй длинный редактор за первым.
|
||||
- «Профиль» — просмотр сведений; «Безопасность» — смена пароля. Доступ
|
||||
к администрированию определяется сервером, редизайн не меняет права.
|
||||
- В форме пароля постоянные подписи, подсказка требований, показать/скрыть
|
||||
пароль с доступным именем; подтверждение нового пароля как защита от
|
||||
опечатки. Никакие реальные пароли не предзаполняются и не записываются
|
||||
в материалы аудита. Изменение пароля выполняет человек.
|
||||
- Основное действие одно; «Отмена» вторична; «Выйти» отдельно от сохранения.
|
||||
Enter отправляет только активную форму. Ошибки рядом с полем и в
|
||||
доступном сообщении, введённые допустимые данные сохраняются.
|
||||
- При несохранённом редактировании — понятная защита от случайной потери;
|
||||
обычное закрытие окна просмотра не требует лишнего подтверждения.
|
||||
- Loading исключает повторную отправку; при успехе сообщается результат;
|
||||
при ошибке есть путь восстановления. Валидация не ограничивается цветом.
|
||||
- Эти правила распространяются на кабинет, вход, обязательную смену пароля,
|
||||
источники, создание/сброс/удаление пользователя, обучение и документный viewer.
|
||||
|
||||
Основания для доступного поведения:
|
||||
[WAI-ARIA Dialog](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/),
|
||||
[Window Splitter](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/)
|
||||
(паттерн APG находится в разработке; использовать как ориентир),
|
||||
[WCAG Reflow](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html).
|
||||
Крестик и структура кабинета — также прямое требование пользователя.
|
||||
|
||||
## 6. Реализация и проверяемое покрытие
|
||||
|
||||
Кодовая реализация сгруппирована по общей причине, а не по числу замечаний.
|
||||
Ниже перечислены все UI-01—UI-33, привязанные к коду и регрессионному
|
||||
сценарию. Playwright fixture использует только условные записи `admin`,
|
||||
`fixture-user` и `fixture-created`; browser requests полностью перехватываются
|
||||
тестом и не обращаются к приложению, Gitea или production API. Локальный browser
|
||||
run завершился успешно.
|
||||
|
||||
| Пункты | Изменение | Регрессионное покрытие |
|
||||
|---|---|---|
|
||||
| UI-01—02 | Разделены initial/loading/results/empty/error; панель документа показывает инструменты после выбора редакции | Пустой результат, API ошибка и редакция без текста |
|
||||
| UI-03—08 | Скомпонованы фильтры и сворачиваемые группы, читаемые счётчики и языковые select; навигация адаптируется на промежуточной ширине | Reflow 320/390/640/1024/1280/1440 CSS px; на 640 px проверены кабинет, выход и создание пользователя на `/`, `/review`, `/users` |
|
||||
| UI-09—10 | Кабинет подписан на desktop; экран переходит от трёх колонок к адаптивной компоновке | Геометрия действий и отсутствие page overflow на 640 px; layout измерен на 1024/1280/1440 px |
|
||||
| UI-11—16 | Breadcrumb не дублирует заголовок; toolbar переносится; широкие таблицы имеют собственный scroll; URL-атрибуты очищаются; разворот остаётся в панели документа | Fixture удаляет `srcset`, `ping`, private-IP `href/src`, относительные и HTTP ссылки, сохраняя текст и официальную HTTPS-ссылку; лаборатория проверяет A → ошибка B → успешная B и устаревший ответ A |
|
||||
| UI-17 | Переведены подписи поиска, фильтров, кабинета и `/users`; язык документа остаётся независимым | Переключение RU/KY и язык редакции; полный ручной лингвистический review остаётся открытым |
|
||||
| UI-18—20 | Убрано дублирование footer, согласованы контейнеры, лаборатория ограничена по высоте документа | Fixture проверяет один видимый footer; панель 612 px при viewport 900 px и scrollHeight 41027 px |
|
||||
| UI-21—24 | Кабинет имеет имя и разделы профиля/безопасности; переход к пользователям закрывает кабинет и открывает отдельный маршрут | ARIA имя, Escape, focus trap/return и отдельные маршруты |
|
||||
| UI-25—28 | Управление пользователями — отдельный экран; создание и удаление — отдельные окна; сброс и история раскрываются по запросу; общие крестики | Fixture выполнил смену пароля, создание, сброс пароля и удаление только `fixture-created`; production учётные записи и API не использовались |
|
||||
| UI-29 | Обучение запускается пользователем, без автоматического перехвата фокуса | Регрессионный сценарий обучения |
|
||||
| UI-30—31 | Оценка 2×2, комментарий скрываемый; сохранение выключено до выбора результата, есть состояние черновика | Fixture проверяет черновик и фиктивное сохранение; рейтинг и комментарий сохраняются при переключении документа, включая ошибку B и устаревший ответ A |
|
||||
| UI-32—33 | Footer содержит продукт и версии; документ лаборатории имеет собственную прокрутку до панели сохранения | Версии в обоих footer; длинный текст прокручивается внутри панели, сохранение и footer остаются ниже неё |
|
||||
|
||||
Локальный Playwright run прошёл на **320×800, 390×844, 640×450, 1024×900,
|
||||
1280×800 и 1440×900 CSS px**; горизонтального переполнения документа нет.
|
||||
При 640 CSS px кабинет, выход и создание пользователя остаются видимыми и
|
||||
помещаются в viewport на трёх маршрутах. Это проверка reflow, не проверка
|
||||
browser zoom: в доступном headless Chromium Ctrl+Plus не меняет CSS viewport;
|
||||
реальный zoom 200–400% остаётся непроверенным.
|
||||
При 1280 px сетка измерена как 256 / 384 / 640 px. Для длинной лабораторной
|
||||
редакции панель ограничена 544 px при viewport 800 px, scrollHeight 27278 px;
|
||||
сохранение и footer остаются ниже неё в DOM и доступны независимо от текста.
|
||||
Снимки, overlay и pixel-difference с целевым изображением сохранены в
|
||||
[каталоге visual review](visual-review/). Растровый diff является обзорной
|
||||
диагностикой, а не пороговой проверкой: макет и fixture-контент различаются.
|
||||
Ручной лингвистический review всех скрытых KY-подписей, физический touch,
|
||||
200–400% zoom и операции с реальными пользователями/паролями не выполнялись.
|
||||
|
||||
Не мигрировать существующий Python frontend на React/Next.js ради этих
|
||||
исправлений. Не менять ранжирование, корпус или права доступа в UI-задаче.
|
||||
|
||||
## 7. Приёмка: что значит «вылизанный интерфейс»
|
||||
|
||||
| Проверка | Условие |
|
||||
|---|---|
|
||||
| Оболочка | Один видимый header и footer на каждом маршруте, единые границы блоков |
|
||||
| Фильтры | Нет горизонтального скролла; полный текст действий, читаемые подписи и счётчики |
|
||||
| Начальное состояние | Не сообщает об отсутствии результатов до первого поиска |
|
||||
| Документ | Нет пустых селектов и активных бесполезных действий до выбора |
|
||||
| Колонки | Drag и клавиатура работают; min/max соблюдаются; скрытие/возврат и сброс сохраняют контекст |
|
||||
| Телефон | Основные действия видимы без горизонтального скролла, есть возврат из документа/фильтров |
|
||||
| Окна | Заголовок, крестик, Escape, trap/return focus; нет невидимого окна поверх целевого |
|
||||
| Лаборатория | Поиск → чтение → оценка → черновик → сохранение, панель до footer, данные не теряются |
|
||||
| Локализация | RU/KY-подписи, Ө ө Ү ү Ң ң, язык UI не подменяет язык документа |
|
||||
| Типографика | Реально загрузились Noto, основной текст 17–18 px / 1.6–1.7, длинные названия читаемы |
|
||||
| Таблицы | Собственная обозначенная прокрутка, основной текст и controls без обрезки |
|
||||
| Доступность | Контраст WCAG AA, видимый фокус, Tab/Shift+Tab/Enter/Escape, zoom/reflow, reduced motion |
|
||||
| Авторизация | Проверены вход, неверный пароль, истечение сессии и обязательная смена в безопасном тестовом окружении |
|
||||
| Снимки | 1440×900, 1280×800, 1024×768, 390×844; дополнительно 320 CSS px для reflow |
|
||||
|
||||
Для визуального сравнения использовать изолированный детерминированный fixture
|
||||
и одинаковые состояния, а не изменчивую выдачу. Fixture не включается в
|
||||
production-путь и не обходит авторизацию. Сравнивать отдельно шапку,
|
||||
фильтры, выдачу, документ, окна, сохранение и footer. Сглаживание текста
|
||||
не требует нулевого pixel diff; совпадение растра не оправдывает обрезку,
|
||||
неработающий control или нечитаемый реальный контент.
|
||||
|
||||
Локальные проверки, указанные в PR, включая Playwright, прошли. Changelog
|
||||
обновлён в этой ветке. Дальше остаются CI и ручное PR review по обычному
|
||||
процессу репозитория; автоматический reviewer не запускается. Production не
|
||||
меняется автоматически.
|
||||
|
||||
## 8. Материалы и оставшаяся проверка
|
||||
|
||||
Figma содержит целевые редактируемые состояния, а не исправленный live-сайт
|
||||
и не доказательство работоспособности функций. Учётные записи в ней
|
||||
обезличены, текст документа помечен как пример оформления.
|
||||
|
||||
| Figma-экран | Ссылка |
|
||||
|---|---|
|
||||
| 01. Поиск с документом, 1440×900 | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-2) |
|
||||
| 02. Фильтры скрыты | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-173) |
|
||||
| 03. Начальный поиск | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-288) |
|
||||
| 04. Лаборатория и доступное сохранение | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-395) |
|
||||
| 05. Кабинет: профиль и закрытие | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-30) |
|
||||
| 06. Кабинет: безопасность | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-229) |
|
||||
| 07. Источники | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-428) |
|
||||
| 08. Пользователи: отдельный экран | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-605) |
|
||||
| 09–11. Телефон: выдача, чтение, фильтры | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-670) |
|
||||
| 12. Эталон 1280×800 | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-74) |
|
||||
| 13. Планшет: две панели | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-245) |
|
||||
| 14. Кабинет на телефоне | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-360) |
|
||||
| 15. Вход, создание и подтверждение удаления | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=8-118) |
|
||||
|
||||
Макеты построены редактируемыми текстами, Auto Layout, SVG, локальными
|
||||
переменными цветов/отступов и экземплярами общих кнопок; целый интерфейс
|
||||
не вставлен одной картинкой. Прокрутка длинной выдачи/текста в Figma
|
||||
обозначает намеренное продолжение содержимого. Drag-разделители,
|
||||
закрытие и формы — спецификация будущего поведения, не работающий web-код.
|
||||
|
||||
Локальные экспорты целевых экранов для просмотра без Figma:
|
||||
[поиск](../design/tunduk-search-target.png),
|
||||
[лаборатория](../design/tunduk-review-target.png),
|
||||
[кабинет](../design/tunduk-account-target.png).
|
||||
|
||||
Контрольные свидетельства live-сайта:
|
||||
[поиск с кодексом](../design/audit-2026-10-23/search-document-1440.png),
|
||||
[мобильный поиск](../design/audit-2026-10-23/search-mobile-390.png),
|
||||
[фильтры телефона](../design/audit-2026-10-23/filters-mobile-390.png),
|
||||
[кабинет](../design/audit-2026-10-23/account-desktop.png),
|
||||
[кабинет телефона](../design/audit-2026-10-23/account-mobile-390.png),
|
||||
[лабораторный документ](../design/audit-2026-10-23/review-document-1440.png).
|
||||
|
||||
Browser evidence текущей ветки:
|
||||
[desktop 1280×800](visual-review/desktop-1280x800.png),
|
||||
[mobile 390 px](visual-review/mobile-390px.png),
|
||||
[Figma overlay](visual-review/overlay-50.png) и
|
||||
[pixel difference](visual-review/pixel-difference.png). Overlay показывает
|
||||
различия компоновки между live-target frame и deterministic fixture; pixel
|
||||
diff не используется как pass/fail.
|
||||
|
||||
Регрессия UI10: сетка поиска остаётся двухпанельной на ширине 1101 и 1175 px;
|
||||
третья панель включается при 1176 px, когда минимальные ширины трёх колонок
|
||||
помещаются. Проверка также меняет ширину окна при скрытых фильтрах и проверяет
|
||||
отсутствие горизонтального переполнения. Реальные browser zoom и экспертиза
|
||||
юридической корректности по законодательству не подтверждены этими тестами.
|
||||
|
||||
Не проверялись реальные destructive/auth-сценарии: вход с неверным паролем,
|
||||
смена пароля, удаление/создание пользователей и отправка оценки в production.
|
||||
Наличие макета не считается доказательством этих функций.
|
||||
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 454 KiB |
|
Before Width: | Height: | Size: 701 KiB After Width: | Height: | Size: 311 KiB |
|
Before Width: | Height: | Size: 286 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 442 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 293 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
@@ -11,9 +11,14 @@ const docs = [
|
||||
{code:'doc-2',name:'О введении в действие Налогового кодекса Кыргызской Республики',type:'Закон',status:'Действует',snippet:'… устанавливается порядок введения в действие Налогового кодекса Кыргызской Республики …'},
|
||||
{code:'doc-3',name:'Порядок заполнения налоговой отчётности',type:'Постановление',snippet:'… рекомендации по заполнению налоговой отчётности, включая показатели по налогу на прибыль …'}
|
||||
];
|
||||
const reviewDocs = [
|
||||
{code:'review-a',name:'Fixture document A',type:'fixture',status:'active',edition:'2026'},
|
||||
{code:'review-b',name:'Fixture document B',type:'fixture',status:'active',edition:'2025'}
|
||||
];
|
||||
const fixtureState = {users:[{username:'fixture-user',role:'user',must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null}],passwordChanges:0,userCreates:0,passwordResets:0,userDeletes:0,reviewSaves:0,lastReviewRatings:[]};
|
||||
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><h2>Налоговая отчётность</h2><p>В этом разделе отображается текст выбранной редакции. Поиск внутри документа помогает перейти к нужному фрагменту.</p><p>Далее приводятся положения о налоге на прибыль.</p><p>Упоминание запроса налог на прибыль в следующем разделе.</p>';
|
||||
let failNextEdition = false; let delayNextEdition = false;
|
||||
const article = '<h2>Налог на прибыль</h2><p><a href="../../tmp/old.html">Old local reference</a> <a href="http://cbd.minjust.gov.kg/old">Legacy HTTP reference</a> <a href="https://cbd.minjust.gov.kg/doc-1" ping="https://evil.invalid/ping">Official reference</a> <a href="https://127.0.0.1/private">Private address reference</a><img src="../../tmp/old.png" srcset="https://evil.invalid/pixel 1x, https://192.168.1.10/pixel 2x"><img alt="Private address image" src="https://192.168.1.10/pixel"></p><p>Текст документа приведён для примера оформления. Здесь будут отображаться положения выбранной редакции. По запросу налог на прибыль читатель найдёт нужный фрагмент.</p><h2>Налоговая отчётность</h2><p>В этом разделе отображается текст выбранной редакции. Поиск внутри документа помогает перейти к нужному фрагменту.</p><p>Далее приводятся положения о налоге на прибыль.</p><p>Упоминание запроса налог на прибыль в следующем разделе.</p>';
|
||||
let failNextEdition = false; let delayNextEdition = false; let failReviewDocumentB = false; let delayReviewDocumentA = false;
|
||||
(async()=>{
|
||||
const browser = await chromium.launch({headless:true});
|
||||
const page = await browser.newPage({viewport:{width:1280,height:800},deviceScaleFactor:1});
|
||||
@@ -24,12 +29,25 @@ let failNextEdition = false; let delayNextEdition = false;
|
||||
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(url.searchParams.get('q')==='service-error' ? {status:503,json:{error:'Fixture search error'}} : {json:{results:url.searchParams.get('q')==='not-found'?[]:docs,has_next:false}});
|
||||
if(p==='/search-reviews' && req.method()==='POST') { fixtureState.reviewSaves++; fixtureState.lastReviewRatings=req.postDataJSON().results.map((result)=>({code:result.code,rating:result.rating})); return route.fulfill({json:{id:42}}); }
|
||||
if(p==='/auth/change-password' && req.method()==='POST') { fixtureState.passwordChanges++; return route.fulfill({json:{account:{username:'admin',role:'admin',created_at:1780000000,must_change_password:false}}}); }
|
||||
if(p==='/admin/users' && req.method()==='GET') return route.fulfill({json:{users:fixtureState.users}});
|
||||
if(p==='/admin/users' && req.method()==='POST') { const body=req.postDataJSON(); fixtureState.userCreates++; fixtureState.users.push({username:body.username,role:'user',must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null}); return route.fulfill({status:201,json:{username:body.username}}); }
|
||||
const resetMatch=p.match(/^\/admin\/users\/([^/]+)\/reset-password$/);
|
||||
if(resetMatch && req.method()==='POST') { fixtureState.passwordResets++; const username=decodeURIComponent(resetMatch[1]); const user=fixtureState.users.find((candidate)=>candidate.username===username); if(user) user.must_change_password=true; return route.fulfill({json:{username}}); }
|
||||
const deleteMatch=p.match(/^\/admin\/users\/([^/]+)$/);
|
||||
if(deleteMatch && req.method()==='DELETE') { fixtureState.userDeletes++; const username=decodeURIComponent(deleteMatch[1]); fixtureState.users=fixtureState.users.filter((candidate)=>candidate.username!==username); return route.fulfill({json:{}}); }
|
||||
if(p==='/search') {
|
||||
if(url.searchParams.has('page_size')&&!url.searchParams.has('sort')) return route.fulfill({json:{results:reviewDocs,has_next:false,review_token:'fixture-review-token'}});
|
||||
return route.fulfill(url.searchParams.get('q')==='service-error' ? {status:503,json:{error:'Fixture search error'}} : {json:{results:url.searchParams.get('q')==='not-found'?[]:docs,has_next:false,review_token:'fixture-review-token'}});
|
||||
}
|
||||
if(p==='/documents/doc-2/editions') return route.fulfill({json:{available_languages:[],editions:[]}});
|
||||
if(p==='/documents/doc-3/editions') return route.fulfill({status:503,json:{error:'Fixture editions error'}});
|
||||
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']},{source_code:'2023',name:{ru:'Редакция только на русском'},available_languages:['ru']}]}});
|
||||
if(p==='/documents/doc-1/editions/2023') return route.fulfill({json:{content:{ru:{html:'<h2>Russian-only edition</h2><p>Regression fixture.</p>'}}}});
|
||||
if(p==='/documents/doc-1/editions/2024') { if(delayNextEdition){delayNextEdition=false;await new Promise(resolve=>setTimeout(resolve,200));} if(failNextEdition){failNextEdition=false;await new Promise(resolve=>setTimeout(resolve,120));return route.fulfill({status:503,json:{error:'Fixture edition error'}});} return route.fulfill({json:{content:{ru:{html:article},ky:{html:article}}}}); }
|
||||
if(p==='/documents/review-a/editions/2026') { if(delayReviewDocumentA){delayReviewDocumentA=false;await new Promise(resolve=>setTimeout(resolve,350));} return route.fulfill({json:{content:{ru:{html:'<h2>Fixture document A</h2><p>Content for fixture A only.</p>'}}}}); }
|
||||
if(p==='/documents/review-b/editions/2025') { if(failReviewDocumentB) return route.fulfill({status:503,json:{error:'Fixture document B unavailable'}}); return route.fulfill({json:{content:{ru:{html:'<h2>Fixture document B</h2><p>Content for fixture B only.</p>'}}}}); }
|
||||
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)}); }
|
||||
@@ -40,15 +58,16 @@ let failNextEdition = false; let delayNextEdition = false;
|
||||
await page.evaluate(()=>document.fonts.ready);
|
||||
await page.locator('.search__result-title').first().click();
|
||||
await page.locator('#public-document-body h2').first().waitFor();
|
||||
const safeLinks=await page.evaluate(()=>{const body=document.querySelector('#public-document-body'),official=[...body.querySelectorAll('a')].find(a=>a.textContent.includes('Official')),privateLink=[...body.querySelectorAll('a')].find(a=>a.textContent.includes('Private address'));return {relative:body.querySelector('a[href*=old]')?.getAttribute('href')||null,legacy:[...body.querySelectorAll('a')].find(a=>a.textContent.includes('Legacy'))?.getAttribute('href')||null,official:official?.href||null,ping:official?.getAttribute('ping')||null,privateHref:privateLink?.getAttribute('href')||null,image:body.querySelector('img')?.getAttribute('src')||null,srcset:body.querySelector('img')?.getAttribute('srcset')||null,privateImage:[...body.querySelectorAll('img')].find(image=>image.alt==='Private address image')?.getAttribute('src')||null};});
|
||||
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:0;padding-inline-start:12px;border-inline-start:1px solid #ded5cd;color:#786469;font-size:13px}' });
|
||||
await page.evaluate(()=>{const n=document.createElement('span');n.className='search__fixture-label';n.textContent='Демонстрационные данные';document.querySelector('#active-filters').after(n);const note=document.querySelector('#document-note');note.textContent='Текст приведён для примера оформления';note.hidden=false});
|
||||
console.log('subnavBoxes',await page.evaluate(()=>[...document.querySelector('.search__subnav').children].map(el=>({className:el.className||el.id,text:el.innerText,rect:(()=>{const r=el.getBoundingClientRect();return {left:r.left,right:r.right,width:r.width}})()})))); const navGeometry=await page.evaluate(()=>{const box=s=>{const r=document.querySelector(s).getBoundingClientRect();return {left:r.left,right:r.right}};const label=document.querySelector('.search__fixture-label'),range=document.createRange();range.selectNodeContents(label);const text=range.getBoundingClientRect();return {search:box('#search-nav'),sources:box('#sources-open'),language:box('.search__language-chip'),status:box('#active-filters .search__chip'),demoText:{left:text.left,right:text.right}}}); console.log('navGeometry',navGeometry); const resultHighlight=await page.locator('.search__result--selected .search__result-snippet mark').first().textContent(); 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('input[name="filter-document_type"][value="code"]').check(); await page.waitForFunction(()=>new URL(location.href).searchParams.get('document_type')==='code'); const chipsWithTwo=await page.locator('#active-filters .search__chip').count(); await page.locator('#active-filters button').first().click(); await page.waitForFunction(()=>new URL(location.href).searchParams.get('status')===null); const preservedFilters=await page.evaluate(()=>({status:new URL(location.href).searchParams.get('status'),documentType:new URL(location.href).searchParams.get('document_type')})); 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,statusValues:[...document.querySelectorAll('input[name="filter-status"]')].map(input=>input.parentElement.textContent.trim()),documentTypes:[...document.querySelectorAll('input[name="filter-document_type"]')].map(input=>input.parentElement.textContent.trim())})); 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.keyboard.press('Escape'); const expandedEscapeClosed=await page.locator('#document-dialog').evaluate(d=>!d.open); const expandedFocusRestored=await page.evaluate(()=>document.activeElement.id); 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&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});
|
||||
console.log('subnavBoxes',await page.evaluate(()=>[...document.querySelector('.search__subnav').children].map(el=>({className:el.className||el.id,text:el.innerText,rect:(()=>{const r=el.getBoundingClientRect();return {left:r.left,right:r.right,width:r.width}})()})))); const navGeometry=await page.evaluate(()=>{const box=s=>{const r=document.querySelector(s).getBoundingClientRect();return {left:r.left,right:r.right}};const label=document.querySelector('.search__fixture-label'),range=document.createRange();range.selectNodeContents(label);const text=range.getBoundingClientRect();return {search:box('#search-nav'),sources:box('#sources-open'),language:box('.search__language-chip'),status:box('#active-filters .search__chip'),account:box('#search-account-open'),accountContentFits:(()=>{const e=document.querySelector('#search-account-open');return e.scrollWidth<=e.clientWidth})(),demoText:{left:text.left,right:text.right}}}); console.log('navGeometry',navGeometry); const resultHighlight=await page.locator('.search__result--selected .search__result-snippet mark').first().textContent(); 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('input[name="filter-document_type"][value="code"]').check(); await page.waitForFunction(()=>new URL(location.href).searchParams.get('document_type')==='code'); const chipsWithTwo=await page.locator('#active-filters .search__chip').count(); await page.locator('#active-filters button').first().click(); await page.waitForFunction(()=>new URL(location.href).searchParams.get('status')===null); const preservedFilters=await page.evaluate(()=>({status:new URL(location.href).searchParams.get('status'),documentType:new URL(location.href).searchParams.get('document_type')})); 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,statusValues:[...document.querySelectorAll('input[name="filter-status"]')].map(input=>{const spans=input.parentElement.querySelectorAll('span');return `${spans[0].textContent} (${spans[1].textContent})`}),documentTypes:[...document.querySelectorAll('input[name="filter-document_type"]')].map(input=>{const spans=input.parentElement.querySelectorAll('span');return `${spans[0].textContent} (${spans[1].textContent})`})})); await page.locator('#ui-ru').click(); await page.locator('#document-expand').click(); const expanded=await page.locator('#search-layout').evaluate(e=>e.classList.contains('is-expanded')); await page.keyboard.press('Escape'); const expandedEscapeClosed=await page.locator('#search-layout').evaluate(e=>!e.classList.contains('is-expanded')); const expandedFocusRestored=await page.evaluate(()=>document.activeElement.id); 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&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-docuLine truncated
|
||||
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}));
|
||||
const widths={}; for(const width of [1440,1280,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,columns:getComputedStyle(document.querySelector('#search-layout')).gridTemplateColumns.split(' ').map(parseFloat)}));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}));
|
||||
@@ -60,12 +79,25 @@ let failNextEdition = false; let delayNextEdition = false;
|
||||
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()};
|
||||
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()}; const labLayout=await page.evaluate(()=>{const panel=document.querySelector('#document-panel'),body=document.querySelector('#document-body');for(let i=0;i<160;i++){const p=document.createElement('p');p.textContent='Эталонный текст длинной фиктивной редакции для проверки прокрутки панели документа. '.repeat(4);body.append(p);}const save=document.querySelector('#save'),footer=document.querySelector('body > footer.review__header'),r=panel.getBoundingClientRect();return {initialSaveDisabled:save.disabled,initialSaveText:save.textContent,initialDraft:document.querySelector('#draft-status').textContent,panelHeight:r.height,panelClient:panel.clientHeight,panelScroll:panel.scrollHeight,viewport:innerHeight,saveAfterPanel:Boolean(panel.compareDocumentPosition(save)&Node.DOCUMENT_POSITION_FOLLOWING),footerAfterSave:Boolean(save.compareDocumentPosition(footer)&Node.DOCUMENT_POSITION_FOLLOWING),footerVisible:getComputedStyle(footer).display!=='none',publicFooterVisible:getComputedStyle(document.querySelector('.search__footer')).display!=='none'};});
|
||||
const tutorialScreens={}; for(const [name,viewport] of [['desktop',{width:1280,height:800}],['mobile',{width:390,height:844}]]){await page.setViewportSize(viewport); await page.locator('#tutorial-open').click(); tutorialScreens[name]=[]; for(const target of ['#query','#results','#document-panel','#save']){const geometry=await page.evaluate((selector)=>{const dialog=document.querySelector('#tutorial-dialog'),target=document.querySelector(selector),d=dialog.getBoundingClientRect(),t=target.getBoundingClientRect(),style=getComputedStyle(dialog),targetStyle=getComputedStyle(target);return {step:document.querySelector('#tutorial-progress').textContent,width:d.width,height:d.height,overlaps:d.left<t.right&&d.right>t.left&&d.top<t.bottom&&d.bottom>t.top,font:style.fontFamily,titleFont:getComputedStyle(document.querySelector('#tutorial-heading')).fontFamily,background:style.backgroundColor,targetDisplay:targetStyle.display,targetInViewport:t.bottom>0&&t.top<innerHeight,target:{left:t.left,top:t.top,right:t.right,bottom:t.bottom}}},target);if(geometry.targetDisplay==='none'||geometry.target.right===geometry.target.left||geometry.target.bottom===geometry.target.top||!geometry.targetInViewport) throw new Error(`Tutorial target is not visible at ${name}: ${JSON.stringify(geometry)}`);if(geometry.overlaps) throw new Error(`Tutorial overlaps ${target} at ${name}`);if(geometry.width>320||!geometry.font.includes('Noto Sans')||!geometry.titleFont.includes('Noto Serif')||!geometry.background.includes('0.92')) throw new Error(`Tutorial style mismatch at ${name}: ${JSON.stringify(geometry)}`);tutorialScreens[name].push(geometry);if(target==='#query') await page.screenshot({path:path.join(artifacts,`review-tour-${name}.png`),fullPage:false});if(target!=='#save') await page.locator('#tutorial-next').press('Enter');}await page.locator('#tutorial-next').press('Enter');}
|
||||
await page.setViewportSize({width:1280,height:800}); await page.locator('#query').fill('налог на прибыль'); await page.locator('#search-form button[type=submit]').click(); await page.locator('.review__result').first().waitFor(); const reviewSaveEnabled=await page.locator('#save').isEnabled(); const commentDisclosure=page.locator('.review__comment-disclosure').first(); const commentStartsCollapsed=!(await commentDisclosure.evaluate(e=>e.open)); await commentDisclosure.locator('summary').click(); await commentDisclosure.locator('textarea').fill('Фиктивный комментарий для проверки черновика'); await page.locator('input[name="rating-0"][value="2"]').check(); await page.waitForFunction(()=>document.querySelector('#draft-status').textContent==='Черновик сохранён на этом устройстве.'); await page.locator('.review__result-title').first().click(); await page.locator('#document-body h2').first().waitFor(); await page.locator('#document-body').evaluate(body=>{for(let i=0;i<160;i++){const p=document.createElement('p');p.textContent='Эталонный длинный документ в безопасном browser fixture. '.repeat(5);body.append(p);}}); const reviewScroll=await page.locator('#document-panel').evaluate(panel=>({height:panel.getBoundingClientRect().height,client:panel.clientHeight,scroll:panel.scrollHeight,viewport:innerHeight,saveAfter:Boolean(panel.compareDocumentPosition(document.querySelector('#save'))&Node.DOCUMENT_POSITION_FOLLOWING),footerAfterSave:Boolean(document.querySelector('#save').compareDocumentPosition(document.querySelector('body > footer.review__header'))&Node.DOCUMENT_POSITION_FOLLOWING)})); await page.locator('#save').click(); await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Оценка сохранена')); const reviewFlow={reviewSaveEnabled,commentStartsCollapsed,commentExpanded:await commentDisclosure.evaluate(e=>e.open),draftStatus:await page.locator('#draft-status').textContent(),reviewScroll,reviewSaved:await page.locator('#status').textContent()};
|
||||
await page.locator('#query').fill('document-switch-fixture'); await page.locator('#search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('.review__result-title span:last-child')?.textContent==='Fixture document A');
|
||||
await page.locator('.review__comment-disclosure').first().locator('summary').click(); await page.locator('.review__comment').first().fill('Fixture-only note for result A.'); const fixtureRating=page.locator('input[name="rating-0"][value="2"]'); await fixtureRating.check(); const ratingBeforeDocumentSwitch=await fixtureRating.isChecked();
|
||||
await page.locator('.review__result-title').nth(0).click(); await page.locator('#document-body h2').filter({hasText:'Fixture document A'}).waitFor();
|
||||
failReviewDocumentB=true; await page.locator('.review__result-title').nth(1).click(); await page.waitForFunction(()=>document.querySelector('#status').textContent==='Не удалось загрузить документ. Повторите попытку.');
|
||||
const failedDocumentB=await page.evaluate(()=>({selected:document.querySelector('.review__result--selected .review__result-title span:last-child').textContent,heading:document.querySelector('#dialog-heading').textContent,body:document.querySelector('#document-body').textContent,dialogBody:document.querySelector('#dialog-body').textContent,sourceCount:document.querySelectorAll('#document-meta a').length,rating:document.querySelector('input[name="rating-0"]:checked')?.value||null,comment:document.querySelector('.review__comment').value}));
|
||||
failReviewDocumentB=false; await page.locator('.review__result-title').nth(1).click(); await page.locator('#document-body h2').filter({hasText:'Fixture document B'}).waitFor();
|
||||
const successfulDocumentB=await page.evaluate(()=>({heading:document.querySelector('#dialog-heading').textContent,body:document.querySelector('#document-body').textContent,source:document.querySelector('#document-meta a')?.href||null,rating:document.querySelector('input[name="rating-0"]:checked')?.value||null,comment:document.querySelector('.review__comment').value}));
|
||||
delayReviewDocumentA=true; const staleRequest=page.waitForRequest((request)=>new URL(request.url()).pathname==='/documents/review-a/editions/2026'); await page.locator('.review__result-title').nth(0).click(); await staleRequest; await page.locator('.review__result-title').nth(1).click(); await page.locator('#document-body h2').filter({hasText:'Fixture document B'}).waitFor(); await page.waitForTimeout(400);
|
||||
const staleDocumentA=await page.evaluate(()=>({heading:document.querySelector('#dialog-heading').textContent,body:document.querySelector('#document-body').textContent,source:document.querySelector('#document-meta a')?.href||null,selected:document.querySelector('.review__result--selected .review__result-title span:last-child').textContent}));
|
||||
await page.locator('#save').click(); await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Оценка сохранена'));
|
||||
const documentSwitch={ratingBeforeDocumentSwitch,failedDocumentB,successfulDocumentB,staleDocumentA,lastReviewRatings:fixtureState.lastReviewRatings,reviewSaves:fixtureState.reviewSaves}; console.log('documentSwitchFixture',JSON.stringify(documentSwitch));
|
||||
if(!ratingBeforeDocumentSwitch||failedDocumentB.selected!=='Fixture document B'||failedDocumentB.heading!=='Fixture document B'||failedDocumentB.body||failedDocumentB.dialogBody||failedDocumentB.sourceCount||failedDocumentB.rating!=='2'||failedDocumentB.comment!=='Fixture-only note for result A.'||successfulDocumentB.heading!=='Fixture document B'||!successfulDocumentB.body.includes('Content for fixture B only.')||!successfulDocumentB.source.includes('/review-b/edition/2025/ru')||successfulDocumentB.rating!=='2'||successfulDocumentB.comment!=='Fixture-only note for result A.'||staleDocumentA.heading!=='Fixture document B'||!staleDocumentA.body.includes('Content for fixture B only.')||!staleDocumentA.source.includes('/review-b/edition/2025/ru')||staleDocumentA.selected!=='Fixture document B'||!fixtureState.lastReviewRatings.some((item)=>item.code==='review-a'&&item.rating===2)||fixtureState.reviewSaves<2) process.exit(1);
|
||||
await page.setViewportSize({width:1280,height:800}); await page.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3%20%D0%BD%D0%B0%20%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&status=active');
|
||||
await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); await page.locator('#document-language').selectOption('ky'); await page.locator('#document-edition').selectOption('2023'); await page.waitForFunction(()=>document.querySelector('#public-document-body h2')?.textContent==='Russian-only edition'); const languageFallback={language:await page.locator('#document-language').inputValue(),heading:await page.locator('#public-document-body h2').textContent()};
|
||||
await page.locator('.search__result-title').nth(1).click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Для документа нет доступных редакций с текстом.'); await page.locator('#document-expand').click(); const noEditionExpandedText=await page.locator('#dialog-body').textContent(); const noEditionExpandedHeading=await page.locator('#dialog-heading').textContent(); await page.keyboard.press('Escape');
|
||||
await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); await page.locator('.search__result-title').nth(2).click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Fixture editions error'); await page.locator('#document-expand').click(); const editionErrorExpandedText=await page.locator('#dialog-body').textContent(); const editionErrorExpandedHeading=await page.locator('#dialog-heading').textContent(); await page.keyboard.press('Escape');
|
||||
await page.locator('.search__result-title').nth(1).click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Для документа нет доступных редакций с текстом.'); const noEditionExpandedText=await page.locator('#public-document-body').textContent(); const noEditionExpandedHeading=await page.locator('#public-document-heading').textContent(); const noEditionExpandHidden=await page.locator('#document-expand').evaluate(e=>getComputedStyle(e).display==='none');
|
||||
await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); await page.locator('.search__result-title').nth(2).click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Fixture editions error'); const editionErrorExpandedText=await page.locator('#public-document-body').textContent(); const editionErrorExpandedHeading=await page.locator('#public-document-heading').textContent(); const editionErrorExpandHidden=await page.locator('#document-expand').evaluate(e=>getComputedStyle(e).display==='none');
|
||||
await page.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3%20%D0%BD%D0%B0%20%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&status=active');
|
||||
await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor();
|
||||
await page.locator('#sources-open').click(); const sourcesDialog=await page.evaluate(()=>({open:document.querySelector('#sources-dialog').open,labelledBy:document.querySelector('#sources-dialog').getAttribute('aria-labelledby'),focusInside:document.querySelector('#sources-dialog').contains(document.activeElement)})); await page.keyboard.press('Escape'); const sourcesEscapeClosed=await page.locator('#sources-dialog').evaluate(d=>!d.open); const sourcesFocusRestored=await page.evaluate(()=>document.activeElement.id);
|
||||
@@ -73,16 +105,22 @@ let failNextEdition = false; let delayNextEdition = false;
|
||||
await page.emulateMedia({media:'print'}); const printState=await page.evaluate(()=>({chrome:getComputedStyle(document.querySelector('.search__chrome')).display,filters:getComputedStyle(document.querySelector('.search__filters')).display,results:getComputedStyle(document.querySelector('.search__results')).display,footer:getComputedStyle(document.querySelector('.search__footer')).display,document:getComputedStyle(document.querySelector('#public-document-panel')).display,source:getComputedStyle(document.querySelector('#document-source')).display,sourceHidden:document.querySelector('#document-source').hidden})); await page.emulateMedia({media:'screen'});
|
||||
const tableOverflow=await page.locator('#public-document-body').evaluate(body=>{const table=document.createElement('table');table.style.width='100%';table.innerHTML='<tbody><tr>'+Array.from({length:10},()=>'<td>'+('широкаятаблица'.repeat(20))+'</td>').join('')+'</tr></tbody>';body.append(table);const result={clientWidth:table.clientWidth,scrollWidth:table.scrollWidth,pageWidth:document.documentElement.scrollWidth,viewport:innerWidth};table.remove();return result});
|
||||
await page.emulateMedia({reducedMotion:'reduce'}); const reducedMotion=await page.evaluate(()=>({scrollBehavior:getComputedStyle(document.documentElement).scrollBehavior,transitionDuration:getComputedStyle(document.querySelector('#document-print')).transitionDuration})); await page.emulateMedia({reducedMotion:'no-preference'});
|
||||
await page.locator('#public-query').fill('service-error'); await page.locator('#public-search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('#public-status').textContent==='Fixture search error'); const searchError={message:await page.locator('#public-status').textContent(),query:await page.locator('#public-query').inputValue(),role:await page.locator('#public-status').getAttribute('role')};
|
||||
await page.locator('#public-query').fill('service-error'); await page.locator('#public-search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('#public-status').textContent==='Fixture search error'); const searchError={message:await page.locator('#public-status').textContent(),query:await page.locator('#public-query').inputValue(),role:await page.locator('#public-status').getAttribute('role')}; await page.locator('#public-query').fill('налог на прибыль'); await page.locator('#public-search-form button[type=submit]').click(); 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.locator('#document-edition').selectOption('2023'); await page.waitForFunction(()=>document.querySelector('#public-document-body h2')?.textContent==='Russian-only edition'); const previousEditionSource=await page.locator('#document-source').getAttribute('href'); failNextEdition=true; await page.locator('#document-edition').selectOption('2024'); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Загрузка редакции…'); const sourceDuringEditionLoad=await page.locator('#document-source').evaluate(link=>({hidden:link.hidden,href:link.getAttribute('href')})); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Fixture edition error'); const editionError=await page.locator('#document-facts').textContent(); const sourceAfterEditionError=await page.locator('#document-source').evaluate(link=>({hidden:link.hidden,href:link.getAttribute('href')})); await page.locator('#document-edition').selectOption('2024'); await page.locator('#public-document-body h2').first().waitFor(); const editionRetry=await page.locator('#public-document-body h2').first().textContent(); const sourceAfterEditionRetry=await page.locator('#document-source').evaluate(link=>({hidden:link.hidden,href:link.getAttribute('href')}));
|
||||
await page.locator('.search__result-title').nth(1).click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Для документа нет доступных редакций с текстом.'); const noEditions={message:await page.locator('#document-facts').textContent(),bodyChildren:await page.locator('#public-document-body').locator(':scope > *').count(),source:await page.locator('#document-source').evaluate(link=>({hidden:link.hidden,href:link.getAttribute('href')}))};
|
||||
delayNextEdition=true; await page.locator('.search__result-title').first().click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Загрузка редакции…'); const editionLoading=true; await page.locator('#public-document-body h2').first().waitFor();
|
||||
await page.setViewportSize({width:1280,height:800});
|
||||
await page.locator('#search-account-open').click(); const accountControls=page.locator('#account-dialog a, #account-dialog button, #account-dialog input'); await accountControls.last().focus(); await page.keyboard.press('Tab'); const accountTabTarget=await page.evaluate(()=>({tag:document.activeElement.tagName,id:document.activeElement.id,dialog:document.querySelector('#account-dialog').contains(document.activeElement)})); const accountTabWrap=accountTabTarget.dialog; await page.keyboard.press('Shift+Tab'); const accountReverseTabWrap=await page.evaluate(()=>document.activeElement.getAttribute('data-close')); await page.keyboard.press('Escape');
|
||||
await page.locator('#search-account-open').click(); await page.evaluate(()=>{const d=document.querySelector('#account-dialog');const controls=[...d.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')].filter(e=>e.getClientRects().length&&getComputedStyle(e).visibility!=='hidden');controls.at(-1).focus();}); await page.keyboard.press('Tab'); const accountTabTarget=await page.evaluate(()=>({tag:document.activeElement.tagName,id:document.activeElement.id,dialog:document.querySelector('#account-dialog').contains(document.activeElement)})); const accountTabWrap=accountTabTarget.dialog; await page.keyboard.press('Shift+Tab'); const accountReverseTabWrap=await page.evaluate(()=>({id:document.activeElement.id,dialog:document.querySelector('#account-dialog').contains(document.activeElement)})); await page.keyboard.press('Escape');
|
||||
const viewportWidths=[]; for (const width of [320,390,640,1024,1280,1440]) { await page.setViewportSize({width,height:800}); viewportWidths.push(await page.evaluate(()=>({width:innerWidth,scrollWidth:document.documentElement.scrollWidth}))); } await page.setViewportSize({width:1280,height:800});
|
||||
const breakpointLayouts=[]; for (const width of [1101,1175,1176]) { await page.setViewportSize({width,height:800}); breakpointLayouts.push(await page.evaluate(()=>({width:innerWidth,scrollWidth:document.documentElement.scrollWidth,columns:getComputedStyle(document.querySelector('#search-layout')).gridTemplateColumns.split(' ').length,filtersHidden:false}))); }
|
||||
await page.evaluate(()=>{document.querySelector('#search-layout').dataset.filtersHidden='true';});
|
||||
for (const width of [1101,1175,1176]) { await page.setViewportSize({width,height:800}); breakpointLayouts.push(await page.evaluate(()=>({width:innerWidth,scrollWidth:document.documentElement.scrollWidth,columns:getComputedStyle(document.querySelector('#search-layout')).gridTemplateColumns.split(' ').length,filtersHidden:document.querySelector('#search-layout').dataset.filtersHidden==='true'}))); }
|
||||
await page.evaluate(()=>delete document.querySelector('#search-layout').dataset.filtersHidden); await page.setViewportSize({width:1280,height:800});
|
||||
const longContent=await page.evaluate(()=>{const result=document.querySelector('.search__result-title'),heading=document.querySelector('#public-document-heading'),oldResult=result.textContent,oldHeading=heading.textContent;const longName='Кыргызская Республика '.repeat(8);result.textContent=longName;heading.textContent=longName;const image=document.createElement('img');image.width=2200;image.height=100;image.src='data:image/svg+xml,'+encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="2200" height="100"><rect width="2200" height="100" fill="gold"/></svg>');document.querySelector('#public-document-body').append(image);const titleWidths=[result,heading].map(el=>({client:el.clientWidth,scroll:el.scrollWidth}));const imageWidth=image.getBoundingClientRect().width,bodyWidth=document.querySelector('#public-document-body').clientWidth,pageWidth=document.documentElement.scrollWidth;result.textContent=oldResult;heading.textContent=oldHeading;image.remove();return {titleWidths,imageWidth,bodyWidth,pageWidth,viewport:innerWidth}});
|
||||
console.log('subnavAcceptance',JSON.stringify({sourcesDialog,sourcesEscapeClosed,sourcesFocusRestored,accountDialogFocusInside,accountEscapeClosed,accountFocusRestored,printState,tableOverflow,reducedMotion,searchError,editionError,editionRetry,previousEditionSource,sourceDuringEditionLoad,sourceAfterEditionError,sourceAfterEditionRetry,noEditions,languageFallback,noEditionExpandedText,noEditionExpandedHeading,editionErrorExpandedText,editionErrorExpandedHeading,expandedEscapeClosed,expandedFocusRestored,editionLoading,accountTabWrap,accountTabTarget,accountReverseTabWrap,longContent,tutorialScreens}));
|
||||
console.log('safeLinks',JSON.stringify(safeLinks)); console.log('viewportWidths',JSON.stringify(viewportWidths)); console.log('breakpointLayouts',JSON.stringify(breakpointLayouts));
|
||||
console.log('subnavAcceptance',JSON.stringify({sourcesDialog,sourcesEscapeClosed,sourcesFocusRestored,accountDialogFocusInside,accountEscapeClosed,accountFocusRestored,printState,tableOverflow,reducedMotion,searchError,editionError,editionRetry,previousEditionSource,sourceDuringEditionLoad,sourceAfterEditionError,sourceAfterEditionRetry,noEditions,languageFallback,noEditionExpandedText,noEditionExpandedHeading,noEditionExpandHidden,editionErrorExpandedText,editionErrorExpandedHeading,editionErrorExpandHidden,expandedEscapeClosed,expandedFocusRestored,editionLoading,accountTabWrap,accountTabTarget,accountReverseTabWrap,longContent,tutorialScreens,viewportWidths,breakpointLayouts}));
|
||||
console.log(JSON.stringify({resultHighlight,fonts,favicons,widths,view,drawer,modalBackground,focusWrap,focusVisible,focusReverseWrap,focusRestored,back,chipsBefore,chipsWithTwo,preservedFilters,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';
|
||||
const referencePath=process.env.REFERENCE_IMAGE || path.join(repo, 'docs/design/tunduk-search-target.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');
|
||||
@@ -105,6 +143,166 @@ let failNextEdition = false; let delayNextEdition = false;
|
||||
for(const region of comparison.regions){fs.writeFileSync(path.join(artifacts,`region-${region.name}-overlay.png`),Buffer.from(region.overlay.split(',')[1],'base64'));fs.writeFileSync(path.join(artifacts,`region-${region.name}-difference.png`),Buffer.from(region.difference.split(',')[1],'base64'));}
|
||||
await comparePage.close();
|
||||
}
|
||||
const failedCoreChecks = [
|
||||
["safeLinks.relative!==null", safeLinks.relative!==null],
|
||||
["safeLinks.legacy!==null", safeLinks.legacy!==null],
|
||||
["safeLinks.official!=='https://cbd.minjust.gov.kg/doc-1'", safeLinks.official!=='https://cbd.minjust.gov.kg/doc-1'],
|
||||
["safeLinks.ping!==null", safeLinks.ping!==null],
|
||||
["safeLinks.privateHref!==null", safeLinks.privateHref!==null],
|
||||
["safeLinks.image!==null", safeLinks.image!==null],
|
||||
["safeLinks.srcset!==null", safeLinks.srcset!==null],
|
||||
["safeLinks.privateImage!==null", safeLinks.privateImage!==null],
|
||||
["viewportWidths.some(item=>item.scrollWidth>item.width)", viewportWidths.some(item=>item.scrollWidth>item.width)],
|
||||
["breakpointLayouts.some(item=>item.scrollWidth>item.width)", breakpointLayouts.some(item=>item.scrollWidth>item.width)],
|
||||
["visible layouts do not switch before 1176px", breakpointLayouts.slice(0,3).some((item,index)=>item.columns!==(index===2?3:2))],
|
||||
["hidden filters are lost during resize", breakpointLayouts.slice(3).some(item=>item.columns!==2||!item.filtersHidden)],
|
||||
["resultHighlight!=='\u043d\u0430\u043b\u043e\u0433\u0435 \u043d\u0430 \u043f\u0440\u0438\u0431\u044b\u043b\u044c'", resultHighlight!=='налоге на прибыль'],
|
||||
["navGeometry.search.left!==26", navGeometry.search.left!==26],
|
||||
["navGeometry.search.right!==123", navGeometry.search.right!==123],
|
||||
["navGeometry.sources.left!==146", navGeometry.sources.left!==146],
|
||||
["Math.abs(navGeometry.sources.right-244)>3", Math.abs(navGeometry.sources.right-244)>3],
|
||||
["Math.abs(navGeometry.language.left-284)>2", Math.abs(navGeometry.language.left-284)>2],
|
||||
["Math.abs(navGeometry.language.right-372)>2", Math.abs(navGeometry.language.right-372)>2],
|
||||
["navGeometry.status.left!==381", navGeometry.status.left!==381],
|
||||
["Math.abs(navGeometry.demoText.left-510)>3", Math.abs(navGeometry.demoText.left-510)>3],
|
||||
["errors.length", errors.length],
|
||||
["!fonts.sans", !fonts.sans],
|
||||
["!fonts.serif", !fonts.serif],
|
||||
["!fonts.kyrgyzSans", !fonts.kyrgyzSans],
|
||||
["!fonts.kyrgyzSerif", !fonts.kyrgyzSerif],
|
||||
["fonts.ids.length", fonts.ids.length],
|
||||
["favicons.length!==2", favicons.length!==2],
|
||||
["favicons.some(item=>item.status!==200)", favicons.some(item=>item.status!==200)],
|
||||
["Object.values(widths).some(x=>x.scrollWidth>x.viewport)", Object.values(widths).some(x=>x.scrollWidth>x.viewport)],
|
||||
["view.scrollWidth>view.width", view.scrollWidth>view.width],
|
||||
["drawer!=='true'", drawer!=='true'],
|
||||
["Object.values(modalBackground).some(value=>value!==true)", Object.values(modalBackground).some(value=>value!==true)],
|
||||
["focusWrap!=='clear-filters'", focusWrap!=='clear-filters'],
|
||||
["focusVisible!=='3px'", focusVisible!=='3px'],
|
||||
["focusReverseWrap!=='date-to'", focusReverseWrap!=='date-to'],
|
||||
["focusRestored!=='mobile-filters-open'", focusRestored!=='mobile-filters-open'],
|
||||
["back!=='results'", back!=='results'],
|
||||
["openedCount!==1", openedCount!==1],
|
||||
["chipsBefore!==1", chipsBefore!==1],
|
||||
["chipsWithTwo!==2", chipsWithTwo!==2],
|
||||
["preservedFilters.status!==null", preservedFilters.status!==null],
|
||||
["preservedFilters.documentType!=='code'", preservedFilters.documentType!=='code'],
|
||||
["urlFilters.status!==null", urlFilters.status!==null],
|
||||
["urlFilters.documentType!==null", urlFilters.documentType!==null],
|
||||
["firstFindCount!=='1 \u0438\u0437 3'", firstFindCount!=='1 из 3'],
|
||||
["nextFindCount!=='2 \u0438\u0437 3'", nextFindCount!=='2 из 3'],
|
||||
["documentLanguage!=='ru'", documentLanguage!=='ru'],
|
||||
["kyrgyzUi.htmlLang!=='ky'", kyrgyzUi.htmlLang!=='ky'],
|
||||
["kyrgyzUi.queryLabel!=='\u0423\u043a\u0443\u043a\u0442\u0443\u043a \u0431\u0430\u0437\u0430 \u0431\u043e\u044e\u043d\u0447\u0430 \u0438\u0437\u0434\u04e9\u04e9'", kyrgyzUi.queryLabel!=='Укуктук база боюнча издөө'],
|
||||
["kyrgyzUi.statusHeading!=='\u0421\u0442\u0430\u0442\u0443\u0441'", kyrgyzUi.statusHeading!=='Статус'],
|
||||
["kyrgyzUi.filterHeading!=='\u0414\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0442\u0438\u043d \u0442\u04af\u0440\u04af'", kyrgyzUi.filterHeading!=='Документтин түрү'],
|
||||
["kyrgyzUi.authority!=='\u0411\u0430\u0440\u0434\u044b\u043a \u043e\u0440\u0433\u0430\u043d\u0434\u0430\u0440'", kyrgyzUi.authority!=='Бардык органдар'],
|
||||
["kyrgyzUi.activeStatus!=='\u041a\u04af\u0447\u04af\u043d\u0434\u04e9'", kyrgyzUi.activeStatus!=='Күчүндө'],
|
||||
["JSON.stringify(kyrgyzUi.statusValues)!==JSON.stringify(['\u041a\u04af\u0447\u04af\u043d\u0434\u04e9 (24)','\u041a\u04af\u0447\u04af\u043d \u0436\u043e\u0433\u043e\u0442\u0442\u0443 (5)'])", JSON.stringify(kyrgyzUi.statusValues)!==JSON.stringify(['Күчүндө (24)','Күчүн жоготту (5)'])],
|
||||
["JSON.stringify(kyrgyzUi.documentTypes)!==JSON.stringify(['\u041a\u043e\u0434\u0435\u043a\u0441 (12)','\u041c\u044b\u0439\u0437\u0430\u043c (10)','\u0422\u043e\u043a\u0442\u043e\u043c (8)'])", JSON.stringify(kyrgyzUi.documentTypes)!==JSON.stringify(['Кодекс (12)','Мыйзам (10)','Токтом (8)'])],
|
||||
["kyrgyzUi.resultCount!=='3 \u043d\u0430\u0442\u044b\u0439\u0436\u0430 \u0431\u0435\u0442\u0442\u0435'", kyrgyzUi.resultCount!=='3 натыйжа бетте'],
|
||||
["kyrgyzUi.findCount!=='2 \u0438\u0447\u0438\u043d\u0435\u043d 3'", kyrgyzUi.findCount!=='2 ичинен 3'],
|
||||
["kyrgyzUi.opened!=='\u0410\u0447\u044b\u043b\u0434\u044b'", kyrgyzUi.opened!=='Ачылды'],
|
||||
["kyrgyzEmpty.count!=='0 \u043d\u0430\u0442\u044b\u0439\u0436\u0430 \u0431\u0435\u0442\u0442\u0435'", kyrgyzEmpty.count!=='0 натыйжа бетте'],
|
||||
["kyrgyzEmpty.heading!=='\u0414\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0442\u0435\u0440 \u0442\u0430\u0431\u044b\u043b\u0433\u0430\u043d \u0436\u043e\u043a'", kyrgyzEmpty.heading!=='Документтер табылган жок'],
|
||||
["!expanded", !expanded],
|
||||
["!expandedEscapeClosed", !expandedEscapeClosed],
|
||||
["expandedFocusRestored!=='document-expand'", expandedFocusRestored!=='document-expand'],
|
||||
["!accountActions.open", !accountActions.open],
|
||||
["!accountActions.reviewVisible", !accountActions.reviewVisible],
|
||||
["!accountActions.usersVisible", !accountActions.usersVisible],
|
||||
["!accountActions.logoutVisible", !accountActions.logoutVisible],
|
||||
["desktopFilters.visible!==true", desktopFilters.visible!==true],
|
||||
["desktopFilters.ariaHidden!==null", desktopFilters.ariaHidden!==null],
|
||||
["routes.searchHidden!==true", routes.searchHidden!==true],
|
||||
["routes.reviewVisible!==true", routes.reviewVisible!==true],
|
||||
["!sourcesDialog.open", !sourcesDialog.open],
|
||||
["sourcesDialog.labelledBy!=='sources-heading'", sourcesDialog.labelledBy!=='sources-heading'],
|
||||
["!sourcesDialog.focusInside", !sourcesDialog.focusInside],
|
||||
["!sourcesEscapeClosed", !sourcesEscapeClosed],
|
||||
["sourcesFocusRestored!=='sources-open'", sourcesFocusRestored!=='sources-open'],
|
||||
["!accountDialogFocusInside", !accountDialogFocusInside],
|
||||
["!accountEscapeClosed", !accountEscapeClosed],
|
||||
["accountFocusRestored!=='search-account-open'", accountFocusRestored!=='search-account-open'],
|
||||
["printState.chrome!=='none'", printState.chrome!=='none'],
|
||||
["printState.filters!=='none'", printState.filters!=='none'],
|
||||
["printState.results!=='none'", printState.results!=='none'],
|
||||
["printState.footer!=='none'", printState.footer!=='none'],
|
||||
["printState.document==='none'", printState.document==='none'],
|
||||
["printState.source==='none'", printState.source==='none'],
|
||||
["printState.sourceHidden", printState.sourceHidden],
|
||||
["tableOverflow.scrollWidth<=tableOverflow.clientWidth", tableOverflow.scrollWidth<=tableOverflow.clientWidth],
|
||||
["tableOverflow.pageWidth>tableOverflow.viewport", tableOverflow.pageWidth>tableOverflow.viewport],
|
||||
["reducedMotion.scrollBehavior!=='auto'", reducedMotion.scrollBehavior!=='auto'],
|
||||
["reducedMotion.transitionDuration!=='0s'", reducedMotion.transitionDuration!=='0s'],
|
||||
["searchError.message!=='Fixture search error'", searchError.message!=='Fixture search error'],
|
||||
["searchError.query!=='service-error'", searchError.query!=='service-error'],
|
||||
["searchError.role!=='status'", searchError.role!=='status'],
|
||||
["editionError!=='Fixture edition error'", editionError!=='Fixture edition error'],
|
||||
["editionRetry!=='\u041d\u0430\u043b\u043e\u0433 \u043d\u0430 \u043f\u0440\u0438\u0431\u044b\u043b\u044c'", editionRetry!=='Налог на прибыль'],
|
||||
["previousEditionSource!=='https://cbd.minjust.gov.kg/doc-1/edition/2023/ru'", previousEditionSource!=='https://cbd.minjust.gov.kg/doc-1/edition/2023/ru'],
|
||||
["!sourceDuringEditionLoad.hidden", !sourceDuringEditionLoad.hidden],
|
||||
["sourceDuringEditionLoad.href!==null", sourceDuringEditionLoad.href!==null],
|
||||
["!sourceAfterEditionError.hidden", !sourceAfterEditionError.hidden],
|
||||
["sourceAfterEditionError.href!==null", sourceAfterEditionError.href!==null],
|
||||
["sourceAfterEditionRetry.hidden", sourceAfterEditionRetry.hidden],
|
||||
["sourceAfterEditionRetry.href!=='https://cbd.minjust.gov.kg/doc-1/edition/2024/ru'", sourceAfterEditionRetry.href!=='https://cbd.minjust.gov.kg/doc-1/edition/2024/ru'],
|
||||
["noEditions.message!=='\u0414\u043b\u044f \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u043d\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0445 \u0440\u0435\u0434\u0430\u043a\u0446\u0438\u0439 \u0441 \u0442\u0435\u043a\u0441\u0442\u043e\u043c.'", noEditions.message!=='Для документа нет доступных редакций с текстом.'],
|
||||
["noEditions.bodyChildren!==0", noEditions.bodyChildren!==0],
|
||||
["!noEditions.source.hidden", !noEditions.source.hidden],
|
||||
["noEditions.source.href!==null", noEditions.source.href!==null],
|
||||
["languageFallback.language!=='ru'", languageFallback.language!=='ru'],
|
||||
["languageFallback.heading!=='Russian-only edition'", languageFallback.heading!=='Russian-only edition'],
|
||||
["noEditionExpandedText!==''", noEditionExpandedText!==''],
|
||||
["!noEditionExpandedHeading.includes('\u041e \u0432\u0432\u0435\u0434\u0435\u043d\u0438\u0438 \u0432 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435')", !noEditionExpandedHeading.includes('О введении в действие')],
|
||||
["!noEditionExpandHidden", !noEditionExpandHidden],
|
||||
["editionErrorExpandedText!==''", editionErrorExpandedText!==''],
|
||||
["!editionErrorExpandedHeading.includes('\u041f\u043e\u0440\u044f\u0434\u043e\u043a \u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f')", !editionErrorExpandedHeading.includes('Порядок заполнения')],
|
||||
["!editionErrorExpandHidden", !editionErrorExpandHidden],
|
||||
["!editionLoading", !editionLoading],
|
||||
["!accountTabWrap", !accountTabWrap],
|
||||
["!accountTabTarget.dialog", !accountTabTarget.dialog],
|
||||
["!accountReverseTabWrap.dialog", !accountReverseTabWrap.dialog],
|
||||
["longContent.titleWidths.some(item=>item.scroll>item.client)", longContent.titleWidths.some(item=>item.scroll>item.client)],
|
||||
["longContent.imageWidth>longContent.bodyWidth", longContent.imageWidth>longContent.bodyWidth],
|
||||
["longContent.pageWidth>longContent.viewport", longContent.pageWidth>longContent.viewport],
|
||||
["!labInitial.saveDisabled", !labLayout.initialSaveDisabled],
|
||||
["!labLayout.initialSaveText.includes('Сначала выполните поиск')", !labLayout.initialSaveText.includes('Сначала выполните поиск')],
|
||||
["labLayout.panelScroll<=labLayout.panelClient", labLayout.panelScroll<=labLayout.panelClient],
|
||||
["labLayout.panelHeight>labLayout.viewport*0.69", labLayout.panelHeight>labLayout.viewport*0.69],
|
||||
["!labLayout.saveAfterPanel", !labLayout.saveAfterPanel],
|
||||
["!labLayout.footerAfterSave", !labLayout.footerAfterSave],
|
||||
["!labLayout.footerVisible", !labLayout.footerVisible],
|
||||
["labLayout.publicFooterVisible", labLayout.publicFooterVisible],
|
||||
["!reviewFlow.reviewSaveEnabled", !reviewFlow.reviewSaveEnabled],
|
||||
["!reviewFlow.commentStartsCollapsed", !reviewFlow.commentStartsCollapsed],
|
||||
["!reviewFlow.commentExpanded", !reviewFlow.commentExpanded],
|
||||
["reviewFlow.draftStatus!=='Черновик сохранён на этом устройстве.'", reviewFlow.draftStatus!=='Черновик сохранён на этом устройстве.'],
|
||||
["reviewFlow.reviewScroll.scroll<=reviewFlow.reviewScroll.client", reviewFlow.reviewScroll.scroll<=reviewFlow.reviewScroll.client],
|
||||
["reviewFlow.reviewScroll.height>reviewFlow.reviewScroll.viewport*0.69", reviewFlow.reviewScroll.height>reviewFlow.reviewScroll.viewport*0.69],
|
||||
["!reviewFlow.reviewScroll.saveAfter", !reviewFlow.reviewScroll.saveAfter],
|
||||
["!reviewFlow.reviewScroll.footerAfterSave", !reviewFlow.reviewScroll.footerAfterSave],
|
||||
["!reviewFlow.reviewSaved.includes('Оценка сохранена')", !reviewFlow.reviewSaved.includes('Оценка сохранена')]
|
||||
].filter(([, failed]) => failed).map(([name]) => name); console.log('labLayout',JSON.stringify(labLayout)); console.log('reviewFlow',JSON.stringify(reviewFlow)); console.log('failedCoreChecks', JSON.stringify(failedCoreChecks)); if (failedCoreChecks.length) process.exit(1);
|
||||
const usersPage=page; await usersPage.setViewportSize({width:320,height:760}); await usersPage.goto('https://fixture.invalid/users'); await usersPage.locator('#user-list .review__user-row').waitFor(); const usersRoute=await usersPage.evaluate(()=>({visible:!document.querySelector('#users-page').hidden,admin:document.body.classList.contains('is-admin'),accountOpen:document.querySelector('#account-dialog').open,headerVisible:getComputedStyle(document.querySelector('body > header.review__header')).display!=='none',footerAfterMain:Boolean(document.querySelector('#users-page').compareDocumentPosition(document.querySelector('body > footer.review__header')) & Node.DOCUMENT_POSITION_FOLLOWING),overflow:document.documentElement.scrollWidth>innerWidth})); await usersPage.locator('#users-ui-ky').click(); const usersKy=await usersPage.evaluate(()=>({lang:document.documentElement.lang,heading:document.querySelector('#users-heading').textContent})); console.log('usersRouteCheck',JSON.stringify({usersRoute,usersKy,errors})); if(!usersRoute.visible||!usersRoute.admin||usersRoute.accountOpen||!usersRoute.headerVisible||!usersRoute.footerAfterMain||usersRoute.overflow||errors.length||usersKy.lang!=='ky'||usersKy.heading!=='Колдонуучулар') process.exit(1);
|
||||
const reflowWidth=640; await usersPage.setViewportSize({width:reflowWidth,height:450});
|
||||
await usersPage.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3'); await usersPage.locator('.search__result-title').first().waitFor();
|
||||
const search640=await usersPage.evaluate(()=>{const button=document.querySelector('#search-account-open'),r=button.getBoundingClientRect();return {documentWidth:document.documentElement.scrollWidth,accountVisible:r.width>0&&getComputedStyle(button).visibility!=='hidden',accountWithinViewport:r.left>=0&&r.right<=innerWidth};});
|
||||
await usersPage.locator('#search-account-open').click();
|
||||
const accountDialog640=await usersPage.evaluate(()=>{const dialog=document.querySelector('#account-dialog'),r=dialog.getBoundingClientRect(),actions=[...dialog.querySelectorAll('#review-nav,#search-users-open,#search-logout')].filter(el=>!el.hidden);return {open:dialog.open,withinViewport:r.left>=0&&r.right<=innerWidth,actionsVisible:actions.length>0&&actions.every(el=>{const a=el.getBoundingClientRect();return a.width>0&&a.left>=0&&a.right<=innerWidth;})};});
|
||||
await usersPage.keyboard.press('Escape');
|
||||
await usersPage.goto('https://fixture.invalid/review'); await usersPage.locator('#review').waitFor();
|
||||
const review640=await usersPage.evaluate(()=>{const account=document.querySelector('.review__nav-account'),logout=document.querySelector('#review-logout'),r=account.getBoundingClientRect(),a=logout.getBoundingClientRect();return {documentWidth:document.documentElement.scrollWidth,accountWithinViewport:r.left>=0&&r.right<=innerWidth,logoutVisible:a.width>0&&getComputedStyle(logout).visibility!=='hidden',logoutWithinViewport:a.left>=0&&a.right<=innerWidth};});
|
||||
await usersPage.goto('https://fixture.invalid/users'); await usersPage.locator('#user-list .review__user-row').waitFor();
|
||||
const users640=await usersPage.evaluate(()=>{const account=document.querySelector('.review__nav-account'),logout=document.querySelector('#review-logout'),create=document.querySelector('#create-user-open'),r=account.getBoundingClientRect(),a=logout.getBoundingClientRect(),c=create.getBoundingClientRect();return {documentWidth:document.documentElement.scrollWidth,accountWithinViewport:r.left>=0&&r.right<=innerWidth,logoutVisible:a.width>0&&getComputedStyle(logout).visibility!=='hidden',logoutWithinViewport:a.left>=0&&a.right<=innerWidth,createVisible:c.width>0&&getComputedStyle(create).visibility!=='hidden',createWithinViewport:c.left>=0&&c.right<=innerWidth};});
|
||||
const reflow640={search640,accountDialog640,review640,users640}; console.log('reflow640',JSON.stringify(reflow640));
|
||||
if(search640.documentWidth>reflowWidth||!search640.accountVisible||!search640.accountWithinViewport||!accountDialog640.open||!accountDialog640.withinViewport||!accountDialog640.actionsVisible||review640.documentWidth>reflowWidth||!review640.accountWithinViewport||!review640.logoutVisible||!review640.logoutWithinViewport||users640.documentWidth>reflowWidth||!users640.accountWithinViewport||!users640.logoutVisible||!users640.logoutWithinViewport||!users640.createVisible||!users640.createWithinViewport) process.exit(1);
|
||||
await usersPage.locator('#create-user-open').click(); await usersPage.locator('#new-username').fill('fixture-created'); await usersPage.locator('#temporary-password').fill('Fixture-only-temporary-password-2026'); await usersPage.locator('#create-user-form button[type="submit"]').click(); await usersPage.waitForFunction(()=>!document.querySelector('#create-user-dialog').open); let createdRow=usersPage.locator('#user-list .review__user-row').filter({hasText:'fixture-created'}); await createdRow.waitFor();
|
||||
await createdRow.locator('.review__user-reset-disclosure summary').click(); await createdRow.locator('.review__user-reset input[type="password"]').fill('Fixture-only-reset-password-2026'); const resetResponse=usersPage.waitForResponse((response)=>new URL(response.url()).pathname==='/admin/users/fixture-created/reset-password'); await createdRow.locator('.review__user-reset button[type="submit"]').click(); await resetResponse;
|
||||
await usersPage.locator('#review-account-open').click(); await usersPage.locator('#security-tab').click(); await usersPage.locator('#profile-current-password').fill('Fixture-only-current-password-2026'); await usersPage.locator('#profile-new-password').fill('Fixture-only-new-password-2026'); await usersPage.locator('#profile-confirm-password').fill('Fixture-only-new-password-2026'); const passwordResponse=usersPage.waitForResponse((response)=>new URL(response.url()).pathname==='/auth/change-password'); await usersPage.locator('#profile-password-form button[type="submit"]').click(); await passwordResponse; await usersPage.waitForFunction(()=>!document.querySelector('#account-dialog').open);
|
||||
createdRow=usersPage.locator('#user-list .review__user-row').filter({hasText:'fixture-created'}); await createdRow.waitFor(); await createdRow.locator('.review__user-actions > button').last().click(); await usersPage.locator('#delete-user-confirm').click(); await usersPage.waitForFunction(()=>![...document.querySelectorAll('#user-list .review__user-row')].some(row=>row.textContent.includes('fixture-created')));
|
||||
const safeFixtureOperations={passwordChanges:fixtureState.passwordChanges,userCreates:fixtureState.userCreates,passwordResets:fixtureState.passwordResets,userDeletes:fixtureState.userDeletes,reviewSaves:fixtureState.reviewSaves,createdUserRemoved:!fixtureState.users.some((user)=>user.username==='fixture-created')}; console.log('safeFixtureOperations',JSON.stringify(safeFixtureOperations));
|
||||
if(safeFixtureOperations.passwordChanges!==1||safeFixtureOperations.userCreates!==1||safeFixtureOperations.passwordResets!==1||safeFixtureOperations.userDeletes!==1||safeFixtureOperations.reviewSaves<2||!safeFixtureOperations.createdUserRemoved) process.exit(1);
|
||||
await browser.close();
|
||||
if(resultHighlight!=='налоге на прибыль' || navGeometry.search.left!==26 || navGeometry.search.right!==123 || navGeometry.sources.left!==146 || Math.abs(navGeometry.sources.right-244)>3 || Math.abs(navGeometry.language.left-284)>2 || Math.abs(navGeometry.language.right-372)>2 || navGeometry.status.left!==381 || Math.abs(navGeometry.demoText.left-510)>3 || 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!==1 || chipsWithTwo!==2 || preservedFilters.status!==null || preservedFilters.documentType!=='code' || urlFilters.status!==null || urlFilters.documentType!==null || firstFindCount!=='1 из 3' || nextFindCount!=='2 из 3' || documentLanguage!=='ru' || kyrgyzUi.htmlLang!=='ky' || kyrgyzUi.queryLabel!=='Укуктук база боюнча издөө' || kyrgyzUi.statusHeading!=='Статус' || kyrgyzUi.filterHeading!=='Документтин түрү' || kyrgyzUi.authority!=='Бардык органдар' || kyrgyzUi.activeStatus!=='Күчүндө' || JSON.stringify(kyrgyzUi.statusValues)!==JSON.stringify(['Күчүндө (24)','Күчүн жоготту (5)']) || JSON.stringify(kyrgyzUi.documentTypes)!==JSON.stringify(['Кодекс (12)','Мыйзам (10)','Токтом (8)']) || kyrgyzUi.resultCount!=='3 натыйжа бетте' || kyrgyzUi.findCount!=='2 ичинен 3' || kyrgyzUi.opened!=='Ачылды' || kyrgyzEmpty.count!=='0 натыйжа бетте' || kyrgyzEmpty.heading!=='Документтер табылган жок' || !expanded || !expandedEscapeClosed || expandedFocusRestored!=='document-expand' || !accountActions.open || !accountActions.reviewVisible || !accountActions.usersVisible || !accountActions.logoutVisible || desktopFilters.visible!==true || desktopFilters.ariaHidden!==null || routes.searchHidden!==true || routes.reviewVisible!==true || !sourcesDialog.open || sourcesDialog.labelledBy!=='sources-heading' || !sourcesDialog.focusInside || !sourcesEscapeClosed || sourcesFocusRestored!=='sources-open' || !accountDialogFocusInside || !accountEscapeClosed || accountFocusRestored!=='search-account-open' || printState.chrome!=='none' || printState.filters!=='none' || printState.results!=='none' || printState.footer!=='none' || printState.document==='none' || printState.source==='none' || printState.sourceHidden || tableOverflow.scrollWidth<=tableOverflow.clientWidth || tableOverflow.pageWidth>tableOverflow.viewport || reducedMotion.scrollBehavior!=='auto' || reducedMotion.transitionDuration!=='0s' || searchError.message!=='Fixture search error' || searchError.query!=='service-error' || searchError.role!=='status' || editionError!=='Fixture edition error' || editionRetry!=='Налог на прибыль' || previousEditionSource!=='https://cbd.minjust.gov.kg/doc-1/edition/2023/ru' || !sourceDuringEditionLoad.hidden || sourceDuringEditionLoad.href!==null || !sourceAfterEditionError.hidden || sourceAfterEditionError.href!==null || sourceAfterEditionRetry.hidden || sourceAfterEditionRetry.href!=='https://cbd.minjust.gov.kg/doc-1/edition/2024/ru' || noEditions.message!=='Для документа нет доступных редакций с текстом.' || noEditions.bodyChildren!==0 || !noEditions.source.hidden || noEditions.source.href!==null || languageFallback.language!=='ru' || languageFallback.heading!=='Russian-only edition' || noEditionExpandedText!=='' || !noEditionExpandedHeading.includes('О введении в действие') || editionErrorExpandedText!=='' || !editionErrorExpandedHeading.includes('Порядок заполнения') || !editionLoading || !accountTabWrap || accountTabTarget.id!=='review-nav' || accountReverseTabWrap!=='account-dialog' || longContent.titleWidths.some(item=>item.scroll>item.client) || longContent.imageWidth>longContent.bodyWidth || longContent.pageWidth>longContent.viewport) process.exit(1);
|
||||
|
||||
})();
|
||||