Kurs Vue.js · Moduł 9: Composables i VueUse

Projekt: Lab Manager z Composables

6 min czytania
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. 1. Co powinien zwracać composable useExperiments?

  2. 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:

Przydatne artykuły