פאנלים עם Vue

איך בונים פאנל ניהול שהממשק שלו הוא אפליקציית Vue 3: הטעינה בניווט AJAX, דפוס mount ו-unmount, תקשורת JSON עם הפאנל, CSRF, וכללי הכתיבה של Vue בעולם הניהול.

⏱ 12 דק' קריאה 2229 מילים ערוך דף זה ב-GitHub

חלק מהפאנלים אינם טבלה וטופס אלא אפליקציה שלמה: מנהל קבצים, מנהל בסיס נתונים, צ'אט. הם בנויים כאפליקציית 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
axiosaxios (נטען ב-body_end)
select2, wzselectנטענים גם הם
alertServicealertService.alert/confirm/toast/fire (ראו ערכת רכיבים)
FontAwesome 5all.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. מכאן שלוש בעיות:

  1. הסקריפט רץ לפני שהאלמנט שלו קיים ב-DOM.
  2. loadobjs() זוכרת כל כתובת שכבר נטענה ולא טוענת אותה פעם שנייה, ולכן בביקור חוזר הסקריפט כלל לא רץ.
  3. 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) (בודק שאין שינויים שלא נשמרו)
מעבר עם POSTadminMovePagePost(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.

ראו גם#

מצאתם טעות או חוסר? תקנו את הדף או פתחו Issue בריפו. התיעוד נכתב מתוך הקוד של ליבה 5.0.115.