חלק מהפאנלים אינם טבלה וטופס אלא אפליקציה שלמה: מנהל קבצים, מנהל בסיס נתונים, צ'אט. הם בנויים כאפליקציית Vue 3 שמדברת עם מתודות JSON של אותו פאנל. הדף מסביר את הדפוס שהליבה עצמה משתמשת בו (פאנל file_manager), ואת המלכודות שנובעות מכך שהאדמין הוא SPA שמחליף את ה-DOM בכל ניווט.
{admin} הוא CONFIG::$admin_url, ו-system/ הוא תיקיית הליבה (api/core בריפו). לפני שממשיכים כדאי להכיר את בניית פאנל חדש ואת JSON API.
מה יש בעמוד האדמין#
תבנית ה-admin_panel טוענת לכל עמוד אדמין, בלי שתצטרכו להוסיף כלום:
| מה | איך זמין |
|---|---|
Vue 3 (גרסת global) | const { createApp } = Vue; מוגדר גלובלית, ו-Vue זמין ב-window |
| jQuery | $ / jQuery |
| axios | axios (נטען ב-body_end) |
select2, wzselect | נטענים גם הם |
alertService | alertService.alert/confirm/toast/fire (ראו ערכת רכיבים) |
| FontAwesome 5 | all.min.js מחליף תגיות <i> ב-<svg> (ראו למטה) |
window.admin_url | הערך של CONFIG::$admin_url |
| פונקציות ניווט | adminMovePage, adminMovePagePost, adminMoveToLastPanel, sidePage |
Vue של האדמין הוא ה-build הגלובלי: אין import, אין SFC (.vue) ואין שלב build. כותבים קובץ JS רגיל, עם Options API.
עזרים כמו this.api(), this.goto(), this.toast() ומחלקות wz_if / wz_spin שייכים לממשקי ה-frontend של האתרים (mixin גלובלי שלהם) ואינם קיימים בליבת האדמין. בפאנל אדמין כותבים fetch או axios בעצמכם, מציגים הודעות עם alertService או wz_success / wz_error, ומנווטים עם adminMovePage. הדוגמאות כאן עושות בדיוק את זה.
למה צריך דפוס mount#
האדמין הוא SPA. בלחיצה על פאנל funcs.js טוען את {admin}/<panel>?pmode=inner, מרוקן את #mainframe, טוען את הסקריפטים של הפאנל ורק אחר כך מזריק את ה-HTML. מכאן שלוש בעיות:
- הסקריפט רץ לפני שהאלמנט שלו קיים ב-DOM.
loadobjs()זוכרת כל כתובת שכבר נטענה ולא טוענת אותה פעם שנייה, ולכן בביקור חוזר הסקריפט כלל לא רץ.- Vue לא שומע על כך שהאלמנט נמחק בניווט, ולכן מאזינים גלובליים של האפליקציה הישנה נשארים חיים.
הפתרון שבו משתמש file_manager (system/assets/file_manager/app.js): מצב האפליקציה נשמר על window, יש פונקציות mountApp ו-unmountApp, וצופה MutationObserver אחד על #mainframe שמרכיב את האפליקציה כשהאלמנט מופיע ומפרק אותה כשהוא נעלם.
(function () {
"use strict";
var PANEL = "tasks";
var S = window.__wz_tasks || (window.__wz_tasks = { app: null, el: null, watching: false });
var OPTIONS = {
data: function () {
return { base: "", items: [], loading: true, error: "", csrf: "" };
},
mounted: function () {
this.base = S.el.getAttribute("data-base") || (window.admin_url + "/" + PANEL);
this.load();
},
methods: {
api: function (action, payload) {
var self = this;
return fetch(this.base + "/api?action=" + encodeURIComponent(action), {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json", "X-WZ-CSRF": this.csrf },
body: JSON.stringify(Object.assign({ action: action, csrf: this.csrf }, payload || {}))
}).then(function (r) { return r.json(); }).then(function (json) {
if (!json.success && json.code === "csrf") {
return self.refreshCsrf().then(function () { return self.api(action, payload); });
}
if (!json.success) throw new Error(json.error || "שגיאה");
return json.data;
});
},
refreshCsrf: function () {
var self = this;
return fetch(this.base + "/api?action=csrf", { credentials: "same-origin", method: "POST", body: "{}" })
.then(function (r) { return r.json(); })
.then(function (j) { self.csrf = j.data.csrf; });
},
load: function () {
var self = this;
this.loading = true;
this.refreshCsrf().then(function () { return self.api("list"); })
.then(function (d) { self.items = d.items; })
.catch(function (e) { self.error = e.message; })
.then(function () { self.loading = false; });
},
remove: function (item) {
var self = this;
alertService.confirm({ title: "למחוק את המשימה?", text: item.title }).then(function (ok) {
if (!ok) return;
self.api("delete", { id: item.id }).then(function () {
self.items = self.items.filter(function (x) { return x.id !== item.id; });
wz_success("נמחק");
}).catch(function (e) { wz_error(e.message); });
});
}
}
};
function mountApp() {
var el = document.getElementById("tasksApp");
if (!el || el === S.el) return;
unmountApp();
S.el = el;
S.app = createApp(OPTIONS);
S.app.mount(el);
}
function unmountApp() {
if (!S.app) return;
try { S.app.unmount(); } catch (e) {}
S.app = null;
S.el = null;
}
mountApp();
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", mountApp);
if (!S.watching) {
S.watching = true;
var host = document.getElementById("mainframe");
new MutationObserver(function () {
if (document.getElementById("tasksApp")) mountApp();
else if (S.el) unmountApp();
}).observe(host || document.body, { childList: true, subtree: !host });
}
})();
הנקודות החשובות בקוד:
window.__wz_tasksשורד הרצה חוזרת של הקובץ, ו-S.watchingמבטיח שיש צופה אחד בלבד.mountApp()נקראת מיד (האלמנט אולי כבר קיים), ב-DOMContentLoaded(טעינה ישירה של העמוד), ובכל שינוי ב-#mainframe.- הצופה מסתכל רק על ילדים ישירים של
#mainframe, כדי שהשינויים הפנימיים של הפאנל לא יעירו אותו. - את כתובת הבסיס לוקחים מ-
data-baseשה-PHP הדפיס, לא מ-window.location: בניווט AJAX הכתובת בשורת הדפדפן עדיין שייכת לפאנל הקודם.
הצד של PHP: עמוד ו-JSON#
הפאנל מייצר עמוד אחד שמכיל את אלמנט השורש ואת הסקריפט, ומתודת api שעונה JSON:
// application/admin/tasks.php
class ADMINMODULE_tasks extends bgl_controller
{
public function index()
{
PAGE::add_asset("path/to/tasks.scss.css"); // ראו "סגנונות" למטה
PAGE::add_asset("path/to/tasks.js", false, "body_end");
return $this->view("admin/tasks_dashboard", []);
}
public function api()
{
$_GET["pmode"] = "empg";
header("Content-Type: application/json; charset=utf-8");
$data = json_decode(file_get_contents("php://input"), true);
if (!is_array($data)) $data = $_POST;
$action = $_GET["action"] ?? $data["action"] ?? "";
// כל פעולה שמשנה מידע דורשת אסימון CSRF
$write = ["delete", "save"];
if (in_array($action, $write, true)) {
$token = $data["csrf"] ?? $_SERVER["HTTP_X_WZ_CSRF"] ?? "";
if (!ADMIN::verify_csrf_token($token))
return json_encode(["success" => false, "code" => "csrf", "error" => "פג תוקף הטוקן"]);
}
switch ($action) {
case "csrf":
return json_encode(["success" => true, "data" => ["csrf" => ADMIN::generate_csrf_cookie()]]);
case "list":
$items = DB::query("tasks", [], "id DESC")->get_all();
return json_encode(["success" => true, "data" => ["items" => $items]], JSON_UNESCAPED_UNICODE);
case "delete":
if (!ADMIN::has_perms("delete")) return json_encode(["success" => false, "error" => "אין הרשאה"]);
DB::delete("tasks", (int)($data["id"] ?? 0));
return json_encode(["success" => true, "data" => new stdClass()]);
}
return json_encode(["success" => false, "error" => "פעולה לא מוכרת"]);
}
}
התבנית application/views/admin/tasks_dashboard.tpl:
<div id="tasksApp" data-base="{CONFIG::$admin_url}/tasks">
<div class="tasks">
<p v-if="error" class="tasks-error">{literal}{{ error }}{/literal}</p>
<div v-else-if="loading" class="wz_spin"></div>
<ul v-else>
<li v-for="item in items" :key="item.id">
<span>{literal}{{ item.title }}{/literal}</span>
<button class="btn btn-danger" @click="remove(item)">
<span class="wz_if"><i class="fas fa-trash"></i></span>
</button>
</li>
</ul>
</div>
</div>
שימו לב ל-{literal}: Smarty וגם Vue משתמשים בסוגריים מסולסלים, וכדי ש-Smarty לא ינסה לפרש את {{ ... }} עוטפים אותם ב-{literal}.
מתודת api מגינה על עצמה בעצמה. הרשאת הפאנל (has_perms) נבדקת בכניסה לפאנל, אבל כל מתודה ציבורית היא כתובת, ולכן בפעולות כתיבה בדקו CSRF (ADMIN::verify_csrf_token) והרשאה ספציפית. כפי שמוסבר ב-JSON API, טפסים וטבלאות של הליבה אינם מוגנים ב-CSRF.
כללי Vue בפאנלים#
- Options API בלבד (
data,methods,computed,mounted), בלי<script setup>ובליimport. - אסור
v-if,v-elseאוv-forעל<i>של FontAwesome.all.min.jsמחליף את התגית ב-<svg>, ו-Vue מאבד את הצומת שהוא מנהל וה-patch נשבר. הרכיב כולו מפסיק להתעדכן. עוטפים בתגית שאינה אייקון:
<span v-if="done" class="wz_if"><i class="fas fa-check"></i></span>
<span v-else class="wz_if"><i class="fas fa-clock"></i></span>
- ספינר טעינה לא בונים מאייקון. משתמשים ב-
<span v-if="loading" class="wz_spin"></span>. - שתי המחלקות
wz_ifו-wz_spinאינן מוגדרות בליבת האדמין, והפאנל צריך להגדיר אותן ב-SCSS שלו:
.tasks {
.wz_if { display: contents; }
.wz_spin {
display: inline-block;
inline-size: 1.25rem;
block-size: 1.25rem;
border: 2px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: tasks-spin 0.7s linear infinite;
}
}
@keyframes tasks-spin { to { transform: rotate(360deg); } }
- FontAwesome הוא גרסה 5 (Free 5.15.4): שמות של גרסה 6 אינם מוצגים. הכתיבה
fas fa-trashנכונה,fa-solid fa-trashלא. - CSS לוגי בלבד:
margin-inline-start,padding-inline-end,inset-inline-start,text-align: start. אסוריםleft/right. - כיוון: האדמין מגדיר
html { direction: rtl }בקוד, ומוסיף ל-bodyאת המחלקהrtlאוltr. הסלקטור[dir=rtl]לא תופס באדמין. כשצריך כלל לכיוון מסוים כתבו.rtl &. - מצב כהה:
body.dark-mode. השתמשו במשתני ה-CSS של התבנית (--background,--foreground,--primary,--border,--card-bg,--radius) ולא בצבעים קבועים, והמצב הכהה יעבוד לבד.
סגנונות#
קובצי הסגנון נכתבים כ-.scss ונטענים עם הסיומת .scss.css: PAGE::add_asset("path/tasks.scss.css") מקמפל את path/tasks.scss בצד השרת. עבור assets/... הקובץ נקרא מתיקיית assets של הליבה (כמו assets/file_manager/style.scss.css), ובכל נתיב אחר הוא נקרא יחסית לשורש האתר. אין לערוך .css ישירות.
הפעילו את הסקריפט והסגנון רק מתוך מתודת הפאנל (PAGE::add_asset), ולא מ-application/admin/includes, שרץ בכל בקשת אדמין.
ניווט מתוך האפליקציה#
| צורך | פונקציה |
|---|---|
| מעבר לפאנל אחר בלי טעינה מחדש | adminMovePage(url) (בודק שאין שינויים שלא נשמרו) |
| מעבר עם POST | adminMovePagePost(url, data) |
| חזרה לפאנל הקודם | adminMoveToLastPanel(backupUrl) |
| פתיחת טופס בחלונית צד | sidePage(url) ואז sidePageClose() |
// מעבר לעריכת משימה בטופס רגיל של הפאנל
adminMovePage(window.admin_url + "/tasks/insert/" + item.id);
הדוגמה המלאה בליבה היא system/admin/file_manager.php יחד עם system/assets/file_manager/app.js ו-system/views/admin/file_manager/dashboard.tpl: מבנה הנתונים, ה-api והמעקב אחרי ה-CSRF.