fix doc
This commit is contained in:
parent
6d93f23292
commit
ec9ea36610
658
app.css
658
app.css
|
|
@ -1,319 +1,455 @@
|
|||
/* ═══════════════════════════════════════════════════════════
|
||||
Emmy 投資台 — 學習教材 / 財報健檢 / 交易復盤 的樣式
|
||||
沿用 index.html 既有的 CSS 變數(--bg/--surface/--card…)
|
||||
Emmy 投資台 — Apple 風格 UI(簡潔、色塊、幾何互動)
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── 主視圖切換 tabs ── */
|
||||
.view-tabs{display:flex;gap:4px;flex-wrap:wrap}
|
||||
.view-tabs a{
|
||||
padding:7px 16px;border-radius:8px;font-size:.9rem;font-weight:600;color:var(--text2);cursor:pointer;
|
||||
transition:background .15s,color .15s;
|
||||
/* ── 主視圖切換(膠囊分段)── */
|
||||
.view-tabs{
|
||||
display:flex;gap:2px;flex-wrap:wrap;
|
||||
background:rgba(0,0,0,.04);border-radius:12px;padding:3px;
|
||||
}
|
||||
.view-tabs a{
|
||||
padding:8px 18px;border-radius:10px;font-size:.88rem;font-weight:600;color:var(--text2);
|
||||
cursor:pointer;transition:background .2s,color .2s,box-shadow .2s;
|
||||
}
|
||||
.view-tabs a:hover{color:var(--text)}
|
||||
.view-tabs a.active{
|
||||
background:var(--surface);color:var(--text);
|
||||
box-shadow:0 1px 4px rgba(0,0,0,.08);
|
||||
}
|
||||
.view-tabs a:hover{color:var(--text);background:rgba(77,166,255,.08)}
|
||||
.view-tabs a.active{background:rgba(77,166,255,.16);color:var(--blue)}
|
||||
|
||||
.view[hidden]{display:none}
|
||||
|
||||
/* 非總經視圖時,隱藏總經的群組子導覽 */
|
||||
body[data-view="macro"] #navLinks{display:flex}
|
||||
body:not([data-view="macro"]) #navLinks{display:none}
|
||||
|
||||
/* ── 共用:頁面區塊標題 ── */
|
||||
.page{margin:24px 32px 0;animation:fadeInUp .4s ease both}
|
||||
.page-head{margin-bottom:18px}
|
||||
.page-title{font-size:1.35rem;font-weight:700;letter-spacing:-.01em;display:flex;align-items:center;gap:10px}
|
||||
.page-sub{font-size:.85rem;color:var(--text2);margin-top:6px;line-height:1.6;max-width:880px}
|
||||
.disclaimer{font-size:.72rem;color:var(--text2);background:rgba(255,138,77,.08);border:1px solid rgba(255,138,77,.2);
|
||||
border-radius:8px;padding:8px 14px;margin-top:14px;line-height:1.6}
|
||||
/* ── 頁面 ── */
|
||||
.page{margin:28px 32px 0;animation:fadeInUp .35s ease both}
|
||||
.page-head{margin-bottom:22px}
|
||||
.page-title{font-size:1.5rem;font-weight:700;letter-spacing:-.02em}
|
||||
.page-sub{font-size:.9rem;color:var(--text2);margin-top:8px;line-height:1.65;max-width:720px}
|
||||
.disclaimer{
|
||||
font-size:.78rem;color:var(--text2);
|
||||
background:rgba(255,149,0,.08);border:1px solid rgba(255,149,0,.18);
|
||||
border-radius:var(--radius);padding:12px 16px;margin-top:16px;line-height:1.6;
|
||||
}
|
||||
|
||||
@media(max-width:900px){ .page{margin:18px 16px 0} }
|
||||
@keyframes fadeInUp{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
|
||||
@media(max-width:900px){ .page{margin:20px 16px 0} }
|
||||
|
||||
/* ── 色塊 / 分段控制(取代下拉)── */
|
||||
.chip-row,.chip-group{display:flex;flex-wrap:wrap;gap:8px}
|
||||
.chip,.chip-item{
|
||||
display:inline-flex;align-items:center;gap:6px;
|
||||
padding:10px 18px;border-radius:12px;font-size:.86rem;font-weight:600;
|
||||
border:1.5px solid var(--border);background:var(--surface);color:var(--text2);
|
||||
cursor:pointer;transition:all .18s;font-family:inherit;user-select:none;
|
||||
}
|
||||
.chip:hover,.chip-item:hover{border-color:rgba(0,113,227,.35);color:var(--text)}
|
||||
.chip.on,.chip-item.on{
|
||||
background:var(--blue);border-color:var(--blue);color:#fff;
|
||||
box-shadow:0 4px 14px rgba(0,113,227,.28);
|
||||
}
|
||||
.chip.sm,.chip-item.sm{padding:6px 14px;font-size:.8rem;border-radius:10px}
|
||||
.chip.tint-green.on{background:var(--green);border-color:var(--green);box-shadow:0 4px 14px rgba(52,199,89,.25)}
|
||||
.chip.tint-red.on{background:var(--red);border-color:var(--red);box-shadow:0 4px 14px rgba(255,59,48,.25)}
|
||||
.chip.tint-purple.on{background:var(--purple);border-color:var(--purple);box-shadow:0 4px 14px rgba(175,82,222,.25)}
|
||||
|
||||
.seg-pill{
|
||||
display:inline-flex;background:rgba(0,0,0,.05);border-radius:12px;padding:3px;gap:2px;
|
||||
}
|
||||
.seg-pill button{
|
||||
padding:8px 16px;border:none;border-radius:10px;font-size:.84rem;font-weight:600;
|
||||
background:transparent;color:var(--text2);cursor:pointer;transition:.18s;font-family:inherit;
|
||||
}
|
||||
.seg-pill button.on{background:var(--surface);color:var(--text);box-shadow:0 1px 4px rgba(0,0,0,.08)}
|
||||
|
||||
/* 大色塊選項(交易方向等) */
|
||||
.tile-row{display:flex;gap:10px;flex-wrap:wrap}
|
||||
.tile{
|
||||
flex:1;min-width:120px;padding:16px 18px;border-radius:var(--radius);
|
||||
border:2px solid var(--border);background:var(--surface);cursor:pointer;
|
||||
text-align:center;transition:all .18s;
|
||||
}
|
||||
.tile .tile-label{font-size:.95rem;font-weight:700}
|
||||
.tile .tile-sub{font-size:.72rem;color:var(--text2);margin-top:4px}
|
||||
.tile.on{border-color:var(--blue);background:rgba(0,113,227,.06);box-shadow:0 4px 16px rgba(0,113,227,.12)}
|
||||
.tile.on.tint-green{border-color:var(--green);background:rgba(52,199,89,.08)}
|
||||
.tile.on.tint-red{border-color:var(--red);background:rgba(255,59,48,.06)}
|
||||
|
||||
/* ═══════════ 學習教材 ═══════════ */
|
||||
.learn-layout{display:grid;grid-template-columns:230px 1fr;gap:22px;align-items:start}
|
||||
.learn-side{position:sticky;top:78px;display:flex;flex-direction:column;gap:4px}
|
||||
.learn-side .side-group{font-size:.7rem;color:var(--text2);letter-spacing:.06em;margin:12px 4px 4px;text-transform:uppercase}
|
||||
.learn-side a{
|
||||
padding:7px 12px;border-radius:7px;font-size:.85rem;color:var(--text);cursor:pointer;transition:.15s;
|
||||
display:flex;justify-content:space-between;align-items:center;gap:8px;
|
||||
.learn-layout{display:grid;grid-template-columns:240px 1fr;gap:24px;align-items:start}
|
||||
.learn-side{
|
||||
position:sticky;top:88px;display:flex;flex-direction:column;gap:6px;
|
||||
}
|
||||
.learn-side a:hover{background:rgba(77,166,255,.08)}
|
||||
.learn-side a.active{background:rgba(77,166,255,.15);color:var(--blue)}
|
||||
.learn-side a .count{font-size:.68rem;color:var(--text2)}
|
||||
.learn-side .side-group{
|
||||
font-size:.68rem;color:var(--text2);letter-spacing:.08em;margin:14px 4px 6px;
|
||||
text-transform:uppercase;font-weight:600;
|
||||
}
|
||||
.learn-side a,.side-tile{
|
||||
padding:10px 14px;border-radius:12px;font-size:.86rem;color:var(--text);
|
||||
cursor:pointer;transition:.15s;display:flex;justify-content:space-between;align-items:center;gap:8px;
|
||||
border:1px solid transparent;background:transparent;text-decoration:none;
|
||||
}
|
||||
.learn-side a:hover,.side-tile:hover{background:rgba(0,0,0,.04)}
|
||||
.learn-side a.active,.side-tile.active{
|
||||
background:var(--surface);border-color:var(--border);
|
||||
box-shadow:var(--shadow);font-weight:600;
|
||||
}
|
||||
.learn-side a .count{font-size:.68rem;color:var(--text2);background:rgba(0,0,0,.05);
|
||||
padding:2px 8px;border-radius:20px}
|
||||
.learn-content{min-width:0}
|
||||
|
||||
@media(max-width:780px){
|
||||
.learn-layout{grid-template-columns:1fr}
|
||||
.learn-side{position:static;flex-direction:row;flex-wrap:wrap;gap:6px;margin-bottom:14px}
|
||||
.learn-side .side-group{width:100%;margin:6px 0 0}
|
||||
.learn-side{position:static;flex-direction:row;flex-wrap:wrap}
|
||||
.learn-side .side-group{width:100%}
|
||||
}
|
||||
|
||||
/* 三階段課綱卡片 */
|
||||
.stage{margin-bottom:24px}
|
||||
.stage-title{font-size:1.05rem;font-weight:700;margin-bottom:4px;display:flex;align-items:center;gap:8px}
|
||||
.stage-badge{font-size:.66rem;font-weight:700;padding:2px 9px;border-radius:20px}
|
||||
.stage-desc{font-size:.82rem;color:var(--text2);margin-bottom:12px;line-height:1.6}
|
||||
.module-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}
|
||||
.stage{margin-bottom:28px}
|
||||
.stage-title{font-size:1.1rem;font-weight:700;margin-bottom:6px}
|
||||
.stage-badge{font-size:.66rem;font-weight:700;padding:3px 10px;border-radius:20px}
|
||||
.stage-desc{font-size:.84rem;color:var(--text2);margin-bottom:14px;line-height:1.6}
|
||||
.module-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
|
||||
.module-card{
|
||||
background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px;cursor:pointer;
|
||||
transition:border-color .2s,box-shadow .2s;display:flex;flex-direction:column;gap:8px;
|
||||
background:var(--card);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:18px 20px;cursor:pointer;transition:transform .15s,box-shadow .2s;
|
||||
display:flex;flex-direction:column;gap:8px;box-shadow:var(--shadow);
|
||||
}
|
||||
.module-card:hover{border-color:rgba(77,166,255,.35);box-shadow:0 0 18px rgba(77,166,255,.06)}
|
||||
.module-card:hover{transform:translateY(-2px);box-shadow:0 8px 28px rgba(0,0,0,.1)}
|
||||
.module-card .mod-name{font-size:.98rem;font-weight:700}
|
||||
.module-card .mod-meta{font-size:.74rem;color:var(--text2);line-height:1.55}
|
||||
.module-card .mod-tags{display:flex;flex-wrap:wrap;gap:5px;margin-top:2px}
|
||||
.chip{font-size:.68rem;color:var(--text2);background:var(--surface);border:1px solid var(--border);
|
||||
border-radius:20px;padding:2px 9px;cursor:pointer;transition:.15s;white-space:nowrap}
|
||||
.chip:hover{border-color:var(--blue);color:var(--blue)}
|
||||
.module-card .mod-meta{font-size:.76rem;color:var(--text2);line-height:1.55}
|
||||
.module-card .mod-tags{display:flex;flex-wrap:wrap;gap:6px;margin-top:4px}
|
||||
.chip-tag{
|
||||
font-size:.68rem;color:var(--text2);background:rgba(0,0,0,.04);
|
||||
border-radius:20px;padding:3px 10px;
|
||||
}
|
||||
|
||||
/* 速查(名詞 / 公司 / 單集)搜尋 */
|
||||
.search-box{display:flex;gap:8px;margin-bottom:14px}
|
||||
.search-box{display:flex;gap:10px;margin-bottom:16px}
|
||||
.search-box input{
|
||||
flex:1;background:var(--surface);border:1px solid var(--border);border-radius:8px;color:var(--text);
|
||||
padding:10px 14px;font-size:.9rem;outline:none;transition:.15s;font-family:inherit;
|
||||
flex:1;background:var(--surface);border:1px solid var(--border);border-radius:12px;
|
||||
color:var(--text);padding:12px 16px;font-size:.92rem;outline:none;
|
||||
box-shadow:var(--shadow);font-family:inherit;
|
||||
}
|
||||
.search-box input:focus{border-color:var(--blue)}
|
||||
.glossary-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}
|
||||
.search-box input:focus{border-color:var(--blue);box-shadow:0 0 0 4px rgba(0,113,227,.15)}
|
||||
.glossary-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}
|
||||
.gloss-item{
|
||||
background:var(--card);border:1px solid var(--border);border-radius:8px;padding:9px 12px;cursor:pointer;transition:.15s;
|
||||
background:var(--card);border:1px solid var(--border);border-radius:12px;
|
||||
padding:12px 14px;cursor:pointer;transition:.15s;box-shadow:var(--shadow);
|
||||
}
|
||||
.gloss-item:hover{border-color:var(--blue);background:rgba(77,166,255,.06)}
|
||||
.gloss-item .gi-title{font-size:.85rem;font-weight:600;color:var(--text)}
|
||||
.gloss-item .gi-sub{font-size:.7rem;color:var(--text2);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.list-meta{font-size:.76rem;color:var(--text2);margin-bottom:10px}
|
||||
.gloss-item:hover{border-color:rgba(0,113,227,.3);transform:translateY(-1px)}
|
||||
.gloss-item .gi-title{font-size:.86rem;font-weight:600}
|
||||
.gloss-item .gi-sub{font-size:.72rem;color:var(--text2);margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.list-meta{font-size:.78rem;color:var(--text2);margin-bottom:12px}
|
||||
|
||||
/* Markdown 內文渲染 */
|
||||
.md{font-size:.9rem;line-height:1.75;color:var(--text)}
|
||||
.md h1{font-size:1.5rem;font-weight:700;margin:.2em 0 .5em}
|
||||
.md h2{font-size:1.18rem;font-weight:700;margin:1.3em 0 .5em;padding-bottom:.3em;border-bottom:1px solid var(--border)}
|
||||
.md h3{font-size:1.02rem;font-weight:700;margin:1.1em 0 .4em;color:var(--text)}
|
||||
.md h4{font-size:.92rem;font-weight:700;margin:1em 0 .3em;color:var(--text2)}
|
||||
.md p{margin:.6em 0}
|
||||
/* Markdown */
|
||||
.md{font-size:.94rem;line-height:1.75;color:var(--text)}
|
||||
.md h1{font-size:1.55rem;font-weight:700;margin:.2em 0 .5em;letter-spacing:-.02em}
|
||||
.md h2{font-size:1.22rem;font-weight:700;margin:1.4em 0 .5em;padding-bottom:.35em;border-bottom:1px solid var(--border)}
|
||||
.md h3{font-size:1.05rem;font-weight:700;margin:1.2em 0 .4em}
|
||||
.md h4{font-size:.94rem;font-weight:600;margin:1em 0 .3em;color:var(--text2)}
|
||||
.md p{margin:.65em 0}
|
||||
.md ul,.md ol{margin:.5em 0 .5em 1.3em}
|
||||
.md li{margin:.25em 0}
|
||||
.md blockquote{border-left:3px solid var(--blue);background:var(--surface);margin:.8em 0;padding:.6em 1em;
|
||||
color:var(--text2);border-radius:0 8px 8px 0}
|
||||
.md blockquote p{margin:.2em 0}
|
||||
.md code{background:var(--surface);padding:2px 6px;border-radius:4px;color:var(--yellow);font-size:.86em;
|
||||
font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
||||
.md pre{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:12px 14px;overflow:auto;margin:.8em 0}
|
||||
.md pre code{background:none;padding:0;color:var(--text2)}
|
||||
.md hr{border:none;border-top:1px solid var(--border);margin:1.2em 0}
|
||||
.md table{border-collapse:collapse;width:100%;margin:.9em 0;font-size:.84rem;display:block;overflow-x:auto}
|
||||
.md th,.md td{border:1px solid var(--border);padding:7px 11px;text-align:left;vertical-align:top}
|
||||
.md th{background:var(--surface);font-weight:600;color:var(--text);white-space:nowrap}
|
||||
.md td{color:var(--text2)}
|
||||
.md a{color:var(--blue)}
|
||||
.md .wlink{color:var(--purple);border-bottom:1px dashed rgba(179,136,255,.4);cursor:pointer}
|
||||
.md .wlink:hover{border-bottom-style:solid}
|
||||
.md .wlink.dead{color:var(--text2);border-bottom-color:transparent;cursor:default}
|
||||
|
||||
.back-link{display:inline-flex;align-items:center;gap:6px;font-size:.82rem;color:var(--text2);cursor:pointer;margin-bottom:14px}
|
||||
.back-link:hover{color:var(--blue)}
|
||||
.note-frontmatter{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px}
|
||||
.fm-tag{font-size:.7rem;color:var(--text2);background:var(--surface);border:1px solid var(--border);border-radius:20px;padding:2px 10px}
|
||||
|
||||
/* 練習題庫 */
|
||||
.quiz-q{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:14px 16px;margin-bottom:10px}
|
||||
.quiz-q .q-text{font-size:.9rem;line-height:1.6}
|
||||
.quiz-q .q-src{font-size:.72rem;color:var(--text2);margin-top:8px;cursor:pointer}
|
||||
.quiz-q .q-src:hover{color:var(--blue)}
|
||||
|
||||
/* ═══════════ 財報健檢 ═══════════ */
|
||||
.finbox-search{display:flex;gap:8px;margin-bottom:6px;max-width:520px}
|
||||
.finbox-search input{
|
||||
flex:1;background:var(--surface);border:1px solid var(--border);border-radius:8px;color:var(--text);
|
||||
padding:11px 15px;font-size:1rem;outline:none;font-family:inherit;letter-spacing:.04em;text-transform:uppercase;
|
||||
.md li{margin:.3em 0}
|
||||
.md blockquote{
|
||||
border-left:4px solid var(--blue);background:rgba(0,113,227,.05);
|
||||
margin:.9em 0;padding:.7em 1.1em;color:var(--text2);border-radius:0 12px 12px 0;
|
||||
}
|
||||
.finbox-search input:focus{border-color:var(--blue)}
|
||||
.md code{background:rgba(0,0,0,.05);padding:2px 8px;border-radius:6px;color:var(--purple);font-size:.88em}
|
||||
.md pre{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px;overflow:auto;margin:.9em 0;box-shadow:var(--shadow)}
|
||||
.md pre code{background:none;padding:0;color:var(--text2)}
|
||||
.md table{border-collapse:collapse;width:100%;margin:1em 0;font-size:.86rem;display:block;overflow-x:auto;border-radius:12px}
|
||||
.md th,.md td{border:1px solid var(--border);padding:10px 14px;text-align:left}
|
||||
.md th{background:rgba(0,0,0,.03);font-weight:600}
|
||||
.md hr{border:none;border-top:1px solid var(--border);margin:1.4em 0}
|
||||
.md a{color:var(--blue)}
|
||||
.md .wlink{color:var(--purple);border-bottom:1px dashed rgba(175,82,222,.4);cursor:pointer;font-weight:500}
|
||||
.md .wlink:hover{border-bottom-style:solid}
|
||||
.md .wlink.dead{color:var(--text2);border-bottom:none;cursor:default}
|
||||
|
||||
/* Mermaid 圖表 */
|
||||
.mermaid-wrap{
|
||||
background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:20px;margin:1.2em 0;overflow-x:auto;box-shadow:var(--shadow);
|
||||
}
|
||||
.mermaid-wrap .mermaid{display:flex;justify-content:center}
|
||||
.mermaid-wrap svg{max-width:100%;height:auto}
|
||||
|
||||
.note-toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px}
|
||||
.back-link{
|
||||
display:inline-flex;align-items:center;gap:6px;font-size:.84rem;color:var(--text2);
|
||||
cursor:pointer;margin-bottom:16px;padding:6px 12px;border-radius:10px;
|
||||
background:rgba(0,0,0,.04);transition:.15s;
|
||||
}
|
||||
.back-link:hover{color:var(--blue);background:rgba(0,113,227,.08)}
|
||||
.note-frontmatter{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:16px}
|
||||
.fm-tag{
|
||||
font-size:.72rem;color:var(--text2);background:var(--surface);
|
||||
border:1px solid var(--border);border-radius:20px;padding:4px 12px;box-shadow:var(--shadow);
|
||||
}
|
||||
|
||||
/* 知識圖譜 */
|
||||
.graph-panel{
|
||||
background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);
|
||||
box-shadow:var(--shadow);overflow:hidden;
|
||||
}
|
||||
.graph-toolbar{
|
||||
display:flex;flex-wrap:wrap;gap:10px;align-items:center;padding:14px 16px;
|
||||
border-bottom:1px solid var(--border);
|
||||
}
|
||||
.graph-canvas{height:min(62vh,520px);background:linear-gradient(180deg,#fafafa 0%,#f5f5f7 100%)}
|
||||
.graph-foot{font-size:.74rem;color:var(--text2);padding:10px 16px;border-top:1px solid var(--border)}
|
||||
.graph-legend{display:flex;flex-wrap:wrap;gap:12px;font-size:.72rem}
|
||||
.graph-legend span{display:inline-flex;align-items:center;gap:5px}
|
||||
.graph-legend i{width:10px;height:10px;border-radius:50%;display:inline-block}
|
||||
|
||||
.quiz-q{
|
||||
background:var(--card);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:16px 18px;margin-bottom:12px;box-shadow:var(--shadow);
|
||||
}
|
||||
.quiz-q .q-text{font-size:.92rem;line-height:1.65}
|
||||
.quiz-q .q-src{font-size:.74rem;color:var(--text2);margin-top:10px;cursor:pointer}
|
||||
|
||||
/* ═══════════ 個股工具 ═══════════ */
|
||||
.finbox-search{display:flex;gap:10px;margin-bottom:8px;max-width:480px}
|
||||
.finbox-search input{
|
||||
flex:1;background:var(--surface);border:1px solid var(--border);border-radius:12px;
|
||||
color:var(--text);padding:14px 18px;font-size:1.05rem;outline:none;
|
||||
box-shadow:var(--shadow);font-family:inherit;letter-spacing:.03em;text-transform:uppercase;
|
||||
}
|
||||
.finbox-search input:focus{border-color:var(--blue);box-shadow:0 0 0 4px rgba(0,113,227,.12)}
|
||||
.finbox-search button{
|
||||
background:var(--blue);color:#08111d;border:none;padding:0 22px;border-radius:8px;font-weight:700;font-size:.92rem;cursor:pointer;
|
||||
background:var(--blue);color:#fff;border:none;padding:0 24px;border-radius:12px;
|
||||
font-weight:600;font-size:.92rem;cursor:pointer;box-shadow:0 4px 14px rgba(0,113,227,.25);
|
||||
}
|
||||
.finbox-search button:disabled{opacity:.5;cursor:wait}
|
||||
.finbox-examples{font-size:.76rem;color:var(--text2);margin-bottom:18px}
|
||||
.finbox-examples b{cursor:pointer;color:var(--blue);font-weight:600;margin:0 4px}
|
||||
.finbox-examples{font-size:.78rem;color:var(--text2);margin-bottom:20px}
|
||||
.finbox-examples b{cursor:pointer;color:var(--blue);font-weight:600;margin:0 6px}
|
||||
|
||||
.fin-summary{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:20px 24px;margin-bottom:18px;
|
||||
display:grid;grid-template-columns:auto 1fr;gap:24px;align-items:center}
|
||||
.fin-verdict{text-align:center}
|
||||
.fin-verdict .v-big{font-size:2.4rem;font-weight:800;line-height:1}
|
||||
.sub-tabs{
|
||||
display:flex;gap:4px;background:rgba(0,0,0,.04);border-radius:12px;
|
||||
padding:4px;margin:8px 0 20px;flex-wrap:wrap;width:fit-content;
|
||||
}
|
||||
.sub-tabs a{
|
||||
padding:10px 20px;border-radius:10px;font-size:.86rem;font-weight:600;
|
||||
color:var(--text2);cursor:pointer;transition:.15s;
|
||||
}
|
||||
.sub-tabs a:hover{color:var(--text)}
|
||||
.sub-tabs a.active{background:var(--surface);color:var(--blue);box-shadow:0 1px 4px rgba(0,0,0,.08)}
|
||||
.stk-pane[hidden]{display:none}
|
||||
|
||||
.chart-wrap{
|
||||
position:relative;width:100%;background:var(--surface);
|
||||
border:1px solid var(--border);border-radius:var(--radius);padding:12px;box-shadow:var(--shadow);
|
||||
}
|
||||
.chart-wrap svg{display:block;width:100%;height:auto}
|
||||
.chart-empty{padding:48px 0;text-align:center;color:var(--text2)}
|
||||
.chart-legend{display:flex;gap:16px;font-size:.78rem;color:var(--text2);margin-bottom:8px}
|
||||
.chart-legend i{display:inline-block;width:12px;height:12px;border-radius:4px;margin-right:6px}
|
||||
.chart-hover{font-size:.8rem;color:var(--text2);margin-top:8px;min-height:1.2em}
|
||||
.range-btns{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px}
|
||||
.range-btns button{
|
||||
background:var(--surface);border:1px solid var(--border);color:var(--text2);
|
||||
border-radius:10px;padding:8px 16px;font-size:.82rem;font-weight:600;
|
||||
cursor:pointer;font-family:inherit;box-shadow:var(--shadow);transition:.15s;
|
||||
}
|
||||
.range-btns button:hover{border-color:var(--blue)}
|
||||
.range-btns button.active{background:var(--blue);border-color:var(--blue);color:#fff}
|
||||
|
||||
.fin-summary{
|
||||
background:var(--card);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:22px 26px;margin-bottom:20px;display:grid;grid-template-columns:auto 1fr;
|
||||
gap:24px;align-items:center;box-shadow:var(--shadow);
|
||||
}
|
||||
.fin-verdict .v-big{font-size:2.5rem;font-weight:800;line-height:1}
|
||||
.fin-verdict .v-sub{font-size:.78rem;color:var(--text2);margin-top:4px}
|
||||
.fin-lights{display:flex;gap:18px}
|
||||
.fin-lights{display:flex;gap:20px}
|
||||
.fin-light{text-align:center}
|
||||
.fin-light .fl-num{font-size:1.6rem;font-weight:700;line-height:1}
|
||||
.fin-light .fl-lab{font-size:.72rem;color:var(--text2);margin-top:3px}
|
||||
.fin-co{font-size:.82rem;color:var(--text2);margin-bottom:8px}
|
||||
.fin-co b{color:var(--text);font-size:1.05rem}
|
||||
.fin-fresh{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;
|
||||
font-size:.74rem;color:var(--text2);margin-bottom:16px}
|
||||
.fin-light .fl-num{font-size:1.55rem;font-weight:700}
|
||||
.fin-light .fl-lab{font-size:.72rem;color:var(--text2);margin-top:4px}
|
||||
.fin-co{font-size:.84rem;color:var(--text2);margin-bottom:10px}
|
||||
.fin-co b{color:var(--text);font-size:1.08rem}
|
||||
.fin-fresh{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;font-size:.76rem;color:var(--text2);margin-bottom:18px}
|
||||
|
||||
.fin-step{margin-bottom:18px}
|
||||
.fin-step-head{display:flex;align-items:center;gap:10px;margin-bottom:8px}
|
||||
.fin-step-num{width:24px;height:24px;border-radius:7px;background:rgba(77,166,255,.15);color:var(--blue);
|
||||
font-size:.78rem;font-weight:700;display:flex;align-items:center;justify-content:center}
|
||||
.fin-step-title{font-size:1rem;font-weight:700}
|
||||
.fin-step{margin-bottom:20px}
|
||||
.fin-step-head{display:flex;align-items:center;gap:10px;margin-bottom:10px}
|
||||
.fin-step-num{
|
||||
width:28px;height:28px;border-radius:10px;background:rgba(0,113,227,.1);
|
||||
color:var(--blue);font-size:.8rem;font-weight:700;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
}
|
||||
.fin-step-title{font-size:1.02rem;font-weight:700}
|
||||
.check-row{
|
||||
background:var(--card);border:1px solid var(--border);border-radius:9px;padding:11px 14px;margin-bottom:8px;
|
||||
display:grid;grid-template-columns:8px 1fr auto;gap:12px;align-items:center;border-left:3px solid var(--border);
|
||||
background:var(--card);border:1px solid var(--border);border-radius:12px;
|
||||
padding:14px 16px;margin-bottom:8px;display:grid;grid-template-columns:10px 1fr auto;
|
||||
gap:14px;align-items:center;border-left:4px solid var(--border);box-shadow:var(--shadow);
|
||||
}
|
||||
.check-row.good{border-left-color:var(--green)}
|
||||
.check-row.warn{border-left-color:var(--yellow)}
|
||||
.check-row.bad{border-left-color:var(--red)}
|
||||
.check-row.na{border-left-color:var(--text2);opacity:.7}
|
||||
.check-dot{width:9px;height:9px;border-radius:50%}
|
||||
.check-row.na{border-left-color:var(--text2);opacity:.75}
|
||||
.check-dot{width:10px;height:10px;border-radius:50%}
|
||||
.check-row.good .check-dot{background:var(--green)}
|
||||
.check-row.warn .check-dot{background:var(--yellow)}
|
||||
.check-row.bad .check-dot{background:var(--red)}
|
||||
.check-row.na .check-dot{background:var(--text2)}
|
||||
.check-main .ck-label{font-size:.88rem;font-weight:600}
|
||||
.check-main .ck-note{font-size:.78rem;color:var(--text2);line-height:1.55;margin-top:3px}
|
||||
.check-main .ck-links{margin-top:5px;display:flex;flex-wrap:wrap;gap:6px}
|
||||
.check-main .ck-links .wlink{font-size:.72rem}
|
||||
.check-val{font-size:1.05rem;font-weight:700;text-align:right;white-space:nowrap}
|
||||
.check-val.good{color:var(--green)}.check-val.warn{color:var(--yellow)}.check-val.bad{color:var(--red)}.check-val.na{color:var(--text2)}
|
||||
|
||||
/* ═══════════ 交易復盤 ═══════════ */
|
||||
.stat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:22px}
|
||||
.stat-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px}
|
||||
.stat-card .st-lab{font-size:.74rem;color:var(--text2);margin-bottom:6px}
|
||||
.stat-card .st-val{font-size:1.7rem;font-weight:700;line-height:1}
|
||||
.stat-card .st-sub{font-size:.72rem;color:var(--text2);margin-top:4px}
|
||||
|
||||
.btn{background:var(--blue);color:#08111d;border:none;padding:8px 16px;border-radius:7px;font-weight:600;font-size:.85rem;cursor:pointer;transition:.15s}
|
||||
.btn:hover{filter:brightness(1.08)}
|
||||
.btn.ghost{background:var(--surface);border:1px solid var(--border);color:var(--text2)}
|
||||
.btn.ghost:hover{border-color:var(--blue);color:var(--blue)}
|
||||
.btn.danger{background:var(--surface);border:1px solid var(--border);color:var(--red)}
|
||||
.btn.danger:hover{border-color:var(--red)}
|
||||
.btn.sm{padding:4px 10px;font-size:.76rem}
|
||||
|
||||
.journal-bar{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap}
|
||||
.seg{display:flex;gap:4px;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:3px}
|
||||
.seg a{padding:6px 14px;border-radius:6px;font-size:.82rem;color:var(--text2);cursor:pointer;transition:.15s}
|
||||
.seg a.active{background:var(--card);color:var(--text)}
|
||||
|
||||
.trade-table{width:100%;border-collapse:collapse;font-size:.82rem}
|
||||
.trade-table th{text-align:left;padding:9px 10px;color:var(--text2);font-weight:600;font-size:.74rem;
|
||||
border-bottom:1px solid var(--border);white-space:nowrap}
|
||||
.trade-table td{padding:10px;border-bottom:1px solid var(--border);vertical-align:middle}
|
||||
.trade-table tr:hover td{background:rgba(77,166,255,.04)}
|
||||
.t-sym{font-weight:700;color:var(--text)}
|
||||
.t-sym .t-name{font-weight:400;color:var(--text2);font-size:.76rem;margin-left:5px}
|
||||
.pill{font-size:.68rem;font-weight:600;padding:2px 8px;border-radius:20px;white-space:nowrap}
|
||||
.pill.long{background:rgba(0,212,170,.12);color:var(--green)}
|
||||
.pill.short{background:rgba(255,77,106,.12);color:var(--red)}
|
||||
.pill.invest{background:rgba(77,166,255,.12);color:var(--blue)}
|
||||
.pill.trade{background:rgba(179,136,255,.12);color:var(--purple)}
|
||||
.pill.open{background:rgba(255,193,77,.12);color:var(--yellow)}
|
||||
.pill.mistake{background:rgba(255,77,106,.14);color:var(--red)}
|
||||
.pnl-pos{color:var(--green);font-weight:700}
|
||||
.pnl-neg{color:var(--red);font-weight:700}
|
||||
.t-actions{display:flex;gap:6px;justify-content:flex-end}
|
||||
.empty-state{text-align:center;color:var(--text2);padding:50px 20px;font-size:.9rem}
|
||||
|
||||
.group-stat{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:14px 16px;margin-bottom:10px}
|
||||
.group-stat h4{font-size:.86rem;margin-bottom:10px;color:var(--text)}
|
||||
.gs-row{display:grid;grid-template-columns:1fr auto auto auto;gap:10px;font-size:.8rem;padding:5px 0;border-top:1px solid var(--border)}
|
||||
.gs-row:first-of-type{border-top:none}
|
||||
.gs-row .gs-name{color:var(--text)}
|
||||
.gs-row .gs-cell{color:var(--text2);text-align:right;min-width:64px}
|
||||
|
||||
/* Modal 表單(沿用 index.html 的 #modalOverlay 樣式,這裡補表單元素) */
|
||||
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px 14px;margin-top:6px}
|
||||
.form-grid .full{grid-column:1/-1}
|
||||
.field label{display:block;font-size:.74rem;color:var(--text2);margin-bottom:4px}
|
||||
.field input,.field select,.field textarea{
|
||||
width:100%;background:var(--surface);border:1px solid var(--border);border-radius:7px;color:var(--text);
|
||||
padding:8px 11px;font-size:.86rem;outline:none;font-family:inherit;
|
||||
}
|
||||
.field input:focus,.field select:focus,.field textarea:focus{border-color:var(--blue)}
|
||||
.field textarea{resize:vertical;min-height:60px}
|
||||
.form-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:18px}
|
||||
.check-inline{display:flex;align-items:center;gap:8px;font-size:.84rem;color:var(--text)}
|
||||
.check-inline input{width:auto}
|
||||
@media(max-width:600px){ .form-grid{grid-template-columns:1fr} }
|
||||
|
||||
/* ═══════════ 個股工具(子分頁 / 圖表 / 投資地圖 / 回測)═══════════ */
|
||||
.sub-tabs{display:flex;gap:4px;background:var(--surface);border:1px solid var(--border);border-radius:9px;padding:3px;margin:4px 0 18px;flex-wrap:wrap;width:fit-content}
|
||||
.sub-tabs a{padding:7px 16px;border-radius:7px;font-size:.85rem;font-weight:600;color:var(--text2);cursor:pointer;transition:.15s}
|
||||
.sub-tabs a:hover{color:var(--text)}
|
||||
.sub-tabs a.active{background:var(--card);color:var(--blue)}
|
||||
.stk-pane[hidden]{display:none}
|
||||
|
||||
/* 共用折線圖 */
|
||||
.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:40px 0;text-align:center;color:var(--text2);font-size:.85rem}
|
||||
.chart-legend{display:flex;gap:16px;font-size:.78rem;color:var(--text2);margin-bottom:6px}
|
||||
.chart-legend i{display:inline-block;width:11px;height:11px;border-radius:3px;margin-right:5px;vertical-align:middle}
|
||||
.chart-hover{font-size:.78rem;color:var(--text2);margin-top:6px;min-height:1.2em}
|
||||
.range-btns{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:12px}
|
||||
.range-btns button{background:var(--surface);border:1px solid var(--border);color:var(--text2);border-radius:7px;
|
||||
padding:5px 13px;font-size:.8rem;cursor:pointer;font-family:inherit;transition:.15s}
|
||||
.range-btns button:hover{border-color:var(--blue);color:var(--text)}
|
||||
.range-btns button.active{background:rgba(77,166,255,.16);border-color:var(--blue);color:var(--blue);font-weight:600}
|
||||
.check-main .ck-label{font-size:.9rem;font-weight:600}
|
||||
.check-main .ck-note{font-size:.8rem;color:var(--text2);line-height:1.55;margin-top:4px}
|
||||
.check-main .ck-links{margin-top:6px;display:flex;flex-wrap:wrap;gap:6px}
|
||||
.check-main .ck-links .wlink{font-size:.74rem;color:var(--purple);cursor:pointer}
|
||||
.check-val{font-size:1.05rem;font-weight:700;text-align:right}
|
||||
.check-val.good{color:var(--green)}.check-val.warn{color:var(--yellow)}.check-val.bad{color:var(--red)}
|
||||
|
||||
/* 投資地圖 */
|
||||
.map-core{background:rgba(179,136,255,.08);border:1px solid rgba(179,136,255,.25);border-radius:10px;
|
||||
padding:13px 16px;font-size:.85rem;font-weight:700;color:var(--purple);margin-bottom:14px;line-height:1.5}
|
||||
.map-core span{display:block;font-weight:400;color:var(--text2);font-size:.8rem;margin-top:5px;line-height:1.6}
|
||||
.map-verdict{background:var(--card);border:1px solid var(--border);border-radius:11px;padding:15px 18px;margin-bottom:16px;
|
||||
border-left:4px solid var(--text2)}
|
||||
.map-core{
|
||||
background:linear-gradient(135deg,rgba(175,82,222,.08),rgba(0,113,227,.06));
|
||||
border:1px solid rgba(175,82,222,.2);border-radius:var(--radius);
|
||||
padding:16px 20px;font-weight:700;margin-bottom:16px;box-shadow:var(--shadow);
|
||||
}
|
||||
.map-core span{display:block;font-weight:400;color:var(--text2);font-size:.84rem;margin-top:6px;line-height:1.6}
|
||||
.map-verdict{
|
||||
background:var(--card);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:18px 22px;margin-bottom:18px;border-left:5px solid var(--text2);box-shadow:var(--shadow);
|
||||
}
|
||||
.map-verdict.good{border-left-color:var(--green)}
|
||||
.map-verdict.warn{border-left-color:var(--yellow)}
|
||||
.map-verdict.bad{border-left-color:var(--red)}
|
||||
.map-verdict .mv-lab{font-size:.74rem;color:var(--text2);margin-bottom:4px}
|
||||
.map-verdict .mv-text{font-size:.96rem;font-weight:700;line-height:1.5}
|
||||
.map-verdict .mv-actions{display:flex;gap:8px;margin-top:12px}
|
||||
.map-layer{background:var(--card);border:1px solid var(--border);border-radius:11px;padding:15px 18px;margin-bottom:12px;
|
||||
border-left:3px solid var(--border)}
|
||||
.map-verdict .mv-lab{font-size:.76rem;color:var(--text2)}
|
||||
.map-verdict .mv-text{font-size:1rem;font-weight:700;line-height:1.5;margin-top:4px}
|
||||
.map-verdict .mv-actions{display:flex;gap:10px;margin-top:14px;flex-wrap:wrap}
|
||||
.map-layer{
|
||||
background:var(--card);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:18px 22px;margin-bottom:14px;border-left:4px solid var(--border);box-shadow:var(--shadow);
|
||||
}
|
||||
.map-layer.pass{border-left-color:var(--green)}
|
||||
.map-layer.watch{border-left-color:var(--yellow)}
|
||||
.map-layer.out{border-left-color:var(--red)}
|
||||
.ml-head{display:flex;align-items:center;gap:10px;margin-bottom:6px}
|
||||
.ml-num{width:24px;height:24px;border-radius:7px;background:rgba(77,166,255,.15);color:var(--blue);
|
||||
font-size:.78rem;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||||
.ml-title{font-size:1rem;font-weight:700;flex:1}
|
||||
.ml-badge{font-size:.68rem;font-weight:700;padding:2px 10px;border-radius:20px}
|
||||
.ml-badge.good{background:rgba(0,212,170,.14);color:var(--green)}
|
||||
.ml-badge.warn{background:rgba(255,193,77,.14);color:var(--yellow)}
|
||||
.ml-badge.bad{background:rgba(255,77,106,.14);color:var(--red)}
|
||||
.ml-badge.na{background:var(--surface);color:var(--text2)}
|
||||
.ml-ask{font-size:.82rem;color:var(--text);line-height:1.6}
|
||||
.ml-pillar{font-size:.74rem;color:var(--text2);margin:3px 0 10px}
|
||||
.map-q{border-top:1px solid var(--border);padding:10px 0}
|
||||
.map-q:last-of-type{border-bottom:1px solid var(--border)}
|
||||
.mq-text{font-size:.85rem;line-height:1.55;margin-bottom:7px}
|
||||
.mq-text .gate{font-size:.64rem;font-weight:700;background:rgba(255,138,77,.16);color:var(--orange);
|
||||
border-radius:4px;padding:1px 6px;margin-right:7px;vertical-align:middle}
|
||||
.mq-ans{display:flex;gap:7px;flex-wrap:wrap}
|
||||
.ans{font-size:.78rem;padding:4px 13px;border-radius:7px;border:1px solid var(--border);background:var(--surface);
|
||||
color:var(--text2);cursor:pointer;transition:.15s;user-select:none}
|
||||
.ml-head{display:flex;align-items:center;gap:12px;margin-bottom:8px}
|
||||
.ml-num{
|
||||
width:32px;height:32px;border-radius:10px;background:rgba(0,113,227,.1);
|
||||
color:var(--blue);font-weight:700;display:flex;align-items:center;justify-content:center;
|
||||
}
|
||||
.ml-title{font-size:1.05rem;font-weight:700;flex:1}
|
||||
.ml-badge{font-size:.68rem;font-weight:700;padding:4px 12px;border-radius:20px}
|
||||
.ml-badge.good{background:rgba(52,199,89,.12);color:var(--green)}
|
||||
.ml-badge.warn{background:rgba(255,149,0,.12);color:var(--orange)}
|
||||
.ml-badge.bad{background:rgba(255,59,48,.12);color:var(--red)}
|
||||
.ml-badge.na{background:rgba(0,0,0,.05);color:var(--text2)}
|
||||
.ml-ask,.ml-pillar{font-size:.82rem;line-height:1.6}
|
||||
.ml-pillar{color:var(--text2);margin:4px 0 12px}
|
||||
.map-q{border-top:1px solid var(--border);padding:12px 0}
|
||||
.mq-text{font-size:.88rem;line-height:1.55;margin-bottom:8px}
|
||||
.mq-text .gate{
|
||||
font-size:.64rem;font-weight:700;background:rgba(255,149,0,.15);color:var(--orange);
|
||||
border-radius:6px;padding:2px 8px;margin-right:8px;
|
||||
}
|
||||
.mq-ans{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.ans{
|
||||
font-size:.8rem;padding:8px 16px;border-radius:10px;border:1.5px solid var(--border);
|
||||
background:var(--surface);color:var(--text2);cursor:pointer;transition:.15s;
|
||||
}
|
||||
.ans input{display:none}
|
||||
.ans:hover{border-color:var(--blue)}
|
||||
.ans.yes.on{background:rgba(0,212,170,.16);border-color:var(--green);color:var(--green)}
|
||||
.ans.unsure.on{background:rgba(255,193,77,.16);border-color:var(--yellow);color:var(--yellow)}
|
||||
.ans.no.on{background:rgba(255,77,106,.16);border-color:var(--red);color:var(--red)}
|
||||
.ml-out{font-size:.72rem;color:var(--text2);margin-top:9px;font-style:italic}
|
||||
.map-q .ck-links{margin-top:6px;display:flex;flex-wrap:wrap;gap:6px}
|
||||
.map-q .ck-links .wlink{font-size:.72rem}
|
||||
.ans.yes.on{background:rgba(52,199,89,.12);border-color:var(--green);color:var(--green);font-weight:600}
|
||||
.ans.unsure.on{background:rgba(255,149,0,.1);border-color:var(--orange);color:var(--orange);font-weight:600}
|
||||
.ans.no.on{background:rgba(255,59,48,.1);border-color:var(--red);color:var(--red);font-weight:600}
|
||||
.ml-out{font-size:.74rem;color:var(--text2);margin-top:10px;font-style:italic}
|
||||
|
||||
/* 回測 */
|
||||
.bt-controls{display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;background:var(--card);border:1px solid var(--border);
|
||||
border-radius:10px;padding:14px 16px;margin-bottom:16px}
|
||||
.bt-params{display:flex;gap:12px;flex-wrap:wrap}
|
||||
.bt-field{display:flex;flex-direction:column;gap:4px}
|
||||
.bt-field label{font-size:.72rem;color:var(--text2)}
|
||||
.bt-field select,.bt-field input{background:var(--surface);border:1px solid var(--border);border-radius:7px;color:var(--text);
|
||||
padding:8px 11px;font-size:.85rem;outline:none;font-family:inherit;min-width:120px}
|
||||
.bt-field input{width:100px;min-width:0}
|
||||
.bt-field select:focus,.bt-field input:focus{border-color:var(--blue)}
|
||||
.bt-controls .btn{align-self:flex-end}
|
||||
.bt-stats{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:14px}
|
||||
.bt-stat{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:14px 16px}
|
||||
.bt-stat .bts-title{font-size:.84rem;font-weight:700;margin-bottom:10px}
|
||||
.bts-grid{display:grid;grid-template-columns:1fr 1fr;gap:9px 14px}
|
||||
.bts-grid div{display:flex;flex-direction:column;gap:2px}
|
||||
.bt-controls{
|
||||
display:flex;gap:14px;align-items:flex-end;flex-wrap:wrap;
|
||||
background:var(--card);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:18px 20px;margin-bottom:18px;box-shadow:var(--shadow);
|
||||
}
|
||||
.bt-field label{font-size:.72rem;color:var(--text2);font-weight:600;margin-bottom:6px;display:block}
|
||||
.bt-params{display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end}
|
||||
.bt-stats{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:16px}
|
||||
.bt-stat{
|
||||
background:var(--card);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:16px 18px;box-shadow:var(--shadow);
|
||||
}
|
||||
.bt-stat .bts-title{font-size:.86rem;font-weight:700;margin-bottom:12px}
|
||||
.bts-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px 16px}
|
||||
.bts-grid span{font-size:.7rem;color:var(--text2)}
|
||||
.bts-grid b{font-size:1.02rem;font-weight:700}
|
||||
.bt-note{font-size:.76rem;color:var(--text2);margin-top:12px;line-height:1.6}
|
||||
.bts-grid b{font-size:1.02rem}
|
||||
.bt-note{font-size:.78rem;color:var(--text2);margin-top:14px;line-height:1.6}
|
||||
@media(max-width:680px){ .bt-stats{grid-template-columns:1fr} }
|
||||
|
||||
/* ═══════════ 交易復盤 ═══════════ */
|
||||
.stat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:14px;margin-bottom:24px}
|
||||
.stat-card{
|
||||
background:var(--card);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:18px 20px;box-shadow:var(--shadow);
|
||||
}
|
||||
.stat-card .st-lab{font-size:.76rem;color:var(--text2);margin-bottom:8px}
|
||||
.stat-card .st-val{font-size:1.75rem;font-weight:700;line-height:1}
|
||||
.stat-card .st-sub{font-size:.74rem;color:var(--text2);margin-top:6px}
|
||||
|
||||
.btn{
|
||||
background:var(--blue);color:#fff;border:none;padding:10px 20px;border-radius:12px;
|
||||
font-weight:600;font-size:.88rem;cursor:pointer;transition:.15s;
|
||||
box-shadow:0 4px 14px rgba(0,113,227,.2);font-family:inherit;
|
||||
}
|
||||
.btn:hover{filter:brightness(1.05);transform:translateY(-1px)}
|
||||
.btn.ghost{background:var(--surface);border:1px solid var(--border);color:var(--text2);box-shadow:var(--shadow)}
|
||||
.btn.ghost:hover{border-color:var(--blue);color:var(--blue)}
|
||||
.btn.danger{background:var(--surface);border:1px solid var(--border);color:var(--red);box-shadow:var(--shadow)}
|
||||
.btn.danger:hover{border-color:var(--red)}
|
||||
.btn.sm{padding:6px 14px;font-size:.8rem;border-radius:10px}
|
||||
|
||||
.journal-bar{display:flex;justify-content:space-between;align-items:center;gap:14px;margin-bottom:18px;flex-wrap:wrap}
|
||||
.seg{display:flex;gap:3px;background:rgba(0,0,0,.04);border-radius:12px;padding:4px}
|
||||
.seg a{
|
||||
padding:8px 16px;border-radius:10px;font-size:.84rem;font-weight:600;
|
||||
color:var(--text2);cursor:pointer;transition:.15s;
|
||||
}
|
||||
.seg a.active{background:var(--surface);color:var(--text);box-shadow:0 1px 4px rgba(0,0,0,.08)}
|
||||
|
||||
.trade-table{width:100%;border-collapse:separate;border-spacing:0;font-size:.84rem}
|
||||
.trade-table th{
|
||||
text-align:left;padding:12px 14px;color:var(--text2);font-weight:600;font-size:.74rem;
|
||||
border-bottom:1px solid var(--border);
|
||||
}
|
||||
.trade-table td{padding:12px 14px;border-bottom:1px solid var(--border);vertical-align:middle}
|
||||
.trade-table tr:hover td{background:rgba(0,113,227,.03)}
|
||||
.t-sym{font-weight:700}
|
||||
.t-sym .t-name{font-weight:400;color:var(--text2);font-size:.76rem;margin-left:6px}
|
||||
.pill{font-size:.68rem;font-weight:600;padding:3px 10px;border-radius:20px}
|
||||
.pill.long{background:rgba(52,199,89,.12);color:var(--green)}
|
||||
.pill.short{background:rgba(255,59,48,.12);color:var(--red)}
|
||||
.pill.invest{background:rgba(0,113,227,.1);color:var(--blue)}
|
||||
.pill.trade{background:rgba(175,82,222,.1);color:var(--purple)}
|
||||
.pill.open{background:rgba(255,149,0,.12);color:var(--orange)}
|
||||
.pill.mistake{background:rgba(255,59,48,.12);color:var(--red)}
|
||||
.pnl-pos{color:var(--green);font-weight:700}
|
||||
.pnl-neg{color:var(--red);font-weight:700}
|
||||
.t-actions{display:flex;gap:8px;justify-content:flex-end}
|
||||
.empty-state{text-align:center;color:var(--text2);padding:56px 24px;font-size:.92rem}
|
||||
|
||||
.group-stat{
|
||||
background:var(--card);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:16px 18px;margin-bottom:12px;box-shadow:var(--shadow);
|
||||
}
|
||||
.group-stat h4{font-size:.88rem;margin-bottom:12px}
|
||||
.gs-row{display:grid;grid-template-columns:1fr auto auto auto;gap:12px;font-size:.82rem;padding:6px 0;border-top:1px solid var(--border)}
|
||||
.gs-row:first-of-type{border-top:none}
|
||||
.gs-row .gs-cell{text-align:right;min-width:64px;color:var(--text2)}
|
||||
|
||||
/* Modal */
|
||||
#tradeModal .modal-panel{background:var(--surface);border-radius:18px;box-shadow:0 24px 80px rgba(0,0,0,.18)}
|
||||
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px 16px;margin-top:8px}
|
||||
.form-grid .full{grid-column:1/-1}
|
||||
.field label{display:block;font-size:.74rem;color:var(--text2);font-weight:600;margin-bottom:6px}
|
||||
.field input,.field textarea{
|
||||
width:100%;background:var(--bg);border:1px solid var(--border);border-radius:12px;
|
||||
color:var(--text);padding:10px 14px;font-size:.88rem;outline:none;font-family:inherit;
|
||||
}
|
||||
.field input:focus,.field textarea:focus{border-color:var(--blue);box-shadow:0 0 0 4px rgba(0,113,227,.12)}
|
||||
.field textarea{resize:vertical;min-height:72px}
|
||||
.field select{display:none}
|
||||
.principle-chips{
|
||||
display:flex;flex-wrap:wrap;gap:8px;max-height:160px;overflow-y:auto;
|
||||
padding:12px;background:var(--bg);border-radius:12px;border:1px solid var(--border);
|
||||
}
|
||||
.form-actions{display:flex;justify-content:flex-end;gap:12px;margin-top:20px}
|
||||
.check-inline{display:flex;align-items:center;gap:10px;font-size:.88rem;cursor:pointer}
|
||||
.check-inline input{width:18px;height:18px;accent-color:var(--blue)}
|
||||
@media(max-width:600px){ .form-grid{grid-template-columns:1fr} }
|
||||
|
|
|
|||
245
app.js
245
app.js
|
|
@ -33,6 +33,52 @@ function fmtMoney(v) {
|
|||
return s + '$' + a.toFixed(2);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// UI 元件:色塊分段(取代傳統下拉)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
function mountChips(container, items, value, onChange, opts = {}) {
|
||||
const cls = opts.sm ? 'chip sm' : 'chip';
|
||||
container.innerHTML = items.map(it => {
|
||||
const tint = it.tint ? ` tint-${it.tint}` : '';
|
||||
const on = it.id === value ? ' on' : '';
|
||||
return `<button type="button" class="${cls}${tint}${on}" data-v="${escapeHtml(it.id)}">${it.icon ? `<span>${it.icon}</span> ` : ''}${escapeHtml(it.label)}</button>`;
|
||||
}).join('');
|
||||
$$('button', container).forEach(btn => btn.addEventListener('click', () => {
|
||||
const v = btn.dataset.v;
|
||||
onChange(v);
|
||||
$$('button', container).forEach(b => b.classList.toggle('on', b.dataset.v === v));
|
||||
}));
|
||||
}
|
||||
function mountTiles(container, items, value, onChange) {
|
||||
container.innerHTML = items.map(it => {
|
||||
const on = it.id === value ? ' on' : '';
|
||||
const tint = it.tint ? ` tint-${it.tint}` : '';
|
||||
return `<div class="tile${tint}${on}" data-v="${escapeHtml(it.id)}" role="button" tabindex="0">
|
||||
<div class="tile-label">${escapeHtml(it.label)}</div>${it.sub ? `<div class="tile-sub">${escapeHtml(it.sub)}</div>` : ''}</div>`;
|
||||
}).join('');
|
||||
$$('.tile', container).forEach(el => {
|
||||
const pick = () => { onChange(el.dataset.v); $$('.tile', container).forEach(t => t.classList.toggle('on', t.dataset.v === el.dataset.v)); };
|
||||
el.addEventListener('click', pick);
|
||||
el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
|
||||
});
|
||||
}
|
||||
|
||||
// Mermaid 初始化(Apple 中性淺色主題)
|
||||
function initMermaid() {
|
||||
if (!window.mermaid || window._mermaidReady) return;
|
||||
window._mermaidReady = true;
|
||||
mermaid.initialize({
|
||||
startOnLoad: false, theme: 'neutral', securityLevel: 'loose',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "PingFang TC", sans-serif',
|
||||
});
|
||||
}
|
||||
async function renderMermaid(container) {
|
||||
initMermaid();
|
||||
const els = $$('.mermaid', container);
|
||||
if (!els.length || !window.mermaid) return;
|
||||
try { await mermaid.run({ nodes: els, suppressErrors: true }); } catch (_) {}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 輕量 Markdown 渲染(支援標題/清單/表格/引用/粗體/行內碼/[[wikilink]])
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
|
@ -85,7 +131,13 @@ function renderListBlock(lines) {
|
|||
function renderMarkdown(md) {
|
||||
md = String(md || '').replace(/\r\n/g, '\n');
|
||||
const fences = [];
|
||||
md = md.replace(/```[\s\S]*?```/g, (m) => { fences.push(m); return '\u0000F' + (fences.length - 1) + '\u0000'; });
|
||||
const fenceLangs = [];
|
||||
md = md.replace(/```[\s\S]*?```/g, (m) => {
|
||||
const lang = (m.match(/^```(\w+)/) || [])[1] || '';
|
||||
fenceLangs.push(lang.toLowerCase());
|
||||
fences.push(m);
|
||||
return '\u0000F' + (fences.length - 1) + '\u0000';
|
||||
});
|
||||
const lines = md.split('\n');
|
||||
const blank = s => !s.trim();
|
||||
let html = '', i = 0;
|
||||
|
|
@ -93,7 +145,15 @@ function renderMarkdown(md) {
|
|||
const line = lines[i];
|
||||
if (blank(line)) { i++; continue; }
|
||||
const fm = line.match(/^\u0000F(\d+)\u0000$/);
|
||||
if (fm) { const code = fences[+fm[1]].replace(/^```[^\n]*\n?/, '').replace(/```\s*$/, ''); html += '<pre><code>' + escapeHtml(code) + '</code></pre>'; i++; continue; }
|
||||
if (fm) {
|
||||
const idx = +fm[1];
|
||||
const raw = fences[idx];
|
||||
const lang = fenceLangs[idx];
|
||||
const code = raw.replace(/^```[^\n]*\n?/, '').replace(/```\s*$/, '');
|
||||
if (lang === 'mermaid') html += `<div class="mermaid-wrap"><pre class="mermaid">${escapeHtml(code)}</pre></div>`;
|
||||
else html += '<pre><code>' + escapeHtml(code) + '</code></pre>';
|
||||
i++; continue;
|
||||
}
|
||||
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (h) { const l = h[1].length; html += `<h${l}>${mdInline(h[2])}</h${l}>`; i++; continue; }
|
||||
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) { html += '<hr>'; i++; continue; }
|
||||
|
|
@ -159,6 +219,7 @@ async function openNote(kind, id) {
|
|||
let note = findLocalNote(kind, id);
|
||||
if (!note) { try { note = await api(`/api/note/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`); } catch (e) { note = null; } }
|
||||
const finalNote = note || { body: `# 找不到這篇筆記\n(${kind} / ${id})` };
|
||||
finalNote.kind = kind;
|
||||
if (!inited.learn) {
|
||||
// 學習教材還沒初始化:暫存,切到 learn 後由 initLearn 渲染(避免被課綱總覽蓋掉)
|
||||
pendingNote = finalNote;
|
||||
|
|
@ -180,25 +241,39 @@ function findLocalNote(kind, id) {
|
|||
function renderNote(note) {
|
||||
const content = $('#learnContent');
|
||||
const fm = note.frontmatter || {};
|
||||
LEARN.currentNote = note;
|
||||
let tags = '';
|
||||
if (fm.ticker) tags += `<span class="fm-tag">代號 ${escapeHtml([].concat(fm.ticker).join(' / '))}</span>`;
|
||||
if (fm.sector) tags += `<span class="fm-tag">${escapeHtml(fm.sector)}</span>`;
|
||||
if (fm.category) tags += `<span class="fm-tag">${escapeHtml(fm.category)}</span>`;
|
||||
if (fm.date) tags += `<span class="fm-tag">${escapeHtml(fm.date)}</span>`;
|
||||
if (Array.isArray(fm.aliases) && fm.aliases.length) tags += `<span class="fm-tag">別名 ${escapeHtml(fm.aliases.join(' · '))}</span>`;
|
||||
const kind = note.kind || LEARN.noteKind;
|
||||
const center = (kind && note.id) ? `${kind}:${note.id}` : '';
|
||||
content.innerHTML =
|
||||
`<span class="back-link" id="noteBack">← 返回</span>` +
|
||||
`<div class="note-toolbar">
|
||||
<span class="back-link" id="noteBack">← 返回</span>
|
||||
${center ? '<button class="btn ghost sm" id="noteGraphBtn">🔗 周邊圖譜</button>' : ''}
|
||||
</div>` +
|
||||
(tags ? `<div class="note-frontmatter">${tags}</div>` : '') +
|
||||
`<div class="md">${renderMarkdown(note.body || '')}</div>`;
|
||||
bindWlinks(content);
|
||||
renderMermaid(content);
|
||||
$('#noteBack').addEventListener('click', () => showSection(LEARN.lastSection || 'overview'));
|
||||
const gb = $('#noteGraphBtn');
|
||||
if (gb) gb.addEventListener('click', () => showGraph({ center, depth: 2 }));
|
||||
window.scrollTo({ top: 0 });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 學習教材視圖
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
const LEARN = { lastSection: 'overview' };
|
||||
const LEARN = { lastSection: 'overview', graphFilter: 'curriculum', currentNote: null, noteKind: null };
|
||||
const GRAPH_KINDS = [
|
||||
{ id: 'curriculum', label: '課程骨架', kinds: 'overview,principleMap,category,case,principle' },
|
||||
{ id: 'terms', label: '名詞', kinds: 'term', includeIndex: '1' },
|
||||
{ id: 'companies', label: '公司', kinds: 'company', includeIndex: '1' },
|
||||
];
|
||||
function setLearnActive(section) {
|
||||
$$('#learnSide a').forEach(a => a.classList.toggle('active', a.dataset.section === section));
|
||||
}
|
||||
|
|
@ -226,6 +301,8 @@ async function initLearn() {
|
|||
<a data-section="categories">學習分類 <span class="count">${(KB.categories || []).length}</span></a>
|
||||
<a data-section="cases">案例講解 <span class="count">${(KB.cases || []).length}</span></a>
|
||||
<a data-section="principles">投資心法 <span class="count">${(KB.principles || []).length}</span></a>
|
||||
<div class="side-group">視覺化</div>
|
||||
<a data-section="graph">🔗 知識圖譜</a>
|
||||
<div class="side-group">速查</div>
|
||||
<a data-section="terms">名詞 <span class="count">${c.terms || 0}</span></a>
|
||||
<a data-section="companies">公司 <span class="count">${c.companies || 0}</span></a>
|
||||
|
|
@ -243,9 +320,10 @@ function showSection(section) {
|
|||
setLearnActive(section);
|
||||
const content = $('#learnContent');
|
||||
if (!content) return;
|
||||
if (section === 'overview') return renderNote(KB.overview || { body: '# 課綱總覽\n(尚無內容)' });
|
||||
if (section === 'principleMap') return renderNote(KB.principleMap || { body: '# 心法地圖\n(尚無內容)' });
|
||||
if (section === 'overview') return renderNote(Object.assign({ kind: 'overview' }, KB.overview || { body: '# 課綱總覽\n(尚無內容)' }));
|
||||
if (section === 'principleMap') return renderNote(Object.assign({ kind: 'principleMap' }, KB.principleMap || { body: '# 心法地圖\n(尚無內容)' }));
|
||||
if (section === 'quiz') return renderQuiz();
|
||||
if (section === 'graph') return showGraph();
|
||||
if (section === 'categories') return renderCardList('學習分類', KB.categories, 'category');
|
||||
if (section === 'cases') return renderCardList('案例講解', KB.cases, 'case');
|
||||
if (section === 'principles') return renderPrincipleList();
|
||||
|
|
@ -305,7 +383,82 @@ function renderGlossary(section) {
|
|||
window.scrollTo({ top: 0 });
|
||||
}
|
||||
function renderQuiz() {
|
||||
renderNote(KB.quiz || { body: '# 練習題庫\n(尚無內容)' });
|
||||
renderNote(Object.assign({ kind: 'quiz' }, KB.quiz || { body: '# 練習題庫\n(尚無內容)' }));
|
||||
}
|
||||
|
||||
// ── 知識圖譜(vis-network)──
|
||||
let graphNetwork = null;
|
||||
const GRAPH_LEGEND = [
|
||||
['category', '分類', '#0071e3'], ['case', '案例', '#34c759'], ['principle', '心法', '#af52de'],
|
||||
['term', '名詞', '#ff9500'], ['company', '公司', '#5ac8fa'], ['episode', '單集', '#8e8e93'],
|
||||
];
|
||||
async function showGraph(opts = {}) {
|
||||
LEARN.lastSection = 'graph';
|
||||
setLearnActive('graph');
|
||||
const content = $('#learnContent');
|
||||
const filter = opts.filter || LEARN.graphFilter || 'curriculum';
|
||||
const center = opts.center || '';
|
||||
const depth = opts.depth || 2;
|
||||
LEARN.graphFilter = filter;
|
||||
content.innerHTML = `
|
||||
<div class="page-title" style="font-size:1.2rem;margin-bottom:8px">知識圖譜</div>
|
||||
<div class="page-sub" style="margin-bottom:16px">節點是筆記與概念,連線來自文內 [[連結]]。點一下節點可開啟該篇;拖曳平移、雙指或滾輪縮放。</div>
|
||||
<div class="graph-panel">
|
||||
<div class="graph-toolbar"><div id="graphFilterChips" class="chip-row"></div></div>
|
||||
<div id="graphCanvas" class="graph-canvas"><div class="empty-state">載入圖譜中…</div></div>
|
||||
<div class="graph-foot"><div class="graph-legend" id="graphLegend"></div><span id="graphStat"></span></div>
|
||||
</div>`;
|
||||
mountChips($('#graphFilterChips'), GRAPH_KINDS.map(g => ({ id: g.id, label: g.label })), filter, v => showGraph({ filter: v }));
|
||||
$('#graphLegend').innerHTML = GRAPH_LEGEND.map(([, lab, col]) =>
|
||||
`<span><i style="background:${col}"></i>${lab}</span>`).join('');
|
||||
const cfg = GRAPH_KINDS.find(g => g.id === filter) || GRAPH_KINDS[0];
|
||||
const qs = new URLSearchParams({ kinds: cfg.kinds, limit: 500 });
|
||||
if (cfg.includeIndex) qs.set('includeIndex', '1');
|
||||
if (center) { qs.set('center', center); qs.set('depth', String(depth)); }
|
||||
try {
|
||||
const data = await api('/api/graph?' + qs);
|
||||
const el = $('#graphCanvas');
|
||||
el.innerHTML = '';
|
||||
if (!data.nodes || !data.nodes.length) {
|
||||
el.innerHTML = '<div class="empty-state">此範圍沒有足夠的連結可繪製。</div>';
|
||||
return;
|
||||
}
|
||||
if (!window.vis) { el.innerHTML = '<div class="empty-state">圖譜元件載入失敗,請重新整理。</div>'; return; }
|
||||
const nodes = new vis.DataSet(data.nodes.map(n => ({
|
||||
id: n.id, label: n.label, title: n.title,
|
||||
color: { background: n.color, border: n.color, highlight: { background: n.color, border: '#1d1d1f' } },
|
||||
shape: n.shape === 'box' ? 'box' : 'dot',
|
||||
font: { face: '-apple-system, BlinkMacSystemFont, sans-serif', size: 13, color: '#1d1d1f' },
|
||||
margin: 10,
|
||||
})));
|
||||
const edges = new vis.DataSet(data.edges.map(e => ({
|
||||
from: e.from, to: e.to, arrows: { to: { scaleFactor: 0.45 } },
|
||||
color: { color: 'rgba(0,0,0,.12)', highlight: 'rgba(0,113,227,.45)' },
|
||||
smooth: { type: 'continuous', roundness: 0.2 },
|
||||
})));
|
||||
if (graphNetwork) { graphNetwork.destroy(); graphNetwork = null; }
|
||||
graphNetwork = new vis.Network(el, { nodes, edges }, {
|
||||
physics: { stabilization: { iterations: 100 }, barnesHut: { gravitationalConstant: -12000, springLength: 120 } },
|
||||
interaction: { hover: true, tooltipDelay: 80, navigationButtons: false },
|
||||
nodes: { borderWidth: 0, shadow: { enabled: true, size: 6, x: 0, y: 2, color: 'rgba(0,0,0,.08)' } },
|
||||
});
|
||||
graphNetwork.on('click', p => {
|
||||
if (!p.nodes.length) return;
|
||||
const nid = p.nodes[0];
|
||||
const node = data.nodes.find(n => n.id === nid);
|
||||
if (!node) return;
|
||||
const colon = nid.indexOf(':');
|
||||
if (colon < 0) return;
|
||||
openNote(nid.slice(0, colon), nid.slice(colon + 1));
|
||||
});
|
||||
if (center && data.nodes.some(n => n.id === center)) {
|
||||
graphNetwork.focus(center, { scale: 1.2, animation: { duration: 500, easingFunction: 'easeInOutQuad' } });
|
||||
}
|
||||
$('#graphStat').textContent = `${data.nodes.length} 個節點 · ${data.edges.length} 條連線${center ? '(聚焦模式)' : ''}`;
|
||||
} catch (e) {
|
||||
$('#graphCanvas').innerHTML = `<div class="empty-state">圖譜載入失敗:${escapeHtml(e.message || '')}</div>`;
|
||||
}
|
||||
window.scrollTo({ top: 0 });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
|
@ -330,9 +483,9 @@ function drawLineChart(el, series, opts = {}) {
|
|||
const toX = i => padL + (i / (n - 1)) * plotW;
|
||||
const toY = v => padT + (1 - (v - yMin) / yRange) * plotH;
|
||||
let grid = '';
|
||||
for (let k = 0; k <= 5; k++) { const v = yMin + yRange * k / 5; 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)"/><text x="${padL - 8}" y="${(y + 3.5).toFixed(1)}" fill="#8899aa" font-size="11" text-anchor="end">${fmt(v)}</text>`; }
|
||||
for (let k = 0; k <= 5; k++) { const v = yMin + yRange * k / 5; 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)"/><text x="${padL - 8}" y="${(y + 3.5).toFixed(1)}" fill="#86868b" font-size="11" text-anchor="end">${fmt(v)}</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)); xlab += `<text x="${toX(idx).toFixed(1)}" y="${h - 9}" fill="#8899aa" font-size="10" text-anchor="middle">${(dates[idx] || '').slice(2, 7).replace('-', '/')}</text>`; }
|
||||
for (let k = 0; k < xt; k++) { const idx = Math.round(k * (n - 1) / (xt - 1)); xlab += `<text x="${toX(idx).toFixed(1)}" y="${h - 9}" fill="#86868b" font-size="10" text-anchor="middle">${(dates[idx] || '').slice(2, 7).replace('-', '/')}</text>`; }
|
||||
let paths = '', dots = '';
|
||||
series.forEach(s => {
|
||||
const d = s.points.slice(0, n).map((p, i) => `${i === 0 ? 'M' : 'L'}${toX(i).toFixed(1)},${toY(p.val).toFixed(1)}`).join(' ');
|
||||
|
|
@ -342,7 +495,7 @@ function drawLineChart(el, series, opts = {}) {
|
|||
const legend = series.length > 1 ? `<div class="chart-legend">${series.map(s => `<span><i style="background:${s.color}"></i>${escapeHtml(s.name)}</span>`).join('')}</div>` : '';
|
||||
el.innerHTML = `${legend}<div class="chart-wrap"><svg id="${uid}" viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">
|
||||
${grid}${xlab}${paths}
|
||||
<g class="hg" style="display:none"><line class="hl" y1="${padT}" y2="${padT + plotH}" stroke="#8899aa" stroke-dasharray="3,3"/></g>
|
||||
<g class="hg" style="display:none"><line class="hl" y1="${padT}" y2="${padT + plotH}" stroke="#86868b" stroke-dasharray="3,3"/></g>
|
||||
${dots}
|
||||
<rect class="ha" x="${padL}" y="${padT}" width="${plotW}" height="${plotH}" fill="transparent" style="cursor:crosshair"/>
|
||||
</svg><div class="chart-hover" id="${uid}h"></div></div>`;
|
||||
|
|
@ -643,23 +796,35 @@ function renderBacktestPane() {
|
|||
if (!STOCK.bt) STOCK.bt = { strategy: 'sma', range: '5y', params: {} };
|
||||
pane.innerHTML = `
|
||||
<div class="bt-controls">
|
||||
<div class="bt-field"><label>策略</label><select id="btStrat">${Object.entries(BT_STRATS).map(([k, v]) => `<option value="${k}" ${k === STOCK.bt.strategy ? 'selected' : ''}>${v.label}</option>`).join('')}</select></div>
|
||||
<div class="bt-field"><label>期間</label><select id="btRange">${BT_RANGES.map(r => `<option value="${r[0]}" ${r[0] === STOCK.bt.range ? 'selected' : ''}>${r[1]}</option>`).join('')}</select></div>
|
||||
<div class="full" style="grid-column:1/-1;width:100%">
|
||||
<label style="font-size:.72rem;color:var(--text2);font-weight:600;display:block;margin-bottom:8px">策略</label>
|
||||
<div id="btStratChips" class="chip-row"></div>
|
||||
</div>
|
||||
<div class="full" style="grid-column:1/-1;width:100%;margin-top:4px">
|
||||
<label style="font-size:.72rem;color:var(--text2);font-weight:600;display:block;margin-bottom:8px">期間</label>
|
||||
<div id="btRangeChips" class="chip-row"></div>
|
||||
</div>
|
||||
<div id="btParams" class="bt-params"></div>
|
||||
<button class="btn" id="btRun">跑回測</button>
|
||||
</div>
|
||||
<div id="btResult"><div class="empty-state">選好策略與期間,按「跑回測」。以還原股價、初始資金 $10,000 模擬。</div></div>`;
|
||||
const drawParams = () => {
|
||||
const s = BT_STRATS[$('#btStrat').value];
|
||||
$('#btParams').innerHTML = s.params.map(p => `<div class="bt-field"><label>${escapeHtml(p.label)}</label><input type="number" step="any" data-pk="${p.key}" value="${STOCK.bt.params[p.key] != null ? STOCK.bt.params[p.key] : p.def}"></div>`).join('');
|
||||
mountChips($('#btStratChips'), Object.entries(BT_STRATS).map(([k, v]) => ({ id: k, label: v.label })), STOCK.bt.strategy, v => {
|
||||
STOCK.bt.strategy = v; drawBtParams();
|
||||
});
|
||||
mountChips($('#btRangeChips'), BT_RANGES.map(r => ({ id: r[0], label: r[1] })), STOCK.bt.range, v => { STOCK.bt.range = v; });
|
||||
const drawBtParams = () => {
|
||||
const s = BT_STRATS[STOCK.bt.strategy];
|
||||
const box = $('#btParams');
|
||||
if (!s.params.length) { box.innerHTML = ''; return; }
|
||||
box.innerHTML = s.params.map(p => `
|
||||
<div><label style="font-size:.72rem;color:var(--text2);font-weight:600">${escapeHtml(p.label)}</label>
|
||||
<input type="number" step="any" data-pk="${p.key}" value="${STOCK.bt.params[p.key] != null ? STOCK.bt.params[p.key] : p.def}"
|
||||
style="width:100px;padding:10px 12px;border-radius:10px;border:1px solid var(--border);margin-top:6px;font-family:inherit"></div>`).join('');
|
||||
};
|
||||
$('#btStrat').addEventListener('change', drawParams);
|
||||
drawParams();
|
||||
drawBtParams();
|
||||
$('#btRun').addEventListener('click', runBacktestUI);
|
||||
}
|
||||
async function runBacktestUI() {
|
||||
STOCK.bt.strategy = $('#btStrat').value;
|
||||
STOCK.bt.range = $('#btRange').value;
|
||||
const params = {}; $$('#btParams input').forEach(i => params[i.dataset.pk] = i.value); STOCK.bt.params = params;
|
||||
const out = $('#btResult');
|
||||
out.innerHTML = `<div class="empty-state"><div class="spinner" style="width:26px;height:26px;border:3px solid var(--border);border-top-color:var(--blue);border-radius:50%;margin:0 auto 12px;animation:spin .8s linear infinite"></div>回測中…</div>`;
|
||||
|
|
@ -812,25 +977,25 @@ function ensureTradeModal() {
|
|||
const div = document.createElement('div');
|
||||
div.id = 'tradeModal';
|
||||
div.className = 'view'; // reuse nothing; styled inline below
|
||||
div.style.cssText = 'position:fixed;inset:0;z-index:600;background:rgba(4,8,14,.72);backdrop-filter:blur(3px);display:none;align-items:center;justify-content:center;padding:20px';
|
||||
div.style.cssText = 'position:fixed;inset:0;z-index:600;background:rgba(0,0,0,.35);backdrop-filter:blur(8px);display:none;align-items:center;justify-content:center;padding:20px';
|
||||
div.innerHTML = `<div class="modal-panel" style="width:min(640px,100%)">
|
||||
<div class="modal-head"><div class="modal-title" id="tradeFormTitle">新增交易</div><button class="modal-close" id="tradeFormClose">✕</button></div>
|
||||
<form id="tradeForm"><div class="form-grid">
|
||||
<div class="field"><label>股票代號 *</label><input name="symbol" required placeholder="NVDA"></div>
|
||||
<div class="field"><label>名稱</label><input name="name" placeholder="輝達"></div>
|
||||
<div class="field"><label>方向</label><select name="direction"><option value="long">做多 Long</option><option value="short">做空 Short</option></select></div>
|
||||
<div class="field"><label>交易 / 投資</label><select name="kind"><option value="投資">投資(看基本面與趨勢)</option><option value="交易">交易(看情緒與資金)</option></select></div>
|
||||
<div class="field full"><label>方向</label><div id="dirTiles" class="tile-row"></div><input type="hidden" name="direction" value="long"></div>
|
||||
<div class="field full"><label>交易 / 投資</label><div id="kindTiles" class="tile-row"></div><input type="hidden" name="kind" value="投資"></div>
|
||||
<div class="field"><label>進場日期 *</label><input name="entry_date" type="date" required></div>
|
||||
<div class="field"><label>進場價 *</label><input name="entry_price" type="number" step="any" required placeholder="120.5"></div>
|
||||
<div class="field"><label>股數 *</label><input name="shares" type="number" step="any" required placeholder="100"></div>
|
||||
<div class="field"><label>進場理由</label><input name="entry_reason" placeholder="資料中心營收續強,趨勢回測支撐"></div>
|
||||
<div class="field full"><label>依據的心法</label><select name="principle"><option value="">(不指定)</option></select></div>
|
||||
<div class="field full"><label>依據的心法(點選色塊)</label><div id="principleChips" class="principle-chips"></div><input type="hidden" name="principle" value=""></div>
|
||||
<div class="field"><label>出場日期(留空=持倉中)</label><input name="exit_date" type="date"></div>
|
||||
<div class="field"><label>出場價</label><input name="exit_price" type="number" step="any"></div>
|
||||
<div class="field full"><label>出場理由</label><input name="exit_reason" placeholder="觸發減倉條件 / 停損 / 換倉"></div>
|
||||
<div class="field full"><label>心得 / 復盤筆記</label><textarea name="note" placeholder="當初判斷是否成立?事後看哪裡對、哪裡錯?"></textarea></div>
|
||||
<div class="field full"><label class="check-inline"><input type="checkbox" name="mistake"> 這筆交易我判斷犯了錯(與結果無關)</label></div>
|
||||
<div class="field full" id="mistakeNoteWrap" style="display:none"><label>違反 / 該注意的心法</label><select name="mistake_note"><option value="">(不指定)</option></select></div>
|
||||
<div class="field full" id="mistakeNoteWrap" style="display:none"><label>違反 / 該注意的心法</label><div id="mistakeChips" class="principle-chips"></div><input type="hidden" name="mistake_note" value=""></div>
|
||||
</div>
|
||||
<div class="form-actions"><button type="button" class="btn ghost" id="tradeFormCancel">取消</button><button type="submit" class="btn">儲存</button></div>
|
||||
</form></div>`;
|
||||
|
|
@ -841,10 +1006,11 @@ function ensureTradeModal() {
|
|||
$('#tradeForm [name=mistake]').addEventListener('change', e => { $('#mistakeNoteWrap').style.display = e.target.checked ? '' : 'none'; });
|
||||
$('#tradeForm').addEventListener('submit', submitTradeForm);
|
||||
}
|
||||
function principleOptions(selected) {
|
||||
const ps = (KB.principles || []);
|
||||
return '<option value="">(不指定)</option>' + ps.map(p =>
|
||||
`<option value="${escapeHtml('Emmy 投資心法#' + p.id)}" ${('Emmy 投資心法#' + p.id) === selected ? 'selected' : ''}>${escapeHtml(p.title)}</option>`).join('');
|
||||
function mountPrincipleChips(container, hiddenInput, selected) {
|
||||
const items = [{ id: '', label: '不指定' }].concat((KB.principles || []).map(p => ({
|
||||
id: 'Emmy 投資心法#' + p.id, label: p.title.replace(/^原則[^:]+:/, '').slice(0, 24),
|
||||
})));
|
||||
mountChips(container, items, selected || '', v => { hiddenInput.value = v; }, { sm: true });
|
||||
}
|
||||
async function openTradeForm(trade) {
|
||||
ensureTradeModal();
|
||||
|
|
@ -854,14 +1020,30 @@ async function openTradeForm(trade) {
|
|||
const isEdit = !!(trade && trade.id);
|
||||
$('#tradeFormTitle').textContent = isEdit ? '編輯交易' : '新增交易';
|
||||
f.dataset.id = isEdit ? trade.id : '';
|
||||
f.principle.innerHTML = principleOptions(trade ? trade.principle : '');
|
||||
f.mistake_note.innerHTML = principleOptions(trade ? trade.mistake_note : '');
|
||||
const dir = trade ? trade.direction : 'long';
|
||||
const kind = trade ? trade.kind : '投資';
|
||||
mountTiles($('#dirTiles'), [
|
||||
{ id: 'long', label: '做多', sub: 'Long', tint: 'green' },
|
||||
{ id: 'short', label: '做空', sub: 'Short', tint: 'red' },
|
||||
], dir, v => { f.direction.value = v; });
|
||||
mountTiles($('#kindTiles'), [
|
||||
{ id: '投資', label: '投資', sub: '基本面 · 趨勢' },
|
||||
{ id: '交易', label: '交易', sub: '情緒 · 資金' },
|
||||
], kind, v => { f.kind.value = v; });
|
||||
mountPrincipleChips($('#principleChips'), f.principle, trade ? trade.principle : '');
|
||||
mountPrincipleChips($('#mistakeChips'), f.mistake_note, trade ? trade.mistake_note : '');
|
||||
if (trade) {
|
||||
['symbol', 'name', 'direction', 'kind', 'entry_date', 'entry_price', 'shares', 'entry_reason', 'exit_date', 'exit_price', 'exit_reason', 'note'].forEach(k => { if (f[k] != null && trade[k] != null) f[k].value = trade[k]; });
|
||||
['symbol', 'name', 'entry_date', 'entry_price', 'shares', 'entry_reason', 'exit_date', 'exit_price', 'exit_reason', 'note'].forEach(k => { if (f[k] != null && trade[k] != null) f[k].value = trade[k]; });
|
||||
f.direction.value = trade.direction || 'long';
|
||||
f.kind.value = trade.kind || '投資';
|
||||
f.mistake.checked = !!trade.mistake;
|
||||
f.principle.value = trade.principle || '';
|
||||
f.mistake_note.value = trade.mistake_note || '';
|
||||
$('#mistakeNoteWrap').style.display = trade.mistake ? '' : 'none';
|
||||
mountTiles($('#dirTiles'), [{ id: 'long', label: '做多', sub: 'Long', tint: 'green' }, { id: 'short', label: '做空', sub: 'Short', tint: 'red' }], f.direction.value, v => { f.direction.value = v; });
|
||||
mountTiles($('#kindTiles'), [{ id: '投資', label: '投資', sub: '基本面 · 趨勢' }, { id: '交易', label: '交易', sub: '情緒 · 資金' }], f.kind.value, v => { f.kind.value = v; });
|
||||
mountPrincipleChips($('#principleChips'), f.principle, f.principle.value);
|
||||
mountPrincipleChips($('#mistakeChips'), f.mistake_note, f.mistake_note.value);
|
||||
}
|
||||
$('#tradeModal').style.display = 'flex';
|
||||
}
|
||||
|
|
@ -895,4 +1077,5 @@ async function submitTradeForm(e) {
|
|||
}
|
||||
|
||||
// 啟動:依目前 hash 顯示視圖(macro 由 index.html 內聯負責載入)
|
||||
initMermaid();
|
||||
setView(parseHash());
|
||||
|
|
|
|||
69
index.html
69
index.html
|
|
@ -3,35 +3,38 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Emmy 投資台 — 學習 · 財報健檢 · 交易復盤</title>
|
||||
<title>Emmy 投資台 — 學習 · 個股工具 · 交易復盤</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: #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);
|
||||
--bg: #f5f5f7;
|
||||
--surface: #ffffff;
|
||||
--card: #ffffff;
|
||||
--border: rgba(0,0,0,.08);
|
||||
--text: #1d1d1f;
|
||||
--text2: #86868b;
|
||||
--green: #34c759;
|
||||
--red: #ff3b30;
|
||||
--yellow: #ff9500;
|
||||
--blue: #0071e3;
|
||||
--purple: #af52de;
|
||||
--orange: #ff9500;
|
||||
--radius: 14px;
|
||||
--shadow: 0 2px 20px rgba(0,0,0,.06);
|
||||
--glass: rgba(255,255,255,.72);
|
||||
}
|
||||
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}
|
||||
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:rgba(10,14,23,.82);
|
||||
backdrop-filter:blur(12px);
|
||||
background:var(--glass);
|
||||
backdrop-filter:saturate(180%) blur(20px);
|
||||
-webkit-backdrop-filter:saturate(180%) blur(20px);
|
||||
border-bottom:1px solid var(--border);
|
||||
padding:14px 32px;
|
||||
display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;
|
||||
|
|
@ -153,13 +156,13 @@ a{color:var(--blue);text-decoration:none}
|
|||
.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);
|
||||
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:14px;
|
||||
width:min(820px,100%);max-height:90vh;overflow:auto;box-shadow:0 10px 50px rgba(0,0,0,.5);
|
||||
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}
|
||||
|
|
@ -171,7 +174,7 @@ a{color:var(--blue);text-decoration:none}
|
|||
.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)}
|
||||
.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}
|
||||
|
|
@ -289,7 +292,7 @@ a{color:var(--blue);text-decoration:none}
|
|||
// ═══════════════════════════════════════════════════════════
|
||||
// 顏色對應(colorKey → CSS 變數 / 十六進位)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
const HEX = {green:'#00d4aa',red:'#ff4d6a',yellow:'#ffc14d',blue:'#4da6ff',purple:'#b388ff',orange:'#ff8a4d',text:'#e8ecf1',text2:'#8899aa'};
|
||||
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 使用
|
||||
|
|
@ -376,9 +379,9 @@ function yieldCurveHTML(yc){
|
|||
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>`;});
|
||||
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:'正常是右上斜(長率>短率)。若左高右低(倒掛),代表短率高於長率。',
|
||||
|
|
@ -404,7 +407,7 @@ function yieldCurveHTML(yc){
|
|||
<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>
|
||||
<text x="${padL+102}" y="${padT+4}" fill="#86868b" font-size="9">一個月前</text>
|
||||
</svg>
|
||||
</div>`;
|
||||
}
|
||||
|
|
@ -748,11 +751,11 @@ function lineChart(points,opts){
|
|||
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>`;}
|
||||
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="#8899aa" font-size="10" text-anchor="middle">${dt.slice(2,7).replace('-','/')}</text>`;}
|
||||
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='';
|
||||
|
|
@ -775,9 +778,9 @@ function lineChart(points,opts){
|
|||
<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>
|
||||
<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="#e8ecf1" font-size="11.5" font-weight="600" text-anchor="middle" style="display:none"></text>
|
||||
<text id="hoverText" fill="#1d1d1f" font-size="11.5" font-weight="600" text-anchor="middle" style="display:none"></text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
|
|
@ -850,6 +853,8 @@ function renderError(data){
|
|||
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="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
// ═══════════════════════════════════════════════════════════
|
||||
// graph.js — 從知識庫筆記內 [[wikilink]] 建築節點與邊(給圖譜視圖)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
const WIKI_RE = /\[\[([^\]]+)\]\]/g;
|
||||
|
||||
const KIND_META = {
|
||||
overview: { color: '#0071e3', shape: 'box' },
|
||||
principleMap: { color: '#5856d6', shape: 'box' },
|
||||
quiz: { color: '#ff2d55', shape: 'box' },
|
||||
category: { color: '#0071e3', shape: 'box' },
|
||||
case: { color: '#34c759', shape: 'dot' },
|
||||
principle: { color: '#af52de', shape: 'dot' },
|
||||
term: { color: '#ff9500', shape: 'dot' },
|
||||
company: { color: '#5ac8fa', shape: 'dot' },
|
||||
episode: { color: '#8e8e93', shape: 'dot' },
|
||||
};
|
||||
|
||||
function nodeKey(hit) { return `${hit.kind}:${hit.id}`; }
|
||||
|
||||
function resolveLink(linkMap, target) {
|
||||
const t = (target || '').trim();
|
||||
return linkMap[t] || linkMap[t.split('#').pop()] || linkMap[t.split('/').pop()] || null;
|
||||
}
|
||||
|
||||
function shortLabel(title, max = 28) {
|
||||
const s = String(title || '');
|
||||
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
||||
}
|
||||
|
||||
export function buildGraph(knowledge, opts = {}) {
|
||||
const linkMap = knowledge.linkMap || {};
|
||||
const includeIndex = opts.includeIndex === '1' || opts.includeIndex === true;
|
||||
const kinds = opts.kinds ? new Set(String(opts.kinds).split(',')) : null;
|
||||
const limit = Math.min(Number(opts.limit) || 450, 800);
|
||||
const center = opts.center ? String(opts.center) : null;
|
||||
const depth = Math.min(Math.max(Number(opts.depth) || 2, 1), 4);
|
||||
|
||||
const nodes = new Map();
|
||||
const edgeSet = new Set();
|
||||
const edges = [];
|
||||
|
||||
const addNode = (hit) => {
|
||||
if (!hit) return null;
|
||||
if (kinds && !kinds.has(hit.kind)) return null;
|
||||
const id = nodeKey(hit);
|
||||
if (nodes.size >= limit && !nodes.has(id)) return null;
|
||||
if (!nodes.has(id)) {
|
||||
const meta = KIND_META[hit.kind] || { color: '#8e8e93', shape: 'dot' };
|
||||
nodes.set(id, {
|
||||
id, label: shortLabel(hit.title || hit.id),
|
||||
title: hit.title || hit.id,
|
||||
kind: hit.kind,
|
||||
color: meta.color,
|
||||
shape: meta.shape,
|
||||
});
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
const addEdge = (from, to) => {
|
||||
if (!from || !to || from === to) return;
|
||||
const k = `${from}|${to}`;
|
||||
if (edgeSet.has(k)) return;
|
||||
edgeSet.add(k);
|
||||
edges.push({ from, to });
|
||||
};
|
||||
|
||||
const parseBody = (body, fromId) => {
|
||||
if (!body || !fromId) return;
|
||||
let m;
|
||||
WIKI_RE.lastIndex = 0;
|
||||
while ((m = WIKI_RE.exec(body)) !== null) {
|
||||
const hit = resolveLink(linkMap, m[1]);
|
||||
const toId = addNode(hit);
|
||||
if (toId) addEdge(fromId, toId);
|
||||
}
|
||||
};
|
||||
|
||||
const seed = (hit, body) => {
|
||||
const id = addNode(hit);
|
||||
if (id && body) parseBody(body, id);
|
||||
return id;
|
||||
};
|
||||
|
||||
if (knowledge.overview) seed({ kind: 'overview', id: knowledge.overview.id, title: knowledge.overview.title }, knowledge.overview.body);
|
||||
if (knowledge.principleMap) seed({ kind: 'principleMap', id: knowledge.principleMap.id, title: knowledge.principleMap.title }, knowledge.principleMap.body);
|
||||
if (knowledge.quiz) seed({ kind: 'quiz', id: knowledge.quiz.id, title: knowledge.quiz.title }, knowledge.quiz.body);
|
||||
|
||||
for (const c of (knowledge.categories || [])) seed({ kind: 'category', id: c.id, title: c.title }, c.body);
|
||||
for (const c of (knowledge.cases || [])) seed({ kind: 'case', id: c.id, title: c.title }, c.body);
|
||||
for (const p of (knowledge.principles || [])) seed({ kind: 'principle', id: p.id, title: p.title }, p.body);
|
||||
|
||||
if (includeIndex) {
|
||||
for (const x of (knowledge.index || [])) addNode({ kind: x.kind, id: x.id, title: x.title });
|
||||
}
|
||||
|
||||
// 以某節點為中心:BFS 保留 depth 層內的節點與邊
|
||||
if (center && nodes.has(center)) {
|
||||
const keep = new Set([center]);
|
||||
let frontier = [center];
|
||||
for (let d = 0; d < depth; d++) {
|
||||
const next = [];
|
||||
for (const e of edges) {
|
||||
if (frontier.includes(e.from) && nodes.has(e.to)) { keep.add(e.to); next.push(e.to); }
|
||||
if (frontier.includes(e.to) && nodes.has(e.from)) { keep.add(e.from); next.push(e.from); }
|
||||
}
|
||||
frontier = [...new Set(next)];
|
||||
}
|
||||
const filtEdges = edges.filter(e => keep.has(e.from) && keep.has(e.to));
|
||||
const filtNodes = [...nodes.values()].filter(n => keep.has(n.id));
|
||||
return { nodes: filtNodes, edges: filtEdges, center, depth, total: filtNodes.length };
|
||||
}
|
||||
|
||||
return { nodes: [...nodes.values()], edges, total: nodes.size };
|
||||
}
|
||||
11
server.js
11
server.js
|
|
@ -28,6 +28,7 @@ import { buildReport } from './lib/fincheck.js';
|
|||
import { getHistory, RANGES, INTERVALS } from './lib/marketdata.js';
|
||||
import { runBacktest, STRATEGIES } from './lib/backtest.js';
|
||||
import { getInvestMap } from './lib/investmap.js';
|
||||
import { buildGraph } from './lib/graph.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
|
|
@ -233,6 +234,16 @@ app.get('/api/backtest/:symbol', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
app.get('/api/graph', (req, res) => {
|
||||
try {
|
||||
const k = getKnowledge();
|
||||
if (!k) return res.status(503).json({ error: 'knowledge_not_built', message: '知識庫尚未建立。' });
|
||||
res.json(buildGraph(k, req.query));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'graph_failed', message: String(err?.message || err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/investmap', (req, res) => {
|
||||
try {
|
||||
const k = getKnowledge();
|
||||
|
|
|
|||
Loading…
Reference in New Issue