diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8892619..ca7ee15 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 1.6.2
+
+- Обучение после первой смены пароля открывает нужные разделы Workers и показывает до трёх коротких подсказок у полей, показателей диссовета и сведений о публикациях. Карточки вписаны в страницу и читаются без внутренней прокрутки.
+- Обучение сохраняет текущий шаг при работе с фильтрами и доступно повторно из меню «Как пользоваться».
+
## 1.5.1
- В карточке сотрудника убран дублирующий блок `news`. Раздел «В новостях» со ссылками теперь можно раскрывать и сворачивать; при загрузке карточки он закрыт по умолчанию.
diff --git a/README.md b/README.md
index 80c56cd..e2534b8 100644
--- a/README.md
+++ b/README.md
@@ -288,6 +288,6 @@ python scripts/prepare_council_articles.py app/data/council_articles.csv
### Подсказки первого входа
-После первой успешной смены пароля автоматически открываются шесть шагов: навигация, поиск сотрудников и профиль, фильтры диссовета, показатели сотрудника, строка публикации, редактирование и XLSX. Подсказки можно закрыть или пропустить и снова открыть через «Как пользоваться» в меню. Без JavaScript доступна полная инструкция `/admin/guide` и обычная форма завершения.
+После первой успешной смены пароля запускается обучение. Кнопка «Далее» открывает нужный раздел, а короткие подсказки появляются рядом с полями поиска сотрудников, фильтрами и показателями диссовета, выгрузкой XLSX и сведениями о публикациях. Обучение можно закрыть в любой момент и снова открыть через «Как пользоваться» в меню. Без JavaScript доступна полная инструкция `/admin/guide` и обычная форма завершения.
Состояние хранится для учётной записи в существующей истории действий (`guide_start` / `guide_finish`), без миграции схемы. Закрытие сохраняется на сервере; повторная смена или сброс пароля не запускают обучение заново. Для существующих пользователей, уже сменивших пароль, автоматический показ не включается. POST `/admin/guide/dismiss` требует входа, смены временного пароля и CSRF. При ошибке сохранения подсказки остаются доступными для повторного завершения.
diff --git a/app/static/admin.css b/app/static/admin.css
index db1d93f..92b2092 100644
--- a/app/static/admin.css
+++ b/app/static/admin.css
@@ -990,15 +990,71 @@
.users__history-table .table__cell:nth-child(2) { min-width: 100px; white-space: nowrap; }
-.guide__panel { box-sizing: border-box; display: flex; flex-direction: column; overflow: hidden; }
-.guide__panel > :not([data-guide-step]) { flex-shrink: 0; }
-.guide__panel .guide__step { min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
-.guide__progress { margin-block: 16px; color: #374151; font-variant-numeric: tabular-nums; }
-.guide__heading { margin-block: 20px 12px; font-size: 18px; }
+.guide {
+ margin-block: 16px;
+ padding: clamp(12px, 2vw, 20px);
+ border: 1px solid #99d5ce;
+ border-radius: 12px;
+ background: #f0fdfa;
+ color: #164e49;
+ scroll-margin-block: 16px;
+}
+.guide__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 12px 20px;
+}
+.guide__progress { margin: 0 0 4px; color: #35615b; font-size: 13px; font-variant-numeric: tabular-nums; }
+.guide__heading { margin: 0; font-size: 18px; }
+.guide--page .guide__heading { margin-block: 20px 12px; }
+.guide__heading:focus-visible { outline: 2px solid #0f766e; outline-offset: 4px; border-radius: 2px; }
+.guide__intro { max-width: 66ch; margin: 8px 0 0; line-height: 1.5; }
+.guide__actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
+.guide__actions .button { min-height: 44px; }
+.guide__field { display: grid; justify-items: start; min-width: 0; }
+.guide__tip {
+ position: relative;
+ display: block;
+ box-sizing: border-box;
+ min-width: 0;
+ max-width: 44ch;
+ margin-block: 7px 2px;
+ padding: 7px 10px;
+ border-radius: 8px;
+ background: #e0f5f1;
+ color: #164e49;
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 1.4;
+ text-align: start;
+ overflow-wrap: anywhere;
+}
+.guide__tip::before {
+ position: absolute;
+ inset-block-start: -4px;
+ inset-inline-start: 13px;
+ width: 8px;
+ height: 8px;
+ background: inherit;
+ content: "";
+ transform: rotate(45deg);
+}
+.council__candidate-count .guide__tip { padding-inline: 6px; }
+.council__search:has(.guide__tip) > .directory__field { align-self: start; }
+@media (min-width: 701px) {
+ .council__candidate-summary:has(.guide__tip) {
+ grid-template-columns: minmax(0, 1fr) minmax(0, 3fr) 18px;
+ }
+}
+@media (max-width: 420px) {
+ .council__counts:has(.guide__tip) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+.directory__checkbox:has(.guide__tip) { flex-wrap: wrap; }
+.directory__checkbox .guide__tip { flex-basis: 100%; }
.guide__fields { margin-block: 16px; }
.guide__fields dt { margin-block-start: 14px; font-weight: 600; }
.guide__fields dd { margin: 4px 0 0; overflow-wrap: anywhere; }
-.guide__actions { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; margin-block-start: 24px; }
.guide--page { max-width: 75ch; }
.guide--page .guide__step + .guide__step { margin-block-start: 32px; }
-.guide .columns-modal__header { flex-wrap: wrap; }
diff --git a/app/static/guide.js b/app/static/guide.js
index 8311e91..a560db7 100644
--- a/app/static/guide.js
+++ b/app/static/guide.js
@@ -1,69 +1,260 @@
(() => {
- const dialog = document.querySelector('[data-guide-dialog]');
- if (!dialog) return;
- const trigger = document.querySelector('[data-guide-open]');
- const steps = Array.from(dialog.querySelectorAll('[data-guide-step]'));
- const back = dialog.querySelector('[data-guide-back]');
- const next = dialog.querySelector('[data-guide-next]');
- const close = dialog.querySelector('[data-guide-close]');
- const error = dialog.querySelector('[data-guide-error]');
- const unsaved = dialog.querySelector('[data-guide-unsaved]');
- let index = 0;
- let saving = false;
+ const guide = document.querySelector('[data-guide]');
+ if (!guide) return;
- function showStep() {
- steps.forEach((step, number) => { step.hidden = number !== index; });
- dialog.querySelector('[data-guide-progress]').textContent = `Шаг ${index + 1} из ${steps.length}`;
- back.disabled = index === 0;
- next.textContent = index === steps.length - 1 ? 'Завершить подсказки' : 'Далее';
- steps[index].scrollTop = 0;
- steps[index].querySelector('h3').focus({preventScroll: true});
+ const directory = '/admin/directory';
+ const council = '/admin/council';
+ const publications = '/admin/council/publications';
+ const steps = [
+ {id: 'welcome', title: 'Познакомьтесь с Workers',
+ intro: 'Найдите сотрудника, подберите кандидатов в диссовет и проверьте публикации. Подсказки можно повторить из меню «Как пользоваться».'},
+ {id: 'employee-search', path: directory, title: 'Найдите сотрудника', region: '.directory__filters', tips: [
+ ['[name="q"]', 'Введите фамилию, часть ФИО или ссылку на профиль ВШЭ.'],
+ ['[name="status"]', '«Требует проверки» означает, что профиль недоступен, а увольнение ещё не подтверждено.'],
+ ['[name="has_email"]', 'Чтобы найти рабочие контакты, выберите «Есть email».'],
+ ]},
+ {id: 'employee-filters', path: directory, title: 'Уточните список', region: '.directory__filters', tips: [
+ ['[name="has_academic_degree"]', 'Звание профессора или доцента само по себе не считается учёной степенью.'],
+ ['[name="pps"]', 'Оставьте сотрудников профессорско-преподавательского состава.'],
+ ['[name="started_to"]', '«От» и «До» задают период первого обнаружения профиля системой.'],
+ ]},
+ {id: 'employee-sort', path: directory, title: 'Настройте порядок списка', region: '.directory__filters', tips: [
+ ['[name="sort"]', '«Год начала» — начало работы в ВШЭ. «Впервые найден» — когда профиль обнаружила система.'],
+ ['[name="direction"]', 'Чтобы увидеть новые записи первыми, выберите «По убыванию».'],
+ ['[name="limit"]', 'Выберите число строк и нажмите «Применить фильтры».'],
+ ]},
+ {id: 'employee-profile', path: directory, title: 'Откройте карточку сотрудника', region: '.directory__header', tips: [
+ ['[data-columns-open]', 'Выберите столбцы: например, должности, контакты и число публикаций.'],
+ ['.directory-table__cell[data-column="full_name"] a', 'Нажмите на ФИО, чтобы открыть контакты, степени и публикации сотрудника.'],
+ ]},
+ {id: 'council-filters', path: council, title: 'Задайте параметры подбора', region: '.council__search', tips: [
+ ['#council-branch', 'Отрасль определяет публикационный минимум для доктора наук. Варианты зависят от специальности ВАК.'],
+ ['#council-specialty', 'Фильтр ищет связь журнала со специальностью. Тематику отдельной статьи проверяют отдельно.'],
+ ['#council-search', 'Введите фамилию или часть ФИО, чтобы сузить список кандидатов.'],
+ ]},
+ {id: 'council-sort', path: council, title: 'Отсортируйте кандидатов', region: '.council__search', tips: [
+ ['[name="sort"]', 'Выберите «Подтверждено», чтобы сравнить число работ с проверенными основаниями.'],
+ ['[name="direction"]', 'Выберите «По убыванию» и нажмите «Применить», чтобы вывести наибольшие значения первыми.'],
+ ]},
+ {id: 'council-counts', path: council, title: 'Прочитайте оценку сотрудника', region: '.council__list-heading',
+ intro: 'Нажмите на ФИО, чтобы открыть публикации и основания зачёта.', tips: [
+ ['.council__candidate-identity', '«Подтверждено» — работы с проверенными видом, периодом и основанием зачёта.', 'inside'],
+ ['[data-guide-count="total"]', 'Уникальные работы Q1/Q2/К1/RSCI. Для докторов учитывается также К2.', 'inside'],
+ ['[data-guide-count="all"]', 'Публикации из ЛК и выгрузки ВШЭ за все годы, без дублей.', 'inside'],
+ ]},
+ {id: 'council-sources', path: council, title: 'Сравните счётчики публикаций', region: '.council__list-heading',
+ intro: 'В раскрытой карточке можно проверить источник записи, DOI и основания зачёта.', tips: [
+ ['[data-guide-count="q1"]', 'Одна работа может входить сразу в несколько счётчиков Q1, Q2 и К1.', 'inside'],
+ ['[data-guide-count="hse"]', 'Здесь только публикации из личного кабинета сотрудника ВШЭ за все годы.', 'inside'],
+ ]},
+ {id: 'council-export', path: council, title: 'Проверьте расчёт и выгрузите список', region: '.council__notice', tips: [
+ ['.council__notice', 'Здесь описаны период оценки, минимумы по отраслям и правила учёта работ.'],
+ ['[formaction="/admin/council/export.xlsx"]', 'В XLSX попадут все найденные сотрудники и их публикации, включая другие страницы списка.'],
+ ]},
+ {id: 'publication-search', path: publications, title: 'Найдите нужную публикацию', region: '.council__search', tips: [
+ ['#publications-employee', 'Укажите фамилию или часть ФИО автора.'],
+ ['#publications-article', 'Введите несколько слов из названия статьи.'],
+ ['#publications-journal', 'Укажите часть названия журнала. Фильтры можно сочетать.'],
+ ]},
+ {id: 'publication-check', path: publications, title: 'Проверьте сведения о статье', region: '.council__articles',
+ intro: 'Выберите строку статьи, чтобы открыть кнопку «Редактировать статью». С клавиатуры нажмите Enter на строке.', tips: [
+ ['.council__article-heading', 'Год определяет, за какой период можно учесть работу. Кнопка «Уточнение» показывает, что нужно проверить.'],
+ ['[data-guide-publication="journal"]', 'Квартиль Q1–Q4 относится к году статьи. К1 и К2 — категории ВАК.'],
+ ['[data-guide-publication="specialties"]', 'Ручная привязка действует для всех статей журнала за все годы.'],
+ ], editorTips: [
+ ['#article-edit-name', 'Укажите название так, как оно приведено в источнике.'],
+ ['#article-edit-journal', 'Начните вводить название и выберите журнал из списка, если он там есть.'],
+ ['#article-edit-quartile', 'Правка затронет статьи этого журнала за тот же год. Если год неизвестен, поле недоступно.'],
+ ]},
+ ];
+
+ const main = document.querySelector('#main-content');
+ const trigger = document.querySelector('[data-guide-open]');
+ const fallback = document.querySelector('[data-guide-fallback]');
+ const heading = guide.querySelector('#guide-title');
+ const progress = guide.querySelector('[data-guide-progress]');
+ const intro = guide.querySelector('[data-guide-intro]');
+ const reference = guide.querySelector('[data-guide-reference]');
+ const back = guide.querySelector('[data-guide-back]');
+ const next = guide.querySelector('[data-guide-next]');
+ const close = guide.querySelector('[data-guide-close]');
+ const error = guide.querySelector('[data-guide-error]');
+ const unsaved = guide.querySelector('[data-guide-unsaved]');
+ const storageKey = 'workers-guide:' + guide.dataset.guideUser;
+ let index = 0;
+ let paused = false;
+ let saving = false;
+ let removeTips = [];
+
+ function addTip([selector, message, placement], id, allowHidden = false) {
+ const target = main.querySelector(selector);
+ if (!target || (!allowHidden && !target.getClientRects().length)) return;
+ const tip = document.createElement('span');
+ tip.className = 'guide__tip';
+ tip.id = 'guide-tip-' + id;
+ tip.textContent = message;
+
+ const description = target.getAttribute('aria-describedby');
+ target.setAttribute('aria-describedby', [description, tip.id].filter(Boolean).join(' '));
+ const label = target.labels?.[0];
+ let group;
+ if (label) {
+ // The description is announced separately from the label text.
+ tip.setAttribute('aria-hidden', 'true');
+ label.append(tip);
+ } else if (placement === 'inside') {
+ target.append(tip);
+ } else if (target.matches('button')) {
+ group = document.createElement('div');
+ group.className = 'guide__field';
+ target.before(group);
+ group.append(target, tip);
+ } else {
+ target.after(tip);
+ }
+ removeTips.push(() => {
+ tip.remove();
+ if (description === null) target.removeAttribute('aria-describedby');
+ else target.setAttribute('aria-describedby', description);
+ if (group) group.replaceWith(target);
+ });
}
- function open() {
- index = 0;
- error.hidden = true;
- unsaved.hidden = true;
- dialog.showModal();
+
+ function clearTips() {
+ removeTips.forEach(remove => remove());
+ removeTips = [];
+ }
+
+ function showStep(focus = true) {
+ const step = steps[index];
+ paused = Boolean(step.path && step.path !== location.pathname);
+ clearTips();
+ const region = !paused && step.region && main.querySelector(step.region);
+ if (region) region.before(guide);
+ else main.prepend(guide);
+ heading.textContent = paused ? 'Продолжите обучение' : step.title;
+ progress.textContent = `Шаг ${index + 1} из ${steps.length}`;
+ intro.textContent = paused ? step.title + '.' : step.intro || '';
+ intro.hidden = !intro.textContent;
+ reference.hidden = index !== 0;
+ error.hidden = unsaved.hidden = true;
+ back.hidden = paused;
+ back.disabled = index === 0;
+ next.textContent = paused ? 'Продолжить' : index === steps.length - 1 ? 'Завершить подсказки' : 'Далее';
+ if (!paused) {
+ step.tips?.forEach((tip, number) => addTip(tip, number));
+ step.editorTips?.forEach((tip, number) => addTip(tip, 'editor-' + number, true));
+ }
+ guide.hidden = false;
+ if (focus) {
+ heading.focus({preventScroll: true});
+ guide.scrollIntoView({block: 'start'});
+ }
+ }
+
+ function goTo(number, path = steps[number].path || '/admin/account') {
+ if (!saveStep(steps[number].id)) {
+ location.assign(trigger.href);
+ return;
+ }
+ if (path !== location.pathname) {
+ location.assign(path);
+ return;
+ }
+ index = number;
showStep();
}
+
+ function hide(remember = true) {
+ clearTips();
+ guide.hidden = true;
+ saveStep(remember ? 'closed' : null);
+ trigger?.focus();
+ }
+
+ function saveStep(step) {
+ try {
+ if (step === null) sessionStorage.removeItem(storageKey);
+ else sessionStorage.setItem(storageKey, step);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
async function finish() {
if (saving) return;
- if (dialog.dataset.guidePending !== 'true') {
- dialog.close();
+ if (guide.dataset.guidePending !== 'true') {
+ hide();
return;
}
saving = true;
- dialog.setAttribute('aria-busy', 'true');
+ guide.setAttribute('aria-busy', 'true');
close.disabled = next.disabled = back.disabled = true;
- error.hidden = true;
+ error.hidden = unsaved.hidden = true;
try {
const response = await fetch('/admin/guide/dismiss', {
method: 'POST', credentials: 'same-origin', redirect: 'error',
- headers: {'X-CSRF-Token': dialog.dataset.guideCsrf, 'Accept': 'application/json'}, signal: AbortSignal.timeout(15000),
+ headers: {'X-CSRF-Token': guide.dataset.guideCsrf, 'Accept': 'application/json'}, signal: AbortSignal.timeout(15000),
});
if (!response.ok) throw new Error();
- dialog.dataset.guidePending = 'false';
- dialog.close();
+ guide.dataset.guidePending = 'false';
+ hide();
} catch (_failure) {
- error.textContent = 'Не удалось сохранить закрытие подсказок. Попробуйте ещё раз или продолжите без сохранения: при следующем открытии страницы подсказки могут появиться снова.';
- error.hidden = false;
- unsaved.hidden = false;
+ error.textContent = 'Не удалось сохранить закрытие. Повторите попытку или продолжите без сохранения.';
+ error.hidden = unsaved.hidden = false;
+ unsaved.focus();
} finally {
saving = false;
- dialog.removeAttribute('aria-busy');
+ guide.removeAttribute('aria-busy');
close.disabled = next.disabled = false;
back.disabled = index === 0;
}
}
- trigger?.addEventListener('click', event => { event.preventDefault(); open(); });
- back.addEventListener('click', () => { index -= 1; showStep(); });
+
+ trigger?.addEventListener('click', event => {
+ if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
+ event.preventDefault();
+ if (saving) return;
+ goTo(0, location.pathname);
+ });
+ back.addEventListener('click', () => goTo(index - 1));
next.addEventListener('click', () => {
- if (index === steps.length - 1) finish();
- else { index += 1; showStep(); }
+ if (paused) goTo(index);
+ else if (index === steps.length - 1) finish();
+ else goTo(index + 1);
});
close.addEventListener('click', finish);
- unsaved.addEventListener('click', () => { if (!saving) dialog.close(); });
- dialog.addEventListener('cancel', event => { event.preventDefault(); finish(); });
- dialog.addEventListener('close', () => trigger?.focus());
- if (dialog.dataset.guidePending === 'true') open();
+ unsaved.addEventListener('click', () => hide(false));
+ guide.addEventListener('keydown', event => {
+ if (event.key !== 'Escape' || event.defaultPrevented) return;
+ event.preventDefault();
+ finish();
+ });
+ function restore(allowAutoStart) {
+ let active;
+ try {
+ active = sessionStorage.getItem(storageKey);
+ } catch {
+ return;
+ }
+ const requested = steps.findIndex(step => step.id === active);
+ const autoStart = allowAutoStart && requested < 0 && active !== 'closed' && guide.dataset.guidePending === 'true';
+ if (autoStart && !saveStep(steps[0].id)) return;
+ if (fallback) fallback.hidden = true;
+ if (requested >= 0) {
+ index = requested;
+ showStep(!allowAutoStart || index > 0);
+ } else if (autoStart) {
+ showStep(false);
+ } else {
+ clearTips();
+ guide.hidden = true;
+ }
+ }
+
+ window.addEventListener('pageshow', event => {
+ if (event.persisted) restore(false);
+ });
+ restore(true);
})();
diff --git a/app/templates/base.html b/app/templates/base.html
index 27ae68e..1d74b3a 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -30,13 +30,15 @@
{{ article.employee_name }}
{{ article.display_type }}{% set field = 'type' %}{% set label = 'тип материала' %}{% include 'council_warning.html' %}
-Журнал: {{ article.journals|join(' / ') or 'не указан' }}{% set field = 'journal' %}{% set label = 'журнал' %}{% include 'council_warning.html' %} · Квартиль: {{ article.edit_quartile or 'не указан или неоднозначен' }}{% set field = 'quartile' %}{% set label = 'квартиль и основание зачёта' %}{% include 'council_warning.html' %}
-Специальности журнала: {{ article.edit_specialty_codes|join(", ") or "связь не установлена" }}{% if article.manual_specialties %} · ручная связь{% endif %}
+Журнал: {{ article.journals|join(' / ') or 'не указан' }}{% set field = 'journal' %}{% set label = 'журнал' %}{% include 'council_warning.html' %} · Квартиль: {{ article.edit_quartile or 'не указан или неоднозначен' }}{% set field = 'quartile' %}{% set label = 'квартиль и основание зачёта' %}{% include 'council_warning.html' %}
+Специальности журнала: {{ article.edit_specialty_codes|join(", ") or "связь не установлена" }}{% if article.manual_specialties %} · ручная связь{% endif %}
{% else %}{% endfor %} diff --git a/app/templates/guide.html b/app/templates/guide.html index 61fb31c..f16e094 100644 --- a/app/templates/guide.html +++ b/app/templates/guide.html @@ -1,7 +1,7 @@ {% extends 'base.html' %} {% block title %}Как пользоваться · MIEM Employees{% endblock %} {% block content %} -