finance-dashboard/index.html

1200 lines
67 KiB
HTML
Raw Permalink 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>MacroScope — 學習 · 個股 · 復盤</title>
<link rel="stylesheet" href="https://unpkg.com/vis-network@9.1.9/styles/vis-network.min.css">
<link rel="stylesheet" href="app.css">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg: #f6f7f4;
--surface: #ffffff;
--card: #ffffff;
--border: rgba(32,40,33,.10);
--text: #202421;
--text2: #667064;
--green: #1f9d66;
--red: #d84f45;
--yellow: #c88a1d;
--blue: #2367c7;
--purple: #7b57c9;
--orange: #d4772f;
--teal: #0f8f8c;
--radius: 12px;
--shadow: 0 14px 40px rgba(32,40,33,.08);
--soft-shadow: 0 1px 2px rgba(32,40,33,.06),0 12px 28px rgba(32,40,33,.06);
--glass: rgba(255,255,255,.82);
}
html{font-size:15px;background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","SF Pro Display","PingFang TC","Noto Sans TC",system-ui,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:var(--glass);
backdrop-filter:saturate(180%) blur(20px);
-webkit-backdrop-filter:saturate(180%) blur(20px);
border-bottom:1px solid var(--border);
padding:12px 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.02rem;font-weight:760;letter-spacing:0}
.logo-icon{width:30px;height:30px;border-radius:8px;background:#202421;color:#f7f3e8;display:flex;align-items:center;justify-content:center;font-size:.82rem}
.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:8px 12px;border-radius:9px;font-size:.8rem;cursor:pointer;transition:.15s;
box-shadow:0 1px 2px rgba(32,40,33,.04);
}
.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:18px 20px;box-shadow:var(--soft-shadow);
}
.guide-title{font-size:.92rem;font-weight:760;margin-bottom:12px;display:flex;align-items:center;gap:8px}
.guide-title .tag{font-size:.68rem;font-weight:760;color:#f7f3e8;background:#202421;padding:3px 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 ── */
.macro-hero{
margin:22px 32px 0;
display:grid;grid-template-columns:minmax(280px,1.05fr) minmax(360px,1.7fr) minmax(240px,.9fr);
gap:18px;align-items:stretch;
}
.hero-panel,.coverage-panel{
background:var(--surface);border:1px solid var(--border);border-radius:16px;
box-shadow:var(--shadow);padding:22px 24px;position:relative;overflow:hidden;
}
.hero-panel::before{
content:'';position:absolute;inset:0 auto 0 0;width:6px;background:var(--hero-color,var(--blue));
}
.hero-kicker{font-size:.72rem;color:var(--text2);font-weight:760;letter-spacing:.08em;margin-bottom:12px;text-transform:uppercase}
.hero-title{font-size:1.65rem;font-weight:820;line-height:1.18;margin-bottom:10px;letter-spacing:0}
.hero-copy{font-size:.88rem;color:var(--text2);line-height:1.7;max-width:620px}
.hero-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:18px}
.action-link{
border:1px solid var(--border);background:#f9faf7;color:var(--text);
border-radius:10px;padding:8px 12px;font-size:.8rem;font-weight:720;cursor:pointer;
}
.action-link:hover{border-color:var(--blue);color:var(--blue)}
.signal-bar{
margin:0;background:var(--card);border:1px solid var(--border);border-radius:16px;
padding:20px 24px;display:grid;grid-template-columns:160px 1fr;gap:18px;align-items:center;
box-shadow:var(--shadow);
}
.signal-bar .signal-regime{grid-column:1/-1;text-align:left;border-top:1px solid var(--border);padding-top:14px;display:flex;align-items:center;justify-content:space-between;gap:12px}
.signal-bar .signal-regime .label{margin:0}
.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:3.2rem;font-weight:820;line-height:1}
.signal-score .sublabel{font-size:.75rem;color:var(--text2);margin-top:4px}
.signal-details{display:grid;grid-template-columns:repeat(auto-fit,minmax(92px,1fr));gap:10px}
.signal-pill{padding:11px 12px;border-radius:10px;text-align:left;background:#f9faf7;border:1px solid var(--border)}
.signal-pill .pill-label{font-size:.7rem;color:var(--text2);letter-spacing:.02em;margin-bottom:4px;line-height:1.35}
.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}
.coverage-panel{display:flex;flex-direction:column;gap:14px}
.coverage-title{font-size:.92rem;font-weight:780}
.coverage-list{display:grid;gap:10px}
.coverage-row{display:flex;justify-content:space-between;gap:12px;font-size:.8rem;color:var(--text2);border-top:1px solid var(--border);padding-top:10px}
.coverage-row:first-child{border-top:none;padding-top:0}
.coverage-row b{font-size:1rem;color:var(--text)}
.coverage-note{font-size:.76rem;color:var(--text2);line-height:1.6;background:#f9faf7;border:1px solid var(--border);border-radius:10px;padding:10px 12px}
.coverage-good{color:var(--green)}.coverage-warn{color:var(--orange)}
/* ── 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,transform .2s;position:relative;
display:flex;flex-direction:column;
box-shadow:0 1px 2px rgba(32,40,33,.04);
}
.card:hover{border-color:rgba(35,103,199,.24);box-shadow:var(--soft-shadow);transform:translateY(-2px)}
.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-human{font-size:.74rem;color:var(--text2);line-height:1.45;margin:-2px 0 10px;min-height:2.1em}
.card-context{
font-size:.72rem;line-height:1.45;margin:-2px 0 8px;padding:6px 8px;border-radius:8px;
background:rgba(35,103,199,.06);border:1px solid rgba(35,103,199,.12);color:var(--text2);
}
.card-context.good{background:rgba(31,157,102,.07);border-color:rgba(31,157,102,.18);color:#1a6b45}
.card-context.bad{background:rgba(216,79,69,.07);border-color:rgba(216,79,69,.16);color:#9a3a32}
.card-context.warn{background:rgba(200,138,29,.08);border-color:rgba(200,138,29,.18);color:#8a5a12}
.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:360px;background:#202421;border:1px solid rgba(255,255,255,.12);
border-radius:8px;padding:12px 14px;box-shadow:var(--shadow);font-size:.76rem;line-height:1.6;
color:#c7d0c5;opacity:0;pointer-events:none;transition:opacity .12s;
}
#tooltip.show{opacity:1}
#tooltip .tip-title{color:#fff;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-formula{margin:6px 0 0;padding:8px 10px;background:rgba(0,0,0,.35);border-radius:8px;font-size:.68rem;line-height:1.45;white-space:pre-wrap;word-break:break-word}
#tooltip .tip-caveat{color:#ffb4a8}
#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-context{margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,.1)}
#tooltip .tip-link-hint{margin-top:8px;font-size:.68rem;color:#8fa0ff}
#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(0,0,0,.35);backdrop-filter:blur(12px);
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:18px;
width:min(820px,100%);max-height:90vh;overflow:auto;box-shadow:0 24px 80px rgba(0,0,0,.15);
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:var(--blue);color:#fff;border-color:var(--blue)}
.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}
/* ── 板塊熱力圖/輪動/資金 ── */
#group-sectors.section{margin-top:24px}
#group-sectors .sector-panel{margin:0;background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:18px 20px;box-shadow:var(--soft-shadow);max-width:100%;overflow:hidden}
.sector-panel-head{display:flex;flex-wrap:wrap;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:16px}
.sector-panel-head h2{font-size:1.05rem;font-weight:800;margin:0}
.sector-panel-head p{margin:6px 0 0;font-size:.78rem;color:var(--text2);line-height:1.5;max-width:100%}
.sector-rotation-banner{background:#f6f8f4;border:1px solid var(--border);border-radius:12px;padding:14px 16px;margin-bottom:16px}
.sector-rotation-banner b{display:block;font-size:.92rem;margin-bottom:6px}
.sector-rotation-banner span{font-size:.78rem;color:var(--text2);line-height:1.55}
.sector-quad-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-top:12px}
.sector-quad{padding:10px;border-radius:10px;border:1px solid var(--border);background:#fff;min-width:0;overflow:hidden}
.sector-quad em{display:block;font-size:.68rem;color:var(--text2);font-style:normal;margin-bottom:6px}
.sector-quad span{font-size:.72rem;line-height:1.4;word-break:break-word;overflow-wrap:anywhere}
.sector-heatmap-wrap{margin-bottom:16px;max-width:100%}
.sector-heatmap{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
.sector-heat-cell{border-radius:10px;border:1px solid rgba(0,0,0,.06);padding:10px;min-height:88px;display:flex;flex-direction:column;justify-content:space-between}
.sector-heat-cell b{font-size:.8rem;line-height:1.25}
.sector-heat-cell small{font-size:.65rem;color:var(--text2)}
.sector-heat-val{font-size:1rem;font-weight:800;margin:6px 0}
.sector-heat-row{display:flex;gap:6px;flex-wrap:wrap;font-size:.62rem;color:var(--text2)}
.sector-heat-row span{background:rgba(255,255,255,.55);padding:2px 5px;border-radius:4px}
.sector-flow-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px}
.sector-flow-col h4{margin:0 0 10px;font-size:.82rem}
.sector-flow-bar{display:grid;gap:6px}
.sector-flow-item{display:grid;grid-template-columns:72px 1fr 52px;gap:8px;align-items:center;font-size:.72rem}
.sector-flow-item .bar{height:8px;border-radius:4px;background:rgba(0,0,0,.06);overflow:hidden}
.sector-flow-item .bar i{display:block;height:100%;border-radius:4px}
.sector-inst-wrap{overflow-x:auto;margin-bottom:8px;-webkit-overflow-scrolling:touch}
.sector-inst-table{width:100%;min-width:320px;border-collapse:collapse;font-size:.74rem;table-layout:fixed}
.sector-inst-table th,.sector-inst-table td{padding:8px 10px;text-align:left;border-bottom:1px solid var(--border);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sector-inst-table th{color:var(--text2);font-weight:600}
.sector-inst-table td:first-child{white-space:normal}
.sector-inst-note{font-size:.7rem;color:var(--text2);line-height:1.5;margin-top:10px}
.sector-holdings-block{margin-top:18px;padding-top:16px;border-top:1px solid var(--border)}
.sector-holdings-block>h4{font-size:.82rem;margin:0 0 8px}
.sector-holdings-lead{font-size:.76rem;color:var(--text2);line-height:1.55;margin:0 0 12px}
.sector-holdings-top{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px}
.sector-hold-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:20px;border:1px solid var(--border);background:#f9faf7;font-size:.72rem}
.sector-hold-chip button{background:none;border:none;padding:0;color:var(--blue);font-weight:700;cursor:pointer;font-size:.72rem}
.sector-hold-chip small{color:var(--text2)}
.sector-holdings-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px}
.sector-hold-card{border:1px solid var(--border);border-radius:12px;padding:12px 14px;background:#fafbf8;min-width:0}
.sector-hold-card h5{margin:0 0 4px;font-size:.8rem}
.sector-hold-card .hold-reason{font-size:.68rem;color:var(--text2);line-height:1.45;margin:0 0 10px}
.sector-hold-list{list-style:none;margin:0;padding:0;display:grid;gap:6px}
.sector-hold-list li{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;align-items:center;font-size:.72rem}
.sector-hold-list button{background:none;border:none;padding:0;text-align:left;color:var(--blue);font-weight:600;cursor:pointer;font-size:.72rem}
.sector-hold-list span{color:var(--text2);font-variant-numeric:tabular-nums}
.sector-hold-faq{margin-top:12px;font-size:.72rem;color:var(--text2);line-height:1.55}
.sector-hold-faq summary{cursor:pointer;color:var(--text);font-weight:600}
@media(max-width:1100px){
.macro-hero{grid-template-columns:1fr}
.signal-bar{grid-template-columns:1fr}
.signal-bar .signal-regime{flex-wrap:wrap}
}
@media(max-width:900px){
#group-sectors.section{margin-left:16px;margin-right:16px}
.sector-quad-grid{grid-template-columns:1fr 1fr}
.sector-flow-grid{grid-template-columns:1fr}
.sector-heatmap{grid-template-columns:repeat(3,minmax(0,1fr))}
}
@media(max-width:600px){
.sector-heatmap{grid-template-columns:repeat(2,minmax(0,1fr))}
.sector-quad-grid{grid-template-columns:1fr}
.sector-holdings-grid{grid-template-columns:1fr}
}
/* ── Responsive ── */
@media(max-width:900px){
.macro-hero{grid-template-columns:1fr;margin-left:16px;margin-right:16px}
.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)}
.signal-bar .signal-regime{display:block;text-align:center}
}
@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>
MacroScope
</div>
<nav class="view-tabs" id="viewTabs">
<a data-view="macro" class="active">總經</a>
<a data-view="calendar">日曆</a>
<a data-view="watchlist">追蹤</a>
<a data-view="learn">學習教材</a>
<a data-view="stock">個股工具</a>
<a data-view="journal">交易復盤</a>
<a data-view="settings">AI 設定</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-calendar" hidden></section>
<section class="view" id="view-watchlist" hidden></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>
<section class="view" id="view-settings" hidden></section>
</main>
<div id="aiDock" class="ai-dock"></div>
<!-- 浮動說明框 -->
<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:'#34c759',red:'#ff3b30',yellow:'#ff9500',blue:'#0071e3',purple:'#af52de',orange:'#ff9500',text:'#1d1d1f',text2:'#86868b'};
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,context:c.context};
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>`;
const ctxTone=c.context?.tone&&c.context.tone!=='neutral'?` ${c.context.tone}`:'';
const ctxLine=c.context?.summary?`<div class="card-context${ctxTone}">${c.context.summary}</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}
${ctxLine}
<div class="card-sparkline">${sparkle(c.spark,180,36,sparkHex)}</div>
<div class="card-meta"><span>資料日 ${c.asOf||'—'}</span><span>點卡片看歷史走勢</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(0,0,0,.06)"/>`;
grid+=`<text x="${padL-5}" y="${toY(v)+4}" fill="#86868b" font-size="9" text-anchor="end">${v.toFixed(1)}%</text>`;}
maturities.forEach((m,i)=>{grid+=`<text x="${toX(i)}" y="${h-6}" fill="#86868b" 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="#86868b" 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>1. 風險環境</b>:先看健康分數與景氣狀態,只判斷現在是順風、逆風,還是需要保留現金。</div>
<div><b>2. 壓力來源</b>:再看哪一組在拖累:利率、通膨、就業、信用或市場情緒。這會決定你該研究哪類資產。</div>
<div><b>3. 證據細節</b>:最後才點卡片看長期走勢與事件標記。不要只看最新值,轉折比單點更重要。</div>
<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>失業率、VIX、信用利差、衰退機率這類「越高越糟」所以下降才是改善。</div>
<div><b>?</b>:滑鼠移上去或點一下,看「這數字代表什麼、歷史上算高還是低、會影響什麼」。卡片上的藍色小字是近 20 年百分位摘要。</div>
<div><b>資料缺口</b>:付費指標會以免費替代資料呈現,抓不到的項目會列在資料覆蓋狀態,不再混在表格裡讓人誤判。</div>
</div>
</div>`;
}
function macroHeroHTML(data, scoreColor, signals){
const total=(data.groups||[]).reduce((n,g)=>n+(g.cards?g.cards.length:0),0);
const substitute=(data.groups||[]).flatMap(g=>g.cards||[]).filter(c=>c.substitute).length;
const degraded=(data.degraded||[]).length;
const scored=(data.breakdown||[]).length;
const score=Number(data.score);
let title='先看風向,再決定要不要出手';
let copy='總經頁會把資料整理成一個「投資天氣」:不是要你背指標,而是快速知道現在順風在哪、風險在哪。';
if(!Number.isNaN(score)){
if(score>=70){title='環境偏順風,但仍要確認估值與部位';copy='健康分數偏高,代表多數總經訊號對風險資產較友善。接著看信用、通膨是否同步支持,避免只被股價情緒帶走。';}
else if(score>=45){title='環境混合,適合分批與等待確認';copy='訊號並不單邊,這時候最重要的是辨認拖累來源。先縮小觀察清單,等數據方向更一致再加大部位。';}
else {title='環境偏逆風,重點是防守與耐心';copy='健康分數偏低,代表風險資產面臨較多壓力。先保護本金、觀察信用和就業是否止穩,再談積極進場。';}
}
return `
<div class="macro-hero">
<section class="hero-panel" style="--hero-color:${scoreColor}">
<div class="hero-kicker">Market read</div>
<div class="hero-title">${title}</div>
<div class="hero-copy">${copy}</div>
<div class="hero-actions">
<button class="action-link" data-scroll-target="group-rates">看利率壓力</button>
<button class="action-link" data-scroll-target="group-money">看信用壓力</button>
<button class="action-link" data-scroll-target="group-sectors">看板塊輪動</button>
<button class="action-link" data-scroll-target="group-history">對照歷史</button>
</div>
</section>
<section 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>
</section>
<aside class="coverage-panel">
<div class="coverage-title">資料覆蓋狀態</div>
<div class="coverage-list">
<div class="coverage-row"><span>已接入指標</span><b>${total}</b></div>
<div class="coverage-row"><span>納入健康分數</span><b>${scored}</b></div>
<div class="coverage-row"><span>免費替代指標</span><b>${substitute}</b></div>
<div class="coverage-row"><span>暫時缺資料</span><b class="${degraded?'coverage-warn':'coverage-good'}">${degraded}</b></div>
</div>
<div class="coverage-note">${degraded?`${degraded} 個資料源暫時抓不到,頁面仍會顯示其他真實資料。`:'目前主要資料源可用;付費資料已用免費替代指標補上。'}</div>
</aside>
</div>`;
}
function fmtPct(v,d=1){
if(v==null||Number.isNaN(v)) return '—';
return (v>=0?'+':'')+v.toFixed(d)+'%';
}
function heatCellStyle(pct){
if(pct==null) return 'background:#f0f1ee';
const v=Math.max(-8,Math.min(8,pct));
if(v>=0) return `background:rgba(31,157,102,${0.15+v/8*0.45})`;
return `background:rgba(216,79,69,${0.15+Math.abs(v)/8*0.45})`;
}
function sectorSectionHTML(sd){
if(!sd||!sd.sectors) return '';
const rows=(sd.sectors||[]).filter(s=>!s.error);
const rot=sd.rotation||{};
const inst=sd.institutional||{};
const quadLabels={leading:'領漲',weakening:'轉弱',improving:'改善',lagging:'落後'};
const quadHtml=Object.keys(quadLabels).map(k=>{
const list=(rot.byQuadrant&&rot.byQuadrant[k])||[];
const names=list.map(e=>{
const m=rows.find(r=>r.etf===e);
return m?m.nameZh:e;
}).join('、')||'—';
return `<div class="sector-quad"><em>${quadLabels[k]}</em><span>${names}</span></div>`;
}).join('');
const heatHtml=rows.map(s=>{
const main=s.ret5d!=null?s.ret5d:s.ret1d;
return `<div class="sector-heat-cell" style="${heatCellStyle(main)}" title="${s.etf}">
<div><b>${s.nameZh}</b><small>${s.etf}</small></div>
<div class="sector-heat-val" style="color:${main>=0?'var(--green)':'var(--red)'}">${fmtPct(main)}</div>
<div class="sector-heat-row">
<span>1日 ${fmtPct(s.ret1d)}</span>
<span>20日 ${fmtPct(s.ret20d)}</span>
<span>RS ${fmtPct(s.rs20)}</span>
</div>
</div>`;
}).join('');
const maxFlow=Math.max(...rows.map(r=>Math.abs(r.flowScore||0)),0.01);
const flowBar=(list,title)=>{
const items=(list||[]).map(r=>{
const w=Math.min(100,Math.abs((r.flowScore||0)/maxFlow)*100);
const col=(r.flowScore||0)>=0?'var(--green)':'var(--red)';
return `<div class="sector-flow-item">
<span>${r.nameZh}</span>
<div class="bar"><i style="width:${w}%;background:${col}"></i></div>
<span>${fmtPct(r.ret5d)}</span>
</div>`;
}).join('');
return `<div class="sector-flow-col"><h4>${title}</h4><div class="sector-flow-bar">${items||'<span>—</span>'}</div></div>`;
};
const instRows=(inst.byAum||[]).slice(0,11).map(r=>`<tr>
<td>${r.nameZh} <small style="color:var(--text2)">${r.etf||''}</small></td>
<td>${inst.aumProxy?(r.sharePct!=null?r.sharePct.toFixed(1)+'%':'—'):('$'+(r.aumB!=null?r.aumB.toFixed(1):'—')+'B')}</td>
<td>${r.sharePct!=null?r.sharePct.toFixed(1)+'%':'—'}</td>
</tr>`).join('');
const instHead=inst.aumProxy?'<th>動能占比</th><th>相對權重</th>':'<th>ETF 規模</th><th>占 11 板塊合計</th>';
const exp=sd.stockExposure||{};
const topChips=(exp.topStocks||[]).slice(0,8).map(s=>`<span class="sector-hold-chip"><button type="button" class="stk-jump" data-sym="${s.symbol}">${s.symbol}</button><small>${s.name}</small></span>`).join('');
const packHtml=(exp.packs||[]).map(p=>`<div class="sector-hold-card">
<h5>${p.nameZh} <small style="color:var(--text2)">${p.etf}</small></h5>
<p class="hold-reason">${p.reason||''}</p>
<ul class="sector-hold-list">${(p.holdings||[]).map(h=>`<li><button type="button" class="stk-jump" data-sym="${h.symbol}">${h.symbol}</button><span>${h.pctFmt||(h.pct!=null?h.pct.toFixed(2)+'%':'—')}</span></li>`).join('')}</ul>
</div>`).join('');
const holdingsBlock=(exp.packs&&exp.packs.length)?`
<div class="sector-holdings-block">
<h4>機構資金落在哪些股票?</h4>
<p class="sector-holdings-lead">${exp.howToRead||''}</p>
${topChips?`<div class="sector-holdings-top">${topChips}</div>`:''}
<div class="sector-holdings-grid">${packHtml}</div>
<details class="sector-hold-faq"><summary>ETF 持股 vs 13F差在哪</summary>
<p style="margin-top:8px"><b>這裡ETF 持股)</b>:看 XLK、SPY 等基金「裡面裝什麼」,反映被動指數與板塊 ETF 的結構性配置,更新約每月。</p>
<p style="margin-top:6px"><b>13F</b>:美國管理超過 1 億美元機構每季申報的「股票部位」清單(含主動基金),約延遲 45 天,可在 SEC EDGAR 查 Berkshire、Bridgewater 等個別持倉。</p>
<p style="margin-top:6px"><b>近期流向</b>:上方「資金流向偏強/偏弱」是價量推估,不是逐筆買賣;要追單一股票可再到「個股工具」看量能與新聞。</p>
</details>
<p class="sector-inst-note">${exp.disclaimer||''}</p>
</div>`:'';
const cached=sd.cached?` · 快取${sd.cachedAt?new Date(sd.cachedAt).toLocaleString('zh-TW',{hour:'2-digit',minute:'2-digit'}):''}`:'';
return `
<div class="section" id="group-sectors">
<div class="sector-panel">
<div class="sector-panel-head">
<div>
<h2>板塊熱力圖與資金輪動</h2>
<p>以 SPDR 11 大行業 ETF 對照 ${sd.benchmark||'SPY'}:熱力圖看漲跌、輪動看相對強度、流向看價量、下方可看 ETF 實際持股(機構多透過 ETF 間接持有)。${cached}</p>
</div>
<button class="refresh-btn" type="button" id="sectorRefreshBtn">↻ 更新板塊</button>
</div>
<div class="sector-rotation-banner">
<b>目前輪動:${rot.regime||'—'}${rot.leader?` · 領先 <span style="color:var(--green)">${rot.leader.nameZh}</span>`:''}${rot.laggard?` · 落後 <span style="color:var(--red)">${rot.laggard.nameZh}</span>`:''}</b>
<span>${rot.regimeNote||''}</span>
<div class="sector-quad-grid">${quadHtml}</div>
</div>
<h4 style="font-size:.82rem;margin:0 0 8px">板塊熱力圖 <small style="color:var(--text2);font-weight:400">(格內主數字為 5 日漲跌,列為 1日20日相對大盤 RS</small></h4>
<div class="sector-heatmap-wrap"><div class="sector-heatmap">${heatHtml}</div></div>
<div class="sector-flow-grid">
${flowBar(inst.flowLeaders,'近期資金流向偏強')}
${flowBar(inst.flowLaggards,'近期資金流向偏弱')}
</div>
<h4 style="font-size:.82rem;margin:0 0 8px">板塊 ETF 規模${inst.aumProxy?'(流向動能占比)':'(總資產)'}</h4>
<div class="sector-inst-wrap">
<table class="sector-inst-table">
<thead><tr><th>板塊</th>${instHead}</tr></thead>
<tbody>${instRows}</tbody>
</table>
</div>
<p class="sector-inst-note">${inst.disclaimer||''}${inst.totalAumB!=null?` 合計約 $${inst.totalAumB.toFixed(0)}B。`:''}</p>
${holdingsBlock}
</div>
</div>`;
}
function sectorFailHTML(reason){
const msg=reason||'板塊資料暫時無法載入。請確認已用最新程式啟動伺服器(終端機執行 npm start再按 Cmd+Shift+R 強制重新整理。';
return `
<div class="section" id="group-sectors">
<div class="sector-panel">
<div class="sector-panel-head">
<div>
<h2>板塊熱力圖與資金輪動</h2>
<p>${msg}</p>
</div>
<button class="refresh-btn" type="button" id="sectorRefreshBtn">↻ 重試板塊</button>
</div>
</div>
</div>`;
}
function render(data, sectorData, sectorFailed){
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 = macroHeroHTML(data, scoreColor, signals) + guideHTML();
if(sectorData) html += sectorSectionHTML(sectorData);
else if(sectorFailed) html += sectorFailHTML();
// 降級提示
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=[];
if(sectorData||sectorFailed) nav.push(`<a data-target="group-sectors">板塊資金</a>`);
(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('');
document.getElementById('sectorRefreshBtn')?.addEventListener('click',()=>loadSectors(true).then(sd=>{
if(!window.__MACRO_DATA) return;
render(window.__MACRO_DATA, sd||null, !sd);
}));
// 更新時間
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();
document.querySelectorAll('[data-scroll-target]').forEach(btn=>btn.addEventListener('click',()=>{
const el=document.getElementById(btn.dataset.scrollTarget);
if(el) window.scrollTo({top:el.offsetTop-70,behavior:'smooth'});
}));
document.querySelectorAll('.stk-jump').forEach(btn=>btn.addEventListener('click',()=>{
const sym=btn.dataset.sym;
if(!sym) return;
if(typeof window.setStockSymbol==='function') window.setStockSymbol(sym);
location.hash='#/stock';
}));
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;
const ctx=tip.context;
let html=`<div class="tip-title">${t.label}</div>
<div class="tip-row"><span class="tip-k">這是什麼</span>${tip.what}</div>`;
if(ctx?.number) html+=`<div class="tip-row"><span class="tip-k">這數字</span>${ctx.number}</div>`;
if(ctx?.history) html+=`<div class="tip-row"><span class="tip-k">歷史上</span>${ctx.history}</div>`;
if(ctx?.affects||tip.impact) html+=`<div class="tip-row"><span class="tip-k">會影響</span>${ctx?.affects||tip.impact}</div>`;
html+=`<div class="tip-row"><span class="tip-k">怎麼看</span>${tip.how}</div>
<div class="tip-foot"><span>${tip.source||''}</span><span>${tip.freq||''}</span></div>
<div class="tip-link-hint">點卡片可看幾十年走勢,對照 2008、2020 等轉折。</div>`;
return html;
}
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};
window.setAIFocus?.({type:'macro-indicator',key,label:meta.label||key,range:MODAL.range});
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;
const ctx=tip&&tip.context;
let modalTip='';
if(ctx){
modalTip=`<div><span class="tip-k">這數字</span>${ctx.number}</div>`;
modalTip+=`<div style="margin-top:8px"><span class="tip-k">歷史上</span>${ctx.history}</div>`;
modalTip+=`<div style="margin-top:8px"><span class="tip-k">會影響</span>${ctx.affects||tip.impact||''}</div>`;
}else if(tip) modalTip=`<div><span class="tip-k">怎麼看</span>${tip.how}</div>`;
document.getElementById('modalTip').innerHTML=modalTip;
document.getElementById('modalFoot').textContent=tip?`${tip.source||''} ${tip.freq||''}`:'';
overlay.classList.add('show');
loadSeries();
}
function openScoreModal(){
MODAL={key:'__score',range:'max',isScore:true};
window.setAIFocus?.({type:'macro-score',key:'__score',label:'總經健康分數',range:'max'});
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(0,0,0,.06)"/>`;
grid+=`<text x="${padL-8}" y="${(y+3.5).toFixed(1)}" fill="#86868b" 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="#86868b" 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="#ffffff" stroke-width="2"/></g>
<rect id="hoverArea" x="${padL}" y="${padT}" width="${plotW}" height="${plotH}" fill="transparent" style="cursor:crosshair"/>
<text id="hoverText" fill="#1d1d1f" 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 loadSectors(fresh){
try{
const r=await fetch('/api/sectors'+(fresh?'?fresh=1':''));
const sd=await r.json();
if(!r.ok) return null;
return sd;
}catch{ return null; }
}
async function load(fresh){
const main=document.getElementById('view-macro');
main.innerHTML=`<div class="state"><div class="spinner"></div>正在抓取真實總經資料…</div>`;
try{
const [res,evRes,sectorRes]=await Promise.all([
fetch('/api/macro'+(fresh?'?fresh=1':'')),
fetch('/api/events').catch(()=>null),
fetch('/api/sectors'+(fresh?'?fresh=1':'')).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{} }
let sectorData=null;
let sectorFailed=false;
if(sectorRes&&sectorRes.ok){ try{sectorData=await sectorRes.json();}catch{sectorFailed=true;} }
else if(sectorRes) sectorFailed=true;
window.__MACRO_DATA=data;
render(data,sectorData,sectorFailed);
}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。你可以到頂部「AI 設定」頁貼上金鑰,系統會寫入本機 <code>.env</code>。</p>
<ol style="margin:12px 0 0 18px">
<li>到 <a href="${data.hint}" target="_blank" rel="noopener">FRED 申請頁面</a> 註冊並取得免費金鑰</li>
<li>切到「AI 設定」頁,把金鑰貼到 <code>FRED_API_KEY</code></li>
<li>按「儲存設定」後回來重新載入總經頁</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="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
<script src="lib/glossary.js"></script>
<script src="lib/learn-html.js"></script>
<script src="app.js"></script>
</body>
</html>