Initial standalone crypto_okx with one-click deploy.

Add deploy/manage.sh bootstrap for git.bz121.com/dekun/crypto_okx and point docs at this repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-13 20:00:59 +08:00
commit a1abe159fa
238 changed files with 69214 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Shared library package."""
+130
View File
@@ -0,0 +1,130 @@
"""
每日自动划转:北京时间指定整点小时内,将交易账户(AUTO_TRANSFER_TO)余额调整至目标额.
- 交易账户 < 目标:从资金账户划入差额
- 交易账户 > 目标:将多余划回资金账户
- 有 active 持仓:不划转,写账簿并企业微信说明
"""
from __future__ import annotations
from typing import Any, Callable
def run_auto_transfer_once_per_day(
*,
enabled: bool,
bj_hour: int,
target_amount: float,
from_account: str,
to_account: str,
funds_decimals: int,
get_db: Callable[[], Any],
get_active_position_count: Callable[[Any], int],
get_account_usdt_total: Callable[[str], float | None],
execute_transfer_usdt: Callable[[float, str, str], tuple[bool, str, Any]],
send_wechat_msg: Callable[[str], None],
utc_now_dt: Callable[[], Any],
app_tz: Any,
utc_calendar_date_str: Callable[[], str],
app_now_str: Callable[[], str],
min_transfer: float = 0.01,
) -> None:
if not enabled:
return
utc_dt = utc_now_dt()
bj = utc_dt.astimezone(app_tz)
if bj.hour != bj_hour:
return
transfer_day = utc_calendar_date_str()
conn = get_db()
exists = conn.execute(
"SELECT id FROM transfer_logs WHERE transfer_type=? AND transfer_day=?",
("auto_daily", transfer_day),
).fetchone()
if exists:
conn.close()
return
def _log(
amount: float,
fr: str,
to: str,
status: str,
message: str,
*,
commit_close: bool = True,
) -> None:
conn.execute(
"INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)",
("auto_daily", transfer_day, amount, fr, to, status, message[:500]),
)
conn.commit()
if commit_close:
conn.close()
active = get_active_position_count(conn)
if active > 0:
msg = f"持仓中({active}笔),本次资金无划转"
_log(0, from_account, to_account, "skipped", msg)
send_wechat_msg(
f"自动划转:{msg}\n"
f"目标:{to_account} 调整至 {round(float(target_amount), funds_decimals)}U\n"
f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}"
)
return
target = round(float(target_amount), funds_decimals)
trade_bal = get_account_usdt_total(to_account)
if trade_bal is None:
_log(
0,
from_account,
to_account,
"failed",
f"读取{to_account}账户USDT失败",
)
return
trade = round(float(trade_bal), funds_decimals)
diff = round(target - trade, funds_decimals)
if abs(diff) < min_transfer:
_log(
0,
from_account,
to_account,
"skipped",
f"{to_account}账户已为{trade}U(目标{target}U)",
)
return
if diff > 0:
fr, to, amount = from_account, to_account, diff
action = "划入"
else:
fr, to, amount = to_account, from_account, round(abs(diff), funds_decimals)
action = "划出"
from_bal = get_account_usdt_total(fr)
if from_bal is not None and round(float(from_bal), funds_decimals) < amount:
cur = round(float(from_bal), funds_decimals)
_log(amount, fr, to, "failed", f"{fr}账户USDT不足,需{amount}U,当前{cur}U")
send_wechat_msg(
f"自动划转失败:{fr}余额不足,需{amount}U,当前{cur}U({action}{to_account}目标{target}U)\n"
f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}"
)
return
ok, msg, _ = execute_transfer_usdt(amount, fr, to)
_log(amount, fr, to, "success" if ok else "failed", msg)
if ok:
send_wechat_msg(
f"自动划转成功:{to_account} {trade}U→目标{target}U,{action}{amount}U {fr}->{to}\n"
f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}"
)
else:
send_wechat_msg(
f"自动划转失败:计划{action}{amount}U {fr}->{to}(目标{target}U)\n原因:{msg}\n"
f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}"
)
+16
View File
@@ -0,0 +1,16 @@
"""关闭 Flask/Werkzeug 开发服务器 access log 刷屏(避免灌满 PM2 error 日志)."""
from __future__ import annotations
import logging
def silence_werkzeug_access_log() -> None:
"""仅抑制 request access 行;WARNING/ERROR 仍可读."""
log = logging.getLogger("werkzeug")
log.setLevel(logging.WARNING)
# 部分环境会挂 StreamHandler 到 stderr;抬高阈值即可
for h in list(log.handlers):
try:
h.setLevel(logging.WARNING)
except Exception:
pass
+51
View File
@@ -0,0 +1,51 @@
"""防重复提交:Flask session 短窗口去重(下单 / 关键位等)."""
from __future__ import annotations
import time
from typing import Any, Optional
DEFAULT_SUBMIT_GUARD_TTL = 90.0
def _prune_locks(locks: dict, now: float) -> dict:
return {k: float(v) for k, v in (locks or {}).items() if float(v) > now}
def check_duplicate_submit(
session: Any,
scope: str,
*,
ttl: float = DEFAULT_SUBMIT_GUARD_TTL,
) -> Optional[str]:
"""
同一 scope 在 ttl 秒内仅允许通过一次.
返回提示文案表示应拒绝;返回 None 表示可继续处理.
"""
scope = (scope or "").strip()
if not scope:
return None
now = time.time()
locks = _prune_locks(session.get("_form_submit_guard") or {}, now)
if scope in locks:
return "请求正在处理或刚提交过,请勿重复点击(请等待页面刷新后再试)"
locks[scope] = now + float(ttl)
session["_form_submit_guard"] = locks
try:
session.modified = True
except Exception:
pass
return None
def submit_scope_add_order(symbol: str, direction: str) -> str:
sym = (symbol or "").strip().upper()
d = (direction or "").strip().lower()
return f"add_order:{sym}:{d}"
def submit_scope_add_key(symbol: str, monitor_type: str, direction: str) -> str:
sym = (symbol or "").strip().upper()
mt = (monitor_type or "").strip()
d = (direction or "").strip().lower() or "watch"
return f"add_key:{sym}:{mt}:{d}"
+187
View File
@@ -0,0 +1,187 @@
"""列表/导出用 UTC 时间窗(Gate / Binance 主站共用)."""
from datetime import datetime, timedelta, timezone
PRESET_UTC_TODAY = "utc_today"
PRESET_UTC_LAST24H = "utc_last24h"
PRESET_UTC_LAST7D = "utc_last7d"
PRESET_UTC_THIS_MONTH = "utc_this_month"
PRESET_UTC_LAST3M = "utc_last3m"
PRESET_UTC_LAST6M = "utc_last6m"
PRESET_ALL = "all"
PRESET_CUSTOM = "custom"
PRESET_DEFAULT = PRESET_UTC_THIS_MONTH
def utc_now():
return datetime.now(timezone.utc)
def utc_today_bounds(now=None):
now = now or utc_now()
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
return start, now
def resolve_window(query_mapping, default_preset=PRESET_DEFAULT):
"""
从 ?win_preset= & from_utc= & to_utc= 解析窗口.
返回 dict: preset, start_utc, end_utc, label, start_ms, end_ms
"""
preset = (query_mapping.get("win_preset") or default_preset or PRESET_DEFAULT).strip().lower()
now = utc_now()
if preset == PRESET_UTC_LAST24H:
start = now - timedelta(hours=24)
end = now
label = "近24小时(UTC)"
elif preset == PRESET_UTC_LAST7D:
start = now - timedelta(days=7)
end = now
label = "近7天(UTC)"
elif preset == PRESET_UTC_THIS_MONTH:
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end = now
label = f"本月 {start.strftime('%Y-%m')}"
elif preset == PRESET_UTC_LAST3M:
start = now - timedelta(days=90)
end = now
label = "近3月"
elif preset == PRESET_UTC_LAST6M:
start = now - timedelta(days=180)
end = now
label = "近6月"
elif preset == PRESET_ALL:
start = datetime(2000, 1, 1, tzinfo=timezone.utc)
end = now
label = "全部"
elif preset == PRESET_CUSTOM:
start = _parse_utc_input(query_mapping.get("from_utc")) or utc_today_bounds(now)[0]
end = _parse_utc_input(query_mapping.get("to_utc")) or now
if end < start:
start, end = end, start
label = f"{start.strftime('%Y-%m-%d %H:%M')} ~ {end.strftime('%Y-%m-%d %H:%M')} UTC"
elif preset == PRESET_UTC_TODAY:
start, end = utc_today_bounds(now)
label = f"UTC当日 {start.strftime('%Y-%m-%d')}"
else:
return resolve_window(
{**(query_mapping or {}), "win_preset": default_preset},
default_preset=default_preset,
)
return {
"preset": preset,
"start_utc": start,
"end_utc": end,
"label": label,
"start_ms": int(start.timestamp() * 1000),
"end_ms": int(end.timestamp() * 1000),
}
def _parse_utc_input(raw):
s = (raw or "").strip().replace("T", " ").replace("Z", "").strip()
if not s:
return None
for fmt, n in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)):
try:
dt = datetime.strptime(s[:n], fmt)
return dt.replace(tzinfo=timezone.utc)
except Exception:
continue
return None
def utc_window_to_bj_sql_strings(start_utc, end_utc, app_tz):
"""DB 存北京时间字符串时,用于 SQLite 字符串范围比较."""
start_bj = start_utc.astimezone(app_tz).strftime("%Y-%m-%d %H:%M:%S")
end_bj = end_utc.astimezone(app_tz).strftime("%Y-%m-%d %H:%M:%S")
return start_bj, end_bj
def utc_window_to_utc_sql_strings(start_utc, end_utc):
"""SQLite CURRENT_TIMESTAMP 写入 UTC 时,用于 created_at 范围比较."""
return (
start_utc.strftime("%Y-%m-%d %H:%M:%S"),
end_utc.strftime("%Y-%m-%d %H:%M:%S"),
)
def normalize_bj_datetime_storage(raw):
"""表单 datetime-local(含 T)入库前统一为 YYYY-MM-DD HH:MM:SS(北京时间)."""
s = (raw or "").strip().replace("T", " ").replace("Z", "").strip()
if not s:
return ""
for fmt, n in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)):
try:
return datetime.strptime(s[:n], fmt).strftime("%Y-%m-%d %H:%M:%S")
except ValueError:
continue
return s
def sql_list_time_field(*columns):
"""
SQLite 列表时间窗比较表达式.
journal_entries 的 open/close 可能含 'T',直接与 bounds(空格格式)比会误判为超出上界.
单列时不用 COALESCE(SQLite 要求 COALESCE 至少 2 个参数).
"""
cols = [c for c in columns if c]
if not cols:
raise ValueError("sql_list_time_field requires at least one column")
if len(cols) == 1:
return f"REPLACE({cols[0]}, 'T', ' ')"
return f"REPLACE(COALESCE({', '.join(cols)}), 'T', ' ')"
SESSION_KEY_LIST_WIN = "list_win_filter"
def query_mapping_from_session(session_store):
"""从 Flask session 恢复 win_preset / from_utc / to_utc."""
if not session_store:
return {}
block = session_store.get(SESSION_KEY_LIST_WIN)
if not isinstance(block, dict):
return {}
preset = (block.get("preset") or "").strip()
if not preset:
return {}
return {
"win_preset": preset,
"from_utc": (block.get("from_utc") or "").strip(),
"to_utc": (block.get("to_utc") or "").strip(),
}
def resolve_list_window(query_mapping, session_store=None, default_preset=PRESET_DEFAULT):
"""
URL 带 win_preset 时解析并写入 session;无参数时用 session 中上次「应用」的预设.
"""
qm = query_mapping or {}
preset_in_q = (qm.get("win_preset") or "").strip()
if preset_in_q:
win = resolve_window(qm, default_preset=default_preset)
if session_store is not None:
session_store[SESSION_KEY_LIST_WIN] = {
"preset": win["preset"],
"from_utc": (qm.get("from_utc") or "").strip(),
"to_utc": (qm.get("to_utc") or "").strip(),
}
return win
stored = query_mapping_from_session(session_store)
if stored.get("win_preset"):
return resolve_window(stored, default_preset=default_preset)
return resolve_window(qm, default_preset=default_preset)
def list_window_redirect_query(session_store):
"""复盘/表单 POST 后重定向时附带列表筛选 query."""
from urllib.parse import urlencode
stored = query_mapping_from_session(session_store)
if not stored.get("win_preset"):
return ""
params = {k: v for k, v in stored.items() if v}
return urlencode(params)
+128
View File
@@ -0,0 +1,128 @@
"""Markdown → HTML for system guide / options docs."""
from __future__ import annotations
import re
def render_markdown_html(md_text: str) -> str:
try:
import markdown # type: ignore
return markdown.markdown(
md_text,
extensions=["tables", "fenced_code", "nl2br", "sane_lists"],
)
except Exception:
return _simple_md_html(md_text)
def _simple_md_html(md_text: str) -> str:
from html import escape
lines = md_text.replace("\r\n", "\n").replace("\r", "\n").splitlines()
out: list[str] = []
i = 0
in_code = False
code_buf: list[str] = []
list_buf: list[str] = []
list_ordered = False
def flush_list() -> None:
nonlocal list_buf, list_ordered
if not list_buf:
return
tag = "ol" if list_ordered else "ul"
out.append(f"<{tag}>")
for item in list_buf:
out.append(f"<li>{_inline_md(item)}</li>")
out.append(f"</{tag}>")
list_buf = []
def flush_code() -> None:
nonlocal code_buf, in_code
if not code_buf:
return
out.append(f"<pre><code>{escape(chr(10).join(code_buf))}</code></pre>")
code_buf = []
in_code = False
while i < len(lines):
line = lines[i]
if line.strip().startswith("```"):
flush_list()
if in_code:
flush_code()
else:
in_code = True
i += 1
continue
if in_code:
code_buf.append(line)
i += 1
continue
if re.match(r"^\s*\|", line) and i + 1 < len(lines) and re.match(r"^\s*\|?\s*[-:| ]+\|", lines[i + 1]):
flush_list()
header = [c.strip() for c in line.strip().strip("|").split("|")]
i += 2
rows: list[list[str]] = []
while i < len(lines) and re.match(r"^\s*\|", lines[i]):
rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")])
i += 1
out.append("<table><thead><tr>" + "".join(f"<th>{_inline_md(h)}</th>" for h in header) + "</tr></thead><tbody>")
for row in rows:
out.append("<tr>" + "".join(f"<td>{_inline_md(c)}</td>" for c in row) + "</tr>")
out.append("</tbody></table>")
continue
if re.match(r"^#{1,3}\s+", line):
flush_list()
m = re.match(r"^(#{1,3})\s+(.*)$", line)
if m:
level = len(m.group(1))
out.append(f"<h{level}>{_inline_md(m.group(2))}</h{level}>")
i += 1
continue
if line.strip() == "---":
flush_list()
out.append("<hr>")
i += 1
continue
if line.startswith(">"):
flush_list()
out.append(f"<blockquote>{_inline_md(line.lstrip('>').strip())}</blockquote>")
i += 1
continue
m = re.match(r"^(\d+)\.\s+(.*)$", line.strip())
if m:
if list_buf and not list_ordered:
flush_list()
list_ordered = True
list_buf.append(m.group(2))
i += 1
continue
if re.match(r"^[-*]\s+", line.strip()):
if list_buf and list_ordered:
flush_list()
list_ordered = False
list_buf.append(re.sub(r"^[-*]\s+", "", line.strip()))
i += 1
continue
if not line.strip():
flush_list()
i += 1
continue
flush_list()
out.append(f"<p>{_inline_md(line.strip())}</p>")
i += 1
flush_list()
flush_code()
return "\n".join(out)
def _inline_md(text: str) -> str:
from html import escape
s = escape(text)
s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
s = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", s)
return s
+337
View File
@@ -0,0 +1,337 @@
/**
* 账户流水:资金/交易 Tab · 分页 10 · SSE 自动刷新 · 时间窗跟随顶栏预设.
*/
(function (global) {
const PAGE_SIZE = 10;
let account = "funding";
let page = 1;
let pages = 0;
let localVersion = 0;
let es = null;
let reconnectTimer = null;
let loading = false;
let booted = false;
function root() {
const active = document.querySelector('.embed-tab-pane.is-active-pane [data-account-ledger="1"]');
if (active) return active;
return document.getElementById("account-ledger-root");
}
function $(id) {
const r = root();
return (r && r.querySelector("#" + id)) || document.getElementById(id);
}
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function listWindowQs() {
if (typeof global.listWindowQueryString === "function") {
const q = global.listWindowQueryString();
return q ? (q.charAt(0) === "?" ? q.slice(1) : q) : "";
}
try {
return new URLSearchParams(location.search).toString();
} catch (_) {
return "";
}
}
function fmtBj(ms) {
const n = Number(ms);
if (!Number.isFinite(n) || n <= 0) return "—";
try {
const d = new Date(n);
const parts = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(d);
const get = (t) => (parts.find((p) => p.type === t) || {}).value || "";
return (
get("year") +
"-" +
get("month") +
"-" +
get("day") +
" " +
get("hour") +
":" +
get("minute") +
":" +
get("second")
);
} catch (_) {
return "—";
}
}
function fmtAmt(v) {
const n = Number(v);
if (!Number.isFinite(n)) return "—";
const cls = n > 0 ? "account-ledger-amt-pos" : n < 0 ? "account-ledger-amt-neg" : "";
const sign = n > 0 ? "+" : "";
return '<span class="' + cls + '">' + sign + n.toFixed(6).replace(/\.?0+$/, "") + "</span>";
}
function fmtBal(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return "—";
return n.toFixed(6).replace(/\.?0+$/, "");
}
function setStatus(msg, isErr) {
const el = $("account-ledger-status");
if (!el) return;
el.textContent = msg || "";
el.style.color = isErr ? "#f07178" : "";
}
function setSyncLabel(data) {
const el = $("account-ledger-sync");
if (!el) return;
const ts = data && data.last_sync_at;
if (!ts) {
el.textContent = "尚未同步";
return;
}
el.textContent = "同步 " + fmtBj(Number(ts) * 1000);
}
function renderRows(items) {
const tbody = $("account-ledger-tbody");
if (!tbody) return;
if (!items || !items.length) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">当前时间窗暂无流水</td></tr>';
return;
}
tbody.innerHTML = items
.map(function (it) {
const note = [it.symbol, it.note, it.raw_type].filter(Boolean).join(" · ");
return (
"<tr>" +
"<td>" +
escapeHtml(fmtBj(it.ts_ms)) +
"</td>" +
"<td>" +
escapeHtml(it.ccy || "") +
"</td>" +
"<td>" +
escapeHtml(it.kind_label || it.kind || "") +
"</td>" +
"<td>" +
fmtAmt(it.amount) +
"</td>" +
"<td>" +
escapeHtml(fmtBal(it.balance_after)) +
"</td>" +
"<td>" +
escapeHtml(note || "—") +
"</td>" +
"</tr>"
);
})
.join("");
}
function renderPager(data) {
pages = Number(data.pages || 0);
page = Number(data.page || 1);
const info = $("account-ledger-page-info");
const prev = $("account-ledger-prev");
const next = $("account-ledger-next");
if (info) {
info.textContent =
"第 " + page + " / " + (pages || 1) + " 页 · 共 " + (data.total || 0) + " 条 · 每页 " + PAGE_SIZE;
}
if (prev) prev.disabled = page <= 1;
if (next) next.disabled = !pages || page >= pages;
}
async function loadList(opts) {
const r = root();
if (!r) return;
if (loading) return;
loading = true;
const force = opts && opts.force;
try {
if (!force) setStatus("加载中…");
const qs = new URLSearchParams(listWindowQs());
qs.set("account", account);
qs.set("page", String(page));
const res = await fetch("/api/account_ledger?" + qs.toString(), {
credentials: "same-origin",
});
const data = await res.json().catch(function () {
return {};
});
if (!res.ok || data.ok === false) {
throw new Error(data.msg || res.statusText || "加载失败");
}
if (data.ledger_version != null) localVersion = Number(data.ledger_version) || localVersion;
renderRows(data.items || []);
renderPager(data);
setSyncLabel(data);
const winLabel = (data.window && data.window.label) || "";
const err = data.last_error ? " · 同步提示: " + data.last_error : "";
setStatus(
(winLabel ? "时间窗 " + winLabel + " · " : "") +
(account === "trading" ? "交易账户" : "资金账户") +
err,
!!data.last_error
);
} catch (e) {
setStatus(e.message || String(e), true);
} finally {
loading = false;
}
}
async function refreshNow() {
setStatus("正在从交易所同步…");
try {
const res = await fetch("/api/account_ledger/refresh", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: "{}",
});
const data = await res.json().catch(function () {
return {};
});
if (!res.ok || data.ok === false) {
throw new Error(data.msg || "同步失败");
}
await loadList({ force: true });
} catch (e) {
setStatus(e.message || String(e), true);
}
}
function bindUi() {
const r = root();
if (!r || r.getAttribute("data-ledger-bound") === "1") return;
r.setAttribute("data-ledger-bound", "1");
r.querySelectorAll(".account-ledger-tab").forEach(function (btn) {
btn.addEventListener("click", function () {
const acc = btn.getAttribute("data-ledger-account") || "funding";
if (acc === account) return;
account = acc;
page = 1;
r.querySelectorAll(".account-ledger-tab").forEach(function (b) {
const on = b.getAttribute("data-ledger-account") === account;
b.classList.toggle("active", on);
b.setAttribute("aria-selected", on ? "true" : "false");
});
loadList();
});
});
const prev = $("account-ledger-prev");
const next = $("account-ledger-next");
const ref = $("account-ledger-refresh");
if (prev)
prev.addEventListener("click", function () {
if (page > 1) {
page -= 1;
loadList();
}
});
if (next)
next.addEventListener("click", function () {
if (!pages || page < pages) {
page += 1;
loadList();
}
});
if (ref) ref.addEventListener("click", refreshNow);
}
function connectSse() {
if (es) {
try {
es.close();
} catch (_) {}
es = null;
}
if (typeof EventSource === "undefined") return;
try {
es = new EventSource("/api/account_ledger/stream");
es.addEventListener("ledger", function (ev) {
let data = {};
try {
data = JSON.parse(ev.data || "{}");
} catch (_) {}
const ver = Number(data.ledger_version || 0);
if (ver && ver !== localVersion) {
localVersion = ver;
loadList({ force: true });
}
});
es.onerror = function () {
try {
es.close();
} catch (_) {}
es = null;
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(connectSse, 5000);
};
} catch (_) {}
}
function boot() {
const r = root();
if (!r) return;
bindUi();
if (!booted) {
booted = true;
connectSse();
}
loadList();
}
function onTabActivated(tab) {
if (tab !== "account_ledger") return;
boot();
}
global.AccountLedgerPage = {
boot: boot,
onTabActivated: onTabActivated,
reload: function () {
page = 1;
loadList();
},
};
document.addEventListener("DOMContentLoaded", function () {
const page =
(document.body && document.body.getAttribute("data-page")) ||
(document.body && document.body.getAttribute("data-initial-tab")) ||
"";
if (page === "account_ledger" || root()) {
// embed 延后到 tab 激活;独立页直接 boot
if (!document.body || document.body.getAttribute("data-embed-shell") !== "1") {
boot();
} else if (page === "account_ledger") {
boot();
}
}
});
document.addEventListener("instance-embed-tab-activated", function (ev) {
const tab = ev && ev.detail && ev.detail.tab;
onTabActivated(tab);
});
})(window);
+150
View File
@@ -0,0 +1,150 @@
/* 账户风控状态徽章 — 三所实例 + 中控共用;兼容 data-theme light/dark */
:root,
html[data-theme="dark"] {
--risk-normal-fg: #9cf0c4;
--risk-normal-bg: rgba(36, 140, 96, 0.16);
--risk-normal-border: rgba(72, 190, 130, 0.42);
--risk-normal-glow: rgba(72, 190, 130, 0.35);
--risk-1h-fg: #ffd27a;
--risk-1h-bg: rgba(210, 150, 40, 0.16);
--risk-1h-border: rgba(230, 170, 60, 0.45);
--risk-1h-glow: rgba(230, 170, 60, 0.32);
--risk-4h-fg: #ffab8a;
--risk-4h-bg: rgba(210, 90, 55, 0.16);
--risk-4h-border: rgba(230, 110, 70, 0.48);
--risk-4h-glow: rgba(230, 110, 70, 0.34);
--risk-daily-fg: #ff9ec4;
--risk-daily-bg: rgba(190, 55, 100, 0.18);
--risk-daily-border: rgba(210, 75, 120, 0.5);
--risk-daily-glow: rgba(210, 75, 120, 0.36);
--risk-position-fg: #8ec8ff;
--risk-position-bg: rgba(55, 120, 210, 0.18);
--risk-position-border: rgba(75, 145, 230, 0.48);
--risk-position-glow: rgba(75, 145, 230, 0.34);
--risk-badge-shadow: 0 1px 2px rgba(0, 0, 0, 0.28);
}
html[data-theme="light"] {
--risk-normal-fg: #056b44;
--risk-normal-bg: rgba(10, 143, 92, 0.14);
--risk-normal-border: rgba(8, 122, 80, 0.38);
--risk-normal-glow: rgba(10, 143, 92, 0.22);
--risk-1h-fg: #8a5a00;
--risk-1h-bg: rgba(200, 140, 20, 0.14);
--risk-1h-border: rgba(170, 115, 10, 0.38);
--risk-1h-glow: rgba(200, 140, 20, 0.2);
--risk-4h-fg: #a83812;
--risk-4h-bg: rgba(210, 85, 35, 0.12);
--risk-4h-border: rgba(180, 65, 25, 0.36);
--risk-4h-glow: rgba(210, 85, 35, 0.2);
--risk-daily-fg: #9a1248;
--risk-daily-bg: rgba(180, 35, 80, 0.1);
--risk-daily-border: rgba(155, 28, 68, 0.34);
--risk-daily-glow: rgba(180, 35, 80, 0.18);
--risk-position-fg: #0b5cab;
--risk-position-bg: rgba(20, 100, 190, 0.12);
--risk-position-border: rgba(15, 85, 165, 0.36);
--risk-position-glow: rgba(20, 100, 190, 0.2);
--risk-badge-shadow: 0 1px 2px rgba(20, 50, 80, 0.1);
}
.risk-status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.03em;
line-height: 1.15;
padding: 5px 12px 5px 10px;
border-radius: 999px;
border: 1px solid var(--risk-border, transparent);
background: var(--risk-bg, transparent);
color: var(--risk-fg, inherit);
box-shadow: var(--risk-badge-shadow);
white-space: nowrap;
vertical-align: middle;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
/* 中控 iframe 内切页:避免徽章过渡动画造成 header 闪动 */
html[data-hub-linked="1"] .header-row .risk-status-badge {
transition: none;
}
.risk-status-badge::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
box-shadow: 0 0 0 1px color-mix(in srgb, currentColor 30%, transparent),
0 0 8px var(--risk-glow, currentColor);
opacity: 0.92;
}
.risk-status-normal {
--risk-fg: var(--risk-normal-fg);
--risk-bg: var(--risk-normal-bg);
--risk-border: var(--risk-normal-border);
--risk-glow: var(--risk-normal-glow);
}
.risk-status-freeze_1h {
--risk-fg: var(--risk-1h-fg);
--risk-bg: var(--risk-1h-bg);
--risk-border: var(--risk-1h-border);
--risk-glow: var(--risk-1h-glow);
}
.risk-status-freeze_4h {
--risk-fg: var(--risk-4h-fg);
--risk-bg: var(--risk-4h-bg);
--risk-border: var(--risk-4h-border);
--risk-glow: var(--risk-4h-glow);
}
.risk-status-freeze_daily {
--risk-fg: var(--risk-daily-fg);
--risk-bg: var(--risk-daily-bg);
--risk-border: var(--risk-daily-border);
--risk-glow: var(--risk-daily-glow);
}
.risk-status-freeze_position {
--risk-fg: var(--risk-position-fg);
--risk-bg: var(--risk-position-bg);
--risk-border: var(--risk-position-border);
--risk-glow: var(--risk-position-glow);
}
/* 实例页:与交易所标签并排 */
.header-row .risk-status-badge {
min-height: 28px;
}
/* 中控卡片标题内 */
.card-title .risk-status-badge,
.hub-tile-name .risk-status-badge {
font-size: 0.7rem;
padding: 3px 10px 3px 8px;
vertical-align: middle;
}
.card-title .risk-status-badge::before,
.hub-tile-name .risk-status-badge::before {
width: 6px;
height: 6px;
}
+120
View File
@@ -0,0 +1,120 @@
/**
* 账户风控徽章倒计时 — 三所实例 + 中控共用.
*/
(function (global) {
"use strict";
function formatRemaining(totalSec) {
const sec = Math.max(0, Math.floor(Number(totalSec) || 0));
if (sec <= 0) return "";
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`;
if (m > 0) return `${m}m ${String(s).padStart(2, "0")}s`;
return `${s}s`;
}
function baseLabel(riskStatus, el) {
if (riskStatus && riskStatus.status_label) return String(riskStatus.status_label);
if (el && el.dataset && el.dataset.statusLabel) return String(el.dataset.statusLabel);
return "正常";
}
function resolveFreezeUntilMs(riskStatus) {
if (!riskStatus) return null;
const sec = Number(riskStatus.freeze_remaining_sec);
if (Number.isFinite(sec) && sec > 0) {
return Date.now() + sec * 1000;
}
const until = Number(riskStatus.freeze_until_ms);
return Number.isFinite(until) && until > 0 ? until : null;
}
function badgeText(riskStatus) {
const label = baseLabel(riskStatus, null);
const until = resolveFreezeUntilMs(riskStatus);
if (!until || until <= Date.now()) return label;
const cd = formatRemaining((until - Date.now()) / 1000);
return cd ? `${label} · ${cd}` : label;
}
function setNormalBadge(el) {
el.className = "risk-status-badge risk-status-normal";
el.dataset.statusLabel = "正常";
el.textContent = "正常";
el.title = "";
if (el.dataset) delete el.dataset.freezeUntilMs;
}
function refreshElement(el) {
if (!el) return;
const label = baseLabel(null, el);
const until = Number(el.dataset && el.dataset.freezeUntilMs);
if (!Number.isFinite(until) || until <= Date.now()) {
if (el.dataset && el.dataset.freezeUntilMs) {
setNormalBadge(el);
} else {
el.textContent = label;
}
return;
}
const cd = formatRemaining((until - Date.now()) / 1000);
el.textContent = cd ? `${label} · ${cd}` : label;
}
function applyToElement(el, riskStatus) {
if (!el || !riskStatus) return;
const st = riskStatus.status || "normal";
el.className = "risk-status-badge risk-status-" + st;
el.dataset.statusLabel = baseLabel(riskStatus, el);
const until = resolveFreezeUntilMs(riskStatus);
if (until) {
el.dataset.freezeUntilMs = String(until);
} else if (el.dataset) {
delete el.dataset.freezeUntilMs;
}
el.textContent = badgeText(riskStatus);
el.title = riskStatus.reason || "";
}
function formatBadgeHtml(riskStatus, esc) {
if (!riskStatus || typeof riskStatus !== "object") return "";
const safe = typeof esc === "function" ? esc : (s) => String(s);
const st = riskStatus.status || "normal";
const label = safe(riskStatus.status_label || "正常");
const title = safe(riskStatus.reason || "");
const text = safe(badgeText(riskStatus));
const until = resolveFreezeUntilMs(riskStatus);
const untilAttr =
until != null
? ` data-freeze-until-ms="${safe(String(Math.floor(until)))}"`
: "";
return (
`<span class="risk-status-badge risk-status-${safe(st)}" role="status"` +
` title="${title}" data-status-label="${label}"${untilAttr}>${text}</span>`
);
}
function tickAll(root) {
const scope = root || document;
scope.querySelectorAll(".risk-status-badge[data-freeze-until-ms]").forEach(refreshElement);
}
let timer = null;
function startTicker() {
if (timer) return;
tickAll();
timer = setInterval(() => tickAll(), 1000);
}
global.AccountRiskBadge = {
formatRemaining,
badgeText,
refreshElement,
applyToElement,
formatBadgeHtml,
tickAll,
startTicker,
};
})(typeof window !== "undefined" ? window : globalThis);
+148
View File
@@ -0,0 +1,148 @@
/**
* 全局防浏览器自动填充登录账号/密码进业务输入框.
* 跳过真正的登录/改密字段;对划转数量等易中招框用 readonly 到聚焦.
*/
(function () {
"use strict";
var GUARD_ATTRS = {
autocomplete: "off",
autocorrect: "off",
autocapitalize: "off",
spellcheck: "false",
"data-lpignore": "true",
"data-1p-ignore": "true",
"data-bwignore": "true",
"data-form-type": "other",
};
function looksLikeUsername(v) {
return /^[a-z][a-z0-9._-]{1,31}$/i.test(String(v || "").trim());
}
function isAuthField(el) {
if (!el || !el.getAttribute) return true;
var t = String(el.type || "").toLowerCase();
if (t === "hidden" || t === "checkbox" || t === "radio" || t === "file" || t === "submit" || t === "button") {
return true;
}
if (el.getAttribute("aria-hidden") === "true") return true;
if (el.tabIndex === -1 && String(el.getAttribute("autocomplete") || "").toLowerCase() === "username") {
return true; // 诱饵账号框
}
var idName = String(el.id || "") + " " + String(el.name || "");
if (/^(pwd-|hub-pwd-|login-)/i.test(String(el.id || ""))) return true;
if (el.closest) {
if (el.closest(".login-form, #login-form, form.login-form, .password-settings, [data-password-settings]")) {
return true;
}
}
// env API Key 等 type=password 仍要防登录密码灌入,不在此跳过
if (t === "password" && /^(username|password)$/i.test(String(el.name || ""))) {
if (el.closest && el.closest("form[method='post'], form[method='POST']")) return true;
}
return false;
}
function isAmountLike(el) {
var key = String(el.id || "") + " " + String(el.name || "") + " " + String(el.placeholder || "");
return /amount|xfer|transfer|划转|数量|金额/i.test(key);
}
function wipeBad(el) {
if (!el || isAuthField(el)) return;
var v = String(el.value || "").trim();
if (!looksLikeUsername(v)) return;
var t = String(el.type || "text").toLowerCase();
if (t === "number" || isAmountLike(el) || /price|sheets|qty|sl|tp|target|entry|strike/i.test(String(el.id || "") + String(el.name || ""))) {
el.value = "";
}
}
function harden(el) {
if (!el || el.nodeType !== 1) return;
if (isAuthField(el)) return;
if (el.getAttribute("aria-hidden") === "true") return;
if (el.dataset && el.dataset.autofillGuarded === "1") {
wipeBad(el);
return;
}
if (el.dataset) el.dataset.autofillGuarded = "1";
Object.keys(GUARD_ATTRS).forEach(function (k) {
var cur = el.getAttribute(k);
if (k === "autocomplete" && cur && /^(username|current-password)/i.test(cur)) {
return;
}
// env 密钥框用 new-password 更抗登录密码灌入
if (k === "autocomplete" && String(el.type || "").toLowerCase() === "password") {
el.setAttribute(k, "new-password");
return;
}
if (!cur || cur === "on") el.setAttribute(k, GUARD_ATTRS[k]);
});
if (String(el.type || "").toLowerCase() === "password" || isAmountLike(el)) {
el.setAttribute("readonly", "readonly");
el.addEventListener("focus", function () {
el.removeAttribute("readonly");
});
el.addEventListener("blur", function () {
if (!el.value) el.setAttribute("readonly", "readonly");
});
}
wipeBad(el);
setTimeout(function () {
wipeBad(el);
}, 250);
setTimeout(function () {
wipeBad(el);
}, 900);
setTimeout(function () {
wipeBad(el);
}, 2000);
}
function scan(root) {
var scope = root && root.querySelectorAll ? root : document;
var list = scope.querySelectorAll(
'input[type="text"], input[type="number"], input[type="search"], input[type="url"], input[type="email"], input[type="tel"], input[type="password"], input:not([type]), textarea'
);
for (var i = 0; i < list.length; i++) harden(list[i]);
}
function boot() {
scan(document);
if (typeof MutationObserver === "undefined") return;
var obs = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
var m = mutations[i];
if (m.type === "childList") {
for (var j = 0; j < m.addedNodes.length; j++) {
var n = m.addedNodes[j];
if (!n || n.nodeType !== 1) continue;
if (n.matches && n.matches("input, textarea")) harden(n);
else if (n.querySelectorAll) scan(n);
}
} else if (m.type === "attributes" && m.target) {
harden(m.target);
}
}
});
obs.observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["value"],
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
window.cmAutofillGuardScan = scan;
})();
+221
View File
@@ -0,0 +1,221 @@
/* 实盘/关键位放大页:与 instance_theme 联动,高对比 meta + 主题感知图表区 */
body.focus-page {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
padding: 14px;
margin: 0;
background: var(--focus-bg, #0b0d14);
color: var(--focus-fg, #eaeaea);
}
html[data-theme="light"] body.focus-page {
--focus-bg: #eef3f8;
--focus-fg: #142232;
--focus-card-bg: #fff;
--focus-card-border: #b8c8d8;
--focus-meta-bg: #fff;
--focus-meta-border: #9eb4c8;
--focus-meta-label: #2a4a66;
--focus-meta-value: #0a1628;
--focus-status: #4a6078;
--focus-chart-bg: #f0f4f9;
--focus-chart-border: #b8c8d8;
--focus-btn-bg: #fff;
--focus-btn-fg: #006e9a;
--focus-btn-border: rgba(0, 95, 140, 0.22);
--focus-input-bg: #fff;
--focus-input-fg: #142232;
--focus-input-border: #b8c8d8;
--focus-title: #0a1628;
--focus-pnl-up: #0a7a3d;
--focus-pnl-down: #c62828;
--focus-dir-short: #b71c1c;
--focus-dir-long: #0a7a3d;
}
html[data-theme="dark"] body.focus-page {
--focus-bg: #0b0d14;
--focus-fg: #eaeaea;
--focus-card-bg: #121726;
--focus-card-border: #2a3150;
--focus-meta-bg: #141b2f;
--focus-meta-border: #3d4f72;
--focus-meta-label: #c8d8f0;
--focus-meta-value: #f0f4ff;
--focus-status: #95a2c2;
--focus-chart-bg: #0f1320;
--focus-chart-border: #2a3150;
--focus-btn-bg: #151a2a;
--focus-btn-fg: #8fc8ff;
--focus-btn-border: #304164;
--focus-input-bg: #1a1a29;
--focus-input-fg: #fff;
--focus-input-border: #2e2e45;
--focus-title: #dbe4ff;
--focus-pnl-up: #3ddc84;
--focus-pnl-down: #ff7070;
--focus-dir-short: #ff8a80;
--focus-dir-long: #69f0ae;
}
body.focus-page * {
box-sizing: border-box;
}
.focus-page .container {
width: min(98vw, 1900px);
margin: 0 auto;
}
.focus-page .card {
background: var(--focus-card-bg);
border-radius: 10px;
padding: 12px;
border: 1px solid var(--focus-card-border);
margin-bottom: 12px;
}
.focus-page .row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.focus-page .btn {
padding: 7px 10px;
border-radius: 8px;
text-decoration: none;
border: 1px solid var(--focus-btn-border);
background: var(--focus-btn-bg);
color: var(--focus-btn-fg);
cursor: pointer;
}
.focus-page .btn:hover {
filter: brightness(1.06);
}
.focus-page select,
.focus-page input,
.focus-page button {
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--focus-input-border);
background: var(--focus-input-bg);
color: var(--focus-input-fg);
}
.focus-page .focus-title {
color: var(--focus-title);
font-weight: 700;
}
.focus-page .meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
margin-top: 10px;
}
.focus-page .meta-item {
background: var(--focus-meta-bg);
border: 1px solid var(--focus-meta-border);
border-radius: 8px;
padding: 10px 10px 9px;
}
.focus-page .meta-item .k {
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--focus-meta-label);
}
.focus-page .meta-item .v {
font-size: 1.02rem;
font-weight: 600;
margin-top: 5px;
word-break: break-all;
color: var(--focus-meta-value);
}
.focus-page .meta-item--emph {
border-width: 2px;
border-color: var(--focus-meta-label);
}
.focus-page .meta-item--emph .k {
font-size: 0.82rem;
font-weight: 700;
}
.focus-page .meta-item--emph .v {
font-size: 1.12rem;
font-weight: 800;
}
.focus-page .meta-item--pnl .v {
font-size: 1.14rem;
font-weight: 800;
letter-spacing: 0.01em;
}
.focus-page .meta-pnl-up {
color: var(--focus-pnl-up) !important;
}
.focus-page .meta-pnl-down {
color: var(--focus-pnl-down) !important;
}
.focus-page .meta-dir-long {
color: var(--focus-dir-long) !important;
}
.focus-page .meta-dir-short {
color: var(--focus-dir-short) !important;
}
.focus-page .status {
font-size: 0.84rem;
color: var(--focus-status);
}
.focus-page .status.err {
color: var(--focus-pnl-down);
}
.focus-page #chart-wrap {
height: 560px;
background: var(--focus-chart-bg);
border: 1px solid var(--focus-chart-border);
border-radius: 10px;
padding: 8px;
}
.focus-page #chart {
width: 100%;
height: 100%;
}
.focus-page .empty {
padding: 18px;
color: var(--focus-status);
}
.focus-page .exchange-tag {
font-size: 0.72rem;
font-weight: 600;
color: #b8f5d0;
background: #14241e;
border: 1px solid #2d6a4f;
padding: 4px 10px;
border-radius: 999px;
margin-left: 8px;
}
html[data-theme="light"] .focus-page .exchange-tag {
color: #0a5c38;
background: #e8f5ee;
border-color: #7bc9a0;
}
+401
View File
@@ -0,0 +1,401 @@
/**
* 实盘/关键位放大 K 线:交易所 tick 精度,主题感知图表,高对比 meta.
*/
(function (global) {
"use strict";
let activePriceTick = null;
function currentTheme() {
return document.documentElement.getAttribute("data-theme") === "light"
? "light"
: "dark";
}
function chartTheme(theme) {
if (theme === "light") {
return {
layout: { background: { color: "#f0f4f9" }, textColor: "#142232" },
grid: { vertLines: { color: "#d0dae4" }, horzLines: { color: "#d0dae4" } },
rightPriceScale: { borderColor: "#b8c8d8" },
timeScale: { borderColor: "#b8c8d8" },
candle: {
upColor: "#0a7a3d",
downColor: "#c62828",
wickUpColor: "#0a7a3d",
wickDownColor: "#c62828",
},
};
}
return {
layout: { background: { color: "#0f1320" }, textColor: "#d6deff" },
grid: { vertLines: { color: "#1e263d" }, horzLines: { color: "#1e263d" } },
rightPriceScale: { borderColor: "#2a3150" },
timeScale: { borderColor: "#2a3150" },
candle: {
upColor: "#4cd97f",
downColor: "#ff6666",
wickUpColor: "#4cd97f",
wickDownColor: "#ff6666",
},
};
}
const SAFE_PRICE_FORMAT = { type: "price", precision: 4, minMove: 0.0001 };
function decimalsFromTick(tick) {
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return null;
const minMove = Number(tick);
if (minMove >= 1) return 0;
const raw = String(minMove);
const sci = raw.match(/e-(\d+)/i);
if (sci) return Math.min(12, parseInt(sci[1], 10));
const fixed = minMove.toFixed(12);
const frac = fixed.split(".")[1] || "";
const trimmed = frac.replace(/0+$/, "");
if (trimmed.length) return Math.min(12, trimmed.length);
return Math.max(0, Math.min(12, Math.round(-Math.log10(minMove))));
}
function tickToPriceFormat(tick) {
try {
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) {
return { type: "price", precision: 2, minMove: 0.01 };
}
const minMove = Number(tick);
let prec = decimalsFromTick(minMove);
if (prec == null || prec < 0) prec = 4;
prec = Math.min(12, Math.max(0, Math.floor(prec)));
return { type: "price", precision: prec, minMove: minMove };
} catch (_) {
return SAFE_PRICE_FORMAT;
}
}
function roundToTick(v, tick) {
if (v == null || Number.isNaN(Number(v))) return v;
const n = Number(v);
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return n;
const t = Number(tick);
const rounded = Math.round(n / t) * t;
const dec = decimalsFromTick(t);
if (dec == null) return rounded;
return parseFloat(rounded.toFixed(dec));
}
function fmtPriceByTick(v, tick) {
if (v == null || Number.isNaN(Number(v))) return "-";
const n = Number(roundToTick(v, tick));
if (n === 0) return "0";
const dec = decimalsFromTick(tick);
if (dec != null) return n.toFixed(dec);
const av = Math.abs(n);
let d = 8;
if (av >= 10000) d = 2;
else if (av >= 100) d = 3;
else if (av >= 1) d = 4;
else if (av >= 0.01) d = 6;
const text = n.toFixed(d);
return text.includes(".") ? text.replace(/\.?0+$/, "") : text;
}
function setActivePriceTick(tick) {
activePriceTick =
tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0
? null
: Number(tick);
}
function formatSigned(v, digits) {
digits = digits === undefined ? 2 : digits;
if (v === null || typeof v === "undefined" || Number.isNaN(Number(v))) return "-";
const n = Number(v);
const sign = n > 0 ? "+" : "";
return sign + n.toFixed(digits);
}
function formatSignedPrice(v) {
if (v === null || typeof v === "undefined" || Number.isNaN(Number(v))) return "-";
const n = Number(v);
const body = fmtPriceByTick(Math.abs(n), activePriceTick);
if (body === "-") return "-";
return (n > 0 ? "+" : n < 0 ? "-" : "") + body;
}
function formatRrRatio(rr) {
if (rr === null || typeof rr === "undefined") return "-:1";
const n = Number(rr);
if (Number.isNaN(n)) return "-:1";
const body = Number.isInteger(n) ? String(n) : String(parseFloat(n.toFixed(2)));
return body + ":1";
}
function displayPrice(orderOrData, field, rawField) {
const dispKey = field + "_display";
if (orderOrData && orderOrData[dispKey] && orderOrData[dispKey] !== "-") {
return String(orderOrData[dispKey]);
}
const raw = orderOrData ? orderOrData[rawField || field] : null;
if (raw === null || typeof raw === "undefined" || Number.isNaN(Number(raw))) return "-";
return fmtPriceByTick(raw, activePriceTick);
}
function lineTitle(label, display) {
const d = display && display !== "-" ? display : "";
return d ? label + " " + d : label;
}
function paintOrderMeta(order) {
const symEl = document.getElementById("m-symbol");
const dirEl = document.getElementById("m-direction");
const pnlEl = document.getElementById("m-pnl");
if (symEl) symEl.textContent = order.symbol || "-";
if (dirEl) {
const isShort = order.direction === "short";
dirEl.textContent = isShort ? "做空" : "做多";
dirEl.className = "v " + (isShort ? "meta-dir-short" : "meta-dir-long");
}
const set = function (id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
};
set("m-entry", displayPrice(order, "trigger_price"));
set("m-sl", displayPrice(order, "stop_loss"));
set("m-tp", displayPrice(order, "take_profit"));
set("m-rr", formatRrRatio(order.rr_ratio));
set(
"m-breakeven",
order.breakeven_enabled === false || order.breakeven_enabled === 0 ? "关闭" : "开启"
);
set(
"m-price",
order.current_price_display ||
order.price_display ||
displayPrice(order, "current_price")
);
if (pnlEl) {
pnlEl.textContent =
formatSigned(order.float_pnl, 2) +
"U (" +
formatSigned(order.float_pct, 2) +
"%)";
pnlEl.className = "v";
const pnl = Number(order.float_pnl || 0);
if (pnl > 0) pnlEl.classList.add("meta-pnl-up");
else if (pnl < 0) pnlEl.classList.add("meta-pnl-down");
}
}
function paintKeyMeta(data) {
const key = data.key_monitor || null;
const symEl = document.getElementById("m-symbol");
if (symEl) symEl.textContent = data.symbol || "-";
const set = function (id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
};
set(
"m-price",
data.current_price_display || displayPrice(data, "current_price")
);
const dirEl = document.getElementById("m-direction");
if (!key) {
set("m-type", "未匹配到关键位");
set("m-direction", "-");
if (dirEl) dirEl.className = "v";
set("m-upper", "-");
set("m-lower", "-");
set("m-updiff", "-");
set("m-lowdiff", "-");
return;
}
set("m-type", key.monitor_type || "-");
if (dirEl) {
const isShort = key.direction === "short";
dirEl.textContent = isShort ? "做空" : "做多";
dirEl.className = "v " + (isShort ? "meta-dir-short" : "meta-dir-long");
}
set("m-upper", key.upper_display || displayPrice(key, "upper"));
set("m-lower", key.lower_display || displayPrice(key, "lower"));
if (activePriceTick != null) {
set(
"m-updiff",
formatSignedPrice(key.upper_diff) +
" (" +
formatSigned(key.upper_pct, 2) +
"%)"
);
set(
"m-lowdiff",
formatSignedPrice(key.lower_diff) +
" (" +
formatSigned(key.lower_pct, 2) +
"%)"
);
} else {
set(
"m-updiff",
formatSigned(key.upper_diff, 4) + " (" + formatSigned(key.upper_pct, 2) + "%)"
);
set(
"m-lowdiff",
formatSigned(key.lower_diff, 4) + " (" + formatSigned(key.lower_pct, 2) + "%)"
);
}
}
function applyPriceFormatToSeries(series, pf) {
if (!series || !series.applyOptions) return;
try {
series.applyOptions({ priceFormat: pf });
} catch (_) {
try {
series.applyOptions({ priceFormat: SAFE_PRICE_FORMAT });
} catch (_2) {}
}
}
function createFocusChart(host) {
if (!global.LightweightCharts) return null;
const th = chartTheme(currentTheme());
const chart = global.LightweightCharts.createChart(host, {
layout: th.layout,
grid: th.grid,
rightPriceScale: th.rightPriceScale,
timeScale: Object.assign({ timeVisible: true, secondsVisible: false }, th.timeScale),
crosshair: { mode: 0 },
localization: {
priceFormatter: function (p) {
return fmtPriceByTick(p, activePriceTick);
},
},
});
let candleSeries = null;
function applyChartPriceFormat() {
let pf = SAFE_PRICE_FORMAT;
try {
pf = tickToPriceFormat(activePriceTick);
} catch (_) {
pf = SAFE_PRICE_FORMAT;
}
applyPriceFormatToSeries(candleSeries, pf);
try {
chart.applyOptions({
localization: {
priceFormatter: function (p) {
return fmtPriceByTick(p, activePriceTick);
},
},
});
} catch (_) {}
}
function setPriceTick(tick) {
setActivePriceTick(tick);
applyChartPriceFormat();
}
const opts = Object.assign({ borderVisible: false }, th.candle);
if (typeof chart.addCandlestickSeries === "function") {
candleSeries = chart.addCandlestickSeries(opts);
} else if (
typeof chart.addSeries === "function" &&
global.LightweightCharts.CandlestickSeries
) {
candleSeries = chart.addSeries(global.LightweightCharts.CandlestickSeries, opts);
}
applyChartPriceFormat();
const priceLines = [];
function resetPriceLines() {
if (!candleSeries) return;
priceLines.forEach(function (line) {
try {
candleSeries.removePriceLine(line);
} catch (_) {}
});
priceLines.length = 0;
}
function addLine(price, title, color) {
if (!candleSeries || price === null || typeof price === "undefined") return;
const p = Number(roundToTick(price, activePriceTick));
if (Number.isNaN(p) || p <= 0) return;
priceLines.push(
candleSeries.createPriceLine({
price: p,
color: color,
lineWidth: 1,
lineStyle: 0,
axisLabelVisible: true,
title: title,
})
);
}
function applyTheme() {
const t = chartTheme(currentTheme());
chart.applyOptions({
layout: t.layout,
grid: t.grid,
rightPriceScale: t.rightPriceScale,
timeScale: t.timeScale,
localization: {
priceFormatter: function (p) {
return fmtPriceByTick(p, activePriceTick);
},
},
});
if (candleSeries && typeof candleSeries.applyOptions === "function") {
candleSeries.applyOptions(t.candle);
}
applyChartPriceFormat();
}
function resize() {
chart.applyOptions({ width: host.clientWidth, height: host.clientHeight });
}
global.addEventListener("resize", resize);
resize();
const obs = new MutationObserver(applyTheme);
obs.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
return {
chart: chart,
candleSeries: candleSeries,
resetPriceLines: resetPriceLines,
addLine: addLine,
applyTheme: applyTheme,
setPriceTick: setPriceTick,
ensureSeries: function () {
if (candleSeries) return true;
const t = chartTheme(currentTheme());
const o = Object.assign({ borderVisible: false }, t.candle);
if (typeof chart.addCandlestickSeries === "function") {
candleSeries = chart.addCandlestickSeries(o);
} else if (
typeof chart.addSeries === "function" &&
global.LightweightCharts.CandlestickSeries
) {
candleSeries = chart.addSeries(global.LightweightCharts.CandlestickSeries, o);
}
applyChartPriceFormat();
return !!candleSeries;
},
};
}
global.FocusChartPage = {
currentTheme: currentTheme,
chartTheme: chartTheme,
formatSigned: formatSigned,
formatRrRatio: formatRrRatio,
displayPrice: displayPrice,
lineTitle: lineTitle,
paintOrderMeta: paintOrderMeta,
paintKeyMeta: paintKeyMeta,
createFocusChart: createFocusChart,
setActivePriceTick: setActivePriceTick,
fmtPriceByTick: fmtPriceByTick,
};
})(typeof window !== "undefined" ? window : globalThis);
+81
View File
@@ -0,0 +1,81 @@
/**
* 表单提交防重复:网络慢时禁用按钮并显示「提交中」.
*/
(function (global) {
"use strict";
function submitButtons(form) {
if (!form) return [];
return Array.prototype.slice.call(
form.querySelectorAll('button[type="submit"], input[type="submit"]')
);
}
function lockForm(form, label) {
if (!form) return false;
if (form.dataset.submitGuard === "locked") return false;
form.dataset.submitGuard = "locked";
form.classList.add("is-form-submitting");
submitButtons(form).forEach(function (btn) {
if (btn.dataset.submitGuardOrig === undefined) {
btn.dataset.submitGuardOrig =
btn.tagName === "BUTTON" ? btn.textContent : btn.value;
}
btn.disabled = true;
if (label) {
if (btn.tagName === "BUTTON") btn.textContent = label;
else btn.value = label;
}
});
return true;
}
function unlockForm(form) {
if (!form) return;
delete form.dataset.submitGuard;
form.classList.remove("is-form-submitting");
submitButtons(form).forEach(function (btn) {
// 风控灰显(开仓门禁)保持禁用
btn.disabled = btn.classList.contains("is-blocked");
var orig = btn.dataset.submitGuardOrig;
if (orig !== undefined) {
if (btn.tagName === "BUTTON") btn.textContent = orig;
else btn.value = orig;
delete btn.dataset.submitGuardOrig;
}
});
}
function isLocked(form) {
return !!(form && form.dataset.submitGuard === "locked");
}
/** 已锁定时仅更新按钮文案(校验通过 → 真正提交前) */
function setSubmitLabel(form, label) {
if (!form || !label) return;
submitButtons(form).forEach(function (btn) {
if (btn.tagName === "BUTTON") btn.textContent = label;
else btn.value = label;
});
}
/** 已通过前端校验,发起最终 POST(页面将跳转) */
function nativeSubmitOnce(form, label) {
if (!form) return;
var text = label || "提交中…";
if (form.dataset.submitGuard === "locked") {
setSubmitLabel(form, text);
} else {
lockForm(form, text);
}
form.submit();
}
global.FormSubmitGuard = {
lock: lockForm,
unlock: unlockForm,
isLocked: isLocked,
setSubmitLabel: setSubmitLabel,
nativeSubmitOnce: nativeSubmitOnce,
};
})(typeof window !== "undefined" ? window : this);
File diff suppressed because it is too large Load Diff
+508
View File
@@ -0,0 +1,508 @@
/**
* 实例数据看板:拉 /api/instance/dashboard 渲染只读表格.
* 各区块无数据时不展示;有数据按表格展示.
*/
(function (global) {
const SECTION_ORDER = ["orders", "keys", "strategy", "options", "hedge_plan"];
let loading = false;
let localDashVersion = 0;
let dashEventSource = null;
let dashReconnectTimer = null;
let booted = false;
function root() {
const active = document.querySelector('.embed-tab-pane.is-active-pane [data-inst-dashboard="1"]');
if (active) return active;
return document.getElementById("instance-dashboard");
}
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function fmtNum(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return escapeHtml(v);
return String(n);
}
function fmtPnl(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return "—";
const cls = n > 0 ? "pos-pnl-profit" : n < 0 ? "pos-pnl-loss" : "";
const sign = n > 0 ? "+" : "";
return '<span class="' + cls + '">' + sign + n.toFixed(2) + "U</span>";
}
function fmtPnlPlain(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return "—";
const cls = n > 0 ? "pos-pnl-profit" : n < 0 ? "pos-pnl-loss" : "";
return '<span class="' + cls + '">' + n.toFixed(2) + "</span>";
}
function dirCell(it) {
const d = String(it.direction || "").toLowerCase();
const label = it.direction_label || (d === "short" ? "做空" : d === "long" ? "做多" : "-");
const cls = d === "short" ? "inst-dash-dir-short" : d === "long" ? "inst-dash-dir-long" : "";
return '<td class="' + cls + '">' + escapeHtml(label) + "</td>";
}
function fmtExpiry(ms) {
const n = Number(ms);
if (!Number.isFinite(n) || n <= 0) return "—";
let fallback = "—";
try {
const d = new Date(n);
if (!Number.isNaN(d.getTime())) {
const pad = function (x) {
return String(x).padStart(2, "0");
};
fallback =
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
" " +
pad(d.getHours()) +
":" +
pad(d.getMinutes());
}
} catch (_) {}
return (
'<span class="opt-expiry-cd" data-opt-exp-ms="' +
escapeHtml(String(Math.floor(n))) +
'">' +
escapeHtml(fallback) +
"</span>"
);
}
function goTab(tab) {
if (!tab) return;
if (global.InstanceEmbed && typeof global.InstanceEmbed.loadTab === "function") {
global.InstanceEmbed.loadTab(tab);
return;
}
const pathMap = {
trade: "/trade",
key_monitor: "/key_monitor",
strategy: "/strategy",
options: "/options",
hedge_plan: "/hedge-plan",
};
const path = pathMap[tab] || "/" + tab;
location.href = path;
}
function tableWrap(headers, rowsHtml) {
return (
'<div class="inst-dash-table-wrap">' +
'<table class="inst-dash-table">' +
"<thead><tr>" +
headers
.map(function (h) {
return "<th>" + escapeHtml(h) + "</th>";
})
.join("") +
"</tr></thead>" +
"<tbody>" +
rowsHtml +
"</tbody></table></div>"
);
}
function rowClickAttrs(tab) {
return ' class="inst-dash-row" data-dash-tab="' + escapeHtml(tab || "") + '" role="link" tabindex="0"';
}
function renderOrdersTable(items) {
const rows = items
.map(function (it) {
const sym = it.symbol || "-";
const mark =
it.mark_display != null && it.mark_display !== ""
? escapeHtml(it.mark_display)
: fmtNum(it.mark_price);
const tpProfit =
it.tp_profit != null && Number.isFinite(Number(it.tp_profit))
? '<span class="pos-tp-profit">' + Number(it.tp_profit).toFixed(2) + "U</span>"
: "—";
return (
"<tr" +
rowClickAttrs(it.tab || "trade") +
">" +
'<td class="td-symbol"><span class="inst-dash-sym-link">' +
escapeHtml(sym) +
"</span></td>" +
dirCell(it) +
"<td>" +
fmtNum(it.entry) +
"</td>" +
"<td>" +
mark +
"</td>" +
"<td>" +
fmtNum(it.contracts) +
"</td>" +
"<td>" +
tpProfit +
"</td>" +
"<td>" +
fmtPnlPlain(it.float_pnl) +
"</td>" +
"<td>—</td>" +
"</tr>"
);
})
.join("");
return tableWrap(
["合约", "方向", "开仓价", "标记价", "张数", "盈利金额", "浮盈", "操作"],
rows
);
}
function renderKeysTable(items) {
const rows = items
.map(function (it) {
return (
"<tr" +
rowClickAttrs(it.tab || "key_monitor") +
">" +
"<td>" +
escapeHtml(it.symbol || "-") +
"</td>" +
dirCell(it) +
"<td>" +
escapeHtml(it.subtitle || "—") +
"</td>" +
"<td>" +
fmtNum(it.upper) +
"</td>" +
"<td>" +
fmtNum(it.lower) +
"</td>" +
"</tr>"
);
})
.join("");
return tableWrap(["合约", "方向", "信号", "上沿", "下沿"], rows);
}
function renderStrategyTable(items) {
const rows = items
.map(function (it) {
const kindLabel = it.kind === "roll" ? "顺势加仓" : it.kind === "trend" ? "趋势回调" : "策略";
return (
"<tr" +
rowClickAttrs(it.tab || "strategy") +
">" +
"<td>" +
escapeHtml(kindLabel) +
"</td>" +
"<td>" +
escapeHtml(it.symbol || "-") +
"</td>" +
dirCell(it) +
"<td>" +
escapeHtml(it.status || "—") +
"</td>" +
"<td>" +
fmtNum(it.entry) +
"</td>" +
"</tr>"
);
})
.join("");
return tableWrap(["类型", "合约", "方向", "状态", "入场"], rows);
}
function renderOptionsTable(items) {
const rows = items
.map(function (it) {
const opt = it.opt_type_label ||
(String(it.opt_type || "").toUpperCase() === "C"
? "Call"
: String(it.opt_type || "").toUpperCase() === "P"
? "Put"
: it.opt_type || "—");
return (
"<tr" +
rowClickAttrs(it.tab || "options") +
">" +
"<td>" +
escapeHtml(it.inst_id || it.title || "-") +
"</td>" +
"<td>" +
escapeHtml(it.source_label || "纯期权") +
"</td>" +
"<td>" +
escapeHtml(opt) +
"</td>" +
"<td>" +
fmtNum(it.pos) +
"</td>" +
"<td>" +
fmtExpiry(it.exp_time_ms) +
"</td>" +
"<td>" +
escapeHtml(it.target_monitor || "—") +
"</td>" +
"<td>" +
fmtPnl(it.pnl) +
"</td>" +
"</tr>"
);
})
.join("");
return tableWrap(["合约", "来源", "类型", "张数", "到期时间", "目标监控", "净盈亏"], rows);
}
function renderHedgeTable(items) {
const rows = items
.map(function (it) {
const stCls = it.status_active ? "inst-dash-status-active" : "";
const stText = it.status_label || (it.status_active ? "进行中" : it.status || "—");
return (
"<tr" +
rowClickAttrs(it.tab || "hedge_plan") +
">" +
"<td>#" +
escapeHtml(it.id != null ? it.id : "—") +
"</td>" +
"<td>" +
escapeHtml(it.underlying || "-") +
"</td>" +
"<td>" +
escapeHtml(it.plan_type_label || it.plan_type || "—") +
"</td>" +
'<td class="' +
stCls +
'">' +
escapeHtml(stText) +
"</td>" +
"<td>" +
escapeHtml(it.contracts_summary || it.subtitle || "—") +
"</td>" +
"</tr>"
);
})
.join("");
return tableWrap(["ID", "标的", "计划类型", "状态", "说明"], rows);
}
function renderTable(key, items) {
if (key === "orders") return renderOrdersTable(items);
if (key === "keys") return renderKeysTable(items);
if (key === "strategy") return renderStrategyTable(items);
if (key === "options") return renderOptionsTable(items);
if (key === "hedge_plan") return renderHedgeTable(items);
return "";
}
function sectionHasData(sec) {
if (!sec) return false;
const count = Number(sec.count);
if (Number.isFinite(count) && count > 0) return true;
return Array.isArray(sec.items) && sec.items.length > 0;
}
function renderSection(key, sec) {
if (!sectionHasData(sec)) return "";
const items = sec.items || [];
const count = Number(sec.count) || items.length;
return (
'<section class="inst-dash-section" data-dash-section="' +
escapeHtml(key) +
'">' +
'<div class="inst-dash-section-head">' +
"<h3>" +
escapeHtml(sec.title || key) +
' <span class="inst-dash-count">' +
count +
"</span></h3>" +
'<button type="button" class="btn-sm inst-dash-goto" data-dash-tab="' +
escapeHtml(sec.tab || "") +
'">打开</button>' +
"</div>" +
renderTable(key, items) +
"</section>"
);
}
function bindClicks(el) {
if (!el) return;
el.querySelectorAll("[data-dash-tab]").forEach(function (node) {
const handler = function () {
goTab(node.getAttribute("data-dash-tab"));
};
node.addEventListener("click", handler);
node.addEventListener("keydown", function (ev) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
handler();
}
});
});
}
async function load(opts) {
const el = root();
if (!el) return;
const status = el.querySelector("#inst-dash-status") || document.getElementById("inst-dash-status");
const sections = el.querySelector("#inst-dash-sections") || document.getElementById("inst-dash-sections");
const updated = el.querySelector("#inst-dash-updated") || document.getElementById("inst-dash-updated");
const options = opts || {};
if (loading && !options.force) return;
loading = true;
if (status && !options.silent) status.textContent = "同步中…";
try {
const dashRes = await fetch("/api/instance/dashboard", { credentials: "same-origin" });
if (dashRes.status === 401) {
location.href = "/login?next=" + encodeURIComponent(location.pathname);
return;
}
const data = await dashRes.json().catch(function () {
return {};
});
if (!dashRes.ok || !data.ok) {
if (
data.aggregating ||
(data.msg && String(data.msg).indexOf("尚未就绪") >= 0)
) {
if (status) status.textContent = "后台聚合中…";
return;
}
throw new Error(data.msg || data.error || dashRes.statusText || "加载失败");
}
const ver = Number(data.dashboard_version) || 0;
if (ver) localDashVersion = ver;
if (data.orders && Array.isArray(data.orders.items)) {
data.orders.count = data.orders.items.length;
}
if (updated) updated.textContent = "更新 " + (data.updated_at || "—");
if (sections) {
const html = SECTION_ORDER.map(function (k) {
return renderSection(k, data[k]);
}).join("");
sections.innerHTML =
html || '<p class="inst-dash-empty muted">当前无活跃监控与持仓</p>';
bindClicks(sections);
if (global.OptionsExpiryCountdown) {
if (typeof global.OptionsExpiryCountdown.tick === "function") {
global.OptionsExpiryCountdown.tick(sections);
}
if (typeof global.OptionsExpiryCountdown.ensureTimer === "function") {
global.OptionsExpiryCountdown.ensureTimer();
}
}
}
const sec = Number(data.poll_interval_sec) || 5;
if (status) {
status.textContent = options.silent
? "SSE 已连接 · 后台每 " + sec + "s 聚合"
: "已更新 · 后台每 " + sec + "s 聚合";
}
} catch (e) {
if (status) status.textContent = e.message || "加载失败";
} finally {
loading = false;
}
}
function closeDashboardStream() {
if (dashEventSource) {
dashEventSource.close();
dashEventSource = null;
}
if (dashReconnectTimer) {
clearTimeout(dashReconnectTimer);
dashReconnectTimer = null;
}
}
function connectDashboardStream() {
const el = root();
if (!el) return;
closeDashboardStream();
dashEventSource = new EventSource("/api/instance/dashboard/stream");
dashEventSource.addEventListener("dashboard", function (ev) {
try {
const st = JSON.parse(ev.data || "{}");
const ver = Number(st.dashboard_version) || 0;
if (ver && ver !== localDashVersion) {
load({ silent: true });
} else if (st.aggregating) {
const status = el.querySelector("#inst-dash-status") || document.getElementById("inst-dash-status");
if (status) status.textContent = "后台聚合中…";
}
} catch (_) {}
});
dashEventSource.onerror = function () {
closeDashboardStream();
const status = el.querySelector("#inst-dash-status") || document.getElementById("inst-dash-status");
if (status) status.textContent = "SSE 断开,8s 后重连…";
dashReconnectTimer = setTimeout(function () {
if (booted) {
connectDashboardStream();
load({ silent: true });
}
}, 8000);
};
}
async function requestDashboardRefresh() {
try {
await fetch("/api/instance/dashboard/refresh", {
method: "POST",
credentials: "same-origin",
});
} catch (_) {}
load({ force: true });
}
function stopAuto() {
closeDashboardStream();
}
function init(force) {
const el = root();
if (!el) return;
if (!force && el.getAttribute("data-dash-booted") === "1") {
booted = true;
load({ silent: true });
connectDashboardStream();
return;
}
el.setAttribute("data-dash-booted", "1");
booted = true;
const btn = el.querySelector("#inst-dash-refresh") || document.getElementById("inst-dash-refresh");
if (btn && !btn.getAttribute("data-bound")) {
btn.setAttribute("data-bound", "1");
btn.addEventListener("click", function () {
requestDashboardRefresh();
});
}
load({});
connectDashboardStream();
}
function refreshSoft(opts) {
load(Object.assign({ silent: true }, opts || {}));
}
global.InstanceDashboard = {
init: init,
refreshSoft: refreshSoft,
load: load,
stopAuto: stopAuto,
};
})(window);
+595
View File
@@ -0,0 +1,595 @@
/**
* 中控 iframe 壳:顶栏/统计常驻,tab 内容走 /api/embed/page/<tab>.
* 各 tab 面板常驻 DOM,切换时 show/hide;脚本延后到首次激活,回访零请求.
*/
(function (global) {
const TAB_PATH = {
dashboard: "/dashboard",
account_ledger: "/account_ledger",
key_monitor: "/key_monitor",
trade: "/trade",
strategy: "/strategy",
strategy_records: "/strategy/records",
options: "/options",
options_review: "/options/review",
hedge_plan: "/hedge-plan",
records: "/records",
stats: "/stats",
risk_policy: "/risk_policy",
system_guide: "/system_guide",
env_config: "/env_config",
settings: "/settings",
};
let navToken = 0;
let loadingTab = false;
let pendingTabLoad = null;
const tabPanes = new Map();
const tabBooted = new Set();
/** 自带 AJAX/校验提交的表单,勿在捕获阶段再 fetch+reloadCurrentTab(会卡在「加载中…」) */
const CUSTOM_SUBMIT_FORM_IDS = new Set([
"add-order-form",
"key-form",
"roll-form",
"journal-form",
]);
function isEmbedShell() {
return document.body && document.body.getAttribute("data-embed-shell") === "1";
}
function getTab() {
try {
const t = new URLSearchParams(location.search).get("tab");
if (t) return t;
} catch (_) {}
return document.body.getAttribute("data-page") || "trade";
}
function listWindowQueryString() {
if (typeof global.listWindowQueryString === "function") {
return global.listWindowQueryString();
}
return "";
}
function pageRoot() {
return document.getElementById("embed-page-root");
}
function setNavActive(tab) {
document.querySelectorAll(".embed-top-nav [data-embed-tab]").forEach((a) => {
a.classList.toggle("active", a.getAttribute("data-embed-tab") === tab);
});
if (global.InstanceMobileNav && typeof global.InstanceMobileNav.onTabChange === "function") {
global.InstanceMobileNav.onTabChange(tab);
} else if (global.InstanceMobileNav && typeof global.InstanceMobileNav.syncTabActive === "function") {
global.InstanceMobileNav.syncTabActive(tab);
}
}
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);
q.set("embed", "1");
const qs = q.toString();
const url = "/embed?" + qs;
if (replace) history.replaceState({ embedTab: tab }, "", url);
else history.pushState({ embedTab: tab }, "", url);
}
function notifyParentTabSwitch(tab) {
try {
window.parent.postMessage({ type: "instance-frame-navigating", embedShellTab: true, tab: tab }, "*");
} catch (_) {}
}
function runPageInit(tab, opts) {
const options = opts || {};
const revisit = !!options.revisit;
document.body.setAttribute("data-page", tab);
if (!revisit && typeof global.attachListWindowToExports === "function") {
global.attachListWindowToExports();
}
if (tab === "trade") {
if (!revisit && typeof global.refreshOrderDefaults === "function") global.refreshOrderDefaults();
if (!revisit && typeof global.initOrderEntryModelSelect === "function") {
const root = pageRoot() || document;
global.initOrderEntryModelSelect(root);
}
if (!revisit && global.ManualOrderRrPreview && typeof global.ManualOrderRrPreview.wire === "function") {
global.ManualOrderRrPreview.wire();
}
}
if (!revisit && tab === "key_monitor" && global.KeyMonitorForm && typeof global.KeyMonitorForm.init === "function") {
global.KeyMonitorForm.init();
}
if (tab === "dashboard" && global.InstanceDashboard && typeof global.InstanceDashboard.init === "function") {
global.InstanceDashboard.init(!!revisit);
}
if (tab === "account_ledger" && global.AccountLedgerPage && typeof global.AccountLedgerPage.boot === "function") {
global.AccountLedgerPage.boot();
}
if (!revisit && tab === "strategy" && typeof global.initStrategyRollForm === "function") {
global.initStrategyRollForm();
}
if (tab === "records") {
if (global.RecordsReviewPage && typeof global.RecordsReviewPage.init === "function") {
global.RecordsReviewPage.init({ refresh: !!revisit });
} else {
if (!revisit && typeof global.loadJournals === "function") global.loadJournals();
if (!revisit && typeof global.loadReviews === "function") global.loadReviews();
}
if (global.InstanceTheme && typeof global.InstanceTheme.initReviewEditModeSync === "function") {
global.InstanceTheme.initReviewEditModeSync();
} else if (typeof global.toggleReviewMode === "function") {
global.toggleReviewMode();
}
}
if (tab === "stats") {
if (typeof global.initStatsSegmentFromUrl === "function") global.initStatsSegmentFromUrl();
}
if (tab === "settings" || tab === "env_config") {
if (global.InstanceSettingsPrefs) {
if (typeof global.InstanceSettingsPrefs.bindEvents === "function") {
global.InstanceSettingsPrefs.bindEvents();
}
if (tab === "settings" && typeof global.InstanceSettingsPrefs.loadDisplayPrefsForm === "function") {
global.InstanceSettingsPrefs.loadDisplayPrefsForm();
}
if (tab === "env_config") {
if (typeof global.InstanceSettingsPrefs.loadEnvConfig === "function") {
global.InstanceSettingsPrefs.loadEnvConfig();
}
if (typeof global.InstanceSettingsPrefs.bindEnvTabs === "function") {
global.InstanceSettingsPrefs.bindEnvTabs();
}
}
}
}
if (!revisit) {
if (typeof global.refreshAccountSnapshot === "function") {
global.refreshAccountSnapshot({ silent: true });
}
if (typeof global.refreshPriceSnapshotConditional === "function") {
global.refreshPriceSnapshotConditional();
}
if (global.SymbolLivePrice && typeof global.SymbolLivePrice.init === "function") {
const root = pageRoot() || document;
global.SymbolLivePrice.init(root);
}
if (global.JournalUploadSlots && typeof global.JournalUploadSlots.init === "function") {
const root = pageRoot() || document;
global.JournalUploadSlots.init(root);
}
if (global.JournalFormSave && typeof global.JournalFormSave.init === "function") {
global.JournalFormSave.init();
}
}
}
function runScripts(container) {
container.querySelectorAll("script").forEach((old) => {
const s = document.createElement("script");
if (old.src) s.src = old.src;
else s.textContent = old.textContent;
old.replaceWith(s);
});
}
function showPane(tab) {
tabPanes.forEach((pane, name) => {
const on = name === tab;
pane.hidden = !on;
pane.classList.toggle("is-active-pane", on);
});
}
function bootPaneScripts(tab) {
if (tabBooted.has(tab)) return;
const pane = tabPanes.get(tab);
if (!pane) return;
runScripts(pane);
tabBooted.add(tab);
}
function mountPane(tab, html) {
const root = pageRoot();
if (!root) return null;
const existing = tabPanes.get(tab);
if (existing) existing.remove();
const pane = document.createElement("div");
pane.className = "embed-tab-pane";
pane.setAttribute("data-embed-pane", tab);
pane.hidden = true;
const holder = document.createElement("div");
holder.innerHTML = html;
while (holder.firstChild) pane.appendChild(holder.firstChild);
root.appendChild(pane);
tabPanes.set(tab, pane);
return pane;
}
function initBootPane() {
const root = pageRoot();
if (!root || tabPanes.size > 0) return;
const tab = getTab();
if (root.querySelector("[data-embed-pane]")) return;
if (!root.childNodes.length) return;
const pane = document.createElement("div");
pane.className = "embed-tab-pane is-active-pane";
pane.setAttribute("data-embed-pane", tab);
Array.from(root.childNodes).forEach((node) => pane.appendChild(node));
root.appendChild(pane);
tabPanes.set(tab, pane);
tabBooted.add(tab);
showPane(tab);
}
function embedPageUrl(tab) {
const qs = listWindowQueryString();
let url = "/api/embed/page/" + encodeURIComponent(tab);
const parts = [];
if (qs) parts.push(qs);
parts.push("embed=1");
if (tab === "settings") {
try {
const st = new URLSearchParams(location.search).get("settings_tab");
if (st) parts.push("settings_tab=" + encodeURIComponent(st));
} catch (_) {}
}
return url + "?" + parts.join("&");
}
function setSettingsSubTabInUrl(key) {
if (!key) return;
try {
const q = new URLSearchParams(location.search);
q.set("tab", "settings");
q.set("settings_tab", key);
q.set("embed", "1");
history.replaceState(null, "", "/embed?" + q.toString());
} catch (_) {}
}
function activateSettingsSubTab(key) {
if (!key) return;
setSettingsSubTabInUrl(key);
const pane = tabPanes.get("settings") || document;
const radio = pane.querySelector(
'input.env-tab-radio[data-settings-tab="' + key + '"]'
);
if (radio) radio.checked = true;
}
function formActionPath(form) {
try {
return new URL(form.action || "", location.href).pathname.replace(/\/$/, "") || "/";
} catch (_) {
return "";
}
}
function maybeKeepSettingsSubTabAfterForm(form) {
const path = formActionPath(form);
if (path === "/manual_transfer") {
setSettingsSubTabInUrl("transfer");
return "transfer";
}
if (path.indexOf("/api/options/transfer") >= 0) {
setSettingsSubTabInUrl("options_transfer");
return "options_transfer";
}
return "";
}
async function fetchTabHtml(tab) {
const r = await fetch(embedPageUrl(tab), {
credentials: "same-origin",
cache: "no-store",
headers: { "X-Instance-Soft-Nav": "1" },
});
const ct = (r.headers.get("content-type") || "").toLowerCase();
if (!ct.includes("application/json")) {
throw new Error("加载失败(HTTP " + r.status + ")");
}
const j = await r.json();
if (!j.ok || !j.html) throw new Error(j.msg || "加载失败");
return j.html;
}
function warmTabCache(tab) {
if (!tab || tabPanes.has(tab) || loadingTab) return;
fetchTabHtml(tab)
.then((html) => {
if (!tabPanes.has(tab)) mountPane(tab, html);
})
.catch(() => {});
}
function preloadAllTabs() {
const tabs = Object.keys(TAB_PATH);
const current = getTab();
const heavyLast = new Set(["options", "records", "stats"]);
const ordered = tabs.filter((t) => t !== current && !heavyLast.has(t))
.concat(tabs.filter((t) => heavyLast.has(t) && t !== current));
let idx = 0;
function step() {
if (idx >= ordered.length) return;
const tab = ordered[idx++];
if (tabPanes.has(tab)) {
step();
return;
}
fetchTabHtml(tab)
.then((html) => {
if (!tabPanes.has(tab)) mountPane(tab, html);
})
.catch(() => {})
.finally(() => {
setTimeout(step, heavyLast.has(tab) ? 400 : 180);
});
}
const ric = global.requestIdleCallback || function (fn) {
setTimeout(fn, 2000);
};
ric(step);
}
function clearTabCache() {
tabPanes.forEach((pane) => pane.remove());
tabPanes.clear();
tabBooted.clear();
}
function syncShellChrome(tab) {
const hideTopBar =
tab === "settings" || tab === "risk_policy" || tab === "system_guide" || tab === "env_config";
document.querySelectorAll(".instance-top-bar").forEach((el) => {
el.hidden = hideTopBar;
});
}
function initPaneThemeToggle(tab) {
if (tab !== "settings") return;
const pane = tabPanes.get(tab);
if (!pane || !global.InstanceTheme) return;
if (typeof global.InstanceTheme.initToggleUI === "function") {
global.InstanceTheme.initToggleUI(pane);
}
if (typeof global.InstanceTheme.syncToggleUI === "function") {
global.InstanceTheme.syncToggleUI(pane);
}
}
function activateTab(tab, opts) {
const options = opts || {};
const revisit = !!options.revisit;
const firstBoot = !tabBooted.has(tab);
syncShellChrome(tab);
showPane(tab);
setNavActive(tab);
if (!options.skipUrl) syncUrl(tab, !!options.replace);
notifyParentTabSwitch(tab);
if (firstBoot) {
bootPaneScripts(tab);
initPaneThemeToggle(tab);
runPageInit(tab, { revisit: false });
return;
}
if (revisit) {
document.body.setAttribute("data-page", tab);
runPageInit(tab, { revisit: true });
return;
}
runPageInit(tab, { revisit: false });
}
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 }));
return;
}
if (loadingTab) {
pendingTabLoad = { tab: tab, opts: options };
return;
}
const token = ++navToken;
loadingTab = true;
try {
const html = await fetchTabHtml(tab);
if (token !== navToken) return;
mountPane(tab, html);
activateTab(tab, options);
} catch (e) {
if (token === navToken) {
const flash = document.getElementById("embed-flash");
if (flash) {
flash.style.display = "";
flash.textContent = String(e && e.message ? e.message : e);
}
}
} finally {
if (token === navToken) loadingTab = false;
if (pendingTabLoad) {
const pending = pendingTabLoad;
pendingTabLoad = null;
if (pending.tab !== tab) void loadTab(pending.tab, pending.opts);
}
}
}
function reloadCurrentTab() {
const tab = getTab();
const pane = tabPanes.get(tab);
if (pane) pane.remove();
tabPanes.delete(tab);
tabBooted.delete(tab);
return loadTab(tab, { replace: true, skipUrl: true, force: true });
}
function postFormAndReload(form, label) {
if (!form) return Promise.resolve();
if (global.FormSubmitGuard) {
if (global.FormSubmitGuard.isLocked(form)) {
global.FormSubmitGuard.setSubmitLabel(form, label || "提交中…");
} else {
global.FormSubmitGuard.lock(form, label || "提交中…");
}
}
const fd = new FormData(form);
const keepSub = maybeKeepSettingsSubTabAfterForm(form);
return fetch(form.action, {
method: form.method || "POST",
body: fd,
credentials: "same-origin",
redirect: "manual",
})
.then(() => reloadCurrentTab().then(() => activateSettingsSubTab(keepSub)))
.catch(() => reloadCurrentTab().then(() => activateSettingsSubTab(keepSub)));
}
function patchApplyListWindow() {
if (typeof global.applyListWindow !== "function") return;
global.applyListWindow = function embedApplyListWindow() {
clearTabCache();
const qs = listWindowQueryString();
const tab = getTab();
const q = new URLSearchParams(qs);
q.set("tab", tab);
q.set("embed", "1");
window.location.href = "/embed?" + q.toString();
};
}
function patchHardNavigations() {
const resubmitPaths =
/^\/(del_|delete_|add_|stop_|strategy\/|trend_|roll_|cancel_|place_)/;
document.addEventListener(
"click",
(ev) => {
if (!isEmbedShell()) return;
const a = ev.target.closest("a[href]");
if (!a || ev.defaultPrevented) return;
if (a.closest(".embed-top-nav")) return;
if (a.hasAttribute("download") || a.target === "_blank") return;
const raw = a.getAttribute("href");
if (!raw || raw.startsWith("#") || raw.startsWith("javascript:")) return;
let url;
try {
url = new URL(raw, location.href);
} catch (_) {
return;
}
if (url.origin !== location.origin) return;
if (url.pathname.startsWith("/export/") || url.pathname.startsWith("/order_focus") || url.pathname.startsWith("/key_focus")) {
return;
}
if (!resubmitPaths.test(url.pathname)) return;
ev.preventDefault();
fetch(url.pathname + url.search, { credentials: "same-origin", redirect: "manual" })
.then(() => reloadCurrentTab())
.catch(() => reloadCurrentTab());
},
false
);
document.addEventListener(
"submit",
(ev) => {
if (!isEmbedShell()) return;
const form = ev.target;
if (!(form instanceof HTMLFormElement)) return;
if (form.method && form.method.toUpperCase() === "GET") return;
if (CUSTOM_SUBMIT_FORM_IDS.has(form.id)) return;
ev.preventDefault();
const fd = new FormData(form);
const keepSub = maybeKeepSettingsSubTabAfterForm(form);
fetch(form.action, {
method: form.method || "POST",
body: fd,
credentials: "same-origin",
redirect: "manual",
})
.then(() => reloadCurrentTab().then(() => activateSettingsSubTab(keepSub)))
.catch(() => reloadCurrentTab().then(() => activateSettingsSubTab(keepSub)));
},
true
);
}
function bindNav() {
document.querySelectorAll(".embed-top-nav [data-embed-tab]").forEach((a) => {
a.addEventListener("mouseenter", () => {
warmTabCache(a.getAttribute("data-embed-tab"));
});
a.addEventListener("click", (ev) => {
ev.preventDefault();
const tab = a.getAttribute("data-embed-tab");
if (!tab || tab === getTab()) return;
void loadTab(tab);
});
});
window.addEventListener("popstate", () => {
const tab = getTab();
void loadTab(tab, { replace: true, skipUrl: true });
});
}
function boot() {
if (!isEmbedShell()) return;
patchApplyListWindow();
patchHardNavigations();
initBootPane();
const bootTab = getTab();
if (!pageNavAllowed(bootTab)) {
void loadTab("trade", { replace: true });
return;
}
if (bootTab === "settings") {
initPaneThemeToggle("settings");
}
bindNav();
syncShellChrome(getTab());
runPageInit(getTab());
preloadAllTabs();
try {
window.parent.postMessage({ type: "instance-frame-ready" }, "*");
} catch (_) {}
}
global.InstanceEmbed = {
loadTab,
reloadCurrentTab,
getTab,
postFormAndReload,
clearTabCache,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
})(typeof window !== "undefined" ? window : globalThis);
+111
View File
@@ -0,0 +1,111 @@
/**
* embed 壳:SSE 收到后台 tick 后拉 JSON 快照更新 DOM,切换 tab 不再重复请求 HTML.
*/
(function (global) {
let liveEventSource = null;
let liveReconnectTimer = null;
let localLiveVersion = -1;
let sseConnected = false;
let refreshTimer = null;
function isEmbedShell() {
return document.body && document.body.getAttribute("data-embed-shell") === "1";
}
function currentTab() {
if (global.InstanceEmbed && typeof global.InstanceEmbed.getTab === "function") {
return global.InstanceEmbed.getTab();
}
return document.body.getAttribute("data-page") || "trade";
}
function refreshTabData(tab, opts) {
const options = opts || {};
if (typeof global.refreshAccountSnapshot === "function") {
global.refreshAccountSnapshot(options);
}
if (typeof global.refreshPriceSnapshotConditional === "function") {
global.refreshPriceSnapshotConditional();
}
if (tab === "options" && global.OptionsPanelLive && typeof global.OptionsPanelLive.refreshSoft === "function") {
global.OptionsPanelLive.refreshSoft(options);
}
// 数据看板自有 SSE + 快照,不跟 embed live tick 重拉.
}
function scheduleRefresh(opts) {
if (refreshTimer) return;
const options = opts || {};
refreshTimer = setTimeout(function () {
refreshTimer = null;
if (document.hidden) return;
refreshTabData(currentTab(), { silent: true, force: !!options.force });
}, 80);
}
function onLiveEvent(data) {
const reason = data && data.reason;
const ver = Number(data && data.live_version) || 0;
if (!ver) return;
if (reason === "connect") {
localLiveVersion = ver;
scheduleRefresh();
return;
}
if (ver === localLiveVersion) return;
localLiveVersion = ver;
scheduleRefresh({ force: reason === "balance" });
}
function closeLiveStream() {
if (liveEventSource) {
liveEventSource.close();
liveEventSource = null;
}
if (liveReconnectTimer) {
clearTimeout(liveReconnectTimer);
liveReconnectTimer = null;
}
sseConnected = false;
}
function connectLiveStream() {
if (!isEmbedShell()) return;
closeLiveStream();
liveEventSource = new EventSource("/api/instance/live/stream");
liveEventSource.addEventListener("live", function (ev) {
try {
onLiveEvent(JSON.parse(ev.data || "{}"));
} catch (_) {}
});
liveEventSource.onopen = function () {
sseConnected = true;
};
liveEventSource.onerror = function () {
sseConnected = false;
closeLiveStream();
liveReconnectTimer = setTimeout(function () {
connectLiveStream();
}, 8000);
};
}
function startLive() {
if (!isEmbedShell()) return;
connectLiveStream();
}
global.InstanceLive = {
start: startLive,
refreshTabData: refreshTabData,
isConnected: function () {
return sseConnected;
},
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", startLive);
} else {
startLive();
}
})(typeof window !== "undefined" ? window : globalThis);
+177
View File
@@ -0,0 +1,177 @@
/**
* 实例手机壳: ≤720px 底栏 +「更多」,与 embed soft-nav 同步.
*/
(function (global) {
const PRIMARY = { trade: 1, key_monitor: 1, options: 1 };
const MQ = "(max-width: 720px)";
function isEmbedShell() {
return document.body && document.body.getAttribute("data-embed-shell") === "1";
}
function isMobileLayout() {
return window.matchMedia(MQ).matches;
}
function syncPhoneClass() {
if (!document.body) return;
document.body.classList.toggle("inst-phone", isMobileLayout());
}
function currentTab() {
if (global.InstanceEmbed && typeof global.InstanceEmbed.getTab === "function") {
return global.InstanceEmbed.getTab();
}
try {
const t = new URLSearchParams(location.search).get("tab");
if (t) return t;
} catch (_) {}
return (document.body && document.body.getAttribute("data-page")) || "trade";
}
function closeMore() {
document.body.classList.remove("inst-mobile-more-open");
const more = document.getElementById("inst-mobile-more");
const btn = document.getElementById("inst-m-tab-more");
if (more) more.setAttribute("aria-hidden", "true");
if (btn) btn.setAttribute("aria-expanded", "false");
syncTabActive(currentTab());
}
function openMore() {
if (!isMobileLayout()) return;
document.body.classList.add("inst-mobile-more-open");
const more = document.getElementById("inst-mobile-more");
const btn = document.getElementById("inst-m-tab-more");
if (more) more.setAttribute("aria-hidden", "false");
if (btn) btn.setAttribute("aria-expanded", "true");
syncTabActive(currentTab());
}
function toggleMore() {
if (document.body.classList.contains("inst-mobile-more-open")) closeMore();
else openMore();
}
function syncTabActive(tab) {
const page = tab || currentTab();
const primary = !!PRIMARY[page];
const moreOpen = document.body.classList.contains("inst-mobile-more-open");
document.querySelectorAll("#inst-mobile-tabbar .inst-m-tab").forEach((el) => {
const t = el.getAttribute("data-embed-tab") || "";
let on = false;
if (t === "more") on = moreOpen || !primary;
else on = !moreOpen && t === page;
el.classList.toggle("active", on);
});
document.querySelectorAll("#inst-mobile-more .inst-mobile-more-nav [data-embed-tab]").forEach((a) => {
a.classList.toggle("active", a.getAttribute("data-embed-tab") === page);
});
}
/** embed 切页时关闭「更多」并同步高亮 */
function onTabChange(tab) {
document.body.classList.remove("inst-mobile-more-open");
const more = document.getElementById("inst-mobile-more");
const btn = document.getElementById("inst-m-tab-more");
if (more) more.setAttribute("aria-hidden", "true");
if (btn) btn.setAttribute("aria-expanded", "false");
syncTabActive(tab);
}
function goTab(tab) {
if (!tab || tab === "more") return;
closeMore();
if (global.InstanceEmbed && typeof global.InstanceEmbed.loadTab === "function") {
if (tab === currentTab()) {
syncTabActive(tab);
return;
}
void global.InstanceEmbed.loadTab(tab);
return;
}
const pathMap = {
dashboard: "/dashboard",
key_monitor: "/key_monitor",
trade: "/trade",
strategy: "/strategy",
strategy_records: "/strategy/records",
options: "/options",
options_review: "/options/review",
hedge_plan: "/hedge-plan",
records: "/records",
stats: "/stats",
risk_policy: "/risk_policy",
system_guide: "/system_guide",
env_config: "/env_config",
settings: "/settings",
};
location.href = pathMap[tab] || "/trade";
}
function bindChrome() {
const moreBtn = document.getElementById("inst-m-tab-more");
const backdrop = document.getElementById("inst-mobile-more-backdrop");
const closeBtn = document.getElementById("inst-mobile-more-close");
if (moreBtn) {
moreBtn.addEventListener("click", (ev) => {
ev.preventDefault();
toggleMore();
});
}
if (backdrop) backdrop.addEventListener("click", closeMore);
if (closeBtn) closeBtn.addEventListener("click", closeMore);
document.addEventListener("keydown", (ev) => {
if (ev.key === "Escape" && document.body.classList.contains("inst-mobile-more-open")) {
closeMore();
}
});
document.querySelectorAll("#inst-mobile-tabbar .inst-m-tab[data-embed-tab]").forEach((el) => {
if (el.getAttribute("data-embed-tab") === "more") return;
el.addEventListener("click", (ev) => {
if (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return;
ev.preventDefault();
goTab(el.getAttribute("data-embed-tab"));
});
});
document.querySelectorAll("#inst-mobile-more .inst-mobile-more-nav [data-embed-tab]").forEach((a) => {
a.addEventListener("click", (ev) => {
if (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return;
ev.preventDefault();
goTab(a.getAttribute("data-embed-tab"));
});
});
}
function boot() {
if (!isEmbedShell()) return;
if (!document.getElementById("inst-mobile-tabbar")) return;
syncPhoneClass();
bindChrome();
syncTabActive(currentTab());
let resizeTimer = null;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
const was = document.body.classList.contains("inst-phone");
syncPhoneClass();
if (!isMobileLayout()) closeMore();
else if (!was) syncTabActive(currentTab());
}, 120);
});
}
global.InstanceMobileNav = {
syncTabActive,
onTabChange,
closeMore,
isMobileLayout,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
})(typeof window !== "undefined" ? window : globalThis);
+332
View File
@@ -0,0 +1,332 @@
.order-trade-style-hint{font-size:.78rem;color:#8fc8ff;margin-left:4px;white-space:nowrap}
.order-entry-model-row{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
.order-entry-model-row select.order-entry-category{min-width:4.8em;max-width:6.5em}
.order-entry-model-row select.order-entry-model-sub{min-width:7em;max-width:10rem}
.order-leverage-hint{font-size:.78rem;color:#cfd3ef;white-space:nowrap;align-self:center}
body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;background:#0b0d14;color:#eaeaea;padding:14px 20px}
.container{width:100%;max-width:min(1440px,94vw);margin:0 auto;padding:0 clamp(8px,1.5vw,20px)}
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px}
.header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25}
.exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em}
.header-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center}
.top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px}
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
.stat-box{display:grid;grid-template-columns:repeat(auto-fit,minmax(148px,1fr));gap:12px;margin-bottom:16px;align-items:stretch}
.stat-item{min-width:0;min-height:76px;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:6px;background:#151a2a;padding:12px 10px;border-radius:10px;text-align:center;border:1px solid #2a3152}
.stat-item .label{font-size:.8rem;color:#aaa;line-height:1.25;max-width:100%}
.stat-item .value{font-size:1.25rem;font-weight:600;color:#fff;line-height:1.3;min-height:1.35em;display:flex;align-items:center;justify-content:center}
.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
.card{background:#121726;border-radius:10px;padding:12px;border:1px solid #2a3150}
.full{grid-column:1/-1}
.card h2{font-size:1rem;margin-bottom:10px;color:#d4d9ff}
.form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;align-items:center}
.form-row > input:not([type=checkbox]):not([type=radio]),.form-row > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem}
/* 实盘下单监控:分层布局 */
.order-monitor-form{display:flex;flex-direction:column;gap:10px;margin-bottom:4px}
.order-monitor-form .om-row{display:flex;flex-wrap:wrap;align-items:flex-end;gap:8px}
.order-monitor-form .om-row-policy > input:not([type=checkbox]):not([type=radio]),
.order-monitor-form .om-row-policy > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem}
.order-monitor-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
.order-monitor-form .om-field{display:flex;flex-direction:column;gap:4px;min-width:7.5rem}
.order-monitor-form .om-field-lab{font-size:.72rem;color:#9aa3c7;line-height:1;letter-spacing:.02em}
.order-monitor-form .om-field input{width:9.5rem;max-width:160px;box-sizing:border-box}
.order-monitor-form .om-live-meta{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding-bottom:2px;margin-left:auto}
.order-monitor-form .om-row-opts{align-items:center;gap:12px;padding-top:2px}
.order-monitor-form .om-check{display:inline-flex;align-items:center;gap:5px;font-size:.82rem;color:#cfd3ef;cursor:pointer;user-select:none}
.order-monitor-form .om-time-close{display:inline-flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef}
.order-monitor-form .om-time-close select{width:auto;min-width:4.2rem;max-width:5.5rem;padding:6px 8px}
.order-monitor-form .om-row-action{padding-top:2px;display:flex;flex-wrap:wrap;align-items:center;gap:10px 14px}
.order-monitor-form .om-submit{min-width:11rem;padding:10px 18px;font-weight:600}
.order-monitor-form .om-submit.is-blocked,
.order-monitor-form .om-submit:disabled{
opacity:.45;
cursor:not-allowed;
filter:grayscale(.35);
pointer-events:none;
}
.order-monitor-form .om-open-block-note{
color:var(--danger,#ff7b7b);
font-size:13px;
line-height:1.4;
max-width:min(28rem,100%);
}
.order-plan-preview{display:flex;gap:18px;flex-wrap:wrap;align-items:center;margin:4px 0 10px;padding:10px 12px;background:#151a28;border:1px solid #2a3150;border-radius:8px;font-size:.85rem}
#add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
.order-preview-risk{color:#ff6b6b}
.order-preview-risk strong{color:#ff8f8f;font-weight:600}
.order-preview-profit{color:#4cd97f}
.order-preview-profit strong{color:#6ee7a0;font-weight:600}
.order-preview-rr{color:#cfd3ef}
.order-preview-rr strong{font-weight:600;color:#dbe4ff}
.order-preview-rr.order-preview-rr-low strong{color:#ff8f8f}
.order-preview-rr.order-preview-rr-ok strong{color:#8fc8ff}
.form-row > button,.form-row > label{flex:0 0 auto}
.form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
/* 复盘表单:长下拉文案需可收缩,否则会撑破四列网格 */
.journal-card .form-grid{gap:10px}
.journal-card .form-grid > input,
.journal-card .form-grid > select{
min-width:0;
width:100%;
max-width:100%;
box-sizing:border-box;
}
.journal-card #journal-form textarea[name="note"]{
display:block;width:100%;max-width:100%;box-sizing:border-box;margin-top:8px;
}
input,select,button,textarea{padding:8px 10px;border-radius:8px;border:1px solid #2e2e45;background:#1a1a29;color:#fff;font-size:.88rem;outline:none}
button{background:linear-gradient(90deg,#4285f4,#7b42ff);border:none;cursor:pointer}
.list{display:flex;flex-direction:column;gap:8px;margin-top:8px;max-height:240px;overflow:auto}
.list-item{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:9px;background:#1a2034;border:1px solid #2a3150;border-radius:8px}
.btn-del{padding:5px 9px;background:#2f2134;color:#ff7b7b;border-radius:8px;text-decoration:none;font-size:.8rem}
.rule-tip{font-size:.8rem;color:#95a2c2;margin-bottom:8px}
table{width:100%;border-collapse:collapse}
th,td{padding:8px;text-align:left;border-bottom:1px solid #25253b;font-size:.85rem}
th{color:#a9a9ff}
.badge{padding:2px 6px;border-radius:6px;font-size:.72rem}
.profit{background:#1e332f;color:#4cd97f}
.loss{background:#331e24;color:#ff6666}
.miss{background:#29241e;color:#eac147}
.direction{background:#1e2533;color:#4cc2ff}
.direction-long{background:#1e332f;color:#4cd97f}
.direction-short{background:#331e24;color:#ff6666}
.pnl-profit{color:#4cd97f;font-weight:600}
.pnl-loss{color:#ff6666;font-weight:600}
.flash{padding:10px;background:#1e2533;color:#4cc2ff;border-radius:10px;margin-bottom:12px;text-align:center;border:1px solid #304164}
form.is-form-submitting{opacity:.88;pointer-events:none}
form.is-form-submitting button[type=submit],form.is-form-submitting input[type=submit]{cursor:wait}
.ai-result{background:#1a1a29;border:1px solid #2e2e45;border-radius:8px;padding:10px;white-space:pre-wrap;max-height:220px;overflow:auto;font-size:.84rem;line-height:1.45;margin-top:8px}
.ai-result.ai-result-md,.detail-modal .panel-body.md-review{white-space:normal}
.ai-result-md p,.detail-modal .panel-body.md-review p{margin:6px 0;color:#dde2ff}
.ai-result-md ul,.ai-result-md ol,.detail-modal .panel-body.md-review ul,.detail-modal .panel-body.md-review ol{margin:6px 0 8px 1.25em;padding:0}
.ai-result-md li,.detail-modal .panel-body.md-review li{margin:5px 0;line-height:1.5}
.ai-result-md strong,.detail-modal .panel-body.md-review strong{color:#f0f3ff;font-weight:600}
.ai-result-md h2,.detail-modal .panel-body.md-review h2{font-size:1.02rem;color:#b8c8ff;margin:14px 0 8px;padding-bottom:4px;border-bottom:1px solid #2e2e45}
.ai-result-md h3,.detail-modal .panel-body.md-review h3{font-size:.92rem;color:#c9d4ff;margin:10px 0 6px}
.ai-result-md code,.detail-modal .panel-body.md-review code{background:#252538;padding:1px 4px;border-radius:4px;font-size:.82em}
.ai-result-md .md-raw-block-title,.detail-modal .panel-body.md-review .md-raw-block-title{margin-top:14px;padding-top:10px;border-top:1px dashed #3a3a55;color:#a8b0d8;font-weight:600}
.price-up{color:#4cd97f}
.price-down{color:#ff6666}
.price-flat{color:#cfd3ef}
.panel-list{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.panel-item{background:#141423;border:1px solid #24243b;border-radius:10px;padding:10px;max-height:260px;overflow:auto}
.entry{border-bottom:1px solid #2b2b43;padding:8px 0}
.entry:last-child{border-bottom:none}
.table-del{padding:4px 8px;background:#2f2134;color:#ff7b7b;border:none;border-radius:6px;cursor:pointer;font-size:.78rem}
.mood-grid{display:flex;gap:10px;flex-wrap:wrap;font-size:.82rem;color:#d7d7ea}
.mood-grid label{display:flex;align-items:center;gap:3px}
.screenshot{width:100px;border-radius:6px;cursor:pointer;margin-top:6px}
.modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.78);justify-content:center;align-items:center;z-index:2100}
.modal img{max-width:90%;max-height:90%;border-radius:8px}
.detail-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.78);justify-content:center;align-items:center;z-index:1200;padding:20px}
.detail-modal .panel{width:min(92vw,980px);max-height:88vh;overflow:auto;background:#121726;border:1px solid #2a3150;border-radius:10px;padding:14px}
.detail-modal .panel-head{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:10px}
.detail-modal .panel-title{font-size:1rem;color:#dbe4ff}
.detail-modal .panel-close{padding:6px 10px;background:#2f2134;color:#ffb2b2;border:none;border-radius:8px;cursor:pointer}
.detail-modal .panel-body{white-space:pre-wrap;line-height:1.5;font-size:.86rem;color:#e5e9ff}
.detail-modal .panel-image{margin-top:10px;max-width:min(100%,680px);border-radius:8px;cursor:pointer;border:1px solid #2a3150}
.detail-modal .panel-actions{display:flex;gap:8px;align-items:center;flex-shrink:0}
.detail-modal .panel-fs{padding:6px 10px;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;cursor:pointer;font-size:.82rem}
.detail-modal.fullscreen{padding:10px}
.detail-modal.fullscreen .panel{width:100%;height:100%;max-width:none;max-height:none;display:flex;flex-direction:column;overflow:hidden}
.detail-modal.fullscreen .panel-body{flex:1;overflow:auto;min-height:0;font-size:.9rem}
.ai-result-wrap{margin-top:8px}
.ai-result-toolbar{display:flex;gap:8px;margin-top:6px}
.ai-result-toolbar .btn-fs{padding:4px 10px;font-size:.78rem;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:6px;cursor:pointer}
.table-wrap{overflow-x:auto}
.dual-panel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:stretch}
.dual-panel-grid .card{height:100%;display:flex;flex-direction:column}
.panel-scroll{flex:1;min-height:280px;max-height:420px;overflow:auto}
.records-card{grid-column:1/-1}
.review-card{grid-column:1/-1}
.review-card-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap}
.review-card-head h2{margin:0}
.review-card-fs-btn{padding:6px 12px;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;cursor:pointer;font-size:.82rem;white-space:nowrap}
.review-card-fs-btn:hover{filter:brightness(1.08)}
body.review-card-fullscreen-open{overflow:hidden}
.review-card.is-fullscreen{
position:fixed;inset:12px;z-index:1100;margin:0;
width:auto !important;max-width:none;height:auto;
overflow:auto;display:flex;flex-direction:column;
box-shadow:0 12px 48px rgba(0,0,0,.55);
}
.review-card.is-fullscreen .panel-list{flex:1;min-height:320px}
.review-card.is-fullscreen .panel-item{max-height:none;height:auto;min-height:280px}
.review-card.is-fullscreen .ai-result{max-height:min(36vh, 320px)}
@media (max-width: 1200px){
.stat-box{grid-template-columns:repeat(auto-fill,minmax(140px,1fr))}
}
@media (min-width: 1440px){
.panel-scroll,.pos-list{max-height:420px}
.records-card .table-wrap{max-height:620px;overflow:auto}
}
@media (min-width: 2200px){
.container{max-width:min(1720px,90vw)}
}
@media (min-width: 2560px){
.container{max-width:min(1860px,88vw)}
.dual-panel-grid{gap:18px}
}
@media (min-width: 3000px){
.container{max-width:min(1980px,86vw)}
.pos-grid{grid-template-columns:repeat(4,minmax(0,1fr))}
}
@media (max-width: 1100px){
.grid{grid-template-columns:1fr}
.dual-panel-grid{grid-template-columns:1fr}
.records-card,.review-card{grid-column:auto}
.panel-list{grid-template-columns:1fr}
}
@media (max-width: 960px){
body{padding:10px}
.form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
.stat-box{grid-template-columns:repeat(2,minmax(0,1fr))}
}
.stats-detail{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px;margin-top:10px}
.stats-detail .stat-item{min-width:0;min-height:0;display:block;text-align:left;padding:10px 12px;align-items:stretch;gap:4px}
.stats-detail .stat-item .value{min-height:0;display:block;font-size:1.05rem}
.stats-detail .stat-item .label{font-size:.75rem}
.stats-detail .stat-item .value{font-size:1.05rem;word-break:break-all}
.export-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px;font-size:.85rem}
.export-bar a{color:#8fc8ff;text-decoration:none;padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a}
.export-bar a:hover{background:#1f2740}
.list-window-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px;padding:10px 12px;background:#151a2a;border:1px solid #304164;border-radius:10px;font-size:.82rem}
.list-window-bar label{color:#9aa;display:flex;align-items:center;gap:6px}
.stats-segment-block{margin-top:20px;padding-top:14px;border-top:1px solid #3a4468}
.stats-segment-block h2{font-size:1.05rem;color:#dbe4ff;margin-bottom:8px}
.stats-period-tabs{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px;position:relative;z-index:2}
.stats-period-tab{background:#151a2a;color:#9aa3bf;border:1px solid #304164;border-radius:8px;padding:7px 14px;font-size:.84rem;cursor:pointer;transition:background .15s,border-color .15s,color .15s}
.stats-period-tab:hover{background:#1c2438;color:#cfd3ef}
.stats-period-tab.active{background:#1f3a5a;color:#8fc8ff;border-color:#3d5f8a;font-weight:600}
.stats-period-pane[hidden]{display:none!important}
.stats-period-range{font-size:.78rem;color:#8892b0;margin-bottom:12px;line-height:1.45}
.inst-stats-viz{display:flex;flex-direction:column;gap:14px;margin-bottom:14px}
.inst-stats-kpis{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}
.inst-stats-kpi{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;padding:12px 10px;background:#151a2a;border:1px solid #2a3152;border-radius:10px;text-align:center;min-height:88px}
.inst-stats-kpi-val{font-size:1.15rem;font-weight:700;font-variant-numeric:tabular-nums;line-height:1.2}
.inst-stats-kpi-lbl{font-size:.72rem;color:#8892b0;line-height:1.3}
.inst-stats-ring{--win-pct:0;width:56px;height:56px;border-radius:50%;background:conic-gradient(#4cd97f 0 calc(var(--win-pct) * 1%),#ff6b6b calc(var(--win-pct) * 1%) 100%);display:flex;align-items:center;justify-content:center;position:relative}
.inst-stats-ring::before{content:"";position:absolute;inset:7px;border-radius:50%;background:#151a2a}
.inst-stats-ring-label{position:relative;z-index:1;font-size:.78rem;font-weight:700;font-variant-numeric:tabular-nums}
.inst-stats-block{padding:12px;background:#141923;border:1px solid #2a3150;border-radius:10px}
.inst-stats-block-title{font-size:.72rem;color:#8892b0;margin-bottom:8px}
.inst-stats-stacked-bar{display:flex;height:10px;border-radius:6px;overflow:hidden;background:#1e2438}
.inst-stats-stacked-fill{height:100%;min-width:0;transition:width .2s ease}
.inst-stats-stacked-fill--profit{background:#4cd97f}
.inst-stats-stacked-fill--loss{background:#ff6b6b}
.inst-stats-bar-labels{display:flex;justify-content:space-between;gap:10px;margin-top:8px;font-size:.76rem;font-variant-numeric:tabular-nums}
.inst-stats-risk-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 12px}
.inst-stats-risk-item{display:flex;flex-direction:column;gap:3px;min-width:0}
.inst-stats-risk-item .k{font-size:.7rem;color:#8892b0}
.inst-stats-risk-item .v{font-size:.84rem;font-weight:600;font-variant-numeric:tabular-nums;color:#e8ecf4;word-break:break-word}
.inst-stats-empty{margin:0;padding:18px;text-align:center;color:#8892b0;font-size:.85rem;background:#141923;border:1px dashed #2a3348;border-radius:10px}
.inst-stats-details{margin-top:4px}
.inst-stats-details>summary{cursor:pointer;font-size:.84rem;color:#9aa3bf;padding:8px 0;user-select:none;list-style-position:inside}
.inst-stats-details>summary::-webkit-details-marker{color:#6d7689}
.inst-stats-details[open]>summary{margin-bottom:6px;color:#cfd3ef}
.inst-stats-month-table-wrap{overflow:auto;-webkit-overflow-scrolling:touch}
.inst-stats-month-table{width:100%;border-collapse:collapse;font-size:.8rem;font-variant-numeric:tabular-nums}
.inst-stats-month-table th,.inst-stats-month-table td{padding:8px 10px;text-align:right;border-bottom:1px solid #2a3348;white-space:nowrap}
.inst-stats-month-table th:first-child,.inst-stats-month-table td:first-child{text-align:left}
.inst-stats-month-table th{color:#8892b0;font-weight:600;font-size:.72rem}
.inst-stats-month-table td{color:#e8ecf4}
.inst-stats-month-table tbody tr:last-child td{border-bottom:none}
@media (max-width:640px){.inst-stats-kpis{grid-template-columns:1fr}.inst-stats-risk-grid{grid-template-columns:1fr}}
.key-history{margin-top:12px;padding-top:10px;border-top:1px solid #2a3150}
.key-history h3{font-size:.88rem;color:#b8c4ff;margin-bottom:6px}
.key-history .sub{font-size:.72rem;color:#8892b0;margin-bottom:6px}
.key-history .list{max-height:200px}
.pos-section{margin-top:12px}
.pos-section-title{font-size:.82rem;color:#8892b0;margin-bottom:8px;font-weight:500}
.pos-list{display:flex;flex-direction:column;gap:10px;max-height:280px;overflow:auto}
.dual-panel-grid .pos-list-live{max-height:none;overflow:visible;flex:1 1 auto}
.dual-panel-grid .panel-scroll.pos-list-live{max-height:none;overflow:visible}
.pos-card{background:#141923;border:1px solid #2a3348;border-radius:10px;padding:12px 14px}
.pos-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px}
.pos-meta{font-size:.74rem;color:#8b95a8;line-height:1.45;margin-bottom:12px;display:flex;flex-wrap:wrap;align-items:center;gap:4px 0}
.pos-meta-item{display:inline-flex;align-items:center}
.pos-meta-item:not(:last-child)::after{content:'|';margin:0 8px;color:#3d4659}
.pos-meta-on{color:#6eb5ff}
.pos-meta-off{color:#7d8799}
.pos-breakeven-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:6px;font-size:.72rem;font-weight:600;background:#1a3d2e;color:#4cd97f}
.pos-card-symbol{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}
.pos-card-symbol strong{font-size:.95rem;color:#fff;font-weight:600}
.pos-side-badge{padding:3px 8px;border-radius:6px;font-size:.72rem;font-weight:500;line-height:1.2}
.pos-side-long{background:#253a6e;color:#6eb5ff}
.pos-side-short{background:#4a2230;color:#ff8a8a}
.pos-head-actions{display:flex;align-items:center;gap:6px;flex-shrink:0}
.pos-entrust-btn{padding:6px 12px;background:#2a4a7a;color:#8fc8ff;border:none;border-radius:8px;font-size:.82rem;font-weight:500;cursor:pointer;white-space:nowrap}
.pos-entrust-btn:hover{background:#355d96}
.pos-close-btn{padding:6px 14px;background:#c45454;color:#fff;border-radius:8px;text-decoration:none;font-size:.82rem;font-weight:500;flex-shrink:0;white-space:nowrap;border:none;cursor:pointer;display:inline-block}
.pos-close-btn:hover{background:#d66565;color:#fff}
.pos-ex-orders{margin-top:10px;padding-top:10px;border-top:1px dashed #2a3348}
.pos-ex-orders-title{font-size:.74rem;color:#7d8799;margin-bottom:6px}
.pos-ex-order-row{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:.78rem;color:#c5cce0;margin-top:5px}
.pos-ex-order-main{flex:1;min-width:0;line-height:1.35}
.pos-ex-cancel-btn{padding:3px 10px;background:#3a3048;color:#d4b8ff;border:none;border-radius:6px;font-size:.74rem;cursor:pointer;flex-shrink:0}
.pos-ex-cancel-btn:disabled{opacity:.4;cursor:not-allowed}
.tpsl-modal-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9000;align-items:center;justify-content:center;padding:16px}
.tpsl-modal-backdrop.open{display:flex}
.tpsl-modal{background:#1a2030;border:1px solid #3a4a66;border-radius:12px;padding:16px 18px;width:min(440px,100%);max-height:90vh;overflow:auto}
.tpsl-modal h3{margin:0 0 12px;font-size:1rem;color:#fff}
.tpsl-modal .form-row{margin-bottom:10px}
.tpsl-modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
.tpsl-modal-actions button{padding:8px 16px;border-radius:8px;border:none;cursor:pointer;font-size:.85rem}
.tpsl-modal-submit{background:#2d6a4f;color:#fff}
.tpsl-modal-cancel{background:#3a3f52;color:#ddd}
.review-entry-reason-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9100;align-items:center;justify-content:center;padding:16px}
.review-entry-reason-backdrop.open{display:flex}
.review-entry-reason-modal{background:#1a2030;border:1px solid #3a4a66;border-radius:12px;padding:16px 18px;width:min(480px,100%);max-height:90vh;overflow:auto}
.review-entry-reason-modal h3{margin:0 0 8px;font-size:1rem;color:#fff}
.review-entry-reason-hint{margin:0 0 12px;font-size:.82rem;color:#9aa3c7;line-height:1.45}
.review-entry-reason-select{width:100%;padding:8px 10px;border-radius:8px;border:1px solid #3a4a66;background:#121726;color:#e8ecff;font-size:.9rem}
.review-entry-reason-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
.review-entry-reason-actions button{padding:8px 16px;border-radius:8px;border:none;cursor:pointer;font-size:.85rem}
.review-entry-reason-ok{background:#2d6a4f;color:#fff}
.review-entry-reason-cancel{background:#3a3f52;color:#ddd}
.pos-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px 14px;margin-bottom:12px}
.pos-cell{display:flex;flex-direction:column;gap:4px;min-width:0}
.pos-label{font-size:.72rem;color:#7d8799}
.pos-value{font-size:.88rem;color:#e8ecf4;font-weight:500;line-height:1.25}
.pos-val-dash{opacity:.75;color:#8b95a8}
.pos-value.price-up{color:#4cd97f}
.pos-value.price-down{color:#ff6666}
.pos-value.price-flat{color:#e8ecf4}
.pos-footer{display:flex;flex-wrap:wrap;gap:14px 18px;font-size:.75rem;color:#6d7689}
.pos-empty{padding:18px;text-align:center;color:#8892b0;font-size:.85rem;background:#141923;border:1px dashed #2a3348;border-radius:10px}
@media (max-width:520px){.pos-grid{grid-template-columns:repeat(2,1fr)}}
.stats-card{grid-column:1/-1;margin-top:14px}
.stats-card .stats-toggle{background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;padding:6px 10px;cursor:pointer}
.stats-card.collapsed .stats-content{display:none}
.stats-period-block{margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid #2a3150}
.stats-period-block:last-child{border-bottom:none;margin-bottom:0;padding-bottom:0}
.stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px}
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
#embed-page-root{min-height:120px;position:relative}
.embed-tab-pane[hidden]{display:none!important}
.inst-dash-card{grid-column:1/-1}
.inst-dash-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:6px}
.inst-dash-card > .inst-dash-head h2{font-size:.88rem;margin:0 0 2px;font-weight:600}
.inst-dash-desc{margin:0;font-size:.72rem;line-height:1.35}
.inst-dash-head-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.inst-dash-updated{font-size:.72rem}
.inst-dash-status{min-height:1.1em;margin:0 0 8px;font-size:.72rem}
.inst-dash-sections{display:flex;flex-direction:column;gap:14px}
.inst-dash-section{padding:12px;background:#141923;border:1px solid #2a3150;border-radius:10px}
.inst-dash-section-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px}
.inst-dash-section-head h3{margin:0;font-size:.95rem;color:#dbe4ff}
.inst-dash-count{display:inline-block;min-width:1.4em;padding:1px 7px;margin-left:4px;border-radius:999px;background:#1f3a5a;color:#8fc8ff;font-size:.75rem;font-weight:600}
.inst-dash-empty{margin:0;padding:14px;text-align:center;border:1px dashed #2a3348;border-radius:8px;font-size:.84rem}
.inst-dash-table-wrap{overflow:auto;border:1px solid #2a3150;border-radius:8px}
.inst-dash-table{width:100%;border-collapse:collapse;font-size:.84rem}
.inst-dash-table th,.inst-dash-table td{padding:8px 10px;text-align:left;border-bottom:1px solid #25253b;white-space:nowrap}
.inst-dash-table th{color:#a9a9ff;background:#151a2a;font-weight:600}
.inst-dash-table tbody tr:last-child td{border-bottom:none}
.inst-dash-table tbody tr.inst-dash-row{cursor:pointer}
.inst-dash-table tbody tr.inst-dash-row:hover{background:#1e2740}
.inst-dash-sym-link{color:#8fc8ff;text-decoration:underline}
.inst-dash-dir-long{color:#4cd97f;font-weight:600}
.inst-dash-dir-short{color:#ff6666;font-weight:600}
.inst-dash-status-active{color:#4cd97f;font-weight:600}
.inst-dash-table .pos-tp-profit{color:#cfd3ef}
@@ -0,0 +1,74 @@
/**
* 手机端:交易记录 / 复盘记录紧凑列表(币种 · 方向 · 盈亏),点击展开详情.
*/
(function (global) {
"use strict";
var resizeTimer = null;
function refreshTradeRecords() {
var UI = global.InstanceUI;
if (!UI) return;
var card = document.querySelector(".records-card");
if (!card) return;
var tableWrap = card.querySelector(".table-wrap");
var table = tableWrap && tableWrap.querySelector("table");
if (!table) return;
var listEl = card.querySelector(".mobile-record-list");
var mobile = UI.isMobileCompactRecords();
if (!mobile) {
if (listEl) listEl.remove();
return;
}
if (!listEl) {
listEl = document.createElement("div");
listEl.className = "mobile-record-list";
tableWrap.parentNode.insertBefore(listEl, tableWrap);
}
var rows = table.querySelectorAll('tr[id^="trade-row-"]');
listEl.innerHTML = rows.length
? Array.prototype.map
.call(rows, function (tr) {
return UI.renderMobileTradeRow(tr);
})
.join("")
: '<div class="journal-empty-msg">暂无交易记录</div>';
listEl.querySelectorAll(".mobile-record-row").forEach(function (btn) {
btn.addEventListener("click", function () {
var rowId = btn.getAttribute("data-row-id");
var tr = rowId && document.getElementById(rowId);
if (tr) UI.openTradeRecordDetailModal(tr);
});
});
}
function onResize() {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () {
refreshTradeRecords();
if (typeof global.loadJournals === "function" && document.getElementById("journal-list")) {
global.loadJournals();
}
}, 180);
}
function init() {
refreshTradeRecords();
global.addEventListener("resize", onResize);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
global.InstanceRecordsMobile = {
refresh: refreshTradeRecords,
};
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,604 @@
/**
* 实例:导航显示,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;
}
/** 默认关闭的导航开关:缺失时按 false,不能用 !== false */
const NAV_DEFAULT_OFF = {
show_nav_dashboard: true,
show_nav_account_ledger: true,
show_nav_system_guide: true,
};
function navPrefShow(display, key) {
if (!key) return true;
if (NAV_DEFAULT_OFF[key]) return display[key] === true;
return display[key] !== false;
}
function applyDisplayToNav(display) {
const map = {
dashboard: "show_nav_dashboard",
account_ledger: "show_nav_account_ledger",
key_monitor: "show_nav_key_monitor",
trade: "show_nav_trade",
strategy: "show_nav_strategy",
strategy_records: "show_nav_strategy_records",
records: "show_nav_records",
stats: "show_nav_stats",
options: "show_nav_options",
"options-review": "show_nav_options_review",
options_review: "show_nav_options_review",
"hedge-plan": "show_nav_hedge_plan",
hedge_plan: "show_nav_hedge_plan",
risk_policy: "show_nav_risk_policy",
system_guide: "show_nav_system_guide",
env_config: "show_nav_env_config",
};
document
.querySelectorAll(
".embed-top-nav [data-embed-tab], .top-nav a[href^='/'], #inst-mobile-tabbar [data-embed-tab], #inst-mobile-more [data-embed-tab]"
)
.forEach((a) => {
const tab = a.getAttribute("data-embed-tab") || (a.getAttribute("href") || "").replace(/^\//, "").split("?")[0];
if (tab === "more") return;
const key = map[tab];
if (!key) return;
const show = navPrefShow(display, key);
a.classList.toggle("nav-hidden", !show);
a.style.display = show ? "" : "none";
});
global.__INSTANCE_DISPLAY__ = display;
}
function pageNavAllowed(tab) {
const d = DISPLAY();
const map = {
dashboard: "show_nav_dashboard",
account_ledger: "show_nav_account_ledger",
key_monitor: "show_nav_key_monitor",
trade: "show_nav_trade",
strategy: "show_nav_strategy",
strategy_records: "show_nav_strategy_records",
records: "show_nav_records",
stats: "show_nav_stats",
options: "show_nav_options",
"options-review": "show_nav_options_review",
options_review: "show_nav_options_review",
"hedge-plan": "show_nav_hedge_plan",
hedge_plan: "show_nav_hedge_plan",
risk_policy: "show_nav_risk_policy",
system_guide: "show_nav_system_guide",
env_config: "show_nav_env_config",
};
const key = map[tab];
if (!key) return true;
return navPrefShow(d, key);
}
function displayPrefsRoot() {
const settingsPane = document.querySelector('.embed-tab-pane[data-embed-pane="settings"]');
if (settingsPane) {
const inSettings = settingsPane.querySelector("#display-prefs-form");
if (inSettings) return inSettings;
}
const pane = document.querySelector(".embed-tab-pane.is-active-pane");
if (pane) {
const inPane = pane.querySelector("#display-prefs-form");
if (inPane) return inPane;
}
return document.getElementById("display-prefs-form");
}
function displayPrefsStatusEl() {
const card = document.getElementById("display-prefs-card");
if (card) {
const el = card.querySelector("#display-prefs-status");
if (el) return el;
}
return document.getElementById("display-prefs-status");
}
function envConfigRoot() {
const activePane = document.querySelector(".embed-tab-pane.is-active-pane");
if (activePane) {
return activePane.querySelector(".env-config-page");
}
return document.querySelector(".env-config-page");
}
function bindEnvTabs() {
/* Tab 切换由 CSS radio+label 实现 */
}
async function loadDisplayPrefsForm(force) {
const root = displayPrefsRoot();
if (!root) return;
if (!force && root.getAttribute("data-prefs-ssr") === "1" && root.querySelector("[data-pref-key]")) {
return;
}
return loadDisplayPrefsFormIn(root);
}
async function loadDisplayPrefsFormIn(root) {
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.entries || []).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 = NAV_DEFAULT_OFF[item.key]
? display[item.key] === true
: display[item.key] !== false;
label.appendChild(cb);
label.appendChild(document.createTextNode(" " + item.label));
grid.appendChild(label);
});
section.appendChild(grid);
root.appendChild(section);
});
root.setAttribute("data-prefs-ssr", "1");
} catch (e) {
root.innerHTML = '<span class="err">' + (e.message || "加载失败") + "</span>";
}
}
async function saveDisplayPrefs() {
const status = displayPrefsStatusEl();
const root = displayPrefsRoot();
if (!root) {
setStatus(status, "未找到导航设置表单", true);
return;
}
const display = {};
root.querySelectorAll("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 renderEnvFieldRow(field) {
const row = document.createElement("div");
row.className = "env-field-row" + (field.restart_required ? " env-field-row--restart" : "");
row.dataset.envKey = field.key;
const label = document.createElement("label");
label.className = "env-field-label";
label.htmlFor = "env-f-" + field.key;
label.textContent = field.label || field.key;
if (field.restart_required) {
const mark = document.createElement("span");
mark.className = "env-restart-mark";
mark.title = "需重启";
mark.textContent = "*";
label.appendChild(mark);
}
row.appendChild(label);
if (field.note) {
const note = document.createElement("div");
note.className = "env-field-note muted";
note.textContent = field.note;
row.appendChild(note);
}
let input;
if (field.type === "bool") {
input = document.createElement("select");
input.id = "env-f-" + field.key;
[["true", "开启"], ["false", "关闭"]].forEach(([v, text]) => {
const o = document.createElement("option");
o.value = v;
o.textContent = text;
input.appendChild(o);
});
const cur = (field.current || field.default || "false").toLowerCase();
input.value = cur === "true" || cur === "1" ? "true" : "false";
} else if (field.type === "select" && Array.isArray(field.options) && field.options.length) {
input = document.createElement("select");
input.id = "env-f-" + field.key;
const cur = String(field.current || field.default || "");
const seen = new Set();
field.options.forEach((opt) => {
const v = String(opt.value != null ? opt.value : "");
if (seen.has(v)) return;
seen.add(v);
const o = document.createElement("option");
o.value = v;
o.textContent = opt.label || v;
input.appendChild(o);
});
if (cur && !seen.has(cur)) {
const o = document.createElement("option");
o.value = cur;
o.textContent = cur;
input.insertBefore(o, input.firstChild);
}
input.value = cur || (field.options[0] && field.options[0].value) || "";
} else {
input = document.createElement("input");
input.id = "env-f-" + field.key;
input.type = "password";
// 防止浏览器把登录密码自动填进 API Key/Secret(保存对冲开关时曾误写入密钥)
input.autocomplete = "new-password";
input.setAttribute("data-lpignore", "true");
input.setAttribute("data-1p-ignore", "true");
input.setAttribute("data-form-type", "other");
input.readOnly = true;
input.addEventListener("focus", function () {
input.readOnly = false;
});
if (field.sensitive) {
input.dataset.envSensitive = "1";
input.dataset.envDirty = "0";
input.addEventListener("input", function () {
input.dataset.envDirty = "1";
});
if (field.has_value) {
const cur = document.createElement("div");
cur.className = "env-sensitive-current muted";
const labelSpan = document.createElement("span");
labelSpan.textContent = "已配置 ";
const masked = document.createElement("span");
masked.className = "env-masked-value";
masked.textContent = field.masked || "";
cur.appendChild(labelSpan);
cur.appendChild(masked);
row.appendChild(cur);
}
input.placeholder = field.has_value ? "修改时填写新值,留空不修改" : "请输入";
} else {
input.type = "text";
input.autocomplete = "off";
input.value = field.current || field.default || "";
}
}
input.dataset.envKey = field.key;
input.className = "env-field-input";
row.appendChild(input);
if (field.hidden) {
row.hidden = true;
row.style.display = "none";
}
return row;
}
function renderEnvConfigBody(groups) {
const body = document.createElement("div");
body.className = "env-config-body card";
body.id = "env-config-body";
body.setAttribute("data-env-ssr", "1");
groups.forEach((_group, idx) => {
const radio = document.createElement("input");
radio.type = "radio";
radio.name = "env-section";
radio.id = "env-sec-" + idx;
radio.className = "env-tab-radio";
if (idx === 0) radio.checked = true;
body.appendChild(radio);
});
const tabBar = document.createElement("div");
tabBar.className = "env-config-tabs";
tabBar.setAttribute("role", "tablist");
const panelsWrap = document.createElement("div");
panelsWrap.className = "env-config-panels";
panelsWrap.id = "env-config-grid";
let modeSectionIdx = 0;
groups.forEach((group, idx) => {
if ((group.title || "").indexOf("期权/对冲模式") >= 0) modeSectionIdx = idx;
const label = document.createElement("label");
label.className = "env-tab-btn";
label.htmlFor = "env-sec-" + idx;
label.setAttribute("role", "tab");
label.textContent = group.title || "其他";
tabBar.appendChild(label);
const panel = document.createElement("section");
panel.className = "env-panel env-panel--" + idx;
panel.setAttribute("role", "tabpanel");
if (group.has_restart) {
const hint = document.createElement("p");
hint.className = "env-panel-hint";
hint.textContent = "本组含需重启项,修改后请点「保存并重启」.";
panel.appendChild(hint);
}
const grid = document.createElement("div");
grid.className = "env-form-grid";
(group.fields || []).forEach((field) => grid.appendChild(renderEnvFieldRow(field)));
panel.appendChild(grid);
panelsWrap.appendChild(panel);
});
body.appendChild(tabBar);
body.appendChild(panelsWrap);
body.dataset.envModeSectionIdx = String(modeSectionIdx);
bindTradeModeAutoRefresh(body);
bindCompoundBudgetVisibility(body);
return body;
}
function envFieldRowByKey(body, key) {
if (!body || !key) return null;
const byRow = body.querySelector('.env-field-row[data-env-key="' + key + '"]');
if (byRow) return byRow;
const input = body.querySelector('.env-field-input[data-env-key="' + key + '"]');
return input ? input.closest(".env-field-row") : null;
}
function syncCompoundBudgetVisibility(body) {
if (!body) return;
const compoundSel = body.querySelector(
'.env-field-input[data-env-key="OKX_OPTIONS_COMPOUND_FULL_ENABLED"]'
);
const budgetRow = envFieldRowByKey(body, "OKX_OPTIONS_TRADE_BUDGET_USDC");
if (!budgetRow) return;
const compoundOn = !compoundSel || String(compoundSel.value || "").toLowerCase() === "true";
budgetRow.hidden = compoundOn;
budgetRow.style.display = compoundOn ? "none" : "";
}
function bindCompoundBudgetVisibility(body) {
if (!body) return;
syncCompoundBudgetVisibility(body);
const compoundSel = body.querySelector(
'.env-field-input[data-env-key="OKX_OPTIONS_COMPOUND_FULL_ENABLED"]'
);
if (!compoundSel || compoundSel.dataset.compoundBudgetBound === "1") return;
compoundSel.dataset.compoundBudgetBound = "1";
compoundSel.addEventListener("change", () => syncCompoundBudgetVisibility(body));
}
function bindTradeModeAutoRefresh(body) {
const modeSel = body.querySelector('.env-field-input[data-env-key="OKX_TRADE_MODE"]');
if (!modeSel || modeSel.dataset.modeRefreshBound === "1") return;
modeSel.dataset.modeRefreshBound = "1";
modeSel.addEventListener("change", async () => {
const status = document.getElementById("env-config-status");
const nextMode = modeSel.value;
setStatus(status, "切换交易模式并刷新配置…");
try {
await fetchJson("/api/settings/env", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ values: { OKX_TRADE_MODE: nextMode } }),
});
await loadEnvConfig(true);
const page = envConfigRoot() || document.querySelector(".env-config-page");
const newBody = page && page.querySelector("#env-config-body");
const idx = newBody && newBody.dataset.envModeSectionIdx;
if (idx != null) {
const radio = document.getElementById("env-sec-" + idx);
if (radio) radio.checked = true;
}
setStatus(status, "交易模式已切换为当前选项,配置区已刷新");
} catch (e) {
setStatus(status, e.message || "切换失败", true);
}
});
}
async function loadEnvConfig(force) {
const root = envConfigRoot();
const body = root && root.querySelector("#env-config-body");
if (!force && body && body.getAttribute("data-env-ssr") === "1" && body.querySelector("[data-env-key]")) {
return;
}
return loadEnvConfigIn(root);
}
async function loadEnvConfigIn(root) {
const page = root || envConfigRoot() || document.querySelector(".env-config-page");
if (!page) return;
const loading = document.createElement("div");
loading.className = "env-config-loading-wrap card";
loading.id = "env-config-body";
loading.innerHTML = '<div class="env-config-loading muted">加载配置中…</div>';
const oldBody = page.querySelector("#env-config-body");
const oldGrid = page.querySelector("#env-config-grid.env-config-loading-wrap");
if (oldBody) oldBody.replaceWith(loading);
else if (oldGrid) oldGrid.replaceWith(loading);
try {
const data = await fetchJson("/api/settings/env");
envSchemaGroups = data.groups || [];
loading.replaceWith(renderEnvConfigBody(envSchemaGroups));
} catch (e) {
loading.innerHTML = '<span class="err">' + (e.message || "加载失败") + "</span>";
}
}
function collectEnvValues() {
const root = envConfigRoot();
const values = {};
const scope = root || document;
scope.querySelectorAll(".env-field-input[data-env-key]").forEach((el) => {
if (el.dataset.envSensitive === "1" && el.dataset.envDirty !== "1") {
// 未改动过的敏感项不提交,避免浏览器自动填充覆盖已有密钥
return;
}
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(true);
} else {
setStatus(status, "已保存(即时生效项已应用)");
await loadEnvConfig(true);
}
} catch (e) {
setStatus(status, e.message || "保存失败", true);
}
}
async function restartInstance() {
try {
await fetchJson("/api/admin/restart", { method: "POST" });
} catch (_) {
// 重启会中断当前 HTTP 连接;只要后续 health 恢复即视为成功.
}
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 installDelegatedHandlers() {
if (document.documentElement.dataset.prefsDelegateBound === "1") return;
document.documentElement.dataset.prefsDelegateBound = "1";
document.addEventListener("click", (ev) => {
const target = ev.target;
if (!(target instanceof Element)) return;
if (target.closest("#display-prefs-save")) {
ev.preventDefault();
void saveDisplayPrefs();
return;
}
if (target.closest("#env-config-save")) {
ev.preventDefault();
void saveEnvConfig(false);
return;
}
if (target.closest("#env-config-save-restart")) {
ev.preventDefault();
void saveEnvConfig(true);
return;
}
if (target.closest("#env-config-reload")) {
ev.preventDefault();
void loadEnvConfig(true);
return;
}
if (target.closest("#pwd-save-btn")) {
ev.preventDefault();
void savePassword();
}
});
}
function bindClickOnce(id, handler) {
const el = document.getElementById(id);
if (!el || el.dataset.bound === "1") return;
el.dataset.bound = "1";
el.addEventListener("click", handler);
}
function bindEvents() {
bindClickOnce("display-prefs-save", saveDisplayPrefs);
bindClickOnce("env-config-save", () => saveEnvConfig(false));
bindClickOnce("env-config-save-restart", () => saveEnvConfig(true));
bindClickOnce("env-config-reload", () => loadEnvConfig(true));
bindClickOnce("pwd-save-btn", savePassword);
}
function initPage() {
installDelegatedHandlers();
bindEvents();
loadDisplayPrefsForm(false);
loadEnvConfig(false);
const root = envConfigRoot();
const body = root && root.querySelector("#env-config-body");
if (body) {
bindTradeModeAutoRefresh(body);
bindCompoundBudgetVisibility(body);
}
if (global.__INSTANCE_DISPLAY__) applyDisplayToNav(global.__INSTANCE_DISPLAY__);
}
global.InstanceSettingsPrefs = {
pageNavAllowed,
applyDisplayToNav,
loadDisplayPrefsForm,
loadEnvConfig,
bindEnvTabs,
bindEvents,
restartInstance,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initPage);
} else {
initPage();
}
})(typeof window !== "undefined" ? window : globalThis);
+117
View File
@@ -0,0 +1,117 @@
(function (global) {
"use strict";
var PERIODS = ["day", "week", "month", "all"];
function statsSegmentSelect() {
return document.getElementById("stats-segment-select");
}
function panelFromTrigger(triggerEl) {
if (triggerEl && triggerEl.closest) {
var fromBtn = triggerEl.closest(".stats-segment-panel");
if (fromBtn) return fromBtn;
}
return null;
}
function activeSegmentPanel(triggerEl) {
var panel = panelFromTrigger(triggerEl);
if (panel) return panel;
var sel = statsSegmentSelect();
if (!sel) return null;
var key = sel.value;
return document.querySelector(
'.stats-segment-panel[data-stats-segment="' + key + '"]'
);
}
function replaceStatsUrl(params) {
var q = new URLSearchParams(global.location.search);
Object.keys(params).forEach(function (k) {
if (params[k] == null || params[k] === "") q.delete(k);
else q.set(k, params[k]);
});
var qs = q.toString();
global.history.replaceState(
null,
"",
qs ? global.location.pathname + "?" + qs : global.location.pathname
);
}
function switchStatsPeriod(periodKey, triggerEl) {
var panel = activeSegmentPanel(triggerEl);
if (!panel) return;
var key = PERIODS.indexOf(periodKey) >= 0 ? periodKey : "day";
panel.querySelectorAll(".stats-period-pane").forEach(function (pane) {
var match = pane.getAttribute("data-stats-period") === key;
if (match) pane.removeAttribute("hidden");
else pane.setAttribute("hidden", "");
});
panel.querySelectorAll(".stats-period-tab").forEach(function (btn) {
var on = btn.getAttribute("data-stats-period") === key;
btn.classList.toggle("active", on);
btn.setAttribute("aria-selected", on ? "true" : "false");
});
replaceStatsUrl({ stats_period: key });
}
function switchStatsSegment() {
var sel = statsSegmentSelect();
if (!sel) return;
var key = sel.value;
document.querySelectorAll(".stats-segment-panel").forEach(function (p) {
p.style.display =
p.getAttribute("data-stats-segment") === key ? "block" : "none";
});
replaceStatsUrl({ stats_segment: key });
var period =
new URLSearchParams(global.location.search).get("stats_period") || "day";
switchStatsPeriod(period);
}
function ensurePeriodTabDelegation() {
if (global.__instanceStatsTabsDelegated) return;
global.__instanceStatsTabsDelegated = true;
document.addEventListener(
"click",
function (e) {
var btn =
e.target && e.target.closest
? e.target.closest(".stats-period-tab")
: null;
if (!btn) return;
var card = document.getElementById("stats-card");
if (!card || !card.contains(btn)) return;
switchStatsPeriod(btn.getAttribute("data-stats-period") || "day", btn);
},
true
);
}
function initStatsFromUrl() {
var sel = statsSegmentSelect();
if (!sel) return;
ensurePeriodTabDelegation();
var url = new URLSearchParams(global.location.search);
var segKey = url.get("stats_segment");
if (
segKey &&
sel.querySelector('option[value="' + segKey.replace(/"/g, "") + '"]')
) {
sel.value = segKey;
}
switchStatsSegment();
var period = url.get("stats_period") || "day";
if (PERIODS.indexOf(period) < 0) period = "day";
switchStatsPeriod(period);
}
ensurePeriodTabDelegation();
global.switchStatsSegment = switchStatsSegment;
global.switchStatsPeriod = switchStatsPeriod;
global.initStatsFromUrl = initStatsFromUrl;
global.initStatsSegmentFromUrl = initStatsFromUrl;
})(window);
File diff suppressed because it is too large Load Diff
+570
View File
@@ -0,0 +1,570 @@
/**
* 三所实例主题:默认暗色;单独登录用 instance-theme;中控 iframe/SSO hub-theme 联动.
*/
(function (global) {
const STANDALONE_KEY = "instance-theme";
const HUB_LINKED_THEME_KEY = "hub-linked-theme";
const META = { dark: "#0b0d14", light: "#c8d4de" };
function normalize(theme) {
return theme === "light" ? "light" : "dark";
}
function isHubLinked() {
try {
if (window.self !== window.top) return true;
} catch (_) {
return true;
}
return false;
}
function themeFromUrl() {
try {
const t = new URLSearchParams(location.search).get("hub_theme");
if (t === "light" || t === "dark") return t;
} catch (_) {}
return null;
}
function readLinkedThemeStorage() {
try {
const t = sessionStorage.getItem(HUB_LINKED_THEME_KEY);
if (t === "light" || t === "dark") return t;
} catch (_) {}
return null;
}
function writeLinkedThemeStorage(theme) {
if (!isHubLinked()) return;
try {
sessionStorage.setItem(HUB_LINKED_THEME_KEY, normalize(theme));
} catch (_) {}
}
function getStandalone() {
try {
return normalize(localStorage.getItem(STANDALONE_KEY));
} catch (_) {
return "dark";
}
}
function setStandalone(theme) {
try {
localStorage.setItem(STANDALONE_KEY, normalize(theme));
} catch (_) {}
}
let _linkedTheme = null;
let _appliedTheme = null;
function get() {
if (isHubLinked()) {
return themeFromUrl() || _linkedTheme || readLinkedThemeStorage() || "dark";
}
return getStandalone();
}
/** 模板内联暗色 → 亮色(切换时重写 style 属性) */
const INLINE_HEX_LIGHT = {
"#cfd3ef": "#1a2838",
"#8892b0": "#4a6078",
"#9aa3c4": "#4a6078",
"#8b95a8": "#4a6078",
"#8b95b8": "#4a6078",
"#6a7598": "#4a6078",
"#7d8799": "#4a6078",
"#6d7689": "#4a6078",
"#dbe4ff": "#142232",
"#f0f2ff": "#142232",
"#e8ecf4": "#142232",
"#c5cce0": "#4a6078",
"#b8c4ff": "#142232",
"#8fc8ff": "#006e9a",
"#6ab8ff": "#006e9a",
"#6eb5ff": "#006e9a",
"#101522": "#ffffff",
"#121726": "#ffffff",
"#141423": "#ffffff",
"#24243b": "#b8c8d8",
"#252a45": "#b8c8d8",
"#252538": "#eef3f8",
"#1a1a29": "#f6f9fc",
"#2e2e45": "#b8c8d8",
"#2b2b43": "#d0dae4",
"#151a2a": "#eef3f8",
"#141a2a": "#ffffff",
"#141923": "#ffffff",
"#141a2e": "#ffffff",
"#0f1424": "#f6f9fc",
"#0f1420": "#f6f9fc",
"#0f1117": "#d8e2ec",
"#1a2034": "#eef3f8",
"#1a2030": "#ffffff",
"#1f3a5a": "#e8eef5",
"#2f2f44": "#dde5ec",
"#2a3f6c": "rgba(0,110,154,0.14)",
"#304164": "rgba(0,95,140,0.22)",
"#2a3150": "#b8c8d8",
"#2a3152": "#b8c8d8",
"#3a5a8a": "rgba(0,95,140,0.35)",
"#2a3348": "#b8c8d8",
"#243050": "rgba(0,75,115,0.16)",
"#2a3558": "#d0dae4",
"#3a4468": "#c8d4e0",
"#3a4a66": "#b8c8d8",
"#3a3f52": "#dde5ec",
"#3d4659": "#b8c8d8",
"#1f2740": "#eef3f8",
"#1f2a44": "rgba(0,110,154,0.1)",
"#1f4a3a": "#e8f5ef",
"#2a4a7a": "#e8eef5",
"#3a3048": "#eef3f8",
"#d4b8ff": "#5b4fc7",
"#e6e8ef": "#1a2838",
};
function remapInlineStyle(style, theme) {
if (!style) return style;
if (theme !== "light") return style;
const hadSecondaryBtnBg = /#1f3a5a/i.test(style);
let out = style;
for (const [from, to] of Object.entries(INLINE_HEX_LIGHT)) {
out = out.replace(new RegExp(from.replace("#", "\\#"), "gi"), to);
}
if (hadSecondaryBtnBg && !/color\s*:/i.test(style)) {
out = `${out.replace(/;+\s*$/, "")};color:#006e9a`;
}
return out;
}
function syncInlineStyles(theme, root) {
const scope = root || document;
scope.querySelectorAll("[style]").forEach((el) => {
const raw = el.getAttribute("style");
if (!raw) return;
if (!el.dataset.instStyleBase) {
el.dataset.instStyleBase = raw;
}
const base = el.dataset.instStyleBase;
el.setAttribute("style", theme === "light" ? remapInlineStyle(base, "light") : base);
});
}
function mergeHubQueryIntoHref(href, theme) {
if (!href || href.startsWith("#") || href.startsWith("javascript:")) return href;
try {
const u = new URL(href, location.origin);
if (u.origin !== location.origin) return href;
if (isHubLinked()) {
u.searchParams.set("embed", "1");
if (theme === "light" || theme === "dark") {
u.searchParams.set("hub_theme", theme);
}
}
return u.pathname + u.search + u.hash;
} catch (_) {
return href;
}
}
function patchHubNavLinks(theme) {
if (!isHubLinked()) return;
const t = normalize(theme || get());
document
.querySelectorAll(".top-nav a[href], .strategy-subnav a[href]")
.forEach((a) => {
const href = a.getAttribute("href");
if (!href) return;
const next = mergeHubQueryIntoHref(href, t);
if (next !== href) a.setAttribute("href", next);
});
}
function apply(theme, opts) {
const options = opts || {};
const linked = isHubLinked();
const t = normalize(theme);
const root = document.documentElement;
const unchanged =
!options.force &&
_appliedTheme === t &&
root.getAttribute("data-theme") === t;
if (unchanged) {
return t;
}
_appliedTheme = t;
if (linked) {
_linkedTheme = t;
writeLinkedThemeStorage(t);
root.setAttribute("data-hub-linked", "1");
} else {
root.removeAttribute("data-hub-linked");
}
if (!linked && !options.skipStore) {
setStandalone(t);
}
root.setAttribute("data-theme", t);
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute("content", META[t]);
root.style.colorScheme = t;
if (document.body) {
syncInlineStyles(t);
patchHubNavLinks(t);
} else {
document.addEventListener(
"DOMContentLoaded",
function onDom() {
syncInlineStyles(t);
patchHubNavLinks(t);
},
{ once: true }
);
}
syncToggleUI();
document.dispatchEvent(
new CustomEvent("instance-theme-change", { detail: { theme: t, hubLinked: linked } })
);
return t;
}
function syncToggleUI(root) {
const scope = root || document;
const linked = isHubLinked();
const toggle = scope.querySelector(".instance-theme-toggle");
if (toggle) {
toggle.classList.toggle("is-hub-linked", linked);
toggle.setAttribute("aria-hidden", linked ? "true" : "false");
}
if (linked) return;
scope.querySelectorAll(".theme-toggle-btn[data-theme-value]").forEach((btn) => {
const on = btn.getAttribute("data-theme-value") === getStandalone();
btn.classList.toggle("is-active", on);
btn.setAttribute("aria-pressed", on ? "true" : "false");
});
}
function initToggleUI(root) {
const scope = root || document;
syncToggleUI(scope);
scope.querySelectorAll(".theme-toggle-btn[data-theme-value]").forEach((btn) => {
if (btn.dataset.themeBound === "1") return;
btn.dataset.themeBound = "1";
btn.addEventListener("click", () => {
if (isHubLinked()) return;
apply(btn.getAttribute("data-theme-value"));
});
});
}
function initMobileTopNav() {
const mq = window.matchMedia("(max-width: 720px)");
function scrollActiveTab(nav) {
const active = nav.querySelector("a.active");
if (!active) return;
requestAnimationFrame(() => {
try {
active.scrollIntoView({ inline: "center", block: "nearest", behavior: "instant" });
} catch (_) {
active.scrollIntoView(false);
}
});
}
function apply() {
if (!mq.matches) return;
document.querySelectorAll(".top-nav").forEach(scrollActiveTab);
}
apply();
mq.addEventListener("change", apply);
window.addEventListener("resize", apply);
window.addEventListener("orientationchange", apply);
}
function initFromHubMessage(data) {
if (!data || data.type !== "hub-theme-sync") return;
if (!isHubLinked()) return;
apply(data.theme, { skipStore: true });
}
/** 交易记录页:核对开关与按钮 disabled 保持同步(含 iframe 软导航后动态挂载的 toggle) */
function syncReviewEditButtons() {
const toggle = document.getElementById("review-mode-toggle");
if (!toggle) return;
const on = !!toggle.checked;
document.querySelectorAll(".review-edit-btn").forEach((btn) => {
btn.disabled = !on;
});
}
function initReviewEditModeSync() {
if (!global.__instReviewModeBound) {
global.__instReviewModeBound = true;
const onToggle = () => {
if (typeof global.toggleReviewMode === "function") global.toggleReviewMode();
else syncReviewEditButtons();
};
document.addEventListener("change", (ev) => {
if (ev.target && ev.target.id === "review-mode-toggle") onToggle();
});
document.addEventListener("input", (ev) => {
if (ev.target && ev.target.id === "review-mode-toggle") onToggle();
});
}
const run = () => {
if (typeof global.toggleReviewMode === "function") global.toggleReviewMode();
else syncReviewEditButtons();
};
run();
requestAnimationFrame(run);
setTimeout(run, 0);
if (!global.__instReviewModePageshowBound) {
global.__instReviewModePageshowBound = true;
window.addEventListener("pageshow", run);
}
}
function notifyParentFrameNavStart() {
if (!isHubLinked()) return;
try {
window.parent.postMessage({ type: "instance-frame-navigating", theme: get() }, "*");
} catch (_) {}
}
function notifyParentFrameReady() {
if (!isHubLinked()) return;
dismissNavOverlay();
try {
window.parent.postMessage({ type: "instance-frame-ready", theme: get() }, "*");
} catch (_) {}
}
function ensureNavOverlay() {
const t = normalize(get());
const bg = META[t];
let el = document.getElementById("inst-nav-overlay");
if (!el) {
el = document.createElement("div");
el.id = "inst-nav-overlay";
el.setAttribute("aria-hidden", "true");
(document.body || document.documentElement).appendChild(el);
}
el.style.cssText =
"position:fixed;inset:0;z-index:2147483646;background:" +
bg +
";opacity:1;pointer-events:auto;transition:opacity 80ms ease;";
return el;
}
function dismissNavOverlay() {
const el = document.getElementById("inst-nav-overlay");
if (!el) return;
el.style.opacity = "0";
window.setTimeout(() => {
try {
el.remove();
} catch (_) {}
}, 90);
}
function injectNavOverlayIntoHtml(html, theme) {
const t = normalize(theme || get());
const bg = META[t];
let out = html || "";
const guard =
'<style id="inst-nav-guard">html,body{background:' +
bg +
"!important;color-scheme:" +
t +
';}</style>';
if (out.includes("</head>")) {
out = out.replace("</head>", guard + "</head>");
} else {
out = guard + out;
}
out = out.replace(/<html([^>]*)>/i, (m, attrs) => {
if (/data-theme=/i.test(attrs)) {
return m.replace(/data-theme="[^"]*"/i, 'data-theme="' + t + '"');
}
return "<html" + attrs + ' data-theme="' + t + '">';
});
const overlay =
'<div id="inst-nav-overlay" aria-hidden="true" style="position:fixed;inset:0;z-index:2147483646;background:' +
bg +
';opacity:1;pointer-events:auto"></div>';
if (/<body[^>]*>/i.test(out)) {
out = out.replace(/<body([^>]*)>/i, "<body$1>" + overlay);
}
return out;
}
/** 中控 iframe:fetch 换页 + 页内遮罩,避免整页卸载与中控侧长时间空白. */
function initHubEmbedInFrameNav() {
if (!isHubLinked()) return;
if (document.body && document.body.getAttribute("data-embed-shell") === "1") return;
let navToken = 0;
function isSoftNavLink(a) {
if (!a || !a.getAttribute) return false;
if (a.hasAttribute("download") || a.target === "_blank") return false;
return !!a.closest(".top-nav, .strategy-subnav");
}
function softNavFetch(href) {
return fetch(href, {
credentials: "same-origin",
headers: { "X-Instance-Soft-Nav": "1" },
});
}
async function navigateInFrame(href, opts) {
const token = ++navToken;
notifyParentFrameNavStart();
ensureNavOverlay();
try {
const r = await softNavFetch(href);
if (token !== navToken) return;
if (!r.ok) {
location.assign(href);
return;
}
let html = await r.text();
if (token !== navToken) return;
html = injectNavOverlayIntoHtml(html, get());
let path = href;
try {
const u = new URL(href, location.href);
path = u.pathname + u.search + u.hash;
} catch (_) {}
if (opts && opts.replace) history.replaceState(null, "", path);
else history.pushState(null, "", path);
document.open();
document.write(html);
document.close();
} catch (_) {
if (token === navToken) location.assign(href);
}
}
document.addEventListener(
"click",
(ev) => {
const a = ev.target.closest("a[href]");
if (!a || !isSoftNavLink(a) || ev.defaultPrevented) return;
if (ev.button !== 0 || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return;
const rawHref = a.getAttribute("href");
if (!rawHref || rawHref.startsWith("#") || rawHref.startsWith("javascript:")) return;
let target;
try {
target = new URL(rawHref, location.href);
} catch (_) {
return;
}
if (target.origin !== location.origin) return;
const nextHref = target.pathname + target.search + target.hash;
if (target.pathname === location.pathname && target.search === location.search) return;
ev.preventDefault();
void navigateInFrame(nextHref);
},
true
);
window.addEventListener("popstate", () => {
void navigateInFrame(location.pathname + location.search + location.hash, { replace: true });
});
}
function purgeLegacySoftNavCache() {
try {
for (let i = localStorage.length - 1; i >= 0; i -= 1) {
const key = localStorage.key(i);
if (!key) continue;
if (
key.startsWith("inst-pc:") ||
key === "inst-page-cache-index" ||
key === "inst-page-cache-days"
) {
localStorage.removeItem(key);
}
}
sessionStorage.removeItem("inst-soft-nav");
sessionStorage.removeItem("inst-cache-revalidate");
} catch (_) {}
}
function boot() {
purgeLegacySoftNavCache();
if (isHubLinked()) {
apply(get(), { skipStore: true });
window.addEventListener("message", (ev) => initFromHubMessage(ev.data));
initHubEmbedInFrameNav();
try {
window.parent.postMessage({ type: "instance-theme-ready" }, "*");
} catch (_) {}
} else {
apply(getStandalone());
}
function observeDynamicLists() {
["journal-list", "review-list"].forEach((id) => {
const el = document.getElementById(id);
if (!el || el.dataset.instThemeObserved === "1") return;
el.dataset.instThemeObserved = "1";
new MutationObserver(() => {
syncInlineStyles(get());
patchHubNavLinks(get());
}).observe(el, {
childList: true,
subtree: true,
});
});
}
const onReady = () => {
initToggleUI();
initMobileTopNav();
initReviewEditModeSync();
syncInlineStyles(get());
patchHubNavLinks(get());
observeDynamicLists();
if (isHubLinked()) {
requestAnimationFrame(() => {
requestAnimationFrame(() => notifyParentFrameReady());
});
}
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", onReady);
} else {
onReady();
}
document.addEventListener("instance-theme-change", (ev) => {
const t = ev.detail && ev.detail.theme;
if (t) {
syncInlineStyles(t);
patchHubNavLinks(t);
}
});
}
boot();
global.InstanceTheme = {
STANDALONE_KEY,
HUB_LINKED_THEME_KEY,
isHubLinked,
get,
apply,
initToggleUI,
syncToggleUI,
syncInlineStyles,
patchHubNavLinks,
mergeHubQueryIntoHref,
syncReviewEditButtons,
initReviewEditModeSync,
};
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,54 @@
/* 紧接 instance_theme.js 之后加载,避免亮色下先闪暗色底 */
html {
background: #0b0d14;
color-scheme: dark;
}
html[data-theme="light"] {
background: #c8d4de;
color-scheme: light;
}
html[data-theme="light"] body {
background: #c8d4de !important;
color: #142232 !important;
}
.review-edit-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
html[data-theme="light"] .header h1 {
color: #142232 !important;
}
html[data-theme="light"] .top-nav a,
html[data-theme="light"] .embed-top-nav a,
html[data-theme="light"] .strategy-subnav a {
background: #fff !important;
color: #006e9a !important;
border-color: rgba(0, 95, 140, 0.22) !important;
}
html[data-theme="light"] .top-nav a:hover,
html[data-theme="light"] .embed-top-nav a:hover,
html[data-theme="light"] .strategy-subnav a:hover {
background: rgba(0, 110, 154, 0.1) !important;
color: #004d6e !important;
}
html[data-theme="light"] .top-nav a.active,
html[data-theme="light"] .embed-top-nav a.active,
html[data-theme="light"] .strategy-subnav a.active {
background: rgba(0, 110, 154, 0.12) !important;
color: #004d6e !important;
border: 1px solid rgba(0, 95, 140, 0.28) !important;
font-weight: 600;
}
html[data-theme="light"] .card,
html[data-theme="light"] .stat-item {
background: #fff !important;
border-color: #b8c8d8 !important;
}
+456
View File
@@ -0,0 +1,456 @@
/**
* 三所实例共用 UI:复盘详情,盈亏着色等.
*/
(function (global) {
"use strict";
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function pnlClassFromValue(val) {
const n = Number(String(val == null ? "" : val).replace(/[^\d.-]/g, ""));
if (!Number.isFinite(n) || n === 0) return "";
return n > 0 ? "pnl-profit" : "pnl-loss";
}
function formatPnlSpan(val, suffix) {
const sfx = suffix == null ? "U" : suffix;
const cls = pnlClassFromValue(val);
const text = escapeHtml(val == null || val === "" ? "-" : val) + sfx;
return cls ? `<span class="${cls}">${text}</span>` : text;
}
function buildJournalDetailHtml(o, formatExitLine) {
const moodTags =
Array.isArray(o.mood_issues) && o.mood_issues.length
? o.mood_issues.join(",")
: o.mood_issues || "无";
const exitText =
typeof formatExitLine === "function" ? formatExitLine(o) : o.exit_reason || "无";
const lines = [
`币种/周期:${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "-")}`,
`方向:${escapeHtml((function(){ const d = inferJournalDirection(o); return d ? d.text : "-"; })())}`,
`开仓时间:${escapeHtml(o.open_datetime || "-")}`,
`平仓时间:${escapeHtml(o.close_datetime || "-")}`,
`持仓时长:${escapeHtml(o.hold_duration || "-")}`,
`盈亏:${formatPnlSpan(o.pnl)}`,
`下单类型:${escapeHtml(o.order_type || "无")}`,
`开仓类型:${escapeHtml(o.entry_reason || "无")}`,
`平仓/离场:${escapeHtml(exitText)}`,
`预期RR:${escapeHtml(o.expect_rr || "-")}`,
`实际RR:${escapeHtml(o.real_rr || "-")}`,
`保本后盯盘:${escapeHtml(o.post_breakeven_stare || "-")}`,
`心态标签:${escapeHtml(moodTags)}`,
`备注:${escapeHtml(o.note || "无")}`,
];
return lines.join("<br>");
}
function resolveJournalImages(o) {
if (Array.isArray(o.images) && o.images.length) return o.images;
if (o.image) return [{ tf: "", file: o.image }];
return [];
}
function setJournalDetailImages(o) {
const grid = document.getElementById("detailImages");
const legacyImg = document.getElementById("detailImage");
const images = resolveJournalImages(o || {});
if (grid) {
if (!images.length) {
grid.innerHTML = "";
grid.style.display = "none";
} else {
grid.innerHTML = images
.map(function (img) {
const tf = String(img.tf || "").trim();
const file = String(img.file || "").trim();
if (!file) return "";
const label = tf ? escapeHtml(tf) : "截图";
const src = "/static/images/" + encodeURIComponent(file).replace(/%2F/g, "/");
return (
'<div class="journal-detail-img-cell">' +
'<span class="journal-detail-img-label">' +
label +
"</span>" +
'<img class="journal-detail-img-thumb" src="' +
src +
'" alt="' +
label +
'" onclick="showImage(this.src)">' +
"</div>"
);
})
.join("");
grid.style.display = "grid";
}
if (legacyImg) {
legacyImg.src = "";
legacyImg.style.display = "none";
}
return;
}
if (legacyImg) {
if (images.length === 1) {
legacyImg.src = "/static/images/" + images[0].file;
legacyImg.style.display = "block";
} else {
legacyImg.src = "";
legacyImg.style.display = "none";
}
}
}
function clearJournalDetailImages() {
const grid = document.getElementById("detailImages");
if (grid) {
grid.innerHTML = "";
grid.style.display = "none";
}
const legacyImg = document.getElementById("detailImage");
if (legacyImg) {
legacyImg.src = "";
legacyImg.style.display = "none";
}
}
function setJournalDetailBody(o, formatExitLine) {
const body = document.getElementById("detailBody");
if (!body) return;
body.classList.remove("md-review", "trade-record-detail-wrap");
body.classList.add("journal-detail-meta");
body.innerHTML = buildJournalDetailHtml(o, formatExitLine);
}
function openJournalDetailModal(id, journalCache, formatExitLine) {
const o = journalCache && journalCache[id];
if (!o) return;
const titleEl = document.getElementById("detailTitle");
if (titleEl) {
titleEl.innerText = `交易复盘详情|${o.coin || "-"} ${o.tf || "-"}`;
}
setJournalDetailBody(o, formatExitLine);
clearDetailActions();
setJournalDetailImages(o);
if (typeof setDetailModalFullscreen === "function") {
setDetailModalFullscreen(false);
}
const modal = document.getElementById("detailModal");
if (modal) modal.style.display = "flex";
}
function isMobileCompactRecords() {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia("(max-width: 720px)").matches;
}
function inferJournalDirection(o) {
const hint = String((o && (o.direction_hint || o.direction)) || "").toLowerCase();
if (hint === "long" || hint === "buy" || hint === "多") {
return { text: "做多", cls: "direction-long" };
}
if (hint === "short" || hint === "sell" || hint === "空") {
return { text: "做空", cls: "direction-short" };
}
const text = String((o && (o.entry_reason || o.note)) || "");
if (/做空|空头|short/i.test(text)) {
return { text: "做空", cls: "direction-short" };
}
if (/做多|多头|long/i.test(text)) {
return { text: "做多", cls: "direction-long" };
}
return null;
}
function renderJournalListHtml(data) {
if (!data || !data.length) return "";
const mobile = isMobileCompactRecords();
if (mobile) {
return data
.map(function (o) {
const dir = inferJournalDirection(o);
const pnlCls = pnlClassFromValue(o.pnl);
const dirHtml = dir
? `<span class="badge ${dir.cls}">${escapeHtml(dir.text)}</span>`
: `<span class="mrr-muted">-</span>`;
const id = escapeHtml(o.id);
return `<div class="mobile-record-row-wrap">
<button type="button" class="mobile-record-row" onclick="openJournalDetail('${id}')">
<span class="mrr-symbol">${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "")}</span>
<span class="mrr-dir">${dirHtml}</span>
<span class="mrr-pnl ${pnlCls}">${escapeHtml(o.pnl == null || o.pnl === "" ? "-" : o.pnl)}U</span>
</button>
<button type="button" class="mobile-record-del" title="删除" onclick="deleteJournal('${id}')">×</button>
</div>`;
})
.join("");
}
const rows = data
.map(function (o) {
const moodTags = Array.isArray(o.mood_issues)
? o.mood_issues.join(",")
: o.mood_issues || "";
const mood = moodTags || "无";
const id = escapeHtml(o.id);
const pnlCls = pnlClassFromValue(o.pnl);
const pnlTxt =
o.pnl == null || o.pnl === "" ? "-" : String(o.pnl);
const dir = inferJournalDirection(o);
const dirHtml = dir
? `<span class="badge ${dir.cls}">${escapeHtml(dir.text)}</span>`
: "-";
return `<tr id="journal-row-${id}">
<td>${escapeHtml(o.coin || "-")}</td>
<td>${escapeHtml(o.tf || "-")}</td>
<td>${dirHtml}</td>
<td>${escapeHtml(o.order_type || "-")}</td>
<td>${escapeHtml(o.entry_reason || "-")}</td>
<td><span class="${pnlCls}">${escapeHtml(pnlTxt)}</span></td>
<td>${escapeHtml((o.open_datetime || "-").toString().slice(0, 16))}</td>
<td>${escapeHtml((o.close_datetime || "-").toString().slice(0, 16))}</td>
<td>${escapeHtml(o.hold_duration || "-")}</td>
<td>${escapeHtml(mood)}</td>
<td>
<button type="button" class="table-del" style="background:#1f3a5a;color:#8fc8ff;margin-right:6px" onclick="openJournalDetail('${id}')">查看详情</button>
<button type="button" class="table-del" onclick="deleteJournal('${id}')">删除</button>
</td>
</tr>`;
})
.join("");
return `<div class="table-wrap"><table class="rr-journals-table">
<thead><tr>
<th>品种</th><th></th><th></th><th></th><th></th>
<th>盈亏U</th><th></th><th></th><th></th><th></th><th></th>
</tr></thead>
<tbody>${rows}</tbody>
</table></div>`;
}
function parseTradeRecordRow(tr) {
const cells = tr.querySelectorAll("td");
if (cells.length < 15) return null;
const dirBadge = cells[3].querySelector(".badge");
return {
rowId: tr.id,
symbol: cells[0].textContent.trim(),
type: cells[1].textContent.trim(),
entryReason: cells[2].textContent.trim(),
directionHtml: (dirBadge ? dirBadge.outerHTML : cells[3].innerHTML).trim(),
directionText: cells[3].textContent.trim(),
trigger: cells[4].textContent.trim(),
stopLoss: cells[5].textContent.trim(),
takeProfit: cells[6].textContent.trim(),
margin: cells[7].textContent.trim(),
leverage: cells[8].textContent.trim(),
holdMinutes: cells[9].textContent.trim(),
openedAt: cells[10].textContent.trim(),
closedAt: cells[11].textContent.trim(),
pnlHtml: cells[12].innerHTML.trim(),
pnlText: cells[12].textContent.trim(),
resultHtml: cells[13].innerHTML.trim(),
resultText: cells[13].textContent.trim(),
actionsHtml: cells[14].innerHTML,
};
}
function renderMobileTradeRow(tr) {
const row = parseTradeRecordRow(tr);
if (!row) return "";
const pnlCls = pnlClassFromValue(row.pnlText);
return `<button type="button" class="mobile-record-row" data-row-id="${escapeHtml(row.rowId)}">
<span class="mrr-symbol">${escapeHtml(row.symbol)}</span>
<span class="mrr-dir">${row.directionHtml}</span>
<span class="mrr-pnl ${pnlCls}">${escapeHtml(row.pnlText || "-")}</span>
</button>`;
}
function tradeDetailRow(label, valueHtml) {
return `<div class="trd-row"><span class="trd-label">${escapeHtml(label)}</span><span class="trd-value">${valueHtml}</span></div>`;
}
function buildTradeRecordDetailHtml(row) {
return `<div class="trade-record-detail">${
tradeDetailRow("品种", escapeHtml(row.symbol)) +
tradeDetailRow("下单类型", escapeHtml(row.type)) +
tradeDetailRow("开仓类型", escapeHtml(row.entryReason || "-")) +
tradeDetailRow("方向", row.directionHtml) +
tradeDetailRow("成交价", escapeHtml(row.trigger)) +
tradeDetailRow("止损(开仓)", escapeHtml(row.stopLoss)) +
tradeDetailRow("止盈", escapeHtml(row.takeProfit)) +
tradeDetailRow("基数", escapeHtml(row.margin)) +
tradeDetailRow("杠杆", escapeHtml(row.leverage)) +
tradeDetailRow("持仓分钟", escapeHtml(row.holdMinutes)) +
tradeDetailRow("开仓时间", escapeHtml(row.openedAt)) +
tradeDetailRow("平仓时间", escapeHtml(row.closedAt)) +
tradeDetailRow("盈亏U", row.pnlHtml) +
tradeDetailRow("结果", row.resultHtml)
}</div>`;
}
function clearDetailActions() {
const el = document.getElementById("detailActions");
if (el) {
el.innerHTML = "";
el.style.display = "none";
}
}
function setDetailActionsHtml(html) {
let el = document.getElementById("detailActions");
if (!el) {
const panel = document.querySelector("#detailModal .panel");
if (!panel) return;
el = document.createElement("div");
el.id = "detailActions";
el.className = "detail-actions";
const body = document.getElementById("detailBody");
if (body && body.parentNode === panel) {
panel.insertBefore(el, body.nextSibling);
} else {
panel.appendChild(el);
}
}
el.innerHTML = html || "";
el.style.display = html ? "flex" : "none";
}
function promptReviewEntryReason(options, currentValue) {
const opts = Array.isArray(options) ? options : [];
const cur = String(currentValue == null ? "" : currentValue).trim();
return new Promise(function (resolve) {
const backdrop = document.createElement("div");
backdrop.className = "review-entry-reason-backdrop open";
const modal = document.createElement("div");
modal.className = "review-entry-reason-modal";
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
const title = document.createElement("h3");
title.textContent = "开仓类型";
modal.appendChild(title);
const hint = document.createElement("p");
hint.className = "review-entry-reason-hint";
hint.textContent = "请选择下拉选项之一;选「不改该项」则保留原值.";
modal.appendChild(hint);
const select = document.createElement("select");
select.className = "review-entry-reason-select";
const emptyOpt = document.createElement("option");
emptyOpt.value = "";
emptyOpt.textContent = "(不改该项)";
select.appendChild(emptyOpt);
const seen = new Set([""]);
if (cur && opts.indexOf(cur) < 0) {
const curOpt = document.createElement("option");
curOpt.value = cur;
curOpt.textContent = cur + "(当前)";
select.appendChild(curOpt);
seen.add(cur);
}
opts.forEach(function (opt) {
const v = String(opt || "").trim();
if (!v || seen.has(v)) return;
const o = document.createElement("option");
o.value = v;
o.textContent = v;
select.appendChild(o);
seen.add(v);
});
if (cur) select.value = cur;
modal.appendChild(select);
const actions = document.createElement("div");
actions.className = "review-entry-reason-actions";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "review-entry-reason-cancel";
cancelBtn.textContent = "取消";
const okBtn = document.createElement("button");
okBtn.type = "button";
okBtn.className = "review-entry-reason-ok";
okBtn.textContent = "确定";
actions.appendChild(cancelBtn);
actions.appendChild(okBtn);
modal.appendChild(actions);
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
function cleanup(result) {
document.removeEventListener("keydown", onKey);
backdrop.remove();
resolve(result);
}
function onKey(ev) {
if (ev.key === "Escape") cleanup(null);
}
cancelBtn.addEventListener("click", function () {
cleanup(null);
});
backdrop.addEventListener("click", function (ev) {
if (ev.target === backdrop) cleanup(null);
});
okBtn.addEventListener("click", function () {
cleanup(select.value);
});
document.addEventListener("keydown", onKey);
select.focus();
});
}
function openTradeRecordDetailModal(tr) {
const row = parseTradeRecordRow(tr);
if (!row) return;
const titleEl = document.getElementById("detailTitle");
if (titleEl) {
titleEl.innerText = `交易记录|${row.symbol}`;
}
const body = document.getElementById("detailBody");
if (body) {
body.classList.remove("md-review", "journal-detail-meta");
body.classList.add("trade-record-detail-wrap");
body.innerHTML = buildTradeRecordDetailHtml(row);
}
setDetailActionsHtml(
`<div class="detail-actions-inner">${row.actionsHtml}</div>`
);
const imgEl = document.getElementById("detailImage");
if (imgEl) {
imgEl.src = "";
imgEl.style.display = "none";
}
if (typeof setDetailModalFullscreen === "function") {
setDetailModalFullscreen(false);
}
const modal = document.getElementById("detailModal");
if (modal) modal.style.display = "flex";
}
global.InstanceUI = {
escapeHtml: escapeHtml,
pnlClassFromValue: pnlClassFromValue,
formatPnlSpan: formatPnlSpan,
buildJournalDetailHtml: buildJournalDetailHtml,
setJournalDetailBody: setJournalDetailBody,
openJournalDetailModal: openJournalDetailModal,
isMobileCompactRecords: isMobileCompactRecords,
inferJournalDirection: inferJournalDirection,
renderJournalListHtml: renderJournalListHtml,
parseTradeRecordRow: parseTradeRecordRow,
renderMobileTradeRow: renderMobileTradeRow,
buildTradeRecordDetailHtml: buildTradeRecordDetailHtml,
openTradeRecordDetailModal: openTradeRecordDetailModal,
clearDetailActions: clearDetailActions,
clearJournalDetailImages: clearJournalDetailImages,
setJournalDetailImages: setJournalDetailImages,
promptReviewEntryReason: promptReviewEntryReason,
};
})(typeof window !== "undefined" ? window : globalThis);
+175
View File
@@ -0,0 +1,175 @@
/**
* 复盘表单 AJAX 保存:避免整页刷新卡顿;配合 FormSubmitGuard 即时反馈.
* 使用 document 委托,兼容中控 embed 后插入的 #journal-form.
*/
(function (global) {
"use strict";
function $(id) {
return document.getElementById(id);
}
function toast(msg) {
if (!msg) return;
try {
if (global.InstanceTheme && typeof InstanceTheme.toast === "function") {
InstanceTheme.toast(msg);
return;
}
} catch (_) {}
try {
alert(msg);
} catch (_) {}
}
function refreshLists() {
if (global.RecordsReviewPage && typeof RecordsReviewPage.loadJournals === "function") {
try {
RecordsReviewPage.loadJournals();
} catch (_) {}
} else if (typeof global.loadJournals === "function") {
try {
global.loadJournals();
} catch (_) {}
}
// 交易记录保持可见:soft 刷新,勿「加载中…」占位
if (global.RecordsReviewPage && typeof RecordsReviewPage.loadTradeRecords === "function") {
try {
RecordsReviewPage.loadTradeRecords({ soft: true });
} catch (_) {}
} else if (typeof global.loadTradeRecords === "function") {
try {
global.loadTradeRecords({ soft: true });
} catch (_) {}
}
}
function resetJournalForm(form) {
if (!form) return;
form.reset();
["risk-amount-hint", "entry-price-hint", "stop-loss-hint", "exit-price-hint", "direction-hint"].forEach(
function (id) {
var el = $(id);
if (el) el.value = "";
}
);
if (global.JournalUploadSlots && typeof JournalUploadSlots.reset === "function") {
JournalUploadSlots.reset(form);
}
if (typeof global.syncEarlyExitNoteRequired === "function") {
try {
global.syncEarlyExitNoteRequired();
} catch (_) {}
}
if (global.RecordsReviewPage && typeof RecordsReviewPage.hideJournalCard === "function") {
try {
RecordsReviewPage.hideJournalCard();
} catch (_) {}
}
}
function onSuccess(form, data) {
var msg = (data && data.msg) || "交易复盘记录已保存";
toast(msg);
resetJournalForm(form);
refreshLists();
if (data && data.chart_pending) {
setTimeout(refreshLists, 4000);
setTimeout(refreshLists, 12000);
}
}
function parseJsonSafe(res) {
var ct = (res.headers.get("content-type") || "").toLowerCase();
if (ct.indexOf("application/json") >= 0) {
return res.json().then(function (data) {
return { kind: "json", ok: res.ok, data: data };
});
}
return res.text().then(function (text) {
return { kind: "html", ok: res.ok, redirected: !!res.redirected, text: text };
});
}
function submitAjax(form) {
if (global.FormSubmitGuard && FormSubmitGuard.isLocked(form)) return;
if (global.FormSubmitGuard) FormSubmitGuard.lock(form, "保存中…");
var fd = new FormData(form);
fd.set("ajax", "1");
var action = form.getAttribute("action") || "/add_journal";
fetch(action, {
method: "POST",
body: fd,
credentials: "same-origin",
redirect: "manual",
headers: {
"X-Requested-With": "XMLHttpRequest",
Accept: "application/json",
},
})
.then(function (res) {
// opaque redirect:后端未认 AJAX,仍走了 302
if (res.type === "opaqueredirect" || (res.status >= 300 && res.status < 400)) {
throw new Error("保存接口未返回 JSON,请硬刷新页面后重试");
}
return parseJsonSafe(res);
})
.then(function (result) {
if (result.kind !== "json") {
throw new Error("保存接口未返回 JSON,请硬刷新页面后重试");
}
if (!result.ok || !result.data || result.data.ok === false) {
var err =
(result.data && (result.data.msg || result.data.error)) || "保存失败";
throw new Error(err);
}
onSuccess(form, result.data);
})
.catch(function (err) {
toast((err && err.message) || "保存失败,请稍后重试");
})
.finally(function () {
if (global.FormSubmitGuard) FormSubmitGuard.unlock(form);
});
}
function onSubmit(ev) {
var form = ev.target;
if (!form || form.id !== "journal-form") return;
if (typeof global.validateJournalEntryReason === "function") {
if (!global.validateJournalEntryReason()) {
ev.preventDefault();
if (typeof ev.stopImmediatePropagation === "function") ev.stopImmediatePropagation();
return;
}
}
ev.preventDefault();
if (typeof ev.stopImmediatePropagation === "function") ev.stopImmediatePropagation();
submitAjax(form);
}
function bind(form) {
// 保留显式 bind 入口(embed 切 tab 时可再调);真正拦截靠 document 委托
if (form) form.dataset.journalAjaxBound = "1";
}
function init() {
if (document.documentElement.dataset.journalFormSaveDelegated === "1") {
bind($("journal-form"));
return;
}
document.documentElement.dataset.journalFormSaveDelegated = "1";
document.addEventListener("submit", onSubmit, true);
bind($("journal-form"));
}
global.JournalFormSave = { init: init, bind: bind };
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})(typeof window !== "undefined" ? window : globalThis);
+153
View File
@@ -0,0 +1,153 @@
/**
* 复盘表单:四周期截图即时上传与状态展示.
*/
(function (global) {
"use strict";
function newDraftId() {
if (global.crypto && typeof global.crypto.randomUUID === "function") {
return global.crypto.randomUUID().replace(/-/g, "");
}
var s = "";
for (var i = 0; i < 32; i++) {
s += Math.floor(Math.random() * 16).toString(16);
}
return s;
}
function ensureDraftId(root) {
var scope = root || document;
var el = scope.querySelector("#journal-draft-id");
if (!el) return "";
if (!el.value) {
el.value = newDraftId();
}
return el.value;
}
function rowParts(input) {
var row = input.closest(".journal-upload-row");
if (!row) return {};
return {
row: row,
status: row.querySelector(".journal-upload-status"),
hidden: row.querySelector(".journal-upload-hidden-file"),
};
}
function setStatus(statusEl, text, kind) {
if (!statusEl) return;
statusEl.textContent = text || "";
statusEl.classList.remove(
"journal-upload-status--pending",
"journal-upload-status--ok",
"journal-upload-status--err"
);
if (kind) {
statusEl.classList.add("journal-upload-status--" + kind);
}
}
function uploadSlotFile(input, file) {
var parts = rowParts(input);
var draftId = ensureDraftId(input.form || document);
if (!draftId || !file) {
setStatus(parts.status, "上传失败", "err");
return;
}
setStatus(parts.status, "上传中…", "pending");
if (parts.hidden) {
parts.hidden.value = "";
}
var fd = new FormData();
fd.append("journal_draft_id", draftId);
fd.append("tf", input.getAttribute("data-tf") || "");
fd.append("file", file);
fetch("/api/journal_upload_slot", { method: "POST", body: fd, credentials: "same-origin" })
.then(function (res) {
return res.json().then(function (data) {
return { ok: res.ok, data: data };
});
})
.then(function (result) {
if (!result.ok || !result.data || !result.data.ok) {
throw new Error(
(result.data && result.data.error) || "upload failed"
);
}
var fname = String(result.data.file || "").trim();
if (parts.hidden) {
parts.hidden.value = fname;
}
input.value = "";
setStatus(parts.status, "上传成功 " + fname, "ok");
})
.catch(function () {
if (parts.hidden) {
parts.hidden.value = "";
}
setStatus(parts.status, "上传失败", "err");
});
}
function isOptionsReviewSlot(input) {
if (!input) return false;
if (input.classList && input.classList.contains("or-upload-input")) return true;
return !!(input.closest && input.closest("#or-upload-slots, #options-review-root"));
}
function bindInput(input) {
if (!input || input.dataset.journalSlotBound === "1") return;
// 期权复盘槽位由 options_review.js 处理,勿被合约复盘上传抢走
if (isOptionsReviewSlot(input)) return;
input.dataset.journalSlotBound = "1";
input.addEventListener("change", function () {
var file = input.files && input.files[0];
if (!file) {
var parts = rowParts(input);
if (parts.hidden) {
parts.hidden.value = "";
}
setStatus(parts.status, "", "");
return;
}
uploadSlotFile(input, file);
});
}
function resetSlots(root) {
var scope = root || document;
var draftEl = scope.querySelector("#journal-draft-id");
if (draftEl) {
draftEl.value = newDraftId();
}
scope.querySelectorAll(".journal-upload-hidden-file").forEach(function (el) {
el.value = "";
});
scope.querySelectorAll(".journal-upload-slot-input").forEach(function (el) {
el.value = "";
});
scope.querySelectorAll(".journal-upload-status").forEach(function (el) {
setStatus(el, "", "");
});
}
function init(root) {
var scope = root || document;
ensureDraftId(scope);
scope.querySelectorAll(".journal-upload-slot-input").forEach(bindInput);
}
global.JournalUploadSlots = { init: init, reset: resetSlots };
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
init(document);
});
} else {
init(document);
}
})(typeof window !== "undefined" ? window : globalThis);
+160
View File
@@ -0,0 +1,160 @@
/**
* 关键位监控添加表单:类型切换显隐,成交量排名校验(三所实例共用).
*/
(function (global) {
const RS_TYPES = new Set([
"关键支撑阻力",
"关键阻力位",
"关键支撑位",
]);
function syncKeyMonitorFormFields() {
const typeEl = document.querySelector('#key-form [name="type"]');
const dirEl = document.getElementById("key-direction");
const modeEl = document.getElementById("key-sl-tp-mode");
const manualTp = document.getElementById("key-manual-tp");
const beWrap = document.getElementById("key-breakeven-wrap");
if (!typeEl) return;
const t = (typeEl.value || "").trim();
const autoTypes = new Set(["箱体突破", "收敛突破"]);
const fibTypes = new Set(["斐波回调0.618", "斐波回调0.786"]);
const fbTypes = new Set(["假突破"]);
const teTypes = new Set(["回调触价开仓", "突破触价开仓", "触价开仓"]);
const showAuto = autoTypes.has(t);
const showFb = fbTypes.has(t);
const showTe = teTypes.has(t);
const showBe = showAuto || fibTypes.has(t) || showFb || showTe;
const showDir = !RS_TYPES.has(t);
const upperEl = document.getElementById("key-upper");
const lowerEl = document.getElementById("key-lower");
const fbPriceEl = document.getElementById("key-fb-price");
const teEntryEl = document.getElementById("key-trigger-entry");
const teSlEl = document.getElementById("key-trigger-sl");
const teTpEl = document.getElementById("key-trigger-tp");
if (dirEl) {
dirEl.style.display = showDir ? "" : "none";
dirEl.required = showDir;
if (!showDir) dirEl.value = "";
}
if (modeEl) modeEl.style.display = showAuto ? "" : "none";
if (manualTp) {
const trend = showAuto && modeEl && modeEl.value === "trend_manual";
manualTp.style.display = trend ? "" : "none";
manualTp.required = !!trend;
}
if (beWrap) beWrap.style.display = showBe ? "inline-flex" : "none";
if (global.TimeCloseUI) global.TimeCloseUI.syncKeyTimeCloseVisibility(showBe);
const hideBounds = showFb || showTe;
if (upperEl) {
upperEl.style.display = hideBounds ? "none" : "";
upperEl.required = !hideBounds;
if (hideBounds) upperEl.value = "";
}
if (lowerEl) {
lowerEl.style.display = hideBounds ? "none" : "";
lowerEl.required = !hideBounds;
if (hideBounds) lowerEl.value = "";
}
if (fbPriceEl) {
fbPriceEl.style.display = showFb ? "" : "none";
fbPriceEl.required = showFb;
if (!showFb) fbPriceEl.value = "";
fbPriceEl.placeholder =
dirEl && dirEl.value === "short"
? "高点(阻力)"
: dirEl && dirEl.value === "long"
? "低点(支撑)"
: "做空填高点/做多填低点";
}
[teEntryEl, teSlEl, teTpEl].forEach((el) => {
if (!el) return;
el.style.display = showTe ? "" : "none";
el.required = showTe;
if (!showTe) el.value = "";
});
}
function submitKeyForm(keyForm, label) {
if (
document.body &&
document.body.getAttribute("data-embed-shell") === "1" &&
global.InstanceEmbed &&
typeof global.InstanceEmbed.postFormAndReload === "function"
) {
global.InstanceEmbed.postFormAndReload(keyForm, label || "提交中…");
return;
}
if (global.FormSubmitGuard) global.FormSubmitGuard.nativeSubmitOnce(keyForm, label || "提交中…");
else keyForm.submit();
}
function bindKeyMonitorForm() {
const keyForm = document.getElementById("key-form");
const keyTypeSel = document.querySelector('#key-form [name="type"]');
const keyModeSel = document.getElementById("key-sl-tp-mode");
const keyDirSel = document.getElementById("key-direction");
if (keyTypeSel) keyTypeSel.addEventListener("change", syncKeyMonitorFormFields);
if (keyModeSel) keyModeSel.addEventListener("change", syncKeyMonitorFormFields);
if (keyDirSel) keyDirSel.addEventListener("change", syncKeyMonitorFormFields);
syncKeyMonitorFormFields();
if (global.TimeCloseUI) {
global.TimeCloseUI.bindTimeCloseForm(
"key-time-close-cb",
"key-time-close-hours",
"key-time-close-wrap"
);
}
if (!keyForm || keyForm.dataset.keyFormBound === "1") return;
keyForm.dataset.keyFormBound = "1";
keyForm.addEventListener("submit", (e) => {
e.preventDefault();
if (global.FormSubmitGuard && global.FormSubmitGuard.isLocked(keyForm)) return;
const symbolEl = keyForm.querySelector('[name="symbol"]');
const symbol = (symbolEl ? symbolEl.value : "").trim();
if (!symbol) {
alert("请先输入交易对");
return;
}
const typeVal = (keyForm.querySelector('[name="type"]') || {}).value || "";
if (typeVal === "假突破") {
submitKeyForm(keyForm, "提交中…");
return;
}
if (global.FormSubmitGuard) global.FormSubmitGuard.lock(keyForm, "校验排名中…");
fetch(`/api/symbol_liquidity_rank?symbol=${encodeURIComponent(symbol)}`)
.then((r) => r.json().then((d) => ({ status: r.status, data: d })))
.then(({ status, data }) => {
if (status >= 400 || !data.ok) {
alert((data && data.msg) || "日成交量排名读取失败");
if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm);
return;
}
const rankMax = data.rank_max || 30;
const inTop = data.in_top != null ? data.in_top : data.in_top30;
if (data.rank == null || !inTop) {
alert(
`${data.symbol} 当前日成交量排名 ${data.rank == null ? "—" : data.rank}/${data.total},不在前${rankMax},已拦截.`
);
if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm);
return;
}
submitKeyForm(keyForm, "提交中…");
})
.catch(() => {
alert("日成交量排名检查失败,请稍后重试");
if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm);
});
});
}
global.KeyMonitorForm = {
syncFields: syncKeyMonitorFormFields,
init: bindKeyMonitorForm,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", bindKeyMonitorForm);
} else {
bindKeyMonitorForm();
}
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,340 @@
/**
* 实盘下单:填完币种与止盈止损后,在表单下方显示预估风险 / 预估盈利 / 预估盈亏比.
* 以损定仓:风险 = 当前交易基数 × risk%.
* 全仓杠杆:风险 = 可用保证金×缓冲 × 杠杆 × |SL-入场|/( calc_risk_amount_from_plan ).
*/
(function (global) {
"use strict";
let debounceMs = 400;
let minRr = 1.5;
let debounceTimer = null;
let fetchSeq = 0;
function $(id) {
return document.getElementById(id);
}
function num(v) {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function formatRr(rr) {
if (rr === null || typeof rr === "undefined") return "—";
const n = Number(rr);
if (!Number.isFinite(n)) return "—";
const body = Number.isInteger(n) ? String(n) : String(parseFloat(n.toFixed(2)));
return body + ":1";
}
function formatU(v) {
if (v === null || typeof v === "undefined" || !Number.isFinite(Number(v))) return "—";
return Number(v).toFixed(2) + "U";
}
function setMetric(el, label, valueText) {
if (!el) return;
el.innerHTML = label + ":<strong>" + valueText + "</strong>";
}
function sizingMode() {
return (document.body && document.body.getAttribute("data-position-sizing-mode")) || "risk";
}
function isFullMarginMode() {
return sizingMode() === "full_margin";
}
function fullMarginBuffer() {
const n = Number(document.body && document.body.getAttribute("data-full-margin-buffer"));
return Number.isFinite(n) && n > 0 ? n : 0.9;
}
function leverageForSymbol(sym) {
const u = (sym || "").trim().toUpperCase();
const btc = Number(document.body && document.body.getAttribute("data-btc-leverage"));
const alt = Number(document.body && document.body.getAttribute("data-alt-leverage"));
if (u.startsWith("BTC") || u.startsWith("ETH")) {
return Number.isFinite(btc) && btc > 0 ? btc : 10;
}
return Number.isFinite(alt) && alt > 0 ? alt : 5;
}
function riskPercent() {
const form = $("add-order-form");
const raw =
(form && form.getAttribute("data-risk-percent")) ||
(document.body && document.body.getAttribute("data-risk-percent")) ||
"";
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : 1;
}
function calcRiskFraction(direction, entry, sl) {
const e = num(entry);
const s = num(sl);
if (e === null || s === null || e <= 0 || s <= 0) return null;
let risk = 0;
if (direction === "short") {
risk = s - e;
} else {
risk = e - s;
}
if (risk <= 0) return null;
return risk / e;
}
function calcRr(direction, entry, sl, tp) {
const e = num(entry);
const s = num(sl);
const t = num(tp);
if (e === null || s === null || t === null) return null;
if (direction === "short") {
if (s <= e || t >= e) return null;
return (e - t) / (s - e);
}
if (s >= e || t <= e) return null;
return (t - e) / (e - s);
}
function calcRrFromPct(slPct, tpPct) {
const sl = num(slPct);
const tp = num(tpPct);
if (sl === null || tp === null || sl <= 0 || tp <= 0) return null;
return tp / sl;
}
function calcTpFromFixedRr(direction, entry, sl, rr) {
const e = num(entry);
const s = num(sl);
const r = num(rr);
if (e === null || s === null || r === null || r <= 0) return null;
if (direction === "short") {
if (s <= e) return null;
return e - (s - e) * r;
}
if (s >= e) return null;
return e + (e - s) * r;
}
function resolveSlPrice(mode, direction, entry) {
if (mode === "pct") {
const slPct = num($("order-sl-pct") && $("order-sl-pct").value);
if (slPct === null || slPct <= 0) return null;
if (direction === "short") return entry * (1 + slPct / 100);
return entry * (1 - slPct / 100);
}
return num($("order-sl") && $("order-sl").value);
}
function currentMode() {
return ($("sltp-mode") && $("sltp-mode").value) || "fixed_rr";
}
function currentDirection() {
return ($("order-direction") && $("order-direction").value) || "long";
}
function currentSymbol() {
return (($("order-symbol") && $("order-symbol").value) || "").trim();
}
function inputsComplete(m) {
const dir = currentDirection();
if (!currentSymbol() || !dir) return false;
if (m === "pct") {
const sl = num($("order-sl-pct") && $("order-sl-pct").value);
const tp = num($("order-tp-pct") && $("order-tp-pct").value);
return sl !== null && tp !== null && sl > 0 && tp > 0;
}
if (m === "fixed_rr") {
const sl = num($("order-sl") && $("order-sl").value);
const rr = num($("order-fixed-rr") && $("order-fixed-rr").value);
return sl !== null && rr !== null && sl > 0 && rr > 0;
}
const sl = num($("order-sl") && $("order-sl").value);
const tp = num($("order-tp") && $("order-tp").value);
return sl !== null && tp !== null && sl > 0 && tp > 0;
}
function paintEmpty() {
setMetric($("order-risk-preview"), "预估风险", "—");
setMetric($("order-profit-preview"), "预估盈利", "—");
setMetric($("order-rr-preview"), "预估盈亏比", "—");
}
function paintLoading() {
setMetric($("order-risk-preview"), "预估风险", "计算中…");
setMetric($("order-profit-preview"), "预估盈利", "计算中…");
setMetric($("order-rr-preview"), "预估盈亏比", "计算中…");
}
function paintFail(kind) {
const msg = kind === "fetch_fail" ? "取价失败" : "无效";
setMetric($("order-risk-preview"), "预估风险", msg);
setMetric($("order-profit-preview"), "预估盈利", msg);
setMetric($("order-rr-preview"), "预估盈亏比", msg);
}
function paintOk(riskU, profitU, rr) {
setMetric($("order-risk-preview"), "预估风险", formatU(riskU));
setMetric($("order-profit-preview"), "预估盈利", formatU(profitU));
const rrEl = $("order-rr-preview");
const rrText = formatRr(rr);
setMetric(rrEl, "预估盈亏比", rrText);
if (rrEl && rr !== null && Number.isFinite(Number(rr))) {
rrEl.classList.toggle("order-preview-rr-low", Number(rr) < minRr);
rrEl.classList.toggle("order-preview-rr-ok", Number(rr) >= minRr);
}
}
function plannedRiskFromRiskMode(capital) {
const cap = num(capital);
if (cap === null || cap <= 0) return null;
return Math.round((cap * riskPercent()) / 100 * 100) / 100;
}
function plannedRiskFromFullMargin(availableUsdt, symbol, direction, entry, sl) {
const avail = num(availableUsdt);
if (avail === null || avail <= 0) return null;
const slPx = num(sl);
const entryPx = num(entry);
if (slPx === null || entryPx === null) return null;
const rf = calcRiskFraction(direction, entryPx, slPx);
if (rf === null) return null;
const margin = Math.round(avail * fullMarginBuffer() * 100) / 100;
const lev = leverageForSymbol(symbol);
return Math.round(margin * lev * rf * 100) / 100;
}
function resolvePreviewRr(m, dir, entry) {
if (m === "pct") {
return calcRrFromPct(
$("order-sl-pct") && $("order-sl-pct").value,
$("order-tp-pct") && $("order-tp-pct").value
);
}
const sl = num($("order-sl") && $("order-sl").value);
if (m === "fixed_rr") {
const fixed = num($("order-fixed-rr") && $("order-fixed-rr").value);
if (fixed !== null && fixed > 0) return fixed;
const tp = calcTpFromFixedRr(dir, entry, sl, fixed);
return calcRr(dir, entry, sl, tp);
}
const tp = num($("order-tp") && $("order-tp").value);
return calcRr(dir, entry, sl, tp);
}
function refreshNow() {
if (!$("order-plan-preview")) return;
const m = currentMode();
if (!inputsComplete(m)) {
paintEmpty();
return;
}
const sym = currentSymbol();
const dir = currentDirection();
const seq = ++fetchSeq;
paintLoading();
const defaultsP = fetch(
"/api/order_defaults?symbol=" +
encodeURIComponent(sym) +
"&direction=" +
encodeURIComponent(dir)
).then(function (r) {
return r.json();
});
const capitalP = fetch("/api/account_snapshot").then(function (r) {
return r.json();
});
Promise.all([defaultsP, capitalP])
.then(function (results) {
if (seq !== fetchSeq) return;
const data = results[0];
const account = results[1] || {};
if (!data.ok) {
paintFail("fetch_fail");
return;
}
const entry = num(data.last_price != null ? data.last_price : data.price);
if (entry === null) {
paintFail("fetch_fail");
return;
}
const rr = resolvePreviewRr(m, dir, entry);
if (rr === null) {
paintFail("invalid");
return;
}
let riskU = null;
if (isFullMarginMode()) {
const slPx = resolveSlPrice(m, dir, entry);
const avail =
data.available_trading_usdt != null
? data.available_trading_usdt
: account.available_trading_usdt;
riskU = plannedRiskFromFullMargin(avail, sym, dir, entry, slPx);
} else {
riskU = plannedRiskFromRiskMode(account.current_capital);
}
if (riskU === null) {
paintFail("fetch_fail");
return;
}
const profitU = Math.round(riskU * rr * 100) / 100;
paintOk(riskU, profitU, rr);
})
.catch(function () {
if (seq !== fetchSeq) return;
paintFail("fetch_fail");
});
}
function schedule() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(refreshNow, debounceMs);
}
function wire(opts) {
opts = opts || {};
if (opts.minRr != null && Number.isFinite(Number(opts.minRr))) {
minRr = Number(opts.minRr);
}
if (opts.debounceMs != null && Number.isFinite(Number(opts.debounceMs))) {
debounceMs = Number(opts.debounceMs);
}
[
"order-symbol",
"order-direction",
"sltp-mode",
"order-sl",
"order-tp",
"order-sl-pct",
"order-tp-pct",
"order-fixed-rr",
"order-leverage",
].forEach(function (id) {
const el = $(id);
if (!el || el._rrPreviewBound) return;
el._rrPreviewBound = true;
el.addEventListener("input", schedule);
el.addEventListener("change", schedule);
});
schedule();
}
global.ManualOrderRrPreview = {
wire: wire,
schedule: schedule,
refresh: refreshNow,
calcRr: calcRr,
calcRrFromPct: calcRrFromPct,
calcRiskFraction: calcRiskFraction,
formatRr: formatRr,
};
})(typeof window !== "undefined" ? window : globalThis);
+55
View File
@@ -0,0 +1,55 @@
/**
* 实盘下单监控:开仓按钮灰显 + 旁注(强制清仓/冷静期/日冻结等).
*/
(function (global) {
function apply(data) {
const d = data || {};
const btn =
document.getElementById("om-submit-btn") ||
document.querySelector("#add-order-form button.om-submit");
const noteEl = document.getElementById("om-open-block-note");
if (!btn && !noteEl) return;
const canTrade = d.can_trade !== false;
let note = (d.open_block_note || "").trim();
const fc = d.force_close || {};
const rs = d.risk_status || {};
if (!note && fc.enabled && fc.executing) {
const grace = fc.grace_minutes != null ? fc.grace_minutes : 5;
note =
"强制清仓窗口内(北京时间 " +
(fc.hour_label || "--:--") +
" 起 " +
grace +
" 分钟),暂不可开仓";
}
if (!note && rs.can_trade === false && rs.reason) {
note = String(rs.reason);
}
if (!note && !canTrade) {
note = "当前不可开仓";
}
if (btn) {
btn.disabled = !canTrade;
btn.classList.toggle("is-blocked", !canTrade);
btn.setAttribute("aria-disabled", canTrade ? "false" : "true");
if (!canTrade) {
btn.title = note || "当前不可开仓";
} else {
btn.removeAttribute("title");
}
}
if (noteEl) {
if (!canTrade && note) {
noteEl.hidden = false;
noteEl.textContent = note;
} else {
noteEl.hidden = true;
noteEl.textContent = "";
}
}
}
global.OpenSubmitGate = { apply: apply };
})(window);
@@ -0,0 +1,58 @@
/**
* 期权到期倒计时(实例期权页 + 中控监控/看板共用)
*/
(function (global) {
function normalizeExpMs(v) {
if (v == null || v === "") return null;
var n = Number(v);
if (!Number.isFinite(n) || n <= 0) return null;
if (n < 1e12) n *= 1000;
return n;
}
function formatCountdown(expMs, nowMs) {
var ms = normalizeExpMs(expMs);
if (ms == null) return "—";
var now = nowMs != null ? nowMs : Date.now();
var rem = Math.max(0, Math.floor((ms - now) / 1000));
if (rem <= 0) return "已到期";
var d = Math.floor(rem / 86400);
var h = Math.floor((rem % 86400) / 3600);
var m = Math.floor((rem % 3600) / 60);
var s = rem % 60;
var pad = function (x) {
return String(x).padStart(2, "0");
};
if (d > 0) return d + "天 " + pad(h) + ":" + pad(m) + ":" + pad(s);
return pad(h) + ":" + pad(m) + ":" + pad(s);
}
function tick(root) {
var scope = root && root.querySelectorAll ? root : document;
var now = Date.now();
scope.querySelectorAll("[data-opt-exp-ms]").forEach(function (el) {
var exp = el.getAttribute("data-opt-exp-ms");
var text = formatCountdown(exp, now);
el.textContent = text;
var expMs = normalizeExpMs(exp);
el.classList.toggle("opt-expiry-cd--urgent", expMs != null && expMs - now > 0 && expMs - now < 3600000);
el.classList.toggle("opt-expiry-cd--expired", text === "已到期");
});
}
var timer = null;
function ensureTimer() {
tick();
if (timer) return;
timer = setInterval(function () {
tick();
}, 1000);
}
global.OptionsExpiryCountdown = {
normalizeExpMs: normalizeExpMs,
format: formatCountdown,
tick: tick,
ensureTimer: ensureTimer,
};
})(typeof window !== "undefined" ? window : globalThis);
File diff suppressed because it is too large Load Diff
+276
View File
@@ -0,0 +1,276 @@
(function (global) {
"use strict";
function fmt(v, d) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
return Number(v).toFixed(d == null ? 2 : d);
}
function fmtDisplay(v, fallback) {
if (v !== null && v !== undefined && String(v).trim() !== "") return String(v);
if (fallback !== undefined) return fmtDisplay(fallback);
return "—";
}
function fmtOptionPx(v, tickSz) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
const n = Number(v);
const tick = Number(tickSz);
if (!tickSz || Number.isNaN(tick) || tick <= 0) {
let s = n.toFixed(4).replace(/\.?0+$/, "");
return s || "0";
}
let decimals = 0;
if (tick < 1) decimals = Math.max(0, -Math.round(Math.log10(tick)));
else if (String(tick).indexOf(".") >= 0) decimals = String(tick).split(".")[1].length;
let s = n.toFixed(decimals);
// 仅裁小数尾零;整数 tick(BTC=5)时绝不能把 1370 裁成 137
if (decimals > 0) s = s.replace(/\.?0+$/, "");
return s || "0";
}
function fmtUsdc(v) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
return Number(v).toFixed(2);
}
function optTypeLabel(t) {
return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call";
}
function sourceText(p) {
const lab = (p && p.source_label) || "纯期权";
const src = (p && p.source) || "option";
let pid = p && p.source_plan_id;
if (pid == null && p && p.hedge_plan_target && p.hedge_plan_target.plan_id != null) {
pid = p.hedge_plan_target.plan_id;
}
if (src !== "option" && pid != null && pid !== "") return lab + " #" + pid;
return lab;
}
function sourceBadgeHtml(p) {
const src = (p && p.source) || "option";
const cls =
src === "options_options"
? "opt-source-badge opt-source-badge--oo"
: src === "perp_options"
? "opt-source-badge opt-source-badge--po"
: "opt-source-badge opt-source-badge--plain";
return '<span class="' + cls + '" title="持仓来源">' + sourceText(p) + "</span>";
}
function pnlCls(upl, hub) {
if (upl > 0) return hub ? "pnl-pos" : "pos-pnl-profit";
if (upl < 0) return hub ? "pnl-neg" : "pos-pnl-loss";
return "";
}
function fmtPxSz(px, sz, tickSz) {
if (px === null || px === undefined || Number.isNaN(Number(px))) return "—";
let price = fmtOptionPx(px, tickSz);
if (sz === null || sz === undefined || sz === "" || Number.isNaN(Number(sz))) return price;
const s = Number(sz);
const size = Math.abs(s - Math.round(s)) < 1e-9 ? String(Math.round(s)) : String(s);
return price + "/" + size;
}
/** 买盘深度:价格/流动性;仅展示平仓所需档位(买一不够才出买二…). */
function fmtCloseLevels(preview, tickSz) {
if (preview && preview.bid_invalid) {
return "暂无有效买盘";
}
const levels = ((preview && preview.levels) || []).slice(0, 5);
if (!levels.length) return "—";
return levels.map(function (x, idx) {
const levelNo = x.level != null ? x.level : idx + 1;
const liq = x.available_sheets != null ? x.available_sheets : x.sz;
return "买" + levelNo + " " + fmtPxSz(x.px, liq, tickSz);
}).join(" · ");
}
function closeGateHint(preview) {
if (!preview) return "";
if (preview.bid_invalid || preview.manual_close_blocked) {
return preview.bid_invalid_reason || "当前买一无效,禁止买一平仓";
}
const gate = preview.close_gate || {};
if (preview.close_gate_blocked || (gate.ready === false && !gate.passed)) {
return "目标门控: " + (preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟");
}
return "";
}
function netPnlFromPos(p) {
const preview = (p && p.close_preview) || {};
if (preview.bid_invalid) {
const upl = p && p.upl != null ? Number(p.upl) : NaN;
return Number.isFinite(upl) ? upl : null;
}
if (preview.estimated_pnl != null && !Number.isNaN(Number(preview.estimated_pnl))) {
return Number(preview.estimated_pnl);
}
const covered = Number(preview.covered_sheets);
const recv = Number(preview.total_received);
const prem = Number(p && p.premium_paid);
if (
preview.total_received != null &&
Number.isFinite(covered) &&
covered > 0 &&
!Number.isNaN(recv) &&
!Number.isNaN(prem)
) {
return recv - prem;
}
const upl = p && p.upl != null ? Number(p.upl) : NaN;
return Number.isFinite(upl) ? upl : null;
}
function netRoiFromPos(p, net) {
const preview = (p && p.close_preview) || {};
if (preview.estimated_pnl_ratio_pct != null && !Number.isNaN(Number(preview.estimated_pnl_ratio_pct))) {
return Number(preview.estimated_pnl_ratio_pct);
}
const prem = Number(p && p.premium_paid);
if (net == null || Number.isNaN(prem) || prem <= 0) return null;
return (net / prem) * 100;
}
function fmtClosePreview(preview, premiumPaid, hub) {
if (!preview || preview.total_received == null) return "—";
const recvTxt = fmtUsdc(preview.total_received);
let cls = "";
const prem = Number(premiumPaid);
const recv = Number(preview.total_received);
if (!Number.isNaN(prem) && !Number.isNaN(recv)) {
if (recv > prem) cls = " " + pnlCls(1, hub);
else if (recv < prem) cls = " " + pnlCls(-1, hub);
}
return '<span class="opt-close-value' + cls + '">' + recvTxt + " USDC</span>";
}
function expiryCdHtml(expMs) {
const ms = expMs != null && expMs !== "" ? String(expMs) : "";
if (!ms) return "—";
return '<span class="opt-expiry-cd" data-opt-exp-ms="' + ms + '">—</span>';
}
function renderCardInner(p, opts) {
opts = opts || {};
const hub = !!opts.hub;
const readOnly = !!opts.readOnly;
const hidePnl = !!opts.hidePnl;
const net = hidePnl ? null : netPnlFromPos(p);
const roi = hidePnl ? null : netRoiFromPos(p, net);
const uplCls = hidePnl ? "" : pnlCls(net, hub);
const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long";
const expMs = p.exp_time_ms != null ? p.exp_time_ms : p.exp_time;
const expAttr = expMs != null && expMs !== "" ? String(expMs) : "";
const closePreview = p.close_preview || {};
const tickSz = p.tick_sz;
const premTxt = fmtDisplay(p.premium_paid_fmt, p.premium_paid != null ? fmtUsdc(p.premium_paid) : null);
const avgTxt = p.avg_px != null ? fmtOptionPx(p.avg_px, tickSz) : fmtDisplay(p.avg_px_fmt);
const markTxt = p.mark_px != null ? fmtOptionPx(p.mark_px, tickSz) : fmtDisplay(p.mark_px_fmt);
let headActions = "";
if (!readOnly) {
const closeSheets = p.avail_pos != null && Number(p.avail_pos) > 0 ? p.avail_pos : p.pos;
headActions =
'<div class="pos-head-actions">' +
'<button type="button" class="btn-primary opt-close-btn" data-inst="' + (p.inst_id || "") + '" data-sheets="' + closeSheets + '">买一平仓</button>' +
"</div>";
}
const pnlCells = hidePnl
? ""
: '<div class="pos-cell"><span class="pos-label">净盈亏</span><span class="pos-value ' + uplCls + '">' +
(net == null ? "—" : fmt(net, 2)) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
(roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>";
return (
'<div class="pos-card-head">' +
'<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + "</strong>" +
'<span class="pos-side-badge ' + sideCls + '">' + optTypeLabel(p.opt_type) + "</span>" +
sourceBadgeHtml(p) +
"</div>" +
headActions +
"</div>" +
'<div class="pos-meta">' +
'<span class="pos-meta-item">持仓来源: ' + sourceText(p) + "</span>" +
'<span class="pos-meta-item">行权价: ' + fmt(p.strike, 0) + "</span>" +
'<span class="pos-meta-item">张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "</span>" +
(expAttr
? '<span class="pos-meta-item">到期倒计时: ' + expiryCdHtml(expAttr) + "</span>"
: "") +
"</div>" +
'<div class="pos-grid">' +
'<div class="pos-cell"><span class="pos-label">权利金</span><span class="pos-value">' + premTxt + " USDC</span></div>" +
'<div class="pos-cell"><span class="pos-label">开仓均价</span><span class="pos-value">' + avgTxt + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">' + markTxt + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">指数价</span><span class="pos-value">' + fmt(p.idx_px, 0) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">到期平衡</span><span class="pos-value">' + fmt(p.expiry_be_px, 0) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">平掉回本</span><span class="pos-value">' + fmt(p.close_be_px, 0) + "</span></div>" +
pnlCells +
'<div class="pos-cell opt-pos-cell--depth"><span class="pos-label">买盘深度</span><span class="pos-value opt-bid-plain">' + fmtCloseLevels(closePreview, tickSz) + "</span></div>" +
'<div class="pos-cell opt-pos-cell--close"><span class="pos-label">按买盘回收</span><span class="pos-value">' +
(closePreview.bid_invalid
? '<span class="muted">暂无有效买盘</span>'
: fmtClosePreview(closePreview, hidePnl ? null : p.premium_paid, hub)) + "</span></div>" +
"</div>" +
(function () {
const hint = closeGateHint(closePreview);
return hint ? '<div class="muted opt-bid-invalid-hint">' + hint + "</div>" : "";
})() +
(p.target_index != null
? (function () {
const eth = p.eth_amount != null ? Number(p.eth_amount)
: (Number(p.pos) > 0 ? Number(p.pos) * Number(p.ct_mult || 0.01) : null);
const strike = Number(p.strike);
const tgt = Number(p.target_index);
const prem = Number(p.premium_paid);
let profit = null;
let value = null;
if (Number.isFinite(tgt) && Number.isFinite(strike) && eth > 0) {
const o = String(p.opt_type || "").toUpperCase();
const intrinsic = o === "C" ? Math.max(0, tgt - strike) : o === "P" ? Math.max(0, strike - tgt) : null;
if (intrinsic != null) {
value = Math.round(intrinsic * eth * 100) / 100;
if (!hidePnl && Number.isFinite(prem)) profit = Math.round((value - prem) * 100) / 100;
}
}
const profitTxt = profit == null ? "—" : ((profit > 0 ? "+" : "") + fmtUsdc(profit) + " USDC");
const profitCls = profit > 0 ? " pnl-pos" : profit < 0 ? " pnl-neg" : "";
const hedgeTarget = p.hedge_plan_target || null;
const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan";
const profitSpan = hidePnl
? ""
: '<span class="pos-value' + profitCls + '">预估盈利 ' + profitTxt + "</span>";
return (
'<div class="opt-target-row opt-target-row--ro' + (managed ? " opt-target-row--managed" : "") + '">' +
'<span class="opt-target-row-label">' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "</span>" +
'<span class="pos-value">目标 ' + fmt(p.target_index, 1) + "</span>" +
'<span class="pos-value">价值 ' + (value == null ? "—" : fmtUsdc(value) + " USDC") + "</span>" +
profitSpan +
'<span class="muted opt-target-row-hint">' +
(managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") +
"</span></div>"
);
})()
: "")
);
}
function renderCard(p, opts) {
opts = opts || {};
const hub = !!opts.hub;
const extraCls = hub ? " hub-pos-card hub-opt-pos-card" : " opt-pos-card";
return (
'<div class="pos-card' + extraCls + '" data-inst="' + (p.inst_id || "") + '">' +
renderCardInner(p, opts) +
"</div>"
);
}
global.OptionsPositionCards = {
renderCardInner: renderCardInner,
renderCard: renderCard,
};
})(typeof window !== "undefined" ? window : globalThis);
File diff suppressed because it is too large Load Diff
+312
View File
@@ -0,0 +1,312 @@
(function () {
"use strict";
const root = document.getElementById("options-settings-root");
if (!root) return;
const SWAP_BTNS = ["opt-set-swap-btn", "opt-set-swap-all-btn"];
const INT_BTNS = ["opt-set-int-btn", "opt-set-int-all-btn"];
async function apiJson(url, opts) {
const r = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {}));
return r.json();
}
function refreshFundsAfterMutation() {
if (typeof refreshAccountSnapshot !== "function") return;
refreshAccountSnapshot({ force: true });
setTimeout(function () {
refreshAccountSnapshot({ force: true, silent: true });
}, 1500);
}
function setMsg(id, text, isErr) {
const el = document.getElementById(id);
if (!el) return;
el.textContent = text || "";
el.classList.toggle("opt-error", !!isErr);
el.classList.toggle("opt-success", !!text && !isErr);
}
function fmtAmt(amount, ccy) {
return `${Number(amount).toFixed(2)} ${ccy}`;
}
function accountLabel(acct) {
return acct === "trading" ? "交易账户" : "资金账户";
}
function swapDirLabel(dir) {
return dir === "usdc_to_usdt" ? "USDC → USDT" : "USDT → USDC";
}
function confirmOk(message) {
return window.confirm(message);
}
function setButtonsBusy(btnIds, busy, busyText) {
btnIds.forEach(function (id) {
const btn = document.getElementById(id);
if (!btn) return;
if (busy) {
if (!btn.dataset.origText) btn.dataset.origText = btn.textContent;
btn.disabled = true;
if (busyText) btn.textContent = busyText;
} else {
btn.disabled = false;
if (btn.dataset.origText) {
btn.textContent = btn.dataset.origText;
delete btn.dataset.origText;
}
}
});
const amountIds = {
"opt-set-swap-btn": "opt-set-swap-amount",
"opt-set-swap-all-btn": "opt-set-swap-amount",
"opt-set-int-btn": "opt-set-int-amount",
"opt-set-int-all-btn": "opt-set-int-amount",
"opt-set-cross-btn": "opt-set-cross-amount",
"opt-set-cross-all-btn": "opt-set-cross-amount",
};
btnIds.forEach(function (id) {
const input = document.getElementById(amountIds[id]);
if (input) input.disabled = busy;
});
}
function roundAvail(v) {
const n = Number(v);
if (!Number.isFinite(n) || n <= 0) return null;
return Math.round(n * 100) / 100;
}
async function loadBalances(force, scope) {
const parts = [];
if (force) parts.push("force=1");
if (scope && scope !== "main") parts.push("scope=" + encodeURIComponent(scope));
const q = parts.length ? "?" + parts.join("&") : "";
const d = await apiJson("/api/options/balances" + q);
if (!d.ok) throw new Error(d.msg || "余额拉取失败");
return d;
}
function pickBalance(bal, account, ccy) {
const acct = account === "trading" ? "trading" : "funding";
const c = String(ccy || "").toLowerCase();
const availKey = acct + "_" + c + "_avail";
const totalKey = acct + "_" + c;
return roundAvail(bal[availKey] != null ? bal[availKey] : bal[totalKey]);
}
async function resolveSwapMaxAmount(dir) {
const bal = await loadBalances(true, "main");
const ccy = dir === "usdc_to_usdt" ? "USDC" : "USDT";
// 币种兑换走资金账户现货;统一账户下 USDT 有时在交易户,市价单仍可能成交
let amount = pickBalance(bal, "funding", ccy);
let source = "funding";
if (!amount && ccy === "USDT") {
const tradingAmt = pickBalance(bal, "trading", ccy);
if (tradingAmt) {
amount = tradingAmt;
source = "trading";
}
}
return { amount, bal, ccy, source };
}
async function resolveMaxAmount(account, ccy, scope) {
const bal = await loadBalances(true, scope || "main");
return pickBalance(bal, account, ccy);
}
async function submitSwap(amount) {
setButtonsBusy(SWAP_BTNS, true, "兑换中…");
setMsg("opt-set-swap-msg", "兑换中,市价成交可能有延时…", false);
try {
const d = await apiJson("/api/options/spot/swap", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
direction: document.getElementById("opt-set-swap-dir").value,
amount: amount,
}),
});
if (d.ok) {
setMsg("opt-set-swap-msg", "兑换成功", false);
refreshFundsAfterMutation();
} else {
setMsg("opt-set-swap-msg", "兑换失败:" + (d.msg || "未知错误"), true);
}
return d;
} catch (e) {
setMsg("opt-set-swap-msg", "兑换失败:" + (e.message || "网络错误"), true);
return { ok: false };
} finally {
setButtonsBusy(SWAP_BTNS, false);
}
}
const swapBtn = document.getElementById("opt-set-swap-btn");
if (swapBtn) {
swapBtn.addEventListener("click", async function () {
const amount = parseFloat(document.getElementById("opt-set-swap-amount").value);
if (!amount || amount <= 0) {
setMsg("opt-set-swap-msg", "请输入有效数量", true);
return;
}
await submitSwap(amount);
});
}
const swapAllBtn = document.getElementById("opt-set-swap-all-btn");
if (swapAllBtn) {
swapAllBtn.addEventListener("click", async function () {
try {
const dir = document.getElementById("opt-set-swap-dir").value;
const { amount, bal, ccy, source } = await resolveSwapMaxAmount(dir);
if (!amount) {
const fu = bal.funding_usdt_avail != null ? bal.funding_usdt_avail : bal.funding_usdt;
const tu = bal.trading_usdt_avail != null ? bal.trading_usdt_avail : bal.trading_usdt;
const fc = bal.funding_usdc_avail != null ? bal.funding_usdc_avail : bal.funding_usdc;
setMsg(
"opt-set-swap-msg",
"资金账户可用 " +
ccy +
" 不足(资金户 USDT:" +
(fu != null ? fu : "—") +
" USDC:" +
(fc != null ? fc : "—") +
"; 交易户 USDT:" +
(tu != null ? tu : "—") +
")",
true
);
return;
}
const srcLabel = source === "trading" ? "交易账户" : "资金账户";
const msg =
"确认全部兑换?\n\n" +
"方向:" + swapDirLabel(dir) + "\n" +
"金额:" + fmtAmt(amount, ccy) + "\n" +
"来源:" + srcLabel + "\n\n" +
"将按该账户可用余额发起市价兑换(可能有延时)。请确认。";
if (!confirmOk(msg)) return;
document.getElementById("opt-set-swap-amount").value = String(amount);
await submitSwap(amount);
} catch (e) {
setMsg("opt-set-swap-msg", "兑换失败:" + (e.message || "余额拉取失败"), true);
}
});
}
async function submitInternalTransfer(amount) {
setButtonsBusy(INT_BTNS, true, "划转中…");
setMsg("opt-set-int-msg", "划转中…", false);
try {
const d = await apiJson("/api/options/transfer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ccy: document.getElementById("opt-set-int-ccy").value,
from: document.getElementById("opt-set-int-from").value,
to: document.getElementById("opt-set-int-to").value,
amount: amount,
}),
});
if (d.ok) {
setMsg("opt-set-int-msg", "划转成功", false);
refreshFundsAfterMutation();
} else {
setMsg("opt-set-int-msg", "划转失败:" + (d.msg || "未知错误"), true);
}
return d;
} catch (e) {
setMsg("opt-set-int-msg", "划转失败:" + (e.message || "网络错误"), true);
return { ok: false };
} finally {
setButtonsBusy(INT_BTNS, false);
}
}
const intBtn = document.getElementById("opt-set-int-btn");
if (intBtn) {
intBtn.addEventListener("click", async function () {
const amount = parseFloat(document.getElementById("opt-set-int-amount").value);
if (!amount || amount <= 0) {
setMsg("opt-set-int-msg", "请输入有效数量", true);
return;
}
await submitInternalTransfer(amount);
});
}
const intAllBtn = document.getElementById("opt-set-int-all-btn");
if (intAllBtn) {
intAllBtn.addEventListener("click", async function () {
try {
const ccy = document.getElementById("opt-set-int-ccy").value;
const from = document.getElementById("opt-set-int-from").value;
const to = document.getElementById("opt-set-int-to").value;
const amount = await resolveMaxAmount(from, ccy, "main");
if (!amount) {
setMsg("opt-set-int-msg", "划出账户可用余额不足", true);
return;
}
const msg =
"确认全部划转?\n\n" +
"币种:" + ccy + "\n" +
"划出:" + accountLabel(from) + "\n" +
"划入:" + accountLabel(to) + "\n" +
"金额:" + fmtAmt(amount, ccy) + "\n\n" +
"将划转该账户全部可用余额。";
if (!confirmOk(msg)) return;
document.getElementById("opt-set-int-amount").value = String(amount);
await submitInternalTransfer(amount);
} catch (e) {
setMsg("opt-set-int-msg", "划转失败:" + (e.message || "余额拉取失败"), true);
}
});
}
function hardenAmountAutofill(ids) {
ids.forEach(function (id) {
const el = document.getElementById(id);
if (!el) return;
function wipe() {
const v = String(el.value || "").trim();
if (/^[a-z][a-z0-9._-]{1,31}$/i.test(v)) el.value = "";
}
wipe();
el.setAttribute("readonly", "readonly");
el.addEventListener("focus", function () {
el.removeAttribute("readonly");
});
el.addEventListener("blur", function () {
if (!el.value) el.setAttribute("readonly", "readonly");
});
setTimeout(wipe, 200);
setTimeout(wipe, 800);
setTimeout(wipe, 2000);
});
}
// 全部划转/兑换前去掉 readonly,避免写不进数量
["opt-set-swap-all-btn", "opt-set-int-all-btn"].forEach(function (btnId) {
const btn = document.getElementById(btnId);
if (!btn) return;
btn.addEventListener(
"click",
function () {
const map = {
"opt-set-swap-all-btn": "opt-set-swap-amount",
"opt-set-int-all-btn": "opt-set-int-amount",
};
const input = document.getElementById(map[btnId]);
if (input) input.removeAttribute("readonly");
},
true
);
});
hardenAmountAutofill(["opt-set-swap-amount", "opt-set-int-amount"]);
})();
+214
View File
@@ -0,0 +1,214 @@
(function (global) {
var delegated = false;
function queryInScope(scope, id) {
if (scope && scope.querySelector) return scope.querySelector("#" + id);
return document.getElementById(id);
}
function categoriesData() {
return global.ORDER_ENTRY_MODEL_CATEGORIES || [];
}
function codeToCategoryMap() {
return global.ORDER_ENTRY_MODEL_CODE_TO_CATEGORY || {};
}
function tradeStyleForCode(code, modelSel) {
if (modelSel && code) {
var opt = modelSel.querySelector('option[value="' + code.replace(/"/g, '\\"') + '"]');
if (opt) {
var ds = opt.getAttribute("data-trade-style");
if (ds === "swing" || ds === "trend") return ds;
}
}
var map = global.ORDER_ENTRY_MODEL_TRADE_STYLE || {};
return map[code] || "trend";
}
function findOption(catKey, code) {
var cats = categoriesData();
for (var i = 0; i < cats.length; i++) {
if (cats[i].key !== catKey) continue;
var opts = cats[i].options || [];
for (var j = 0; j < opts.length; j++) {
if (opts[j].code === code) return opts[j];
}
}
return null;
}
function filterDomSubOptions(modelSel, catKey, preserveCode) {
var tagged = modelSel.querySelectorAll("option[data-entry-category]");
if (!tagged.length) return false;
var any = false;
for (var i = 0; i < tagged.length; i++) {
var opt = tagged[i];
var show = !!catKey && opt.getAttribute("data-entry-category") === catKey;
opt.hidden = !show;
opt.disabled = !show;
if (show) any = true;
}
modelSel.disabled = !any;
if (!any) {
modelSel.value = "";
return true;
}
var pick = preserveCode || "";
if (pick) {
var picked = modelSel.querySelector('option[value="' + pick.replace(/"/g, '\\"') + '"]:not([disabled])');
if (picked) {
modelSel.value = pick;
return true;
}
}
var visible = [];
for (var k = 0; k < tagged.length; k++) {
if (!tagged[k].disabled) visible.push(tagged[k]);
}
if (visible.length === 1) modelSel.value = visible[0].value;
else modelSel.value = "";
return true;
}
function rebuildFromCategories(modelSel, catKey, preserveCode) {
var cats = categoriesData();
var cat = null;
for (var i = 0; i < cats.length; i++) {
if (cats[i].key === catKey) {
cat = cats[i];
break;
}
}
modelSel.innerHTML = "";
var placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = "类型";
modelSel.appendChild(placeholder);
if (!cat || !cat.options || !cat.options.length) {
modelSel.disabled = true;
modelSel.value = "";
return;
}
modelSel.disabled = false;
var pick = preserveCode || "";
for (var k = 0; k < cat.options.length; k++) {
var o = cat.options[k];
var opt = document.createElement("option");
opt.value = o.code;
opt.textContent = o.label;
if (o.trade_style) opt.setAttribute("data-trade-style", o.trade_style);
if (o.help) opt.title = o.help;
modelSel.appendChild(opt);
}
if (pick && findOption(catKey, pick)) {
modelSel.value = pick;
} else if (cat.options.length === 1) {
modelSel.value = cat.options[0].code;
} else {
modelSel.value = "";
}
}
function rebuildEntryModelSubSelect(preserveCode, scope) {
var root = scope && scope.querySelector ? scope : document;
var catSel = queryInScope(root, "order-entry-category");
var modelSel = queryInScope(root, "order-entry-model");
if (!catSel || !modelSel) return;
var catKey = catSel.value;
if (filterDomSubOptions(modelSel, catKey, preserveCode)) {
syncOrderEntryModelTradeStyle(modelSel);
return;
}
rebuildFromCategories(modelSel, catKey, preserveCode);
syncOrderEntryModelTradeStyle(modelSel);
}
function syncOrderEntryModelTradeStyle(modelSel) {
if (!modelSel) modelSel = document.getElementById("order-entry-model");
var hidden = document.getElementById("order-trade-style-hidden");
var hint = document.getElementById("order-trade-style-hint");
if (!modelSel || !hidden) return;
var labels = { trend: "趋势单", swing: "波段单" };
var code = modelSel.value || "";
var ts = tradeStyleForCode(code, modelSel);
hidden.value = ts;
if (hint) hint.textContent = labels[ts] || ts;
}
function wireDelegation() {
if (delegated) return;
delegated = true;
document.addEventListener(
"change",
function (ev) {
var t = ev.target;
if (!t || !t.id) return;
if (t.id === "order-entry-category") {
rebuildEntryModelSubSelect("");
return;
}
if (t.id === "order-entry-model") {
syncOrderEntryModelTradeStyle(t);
}
},
false
);
}
function initOrderEntryModelSelect(root) {
wireDelegation();
var scope = root && root.querySelector ? root : document;
var catSel = queryInScope(scope, "order-entry-category");
var modelSel = queryInScope(scope, "order-entry-model");
if (!catSel || !modelSel) return;
var presetCode = modelSel.getAttribute("data-preset-code") || modelSel.value || "";
if (presetCode) {
var catMap = codeToCategoryMap();
var catKey = catMap[presetCode];
if (catKey) {
catSel.value = catKey;
rebuildEntryModelSubSelect(presetCode, scope);
return;
}
}
rebuildEntryModelSubSelect("", scope);
}
global.paintOrderLeverageHint = function (leverage) {
var hidden = document.getElementById("order-leverage");
var hint = document.getElementById("order-leverage-hint");
if (!hidden && !hint) return;
var lev = parseInt(leverage, 10);
if (!Number.isFinite(lev) || lev <= 0) {
if (hint) hint.textContent = "杠杆 —";
if (hidden) hidden.value = "";
return;
}
if (hidden) hidden.value = String(lev);
if (hint) hint.textContent = "杠杆 " + lev + "x";
};
global.initOrderEntryModelSelect = initOrderEntryModelSelect;
global.syncOrderEntryModelTradeStyle = syncOrderEntryModelTradeStyle;
global.rebuildEntryModelSubSelect = rebuildEntryModelSubSelect;
wireDelegation();
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
initOrderEntryModelSelect();
});
} else {
initOrderEntryModelSelect();
}
})(typeof window !== "undefined" ? window : globalThis);
+641
View File
@@ -0,0 +1,641 @@
/**
* 三所 /records:交易记录分页 + 复盘表单显隐 + 复盘/AI 列表分页(soft,每页5).
*/
(function (global) {
"use strict";
var PAGE_SIZE = 5;
var tradesPage = 0;
var tradesPages = 1;
var journalsAll = [];
var journalsPage = 0;
var journalsPages = 1;
var reviewsAll = [];
var reviewsPage = 0;
var reviewsPages = 1;
var tradesCache = {};
var booted = false;
function $(id) {
return document.getElementById(id);
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function listQs() {
if (typeof global.listWindowQueryString === "function") {
return global.listWindowQueryString() || "";
}
return "";
}
function fmtNum(v, digits) {
if (v == null || v === "") return "—";
var n = Number(v);
if (!Number.isFinite(n)) return esc(v);
return n.toFixed(digits == null ? 2 : digits);
}
/** 优先用后端交易所精度字符串;否则回退量级格式(与 formatPriceForInput 一致). */
function fmtPx(display, raw) {
if (display != null && display !== "") return esc(display);
if (raw == null || raw === "") return "—";
var n = Number(raw);
if (!Number.isFinite(n)) return esc(raw);
var av = Math.abs(n);
var d;
if (av >= 10000) d = 2;
else if (av >= 100) d = 3;
else if (av >= 1) d = 4;
else if (av >= 0.01) d = 6;
else if (av >= 0.0001) d = 8;
else d = 10;
var text = n.toFixed(d);
if (text.indexOf(".") >= 0) text = text.replace(/\.?0+$/, "");
return text;
}
function fmtTime(s) {
if (!s) return "—";
return esc(String(s).slice(0, 16));
}
function resultBadge(result) {
var er = String(result || "").trim();
if (["止盈", "保本止盈", "移动止盈"].indexOf(er) >= 0) {
return '<span class="badge profit">' + esc(er) + "</span>";
}
if (["止损", "强制清仓", "手动平仓"].indexOf(er) >= 0) {
return '<span class="badge loss">' + esc(er) + "</span>";
}
if (er === "时间平仓") return '<span class="badge miss">' + esc(er) + "</span>";
return '<span class="badge">' + esc(er || "-") + "</span>";
}
function pnlClass(v) {
var n = Number(v);
if (!Number.isFinite(n) || n === 0) return "";
return n > 0 ? "pnl-profit" : "pnl-loss";
}
function beginSoft(wrapId, soft) {
var wrap = $(wrapId);
if (!wrap) return null;
if (soft) {
if (!wrap.style.minHeight) {
wrap.style.minHeight = Math.max(wrap.offsetHeight, 1) + "px";
}
wrap.classList.add("rr-list-loading");
} else {
wrap.classList.remove("rr-list-loading");
wrap.style.minHeight = "";
}
return wrap;
}
function endSoft(wrap) {
if (!wrap) return;
wrap.classList.remove("rr-list-loading");
wrap.style.minHeight = "";
}
function updatePager(kind) {
var map = {
trades: {
page: tradesPage,
pages: tradesPages,
label: "rr-trades-page-label",
prev: "rr-trades-prev",
next: "rr-trades-next",
},
journals: {
page: journalsPage,
pages: journalsPages,
label: "rr-journals-page-label",
prev: "rr-journals-prev",
next: "rr-journals-next",
},
reviews: {
page: reviewsPage,
pages: reviewsPages,
label: "rr-reviews-page-label",
prev: "rr-reviews-prev",
next: "rr-reviews-next",
},
};
var m = map[kind];
if (!m) return;
var label = $(m.label);
var prev = $(m.prev);
var next = $(m.next);
if (label) label.textContent = "第 " + (m.page + 1) + " / " + m.pages + " 页";
if (prev) prev.disabled = m.page <= 0;
if (next) next.disabled = m.page + 1 >= m.pages;
}
function fillPayload(t) {
return {
symbol: t.symbol,
monitor_type: t.monitor_type,
key_signal_type: t.key_signal_type || "",
direction: t.direction,
trigger_price: t.trigger_price,
stop_loss: t.display_open_stop_loss || t.initial_stop_loss || t.stop_loss,
take_profit: t.effective_take_profit || t.take_profit,
opened_at: t.effective_opened_at,
closed_at: t.effective_closed_at,
pnl_amount: t.effective_pnl_amount,
result: t.effective_result,
risk_amount: t.risk_amount,
effective_entry_reason: t.effective_entry_reason || "",
};
}
function editPayload(t) {
return {
id: t.id,
opened_at: t.effective_opened_at,
closed_at: t.effective_closed_at,
stop_loss: t.effective_stop_loss || t.initial_stop_loss || t.stop_loss,
take_profit: t.effective_take_profit || t.take_profit,
pnl_amount: t.effective_pnl_amount,
result: t.effective_result,
miss_reason: t.effective_miss_reason,
effective_entry_reason: t.effective_entry_reason || "",
};
}
function renderTradesRows(rows) {
var tbody = $("rr-trades-tbody");
if (!tbody) return;
tradesCache = {};
if (!rows || !rows.length) {
tbody.innerHTML = '<tr><td colspan="15" class="muted">暂无交易记录</td></tr>';
return;
}
tbody.innerHTML = rows
.map(function (t) {
tradesCache[t.id] = t;
var mon = esc(t.monitor_type || "");
if (t.key_signal_type) mon += " · " + esc(t.key_signal_type);
var stopShow = t.display_open_stop_loss || t.initial_stop_loss || t.stop_loss;
var tpShow = t.effective_take_profit || t.take_profit;
var pnl = t.effective_pnl_amount;
var pnlSrc = "";
if (t.display_pnl_source === "exchange") {
pnlSrc = '<span style="font-size:.68rem;color:#6ab88a">所</span>';
} else if (t.display_pnl_source !== "reviewed") {
pnlSrc = '<span style="font-size:.68rem;color:#8892b0">估</span>';
}
var dirCls = t.direction === "long" ? "direction-long" : "direction-short";
var dirTxt = t.direction === "long" ? "做多" : "做空";
var margin =
t.margin_capital != null && t.margin_capital !== ""
? fmtNum(t.margin_capital, 2)
: "-";
return (
'<tr id="trade-row-' +
esc(t.id) +
'">' +
"<td>" +
esc(t.symbol) +
"</td>" +
"<td>" +
mon +
"</td>" +
"<td>" +
esc(t.effective_entry_reason || "-") +
"</td>" +
'<td><span class="badge ' +
dirCls +
'">' +
dirTxt +
"</span></td>" +
"<td>" +
fmtPx(t.trigger_price_display, t.trigger_price) +
"</td>" +
"<td>" +
fmtPx(t.stop_loss_display, stopShow) +
"</td>" +
"<td>" +
fmtPx(t.take_profit_display, tpShow) +
"</td>" +
"<td>" +
margin +
"</td>" +
"<td>" +
esc(t.leverage != null ? t.leverage : "-") +
"</td>" +
"<td>" +
esc(t.effective_hold_minutes || 0) +
"</td>" +
"<td>" +
fmtTime(t.effective_opened_at) +
"</td>" +
"<td>" +
fmtTime(t.effective_closed_at || t.created_at) +
"</td>" +
'<td><span class="' +
pnlClass(pnl) +
'">' +
fmtNum(pnl, 2) +
"</span>" +
pnlSrc +
"</td>" +
"<td>" +
resultBadge(t.effective_result) +
"</td>" +
"<td>" +
'<button type="button" class="table-del rr-fill-btn" style="background:#1f3a5a;color:#8fc8ff;margin-right:6px" data-id="' +
esc(t.id) +
'">填入复盘</button> ' +
'<button type="button" class="table-del review-edit-btn" style="background:#1f3a5a;color:#8fc8ff;margin-right:6px" data-id="' +
esc(t.id) +
'" disabled>核对修改</button> ' +
'<button type="button" class="table-del" onclick="deleteTradeRecord(' +
Number(t.id) +
')">删除</button>' +
"</td>" +
"</tr>"
);
})
.join("");
tbody.querySelectorAll(".rr-fill-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
var id = btn.getAttribute("data-id");
var t = tradesCache[id];
if (!t) return;
showJournalCard();
if (typeof global.fillJournalFromTrade === "function") {
global.fillJournalFromTrade(fillPayload(t));
}
});
});
tbody.querySelectorAll(".review-edit-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
var id = btn.getAttribute("data-id");
var t = tradesCache[id];
if (!t) return;
if (typeof global.editTradeRecordReview === "function") {
global.editTradeRecordReview(editPayload(t));
}
});
});
if (typeof global.toggleReviewMode === "function") {
global.toggleReviewMode();
}
}
function loadTradeRecords(opts) {
opts = opts || {};
var soft = !!opts.soft;
var tbody = $("rr-trades-tbody");
if (!tbody) return;
var wrap = beginSoft("rr-trades-wrap", soft);
if (!soft) {
tbody.innerHTML = '<tr><td colspan="15" class="muted">加载中…</td></tr>';
}
var qs = listQs();
var p = new URLSearchParams(qs || "");
p.set("limit", String(PAGE_SIZE));
p.set("offset", String(tradesPage * PAGE_SIZE));
fetch("/api/trade_records?" + p.toString(), { credentials: "same-origin" })
.then(function (r) {
return r.json();
})
.then(function (data) {
if (!data || !data.ok) {
tbody.innerHTML = '<tr><td colspan="15" class="muted">加载失败</td></tr>';
endSoft(wrap);
return;
}
tradesPages = Math.max(1, Number(data.pages) || 1);
if (tradesPage >= tradesPages) {
tradesPage = Math.max(0, tradesPages - 1);
updatePager("trades");
if (Number(data.total || 0) > 0) {
loadTradeRecords(opts);
return;
}
}
updatePager("trades");
renderTradesRows(data.items || []);
endSoft(wrap);
})
.catch(function () {
tbody.innerHTML = '<tr><td colspan="15" class="muted">加载失败</td></tr>';
endSoft(wrap);
});
}
function renderJournalsPage(soft) {
var box = $("journal-list");
if (!box) return;
var wrap = beginSoft("journal-list-wrap", soft);
var total = journalsAll.length;
journalsPages = Math.max(1, Math.ceil(total / PAGE_SIZE) || 1);
if (journalsPage >= journalsPages) journalsPage = Math.max(0, journalsPages - 1);
updatePager("journals");
var hint = $("rr-journals-hint");
if (hint) {
hint.textContent =
total > 0
? "已保存的复盘(共" + total + "条,每页5条)."
: "已保存的复盘(每页5条).";
}
var slice = journalsAll.slice(
journalsPage * PAGE_SIZE,
journalsPage * PAGE_SIZE + PAGE_SIZE
);
if (global.InstanceUI && typeof InstanceUI.renderJournalListHtml === "function") {
var html = InstanceUI.renderJournalListHtml(slice);
box.innerHTML = html || "<div class='journal-empty-msg'>暂无数据</div>";
} else {
box.innerHTML = "<div class='journal-empty-msg'>暂无数据</div>";
}
endSoft(wrap);
}
function renderReviewsPage(soft) {
var box = $("review-list");
if (!box) return;
var wrap = beginSoft("review-list-wrap", soft);
var total = reviewsAll.length;
reviewsPages = Math.max(1, Math.ceil(total / PAGE_SIZE) || 1);
if (reviewsPage >= reviewsPages) reviewsPage = Math.max(0, reviewsPages - 1);
updatePager("reviews");
var slice = reviewsAll.slice(
reviewsPage * PAGE_SIZE,
reviewsPage * PAGE_SIZE + PAGE_SIZE
);
if (!slice.length) {
box.innerHTML = "<div class='entry'>暂无数据</div>";
endSoft(wrap);
return;
}
var html = "";
slice.forEach(function (r) {
if (global.reviewCache) global.reviewCache[r.id] = r;
var preview = (r.content || "").replace(/\s+/g, " ").trim();
var shortText = preview.length > 90 ? preview.slice(0, 90) + "..." : preview;
html +=
'<div class="entry">' +
"<div><strong>" +
(r.review_type === "daily" ? "日复盘" : "周复盘") +
"</strong> | " +
esc(r.target_date) +
"</div>" +
'<div style="font-size:12px;color:#9aa">' +
esc(r.created_at || "") +
"</div>" +
'<div style="margin-top:4px;color:#c9d2ff">' +
esc(shortText || "(空)") +
"</div>" +
'<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:6px">' +
'<button type="button" class="btn-del" style="border:none;cursor:pointer;background:#1f3a5a;color:#8fc8ff" onclick="openReviewDetail(\'' +
esc(r.id) +
"', false)\">查看</button>" +
'<button type="button" class="btn-del" style="border:none;cursor:pointer;background:#1f3a5a;color:#8fc8ff" onclick="openReviewDetail(\'' +
esc(r.id) +
"', true)\">全屏</button>" +
'<a class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff" href="/export/review_md/' +
esc(r.id) +
'">导出MD</a>' +
'<button type="button" class="btn-del" onclick="deleteReview(\'' +
esc(r.id) +
"')\">删除</button>" +
"</div></div>";
});
box.innerHTML = html;
endSoft(wrap);
}
function loadJournalsPaged() {
var qs = listQs();
fetch("/api/journals" + (qs ? "?" + qs : ""), { credentials: "same-origin" })
.then(function (r) {
return r.json();
})
.then(function (data) {
journalsAll = Array.isArray(data) ? data : [];
if (global.journalCache) {
Object.keys(global.journalCache).forEach(function (k) {
delete global.journalCache[k];
});
journalsAll.forEach(function (o) {
global.journalCache[o.id] = o;
});
}
journalsPage = 0;
renderJournalsPage(false);
});
}
function loadReviewsPaged() {
var qs = listQs();
fetch("/api/reviews" + (qs ? "?" + qs : ""), { credentials: "same-origin" })
.then(function (r) {
return r.json();
})
.then(function (data) {
reviewsAll = Array.isArray(data) ? data : [];
if (global.reviewCache) {
Object.keys(global.reviewCache).forEach(function (k) {
delete global.reviewCache[k];
});
} else {
global.reviewCache = {};
}
reviewsAll.forEach(function (r) {
global.reviewCache[r.id] = r;
});
reviewsPage = 0;
renderReviewsPage(false);
});
}
function showJournalCard() {
var card = $("journal-card");
if (card) card.classList.remove("hidden");
var hint = $("rr-journal-fill-hint");
if (hint) hint.style.display = "";
}
function hideJournalCard() {
var card = $("journal-card");
if (card) card.classList.add("hidden");
var hint = $("rr-journal-fill-hint");
if (hint) hint.style.display = "none";
}
function patchFillJournalFromTrade() {
var prev = global.fillJournalFromTrade;
if (typeof prev !== "function") return;
if (prev.__rrPatched) return;
global.fillJournalFromTrade = function (t) {
showJournalCard();
prev(t);
var hint = $("rr-journal-fill-hint");
if (hint) hint.style.display = "";
};
global.fillJournalFromTrade.__rrPatched = true;
}
function patchDeleteTradeRecord() {
var prev = global.deleteTradeRecord;
if (typeof prev !== "function") return;
if (prev.__rrPatched) return;
global.deleteTradeRecord = function (id) {
if (!confirm("确定删除这条交易记录?")) return;
fetch("/delete_trade_record/" + id, { method: "POST", credentials: "same-origin" })
.then(function (r) {
return r.json();
})
.then(function (data) {
if (data && data.ok) {
loadTradeRecords({ soft: true });
return;
}
if (typeof prev === "function") {
/* fallthrough reload */
}
global.location.href =
(global.location.pathname || "/records") + "?_ts=" + Date.now();
})
.catch(function () {
global.location.href =
(global.location.pathname || "/records") + "?_ts=" + Date.now();
});
};
global.deleteTradeRecord.__rrPatched = true;
}
function bindPagers() {
var tp = $("rr-trades-prev");
var tn = $("rr-trades-next");
var jp = $("rr-journals-prev");
var jn = $("rr-journals-next");
var rp = $("rr-reviews-prev");
var rn = $("rr-reviews-next");
var hideBtn = $("rr-journal-hide-btn");
if (tp) {
tp.addEventListener("click", function (ev) {
ev.preventDefault();
if (tradesPage <= 0) return;
tradesPage -= 1;
updatePager("trades");
loadTradeRecords({ soft: true });
});
}
if (tn) {
tn.addEventListener("click", function (ev) {
ev.preventDefault();
if (tradesPage + 1 >= tradesPages) return;
tradesPage += 1;
updatePager("trades");
loadTradeRecords({ soft: true });
});
}
if (jp) {
jp.addEventListener("click", function (ev) {
ev.preventDefault();
if (journalsPage <= 0) return;
journalsPage -= 1;
renderJournalsPage(true);
});
}
if (jn) {
jn.addEventListener("click", function (ev) {
ev.preventDefault();
if (journalsPage + 1 >= journalsPages) return;
journalsPage += 1;
renderJournalsPage(true);
});
}
if (rp) {
rp.addEventListener("click", function (ev) {
ev.preventDefault();
if (reviewsPage <= 0) return;
reviewsPage -= 1;
renderReviewsPage(true);
});
}
if (rn) {
rn.addEventListener("click", function (ev) {
ev.preventDefault();
if (reviewsPage + 1 >= reviewsPages) return;
reviewsPage += 1;
renderReviewsPage(true);
});
}
if (hideBtn) {
hideBtn.addEventListener("click", function (ev) {
ev.preventDefault();
hideJournalCard();
});
}
}
function init(opts) {
opts = opts || {};
if (!$("records-panel-root")) return;
var tbody = $("rr-trades-tbody");
var stuckLoading =
!!tbody &&
tbody.querySelectorAll("tr").length <= 1 &&
/加载中/.test(String(tbody.textContent || ""));
if (booted) {
if (opts.refresh || stuckLoading) {
loadTradeRecords({ soft: !stuckLoading });
loadJournalsPaged();
loadReviewsPaged();
}
patchFillJournalFromTrade();
patchDeleteTradeRecord();
if (global.JournalFormSave && typeof global.JournalFormSave.init === "function") {
global.JournalFormSave.init();
}
return;
}
booted = true;
if (!global.journalCache) global.journalCache = {};
if (!global.reviewCache) global.reviewCache = {};
global.loadJournals = loadJournalsPaged;
global.loadReviews = loadReviewsPaged;
global.loadTradeRecords = loadTradeRecords;
patchFillJournalFromTrade();
patchDeleteTradeRecord();
bindPagers();
updatePager("trades");
updatePager("journals");
updatePager("reviews");
if (global.JournalFormSave && typeof global.JournalFormSave.init === "function") {
global.JournalFormSave.init();
}
loadTradeRecords({ soft: false });
loadJournalsPaged();
loadReviewsPaged();
}
global.RecordsReviewPage = {
init: init,
loadTradeRecords: loadTradeRecords,
loadJournals: loadJournalsPaged,
loadReviews: loadReviewsPaged,
showJournalCard: showJournalCard,
hideJournalCard: hideJournalCard,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})(typeof window !== "undefined" ? window : globalThis);
+169
View File
@@ -0,0 +1,169 @@
/**
* 表单币种输入:防抖 + 定时刷新,展示交易所最新价(/api/order_defaults).
*/
(function (global) {
"use strict";
const DEFAULT_DEBOUNCE_MS = 350;
const DEFAULT_POLL_MS = 5000;
const bound = new WeakSet();
function $(id) {
return id ? document.getElementById(id) : null;
}
function symbolValue(el) {
if (!el) return "";
return (el.value || "").trim();
}
function directionValue(dirId) {
const el = dirId ? $(dirId) : null;
const v = (el && el.value ? el.value : "long").trim().toLowerCase();
return v === "short" ? "short" : "long";
}
function formatPrice(px, sym) {
const n = Number(px);
if (!Number.isFinite(n)) return "—";
const u = (sym || "").trim().toUpperCase();
let digits = 4;
if (u.startsWith("BTC") || u.startsWith("ETH") || n >= 1000) digits = 2;
else if (n >= 10) digits = 3;
else if (n >= 1) digits = 4;
else if (n >= 0.01) digits = 5;
else digits = 6;
return n.toFixed(digits);
}
function pollMs() {
const raw =
(document.body && document.body.getAttribute("data-price-refresh-ms")) || "";
const n = Number(raw);
return Number.isFinite(n) && n >= 2000 ? n : DEFAULT_POLL_MS;
}
function paint(el, sym, px, err) {
if (!el) return;
if (err) {
el.textContent = "现价:—";
el.classList.add("symbol-live-price--err");
el.classList.remove("symbol-live-price--ok");
el.title = err;
return;
}
if (px === null || typeof px === "undefined") {
el.textContent = "现价:—";
el.classList.remove("symbol-live-price--ok", "symbol-live-price--err");
el.title = sym ? "无法读取交易所价格" : "";
return;
}
const label = sym ? sym.toUpperCase().replace(/\/USDT.*/, "") : "";
el.textContent = label ? label + " 现价 " + formatPrice(px, sym) : "现价 " + formatPrice(px, sym);
el.classList.add("symbol-live-price--ok");
el.classList.remove("symbol-live-price--err");
el.title = "交易所最新价(约 " + pollMs() / 1000 + "s 刷新)";
}
function bindOne(el) {
if (!el || bound.has(el)) return;
bound.add(el);
const symId = el.getAttribute("data-symbol-input");
const dirId = el.getAttribute("data-direction-input") || "";
let debounceTimer = null;
let pollTimer = null;
let fetchSeq = 0;
function clearPoll() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function startPoll() {
clearPoll();
pollTimer = setInterval(refresh, pollMs());
}
function refresh() {
const symEl = $(symId);
const sym = symbolValue(symEl);
if (!sym) {
paint(el, "", null, "");
clearPoll();
return;
}
const dir = directionValue(dirId);
const seq = ++fetchSeq;
el.classList.add("symbol-live-price--loading");
fetch(
"/api/order_defaults?symbol=" +
encodeURIComponent(sym) +
"&direction=" +
encodeURIComponent(dir)
)
.then(function (r) {
return r.json().then(function (d) {
return { status: r.status, data: d };
}).catch(function () {
return { status: r.status, data: null };
});
})
.then(function (res) {
if (seq !== fetchSeq) return;
el.classList.remove("symbol-live-price--loading");
const data = res.data || {};
if (res.status >= 400 || !data || !data.ok) {
paint(el, sym, null, (data && data.msg) || "读取失败");
return;
}
const px = data.last_price != null ? data.last_price : data.price;
if (px === null || typeof px === "undefined") {
paint(el, data.symbol || sym, null, "无法读取交易所价格");
return;
}
paint(el, data.symbol || sym, px, "");
if (!pollTimer) startPoll();
})
.catch(function () {
if (seq !== fetchSeq) return;
el.classList.remove("symbol-live-price--loading");
paint(el, sym, null, "网络错误");
});
}
function schedule() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(refresh, DEFAULT_DEBOUNCE_MS);
}
const symEl = $(symId);
if (symEl) {
symEl.addEventListener("input", schedule);
symEl.addEventListener("change", schedule);
}
const dirEl = dirId ? $(dirId) : null;
if (dirEl) {
dirEl.addEventListener("change", schedule);
}
schedule();
}
function init(root) {
const scope = root || document;
scope.querySelectorAll(".symbol-live-price").forEach(bindOne);
}
global.SymbolLivePrice = { init: init, bind: bindOne };
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
init(document);
});
} else {
init(document);
}
})(typeof window !== "undefined" ? window : globalThis);
+194
View File
@@ -0,0 +1,194 @@
/**
* 时间平仓 + 整点强制清仓:表单开关 + 持仓/顶栏倒计时.
*/
(function (global) {
"use strict";
function pad2(n) {
return n < 10 ? "0" + n : String(n);
}
function formatCountdown(sec) {
const s = Math.max(0, parseInt(sec, 10) || 0);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const r = s % 60;
return pad2(h) + ":" + pad2(m) + ":" + pad2(r);
}
function isForceCloseActive(wrap) {
if (!wrap) return false;
const raw =
wrap.dataset.forceCloseActive ||
wrap.getAttribute("data-force-close-active") ||
"";
return raw === "1" || raw === "true";
}
function bindTimeCloseForm(checkboxId, selectId, wrapId) {
const cb = document.getElementById(checkboxId);
const sel = document.getElementById(selectId);
const wrap = wrapId ? document.getElementById(wrapId) : null;
if (!cb || !sel) return;
function sync() {
const on = !!cb.checked;
sel.disabled = false;
sel.tabIndex = 0;
if (wrap) wrap.classList.toggle("is-disabled", !on);
}
sel.addEventListener("mousedown", function (ev) {
ev.stopPropagation();
});
sel.addEventListener("click", function (ev) {
ev.stopPropagation();
});
cb.addEventListener("change", sync);
sync();
}
function paintCountdownEl(cd, rem, active) {
if (!cd) return;
if (active) {
cd.textContent = "执行中";
return;
}
cd.textContent = Number.isFinite(rem) ? formatCountdown(rem) : "--:--:--";
}
function paintOrderTimeClose(order) {
if (!order || order.id == null) return;
const wrap = document.getElementById("order-time-close-wrap-" + order.id);
const cd = document.getElementById("order-time-close-cd-" + order.id);
if (!wrap || !cd) return;
const enabled = !!(order.time_close_enabled || order.time_close_at_ms);
if (!enabled) {
wrap.style.display = "none";
return;
}
wrap.style.display = "";
const hours = order.time_close_hours;
const label = order.time_close_label || (hours ? "时间平仓 " + hours + "h" : "时间平仓");
const labelEl = wrap.querySelector(".pos-time-close-label");
if (labelEl) labelEl.textContent = label;
let rem =
order.time_close_remaining_sec != null
? Number(order.time_close_remaining_sec)
: null;
if ((rem == null || !Number.isFinite(rem)) && order.time_close_at_ms) {
rem = Math.max(0, Math.floor((Number(order.time_close_at_ms) - Date.now()) / 1000));
}
paintCountdownEl(cd, rem, false);
wrap.dataset.closeAtMs = order.time_close_at_ms ? String(order.time_close_at_ms) : "";
}
function paintOrderForceClose(order) {
if (!order || order.id == null) return;
const wrap = document.getElementById("order-force-close-wrap-" + order.id);
const cd = document.getElementById("order-force-close-cd-" + order.id);
if (!wrap || !cd) return;
const enabled = !!order.force_close_enabled;
if (!enabled) {
wrap.style.display = "none";
return;
}
wrap.style.display = "";
const label = order.force_close_label || "强制清仓";
const labelEl = wrap.querySelector(".pos-force-close-label");
if (labelEl) labelEl.textContent = label;
let rem =
order.force_close_remaining_sec != null
? Number(order.force_close_remaining_sec)
: null;
const atMs = order.force_close_at_ms;
if ((rem == null || !Number.isFinite(rem)) && atMs) {
rem = Math.max(0, Math.floor((Number(atMs) - Date.now()) / 1000));
}
const active = !!order.force_close_active;
paintCountdownEl(cd, rem, active);
wrap.dataset.forceCloseAtMs = atMs ? String(atMs) : "";
wrap.dataset.forceCloseActive = active ? "1" : "0";
}
function paintForceCloseHeader(state) {
const wrap = document.getElementById("force-close-header-badge");
if (!wrap) return;
if (!state || !state.enabled) {
wrap.style.display = "none";
return;
}
wrap.style.display = "";
const label = state.label || "强制清仓";
const labelPrefix = label + " 已开启 · ";
let prefixNode = wrap.querySelector(".force-close-header-prefix");
if (!prefixNode) {
wrap.textContent = "";
prefixNode = document.createElement("span");
prefixNode.className = "force-close-header-prefix";
prefixNode.textContent = labelPrefix;
wrap.appendChild(prefixNode);
const cd = document.createElement("span");
cd.className = "force-close-header-cd";
wrap.appendChild(cd);
} else {
prefixNode.textContent = labelPrefix;
}
const cd = wrap.querySelector(".force-close-header-cd");
let rem = state.remaining_sec != null ? Number(state.remaining_sec) : null;
if ((rem == null || !Number.isFinite(rem)) && state.next_at_ms) {
rem = Math.max(0, Math.floor((Number(state.next_at_ms) - Date.now()) / 1000));
}
paintCountdownEl(cd, rem, !!state.active);
wrap.dataset.forceCloseAtMs = state.next_at_ms ? String(state.next_at_ms) : "";
wrap.dataset.forceCloseActive = state.active ? "1" : "0";
}
function tickLocalCountdowns() {
document.querySelectorAll("[data-close-at-ms]").forEach(function (wrap) {
const closeAtRaw = wrap.dataset.closeAtMs || wrap.getAttribute("data-close-at-ms") || "";
const cd = wrap.querySelector(".pos-time-close-cd");
if (!cd) return;
const closeAt = Number(closeAtRaw);
if (!closeAt) return;
const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000));
cd.textContent = formatCountdown(rem);
});
document.querySelectorAll("[data-force-close-at-ms]").forEach(function (wrap) {
const closeAtRaw =
wrap.dataset.forceCloseAtMs || wrap.getAttribute("data-force-close-at-ms") || "";
const cd = wrap.querySelector(".pos-force-close-cd, .force-close-header-cd");
if (!cd) return;
const closeAt = Number(closeAtRaw);
if (!closeAt) return;
const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000));
paintCountdownEl(cd, rem, isForceCloseActive(wrap));
});
}
function paintOrders(orders) {
(orders || []).forEach(function (order) {
paintOrderTimeClose(order);
paintOrderForceClose(order);
});
}
function syncKeyTimeCloseVisibility(show) {
const wrap = document.getElementById("key-time-close-wrap");
if (!wrap) return;
wrap.style.display = show ? "inline-flex" : "none";
}
global.TimeCloseUI = {
bindTimeCloseForm: bindTimeCloseForm,
paintOrderTimeClose: paintOrderTimeClose,
paintOrderForceClose: paintOrderForceClose,
paintForceCloseHeader: paintForceCloseHeader,
paintOrders: paintOrders,
tickLocalCountdowns: tickLocalCountdowns,
syncKeyTimeCloseVisibility: syncKeyTimeCloseVisibility,
formatCountdown: formatCountdown,
};
if (!global.__timeCloseCountdownTimer) {
global.__timeCloseCountdownTimer = setInterval(tickLocalCountdowns, 1000);
}
})(typeof window !== "undefined" ? window : globalThis);
+171
View File
@@ -0,0 +1,171 @@
/* 交易日历:内照明心 + 三所统计分析共用,随 data-theme 浅/深切换 */
.trade-cal-wrap {
--trade-cal-wrap-bg: var(--inset-surface, rgba(0, 0, 0, 0.22));
--trade-cal-cell-bg: var(--section-surface, var(--inset-surface, rgba(0, 0, 0, 0.32)));
--trade-cal-cell-border: rgba(255, 255, 255, 0.14);
--trade-cal-cell-shadow: 0 1px 3px rgba(0, 0, 0, 0.22);
--trade-cal-cell-empty-bg: color-mix(in srgb, var(--trade-cal-cell-bg) 72%, transparent);
--trade-cal-cell-hover-bg: color-mix(in srgb, var(--accent, #6366f1) 12%, var(--trade-cal-cell-bg));
--trade-cal-cell-hover-border: color-mix(in srgb, var(--accent, #6366f1) 45%, transparent);
--trade-cal-selected-border: rgba(59, 130, 246, 0.85);
--trade-cal-selected-bg: color-mix(in srgb, #3b82f6 16%, var(--trade-cal-cell-bg));
--trade-cal-selected-shadow: rgba(59, 130, 246, 0.45);
--trade-cal-sick-bg: color-mix(in srgb, var(--red, #ef4444) 14%, var(--trade-cal-cell-bg));
--trade-cal-sick-border: color-mix(in srgb, var(--red, #ef4444) 55%, transparent);
--trade-cal-sick-shadow: color-mix(in srgb, var(--red, #ef4444) 45%, transparent);
--trade-cal-sick-tag-bg: color-mix(in srgb, var(--red, #ef4444) 25%, transparent);
--trade-cal-sick-tag-fg: color-mix(in srgb, var(--red, #ef4444) 70%, #fff);
--trade-cal-pos: var(--green, #22c55e);
--trade-cal-neg: var(--red, #ef4444);
margin-top: 4px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border-soft, rgba(120, 140, 200, 0.28));
background: var(--trade-cal-wrap-bg);
}
.stats-calendar-wrap {
margin-bottom: 14px;
}
.trade-cal-wrap button.trade-cal-cell {
background: var(--trade-cal-cell-bg) !important;
background-image: none !important;
border: 1px solid var(--trade-cal-cell-border);
box-shadow: var(--trade-cal-cell-shadow);
padding: 6px 4px;
min-height: 72px;
width: 100%;
line-height: 1.15;
font-size: inherit;
text-align: center;
}
.trade-cal-wrap button.trade-cal-cell:not(.has-trade) {
background: var(--trade-cal-cell-empty-bg) !important;
cursor: default;
}
.trade-cal-wrap button.trade-cal-cell:disabled {
opacity: 1;
cursor: default;
}
.trade-cal-wrap .trade-cal-head .btn,
.trade-cal-wrap .trade-cal-head button {
min-height: 0;
min-width: 34px;
padding: 4px 12px;
line-height: 1.2;
}
.trade-cal-head {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.trade-cal-title {
font-size: 0.95rem;
font-weight: 600;
min-width: 120px;
text-align: center;
color: var(--text, #e8ecff);
}
.trade-cal-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 4px;
}
.trade-cal-wd {
text-align: center;
font-size: 0.72rem;
color: var(--muted, #8892b0);
}
.trade-cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
}
.trade-cal-cell {
min-height: 72px;
padding: 6px 4px;
border-radius: 8px;
border: 1px solid var(--trade-cal-cell-border);
box-shadow: var(--trade-cal-cell-shadow);
background: var(--trade-cal-cell-bg);
color: inherit;
font: inherit;
cursor: default;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 2px;
}
.trade-cal-cell.has-trade {
cursor: pointer;
}
.trade-cal-wrap button.trade-cal-cell.has-trade:hover {
background: var(--trade-cal-cell-hover-bg) !important;
background-image: none !important;
border-color: var(--trade-cal-cell-hover-border);
}
.trade-cal-cell.is-selected {
border-color: var(--trade-cal-selected-border);
background: var(--trade-cal-selected-bg);
box-shadow: 0 0 0 2px var(--trade-cal-selected-shadow);
}
.trade-cal-cell.is-sick-day {
border-color: var(--trade-cal-sick-border);
background: var(--trade-cal-sick-bg);
}
.trade-cal-cell.is-sick-day.is-selected {
border-color: var(--trade-cal-selected-border);
background: color-mix(in srgb, #3b82f6 14%, var(--trade-cal-sick-bg));
box-shadow: 0 0 0 2px var(--trade-cal-selected-shadow);
}
.trade-cal-day-num {
font-size: 0.78rem;
font-weight: 600;
color: var(--text, #e8ecff);
}
.trade-cal-pnl {
font-size: 0.72rem;
font-weight: 600;
line-height: 1.1;
color: var(--text, #e8ecff);
}
.trade-cal-cell.pnl-pos .trade-cal-pnl {
color: var(--trade-cal-pos);
}
.trade-cal-cell.pnl-neg .trade-cal-pnl {
color: var(--trade-cal-neg);
}
.trade-cal-cnt {
font-size: 0.65rem;
color: var(--muted, #8892b0);
font-weight: 500;
}
.trade-cal-sick-tag {
font-size: 0.62rem;
padding: 1px 4px;
border-radius: 4px;
background: var(--trade-cal-sick-tag-bg);
color: var(--trade-cal-sick-tag-fg);
font-weight: 600;
}
.trade-cal-pad {
background: transparent;
border: none;
min-height: 0;
}
html[data-theme="light"] .trade-cal-wrap {
--trade-cal-wrap-bg: var(--inset-surface, #eef3f8);
--trade-cal-cell-bg: #ffffff;
--trade-cal-cell-empty-bg: #f6f9fc;
--trade-cal-cell-border: rgba(0, 75, 115, 0.18);
--trade-cal-cell-shadow: 0 1px 4px rgba(30, 60, 100, 0.08);
--trade-cal-cell-hover-bg: color-mix(in srgb, var(--accent, #2563eb) 10%, #ffffff);
--trade-cal-selected-border: rgba(37, 99, 235, 0.75);
--trade-cal-selected-bg: color-mix(in srgb, #2563eb 12%, #ffffff);
--trade-cal-selected-shadow: rgba(37, 99, 235, 0.35);
--trade-cal-sick-tag-fg: #b91c1c;
}
+314
View File
@@ -0,0 +1,314 @@
/**
* 交易日历组件:内照明心档案 + 三所统计分析共用.
*/
(function (global) {
"use strict";
var WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function monthLabel(y, m) {
return y + "年" + m + "月";
}
function formatCalPnl(pnl) {
var n = Number(pnl);
if (!Number.isFinite(n)) n = 0;
return (n >= 0 ? "+" : "") + n.toFixed(1) + "U";
}
function dayHasTrade(info) {
if (!info) return false;
var cnt = Number(info.open_count);
if (Number.isFinite(cnt) && cnt > 0) return true;
var pnl = Number(info.pnl_total);
return Number.isFinite(pnl) && Math.abs(pnl) > 0.0001;
}
function dayOpenCount(info) {
var cnt = Number(info && info.open_count);
return Number.isFinite(cnt) && cnt > 0 ? cnt : 0;
}
function dayPnl(info) {
return Number(info && info.pnl_total) || 0;
}
function TradeStatsCalendar(config) {
this.gridEl = config.gridEl;
this.titleEl = config.titleEl;
this.prevBtn = config.prevBtn || null;
this.nextBtn = config.nextBtn || null;
this.apiUrl = config.apiUrl || "/api/stats/calendar";
this.buildQuery =
config.buildQuery ||
function (year, month) {
var q = new URLSearchParams();
q.set("year", String(year));
q.set("month", String(month));
return q;
};
this.parseResponse =
config.parseResponse ||
function (data) {
if (data && data.ok === false) return {};
return (data && data.days) || {};
};
this.fetchFn = config.fetchFn || null;
this.showSick = config.showSick !== false;
this.selectedDay = config.selectedDay || "";
this.onDayClick = config.onDayClick || null;
this.onMonthChange = config.onMonthChange || null;
this.year = config.year || 0;
this.month = config.month || 0;
this.days = {};
this.monthPnlTotal = 0;
this.monthOpenCount = 0;
this._navBound = false;
this._bindNav();
}
TradeStatsCalendar.prototype.ensureMonth = function (ref) {
if (this.year > 0 && this.month > 0) return;
var d;
if (ref instanceof Date) d = ref;
else if (typeof ref === "string" && ref.length >= 7) {
var p = ref.slice(0, 10).split("-");
this.year = parseInt(p[0], 10) || new Date().getFullYear();
this.month = parseInt(p[1], 10) || new Date().getMonth() + 1;
return;
} else d = new Date();
this.year = d.getFullYear();
this.month = d.getMonth() + 1;
};
TradeStatsCalendar.prototype.applyPayload = function (data) {
if (!data) return;
var y = Number(data.year);
var m = Number(data.month);
if (Number.isFinite(y) && y > 0) this.year = y;
if (Number.isFinite(m) && m > 0) this.month = m;
this.days = this.parseResponse(data) || {};
this.monthPnlTotal = Number(data.month_pnl_total) || 0;
this.monthOpenCount = Number(data.month_open_count) || 0;
if (!this.monthOpenCount) {
var self = this;
Object.keys(this.days).forEach(function (k) {
if (dayHasTrade(self.days[k])) {
self.monthOpenCount += dayOpenCount(self.days[k]);
self.monthPnlTotal += dayPnl(self.days[k]);
}
});
this.monthPnlTotal = Math.round(this.monthPnlTotal * 10000) / 10000;
}
};
function readStatsCalendarBootstrap() {
var el = document.getElementById("stats-calendar-bootstrap");
if (!el || !el.textContent) return null;
try {
return JSON.parse(el.textContent);
} catch (e) {
console.warn("[trade calendar] bootstrap parse", e);
return null;
}
}
TradeStatsCalendar.prototype.setSelectedDay = function (day) {
this.selectedDay = day || "";
this.render();
};
TradeStatsCalendar.prototype.render = function () {
if (!this.gridEl || !this.titleEl) return;
if (this.year <= 0 || this.month <= 0) this.ensureMonth(new Date());
var title = monthLabel(this.year, this.month);
if (this.monthOpenCount > 0) {
title +=
" · " + formatCalPnl(this.monthPnlTotal) + " · " + this.monthOpenCount + "笔";
}
this.titleEl.textContent = title;
var first = new Date(this.year, this.month - 1, 1);
var lastDay = new Date(this.year, this.month, 0).getDate();
var startWd = first.getDay();
var html =
'<div class="trade-cal-weekdays">' +
WEEKDAYS.map(function (w) {
return '<span class="trade-cal-wd">' + w + "</span>";
}).join("") +
'</div><div class="trade-cal-grid">';
var i;
for (i = 0; i < startWd; i++) {
html += '<span class="trade-cal-cell trade-cal-pad"></span>';
}
for (var d = 1; d <= lastDay; d++) {
var dayStr =
this.year +
"-" +
String(this.month).padStart(2, "0") +
"-" +
String(d).padStart(2, "0");
var info = this.days[dayStr];
var hasTrade = dayHasTrade(info);
var sick = this.showSick && info && info.has_sick;
var pnl = hasTrade ? dayPnl(info) : null;
var cnt = hasTrade ? dayOpenCount(info) : 0;
var cls =
"trade-cal-cell" +
(hasTrade ? " has-trade" : "") +
(sick ? " is-sick-day" : "") +
(this.selectedDay === dayStr ? " is-selected" : "") +
(pnl != null && pnl > 0.0001
? " pnl-pos"
: pnl != null && pnl < -0.0001
? " pnl-neg"
: "");
var body = '<span class="trade-cal-day-num">' + d + "</span>";
if (hasTrade) {
body +=
'<span class="trade-cal-pnl">' +
esc(formatCalPnl(pnl)) +
"</span>" +
'<span class="trade-cal-cnt">' +
cnt +
"笔</span>";
if (sick) body += '<span class="trade-cal-sick-tag">犯病</span>';
}
html +=
'<button type="button" class="' +
cls +
'" data-day="' +
dayStr +
'" data-sick="' +
(sick ? "1" : "0") +
'"' +
(hasTrade ? "" : " disabled") +
">" +
body +
"</button>";
}
html += "</div>";
this.gridEl.innerHTML = html;
var self = this;
this.gridEl.querySelectorAll(".trade-cal-cell[data-day]").forEach(function (btn) {
btn.addEventListener("click", function () {
var day = btn.getAttribute("data-day");
if (!day || !self.onDayClick) return;
self.selectedDay = day;
self.render();
self.onDayClick(day, btn.getAttribute("data-sick") === "1", self.days[day] || null);
});
});
};
TradeStatsCalendar.prototype.load = async function () {
this.ensureMonth(new Date());
this.render();
var q = this.buildQuery(this.year, this.month);
if (!q.has("year")) q.set("year", String(this.year));
if (!q.has("month")) q.set("month", String(this.month));
try {
var data;
if (this.fetchFn) {
data = await this.fetchFn(q);
} else {
var resp = await fetch(this.apiUrl + "?" + q.toString(), {
credentials: "same-origin",
});
if (!resp.ok) {
console.warn("[trade calendar] api", resp.status);
this.render();
return;
}
data = await resp.json();
}
this.applyPayload(data);
this.render();
if (this.onMonthChange) this.onMonthChange(this.year, this.month, this.days);
} catch (e) {
console.warn("[trade calendar]", e);
this.render();
}
};
TradeStatsCalendar.prototype.shiftMonth = function (delta) {
this.ensureMonth(new Date());
this.month += delta;
if (this.month > 12) {
this.month = 1;
this.year += 1;
} else if (this.month < 1) {
this.month = 12;
this.year -= 1;
}
void this.load();
};
TradeStatsCalendar.prototype._bindNav = function () {
if (this._navBound) return;
var self = this;
if (this.prevBtn) {
this.prevBtn.addEventListener("click", function () {
self.shiftMonth(-1);
});
}
if (this.nextBtn) {
this.nextBtn.addEventListener("click", function () {
self.shiftMonth(1);
});
}
this._navBound = true;
};
global.TradeStatsCalendar = TradeStatsCalendar;
global.statsCalendarWidget = null;
global.initInstanceStatsCalendar = function () {
var grid = document.getElementById("stats-calendar");
if (!grid || !global.TradeStatsCalendar) return null;
var bootstrap = readStatsCalendarBootstrap();
if (
global.statsCalendarWidget &&
global.statsCalendarWidget.gridEl === grid
) {
if (bootstrap) global.statsCalendarWidget.applyPayload(bootstrap);
global.statsCalendarWidget.render();
void global.statsCalendarWidget.load();
return global.statsCalendarWidget;
}
global.statsCalendarWidget = new TradeStatsCalendar({
gridEl: grid,
titleEl: document.getElementById("stats-cal-title"),
prevBtn: document.getElementById("stats-cal-prev"),
nextBtn: document.getElementById("stats-cal-next"),
apiUrl: "/api/stats/calendar",
showSick: false,
buildQuery: function (year, month) {
var q = new URLSearchParams();
q.set("year", String(year));
q.set("month", String(month));
var sel = document.getElementById("stats-segment-select");
if (sel) q.set("segment", sel.value || "all");
return q;
},
parseResponse: function (data) {
if (data && data.ok === false) return {};
return (data && data.days) || {};
},
});
if (bootstrap) global.statsCalendarWidget.applyPayload(bootstrap);
global.statsCalendarWidget.render();
void global.statsCalendarWidget.load();
return global.statsCalendarWidget;
};
global.initStatsCalendarWidget = global.initInstanceStatsCalendar;
})(window);
+117
View File
@@ -0,0 +1,117 @@
"""企业微信机器人 Webhook 推送(多实例共用)."""
from __future__ import annotations
import re
from typing import Optional
import requests
def strip_markdown_for_text(content: str) -> str:
s = str(content or "")
s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s)
s = re.sub(r"`([^`]+)`", r"\1", s)
s = re.sub(r"^#+\s*", "", s, flags=re.MULTILINE)
s = re.sub(r"^---\s*$", "", s, flags=re.MULTILINE)
return s.strip()
def looks_like_wechat_markdown(content: str) -> bool:
if not content:
return False
if re.search(r"^#+\s", content, re.MULTILINE):
return True
return "**" in content or "`" in content
def send_wechat_webhook(
webhook_url: str,
content: str,
*,
timeout: int = 10,
prefix: str = "【加密货币】",
) -> bool:
url = (webhook_url or "").strip()
if not url or "replace-me" in url:
return False
body = str(content or "").strip()
if prefix:
full = f"{prefix}\n{body}" if body else prefix
else:
full = body
if not full.strip():
return False
payloads = []
if looks_like_wechat_markdown(full):
payloads.append({"msgtype": "markdown", "markdown": {"content": full}})
plain = strip_markdown_for_text(full) if looks_like_wechat_markdown(full) else full
payloads.append({"msgtype": "text", "text": {"content": plain}})
seen = set()
for payload in payloads:
key = payload["msgtype"]
if key in seen:
continue
seen.add(key)
try:
resp = requests.post(url, json=payload, timeout=timeout)
if resp.status_code != 200:
continue
data = resp.json()
if int(data.get("errcode", -1)) == 0:
return True
except Exception:
continue
return False
def wechat_direction_label(direction: str) -> str:
d = (direction or "").strip().lower()
if d == "long":
return "多头(long)"
if d == "short":
return "空头(short)"
return "双向(watch)"
def build_wechat_rs_level_message(
*,
symbol: str,
monitor_type: str,
account_label: str,
trigger_time: str,
upper_txt: str,
lower_txt: str,
close_txt: str,
edge_txt: str,
break_label: str,
direction: str,
notify_index: int,
notify_max: int,
interval_min: int,
extra_note: Optional[str] = None,
) -> str:
"""阻力/支撑突破提醒(与开平仓推送一致的 emoji 纯文本风格)."""
head = "📈" if (direction or "").strip().lower() == "long" else "📉"
dir_txt = wechat_direction_label(direction)
lines = [
f"{head} {symbol} 关键位突破提醒({notify_index}/{notify_max})",
f"💼 账户:{account_label}",
"",
"🧾 突破概要",
f"📌 类型:{monitor_type}",
f"⏱ 触发时间:{trigger_time}",
f"📊 上沿:{upper_txt}|下沿:{lower_txt}",
f"💹 触发收盘:{close_txt}",
f"🎯 {break_label}({dir_txt})",
f"📍 突破价位:{edge_txt}",
"",
"📎 说明",
f"· 人工盯盘,共推送 {notify_max} 次(间隔约 {interval_min} 分钟)",
"· 推送完毕后本条监控自动结案",
"· 不参与自动开仓",
]
if extra_note:
lines.append(f"· {extra_note}")
return "\n".join(lines)