kreftjs 0.0.19-beta → 0.0.20-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/bin/build.js +13 -2
- package/bin/kreftjs.js +1 -0
- package/book/ar/01-getting-started.md +169 -0
- package/book/ar/02-language.md +330 -0
- package/book/ar/03-components-and-state.md +258 -0
- package/book/ar/04-router-data-forms.md +333 -0
- package/book/ar/05-arabic-rtl.md +175 -0
- package/book/ar/06-backend.md +247 -0
- package/book/ar/07-auth-and-security.md +157 -0
- package/book/ar/08-background-work.md +250 -0
- package/book/ar/09-testing-and-deploy.md +234 -0
- package/book/ar/10-electron-desktop.md +258 -0
- package/book/ar/11-advanced-blog.md +370 -0
- package/book/ar/12-advanced-shop.md +426 -0
- package/book/ar/13-advanced-ui.md +440 -0
- package/book/ar/14-advanced-ops.md +167 -0
- package/book/ar/15-appendix.md +81 -0
- package/book/ar/README.md +62 -0
- package/book/kreft-book-ar.docx +0 -0
- package/docs/README.md +2 -2
- package/kreftjs.d.ts +1 -1
- package/kreftjs.js +436 -74
- package/package.json +4 -3
- package/templates/app/frontend/errors/ErrorMonitor.kreft +2 -2
- package/templates/app/frontend/kreftjs.d.ts +1 -1
- package/templates/app/frontend/kreftjs.js +436 -74
- package/templates/app/frontend/pages/Dashboard.kreft +1 -1
- package/templates/errors/ErrorMonitor.kreft +1 -1
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
# الفصل الثالث عشر (متقدم): واجهات متقدمة ومكتبة مكوّنات
|
|
2
|
+
|
|
3
|
+
هنا نبني مكتبة صغيرة قابلة لإعادة الاستخدام: نافذة منبثقة، وجدول عام، وتوجيهات خاصة، ومنطق مشترك (تراجع/إعادة، استطلاع حي، تحديث متفائل). **جرّبنا هذه الأمثلة في المتصفح** عدا ما نذكره صراحة.
|
|
4
|
+
|
|
5
|
+
## 1) توجيهان خاصان: النقر خارج العنصر والتركيز التلقائي
|
|
6
|
+
|
|
7
|
+
التوجيهات تُسجَّل على التطبيق قبل `mount`:
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
const app = Kreftjs.app('App.kreft')
|
|
11
|
+
.component('Field', 'components/Field.kreft')
|
|
12
|
+
.use(router)
|
|
13
|
+
|
|
14
|
+
// k-click-outside={close}: يستدعي الدالة عند النقر خارج العنصر
|
|
15
|
+
app.directive('click-outside', {
|
|
16
|
+
ready(el, binding) {
|
|
17
|
+
el.__out = (e) => { if (!el.contains(e.target)) binding.value && binding.value(e) }
|
|
18
|
+
document.addEventListener('pointerdown', el.__out)
|
|
19
|
+
},
|
|
20
|
+
update(el, binding) {},
|
|
21
|
+
destroy(el) { document.removeEventListener('pointerdown', el.__out) },
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
// k-autofocus: يركّز الحقل عند ظهوره
|
|
25
|
+
app.directive('autofocus', { ready(el) { el.focus() } })
|
|
26
|
+
|
|
27
|
+
app.mount('#app')
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- `ready` عند ظهور العنصر، و`destroy` عند زواله: نظّف المستمعات هنا دائمًا.
|
|
31
|
+
- `binding.value` هو القيمة بعد `=`، و`binding.arg` ما بعد `:` (`k-tooltip:top`).
|
|
32
|
+
|
|
33
|
+
## 2) نافذة منبثقة (Modal) بـ kreft-portal
|
|
34
|
+
|
|
35
|
+
```html
|
|
36
|
+
<!-- components/Modal.kreft -->
|
|
37
|
+
prop open = false
|
|
38
|
+
prop title = ''
|
|
39
|
+
event close
|
|
40
|
+
|
|
41
|
+
action esc(e) { if (e.key === 'Escape' && open) send('close') }
|
|
42
|
+
on ready { window.addEventListener('keydown', esc) }
|
|
43
|
+
on destroy { window.removeEventListener('keydown', esc) }
|
|
44
|
+
|
|
45
|
+
<view>
|
|
46
|
+
<kreft-portal to="body">
|
|
47
|
+
<div k-if="open" class="backdrop" k-animate="fade">
|
|
48
|
+
<div class="dialog" role="dialog" aria-modal="true" k-click-outside={() => send('close')}>
|
|
49
|
+
<header>
|
|
50
|
+
<h3>{title}</h3>
|
|
51
|
+
<button @click="send('close')" aria-label="إغلاق">✕</button>
|
|
52
|
+
</header>
|
|
53
|
+
<slot></slot>
|
|
54
|
+
<footer><slot name="footer"></slot></footer>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</kreft-portal>
|
|
58
|
+
</view>
|
|
59
|
+
|
|
60
|
+
<style>
|
|
61
|
+
.backdrop { position: fixed; inset: 0; background: #0006; display: grid; place-items: center; z-index: 100; }
|
|
62
|
+
.dialog { background: var(--background, #fff); color: var(--foreground, #111); padding: 20px; min-width: 300px; border: 1px solid var(--border, #ddd); }
|
|
63
|
+
.dialog header { display: flex; justify-content: space-between; align-items: center; }
|
|
64
|
+
.dialog footer { margin-block-start: 16px; display: flex; gap: 8px; justify-content: flex-end; }
|
|
65
|
+
</style>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
لماذا `kreft-portal`؟ حتى لا تتأثر النافذة بـ `overflow` أو `z-index` للآباء؛ فهي تُرسم في `<body>` مباشرة. واستعملنا:
|
|
69
|
+
|
|
70
|
+
- **prop `open`** وحدث **`close`**: النافذة لا تغلق نفسها، بل تطلب من أبيها.
|
|
71
|
+
- **Esc والنقر خارجها** يرسلان `close`.
|
|
72
|
+
- **`k-animate="fade"`** لحركة الدخول والخروج.
|
|
73
|
+
- **فتحة `footer` مسمّاة** لأزرار الأب.
|
|
74
|
+
|
|
75
|
+
ووجدنا في الاختبار أن الإغلاق (✕ وEsc والنقر الخارجي) يعمل كما ينبغي. **تنبيه عن بيئة الاختبار:** متصفح اللوحة المدمج الذي اختبرنا به لا ينفّذ `requestAnimationFrame`، فتعلق حركة الخروج فيه وتبقى النافذة في الـ DOM. هذا ليس خللًا في الإطار؛ لكن إن رأيت ما يشبهه في متصفح غير مرئي أو في خلفية، فجرّب بدون `k-animate` للتأكد.
|
|
76
|
+
|
|
77
|
+
## 3) جدول عام: فرز وفتحات وأداء
|
|
78
|
+
|
|
79
|
+
```html
|
|
80
|
+
<!-- components/DataTable.kreft: جدول عام مع فرز وفتحات للخلايا -->
|
|
81
|
+
prop rows = []
|
|
82
|
+
prop columns = [] // [{ key: 'name', label: 'الاسم' }, …]
|
|
83
|
+
prop rowKey = 'id'
|
|
84
|
+
event select
|
|
85
|
+
|
|
86
|
+
state sortKey = ''
|
|
87
|
+
state dir = 1
|
|
88
|
+
state selected = null
|
|
89
|
+
|
|
90
|
+
derive sorted = !sortKey ? rows : [...rows].sort((a, b) => (a[sortKey] > b[sortKey] ? 1 : a[sortKey] < b[sortKey] ? -1 : 0) * dir)
|
|
91
|
+
|
|
92
|
+
action sortBy(key) {
|
|
93
|
+
if (sortKey === key) dir = -dir
|
|
94
|
+
else { sortKey = key; dir = 1 }
|
|
95
|
+
}
|
|
96
|
+
action pick(row) { selected = row[rowKey]; send('select', row) }
|
|
97
|
+
|
|
98
|
+
<view>
|
|
99
|
+
<table>
|
|
100
|
+
<thead>
|
|
101
|
+
<tr>
|
|
102
|
+
<th k-each="c in columns" k-key="c.key" @click="sortBy(c.key)" style="cursor:pointer">
|
|
103
|
+
{c.label} <span k-if="sortKey === c.key">{dir === 1 ? '▲' : '▼'}</span>
|
|
104
|
+
</th>
|
|
105
|
+
</tr>
|
|
106
|
+
</thead>
|
|
107
|
+
<tbody>
|
|
108
|
+
{for row in sorted key row[rowKey]}
|
|
109
|
+
<tr k-memo="[row, selected === row[rowKey]]" class={selected === row[rowKey] ? 'on' : ''} @click="pick(row)">
|
|
110
|
+
<td k-each="c in columns" k-key="c.key">
|
|
111
|
+
<slot name="cell" row={row} column={c}>{row[c.key]}</slot>
|
|
112
|
+
</td>
|
|
113
|
+
</tr>
|
|
114
|
+
{empty}
|
|
115
|
+
<tr><td colspan={columns.length}><slot name="empty">لا بيانات</slot></td></tr>
|
|
116
|
+
{/for}
|
|
117
|
+
</tbody>
|
|
118
|
+
</table>
|
|
119
|
+
</view>
|
|
120
|
+
|
|
121
|
+
<style scoped>
|
|
122
|
+
.on { background: #8881; }
|
|
123
|
+
th, td { text-align: start; padding: 6px 10px; border-block-end: 1px solid #8883; }
|
|
124
|
+
</style>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
الأفكار:
|
|
128
|
+
|
|
129
|
+
- **`derive sorted`**: القيمة المرتبة تُحسب وحدها كلما تغيّر `rows` أو `sortKey` أو `dir`.
|
|
130
|
+
- **فتحة `cell` بمعاملات** (`row` و`column`): الأب يقرر كيف تُعرض أي خلية.
|
|
131
|
+
- **`k-memo="[row, selected === row[rowKey]]"`**: عند اختيار صف جديد يُعاد رسم صفين فقط، لا الجدول كله.
|
|
132
|
+
- **فتحة `empty`** عندما لا بيانات.
|
|
133
|
+
|
|
134
|
+
الاستعمال:
|
|
135
|
+
|
|
136
|
+
```html
|
|
137
|
+
state rows = [
|
|
138
|
+
{ id: 1, name: 'قهوة', price: 15 },
|
|
139
|
+
{ id: 2, name: 'تمر', price: 25 },
|
|
140
|
+
{ id: 3, name: 'عود', price: 99 },
|
|
141
|
+
]
|
|
142
|
+
state columns = [{ key: 'name', label: 'الاسم' }, { key: 'price', label: 'السعر' }]
|
|
143
|
+
state picked = null
|
|
144
|
+
|
|
145
|
+
<data-table rows={rows} columns={columns} @select="(r) => picked = r.name">
|
|
146
|
+
<template k-slot:cell="{ row, column }">
|
|
147
|
+
<b k-if="column.key === 'price'">{row.price} $</b>
|
|
148
|
+
<span k-else>{row[column.key]}</span>
|
|
149
|
+
</template>
|
|
150
|
+
</data-table>
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
> **فخ شاهدناه بأعيننا:** كتبنا في أول تجربة `const rows = [...]` بدل `state rows = [...]` فظهر الجدول فارغًا ("لا بيانات") **بلا أي خطأ**. السبب: السطر الذي لا يبدأ بكلمة مفتاحية JavaScript عادي لا تراه الصفحة. كل ما تحتاجه الصفحة يجب أن يكون `state` أو `derive` أو `action`.
|
|
154
|
+
|
|
155
|
+
نتائج اختبارنا: النقر على رأس "السعر" مرتين يعرض الصفوف تنازليًا (عود ثم تمر ثم قهوة)، والنقر على صف يضعه "المختار".
|
|
156
|
+
|
|
157
|
+
## 4) منطق مشترك: تراجع وإعادة
|
|
158
|
+
|
|
159
|
+
```js
|
|
160
|
+
// logic/undo.js: تراجع وإعادة لأي قيمة (يستعملها أي مكوّن)
|
|
161
|
+
export function useUndo(initial, limit = 50) {
|
|
162
|
+
const s = Kreftjs.state({ value: initial, past: [], future: [] })
|
|
163
|
+
|
|
164
|
+
function set(next) {
|
|
165
|
+
s.past = [...s.past, JSON.stringify(s.value)].slice(-limit)
|
|
166
|
+
s.value = next
|
|
167
|
+
s.future = []
|
|
168
|
+
}
|
|
169
|
+
function undo() {
|
|
170
|
+
if (!s.past.length) return
|
|
171
|
+
s.future = [JSON.stringify(s.value), ...s.future]
|
|
172
|
+
s.value = JSON.parse(s.past[s.past.length - 1])
|
|
173
|
+
s.past = s.past.slice(0, -1)
|
|
174
|
+
}
|
|
175
|
+
function redo() {
|
|
176
|
+
if (!s.future.length) return
|
|
177
|
+
s.past = [...s.past, JSON.stringify(s.value)]
|
|
178
|
+
s.value = JSON.parse(s.future[0])
|
|
179
|
+
s.future = s.future.slice(1)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
get value() { return s.value },
|
|
184
|
+
get canUndo() { return s.past.length > 0 },
|
|
185
|
+
get canRedo() { return s.future.length > 0 },
|
|
186
|
+
set, undo, redo,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
يُستعمل في أي مكوّن بسطر واحد:
|
|
192
|
+
|
|
193
|
+
```html
|
|
194
|
+
import { useUndo } from '../logic/undo.js'
|
|
195
|
+
use history = useUndo({ title: 'عنوان', items: [] })
|
|
196
|
+
|
|
197
|
+
action rename(e) { history.set({ ...history.value, title: e.target.value }) }
|
|
198
|
+
action addItem() { history.set({ ...history.value, items: [...history.value.items, 'عنصر'] }) }
|
|
199
|
+
|
|
200
|
+
<input value={history.value.title} @input="rename($event)">
|
|
201
|
+
<button @click="addItem">أضف</button>
|
|
202
|
+
<button disabled={!history.canUndo} @click="history.undo()">تراجع</button>
|
|
203
|
+
<button disabled={!history.canRedo} @click="history.redo()">إعادة</button>
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
اختبرنا: إضافة عنصرين ثم تراجع (1 عنصر) ثم إعادة (2 عنصر).
|
|
207
|
+
|
|
208
|
+
لماذا `JSON.stringify` في `past`؟ لحفظ **لقطة** مستقلة؛ فتغييرات لاحقة على كائنات الحالة لا تلوّث التاريخ.
|
|
209
|
+
|
|
210
|
+
## 5) تحديث متفائل (Optimistic UI)
|
|
211
|
+
|
|
212
|
+
غيّر الواجهة فورًا لتبدو سريعة، ثم أكّد من الخادم، وتراجع إن فشل:
|
|
213
|
+
|
|
214
|
+
```js
|
|
215
|
+
// logic/optimistic.js: غيّر الواجهة فورًا، ثم أكّد من الخادم، وتراجع عند الفشل
|
|
216
|
+
export async function optimistic(apply, rollback, request) {
|
|
217
|
+
apply() // الواجهة تتغير الآن
|
|
218
|
+
try {
|
|
219
|
+
return await request() // ثم نسأل الخادم
|
|
220
|
+
} catch (e) {
|
|
221
|
+
rollback() // فشل: أعد القيمة
|
|
222
|
+
throw e
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
```html
|
|
228
|
+
state likes = 10
|
|
229
|
+
state note = ''
|
|
230
|
+
|
|
231
|
+
action like() {
|
|
232
|
+
const old = likes
|
|
233
|
+
try {
|
|
234
|
+
await optimistic(
|
|
235
|
+
() => { likes++ }, // الآن
|
|
236
|
+
() => { likes = old }, // عند الفشل
|
|
237
|
+
() => api.post('/api/like') // الطلب الفعلي
|
|
238
|
+
)
|
|
239
|
+
} catch (e) { note = e.message }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
<button @click="like">♥ {likes}</button> <span class="error">{note}</span>
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
اختبرناه بطلب يفشل عمدًا: العدّاد ارتفع إلى 11 فورًا، ثم رجع إلى 10 وظهرت رسالة "فشل الخادم".
|
|
246
|
+
|
|
247
|
+
## 6) استطلاع حي (Polling) يحترم التبويب والتنظيف
|
|
248
|
+
|
|
249
|
+
```js
|
|
250
|
+
// logic/poll.js: يجلب كل بضع ثوانٍ، ويتوقف إن كانت الصفحة مخفية، وينظّف نفسه
|
|
251
|
+
export function usePoll(fetcher, every = 5000) {
|
|
252
|
+
const s = Kreftjs.state({ data: null, error: null, loading: true, updated: null })
|
|
253
|
+
let timer = null
|
|
254
|
+
|
|
255
|
+
async function tick() {
|
|
256
|
+
if (document.hidden) return // لا طلبات والتبويب غير مرئي
|
|
257
|
+
try {
|
|
258
|
+
s.data = await fetcher()
|
|
259
|
+
s.error = null
|
|
260
|
+
s.updated = new Date()
|
|
261
|
+
} catch (e) {
|
|
262
|
+
s.error = e // نُبقي آخر بيانات سليمة
|
|
263
|
+
} finally {
|
|
264
|
+
s.loading = false
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
tick()
|
|
269
|
+
timer = setInterval(tick, every)
|
|
270
|
+
document.addEventListener('visibilitychange', tick) // عند العودة للتبويب حدّث فورًا
|
|
271
|
+
Kreftjs.onDestroy(() => {
|
|
272
|
+
clearInterval(timer)
|
|
273
|
+
document.removeEventListener('visibilitychange', tick)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
get data() { return s.data },
|
|
278
|
+
get error() { return s.error },
|
|
279
|
+
get loading() { return s.loading },
|
|
280
|
+
get updated() { return s.updated },
|
|
281
|
+
refresh: tick,
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
الاستعمال في لوحة أرقام:
|
|
287
|
+
|
|
288
|
+
```html
|
|
289
|
+
<!-- pages/Dashboard.kreft: لوحة أرقام حيّة -->
|
|
290
|
+
import { usePoll } from '../logic/poll.js'
|
|
291
|
+
|
|
292
|
+
use stats = usePoll(() => api.get('/api/stats'), 3000)
|
|
293
|
+
|
|
294
|
+
derive money = (cents) => ((cents || 0) / 100).toFixed(2) + ' $'
|
|
295
|
+
derive time = (d) => (d ? d.toLocaleTimeString('ar') : '—')
|
|
296
|
+
|
|
297
|
+
<view>
|
|
298
|
+
<h1>لوحة المتجر</h1>
|
|
299
|
+
|
|
300
|
+
<p k-if="stats.loading">جارٍ التحميل…</p>
|
|
301
|
+
<p k-if="stats.error" class="error">انقطع الاتصال، نعرض آخر بيانات سليمة.</p>
|
|
302
|
+
|
|
303
|
+
<div k-if="stats.data" class="cards">
|
|
304
|
+
<div class="card"><h4>الطلبات</h4><b>{stats.data.orders}</b></div>
|
|
305
|
+
<div class="card"><h4>الإيرادات</h4><b>{money(stats.data.revenue_cents)}</b></div>
|
|
306
|
+
<div class="card"><h4>منتجات قاربت على النفاد</h4><b>{stats.data.low_stock}</b></div>
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
<p>آخر تحديث: {time(stats.updated)} <button @click="stats.refresh()">حدّث الآن</button></p>
|
|
310
|
+
</view>
|
|
311
|
+
|
|
312
|
+
<style scoped>
|
|
313
|
+
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
|
|
314
|
+
.card b { font-size: 28px; }
|
|
315
|
+
</style>
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
لماذا هذا التصميم جيد؟
|
|
319
|
+
|
|
320
|
+
- **`document.hidden`**: لا طلبات والتبويب مخفي.
|
|
321
|
+
- **`visibilitychange`**: عند العودة يُحدَّث فورًا.
|
|
322
|
+
- **أخطاء الشبكة** لا تمسح آخر بيانات سليمة؛ نعرض رسالة ونبقي الأرقام.
|
|
323
|
+
- **`Kreftjs.onDestroy`** ينظّف المؤقت والمستمع حين تُغادر الصفحة.
|
|
324
|
+
|
|
325
|
+
اختبرناه: ظهرت الأرقام الحية وتحدّثت (آخر تحديث بتوقيت جديد)، وزر "حدّث الآن" يعمل. وملاحظة: لم نستطع التأكد من توقف الطلبات بعد مغادرة الصفحة في بيئتنا بسبب تجمّد حركة الانتقال فيها؛ فاختبر ذلك في متصفحك بفتح تبويب الشبكة.
|
|
326
|
+
|
|
327
|
+
واجهته الخلفية (متحكم صغير بذاكرة مؤقتة 5 ثوانٍ):
|
|
328
|
+
|
|
329
|
+
```js
|
|
330
|
+
const { Cache } = require('kreftjs/server')
|
|
331
|
+
const Order = require('../../Models/Order')
|
|
332
|
+
const Product = require('../../Models/Product')
|
|
333
|
+
|
|
334
|
+
class StatsController {
|
|
335
|
+
// يُحسب مرة كل 5 ثوانٍ مهما كثر الزوار
|
|
336
|
+
async index() {
|
|
337
|
+
return Cache.remember('stats', 5, async () => ({
|
|
338
|
+
orders: await Order.query().count(),
|
|
339
|
+
revenue_cents: await Order.query().sum('total_cents'),
|
|
340
|
+
low_stock: await Product.where('stock', '<', 5).count(),
|
|
341
|
+
at: new Date().toISOString(),
|
|
342
|
+
}))
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
module.exports = StatsController
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
مهما كثر الزوار، تُحسب الأرقام مرة كل 5 ثوانٍ: رأينا أن طلبين متتاليين أعادا الطابع الزمني `at` نفسه.
|
|
350
|
+
|
|
351
|
+
## 7) تبويبات تحتفظ بحالتها
|
|
352
|
+
|
|
353
|
+
```html
|
|
354
|
+
state tab = 'one'
|
|
355
|
+
|
|
356
|
+
<button @click="tab = 'one'">واحد</button>
|
|
357
|
+
<button @click="tab = 'two'">اثنان</button>
|
|
358
|
+
<kreft-component :is="tab === 'one' ? 'Counter' : 'Field'" keep></kreft-component>
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
مع `keep` تبقى حالة التبويب (نص مكتوب، عدّاد) عند الرجوع إليه، ويمكنك استعمال `on show` و`on hide` لإيقاف مؤقتات التبويب غير الظاهر.
|
|
362
|
+
|
|
363
|
+
## 8) تجميع كل ذلك في صفحة واحدة
|
|
364
|
+
|
|
365
|
+
```html
|
|
366
|
+
<!-- pages/Lab.kreft: مختبر المكوّنات المتقدمة -->
|
|
367
|
+
import Modal from '../components/Modal.kreft'
|
|
368
|
+
import DataTable from '../components/DataTable.kreft'
|
|
369
|
+
import { useUndo } from '../logic/undo.js'
|
|
370
|
+
import { optimistic } from '../logic/optimistic.js'
|
|
371
|
+
|
|
372
|
+
state open = false
|
|
373
|
+
state picked = null
|
|
374
|
+
state tab = 'one'
|
|
375
|
+
state likes = 10
|
|
376
|
+
state note = ''
|
|
377
|
+
|
|
378
|
+
use history = useUndo({ title: 'عنوان', items: [] })
|
|
379
|
+
|
|
380
|
+
state rows = [
|
|
381
|
+
{ id: 1, name: 'قهوة', price: 15 },
|
|
382
|
+
{ id: 2, name: 'تمر', price: 25 },
|
|
383
|
+
{ id: 3, name: 'عود', price: 99 },
|
|
384
|
+
]
|
|
385
|
+
state columns = [{ key: 'name', label: 'الاسم' }, { key: 'price', label: 'السعر' }]
|
|
386
|
+
|
|
387
|
+
action rename(e) { history.set({ ...history.value, title: e.target.value }) }
|
|
388
|
+
action addItem() { history.set({ ...history.value, items: [...history.value.items, 'عنصر ' + (history.value.items.length + 1)] }) }
|
|
389
|
+
|
|
390
|
+
action like() {
|
|
391
|
+
const old = likes
|
|
392
|
+
try {
|
|
393
|
+
await optimistic(
|
|
394
|
+
() => { likes++ },
|
|
395
|
+
() => { likes = old },
|
|
396
|
+
() => new Promise((ok, no) => setTimeout(() => (Math.random() < 0 ? ok() : no(new Error('فشل الخادم'))), 400))
|
|
397
|
+
)
|
|
398
|
+
} catch (e) { note = e.message }
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
<view>
|
|
402
|
+
<h1>مختبر</h1>
|
|
403
|
+
|
|
404
|
+
<h3>نافذة منبثقة</h3>
|
|
405
|
+
<button @click="open = true">افتح</button>
|
|
406
|
+
<modal open={open} title="مرحبًا" @close="open = false">
|
|
407
|
+
<p>هذه نافذة في <code>body</code> عبر kreft-portal.</p>
|
|
408
|
+
<template k-slot:footer><button @click="open = false">حسنًا</button></template>
|
|
409
|
+
</modal>
|
|
410
|
+
|
|
411
|
+
<h3>جدول مع فرز وفتحة</h3>
|
|
412
|
+
<data-table rows={rows} columns={columns} @select="(r) => picked = r.name">
|
|
413
|
+
<template k-slot:cell="{ row, column }">
|
|
414
|
+
<b k-if="column.key === 'price'">{row.price} $</b>
|
|
415
|
+
<span k-else>{row[column.key]}</span>
|
|
416
|
+
</template>
|
|
417
|
+
</data-table>
|
|
418
|
+
<p>المختار: {picked || '—'}</p>
|
|
419
|
+
|
|
420
|
+
<h3>تراجع وإعادة</h3>
|
|
421
|
+
<input value={history.value.title} @input="rename($event)">
|
|
422
|
+
<button @click="addItem">أضف عنصرًا</button>
|
|
423
|
+
<button disabled={!history.canUndo} @click="history.undo()">تراجع</button>
|
|
424
|
+
<button disabled={!history.canRedo} @click="history.redo()">إعادة</button>
|
|
425
|
+
<p>{history.value.title} · {history.value.items.length} عناصر</p>
|
|
426
|
+
|
|
427
|
+
<h3>تحديث متفائل</h3>
|
|
428
|
+
<button @click="like">♥ {likes}</button> <span class="error">{note}</span>
|
|
429
|
+
|
|
430
|
+
<h3>تبويبات تحتفظ بحالتها</h3>
|
|
431
|
+
<button @click="tab = 'one'">واحد</button> <button @click="tab = 'two'">اثنان</button>
|
|
432
|
+
<kreft-component :is="tab === 'one' ? 'Counter' : 'Field'" keep></kreft-component>
|
|
433
|
+
</view>
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
## تمارين
|
|
437
|
+
|
|
438
|
+
1. أضف توجيهًا `k-tooltip:top={message}` يعرض تلميحًا عند المرور.
|
|
439
|
+
2. وسّع `DataTable` بترقيم صفحات داخلي (`pageSize`) وبحث نصي.
|
|
440
|
+
3. اجعل `useUndo` يدعم اختصارات لوحة المفاتيح Ctrl+Z وCtrl+Shift+Z وينظّف المستمع بـ `onDestroy`.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# الفصل الرابع عشر (متقدم): تقارير مجدولة ومهام موثوقة وتشغيل حقيقي
|
|
2
|
+
|
|
3
|
+
بعد أن يعمل التطبيق، يأتي العمل الذي لا يراه المستخدم: تقارير تُرسل وحدها، ومهام تعيد المحاولة عند الفشل، ونظام يُراقَب ويُنشر. الأمثلة هنا جُرِّبت على المتجر السابق.
|
|
4
|
+
|
|
5
|
+
## 1) تقرير يومي مجدول على الطابور
|
|
6
|
+
|
|
7
|
+
مهمة تحسب الأرقام وترسلها بالبريد، مع محاولات تلقائية عند الفشل:
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
const { Job, Mail } = require('kreftjs/server')
|
|
11
|
+
const Order = require('../Models/Order')
|
|
12
|
+
|
|
13
|
+
class SendDailyReport extends Job {
|
|
14
|
+
static tries = 3
|
|
15
|
+
static backoff = 30 // ثانية بين المحاولات
|
|
16
|
+
|
|
17
|
+
async handle() {
|
|
18
|
+
const orders = await Order.query().count()
|
|
19
|
+
const revenue = await Order.query().sum('total_cents')
|
|
20
|
+
await Mail.send({
|
|
21
|
+
to: process.env.ADMIN_EMAIL || 'admin@example.com',
|
|
22
|
+
subject: 'التقرير اليومي',
|
|
23
|
+
text: `الطلبات: ${orders}\nالإيرادات: ${(revenue / 100).toFixed(2)} $`,
|
|
24
|
+
})
|
|
25
|
+
console.log(`[report] orders=${orders} revenue=${revenue}`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
failed(error) {
|
|
29
|
+
console.error('[report] failed:', error.message)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = SendDailyReport
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- `static tries = 3` و`static backoff = 30`: عند فشل البريد (خادم SMTP مشغول مثلًا) تُعاد المحاولة بعد 30 ثانية حتى 3 مرات.
|
|
37
|
+
- `failed(error)` يعمل بعد آخر محاولة فاشلة: مكانك لإرسال تنبيه أو تسجيل الخطأ.
|
|
38
|
+
- `Order.query().sum('total_cents')` و`.count()` تجميعات تُحسب في قاعدة البيانات.
|
|
39
|
+
|
|
40
|
+
ثم جدولتها في `backend/routes/console.js`:
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
const SendDailyReport = require('../app/Jobs/SendDailyReport')
|
|
44
|
+
|
|
45
|
+
Schedule.job(new SendDailyReport()).dailyAt('08:00').name('daily-report') // كل يوم 08:00
|
|
46
|
+
// للتجربة: .everyMinute()
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
دوال التوقيت المتاحة تشمل: `everyMinute()` و`everyFiveMinutes()` و`everyFifteenMinutes()` و`hourly()` و`hourlyAt(30)` و`daily()` و`dailyAt('08:00')` و`weekly()` و`weeklyOn(day, time)` و`monthly()` و`cron('expr')` و`weekdays()` مع `at('08:00')`.
|
|
50
|
+
|
|
51
|
+
نتيجة تجربتنا:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
$ kreft schedule:list
|
|
55
|
+
0 0 * * * prune-old-tokens
|
|
56
|
+
* * * * * daily-report
|
|
57
|
+
|
|
58
|
+
$ kreft schedule:run
|
|
59
|
+
ran daily-report (2 ms)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
وفي سجل الخادم ظهر الناتج: `Email to admin@example.com: التقرير اليومي` ثم `[report] orders=5 revenue=15500`. (في التطوير تُطبع الرسالة بدل أن تُرسل حتى تضبط `MAIL_URL`).
|
|
63
|
+
|
|
64
|
+
### تشغيل المجدول في الإنتاج
|
|
65
|
+
|
|
66
|
+
| الخيار | كيف |
|
|
67
|
+
|---|---|
|
|
68
|
+
| المجدول داخل الخادم (الافتراضي) | `kreft serve` يفحص الجدول كل دقيقة |
|
|
69
|
+
| من cron | اضبط `SCHEDULER=off` وأضف `* * * * * cd /app && kreft schedule:run` إلى crontab |
|
|
70
|
+
| عملية دائمة | `kreft schedule:work` |
|
|
71
|
+
|
|
72
|
+
**مهم عند عدة خوادم:** إن شغّلت عدة نسخ من التطبيق فكل نسخة ستشغّل المجدول وتتكرر المهمة. شغّل المجدول على نسخة واحدة (`SCHEDULER=off` على الباقي)، أو استعمل `Cache.lock` داخل المهمة.
|
|
73
|
+
|
|
74
|
+
## 2) طابور حقيقي بدل التطوير
|
|
75
|
+
|
|
76
|
+
```env
|
|
77
|
+
QUEUE_CONNECTION=redis # أو database
|
|
78
|
+
CACHE_STORE=redis
|
|
79
|
+
REDIS_URL=rediss://default:secret@host:6379/0
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
kreft queue:work # عامل في الخلفية (يعمل أيضًا داخل kreft dev و kreft serve)
|
|
84
|
+
kreft queue:failed # ما فشل بعد كل المحاولات
|
|
85
|
+
kreft queue:retry all # أعد المحاولة
|
|
86
|
+
kreft queue:flush # امسح الفاشلة
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
أنماط عملية:
|
|
90
|
+
|
|
91
|
+
- **مهام صغيرة مستقلة:** مهمة لكل رسالة، لا مهمة واحدة ضخمة تُرسل لألف مستخدم (فإن فشلت في المنتصف أعادت الكل).
|
|
92
|
+
- **عدم التكرار (idempotency):** المهمة قد تُنفَّذ أكثر من مرة (بعد انقطاع) فاكتبها بحيث لا يضر تكرارها: تحقق من حالة قبل الإرسال.
|
|
93
|
+
- **طوابير مسماة:** `dispatch(...).onQueue('mail')` ليأخذ عامل مخصص أمور البريد.
|
|
94
|
+
|
|
95
|
+
## 3) قاعدة واحدة، ثلاث بيئات
|
|
96
|
+
|
|
97
|
+
الترحيلات والنماذج نفسها تعمل على SQLite وMySQL وPostgreSQL، فاستعمل:
|
|
98
|
+
|
|
99
|
+
| البيئة | قاعدة البيانات |
|
|
100
|
+
|---|---|
|
|
101
|
+
| التطوير | `sqlite:backend/database/app.db` (بلا تثبيت) |
|
|
102
|
+
| الاختبار | `sqlite::memory:` (سريعة، تُنشأ لكل ملف اختبار) |
|
|
103
|
+
| الإنتاج | MySQL أو PostgreSQL، مع نسخ احتياطي مجدول |
|
|
104
|
+
|
|
105
|
+
ولا تكتب SQL يخص محرّكًا بعينه في الترحيلات؛ استعمل `table.*` ليبقى الملف قابلًا للنقل.
|
|
106
|
+
|
|
107
|
+
## 4) وكلاء الذكاء الاصطناعي: خادم MCP
|
|
108
|
+
|
|
109
|
+
يتضمن Kreft خادم Model Context Protocol يتيح لوكيل برمجة ذكي أن يرى مساراتك ومخطط قاعدة بياناتك وأن يستعلم **قراءةً فقط**.
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
kreft mcp
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"mcpServers": {
|
|
118
|
+
"kreft": { "command": "kreft", "args": ["mcp"], "cwd": "/path/to/your-project" }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
للاستعمال عبر HTTP اضبط `MCP_TOKEN` وأرسله:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
curl -H "Authorization: Bearer a-long-random-secret" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' http://localhost:8000/mcp
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
| الأداة | ما تعطيه |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `query_database` | يشغّل `SELECT` واحدًا للقراءة فقط |
|
|
132
|
+
| `list_routes` | كل المسارات بطرقها وأسمائها ووسائطها |
|
|
133
|
+
| `server_health` | زمن التشغيل والذاكرة |
|
|
134
|
+
| `kreft://routes` و`kreft://schema` | فهرس المسارات؛ الجداول والأعمدة والمفاتيح |
|
|
135
|
+
|
|
136
|
+
> **تحذير:** `query_database` يستطيع قراءة كل الجداول الأخرى. استعمل حساب قاعدة بيانات للقراءة فقط بلا وصول إلى جداول الأسرار، واحفظ الرمز خارج Git. وفي الإنتاج النقطة معطلة (403) حتى تضبط `MCP_TOKEN`.
|
|
137
|
+
|
|
138
|
+
## 5) رمز API بصلاحيات محدودة
|
|
139
|
+
|
|
140
|
+
للسكربتات والتطبيقات الأخرى أنشئ رمزًا من `/profile` بالصلاحيات `read` و`create` و`update` و`delete` فقط حسب الحاجة. الرمز لا يستعمل إلا طرق HTTP التي تسمح بها صلاحياته:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
curl -H "Authorization: Bearer 5|xxxxxxxx" https://example.com/api/orders # يحتاج read
|
|
144
|
+
curl -X POST -H "Authorization: Bearer 5|xxxxxxxx" -d '{…}' https://example.com/api/orders # يحتاج create
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
وتطبيق جوال أو سكربت يرسل `device_name` عند الدخول يحصل على رمز بدل الكوكي.
|
|
148
|
+
|
|
149
|
+
## 6) مراقبة الذاكرة
|
|
150
|
+
|
|
151
|
+
الخوادم الطويلة قد تجمع ذاكرة ببطء. شغّل بـ `--gc` (أو `AUTO_GC=1`) فيفحص Kreft الذاكرة كل 30 ثانية، وإذا تجاوزت `AUTO_GC_THRESHOLD_MB` نظّف ذاكرته المؤقتة وشغّل جامع القمامة. هذه شبكة أمان؛ إن رأيتها تعمل كثيرًا فابحث عن التسرب.
|
|
152
|
+
|
|
153
|
+
## 7) قائمة نهائية قبل الإطلاق
|
|
154
|
+
|
|
155
|
+
1. `NODE_ENV=production` و`APP_DEBUG=false` و`APP_KEY` حقيقي (الفصل السابع).
|
|
156
|
+
2. قاعدة حقيقية + نسخ احتياطي.
|
|
157
|
+
3. `QUEUE_CONNECTION` و`CACHE_STORE` غير `memory` إن كان لك أكثر من عملية.
|
|
158
|
+
4. `MAIL_URL` مضبوط.
|
|
159
|
+
5. `kreft build --base /` وفحص وسم `<base>` وملف `.htaccess` (الفصل التاسع).
|
|
160
|
+
6. `npm audit` و`kreft update`.
|
|
161
|
+
7. جرّب **مسار الدفع أو التسجيل** (أهم مسار) من البداية للنهاية على نسخة تجريبية.
|
|
162
|
+
|
|
163
|
+
## تمارين
|
|
164
|
+
|
|
165
|
+
1. اكتب مهمة `ExpireOrders` تلغي الطلبات المعلّقة وتعيد المخزون في معاملة، وجدولها كل ساعة.
|
|
166
|
+
2. اجعل مهمة التقرير ترسل لعدة مدراء بمهمة صغيرة لكل مدير.
|
|
167
|
+
3. أنشئ رمز API بصلاحية `read` فقط وجرّب أن `POST` يُرفض به.
|