finance-dashboard/index.html

856 lines
46 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Emmy 投資台 — 學習 · 財報健檢 · 交易復盤</title>
<link rel="stylesheet" href="app.css">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg: #0a0e17;
--surface: #111822;
--card: #161f2e;
--border: #1e2a3a;
--text: #e8ecf1;
--text2: #8899aa;
--green: #00d4aa;
--red: #ff4d6a;
--yellow: #ffc14d;
--blue: #4da6ff;
--purple: #b388ff;
--orange: #ff8a4d;
--radius: 10px;
--shadow: 0 2px 12px rgba(0,0,0,.35);
}
html{font-size:14px;background:var(--bg);color:var(--text);font-family:'Inter',system-ui,-apple-system,"PingFang TC","Noto Sans TC",sans-serif;-webkit-font-smoothing:antialiased}
body{min-height:100vh;padding:0 0 60px}
a{color:var(--blue);text-decoration:none}
/* ── Header ── */
.header{
position:sticky;top:0;z-index:100;
background:rgba(10,14,23,.82);
backdrop-filter:blur(12px);
border-bottom:1px solid var(--border);
padding:14px 32px;
display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;
}
.logo{display:flex;align-items:center;gap:10px;font-size:1.25rem;font-weight:700;letter-spacing:-.02em}
.logo-icon{width:28px;height:28px;border-radius:6px;background:linear-gradient(135deg,var(--blue),var(--purple));display:flex;align-items:center;justify-content:center;font-size:.85rem}
.header-right{display:flex;align-items:center;gap:16px}
.last-updated{font-size:.8rem;color:var(--text2)}
.nav-links{display:flex;gap:6px;flex-wrap:wrap}
.nav-links a{
padding:6px 14px;border-radius:6px;font-size:.85rem;font-weight:500;color:var(--text);cursor:pointer;
transition:background .15s,color .15s;
}
.nav-links a.active{background:rgba(77,166,255,.15);color:var(--blue)}
.nav-links a:hover{background:rgba(77,166,255,.08)}
.refresh-btn{
background:var(--surface);border:1px solid var(--border);color:var(--text2);
padding:6px 12px;border-radius:6px;font-size:.8rem;cursor:pointer;transition:.15s;
}
.refresh-btn:hover{border-color:var(--blue);color:var(--blue)}
/* ── 導讀條(如何閱讀)── */
.guide{
margin:20px 32px 0;background:var(--surface);border:1px solid var(--border);
border-radius:var(--radius);padding:14px 20px;
}
.guide-title{font-size:.85rem;font-weight:600;margin-bottom:10px;display:flex;align-items:center;gap:8px}
.guide-title .tag{font-size:.68rem;font-weight:600;color:var(--blue);background:rgba(77,166,255,.12);padding:2px 8px;border-radius:20px}
.guide-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:10px 24px;font-size:.78rem;color:var(--text2);line-height:1.55}
.guide-grid b{color:var(--text);font-weight:600}
.legend-dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:5px;vertical-align:middle}
/* ── Macro Signal Bar ── */
.signal-bar{
margin:20px 32px 0;background:var(--card);border:1px solid var(--border);border-radius:var(--radius);
padding:20px 28px;display:grid;grid-template-columns:1fr 2.5fr 1fr;gap:24px;align-items:center;
}
.signal-score{text-align:center;position:relative}
.signal-score .label{font-size:.75rem;color:var(--text2);letter-spacing:.04em;margin-bottom:4px;display:flex;align-items:center;justify-content:center;gap:6px}
.signal-score .value{font-size:2.8rem;font-weight:700;line-height:1}
.signal-score .sublabel{font-size:.75rem;color:var(--text2);margin-top:4px}
.signal-details{display:grid;grid-template-columns:repeat(5,1fr);gap:12px}
.signal-pill{padding:10px 14px;border-radius:8px;text-align:center;background:var(--surface);border:1px solid var(--border)}
.signal-pill .pill-label{font-size:.7rem;color:var(--text2);letter-spacing:.02em;margin-bottom:4px}
.signal-pill .pill-value{font-size:1rem;font-weight:600}
.signal-regime{text-align:center}
.signal-regime .label{font-size:.75rem;color:var(--text2);letter-spacing:.04em;margin-bottom:8px}
.regime-badge{display:inline-block;padding:8px 20px;border-radius:20px;font-weight:700;font-size:.9rem;letter-spacing:.02em}
/* ── Section ── */
.section{margin:28px 32px 0}
.section-header{display:flex;align-items:center;gap:10px;margin-bottom:6px}
.section-icon{width:28px;height:28px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:.85rem}
.section-title{font-size:1.05rem;font-weight:600;letter-spacing:-.01em}
.section-subtitle{font-size:.75rem;color:var(--text2);margin-left:8px}
.section-intro{font-size:.82rem;color:var(--text2);line-height:1.65;margin:0 0 14px 38px;max-width:880px}
/* ── Card Grid ── */
.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:14px}
.card{
background:var(--card);border:1px solid var(--border);border-radius:var(--radius);
padding:18px 20px;transition:border-color .2s,box-shadow .2s;position:relative;
display:flex;flex-direction:column;
}
.card:hover{border-color:rgba(77,166,255,.3);box-shadow:0 0 20px rgba(77,166,255,.06)}
.card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:8px}
.card-labels{display:flex;flex-direction:column;gap:1px;min-width:0}
.card-label{font-size:.82rem;color:var(--text);font-weight:600;line-height:1.3}
.card-label-en{font-size:.66rem;color:var(--text2);letter-spacing:.02em}
.card-top-right{display:flex;align-items:center;gap:6px;flex-shrink:0}
.badge{padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:600;white-space:nowrap}
.badge.good{background:rgba(0,212,170,.12);color:var(--green)}
.badge.bad{background:rgba(255,77,106,.12);color:var(--red)}
.badge.neutral{background:rgba(136,153,170,.14);color:var(--text2)}
.info-btn{
width:18px;height:18px;border-radius:50%;border:1px solid var(--border);background:var(--surface);
color:var(--text2);font-size:.7rem;line-height:1;cursor:help;flex-shrink:0;
display:flex;align-items:center;justify-content:center;transition:.15s;font-weight:700;
}
.info-btn:hover,.info-btn:focus{border-color:var(--blue);color:var(--blue);outline:none}
.sub-tag{font-size:.62rem;color:var(--orange);background:rgba(255,138,77,.12);padding:1px 6px;border-radius:4px;align-self:flex-start;margin-bottom:6px}
.card-value{font-size:1.75rem;font-weight:700;line-height:1.1;margin-bottom:2px}
.card-change{font-size:.75rem;font-weight:500;margin-bottom:10px;min-height:1em}
.card-sparkline{height:36px;margin-top:auto;opacity:.9}
.card-meta{display:flex;justify-content:space-between;gap:8px;font-size:.66rem;color:var(--text2);margin-top:8px}
/* ── Yield Curve (wide card) ── */
.card.wide{grid-column:span 2}
.c-green{color:var(--green)}.c-red{color:var(--red)}.c-yellow{color:var(--yellow)}.c-blue{color:var(--blue)}.c-purple{color:var(--purple)}.c-orange{color:var(--orange)}.c-text{color:var(--text)}.c-text2{color:var(--text2)}
/* ── Tooltip多行解釋── */
#tooltip{
position:fixed;z-index:1000;max-width:300px;background:#18222f;border:1px solid var(--border);
border-radius:8px;padding:12px 14px;box-shadow:var(--shadow);font-size:.76rem;line-height:1.6;
color:var(--text2);opacity:0;pointer-events:none;transition:opacity .12s;
}
#tooltip.show{opacity:1}
#tooltip .tip-title{color:var(--text);font-weight:700;font-size:.82rem;margin-bottom:8px}
#tooltip .tip-row{margin-bottom:7px}
#tooltip .tip-row:last-child{margin-bottom:0}
#tooltip .tip-k{color:var(--blue);font-weight:600;margin-right:4px}
#tooltip .tip-foot{margin-top:9px;padding-top:8px;border-top:1px solid var(--border);font-size:.68rem;color:var(--text2);display:flex;justify-content:space-between;gap:10px}
#tooltip .tip-break{display:flex;justify-content:space-between;gap:12px;margin-bottom:4px}
#tooltip .tip-break .d-pos{color:var(--green)}#tooltip .tip-break .d-neg{color:var(--red)}
/* ── 載入 / 錯誤 ── */
.state{margin:60px 32px;text-align:center;color:var(--text2)}
.state h2{color:var(--text);font-size:1.1rem;margin-bottom:10px}
.state .spinner{width:34px;height:34px;border:3px solid var(--border);border-top-color:var(--blue);border-radius:50%;margin:0 auto 18px;animation:spin .8s linear infinite}
.state .err-box{max-width:520px;margin:0 auto;background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:24px;text-align:left;line-height:1.7}
.state code{background:var(--surface);padding:2px 6px;border-radius:4px;color:var(--yellow);font-size:.85em}
.state .retry{margin-top:16px;background:var(--blue);color:#08111d;border:none;padding:8px 18px;border-radius:6px;font-weight:600;cursor:pointer}
.degraded-note{margin:14px 32px 0;font-size:.74rem;color:var(--orange);background:rgba(255,138,77,.08);border:1px solid rgba(255,138,77,.2);border-radius:8px;padding:8px 14px}
@keyframes spin{to{transform:rotate(360deg)}}
/* ── 走勢大圖 Modal ── */
.card[data-key]{cursor:pointer}
.card[data-key]::after{content:'點擊看走勢';position:absolute;bottom:8px;right:12px;font-size:.6rem;color:var(--text2);opacity:0;transition:opacity .15s}
.card[data-key]:hover::after{opacity:.6}
#scoreClick{cursor:pointer}
#modalOverlay{
position:fixed;inset:0;z-index:500;background:rgba(4,8,14,.72);backdrop-filter:blur(3px);
display:none;align-items:center;justify-content:center;padding:20px;
}
#modalOverlay.show{display:flex}
.modal-panel{
background:var(--card);border:1px solid var(--border);border-radius:14px;
width:min(820px,100%);max-height:90vh;overflow:auto;box-shadow:0 10px 50px rgba(0,0,0,.5);
padding:22px 24px;animation:fadeInUp .25s ease both;
}
.modal-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:4px}
.modal-title{font-size:1.15rem;font-weight:700}
.modal-title .en{font-size:.72rem;color:var(--text2);font-weight:400;margin-left:6px}
.modal-now{font-size:1.05rem;font-weight:700;margin-top:2px}
.modal-close{background:var(--surface);border:1px solid var(--border);color:var(--text2);width:30px;height:30px;border-radius:8px;font-size:1rem;cursor:pointer;flex-shrink:0}
.modal-close:hover{border-color:var(--red);color:var(--red)}
.range-btns{display:flex;gap:6px;margin:14px 0}
.range-btn{background:var(--surface);border:1px solid var(--border);color:var(--text2);padding:5px 14px;border-radius:6px;font-size:.8rem;cursor:pointer;transition:.15s}
.range-btn:hover{border-color:var(--blue)}
.range-btn.active{background:rgba(77,166,255,.15);color:var(--blue);border-color:rgba(77,166,255,.4)}
.chart-wrap{position:relative;width:100%;background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:8px}
.chart-wrap svg{display:block;width:100%;height:auto}
.chart-empty{padding:50px 0;text-align:center;color:var(--text2);font-size:.85rem}
.modal-tip{margin-top:14px;font-size:.76rem;color:var(--text2);line-height:1.6;border-top:1px solid var(--border);padding-top:12px}
.modal-tip .tip-k{color:var(--blue);font-weight:600;margin-right:4px}
.modal-foot{display:flex;justify-content:space-between;font-size:.68rem;color:var(--text2);margin-top:8px}
/* ── 歷史殷鑑(危機案例)── */
.episode-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:16px}
.episode{
background:var(--card);border:1px solid var(--border);border-radius:var(--radius);
padding:18px 20px;display:flex;flex-direction:column;
border-top:3px solid var(--ec);transition:border-color .2s,box-shadow .2s;
}
.episode:hover{box-shadow:0 0 22px rgba(0,0,0,.25)}
.ep-head{display:flex;align-items:center;gap:12px;margin-bottom:10px}
.ep-emoji{font-size:1.5rem;line-height:1}
.ep-title{font-size:1rem;font-weight:700;display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
.ep-period{font-size:.72rem;color:var(--text2);font-weight:500}
.ep-type{font-size:.64rem;font-weight:600;color:var(--ec);background:color-mix(in srgb,var(--ec) 14%,transparent);padding:2px 8px;border-radius:20px;display:inline-block;margin-top:3px}
.ep-summary{font-size:.78rem;color:var(--text2);line-height:1.6;margin-bottom:12px}
.ep-sig-title{font-size:.7rem;font-weight:600;color:var(--text);letter-spacing:.03em;margin-bottom:7px;display:flex;align-items:center;gap:6px}
.ep-sig-title::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--ec)}
.ep-sigs{display:flex;flex-direction:column;gap:7px;margin-bottom:12px}
.ep-sig{
font-size:.74rem;line-height:1.55;color:var(--text2);background:var(--surface);
border:1px solid var(--border);border-radius:7px;padding:7px 10px;cursor:pointer;transition:.15s;
}
.ep-sig:hover{border-color:var(--ec);color:var(--text)}
.ep-sig b{color:var(--text);font-weight:600}
.ep-sig .sig-go{color:var(--ec);font-size:.66rem;margin-left:4px;opacity:0;transition:.15s}
.ep-sig:hover .sig-go{opacity:1}
.ep-lesson{font-size:.76rem;line-height:1.6;color:var(--text);background:color-mix(in srgb,var(--ec) 7%,transparent);border-radius:7px;padding:9px 11px;margin-bottom:8px}
.ep-lesson b{color:var(--ec)}
.ep-watch{font-size:.74rem;line-height:1.6;color:var(--text2);margin-bottom:14px}
.ep-watch b{color:var(--text)}
.ep-btn{margin-top:auto;background:var(--surface);border:1px solid var(--border);color:var(--ec);padding:8px 14px;border-radius:7px;font-size:.78rem;font-weight:600;cursor:pointer;transition:.15s;width:100%}
.ep-btn:hover{border-color:var(--ec);background:color-mix(in srgb,var(--ec) 10%,transparent)}
.ep-legend{font-size:.74rem;color:var(--text2);margin:0 0 14px 38px;line-height:1.6;max-width:880px}
.ep-legend .ev-chip{display:inline-flex;align-items:center;gap:4px;background:var(--surface);border:1px solid var(--border);border-radius:20px;padding:2px 9px;margin:2px 4px 2px 0;font-size:.72rem}
/* ── Responsive ── */
@media(max-width:900px){
.signal-bar{grid-template-columns:1fr;gap:16px;text-align:center}
.signal-details{grid-template-columns:repeat(3,1fr)}
.card.wide{grid-column:span 1}
.header{padding:12px 16px}
.section{margin:20px 16px 0}
.signal-bar,.guide,.degraded-note{margin-left:16px;margin-right:16px}
.section-intro{margin-left:0}
}
@media(max-width:600px){
.card-grid{grid-template-columns:1fr}
.signal-details{grid-template-columns:repeat(2,1fr)}
}
@keyframes fadeInUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
.section,.signal-bar,.guide{animation:fadeInUp .45s ease both}
</style>
</head>
<body>
<!-- ─── Header ─── -->
<header class="header">
<div class="logo">
<div class="logo-icon">E</div>
Emmy 投資台
</div>
<nav class="view-tabs" id="viewTabs">
<a data-view="macro" class="active">總經</a>
<a data-view="learn">學習教材</a>
<a data-view="stock">個股工具</a>
<a data-view="journal">交易復盤</a>
</nav>
<div class="header-right">
<nav class="nav-links" id="navLinks"></nav>
<span class="last-updated" id="lastUpdated"></span>
<button class="refresh-btn" id="refreshBtn" title="重新抓取最新資料">↻ 更新</button>
</div>
</header>
<main id="main">
<section class="view" id="view-macro">
<div class="state" id="loadingState">
<div class="spinner"></div>
正在抓取真實總經資料…
</div>
</section>
<section class="view" id="view-learn" hidden></section>
<section class="view" id="view-stock" hidden></section>
<section class="view" id="view-journal" hidden></section>
</main>
<!-- 浮動說明框 -->
<div id="tooltip" role="tooltip"></div>
<!-- 走勢大圖 -->
<div id="modalOverlay" role="dialog" aria-modal="true">
<div class="modal-panel" id="modalPanel">
<div class="modal-head">
<div>
<div class="modal-title" id="modalTitle"></div>
<div class="modal-now" id="modalNow"></div>
</div>
<button class="modal-close" id="modalClose" aria-label="關閉"></button>
</div>
<div class="range-btns" id="rangeBtns"></div>
<div class="chart-wrap" id="chartWrap"></div>
<div class="modal-tip" id="modalTip"></div>
<div class="modal-foot" id="modalFoot"></div>
</div>
</div>
<script>
// ═══════════════════════════════════════════════════════════
// 顏色對應colorKey → CSS 變數 / 十六進位)
// ═══════════════════════════════════════════════════════════
const HEX = {green:'#00d4aa',red:'#ff4d6a',yellow:'#ffc14d',blue:'#4da6ff',purple:'#b388ff',orange:'#ff8a4d',text:'#e8ecf1',text2:'#8899aa'};
const cssVar = (k)=>`var(--${k})`;
// 給每張卡片的說明資料key → {label, tip, substitute}),供 tooltip 使用
const TIPS = {};
// 卡片基本資訊key → {label, labelEn, colorKey, value}),供走勢大圖使用
const CARD_META = {};
// 歷史事件標記與危機案例(由 /api/events 載入)
let EVENTS = [];
let EPISODES = [];
// ═══════════════════════════════════════════════════════════
// Sparkline SVG吃真實歷史資料
// ═══════════════════════════════════════════════════════════
function sparkle(data,w=180,h=36,color){
if(!data||data.length<2) return '';
const mn=Math.min(...data),mx=Math.max(...data),rng=mx-mn||1;
const pts=data.map((v,i)=>{
const x=(i/(data.length-1))*w;
const y=h-4-((v-mn)/rng)*(h-8);
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
const lastX=w,lastY=h-4-((data[data.length-1]-mn)/rng)*(h-8);
const id='g_'+color.replace('#','');
const areaPath=`M0,${h} `+pts.map(p=>`L${p}`).join(' ')+` L${w},${h} Z`;
const linePath=pts.map(p=>`L${p}`).join(' ').replace(/^L/,'M');
return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="${id}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${color}" stop-opacity="0.3"/>
<stop offset="100%" stop-color="${color}" stop-opacity="0.02"/>
</linearGradient></defs>
<path d="${areaPath}" fill="url(#${id})"/>
<path d="${linePath}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="${lastX.toFixed(1)}" cy="${lastY.toFixed(1)}" r="2.5" fill="${color}"/>
</svg>`;
}
// ═══════════════════════════════════════════════════════════
// 卡片 HTML
// ═══════════════════════════════════════════════════════════
function arrow(dir){return dir==='up'?'▲':dir==='down'?'▼':'●';}
function cardHTML(c){
TIPS[c.key]={label:c.label,tip:c.tip,substitute:c.substitute};
CARD_META[c.key]={label:c.label,labelEn:c.labelEn,colorKey:c.valueColorKey,value:c.value};
const valColor=cssVar(c.valueColorKey);
const chColor=cssVar(c.changeColorKey);
const sparkHex=HEX[c.valueColorKey]||HEX.blue;
const subTag=c.substitute?`<span class="sub-tag" title="免費替代指標">替代:${c.substitute}</span>`:'';
const change=c.change?`<div class="card-change" style="color:${chColor}">${arrow(c.dir)} ${c.change}</div>`:`<div class="card-change"></div>`;
return `
<div class="card" data-key="${c.key}" role="button" tabindex="0" aria-label="${c.label},點擊看走勢">
<div class="card-top">
<div class="card-labels">
<span class="card-label">${c.label}</span>
<span class="card-label-en">${c.labelEn||''}</span>
</div>
<div class="card-top-right">
<span class="badge ${c.badgeKind}">${c.badge}</span>
<button class="info-btn" data-tip-key="${c.key}" aria-label="說明:${c.label}" tabindex="0">?</button>
</div>
</div>
${subTag}
<div class="card-value" style="color:${valColor}">${c.value}</div>
${change}
<div class="card-sparkline">${sparkle(c.spark,180,36,sparkHex)}</div>
<div class="card-meta"><span>資料日 ${c.asOf||'—'}</span></div>
</div>`;
}
// ─── 殖利率曲線(真實資料)───
function yieldCurveHTML(yc){
if(!yc||!yc.maturities||yc.maturities.length<2) return '';
const maturities=yc.maturities, yields=yc.yields, prevYields=yc.prevYields;
const w=520,h=150,padL=40,padB=25,padT=14,padR=10;
const plotW=w-padL-padR, plotH=h-padB-padT;
const all=yields.concat(prevYields);
let yMin=Math.min(...all), yMax=Math.max(...all);
const pad=(yMax-yMin)*0.15||0.3; yMin-=pad; yMax+=pad;
const yRange=yMax-yMin||1;
const toX=(i)=>padL+(i/(maturities.length-1))*plotW;
const toY=(v)=>padT+(1-(v-yMin)/yRange)*plotH;
const cur=yields.map((y,i)=>`${i===0?'M':'L'}${toX(i).toFixed(1)},${toY(y).toFixed(1)}`).join(' ');
const prev=prevYields.map((y,i)=>`${i===0?'M':'L'}${toX(i).toFixed(1)},${toY(y).toFixed(1)}`).join(' ');
let grid='';
const step=(yMax-yMin)/4;
for(let k=0;k<=4;k++){const v=yMin+step*k;
grid+=`<line x1="${padL}" y1="${toY(v)}" x2="${w-padR}" y2="${toY(v)}" stroke="rgba(255,255,255,.06)"/>`;
grid+=`<text x="${padL-5}" y="${toY(v)+4}" fill="#8899aa" font-size="9" text-anchor="end">${v.toFixed(1)}%</text>`;}
maturities.forEach((m,i)=>{grid+=`<text x="${toX(i)}" y="${h-6}" fill="#8899aa" font-size="9" text-anchor="middle">${m}</text>`;});
TIPS['yield_curve']={label:'殖利率曲線',tip:{
what:'不同到期天數3個月到30年的公債殖利率連成的曲線。',
how:'正常是右上斜(長率>短率)。若左高右低(倒掛),代表短率高於長率。',
impact:'反向警訊:倒掛在歷史上是相當可靠的衰退前兆。虛線為一個月前比較。',
source:'FRED · DGS 系列', freq:'每日'},substitute:null};
return `
<div class="card wide">
<div class="card-top">
<div class="card-labels">
<span class="card-label">殖利率曲線</span>
<span class="card-label-en">Yield Curve</span>
</div>
<div class="card-top-right">
<span class="badge ${yc.inverted?'bad':'good'}">${yc.inverted?'倒掛':'正常'}</span>
<button class="info-btn" data-tip-key="yield_curve" aria-label="說明:殖利率曲線" tabindex="0">?</button>
</div>
</div>
<svg width="100%" viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg">
${grid}
<path d="${prev}" fill="none" stroke="#8899aa" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
<path d="${cur}" fill="none" stroke="#4da6ff" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
${yields.map((y,i)=>`<circle cx="${toX(i).toFixed(1)}" cy="${toY(y).toFixed(1)}" r="3.2" fill="#4da6ff" stroke="#111822" stroke-width="1.5"/>`).join('')}
<line x1="${padL+10}" y1="${padT}" x2="${padL+30}" y2="${padT}" stroke="#4da6ff" stroke-width="2"/>
<text x="${padL+34}" y="${padT+4}" fill="#4da6ff" font-size="9">目前</text>
<line x1="${padL+78}" y1="${padT}" x2="${padL+98}" y2="${padT}" stroke="#8899aa" stroke-width="1.5" stroke-dasharray="4,3" opacity=".6"/>
<text x="${padL+102}" y="${padT+4}" fill="#8899aa" font-size="9">一個月前</text>
</svg>
</div>`;
}
// ═══════════════════════════════════════════════════════════
// 渲染整頁
// ═══════════════════════════════════════════════════════════
function guideHTML(){
return `
<div class="guide">
<div class="guide-title"><span class="tag">新手必讀</span>如何閱讀這個儀表板</div>
<div class="guide-grid">
<div><b>顏色</b><span class="legend-dot" style="background:var(--green)"></span>綠=偏好訊號、<span class="legend-dot" style="background:var(--red)"></span>紅=警訊、<span class="legend-dot" style="background:var(--yellow)"></span>黃=中性/持平、<span class="legend-dot" style="background:var(--blue)"></span>藍=純數值(無好壞)。</div>
<div><b>箭頭 / 徽章</b>:▲上升、▼下降、●持平,描述「和上一期相比的變化方向」。</div>
<div><b>反向指標</b>:有些指標「數字越高越糟」,例如<b>失業率、VIX、信用利差、衰退機率</b>——這類下降才是好消息。</div>
<div><b>總經健康分數</b>:把下方關鍵指標用透明公式加總成 0100 分,越高代表環境對風險性資產越友善(滑到分數上看計算明細)。</div>
<div><b>每張卡片的「?」</b>:滑過去(手機點一下)看白話解釋:這是什麼、怎麼看、對市場的影響。</div>
<div><b>替代指標</b>:少數付費資料(如 ISM、CCI、LEI以免費的等價指標替代卡片上會標示。</div>
</div>
</div>`;
}
function render(data){
const main=document.getElementById('view-macro');
const scoreColor=cssVar(data.regime?data.regime.colorKey:'yellow');
// 訊號 pills
const signals=(data.signals||[]).map(s=>
`<div class="signal-pill"><div class="pill-label">${s.label}</div><div class="pill-value" style="color:${cssVar(s.colorKey)}">${s.value}</div></div>`
).join('');
// 健康分數說明breakdown放進 TIPS
TIPS['__score']={label:'總經健康分數怎麼算',breakdown:data.breakdown||[]};
let html = guideHTML();
html += `
<div class="signal-bar">
<div class="signal-score">
<div class="label">總經健康分數 <button class="info-btn" data-tip-key="__score" aria-label="分數計算說明" tabindex="0">?</button></div>
<div class="value" id="scoreClick" style="color:${scoreColor}" role="button" tabindex="0" title="點擊看分數走勢">${data.score ?? '--'}</div>
<div class="sublabel">/ 100 · 點擊看走勢</div>
</div>
<div class="signal-details">${signals}</div>
<div class="signal-regime">
<div class="label">目前景氣狀態</div>
<div class="regime-badge" style="background:${hexA(data.regime?data.regime.colorKey:'yellow',.15)};color:${scoreColor}">${data.regime?data.regime.label:'--'}</div>
</div>
</div>`;
// 降級提示
if(data.degraded&&data.degraded.length){
html += `<div class="degraded-note">提醒:有 ${data.degraded.length} 個指標暫時抓取失敗(${data.degraded.slice(0,3).map(d=>d.label).join('、')}${data.degraded.length>3?'…':''}),其餘為真實資料。</div>`;
}
// 各分組
const nav=[];
(data.groups||[]).forEach(g=>{
if(!g.cards||g.cards.length===0) return;
nav.push(`<a data-target="group-${g.key}">${g.title}</a>`);
let cards;
if(g.key==='rates'){
// 把殖利率曲線插進利率組(放在利差之後)
const arr=g.cards.map(cardHTML);
arr.splice(Math.min(4,arr.length),0,yieldCurveHTML(data.yieldCurve));
cards=arr.join('');
}else{
cards=g.cards.map(cardHTML).join('');
}
html += `
<div class="section" id="group-${g.key}">
<div class="section-header">
<div class="section-icon" style="background:${hexA(g.colorKey,.15)};color:${cssVar(g.colorKey)}">${g.icon}</div>
<div class="section-title">${g.title}</div>
<div class="section-subtitle">${g.titleEn}</div>
</div>
<p class="section-intro">${g.intro}</p>
<div class="card-grid">${cards}</div>
</div>`;
});
// 歷史殷鑑(危機案例)
if(EPISODES&&EPISODES.length){
nav.push(`<a data-target="group-history">歷史殷鑑</a>`);
html += episodesSectionHTML();
}
main.innerHTML=html;
document.getElementById('navLinks').innerHTML=nav.join('');
// 更新時間
const t=new Date(data.updatedAt||Date.now());
document.getElementById('lastUpdated').textContent=
`更新:${t.toLocaleDateString('zh-TW')} ${t.toLocaleTimeString('zh-TW',{hour:'2-digit',minute:'2-digit'})}${data.cached?'(快取)':''}`;
bindNav();
bindTooltips();
bindCardClicks();
bindEpisodes();
const sc=document.getElementById('scoreClick');
if(sc){sc.addEventListener('click',openScoreModal);sc.addEventListener('keydown',e=>{if(e.key==='Enter')openScoreModal();});}
}
function bindCardClicks(){
document.querySelectorAll('#main .card[data-key]').forEach(el=>{
const key=el.dataset.key;
el.addEventListener('click',(e)=>{ if(e.target.closest('.info-btn'))return; openModal(key); });
el.addEventListener('keydown',(e)=>{ if(e.key==='Enter'||e.key===' '){e.preventDefault();openModal(key);} });
});
}
function hexA(colorKey,a){
const map={green:'0,212,170',red:'255,77,106',yellow:'255,193,77',blue:'77,166,255',purple:'179,136,255',orange:'255,138,77'};
return `rgba(${map[colorKey]||map.yellow},${a})`;
}
// ─── 歷史殷鑑(危機案例)───
function episodesSectionHTML(){
const evChips=(EVENTS||[]).map(e=>`<span class="ev-chip">${e.emoji} ${e.label}${e.date.slice(0,4)}</span>`).join('');
const cards=EPISODES.map(ep=>{
const ec=cssVar(ep.colorKey||'red');
const sigs=ep.signals.map(s=>
`<div class="ep-sig" data-key="${s.key}"><b>${s.label}</b>${s.text}<span class="sig-go">看走勢 →</span></div>`
).join('');
return `
<div class="episode" style="--ec:${ec}">
<div class="ep-head">
<span class="ep-emoji">${ep.emoji}</span>
<div>
<div class="ep-title">${ep.title}<span class="ep-period">${ep.period}</span></div>
<span class="ep-type">${ep.type==='recovery'?'復甦範例':'危機'}</span>
</div>
</div>
<p class="ep-summary">${ep.summary}</p>
<div class="ep-sig-title">當時出現的預警訊號</div>
<div class="ep-sigs">${sigs}</div>
<div class="ep-lesson"><b>啟示|</b>${ep.lesson}</div>
<div class="ep-watch"><b>現在可觀察|</b>${ep.watchNow}</div>
<button class="ep-btn" data-key="${ep.focusKey}">看當時走勢(${CARD_META[ep.focusKey]?CARD_META[ep.focusKey].label:ep.focusKey})→</button>
</div>`;
}).join('');
return `
<div class="section" id="group-history">
<div class="section-header">
<div class="section-icon" style="background:${hexA('red',.15)};color:var(--red)">📜</div>
<div class="section-title">歷史殷鑑</div>
<div class="section-subtitle">Crisis Playbook</div>
</div>
<p class="section-intro">回顧幾次重大危機與反彈:當時哪些總經指標「提前」出現異常?學會辨識這些訊號,下一次就能更早觀察。點任一訊號或卡片,會打開該指標的長期走勢,並標出事件發生的時點。</p>
<p class="ep-legend">走勢大圖上的標記:${evChips}</p>
<div class="episode-grid">${cards}</div>
</div>`;
}
function bindEpisodes(){
document.querySelectorAll('#group-history .ep-sig, #group-history .ep-btn').forEach(el=>{
el.addEventListener('click',()=>{ const k=el.dataset.key; if(k) openModal(k,'max'); });
});
}
// ─── 導覽列:平滑捲動 + 捲動高亮 ───
function bindNav(){
document.querySelectorAll('#navLinks a').forEach(a=>{
a.addEventListener('click',()=>{
const el=document.getElementById(a.dataset.target);
if(el) window.scrollTo({top:el.offsetTop-70,behavior:'smooth'});
});
});
}
window.addEventListener('scroll',()=>{
const links=[...document.querySelectorAll('#navLinks a')];
let cur=null;
links.forEach(a=>{
const el=document.getElementById(a.dataset.target);
if(el&&el.offsetTop-90<=window.scrollY) cur=a;
});
links.forEach(a=>a.classList.toggle('active',a===cur));
});
// ─── Tooltip ───
const tooltipEl=document.getElementById('tooltip');
function tipContent(key){
if(key==='__score'){
const b=TIPS['__score'].breakdown||[];
const rows=b.length?b.map(x=>`<div class="tip-break"><span>${x.label}${x.note}</span><span class="${x.delta>=0?'d-pos':'d-neg'}">${x.delta>=0?'+':''}${x.delta}</span></div>`).join(''):'<div class="tip-row">尚無足夠資料計分。</div>';
return `<div class="tip-title">總經健康分數怎麼算</div><div class="tip-row" style="margin-bottom:8px">從 50 分(中性)出發,依下列規則加減:</div>${rows}`;
}
const t=TIPS[key]; if(!t||!t.tip) return '';
const tip=t.tip;
return `<div class="tip-title">${t.label}</div>
<div class="tip-row"><span class="tip-k">這是什麼</span>${tip.what}</div>
<div class="tip-row"><span class="tip-k">怎麼看</span>${tip.how}</div>
<div class="tip-row"><span class="tip-k">影響</span>${tip.impact}</div>
<div class="tip-foot"><span>${tip.source||''}</span><span>${tip.freq||''}</span></div>`;
}
function showTip(btn){
const key=btn.dataset.tipKey;
const html=tipContent(key);
if(!html) return;
tooltipEl.innerHTML=html;
tooltipEl.classList.add('show');
const r=btn.getBoundingClientRect();
const tw=tooltipEl.offsetWidth, th=tooltipEl.offsetHeight;
let left=r.left+r.width/2-tw/2;
left=Math.max(10,Math.min(left,window.innerWidth-tw-10));
let top=r.top-th-10;
if(top<10) top=r.bottom+10; // 上方空間不足就放下方
tooltipEl.style.left=left+'px';
tooltipEl.style.top=top+'px';
}
function hideTip(){tooltipEl.classList.remove('show');}
function bindTooltips(){
document.querySelectorAll('.info-btn').forEach(btn=>{
btn.addEventListener('mouseenter',()=>showTip(btn));
btn.addEventListener('mouseleave',hideTip);
btn.addEventListener('focus',()=>showTip(btn));
btn.addEventListener('blur',hideTip);
btn.addEventListener('click',(e)=>{e.preventDefault();tooltipEl.classList.contains('show')?hideTip():showTip(btn);});
});
}
// ═══════════════════════════════════════════════════════════
// 走勢大圖 Modal
// ═══════════════════════════════════════════════════════════
const overlay=document.getElementById('modalOverlay');
let MODAL={key:null,range:'1y',isScore:false};
let CHARTGEO={};
const RANGES=[['1m','1個月'],['6m','6個月'],['1y','1年'],['5y','5年'],['10y','10年'],['max','全部']];
function openModal(key,range){
// 預設開啟即顯示「全部」長期走勢,直接看到十年以上歷史與事件標記
MODAL={key,range:range||'max',isScore:false};
const meta=CARD_META[key]||{label:key};
document.getElementById('modalTitle').innerHTML=`${meta.label}<span class="en">${meta.labelEn||''}</span>`;
const now=document.getElementById('modalNow');
now.textContent=meta.value?('目前:'+meta.value):'';
now.style.color=meta.colorKey?cssVar(meta.colorKey):'var(--text)';
renderRangeBtns();
const tip=TIPS[key]&&TIPS[key].tip;
document.getElementById('modalTip').innerHTML=tip?`<div><span class="tip-k">怎麼看</span>${tip.how}</div>`:'';
document.getElementById('modalFoot').textContent=tip?`${tip.source||''} ${tip.freq||''}`:'';
overlay.classList.add('show');
loadSeries();
}
function openScoreModal(){
MODAL={key:'__score',range:'max',isScore:true};
document.getElementById('modalTitle').innerHTML='總經健康分數走勢<span class="en">Macro Health Score</span>';
document.getElementById('modalNow').textContent='';
document.getElementById('rangeBtns').innerHTML='';
document.getElementById('modalTip').innerHTML='<div><span class="tip-k">說明</span>每天記錄一筆分數,會隨使用天數累積成走勢。</div>';
document.getElementById('modalFoot').textContent='本機累積資料';
overlay.classList.add('show');
loadScoreHistory();
}
function renderRangeBtns(){
document.getElementById('rangeBtns').innerHTML=RANGES.map(([r,l])=>
`<button class="range-btn ${r===MODAL.range?'active':''}" data-range="${r}">${l}</button>`).join('');
document.querySelectorAll('#rangeBtns .range-btn').forEach(b=>
b.addEventListener('click',()=>{MODAL.range=b.dataset.range;renderRangeBtns();loadSeries();}));
}
async function loadSeries(){
const wrap=document.getElementById('chartWrap');
wrap.innerHTML='<div class="chart-empty">載入中…</div>';
try{
const res=await fetch(`/api/series/${encodeURIComponent(MODAL.key)}?range=${MODAL.range}`);
const data=await res.json();
if(!data.points||data.points.length<2){wrap.innerHTML='<div class="chart-empty">此區間資料不足。</div>';return;}
const color=HEX[(CARD_META[MODAL.key]&&CARD_META[MODAL.key].colorKey)||'blue']||HEX.blue;
wrap.innerHTML=lineChart(data.points,{format:data.format,decimals:data.decimals,color,events:EVENTS});
bindChartHover(data.points,{format:data.format,decimals:data.decimals});
// 底部標出此指標實際的資料起訖,讓人一眼知道能回看多久
const sp=data.points,src=(TIPS[MODAL.key]&&TIPS[MODAL.key].tip&&TIPS[MODAL.key].tip.source)||'';
document.getElementById('modalFoot').textContent=`資料區間 ${sp[0].date} ~ ${sp[sp.length-1].date} ${src}`;
}catch(e){wrap.innerHTML='<div class="chart-empty">載入失敗。</div>';}
}
async function loadScoreHistory(){
const wrap=document.getElementById('chartWrap');
wrap.innerHTML='<div class="chart-empty">載入中…</div>';
try{
const res=await fetch('/api/score-history');
const data=await res.json();
const pts=(data.points||[]).map(p=>({date:p.date,val:p.score}));
if(pts.length<2){wrap.innerHTML='<div class="chart-empty">分數走勢從今天開始累積,明天起就會出現曲線。</div>';return;}
wrap.innerHTML=lineChart(pts,{format:'num0',decimals:0,color:HEX.blue,yMin:0,yMax:100});
bindChartHover(pts,{format:'num0',decimals:0});
}catch(e){wrap.innerHTML='<div class="chart-empty">載入失敗。</div>';}
}
function closeModal(){overlay.classList.remove('show');}
document.getElementById('modalClose').addEventListener('click',closeModal);
overlay.addEventListener('click',e=>{if(e.target===overlay)closeModal();});
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeModal();});
// 數值格式化(與後端一致,給座標軸用)
function fmtVal(v,format,d){
d=d??2;
const c=(n,dd)=>n.toLocaleString('en-US',{minimumFractionDigits:dd,maximumFractionDigits:dd});
switch(format){
case 'pct':case 'pct_signed':return v.toFixed(d)+'%';
case 'bp':return Math.round(v)+'bp';
case 'num0':return c(v,0);
case 'num1':return v.toFixed(1);
case 'num2':case 'num2_signed':return v.toFixed(2);
case 'k':case 'k_signed':return Math.round(v)+'K';
case 'trillions':return '$'+v.toFixed(d)+'T';
case 'usd':return '$'+v.toFixed(d);
case 'usd0':return '$'+c(v,0);
default:return v.toFixed(d);
}
}
// 在已排序(日期遞增)的序列中,找最接近指定日期的索引;超出範圍回 -1
function nearestIdxByDate(points,dateStr){
const t=new Date(dateStr).getTime();
if(!points.length) return -1;
if(t<new Date(points[0].date).getTime()||t>new Date(points[points.length-1].date).getTime()) return -1;
let best=0,bestDiff=Infinity;
for(let i=0;i<points.length;i++){
const d=Math.abs(new Date(points[i].date).getTime()-t);
if(d<bestDiff){bestDiff=d;best=i;}
}
return best;
}
function lineChart(points,opts){
const w=720,h=320,padL=58,padR=16,padT=24,padB=34;
const plotW=w-padL-padR, plotH=h-padT-padB;
const vals=points.map(p=>p.val);
let yMin=opts.yMin!=null?opts.yMin:Math.min(...vals);
let yMax=opts.yMax!=null?opts.yMax:Math.max(...vals);
if(yMin===yMax){yMin-=1;yMax+=1;}
if(opts.yMin==null){const p=(yMax-yMin)*0.08;yMin-=p;yMax+=p;}
const yRange=yMax-yMin||1;
const n=points.length;
CHARTGEO={padL,plotW,padT,plotH,n,yMin,yRange,w,h};
const toX=i=>padL+(i/(n-1))*plotW;
const toY=v=>padT+(1-(v-yMin)/yRange)*plotH;
let grid='';const ticks=5;
for(let k=0;k<=ticks;k++){const v=yMin+(yRange*k/ticks);const y=toY(v);
grid+=`<line x1="${padL}" y1="${y.toFixed(1)}" x2="${w-padR}" y2="${y.toFixed(1)}" stroke="rgba(255,255,255,.06)"/>`;
grid+=`<text x="${padL-8}" y="${(y+3.5).toFixed(1)}" fill="#8899aa" font-size="11" text-anchor="end">${fmtVal(v,opts.format,opts.decimals)}</text>`;}
let xlab='';const xt=Math.min(5,n);
for(let k=0;k<xt;k++){const idx=Math.round(k*(n-1)/(xt-1));const dt=points[idx].date;
xlab+=`<text x="${toX(idx).toFixed(1)}" y="${h-12}" fill="#8899aa" font-size="10" text-anchor="middle">${dt.slice(2,7).replace('-','/')}</text>`;}
let zero='';if(yMin<0&&yMax>0){const zy=toY(0);zero=`<line x1="${padL}" y1="${zy.toFixed(1)}" x2="${w-padR}" y2="${zy.toFixed(1)}" stroke="rgba(255,255,255,.18)" stroke-dasharray="3,3"/>`;}
// 歷史事件垂直標記(只畫落在此區間內的)
let marks='';
(opts.events||[]).forEach(ev=>{
const idx=nearestIdxByDate(points,ev.date);
if(idx<0) return;
const x=toX(idx);
marks+=`<g><title>${ev.date} ${ev.label}</title>`+
`<line x1="${x.toFixed(1)}" y1="${padT}" x2="${x.toFixed(1)}" y2="${(padT+plotH).toFixed(1)}" stroke="rgba(255,193,77,.45)" stroke-width="1" stroke-dasharray="3,3"/>`+
`<text x="${x.toFixed(1)}" y="${(padT-7).toFixed(1)}" fill="#ffc14d" font-size="12" text-anchor="middle">${ev.emoji}</text>`+
`</g>`;
});
const linePts=points.map((p,i)=>`${i===0?'M':'L'}${toX(i).toFixed(1)},${toY(p.val).toFixed(1)}`).join(' ');
const area=`M${padL},${(padT+plotH).toFixed(1)} `+points.map((p,i)=>`L${toX(i).toFixed(1)},${toY(p.val).toFixed(1)}`).join(' ')+` L${(padL+plotW).toFixed(1)},${(padT+plotH).toFixed(1)} Z`;
const last=points[n-1];
return `<svg id="bigChart" viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="${opts.color}" stop-opacity=".25"/><stop offset="100%" stop-color="${opts.color}" stop-opacity="0"/></linearGradient></defs>
${grid}${xlab}${zero}
<path d="${area}" fill="url(#bgGrad)"/>
<path d="${linePts}" fill="none" stroke="${opts.color}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
${marks}
<circle cx="${toX(n-1).toFixed(1)}" cy="${toY(last.val).toFixed(1)}" r="3.5" fill="${opts.color}"/>
<g id="hoverG" style="display:none"><line id="hoverLine" y1="${padT}" y2="${padT+plotH}" stroke="#8899aa" stroke-dasharray="3,3"/><circle id="hoverDot" r="4" fill="${opts.color}" stroke="#0a0e17" stroke-width="2"/></g>
<rect id="hoverArea" x="${padL}" y="${padT}" width="${plotW}" height="${plotH}" fill="transparent" style="cursor:crosshair"/>
<text id="hoverText" fill="#e8ecf1" font-size="11.5" font-weight="600" text-anchor="middle" style="display:none"></text>
</svg>`;
}
function bindChartHover(points,opts){
const svg=document.getElementById('bigChart');if(!svg)return;
const g=CHARTGEO;
const area=document.getElementById('hoverArea');
const hg=document.getElementById('hoverG'),line=document.getElementById('hoverLine'),dot=document.getElementById('hoverDot'),txt=document.getElementById('hoverText');
const toX=i=>g.padL+(i/(g.n-1))*g.plotW;
const toY=v=>g.padT+(1-(v-g.yMin)/g.yRange)*g.plotH;
function move(evt){
const r=svg.getBoundingClientRect();
const sx=(evt.clientX-r.left)*(g.w/r.width);
let i=Math.round(((sx-g.padL)/g.plotW)*(g.n-1));
i=Math.max(0,Math.min(g.n-1,i));
const p=points[i],x=toX(i),y=toY(p.val);
hg.style.display='';line.setAttribute('x1',x);line.setAttribute('x2',x);
dot.setAttribute('cx',x);dot.setAttribute('cy',y);
txt.style.display='';txt.setAttribute('x',Math.max(46,Math.min(g.w-46,x)));txt.setAttribute('y',g.padT+11);
txt.textContent=`${p.date} ${fmtVal(p.val,opts.format,opts.decimals)}`;
}
area.addEventListener('mousemove',move);
area.addEventListener('mouseleave',()=>{hg.style.display='none';txt.style.display='none';});
}
// ═══════════════════════════════════════════════════════════
// 載入資料
// ═══════════════════════════════════════════════════════════
async function load(fresh){
const main=document.getElementById('view-macro');
main.innerHTML=`<div class="state"><div class="spinner"></div>正在抓取真實總經資料…</div>`;
try{
const [res,evRes]=await Promise.all([
fetch('/api/macro'+(fresh?'?fresh=1':'')),
fetch('/api/events').catch(()=>null),
]);
const data=await res.json();
if(!res.ok){ renderError(data); return; }
if(evRes&&evRes.ok){ try{const ev=await evRes.json();EVENTS=ev.events||[];EPISODES=ev.episodes||[];}catch{} }
render(data);
}catch(err){
renderError({message:'無法連線到伺服器。請確認伺服器已啟動npm start。',detail:String(err)});
}
}
function renderError(data){
const main=document.getElementById('view-macro');
if(data&&data.error==='missing_api_key'){
main.innerHTML=`<div class="state"><div class="err-box">
<h2>還差一步:設定免費的 FRED 金鑰</h2>
<p>本儀表板的真實資料來自美國聖路易聯儲的 FRED。請依下列步驟設定約 1 分鐘):</p>
<ol style="margin:12px 0 0 18px">
<li>到 <a href="${data.hint}" target="_blank" rel="noopener">FRED 申請頁面</a> 註冊並取得免費金鑰</li>
<li>把專案內的 <code>.env.example</code> 複製成 <code>.env</code></li>
<li>在 <code>.env</code> 填入 <code>FRED_API_KEY=你的金鑰</code></li>
<li>重新啟動伺服器:<code>npm start</code></li>
</ol>
<button class="retry" onclick="load(true)">我設定好了,重新載入</button>
</div></div>`;
return;
}
main.innerHTML=`<div class="state"><div class="err-box">
<h2>載入失敗</h2>
<p>${(data&&data.message)||'發生未知錯誤。'}</p>
${data&&data.detail?`<p style="color:var(--text2);font-size:.8rem;margin-top:8px">${data.detail}</p>`:''}
<button class="retry" onclick="load(true)">重新載入</button>
</div></div>`;
}
document.getElementById('refreshBtn').addEventListener('click',()=>load(true));
document.addEventListener('DOMContentLoaded',()=>load(false));
</script>
<script src="app.js"></script>
</body>
</html>