Kurs Vue.js · Moduł 11: Pinia
Projekt: System Zasobów NOVA LAB
W tej lekcji3
Czas zastosować całą wiedzę o Pinia w jednym projekcie! Zbudujesz kompletny system zarządzania zasobami stacji NOVA LAB, który wykorzystuje:
- Store z state - przechowywanie danych o zasobach (tlen, woda, energia)
- Getters - wyliczanie statusów, alertów, statystyk
- Actions - operacje CRUD, zużycie zasobów, alarmy
- Kompozycja stores - resource store + alert store
- $subscribe - monitorowanie zmian
Architektura projektu
1stores/
2├── resources.js # Zasoby stacji (state, getters, actions)
3└── alerts.js # System alertów (subscribe do resources)Przepływ danych
1ResourceStore (state: oxygen, water, energy)
2 ├── getters → statusLevel, criticalResources, summary
3 ├── actions → consume(), refill(), emergencyProtocol()
4 └── $subscribe → AlertStore.checkLevels()
5
6AlertStore (state: alerts)
7 ├── getters → activeAlerts, criticalCount
8 └── actions → addAlert(), dismissAlert()Zadanie
Przeanalizuj działający system w edytorze. Zwróć uwagę na:
- ResourceStore - jak state, getters i actions współpracują
- AlertStore - jak jeden store reaguje na zmiany w drugim
- Komponent App - jak storeToRefs i actions są używane w template
- $subscribe - automatyczne tworzenie alertów przy niskim poziomie zasobów
Kluczowe wzorce:
- Composition API style z
ref()icomputed() - Asynchroniczne actions z try/catch/finally
- Store composition - resource store wywołuje alert store
storeToRefsdo reaktywnej destrukturyzacji$patchdo grupowej aktualizacji state
Kod do tej lekcji: App.vue
1<script setup>
2import { ref, computed, watch } from 'vue'
3
4// ===== ResourceStore =====
5const resources = ref([
6 { id: 'oxygen', name: 'Tlen', level: 85, max: 100, unit: '%', consumeRate: 2 },
7 { id: 'water', name: 'Woda', level: 1200, max: 1500, unit: 'L', consumeRate: 30 },
8 { id: 'energy', name: 'Energia', level: 72, max: 100, unit: '%', consumeRate: 5 }
9])
10
11// Gettery zasobów
12const totalResourceHealth = computed(() => {
13 const avg = resources.value.reduce((sum, r) => sum + (r.level / r.max * 100), 0)
14 return Math.round(avg / resources.value.length)
15})
16
17const criticalResources = computed(() =>
18 resources.value.filter(r => (r.level / r.max * 100) < 25)
19)
20
21const stationStatus = computed(() => {
22 if (criticalResources.value.length > 0) return 'CRITICAL'
23 if (totalResourceHealth.value < 50) return 'WARNING'
24 return 'NOMINAL'
25})
26
27// Actions zasobów
28function consumeResource(id) {
29 const r = resources.value.find(res => res.id === id)
30 if (r) {
31 r.level = Math.max(0, r.level - r.consumeRate)
32 }
33}
34
35function refillResource(id) {
36 const r = resources.value.find(res => res.id === id)
37 if (r) r.level = r.max
38}
39
40function consumeAll() {
41 resources.value.forEach(r => {
42 r.level = Math.max(0, r.level - r.consumeRate)
43 })
44}
45
46function refillAll() {
47 resources.value.forEach(r => r.level = r.max)
48}
49
50// ===== AlertStore =====
51const alerts = ref([])
52
53const activeAlerts = computed(() => alerts.value.filter(a => !a.dismissed))
54const criticalAlertCount = computed(() => alerts.value.filter(a => a.type === 'critical' && !a.dismissed).length)
55
56function addAlert(message, type = 'info') {
57 alerts.value.push({
58 id: Date.now() + Math.random(),
59 message,
60 type,
61 time: new Date().toLocaleTimeString(),
62 dismissed: false
63 })
64}
65
66function dismissAlert(id) {
67 const alert = alerts.value.find(a => a.id === id)
68 if (alert) alert.dismissed = true
69}
70
71function clearAlerts() {
72 alerts.value = []
73}
74
75// ===== Między stores: symulacja $subscribe =====
76watch(resources, () => {
77 resources.value.forEach(r => {
78 const pct = (r.level / r.max) * 100
79 if (pct < 10) {
80 const exists = alerts.value.some(a =>
81 a.message.includes(r.name) && a.type === 'critical' && !a.dismissed
82 )
83 if (!exists) addAlert(`${r.name} - STAN AWARYJNY: ${r.level}${r.unit}!`, 'critical')
84 } else if (pct < 25) {
85 const exists = alerts.value.some(a =>
86 a.message.includes(r.name) && a.type === 'warning' && !a.dismissed
87 )
88 if (!exists) addAlert(`${r.name} - niski poziom: ${r.level}${r.unit}`, 'warning')
89 }
90 })
91}, { deep: true })
92</script>
93
94<template>
95 <div class="nova-lab">
96 <h1>NOVA LAB - system zarządzania zasobami</h1>
97
98 <div class="status-bar" :class="stationStatus.toLowerCase()">
99 Status stacji: {{ stationStatus }} | Kondycja: {{ totalResourceHealth }}%
100 <span v-if="criticalAlertCount"> | Alarmy: {{ criticalAlertCount }}</span>
101 </div>
102
103 <div class="main-grid">
104 <div class="resources-section">
105 <h2>Zasoby</h2>
106 <div v-for="r in resources" :key="r.id" class="resource-card"
107 :class="{ critical: (r.level/r.max*100) < 25, warning: (r.level/r.max*100) < 50 }">
108 <div class="r-header">
109 <strong>{{ r.name }}</strong>
110 <span>{{ r.level }}/{{ r.max }} {{ r.unit }}</span>
111 </div>
112 <div class="r-bar">
113 <div class="r-fill" :style="{ width: (r.level/r.max*100) + '%' }"></div>
114 </div>
115 <div class="r-btns">
116 <button @click="consumeResource(r.id)" class="btn-sm">
117 Zużyj (-{{ r.consumeRate }})
118 </button>
119 <button @click="refillResource(r.id)" class="btn-sm btn-green">Uzupełnij</button>
120 </div>
121 </div>
122 <div class="global-btns">
123 <button @click="consumeAll" class="btn">Zużyj wszystko</button>
124 <button @click="refillAll" class="btn btn-green">Uzupełnij wszystko</button>
125 </div>
126 </div>
127
128 <div class="alerts-section">
129 <div class="alerts-header">
130 <h2>System alarmów</h2>
131 <button v-if="alerts.length" @click="clearAlerts" class="btn-sm">Wyczyść</button>
132 </div>
133 <div v-for="a in activeAlerts" :key="a.id" class="alert-item" :class="a.type">
134 <div class="alert-content">
135 <span class="alert-time">{{ a.time }}</span>
136 <span>{{ a.message }}</span>
137 </div>
138 <button @click="dismissAlert(a.id)" class="dismiss">x</button>
139 </div>
140 <p v-if="!activeAlerts.length" class="empty">Wszystkie systemy w normie</p>
141 </div>
142 </div>
143 </div>
144</template>
145
146<style scoped>
147.nova-lab {
148 background: #0a0e27;
149 color: #00ff88;
150 min-height: 100vh;
151 padding: 20px;
152 font-family: monospace;
153}
154h1 { color: #00ff88; margin-bottom: 15px; text-align: center; }
155h2 { color: #00b4d8; margin-bottom: 10px; }
156.status-bar {
157 text-align: center;
158 padding: 10px;
159 border: 2px solid;
160 margin-bottom: 15px;
161 font-weight: bold;
162}
163.status-bar.nominal { border-color: #00ff88; color: #00ff88; }
164.status-bar.warning { border-color: #ff9900; color: #ff9900; }
165.status-bar.critical { border-color: #ff4444; color: #ff4444; background: rgba(255,68,68,0.1); }
166.main-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
167.resources-section, .alerts-section {
168 background: rgba(0, 180, 216, 0.1);
169 border: 2px solid #00b4d8;
170 padding: 15px;
171}
172.resource-card {
173 border: 2px solid #00ff88;
174 padding: 10px;
175 margin-bottom: 8px;
176}
177.resource-card.warning { border-color: #ff9900; }
178.resource-card.critical { border-color: #ff4444; background: rgba(255,68,68,0.05); }
179.r-header { display: flex; justify-content: space-between; margin-bottom: 6px; }
180.r-bar { height: 10px; background: #1a1e3f; border: 1px solid #00b4d8; }
181.r-fill { height: 100%; background: #00ff88; transition: width 0.3s; }
182.resource-card.warning .r-fill { background: #ff9900; }
183.resource-card.critical .r-fill { background: #ff4444; }
184.r-btns { display: flex; gap: 5px; margin-top: 6px; }
185.btn-sm {
186 background: #00b4d8;
187 color: #0a0e27;
188 border: none;
189 padding: 3px 10px;
190 cursor: pointer;
191 font-family: monospace;
192 font-size: 0.85em;
193}
194.btn-green { background: #00ff88; }
195.global-btns { display: flex; gap: 8px; margin-top: 10px; }
196.btn {
197 background: #00b4d8;
198 color: #0a0e27;
199 border: none;
200 padding: 6px 14px;
201 cursor: pointer;
202 font-family: monospace;
203 flex: 1;
204}
205.btn:hover { background: #00ff88; }
206.btn.btn-green { background: #00ff88; }
207.alerts-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
208.alert-item {
209 display: flex;
210 justify-content: space-between;
211 align-items: center;
212 padding: 8px;
213 margin: 4px 0;
214 border: 1px solid;
215}
216.alert-item.info { border-color: #00b4d8; }
217.alert-item.warning { border-color: #ff9900; color: #ff9900; }
218.alert-item.critical { border-color: #ff4444; color: #ff4444; }
219.alert-time { color: #00b4d8; margin-right: 8px; font-size: 0.85em; }
220.dismiss {
221 background: none;
222 border: 1px solid currentColor;
223 color: inherit;
224 cursor: pointer;
225 padding: 1px 5px;
226 font-family: monospace;
227}
228.empty { color: #666; font-style: italic; }
229</style>Widzisz błąd w tej lekcji?
Zadania praktyczne w grze
- Edytor kodu
Stwórz resource store z state, getters, actions, store composition i alertami.