Kurs Vue.js · Moduł 9: Composables i VueUse
Projekt: Lab Manager z Composables
W tej lekcji4
Kompleksowy system zarządzania laboratorium NOVA LAB wykorzystujący wszystkie poznane techniki! Dr. Nova potrzebuje panelu, który pobierze listę eksperymentów z serwera, pozwoli ją przeszukiwać, filtrować i sortować, podzieli wyniki na strony i umożliwi usuwanie wpisów. Gdybyś wpisał to wszystko do jednego komponentu, dostałbyś kilkaset linii, w których pobieranie danych miesza się z paginacją. Zamiast tego zbudujemy trzy composables, z których każdy ma jedno zadanie, i komponent, który je łączy. Dane płyną jedną drogą: useExperiments() pobiera i przechowuje eksperymenty, useLabFilters(experiments) je filtruje, usePagination(filtered) dzieli wynik na strony, a template wyświetla paginatedItems.
Warstwa danych: useExperiments
Pierwszy plik, composables/useExperiments.js, odpowiada za dane. Zwraca stan, flagi loading i error oraz funkcje CRUD - od angielskiego Create, Read, Update, Delete, czyli tworzenie, odczyt, aktualizację i usuwanie. Wartości pochodne, takie jak lista krytycznych eksperymentów i liczba kategorii, wyliczają computed:
1// composables/useExperiments.js
2import { ref, computed } from 'vue'
3
4export function useExperiments() {
5 const experiments = ref([])
6 const loading = ref(false)
7 const error = ref(null)
8
9 const criticalExperiments = computed(() =>
10 experiments.value.filter(exp => exp.critical)
11 )
12
13 const categoriesCount = computed(() =>
14 new Set(experiments.value.map(exp => exp.category)).size
15 )
16
17 async function fetchExperiments() {
18 loading.value = true
19 error.value = null
20
21 try {
22 const response = await fetch('/api/mars/experiments')
23 if (!response.ok) throw new Error('Błąd pobierania')
24
25 experiments.value = await response.json()
26 } catch (e) {
27 error.value = e.message
28 } finally {
29 loading.value = false
30 }
31 }
32
33 async function addExperiment(experiment) {
34 const response = await fetch('/api/mars/experiments', {
35 method: 'POST',
36 headers: { 'Content-Type': 'application/json' },
37 body: JSON.stringify(experiment)
38 })
39
40 const newExperiment = await response.json()
41 experiments.value.push(newExperiment)
42 }
43
44 async function deleteExperiment(id) {
45 await fetch(`/api/mars/experiments/${id}`, { method: 'DELETE' })
46 experiments.value = experiments.value.filter(exp => exp.id !== id)
47 }
48
49 return {
50 experiments,
51 loading,
52 error,
53 criticalExperiments,
54 categoriesCount,
55 fetchExperiments,
56 addExperiment,
57 deleteExperiment
58 }
59}Mamy odczyt, tworzenie i usuwanie; aktualizację dopiszesz w ramach rozbudowy. Warto też od razu dodać dwie rzeczy z poprzednich lekcji: sprawdzenie response.ok w addExperiment oraz AbortController, który anuluje pobieranie przy odmontowaniu.
Filtrowanie: useLabFilters
Drugi composable przyjmuje parametr - ref z listą eksperymentów - i buduje z niego przefiltrowaną, posortowaną wersję. Nie wie nic o serwerze, więc zadziała z każdą listą o polach name i category:
1// composables/useLabFilters.js
2import { ref, computed } from 'vue'
3
4export function useLabFilters(experiments) {
5 const searchQuery = ref('')
6 const selectedCategory = ref('')
7 const sortBy = ref('name')
8 const sortOrder = ref('asc')
9
10 const filteredExperiments = computed(() => {
11 let result = experiments.value
12
13 // Search
14 if (searchQuery.value) {
15 const query = searchQuery.value.toLowerCase()
16 result = result.filter(exp =>
17 exp.name.toLowerCase().includes(query) ||
18 exp.category.toLowerCase().includes(query)
19 )
20 }
21
22 // Filter by category
23 if (selectedCategory.value) {
24 result = result.filter(exp => exp.category === selectedCategory.value)
25 }
26
27 // Sort
28 result = [...result].sort((a, b) => {
29 const aVal = a[sortBy.value]
30 const bVal = b[sortBy.value]
31 const order = sortOrder.value === 'asc' ? 1 : -1
32
33 if (typeof aVal === 'string') {
34 return aVal.localeCompare(bVal) * order
35 }
36 return (aVal - bVal) * order
37 })
38
39 return result
40 })
41
42 const uniqueCategories = computed(() =>
43 [...new Set(experiments.value.map(exp => exp.category))].sort()
44 )
45
46 function resetFilters() {
47 searchQuery.value = ''
48 selectedCategory.value = ''
49 sortBy.value = 'name'
50 sortOrder.value = 'asc'
51 }
52
53 return {
54 searchQuery,
55 selectedCategory,
56 sortBy,
57 sortOrder,
58 filteredExperiments,
59 uniqueCategories,
60 resetFilters
61 }
62}Zwróć uwagę na [...result].sort: metoda sort zmienia tablicę w miejscu, więc bez kopii posortowalibyśmy oryginalną listę z useExperiments. Napisy porównuje localeCompare, a liczby, na przykład rok, zwykłe odejmowanie.
Paginacja: usePagination
Trzeci composable dzieli dowolną listę na strony. Liczbę stron wylicza computed z Math.ceil, a bieżący wycinek - slice:
1// composables/usePagination.js
2import { ref, computed } from 'vue'
3
4export function usePagination(items, itemsPerPage = 12) {
5 const currentPage = ref(1)
6 const perPage = ref(itemsPerPage)
7
8 const totalPages = computed(() =>
9 Math.ceil(items.value.length / perPage.value)
10 )
11
12 const paginatedItems = computed(() => {
13 const start = (currentPage.value - 1) * perPage.value
14 const end = start + perPage.value
15 return items.value.slice(start, end)
16 })
17
18 function goToPage(page) {
19 if (page >= 1 && page <= totalPages.value) {
20 currentPage.value = page
21 }
22 }
23
24 function nextPage() {
25 goToPage(currentPage.value + 1)
26 }
27
28 function prevPage() {
29 goToPage(currentPage.value - 1)
30 }
31
32 return {
33 currentPage,
34 perPage,
35 totalPages,
36 paginatedItems,
37 goToPage,
38 nextPage,
39 prevPage
40 }
41}goToPage ignoruje numery spoza zakresu. Jedna pułapka: jeśli jesteś na stronie 3 i zawęzisz filtr do jednej strony wyników, currentPage zostanie na 3, a lista pokaże się pusta. Poprawka to jedna linijka w composable, plus import watch: watch(items, () => { currentPage.value = 1 }).
Komponent LabManager
Czas połączyć moduły w panelu. Pierwsza część szablonu to nagłówek z licznikami i panel filtrów, w którym v-model wiąże pola formularza z refami z useLabFilters:
1<!-- LabManager.vue -->
2<template>
3 <div class="lab-manager">
4 <!-- Header -->
5 <header>
6 <h1>NOVA LAB - Zarządzanie Eksperymentami</h1>
7 <p>{{ categoriesCount }} kategorii, {{ experiments.length }} eksperymentów</p>
8 </header>
9
10 <!-- Filters -->
11 <aside class="filters">
12 <input v-model="searchQuery" placeholder="Szukaj eksperymentów..." />
13
14 <select v-model="selectedCategory">
15 <option value="">Wszystkie kategorie</option>
16 <option v-for="category in uniqueCategories" :key="category" :value="category">
17 {{ category }}
18 </option>
19 </select>
20
21 <select v-model="sortBy">
22 <option value="name">Nazwa</option>
23 <option value="category">Kategoria</option>
24 <option value="year">Rok</option>
25 </select>
26
27 <button @click="sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'">
28 {{ sortOrder === 'asc' ? '↑' : '↓' }}
29 </button>
30
31 <button @click="resetFilters">Resetuj</button>
32 </aside>Każda zmiana w polu wyszukiwania od razu przelicza filteredExperiments, a za nią paginatedItems - bez jednej linii kodu obsługi zdarzeń. Druga część pokazuje stan ładowania, siatkę kart i paginację. Komponent ExperimentCard pomijamy - wyświetla kartę eksperymentu i emituje zdarzenie delete:
1 <!-- Loading/Error -->
2 <div v-if="loading">Ładowanie eksperymentów...</div>
3 <div v-else-if="error">Błąd: {{ error }}</div>
4
5 <!-- Lab Dashboard -->
6 <main v-else class="lab-grid">
7 <ExperimentCard
8 v-for="experiment in paginatedItems"
9 :key="experiment.id"
10 :experiment="experiment"
11 @delete="deleteExperiment(experiment.id)"
12 />
13 </main>
14
15 <!-- Pagination -->
16 <footer class="pagination">
17 <button @click="prevPage" :disabled="currentPage === 1">
18 Poprzednia
19 </button>
20
21 <button
22 v-for="page in totalPages"
23 :key="page"
24 @click="goToPage(page)"
25 :class="{ active: currentPage === page }"
26 >
27 {{ page }}
28 </button>
29
30 <button @click="nextPage" :disabled="currentPage === totalPages">
31 Następna
32 </button>
33 </footer>
34 </div>
35</template>v-for="page in totalPages" iteruje po liczbie, więc daje kolejno 1, 2, 3 aż do totalPages. Na koniec skrypt, w którym importujesz composables, destrukturyzujesz zwrócone API i przekazujesz wynik jednego jako parametr następnego:
1<script setup>
2import { onMounted } from 'vue'
3import { useExperiments } from './composables/useExperiments'
4import { useLabFilters } from './composables/useLabFilters'
5import { usePagination } from './composables/usePagination'
6
7// Composables
8const {
9 experiments,
10 loading,
11 error,
12 categoriesCount,
13 fetchExperiments,
14 deleteExperiment
15} = useExperiments()
16
17const {
18 searchQuery,
19 selectedCategory,
20 sortBy,
21 sortOrder,
22 filteredExperiments,
23 uniqueCategories,
24 resetFilters
25} = useLabFilters(experiments)
26
27const {
28 currentPage,
29 totalPages,
30 paginatedItems,
31 goToPage,
32 nextPage,
33 prevPage
34} = usePagination(filteredExperiments, 12)
35
36// Lifecycle
37onMounted(() => {
38 fetchExperiments()
39})
40</script>Kolejność wywołań odzwierciedla przepływ danych: experiments trafia do filtrów, filteredExperiments do paginacji, a dane pobieramy w onMounted. Komponent nie zawiera żadnej logiki biznesowej - tylko ją skleja.
Moja rada: pilnuj zasady jednego zadania na composable. Dzięki niej usePagination użyjesz w dowolnej innej liście stacji, a każdy composable przetestujesz osobno, jak w lekcji o testowaniu. W edytorze poniżej czeka pokrewny przykład integracji modułów: useTimer, useModuleStatus i useNotifications łączą się w jeden panel według tej samej zasady.
Zapamiętaj: Lab Manager to stacja zbudowana z modułów - każdy composable robi jedną rzecz, a komponent tylko je ze sobą łączy.
Kod do tej lekcji: App.vue
1<script setup>
2import { ref, computed, watch, onMounted } from 'vue'
3
4// Dobra praktyka 1: jedna odpowiedzialność
5function useTimer(initialSeconds = 0) {
6 const seconds = ref(initialSeconds)
7 const isRunning = ref(false)
8 let intervalId = null
9
10 const formattedTime = computed(() => {
11 const mins = Math.floor(seconds.value / 60)
12 const secs = seconds.value % 60
13 return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
14 })
15
16 function start() {
17 if (isRunning.value) return
18 isRunning.value = true
19 intervalId = setInterval(() => {
20 seconds.value++
21 }, 1000)
22 }
23
24 function pause() {
25 isRunning.value = false
26 if (intervalId) {
27 clearInterval(intervalId)
28 intervalId = null
29 }
30 }
31
32 function reset() {
33 pause()
34 seconds.value = initialSeconds
35 }
36
37 return {
38 seconds,
39 formattedTime,
40 isRunning,
41 start,
42 pause,
43 reset
44 }
45}
46
47// Dobra praktyka 2: kompozycja composables
48function useModuleStatus(moduleName) {
49 const isOnline = ref(true)
50 const temperature = ref(22)
51 const powerConsumption = ref(45)
52
53 const status = computed(() => {
54 if (!isOnline.value) return { level: 'offline', color: '#666666' }
55 if (temperature.value > 80) return { level: 'krytyczny', color: '#ff0055' }
56 if (powerConsumption.value > 80) return { level: 'ostrzeżenie', color: '#ffb400' }
57 return { level: 'optymalny', color: '#00ff88' }
58 })
59
60 // Używamy composable timera
61 const uptime = useTimer()
62
63 // Licznik czasu pracy startuje sam, gdy moduł jest online
64 watch(isOnline, (newValue) => {
65 if (newValue) {
66 uptime.start()
67 } else {
68 uptime.pause()
69 }
70 }, { immediate: true })
71
72 function toggleOnline() {
73 isOnline.value = !isOnline.value
74 }
75
76 function adjustTemperature(delta) {
77 temperature.value = Math.max(0, Math.min(100, temperature.value + delta))
78 }
79
80 function adjustPower(delta) {
81 powerConsumption.value = Math.max(0, Math.min(100, powerConsumption.value + delta))
82 }
83
84 return {
85 moduleName: ref(moduleName),
86 isOnline,
87 temperature,
88 powerConsumption,
89 status,
90 uptime,
91 toggleOnline,
92 adjustTemperature,
93 adjustPower
94 }
95}
96
97// Dobra praktyka 3: opcje z wartościami domyślnymi
98function useNotifications(options = {}) {
99 const {
100 maxNotifications = 10,
101 autoRemove = true,
102 autoRemoveDelay = 5000
103 } = options
104
105 const notifications = ref([])
106 let notificationId = 0
107
108 function add(message, type = 'info') {
109 notificationId++
110 const notification = {
111 id: notificationId,
112 message,
113 type,
114 timestamp: new Date()
115 }
116
117 notifications.value.unshift(notification)
118
119 if (notifications.value.length > maxNotifications) {
120 notifications.value.pop()
121 }
122
123 if (autoRemove) {
124 setTimeout(() => {
125 remove(notification.id)
126 }, autoRemoveDelay)
127 }
128
129 return notification
130 }
131
132 function remove(id) {
133 const index = notifications.value.findIndex(n => n.id === id)
134 if (index !== -1) {
135 notifications.value.splice(index, 1)
136 }
137 }
138
139 function clear() {
140 notifications.value = []
141 }
142
143 return {
144 notifications,
145 add,
146 remove,
147 clear
148 }
149}
150
151// Inicjalizacja modułów
152const habitatModule = useModuleStatus('Moduł mieszkalny')
153const researchModule = useModuleStatus('Moduł badawczy')
154const powerModule = useModuleStatus('Moduł energetyczny')
155
156// Inicjalizacja powiadomień
157const notify = useNotifications({
158 maxNotifications: 5,
159 autoRemove: true,
160 autoRemoveDelay: 4000
161})
162
163// Symulacja zdarzeń w modułach
164function simulateHeatWave(module) {
165 module.adjustTemperature(30)
166 notify.add(`Wykryto falę ciepła: ${module.moduleName.value}`, 'warning')
167}
168
169function simulateCooling(module) {
170 module.adjustTemperature(-20)
171 notify.add(`Włączono chłodzenie: ${module.moduleName.value}`, 'info')
172}
173
174function simulatePowerSpike(module) {
175 module.adjustPower(40)
176 notify.add(`Skok mocy: ${module.moduleName.value}`, 'warning')
177}
178
179onMounted(() => {
180 notify.add('System integracji modułów NOVA LAB działa', 'success')
181})
182</script>
183
184<template>
185 <div class="integration-lab">
186 <div class="header">
187 <h1>Integracja Modułów - NOVA LAB</h1>
188 <p class="subtitle">Moduł 6: dobre praktyki i wzorce composables</p>
189 </div>
190
191 <!-- Panel powiadomień -->
192 <div class="notifications-panel">
193 <div class="notifications-header">
194 <h3>Powiadomienia systemu</h3>
195 <button @click="notify.clear()" class="btn btn-sm btn-danger">Wyczyść wszystko</button>
196 </div>
197 <div class="notifications-list">
198 <div
199 v-for="notification in notify.notifications.value"
200 :key="notification.id"
201 class="notification"
202 :class="'notification-' + notification.type"
203 >
204 <span class="notification-type">{{ notification.type.toUpperCase() }}</span>
205 <span class="notification-message">{{ notification.message }}</span>
206 <button @click="notify.remove(notification.id)" class="btn-close">×</button>
207 </div>
208 </div>
209 </div>
210
211 <!-- Siatka modułów -->
212 <div class="modules-grid">
213 <!-- Moduł mieszkalny -->
214 <div class="module-card" :style="{ borderColor: habitatModule.status.value.color }">
215 <div class="module-header">
216 <h3>{{ habitatModule.moduleName.value }}</h3>
217 <div
218 class="status-badge"
219 :style="{ background: habitatModule.status.value.color }"
220 >
221 {{ habitatModule.status.value.level.toUpperCase() }}
222 </div>
223 </div>
224
225 <div class="module-stats">
226 <div class="stat">
227 <span class="stat-label">Temperatura:</span>
228 <span class="stat-value">{{ habitatModule.temperature.value }}°C</span>
229 </div>
230 <div class="stat">
231 <span class="stat-label">Moc:</span>
232 <span class="stat-value">{{ habitatModule.powerConsumption.value }}%</span>
233 </div>
234 <div class="stat">
235 <span class="stat-label">Czas pracy:</span>
236 <span class="stat-value">{{ habitatModule.uptime.formattedTime.value }}</span>
237 </div>
238 </div>
239
240 <div class="module-controls">
241 <button
242 @click="habitatModule.toggleOnline()"
243 class="btn"
244 :class="habitatModule.isOnline.value ? 'btn-warning' : 'btn-success'"
245 >
246 {{ habitatModule.isOnline.value ? 'Wyłącz' : 'Włącz' }}
247 </button>
248 </div>
249
250 <div class="module-simulations">
251 <button @click="simulateHeatWave(habitatModule)" class="btn btn-sm">Fala ciepła</button>
252 <button @click="simulateCooling(habitatModule)" class="btn btn-sm">Chłodzenie</button>
253 <button @click="simulatePowerSpike(habitatModule)" class="btn btn-sm">Skok mocy</button>
254 </div>
255 </div>
256
257 <!-- Moduł badawczy -->
258 <div class="module-card" :style="{ borderColor: researchModule.status.value.color }">
259 <div class="module-header">
260 <h3>{{ researchModule.moduleName.value }}</h3>
261 <div
262 class="status-badge"
263 :style="{ background: researchModule.status.value.color }"
264 >
265 {{ researchModule.status.value.level.toUpperCase() }}
266 </div>
267 </div>
268
269 <div class="module-stats">
270 <div class="stat">
271 <span class="stat-label">Temperatura:</span>
272 <span class="stat-value">{{ researchModule.temperature.value }}°C</span>
273 </div>
274 <div class="stat">
275 <span class="stat-label">Moc:</span>
276 <span class="stat-value">{{ researchModule.powerConsumption.value }}%</span>
277 </div>
278 <div class="stat">
279 <span class="stat-label">Czas pracy:</span>
280 <span class="stat-value">{{ researchModule.uptime.formattedTime.value }}</span>
281 </div>
282 </div>
283
284 <div class="module-controls">
285 <button
286 @click="researchModule.toggleOnline()"
287 class="btn"
288 :class="researchModule.isOnline.value ? 'btn-warning' : 'btn-success'"
289 >
290 {{ researchModule.isOnline.value ? 'Wyłącz' : 'Włącz' }}
291 </button>
292 </div>
293
294 <div class="module-simulations">
295 <button @click="simulateHeatWave(researchModule)" class="btn btn-sm">Fala ciepła</button>
296 <button @click="simulateCooling(researchModule)" class="btn btn-sm">Chłodzenie</button>
297 <button @click="simulatePowerSpike(researchModule)" class="btn btn-sm">Skok mocy</button>
298 </div>
299 </div>
300
301 <!-- Moduł energetyczny -->
302 <div class="module-card" :style="{ borderColor: powerModule.status.value.color }">
303 <div class="module-header">
304 <h3>{{ powerModule.moduleName.value }}</h3>
305 <div
306 class="status-badge"
307 :style="{ background: powerModule.status.value.color }"
308 >
309 {{ powerModule.status.value.level.toUpperCase() }}
310 </div>
311 </div>
312
313 <div class="module-stats">
314 <div class="stat">
315 <span class="stat-label">Temperatura:</span>
316 <span class="stat-value">{{ powerModule.temperature.value }}°C</span>
317 </div>
318 <div class="stat">
319 <span class="stat-label">Moc:</span>
320 <span class="stat-value">{{ powerModule.powerConsumption.value }}%</span>
321 </div>
322 <div class="stat">
323 <span class="stat-label">Czas pracy:</span>
324 <span class="stat-value">{{ powerModule.uptime.formattedTime.value }}</span>
325 </div>
326 </div>
327
328 <div class="module-controls">
329 <button
330 @click="powerModule.toggleOnline()"
331 class="btn"
332 :class="powerModule.isOnline.value ? 'btn-warning' : 'btn-success'"
333 >
334 {{ powerModule.isOnline.value ? 'Wyłącz' : 'Włącz' }}
335 </button>
336 </div>
337
338 <div class="module-simulations">
339 <button @click="simulateHeatWave(powerModule)" class="btn btn-sm">Fala ciepła</button>
340 <button @click="simulateCooling(powerModule)" class="btn btn-sm">Chłodzenie</button>
341 <button @click="simulatePowerSpike(powerModule)" class="btn btn-sm">Skok mocy</button>
342 </div>
343 </div>
344 </div>
345
346 <div class="info-panel">
347 <h3>Dobre praktyki dla composables</h3>
348 <div class="info-grid">
349 <div class="info-section">
350 <h4>1. Jedna odpowiedzialność</h4>
351 <ul>
352 <li>Każdy composable ma jedną odpowiedzialność</li>
353 <li>useTimer - tylko zarządzanie timerem</li>
354 <li>Łatwy w testowaniu i utrzymaniu</li>
355 </ul>
356 </div>
357
358 <div class="info-section">
359 <h4>2. Kompozycja composables</h4>
360 <ul>
361 <li>Łączenie composables dla złożonej logiki</li>
362 <li>useModuleStatus używa useTimer</li>
363 <li>Reużywalność i modularność</li>
364 </ul>
365 </div>
366
367 <div class="info-section">
368 <h4>3. Wzorzec opcji</h4>
369 <ul>
370 <li>Przyjmowanie opcji z wartościami domyślnymi</li>
371 <li>useNotifications({ maxNotifications: 5 })</li>
372 <li>Elastyczność i łatwe dostosowanie</li>
373 </ul>
374 </div>
375
376 <div class="info-section">
377 <h4>4. Konwencja nazewnictwa</h4>
378 <ul>
379 <li>Zawsze przedrostek "use"</li>
380 <li>Opisowa nazwa: useModuleStatus</li>
381 <li>Łatwe do zidentyfikowania</li>
382 </ul>
383 </div>
384 </div>
385
386 <div class="code-example">
387 <code>
388// Dobrze: jedna odpowiedzialność
389function useTimer() { ... }
390
391// Dobrze: kompozycja
392function useModule() {
393 const timer = useTimer()
394 return { timer, ... }
395}
396
397// Dobrze: opcje z wartościami domyślnymi
398function useNotify(options = {}) {
399 const { maxItems = 10 } = options
400 ...
401}
402 </code>
403 </div>
404 </div>
405 </div>
406</template>
407
408<style scoped>
409.integration-lab {
410 background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
411 min-height: 100vh;
412 padding: 2rem;
413 color: #ffffff;
414 font-family: 'Courier New', monospace;
415}
416
417.header {
418 text-align: center;
419 margin-bottom: 2rem;
420 padding-bottom: 1rem;
421 border-bottom: 2px solid #00ff88;
422}
423
424.header h1 {
425 font-size: 2.5rem;
426 color: #00ff88;
427 text-shadow: 0 0 20px rgba(0, 255, 136, 0.5);
428 margin: 0;
429}
430
431.subtitle {
432 color: #00b4d8;
433 font-size: 1.2rem;
434 margin-top: 0.5rem;
435}
436
437.notifications-panel {
438 background: rgba(0, 180, 216, 0.05);
439 border: 2px solid #00b4d8;
440 border-radius: 10px;
441 padding: 1.5rem;
442 margin-bottom: 2rem;
443}
444
445.notifications-header {
446 display: flex;
447 justify-content: space-between;
448 align-items: center;
449 margin-bottom: 1rem;
450}
451
452.notifications-header h3 {
453 color: #00b4d8;
454 margin: 0;
455}
456
457.notifications-list {
458 display: flex;
459 flex-direction: column;
460 gap: 0.5rem;
461 max-height: 300px;
462 overflow-y: auto;
463}
464
465.notification {
466 display: flex;
467 align-items: center;
468 gap: 1rem;
469 padding: 1rem;
470 border-radius: 5px;
471 border-left: 4px solid;
472 animation: slideIn 0.3s ease;
473}
474
475@keyframes slideIn {
476 from {
477 transform: translateX(-20px);
478 opacity: 0;
479 }
480 to {
481 transform: translateX(0);
482 opacity: 1;
483 }
484}
485
486.notification-success {
487 background: rgba(0, 255, 136, 0.1);
488 border-color: #00ff88;
489}
490
491.notification-info {
492 background: rgba(0, 180, 216, 0.1);
493 border-color: #00b4d8;
494}
495
496.notification-warning {
497 background: rgba(255, 180, 0, 0.1);
498 border-color: #ffb400;
499}
500
501.notification-type {
502 color: #00b4d8;
503 font-size: 0.8rem;
504 font-weight: bold;
505 min-width: 80px;
506}
507
508.notification-message {
509 flex: 1;
510 color: #ffffff;
511}
512
513.btn-close {
514 background: rgba(255, 0, 85, 0.3);
515 color: #ff0055;
516 border: none;
517 width: 30px;
518 height: 30px;
519 border-radius: 50%;
520 font-size: 1.5rem;
521 cursor: pointer;
522 display: flex;
523 align-items: center;
524 justify-content: center;
525 transition: all 0.3s ease;
526}
527
528.btn-close:hover {
529 background: #ff0055;
530 color: #ffffff;
531}
532
533.modules-grid {
534 display: grid;
535 grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
536 gap: 2rem;
537 margin-bottom: 2rem;
538}
539
540.module-card {
541 background: rgba(0, 255, 136, 0.05);
542 border: 3px solid;
543 border-radius: 10px;
544 padding: 1.5rem;
545 transition: all 0.3s ease;
546}
547
548.module-header {
549 display: flex;
550 justify-content: space-between;
551 align-items: center;
552 margin-bottom: 1.5rem;
553}
554
555.module-header h3 {
556 color: #00ff88;
557 margin: 0;
558}
559
560.status-badge {
561 padding: 0.5rem 1rem;
562 border-radius: 20px;
563 font-size: 0.8rem;
564 font-weight: bold;
565 color: #0a0e27;
566}
567
568.module-stats {
569 background: rgba(0, 0, 0, 0.3);
570 border-radius: 8px;
571 padding: 1rem;
572 margin-bottom: 1rem;
573}
574
575.stat {
576 display: flex;
577 justify-content: space-between;
578 padding: 0.5rem 0;
579 border-bottom: 1px solid rgba(255, 255, 255, 0.1);
580}
581
582.stat:last-child {
583 border-bottom: none;
584}
585
586.stat-label {
587 color: #00b4d8;
588}
589
590.stat-value {
591 color: #00ff88;
592 font-weight: bold;
593}
594
595.module-controls {
596 margin: 1rem 0;
597}
598
599.module-simulations {
600 display: flex;
601 gap: 0.5rem;
602 flex-wrap: wrap;
603}
604
605.btn {
606 padding: 0.75rem 1.5rem;
607 border: none;
608 border-radius: 5px;
609 font-weight: bold;
610 cursor: pointer;
611 font-family: 'Courier New', monospace;
612 transition: all 0.3s ease;
613 width: 100%;
614}
615
616.btn-sm {
617 padding: 0.5rem 1rem;
618 font-size: 0.85rem;
619 width: auto;
620 flex: 1;
621 background: rgba(0, 180, 216, 0.3);
622 color: #00b4d8;
623}
624
625.btn-success {
626 background: #00ff88;
627 color: #0a0e27;
628}
629
630.btn-warning {
631 background: #ffb400;
632 color: #0a0e27;
633}
634
635.btn-danger {
636 background: #ff0055;
637 color: #ffffff;
638}
639
640.btn:hover {
641 transform: scale(1.05);
642 box-shadow: 0 5px 15px rgba(0, 255, 136, 0.3);
643}
644
645.info-panel {
646 background: rgba(0, 255, 136, 0.05);
647 border: 2px solid #00ff88;
648 border-radius: 10px;
649 padding: 2rem;
650}
651
652.info-panel h3 {
653 color: #00ff88;
654 margin-top: 0;
655 margin-bottom: 1.5rem;
656 text-align: center;
657}
658
659.info-grid {
660 display: grid;
661 grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
662 gap: 1.5rem;
663 margin-bottom: 2rem;
664}
665
666.info-section {
667 background: rgba(0, 180, 216, 0.1);
668 border: 2px solid #00b4d8;
669 border-radius: 8px;
670 padding: 1.5rem;
671}
672
673.info-section h4 {
674 color: #00b4d8;
675 margin-top: 0;
676 margin-bottom: 1rem;
677}
678
679.info-section ul {
680 list-style: none;
681 padding: 0;
682 margin: 0;
683}
684
685.info-section li {
686 padding: 0.5rem 0;
687 color: #ffffff;
688 font-size: 0.9rem;
689 line-height: 1.6;
690}
691
692.code-example {
693 background: rgba(0, 0, 0, 0.5);
694 border-left: 4px solid #00ff88;
695 border-radius: 5px;
696 padding: 1.5rem;
697}
698
699.code-example code {
700 color: #00ff88;
701 font-size: 0.9rem;
702 white-space: pre;
703 display: block;
704}
705</style>Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Co powinien zwracać composable useExperiments?
2. Do czego używamy computed() w composables?
To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Edytor kodu
Zbuduj useExperiments z fetchExperiments, addExperiment, deleteExperiment i computed properties.
- Układanie w pionie
Ułóż workflow pracy z composables od stworzenia do użycia:
- Klikanie w kolejności
Ułóż pattern composable zarządzającego listą danych:
- Układanie w poziomie
Ułóż composable przyjmujący parametr:
- Edytor kodu
Zbuduj useLabFilters z search, category, sort i computed filteredItems.
- Układanie w pionie
Ułóż kroki integracji wielu composables w jednym komponencie:
- Układanie w poziomie
Ułóż computed property w composable:
- Klikanie w kolejności
Ułóż pełny cykl życia composable w aplikacji:
- Układanie w pionie
Ułóż architekturę Lab Manager od danych do widoku:
- Klikanie w kolejności
Ułóż script setup integrujący composables:
- Układanie w poziomie
Ułóż ścieżkę importu composable:
- Układanie w pionie
Ułóż kroki inicjalizacji composable który pobiera dane z serwera: