feat: strategy records dual panels with filters and 100-row cap

Split trend and roll snapshot lists with expandable rows, client filters, and DB prune to latest 100 entries.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-04 10:55:34 +08:00
parent 7037dc2334
commit f1e95afb89
3 changed files with 402 additions and 72 deletions
+15 -4
View File
@@ -6,12 +6,23 @@ from typing import Any
from flask import flash, redirect, url_for
from strategy_snapshot_lib import list_strategy_snapshots
from strategy_snapshot_lib import (
STRATEGY_SNAPSHOTS_MAX_ROWS,
list_strategy_snapshots_split,
)
def load_strategy_records_page(conn, *, limit: int = 200) -> dict[str, Any]:
snaps = list_strategy_snapshots(conn, limit=limit)
return {"strategy_snapshots": snaps, "strategy_records_limit": limit}
def load_strategy_records_page(
conn, *, limit: int = STRATEGY_SNAPSHOTS_MAX_ROWS
) -> dict[str, Any]:
trend, roll, symbols = list_strategy_snapshots_split(conn, limit=limit)
return {
"strategy_trend_records": trend,
"strategy_roll_records": roll,
"strategy_record_symbols": symbols,
"strategy_records_limit": limit,
"strategy_snapshots": trend + roll,
}
def register_strategy_records(app, cfg: dict[str, Any]) -> None:
+133 -1
View File
@@ -7,6 +7,7 @@ from typing import Any, Callable, Optional
STRATEGY_TREND = "trend_pullback"
STRATEGY_ROLL = "roll"
STRATEGY_SNAPSHOTS_MAX_ROWS = 100
STRATEGY_SNAPSHOTS_SQL = """
CREATE TABLE IF NOT EXISTS strategy_trade_snapshots (
@@ -160,6 +161,7 @@ def save_trend_plan_snapshot(
closed_at,
),
)
prune_strategy_snapshots(conn, keep=STRATEGY_SNAPSHOTS_MAX_ROWS)
def save_roll_group_snapshot(
@@ -220,6 +222,125 @@ def save_roll_group_snapshot(
closed_at,
),
)
prune_strategy_snapshots(conn, keep=STRATEGY_SNAPSHOTS_MAX_ROWS)
def prune_strategy_snapshots(conn, *, keep: int = STRATEGY_SNAPSHOTS_MAX_ROWS) -> None:
"""仅保留最近 keep 条策略快照(按 closed_at / id 倒序)。"""
k = max(1, min(int(keep), 500))
conn.execute(
"""DELETE FROM strategy_trade_snapshots
WHERE id NOT IN (
SELECT id FROM strategy_trade_snapshots
ORDER BY COALESCE(closed_at, created_at, '') DESC, id DESC
LIMIT ?
)""",
(k,),
)
def _snapshot_pnl(row: dict, snap: dict) -> float | None:
for key in ("pnl_amount",):
v = row.get(key)
if v is not None and v != "":
try:
return float(v)
except (TypeError, ValueError):
pass
v = snap.get("pnl_amount")
if v is not None and v != "":
try:
return float(v)
except (TypeError, ValueError):
pass
return None
def _trend_dca_stats(snap: dict) -> dict:
levels = snap.get("dca_levels") or build_trend_dca_levels(snap)
dca_only = [
lv
for lv in levels
if (lv.get("leg_key") or "") != "first" and (lv.get("label") or "") != "首仓"
]
done = sum(1 for lv in dca_only if lv.get("status") == "done")
total = len(dca_only)
pending = total - done
if total <= 0:
tag = "na"
elif done <= 0:
tag = "no_dca"
elif done >= total:
tag = "dca_done"
else:
tag = "dca_partial"
return {
"dca_done": done,
"dca_total": total,
"dca_pending": pending,
"dca_tag": tag,
}
def _roll_leg_stats(snap: dict) -> dict:
legs = snap.get("legs") or []
if not isinstance(legs, list):
legs = []
filled = sum(1 for lg in legs if (lg.get("status") or "").lower() == "filled")
total = len(legs)
pending = total - filled
if total <= 0:
tag = "na"
elif filled <= 0:
tag = "no_dca"
elif filled >= total:
tag = "dca_done"
else:
tag = "dca_partial"
return {
"dca_done": filled,
"dca_total": total,
"dca_pending": pending,
"dca_tag": tag,
}
def enrich_strategy_snapshot_row(row: dict) -> dict:
d = dict(row or {})
snap = d.get("snapshot") or {}
st = (d.get("strategy_type") or "").strip()
pnl = _snapshot_pnl(d, snap)
if pnl is not None:
if pnl > 1e-9:
d["filter_pnl"] = "profit"
elif pnl < -1e-9:
d["filter_pnl"] = "loss"
else:
d["filter_pnl"] = "flat"
else:
d["filter_pnl"] = "unknown"
sym = (d.get("symbol") or d.get("exchange_symbol") or "").strip()
d["filter_symbol"] = sym.upper().split("/")[0].split(":")[0] if sym else ""
closed = (d.get("closed_at") or d.get("created_at") or "").strip()
d["sort_ts"] = closed
if st == STRATEGY_TREND:
stats = _trend_dca_stats(snap)
d.update(stats)
legs_txt = (
f"{stats['dca_done']}/{stats['dca_total']}"
if stats["dca_total"] > 0
else "0/0"
)
d["summary_dca"] = legs_txt
else:
stats = _roll_leg_stats(snap)
d.update(stats)
d["summary_dca"] = (
f"{stats['dca_done']}/{stats['dca_total']}"
if stats["dca_total"] > 0
else ""
)
return d
def list_strategy_snapshots(conn, *, limit: int = 200) -> list[dict]:
@@ -237,5 +358,16 @@ def list_strategy_snapshots(conn, *, limit: int = 200) -> list[dict]:
d["snapshot"] = {}
st = (d.get("strategy_type") or "").strip()
d["strategy_label"] = "趋势回调" if st == STRATEGY_TREND else "顺势加仓"
out.append(d)
out.append(enrich_strategy_snapshot_row(d))
return out
def list_strategy_snapshots_split(
conn, *, limit: int = STRATEGY_SNAPSHOTS_MAX_ROWS
) -> tuple[list[dict], list[dict], list[str]]:
"""趋势 / 顺势分组,及筛选用币种列表。"""
all_rows = list_strategy_snapshots(conn, limit=limit)
trend = [r for r in all_rows if (r.get("strategy_type") or "") == STRATEGY_TREND]
roll = [r for r in all_rows if (r.get("strategy_type") or "") == STRATEGY_ROLL]
symbols = sorted({r.get("filter_symbol") or "" for r in all_rows if r.get("filter_symbol")})
return trend, roll, symbols
+254 -67
View File
@@ -1,89 +1,276 @@
{% set mf = money_fmt|default(funds_fmt) %}
<style>
.strategy-records-page{padding:4px 0 16px}
.strategy-records-page h2{margin:0 0 10px;color:#dbe4ff}
.strategy-records-tip{font-size:.78rem;color:#8892b0;line-height:1.55;margin-bottom:14px}
.strategy-records-table{width:100%;border-collapse:collapse;font-size:.82rem}
.strategy-records-table th,.strategy-records-table td{padding:8px 10px;border-bottom:1px solid #2a3150;text-align:left}
.strategy-records-table th{color:#8b95b8;font-weight:600;font-size:.74rem}
.strategy-records-table tr:hover td{background:rgba(42,63,108,.25)}
.strategy-records-table .tag-trend{color:#6ab8ff}
.strategy-records-table .tag-roll{color:#ffb020}
.strategy-records-detail{margin-top:8px;padding:10px 12px;background:#0f1424;border:1px solid #2a3150;border-radius:8px;font-size:.76rem;color:#cfd3ef;line-height:1.5}
.strategy-dca-mini{width:100%;margin-top:6px;border-collapse:collapse;font-size:.72rem}
.strategy-dca-mini th,.strategy-dca-mini td{padding:4px 8px;border-bottom:1px solid #243050}
.strategy-dca-mini .st-done{color:#4cd97f}
.strategy-dca-mini .st-pending{color:#8892b0}
.strategy-snap-pnl.pos{color:#4cd97f}
.strategy-snap-pnl.neg{color:#ff6666}
.strategy-records-page{padding:4px 0 20px}
.strategy-records-page h2{margin:0 0 8px;color:#dbe4ff}
.strategy-records-tip{font-size:.76rem;color:#8892b0;line-height:1.55;margin-bottom:12px}
.sr-filters{display:flex;flex-wrap:wrap;gap:10px 14px;align-items:center;padding:12px 14px;background:#141a2a;border:1px solid #2a3150;border-radius:10px;margin-bottom:16px}
.sr-filters label{font-size:.76rem;color:#8b95b8;display:flex;align-items:center;gap:6px}
.sr-filters select,.sr-filters input[type=datetime-local]{padding:5px 8px;background:#0f1424;border:1px solid #304164;border-radius:6px;color:#dbe4ff;font-size:.78rem}
.sr-chip-row{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
.sr-chip{padding:5px 12px;border:1px solid #304164;border-radius:16px;background:#151a2a;color:#9aa3c4;font-size:.74rem;cursor:pointer;user-select:none}
.sr-chip.active{background:#2a3f6c;color:#dbe4ff;border-color:#4a6a9a}
.sr-panels{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
@media (max-width:960px){.sr-panels{grid-template-columns:1fr}}
.sr-panel{background:#141a2a;border:1px solid #2a3150;border-radius:12px;padding:12px 14px;min-height:120px}
.sr-panel-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;gap:8px}
.sr-panel-title{font-size:.92rem;font-weight:700;color:#f0f2ff}
.sr-panel-title.trend{color:#6ab8ff}
.sr-panel-title.roll{color:#ffb020}
.sr-panel-count{font-size:.72rem;color:#8892b0}
.sr-list{display:flex;flex-direction:column;gap:8px;max-height:62vh;overflow:auto}
.sr-item{border:1px solid #243050;border-radius:10px;background:#0f1424;overflow:hidden}
.sr-item.sr-hidden{display:none}
.sr-summary{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px;padding:10px 12px;cursor:pointer;font-size:.78rem;color:#cfd3ef;line-height:1.45}
.sr-summary:hover{background:rgba(42,63,108,.2)}
.sr-summary::before{content:"▸";color:#6ab8ff;margin-right:2px;transition:transform .15s}
.sr-item.sr-open .sr-summary::before{transform:rotate(90deg)}
.sr-summary .sr-sym{font-weight:600;color:#f0f2ff}
.sr-summary .sr-pnl.pos{color:#4cd97f}
.sr-summary .sr-pnl.neg{color:#ff6666}
.sr-summary .sr-dca-tag{font-size:.7rem;color:#8892b0}
.sr-detail{display:none;padding:0 12px 12px;border-top:1px dashed #2a3558;font-size:.76rem;color:#cfd3ef}
.sr-item.sr-open .sr-detail{display:block}
.sr-detail-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px 12px;margin:10px 0}
.sr-detail-grid .lbl{color:#8b95b8;font-size:.7rem}
.sr-detail-grid .val{color:#f0f2ff}
.sr-dca-table{width:100%;border-collapse:collapse;font-size:.72rem;margin-top:8px}
.sr-dca-table th,.sr-dca-table td{padding:5px 8px;border-bottom:1px solid #243050;text-align:left}
.sr-dca-table th{color:#6a7598}
.sr-dca-table .st-done{color:#4cd97f}
.sr-dca-table .st-pending{color:#9aa3c4}
.sr-empty{padding:20px;text-align:center;color:#8892b0;font-size:.8rem}
</style>
<div class="strategy-records-page card full">
<h2>策略交易记录</h2>
<p class="strategy-records-tip">
已结束的趋势回调计划与顺势加仓组会在此留存快照(含补仓档位明细)。保本移交、手动结束、止盈止损均会写入
数据库保留最近 <strong>{{ strategy_records_limit|default(100) }}</strong> 条结束快照(按结束时间排序)
趋势回调与顺势加仓分栏展示;点击行展开详情。结束计划、保本移交、止盈止损会自动写入。
</p>
{% if strategy_snapshots %}
<div class="table-wrap">
<table class="strategy-records-table">
<tr>
<th>#</th>
<th>策略</th>
<th>品种</th>
<th>方向</th>
<th>结果</th>
<th>盈亏U</th>
<th>结束时间</th>
<th>补仓明细</th>
</tr>
{% for s in strategy_snapshots %}
{% set snap = s.snapshot or {} %}
{% set dca = snap.dca_levels if snap.dca_levels is defined else [] %}
{% set pnl = s.pnl_amount if s.pnl_amount is not none else snap.pnl_amount %}
<tr>
<td>{{ s.id }}</td>
<td><span class="{% if s.strategy_type == 'trend_pullback' %}tag-trend{% else %}tag-roll{% endif %}">{{ s.strategy_label }}</span></td>
<td>{{ s.symbol or s.exchange_symbol or '—' }}</td>
<td><span class="badge {{ 'direction-long' if s.direction == 'long' else 'direction-short' }}">{{ '做多' if s.direction == 'long' else '做空' }}</span></td>
<td>{{ s.result_label or '—' }}</td>
<td class="{% if pnl is not none %}{% if pnl|float > 0 %}strategy-snap-pnl pos{% elif pnl|float < 0 %}strategy-snap-pnl neg{% endif %}{% endif %}">
{% if pnl is not none %}{{ funds_fmt(pnl) }}{% else %}—{% endif %}
</td>
<td>{{ (s.closed_at or '')[:19] }}</td>
<td>
{% if dca and dca|length %}
<details>
<summary style="cursor:pointer;color:#8fc8ff">{{ dca|length }} </summary>
<table class="strategy-dca-mini">
<div class="sr-filters" id="sr-filters">
<label>币种
<select id="sr-filter-symbol">
<option value="">全部</option>
{% for sym in strategy_record_symbols %}
<option value="{{ sym }}">{{ sym }}</option>
{% endfor %}
</select>
</label>
<label>时间
<select id="sr-filter-time">
<option value="desc">最新优先</option>
<option value="asc">最早优先</option>
</select>
</label>
<div class="sr-chip-row">
<span style="font-size:.72rem;color:#8b95b8">筛选</span>
<button type="button" class="sr-chip" data-filter="profit">盈利</button>
<button type="button" class="sr-chip" data-filter="loss">亏损</button>
<button type="button" class="sr-chip" data-filter="no_dca">未补仓</button>
<button type="button" class="sr-chip" data-filter="dca">补仓</button>
<button type="button" class="sr-chip" id="sr-filter-reset" style="border-style:dashed">重置</button>
</div>
</div>
<div class="sr-panels">
<div class="sr-panel" data-panel="trend">
<div class="sr-panel-head">
<span class="sr-panel-title trend">趋势回调记录</span>
<span class="sr-panel-count" id="sr-trend-count">{{ strategy_trend_records|length }} </span>
</div>
<div class="sr-list" id="sr-trend-list">
{% for s in strategy_trend_records %}
{% set snap = s.snapshot or {} %}
{% set dca = snap.dca_levels if snap.dca_levels is defined else [] %}
{% set pnl = s.pnl_amount if s.pnl_amount is not none else snap.pnl_amount %}
{% set sym = s.symbol or s.exchange_symbol or '—' %}
<div class="sr-item" data-symbol="{{ s.filter_symbol or '' }}" data-pnl="{{ s.filter_pnl or '' }}" data-dca-tag="{{ s.dca_tag or '' }}" data-dca-done="{{ s.dca_done|default(0) }}" data-sort-ts="{{ s.sort_ts or '' }}">
<div class="sr-summary" role="button" tabindex="0">
<span class="sr-sym">#{{ s.id }} {{ sym }}</span>
<span class="badge {{ 'direction-long' if s.direction == 'long' else 'direction-short' }}">{{ '做多' if s.direction == 'long' else '做空' }}</span>
<span>{{ s.result_label or '—' }}</span>
<span class="sr-pnl {% if pnl is not none %}{% if pnl|float > 0 %}pos{% elif pnl|float < 0 %}neg{% endif %}{% endif %}">{% if pnl is not none %}{{ mf(pnl) }}U{% else %}—{% endif %}</span>
<span class="sr-dca-tag">补仓 {{ s.summary_dca or '—' }}</span>
<span style="color:#8892b0">{{ (s.closed_at or '')[:16] }}</span>
</div>
<div class="sr-detail">
<div class="sr-detail-grid">
<div><div class="lbl">计划 ID</div><div class="val">{{ s.source_id or '—' }}</div></div>
<div><div class="lbl">开仓</div><div class="val">{{ (s.opened_at or '')[:16] or '—' }}</div></div>
<div><div class="lbl">结束</div><div class="val">{{ (s.closed_at or '')[:16] or '—' }}</div></div>
<div><div class="lbl">均价</div><div class="val">{% if snap.avg_entry_price is not none %}{{ price_fmt(sym, snap.avg_entry_price) }}{% else %}—{% endif %}</div></div>
<div><div class="lbl">止损</div><div class="val">{% if snap.stop_loss is not none %}{{ price_fmt(sym, snap.stop_loss) }}{% else %}—{% endif %}</div></div>
<div><div class="lbl">止盈</div><div class="val">{% if snap.take_profit is not none %}{{ price_fmt(sym, snap.take_profit) }}{% else %}—{% endif %}</div></div>
<div><div class="lbl">风险%</div><div class="val">{{ snap.risk_percent if snap.risk_percent is defined else '—' }}</div></div>
<div><div class="lbl">杠杆</div><div class="val">{{ snap.leverage if snap.leverage is defined else '—' }}x</div></div>
<div><div class="lbl">计划保证金</div><div class="val">{% if snap.plan_margin_capital is not none %}{{ mf(snap.plan_margin_capital) }}U{% else %}—{% endif %}</div></div>
</div>
{% if dca and dca|length %}
<table class="sr-dca-table">
<tr><th>档位</th><th>触发价</th><th>张数</th><th>状态</th></tr>
{% for lv in dca %}
<tr>
<td>{{ lv.label or lv.leg_key }}</td>
<td>{% if lv.price is not none %}{{ price_fmt(s.symbol or s.exchange_symbol, lv.price) }}{% else %}—{% endif %}</td>
<td>{% if lv.price is not none %}{{ price_fmt(sym, lv.price) }}{% else %}—{% endif %}</td>
<td>{% if lv.contracts is not none %}{{ lv.contracts }}{% else %}—{% endif %}</td>
<td class="{% if lv.status == 'done' %}st-done{% else %}st-pending{% endif %}">{{ lv.status_label or '—' }}</td>
</tr>
{% endfor %}
</table>
</details>
{% elif s.strategy_type == 'roll' and snap.legs %}
<details>
<summary style="cursor:pointer;color:#8fc8ff">{{ snap.legs|length }} 腿</summary>
<table class="strategy-dca-mini">
<tr><th>#</th><th>状态</th></tr>
{% for leg in snap.legs %}
{% endif %}
</div>
</div>
{% else %}
<div class="sr-empty sr-empty-default">暂无趋势回调结束记录</div>
{% endfor %}
</div>
</div>
<div class="sr-panel" data-panel="roll">
<div class="sr-panel-head">
<span class="sr-panel-title roll">顺势加仓记录</span>
<span class="sr-panel-count" id="sr-roll-count">{{ strategy_roll_records|length }} 条</span>
</div>
<div class="sr-list" id="sr-roll-list">
{% for s in strategy_roll_records %}
{% set snap = s.snapshot or {} %}
{% set group = snap.group if snap.group is defined else {} %}
{% set legs = snap.legs if snap.legs is defined else [] %}
{% set pnl = s.pnl_amount if s.pnl_amount is not none else snap.pnl_amount %}
{% set sym = s.symbol or s.exchange_symbol or '—' %}
<div class="sr-item" data-symbol="{{ s.filter_symbol or '' }}" data-pnl="{{ s.filter_pnl or '' }}" data-dca-tag="{{ s.dca_tag or '' }}" data-dca-done="{{ s.dca_done|default(0) }}" data-sort-ts="{{ s.sort_ts or '' }}">
<div class="sr-summary" role="button" tabindex="0">
<span class="sr-sym">#{{ s.id }} {{ sym }}</span>
<span class="badge {{ 'direction-long' if s.direction == 'long' else 'direction-short' }}">{{ '做多' if s.direction == 'long' else '做空' }}</span>
<span>{{ s.result_label or '—' }}</span>
<span class="sr-pnl {% if pnl is not none %}{% if pnl|float > 0 %}pos{% elif pnl|float < 0 %}neg{% endif %}{% endif %}">{% if pnl is not none %}{{ mf(pnl) }}U{% else %}—{% endif %}</span>
<span class="sr-dca-tag">成交 {{ s.summary_dca or '—' }}</span>
<span style="color:#8892b0">{{ (s.closed_at or '')[:16] }}</span>
</div>
<div class="sr-detail">
<div class="sr-detail-grid">
<div><div class="lbl">组 ID</div><div class="val">{{ s.source_id or '—' }}</div></div>
<div><div class="lbl">创建</div><div class="val">{{ (s.opened_at or group.created_at or '')[:16] or '—' }}</div></div>
<div><div class="lbl">结束</div><div class="val">{{ (s.closed_at or '')[:16] or '—' }}</div></div>
<div><div class="lbl">状态</div><div class="val">{{ s.status_at_close or group.status or '—' }}</div></div>
<div><div class="lbl">杠杆</div><div class="val">{{ group.leverage if group.leverage is defined else '—' }}x</div></div>
<div><div class="lbl">备注</div><div class="val">{{ group.message if group.message is defined else '—' }}</div></div>
</div>
{% if legs and legs|length %}
<table class="sr-dca-table">
<tr><th>腿次</th><th>挂单价</th><th>张数</th><th>状态</th></tr>
{% for leg in legs %}
<tr>
<td>{{ leg.leg_index or loop.index }}</td>
<td>{{ leg.status_label or leg.status }}</td>
<td>{% if leg.limit_price is not none %}{{ price_fmt(sym, leg.limit_price) }}{% else %}—{% endif %}</td>
<td>{% if leg.order_amount is not none %}{{ leg.order_amount }}{% else %}—{% endif %}</td>
<td>{{ leg.status_label or leg.status or '—' }}</td>
</tr>
{% endfor %}
</table>
</details>
{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% endif %}
</div>
</div>
{% else %}
<div class="sr-empty sr-empty-default">暂无顺势加仓结束记录</div>
{% endfor %}
</div>
</div>
</div>
{% else %}
<div class="rule-tip" style="color:#8892b0">暂无策略结束快照。结束趋势回调计划或顺势加仓组后会自动出现在此。</div>
{% endif %}
</div>
<script>
(function(){
const symbolSel = document.getElementById("sr-filter-symbol");
const timeSel = document.getElementById("sr-filter-time");
const chips = document.querySelectorAll(".sr-chip[data-filter]");
const resetBtn = document.getElementById("sr-filter-reset");
const active = new Set();
function itemMatches(el){
const sym = (symbolSel && symbolSel.value) || "";
if(sym && (el.getAttribute("data-symbol")||"").toUpperCase() !== sym.toUpperCase()) return false;
if(active.has("profit") && el.getAttribute("data-pnl") !== "profit") return false;
if(active.has("loss") && el.getAttribute("data-pnl") !== "loss") return false;
if(active.has("no_dca")){
const tag = el.getAttribute("data-dca-tag") || "";
const done = parseInt(el.getAttribute("data-dca-done")||"0",10);
if(tag !== "no_dca" && done > 0) return false;
}
if(active.has("dca")){
const done = parseInt(el.getAttribute("data-dca-done")||"0",10);
if(!(done > 0)) return false;
}
return true;
}
function sortList(listEl){
if(!listEl) return;
const asc = timeSel && timeSel.value === "asc";
const items = Array.from(listEl.querySelectorAll(".sr-item"));
items.sort((a,b)=>{
const ta = a.getAttribute("data-sort-ts") || "";
const tb = b.getAttribute("data-sort-ts") || "";
if(ta === tb) return 0;
return asc ? (ta < tb ? -1 : 1) : (ta > tb ? -1 : 1);
});
items.forEach(it => listEl.appendChild(it));
}
function applyFilters(){
["sr-trend-list","sr-roll-list"].forEach(id=>{
const list = document.getElementById(id);
if(!list) return;
sortList(list);
let visible = 0;
list.querySelectorAll(".sr-item").forEach(el=>{
const ok = itemMatches(el);
el.classList.toggle("sr-hidden", !ok);
if(ok) visible++;
});
const panel = list.closest(".sr-panel");
const cnt = panel && panel.querySelector(".sr-panel-count");
if(cnt) cnt.textContent = visible + " 条";
let empty = list.querySelector(".sr-empty-filter");
if(!visible && !list.querySelector(".sr-empty-default")){
if(!empty){
empty = document.createElement("div");
empty.className = "sr-empty sr-empty-filter";
empty.textContent = "无符合筛选的记录";
list.appendChild(empty);
}
} else if(empty) empty.remove();
});
}
chips.forEach(ch=>{
ch.addEventListener("click", ()=>{
const k = ch.getAttribute("data-filter");
if(active.has(k)) active.delete(k); else active.add(k);
ch.classList.toggle("active", active.has(k));
applyFilters();
});
});
if(symbolSel) symbolSel.addEventListener("change", applyFilters);
if(timeSel) timeSel.addEventListener("change", applyFilters);
if(resetBtn) resetBtn.addEventListener("click", ()=>{
active.clear();
chips.forEach(c=>c.classList.remove("active"));
if(symbolSel) symbolSel.value = "";
if(timeSel) timeSel.value = "desc";
applyFilters();
});
document.querySelectorAll(".sr-summary").forEach(sum=>{
const toggle = ()=>{
const item = sum.closest(".sr-item");
if(item) item.classList.toggle("sr-open");
};
sum.addEventListener("click", toggle);
sum.addEventListener("keydown", e=>{
if(e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); }
});
});
applyFilters();
})();
</script>