Cours Vue.js · Module 9: Composables et VueUse
Projet : Lab Manager avec composables
Dans cette leçon4
Un système complet de gestion du laboratoire NOVA LAB qui utilise toutes les techniques apprises ! Dr. Nova a besoin d'un panneau qui récupère la liste des expériences depuis le serveur, permet de la rechercher, de la filtrer et de la trier, découpe les résultats en pages et permet de supprimer des entrées. Si tu écrivais tout cela dans un seul composant, tu obtiendrais plusieurs centaines de lignes où la récupération des données se mélange à la pagination. À la place, nous allons construire trois composables, chacun avec une seule tâche, et un composant qui les relie. Les données suivent un seul chemin : useExperiments() récupère et stocke les expériences, useLabFilters(experiments) les filtre, usePagination(filtered) découpe le résultat en pages, et le template affiche paginatedItems.
La couche de données : useExperiments
Le premier fichier, composables/useExperiments.js, s'occupe des données. Il retourne l'état, les indicateurs loading et error ainsi que les fonctions CRUD - de l'anglais Create, Read, Update, Delete, c'est-à-dire créer, lire, mettre à jour et supprimer. Les valeurs dérivées, comme la liste des expériences critiques et le nombre de catégories, sont calculées par 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('Erreur de récupération')
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}Nous avons la lecture, la création et la suppression ; tu ajouteras la mise à jour pour aller plus loin. Il vaut aussi la peine d'ajouter tout de suite deux choses vues dans les leçons précédentes : une vérification de response.ok dans addExperiment et un AbortController qui annule la récupération au démontage.
Le filtrage : useLabFilters
Le deuxième composable prend un paramètre - une ref avec la liste des expériences - et en construit une version filtrée et triée. Il ne sait rien du serveur, donc il fonctionne avec n'importe quelle liste dont les éléments ont les champs name et 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}Remarque le [...result].sort : la méthode sort modifie un tableau sur place, donc sans la copie nous trierions la liste d'origine de useExperiments. Les chaînes sont comparées avec localeCompare, et les nombres, comme l'année, par simple soustraction.
La pagination : usePagination
Le troisième composable découpe n'importe quelle liste en pages. Le nombre de pages est calculé par un computed avec Math.ceil, et la tranche courante par 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 ignore les numéros hors limites. Un piège : si tu es sur la page 3 et que tu restreins le filtre à une seule page de résultats, currentPage reste à 3 et la liste s'affiche vide. La correction tient en une ligne dans le composable, plus un import de watch : watch(items, () => { currentPage.value = 1 }).
Le composant LabManager
Il est temps de relier les modules dans le panneau. La première partie du template est l'en-tête avec les compteurs et le panneau de filtres, dans lequel v-model lie les champs du formulaire aux refs de useLabFilters :
1<!-- LabManager.vue -->
2<template>
3 <div class="lab-manager">
4 <!-- Header -->
5 <header>
6 <h1>NOVA LAB - Gestion des expériences</h1>
7 <p>{{ categoriesCount }} catégories, {{ experiments.length }} expériences</p>
8 </header>
9
10 <!-- Filters -->
11 <aside class="filters">
12 <input v-model="searchQuery" placeholder="Rechercher des expériences..." />
13
14 <select v-model="selectedCategory">
15 <option value="">Toutes les catégories</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">Nom</option>
23 <option value="category">Catégorie</option>
24 <option value="year">Année</option>
25 </select>
26
27 <button @click="sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'">
28 {{ sortOrder === 'asc' ? '↑' : '↓' }}
29 </button>
30
31 <button @click="resetFilters">Réinitialiser</button>
32 </aside>Chaque modification du champ de recherche recalcule immédiatement filteredExperiments, puis paginatedItems - sans une seule ligne de code de gestion d'événements. La deuxième partie affiche l'état de chargement, la grille de cartes et la pagination. On laisse de côté le composant ExperimentCard - il affiche la carte d'une expérience et émet un événement delete :
1 <!-- Loading/Error -->
2 <div v-if="loading">Chargement des expériences...</div>
3 <div v-else-if="error">Erreur : {{ 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 Précédent
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 Suivant
32 </button>
33 </footer>
34 </div>
35</template>v-for="page in totalPages" parcourt un nombre, donc il donne successivement 1, 2, 3 et ainsi de suite jusqu'à totalPages. Enfin, le script, dans lequel tu importes les composables, déstructures l'API retournée et passes le résultat de l'un comme paramètre du suivant :
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>L'ordre des appels reflète le flux des données : experiments va dans les filtres, filteredExperiments dans la pagination, et on récupère les données dans onMounted. Le composant ne contient aucune logique métier - il ne fait que l'assembler.
Mon conseil : respecte la règle d'une seule tâche par composable. Grâce à elle, tu pourras utiliser usePagination dans n'importe quelle autre liste de la station, et tester chaque composable séparément, comme dans la leçon sur les tests. Dans l'éditeur ci-dessous t'attend un exemple voisin d'intégration de modules : useTimer, useModuleStatus et useNotifications se combinent en un seul panneau selon la même règle.
Retiens : le Lab Manager est une station construite à partir de modules - chaque composable fait une seule chose, et le composant se contente de les relier.
Code de cette leçon : App.vue
1<script setup>
2import { ref, computed, watch, onMounted } from 'vue'
3
4// Bonne pratique 1 : responsabilité unique
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// Bonne pratique 2 : composition de 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: 'critical', color: '#ff0055' }
56 if (powerConsumption.value > 80) return { level: 'warning', color: '#ffb400' }
57 return { level: 'optimal', color: '#00ff88' }
58 })
59
60 // Utilise le composable timer
61 const uptime = useTimer()
62
63 // Démarre automatiquement le timer d'uptime quand en ligne
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// Bonne pratique 3 : options avec valeurs par défaut
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// Initialise les modules
152const habitatModule = useModuleStatus('Habitat Module')
153const researchModule = useModuleStatus('Research Module')
154const powerModule = useModuleStatus('Power Module')
155
156// Initialise les notifications
157const notify = useNotifications({
158 maxNotifications: 5,
159 autoRemove: true,
160 autoRemoveDelay: 4000
161})
162
163// Simulation des modules
164function simulateHeatWave(module) {
165 module.adjustTemperature(30)
166 notify.add(`Heat wave detected in ${module.moduleName.value}`, 'warning')
167}
168
169function simulateCooling(module) {
170 module.adjustTemperature(-20)
171 notify.add(`Cooling system activated in ${module.moduleName.value}`, 'info')
172}
173
174function simulatePowerSpike(module) {
175 module.adjustPower(40)
176 notify.add(`Power spike in ${module.moduleName.value}`, 'warning')
177}
178
179onMounted(() => {
180 notify.add('NOVA LAB Module Integration System Online', 'success')
181})
182</script>
183
184<template>
185 <div class="integration-lab">
186 <div class="header">
187 <h1>Intégration des modules - NOVA LAB</h1>
188 <p class="subtitle">Module 6 : bonnes pratiques & modèles de composables</p>
189 </div>
190
191 <!-- Panneau de notifications -->
192 <div class="notifications-panel">
193 <div class="notifications-header">
194 <h3>Notifications système</h3>
195 <button @click="notify.clear()" class="btn btn-sm btn-danger">Tout effacer</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 <!-- Modules Grid -->
212 <div class="modules-grid">
213 <!-- Module Habitat -->
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">Température :</span>
228 <span class="stat-value">{{ habitatModule.temperature.value }}°C</span>
229 </div>
230 <div class="stat">
231 <span class="stat-label">Énergie :</span>
232 <span class="stat-value">{{ habitatModule.powerConsumption.value }}%</span>
233 </div>
234 <div class="stat">
235 <span class="stat-label">Disponibilité :</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 ? 'Éteindre' : 'Allumer' }}
247 </button>
248 </div>
249
250 <div class="module-simulations">
251 <button @click="simulateHeatWave(habitatModule)" class="btn btn-sm">Vague de chaleur</button>
252 <button @click="simulateCooling(habitatModule)" class="btn btn-sm">Refroidissement</button>
253 <button @click="simulatePowerSpike(habitatModule)" class="btn btn-sm">Pic d'énergie</button>
254 </div>
255 </div>
256
257 <!-- Module Recherche -->
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">Température :</span>
272 <span class="stat-value">{{ researchModule.temperature.value }}°C</span>
273 </div>
274 <div class="stat">
275 <span class="stat-label">Énergie :</span>
276 <span class="stat-value">{{ researchModule.powerConsumption.value }}%</span>
277 </div>
278 <div class="stat">
279 <span class="stat-label">Disponibilité :</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 ? 'Éteindre' : 'Allumer' }}
291 </button>
292 </div>
293
294 <div class="module-simulations">
295 <button @click="simulateHeatWave(researchModule)" class="btn btn-sm">Vague de chaleur</button>
296 <button @click="simulateCooling(researchModule)" class="btn btn-sm">Refroidissement</button>
297 <button @click="simulatePowerSpike(researchModule)" class="btn btn-sm">Pic d'énergie</button>
298 </div>
299 </div>
300
301 <!-- Module Énergie -->
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">Température :</span>
316 <span class="stat-value">{{ powerModule.temperature.value }}°C</span>
317 </div>
318 <div class="stat">
319 <span class="stat-label">Énergie :</span>
320 <span class="stat-value">{{ powerModule.powerConsumption.value }}%</span>
321 </div>
322 <div class="stat">
323 <span class="stat-label">Disponibilité :</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 ? 'Éteindre' : 'Allumer' }}
335 </button>
336 </div>
337
338 <div class="module-simulations">
339 <button @click="simulateHeatWave(powerModule)" class="btn btn-sm">Vague de chaleur</button>
340 <button @click="simulateCooling(powerModule)" class="btn btn-sm">Refroidissement</button>
341 <button @click="simulatePowerSpike(powerModule)" class="btn btn-sm">Pic d'énergie</button>
342 </div>
343 </div>
344 </div>
345
346 <div class="info-panel">
347 <h3>Bonnes pratiques pour les composables</h3>
348 <div class="info-grid">
349 <div class="info-section">
350 <h4>1. Responsabilité unique</h4>
351 <ul>
352 <li>Chaque composable a une responsabilité unique</li>
353 <li>useTimer - uniquement la gestion du timer</li>
354 <li>Facile à tester et à maintenir</li>
355 </ul>
356 </div>
357
358 <div class="info-section">
359 <h4>2. Composition de composables</h4>
360 <ul>
361 <li>Combiner des composables pour une logique complexe</li>
362 <li>useModuleStatus utilise useTimer</li>
363 <li>Réutilisabilité et modularité</li>
364 </ul>
365 </div>
366
367 <div class="info-section">
368 <h4>3. Modèle des options</h4>
369 <ul>
370 <li>Accepter des options avec des valeurs par défaut</li>
371 <li>useNotifications({ maxNotifications: 5 })</li>
372 <li>Flexibilité et personnalisation</li>
373 </ul>
374 </div>
375
376 <div class="info-section">
377 <h4>4. Convention de nommage</h4>
378 <ul>
379 <li>Toujours le préfixe « use »</li>
380 <li>Nom descriptif : useModuleStatus</li>
381 <li>Facile à identifier</li>
382 </ul>
383 </div>
384 </div>
385
386 <div class="code-example">
387 <code>
388// Bon : responsabilité unique
389function useTimer() { ... }
390
391// Bon : composition
392function useModule() {
393 const timer = useTimer()
394 return { timer, ... }
395}
396
397// Bon : options avec valeurs par défaut
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>Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Que doit retourner le composable useExperiments ?
2. À quoi utilise-t-on computed() dans les composables ?
Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Éditeur de code
Construis useExperiments avec fetchExperiments, addExperiment, deleteExperiment et des computed.
- Tri vertical
Ordonne le flux de travail des composables, de la création à l'utilisation :
- Clique dans l'ordre
Ordonne le modèle d'un composable qui gère une liste de données :
- Tri horizontal
Ordonne un composable qui accepte un paramètre :
- Éditeur de code
Construis useLabFilters avec search, category, sort et un computed filteredItems.
- Tri vertical
Ordonne les étapes pour intégrer plusieurs composables dans un seul composant :
- Tri horizontal
Ordonne une propriété computed dans un composable :
- Clique dans l'ordre
Ordonne le cycle de vie complet d'un composable dans une application :
- Tri vertical
Ordonne l'architecture de Lab Manager, des données à la vue :
- Clique dans l'ordre
Ordonne un script setup qui intègre des composables :
- Tri horizontal
Ordonne le chemin d'import d'un composable :
- Tri vertical
Ordonne les étapes d'initialisation d'un composable qui récupère des données depuis un serveur :