diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py
index 970e0a5..41fbbc3 100644
--- a/crypto_monitor_binance/app.py
+++ b/crypto_monitor_binance/app.py
@@ -1235,6 +1235,11 @@ def init_db():
(id INTEGER PRIMARY KEY AUTOINCREMENT, transfer_type TEXT, transfer_day TEXT,
amount REAL, from_account TEXT, to_account TEXT, status TEXT, message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
+ c.execute(
+ """CREATE TABLE IF NOT EXISTS app_runtime_settings
+ (key TEXT PRIMARY KEY, value TEXT,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"""
+ )
c.execute('''DROP INDEX IF EXISTS idx_transfer_logs_unique_day''')
c.execute('''CREATE UNIQUE INDEX IF NOT EXISTS idx_transfer_logs_auto_daily_unique
ON transfer_logs(transfer_type, transfer_day)
@@ -7252,6 +7257,7 @@ def render_main_page(page="trade", embed_mode=None):
conn.close()
from lib.instance.instance_embed_lib import embed_context_extras
from lib.instance.instance_settings_lib import settings_page_context
+ from lib.instance.instance_display_prefs_lib import display_prefs_template_context
template_ctx = dict(
page=page,
@@ -7334,6 +7340,7 @@ def render_main_page(page="trade", embed_mode=None):
kline_timeframe=KLINE_TIMEFRAME,
**strategy_extra,
**embed_context_extras("binance"),
+ **display_prefs_template_context(get_db),
**settings_page_context(
page,
exchange_key="binance",
@@ -7391,6 +7398,12 @@ def risk_policy_page():
return render_main_page("risk_policy")
+@app.route("/env_config")
+@login_required
+def env_config_page():
+ return render_main_page("env_config")
+
+
@app.route("/settings")
@login_required
def settings_page():
@@ -9883,6 +9896,21 @@ try:
except Exception as _hub_err:
print(f"[hub_bridge] binance: {_hub_err}")
+try:
+ from lib.instance.instance_settings_register import register_instance_settings_routes
+
+ register_instance_settings_routes(
+ app,
+ get_db=get_db,
+ login_required_fn=login_required,
+ base_dir=BASE_DIR,
+ exchange_key="binance",
+ username=USERNAME,
+ password=PASSWORD,
+ )
+except Exception as _settings_err:
+ print(f"[instance_settings] binance: {_settings_err}")
+
@app.route("/strategy")
@login_required
diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py
index 3d0d116..e246323 100644
--- a/crypto_monitor_gate/app.py
+++ b/crypto_monitor_gate/app.py
@@ -1226,6 +1226,11 @@ def init_db():
(id INTEGER PRIMARY KEY AUTOINCREMENT, transfer_type TEXT, transfer_day TEXT,
amount REAL, from_account TEXT, to_account TEXT, status TEXT, message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
+ c.execute(
+ """CREATE TABLE IF NOT EXISTS app_runtime_settings
+ (key TEXT PRIMARY KEY, value TEXT,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"""
+ )
c.execute('''DROP INDEX IF EXISTS idx_transfer_logs_unique_day''')
c.execute('''CREATE UNIQUE INDEX IF NOT EXISTS idx_transfer_logs_auto_daily_unique
ON transfer_logs(transfer_type, transfer_day)
@@ -7046,6 +7051,7 @@ def render_main_page(page="trade", embed_mode=None):
conn.close()
from lib.instance.instance_embed_lib import embed_context_extras
from lib.instance.instance_settings_lib import settings_page_context
+ from lib.instance.instance_display_prefs_lib import display_prefs_template_context
template_ctx = dict(
page=page,
@@ -7137,6 +7143,7 @@ def render_main_page(page="trade", embed_mode=None):
has_active_positions=bool(order_list),
),
**embed_context_extras("gate"),
+ **display_prefs_template_context(get_db),
**settings_page_context(
page,
exchange_key="gate",
@@ -7206,6 +7213,12 @@ def risk_policy_page():
return render_main_page("risk_policy")
+@app.route("/env_config")
+@login_required
+def env_config_page():
+ return render_main_page("env_config")
+
+
@app.route("/settings")
@login_required
def settings_page():
@@ -9758,6 +9771,21 @@ try:
except Exception as _hub_err:
print(f"[hub_bridge] gate: {_hub_err}")
+try:
+ from lib.instance.instance_settings_register import register_instance_settings_routes
+
+ register_instance_settings_routes(
+ app,
+ get_db=get_db,
+ login_required_fn=login_required,
+ base_dir=BASE_DIR,
+ exchange_key="gate",
+ username=USERNAME,
+ password=PASSWORD,
+ )
+except Exception as _settings_err:
+ print(f"[instance_settings] gate: {_settings_err}")
+
@app.route("/strategy")
@login_required
diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py
index 7cc4b99..1bc0cfc 100644
--- a/crypto_monitor_okx/app.py
+++ b/crypto_monitor_okx/app.py
@@ -6645,6 +6645,7 @@ def render_main_page(page="trade", embed_mode=None):
conn.close()
from lib.instance.instance_embed_lib import embed_context_extras
from lib.instance.instance_settings_lib import settings_page_context
+ from lib.instance.instance_display_prefs_lib import display_prefs_template_context
template_ctx = dict(
page=page,
@@ -6743,6 +6744,7 @@ def render_main_page(page="trade", embed_mode=None):
exchange_pnl_sync=exchange_pnl_sync,
**strategy_extra,
**embed_context_extras("okx"),
+ **display_prefs_template_context(get_db),
**settings_page_context(
page,
exchange_key="okx",
@@ -6828,6 +6830,15 @@ def risk_policy_page():
return render_main_page("risk_policy")
+@app.route("/env_config")
+@login_required
+def env_config_page():
+ redir = redirect_to_embed_shell_if_enabled("env_config")
+ if redir is not None:
+ return redir
+ return render_main_page("env_config")
+
+
@app.route("/settings")
@login_required
def settings_page():
@@ -9432,6 +9443,21 @@ try:
except Exception as _hub_err:
print(f"[hub_bridge] okx: {_hub_err}")
+try:
+ from lib.instance.instance_settings_register import register_instance_settings_routes
+
+ register_instance_settings_routes(
+ app,
+ get_db=get_db,
+ login_required_fn=login_required,
+ base_dir=BASE_DIR,
+ exchange_key="okx",
+ username=USERNAME,
+ password=PASSWORD,
+ )
+except Exception as _settings_err:
+ print(f"[instance_settings] okx: {_settings_err}")
+
@app.route("/strategy")
@login_required
diff --git a/lib/common/static/instance_embed.js b/lib/common/static/instance_embed.js
index b33659b..c95391b 100644
--- a/lib/common/static/instance_embed.js
+++ b/lib/common/static/instance_embed.js
@@ -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();
diff --git a/lib/common/static/instance_settings_prefs.js b/lib/common/static/instance_settings_prefs.js
new file mode 100644
index 0000000..8098613
--- /dev/null
+++ b/lib/common/static/instance_settings_prefs.js
@@ -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 = '' + (e.message || "加载失败") + "";
+ }
+ }
+
+ 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 = '
加载配置中…
';
+ 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 = '' + (e.message || "加载失败") + "";
+ }
+ }
+
+ 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);
diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css
index 7a32a71..99c5983 100644
--- a/lib/common/static/instance_theme.css
+++ b/lib/common/static/instance_theme.css
@@ -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;
diff --git a/lib/env/env_file_lib.py b/lib/env/env_file_lib.py
new file mode 100644
index 0000000..25def74
--- /dev/null
+++ b/lib/env/env_file_lib.py
@@ -0,0 +1,121 @@
+"""读写实例目录 .env(行级 upsert,原子落盘)。"""
+from __future__ import annotations
+
+import os
+import re
+import tempfile
+from typing import Optional
+
+_KEY_LINE = re.compile(r"^(\s*)([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$")
+
+
+def parse_env_lines(text: str) -> list[str]:
+ return text.replace("\r\n", "\n").replace("\r", "\n").splitlines()
+
+
+def read_env_lines(path: str) -> list[str]:
+ if not os.path.isfile(path):
+ return []
+ with open(path, "r", encoding="utf-8", errors="ignore") as f:
+ return parse_env_lines(f.read())
+
+
+def env_get(lines: list[str], key: str) -> Optional[str]:
+ for line in lines:
+ m = _KEY_LINE.match(line)
+ if m and m.group(2) == key:
+ raw = m.group(3).strip()
+ if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")):
+ return raw[1:-1]
+ return raw
+ return None
+
+
+def env_get_all(lines: list[str]) -> dict[str, str]:
+ out: dict[str, str] = {}
+ for line in lines:
+ m = _KEY_LINE.match(line)
+ if m:
+ key = m.group(2)
+ raw = m.group(3).strip()
+ if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")):
+ out[key] = raw[1:-1]
+ else:
+ out[key] = raw
+ return out
+
+
+def upsert_env_line(lines: list[str], key: str, value: str) -> list[str]:
+ pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=")
+ out: list[str] = []
+ replaced = False
+ safe = value if value is not None else ""
+ if any(c in safe for c in (' ', '#', '"', "'")):
+ safe = '"' + safe.replace("\\", "\\\\").replace('"', '\\"') + '"'
+ new_line = f"{key}={safe}"
+ for line in lines:
+ if pat.match(line):
+ if not replaced:
+ out.append(new_line)
+ replaced = True
+ continue
+ out.append(line)
+ if not replaced:
+ if out and out[-1].strip():
+ out.append("")
+ out.append(new_line)
+ return out
+
+
+def write_env_lines_atomic(path: str, lines: list[str]) -> None:
+ directory = os.path.dirname(os.path.abspath(path)) or "."
+ os.makedirs(directory, exist_ok=True)
+ fd, tmp = tempfile.mkstemp(prefix=".env.", dir=directory, text=True)
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
+ f.write("\n".join(lines))
+ if lines:
+ f.write("\n")
+ os.replace(tmp, path)
+ finally:
+ if os.path.exists(tmp):
+ try:
+ os.remove(tmp)
+ except OSError:
+ pass
+
+
+def apply_env_updates(path: str, updates: dict[str, str]) -> list[str]:
+ lines = read_env_lines(path)
+ changed: list[str] = []
+ for key, value in updates.items():
+ if value is None:
+ continue
+ old = env_get(lines, key)
+ if old == value:
+ continue
+ lines = upsert_env_line(lines, key, value)
+ changed.append(key)
+ if changed:
+ write_env_lines_atomic(path, lines)
+ return changed
+
+
+def load_env_file_into_environ(path: str) -> None:
+ if not os.path.exists(path):
+ return
+ with open(path, "r", encoding="utf-8", errors="ignore") as f:
+ text = f.read()
+ if text.startswith("\ufeff"):
+ text = text[1:]
+ for line in parse_env_lines(text):
+ s = line.strip()
+ if not s or s.startswith("#"):
+ continue
+ if "=" not in s:
+ continue
+ k, _, v = s.partition("=")
+ clean_key = k.strip()
+ clean_val = v.strip().strip('"').strip("'")
+ if clean_key:
+ os.environ[clean_key] = clean_val
diff --git a/lib/env/env_schema.py b/lib/env/env_schema.py
new file mode 100644
index 0000000..cea5ac7
--- /dev/null
+++ b/lib/env/env_schema.py
@@ -0,0 +1,252 @@
+"""从 .env.example 构建 env 配置 schema(分组、敏感、重启标注)。"""
+from __future__ import annotations
+
+import os
+import re
+from typing import Any, Optional
+
+from lib.env.env_file_lib import env_get, env_get_all, read_env_lines
+
+_GROUP_RE = re.compile(r"^#\s*=+\s*(.+?)\s*=+\s*$")
+_KEY_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=")
+
+RESTART_REQUIRED_EXACT = frozenset({
+ "APP_HOST",
+ "APP_PORT",
+ "APP_DEBUG",
+ "DB_PATH",
+ "UPLOAD_DIR",
+ "FLASK_SECRET_KEY",
+ "POSITION_SIZING_MODE",
+ "LIVE_TRADING_ENABLED",
+ "OKX_TD_MODE",
+ "OKX_POS_MODE",
+ "OKX_POSITION_INST_TYPE",
+ "BINANCE_MARGIN_MODE",
+ "BINANCE_POSITION_MODE",
+ "GATE_TD_MODE",
+ "GATE_POS_MODE",
+ "PM2_APP_NAME",
+})
+
+RESTART_REQUIRED_PREFIXES = (
+ "OKX_API_",
+ "OKX_OPTIONS_API_",
+ "BINANCE_API_",
+ "GATE_API_",
+ "OKX_SOCKS_",
+ "OKX_HTTP_",
+ "OKX_HTTPS_",
+ "BINANCE_HTTP_",
+ "BINANCE_HTTPS_",
+ "GATE_HTTP_",
+ "GATE_HTTPS_",
+)
+
+HOT_RELOAD_EXACT = frozenset({
+ "RISK_PERCENT",
+ "MAX_ACTIVE_POSITIONS",
+ "MANUAL_MIN_PLANNED_RR",
+ "KEY_AUTO_MIN_PLANNED_RR",
+ "DAILY_OPEN_ALERT_THRESHOLD",
+ "DAILY_OPEN_HARD_LIMIT",
+ "TRADING_DAY_RESET_HOUR",
+ "TRADING_DAY_RESET_OPEN_GUARD_ENABLED",
+ "RISK_CONTROL_ENABLED",
+ "RISK_COOLING_HOURS_MANUAL",
+ "RISK_COOLING_HOURS_MANUAL_JOURNAL",
+ "RISK_MANUAL_CLOSE_DAILY_LIMIT",
+ "RISK_MOOD_ISSUES_DAILY_FREEZE",
+ "KEY_AUTO_ORDER_ENABLED",
+ "TRADE_DIRECTION_RESTRICT_ENABLED",
+ "TRADE_DIRECTION",
+ "TRADE_SYMBOL_RESTRICT_ENABLED",
+ "TRADE_SYMBOL_WHITELIST",
+ "BALANCE_REFRESH_SECONDS",
+ "PRICE_REFRESH_SECONDS",
+ "MONITOR_POLL_SECONDS",
+ "AUTO_TRANSFER_ENABLED",
+ "AUTO_TRANSFER_AMOUNT",
+ "AUTO_TRANSFER_BJ_HOUR",
+ "FORCE_CLOSE_ENABLED",
+ "FORCE_CLOSE_BJ_HOUR",
+ "BTC_LEVERAGE",
+ "ALT_LEVERAGE",
+ "DAILY_START_CAPITAL",
+ "DAILY_LOSS_CAPITAL",
+ "DAILY_PROFIT_CAPITAL",
+ "FULL_MARGIN_BUFFER_RATIO",
+ "APP_USERNAME",
+ "APP_PASSWORD",
+ "APP_AUTH_DISABLED",
+ "WECHAT_WEBHOOK",
+})
+
+SENSITIVE_EXACT = frozenset({
+ "APP_PASSWORD",
+ "FLASK_SECRET_KEY",
+ "HUB_BRIDGE_TOKEN",
+ "OPENAI_API_KEY",
+})
+
+SENSITIVE_SUBSTR = ("_SECRET", "_PASSPHRASE", "_API_KEY", "_PASSWORD")
+
+
+def _is_sensitive(key: str) -> bool:
+ if key in SENSITIVE_EXACT:
+ return True
+ return any(s in key for s in SENSITIVE_SUBSTR)
+
+
+def _restart_required(key: str) -> bool:
+ if key in HOT_RELOAD_EXACT:
+ return False
+ if key in RESTART_REQUIRED_EXACT:
+ return True
+ return any(key.startswith(p) for p in RESTART_REQUIRED_PREFIXES)
+
+
+def _hot_reload(key: str) -> bool:
+ if key in HOT_RELOAD_EXACT:
+ return True
+ if _restart_required(key):
+ return False
+ return key.startswith(("KEY_", "KLINE_", "BREAKEVEN_", "RECONCILE_", "ORDER_CHART_"))
+
+
+def _field_type(key: str, value: str) -> str:
+ low = (value or "").strip().lower()
+ if low in ("true", "false"):
+ return "bool"
+ if key.endswith("_ENABLED") or key.startswith("RISK_MOOD_"):
+ return "bool"
+ try:
+ if "." in low:
+ float(low)
+ return "float"
+ int(low)
+ return "int"
+ except ValueError:
+ pass
+ return "text"
+
+
+def _mask_value(key: str, value: Optional[str]) -> dict[str, Any]:
+ if value is None or value == "":
+ return {"value": "", "masked": "", "has_value": False}
+ if not _is_sensitive(key):
+ return {"value": value, "masked": value, "has_value": True}
+ tail = value[-4:] if len(value) >= 4 else value
+ return {"value": "", "masked": f"****{tail}", "has_value": True}
+
+
+def parse_env_example_schema(example_path: str) -> list[dict[str, Any]]:
+ if not os.path.isfile(example_path):
+ return []
+ lines = read_env_lines(example_path)
+ groups: list[dict[str, Any]] = []
+ group_map: dict[str, dict[str, Any]] = {}
+ current_group = "应用与鉴权"
+ pending_note: list[str] = []
+
+ def _ensure_group(title: str) -> dict[str, Any]:
+ if title not in group_map:
+ group_map[title] = {"title": title, "fields": []}
+ groups.append(group_map[title])
+ return group_map[title]
+
+ for raw in lines:
+ line = raw.rstrip()
+ stripped = line.strip()
+ if not stripped:
+ pending_note = []
+ continue
+ gm = _GROUP_RE.match(stripped)
+ if gm:
+ current_group = gm.group(1).strip()
+ pending_note = []
+ continue
+ if stripped.startswith("#"):
+ note = stripped.lstrip("#").strip()
+ if note and not note.startswith("="):
+ pending_note.append(note)
+ continue
+ km = _KEY_LINE.match(stripped)
+ if not km:
+ continue
+ key = km.group(1)
+ default_val = env_get(lines, key) or ""
+ grp = _ensure_group(current_group)
+ note = " ".join(pending_note).strip()
+ grp["fields"].append(
+ {
+ "key": key,
+ "label": key,
+ "note": note,
+ "default": default_val,
+ "type": _field_type(key, default_val),
+ "sensitive": _is_sensitive(key),
+ "restart_required": _restart_required(key),
+ "hot_reload": _hot_reload(key),
+ }
+ )
+ pending_note = []
+ return groups
+
+
+def build_env_payload(example_path: str, env_path: str) -> dict[str, Any]:
+ groups = parse_env_example_schema(example_path)
+ env_lines = read_env_lines(env_path)
+ values = env_get_all(env_lines)
+ for group in groups:
+ for field in group.get("fields") or []:
+ key = field["key"]
+ val = values.get(key)
+ if val is None:
+ val = field.get("default") or ""
+ masked = _mask_value(key, val)
+ field["current"] = masked["value"] if not field["sensitive"] else ""
+ field["masked"] = masked["masked"]
+ field["has_value"] = masked["has_value"]
+ return {"groups": groups}
+
+
+def validate_env_updates(groups: list[dict], updates: dict[str, str]) -> tuple[dict[str, str], list[str]]:
+ allowed = {}
+ for group in groups:
+ for field in group.get("fields") or []:
+ allowed[field["key"]] = field
+ clean: dict[str, str] = {}
+ errors: list[str] = []
+ for key, value in (updates or {}).items():
+ if key not in allowed:
+ errors.append(f"未知配置项: {key}")
+ continue
+ if value is None:
+ continue
+ val = str(value).strip()
+ if allowed[key].get("sensitive") and val == "":
+ continue
+ ftype = allowed[key].get("type")
+ if ftype == "bool":
+ low = val.lower()
+ if low not in ("true", "false", "1", "0", "yes", "no", "on", "off"):
+ errors.append(f"{key} 须为 true/false")
+ continue
+ val = "true" if low in ("true", "1", "yes", "on") else "false"
+ clean[key] = val
+ return clean, errors
+
+
+def updates_need_restart(groups: list[dict], changed_keys: list[str]) -> bool:
+ field_map = {}
+ for group in groups:
+ for field in group.get("fields") or []:
+ field_map[field["key"]] = field
+ for key in changed_keys:
+ meta = field_map.get(key) or {}
+ if meta.get("restart_required"):
+ return True
+ if not meta.get("hot_reload"):
+ return True
+ return False
diff --git a/lib/instance/instance_display_prefs_lib.py b/lib/instance/instance_display_prefs_lib.py
new file mode 100644
index 0000000..ca5b02e
--- /dev/null
+++ b/lib/instance/instance_display_prefs_lib.py
@@ -0,0 +1,118 @@
+"""实例顶栏 / 系统设置区块显示开关(存 SQLite,即时生效)。"""
+from __future__ import annotations
+
+from typing import Any, Callable, Optional
+
+from lib.instance.runtime_settings_lib import runtime_get_prefix, runtime_set_many, with_db
+
+DISPLAY_RUNTIME_PREFIX = "display."
+
+DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
+ "show_nav_strategy": True,
+ "show_nav_strategy_records": True,
+ "show_nav_records": True,
+ "show_nav_stats": True,
+ "show_nav_risk_policy": True,
+ "show_nav_env_config": True,
+ "show_nav_options": True,
+ "show_settings_transfer": True,
+ "show_settings_export": True,
+ "show_settings_password": True,
+ "show_settings_options_swap": True,
+ "show_settings_options_transfer": True,
+}
+
+DISPLAY_LABELS: dict[str, str] = {
+ "show_nav_strategy": "策略交易",
+ "show_nav_strategy_records": "策略交易记录",
+ "show_nav_records": "交易记录与复盘",
+ "show_nav_stats": "统计分析",
+ "show_nav_risk_policy": "风控说明",
+ "show_nav_env_config": "env配置",
+ "show_nav_options": "期权",
+ "show_settings_transfer": "资金划转",
+ "show_settings_export": "数据导出",
+ "show_settings_password": "账户密码修改",
+ "show_settings_options_swap": "期权币种兑换",
+ "show_settings_options_transfer": "期权资金划转",
+}
+
+NAV_TAB_ALLOWED: dict[str, str] = {
+ "strategy": "show_nav_strategy",
+ "strategy_records": "show_nav_strategy_records",
+ "records": "show_nav_records",
+ "stats": "show_nav_stats",
+ "risk_policy": "show_nav_risk_policy",
+ "env_config": "show_nav_env_config",
+ "options": "show_nav_options",
+}
+
+
+def normalize_display_prefs(raw: dict | None) -> dict[str, bool]:
+ out = dict(DEFAULT_INSTANCE_DISPLAY)
+ if isinstance(raw, dict):
+ for key in DEFAULT_INSTANCE_DISPLAY:
+ if key in raw:
+ out[key] = bool(raw[key])
+ return out
+
+
+def _load_from_conn(conn) -> dict[str, bool]:
+ stored = runtime_get_prefix(conn, DISPLAY_RUNTIME_PREFIX)
+ merged: dict[str, Any] = {}
+ for key in DEFAULT_INSTANCE_DISPLAY:
+ sk = key
+ if sk in stored:
+ merged[key] = stored[sk].strip().lower() in ("1", "true", "yes", "on")
+ return normalize_display_prefs(merged)
+
+
+def get_display_prefs(get_db: Callable) -> dict[str, bool]:
+ return with_db(get_db, _load_from_conn)
+
+
+def save_display_prefs(get_db: Callable, prefs: dict) -> dict[str, bool]:
+ normalized = normalize_display_prefs(prefs)
+
+ def _save(conn):
+ mapping = {DISPLAY_RUNTIME_PREFIX + k: ("1" if v else "0") for k, v in normalized.items()}
+ runtime_set_many(conn, mapping)
+ return normalized
+
+ return with_db(get_db, _save)
+
+
+def display_prefs_template_context(get_db: Callable) -> dict[str, Any]:
+ prefs = get_display_prefs(get_db)
+ return {"display": prefs}
+
+
+def tab_allowed(tab: str, display: Optional[dict[str, bool]] = None) -> bool:
+ prefs = normalize_display_prefs(display or {})
+ key = NAV_TAB_ALLOWED.get((tab or "").strip())
+ if not key:
+ return True
+ return bool(prefs.get(key, True))
+
+
+def display_meta_for_ui() -> list[dict[str, Any]]:
+ nav_keys = [
+ "show_nav_strategy",
+ "show_nav_strategy_records",
+ "show_nav_records",
+ "show_nav_stats",
+ "show_nav_risk_policy",
+ "show_nav_env_config",
+ "show_nav_options",
+ ]
+ settings_keys = [
+ "show_settings_transfer",
+ "show_settings_export",
+ "show_settings_password",
+ "show_settings_options_swap",
+ "show_settings_options_transfer",
+ ]
+ return [
+ {"group": "顶栏导航", "keys": [{"key": k, "label": DISPLAY_LABELS[k]} for k in nav_keys]},
+ {"group": "系统设置区块", "keys": [{"key": k, "label": DISPLAY_LABELS[k]} for k in settings_keys]},
+ ]
diff --git a/lib/instance/instance_embed_context_lib.py b/lib/instance/instance_embed_context_lib.py
index f7d7463..3cd6bc9 100644
--- a/lib/instance/instance_embed_context_lib.py
+++ b/lib/instance/instance_embed_context_lib.py
@@ -38,7 +38,7 @@ def embed_render_plan(page: str, embed_mode: str | None) -> EmbedRenderPlan:
)
is_shell = embed_mode == "shell"
is_strategy = page in EMBED_STRATEGY_PAGES
- is_settings_like = page in ("settings", "risk_policy")
+ is_settings_like = page in ("settings", "risk_policy", "env_config")
return EmbedRenderPlan(
exchange_capitals=is_shell,
records_rows=page == "records",
diff --git a/lib/instance/instance_embed_lib.py b/lib/instance/instance_embed_lib.py
index 29fe341..6504a5a 100644
--- a/lib/instance/instance_embed_lib.py
+++ b/lib/instance/instance_embed_lib.py
@@ -19,6 +19,7 @@ EMBED_TABS: tuple[str, ...] = (
"records",
"stats",
"risk_policy",
+ "env_config",
"settings",
)
@@ -34,6 +35,7 @@ PATH_TO_EMBED_TAB: dict[str, str] = {
"/records": "records",
"/stats": "stats",
"/risk_policy": "risk_policy",
+ "/env_config": "env_config",
"/settings": "settings",
}
@@ -166,6 +168,9 @@ def register_embed_routes(
tab = (tab or "").strip()
if tab not in EMBED_TABS:
return jsonify({"ok": False, "msg": "unknown tab"}), 404
+ allowed_fn = app.config.get("INSTANCE_TAB_ALLOWED_FN")
+ if callable(allowed_fn) and not allowed_fn(tab):
+ return jsonify({"ok": False, "msg": "tab disabled"}), 403
html = render_main_page_fn(tab, embed_mode="fragment")
if isinstance(html, Response):
html = html.get_data(as_text=True)
diff --git a/lib/instance/instance_pm2_lib.py b/lib/instance/instance_pm2_lib.py
new file mode 100644
index 0000000..1b73ae9
--- /dev/null
+++ b/lib/instance/instance_pm2_lib.py
@@ -0,0 +1,49 @@
+"""PM2 重启当前实例(仅 Linux 部署环境)。"""
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+from typing import Any
+
+
+def default_pm2_app_name(exchange_key: str) -> str:
+ mapping = {
+ "okx": "crypto_okx",
+ "binance": "crypto_binance",
+ "gate": "crypto_gate",
+ }
+ return mapping.get((exchange_key or "").strip().lower(), "crypto_okx")
+
+
+def resolve_pm2_app_name(exchange_key: str) -> str:
+ explicit = (os.getenv("PM2_APP_NAME") or "").strip()
+ if explicit:
+ return explicit
+ return default_pm2_app_name(exchange_key)
+
+
+def restart_instance_pm2(exchange_key: str) -> dict[str, Any]:
+ if not sys.platform.startswith("linux"):
+ return {"ok": False, "msg": "仅 Linux 服务器支持 PM2 重启", "app": None}
+ app_name = resolve_pm2_app_name(exchange_key)
+ try:
+ proc = subprocess.run(
+ ["pm2", "restart", app_name, "--update-env"],
+ capture_output=True,
+ text=True,
+ timeout=120,
+ )
+ ok = proc.returncode == 0
+ return {
+ "ok": ok,
+ "app": app_name,
+ "msg": (proc.stdout or proc.stderr or "").strip()[:500],
+ "returncode": proc.returncode,
+ }
+ except FileNotFoundError:
+ return {"ok": False, "msg": "未找到 pm2 命令", "app": app_name}
+ except subprocess.TimeoutExpired:
+ return {"ok": False, "msg": "pm2 restart 超时", "app": app_name}
+ except Exception as e:
+ return {"ok": False, "msg": str(e), "app": app_name}
diff --git a/lib/instance/instance_settings_lib.py b/lib/instance/instance_settings_lib.py
index c784887..cacbd65 100644
--- a/lib/instance/instance_settings_lib.py
+++ b/lib/instance/instance_settings_lib.py
@@ -184,6 +184,7 @@ def build_instance_settings_view(
def settings_page_context(page: str, **kwargs: Any) -> dict[str, Any]:
- if (page or "").strip() not in ("settings", "risk_policy"):
+ p = (page or "").strip()
+ if p not in ("settings", "risk_policy", "env_config"):
return {}
return {"instance_settings": build_instance_settings_view(**kwargs)}
diff --git a/lib/instance/instance_settings_register.py b/lib/instance/instance_settings_register.py
new file mode 100644
index 0000000..02a72f4
--- /dev/null
+++ b/lib/instance/instance_settings_register.py
@@ -0,0 +1,178 @@
+"""实例系统设置 API:导航开关、env 读写、改密、PM2 重启。"""
+from __future__ import annotations
+
+import os
+from functools import wraps
+from typing import Any, Callable
+
+from flask import jsonify, request, session
+
+from lib.env.env_file_lib import apply_env_updates, env_get, read_env_lines
+from lib.env.env_schema import (
+ build_env_payload,
+ parse_env_example_schema,
+ updates_need_restart,
+ validate_env_updates,
+)
+from lib.instance.instance_display_prefs_lib import (
+ display_meta_for_ui,
+ get_display_prefs,
+ normalize_display_prefs,
+ save_display_prefs,
+ tab_allowed,
+)
+from lib.instance.instance_pm2_lib import restart_instance_pm2
+from lib.instance.runtime_config_lib import apply_env_reload
+
+
+def _api_login_required(hub_token_write_allowed: bool = False):
+ def decorator(f):
+ @wraps(f)
+ def wrapped(*args, **kwargs):
+ from lib.hub.hub_auth import request_allowed as hub_request_allowed
+
+ logged_in = bool(session.get("logged_in"))
+ auth_disabled = (os.getenv("APP_AUTH_DISABLED") or "").strip().lower() in (
+ "1",
+ "true",
+ "yes",
+ "on",
+ )
+ hub_hdr = (request.headers.get("X-Hub-Token") or "").strip()
+ bridge = (os.getenv("HUB_BRIDGE_TOKEN") or "").strip()
+ if hub_hdr and bridge and hub_hdr == bridge and not hub_token_write_allowed:
+ return jsonify({"ok": False, "msg": "Hub Token 不可修改实例设置"}), 403
+ if hub_request_allowed(logged_in, auth_disabled):
+ return f(*args, **kwargs)
+ return jsonify({"ok": False, "msg": "未登录"}), 401
+
+ return wrapped
+
+ return decorator
+
+
+def register_instance_settings_routes(
+ app,
+ *,
+ get_db: Callable,
+ login_required_fn: Callable,
+ base_dir: str,
+ exchange_key: str,
+ username: str,
+ password: str,
+) -> None:
+ env_path = os.path.join(base_dir, ".env")
+ example_path = os.path.join(base_dir, ".env.example")
+ api_auth = _api_login_required()
+
+ @app.route("/api/settings/display", methods=["GET"])
+ @api_auth
+ def api_get_display_prefs():
+ prefs = get_display_prefs(get_db)
+ return jsonify(
+ {
+ "ok": True,
+ "display": prefs,
+ "meta": display_meta_for_ui(),
+ }
+ )
+
+ @app.route("/api/settings/display", methods=["POST"])
+ @api_auth
+ def api_save_display_prefs():
+ body = request.get_json(silent=True) or {}
+ raw = body.get("display") if isinstance(body.get("display"), dict) else body
+ saved = save_display_prefs(get_db, raw)
+ return jsonify({"ok": True, "display": saved})
+
+ @app.route("/api/settings/env/meta", methods=["GET"])
+ @api_auth
+ def api_env_meta():
+ payload = build_env_payload(example_path, env_path)
+ return jsonify({"ok": True, **payload})
+
+ @app.route("/api/settings/env", methods=["GET"])
+ @api_auth
+ def api_env_get():
+ payload = build_env_payload(example_path, env_path)
+ return jsonify({"ok": True, **payload})
+
+ @app.route("/api/settings/env", methods=["POST"])
+ @api_auth
+ def api_env_post():
+ body = request.get_json(silent=True) or {}
+ updates = body.get("values") if isinstance(body.get("values"), dict) else body
+ if not isinstance(updates, dict):
+ return jsonify({"ok": False, "msg": "无效请求体"}), 400
+ groups = parse_env_example_schema(example_path)
+ clean, errors = validate_env_updates(groups, updates)
+ if errors:
+ return jsonify({"ok": False, "msg": "; ".join(errors)}), 400
+ if not clean:
+ return jsonify({"ok": True, "changed_keys": [], "restart_required": False})
+ changed = apply_env_updates(env_path, clean)
+ reload_info = apply_env_reload(env_path, get_db, changed, groups)
+ return jsonify(
+ {
+ "ok": True,
+ "changed_keys": changed,
+ "restart_required": reload_info.get("restart_required", False),
+ }
+ )
+
+ @app.route("/api/settings/password", methods=["POST"])
+ @api_auth
+ def api_change_password():
+ body = request.get_json(silent=True) or {}
+ old_password = str(body.get("old_password") or "")
+ new_username = str(body.get("new_username") or "").strip()
+ new_password = str(body.get("new_password") or "")
+ confirm = str(body.get("confirm_password") or "")
+ if not old_password or old_password != password:
+ return jsonify({"ok": False, "msg": "当前密码错误"}), 400
+ if len(new_password) < 6:
+ return jsonify({"ok": False, "msg": "新密码至少 6 位"}), 400
+ if new_password != confirm:
+ return jsonify({"ok": False, "msg": "两次输入的新密码不一致"}), 400
+ updates: dict[str, str] = {"APP_PASSWORD": new_password}
+ if new_username:
+ updates["APP_USERNAME"] = new_username
+ changed = apply_env_updates(env_path, updates)
+ groups = parse_env_example_schema(example_path)
+ apply_env_reload(env_path, get_db, changed, groups)
+ return jsonify({"ok": True, "restart_required": True, "changed_keys": changed})
+
+ @app.route("/api/admin/restart", methods=["POST"])
+ @api_auth
+ def api_admin_restart():
+ result = restart_instance_pm2(exchange_key)
+ code = 200 if result.get("ok") else 500
+ return jsonify({"ok": bool(result.get("ok")), **result}), code
+
+ @app.route("/api/admin/health", methods=["GET"])
+ def api_admin_health():
+ return jsonify({"ok": True, "status": "up"})
+
+ def tab_allowed_fn(tab: str) -> bool:
+ prefs = get_display_prefs(get_db)
+ return tab_allowed(tab, prefs)
+
+ app.config["INSTANCE_GET_DB"] = get_db
+ app.config["INSTANCE_TAB_ALLOWED_FN"] = tab_allowed_fn
+
+ @app.route("/api/embed/tab_allowed/", methods=["GET"])
+ @api_auth
+ def api_tab_allowed(tab: str):
+ prefs = get_display_prefs(get_db)
+ return jsonify({"ok": True, "tab": tab, "allowed": tab_allowed(tab, prefs)})
+
+
+def merge_ui_template_context(page: str, get_db: Callable, **settings_kwargs: Any) -> dict[str, Any]:
+ from lib.instance.instance_settings_lib import settings_page_context
+
+ prefs = get_display_prefs(get_db)
+ ctx = {
+ "display": prefs,
+ **settings_page_context(page, **settings_kwargs),
+ }
+ return ctx
diff --git a/lib/instance/runtime_config_lib.py b/lib/instance/runtime_config_lib.py
new file mode 100644
index 0000000..7db8ee6
--- /dev/null
+++ b/lib/instance/runtime_config_lib.py
@@ -0,0 +1,62 @@
+"""env 运行时覆盖:热生效项优先读 SQLite,再回退 os.environ。"""
+from __future__ import annotations
+
+import os
+from typing import Callable, Optional
+
+from lib.env.env_file_lib import load_env_file_into_environ
+from lib.instance.runtime_settings_lib import runtime_get, with_db
+
+ENV_OVERRIDE_PREFIX = "env."
+
+
+def runtime_env_key(name: str) -> str:
+ return ENV_OVERRIDE_PREFIX + name
+
+
+def get_config(key: str, get_db: Callable, default: Optional[str] = None) -> Optional[str]:
+ def _read(conn):
+ v = runtime_get(conn, runtime_env_key(key))
+ return v
+
+ try:
+ v = with_db(get_db, _read)
+ if v is not None:
+ return v
+ except Exception:
+ pass
+ raw = os.getenv(key)
+ if raw is None or raw == "":
+ return default
+ return raw
+
+
+def set_config_overrides(get_db: Callable, mapping: dict[str, str]) -> None:
+ from lib.instance.runtime_settings_lib import runtime_set_many
+
+ def _write(conn):
+ payload = {runtime_env_key(k): str(v) for k, v in mapping.items()}
+ runtime_set_many(conn, payload)
+
+ with_db(get_db, _write)
+
+
+def apply_env_reload(env_path: str, get_db: Callable, changed_keys: list[str], groups: list[dict]) -> dict[str, bool]:
+ """写盘后同步 os.environ,并将可热生效项写入 runtime 覆盖。"""
+ load_env_file_into_environ(env_path)
+ hot: dict[str, str] = {}
+ field_map = {}
+ for group in groups:
+ for field in group.get("fields") or []:
+ field_map[field["key"]] = field
+ for key in changed_keys:
+ meta = field_map.get(key) or {}
+ if meta.get("hot_reload") and not meta.get("restart_required"):
+ val = os.getenv(key)
+ if val is not None:
+ hot[key] = val
+ if hot:
+ set_config_overrides(get_db, hot)
+ from lib.env.env_schema import updates_need_restart
+
+ return {"restart_required": updates_need_restart(groups, changed_keys)}
diff --git a/lib/instance/runtime_settings_lib.py b/lib/instance/runtime_settings_lib.py
new file mode 100644
index 0000000..fd6415f
--- /dev/null
+++ b/lib/instance/runtime_settings_lib.py
@@ -0,0 +1,71 @@
+"""实例 SQLite 运行时配置(导航开关、env 热覆盖等)。"""
+from __future__ import annotations
+
+import sqlite3
+from datetime import datetime
+from typing import Any, Callable, Optional
+
+RUNTIME_TABLE_SQL = """
+CREATE TABLE IF NOT EXISTS app_runtime_settings (
+ key TEXT PRIMARY KEY,
+ value TEXT,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+)
+"""
+
+
+def ensure_runtime_settings_table(conn: sqlite3.Connection) -> None:
+ conn.execute(RUNTIME_TABLE_SQL)
+ conn.commit()
+
+
+def runtime_get(conn: sqlite3.Connection, key: str) -> Optional[str]:
+ row = conn.execute(
+ "SELECT value FROM app_runtime_settings WHERE key=?",
+ (key,),
+ ).fetchone()
+ if not row:
+ return None
+ val = row["value"] if isinstance(row, sqlite3.Row) else row[0]
+ return None if val is None else str(val)
+
+
+def runtime_set(conn: sqlite3.Connection, key: str, value: str) -> None:
+ now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
+ conn.execute(
+ "INSERT INTO app_runtime_settings(key, value, updated_at) VALUES (?,?,?) "
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
+ (key, value, now),
+ )
+ conn.commit()
+
+
+def runtime_get_prefix(conn: sqlite3.Connection, prefix: str) -> dict[str, str]:
+ rows = conn.execute(
+ "SELECT key, value FROM app_runtime_settings WHERE key LIKE ?",
+ (prefix + "%",),
+ ).fetchall()
+ out: dict[str, str] = {}
+ for row in rows:
+ k = row["key"] if isinstance(row, sqlite3.Row) else row[0]
+ v = row["value"] if isinstance(row, sqlite3.Row) else row[1]
+ if k.startswith(prefix):
+ out[k[len(prefix) :]] = v if v is not None else ""
+ return out
+
+
+def runtime_set_many(conn: sqlite3.Connection, mapping: dict[str, str]) -> None:
+ for key, value in mapping.items():
+ runtime_set(conn, key, value)
+
+
+def with_db(
+ get_db: Callable[[], sqlite3.Connection],
+ fn: Callable[[sqlite3.Connection], Any],
+) -> Any:
+ conn = get_db()
+ try:
+ ensure_runtime_settings_table(conn)
+ return fn(conn)
+ finally:
+ conn.close()
diff --git a/lib/instance/templates/display_prefs_panel.html b/lib/instance/templates/display_prefs_panel.html
new file mode 100644
index 0000000..43dae42
--- /dev/null
+++ b/lib/instance/templates/display_prefs_panel.html
@@ -0,0 +1,12 @@
+{# 系统设置 · 导航显示开关 #}
+
+
导航显示
+
以下开关控制顶栏导航与系统设置内区块是否显示,保存后立即生效。关键位监控、实盘下单、系统设置为固定项。
+
+
+
+
+
+
diff --git a/lib/instance/templates/embed_page_fragment.html b/lib/instance/templates/embed_page_fragment.html
index 5d5624b..1264da4 100644
--- a/lib/instance/templates/embed_page_fragment.html
+++ b/lib/instance/templates/embed_page_fragment.html
@@ -396,6 +396,9 @@
{% endif %}
+ {% if page == 'env_config' %}
+ {% include 'env_config_panel.html' %}
+ {% endif %}
{% if page == 'risk_policy' %}
{% include 'risk_policy_panel.html' %}
{% endif %}
diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html
index 21a2c71..179de05 100644
--- a/lib/instance/templates/embed_shell.html
+++ b/lib/instance/templates/embed_shell.html
@@ -7,7 +7,7 @@
-
+
{{ exchange_display }} · 加密货币 | 交易监控复盘系统
@@ -30,22 +30,33 @@
{% include 'instance_header_panel.html' %}
- {% if initial_tab not in ('settings', 'risk_policy') and include_transfer_block %}
+ {% if initial_tab not in ('settings', 'risk_policy', 'env_config') and include_transfer_block %}
{% include 'instance_top_bar.html' %}
{% endif %}
@@ -89,7 +100,11 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
{% include 'embed_boot_scripts.html' %}
-
-
+
+
+
+