Kurs Vue.js · Moduł 14: Dyrektywy i pluginy
Projekt: Dashboard API z dyrektywami i pluginami
W tej lekcji3
To jest główny projekt modułu! Połączymy wszystko, czego się nauczyliśmy: custom directives, plugins i komunikację HTTP/API w jeden spójny system - Dashboard Centrum Dowodzenia NOVA LAB.
Architektura projektu
Nasz dashboard zawiera:
- Toast Plugin - powiadomienia o zdarzeniach stacji
- Custom Directives - v-focus, v-tooltip, v-highlight
- Composable useFetch - pobieranie danych z API
- Stany loading/error/data - obsługa błędów
- Akcje CRUD - dodawanie, usuwanie danych
Struktura kodu
Plugin Toast:
1const ToastPlugin = {
2 install(app) {
3 const toasts = ref([])
4 let id = 0
5
6 app.provide('toast', {
7 show(message, type = 'info') {
8 const toastId = id++
9 toasts.value.push({ id: toastId, message, type })
10 setTimeout(() => {
11 toasts.value = toasts.value.filter(t => t.id !== toastId)
12 }, 3000)
13 },
14 success(msg) { this.show(msg, 'success') },
15 error(msg) { this.show(msg, 'error') },
16 list: toasts
17 })
18 }
19}Custom Directives:
1app.directive('focus', {
2 mounted(el) { el.focus() }
3})
4
5app.directive('highlight', (el, binding) => {
6 el.style.border = `2px solid ${binding.value || '#00ff88'}`
7})Composable:
1function useFetch(url) {
2 const data = ref(null)
3 const loading = ref(false)
4 const error = ref(null)
5
6 async function execute() {
7 loading.value = true
8 error.value = null
9 try {
10 const res = await fetch(url)
11 data.value = await res.json()
12 } catch (err) {
13 error.value = err.message
14 } finally {
15 loading.value = false
16 }
17 }
18
19 execute()
20 return { data, loading, error, refetch: execute }
21}Łączenie w dashboard
Dashboard wyświetla:
- Liste misji pobraną z API
- Formularz dodawania nowych misji
- Toast notifications przy akcjach
- Dyrektywy do interaktywności (focus, highlight)
Ten projekt demonstruje, jak różne części Vue (dyrektywy, pluginy, composables) współpracują w realnej aplikacji.
Kod do tej lekcji: App.vue
1<script setup>
2import { ref, inject } from 'vue'
3import { useFetch } from './composables/useFetch.js'
4
5const toast = inject('toast')
6
7const vFocus = {
8 mounted(el) { el.focus() }
9}
10
11const vHighlight = (el, binding) => {
12 el.style.border = '2px solid ' + (binding.value || '#00ff88')
13 el.style.borderRadius = '8px'
14}
15
16const { data: missions, loading, error, refetch } = useFetch(
17 'https://jsonplaceholder.typicode.com/posts?_limit=8'
18)
19
20const newMission = ref('')
21const sending = ref(false)
22
23async function addMission() {
24 if (!newMission.value.trim()) return
25 sending.value = true
26
27 try {
28 const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
29 method: 'POST',
30 headers: { 'Content-Type': 'application/json' },
31 body: JSON.stringify({
32 title: newMission.value,
33 body: 'Misja stacji NOVA LAB',
34 userId: 1
35 })
36 })
37 const created = await response.json()
38 missions.value.unshift({ ...created, id: Date.now() })
39 newMission.value = ''
40 toast.success('Misja dodana pomyslnie!')
41 } catch (err) {
42 toast.error('Blad dodawania misji: ' + err.message)
43 } finally {
44 sending.value = false
45 }
46}
47
48function removeMission(id) {
49 missions.value = missions.value.filter(m => m.id !== id)
50 toast.warning('Misja usunieta')
51}
52</script>
53
54<template>
55 <div class="nova-lab">
56 <h1>Dashboard - Centrum Dowodzenia NOVA LAB</h1>
57
58 <section v-highlight="'#00b4d8'" class="section">
59 <h2>Nowa misja</h2>
60 <form @submit.prevent="addMission" class="form">
61 <input
62 v-focus
63 v-model="newMission"
64 placeholder="Nazwa misji..."
65 class="input"
66 :disabled="sending"
67 />
68 <button type="submit" class="btn" :disabled="sending">
69 {{ sending ? '...' : 'Dodaj' }}
70 </button>
71 </form>
72 </section>
73
74 <section v-highlight class="section">
75 <div class="header-row">
76 <h2>Aktywne misje</h2>
77 <button @click="refetch" class="btn small">Odswiez</button>
78 </div>
79
80 <div v-if="loading" class="status loading">Ladowanie...</div>
81 <div v-else-if="error" class="status error">{{ error }}</div>
82 <div v-else>
83 <div v-for="m in missions" :key="m.id" class="mission">
84 <div>
85 <strong class="mission-id">#{{ m.id }}</strong>
86 <span class="mission-title">{{ m.title }}</span>
87 </div>
88 <button @click="removeMission(m.id)" class="btn del">X</button>
89 </div>
90 <p class="count">Razem: {{ missions.length }} misji</p>
91 </div>
92 </section>
93
94 <div class="toast-container">
95 <div
96 v-for="t in toast.list.value"
97 :key="t.id"
98 :class="['toast', t.type]"
99 >
100 {{ t.message }}
101 </div>
102 </div>
103 </div>
104</template>
105
106<style scoped>
107.nova-lab {
108 background: #0a0e27;
109 color: #c0c0c0;
110 min-height: 100vh;
111 padding: 20px;
112 font-family: monospace;
113}
114h1 { color: #00ff88; margin-bottom: 20px; font-size: 18px; }
115h2 { color: #00b4d8; font-size: 16px; margin: 0; }
116.section { padding: 15px; margin-bottom: 15px; }
117.form { display: flex; gap: 10px; margin-top: 10px; }
118.input {
119 flex: 1;
120 background: rgba(0,255,136,0.05);
121 border: 1px solid #00ff88;
122 color: #00ff88;
123 padding: 10px;
124 border-radius: 6px;
125 font-family: monospace;
126}
127.input:focus { outline: none; box-shadow: 0 0 8px rgba(0,255,136,0.3); }
128.btn {
129 background: rgba(0,255,136,0.15);
130 color: #00ff88;
131 border: 1px solid #00ff88;
132 padding: 8px 16px;
133 border-radius: 6px;
134 cursor: pointer;
135 font-family: monospace;
136}
137.btn.small { font-size: 12px; padding: 4px 10px; }
138.btn.del {
139 color: #e74c3c; border-color: #e74c3c;
140 background: rgba(231,76,60,0.15);
141 padding: 4px 10px;
142}
143.header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
144.status { padding: 15px; text-align: center; }
145.status.loading { color: #00b4d8; }
146.status.error { color: #e74c3c; }
147.mission {
148 display: flex; justify-content: space-between; align-items: center;
149 padding: 8px 0;
150 border-bottom: 1px solid rgba(0,180,216,0.1);
151}
152.mission:last-child { border-bottom: none; }
153.mission-id { color: #00b4d8; margin-right: 8px; }
154.mission-title { font-size: 13px; }
155.count { color: #666; font-size: 12px; margin-top: 10px; text-align: right; }
156.toast-container {
157 position: fixed; top: 20px; right: 20px;
158 display: flex; flex-direction: column; gap: 8px; z-index: 1000;
159}
160.toast {
161 padding: 10px 18px; border-radius: 8px;
162 font-family: monospace; font-size: 13px;
163 animation: slideIn 0.3s ease;
164}
165.toast.success { background: rgba(46,204,113,0.9); color: white; }
166.toast.error { background: rgba(231,76,60,0.9); color: white; }
167.toast.warning { background: rgba(243,156,18,0.9); color: white; }
168.toast.info { background: rgba(0,180,216,0.9); color: white; }
169@keyframes slideIn {
170 from { transform: translateX(100%); opacity: 0; }
171 to { transform: translateX(0); opacity: 1; }
172}
173</style>Widzisz błąd w tej lekcji?