Enhance dashboard with contract symbols and risk control overview.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+86
-2
@@ -16,6 +16,83 @@ def _direction_label(direction: str) -> str:
|
|||||||
return "做多" if (direction or "").strip().lower() == "long" else "做空"
|
return "做多" if (direction or "").strip().lower() == "long" else "做空"
|
||||||
|
|
||||||
|
|
||||||
|
def _symbol_fields(ths_code: str) -> dict[str, Any]:
|
||||||
|
from symbols import position_symbol_meta
|
||||||
|
|
||||||
|
sym = (ths_code or "").strip()
|
||||||
|
meta = position_symbol_meta(sym)
|
||||||
|
return {
|
||||||
|
"symbol_code": sym,
|
||||||
|
"symbol_name": meta.get("name") or sym,
|
||||||
|
"symbol_exchange": meta.get("exchange") or "",
|
||||||
|
"symbol_is_main": bool(meta.get("is_main")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_risk_overview(
|
||||||
|
conn,
|
||||||
|
get_setting: Callable[[str, str], str],
|
||||||
|
*,
|
||||||
|
equity: Optional[float] = None,
|
||||||
|
margin_used: Optional[float] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
from risk.account_risk_lib import (
|
||||||
|
ensure_account_risk_schema,
|
||||||
|
get_risk_status,
|
||||||
|
manual_close_daily_limit,
|
||||||
|
max_active_positions,
|
||||||
|
risk_control_enabled,
|
||||||
|
cooling_hours_manual,
|
||||||
|
cooling_hours_manual_journal,
|
||||||
|
trading_day_label,
|
||||||
|
trading_day_reset_hour,
|
||||||
|
)
|
||||||
|
from trading_context import (
|
||||||
|
get_fixed_amount,
|
||||||
|
get_fixed_lots,
|
||||||
|
get_max_margin_pct,
|
||||||
|
get_roll_max_margin_pct,
|
||||||
|
get_risk_percent,
|
||||||
|
get_sizing_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
ensure_account_risk_schema(conn)
|
||||||
|
risk = dict(get_risk_status(conn) or {})
|
||||||
|
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
|
||||||
|
td = trading_day_label()
|
||||||
|
stored_td = str(row["trading_day"] or "") if row else ""
|
||||||
|
manual_count = int(row["manual_close_count"] or 0) if row and stored_td == td else 0
|
||||||
|
|
||||||
|
margin_pct_used: Optional[float] = None
|
||||||
|
if equity and equity > 0 and margin_used is not None and margin_used >= 0:
|
||||||
|
margin_pct_used = round(float(margin_used) / float(equity) * 100, 2)
|
||||||
|
|
||||||
|
max_margin = get_max_margin_pct(get_setting)
|
||||||
|
sizing = get_sizing_mode(get_setting)
|
||||||
|
sizing_label = "固定金额" if sizing == "amount" else "固定手数"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": risk_control_enabled(),
|
||||||
|
"status": risk,
|
||||||
|
"manual_close_count_today": manual_count,
|
||||||
|
"margin_pct_used": margin_pct_used,
|
||||||
|
"limits": {
|
||||||
|
"max_active_positions": max_active_positions(),
|
||||||
|
"manual_close_daily_limit": manual_close_daily_limit(),
|
||||||
|
"cooling_hours_manual": cooling_hours_manual(),
|
||||||
|
"cooling_hours_manual_journal": cooling_hours_manual_journal(),
|
||||||
|
"trading_day_reset_hour": trading_day_reset_hour(),
|
||||||
|
"max_margin_pct": max_margin,
|
||||||
|
"roll_max_margin_pct": get_roll_max_margin_pct(get_setting),
|
||||||
|
"risk_percent": get_risk_percent(get_setting),
|
||||||
|
"sizing_mode": sizing,
|
||||||
|
"sizing_label": sizing_label,
|
||||||
|
"fixed_lots": get_fixed_lots(get_setting),
|
||||||
|
"fixed_amount": get_fixed_amount(get_setting),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_dashboard_payload(
|
def build_dashboard_payload(
|
||||||
*,
|
*,
|
||||||
get_db: Callable,
|
get_db: Callable,
|
||||||
@@ -94,6 +171,7 @@ def build_dashboard_payload(
|
|||||||
"id": r["id"],
|
"id": r["id"],
|
||||||
"symbol": sym,
|
"symbol": sym,
|
||||||
"symbol_name": r["symbol_name"] or sym,
|
"symbol_name": r["symbol_name"] or sym,
|
||||||
|
**_symbol_fields(sym),
|
||||||
"monitor_type": mtype,
|
"monitor_type": mtype,
|
||||||
"direction": r["direction"] or "",
|
"direction": r["direction"] or "",
|
||||||
"direction_label": _direction_label(r["direction"] or "long")
|
"direction_label": _direction_label(r["direction"] or "long")
|
||||||
@@ -121,10 +199,12 @@ def build_dashboard_payload(
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
closes: list[dict[str, Any]] = []
|
closes: list[dict[str, Any]] = []
|
||||||
for r in close_rows:
|
for r in close_rows:
|
||||||
|
sym_code = r["symbol"] or ""
|
||||||
closes.append({
|
closes.append({
|
||||||
"id": r["id"],
|
"id": r["id"],
|
||||||
"symbol": r["symbol_name"] or r["symbol"],
|
"symbol": r["symbol_name"] or sym_code,
|
||||||
"symbol_code": r["symbol"],
|
"symbol_code": sym_code,
|
||||||
|
**_symbol_fields(sym_code),
|
||||||
"direction": r["direction"] or "long",
|
"direction": r["direction"] or "long",
|
||||||
"direction_label": _direction_label(r["direction"] or "long"),
|
"direction_label": _direction_label(r["direction"] or "long"),
|
||||||
"lots": float(r["lots"] or 0),
|
"lots": float(r["lots"] or 0),
|
||||||
@@ -139,6 +219,9 @@ def build_dashboard_payload(
|
|||||||
})
|
})
|
||||||
|
|
||||||
now_iso = datetime.now(_TZ).strftime("%Y-%m-%d %H:%M:%S")
|
now_iso = datetime.now(_TZ).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
risk = build_risk_overview(
|
||||||
|
conn, get_setting, equity=equity, margin_used=margin_used,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"updated_at": now_iso,
|
"updated_at": now_iso,
|
||||||
@@ -150,6 +233,7 @@ def build_dashboard_payload(
|
|||||||
"available": available,
|
"available": available,
|
||||||
"capital_fallback": round(capital, 2),
|
"capital_fallback": round(capital, 2),
|
||||||
},
|
},
|
||||||
|
"risk": risk,
|
||||||
"keys": keys,
|
"keys": keys,
|
||||||
"closes": closes,
|
"closes": closes,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,62 @@
|
|||||||
margin-bottom: 0.65rem;
|
margin-bottom: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-risk-card h2 {
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-risk-reason {
|
||||||
|
margin: 0 0 0.65rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-risk-reason.is-blocked {
|
||||||
|
color: var(--loss);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-risk-reason.is-ok {
|
||||||
|
color: var(--profit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-risk-grid {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-risk-grid .stat-item {
|
||||||
|
min-width: 5.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-risk-grid .stat-item .value {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-symbol-cell {
|
||||||
|
min-width: 7.5rem;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-symbol-title {
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-symbol-sub {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
margin-top: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-main-badge {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-table .badge.dir {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-table {
|
.dashboard-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
|||||||
+124
-6
@@ -13,12 +13,15 @@
|
|||||||
var ctpBadge = document.getElementById('dash-ctp-badge');
|
var ctpBadge = document.getElementById('dash-ctp-badge');
|
||||||
var modeBadge = document.getElementById('dash-mode-badge');
|
var modeBadge = document.getElementById('dash-mode-badge');
|
||||||
var updatedEl = document.getElementById('dash-updated');
|
var updatedEl = document.getElementById('dash-updated');
|
||||||
|
var riskReasonEl = document.getElementById('dash-risk-reason');
|
||||||
|
var riskGridEl = document.getElementById('dash-risk-grid');
|
||||||
|
|
||||||
var pollTimer = null;
|
var pollTimer = null;
|
||||||
var positionSource = null;
|
var positionSource = null;
|
||||||
var posRowCache = {};
|
var posRowCache = {};
|
||||||
var lastKeyIds = '';
|
var lastKeyIds = '';
|
||||||
var lastCloseHeadId = null;
|
var lastCloseHeadId = null;
|
||||||
|
var lastRiskPayload = null;
|
||||||
|
|
||||||
function fmtNum(v, digits) {
|
function fmtNum(v, digits) {
|
||||||
if (v === null || v === undefined || v === '') return '—';
|
if (v === null || v === undefined || v === '') return '—';
|
||||||
@@ -64,6 +67,118 @@
|
|||||||
return parts.length ? parts.join(' · ') : '—';
|
return parts.length ? parts.join(' · ') : '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function symbolCellHtml(row) {
|
||||||
|
var name = row.symbol_name || row.symbol || '';
|
||||||
|
var code = row.symbol_code || '';
|
||||||
|
var mainBadge = row.symbol_is_main
|
||||||
|
? ' <span class="badge planned dash-main-badge">主力</span>' : '';
|
||||||
|
var titleInner = escHtml(name) + mainBadge;
|
||||||
|
if (code && String(name).toLowerCase() !== String(code).toLowerCase()) {
|
||||||
|
titleInner += ' <span class="text-accent">' + escHtml(code) + '</span>';
|
||||||
|
} else if (!name && code) {
|
||||||
|
titleInner = '<span class="text-accent">' + escHtml(code) + '</span>';
|
||||||
|
}
|
||||||
|
var sub = row.symbol_exchange || code || '';
|
||||||
|
return (
|
||||||
|
'<div class="dash-symbol-cell">' +
|
||||||
|
'<div class="dash-symbol-title">' + titleInner + '</div>' +
|
||||||
|
(sub ? '<div class="dash-symbol-sub text-muted">' + escHtml(sub) + '</div>' : '') +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function directionBadgeHtml(row) {
|
||||||
|
var label = row.direction_label || (row.direction === 'short' ? '做空' : '做多');
|
||||||
|
return '<span class="badge dir">' + escHtml(label) + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtHours(h) {
|
||||||
|
if (h == null) return '—';
|
||||||
|
var n = Number(h);
|
||||||
|
if (isNaN(n)) return '—';
|
||||||
|
if (Math.abs(n - Math.round(n)) < 0.01) return String(Math.round(n)) + 'h';
|
||||||
|
return n.toFixed(1) + 'h';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtRemainSec(sec) {
|
||||||
|
if (sec == null || sec <= 0) return '—';
|
||||||
|
var s = Math.max(0, Math.floor(sec));
|
||||||
|
var h = Math.floor(s / 3600);
|
||||||
|
var m = Math.floor((s % 3600) / 60);
|
||||||
|
if (h > 0) return h + 'h ' + m + 'm';
|
||||||
|
return m + 'm';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRisk(risk, account) {
|
||||||
|
if (!riskGridEl || !risk) return;
|
||||||
|
if (risk.limits && Object.keys(risk.limits).length) {
|
||||||
|
lastRiskPayload = risk;
|
||||||
|
} else if (lastRiskPayload) {
|
||||||
|
risk = {
|
||||||
|
enabled: lastRiskPayload.enabled,
|
||||||
|
limits: lastRiskPayload.limits,
|
||||||
|
manual_close_count_today: lastRiskPayload.manual_close_count_today,
|
||||||
|
margin_pct_used: lastRiskPayload.margin_pct_used,
|
||||||
|
status: risk.status || lastRiskPayload.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (account && account.equity > 0 && account.margin_used != null) {
|
||||||
|
risk.margin_pct_used = Math.round(account.margin_used / account.equity * 10000) / 100;
|
||||||
|
}
|
||||||
|
var lim = risk.limits || {};
|
||||||
|
var st = risk.status || {};
|
||||||
|
var enabled = risk.enabled !== false;
|
||||||
|
var active = st.active_count != null ? st.active_count : '—';
|
||||||
|
var maxPos = lim.max_active_positions != null ? lim.max_active_positions : st.max_active_positions;
|
||||||
|
var manualCnt = risk.manual_close_count_today != null ? risk.manual_close_count_today : 0;
|
||||||
|
var manualLim = lim.manual_close_daily_limit;
|
||||||
|
var marginPct = risk.margin_pct_used;
|
||||||
|
var maxMarginPct = lim.max_margin_pct;
|
||||||
|
var marginPctText = marginPct != null ? fmtNum(marginPct) + '%' : '—';
|
||||||
|
if (maxMarginPct != null && marginPct != null) {
|
||||||
|
marginPctText += ' / ' + fmtNum(maxMarginPct) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (riskReasonEl) {
|
||||||
|
var reason = st.reason || (enabled ? '可新开仓' : '风控已关闭');
|
||||||
|
riskReasonEl.textContent = (st.status_label ? st.status_label + ' · ' : '') + reason;
|
||||||
|
riskReasonEl.className = 'dashboard-risk-reason';
|
||||||
|
if (st.can_trade === false) riskReasonEl.classList.add('is-blocked');
|
||||||
|
else if (st.can_trade) riskReasonEl.classList.add('is-ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
var sizingDetail = lim.sizing_label || '—';
|
||||||
|
if (lim.sizing_mode === 'amount') {
|
||||||
|
sizingDetail += ' · ' + fmtNum(lim.fixed_amount, 0) + ' 元';
|
||||||
|
} else if (lim.fixed_lots != null) {
|
||||||
|
sizingDetail += ' · ' + lim.fixed_lots + ' 手';
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = [
|
||||||
|
{ label: '风控开关', value: enabled ? '开启' : '关闭' },
|
||||||
|
{ label: '持仓限制', value: active + ' / ' + (maxPos != null ? maxPos : '—') },
|
||||||
|
{ label: '日手动平仓', value: manualCnt + ' / ' + (manualLim != null ? manualLim : '—') },
|
||||||
|
{ label: '冷静期(默认)', value: fmtHours(lim.cooling_hours_manual) },
|
||||||
|
{ label: '复盘后冷静', value: fmtHours(lim.cooling_hours_manual_journal) },
|
||||||
|
{ label: '冷静剩余', value: fmtRemainSec(st.freeze_remaining_sec) },
|
||||||
|
{ label: '保证金占比', value: marginPctText },
|
||||||
|
{ label: '保证金上限', value: maxMarginPct != null ? fmtNum(maxMarginPct) + '%' : '—' },
|
||||||
|
{ label: '滚仓保证金上限', value: lim.roll_max_margin_pct != null ? fmtNum(lim.roll_max_margin_pct) + '%' : '—' },
|
||||||
|
{ label: '计仓模式', value: sizingDetail },
|
||||||
|
{ label: '单笔风险', value: lim.risk_percent != null ? fmtNum(lim.risk_percent) + '%' : '—' },
|
||||||
|
{ label: '交易日切', value: lim.trading_day_reset_hour != null ? lim.trading_day_reset_hour + ':00' : '—' }
|
||||||
|
];
|
||||||
|
|
||||||
|
riskGridEl.innerHTML = items.map(function (it) {
|
||||||
|
return (
|
||||||
|
'<div class="stat-item">' +
|
||||||
|
'<div class="label">' + escHtml(it.label) + '</div>' +
|
||||||
|
'<div class="value">' + escHtml(it.value) + '</div>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
function updateCtpBadge(st) {
|
function updateCtpBadge(st) {
|
||||||
if (!ctpBadge || !st) return;
|
if (!ctpBadge || !st) return;
|
||||||
var connected = !!st.connected;
|
var connected = !!st.connected;
|
||||||
@@ -116,7 +231,7 @@
|
|||||||
keysBody.innerHTML = keys.map(function (k) {
|
keysBody.innerHTML = keys.map(function (k) {
|
||||||
return (
|
return (
|
||||||
'<tr data-key-id="' + k.id + '">' +
|
'<tr data-key-id="' + k.id + '">' +
|
||||||
'<td>' + escHtml(k.symbol_name || k.symbol) + '</td>' +
|
'<td>' + symbolCellHtml(k) + '</td>' +
|
||||||
'<td>' + escHtml(k.monitor_type || '—') + '</td>' +
|
'<td>' + escHtml(k.monitor_type || '—') + '</td>' +
|
||||||
'<td>' + escHtml(k.bar_period || '—') + '</td>' +
|
'<td>' + escHtml(k.bar_period || '—') + '</td>' +
|
||||||
'<td>' + fmtNum(k.upper) + '</td>' +
|
'<td>' + fmtNum(k.upper) + '</td>' +
|
||||||
@@ -153,8 +268,8 @@
|
|||||||
var pc = pnlClass(c.pnl_net != null ? c.pnl_net : c.pnl);
|
var pc = pnlClass(c.pnl_net != null ? c.pnl_net : c.pnl);
|
||||||
return (
|
return (
|
||||||
'<tr data-close-id="' + c.id + '">' +
|
'<tr data-close-id="' + c.id + '">' +
|
||||||
'<td>' + escHtml(c.symbol) + '</td>' +
|
'<td>' + symbolCellHtml(c) + '</td>' +
|
||||||
'<td>' + escHtml(c.direction_label || c.direction) + '</td>' +
|
'<td>' + directionBadgeHtml(c) + '</td>' +
|
||||||
'<td>' + fmtNum(c.lots) + '</td>' +
|
'<td>' + fmtNum(c.lots) + '</td>' +
|
||||||
'<td>' + fmtNum(c.entry_price) + '</td>' +
|
'<td>' + fmtNum(c.entry_price) + '</td>' +
|
||||||
'<td>' + fmtNum(c.close_price) + '</td>' +
|
'<td>' + fmtNum(c.close_price) + '</td>' +
|
||||||
@@ -213,11 +328,10 @@
|
|||||||
posBody.innerHTML = rows.map(function (r) {
|
posBody.innerHTML = rows.map(function (r) {
|
||||||
var key = r.key || r.position_key || ((r.symbol_code || '') + ':' + (r.direction || ''));
|
var key = r.key || r.position_key || ((r.symbol_code || '') + ':' + (r.direction || ''));
|
||||||
posRowCache[key] = true;
|
posRowCache[key] = true;
|
||||||
var name = r.symbol || r.symbol_name || r.symbol_code || '—';
|
|
||||||
return (
|
return (
|
||||||
'<tr data-pos-key="' + escHtml(key) + '">' +
|
'<tr data-pos-key="' + escHtml(key) + '">' +
|
||||||
'<td>' + escHtml(name) + '</td>' +
|
'<td>' + symbolCellHtml(r) + '</td>' +
|
||||||
'<td>' + escHtml(r.direction_label || r.direction) + '</td>' +
|
'<td>' + directionBadgeHtml(r) + '</td>' +
|
||||||
'<td class="dash-p-lots">' + escHtml(String(r.lots)) + '</td>' +
|
'<td class="dash-p-lots">' + escHtml(String(r.lots)) + '</td>' +
|
||||||
'<td class="dash-p-entry">' + fmtNum(r.entry_price) + '</td>' +
|
'<td class="dash-p-entry">' + fmtNum(r.entry_price) + '</td>' +
|
||||||
'<td class="dash-p-mark">' + (r.current_price != null ? fmtNum(r.current_price) : '—') + '</td>' +
|
'<td class="dash-p-mark">' + (r.current_price != null ? fmtNum(r.current_price) : '—') + '</td>' +
|
||||||
@@ -256,6 +370,9 @@
|
|||||||
function applyPositionsData(data) {
|
function applyPositionsData(data) {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
if (data.ctp_status) updateCtpBadge(data.ctp_status);
|
if (data.ctp_status) updateCtpBadge(data.ctp_status);
|
||||||
|
if (data.risk_status) {
|
||||||
|
applyRisk({ status: data.risk_status });
|
||||||
|
}
|
||||||
if (data.trading_mode_label && modeBadge) {
|
if (data.trading_mode_label && modeBadge) {
|
||||||
modeBadge.textContent = data.trading_mode_label;
|
modeBadge.textContent = data.trading_mode_label;
|
||||||
}
|
}
|
||||||
@@ -302,6 +419,7 @@
|
|||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
if (!data.ok) return;
|
if (!data.ok) return;
|
||||||
applyAccount(data);
|
applyAccount(data);
|
||||||
|
applyRisk(data.risk, data.account);
|
||||||
applyKeys(data.keys || []);
|
applyKeys(data.keys || []);
|
||||||
applyCloses(data.closes || []);
|
applyCloses(data.closes || []);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,6 +32,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card dashboard-section dashboard-risk-card">
|
||||||
|
<h2>风控说明</h2>
|
||||||
|
<p class="dashboard-risk-reason" id="dash-risk-reason">加载中…</p>
|
||||||
|
<div class="stat-grid stat-grid-summary dashboard-risk-grid" id="dash-risk-grid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card dashboard-section">
|
<div class="card dashboard-section">
|
||||||
<h2>持仓信息</h2>
|
<h2>持仓信息</h2>
|
||||||
<div class="card-scroll">
|
<div class="card-scroll">
|
||||||
|
|||||||
Reference in New Issue
Block a user