Работа над интерактивным обучением
This commit is contained in:
118
resources/js/Components/Onboarding/Onboarding.vue
Normal file
118
resources/js/Components/Onboarding/Onboarding.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
13
resources/js/Stores/modals.js
Normal file
13
resources/js/Stores/modals.js
Normal 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,
|
||||
}
|
||||
})
|
||||
40
resources/js/Stores/onboarding.js
Normal file
40
resources/js/Stores/onboarding.js
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
|
||||
113
resources/js/config/tours.js
Normal file
113
resources/js/config/tours.js
Normal 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 для печати.'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user