refactor: 将共用代码迁入 lib/ 模块化目录
统一 strategy、key_monitor、trade、hub 等共用库到 lib/ 子包,并补充 lib-structure 文档,便于四所与中控维护。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Shared library package."""
|
||||
@@ -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()}"
|
||||
)
|
||||
@@ -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}"
|
||||
@@ -0,0 +1,162 @@
|
||||
"""列表/导出用 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_CUSTOM = "custom"
|
||||
|
||||
|
||||
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_UTC_TODAY):
|
||||
"""
|
||||
从 ?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_UTC_TODAY).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_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"
|
||||
else:
|
||||
start, end = utc_today_bounds(now)
|
||||
preset = PRESET_UTC_TODAY
|
||||
label = f"UTC当日 {start.strftime('%Y-%m-%d')}"
|
||||
|
||||
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_UTC_TODAY):
|
||||
"""
|
||||
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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* AI 日复盘 / 周复盘:Markdown 子集渲染 + 五节大标题图标兜底
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var SECTION_FIXES = [
|
||||
{ re: /^\*\*1\.\s*(?!📊)总体盈亏结构\*\*/m, rep: "**1. 📊 总体盈亏结构**" },
|
||||
{ re: /^\*\*2\.\s*(?!🧠)心态与执行\*\*/m, rep: "**2. 🧠 心态与执行**" },
|
||||
{ re: /^\*\*3\.\s*(?!🏷️)行为标签\*\*/m, rep: "**3. 🏷️ 行为标签**" },
|
||||
{ re: /^\*\*4\.\s*(?!✅)改进建议\*\*/m, rep: "**4. ✅ 改进建议**" },
|
||||
{ re: /^\*\*5\.\s*(?!📈)图表(?:分析)?\*\*/m, rep: "**5. 📈 图表分析**" },
|
||||
{ re: /^1\.\s*(?!📊)总体盈亏结构/m, rep: "**1. 📊 总体盈亏结构**" },
|
||||
{ re: /^2\.\s*(?!🧠)心态与执行/m, rep: "**2. 🧠 心态与执行**" },
|
||||
{ re: /^3\.\s*(?!🏷️)行为标签/m, rep: "**3. 🏷️ 行为标签**" },
|
||||
{ re: /^4\.\s*(?!✅)改进建议/m, rep: "**4. ✅ 改进建议**" },
|
||||
{ re: /^5\.\s*(?!📈)图表/m, rep: "**5. 📈 图表分析**" },
|
||||
];
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function parseInline(raw) {
|
||||
var s = escapeHtml(raw);
|
||||
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
s = s.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||
return s;
|
||||
}
|
||||
|
||||
function enhanceReviewHeadings(text) {
|
||||
var out = String(text || "");
|
||||
SECTION_FIXES.forEach(function (item) {
|
||||
out = out.replace(item.re, item.rep);
|
||||
});
|
||||
if (/^【系统说明/m.test(out) && !/^ℹ️/m.test(out)) {
|
||||
out = out.replace(/^【系统说明/gm, "ℹ️ 【系统说明");
|
||||
}
|
||||
if (/^原始记录:/m.test(out) && !/^📎/m.test(out)) {
|
||||
out = out.replace(/^原始记录:/gm, "📎 **原始记录**");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isNumberedListLine(trimmed) {
|
||||
if (!trimmed) return false;
|
||||
if (/^\d+\.\s+/.test(trimmed)) return true;
|
||||
if (/^\*\*\d+\.\s*.+\*\*$/.test(trimmed)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 编号列表项之间的空行不拆段,避免每条都从 1 重新开始 */
|
||||
function preprocessListBlanks(text) {
|
||||
var lines = String(text || "").replace(/\r\n/g, "\n").split("\n");
|
||||
var out = [];
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var trimmed = lines[i].trim();
|
||||
if (!trimmed) {
|
||||
var prevTrim = out.length ? String(out[out.length - 1]).trim() : "";
|
||||
var nextTrim = "";
|
||||
for (var j = i + 1; j < lines.length; j++) {
|
||||
var t = lines[j].trim();
|
||||
if (t) {
|
||||
nextTrim = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isNumberedListLine(prevTrim) && isNumberedListLine(nextTrim)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(lines[i]);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
function renderMarkdown(text) {
|
||||
var src = enhanceReviewHeadings(preprocessListBlanks(text));
|
||||
var lines = src.replace(/\r\n/g, "\n").split("\n");
|
||||
var html = [];
|
||||
var inUl = false;
|
||||
var inOl = false;
|
||||
|
||||
function closeLists() {
|
||||
if (inUl) {
|
||||
html.push("</ul>");
|
||||
inUl = false;
|
||||
}
|
||||
if (inOl) {
|
||||
html.push("</ol>");
|
||||
inOl = false;
|
||||
}
|
||||
}
|
||||
|
||||
lines.forEach(function (line) {
|
||||
var trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
closeLists();
|
||||
return;
|
||||
}
|
||||
var hm = trimmed.match(/^(#{1,3})\s+(.+)$/);
|
||||
if (hm) {
|
||||
closeLists();
|
||||
var level = hm[1].length + 1;
|
||||
if (level > 4) level = 4;
|
||||
html.push("<h" + level + ">" + parseInline(hm[2]) + "</h" + level + ">");
|
||||
return;
|
||||
}
|
||||
var ulm = trimmed.match(/^[-*]\s+(.+)$/);
|
||||
if (ulm) {
|
||||
if (!inUl) {
|
||||
closeLists();
|
||||
html.push("<ul>");
|
||||
inUl = true;
|
||||
}
|
||||
html.push("<li>" + parseInline(ulm[1]) + "</li>");
|
||||
return;
|
||||
}
|
||||
var boldOl = trimmed.match(/^\*\*(\d+)\.\s*(.+)\*\*$/);
|
||||
if (boldOl) {
|
||||
if (!inOl) {
|
||||
closeLists();
|
||||
html.push("<ol>");
|
||||
inOl = true;
|
||||
}
|
||||
html.push("<li>" + parseInline(trimmed) + "</li>");
|
||||
return;
|
||||
}
|
||||
var olm = trimmed.match(/^\d+\.\s+(.+)$/);
|
||||
if (olm) {
|
||||
if (!inOl) {
|
||||
closeLists();
|
||||
html.push("<ol>");
|
||||
inOl = true;
|
||||
}
|
||||
html.push("<li>" + parseInline(olm[1]) + "</li>");
|
||||
return;
|
||||
}
|
||||
closeLists();
|
||||
if (/^📎\s*\*\*原始记录\*\*/.test(trimmed) || /^原始记录:/.test(trimmed)) {
|
||||
html.push('<div class="md-raw-block-title">' + parseInline(trimmed) + "</div>");
|
||||
return;
|
||||
}
|
||||
html.push("<p>" + parseInline(trimmed) + "</p>");
|
||||
});
|
||||
closeLists();
|
||||
return html.join("\n");
|
||||
}
|
||||
|
||||
var _genBusy = false;
|
||||
|
||||
function setGenerating(opts) {
|
||||
opts = opts || {};
|
||||
_genBusy = true;
|
||||
var wrap = document.getElementById(opts.wrapId);
|
||||
var el = document.getElementById(opts.elId);
|
||||
var btn = opts.btnId ? document.getElementById(opts.btnId) : null;
|
||||
if (wrap) wrap.style.display = "block";
|
||||
if (el) {
|
||||
el.classList.remove("ai-result-md");
|
||||
el.classList.add("is-loading");
|
||||
el.innerHTML = "";
|
||||
el.innerText = opts.message || "生成复盘中,请稍候…";
|
||||
}
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
if (!btn.dataset.aiOrigText) btn.dataset.aiOrigText = btn.textContent;
|
||||
btn.textContent = opts.btnLabel || "生成中…";
|
||||
}
|
||||
if (wrap && wrap.scrollIntoView) {
|
||||
try {
|
||||
wrap.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
function clearGenerating(btnId) {
|
||||
_genBusy = false;
|
||||
var btn = btnId ? document.getElementById(btnId) : null;
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
if (btn.dataset.aiOrigText) {
|
||||
btn.textContent = btn.dataset.aiOrigText;
|
||||
delete btn.dataset.aiOrigText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isGenerating() {
|
||||
return _genBusy;
|
||||
}
|
||||
|
||||
function setElementMarkdown(el, rawText) {
|
||||
if (!el) return;
|
||||
var raw = String(rawText || "");
|
||||
el.dataset.markdownRaw = raw;
|
||||
el.classList.remove("is-loading");
|
||||
el.classList.add("ai-result-md");
|
||||
el.innerHTML = renderMarkdown(raw);
|
||||
}
|
||||
|
||||
function getElementMarkdown(el) {
|
||||
if (!el) return "";
|
||||
if (el.dataset && el.dataset.markdownRaw != null) {
|
||||
return el.dataset.markdownRaw;
|
||||
}
|
||||
return el.innerText || "";
|
||||
}
|
||||
|
||||
global.AiReviewRender = {
|
||||
enhanceReviewHeadings: enhanceReviewHeadings,
|
||||
renderMarkdown: renderMarkdown,
|
||||
setElementMarkdown: setElementMarkdown,
|
||||
getElementMarkdown: getElementMarkdown,
|
||||
setGenerating: setGenerating,
|
||||
clearGenerating: clearGenerating,
|
||||
isGenerating: isGenerating,
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : this);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 表单提交防重复:网络慢时禁用按钮并显示「提交中」。
|
||||
*/
|
||||
(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 = false;
|
||||
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);
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* 中控 iframe 壳:顶栏/统计常驻,tab 内容走 /api/embed/page/<tab>。
|
||||
*/
|
||||
(function (global) {
|
||||
const TAB_PATH = {
|
||||
key_monitor: "/key_monitor",
|
||||
trade: "/trade",
|
||||
strategy: "/strategy",
|
||||
strategy_records: "/strategy/records",
|
||||
records: "/records",
|
||||
stats: "/stats",
|
||||
};
|
||||
|
||||
let navToken = 0;
|
||||
let loadingTab = false;
|
||||
|
||||
/** 自带校验后 form.submit() 的表单,勿在捕获阶段再 fetch 一份(会双发 POST) */
|
||||
const CUSTOM_SUBMIT_FORM_IDS = new Set(["add-order-form", "key-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 setRootLoading(on) {
|
||||
const root = document.getElementById("embed-page-root");
|
||||
if (root) root.classList.toggle("is-embed-tab-loading", !!on);
|
||||
}
|
||||
|
||||
function setNavActive(tab) {
|
||||
document.querySelectorAll(".embed-top-nav [data-embed-tab]").forEach((a) => {
|
||||
a.classList.toggle("active", a.getAttribute("data-embed-tab") === tab);
|
||||
});
|
||||
}
|
||||
|
||||
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 runPageInit(tab) {
|
||||
document.body.setAttribute("data-page", tab);
|
||||
if (typeof global.attachListWindowToExports === "function") {
|
||||
global.attachListWindowToExports();
|
||||
}
|
||||
if (tab === "trade") {
|
||||
if (typeof global.refreshOrderDefaults === "function") global.refreshOrderDefaults();
|
||||
if (global.ManualOrderRrPreview && typeof global.ManualOrderRrPreview.wire === "function") {
|
||||
global.ManualOrderRrPreview.wire();
|
||||
}
|
||||
}
|
||||
if (tab === "key_monitor" && global.KeyMonitorForm && typeof global.KeyMonitorForm.init === "function") {
|
||||
global.KeyMonitorForm.init();
|
||||
}
|
||||
if (tab === "records") {
|
||||
if (typeof global.loadJournals === "function") global.loadJournals();
|
||||
if (typeof global.loadReviews === "function") global.loadReviews();
|
||||
if (typeof global.toggleReviewMode === "function") global.toggleReviewMode();
|
||||
}
|
||||
if (tab === "stats") {
|
||||
if (typeof global.initStatsSegmentFromUrl === "function") global.initStatsSegmentFromUrl();
|
||||
}
|
||||
if (typeof global.refreshPriceSnapshotConditional === "function") {
|
||||
global.refreshPriceSnapshotConditional();
|
||||
}
|
||||
}
|
||||
|
||||
function injectFragment(html) {
|
||||
const root = document.getElementById("embed-page-root");
|
||||
if (!root) return;
|
||||
root.innerHTML = html;
|
||||
root.querySelectorAll("script").forEach((old) => {
|
||||
const s = document.createElement("script");
|
||||
if (old.src) s.src = old.src;
|
||||
else s.textContent = old.textContent;
|
||||
old.replaceWith(s);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadTab(tab, opts) {
|
||||
const options = opts || {};
|
||||
if (!tab || loadingTab) return;
|
||||
const token = ++navToken;
|
||||
loadingTab = true;
|
||||
setRootLoading(true);
|
||||
try {
|
||||
const qs = listWindowQueryString();
|
||||
const url = "/api/embed/page/" + encodeURIComponent(tab) + (qs ? "?" + qs : "");
|
||||
const r = await fetch(url, { credentials: "same-origin" });
|
||||
if (token !== navToken) return;
|
||||
const j = await r.json();
|
||||
if (!j.ok || !j.html) throw new Error(j.msg || "加载失败");
|
||||
injectFragment(j.html);
|
||||
setNavActive(tab);
|
||||
if (!options.skipUrl) syncUrl(tab, !!options.replace);
|
||||
runPageInit(tab);
|
||||
} 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;
|
||||
setRootLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reloadCurrentTab() {
|
||||
return loadTab(getTab(), { replace: true, skipUrl: 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);
|
||||
return fetch(form.action, {
|
||||
method: form.method || "POST",
|
||||
body: fd,
|
||||
credentials: "same-origin",
|
||||
redirect: "manual",
|
||||
})
|
||||
.then(() => reloadCurrentTab())
|
||||
.catch(() => reloadCurrentTab());
|
||||
}
|
||||
|
||||
function patchApplyListWindow() {
|
||||
if (typeof global.applyListWindow !== "function") return;
|
||||
global.applyListWindow = function embedApplyListWindow() {
|
||||
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);
|
||||
fetch(form.action, {
|
||||
method: form.method || "POST",
|
||||
body: fd,
|
||||
credentials: "same-origin",
|
||||
redirect: "manual",
|
||||
})
|
||||
.then(() => reloadCurrentTab())
|
||||
.catch(() => reloadCurrentTab());
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
function bindNav() {
|
||||
document.querySelectorAll(".embed-top-nav [data-embed-tab]").forEach((a) => {
|
||||
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();
|
||||
bindNav();
|
||||
runPageInit(getTab());
|
||||
try {
|
||||
window.parent.postMessage({ type: "instance-frame-ready" }, "*");
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
global.InstanceEmbed = {
|
||||
loadTab,
|
||||
reloadCurrentTab,
|
||||
getTab,
|
||||
postFormAndReload,
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -0,0 +1,231 @@
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
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}
|
||||
#add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
|
||||
.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}
|
||||
.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{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||
.journal-card .form-grid > input,
|
||||
.journal-card .form-grid > select{
|
||||
min-width:0;
|
||||
width:100%;
|
||||
max-width:100%;
|
||||
}
|
||||
.journal-card .form-grid select[name="entry_reason"]{
|
||||
grid-column:1/-1;
|
||||
font-size:.8rem;
|
||||
line-height:1.35;
|
||||
}
|
||||
.journal-card .form-grid input[name="entry_reason_custom"]{
|
||||
grid-column:1/-1;
|
||||
font-size:.8rem;
|
||||
}
|
||||
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:1210}
|
||||
.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}
|
||||
.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}
|
||||
.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{transition:opacity .12s ease}
|
||||
#embed-page-root.is-embed-tab-loading{opacity:.55;pointer-events:none}
|
||||
@@ -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);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* 四所实例主题:默认暗色;单独登录用 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: "#d8e2ec" };
|
||||
|
||||
function normalize(theme) {
|
||||
return theme === "light" ? "light" : "dark";
|
||||
}
|
||||
|
||||
function isHubLinked() {
|
||||
try {
|
||||
const p = new URLSearchParams(location.search);
|
||||
if (p.get("embed") === "1") return true;
|
||||
const ht = p.get("hub_theme");
|
||||
if (ht === "light" || ht === "dark") return true;
|
||||
} catch (_) {}
|
||||
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 软导航/表单恢复后不触发 change) */
|
||||
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() {
|
||||
const toggle = document.getElementById("review-mode-toggle");
|
||||
if (!toggle) return;
|
||||
if (toggle.dataset.instReviewModeBound !== "1") {
|
||||
toggle.dataset.instReviewModeBound = "1";
|
||||
toggle.addEventListener("input", () => {
|
||||
if (typeof global.toggleReviewMode === "function") global.toggleReviewMode();
|
||||
else syncReviewEditButtons();
|
||||
});
|
||||
}
|
||||
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,43 @@
|
||||
/* 紧接 instance_theme.js 之后加载,避免亮色下先闪暗色底 */
|
||||
html {
|
||||
background: #0b0d14;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html[data-theme="light"] {
|
||||
background: #d8e2ec;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html[data-theme="light"] body {
|
||||
background: #d8e2ec !important;
|
||||
color: #1a2838 !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"] .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.active,
|
||||
html[data-theme="light"] .strategy-subnav a.active {
|
||||
background: rgba(0, 110, 154, 0.12) !important;
|
||||
color: #142232 !important;
|
||||
}
|
||||
|
||||
html[data-theme="light"] .card,
|
||||
html[data-theme="light"] .stat-item {
|
||||
background: #fff !important;
|
||||
border-color: #b8c8d8 !important;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* 四所实例共用 UI:复盘详情、盈亏着色等。
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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(o.open_datetime || "-")}`,
|
||||
`平仓时间:${escapeHtml(o.close_datetime || "-")}`,
|
||||
`持仓时长:${escapeHtml(o.hold_duration || "-")}`,
|
||||
`盈亏:${formatPnlSpan(o.pnl)}`,
|
||||
`开仓类型:${escapeHtml(o.entry_reason || "无")}`,
|
||||
`平仓/离场:${escapeHtml(exitText)}`,
|
||||
`预期RR:${escapeHtml(o.expect_rr || "-")}`,
|
||||
`实际RR:${escapeHtml(o.real_rr || "-")}`,
|
||||
`保本后盯盘:${escapeHtml(o.post_breakeven_stare || "-")}`,
|
||||
`占用时新开仓:${escapeHtml(o.new_trade_while_occupied || "-")}`,
|
||||
`心态标签:${escapeHtml(moodTags)}`,
|
||||
`备注:${escapeHtml(o.note || "无")}`,
|
||||
];
|
||||
return lines.join("<br>");
|
||||
}
|
||||
|
||||
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();
|
||||
const imgEl = document.getElementById("detailImage");
|
||||
if (imgEl) {
|
||||
if (o.image) {
|
||||
imgEl.src = `/static/images/${o.image}`;
|
||||
imgEl.style.display = "block";
|
||||
} else {
|
||||
imgEl.src = "";
|
||||
imgEl.style.display = "none";
|
||||
}
|
||||
}
|
||||
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 text = String((o && o.entry_reason) || "");
|
||||
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();
|
||||
return data
|
||||
.map(function (o) {
|
||||
if (mobile) {
|
||||
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>`;
|
||||
}
|
||||
const moodTags = (o.mood_issues || []).join(",") || "无";
|
||||
const id = escapeHtml(o.id);
|
||||
return `<div class="entry">
|
||||
<div><strong>${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "-")}</strong> | 盈亏:${escapeHtml(o.pnl == null || o.pnl === "" ? "-" : o.pnl)}U</div>
|
||||
<div>开:${escapeHtml(o.open_datetime || "-")} 平:${escapeHtml(o.close_datetime || "-")} 持仓:${escapeHtml(o.hold_duration || "-")}</div>
|
||||
<div>心态标签:${escapeHtml(moodTags)}</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="openJournalDetail('${id}')">查看详情</button>
|
||||
<button type="button" class="btn-del" onclick="deleteJournal('${id}')">删除</button>
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function parseTradeRecordRow(tr) {
|
||||
const cells = tr.querySelectorAll("td");
|
||||
if (cells.length < 14) return null;
|
||||
const dirBadge = cells[2].querySelector(".badge");
|
||||
return {
|
||||
rowId: tr.id,
|
||||
symbol: cells[0].textContent.trim(),
|
||||
type: cells[1].textContent.trim(),
|
||||
directionHtml: (dirBadge ? dirBadge.outerHTML : cells[2].innerHTML).trim(),
|
||||
directionText: cells[2].textContent.trim(),
|
||||
trigger: cells[3].textContent.trim(),
|
||||
stopLoss: cells[4].textContent.trim(),
|
||||
takeProfit: cells[5].textContent.trim(),
|
||||
margin: cells[6].textContent.trim(),
|
||||
leverage: cells[7].textContent.trim(),
|
||||
holdMinutes: cells[8].textContent.trim(),
|
||||
openedAt: cells[9].textContent.trim(),
|
||||
closedAt: cells[10].textContent.trim(),
|
||||
pnlHtml: cells[11].innerHTML.trim(),
|
||||
pnlText: cells[11].textContent.trim(),
|
||||
resultHtml: cells[12].innerHTML.trim(),
|
||||
resultText: cells[12].textContent.trim(),
|
||||
actionsHtml: cells[13].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("方向", 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 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,
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,289 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function syncRollFormMode(form, mode) {
|
||||
if (!form) return;
|
||||
const m = mode || "market";
|
||||
form.setAttribute("data-add-mode", m);
|
||||
const showFib = m === "fib_618" || m === "fib_786";
|
||||
const showBreakout = m === "breakout";
|
||||
const fibWrap = form.querySelector(".roll-field-fib");
|
||||
const breakoutWrap = form.querySelector(".roll-field-breakout");
|
||||
const fibUpper = form.querySelector("#roll-fib-upper");
|
||||
const fibLower = form.querySelector("#roll-fib-lower");
|
||||
const breakoutInput = form.querySelector("#roll-breakout");
|
||||
|
||||
function tuneInput(inp, active, required) {
|
||||
if (!inp) return;
|
||||
inp.disabled = !active;
|
||||
inp.required = !!required && active;
|
||||
inp.tabIndex = active ? 0 : -1;
|
||||
if (!active) inp.value = "";
|
||||
}
|
||||
|
||||
if (fibWrap) fibWrap.setAttribute("aria-hidden", showFib ? "false" : "true");
|
||||
if (breakoutWrap) breakoutWrap.setAttribute("aria-hidden", showBreakout ? "false" : "true");
|
||||
tuneInput(fibUpper, showFib, showFib);
|
||||
tuneInput(fibLower, showFib, showFib);
|
||||
tuneInput(breakoutInput, showBreakout, showBreakout);
|
||||
}
|
||||
|
||||
window.syncRollFormMode = syncRollFormMode;
|
||||
|
||||
const form = document.getElementById("roll-form");
|
||||
if (!form) return;
|
||||
if (form.dataset.rollJsInit === "1") return;
|
||||
form.dataset.rollJsInit = "1";
|
||||
|
||||
const symbolSel = document.getElementById("roll-symbol");
|
||||
const dirInput = document.getElementById("roll-direction");
|
||||
const modeSel = document.getElementById("roll-add-mode");
|
||||
const riskBanner = document.getElementById("roll-risk-banner");
|
||||
const previewBtn = document.getElementById("roll-preview-btn");
|
||||
const submitBtn = document.getElementById("roll-submit-btn");
|
||||
const previewBox = document.getElementById("roll-preview-box");
|
||||
const previewText = document.getElementById("roll-preview-text");
|
||||
const countdownEl = document.getElementById("roll-countdown");
|
||||
const trendLocked = submitBtn && submitBtn.getAttribute("data-trend-locked") === "1";
|
||||
|
||||
let countdownTimer = null;
|
||||
let previewOk = false;
|
||||
let lastPreviewMode = "";
|
||||
let monitorSubmitting = false;
|
||||
|
||||
function isMarketMode() {
|
||||
return (modeSel.value || "market") === "market";
|
||||
}
|
||||
|
||||
function isMonitorMode() {
|
||||
const m = modeSel.value || "market";
|
||||
return m === "fib_618" || m === "fib_786" || m === "breakout";
|
||||
}
|
||||
|
||||
function selectedOption() {
|
||||
return symbolSel.options[symbolSel.selectedIndex];
|
||||
}
|
||||
|
||||
function syncDirectionLock() {
|
||||
const opt = selectedOption();
|
||||
if (!opt || !opt.value) {
|
||||
riskBanner.textContent = "当前风险:请选择持仓币种";
|
||||
return;
|
||||
}
|
||||
const dir = opt.getAttribute("data-direction") || "long";
|
||||
const rp = opt.getAttribute("data-risk-percent") || "—";
|
||||
dirInput.value = dir;
|
||||
riskBanner.textContent =
|
||||
"当前风险:" + rp + "%(来自监控单 #" + (opt.getAttribute("data-monitor-id") || "?") + ")";
|
||||
}
|
||||
|
||||
function syncSubmitButton() {
|
||||
if (!submitBtn || trendLocked) return;
|
||||
if (isMonitorMode()) {
|
||||
submitBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
submitBtn.disabled = !previewOk || !!countdownTimer;
|
||||
}
|
||||
|
||||
function clearMessageBox() {
|
||||
if (!previewBox) return;
|
||||
previewBox.style.display = "none";
|
||||
previewBox.classList.remove("is-error", "is-preview");
|
||||
if (previewText) previewText.textContent = "";
|
||||
if (countdownEl) countdownEl.style.display = "none";
|
||||
}
|
||||
|
||||
function showReject(msg) {
|
||||
if (!previewBox || !previewText) return;
|
||||
previewBox.style.display = "block";
|
||||
previewBox.classList.remove("is-preview");
|
||||
previewBox.classList.add("is-error");
|
||||
previewText.textContent = msg || "无法执行";
|
||||
if (countdownEl) countdownEl.style.display = "none";
|
||||
previewBox.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
|
||||
function showPreviewResult(p) {
|
||||
if (!previewBox || !previewText) return;
|
||||
previewBox.style.display = "block";
|
||||
previewBox.classList.remove("is-error");
|
||||
previewBox.classList.add("is-preview");
|
||||
previewText.innerHTML =
|
||||
"<strong>" +
|
||||
(p.add_mode_label || "") +
|
||||
"</strong> · 约 <strong>" +
|
||||
(p.add_amount_display != null ? p.add_amount_display : p.add_amount_raw) +
|
||||
"</strong> 张<br>" +
|
||||
"加仓参考价 " +
|
||||
(p.add_price_display != null ? p.add_price_display : p.add_price) +
|
||||
" · 新止损 " +
|
||||
(p.new_sl_display != null ? p.new_sl_display : p.new_stop_loss) +
|
||||
"<br>" +
|
||||
"合并均价 " +
|
||||
p.avg_entry_after +
|
||||
" · 打到止损约 " +
|
||||
p.loss_at_sl_usdt +
|
||||
"U(风险预算 " +
|
||||
(p.risk_budget_usdt != null ? p.risk_budget_usdt : "—") +
|
||||
"U)";
|
||||
}
|
||||
|
||||
function syncFieldVisibility() {
|
||||
syncRollFormMode(form, modeSel.value || "market");
|
||||
resetPreview();
|
||||
}
|
||||
|
||||
function resetPreview() {
|
||||
previewOk = false;
|
||||
monitorSubmitting = false;
|
||||
clearMessageBox();
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer);
|
||||
countdownTimer = null;
|
||||
}
|
||||
syncSubmitButton();
|
||||
}
|
||||
|
||||
function formPayload() {
|
||||
const fd = new FormData(form);
|
||||
const obj = {};
|
||||
fd.forEach(function (v, k) {
|
||||
if (v !== "") obj[k] = v;
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
|
||||
function requestPreview() {
|
||||
return fetch("/strategy/roll/preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify(formPayload()),
|
||||
credentials: "same-origin",
|
||||
}).then(function (r) {
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function runPreview() {
|
||||
resetPreview();
|
||||
if (!symbolSel.value) {
|
||||
showReject("请先选择持仓币种");
|
||||
return;
|
||||
}
|
||||
if (previewBtn) previewBtn.disabled = true;
|
||||
requestPreview()
|
||||
.then(function (data) {
|
||||
if (previewBtn) previewBtn.disabled = false;
|
||||
if (!data.ok) {
|
||||
showReject(data.msg || "预览失败");
|
||||
return;
|
||||
}
|
||||
const p = data.preview || {};
|
||||
lastPreviewMode = p.add_mode || modeSel.value;
|
||||
showPreviewResult(p);
|
||||
previewOk = true;
|
||||
if (lastPreviewMode === "market") {
|
||||
startCountdown(10);
|
||||
} else {
|
||||
syncSubmitButton();
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
if (previewBtn) previewBtn.disabled = false;
|
||||
showReject("预览请求失败,请稍后重试");
|
||||
});
|
||||
}
|
||||
|
||||
function runMonitorSubmit() {
|
||||
if (monitorSubmitting) return;
|
||||
if (!symbolSel.value) {
|
||||
showReject("请先选择持仓币种");
|
||||
return;
|
||||
}
|
||||
monitorSubmitting = true;
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
requestPreview()
|
||||
.then(function (data) {
|
||||
monitorSubmitting = false;
|
||||
if (submitBtn && !trendLocked) submitBtn.disabled = false;
|
||||
if (!data.ok) {
|
||||
showReject(data.msg || "无法提交监控");
|
||||
return;
|
||||
}
|
||||
const p = data.preview || {};
|
||||
const modeLabel = modeSel.options[modeSel.selectedIndex].text;
|
||||
const summary =
|
||||
"约 " +
|
||||
(p.add_amount_display != null ? p.add_amount_display : p.add_amount_raw) +
|
||||
" 张 · 触发参考价 " +
|
||||
(p.add_price_display != null ? p.add_price_display : p.add_price) +
|
||||
" · 新止损 " +
|
||||
(p.new_sl_display != null ? p.new_sl_display : p.new_stop_loss);
|
||||
if (!confirm("确认提交「" + modeLabel + "」?\n" + summary)) {
|
||||
return;
|
||||
}
|
||||
form.submit();
|
||||
})
|
||||
.catch(function () {
|
||||
monitorSubmitting = false;
|
||||
if (submitBtn && !trendLocked) submitBtn.disabled = false;
|
||||
showReject("校验请求失败,请稍后重试");
|
||||
});
|
||||
}
|
||||
|
||||
function startCountdown(sec) {
|
||||
let left = sec;
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
if (countdownEl) {
|
||||
countdownEl.style.display = "block";
|
||||
countdownEl.textContent = "市价加仓:" + left + " 秒后可执行(修改表单将取消预览)";
|
||||
}
|
||||
countdownTimer = setInterval(function () {
|
||||
left -= 1;
|
||||
if (left <= 0) {
|
||||
clearInterval(countdownTimer);
|
||||
countdownTimer = null;
|
||||
if (countdownEl) countdownEl.textContent = "可以执行市价加仓";
|
||||
syncSubmitButton();
|
||||
return;
|
||||
}
|
||||
if (countdownEl) countdownEl.textContent = "市价加仓:" + left + " 秒后可执行";
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
symbolSel.addEventListener("change", function () {
|
||||
syncDirectionLock();
|
||||
resetPreview();
|
||||
});
|
||||
modeSel.addEventListener("change", syncFieldVisibility);
|
||||
form.addEventListener("input", resetPreview);
|
||||
form.addEventListener("change", function (e) {
|
||||
if (e.target !== previewBtn) resetPreview();
|
||||
});
|
||||
if (previewBtn) previewBtn.addEventListener("click", runPreview);
|
||||
form.addEventListener("submit", function (e) {
|
||||
if (isMonitorMode()) {
|
||||
e.preventDefault();
|
||||
runMonitorSubmit();
|
||||
return;
|
||||
}
|
||||
if (!previewOk) {
|
||||
e.preventDefault();
|
||||
showReject("请先点击「预览」并通过校验");
|
||||
return;
|
||||
}
|
||||
if (submitBtn && submitBtn.disabled) {
|
||||
e.preventDefault();
|
||||
showReject("请等待 10 秒确认倒计时结束后再执行市价加仓");
|
||||
return;
|
||||
}
|
||||
const modeLabel = modeSel.options[modeSel.selectedIndex].text;
|
||||
if (!confirm("确认提交「" + modeLabel + "」?")) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
syncDirectionLock();
|
||||
syncFieldVisibility();
|
||||
})();
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 时间平仓:表单开关 + 持仓倒计时。
|
||||
*/
|
||||
(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 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 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));
|
||||
}
|
||||
cd.textContent = Number.isFinite(rem) ? formatCountdown(rem) : "--:--:--";
|
||||
wrap.dataset.closeAtMs = order.time_close_at_ms ? String(order.time_close_at_ms) : "";
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
function paintOrders(orders) {
|
||||
(orders || []).forEach(paintOrderTimeClose);
|
||||
}
|
||||
|
||||
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,
|
||||
paintOrders: paintOrders,
|
||||
tickLocalCountdowns: tickLocalCountdowns,
|
||||
syncKeyTimeCloseVisibility: syncKeyTimeCloseVisibility,
|
||||
formatCountdown: formatCountdown,
|
||||
};
|
||||
|
||||
if (!global.__timeCloseCountdownTimer) {
|
||||
global.__timeCloseCountdownTimer = setInterval(tickLocalCountdowns, 1000);
|
||||
}
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -0,0 +1,160 @@
|
||||
/* 交易日历:内照明心 + 四所统计分析共用,随 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-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 transparent;
|
||||
padding: 4px 3px;
|
||||
min-height: 68px;
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
line-height: 1.15;
|
||||
font-size: inherit;
|
||||
text-align: center;
|
||||
}
|
||||
.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: 4px;
|
||||
}
|
||||
.trade-cal-cell {
|
||||
min-height: 62px;
|
||||
padding: 4px 3px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
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: var(--section-surface, #f6f9fc);
|
||||
--trade-cal-cell-hover-bg: color-mix(in srgb, var(--accent, #2563eb) 10%, #f6f9fc);
|
||||
--trade-cal-selected-border: rgba(37, 99, 235, 0.75);
|
||||
--trade-cal-selected-bg: color-mix(in srgb, #2563eb 12%, #f6f9fc);
|
||||
--trade-cal-selected-shadow: rgba(37, 99, 235, 0.35);
|
||||
--trade-cal-sick-tag-fg: #b91c1c;
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* 交易日历组件:内照明心档案 + 四所统计分析共用。
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user