as-event/frontend/index.html

322 lines
15 KiB
HTML
Raw Permalink Normal View History

2026-07-27 09:05:52 +08:00
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>A股大事记录 · 大周期择时看板</title>
<script src="./vendor/echarts.min.js"></script>
<style>
:root{
--bg:#0f1420; --panel:#161d2e; --panel2:#1c2740; --line:#2a3654;
--txt:#e6ecf7; --muted:#8ea0c0; --up:#ef4444; --down:#22c55e;
--accent:#4f8cff; --warn:#f59e0b;
}
*{box-sizing:border-box}
body{margin:0;font-family:-apple-system,"PingFang SC","Microsoft YaHei",Segoe UI,sans-serif;
background:var(--bg);color:var(--txt);font-size:14px}
header{display:flex;align-items:center;gap:16px;flex-wrap:wrap;
padding:12px 18px;background:var(--panel);border-bottom:1px solid var(--line)}
header h1{font-size:16px;margin:0;font-weight:600;letter-spacing:.5px}
.idx-btns{display:flex;gap:8px}
.idx-btns button{background:var(--panel2);border:1px solid var(--line);color:var(--muted);
padding:6px 12px;border-radius:6px;cursor:pointer;transition:.15s}
.idx-btns button.active{background:var(--accent);color:#fff;border-color:var(--accent)}
.idx-btns button:hover{color:var(--txt)}
.ctrls{display:flex;align-items:center;gap:8px;margin-left:auto;flex-wrap:wrap}
input,select,textarea{background:var(--panel2);border:1px solid var(--line);color:var(--txt);
padding:6px 8px;border-radius:6px;font-size:13px}
button.act{background:var(--accent);border:1px solid var(--accent);color:#fff;padding:6px 12px;
border-radius:6px;cursor:pointer}
button.act:hover{filter:brightness(1.1)}
button.ghost{background:transparent;border:1px solid var(--line);color:var(--muted);padding:6px 12px;
border-radius:6px;cursor:pointer}
#status{font-size:12px;color:var(--muted);padding:4px 18px;background:var(--panel)}
#chart{width:100%;height:640px}
.bottom{display:grid;grid-template-columns:1.3fr 1fr;gap:14px;padding:14px 18px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px}
.card h3{margin:0 0 10px;font-size:14px;font-weight:600}
table{width:100%;border-collapse:collapse;font-size:12.5px}
th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line)}
th{color:var(--muted);font-weight:500}
.tag{padding:1px 7px;border-radius:10px;font-size:11px}
.bull{background:rgba(239,68,68,.15);color:#ff8080}
.bear{background:rgba(34,197,94,.15);color:#7ee6a0}
.neu{background:rgba(142,160,192,.15);color:var(--muted)}
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.form-grid label{display:flex;flex-direction:column;gap:3px;font-size:12px;color:var(--muted)}
.form-grid .full{grid-column:1/3}
.del{color:#ff8080;cursor:pointer;background:none;border:none;font-size:12px}
.hint{color:var(--muted);font-size:11.5px;line-height:1.6;margin-top:8px}
.legend-dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:4px;vertical-align:middle}
</style>
</head>
<body>
<header>
<h1>📈 A股大事记录 · 大周期择时看板</h1>
<div class="idx-btns" id="idxBtns"></div>
<div class="ctrls">
<input type="date" id="start" />
<span style="color:var(--muted)"></span>
<input type="date" id="end" />
<button class="act" onclick="reload()">刷新</button>
<button class="ghost" onclick="doSync()" title="从现有内网库同步指数/情绪(需配置 DSN)">同步数据</button>
</div>
</header>
<div id="status">加载中…</div>
<div id="chart"></div>
<div class="bottom">
<div class="card">
<h3>重大事件</h3>
<table id="evtTable"><thead>
<tr><th>日期</th><th>标题</th><th>类别</th><th>方向</th><th>级别</th><th>顶底</th><th></th></tr>
</thead><tbody></tbody></table>
<div class="hint">点击图上事件标记或此表可定位。事件可 CSV 批量导入:<code>POST /api/events/import</code>(模板见 ddl/event_import_template.csv</div>
</div>
<div class="card">
<h3>新增事件(人工录入)</h3>
<div class="form-grid">
<label>日期<input type="date" id="f_date" /></label>
<label>类别
<select id="f_cat"><option>监管</option><option>IPO</option><option>政策</option>
<option>资金</option><option>外部</option><option selected>其他</option></select></label>
<label class="full">标题<input type="text" id="f_title" placeholder="如:证监会立案某券商 / 某巨无霸IPO上市" /></label>
<label>方向
<select id="f_dir"><option value="bullish">利多</option>
<option value="bearish">利空</option><option value="neutral" selected>中性</option></select></label>
<label>影响级别
<select id="f_sev"><option>1</option><option>2</option><option selected>3</option>
<option>4</option><option>5</option></select></label>
<label>顶底标记
<select id="f_cycle"><option value="none" selected></option>
<option value="top">顶部信号</option><option value="bottom">底部信号</option></select></label>
<label>关联指数(可空)
<input type="text" id="f_rel" placeholder="000001.SH,399006.SZ" /></label>
<label class="full">说明<textarea id="f_desc" rows="2"></textarea></label>
</div>
<div style="margin-top:10px"><button class="act" onclick="addEvent()">保存事件</button></div>
<div class="hint">
<span class="legend-dot" style="background:#ef4444"></span>利多
<span class="legend-dot" style="background:#22c55e"></span>利空
<span class="legend-dot" style="background:#8ea0c0"></span>中性 ·
温度副图:<b style="color:#ef4444">红=过热(顶部风险)</b><b style="color:#3b82f6">蓝=冰点(底部机会)</b>
</div>
</div>
</div>
<script>
const API = "";
const state = { code:null, list:[], k:null, events:[] };
const CAT_COLOR = { bullish:"#ef4444", bearish:"#22c55e", neutral:"#8ea0c0" };
const DIR_CN = { bullish:"利多", bearish:"利空", neutral:"中性" };
let chart;
function fmt(d){ return d.toISOString().slice(0,10); }
async function j(url, opts){
const r = await fetch(API+url, opts);
if(!r.ok){ throw new Error(await r.text()); }
return r.json();
}
async function init(){
chart = echarts.init(document.getElementById("chart"), "dark");
window.addEventListener("resize", ()=>chart.resize());
const end = new Date(); const start = new Date(); start.setFullYear(end.getFullYear()-3);
document.getElementById("start").value = fmt(start);
document.getElementById("end").value = fmt(end);
document.getElementById("f_date").value = fmt(end);
try{
state.list = await j("/api/index/list");
}catch(e){ setStatus("加载指数列表失败:"+e.message); return; }
renderIdxBtns();
if(state.list.length){ state.code = state.list[0].code; await reload(); }
else setStatus("自有库暂无指数数据,请先运行 ETL 同步(见 README。");
}
function renderIdxBtns(){
const box = document.getElementById("idxBtns"); box.innerHTML="";
state.list.forEach(it=>{
const b=document.createElement("button");
b.textContent = it.name + "("+it.code+")";
b.className = it.code===state.code ? "active":"";
b.onclick = ()=>{ state.code=it.code; renderIdxBtns(); reload(); };
box.appendChild(b);
});
}
function setStatus(s){ document.getElementById("status").textContent = s; }
async function reload(){
if(!state.code) return;
const s = document.getElementById("start").value, e = document.getElementById("end").value;
setStatus("加载中…");
try{
const [k, events] = await Promise.all([
j(`/api/index/${state.code}/kline?start=${s}&end=${e}`),
j(`/api/events?start=${s}&end=${e}`)
]);
state.k = k; state.events = events;
renderChart(); renderEventTable();
const srcTxt = k.ohlc_available ? "蜡烛图(已接入OHLC源)" : "收盘线(未接入OHLC源仅收盘价)";
setStatus(`${k.index_name} · ${k.dates.length} 交易日 · ${srcTxt} · 事件 ${events.length} 条`);
}catch(err){ setStatus("加载失败:"+err.message); }
}
function nearestIdx(dates, target){
// 最后一个 <= target 的下标
let lo=0, hi=dates.length-1, ans=-1;
while(lo<=hi){ const m=(lo+hi)>>1; if(dates[m]<=target){ans=m;lo=m+1;} else hi=m-1; }
return ans;
}
function renderChart(){
const k = state.k;
const dates = k.dates;
const priceLine = k.ohlc.map(x=>x[3]); // close
const cs = k.ohlc.map(x=>[x[0],x[3],x[2],x[1]]); // [open,close,low,high]
const volData = k.amount.map((a,i)=>({
value: a==null? null : +(a/1e8).toFixed(2),
itemStyle:{ color: (k.pct_chg[i]??0) >= 0 ? "#ef4444":"#22c55e" }
}));
const temp = k.temperature;
// 事件散点(吸附到最近交易日)
const evPts = [];
state.events.forEach(ev=>{
const idx = nearestIdx(dates, ev.event_date);
if(idx<0) return;
const y = k.ohlc[idx][1] ?? k.ohlc[idx][3]; // high or close
evPts.push({
value:[dates[idx], y],
symbol: ev.cycle_tag==="top"?"triangle": ev.cycle_tag==="bottom"?"triangle":"pin",
symbolRotate: ev.cycle_tag==="bottom"?180:0,
symbolSize: 8 + (ev.severity||3)*2,
itemStyle:{ color: CAT_COLOR[ev.impact_direction]||"#8ea0c0" },
_ev: ev
});
});
const priceSeries = k.ohlc_available ? {
name:"K线", type:"candlestick", data:cs,
itemStyle:{color:"#ef4444",color0:"#22c55e",borderColor:"#ef4444",borderColor0:"#22c55e"}
} : {
name:"收盘", type:"line", data:priceLine, showSymbol:false,
lineStyle:{width:1.5,color:"#e6ecf7"}, areaStyle:{color:"rgba(79,140,255,.08)"}
};
const option = {
backgroundColor:"transparent",
animation:false,
axisPointer:{link:[{xAxisIndex:"all"}]},
tooltip:{ trigger:"axis", axisPointer:{type:"cross"},
backgroundColor:"rgba(20,28,48,.95)", borderColor:"#2a3654", textStyle:{color:"#e6ecf7"} },
grid:[
{left:60,right:24,top:20,height:"52%"},
{left:60,right:24,top:"60%",height:"14%"},
{left:60,right:24,top:"78%",height:"16%"}
],
xAxis:[
{type:"category",data:dates,scale:true,boundaryGap:false,axisLine:{lineStyle:{color:"#2a3654"}},
axisLabel:{show:false},splitLine:{show:false}},
{type:"category",gridIndex:1,data:dates,axisLabel:{show:false},axisLine:{lineStyle:{color:"#2a3654"}},
splitLine:{show:false}},
{type:"category",gridIndex:2,data:dates,axisLine:{lineStyle:{color:"#2a3654"}},
axisLabel:{color:"#8ea0c0",fontSize:10},splitLine:{show:false}}
],
yAxis:[
{scale:true,axisLabel:{color:"#8ea0c0"},splitLine:{lineStyle:{color:"rgba(42,54,84,.5)"}}},
{scale:true,gridIndex:1,name:"成交额(亿)",nameTextStyle:{color:"#8ea0c0",fontSize:10},
axisLabel:{color:"#8ea0c0",fontSize:10},splitLine:{show:false}},
{scale:true,gridIndex:2,min:0,max:100,name:"温度",nameTextStyle:{color:"#8ea0c0",fontSize:10},
axisLabel:{color:"#8ea0c0",fontSize:10},splitLine:{show:false}}
],
dataZoom:[
{type:"inside",xAxisIndex:[0,1,2],start:60,end:100},
{type:"slider",xAxisIndex:[0,1,2],bottom:6,height:16,start:60,end:100,
borderColor:"#2a3654",textStyle:{color:"#8ea0c0"}}
],
visualMap:{ show:false, seriesIndex:4, dimension:1, min:0, max:100,
inRange:{color:["#3b82f6","#93c5fd","#f59e0b","#ef4444"]} },
series:[
Object.assign({xAxisIndex:0,yAxisIndex:0}, priceSeries),
{ name:"事件", type:"scatter", data:evPts, xAxisIndex:0, yAxisIndex:0, z:10,
tooltip:{trigger:"item", formatter:p=>{
const ev=p.data._ev; if(!ev) return "";
return `<b>${ev.event_date}</b> · ${ev.category}<br/><b>${ev.title}</b><br/>`+
`方向:<span style="color:${CAT_COLOR[ev.impact_direction]}">${DIR_CN[ev.impact_direction]||""}</span>`+
` · 级别 ${ev.severity} ${ev.cycle_tag!=="none"?("· "+(ev.cycle_tag==="top"?"顶部":"底部")):""}<br/>`+
`${ev.description? "<span style='color:#8ea0c0'>"+ev.description+"</span>":""}`;
}}
},
{ name:"标注", type:"scatter", data:[], xAxisIndex:0, yAxisIndex:0 },
{ name:"成交额", type:"bar", data:volData, xAxisIndex:1, yAxisIndex:1 },
{ name:"市场温度", type:"line", data:temp, xAxisIndex:2, yAxisIndex:2, showSymbol:false,
connectNulls:false, lineStyle:{width:1.5},
markLine:{ silent:true, symbol:"none", label:{color:"#8ea0c0",fontSize:10},
data:[
{yAxis:k.thresholds?.hot??80, lineStyle:{color:"#ef4444",type:"dashed"}, label:{formatter:"过热"}},
{yAxis:k.thresholds?.cold??20, lineStyle:{color:"#3b82f6",type:"dashed"}, label:{formatter:"冰点"}}
]}
}
]
};
chart.setOption(option, true);
}
function renderEventTable(){
const tb = document.querySelector("#evtTable tbody"); tb.innerHTML="";
state.events.slice().reverse().forEach(ev=>{
const cls = ev.impact_direction==="bullish"?"bull":ev.impact_direction==="bearish"?"bear":"neu";
const tr=document.createElement("tr");
tr.innerHTML =
`<td>${ev.event_date}</td><td>${ev.title}</td><td>${ev.category}</td>`+
`<td><span class="tag ${cls}">${DIR_CN[ev.impact_direction]||""}</span></td>`+
`<td>${ev.severity}</td>`+
`<td>${ev.cycle_tag==="top"?"顶":ev.cycle_tag==="bottom"?"底":"-"}</td>`+
`<td><button class="del" onclick="delEvent(${ev.id})">删除</button></td>`;
tb.appendChild(tr);
});
}
async function addEvent(){
const payload = {
event_date: document.getElementById("f_date").value,
title: document.getElementById("f_title").value.trim(),
category: document.getElementById("f_cat").value,
impact_direction: document.getElementById("f_dir").value,
severity: parseInt(document.getElementById("f_sev").value),
cycle_tag: document.getElementById("f_cycle").value,
related_indices: document.getElementById("f_rel").value.trim() || null,
description: document.getElementById("f_desc").value.trim() || null
};
if(!payload.title){ alert("请填写标题"); return; }
try{
await j("/api/events", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)});
document.getElementById("f_title").value=""; document.getElementById("f_desc").value="";
await reload();
}catch(e){ alert("保存失败:"+e.message); }
}
async function delEvent(id){
if(!confirm("确认删除该事件?")) return;
try{ await j("/api/events/"+id, {method:"DELETE"}); await reload(); }
catch(e){ alert("删除失败:"+e.message); }
}
async function doSync(){
const s = document.getElementById("start").value;
if(!confirm("从现有内网库同步 "+s+" 至今 的指数与情绪数据?(需已配置 LEGACY_*_DSN)")) return;
setStatus("同步中…可能耗时,请稍候");
try{
const r1 = await j("/api/sync/index",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({start:s.replaceAll("-","")})});
const r2 = await j("/api/sync/sentiment",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({start:s.replaceAll("-","")})});
setStatus("同步完成:"+JSON.stringify(r1.synced)+" / 情绪 "+r2.synced+" 行。刷新中…");
state.list = await j("/api/index/list"); renderIdxBtns(); await reload();
}catch(e){ setStatus("同步失败:"+e.message+"(检查 LEGACY_*_DSN 是否配置且内网可达)"); }
}
init();
</script>
</body>
</html>