Add curated env config UI with Chinese labels and deploy secret bootstrap
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -145,7 +145,7 @@
|
||||
card.className = "env-field-card";
|
||||
const label = document.createElement("label");
|
||||
label.className = "env-field-label";
|
||||
label.textContent = field.key;
|
||||
label.textContent = field.label || field.key;
|
||||
card.appendChild(label);
|
||||
if (field.note) {
|
||||
const note = document.createElement("div");
|
||||
@@ -156,10 +156,13 @@
|
||||
let input;
|
||||
if (field.type === "bool") {
|
||||
input = document.createElement("select");
|
||||
["true", "false"].forEach((v) => {
|
||||
[
|
||||
["true", "开启"],
|
||||
["false", "关闭"],
|
||||
].forEach(([v, text]) => {
|
||||
const o = document.createElement("option");
|
||||
o.value = v;
|
||||
o.textContent = v;
|
||||
o.textContent = text;
|
||||
input.appendChild(o);
|
||||
});
|
||||
const cur = (field.current || field.default || "false").toLowerCase();
|
||||
|
||||
Vendored
+245
@@ -0,0 +1,245 @@
|
||||
"""env 配置页 UI 白名单:中文标签、按交易所过滤。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from lib.env.env_file_lib import env_get_all, read_env_lines
|
||||
from lib.env.env_schema import (
|
||||
_field_type,
|
||||
_hot_reload,
|
||||
_is_sensitive,
|
||||
_mask_value,
|
||||
_restart_required,
|
||||
parse_env_example_schema,
|
||||
)
|
||||
|
||||
# 各所「交易所与实盘」字段(顺序即页面顺序)
|
||||
_EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = {
|
||||
"okx": [
|
||||
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
|
||||
("OKX_API_KEY", "API Key", "永续子账户"),
|
||||
("OKX_API_SECRET", "API Secret", "永续子账户"),
|
||||
("OKX_API_PASSPHRASE", "API Passphrase", "OKX 必填"),
|
||||
("OKX_TD_MODE", "保证金模式", "cross=全仓,isolated=逐仓"),
|
||||
("OKX_POS_MODE", "持仓模式", "hedge=双向,net=单向净持仓"),
|
||||
("OKX_POSITION_INST_TYPE", "仓位查询类型", "如 SWAP"),
|
||||
("OKX_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
|
||||
],
|
||||
"binance": [
|
||||
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
|
||||
("BINANCE_API_KEY", "API Key", "永续子账户"),
|
||||
("BINANCE_API_SECRET", "API Secret", "永续子账户"),
|
||||
("BINANCE_MARGIN_MODE", "保证金模式", "cross=全仓,isolated=逐仓"),
|
||||
("BINANCE_POSITION_MODE", "持仓模式", "hedge=双向,one_way=单向"),
|
||||
("BINANCE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
|
||||
],
|
||||
"gate": [
|
||||
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
|
||||
("GATE_API_KEY", "API Key", "永续子账户"),
|
||||
("GATE_API_SECRET", "API Secret", "永续子账户"),
|
||||
("GATE_TD_MODE", "保证金模式", "cross=全仓,isolated=逐仓"),
|
||||
("GATE_POS_MODE", "持仓模式", "hedge=双向,single=单向"),
|
||||
("GATE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
|
||||
],
|
||||
}
|
||||
|
||||
_SHARED_SECTIONS: list[dict[str, Any]] = [
|
||||
{
|
||||
"title": "企业微信",
|
||||
"fields": [
|
||||
("WECHAT_WEBHOOK", "机器人 Webhook", "行情与风控推送地址"),
|
||||
("WECHAT_TIMEOUT_SECONDS", "推送超时(秒)", "默认 10"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "AI 复盘",
|
||||
"fields": [
|
||||
("AI_PROVIDER", "AI 提供方", "openai 或 ollama"),
|
||||
("OPENAI_API_BASE", "API 地址", "OpenAI 兼容接口"),
|
||||
("OPENAI_API_KEY", "API 密钥", "留空表示不修改"),
|
||||
("OPENAI_MODEL", "云端模型", ""),
|
||||
("OLLAMA_API", "Ollama 地址", "本地服务 URL"),
|
||||
("AI_MODEL", "Ollama 模型", ""),
|
||||
("AI_TIMEOUT_SECONDS", "请求超时(秒)", "默认 120"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "交易执行",
|
||||
"fields": [
|
||||
("POSITION_SIZING_MODE", "计仓模式", "risk=以损定仓,full_margin=全仓杠杆"),
|
||||
("RISK_PERCENT", "以损定仓风险%", "单笔风险占资金比例"),
|
||||
("FULL_MARGIN_BUFFER_RATIO", "全仓资金缓冲比例", "如 0.98"),
|
||||
("BTC_LEVERAGE", "BTC 默认杠杆", ""),
|
||||
("ALT_LEVERAGE", "山寨默认杠杆", ""),
|
||||
("TRADE_DIRECTION_RESTRICT_ENABLED", "方向限制开关", ""),
|
||||
("TRADE_DIRECTION", "允许方向", "long_only / short_only / both"),
|
||||
("TRADE_SYMBOL_RESTRICT_ENABLED", "币种白名单开关", ""),
|
||||
("TRADE_SYMBOL_WHITELIST", "白名单币种", "逗号分隔,如 BTC,ETH"),
|
||||
("TRADING_DAY_RESET_HOUR", "交易日切点(北京时间)", "整点,默认 8"),
|
||||
("TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "切点前禁止新开仓", ""),
|
||||
("MAX_ACTIVE_POSITIONS", "最大同时持仓", ""),
|
||||
("MANUAL_MIN_PLANNED_RR", "人工最低盈亏比", "如 1.4"),
|
||||
("FORCE_CLOSE_ENABLED", "强制清仓开关", ""),
|
||||
("FORCE_CLOSE_BJ_HOUR", "强制清仓整点(北京)", ""),
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "交易风控",
|
||||
"fields": [
|
||||
("DAILY_OPEN_ALERT_THRESHOLD", "单日开仓提醒阈值", "达次数后 AI 提醒,不拦单"),
|
||||
("DAILY_OPEN_HARD_LIMIT", "单日开仓硬上限", "0=不启用"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "账户冷静期",
|
||||
"fields": [
|
||||
("RISK_CONTROL_ENABLED", "冷静期总开关", ""),
|
||||
("RISK_COOLING_HOURS_MANUAL", "手动平仓冷静(小时)", ""),
|
||||
("RISK_COOLING_HOURS_MANUAL_JOURNAL", "复盘情绪冷静(小时)", ""),
|
||||
("RISK_MANUAL_CLOSE_DAILY_LIMIT", "日手动平仓次数上限", ""),
|
||||
("RISK_MOOD_ISSUES_DAILY_FREEZE", "情绪标签日冻结", ""),
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "自动划转",
|
||||
"fields": [
|
||||
("AUTO_TRANSFER_ENABLED", "启用自动划转", ""),
|
||||
("AUTO_TRANSFER_AMOUNT", "目标余额(U)", "交易账户目标 USDT"),
|
||||
("AUTO_TRANSFER_FROM", "划出账户", "funding 或 swap"),
|
||||
("AUTO_TRANSFER_TO", "划入账户", "swap 或 funding"),
|
||||
("AUTO_TRANSFER_BJ_HOUR", "执行整点(北京时间)", ""),
|
||||
("TRANSFER_CCY", "划转币种", "默认 USDT"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "当日资金",
|
||||
"fields": [
|
||||
("DAILY_START_CAPITAL", "日起始基数(U)", ""),
|
||||
("DAILY_LOSS_CAPITAL", "回撤后基数(U)", ""),
|
||||
("DAILY_PROFIT_CAPITAL", "盈利后基数(U)", ""),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
_OPTIONS_SECTION: dict[str, Any] = {
|
||||
"title": "期权账户",
|
||||
"exchanges": frozenset({"okx"}),
|
||||
"fields": [
|
||||
("OKX_OPTIONS_ENABLED", "启用期权模块", ""),
|
||||
("OKX_OPTIONS_API_KEY", "期权 API Key", "主账户,与永续子账户分离"),
|
||||
("OKX_OPTIONS_API_SECRET", "期权 API Secret", ""),
|
||||
("OKX_OPTIONS_API_PASSPHRASE", "期权 API Passphrase", ""),
|
||||
("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""),
|
||||
("OKX_OPTIONS_TRADE_BUDGET_USDC", "单笔预算(USDC)", ""),
|
||||
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95"),
|
||||
("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _schema_field_map(example_path: str) -> dict[str, dict[str, Any]]:
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for group in parse_env_example_schema(example_path):
|
||||
for field in group.get("fields") or []:
|
||||
out[field["key"]] = dict(field)
|
||||
return out
|
||||
|
||||
|
||||
def _build_field(
|
||||
key: str,
|
||||
label: str,
|
||||
note: str,
|
||||
schema: dict[str, dict[str, Any]],
|
||||
values: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
meta = schema.get(key) or {}
|
||||
default_val = meta.get("default") or values.get(key) or ""
|
||||
val = values.get(key)
|
||||
if val is None:
|
||||
val = default_val
|
||||
masked = _mask_value(key, val)
|
||||
ftype = meta.get("type") or _field_type(key, default_val)
|
||||
return {
|
||||
"key": key,
|
||||
"label": label,
|
||||
"note": note or meta.get("note") or "",
|
||||
"default": default_val,
|
||||
"type": ftype,
|
||||
"sensitive": meta.get("sensitive", _is_sensitive(key)),
|
||||
"restart_required": meta.get("restart_required", _restart_required(key)),
|
||||
"hot_reload": meta.get("hot_reload", _hot_reload(key)),
|
||||
"current": masked["value"] if not _is_sensitive(key) else "",
|
||||
"masked": masked["masked"],
|
||||
"has_value": masked["has_value"],
|
||||
}
|
||||
|
||||
|
||||
def ui_sections_for_exchange(exchange_key: str) -> list[dict[str, Any]]:
|
||||
ex = (exchange_key or "").strip().lower()
|
||||
sections: list[dict[str, Any]] = []
|
||||
live_fields = _EXCHANGE_LIVE_FIELDS.get(ex, _EXCHANGE_LIVE_FIELDS["okx"])
|
||||
sections.append({"title": "交易所与实盘", "fields": live_fields})
|
||||
sections.extend(_SHARED_SECTIONS)
|
||||
if ex in _OPTIONS_SECTION.get("exchanges", frozenset()):
|
||||
sections.append(_OPTIONS_SECTION)
|
||||
return sections
|
||||
|
||||
|
||||
def ui_allowed_keys(exchange_key: str) -> frozenset[str]:
|
||||
keys: set[str] = set()
|
||||
for sec in ui_sections_for_exchange(exchange_key):
|
||||
for item in sec["fields"]:
|
||||
keys.add(item[0])
|
||||
return frozenset(keys)
|
||||
|
||||
|
||||
def build_env_ui_payload(
|
||||
exchange_key: str,
|
||||
example_path: str,
|
||||
env_path: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
schema = _schema_field_map(example_path)
|
||||
env_lines = read_env_lines(env_path)
|
||||
values = env_get_all(env_lines)
|
||||
groups: list[dict[str, Any]] = []
|
||||
for sec in ui_sections_for_exchange(exchange_key):
|
||||
fields = [
|
||||
_build_field(key, label, note, schema, values)
|
||||
for key, label, note in sec["fields"]
|
||||
]
|
||||
groups.append({"title": sec["title"], "fields": fields})
|
||||
return groups
|
||||
|
||||
|
||||
def filter_updates_for_ui(exchange_key: str, updates: dict[str, str]) -> dict[str, str]:
|
||||
allowed = ui_allowed_keys(exchange_key)
|
||||
return {k: v for k, v in (updates or {}).items() if k in allowed}
|
||||
|
||||
|
||||
def validate_env_ui_updates(
|
||||
exchange_key: str,
|
||||
example_path: str,
|
||||
updates: dict[str, str],
|
||||
) -> tuple[dict[str, str], list[str]]:
|
||||
from lib.env.env_schema import validate_env_updates
|
||||
|
||||
schema = _schema_field_map(example_path)
|
||||
groups: list[dict[str, Any]] = []
|
||||
for sec in ui_sections_for_exchange(exchange_key):
|
||||
fields: list[dict[str, Any]] = []
|
||||
for key, _label, _note in sec["fields"]:
|
||||
if key in schema:
|
||||
fields.append(schema[key])
|
||||
else:
|
||||
default = ""
|
||||
fields.append(
|
||||
{
|
||||
"key": key,
|
||||
"type": _field_type(key, default),
|
||||
"sensitive": _is_sensitive(key),
|
||||
"restart_required": _restart_required(key),
|
||||
"hot_reload": _hot_reload(key),
|
||||
}
|
||||
)
|
||||
groups.append({"title": sec["title"], "fields": fields})
|
||||
return validate_env_updates(groups, updates)
|
||||
@@ -189,9 +189,10 @@ def settings_page_context(page: str, *, instance_base_dir: str | None = None, **
|
||||
return {}
|
||||
ctx: dict[str, Any] = {"instance_settings": build_instance_settings_view(**kwargs)}
|
||||
if p == "env_config" and instance_base_dir:
|
||||
from lib.env.env_schema import build_env_payload
|
||||
from lib.env.env_ui_manifest import build_env_ui_payload
|
||||
|
||||
exchange_key = str(kwargs.get("exchange_key") or "")
|
||||
env_path = os.path.join(instance_base_dir, ".env")
|
||||
example_path = os.path.join(instance_base_dir, ".env.example")
|
||||
ctx["env_config_groups"] = build_env_payload(example_path, env_path).get("groups") or []
|
||||
ctx["env_config_groups"] = build_env_ui_payload(exchange_key, example_path, env_path)
|
||||
return ctx
|
||||
|
||||
@@ -8,12 +8,12 @@ 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.env.env_ui_manifest import (
|
||||
build_env_ui_payload,
|
||||
filter_updates_for_ui,
|
||||
validate_env_ui_updates,
|
||||
)
|
||||
from lib.env.env_schema import parse_env_example_schema
|
||||
from lib.instance.instance_display_prefs_lib import (
|
||||
display_meta_for_ui,
|
||||
get_display_prefs,
|
||||
@@ -85,26 +85,27 @@ def register_instance_settings_routes(
|
||||
@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})
|
||||
groups = build_env_ui_payload(exchange_key, example_path, env_path)
|
||||
return jsonify({"ok": True, "groups": groups})
|
||||
|
||||
@app.route("/api/settings/env", methods=["GET", "POST"])
|
||||
@api_auth
|
||||
def api_settings_env():
|
||||
if request.method == "GET":
|
||||
payload = build_env_payload(example_path, env_path)
|
||||
return jsonify({"ok": True, **payload})
|
||||
groups = build_env_ui_payload(exchange_key, example_path, env_path)
|
||||
return jsonify({"ok": True, "groups": groups})
|
||||
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)
|
||||
updates = filter_updates_for_ui(exchange_key, updates)
|
||||
clean, errors = validate_env_ui_updates(exchange_key, example_path, 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)
|
||||
groups = parse_env_example_schema(example_path)
|
||||
reload_info = apply_env_reload(env_path, get_db, changed, groups)
|
||||
return jsonify(
|
||||
{
|
||||
|
||||
@@ -103,7 +103,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
|
||||
<script>
|
||||
window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
|
||||
</script>
|
||||
<script src="/static/instance_settings_prefs.js?v=5"></script>
|
||||
<script src="/static/instance_settings_prefs.js?v=6"></script>
|
||||
<script src="/static/instance_live.js?v=4"></script>
|
||||
<script src="/static/instance_embed.js?v=19"></script>
|
||||
</body>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{# env配置:按功能分组,三列卡片布局(SSR 预渲染) #}
|
||||
{# env配置:白名单分组,仅中文标签 #}
|
||||
<div class="env-config-page">
|
||||
<div class="env-config-head card">
|
||||
<h2>env 配置</h2>
|
||||
<p class="settings-env-hint">读取并编辑本实例 <code>.env</code>。标注「保存即生效」的项会立即应用;标注「需重启」的项保存后请点「保存并重启」。</p>
|
||||
<p class="settings-env-hint">仅展示运营相关配置。标注「保存即生效」的项会立即应用;标注「需重启」的项保存后请点「保存并重启」。完整说明见项目文档 <code>docs/env配置说明.md</code>。</p>
|
||||
<div class="env-config-toolbar">
|
||||
<button type="button" class="btn-primary btn-sm" id="env-config-save">保存</button>
|
||||
<button type="button" class="btn-secondary btn-sm" id="env-config-save-restart">保存并重启</button>
|
||||
@@ -18,15 +18,15 @@
|
||||
<div class="env-group-fields">
|
||||
{% for field in group.fields %}
|
||||
<div class="env-field-card">
|
||||
<label class="env-field-label">{{ field.key }}</label>
|
||||
<label class="env-field-label">{{ field.label or field.key }}</label>
|
||||
{% if field.note %}
|
||||
<div class="env-field-note muted">{{ field.note }}</div>
|
||||
{% endif %}
|
||||
{% if field.type == 'bool' %}
|
||||
<select class="env-field-input" data-env-key="{{ field.key }}">
|
||||
{% set cur = (field.current or field.default or 'false')|lower %}
|
||||
<option value="true"{% if cur in ('true', '1', 'yes', 'on') %} selected{% endif %}>true</option>
|
||||
<option value="false"{% if cur not in ('true', '1', 'yes', 'on') %} selected{% endif %}>false</option>
|
||||
<option value="true"{% if cur in ('true', '1', 'yes', 'on') %} selected{% endif %}>开启</option>
|
||||
<option value="false"{% if cur not in ('true', '1', 'yes', 'on') %} selected{% endif %}>关闭</option>
|
||||
</select>
|
||||
{% else %}
|
||||
<input
|
||||
|
||||
@@ -2038,6 +2038,6 @@ setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }})
|
||||
<script>
|
||||
window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
|
||||
</script>
|
||||
<script src="/static/instance_settings_prefs.js?v=5"></script>
|
||||
<script src="/static/instance_settings_prefs.js?v=6"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +1,6 @@
|
||||
{# 系统设置:2 列独立卡片 #}
|
||||
<div class="settings-page settings-page--grid">
|
||||
<p class="settings-env-hint muted" style="margin:0 0 10px">各区块说明见 <code>docs/系统设置说明.md</code>。</p>
|
||||
<div class="settings-grid-2col">
|
||||
<div class="settings-grid-cell">
|
||||
{% include 'display_prefs_panel.html' %}
|
||||
|
||||
Reference in New Issue
Block a user