NGS 3 هفته پیش
والد
کامیت
04916683ae
4فایلهای تغییر یافته به همراه144 افزوده شده و 5 حذف شده
  1. 42 0
      data/www/assets/app.js
  2. 23 3
      data/www/index.html
  3. 12 2
      data/www/login.html
  4. 67 0
      src/api/api_server.cpp

+ 42 - 0
data/www/assets/app.js

@@ -59,6 +59,7 @@
     redisCfgEditor: null,
     nginxCfgEditor: null,
     selfInfo: null,
+    panelName: "NGS",
     siteLogKind: "access",
     siteLogIp: "",
     siteLogQuery: "",
@@ -103,6 +104,7 @@
     processes: { title: "进程管理", eyebrow: "Processes" },
     shell: { title: "终端", eyebrow: "Shell" },
     plans: { title: "计划", eyebrow: "Plans" },
+    settings: { title: "设置", eyebrow: "Settings" },
   };
 
   function toast(msg, type = "ok") {
@@ -166,6 +168,7 @@
     const t = titles[name] || titles.console;
     $("#page-title").textContent = t.title;
     $("#page-eyebrow").textContent = t.eyebrow;
+    document.title = `${state.panelName || "NGS"} · ${t.title}`;
     history.replaceState(null, "", `#${name}`);
     if (name === "console") startMetrics();
     else stopMetrics();
@@ -189,6 +192,29 @@
     } else {
       stopScheduleAutoRefresh();
     }
+    if (name === "settings") fillSettingsForm();
+  }
+
+  function applyPanelName(name) {
+    const n = String(name || "").trim() || "NGS";
+    state.panelName = n;
+    const mark = $("#brand-mark");
+    const brand = $("#brand-name");
+    if (mark) mark.textContent = [...n][0] || "N";
+    if (brand) {
+      brand.textContent = n;
+      brand.title = n;
+    }
+    const view = (location.hash || "#console").slice(1);
+    const t = titles[view] || titles.console;
+    document.title = `${n} · ${t.title}`;
+    const input = $("#setting-panel-name");
+    if (input && document.activeElement !== input) input.value = n;
+  }
+
+  function fillSettingsForm() {
+    const input = $("#setting-panel-name");
+    if (input) input.value = state.panelName || "NGS";
   }
 
   function escapeHtml(s) {
@@ -2126,6 +2152,7 @@
       const [status, sites] = await Promise.all([api("/api/status"), api("/api/websites")]);
       state.status = status;
       state.sites = Array.isArray(sites) ? sites : [];
+      if (status && status.panel_name) applyPanelName(status.panel_name);
       try {
         state.databases = (await api("/api/mysql/databases")) || [];
       } catch (_) {
@@ -4662,6 +4689,21 @@
       withBusy(async () => openIpAccessDetail(state.analyticsSite, ip));
     });
 
+    $("#settings-form")?.addEventListener("submit", (e) => {
+      e.preventDefault();
+      const btn = $("#btn-settings-save");
+      withBusy(async () => {
+        const name = String($("#setting-panel-name")?.value || "").trim();
+        if (!name) throw new Error("请填写网站名称");
+        const data = await api("/api/settings", {
+          method: "POST",
+          body: { panel_name: name },
+        });
+        applyPanelName((data && data.panel_name) || name);
+        toast("已保存");
+      }, btn);
+    });
+
     $("#btn-self-check")?.addEventListener("click", (e) => {
       withBusy(async () => {
         const info = await loadSelfVersion({ fetchRemote: true });

+ 23 - 3
data/www/index.html

@@ -16,9 +16,9 @@
   <div class="app d-flex">
     <aside class="rail flex-shrink-0">
       <div class="brand d-flex align-items-center gap-2 px-3 py-2">
-        <div class="brand-mark">N</div>
-        <div class="brand-text flex-grow-1">
-          <div class="fw-bold text-white lh-1">NGS</div>
+        <div class="brand-mark" id="brand-mark">N</div>
+        <div class="brand-text flex-grow-1 min-w-0">
+          <div class="fw-bold text-white lh-1 text-truncate" id="brand-name">NGS</div>
           <div class="small text-white-50">管理面板</div>
         </div>
         <button type="button" class="btn btn-sm btn-outline-light position-relative" id="task-badge" title="任务队列">
@@ -35,6 +35,7 @@
         <button type="button" class="nav-link text-start nav-item" data-view="shell"><i class="bi bi-terminal me-2"></i>终端</button>
         <button type="button" class="nav-link text-start nav-item" data-view="software"><i class="bi bi-box-seam me-2"></i>软件</button>
         <button type="button" class="nav-link text-start nav-item" data-view="plans"><i class="bi bi-calendar2-check me-2"></i>计划</button>
+        <button type="button" class="nav-link text-start nav-item" data-view="settings"><i class="bi bi-gear me-2"></i>设置</button>
       </nav>
       <div class="rail-foot px-2 pb-2">
         <div class="self-update px-1 pb-2" id="self-update-box">
@@ -396,6 +397,25 @@
           </div>
         </div>
       </section>
+
+      <section class="view" id="view-settings" data-view-panel="settings">
+        <div class="card">
+          <div class="card-header py-2">
+            <strong>面板设置</strong>
+            <span class="small text-secondary ms-2">名称会显示在侧栏、浏览器标题和登录页</span>
+          </div>
+          <div class="card-body">
+            <form id="settings-form" class="col-lg-6 col-xl-5">
+              <div class="mb-3">
+                <label class="form-label" for="setting-panel-name">网站名称</label>
+                <input class="form-control" id="setting-panel-name" name="panel_name" maxlength="64"
+                       required placeholder="NGS" autocomplete="off" />
+              </div>
+              <button type="submit" class="btn btn-primary btn-sm" id="btn-settings-save">保存</button>
+            </form>
+          </div>
+        </div>
+      </section>
     </main>
   </div>
 

+ 12 - 2
data/www/login.html

@@ -66,9 +66,9 @@
   <div class="login-page">
     <form class="login-card" id="login-form" autocomplete="on">
       <div class="login-brand">
-        <div class="brand-mark">N</div>
+        <div class="brand-mark" id="login-brand-mark">N</div>
         <div>
-          <div class="fw-bold lh-1">NGS</div>
+          <div class="fw-bold lh-1" id="login-brand-name">NGS</div>
           <div class="small text-secondary">管理面板登录</div>
         </div>
       </div>
@@ -93,6 +93,16 @@
   </div>
   <script>
     (async () => {
+      try {
+        const ping = await fetch("/api/ping", { credentials: "same-origin" });
+        const pj = await ping.json();
+        const name = String((pj && pj.data && pj.data.panel_name) || "").trim() || "NGS";
+        document.title = name + " · 登录";
+        const mark = document.getElementById("login-brand-mark");
+        const brand = document.getElementById("login-brand-name");
+        if (mark) mark.textContent = Array.from(name)[0] || "N";
+        if (brand) brand.textContent = name;
+      } catch (_) {}
       try {
         const r = await fetch("/api/auth/me", { credentials: "same-origin" });
         const j = await r.json();

+ 67 - 0
src/api/api_server.cpp

@@ -28,6 +28,7 @@
 #include "util/json.h"
 
 #include <atomic>
+#include <cctype>
 #include <chrono>
 #include <cstdint>
 #include <cstdlib>
@@ -336,12 +337,43 @@ void reg(ylib::network::http::router* router, const std::string& path,
 
 // ---- handlers ----
 
+std::string trim_copy(std::string s) {
+    size_t a = 0;
+    while (a < s.size() &&
+           std::isspace(static_cast<unsigned char>(s[a]))) {
+        ++a;
+    }
+    size_t b = s.size();
+    while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) {
+        --b;
+    }
+    return s.substr(a, b - a);
+}
+
+std::string panel_display_name() {
+    std::string n = trim_copy(store::get_setting("panel.name", ""));
+    if (n.empty()) {
+        return "NGS";
+    }
+    if (n.size() > 64) {
+        n.resize(64);
+    }
+    return n;
+}
+
+ylib::json settings_json() {
+    ylib::json data;
+    data["panel_name"] = panel_display_name();
+    return data;
+}
+
 void h_ping(request* req, response* resp) {
     if (!require_method(req, resp, "GET")) {
         return;
     }
     ylib::json data;
     data["name"] = "ngs";
+    data["panel_name"] = panel_display_name();
     data["listen"] = std::string(kDefaultListenAddr) + ":" +
                      std::to_string(kDefaultListenPort);
     reply_ok(resp, data);
@@ -713,9 +745,43 @@ void h_status(request* req, response* resp) {
     data["redis"] = rd;
 
     data["sites"] = static_cast<int>(website::list_sites().size());
+    data["panel_name"] = panel_display_name();
     reply_ok(resp, data);
 }
 
+void h_settings(request* req, response* resp) {
+    const std::string method = req->method();
+    if (method == "GET") {
+        reply_ok(resp, settings_json());
+        return;
+    }
+    if (method == "POST") {
+        const auto body = parse_body(req);
+        std::string name = trim_copy(json_str(body, "panel_name"));
+        if (name.empty()) {
+            reply_err(resp, "请填写网站名称");
+            return;
+        }
+        if (name.size() > 64) {
+            reply_err(resp, "网站名称最多 64 个字符");
+            return;
+        }
+        for (unsigned char c : name) {
+            if (c < 0x20) {
+                reply_err(resp, "网站名称不能包含控制字符");
+                return;
+            }
+        }
+        if (!store::set_setting("panel.name", name)) {
+            reply_err(resp, "保存失败");
+            return;
+        }
+        reply_ok(resp, settings_json(), "已保存");
+        return;
+    }
+    reply_err(resp, "method not allowed", 405);
+}
+
 ylib::json self_info_json(const selfupdate::Info& info) {
     ylib::json data;
     data["source_dir"] = info.source_dir;
@@ -2498,6 +2564,7 @@ void register_routes(ylib::network::http::router* router) {
     reg(router, "/api/auth/me", h_auth_me, false);
 
     reg(router, "/api/status", h_status);
+    reg(router, "/api/settings", h_settings);
     reg(router, "/api/self/version", h_self_version);
     reg(router, "/api/self/check", h_self_check);
     reg(router, "/api/self/update", h_self_update);