Add instance nav prefs, env config UI, and PM2 restart.
P1-P4: configurable nav/section visibility in system settings, full .env editor with restart badges, password change, runtime hot overrides, and single-instance pm2 restart. Works in hub embed iframe. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
records: "/records",
|
||||
stats: "/stats",
|
||||
risk_policy: "/risk_policy",
|
||||
env_config: "/env_config",
|
||||
settings: "/settings",
|
||||
};
|
||||
|
||||
@@ -52,6 +53,13 @@
|
||||
});
|
||||
}
|
||||
|
||||
function pageNavAllowed(tab) {
|
||||
if (global.InstanceSettingsPrefs && typeof global.InstanceSettingsPrefs.pageNavAllowed === "function") {
|
||||
return global.InstanceSettingsPrefs.pageNavAllowed(tab);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function syncUrl(tab, replace) {
|
||||
const q = new URLSearchParams(location.search);
|
||||
q.set("tab", tab);
|
||||
@@ -99,6 +107,16 @@
|
||||
if (!revisit && tab === "stats") {
|
||||
if (typeof global.initStatsSegmentFromUrl === "function") global.initStatsSegmentFromUrl();
|
||||
}
|
||||
if (!revisit && (tab === "settings" || tab === "env_config")) {
|
||||
if (global.InstanceSettingsPrefs) {
|
||||
if (tab === "settings" && typeof global.InstanceSettingsPrefs.loadDisplayPrefsForm === "function") {
|
||||
global.InstanceSettingsPrefs.loadDisplayPrefsForm();
|
||||
}
|
||||
if (tab === "env_config" && typeof global.InstanceSettingsPrefs.loadEnvConfig === "function") {
|
||||
global.InstanceSettingsPrefs.loadEnvConfig();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!revisit) {
|
||||
if (typeof global.refreshAccountSnapshot === "function") {
|
||||
global.refreshAccountSnapshot({ silent: true });
|
||||
@@ -243,7 +261,7 @@
|
||||
}
|
||||
|
||||
function syncShellChrome(tab) {
|
||||
const hideTopBar = tab === "settings" || tab === "risk_policy";
|
||||
const hideTopBar = tab === "settings" || tab === "risk_policy" || tab === "env_config";
|
||||
document.querySelectorAll(".instance-top-bar").forEach((el) => {
|
||||
el.hidden = hideTopBar;
|
||||
});
|
||||
@@ -286,6 +304,10 @@
|
||||
async function loadTab(tab, opts) {
|
||||
const options = opts || {};
|
||||
if (!tab) return;
|
||||
if (!pageNavAllowed(tab)) {
|
||||
void loadTab("trade", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (tabPanes.has(tab) && !options.force) {
|
||||
activateTab(tab, Object.assign({}, options, { revisit: true }));
|
||||
@@ -434,7 +456,12 @@
|
||||
patchApplyListWindow();
|
||||
patchHardNavigations();
|
||||
initBootPane();
|
||||
if (getTab() === "settings") {
|
||||
const bootTab = getTab();
|
||||
if (!pageNavAllowed(bootTab)) {
|
||||
void loadTab("trade", { replace: true });
|
||||
return;
|
||||
}
|
||||
if (bootTab === "settings") {
|
||||
initPaneThemeToggle("settings");
|
||||
}
|
||||
bindNav();
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* 实例:导航显示、env 配置、改密、PM2 重启。
|
||||
*/
|
||||
(function (global) {
|
||||
const DISPLAY = () => global.__INSTANCE_DISPLAY__ || {};
|
||||
|
||||
function setStatus(el, text, isErr) {
|
||||
if (!el) return;
|
||||
el.textContent = text || "";
|
||||
el.classList.toggle("err", !!isErr);
|
||||
}
|
||||
|
||||
async function fetchJson(url, opts) {
|
||||
const res = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {}));
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.msg || res.statusText || "请求失败");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function applyDisplayToNav(display) {
|
||||
const map = {
|
||||
strategy: "show_nav_strategy",
|
||||
strategy_records: "show_nav_strategy_records",
|
||||
records: "show_nav_records",
|
||||
stats: "show_nav_stats",
|
||||
options: "show_nav_options",
|
||||
risk_policy: "show_nav_risk_policy",
|
||||
env_config: "show_nav_env_config",
|
||||
};
|
||||
document.querySelectorAll(".embed-top-nav [data-embed-tab], .top-nav a[href^='/']").forEach((a) => {
|
||||
const tab = a.getAttribute("data-embed-tab") || (a.getAttribute("href") || "").replace(/^\//, "").split("?")[0];
|
||||
const key = map[tab];
|
||||
if (!key) return;
|
||||
const show = display[key] !== false;
|
||||
a.classList.toggle("nav-hidden", !show);
|
||||
a.style.display = show ? "" : "none";
|
||||
});
|
||||
global.__INSTANCE_DISPLAY__ = display;
|
||||
}
|
||||
|
||||
function pageNavAllowed(tab) {
|
||||
const d = DISPLAY();
|
||||
const map = {
|
||||
strategy: "show_nav_strategy",
|
||||
strategy_records: "show_nav_strategy_records",
|
||||
records: "show_nav_records",
|
||||
stats: "show_nav_stats",
|
||||
options: "show_nav_options",
|
||||
risk_policy: "show_nav_risk_policy",
|
||||
env_config: "show_nav_env_config",
|
||||
};
|
||||
const key = map[tab];
|
||||
if (!key) return true;
|
||||
return d[key] !== false;
|
||||
}
|
||||
|
||||
async function loadDisplayPrefsForm() {
|
||||
const root = document.getElementById("display-prefs-form");
|
||||
if (!root) return;
|
||||
try {
|
||||
const data = await fetchJson("/api/settings/display");
|
||||
const display = data.display || {};
|
||||
const meta = data.meta || [];
|
||||
root.innerHTML = "";
|
||||
meta.forEach((group) => {
|
||||
const section = document.createElement("div");
|
||||
section.className = "display-prefs-group";
|
||||
const title = document.createElement("h3");
|
||||
title.className = "settings-subcard-title";
|
||||
title.textContent = group.group;
|
||||
section.appendChild(title);
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "display-prefs-checks";
|
||||
(group.keys || []).forEach((item) => {
|
||||
const label = document.createElement("label");
|
||||
label.className = "chk-label";
|
||||
const cb = document.createElement("input");
|
||||
cb.type = "checkbox";
|
||||
cb.dataset.prefKey = item.key;
|
||||
cb.checked = display[item.key] !== false;
|
||||
label.appendChild(cb);
|
||||
label.appendChild(document.createTextNode(" " + item.label));
|
||||
grid.appendChild(label);
|
||||
});
|
||||
section.appendChild(grid);
|
||||
root.appendChild(section);
|
||||
});
|
||||
} catch (e) {
|
||||
root.innerHTML = '<span class="err">' + (e.message || "加载失败") + "</span>";
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDisplayPrefs() {
|
||||
const status = document.getElementById("display-prefs-status");
|
||||
const display = {};
|
||||
document.querySelectorAll("#display-prefs-form input[data-pref-key]").forEach((cb) => {
|
||||
display[cb.dataset.prefKey] = !!cb.checked;
|
||||
});
|
||||
try {
|
||||
const data = await fetchJson("/api/settings/display", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ display }),
|
||||
});
|
||||
applyDisplayToNav(data.display || display);
|
||||
setStatus(status, "已保存,导航已更新");
|
||||
} catch (e) {
|
||||
setStatus(status, e.message || "保存失败", true);
|
||||
}
|
||||
}
|
||||
|
||||
let envSchemaGroups = [];
|
||||
|
||||
function renderEnvField(field) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "env-field-card";
|
||||
const label = document.createElement("label");
|
||||
label.className = "env-field-label";
|
||||
label.textContent = field.key;
|
||||
card.appendChild(label);
|
||||
if (field.note) {
|
||||
const note = document.createElement("div");
|
||||
note.className = "env-field-note muted";
|
||||
note.textContent = field.note;
|
||||
card.appendChild(note);
|
||||
}
|
||||
let input;
|
||||
if (field.type === "bool") {
|
||||
input = document.createElement("select");
|
||||
["true", "false"].forEach((v) => {
|
||||
const o = document.createElement("option");
|
||||
o.value = v;
|
||||
o.textContent = v;
|
||||
input.appendChild(o);
|
||||
});
|
||||
const cur = (field.current || field.default || "false").toLowerCase();
|
||||
input.value = cur === "true" || cur === "1" ? "true" : "false";
|
||||
} else {
|
||||
input = document.createElement("input");
|
||||
input.type = field.sensitive ? "password" : "text";
|
||||
if (field.sensitive && field.has_value) {
|
||||
input.placeholder = field.masked || "留空不修改";
|
||||
} else {
|
||||
input.value = field.current || field.default || "";
|
||||
}
|
||||
}
|
||||
input.dataset.envKey = field.key;
|
||||
input.className = "env-field-input";
|
||||
card.appendChild(input);
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "env-field-badge " + (field.restart_required ? "env-badge-restart" : "env-badge-hot");
|
||||
badge.textContent = field.restart_required ? "需重启" : "保存即生效";
|
||||
card.appendChild(badge);
|
||||
return card;
|
||||
}
|
||||
|
||||
async function loadEnvConfig() {
|
||||
const grid = document.getElementById("env-config-grid");
|
||||
if (!grid) return;
|
||||
grid.innerHTML = '<div class="env-config-loading muted">加载配置中…</div>';
|
||||
try {
|
||||
const data = await fetchJson("/api/settings/env");
|
||||
envSchemaGroups = data.groups || [];
|
||||
grid.innerHTML = "";
|
||||
envSchemaGroups.forEach((group) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "card env-group-card";
|
||||
const h = document.createElement("h3");
|
||||
h.className = "env-group-title";
|
||||
h.textContent = group.title || "其他";
|
||||
card.appendChild(h);
|
||||
const inner = document.createElement("div");
|
||||
inner.className = "env-group-fields";
|
||||
(group.fields || []).forEach((field) => inner.appendChild(renderEnvField(field)));
|
||||
card.appendChild(inner);
|
||||
grid.appendChild(card);
|
||||
});
|
||||
} catch (e) {
|
||||
grid.innerHTML = '<span class="err">' + (e.message || "加载失败") + "</span>";
|
||||
}
|
||||
}
|
||||
|
||||
function collectEnvValues() {
|
||||
const values = {};
|
||||
document.querySelectorAll(".env-field-input[data-env-key]").forEach((el) => {
|
||||
values[el.dataset.envKey] = el.value;
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
async function saveEnvConfig(restartAfter) {
|
||||
const status = document.getElementById("env-config-status");
|
||||
setStatus(status, "保存中…");
|
||||
try {
|
||||
const data = await fetchJson("/api/settings/env", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ values: collectEnvValues() }),
|
||||
});
|
||||
const needRestart = restartAfter || data.restart_required;
|
||||
if (needRestart) {
|
||||
setStatus(status, "已保存,正在重启实例…");
|
||||
await restartInstance();
|
||||
setStatus(status, "保存并重启完成");
|
||||
await loadEnvConfig();
|
||||
} else {
|
||||
setStatus(status, "已保存(即时生效项已应用)");
|
||||
await loadEnvConfig();
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus(status, e.message || "保存失败", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function restartInstance() {
|
||||
await fetchJson("/api/admin/restart", { method: "POST" });
|
||||
const deadline = Date.now() + 90000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
try {
|
||||
const h = await fetch("/api/admin/health", { credentials: "same-origin" });
|
||||
if (h.ok) return;
|
||||
} catch (_) {}
|
||||
}
|
||||
throw new Error("重启后服务未在预期时间内恢复");
|
||||
}
|
||||
|
||||
async function savePassword() {
|
||||
const status = document.getElementById("pwd-save-status");
|
||||
const body = {
|
||||
old_password: (document.getElementById("pwd-old") || {}).value || "",
|
||||
new_username: (document.getElementById("pwd-new-username") || {}).value || "",
|
||||
new_password: (document.getElementById("pwd-new") || {}).value || "",
|
||||
confirm_password: (document.getElementById("pwd-confirm") || {}).value || "",
|
||||
};
|
||||
try {
|
||||
const data = await fetchJson("/api/settings/password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (data.restart_required) {
|
||||
setStatus(status, "密码已保存,正在重启…");
|
||||
await restartInstance();
|
||||
setStatus(status, "密码已更新,请用新密码登录");
|
||||
} else {
|
||||
setStatus(status, "密码已更新");
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus(status, e.message || "保存失败", true);
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
const dSave = document.getElementById("display-prefs-save");
|
||||
if (dSave) dSave.addEventListener("click", saveDisplayPrefs);
|
||||
const eSave = document.getElementById("env-config-save");
|
||||
if (eSave) eSave.addEventListener("click", () => saveEnvConfig(false));
|
||||
const eRestart = document.getElementById("env-config-save-restart");
|
||||
if (eRestart) eRestart.addEventListener("click", () => saveEnvConfig(true));
|
||||
const eReload = document.getElementById("env-config-reload");
|
||||
if (eReload) eReload.addEventListener("click", loadEnvConfig);
|
||||
const pSave = document.getElementById("pwd-save-btn");
|
||||
if (pSave) pSave.addEventListener("click", savePassword);
|
||||
}
|
||||
|
||||
function initPage() {
|
||||
bindEvents();
|
||||
if (document.getElementById("display-prefs-form")) loadDisplayPrefsForm();
|
||||
if (document.getElementById("env-config-grid")) loadEnvConfig();
|
||||
if (global.__INSTANCE_DISPLAY__) applyDisplayToNav(global.__INSTANCE_DISPLAY__);
|
||||
}
|
||||
|
||||
global.InstanceSettingsPrefs = {
|
||||
pageNavAllowed,
|
||||
applyDisplayToNav,
|
||||
loadDisplayPrefsForm,
|
||||
loadEnvConfig,
|
||||
restartInstance,
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initPage);
|
||||
} else {
|
||||
initPage();
|
||||
}
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -2181,6 +2181,153 @@ html[data-theme="light"] .journal-detail-img-thumb {
|
||||
background: #eef2f7;
|
||||
}
|
||||
|
||||
.nav-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ── env 配置页(三列卡片) ── */
|
||||
.env-config-page {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.env-config-head {
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.env-config-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.env-config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.env-group-card {
|
||||
padding: 10px 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.env-group-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.env-group-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.env-field-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.env-field-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: #a8b0cc;
|
||||
}
|
||||
|
||||
.env-field-note {
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.env-field-input {
|
||||
width: 100%;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.env-field-badge {
|
||||
font-size: 0.65rem;
|
||||
align-self: flex-start;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.env-badge-hot {
|
||||
background: rgba(56, 178, 120, 0.15);
|
||||
color: #6ee7a8;
|
||||
}
|
||||
|
||||
.env-badge-restart {
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.display-prefs-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.display-prefs-checks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
}
|
||||
|
||||
.display-prefs-checks .chk-label {
|
||||
font-size: 0.82rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.settings-password-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px 12px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.settings-password-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted, #8892b0);
|
||||
}
|
||||
|
||||
.settings-actions-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.settings-status-line.err {
|
||||
color: var(--danger, #f87171);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.env-config-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.env-config-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.settings-password-form {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 风控说明页 ── */
|
||||
.risk-policy-page {
|
||||
margin-top: 12px;
|
||||
|
||||
Reference in New Issue
Block a user