as-event/frontend/index.html

322 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>