Работа над интерактивным обучением

This commit is contained in:
brusnitsyn
2026-07-14 17:30:33 +09:00
parent 7ec32eb097
commit 082f2b8b01
15 changed files with 609 additions and 19 deletions

View File

@@ -0,0 +1,118 @@
<script setup>
import {ref, onMounted, nextTick, inject, computed, watch} from 'vue'
import { VOnboardingWrapper, VOnboardingStep } from 'v-onboarding'
import { useOnboardingStore } from '../../Stores/onboarding'
import { router } from '@inertiajs/vue3'
import { route } from 'ziggy-js'
import {NCard, NSpace, NButton, NTag} from 'naive-ui'
const wrapper = ref(null)
const store = useOnboardingStore()
const currentRoute = ref(route().current())
const currentSteps = computed(() => store.currentTourSteps)
onMounted(async () => {
if (store.isActive && store.activeStepIndex !== null) {
await nextTick()
wrapper.value?.goToStep(store.activeStepIndex)
}
})
// Следим за изменением индекса шага
watch(() => store.activeStepIndex, async (newIndex, oldIndex) => {
if (newIndex === null || !store.isActive) return
const steps = store.currentTourSteps
const step = steps[newIndex]
if (!step) return
const stepRoute = step.route
// Если шаг на другой странице — переходим
if (stepRoute && stepRoute !== currentRoute.value) {
// Переход на нужную страницу (индекс уже сохранён в сторе)
router.visit(route(stepRoute))
// Не возвращаем false, просто делаем переход
// После перехода onMounted восстановит шаг
} else {
wrapper.value?.goToStep(store.activeStepIndex)
}
}, { immediate: true }) // immediate, чтобы сработало при первом запуске
// Обработчик кнопки "Далее" в обычных шагах
const handleNext = (next, exit, isLast) => {
if (isLast) {
exit()
store.finish()
} else {
// Вызываем next() это переключит шаг внутри библиотеки,
// но нам также нужно обновить store.activeStepIndex, чтобы watch сработал
const nextIndex = store.activeStepIndex + 1
store.setStep(nextIndex) // обновляем стор
next() // говорим библиотеке переключиться
}
}
// Обработчик кнопки "Назад" в обычных шагах
const handlePrevious = (previous, exit, isFirst) => {
if (isFirst) {
} else {
// Вызываем next() это переключит шаг внутри библиотеки,
// но нам также нужно обновить store.activeStepIndex, чтобы watch сработал
const nextIndex = store.activeStepIndex - 1
store.setStep(nextIndex) // обновляем стор
previous() // говорим библиотеке переключиться
}
}
const onFinish = () => {
store.finish()
}
</script>
<template>
<VOnboardingWrapper
ref="wrapper"
:steps="currentSteps"
@finish="onFinish"
>
<template #default="{ step, next, previous, exit, isFirst, isLast }">
<VOnboardingStep>
<NCard
style="max-width: 320px;"
:title="step.content.title"
class="rounded-lg! mt-1"
size="small"
>
<p>{{ step.content.description }}</p>
<template #footer>
<NSpace justify="end">
<NButton
v-if="!isFirst"
size="small"
@click="handlePrevious(previous, exit, isFirst)"
>
Назад
</NButton>
<NButton
type="primary"
size="small"
:disabled="step.manual"
@click="handleNext(next, exit, isLast)"
>
{{ isLast ? 'Завершить' : 'Далее' }}
</NButton>
</NSpace>
</template>
</NCard>
</VOnboardingStep>
</template>
</VOnboardingWrapper>
</template>
<style>
:root {
--v-onboarding-overlay-z: 9999;
--v-onboarding-step-z: 9999;
}
</style>

View File

@@ -6,6 +6,7 @@ import AppDialogManager from "../Components/AppDialogManager.vue";
import Noise from "../Components/Noise.vue";
import { useThemeVars } from "naive-ui";
import { computed } from "vue";
import Onboarding from "../Components/Onboarding/Onboarding.vue";
const {isGlobalLoading} = useGlobalLoading()
const themeVars = useThemeVars()
@@ -91,6 +92,7 @@ const blobBg = computed(() => {
</NLayout>
</NLayout>
<Onboarding />
</template>
<style scoped>

View File

@@ -22,6 +22,7 @@ const hasHeaderExtra = computed(() => {
<!-- Лого -->
<NFlex align="center" :size="10" :wrap="false">
<component
id="app-logo"
:is="Link"
href="/"
style="text-decoration: none; display: flex; align-items: center; gap: 8px;"

View File

@@ -12,6 +12,15 @@ import SelectUserModal from "./Report/Components/SelectUserModal.vue"
import { Link, router } from "@inertiajs/vue3"
import { useServerTime } from "../Composables/useServerTime.js"
import { useThemeVars } from "naive-ui"
import {useOnboardingStore} from "../Stores/onboarding.js";
import {useModalStore} from "../Stores/modals.js";
const onboarding = useOnboardingStore()
const userStore = useAuthStore()
const modalStore = useModalStore()
// if (userStore.user.role.slug === 'dej')
// onboarding.startTour('welcomeDuty', 0)
const authStore = useAuthStore()
const themeVars = useThemeVars()
@@ -62,11 +71,7 @@ const showSelectUserModal = ref(false)
const showSelectNurseUserModal = ref(false)
const openReport = (path) => {
if (authStore.isDoctor) {
showSelectUserModal.value = true
} else {
router.visit(path)
}
modalStore.showSelectDutyUserModal = true
}
const openNurseReport = () => {
@@ -75,7 +80,7 @@ const openNurseReport = () => {
// } else {
// router.visit('/nurse/report')
// }
showSelectNurseUserModal.value = true
modalStore.showSelectNurseUserModal = true
}
const cardColor = computed(() => themeVars.value.cardColor)
@@ -88,7 +93,7 @@ const dividerColor = computed(() => themeVars.value.dividerColor)
<NFlex vertical :size="10" class="max-w-2xl w-full" style="padding: 0 16px 24px;">
<!-- Профиль -->
<NEl class="profile-card rounded-2xl" style="padding: 18px 22px;">
<NEl id="user-panel" class="profile-card rounded-2xl" style="padding: 18px 22px;">
<NFlex justify="space-between" align="center" :wrap="false">
<NFlex align="center" :size="12" :wrap="false" style="min-width: 0; flex: 1;">
@@ -133,13 +138,15 @@ const dividerColor = computed(() => themeVars.value.dividerColor)
<!-- Действия -->
<div class="bento-grid">
<ActionTile
id="new-report-duty"
v-if="authStore.hasPermission('report.create')"
:icon="TbArticle"
title="Заполнить сводную"
description="Ежедневный отчёт врача"
@click="openReport('/report')"
@click.prevent="openReport('/report')"
/>
<ActionTile
id="new-report-nurse"
v-if="authStore.hasPermission('nurse.report.view')"
:icon="TbStethoscope"
title="Журнал пациентов"
@@ -147,6 +154,7 @@ const dividerColor = computed(() => themeVars.value.dividerColor)
@click="openNurseReport"
/>
<ActionTile
id="statistics"
v-if="authStore.hasPermission('stats.view')"
:icon="TbChartTreemap"
title="Статистика"
@@ -155,6 +163,7 @@ const dividerColor = computed(() => themeVars.value.dividerColor)
href="/statistic"
/>
<ActionTile
id="reports"
v-if="authStore.hasPermission('report.view') || authStore.hasPermission('nurse.report.view')"
:icon="TbReportMedical"
title="Отчёты"
@@ -183,9 +192,9 @@ const dividerColor = computed(() => themeVars.value.dividerColor)
</NFlex>
</div>
<SelectUserModal v-model:show="showSelectUserModal" :only-user-department="false" />
<SelectUserModal v-model:show="modalStore.showSelectDutyUserModal" :only-user-department="false" />
<SelectUserModal
v-model:show="showSelectNurseUserModal"
v-model:show="modalStore.showSelectNurseUserModal"
target-path="/nurse/report"
submit-label="Перейти к журналу пациентов"
:with-exists-check="false"

View File

@@ -4,6 +4,7 @@ import {useReportStore} from "../../../Stores/report.js";
import {computed, ref, watch} from "vue";
import {router, Link} from "@inertiajs/vue3";
import {useDebounceFn} from "@vueuse/core";
import {useOnboardingStore} from "../../../Stores/onboarding.js";
const show = defineModel('show')
const props = defineProps({
@@ -176,6 +177,19 @@ const onChangeDepartment = (departmentId) => {
usersLoaded.value = false
fetchUsers(departmentId)
}
const onboarding = useOnboardingStore()
const onAfterEnter = () => {
// Если онбординг активен и текущий шаг имеет opensModal, переходим к следующему
if (onboarding.isActive) {
const currentSteps = onboarding.currentTourSteps
const currentStep = currentSteps[onboarding.activeStepIndex]
if (currentStep?.opensModal) {
console.log(currentStep)
onboarding.setStep(onboarding.activeStepIndex + 1) // переходим на шаг внутри модалки
}
}
}
</script>
<template>
@@ -184,9 +198,10 @@ const onChangeDepartment = (departmentId) => {
class="max-w-[420px]"
:mask-closable="false"
@before-leave="onAfterLeave"
@after-enter="onAfterEnter"
>
<NForm id="select-user-form" ref="formRef" :model="reportStore.reportInfo" :rules="rules">
<NFormItem label="Выберите отделение" path="departmentId">
<NFormItem id="modal-select-user-department" label="Выберите отделение" path="departmentId">
<NSelect :options="departments"
:loading="loadingDepartmentFetch"
@change="onChangeDepartment"
@@ -194,7 +209,7 @@ const onChangeDepartment = (departmentId) => {
filterable
/>
</NFormItem>
<NFormItem label="Выберите ответственного" path="userId">
<NFormItem id="modal-select-user-doctor" label="Выберите ответственного" path="userId">
<NSelect :options="users"
:loading="loadingUserFetch"
:disabled="!usersLoaded"
@@ -211,6 +226,7 @@ const onChangeDepartment = (departmentId) => {
</NAlert>
<template #action>
<NButton form-id="select-user-form"
id="modal-select-user-submit"
type="primary"
block
@click="onSubmit"

View File

@@ -0,0 +1,13 @@
import {defineStore} from "pinia";
import {computed, ref} from "vue";
import {tours} from "../config/tours.js";
export const useModalStore = defineStore('modals', () => {
const showSelectDutyUserModal = ref(false)
const showSelectNurseUserModal = ref(false)
return {
showSelectDutyUserModal,
showSelectNurseUserModal,
}
})

View File

@@ -0,0 +1,40 @@
import {defineStore} from "pinia";
import {computed, ref} from "vue";
import {tours} from "../config/tours.js";
export const useOnboardingStore = defineStore('onboarding', () => {
const activeTourId = ref(null)
const activeStepIndex = ref(0)
const isActive = ref(false)
const currentTourSteps = computed(() => {
if (!activeTourId.value) return []
return tours[activeTourId.value]?.steps || []
})
const startTour = (tourId, startFrom = 0) => {
activeTourId.value = tourId
activeStepIndex.value = startFrom
isActive.value = true
}
const setStep = (index) => {
activeStepIndex.value = index
}
const finish = () => {
activeTourId.value = null
activeStepIndex.value = 0
isActive.value = false
}
return {
activeTourId,
activeStepIndex,
isActive,
currentTourSteps,
startTour,
finish,
setStep
}
})

View File

@@ -7,6 +7,7 @@ import {startGlobalLoading, stopGlobalLoading} from "./Composables/useGlobalLoad
import './bootstrap';
import '../css/app.css';
import {NConfigProvider, NDialogProvider, NMessageProvider, ruRU, dateRuRU, darkTheme} from "naive-ui";
import { ZiggyVue } from 'ziggy-js';
router.on('start', () => {
startGlobalLoading()
@@ -64,6 +65,7 @@ createInertiaApp({
const pinia = createPinia()
vueApp.use(ZiggyVue)
vueApp.use(plugin)
vueApp.use(pinia)

View File

@@ -0,0 +1,113 @@
import {useModalStore} from "../Stores/modals.js";
import {useOnboardingStore} from "../Stores/onboarding.js";
export const tours = {
// Приветственный тур для новых пользователей
welcomeDuty: {
id: 'welcome-duty',
name: 'Знакомство с Метрикой',
steps: [
{
attachTo: { element: '#user-panel' },
route: 'start', // главная страница
content: {
title: '👋 Добро пожаловать!',
description: 'Это ваша панель управления. Сейчас мы покажем вам основные возможности.'
},
popper: { placement: 'bottom-start' }, // опционально: позиция тултипа
on: {
afterStep: () => {
const modalStore = useModalStore()
modalStore.showSelectDutyUserModal = false
},
beforeStep: () => {
const modalStore = useModalStore()
modalStore.showSelectDutyUserModal = false
},
}
},
{
attachTo: { element: '#new-report-duty' },
route: 'start', // переходим на страницу отчётов
content: {
title: '✏️ Создать отчёт',
description: 'Нажмите эту кнопку, чтобы начать создание нового отчёта.'
},
on: {
afterStep: () => {
const modalStore = useModalStore()
const onboarding = useOnboardingStore()
if (onboarding.activeStepIndex === 2) modalStore.showSelectDutyUserModal = true
if (onboarding.activeStepIndex < 2) modalStore.showSelectDutyUserModal = false
},
beforeStep: () => {
const modalStore = useModalStore()
const onboarding = useOnboardingStore()
if (onboarding.activeStepIndex < 2) modalStore.showSelectDutyUserModal = false
},
},
},
{
attachTo: { element: '#modal-select-user-department' },
route: 'start',
content: { title: 'Выберите отделение', description: 'Можете использовать список, либо ввести ФИО для поиска' },
insideModal: true, // флаг, что этот шаг внутри модалки
},
{
attachTo: { element: '#modal-select-user-doctor' },
route: 'start',
content: { title: 'Введите ответственного', description: 'Можете использовать список, либо ввести ФИО для поиска' },
insideModal: true, // флаг, что этот шаг внутри модалки
},
{
attachTo: { element: '#modal-select-user-submit' },
route: 'start',
content: { title: 'Подтвердите ввод' },
insideModal: true, // флаг, что этот шаг внутри модалки
manual: true
},
{
attachTo: { element: '#report-name-input' },
route: 'report', // страница создания отчёта
content: {
title: '📝 Название отчёта',
description: 'Введите название, чтобы легко находить отчёт позже.'
}
},
{
attachTo: { element: '#save-report-btn' },
route: 'reports.create',
content: {
title: '💾 Сохранить',
description: 'После заполнения всех полей нажмите "Сохранить", чтобы завершить.'
}
}
]
},
// Тур по новой функции "Экспорт"
duty: {
id: 'featureX',
name: 'Новая функция: Экспорт',
steps: [
{
attachTo: { element: '#export-excel-btn' },
route: 'reports.index',
content: {
title: '📤 Экспорт в Excel',
description: 'Теперь вы можете выгрузить отчёт в Excel одним кликом.'
}
},
{
attachTo: { element: '#export-pdf-btn' },
route: 'reports.index',
content: {
title: '📄 Экспорт в PDF',
description: 'А также сохранить отчёт в формате PDF для печати.'
}
}
]
}
}

View File

@@ -16,6 +16,7 @@
</script>
<!-- Styles / Scripts -->
@routes
@if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot')))
@vite(['resources/js/app.js'])
@inertiaHead