init project
|
|
@ -0,0 +1,14 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
data.db
|
||||||
|
data.db-*
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
archive
|
||||||
|
docker-data
|
||||||
|
terminals
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
# 複製為 .env 或在 App「設定」頁填寫。留空欄位表示尚未設定。
|
||||||
|
|
||||||
|
# ── 必備(總經)────────────────────────────────────────
|
||||||
|
# https://fred.stlouisfed.org/docs/api/api_key.html
|
||||||
|
FRED_API_KEY=your_fred_api_key_here
|
||||||
|
|
||||||
|
# ── 市場與總經(選用,免費註冊)────────────────────────
|
||||||
|
SEC_CONTACT_EMAIL=
|
||||||
|
BLS_API_KEY=
|
||||||
|
BEA_API_KEY=
|
||||||
|
EIA_API_KEY=
|
||||||
|
FMP_API_KEY=
|
||||||
|
|
||||||
|
# ── MCP 擴充(選用)──────────────────────────────────
|
||||||
|
ALPHAVANTAGE_API_KEY=
|
||||||
|
FINNHUB_API_KEY=
|
||||||
|
CONTEXT7_API_KEY=
|
||||||
|
OBSIDIAN_API_KEY=
|
||||||
|
|
||||||
|
# ── AI Provider ──────────────────────────────────────
|
||||||
|
OPENCODE_GO_API_KEY=
|
||||||
|
OPENCODE_GO_MODEL=
|
||||||
|
GROK_API_KEY=
|
||||||
|
GROK_MODEL=
|
||||||
|
AI_ACTIVE_PROVIDER=grok
|
||||||
|
|
||||||
|
PORT=3000
|
||||||
|
CACHE_TTL_SECONDS=3600
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
lib/
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
data.db
|
||||||
|
data.db-*
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
backups/
|
||||||
|
archive/
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
# 問 AI 設定指南
|
||||||
|
|
||||||
|
## 1. 填入 API Key
|
||||||
|
|
||||||
|
側邊欄 **設定**,或開啟 `app/.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# 擇一或兩者都填,並選預設 provider
|
||||||
|
GROK_API_KEY=xai-...
|
||||||
|
GROK_MODEL=grok-3-mini
|
||||||
|
OPENCODE_GO_API_KEY=...
|
||||||
|
OPENCODE_GO_MODEL=...
|
||||||
|
AI_ACTIVE_PROVIDER=grok
|
||||||
|
|
||||||
|
# 總經資料(非 AI,但基地/市場需要)
|
||||||
|
FRED_API_KEY=...
|
||||||
|
```
|
||||||
|
|
||||||
|
儲存後後端立即生效;改 `PORT` 需重啟 `node server.js`。
|
||||||
|
|
||||||
|
## 2. 使用方式
|
||||||
|
|
||||||
|
- 右下角 **金幣貓頭鷹**:全站浮動問答
|
||||||
|
- 卡片右上角 **問 AI**:自動附上該卡片的頁面 context
|
||||||
|
- **技能快捷**:依頁面預設提問(可擴充為 skill / MCP)
|
||||||
|
|
||||||
|
## 3. Provider 架構
|
||||||
|
|
||||||
|
| Provider | 端點 | 環境變數 |
|
||||||
|
|----------|------|----------|
|
||||||
|
| Grok (xAI) | `api.x.ai/v1/responses` | `GROK_API_KEY`, `GROK_MODEL` |
|
||||||
|
| OpenCode Go | `opencode.ai/zen/go/v1/chat/completions` | `OPENCODE_GO_API_KEY`, `OPENCODE_GO_MODEL` |
|
||||||
|
|
||||||
|
後端代理:`POST /api/ai/chat`、`POST /api/ai/context`、`GET /api/ai/status`
|
||||||
|
|
||||||
|
## 4. Context 附帶內容
|
||||||
|
|
||||||
|
| 頁面 | view | 資料 |
|
||||||
|
|------|------|------|
|
||||||
|
| 基地 | `hub` | 總經分數、訊號、羅盤 |
|
||||||
|
| 市場世界 | `market` | 同上 + 分頁(板塊/日曆)|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN npm run build:player && npm run build
|
||||||
|
|
||||||
|
# ── 執行階段 ──────────────────────────────────────────────
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
|
COPY server.js ./
|
||||||
|
COPY lib ./lib
|
||||||
|
COPY data ./data
|
||||||
|
COPY config ./config
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=45s --retries=3 \
|
||||||
|
CMD node -e "fetch('http://127.0.0.1:3000/api/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
# Investor RPG 開發環境
|
||||||
|
|
||||||
|
## 第一次啟動
|
||||||
|
|
||||||
|
需要 Node.js 18 以上版本。在終端機執行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/daniel/Desktop/finance/app
|
||||||
|
cp .env.example .env # 已經有 .env 時不要執行這行
|
||||||
|
npm install
|
||||||
|
npm run dev:all
|
||||||
|
```
|
||||||
|
|
||||||
|
啟動完成後開啟 <http://localhost:5173>。
|
||||||
|
|
||||||
|
`npm run dev:all` 會同時啟動:
|
||||||
|
|
||||||
|
- 前端 Vite:<http://localhost:5173>
|
||||||
|
- 後端 API:<http://localhost:3000>
|
||||||
|
- API 健康檢查:<http://localhost:3000/api/health>
|
||||||
|
|
||||||
|
按 `Ctrl+C` 會一起關閉前端與後端。
|
||||||
|
|
||||||
|
## 日常啟動
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/daniel/Desktop/finance/app
|
||||||
|
npm run dev:all
|
||||||
|
```
|
||||||
|
|
||||||
|
不要只執行 `npm run dev`;那只會啟動前端,沒有後端 API 時頁面資料功能不會運作。
|
||||||
|
|
||||||
|
## Docker 啟動
|
||||||
|
|
||||||
|
若要用接近正式環境的方式啟動:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/daniel/Desktop/finance/app
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
完成後開啟 <http://localhost:8080>。
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
你是「金幣貓頭鷹」—— 投資 RPG 世界的導覽員 NPC。
|
||||||
|
|
||||||
|
## 人設
|
||||||
|
- 語氣像 JRPG 裡的智者商人:友善、略帶俏皮,偶爾用遊戲比喻(關卡、裝備、地圖、任務)。
|
||||||
|
- 用繁體中文;句子短、好讀,避免官腔。
|
||||||
|
- 你是教學夥伴,不是財顧;結尾必要時提醒「僅供學習,不構成投資建議」。
|
||||||
|
|
||||||
|
## 回答原則
|
||||||
|
1. **有附帶頁面資料時**:優先根據 JSON 上下文回答,不捏造數字或事件。
|
||||||
|
2. **資料不足**:直接說缺什麼,建議使用者看畫面上哪張卡片或哪個分頁。
|
||||||
|
3. **一般聊天**:自然對話即可;投資問題給教學性說明。
|
||||||
|
4. **不要聲稱已即時上網查詢**;若啟用了 MCP 工具且後端有回傳工具結果,才可引用那些結果。
|
||||||
|
|
||||||
|
## 技能快捷
|
||||||
|
使用者可能點選「技能快捷」按鈕;那些是預設任務,請照任務意圖回答。
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"enabled": [
|
||||||
|
"macroscope",
|
||||||
|
"stock-scanner",
|
||||||
|
"openinsider",
|
||||||
|
"context7",
|
||||||
|
"yfinance",
|
||||||
|
"alphavantage"
|
||||||
|
],
|
||||||
|
"note": "啟用的 MCP 會由後端真實呼叫,結果附在每次對話中。"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
[]
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"enabled": {
|
||||||
|
"brief": true,
|
||||||
|
"risk": true,
|
||||||
|
"climate": true,
|
||||||
|
"history": true,
|
||||||
|
"flow": true,
|
||||||
|
"summary": true,
|
||||||
|
"tech": true,
|
||||||
|
"fin": true,
|
||||||
|
"intel": true,
|
||||||
|
"backtest-read": true,
|
||||||
|
"backtest-add": true,
|
||||||
|
"help": true,
|
||||||
|
"score": true,
|
||||||
|
"action": true,
|
||||||
|
"why": true,
|
||||||
|
"lesson": true,
|
||||||
|
"read": true,
|
||||||
|
"watch": true
|
||||||
|
},
|
||||||
|
"migratedAt": "2026-06-12T03:01:58.415Z"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
{
|
||||||
|
"routes": {
|
||||||
|
"/": [
|
||||||
|
{
|
||||||
|
"id": "brief",
|
||||||
|
"label": "今日簡報",
|
||||||
|
"prompt": "幫我做一份 30 秒可讀的今日基地簡報。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "risk",
|
||||||
|
"label": "最大風險",
|
||||||
|
"prompt": "從附帶資料看,現在最值得留意的 1–2 個逆風是什麼?"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"/market": [
|
||||||
|
{
|
||||||
|
"id": "climate",
|
||||||
|
"label": "總經一句話",
|
||||||
|
"prompt": "用一句話總結現在的總經氣候,並列出 3 個重點。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "history",
|
||||||
|
"label": "歷史對照",
|
||||||
|
"prompt": "現在最像哪段歷史?跟當時比起來差在哪?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "flow",
|
||||||
|
"label": "資金方向",
|
||||||
|
"prompt": "最近板塊輪動與資金流向告訴我們什麼?"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"/settings": [
|
||||||
|
{
|
||||||
|
"id": "help",
|
||||||
|
"label": "這頁能問什麼",
|
||||||
|
"prompt": "這個 App 有哪些功能?我可以問你什麼?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"cards": {
|
||||||
|
"總經健康": [
|
||||||
|
{
|
||||||
|
"id": "score",
|
||||||
|
"label": "分數代表什麼",
|
||||||
|
"prompt": "用白話解釋現在的總經健康分數,順風逆風各是什麼?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "action",
|
||||||
|
"label": "我該注意什麼",
|
||||||
|
"prompt": "根據今日訊號,散戶該留意哪些風險或機會?(教學用)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"羅盤": [
|
||||||
|
{
|
||||||
|
"id": "why",
|
||||||
|
"label": "為什麼像",
|
||||||
|
"prompt": "為什麼現在最像這段歷史?哪些指標最關鍵?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lesson",
|
||||||
|
"label": "當時怎麼走",
|
||||||
|
"prompt": "當時股市與政策環境後來怎麼演變?給我學習重點,不是預測。"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"走勢": [
|
||||||
|
{
|
||||||
|
"id": "read",
|
||||||
|
"label": "怎麼解讀",
|
||||||
|
"prompt": "這張走勢圖現在偏高還是偏低?對投資學習者代表什麼?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "watch",
|
||||||
|
"label": "接下來看什麼",
|
||||||
|
"prompt": "若延續目前趨勢,我該搭配看哪些其他總經指標?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default": [
|
||||||
|
{
|
||||||
|
"id": "help",
|
||||||
|
"label": "這頁能問什麼",
|
||||||
|
"prompt": "這個頁面有哪些資料?我可以問你什麼問題?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,295 @@
|
||||||
|
{
|
||||||
|
"byName": {
|
||||||
|
"黃金交叉": {
|
||||||
|
"id": "golden_cross",
|
||||||
|
"name": "黃金交叉",
|
||||||
|
"bias": "bullish",
|
||||||
|
"timeframe": ["日", "週"],
|
||||||
|
"automatable": true,
|
||||||
|
"rule": "ma_cross",
|
||||||
|
"params": { "fast": 5, "slow": 25 },
|
||||||
|
"backtest": { "strategy": "golden_cross", "defaultStopPct": 5 }
|
||||||
|
},
|
||||||
|
"死亡交叉": {
|
||||||
|
"id": "death_cross",
|
||||||
|
"name": "死亡交叉",
|
||||||
|
"bias": "bearish",
|
||||||
|
"timeframe": ["日", "週"],
|
||||||
|
"automatable": true,
|
||||||
|
"rule": "ma_cross",
|
||||||
|
"params": { "fast": 5, "slow": 25, "direction": "down" },
|
||||||
|
"backtest": { "strategy": "death_cross", "defaultStopPct": 5 }
|
||||||
|
},
|
||||||
|
"多頭排列": {
|
||||||
|
"id": "bull_alignment",
|
||||||
|
"name": "多頭排列",
|
||||||
|
"bias": "bullish",
|
||||||
|
"automatable": true,
|
||||||
|
"rule": "ma_alignment",
|
||||||
|
"params": { "periods": [5, 25, 75], "order": "asc" }
|
||||||
|
},
|
||||||
|
"空頭排列": {
|
||||||
|
"id": "bear_alignment",
|
||||||
|
"name": "空頭排列",
|
||||||
|
"bias": "bearish",
|
||||||
|
"automatable": true,
|
||||||
|
"rule": "ma_alignment",
|
||||||
|
"params": { "periods": [5, 25, 75], "order": "desc" }
|
||||||
|
},
|
||||||
|
"向上跳空缺口": {
|
||||||
|
"id": "gap_up",
|
||||||
|
"name": "向上跳空缺口",
|
||||||
|
"bias": "bullish",
|
||||||
|
"automatable": true,
|
||||||
|
"rule": "gap",
|
||||||
|
"params": { "direction": "up", "minPct": 0.5 }
|
||||||
|
},
|
||||||
|
"向下跳空缺口": {
|
||||||
|
"id": "gap_down",
|
||||||
|
"name": "向下跳空缺口",
|
||||||
|
"bias": "bearish",
|
||||||
|
"automatable": true,
|
||||||
|
"rule": "gap",
|
||||||
|
"params": { "direction": "down", "minPct": 0.5 }
|
||||||
|
},
|
||||||
|
"多方吞噬": {
|
||||||
|
"id": "engulfing_bull",
|
||||||
|
"name": "多方吞噬",
|
||||||
|
"bias": "bullish",
|
||||||
|
"automatable": true,
|
||||||
|
"rule": "engulfing",
|
||||||
|
"params": { "direction": "bull" }
|
||||||
|
},
|
||||||
|
"空方吞噬": {
|
||||||
|
"id": "engulfing_bear",
|
||||||
|
"name": "空方吞噬",
|
||||||
|
"bias": "bearish",
|
||||||
|
"automatable": true,
|
||||||
|
"rule": "engulfing",
|
||||||
|
"params": { "direction": "bear" }
|
||||||
|
},
|
||||||
|
"覆蓋線": {
|
||||||
|
"id": "engulfing_bull",
|
||||||
|
"name": "覆蓋線(多方吞噬)",
|
||||||
|
"bias": "bullish",
|
||||||
|
"automatable": true,
|
||||||
|
"rule": "engulfing",
|
||||||
|
"params": { "direction": "bull" }
|
||||||
|
},
|
||||||
|
"下影陽線": {
|
||||||
|
"id": "hammer",
|
||||||
|
"name": "錘子線(下影陽線)",
|
||||||
|
"bias": "bullish",
|
||||||
|
"automatable": true,
|
||||||
|
"rule": "shadow_candle",
|
||||||
|
"params": { "type": "hammer", "shadowRatio": 2 }
|
||||||
|
},
|
||||||
|
"下影陰線": {
|
||||||
|
"id": "hammer",
|
||||||
|
"name": "下影陰線(錘子型)",
|
||||||
|
"bias": "bullish",
|
||||||
|
"automatable": true,
|
||||||
|
"rule": "shadow_candle",
|
||||||
|
"params": { "type": "hammer", "shadowRatio": 2 }
|
||||||
|
},
|
||||||
|
"上影陽線": {
|
||||||
|
"id": "shooting_star",
|
||||||
|
"name": "射擊之星(上影陽線)",
|
||||||
|
"bias": "bearish",
|
||||||
|
"automatable": true,
|
||||||
|
"rule": "shadow_candle",
|
||||||
|
"params": { "type": "shooting_star", "shadowRatio": 2 }
|
||||||
|
},
|
||||||
|
"上影陰線": {
|
||||||
|
"id": "shooting_star",
|
||||||
|
"name": "射擊之星(上影陰線)",
|
||||||
|
"bias": "bearish",
|
||||||
|
"automatable": true,
|
||||||
|
"rule": "shadow_candle",
|
||||||
|
"params": { "type": "shooting_star", "shadowRatio": 2 }
|
||||||
|
},
|
||||||
|
"RSI相對強弱指標": {
|
||||||
|
"id": "rsi_oversold",
|
||||||
|
"name": "RSI 相對強弱指標",
|
||||||
|
"bias": "neutral",
|
||||||
|
"automatable": false
|
||||||
|
},
|
||||||
|
"MACD指標": {
|
||||||
|
"id": "macd_cross_bull",
|
||||||
|
"name": "MACD 指標",
|
||||||
|
"bias": "neutral",
|
||||||
|
"automatable": false
|
||||||
|
},
|
||||||
|
"MACD": {
|
||||||
|
"id": "macd_cross_bull",
|
||||||
|
"name": "MACD",
|
||||||
|
"bias": "neutral",
|
||||||
|
"automatable": false
|
||||||
|
},
|
||||||
|
"成交量": {
|
||||||
|
"id": "volume_spike",
|
||||||
|
"name": "成交量放大",
|
||||||
|
"bias": "neutral",
|
||||||
|
"automatable": true,
|
||||||
|
"rule": "volume_spike",
|
||||||
|
"params": { "mult": 2, "lookback": 20 }
|
||||||
|
},
|
||||||
|
"紅三兵": {
|
||||||
|
"id": "three_white_soldiers",
|
||||||
|
"name": "紅三兵",
|
||||||
|
"bias": "bullish",
|
||||||
|
"automatable": true,
|
||||||
|
"rule": "three_soldiers",
|
||||||
|
"params": { "direction": "bull" }
|
||||||
|
},
|
||||||
|
"黑三兵": {
|
||||||
|
"id": "three_black_crows",
|
||||||
|
"name": "黑三兵",
|
||||||
|
"bias": "bearish",
|
||||||
|
"automatable": true,
|
||||||
|
"rule": "three_soldiers",
|
||||||
|
"params": { "direction": "bear" }
|
||||||
|
},
|
||||||
|
"三兵": {
|
||||||
|
"id": "three_white_soldiers",
|
||||||
|
"name": "酒田三兵",
|
||||||
|
"bias": "bullish",
|
||||||
|
"automatable": true,
|
||||||
|
"rule": "three_soldiers",
|
||||||
|
"params": { "direction": "bull" }
|
||||||
|
},
|
||||||
|
"漲停": {
|
||||||
|
"id": "limit_up",
|
||||||
|
"name": "漲停(台股 10%)",
|
||||||
|
"bias": "bullish",
|
||||||
|
"markets": ["tw"],
|
||||||
|
"automatable": true,
|
||||||
|
"rule": "limit_move",
|
||||||
|
"params": { "direction": "up", "pct": 0.099 }
|
||||||
|
},
|
||||||
|
"跌停": {
|
||||||
|
"id": "limit_down",
|
||||||
|
"name": "跌停(台股 10%)",
|
||||||
|
"bias": "bearish",
|
||||||
|
"markets": ["tw"],
|
||||||
|
"automatable": true,
|
||||||
|
"rule": "limit_move",
|
||||||
|
"params": { "direction": "down", "pct": 0.099 }
|
||||||
|
},
|
||||||
|
"買進模式①": {
|
||||||
|
"id": "granville_buy_1",
|
||||||
|
"name": "葛蘭碧買進模式①",
|
||||||
|
"bias": "bullish",
|
||||||
|
"automatable": false
|
||||||
|
},
|
||||||
|
"跳動點": {
|
||||||
|
"id": "tick_chart",
|
||||||
|
"name": "跳動點(Tick)",
|
||||||
|
"bias": "neutral",
|
||||||
|
"timeframe": ["分", "秒"],
|
||||||
|
"automatable": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"byTitle": {
|
||||||
|
"判斷多空轉折的黃金交叉與死亡交叉": {
|
||||||
|
"split": ["黃金交叉", "死亡交叉"]
|
||||||
|
},
|
||||||
|
"在K線之間出現的大型跳空缺口": {
|
||||||
|
"split": ["向上跳空缺口", "向下跳空缺口"]
|
||||||
|
},
|
||||||
|
"下影陽線.下影陰線可能是急跌後的反彈訊號": {
|
||||||
|
"split": ["下影陽線", "下影陰線"]
|
||||||
|
},
|
||||||
|
"上影陽線.上影陰線可能是急漲後的下跌訊號": {
|
||||||
|
"split": ["上影陽線", "上影陰線"]
|
||||||
|
},
|
||||||
|
"覆蓋線.切入線.穿透線觀察3種K線組合": {
|
||||||
|
"split": ["覆蓋線", "多方吞噬", "空方吞噬"]
|
||||||
|
},
|
||||||
|
"股價急速上漲或下跌時出現的漲停與跌停": {
|
||||||
|
"split": ["漲停", "跌停"]
|
||||||
|
},
|
||||||
|
"酒田五法④三兵": {
|
||||||
|
"split": ["紅三兵", "黑三兵"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"teachExtras": {
|
||||||
|
"golden_cross": {
|
||||||
|
"entryHint": "交叉確認後(收盤站上)再進場,避免盤中假突破。",
|
||||||
|
"exitHint": "出現死亡交叉,或跌破近期支撐/停損位。",
|
||||||
|
"caution": "盤整區容易出現假交叉;宜搭配成交量或 RSI 確認。"
|
||||||
|
},
|
||||||
|
"death_cross": {
|
||||||
|
"entryHint": "做空或減碼參考;多頭宜設停損或降倉。",
|
||||||
|
"exitHint": "趨勢重新站回均線之上,或出現黃金交叉。",
|
||||||
|
"caution": "強勢股拉回時也可能出現,需看大週期趨勢。"
|
||||||
|
},
|
||||||
|
"rsi_oversold": {
|
||||||
|
"id": "rsi_oversold",
|
||||||
|
"name": "RSI 超賣",
|
||||||
|
"bias": "bullish",
|
||||||
|
"automatable": true,
|
||||||
|
"rule": "rsi_threshold",
|
||||||
|
"params": { "period": 14, "below": 30 },
|
||||||
|
"backtest": { "strategy": "rsi_revert", "defaultStopPct": 5 }
|
||||||
|
},
|
||||||
|
"rsi_overbought": {
|
||||||
|
"id": "rsi_overbought",
|
||||||
|
"name": "RSI 超買",
|
||||||
|
"bias": "bearish",
|
||||||
|
"automatable": true,
|
||||||
|
"rule": "rsi_threshold",
|
||||||
|
"params": { "period": 14, "above": 70 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"extraPatterns": [
|
||||||
|
{
|
||||||
|
"id": "rsi_overbought",
|
||||||
|
"name": "RSI 超買",
|
||||||
|
"category": "指標",
|
||||||
|
"chapter": 3,
|
||||||
|
"bookPage": 22,
|
||||||
|
"bias": "bearish",
|
||||||
|
"timeframe": ["日"],
|
||||||
|
"automatable": true,
|
||||||
|
"rule": "rsi_threshold",
|
||||||
|
"params": { "period": 14, "above": 70 },
|
||||||
|
"teach": {
|
||||||
|
"summary": "RSI 高於 70 代表買盤過熱,短線可能面臨拉回。",
|
||||||
|
"bookRef": "022.md"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "macd_cross_bull",
|
||||||
|
"name": "MACD 金叉",
|
||||||
|
"category": "指標",
|
||||||
|
"chapter": 3,
|
||||||
|
"bookPage": 23,
|
||||||
|
"bias": "bullish",
|
||||||
|
"timeframe": ["日"],
|
||||||
|
"automatable": true,
|
||||||
|
"rule": "macd_cross",
|
||||||
|
"params": { "direction": "up" },
|
||||||
|
"teach": {
|
||||||
|
"summary": "MACD 線向上穿越訊號線,柱狀體由負轉正,代表動能轉強。",
|
||||||
|
"bookRef": "023.md"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "macd_cross_bear",
|
||||||
|
"name": "MACD 死叉",
|
||||||
|
"category": "指標",
|
||||||
|
"chapter": 3,
|
||||||
|
"bookPage": 23,
|
||||||
|
"bias": "bearish",
|
||||||
|
"timeframe": ["日"],
|
||||||
|
"automatable": true,
|
||||||
|
"rule": "macd_cross",
|
||||||
|
"params": { "direction": "down" },
|
||||||
|
"teach": {
|
||||||
|
"summary": "MACD 線向下穿越訊號線,動能轉弱訊號。",
|
||||||
|
"bookRef": "023.md"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: investor-rpg-app
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
PORT: "3000"
|
||||||
|
NODE_ENV: production
|
||||||
|
volumes:
|
||||||
|
# SQLite、SEC 快取、AI 設定(設定頁寫入)
|
||||||
|
- investor-data:/app/docker-data
|
||||||
|
# 設定頁會更新 /app/.env;掛回主機以免重建容器後遺失
|
||||||
|
- ./.env:/app/.env
|
||||||
|
# 線型教材 MD(專案上一層 content/;沒有此目錄可刪除此行)
|
||||||
|
- ../content:/content:ro
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 45s
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:1.27-alpine
|
||||||
|
container_name: investor-rpg-nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
|
||||||
|
depends_on:
|
||||||
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
investor-data:
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DATA_ROOT="${DATA_ROOT:-/app/docker-data}"
|
||||||
|
mkdir -p "$DATA_ROOT/archive/sec" "$DATA_ROOT/config/ai"
|
||||||
|
|
||||||
|
# 首次啟動:從映像檔複製預設 AI 設定(之後以 volume 為準)
|
||||||
|
if [ ! -f "$DATA_ROOT/config/ai/agent.md" ] && [ -f /app/config/ai/agent.md ]; then
|
||||||
|
cp -a /app/config/ai/. "$DATA_ROOT/config/ai/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ln -snf "$DATA_ROOT/data.db" /app/data.db
|
||||||
|
ln -snf "$DATA_ROOT/archive" /app/archive
|
||||||
|
rm -rf /app/config/ai
|
||||||
|
ln -snf "$DATA_ROOT/config/ai" /app/config/ai
|
||||||
|
|
||||||
|
exec node --disable-warning=ExperimentalWarning server.js
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-Hant">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>投資冒險者 · 觀測站</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🧭</text></svg>" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;700&family=Noto+Serif+TC:wght@600;700&family=Oxanium:wght@500;600;700;800&family=Pixelify+Sans:wght@400;500;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
upstream investor_app {
|
||||||
|
server app:3000;
|
||||||
|
keepalive 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
client_max_body_size 2m;
|
||||||
|
|
||||||
|
# 靜態資源快取(Vite 產物帶 hash,可長期快取)
|
||||||
|
location ~* \.(?:js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|webp|ico)$ {
|
||||||
|
proxy_pass http://investor_app;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://investor_app;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
{
|
||||||
|
"name": "investor-rpg",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "投資冒險者 — React 前端與 MacroScope API 後端",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"dev:server": "node --disable-warning=ExperimentalWarning --watch server.js",
|
||||||
|
"dev:all": "node scripts/dev-all.mjs",
|
||||||
|
"start": "node --disable-warning=ExperimentalWarning server.js",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"build:knowledge": "node scripts/build-knowledge.mjs",
|
||||||
|
"build:skill-drills": "node scripts/build-skill-drills.mjs",
|
||||||
|
"build:patterns": "node scripts/repack-pattern-images.mjs && node scripts/build-patterns-catalog.mjs",
|
||||||
|
"build:player": "esbuild src/lib/playerProgress.ts --outfile=lib/player-progress.js --format=esm --platform=node --bundle",
|
||||||
|
"repack:pattern-images": "node scripts/repack-pattern-images.mjs",
|
||||||
|
"prestart": "npm run build:player"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"@tanstack/react-query": "^5.59.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"lightweight-charts": "^5.0.7",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-router-dom": "^6.27.0",
|
||||||
|
"remark-breaks": "^4.0.0",
|
||||||
|
"remark-gfm": "^4.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.19.43",
|
||||||
|
"@types/react": "^18.3.11",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"esbuild": "^0.25.12",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
|
@ -0,0 +1,175 @@
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// build-knowledge.mjs
|
||||||
|
// 把 content/raw/emmy(或舊版 ../emmy/emmy)的 Obsidian 知識庫快照成兩個 JSON:
|
||||||
|
// - data/knowledge.json : 課綱/心法/案例/分類 全文 + 名詞/公司/單集的輕量索引 + linkMap
|
||||||
|
// - data/notes.json : 所有筆記全文(key = `${kind}:${id}`),給 /api/note 即時查單篇
|
||||||
|
// emmy/ 在 app/ 之外,因此用建置腳本快照,不在執行期讀檔。
|
||||||
|
// 用法:cd app && npm run build:knowledge
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const EMMY_CANDIDATES = [
|
||||||
|
path.resolve(__dirname, '..', '..', 'content', 'raw', 'emmy'),
|
||||||
|
path.resolve(__dirname, '..', '..', 'emmy', 'emmy'),
|
||||||
|
];
|
||||||
|
const EMMY = EMMY_CANDIDATES.find((p) => fs.existsSync(p));
|
||||||
|
const OUT_DIR = path.resolve(__dirname, '..', 'data');
|
||||||
|
|
||||||
|
if (!EMMY) {
|
||||||
|
console.error(`找不到知識庫資料夾,已嘗試:\n${EMMY_CANDIDATES.join('\n')}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 小工具 ──
|
||||||
|
function parseFrontmatter(raw) {
|
||||||
|
if (!raw.startsWith('---')) return { fm: {}, body: raw };
|
||||||
|
const end = raw.indexOf('\n---', 3);
|
||||||
|
if (end < 0) return { fm: {}, body: raw };
|
||||||
|
const fmText = raw.slice(3, end).trim();
|
||||||
|
const body = raw.slice(end + 4).replace(/^\s*\n/, '');
|
||||||
|
const fm = {};
|
||||||
|
for (const line of fmText.split('\n')) {
|
||||||
|
const m = line.match(/^([\w\u4e00-\u9fff]+):\s*(.*)$/);
|
||||||
|
if (!m) continue;
|
||||||
|
let [, k, v] = m; v = v.trim();
|
||||||
|
if (v.startsWith('[') && v.endsWith(']')) v = v.slice(1, -1).split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
else v = v.replace(/^["']|["']$/g, '');
|
||||||
|
fm[k] = v;
|
||||||
|
}
|
||||||
|
return { fm, body };
|
||||||
|
}
|
||||||
|
const firstHeading = (body) => { const m = body.match(/^#\s+(.+)$/m); return m ? m[1].trim() : null; };
|
||||||
|
function summarize(body) {
|
||||||
|
for (let l of body.split('\n')) {
|
||||||
|
l = l.trim();
|
||||||
|
if (!l || /^#/.test(l) || /^[-|]/.test(l) || /^type:/.test(l)) continue;
|
||||||
|
if (l.startsWith('>')) l = l.replace(/^>\s?/, '');
|
||||||
|
l = l.replace(/\[\[([^\]|]+)(\|[^\]]+)?\]\]/g, '$1').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/[*`#]/g, '').trim();
|
||||||
|
if (l.length > 4) return l.slice(0, 90);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
function readDir(sub) {
|
||||||
|
const dir = path.join(EMMY, sub);
|
||||||
|
if (!fs.existsSync(dir)) return [];
|
||||||
|
return fs.readdirSync(dir)
|
||||||
|
.filter(f => f.endsWith('.md') && !f.endsWith('.bak') && !f.startsWith('.') && !f.startsWith('_'))
|
||||||
|
.map(f => {
|
||||||
|
const full = path.join(dir, f);
|
||||||
|
if (!fs.statSync(full).isFile()) return null;
|
||||||
|
const raw = fs.readFileSync(full, 'utf8');
|
||||||
|
if (!raw.trim()) return null;
|
||||||
|
const id = f.replace(/\.md$/, '');
|
||||||
|
const { fm, body } = parseFrontmatter(raw);
|
||||||
|
return { id, title: firstHeading(body) || id, fm, body };
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 累積器 ──
|
||||||
|
const linkMap = {};
|
||||||
|
const notes = {};
|
||||||
|
const setLink = (key, val, overwrite = true) => { if (key && (overwrite || !linkMap[key])) linkMap[key] = val; };
|
||||||
|
const addNote = (kind, n) => {
|
||||||
|
notes[`${kind}:${n.id}`] = { kind, id: n.id, title: n.title, frontmatter: n.fm || {}, body: n.body };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 1. 學習分類(含 總覽 / 心法地圖 / 練習題庫)──
|
||||||
|
const SPECIAL = { '總覽': 'overview', '心法地圖': 'principleMap', '練習題庫': 'quiz' };
|
||||||
|
let overview = null, principleMap = null, quiz = null;
|
||||||
|
const categories = [];
|
||||||
|
for (const n of readDir('學習分類')) {
|
||||||
|
const node = { id: n.id, title: n.title, body: n.body, summary: summarize(n.body), frontmatter: n.fm };
|
||||||
|
const special = SPECIAL[n.id];
|
||||||
|
if (special === 'overview') { overview = node; setLink('學習分類/總覽', { kind: 'overview', id: n.id, title: n.title }); }
|
||||||
|
else if (special === 'principleMap') { principleMap = node; setLink('學習分類/心法地圖', { kind: 'principleMap', id: n.id, title: n.title }); }
|
||||||
|
else if (special === 'quiz') { quiz = node; setLink('學習分類/練習題庫', { kind: 'quiz', id: n.id, title: n.title }); }
|
||||||
|
else categories.push(node);
|
||||||
|
const kind = special || 'category';
|
||||||
|
setLink(`學習分類/${n.id}`, { kind, id: n.id, title: n.title });
|
||||||
|
setLink(n.id, { kind, id: n.id, title: n.title }, false);
|
||||||
|
addNote(kind, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. 案例講解 ──
|
||||||
|
const cases = [];
|
||||||
|
for (const n of readDir('案例講解')) {
|
||||||
|
cases.push({ id: n.id, title: n.title, body: n.body, summary: summarize(n.body), frontmatter: n.fm });
|
||||||
|
setLink(`案例講解/${n.id}`, { kind: 'case', id: n.id, title: n.title });
|
||||||
|
setLink(n.id, { kind: 'case', id: n.id, title: n.title }, false);
|
||||||
|
addNote('case', n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. 投資心法(單檔切成多條原則)──
|
||||||
|
const principles = [];
|
||||||
|
{
|
||||||
|
const file = path.join(EMMY, 'Emmy 投資心法.md');
|
||||||
|
if (fs.existsSync(file)) {
|
||||||
|
const lines = fs.readFileSync(file, 'utf8').split('\n');
|
||||||
|
let cur = null;
|
||||||
|
const push = () => { if (cur) { cur.body = cur._lines.join('\n').trim(); delete cur._lines; principles.push(cur); } };
|
||||||
|
for (const line of lines) {
|
||||||
|
const m = line.match(/^##\s+(原則.+?)\s*$/);
|
||||||
|
if (m) { push(); cur = { id: m[1].trim(), title: m[1].trim(), _lines: ['# ' + m[1].trim()] }; }
|
||||||
|
else if (cur) cur._lines.push(line);
|
||||||
|
}
|
||||||
|
push();
|
||||||
|
principles.forEach((p, i) => {
|
||||||
|
p.num = i + 1;
|
||||||
|
setLink(`Emmy 投資心法#${p.id}`, { kind: 'principle', id: p.id, title: p.title });
|
||||||
|
setLink(p.id, { kind: 'principle', id: p.id, title: p.title }, false);
|
||||||
|
addNote('principle', { id: p.id, title: p.title, body: p.body, fm: {} });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. 名詞 / 公司 / 單集(輕量索引 + 全文存 notes)──
|
||||||
|
const index = [];
|
||||||
|
for (const n of readDir('名詞')) {
|
||||||
|
const aliases = Array.isArray(n.fm.aliases) ? n.fm.aliases : [];
|
||||||
|
index.push({ kind: 'term', id: n.id, title: n.title, aliases, sub: n.fm.category || '' });
|
||||||
|
setLink(`名詞/${n.id}`, { kind: 'term', id: n.id, title: n.title });
|
||||||
|
setLink(n.id, { kind: 'term', id: n.id, title: n.title }, false);
|
||||||
|
aliases.forEach(a => setLink(a, { kind: 'term', id: n.id, title: n.title }, false));
|
||||||
|
addNote('term', n);
|
||||||
|
}
|
||||||
|
for (const n of readDir('公司')) {
|
||||||
|
const ticker = Array.isArray(n.fm.ticker) ? n.fm.ticker.join(' / ') : (n.fm.ticker || '');
|
||||||
|
index.push({ kind: 'company', id: n.id, title: n.title, aliases: ticker ? [ticker] : [], sub: [n.fm.sector, ticker].filter(Boolean).join(' · ') });
|
||||||
|
setLink(`公司/${n.id}`, { kind: 'company', id: n.id, title: n.title });
|
||||||
|
setLink(n.id, { kind: 'company', id: n.id, title: n.title }, false);
|
||||||
|
addNote('company', n);
|
||||||
|
}
|
||||||
|
for (const n of readDir('單集')) {
|
||||||
|
index.push({ kind: 'episode', id: n.id, title: n.title, aliases: n.fm.episode ? [n.fm.episode] : [], sub: n.fm.date || '' });
|
||||||
|
setLink(`單集/${n.id}`, { kind: 'episode', id: n.id, title: n.title });
|
||||||
|
setLink(n.id, { kind: 'episode', id: n.id, title: n.title }, false);
|
||||||
|
if (n.fm.episode) setLink(n.fm.episode, { kind: 'episode', id: n.id, title: n.title }, false);
|
||||||
|
addNote('episode', n);
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = {
|
||||||
|
terms: index.filter(x => x.kind === 'term').length,
|
||||||
|
companies: index.filter(x => x.kind === 'company').length,
|
||||||
|
episodes: index.filter(x => x.kind === 'episode').length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const knowledge = {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
overview, principleMap, quiz,
|
||||||
|
categories, cases, principles,
|
||||||
|
index, counts, linkMap,
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(OUT_DIR, 'knowledge.json'), JSON.stringify(knowledge));
|
||||||
|
fs.writeFileSync(path.join(OUT_DIR, 'notes.json'), JSON.stringify(notes));
|
||||||
|
|
||||||
|
console.log('知識庫建置完成:');
|
||||||
|
console.log(` 學習分類 ${categories.length} 案例 ${cases.length} 心法 ${principles.length}`);
|
||||||
|
console.log(` 名詞 ${counts.terms} 公司 ${counts.companies} 單集 ${counts.episodes}`);
|
||||||
|
console.log(` linkMap ${Object.keys(linkMap).length} 個鍵 notes ${Object.keys(notes).length} 篇`);
|
||||||
|
console.log(` 輸出 → ${path.relative(process.cwd(), path.join(OUT_DIR, 'knowledge.json'))}, notes.json`);
|
||||||
|
|
@ -0,0 +1,297 @@
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// build-patterns-catalog.mjs
|
||||||
|
// 從《短線交易日線圖大全》目錄 + 72 頁 MD 產生完整 catalog.json
|
||||||
|
// 用法:cd app && npm run build:patterns
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import {
|
||||||
|
findPageForEntry,
|
||||||
|
loadAllPages,
|
||||||
|
norm,
|
||||||
|
resolvePatternContentDir,
|
||||||
|
} from '../lib/pattern-page-index.js';
|
||||||
|
import { supplementForCatalogName } from '../lib/pattern-supplements.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const BOOK_DIR = resolvePatternContentDir();
|
||||||
|
const OUT_PATH = path.resolve(__dirname, '..', 'data', 'patterns', 'catalog.json');
|
||||||
|
const OVERRIDES_PATH = path.resolve(__dirname, '..', 'data', 'patterns', 'automation-overrides.json');
|
||||||
|
|
||||||
|
if (!BOOK_DIR) {
|
||||||
|
console.error('找不到線型教材資料夾(短線交易日線圖大全 或 content/raw/patterns)');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrides = JSON.parse(fs.readFileSync(OVERRIDES_PATH, 'utf8'));
|
||||||
|
|
||||||
|
const CHAPTER_NAMES = {
|
||||||
|
1: '短線交易模式與交易系統',
|
||||||
|
2: '構成股價走勢圖的要素',
|
||||||
|
3: '當沖交易圖表型態',
|
||||||
|
4: '波段交易圖表型態',
|
||||||
|
5: '超短線交易圖表型態',
|
||||||
|
6: '大盤觀察與選股',
|
||||||
|
0: '附錄與心態',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODE_SLUG_SUFFIX = { '①': '_1', '②': '_2', '③': '_3', '④': '_4' };
|
||||||
|
|
||||||
|
function slugId(name, category, chapter) {
|
||||||
|
const modeMark = String(name || '').match(/[①②③④]/)?.[0] || '';
|
||||||
|
const modeSuffix = MODE_SLUG_SUFFIX[modeMark] || '';
|
||||||
|
const base = norm(name).slice(0, 28) || norm(category).slice(0, 12);
|
||||||
|
const slug = `p${chapter}_${base}${modeSuffix}`
|
||||||
|
.replace(/[^\w\u4e00-\u9fff]/g, '_')
|
||||||
|
.replace(/_+/g, '_')
|
||||||
|
.replace(/_$/g, '');
|
||||||
|
return slug.slice(0, 52);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferBias(text, name) {
|
||||||
|
const t = `${text} ${name}`;
|
||||||
|
if (/賣出|下跌|空方|下殺|偏空|超買|死亡|跌停|黑三兵|空方|減碼|摜破|風險|小心|勿追/.test(t)) return 'bearish';
|
||||||
|
if (/買進|上漲|多方|起漲|偏多|超賣|黃金|漲停|紅三兵|突破|反彈|續強|買點/.test(t)) return 'bullish';
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferTimeframe(chapter, category) {
|
||||||
|
if (chapter === 5 || /超短線|跳動點|VWAP|10檔/.test(category)) return ['分', '日'];
|
||||||
|
if (chapter === 4 || /波段|布林|一目|斐波|酒田/.test(category)) return ['日', '週'];
|
||||||
|
if (chapter === 3 || /當沖|交易時段|9點/.test(category)) return ['日', '分'];
|
||||||
|
if (chapter === 1 || chapter === 2) return ['日'];
|
||||||
|
return ['日'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseToc() {
|
||||||
|
const raw = fs.readFileSync(path.join(BOOK_DIR, '003.md'), 'utf8');
|
||||||
|
const lines = raw.split('\n').map(l => l.trim()).filter(Boolean);
|
||||||
|
const entries = [];
|
||||||
|
let chapter = 0;
|
||||||
|
let chapterTitle = '';
|
||||||
|
let section = 'toc';
|
||||||
|
|
||||||
|
const skipExact = new Set([
|
||||||
|
'交易模式', '時間意識', '未必等於', '10檔報價', '下單方式', '逆限價單', 'OCO單', 'IFD單', 'IFDOCO單',
|
||||||
|
'構成要素', '基本要素', 'K線', '時間軸', '交易時段', '技術指標', '移動平均線', '葛蘭碧法則',
|
||||||
|
'複數均線', '隨機指標', '騰落指標', '相對強弱指標', 'MACD', '複數指標', '道氏理論', 'K線組成的型態',
|
||||||
|
'天花板.地板', '橫盤整理', '三角收斂型態', '旗形型態', '箱型整理', '分價量表', '當日現金交割',
|
||||||
|
'特定的股價波動', '反彈的時機點', '練習問題①', '練習問題②', '基本操作策略', '避免在連假期間持股',
|
||||||
|
'布林通道', '趨勢通道', '顧比均線', '一目均衡表', '基本結構', '三役好轉', '延遲線的用法',
|
||||||
|
'斐波那契回撤', 'SAR拋物線指標', 'ENV包絡線', '不同時間軸的均線', '歷史動率', 'HV歷史波動率',
|
||||||
|
'動向指標', 'DMI動向指標', 'PSY心理線', 'DMA指標', '酒田五法', '島狀反轉', '菱形頂型態', '杯柄型態',
|
||||||
|
'海龜交易法', 'K線種類', 'K線型態', 'K線組合', '帶量上漲的大陽線', '上下影線', '跳空缺口', '連續K線',
|
||||||
|
'壓力線.支撐線', '突破交易', '心理關卡', '摜破大關', '多空趨勢', '哪一種趨勢', 'VWAP', '長期趨勢',
|
||||||
|
'大盤指數', '美股收盤', '歷史數據', '最近兩週', '新股上市', '變更交易市場', '股票下市', '宣布下市',
|
||||||
|
'發布財報', '買賣越活躍', '股價漲幅排行', '尋找買賣點', '當沖交易', '波段交易', '超短線交易',
|
||||||
|
'買進模式①', '買進模式②', '買進模式③', '買進模式④', '賣出模式①', '賣出模式②', '賣出模式③', '賣出模式④',
|
||||||
|
'道氏理論①', '道氏理論②', '道氏理論③', '9點~11點', '收盤前30分', '長短參數', '移動平均乖離率',
|
||||||
|
'黃金交叉', '死亡交叉', '多頭排列', '紅三兵', '黑三兵', '下影陽線', '下影陰線', '上影陽線', '上影陰線',
|
||||||
|
'覆蓋線', '切入線', '穿透線', '環抱線', '孕育線', '大陽線', '大陰線', '同時線', '漲停', '跌停',
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line === '圖表型態一覽表' || line === '本書的頁面構成') continue;
|
||||||
|
const ch = line.match(/^第([1-6])章 (.+)$/);
|
||||||
|
if (ch) {
|
||||||
|
chapter = Number(ch[1]);
|
||||||
|
chapterTitle = ch[2];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('末章 ')) {
|
||||||
|
chapter = 0;
|
||||||
|
chapterTitle = '投資心態';
|
||||||
|
entries.push({ chapter, chapterTitle, category: '末章', name: line.replace(/^末章 /, ''), fullTitle: line });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('專欄 ')) {
|
||||||
|
entries.push({ chapter, chapterTitle, category: '專欄', name: line.replace(/^專欄 /, ''), fullTitle: line });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const m = line.match(/^(.+?) (.+)$/);
|
||||||
|
if (!m) continue;
|
||||||
|
const category = m[1].trim();
|
||||||
|
const title = m[2].trim();
|
||||||
|
if (title.length < 4) continue;
|
||||||
|
if (skipExact.has(category) && category === title) continue;
|
||||||
|
if (category === title) continue;
|
||||||
|
entries.push({ chapter, chapterTitle, category, name: title, fullTitle: `${category} ${title}` });
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_OVERRIDES = {
|
||||||
|
'黃金交叉': 20, '死亡交叉': 20, '多頭排列': 21,
|
||||||
|
'RSI相對強弱指標': 22, 'MACD指標': 23, 'MACD': 23,
|
||||||
|
'成交量': 29, '向上跳空缺口': 55, '向下跳空缺口': 55,
|
||||||
|
'多方吞噬': 54, '空方吞噬': 54, '覆蓋線': 54,
|
||||||
|
'下影陽線': 53, '下影陰線': 53, '上影陽線': 53, '上影陰線': 53,
|
||||||
|
'紅三兵': 45, '黑三兵': 45, '漲停': 10, '跌停': 10,
|
||||||
|
'買進模式①': 15, '買進模式④': 16,
|
||||||
|
'賣出模式①': 17, '賣出模式②': 18,
|
||||||
|
'跳動點': 50, 'VWAP': 59,
|
||||||
|
// 目錄有條目但原始擷取未產生獨立書頁
|
||||||
|
'10分鐘掌握當沖交易的重點!': false,
|
||||||
|
'10分鐘掌握波段交易的重點!': false,
|
||||||
|
|
||||||
|
'運用葛蘭碧法則的賣出模式③': false,
|
||||||
|
'運用葛蘭碧法則的賣出模式④': false,
|
||||||
|
'反轉為上升趨勢的關鍵點位在哪裡?①': false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveByNameOverride(entry) {
|
||||||
|
if (overrides.byName?.[entry.name]) return overrides.byName[entry.name];
|
||||||
|
const keys = Object.keys(overrides.byName || {}).sort((a, b) => b.length - a.length);
|
||||||
|
for (const key of keys) {
|
||||||
|
if (entry.name === key) return overrides.byName[key];
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyOverride(entry, page) {
|
||||||
|
const split = overrides.byTitle?.[entry.name]?.split;
|
||||||
|
if (split?.length) {
|
||||||
|
return split.map(subName => {
|
||||||
|
const sub = { ...entry, name: subName };
|
||||||
|
const o = overrides.byName?.[subName] || {};
|
||||||
|
return buildPattern(sub, page, o);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [buildPattern(entry, page, resolveByNameOverride(entry))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePageForEntry(entry, pages) {
|
||||||
|
return supplementForCatalogName(entry.name) || findPageForEntry(entry, pages, PAGE_OVERRIDES);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPattern(entry, page, o = {}) {
|
||||||
|
const summary = page?.summary || entry.name;
|
||||||
|
const bias = o.bias || inferBias(summary, entry.name);
|
||||||
|
const chapter = entry.chapter || 0;
|
||||||
|
const pattern = {
|
||||||
|
id: o.id || slugId(entry.name, entry.category, chapter),
|
||||||
|
name: o.name || entry.name,
|
||||||
|
category: entry.category,
|
||||||
|
chapter,
|
||||||
|
chapterTitle: entry.chapterTitle || CHAPTER_NAMES[chapter] || '',
|
||||||
|
bookPage: page?.pageNum || null,
|
||||||
|
bias,
|
||||||
|
timeframe: o.timeframe || inferTimeframe(chapter, entry.category),
|
||||||
|
automatable: o.automatable === true,
|
||||||
|
teach: {
|
||||||
|
summary: summary.slice(0, 280),
|
||||||
|
entryHint: o.entryHint || (page?.summary2 ? page.summary2.slice(0, 120) : '依書本圖例與當下趨勢判斷進場時機。'),
|
||||||
|
exitHint: o.exitHint || '趨勢或型態失效時出場;搭配停損/停利紀律。',
|
||||||
|
caution: page?.isSupplement
|
||||||
|
? '此節為教材補遺:原始擷取缺頁,內容依書中交叉引用整理,圖例可能為鏡像對照頁。'
|
||||||
|
: (o.caution || (entry.category === '練習問題' ? '先自行作答,再對照書本解答。' : '訊號需搭配大盤、量能與基本面,避免單一線型決策。')),
|
||||||
|
bookRef: page?.mdFile || null,
|
||||||
|
bookHtml: page?.htmlFile || null,
|
||||||
|
images: page?.imageUrls || [],
|
||||||
|
fullTitle: entry.fullTitle,
|
||||||
|
isSupplement: !!page?.isSupplement,
|
||||||
|
tocHint: page?.tocHint || null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (o.rule) {
|
||||||
|
pattern.rule = o.rule;
|
||||||
|
pattern.params = o.params || {};
|
||||||
|
}
|
||||||
|
if (o.markets) pattern.markets = o.markets;
|
||||||
|
if (o.backtest) pattern.backtest = o.backtest;
|
||||||
|
|
||||||
|
const extra = overrides.teachExtras?.[pattern.id];
|
||||||
|
if (extra) {
|
||||||
|
const { teach: extraTeach, entryHint, exitHint, caution, ...rest } = extra;
|
||||||
|
Object.assign(pattern, rest);
|
||||||
|
if (extraTeach) pattern.teach = { ...pattern.teach, ...extraTeach };
|
||||||
|
if (entryHint) pattern.teach.entryHint = entryHint;
|
||||||
|
if (exitHint) pattern.teach.exitHint = exitHint;
|
||||||
|
if (caution) pattern.teach.caution = caution;
|
||||||
|
}
|
||||||
|
return pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupePatterns(list) {
|
||||||
|
const seen = new Map();
|
||||||
|
const out = [];
|
||||||
|
for (const p of list) {
|
||||||
|
const key = p.id;
|
||||||
|
if (seen.has(key)) {
|
||||||
|
const prev = seen.get(key);
|
||||||
|
if ((p.automatable && !prev.automatable) || (p.bookPage && !prev.bookPage)) {
|
||||||
|
const idx = out.indexOf(prev);
|
||||||
|
out[idx] = p;
|
||||||
|
seen.set(key, p);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.set(key, p);
|
||||||
|
out.push(p);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const toc = parseToc();
|
||||||
|
const pages = loadAllPages(BOOK_DIR);
|
||||||
|
const patterns = [];
|
||||||
|
|
||||||
|
for (const entry of toc) {
|
||||||
|
const page = resolvePageForEntry(entry, pages);
|
||||||
|
// 圖鑑只收錄能回到實際 MD 教材的項目,避免目錄文字變成空卡。
|
||||||
|
if (!page) continue;
|
||||||
|
patterns.push(...applyOverride(entry, page));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const extra of overrides.extraPatterns || []) {
|
||||||
|
const page = pages.find(p => p.pageNum === extra.bookPage);
|
||||||
|
patterns.push(buildPattern(
|
||||||
|
{ name: extra.name, category: extra.category, chapter: extra.chapter, chapterTitle: CHAPTER_NAMES[extra.chapter], fullTitle: extra.name },
|
||||||
|
page,
|
||||||
|
extra,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 補充:前言與導讀頁(004 等)若未出現在 TOC
|
||||||
|
const coveredPages = new Set(patterns.map(p => p.bookPage).filter(Boolean));
|
||||||
|
for (const page of pages) {
|
||||||
|
if (coveredPages.has(page.pageNum) || page.pageNum <= 4) continue;
|
||||||
|
if (page.summary.length < 20) continue;
|
||||||
|
const entry = {
|
||||||
|
chapter: page.pageNum <= 13 ? 1 : page.pageNum <= 19 ? 2 : page.pageNum <= 35 ? 3 : page.pageNum <= 49 ? 4 : page.pageNum <= 63 ? 5 : 6,
|
||||||
|
chapterTitle: '',
|
||||||
|
category: '書頁',
|
||||||
|
name: page.headline || page.titleLines[page.titleLines.length - 1] || `第 ${page.pageNum} 頁`,
|
||||||
|
fullTitle: page.titleBlob.slice(0, 60),
|
||||||
|
};
|
||||||
|
entry.chapterTitle = CHAPTER_NAMES[entry.chapter] || '';
|
||||||
|
patterns.push(buildPattern(entry, page, {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const final = dedupePatterns(patterns).sort((a, b) => {
|
||||||
|
if (a.chapter !== b.chapter) return a.chapter - b.chapter;
|
||||||
|
if ((a.bookPage || 999) !== (b.bookPage || 999)) return (a.bookPage || 999) - (b.bookPage || 999);
|
||||||
|
return a.name.localeCompare(b.name, 'zh-Hant');
|
||||||
|
});
|
||||||
|
|
||||||
|
const catalog = {
|
||||||
|
version: 2,
|
||||||
|
source: '短線交易日線圖大全',
|
||||||
|
builtAt: new Date().toISOString(),
|
||||||
|
stats: {
|
||||||
|
total: final.length,
|
||||||
|
automatable: final.filter(p => p.automatable).length,
|
||||||
|
chapters: Object.keys(CHAPTER_NAMES).map(Number).filter(n => final.some(p => p.chapter === n)),
|
||||||
|
},
|
||||||
|
chapterNames: CHAPTER_NAMES,
|
||||||
|
patterns: final,
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(OUT_PATH, JSON.stringify(catalog, null, 2) + '\n', 'utf8');
|
||||||
|
console.log(`✓ catalog.json:${final.length} 筆(可掃描 ${catalog.stats.automatable})→ ${OUT_PATH}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
|
@ -0,0 +1,194 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* 從 knowledge.json 產生 skill-drills.json 題庫覆寫
|
||||||
|
* - 解析 EP 實例的標的、日期視窗
|
||||||
|
* - 同群心法作為第 1 關干擾項
|
||||||
|
*/
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const DATA_DIR = path.join(__dirname, "..", "data");
|
||||||
|
const KNOWLEDGE_PATH = path.join(DATA_DIR, "knowledge.json");
|
||||||
|
const OUT_PATH = path.join(DATA_DIR, "skill-drills.json");
|
||||||
|
|
||||||
|
const TICKER_IN_TEXT =
|
||||||
|
/\b(NVDA|AMD|AAPL|MSFT|GOOGL|GOOGL|META|AMZN|TSLA|TSM|AVGO|NOC|BA|LMT|RTX|MU|SMCI|ORCL|CRM|COST|WMT|QQQ|SPY|SMH|IWM|LYV|DKNG|2330\.TW)\b/gi;
|
||||||
|
|
||||||
|
const NAME_TO_SYMBOL = [
|
||||||
|
[/輝達|NVIDIA/i, "NVDA"],
|
||||||
|
[/台積電|TSMC/i, "TSM"],
|
||||||
|
[/蘋果|Apple/i, "AAPL"],
|
||||||
|
[/微軟|Microsoft/i, "MSFT"],
|
||||||
|
[/谷歌|Google|Alphabet/i, "GOOGL"],
|
||||||
|
[/亞馬遜|Amazon/i, "AMZN"],
|
||||||
|
[/特斯拉|Tesla/i, "TSLA"],
|
||||||
|
[/博通|Broadcom/i, "AVGO"],
|
||||||
|
[/洛克希德|Raytheon/i, "LMT"],
|
||||||
|
[/萊茵金屬/i, "RTX"],
|
||||||
|
[/台股|加權|大盤/i, "SPY"],
|
||||||
|
[/標普|S&P|美股大盤/i, "SPY"],
|
||||||
|
[/那斯達|Nasdaq|科技板塊/i, "QQQ"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const GROUP_DEFAULT_SYMBOL = {
|
||||||
|
總經與市場水位: "SPY",
|
||||||
|
進攻與擴張時機: "QQQ",
|
||||||
|
護城河與商業模式: "NVDA",
|
||||||
|
財報估值與管理層: "AAPL",
|
||||||
|
交易紀律與資金管理: "SPY",
|
||||||
|
地緣政治與政策博弈: "LMT",
|
||||||
|
生活觀察與消費信號: "COST",
|
||||||
|
市場心理與反指標: "QQQ",
|
||||||
|
};
|
||||||
|
|
||||||
|
function hashSeed(s) {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
|
||||||
|
return Math.abs(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(arr, seed, n) {
|
||||||
|
const copy = [...arr];
|
||||||
|
const out = [];
|
||||||
|
let s = hashSeed(seed);
|
||||||
|
while (copy.length && out.length < n) {
|
||||||
|
s = (s * 1103515245 + 12345) | 0;
|
||||||
|
const idx = Math.abs(s) % copy.length;
|
||||||
|
out.push(copy.splice(idx, 1)[0]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInstances(body) {
|
||||||
|
const instances = [];
|
||||||
|
for (const raw of String(body || "").split("\n")) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line.startsWith("- ") && !line.startsWith("* ")) continue;
|
||||||
|
const content = line.slice(2).trim();
|
||||||
|
if (/^(EP\d|EP\.|會員影片|member_)/i.test(content) || /^EP/.test(content.split(/[::]/)[0] || "")) {
|
||||||
|
const parts = content.split(/[::]/);
|
||||||
|
instances.push({
|
||||||
|
ep: parts[0]?.trim() || "",
|
||||||
|
text: parts.slice(1).join(":").trim() || content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instances;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSymbol(text, groupName) {
|
||||||
|
const tickers = [...text.matchAll(TICKER_IN_TEXT)].map((m) => m[1].toUpperCase());
|
||||||
|
if (tickers[0]) return tickers[0];
|
||||||
|
for (const [re, sym] of NAME_TO_SYMBOL) {
|
||||||
|
if (re.test(text)) return sym;
|
||||||
|
}
|
||||||
|
return GROUP_DEFAULT_SYMBOL[groupName] || "SPY";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScenarioWindow(text, ep) {
|
||||||
|
const zh = text.match(/(20\d{2})\s*年\s*(\d{1,2})\s*月/);
|
||||||
|
if (zh) return `${zh[1]}-${String(zh[2]).padStart(2, "0")}`;
|
||||||
|
const slash = text.match(/(20\d{2})\s*[\/年]\s*(\d{1,2})\s*[\/月]\s*(\d{1,2})/);
|
||||||
|
if (slash) return `${slash[1]}-${String(slash[2]).padStart(2, "0")}`;
|
||||||
|
const en = text.match(/(20\d{2})-(\d{2})(?:-(\d{2}))?/);
|
||||||
|
if (en) return `${en[1]}-${en[2]}`;
|
||||||
|
const epNum = ep?.match(/EP\.?\s*(\d+)/i);
|
||||||
|
if (epNum) return `EP${epNum[1]}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chartRangeForWindow(window) {
|
||||||
|
if (!window || window.startsWith("EP")) return "1y";
|
||||||
|
const y = Number(window.slice(0, 4));
|
||||||
|
const now = new Date().getFullYear();
|
||||||
|
if (y >= now - 1) return "1y";
|
||||||
|
if (y >= now - 3) return "3y";
|
||||||
|
return "5y";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGroupMap(principleMapBody, principles) {
|
||||||
|
const byId = new Map(principles.map((p) => [p.id, p]));
|
||||||
|
const groupOf = new Map();
|
||||||
|
const groups = new Map();
|
||||||
|
|
||||||
|
if (!principleMapBody) return { groupOf, groups };
|
||||||
|
|
||||||
|
let curName = "全部心法";
|
||||||
|
for (const line of principleMapBody.split("\n")) {
|
||||||
|
const hm = line.match(/^## \d+\.\s*(.+?)(/);
|
||||||
|
if (hm) {
|
||||||
|
curName = hm[1].trim();
|
||||||
|
if (!groups.has(curName)) groups.set(curName, []);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const links = [...line.matchAll(/\[\[Emmy 投資心法#([^\]]+)\]\]/g)];
|
||||||
|
for (const m of links) {
|
||||||
|
const p = byId.get(m[1]);
|
||||||
|
if (!p) continue;
|
||||||
|
groupOf.set(p.id, curName);
|
||||||
|
groups.get(curName)?.push(p.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { groupOf, groups };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanTitle(title) {
|
||||||
|
return String(title || "")
|
||||||
|
.replace(/^原則[^::]+[::]\s*/, "")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
if (!fs.existsSync(KNOWLEDGE_PATH)) {
|
||||||
|
console.error("找不到 knowledge.json,請先執行 npm run build:knowledge");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const knowledge = JSON.parse(fs.readFileSync(KNOWLEDGE_PATH, "utf8"));
|
||||||
|
const principles = knowledge.principles || [];
|
||||||
|
const { groupOf, groups } = buildGroupMap(knowledge.principleMap?.body, principles);
|
||||||
|
|
||||||
|
const entries = {};
|
||||||
|
for (const p of principles) {
|
||||||
|
const instances = parseInstances(p.body);
|
||||||
|
const groupName = groupOf.get(p.id) || "全部心法";
|
||||||
|
const idx = instances.length ? hashSeed(p.id) % instances.length : 0;
|
||||||
|
const inst = instances[idx] || instances[0];
|
||||||
|
const blob = inst ? `${inst.ep} ${inst.text} ${p.title}` : p.title;
|
||||||
|
const scenarioSymbol = resolveSymbol(blob, groupName);
|
||||||
|
const scenarioWindow = inst ? parseScenarioWindow(`${inst.text} ${inst.ep}`, inst.ep) : null;
|
||||||
|
|
||||||
|
const peers = (groups.get(groupName) || []).filter((id) => id !== p.id);
|
||||||
|
const distractorPrinciples = pick(peers, `${p.id}:dist`, Math.min(3, peers.length));
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
groupName,
|
||||||
|
instanceIndex: idx,
|
||||||
|
scenarioSymbol,
|
||||||
|
chartRange: chartRangeForWindow(scenarioWindow),
|
||||||
|
};
|
||||||
|
if (scenarioWindow) entry.scenarioWindow = scenarioWindow;
|
||||||
|
if (inst?.ep) entry.epLabel = inst.ep;
|
||||||
|
if (distractorPrinciples.length) entry.distractorPrinciples = distractorPrinciples;
|
||||||
|
|
||||||
|
// 傳說/史詩卡加強提示
|
||||||
|
if (p.num != null && p.num <= 10) {
|
||||||
|
entry.chartHint = "十大基石心法:結合總經水位與長期趨勢判斷";
|
||||||
|
}
|
||||||
|
|
||||||
|
entries[p.id] = entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
version: 1,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
principleCount: principles.length,
|
||||||
|
entries,
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(OUT_PATH, JSON.stringify(payload, null, 2), "utf8");
|
||||||
|
console.log(`Wrote ${OUT_PATH} (${principles.length} entries)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REMOTE="${REMOTE:-daniel@10.0.0.5}"
|
||||||
|
APP_DIR="${APP_DIR:-/opt/investor-rpg}"
|
||||||
|
SERVICE_NAME="${SERVICE_NAME:-investor-rpg}"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
LOCAL_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
CONTENT_DIR="$(cd "${LOCAL_DIR}/.." && pwd)/content/raw/patterns"
|
||||||
|
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
|
||||||
|
echo "部署到 ${REMOTE}:${APP_DIR}"
|
||||||
|
|
||||||
|
ssh "${REMOTE}" "mkdir -p '${APP_DIR}/backups'; \
|
||||||
|
if [ -f '${APP_DIR}/data.db' ]; then sqlite3 '${APP_DIR}/data.db' \".backup '${APP_DIR}/backups/data-${STAMP}.db'\" && chmod 600 '${APP_DIR}/backups/data-${STAMP}.db'; fi; \
|
||||||
|
find '${APP_DIR}/backups' -type f -name 'data-*.db' -mtime +14 -delete 2>/dev/null || true"
|
||||||
|
|
||||||
|
rsync -az --delete \
|
||||||
|
--exclude '.env' \
|
||||||
|
--exclude 'data.db' \
|
||||||
|
--exclude 'data.db-*' \
|
||||||
|
--exclude 'config/ai/' \
|
||||||
|
--exclude 'node_modules/' \
|
||||||
|
--exclude 'dist/' \
|
||||||
|
--exclude 'backups/' \
|
||||||
|
--exclude '*.log' \
|
||||||
|
--exclude '.DS_Store' \
|
||||||
|
"${LOCAL_DIR}/" "${REMOTE}:${APP_DIR}/"
|
||||||
|
|
||||||
|
if [[ -d "${CONTENT_DIR}" ]]; then
|
||||||
|
ssh "${REMOTE}" "mkdir -p '/opt/content/raw/patterns'"
|
||||||
|
rsync -az --delete "${CONTENT_DIR}/" "${REMOTE}:/opt/content/raw/patterns/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ssh "${REMOTE}" "set -e; \
|
||||||
|
cd '${APP_DIR}'; \
|
||||||
|
npm ci; \
|
||||||
|
npm run build; \
|
||||||
|
find dist -type d -exec chmod 755 {} +; \
|
||||||
|
find dist -type f -exec chmod 644 {} +; \
|
||||||
|
sudo -n systemctl restart '${SERVICE_NAME}'; \
|
||||||
|
sudo -n nginx -t; \
|
||||||
|
curl -fsS --retry 10 --retry-connrefused --retry-delay 2 http://127.0.0.1:3000/api/health"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "部署完成:http://${REMOTE#*@}/"
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||||
|
const children = new Set();
|
||||||
|
let stopping = false;
|
||||||
|
|
||||||
|
function start(label, script) {
|
||||||
|
const child = spawn(npm, ["run", script], {
|
||||||
|
stdio: "inherit",
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
|
||||||
|
children.add(child);
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
children.delete(child);
|
||||||
|
if (!stopping) {
|
||||||
|
console.error(`\n[dev:all] ${label} 已停止 (${signal || `exit ${code}`}),正在關閉其他服務。`);
|
||||||
|
stop(code ?? 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop(exitCode = 0) {
|
||||||
|
if (stopping) return;
|
||||||
|
stopping = true;
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
for (const child of children) {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
process.exit(exitCode);
|
||||||
|
}, 3000).unref();
|
||||||
|
|
||||||
|
if (children.size === 0) process.exit(exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGINT", () => stop(0));
|
||||||
|
process.on("SIGTERM", () => stop(0));
|
||||||
|
|
||||||
|
console.log("[dev:all] 啟動 API http://localhost:3000 與前端 http://localhost:5173");
|
||||||
|
start("API", "dev:server");
|
||||||
|
start("前端", "dev");
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_USER="${APP_USER:-daniel}"
|
||||||
|
APP_DIR="${APP_DIR:-/opt/investor-rpg}"
|
||||||
|
SERVICE_NAME="${SERVICE_NAME:-investor-rpg}"
|
||||||
|
SERVER_NAME="${SERVER_NAME:-_}"
|
||||||
|
|
||||||
|
if [[ "${EUID}" -ne 0 ]]; then
|
||||||
|
echo "請用 sudo 執行:sudo bash $0" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
apt-get update
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl nginx rsync sqlite3
|
||||||
|
|
||||||
|
if ! command -v node >/dev/null 2>&1 || [[ "$(node -p 'Number(process.versions.node.split(".")[0])')" -lt 22 ]]; then
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -d -o "${APP_USER}" -g "${APP_USER}" "${APP_DIR}" "${APP_DIR}/backups"
|
||||||
|
install -d -o "${APP_USER}" -g "${APP_USER}" "/opt/content" "/opt/content/raw" "/opt/content/raw/patterns"
|
||||||
|
|
||||||
|
cat >"/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Investor RPG Node backend
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=${APP_USER}
|
||||||
|
Group=${APP_USER}
|
||||||
|
WorkingDirectory=${APP_DIR}
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
Environment=PORT=3000
|
||||||
|
Environment=HOST=127.0.0.1
|
||||||
|
ExecStart=/usr/bin/npm start
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
TimeoutStopSec=20
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat >"/etc/nginx/sites-available/${SERVICE_NAME}" <<EOF
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
listen [::]:80 default_server;
|
||||||
|
server_name ${SERVER_NAME};
|
||||||
|
|
||||||
|
root ${APP_DIR}/dist;
|
||||||
|
index index.html;
|
||||||
|
client_max_body_size 5m;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
proxy_read_timeout 180s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /book-patterns/ {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files \$uri \$uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
rm -f /etc/nginx/sites-enabled/default
|
||||||
|
ln -sfn "/etc/nginx/sites-available/${SERVICE_NAME}" "/etc/nginx/sites-enabled/${SERVICE_NAME}"
|
||||||
|
|
||||||
|
cat >"/etc/sudoers.d/${SERVICE_NAME}-deploy" <<EOF
|
||||||
|
${APP_USER} ALL=(root) NOPASSWD: /usr/bin/systemctl restart ${SERVICE_NAME}, /usr/sbin/nginx -t
|
||||||
|
EOF
|
||||||
|
chmod 0440 "/etc/sudoers.d/${SERVICE_NAME}-deploy"
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable "${SERVICE_NAME}" nginx
|
||||||
|
nginx -t
|
||||||
|
systemctl restart nginx
|
||||||
|
|
||||||
|
echo "Bootstrap 完成:Node $(node -v),Nginx 已啟動,應用程式目錄為 ${APP_DIR}。"
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// 將各書頁圖片整理到 pages/005/img_0_.jpg,避免全書共用 3 張圖被覆寫。
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ROOT = path.resolve(__dirname, '..', '..');
|
||||||
|
const PATTERNS_DIR = path.join(ROOT, 'content', 'raw', 'patterns');
|
||||||
|
const META_PATH = path.join(PATTERNS_DIR, 'metadata.json');
|
||||||
|
|
||||||
|
function fileMd5(filePath) {
|
||||||
|
return crypto.createHash('md5').update(fs.readFileSync(filePath)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSharedHashes() {
|
||||||
|
const flatDir = path.join(PATTERNS_DIR, 'images');
|
||||||
|
const hashes = new Map();
|
||||||
|
if (!fs.existsSync(flatDir)) return hashes;
|
||||||
|
for (const fname of fs.readdirSync(flatDir)) {
|
||||||
|
if (!/\.(jpe?g|png|webp)$/i.test(fname)) continue;
|
||||||
|
hashes.set(fname, fileMd5(path.join(flatDir, fname)));
|
||||||
|
}
|
||||||
|
return hashes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyIfExists(src, dest, { allowShared = false, sharedHashes = null } = {}) {
|
||||||
|
if (!fs.existsSync(src)) return false;
|
||||||
|
const fname = path.basename(dest);
|
||||||
|
if (!allowShared && sharedHashes?.has(fname) && fileMd5(src) === sharedHashes.get(fname)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
|
fs.copyFileSync(src, dest);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidateSourceDirs() {
|
||||||
|
const dirs = [];
|
||||||
|
if (fs.existsSync(META_PATH)) {
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(fs.readFileSync(META_PATH, 'utf8'));
|
||||||
|
if (meta.output_dir) {
|
||||||
|
dirs.push(path.resolve(PATTERNS_DIR, meta.output_dir));
|
||||||
|
dirs.push(path.resolve(ROOT, meta.output_dir));
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
dirs.push(path.join(PATTERNS_DIR, 'output'));
|
||||||
|
dirs.push(path.join(ROOT, '短線交易日線圖大全'));
|
||||||
|
return [...new Set(dirs)].filter((d) => fs.existsSync(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
function imagesFromMd(mdPath) {
|
||||||
|
const raw = fs.readFileSync(mdPath, 'utf8');
|
||||||
|
return [...raw.matchAll(/!\[\]\((images\/[^)]+)\)/g)].map((m) => path.basename(m[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const force = process.argv.includes('--force');
|
||||||
|
const sources = candidateSourceDirs();
|
||||||
|
const sharedHashes = loadSharedHashes();
|
||||||
|
let copied = 0;
|
||||||
|
let missing = 0;
|
||||||
|
let skippedShared = 0;
|
||||||
|
|
||||||
|
for (let i = 1; i <= 72; i++) {
|
||||||
|
const pageId = String(i).padStart(3, '0');
|
||||||
|
const mdPath = path.join(PATTERNS_DIR, `${pageId}.md`);
|
||||||
|
if (!fs.existsSync(mdPath)) continue;
|
||||||
|
const names = imagesFromMd(mdPath);
|
||||||
|
if (!names.length) continue;
|
||||||
|
|
||||||
|
const destDir = path.join(PATTERNS_DIR, 'pages', pageId);
|
||||||
|
fs.mkdirSync(destDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const name of names) {
|
||||||
|
const dest = path.join(destDir, name);
|
||||||
|
if (fs.existsSync(dest)) {
|
||||||
|
const isSharedArtifact = sharedHashes.get(name) === fileMd5(dest);
|
||||||
|
if (isSharedArtifact) fs.unlinkSync(dest);
|
||||||
|
else if (!force) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hit = false;
|
||||||
|
for (const srcRoot of sources) {
|
||||||
|
const tries = [
|
||||||
|
path.join(srcRoot, pageId, 'images', name),
|
||||||
|
path.join(srcRoot, `${pageId}.images`, name),
|
||||||
|
path.join(srcRoot, 'images', pageId, name),
|
||||||
|
path.join(srcRoot, pageId, name),
|
||||||
|
];
|
||||||
|
for (const src of tries) {
|
||||||
|
if (copyIfExists(src, dest, { sharedHashes })) {
|
||||||
|
copied += 1;
|
||||||
|
hit = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hit) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hit) {
|
||||||
|
// flat images/ 是擷取器跨頁共用的檔案,不能當成該頁教材圖。
|
||||||
|
skippedShared += 1;
|
||||||
|
missing += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[repack-pattern-images] copied=${copied} missing=${missing} shared_fallback=${skippedShared}`);
|
||||||
|
if (sources.length) console.log(`來源目錄:${sources.join(', ')}`);
|
||||||
|
if (missing > 0 || skippedShared > 0) {
|
||||||
|
console.log('缺少各頁專屬圖片;已略過擷取器產生的全書共用圖,避免教材顯示錯圖。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { Routes, Route, Navigate } from "react-router-dom";
|
||||||
|
import Chrome from "./components/Chrome";
|
||||||
|
import { AIProvider } from "./context/AIContext";
|
||||||
|
import Home from "./pages/Home";
|
||||||
|
import Market from "./pages/Market";
|
||||||
|
import Settings from "./pages/Settings";
|
||||||
|
import Profile from "./pages/Profile";
|
||||||
|
import Research from "./pages/Research";
|
||||||
|
import Skills from "./pages/Skills";
|
||||||
|
import Patterns from "./pages/Patterns";
|
||||||
|
import Journal from "./pages/Journal";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AIProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<Chrome />}>
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/market" element={<Market />} />
|
||||||
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route path="/research" element={<Research />} />
|
||||||
|
<Route path="/skills" element={<Skills />} />
|
||||||
|
<Route path="/patterns" element={<Patterns />} />
|
||||||
|
<Route path="/journal" element={<Journal />} />
|
||||||
|
<Route path="/profile" element={<Profile />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</AIProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,237 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { aiApi, type AIDebugLog, type AITraderDebugEvent } from "../lib/ai";
|
||||||
|
import { AppIcon } from "./PixelIcons";
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
ai_trader_pre_market: "交易員 · 市場分析",
|
||||||
|
ai_trader_intraday: "交易員 · 盤中執行",
|
||||||
|
ai_trader_intraday_review: "交易員 · 盤中復盤",
|
||||||
|
ai_trader_post_market: "交易員 · 盤後復盤",
|
||||||
|
ai_trader_chat: "交易員 · 對話",
|
||||||
|
owl_guide: "貓頭鷹 · 導覽對話",
|
||||||
|
owl_coach: "貓頭鷹 · 心法教練",
|
||||||
|
owl_skill_assess: "貓頭鷹 · 心法評分",
|
||||||
|
unknown: "其他 AI 呼叫",
|
||||||
|
};
|
||||||
|
|
||||||
|
function sourceLabel(source: string) {
|
||||||
|
return SOURCE_LABELS[source] || source.replace(/_/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatJSON(value: unknown) {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokens(value: number) {
|
||||||
|
return value >= 1_000_000 ? `${(value / 1_000_000).toFixed(2)}M` : value >= 1_000 ? `${(value / 1_000).toFixed(1)}K` : value.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCost(value?: number | null) {
|
||||||
|
if (value == null) return "價格未知";
|
||||||
|
if (value < 0.01) return `$${value.toFixed(5)}`;
|
||||||
|
return `$${value.toFixed(3)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptSections({ log }: { log: AIDebugLog }) {
|
||||||
|
const request = log.request as {
|
||||||
|
input?: { role?: string; content?: string }[];
|
||||||
|
messages?: { role?: string; content?: string }[];
|
||||||
|
};
|
||||||
|
const messages = request.input || request.messages || [];
|
||||||
|
return (
|
||||||
|
<div className="ai-debug-payload">
|
||||||
|
{messages.length > 0 ? (
|
||||||
|
messages.map((message, index) => (
|
||||||
|
<details key={`${message.role}-${index}`} open={index === 0}>
|
||||||
|
<summary>{message.role === "system" ? "隱藏 System Prompt" : "實際 User Prompt / Context"}</summary>
|
||||||
|
<pre>{message.content || ""}</pre>
|
||||||
|
</details>
|
||||||
|
))
|
||||||
|
) : null}
|
||||||
|
<details>
|
||||||
|
<summary>完整外送 Request JSON</summary>
|
||||||
|
<pre>{formatJSON(log.request)}</pre>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>Provider 回傳</summary>
|
||||||
|
<pre>{formatJSON(log.response || { error: log.error || "無回傳資料" })}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventEntry({ event }: { event: AITraderDebugEvent }) {
|
||||||
|
return (
|
||||||
|
<details className={`ai-debug-entry ai-debug-event ${event.status}`} key={event.id}>
|
||||||
|
<summary>
|
||||||
|
<span className="ai-debug-source">{event.title || event.event.replace(/_/g, " ")}</span>
|
||||||
|
<span className="ai-debug-model">{event.phase || event.category} · {event.event}</span>
|
||||||
|
<time>{new Date(event.createdAtIso).toLocaleString("zh-TW")}</time>
|
||||||
|
<b>{event.status}</b>
|
||||||
|
</summary>
|
||||||
|
<div className="ai-debug-entry-meta">
|
||||||
|
{event.accountId != null ? <span>Account ID:{event.accountId}</span> : <span>全域排程</span>}
|
||||||
|
<span>階段:{event.phase || "—"}</span>
|
||||||
|
<span>Run:{event.runKey || "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ai-debug-payload">
|
||||||
|
<details open>
|
||||||
|
<summary>完整事件資料</summary>
|
||||||
|
<pre>{formatJSON(event.detail ?? { message: "此事件沒有附加資料" })}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AIDebugObservatory() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [source, setSource] = useState("all");
|
||||||
|
const [view, setView] = useState<"timeline" | "api">("timeline");
|
||||||
|
const debugQ = useQuery({
|
||||||
|
queryKey: ["ai-debug"],
|
||||||
|
queryFn: () => aiApi.debug(),
|
||||||
|
refetchInterval: open ? 3000 : false,
|
||||||
|
});
|
||||||
|
const toggleM = useMutation({
|
||||||
|
mutationFn: (enabled: boolean) => aiApi.setDebug(enabled),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["ai-debug"] }),
|
||||||
|
});
|
||||||
|
const clearM = useMutation({
|
||||||
|
mutationFn: () => aiApi.clearDebug(),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["ai-debug"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const logs = debugQ.data?.logs || [];
|
||||||
|
const events = debugQ.data?.events || [];
|
||||||
|
const sources = useMemo(() => [...new Set(logs.map((log) => log.source))], [logs]);
|
||||||
|
const visibleLogs = source === "all" ? logs : logs.filter((log) => log.source === source);
|
||||||
|
const enabled = !!debugQ.data?.enabled;
|
||||||
|
const usage = debugQ.data?.usageSummary;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ai-debug-orb${enabled ? " active" : ""}`}
|
||||||
|
aria-label="開啟 AI 真視觀測鏡"
|
||||||
|
title="查看交易員完整輪詢與 AI API Prompt"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<AppIcon name="compass" size={20} variant="nav" />
|
||||||
|
{enabled ? <i /> : null}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
<div className="ai-debug-modal" role="dialog" aria-modal="true" aria-label="AI 真視觀測鏡">
|
||||||
|
<button type="button" className="ai-debug-backdrop" aria-label="關閉真視觀測鏡" onClick={() => setOpen(false)} />
|
||||||
|
<section className="ai-debug-panel">
|
||||||
|
<header className="ai-debug-head">
|
||||||
|
<span className="ai-debug-eye"><AppIcon name="compass" size={26} variant="nav" /></span>
|
||||||
|
<div>
|
||||||
|
<span>TRUE SIGHT · FULL LIFECYCLE</span>
|
||||||
|
<strong>AI 真視觀測鏡</strong>
|
||||||
|
<small>完整查看輪詢判斷、資料蒐集、分析、風控、交易、復盤與 API Prompt</small>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="ai-debug-close" aria-label="關閉" onClick={() => setOpen(false)}>×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="ai-debug-controls">
|
||||||
|
<div className="ai-debug-switch-copy">
|
||||||
|
<strong>{enabled ? "偵錯觀測中" : "偵錯模式關閉"}</strong>
|
||||||
|
<span>{enabled ? "每輪交易員生命週期與 AI API 呼叫都會完整寫入本機資料庫" : "開啟後才開始記錄,不會包含 API Key"}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ai-debug-switch${enabled ? " active" : ""}`}
|
||||||
|
disabled={toggleM.isPending}
|
||||||
|
onClick={() => toggleM.mutate(!enabled)}
|
||||||
|
>
|
||||||
|
<i />
|
||||||
|
{enabled ? "ON" : "OFF"}
|
||||||
|
</button>
|
||||||
|
<select value={source} onChange={(event) => setSource(event.target.value)}>
|
||||||
|
<option value="all">全部來源</option>
|
||||||
|
{sources.map((item) => <option key={item} value={item}>{sourceLabel(item)}</option>)}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ai-debug-clear"
|
||||||
|
disabled={(!logs.length && !events.length) || clearM.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm("清除所有 AI 偵錯紀錄?這不會刪除聊天或交易員記憶。")) clearM.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
清除紀錄
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ai-debug-summary">
|
||||||
|
<span>{debugQ.data?.eventStored || 0} 筆生命週期事件</span>
|
||||||
|
<span>{debugQ.data?.stored || 0} 筆 API 呼叫</span>
|
||||||
|
<span>總 Token {formatTokens(usage?.totalTokens || 0)}</span>
|
||||||
|
<span>Input {formatTokens(usage?.inputTokens || 0)}</span>
|
||||||
|
<span>Output {formatTokens(usage?.outputTokens || 0)}</span>
|
||||||
|
<strong>預估費用 {formatCost(usage?.estimatedCostUsd || 0)} USD</strong>
|
||||||
|
{usage?.estimatedTokenLogs ? <span>{usage.estimatedTokenLogs} 筆 Token 為近似值</span> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ai-debug-view-tabs">
|
||||||
|
<button type="button" className={view === "timeline" ? "active" : ""} onClick={() => setView("timeline")}>交易員完整輪詢</button>
|
||||||
|
<button type="button" className={view === "api" ? "active" : ""} onClick={() => setView("api")}>API Prompt 與回覆</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ai-debug-log">
|
||||||
|
{view === "timeline" && !events.length ? (
|
||||||
|
<div className="ai-debug-empty">
|
||||||
|
<strong>{enabled ? "觀測鏡已啟動,等待下一次交易員輪詢" : "先開啟偵錯模式"}</strong>
|
||||||
|
<p>每次排程檢查,即使跳過沒有交易,也會在這裡留下原因與完整資料。</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{view === "timeline" ? events.map((event) => <EventEntry key={event.id} event={event} />) : null}
|
||||||
|
{view === "api" && !visibleLogs.length ? (
|
||||||
|
<div className="ai-debug-empty">
|
||||||
|
<strong>{enabled ? "等待下一次 AI API 呼叫" : "先開啟偵錯模式"}</strong>
|
||||||
|
<p>實際 System Prompt、User Prompt、Context 與 Provider 回覆會保留在這裡。</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{view === "api" ? visibleLogs.map((log) => (
|
||||||
|
<details className={`ai-debug-entry ${log.status}`} key={log.id}>
|
||||||
|
<summary>
|
||||||
|
<span className="ai-debug-source">{sourceLabel(log.source)}</span>
|
||||||
|
<span className="ai-debug-model">{log.provider} / {log.model || "default"}</span>
|
||||||
|
<time>{new Date(log.createdAtIso).toLocaleString("zh-TW")}</time>
|
||||||
|
<b>{log.status}</b>
|
||||||
|
</summary>
|
||||||
|
<div className="ai-debug-entry-meta">
|
||||||
|
<span>Endpoint:{log.endpoint || "—"}</span>
|
||||||
|
{log.accountId != null ? <span>Account ID:{log.accountId}</span> : null}
|
||||||
|
<span>
|
||||||
|
Token:{formatTokens(log.usage.inputTokens)} in + {formatTokens(log.usage.outputTokens)} out
|
||||||
|
{log.usage.estimated ? "(近似)" : ""}
|
||||||
|
</span>
|
||||||
|
<span>預估費用:{formatCost(log.usage.estimatedCostUsd)} USD</span>
|
||||||
|
<span>價格:{log.usage.pricingLabel}</span>
|
||||||
|
{log.error ? <span className="bad">Error:{log.error}</span> : null}
|
||||||
|
</div>
|
||||||
|
<PromptSections log={log} />
|
||||||
|
</details>
|
||||||
|
)) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { AppIcon } from "./PixelIcons";
|
||||||
|
import { Card, Tag, Loading } from "./ui";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
symbol: string;
|
||||||
|
scope: "stock" | "company" | "fund";
|
||||||
|
title?: string;
|
||||||
|
auto?: boolean;
|
||||||
|
aiFocus: { symbol: string; subPage: string; label: string; cardTitle?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
function verdictTone(v?: string): "up" | "down" | "gold" {
|
||||||
|
if (v === "偏正面") return "up";
|
||||||
|
if (v === "謹慎") return "down";
|
||||||
|
return "gold";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AiSummaryCard({ symbol, scope, title = "AI 今日總結", auto = true, aiFocus }: Props) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ["ai-summary", symbol, scope],
|
||||||
|
queryFn: () => api.aiSummary(symbol, scope, auto),
|
||||||
|
enabled: !!symbol && auto,
|
||||||
|
staleTime: 12 * 3600_000,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncMut = useMutation({
|
||||||
|
mutationFn: () => api.syncAiSummary(symbol, scope),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
qc.setQueryData(["ai-summary", symbol, scope], data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const summary = q.data?.summary;
|
||||||
|
const loading = q.isLoading || syncMut.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className="mt"
|
||||||
|
title={title}
|
||||||
|
ico={<AppIcon name="scroll" size={22} framed variant="hero" />}
|
||||||
|
ai
|
||||||
|
aiFocus={{ ...aiFocus, cardTitle: title }}
|
||||||
|
onRefresh={() => syncMut.mutate()}
|
||||||
|
refreshing={loading}
|
||||||
|
refreshLabel="重新分析"
|
||||||
|
>
|
||||||
|
<div className="ai-summary-toolbar">
|
||||||
|
<span className="small muted">
|
||||||
|
{q.data?.cached
|
||||||
|
? `今日已分析 · ${q.data?.summary?.day || ""} · 讀資料庫(省 token)`
|
||||||
|
: q.data?.mcpUsed
|
||||||
|
? "MCP + 本機資料已送 AI 總結"
|
||||||
|
: "整合本機財報/公司研究;可串 MCP 補充"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && !summary ? (
|
||||||
|
<Loading label="MCP 取資料並產生總結…" />
|
||||||
|
) : summary ? (
|
||||||
|
<>
|
||||||
|
<div className="fund-verdict-row">
|
||||||
|
{summary.verdict ? <Tag tone={verdictTone(summary.verdict)}>{summary.verdict}</Tag> : null}
|
||||||
|
{summary.provider ? <span className="small muted">via {summary.provider}</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="interpret-box">
|
||||||
|
<div className="interpret-label">AI 總結</div>
|
||||||
|
<p>{summary.summaryZh}</p>
|
||||||
|
{summary.bullets?.length ? (
|
||||||
|
<ul className="interpret-bullets">
|
||||||
|
{summary.bullets.map((b, i) => (
|
||||||
|
<li key={i}>{b}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{summary.risks?.length ? (
|
||||||
|
<div className="ai-summary-risks mt-s">
|
||||||
|
<h4 className="small" style={{ color: "var(--crimson)", margin: "0 0 6px" }}>
|
||||||
|
風險/待確認
|
||||||
|
</h4>
|
||||||
|
<ul className="interpret-bullets">
|
||||||
|
{summary.risks.map((r, i) => (
|
||||||
|
<li key={i}>{r}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{summary.sources?.length ? (
|
||||||
|
<p className="small muted mt-s">來源:{summary.sources.join(" · ")}</p>
|
||||||
|
) : null}
|
||||||
|
{q.data?.skipReason ? <p className="small muted">{q.data.skipReason}</p> : null}
|
||||||
|
{q.data?.aiError ? <p className="small muted">AI 略過:{q.data.aiError}(已用規則備援)</p> : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="muted">
|
||||||
|
尚無今日總結。
|
||||||
|
<button type="button" className="btn-primary" style={{ marginLeft: 8 }} disabled={loading} onClick={() => syncMut.mutate()}>
|
||||||
|
產生 AI 總結
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import type { PlayerAttribute } from "../lib/playerProgress";
|
||||||
|
|
||||||
|
export function AttributeRadar({
|
||||||
|
attributes,
|
||||||
|
size = 220,
|
||||||
|
}: {
|
||||||
|
attributes: PlayerAttribute[];
|
||||||
|
size?: number;
|
||||||
|
}) {
|
||||||
|
const center = size / 2;
|
||||||
|
const radius = size * 0.36;
|
||||||
|
const n = attributes.length;
|
||||||
|
const start = -Math.PI / 2;
|
||||||
|
const step = (Math.PI * 2) / n;
|
||||||
|
|
||||||
|
const polar = (i: number, r: number) => {
|
||||||
|
const a = start + i * step;
|
||||||
|
return { x: center + r * Math.cos(a), y: center + r * Math.sin(a) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const rings = [0.25, 0.5, 0.75, 1];
|
||||||
|
const dataPoints = attributes
|
||||||
|
.map((attr, i) => polar(i, radius * (attr.value / 100)))
|
||||||
|
.map((p) => `${p.x},${p.y}`)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="attr-radar-svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox={`0 0 ${size} ${size}`}
|
||||||
|
role="img"
|
||||||
|
aria-label="五維屬性雷達"
|
||||||
|
>
|
||||||
|
{rings.map((ring) => {
|
||||||
|
const pts = attributes
|
||||||
|
.map((_, i) => polar(i, radius * ring))
|
||||||
|
.map((p) => `${p.x},${p.y}`)
|
||||||
|
.join(" ");
|
||||||
|
return (
|
||||||
|
<polygon
|
||||||
|
key={ring}
|
||||||
|
points={pts}
|
||||||
|
fill="none"
|
||||||
|
stroke="color-mix(in srgb, var(--line) 70%, var(--gold) 20%)"
|
||||||
|
strokeWidth={ring === 1 ? 1.5 : 1}
|
||||||
|
opacity={ring === 1 ? 1 : 0.55}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{attributes.map((_, i) => {
|
||||||
|
const outer = polar(i, radius);
|
||||||
|
const inner = polar(i, radius * 0.12);
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={i}
|
||||||
|
x1={inner.x}
|
||||||
|
y1={inner.y}
|
||||||
|
x2={outer.x}
|
||||||
|
y2={outer.y}
|
||||||
|
stroke="color-mix(in srgb, var(--line) 60%, transparent)"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<polygon
|
||||||
|
points={dataPoints}
|
||||||
|
fill="color-mix(in srgb, var(--gold) 28%, transparent)"
|
||||||
|
stroke="var(--gold)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
{attributes.map((attr, i) => {
|
||||||
|
const p = polar(i, radius * 1.18);
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
key={attr.id}
|
||||||
|
x={p.x}
|
||||||
|
y={p.y}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
className="attr-radar-label"
|
||||||
|
>
|
||||||
|
{attr.label}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,316 @@
|
||||||
|
import { useMemo, useState, type ReactNode } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import type { CalendarEvent } from "../lib/api";
|
||||||
|
import {
|
||||||
|
calendarEventShortLabel,
|
||||||
|
calendarRangeFromToday,
|
||||||
|
categoryTip,
|
||||||
|
impactClass,
|
||||||
|
impactLabel,
|
||||||
|
} from "../lib/calendarInfo";
|
||||||
|
import { Explain, Tag } from "./ui";
|
||||||
|
|
||||||
|
const WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"] as const;
|
||||||
|
const IMPACT_RANK: Record<string, number> = { high: 0, medium: 1, low: 2 };
|
||||||
|
|
||||||
|
function sortDayEvents(list: CalendarEvent[]) {
|
||||||
|
return [...list].sort((a, b) => {
|
||||||
|
const ra = IMPACT_RANK[a.impact || ""] ?? 2;
|
||||||
|
const rb = IMPACT_RANK[b.impact || ""] ?? 2;
|
||||||
|
if (ra !== rb) return ra - rb;
|
||||||
|
return String(a.titleZh || a.title).localeCompare(String(b.titleZh || b.title));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDayLabel(iso: string) {
|
||||||
|
const d = new Date(`${iso}T12:00:00`);
|
||||||
|
if (Number.isNaN(d.getTime())) return iso;
|
||||||
|
return d.toLocaleDateString("zh-TW", { month: "long", day: "numeric", weekday: "long" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoFromParts(y: number, m: number, day: number) {
|
||||||
|
return `${y}-${String(m + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chipClass(ev: CalendarEvent) {
|
||||||
|
const cat = ev.category || "macro";
|
||||||
|
const imp = ev.impact || "low";
|
||||||
|
return `cal-ev ${imp} cat-${cat}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dotClass(ev: CalendarEvent) {
|
||||||
|
const cat = ev.category || "macro";
|
||||||
|
const imp = ev.impact || "low";
|
||||||
|
return `cal-dot ${imp} cat-${cat}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarDayDetail({ date, events }: { date: string; events: CalendarEvent[] }) {
|
||||||
|
return (
|
||||||
|
<div className="cal-day-panel">
|
||||||
|
<div className="cal-day-detail-head">
|
||||||
|
<b>{formatDayLabel(date)}</b>
|
||||||
|
<span>{events.length} 項事件</span>
|
||||||
|
</div>
|
||||||
|
{!events.length ? (
|
||||||
|
<p className="muted small cal-day-empty">這天沒有事件。點其他日期格子查看。</p>
|
||||||
|
) : (
|
||||||
|
<div className="cal-detail-list">
|
||||||
|
{events.map((ev, i) => {
|
||||||
|
const ci = categoryTip(ev.category);
|
||||||
|
return (
|
||||||
|
<div className={`cal-detail-row ${ev.impact || "low"}`} key={`${ev.title}-${i}`}>
|
||||||
|
<div className="cal-detail-main">
|
||||||
|
<div className="cal-detail-title">
|
||||||
|
<span className={`event-impact ${ev.impact || "low"}`}>
|
||||||
|
{impactLabel(ev.impact).slice(0, 1)}
|
||||||
|
</span>
|
||||||
|
<b>{ev.titleZh || ev.title}</b>
|
||||||
|
{ev.symbol && (
|
||||||
|
<Link to={`/research?sym=${encodeURIComponent(ev.symbol)}`} className="event-symbol">
|
||||||
|
{ev.symbol}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Tag>{ci?.label}</Tag>
|
||||||
|
<span className={"tag " + impactClass(ev.impact)}>{impactLabel(ev.impact)}</span>
|
||||||
|
<Explain tip={ci?.tip} label={`${ci?.label}・這天會公布什麼`} />
|
||||||
|
</div>
|
||||||
|
<div className="cal-detail-note">
|
||||||
|
{ev.note || "—"}
|
||||||
|
{ev.time ? ` · ${ev.time}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="cal-detail-meta">
|
||||||
|
{ci?.label}
|
||||||
|
{ev.source ? <small>{ev.source}</small> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalendarBoard({
|
||||||
|
events,
|
||||||
|
portfolioCount = 0,
|
||||||
|
start: startIn,
|
||||||
|
end: endIn,
|
||||||
|
}: {
|
||||||
|
events: CalendarEvent[];
|
||||||
|
portfolioCount?: number;
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
|
}) {
|
||||||
|
const fallback = calendarRangeFromToday(60);
|
||||||
|
const range = {
|
||||||
|
start: startIn || fallback.start,
|
||||||
|
end: endIn || fallback.end,
|
||||||
|
today: fallback.today,
|
||||||
|
};
|
||||||
|
|
||||||
|
const todayDate = useMemo(() => new Date(`${range.today}T12:00:00`), [range.today]);
|
||||||
|
const [viewMonth, setViewMonth] = useState(() => ({
|
||||||
|
y: todayDate.getFullYear(),
|
||||||
|
m: todayDate.getMonth(),
|
||||||
|
}));
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string>(range.today);
|
||||||
|
|
||||||
|
const byDate = useMemo(() => {
|
||||||
|
const map = new Map<string, CalendarEvent[]>();
|
||||||
|
for (const ev of events) {
|
||||||
|
if (ev.date < range.start || ev.date > range.end) continue;
|
||||||
|
const list = map.get(ev.date) || [];
|
||||||
|
list.push(ev);
|
||||||
|
map.set(ev.date, list);
|
||||||
|
}
|
||||||
|
for (const [k, list] of map) map.set(k, sortDayEvents(list));
|
||||||
|
return map;
|
||||||
|
}, [events, range.start, range.end]);
|
||||||
|
|
||||||
|
const monthBounds = useMemo(() => {
|
||||||
|
const start = new Date(`${range.start}T12:00:00`);
|
||||||
|
const end = new Date(`${range.end}T12:00:00`);
|
||||||
|
return {
|
||||||
|
minY: start.getFullYear(),
|
||||||
|
minM: start.getMonth(),
|
||||||
|
maxY: end.getFullYear(),
|
||||||
|
maxM: end.getMonth(),
|
||||||
|
};
|
||||||
|
}, [range.start, range.end]);
|
||||||
|
|
||||||
|
const canPrev = useMemo(() => {
|
||||||
|
const { minY, minM } = monthBounds;
|
||||||
|
return viewMonth.y > minY || (viewMonth.y === minY && viewMonth.m > minM);
|
||||||
|
}, [monthBounds, viewMonth]);
|
||||||
|
|
||||||
|
const canNext = useMemo(() => {
|
||||||
|
const { maxY, maxM } = monthBounds;
|
||||||
|
return viewMonth.y < maxY || (viewMonth.y === maxY && viewMonth.m < maxM);
|
||||||
|
}, [monthBounds, viewMonth]);
|
||||||
|
|
||||||
|
const selectedEvents = byDate.get(selectedDate) || [];
|
||||||
|
const eventCount = useMemo(() => {
|
||||||
|
let n = 0;
|
||||||
|
for (const [, list] of byDate) n += list.length;
|
||||||
|
return n;
|
||||||
|
}, [byDate]);
|
||||||
|
|
||||||
|
const viewMonthLabel = useMemo(() => {
|
||||||
|
const d = new Date(viewMonth.y, viewMonth.m, 1);
|
||||||
|
return d.toLocaleDateString("zh-TW", { year: "numeric", month: "long" });
|
||||||
|
}, [viewMonth]);
|
||||||
|
|
||||||
|
const goPrevMonth = () => {
|
||||||
|
if (!canPrev) return;
|
||||||
|
setViewMonth((v) => (v.m === 0 ? { y: v.y - 1, m: 11 } : { y: v.y, m: v.m - 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const goNextMonth = () => {
|
||||||
|
if (!canNext) return;
|
||||||
|
setViewMonth((v) => (v.m === 11 ? { y: v.y + 1, m: 0 } : { y: v.y, m: v.m + 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToday = () => {
|
||||||
|
setSelectedDate(range.today);
|
||||||
|
setViewMonth({ y: todayDate.getFullYear(), m: todayDate.getMonth() });
|
||||||
|
};
|
||||||
|
|
||||||
|
const { y, m } = viewMonth;
|
||||||
|
const firstDow = new Date(y, m, 1).getDay();
|
||||||
|
const daysInMonth = new Date(y, m + 1, 0).getDate();
|
||||||
|
const cells: ReactNode[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < firstDow; i++) {
|
||||||
|
cells.push(<div key={`pad-${y}-${m}-${i}`} className="cal-cell pad" />);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const iso = isoFromParts(y, m, day);
|
||||||
|
const inRange = iso >= range.start && iso <= range.end;
|
||||||
|
const dayEvents = byDate.get(iso) || [];
|
||||||
|
const cls = [
|
||||||
|
"cal-cell",
|
||||||
|
inRange ? "in-range" : "off",
|
||||||
|
iso === range.today ? "today" : "",
|
||||||
|
selectedDate === iso ? "selected" : "",
|
||||||
|
dayEvents.length ? "has-events" : "",
|
||||||
|
dayEvents.some((e) => e.impact === "high") ? "has-hot" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
cells.push(
|
||||||
|
<button
|
||||||
|
key={iso}
|
||||||
|
type="button"
|
||||||
|
className={cls}
|
||||||
|
disabled={!inRange}
|
||||||
|
aria-label={`${iso},${dayEvents.length} 項事件`}
|
||||||
|
aria-pressed={selectedDate === iso}
|
||||||
|
onClick={() => inRange && setSelectedDate(iso)}
|
||||||
|
>
|
||||||
|
<div className="cal-day-top">
|
||||||
|
<span className="cal-day">{day}</span>
|
||||||
|
{dayEvents.length > 0 && <span className="cal-count">{dayEvents.length}</span>}
|
||||||
|
</div>
|
||||||
|
{dayEvents.length > 0 && (
|
||||||
|
<div className="cal-dots" aria-hidden>
|
||||||
|
{dayEvents.slice(0, 4).map((ev, i) => (
|
||||||
|
<span key={`${ev.date}-${ev.symbol || ""}-${i}`} className={dotClass(ev)} />
|
||||||
|
))}
|
||||||
|
{dayEvents.length > 4 && <span className="cal-dots-more">+</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="cal-events">
|
||||||
|
{dayEvents.length === 0 ? (
|
||||||
|
<span className="cal-quiet" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{dayEvents.slice(0, 2).map((ev, i) => (
|
||||||
|
<span
|
||||||
|
key={`${ev.date}-${ev.symbol || ""}-${ev.title}-${i}`}
|
||||||
|
className={chipClass(ev)}
|
||||||
|
title={`${ev.titleZh || ev.title}${ev.time ? ` · ${ev.time}` : ""}${ev.note ? `\n${ev.note}` : ""}`}
|
||||||
|
>
|
||||||
|
{calendarEventShortLabel(ev)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{dayEvents.length > 2 && <span className="cal-more">+{dayEvents.length - 2}</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cal-board-wrap">
|
||||||
|
<div className="cal-summary">
|
||||||
|
<div className="cal-stat">
|
||||||
|
<b>{eventCount}</b>
|
||||||
|
<span>區間內事件</span>
|
||||||
|
</div>
|
||||||
|
<div className="cal-stat">
|
||||||
|
<b>{range.start.slice(5)}</b>
|
||||||
|
<span>起算(今天)</span>
|
||||||
|
</div>
|
||||||
|
<div className="cal-stat">
|
||||||
|
<b>{range.end.slice(5)}</b>
|
||||||
|
<span>結束(約兩個月)</span>
|
||||||
|
</div>
|
||||||
|
<div className="cal-stat">
|
||||||
|
<b>{portfolioCount}</b>
|
||||||
|
<span>背包財報檔數</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cal-legend">
|
||||||
|
<span>
|
||||||
|
<i className="leg high" /> 高關注
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<i className="leg medium" /> 中關注
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<i className="leg fed" /> 聯準會
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<i className="leg deriv" /> 衍生品
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<i className="leg earn" /> 背包財報
|
||||||
|
</span>
|
||||||
|
<span className="cal-legend-note">點日期格子 → 下方看完整說明與 ? 解讀</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cal-layout">
|
||||||
|
<section className="cal-month">
|
||||||
|
<div className="cal-month-head">
|
||||||
|
<div className="cal-nav">
|
||||||
|
<button type="button" className="cal-nav-btn" onClick={goPrevMonth} disabled={!canPrev} aria-label="上個月">
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<h3>{viewMonthLabel}</h3>
|
||||||
|
<button type="button" className="cal-nav-btn" onClick={goNextMonth} disabled={!canNext} aria-label="下個月">
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="cal-today-btn" onClick={goToday}>
|
||||||
|
今天
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="cal-weekdays">
|
||||||
|
{WEEKDAYS.map((w) => (
|
||||||
|
<span key={w}>{w}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="cal-grid">{cells}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<CalendarDayDetail date={selectedDate} events={selectedEvents} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import type { CalendarEvent } from "../lib/api";
|
||||||
|
import {
|
||||||
|
calendarEventShortLabel,
|
||||||
|
categoryTip,
|
||||||
|
impactClass,
|
||||||
|
impactLabel,
|
||||||
|
} from "../lib/calendarInfo";
|
||||||
|
import { Explain, Tag } from "./ui";
|
||||||
|
|
||||||
|
const WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"] as const;
|
||||||
|
|
||||||
|
function sortDayEvents(list: CalendarEvent[]) {
|
||||||
|
const rank: Record<string, number> = { high: 0, medium: 1, low: 2 };
|
||||||
|
return [...list].sort((a, b) => {
|
||||||
|
const ra = rank[a.impact || ""] ?? 2;
|
||||||
|
const rb = rank[b.impact || ""] ?? 2;
|
||||||
|
if (ra !== rb) return ra - rb;
|
||||||
|
return String(a.titleZh || a.title).localeCompare(String(b.titleZh || b.title));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function chipClass(ev: CalendarEvent) {
|
||||||
|
return `cal-ev ${ev.impact || "low"} cat-${ev.category || "macro"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dotClass(ev: CalendarEvent) {
|
||||||
|
return `cal-dot ${ev.impact || "low"} cat-${ev.category || "macro"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalendarWeekPreview({ events }: { events: CalendarEvent[] }) {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const weekDays = useMemo(() => {
|
||||||
|
const out: { iso: string; day: number; dow: string }[] = [];
|
||||||
|
const base = new Date(`${today}T12:00:00`);
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const d = new Date(base.getTime() + i * 86400000);
|
||||||
|
const iso = d.toISOString().slice(0, 10);
|
||||||
|
out.push({ iso, day: d.getDate(), dow: WEEKDAYS[d.getDay()] });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [today]);
|
||||||
|
|
||||||
|
const byDate = useMemo(() => {
|
||||||
|
const map = new Map<string, CalendarEvent[]>();
|
||||||
|
for (const ev of events) {
|
||||||
|
const list = map.get(ev.date) || [];
|
||||||
|
list.push(ev);
|
||||||
|
map.set(ev.date, list);
|
||||||
|
}
|
||||||
|
for (const [k, list] of map) map.set(k, sortDayEvents(list));
|
||||||
|
return map;
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState(today);
|
||||||
|
const selectedEvents = byDate.get(selected) || [];
|
||||||
|
|
||||||
|
if (!weekDays.some((d) => (byDate.get(d.iso) || []).length)) {
|
||||||
|
return <p className="muted small">未來 7 天沒有追蹤中的事件。</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cal-week-preview">
|
||||||
|
<div className="cal-week-grid">
|
||||||
|
{weekDays.map((d) => {
|
||||||
|
const dayEvents = byDate.get(d.iso) || [];
|
||||||
|
const cls = [
|
||||||
|
"cal-week-cell",
|
||||||
|
d.iso === today ? "today" : "",
|
||||||
|
selected === d.iso ? "selected" : "",
|
||||||
|
dayEvents.length ? "has-events" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={d.iso}
|
||||||
|
type="button"
|
||||||
|
className={cls}
|
||||||
|
aria-pressed={selected === d.iso}
|
||||||
|
onClick={() => setSelected(d.iso)}
|
||||||
|
>
|
||||||
|
<span className="cal-week-dow">{d.dow}</span>
|
||||||
|
<span className="cal-week-day">{d.day}</span>
|
||||||
|
{dayEvents.length > 0 && (
|
||||||
|
<span className="cal-dots cal-dots-week" aria-hidden>
|
||||||
|
{dayEvents.slice(0, 3).map((ev, i) => (
|
||||||
|
<span key={i} className={dotClass(ev)} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedEvents.length === 0 ? (
|
||||||
|
<p className="muted small cal-week-empty">這天沒有事件。</p>
|
||||||
|
) : (
|
||||||
|
<div className="cal-week-events">
|
||||||
|
{selectedEvents.map((ev, i) => {
|
||||||
|
const ci = categoryTip(ev.category);
|
||||||
|
return (
|
||||||
|
<div className={`cal-week-ev ${ev.impact || "low"}`} key={i}>
|
||||||
|
<div className="cal-week-ev-head">
|
||||||
|
<span className={chipClass(ev)}>{calendarEventShortLabel(ev)}</span>
|
||||||
|
<b>{ev.titleZh || ev.title}</b>
|
||||||
|
{ev.symbol && (
|
||||||
|
<Link to={`/research?sym=${encodeURIComponent(ev.symbol)}`} className="event-symbol">
|
||||||
|
{ev.symbol}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Tag>{ci?.label}</Tag>
|
||||||
|
<span className={"tag " + impactClass(ev.impact)}>{impactLabel(ev.impact)}</span>
|
||||||
|
<Explain tip={ci?.tip} label={`${ci?.label}・這天會公布什麼`} />
|
||||||
|
</div>
|
||||||
|
<div className="cal-week-ev-note">
|
||||||
|
{ev.note || (ev.impact === "high" ? "高關注事件:數據公布當日宜控槓桿、留意跳空。" : "留意是否偏離市場預期。")}
|
||||||
|
{ev.time ? ` · ${ev.time}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link to="/market" className="small muted cal-week-link">
|
||||||
|
前往市場世界 · 完整事件日曆 →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,396 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import {
|
||||||
|
createChart,
|
||||||
|
CandlestickSeries,
|
||||||
|
HistogramSeries,
|
||||||
|
LineSeries,
|
||||||
|
ColorType,
|
||||||
|
createSeriesMarkers,
|
||||||
|
type IChartApi,
|
||||||
|
type ISeriesApi,
|
||||||
|
type ISeriesMarkersPluginApi,
|
||||||
|
type CandlestickData,
|
||||||
|
type HistogramData,
|
||||||
|
type LineData,
|
||||||
|
type Time,
|
||||||
|
type UTCTimestamp,
|
||||||
|
type SeriesMarker,
|
||||||
|
type IPriceLine,
|
||||||
|
} from "lightweight-charts";
|
||||||
|
import type { BollingerSeries } from "../lib/technicalIndicators";
|
||||||
|
import { GMMA_LONG, GMMA_SHORT } from "../lib/patternRules";
|
||||||
|
|
||||||
|
export interface OhlcPoint {
|
||||||
|
date: string;
|
||||||
|
open: number;
|
||||||
|
high: number;
|
||||||
|
low: number;
|
||||||
|
close: number;
|
||||||
|
volume?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaConfig {
|
||||||
|
period: number;
|
||||||
|
color: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartMarker {
|
||||||
|
date: string;
|
||||||
|
type: "buy" | "sell" | "watch";
|
||||||
|
selected?: boolean;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceLineConfig {
|
||||||
|
price: number;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MAS: MaConfig[] = [
|
||||||
|
{ period: 5, color: "#f0c040", label: "MA5" },
|
||||||
|
{ period: 20, color: "#6fe0d0", label: "MA20" },
|
||||||
|
{ period: 60, color: "#c97bff", label: "MA60" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GMMA_SHORT_COLORS = ["#7ec8ff", "#6eb8f5", "#5ea8eb", "#4e98e1", "#3e88d7", "#2e78cd"];
|
||||||
|
const GMMA_LONG_COLORS = ["#ffb347", "#ffa037", "#ff9027", "#ff8017", "#ff7007", "#e06000"];
|
||||||
|
|
||||||
|
function chartPrefs() {
|
||||||
|
const w = window.innerWidth;
|
||||||
|
const compact = w <= 768;
|
||||||
|
return {
|
||||||
|
fontSize: compact ? 10 : 11,
|
||||||
|
scaleMargins: { top: compact ? 0.12 : 0.1, bottom: compact ? 0.22 : 0.18 },
|
||||||
|
priceScaleWidth: compact ? 52 : 60,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTime(date: string): UTCTimestamp {
|
||||||
|
const ms = date.includes("T") ? Date.parse(date) : Date.parse(`${date}T00:00:00Z`);
|
||||||
|
return (ms / 1000) as UTCTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeMA(data: OhlcPoint[], period: number): LineData[] {
|
||||||
|
const out: LineData[] = [];
|
||||||
|
for (let i = period - 1; i < data.length; i++) {
|
||||||
|
let sum = 0;
|
||||||
|
for (let j = i - period + 1; j <= i; j++) sum += data[j].close;
|
||||||
|
out.push({ time: toTime(data[i].date), value: sum / period });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerShape(type: ChartMarker["type"]): SeriesMarker<Time>["shape"] {
|
||||||
|
if (type === "buy") return "arrowUp";
|
||||||
|
if (type === "sell") return "arrowDown";
|
||||||
|
return "circle";
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerColor(type: ChartMarker["type"], selected?: boolean) {
|
||||||
|
if (selected) return "#ffe566";
|
||||||
|
if (type === "buy") return "#e86a52";
|
||||||
|
if (type === "sell") return "#6fcf97";
|
||||||
|
return "#f0a040";
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerPosition(type: ChartMarker["type"]): "belowBar" | "aboveBar" {
|
||||||
|
return type === "sell" ? "aboveBar" : "belowBar";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CandlestickChart({
|
||||||
|
data,
|
||||||
|
mas = DEFAULT_MAS,
|
||||||
|
showVolume = true,
|
||||||
|
showGuppy = false,
|
||||||
|
showBollinger = false,
|
||||||
|
bollinger,
|
||||||
|
markers = [],
|
||||||
|
priceLines = [],
|
||||||
|
focusDate,
|
||||||
|
heightClass = "candle-chart",
|
||||||
|
}: {
|
||||||
|
data: OhlcPoint[];
|
||||||
|
mas?: MaConfig[];
|
||||||
|
showVolume?: boolean;
|
||||||
|
showGuppy?: boolean;
|
||||||
|
showBollinger?: boolean;
|
||||||
|
bollinger?: BollingerSeries | null;
|
||||||
|
markers?: ChartMarker[];
|
||||||
|
priceLines?: PriceLineConfig[];
|
||||||
|
focusDate?: string | null;
|
||||||
|
heightClass?: string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const chartRef = useRef<IChartApi | null>(null);
|
||||||
|
const candleRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
|
||||||
|
const volRef = useRef<ISeriesApi<"Histogram"> | null>(null);
|
||||||
|
const maRefs = useRef<ISeriesApi<"Line">[]>([]);
|
||||||
|
const bollRefs = useRef<ISeriesApi<"Line">[]>([]);
|
||||||
|
const markerRef = useRef<ISeriesMarkersPluginApi<Time> | null>(null);
|
||||||
|
const priceLineRefs = useRef<IPriceLine[]>([]);
|
||||||
|
const lastRangeKeyRef = useRef("");
|
||||||
|
|
||||||
|
const bollColors = { upper: "rgba(200,123,255,.55)", mid: "rgba(200,123,255,.25)", lower: "rgba(200,123,255,.55)" };
|
||||||
|
|
||||||
|
const overlayMas = useMemo(() => {
|
||||||
|
if (!showGuppy) return mas;
|
||||||
|
const short = GMMA_SHORT.map((p, i) => ({
|
||||||
|
period: p,
|
||||||
|
color: GMMA_SHORT_COLORS[i],
|
||||||
|
label: `S${p}`,
|
||||||
|
}));
|
||||||
|
const long = GMMA_LONG.map((p, i) => ({
|
||||||
|
period: p,
|
||||||
|
color: GMMA_LONG_COLORS[i],
|
||||||
|
label: `L${p}`,
|
||||||
|
}));
|
||||||
|
return [...short, ...long];
|
||||||
|
}, [mas, showGuppy]);
|
||||||
|
|
||||||
|
const clean = useMemo(() => {
|
||||||
|
const byDate = new Map<string, OhlcPoint>();
|
||||||
|
for (const p of data || []) {
|
||||||
|
if (
|
||||||
|
p.open == null ||
|
||||||
|
p.high == null ||
|
||||||
|
p.low == null ||
|
||||||
|
p.close == null ||
|
||||||
|
Number.isNaN(p.close)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
byDate.set(p.date, p);
|
||||||
|
}
|
||||||
|
return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const applyPrefs = useCallback(() => {
|
||||||
|
const chart = chartRef.current;
|
||||||
|
if (!chart) return;
|
||||||
|
const prefs = chartPrefs();
|
||||||
|
chart.applyOptions({
|
||||||
|
layout: { fontSize: prefs.fontSize },
|
||||||
|
rightPriceScale: { scaleMargins: prefs.scaleMargins, minimumWidth: prefs.priceScaleWidth },
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const prefs = chartPrefs();
|
||||||
|
const chart = createChart(ref.current, {
|
||||||
|
layout: {
|
||||||
|
background: { type: ColorType.Solid, color: "transparent" },
|
||||||
|
textColor: "#e8ecf4",
|
||||||
|
fontSize: prefs.fontSize,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
vertLines: { color: "rgba(255,255,255,.06)" },
|
||||||
|
horzLines: { color: "rgba(255,255,255,.06)" },
|
||||||
|
},
|
||||||
|
rightPriceScale: {
|
||||||
|
borderColor: "rgba(231,198,107,.25)",
|
||||||
|
scaleMargins: prefs.scaleMargins,
|
||||||
|
minimumWidth: prefs.priceScaleWidth,
|
||||||
|
},
|
||||||
|
timeScale: {
|
||||||
|
borderColor: "rgba(231,198,107,.25)",
|
||||||
|
timeVisible: true,
|
||||||
|
secondsVisible: false,
|
||||||
|
},
|
||||||
|
crosshair: {
|
||||||
|
vertLine: { color: "rgba(231,198,107,.35)", width: 1, style: 2 },
|
||||||
|
horzLine: { color: "rgba(231,198,107,.35)", width: 1, style: 2 },
|
||||||
|
},
|
||||||
|
autoSize: true,
|
||||||
|
handleScroll: true,
|
||||||
|
handleScale: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const candle = chart.addSeries(CandlestickSeries, {
|
||||||
|
upColor: "#e86a52",
|
||||||
|
downColor: "#6fcf97",
|
||||||
|
borderUpColor: "#e86a52",
|
||||||
|
borderDownColor: "#6fcf97",
|
||||||
|
wickUpColor: "#e86a52",
|
||||||
|
wickDownColor: "#6fcf97",
|
||||||
|
});
|
||||||
|
|
||||||
|
let vol: ISeriesApi<"Histogram"> | null = null;
|
||||||
|
if (showVolume) {
|
||||||
|
vol = chart.addSeries(HistogramSeries, {
|
||||||
|
color: "rgba(111,224,208,.45)",
|
||||||
|
priceFormat: { type: "volume" },
|
||||||
|
priceScaleId: "vol",
|
||||||
|
});
|
||||||
|
chart.priceScale("vol").applyOptions({
|
||||||
|
scaleMargins: { top: 0.82, bottom: 0 },
|
||||||
|
borderVisible: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const maSeries = overlayMas.map((m) =>
|
||||||
|
chart.addSeries(LineSeries, {
|
||||||
|
color: m.color,
|
||||||
|
lineWidth: 1,
|
||||||
|
priceLineVisible: false,
|
||||||
|
lastValueVisible: false,
|
||||||
|
crosshairMarkerRadius: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const bollSeries = showBollinger
|
||||||
|
? (["upper", "mid", "lower"] as const).map((k) =>
|
||||||
|
chart.addSeries(LineSeries, {
|
||||||
|
color: bollColors[k],
|
||||||
|
lineWidth: k === "mid" ? 1 : 1,
|
||||||
|
lineStyle: k === "mid" ? 2 : 0,
|
||||||
|
priceLineVisible: false,
|
||||||
|
lastValueVisible: false,
|
||||||
|
crosshairMarkerRadius: 0,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const markersPlugin = createSeriesMarkers(candle, []);
|
||||||
|
|
||||||
|
chartRef.current = chart;
|
||||||
|
candleRef.current = candle;
|
||||||
|
volRef.current = vol;
|
||||||
|
maRefs.current = maSeries;
|
||||||
|
bollRefs.current = bollSeries;
|
||||||
|
markerRef.current = markersPlugin;
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => applyPrefs());
|
||||||
|
ro.observe(ref.current);
|
||||||
|
window.addEventListener("resize", applyPrefs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
window.removeEventListener("resize", applyPrefs);
|
||||||
|
markerRef.current = null;
|
||||||
|
chart.remove();
|
||||||
|
chartRef.current = null;
|
||||||
|
candleRef.current = null;
|
||||||
|
volRef.current = null;
|
||||||
|
maRefs.current = [];
|
||||||
|
bollRefs.current = [];
|
||||||
|
priceLineRefs.current = [];
|
||||||
|
lastRangeKeyRef.current = "";
|
||||||
|
};
|
||||||
|
}, [overlayMas, showVolume, showBollinger, applyPrefs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!candleRef.current || !clean.length) return;
|
||||||
|
const candles: CandlestickData[] = clean.map((p) => ({
|
||||||
|
time: toTime(p.date),
|
||||||
|
open: p.open,
|
||||||
|
high: p.high,
|
||||||
|
low: p.low,
|
||||||
|
close: p.close,
|
||||||
|
}));
|
||||||
|
candleRef.current.setData(candles);
|
||||||
|
|
||||||
|
if (volRef.current) {
|
||||||
|
const vols: HistogramData[] = clean
|
||||||
|
.filter((p) => p.volume != null && p.volume > 0)
|
||||||
|
.map((p) => ({
|
||||||
|
time: toTime(p.date),
|
||||||
|
value: p.volume!,
|
||||||
|
color: p.close >= p.open ? "rgba(232,106,82,.4)" : "rgba(111,207,151,.35)",
|
||||||
|
}));
|
||||||
|
volRef.current.setData(vols);
|
||||||
|
}
|
||||||
|
|
||||||
|
maRefs.current.forEach((series, i) => {
|
||||||
|
const cfg = overlayMas[i];
|
||||||
|
if (!cfg) return;
|
||||||
|
series.setData(computeMA(clean, cfg.period));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (showBollinger && bollinger && bollRefs.current.length === 3) {
|
||||||
|
const toLine = (pts: { date: string; value: number }[]) =>
|
||||||
|
pts.map((p) => ({ time: toTime(p.date), value: p.value }));
|
||||||
|
bollRefs.current[0]?.setData(toLine(bollinger.upper));
|
||||||
|
bollRefs.current[1]?.setData(toLine(bollinger.mid));
|
||||||
|
bollRefs.current[2]?.setData(toLine(bollinger.lower));
|
||||||
|
}
|
||||||
|
|
||||||
|
const seriesMarkers: SeriesMarker<Time>[] = markers
|
||||||
|
.filter((m) => m.date)
|
||||||
|
.map((m) => ({
|
||||||
|
time: toTime(m.date),
|
||||||
|
position: markerPosition(m.type),
|
||||||
|
color: markerColor(m.type, m.selected),
|
||||||
|
shape: markerShape(m.type),
|
||||||
|
text: m.selected ? `★ ${m.label || ""}` : m.label || "",
|
||||||
|
size: m.selected ? 2 : 1,
|
||||||
|
}));
|
||||||
|
markerRef.current?.setMarkers(seriesMarkers);
|
||||||
|
|
||||||
|
for (const pl of priceLineRefs.current) {
|
||||||
|
try {
|
||||||
|
candleRef.current.removePriceLine(pl);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
priceLineRefs.current = [];
|
||||||
|
for (const pl of priceLines) {
|
||||||
|
if (pl.price == null || Number.isNaN(pl.price)) continue;
|
||||||
|
const line = candleRef.current.createPriceLine({
|
||||||
|
price: pl.price,
|
||||||
|
title: pl.label,
|
||||||
|
color: pl.color,
|
||||||
|
lineWidth: 2,
|
||||||
|
lineStyle: 2,
|
||||||
|
axisLabelVisible: true,
|
||||||
|
});
|
||||||
|
priceLineRefs.current.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chart = chartRef.current;
|
||||||
|
if (chart) {
|
||||||
|
const rangeKey = `${clean.length}:${clean[0]?.date}:${clean[clean.length - 1]?.date}`;
|
||||||
|
if (focusDate) {
|
||||||
|
const t = toTime(focusDate);
|
||||||
|
chart.timeScale().setVisibleRange({
|
||||||
|
from: (t - 86400 * 40) as UTCTimestamp,
|
||||||
|
to: (t + 86400 * 12) as UTCTimestamp,
|
||||||
|
});
|
||||||
|
lastRangeKeyRef.current = rangeKey;
|
||||||
|
} else if (rangeKey !== lastRangeKeyRef.current) {
|
||||||
|
// 僅在 K 線資料區間變更時自動 fit,避免拖曳後被 markers 更新打回
|
||||||
|
chart.timeScale().fitContent();
|
||||||
|
lastRangeKeyRef.current = rangeKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [clean, overlayMas, showBollinger, bollinger, markers, priceLines, focusDate]);
|
||||||
|
|
||||||
|
const legendItems = showGuppy
|
||||||
|
? [
|
||||||
|
{ color: "#4e98e1", label: "顧比短群" },
|
||||||
|
{ color: "#ff8017", label: "顧比長群" },
|
||||||
|
]
|
||||||
|
: mas.map((m) => ({ color: m.color, label: m.label }));
|
||||||
|
|
||||||
|
if (showBollinger) legendItems.push({ color: "#c97bff", label: "布林" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="candle-wrap">
|
||||||
|
<div className="candle-legend">
|
||||||
|
{legendItems.map((m) => (
|
||||||
|
<span key={m.label} className="candle-legend-item">
|
||||||
|
<i style={{ background: m.color }} />
|
||||||
|
{m.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{markers.length ? <span className="candle-legend-item">▲買 ▼賣 ●觀察</span> : null}
|
||||||
|
<span className="candle-legend-item up">陽線(紅)</span>
|
||||||
|
<span className="candle-legend-item down">陰線(綠)</span>
|
||||||
|
</div>
|
||||||
|
<div ref={ref} className={heightClass} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,248 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
|
||||||
|
import GuideMascot from "./GuideMascot";
|
||||||
|
import AIDebugObservatory from "./AIDebugObservatory";
|
||||||
|
import { usePlayerProgress } from "../hooks/usePlayerProgress";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { AppIcon, RouteIcon, type IconName } from "./PixelIcons";
|
||||||
|
|
||||||
|
function fmtAssets(v: number | null | undefined) {
|
||||||
|
if (v == null || Number.isNaN(v)) return "—";
|
||||||
|
const sign = v < 0 ? "-" : "";
|
||||||
|
return `${sign}$${Math.abs(v).toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV: { section: string; items: { to: string; icon: IconName; label: string }[] }[] = [
|
||||||
|
{
|
||||||
|
section: "觀測",
|
||||||
|
items: [
|
||||||
|
{ to: "/", icon: "castle", label: "今日 · 基地" },
|
||||||
|
{ to: "/market", icon: "world", label: "市場 · 世界" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: "研究",
|
||||||
|
items: [{ to: "/research", icon: "folder", label: "個股 · 背包" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: "修練",
|
||||||
|
items: [
|
||||||
|
{ to: "/skills", icon: "scroll", label: "心法 · 技能樹" },
|
||||||
|
{ to: "/patterns", icon: "cards", label: "線型 · 圖鑑" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: "我的",
|
||||||
|
items: [
|
||||||
|
{ to: "/journal", icon: "folder", label: "復盤 · 戰績" },
|
||||||
|
{ to: "/profile", icon: "wizard", label: "角色 · 養成" },
|
||||||
|
{ to: "/settings", icon: "gear", label: "設定" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const BOTTOM_NAV: { to: string; icon: IconName; label: string }[] = [
|
||||||
|
{ to: "/", icon: "castle", label: "基地" },
|
||||||
|
{ to: "/market", icon: "world", label: "市場" },
|
||||||
|
{ to: "/research", icon: "folder", label: "背包" },
|
||||||
|
{ to: "/skills", icon: "scroll", label: "心法" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||||
|
return (
|
||||||
|
<nav className="nav">
|
||||||
|
{NAV.map((g) => (
|
||||||
|
<div key={g.section}>
|
||||||
|
<div className="nav-section">{g.section}</div>
|
||||||
|
{g.items.map((it) => (
|
||||||
|
<NavLink key={it.to} to={it.to} end={it.to === "/"} onClick={onNavigate}>
|
||||||
|
<span className="ico">
|
||||||
|
<RouteIcon to={it.to} size={22} />
|
||||||
|
</span>
|
||||||
|
{it.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useMobileNav() {
|
||||||
|
const [isMobile, setIsMobile] = useState(() =>
|
||||||
|
typeof window !== "undefined" ? window.matchMedia("(max-width: 920px)").matches : false
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia("(max-width: 920px)");
|
||||||
|
const sync = () => setIsMobile(mq.matches);
|
||||||
|
sync();
|
||||||
|
mq.addEventListener("change", sync);
|
||||||
|
return () => mq.removeEventListener("change", sync);
|
||||||
|
}, []);
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Chrome() {
|
||||||
|
const [navOpen, setNavOpen] = useState(false);
|
||||||
|
const isMobile = useMobileNav();
|
||||||
|
const location = useLocation();
|
||||||
|
const { player } = usePlayerProgress();
|
||||||
|
const portfolioQ = useQuery({
|
||||||
|
queryKey: ["portfolio-total"],
|
||||||
|
queryFn: api.portfolioTotal,
|
||||||
|
staleTime: 45_000,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
});
|
||||||
|
const xpPct = player?.xpToNext ? Math.round((player.xpInLevel / player.xpToNext) * 100) : 0;
|
||||||
|
const portfolio = portfolioQ.data;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNavOpen(false);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.classList.toggle("nav-open", navOpen);
|
||||||
|
return () => document.body.classList.remove("nav-open");
|
||||||
|
}, [navOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setNavOpen(false);
|
||||||
|
};
|
||||||
|
const onResize = () => {
|
||||||
|
if (!window.matchMedia("(max-width: 920px)").matches) setNavOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="layout">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={"nav-backdrop" + (navOpen ? " open" : "")}
|
||||||
|
aria-label="關閉選單"
|
||||||
|
onClick={() => setNavOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<aside className={"sidebar" + (navOpen ? " open" : "")} aria-hidden={isMobile && !navOpen}>
|
||||||
|
<div className="brand">
|
||||||
|
<span className="logo">
|
||||||
|
<AppIcon name="compass" size={28} framed glow variant="hero" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="title">投資冒險者</div>
|
||||||
|
<div className="sub">OBSERVATORY</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="nav-close" aria-label="關閉選單" onClick={() => setNavOpen(false)}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<NavLinks onNavigate={() => setNavOpen(false)} />
|
||||||
|
<div className="sidebar-foot">
|
||||||
|
資料僅供學習
|
||||||
|
<br />
|
||||||
|
不構成投資建議
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="main">
|
||||||
|
<header className="topbar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="nav-toggle"
|
||||||
|
aria-label="開啟選單"
|
||||||
|
aria-expanded={navOpen}
|
||||||
|
onClick={() => setNavOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</button>
|
||||||
|
<div className="hero-chip">
|
||||||
|
<div className="avatar">
|
||||||
|
<AppIcon name="wizard" size={32} framed variant="nav" />
|
||||||
|
</div>
|
||||||
|
<div className="hero-meta">
|
||||||
|
<div className="name">{player?.displayName || "投資冒險者"}</div>
|
||||||
|
<div className="lvl">
|
||||||
|
{player ? (
|
||||||
|
<>
|
||||||
|
<span>Lv.{player.level} · {player.title}</span>
|
||||||
|
{player.epithet ? <span className="hero-epithet">「{player.epithet}」</span> : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"載入中…"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="xp-wrap">
|
||||||
|
<div className="xp-label">
|
||||||
|
<span>EXP</span>
|
||||||
|
<span>
|
||||||
|
{player
|
||||||
|
? `${player.xpInLevel.toLocaleString()} / ${player.xpToNext.toLocaleString()}`
|
||||||
|
: "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bar">
|
||||||
|
<span style={{ width: `${xpPct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="topbar-right">
|
||||||
|
<Link
|
||||||
|
to="/journal"
|
||||||
|
className="coin topbar-assets"
|
||||||
|
title={
|
||||||
|
portfolio
|
||||||
|
? `現金 ${fmtAssets(portfolio.totalCash)} + 持倉 ${fmtAssets(portfolio.totalStock)}${
|
||||||
|
portfolio.accountNames?.length
|
||||||
|
? `(${portfolio.accountNames.join("、")})`
|
||||||
|
: ""
|
||||||
|
}`
|
||||||
|
: "前往查看帳戶資產"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AppIcon name="coin" size={18} framed variant="nav" />
|
||||||
|
<span className="topbar-assets-col">
|
||||||
|
<span className="topbar-assets-label">總資產</span>
|
||||||
|
<span className="topbar-assets-val">
|
||||||
|
{portfolioQ.isLoading ? "…" : fmtAssets(portfolio?.totalAssets)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav className="bottom-nav" aria-label="主要導覽">
|
||||||
|
{BOTTOM_NAV.map((it) => (
|
||||||
|
<NavLink key={it.to} to={it.to} end={it.to === "/"}>
|
||||||
|
<span className="ic">
|
||||||
|
<AppIcon name={it.icon} size={22} framed variant="nav" />
|
||||||
|
</span>
|
||||||
|
<span className="lb">{it.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
<button type="button" className="bottom-nav-more" onClick={() => setNavOpen(true)}>
|
||||||
|
<span className="ic">
|
||||||
|
<AppIcon name="menu" size={20} framed={false} />
|
||||||
|
</span>
|
||||||
|
<span className="lb">更多</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="content">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
<GuideMascot />
|
||||||
|
<AIDebugObservatory />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,452 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import AiSummaryCard from "./AiSummaryCard";
|
||||||
|
import { AppIcon } from "./PixelIcons";
|
||||||
|
import { Card, Tag, Loading, RefreshButton } from "./ui";
|
||||||
|
import type { CompanyIntelPayload, HoldersPayload, SecArchivePayload } from "../lib/api";
|
||||||
|
import {
|
||||||
|
entityLabel,
|
||||||
|
interpretCompanyProfile,
|
||||||
|
interpretFilings,
|
||||||
|
interpretInsiders,
|
||||||
|
insiderSignalLabel,
|
||||||
|
secArchiveLocalUrl,
|
||||||
|
} from "../lib/companyInterpret";
|
||||||
|
import { fmtNum } from "../lib/stockTechnical";
|
||||||
|
|
||||||
|
type ChainGroup = { label?: string; entities?: (string | { name?: string; symbol?: string })[]; note?: string };
|
||||||
|
|
||||||
|
function fmtShares(n: number | null | undefined) {
|
||||||
|
if (n == null || Number.isNaN(n)) return "—";
|
||||||
|
const a = Math.abs(n);
|
||||||
|
const s = n < 0 ? "-" : "";
|
||||||
|
if (a >= 1e6) return `${s}${(a / 1e6).toFixed(2)}M`;
|
||||||
|
if (a >= 1e3) return `${s}${(a / 1e3).toFixed(1)}K`;
|
||||||
|
return `${s}${a.toLocaleString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chainColHtml(groups: ChainGroup[] | undefined) {
|
||||||
|
if (!groups?.length) return <p className="muted small">尚無資料,可按「強制更新」請 AI 整理。</p>;
|
||||||
|
return (
|
||||||
|
<ul className="chain-entity-list">
|
||||||
|
{groups.map((g, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<strong>{g.label || "環節"}</strong>
|
||||||
|
<span>{(g.entities || []).map(entityLabel).join("、 ") || "待查證"}</span>
|
||||||
|
{g.note ? <em className="chain-note">{g.note}</em> : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function impactTone(impact?: string): "up" | "down" | "gold" {
|
||||||
|
if (impact === "positive") return "up";
|
||||||
|
if (impact === "negative") return "down";
|
||||||
|
return "gold";
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
symbol: string;
|
||||||
|
intel?: CompanyIntelPayload;
|
||||||
|
intelLoading?: boolean;
|
||||||
|
secArchive?: SecArchivePayload;
|
||||||
|
secLoading?: boolean;
|
||||||
|
holders?: HoldersPayload;
|
||||||
|
syncingIntel?: boolean;
|
||||||
|
syncingSec?: boolean;
|
||||||
|
onSyncIntel: (force?: boolean) => void;
|
||||||
|
onReloadIntel?: () => void;
|
||||||
|
intelReloading?: boolean;
|
||||||
|
onSyncSec: (force?: boolean) => void;
|
||||||
|
aiFocus: { symbol: string; subPage: string; label: string; cardTitle?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CompanyIntelPanel({
|
||||||
|
symbol,
|
||||||
|
intel,
|
||||||
|
intelLoading,
|
||||||
|
secArchive,
|
||||||
|
secLoading,
|
||||||
|
holders,
|
||||||
|
syncingIntel,
|
||||||
|
syncingSec,
|
||||||
|
onSyncIntel,
|
||||||
|
onReloadIntel,
|
||||||
|
intelReloading,
|
||||||
|
onSyncSec,
|
||||||
|
aiFocus,
|
||||||
|
}: Props) {
|
||||||
|
const [newsTab, setNewsTab] = useState<"tw" | "global">("tw");
|
||||||
|
|
||||||
|
const insiderInterp = useMemo(() => interpretInsiders(intel?.insiders), [intel?.insiders]);
|
||||||
|
const filingInterp = useMemo(() => interpretFilings(secArchive?.filings), [secArchive?.filings]);
|
||||||
|
const profileInterp = useMemo(() => interpretCompanyProfile(intel, symbol), [intel, symbol]);
|
||||||
|
|
||||||
|
const profileDesc = intel?.profileZh?.description || intel?.management?.longBusinessSummary;
|
||||||
|
const officers = intel?.management?.officers || [];
|
||||||
|
const chain = intel?.industryChain || {};
|
||||||
|
const mgmtBrief = intel?.managementBrief || [];
|
||||||
|
const newsTw = intel?.newsTw || [];
|
||||||
|
const newsGlobal = intel?.newsGlobal || [];
|
||||||
|
const filings = secArchive?.filings || [];
|
||||||
|
const earnings = secArchive?.earnings || [];
|
||||||
|
const syncNote = intel?.nextPublicLabel || intel?.syncSkipReason || (intel?.enrichedAt ? `上次整理 ${intel.enrichedAt.slice(0, 16).replace("T", " ")}` : "");
|
||||||
|
|
||||||
|
if (intelLoading && !intel) return <Loading />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="backpack-panel company-intel-panel">
|
||||||
|
<div className="intel-sync-bar">
|
||||||
|
<span className="small muted">
|
||||||
|
{syncingIntel ? "正在同步公司研究資料…" : syncNote}
|
||||||
|
{intel?.aiEnriched ? " · AI 已整理" : ""}
|
||||||
|
</span>
|
||||||
|
<span className="intel-sync-actions">
|
||||||
|
{onReloadIntel ? (
|
||||||
|
<RefreshButton onClick={onReloadIntel} loading={intelReloading} label="重新載入" />
|
||||||
|
) : null}
|
||||||
|
<button type="button" className="btn-ghost" disabled={syncingIntel} onClick={() => onSyncIntel(true)}>
|
||||||
|
{syncingIntel ? "更新中…" : "強制更新"}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(intel?.dataHealth?.notes || []).length ? (
|
||||||
|
<ul className="intel-health-notes">
|
||||||
|
{intel!.dataHealth!.notes!.map((n, i) => (
|
||||||
|
<li key={i}>{n}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<AiSummaryCard symbol={symbol} scope="company" title="AI 公司總結(MCP + 今日快取)" aiFocus={aiFocus} />
|
||||||
|
|
||||||
|
<Card className="mt" title="公司介紹" ico={<AppIcon name="world" size={22} framed variant="hero" />} ai aiFocus={{ ...aiFocus, cardTitle: "公司介紹" }}>
|
||||||
|
<div className="intel-tags">
|
||||||
|
{intel?.management?.sector ? <Tag tone="cool">{intel.management.sector}</Tag> : null}
|
||||||
|
{intel?.management?.industry ? <Tag tone="gold">{intel.management.industry}</Tag> : null}
|
||||||
|
{intel?.profileZh?.businessModel ? <Tag tone="gold">{intel.profileZh.businessModel}</Tag> : null}
|
||||||
|
</div>
|
||||||
|
<p className="company-blurb">{profileDesc || "尚無公司簡介,請按「強制更新」。"}</p>
|
||||||
|
<div className="interpret-box">
|
||||||
|
<div className="interpret-label">解讀</div>
|
||||||
|
<p>{profileInterp}</p>
|
||||||
|
</div>
|
||||||
|
{intel?.management?.website ? (
|
||||||
|
<p className="small muted mt-s">
|
||||||
|
<a href={intel.management.website} target="_blank" rel="noopener noreferrer">
|
||||||
|
官網
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="mt" title="產業上下游" ico={<AppIcon name="compass" size={22} framed variant="hero" />} ai aiFocus={{ ...aiFocus, cardTitle: "產業鏈" }}>
|
||||||
|
<div className="chain-map">
|
||||||
|
<div className="chain-col chain-col--up">
|
||||||
|
<b>上游 · 供應商</b>
|
||||||
|
{chainColHtml(chain.upstreamDetail || chain.upstream?.map((u: string) => ({ label: u, entities: [u] })))}
|
||||||
|
</div>
|
||||||
|
<div className="chain-col chain-col--down">
|
||||||
|
<b>下游 · 誰買他們的產品</b>
|
||||||
|
{chainColHtml(chain.downstreamDetail || chain.downstream?.map((d: string) => ({ label: d, entities: [d] })))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{chain.tenKExcerpt ? (
|
||||||
|
<p className="chain-excerpt small muted mt-s">
|
||||||
|
<b>10-K 摘錄:</b>
|
||||||
|
{chain.tenKExcerpt}
|
||||||
|
{chain.tenKExcerpt.length >= 400 ? "…" : ""}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{(intel?.resources || []).length ? (
|
||||||
|
<div className="intel-resource-links mt-s">
|
||||||
|
{(intel?.resources || []).slice(0, 6).map((r, i) => (
|
||||||
|
<a key={i} href={r.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{r.labelZh || r.label || "連結"}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="mt" title="經營管理層" ico={<AppIcon name="scroll" size={22} framed variant="hero" />} ai aiFocus={{ ...aiFocus, cardTitle: "管理層" }}>
|
||||||
|
<p className="small muted">來源:{intel?.management?.source || "Yahoo / SEC"}</p>
|
||||||
|
{officers.length ? (
|
||||||
|
<div className="officer-grid">
|
||||||
|
{officers.map((o) => (
|
||||||
|
<div key={o.name} className="officer-card">
|
||||||
|
<b>{o.name}</b>
|
||||||
|
<span>{o.titleDisplay || o.titleZh || o.title || ""}</span>
|
||||||
|
{o.totalPay != null ? <small>總薪酬 ${fmtNum(o.totalPay / 1e6, 1)}M</small> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="muted">管理層名單載入中或未取得,請按「強制更新」。</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="mt" title="內部人持股/賣股" ico={<AppIcon name="cards" size={22} framed variant="hero" />} ai aiFocus={{ ...aiFocus, cardTitle: "內部人交易" }}>
|
||||||
|
{holders?.breakdown?.insidersPct != null ? (
|
||||||
|
<p className="small muted mb-s">流通持股中內部人約 {fmtNum(holders.breakdown.insidersPct, 1)}%(Yahoo 統計)</p>
|
||||||
|
) : null}
|
||||||
|
<div className="insider-summary">
|
||||||
|
<div className={insiderInterp.acquiredCount ? "good" : ""}>
|
||||||
|
<b>{insiderInterp.acquiredCount}</b>
|
||||||
|
<span>近期偏買進</span>
|
||||||
|
</div>
|
||||||
|
<div className={insiderInterp.disposedCount ? "bad" : ""}>
|
||||||
|
<b>{insiderInterp.disposedCount}</b>
|
||||||
|
<span>近期偏賣出</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="interpret-box">
|
||||||
|
<div className="interpret-label">解讀</div>
|
||||||
|
<p className={insiderInterp.tone}>{insiderInterp.summary}</p>
|
||||||
|
{insiderInterp.bullets.length ? (
|
||||||
|
<ul className="interpret-bullets">
|
||||||
|
{insiderInterp.bullets.map((b, i) => (
|
||||||
|
<li key={i}>{b}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="small muted">SEC Form 4:A=取得、D=處分。美股才有申報;台股請搭配籌碼分頁。</p>
|
||||||
|
{intel?.insiders?.length ? (
|
||||||
|
<div className="insider-tx-grid">
|
||||||
|
{intel.insiders.map((t, i) => {
|
||||||
|
const sig = insiderSignalLabel(t);
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
className={`insider-row insider-row--${sig.tone}`}
|
||||||
|
href={t.url || "#"}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => !t.url && e.preventDefault()}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<b>{t.owner || "內部人"}</b>
|
||||||
|
<span>{t.title || ""}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<b className={sig.tone}>{sig.label}</b>
|
||||||
|
<span>{t.reportDate || t.filingDate || ""}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<b>
|
||||||
|
{fmtShares(t.acquired)} / {fmtShares(t.disposed)}
|
||||||
|
</b>
|
||||||
|
<span>取得 / 處分</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="muted">近期沒有抓到 Form 4 申報。</p>
|
||||||
|
)}
|
||||||
|
{(holders?.insiders || []).length ? (
|
||||||
|
<div className="mt-s">
|
||||||
|
<h4 className="small" style={{ color: "var(--gold-soft)" }}>
|
||||||
|
主要內部人持股(Yahoo)
|
||||||
|
</h4>
|
||||||
|
<ul className="insider-holder-list">
|
||||||
|
{holders!.insiders!.slice(0, 8).map((h, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
{h.name}
|
||||||
|
{h.relation ? ` · ${h.relation}` : ""}
|
||||||
|
{h.pctHeld != null ? ` · ${fmtNum(h.pctHeld, 2)}%` : ""}
|
||||||
|
{h.shares != null ? ` · ${fmtShares(h.shares)} 股` : ""}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="mt" title="經營層動態" ico={<AppIcon name="calendar" size={22} framed variant="hero" />} ai aiFocus={{ ...aiFocus, cardTitle: "經營層動態" }}>
|
||||||
|
{mgmtBrief.length ? (
|
||||||
|
<div className="mgmt-brief-list">
|
||||||
|
{mgmtBrief.map((m, i) => (
|
||||||
|
<div key={i} className={`mgmt-brief-row mgmt-brief--${impactTone(m.impact)}`}>
|
||||||
|
<div>
|
||||||
|
<b>{m.headline}</b>
|
||||||
|
<small>
|
||||||
|
{m.date || ""} · {m.source || ""}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<p>{m.summary}</p>
|
||||||
|
{m.url ? (
|
||||||
|
<a href={m.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
原文
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="muted">尚無管理層相關新聞摘要。</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className="mt"
|
||||||
|
title="重要申報與財報(10-K / 10-Q)"
|
||||||
|
ico={<AppIcon name="scroll" size={22} framed variant="hero" />}
|
||||||
|
ai
|
||||||
|
aiFocus={{ ...aiFocus, cardTitle: "SEC 申報" }}
|
||||||
|
onRefresh={() => onSyncSec(false)}
|
||||||
|
refreshing={secLoading}
|
||||||
|
refreshLabel="重新整理清單"
|
||||||
|
>
|
||||||
|
<div className="interpret-box">
|
||||||
|
<div className="interpret-label">解讀</div>
|
||||||
|
<p>{filingInterp.summary}</p>
|
||||||
|
{filingInterp.bullets.length ? (
|
||||||
|
<ul className="interpret-bullets">
|
||||||
|
{filingInterp.bullets.map((b, i) => (
|
||||||
|
<li key={i}>{b}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="sec-archive-actions">
|
||||||
|
<button type="button" className="btn-primary" disabled={syncingSec} onClick={() => onSyncSec(true)}>
|
||||||
|
{syncingSec ? "封存中…" : "抓取並封存到本機"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="small muted">
|
||||||
|
{secArchive?.meta?.lastSyncAt
|
||||||
|
? `已封存 ${filings.length} 筆 · 上次 ${new Date(secArchive.meta.lastSyncAt).toLocaleString("zh-TW")}`
|
||||||
|
: filings.length
|
||||||
|
? `本機 ${filings.length} 筆`
|
||||||
|
: "尚未同步"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{secLoading && !filings.length ? <Loading /> : null}
|
||||||
|
<div className="sec-archive-block mt-s">
|
||||||
|
<h4>SEC 重要申報</h4>
|
||||||
|
<div className="sec-filing-list">
|
||||||
|
{filings.length ? (
|
||||||
|
filings.map((f) => {
|
||||||
|
const local = f.localPrimary || f.localTxt;
|
||||||
|
const localUrl = secArchiveLocalUrl(symbol, f.accession, local);
|
||||||
|
return (
|
||||||
|
<div key={f.accession} className={`sec-filing-row${f.isEarningsRelated ? " sec-filing-row--earn" : ""}`}>
|
||||||
|
<div className="sec-filing-main">
|
||||||
|
<b>
|
||||||
|
{f.formZh || f.form}
|
||||||
|
<span className="sec-filing-form">{f.form}</span>
|
||||||
|
</b>
|
||||||
|
<small>
|
||||||
|
申報 {f.filedDate || "—"}
|
||||||
|
{f.reportDate && f.reportDate !== f.filedDate ? ` · 報告日 ${f.reportDate}` : ""}
|
||||||
|
</small>
|
||||||
|
{f.description ? <p>{String(f.description).slice(0, 160)}</p> : null}
|
||||||
|
{f.excerpt ? <p className="sec-filing-excerpt">{f.excerpt.slice(0, 280)}…</p> : null}
|
||||||
|
</div>
|
||||||
|
<div className="sec-filing-links">
|
||||||
|
{localUrl ? (
|
||||||
|
<a className="btn-ghost" href={localUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
本機已存
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="sec-missing">未下載</span>
|
||||||
|
)}
|
||||||
|
{f.url ? (
|
||||||
|
<a href={f.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
SEC 線上
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<p className="muted">尚無封存申報。美股可按上方按鈕從 SEC 下載。</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{earnings.length ? (
|
||||||
|
<div className="sec-archive-block mt">
|
||||||
|
<h4>財報日與法說相關</h4>
|
||||||
|
<div className="sec-earn-list">
|
||||||
|
{earnings.slice(0, 8).map((e, i) => (
|
||||||
|
<div key={i} className="sec-earn-row">
|
||||||
|
<div>
|
||||||
|
<b>{e.titleZh || e.title}</b>
|
||||||
|
<small>
|
||||||
|
{e.eventDate || ""} {e.timeLabel || ""} · {e.source || ""}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{e.note ? <p>{String(e.note).slice(0, 200)}</p> : null}
|
||||||
|
<div className="sec-filing-links">
|
||||||
|
{e.url ? (
|
||||||
|
<a href={e.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
連結
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
{e.transcriptSearchUrl ? (
|
||||||
|
<a href={e.transcriptSearchUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
投資人關係
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className="mt"
|
||||||
|
title="相關新聞"
|
||||||
|
ico={<AppIcon name="calendar" size={22} framed variant="hero" />}
|
||||||
|
ai
|
||||||
|
aiFocus={{ ...aiFocus, cardTitle: "公司新聞" }}
|
||||||
|
onRefresh={onReloadIntel}
|
||||||
|
refreshing={intelReloading}
|
||||||
|
>
|
||||||
|
<div className="news-tabs">
|
||||||
|
<button type="button" className={`news-tab ${newsTab === "tw" ? "active" : ""}`} onClick={() => setNewsTab("tw")}>
|
||||||
|
台灣 ({newsTw.length})
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`news-tab ${newsTab === "global" ? "active" : ""}`} onClick={() => setNewsTab("global")}>
|
||||||
|
國際 ({newsGlobal.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<NewsPanel items={newsTab === "tw" ? newsTw : newsGlobal} />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewsPanel({ items }: { items: { title?: string; titleZh?: string; link?: string; url?: string; source?: string; publisher?: string; publishedAt?: string }[] }) {
|
||||||
|
if (!items.length) return <p className="muted mt-s">暫無新聞</p>;
|
||||||
|
return (
|
||||||
|
<ul className="news-list mt-s">
|
||||||
|
{items.map((n, i) => {
|
||||||
|
const href = n.link || n.url;
|
||||||
|
const title = n.titleZh || n.title;
|
||||||
|
return (
|
||||||
|
<li key={i}>
|
||||||
|
{href ? (
|
||||||
|
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||||
|
{title}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span>{title}</span>
|
||||||
|
)}
|
||||||
|
<span className="news-meta">
|
||||||
|
{n.source || n.publisher ? `${n.source || n.publisher} · ` : ""}
|
||||||
|
{n.publishedAt ? n.publishedAt.slice(0, 16).replace("T", " ") : ""}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
/** 編年史羅盤裝飾圖(HD-2D 像素風,取代 emoji) */
|
||||||
|
|
||||||
|
export default function CompassArt({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={"compass-art" + (className ? " " + className : "")} aria-hidden>
|
||||||
|
<svg viewBox="0 0 120 120" className="compass-art-svg">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="compassGlow" cx="50%" cy="45%" r="55%">
|
||||||
|
<stop offset="0%" stopColor="rgba(231,198,107,.35)" />
|
||||||
|
<stop offset="100%" stopColor="rgba(10,13,24,0)" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="60" cy="60" r="56" fill="url(#compassGlow)" />
|
||||||
|
<rect x="8" y="8" width="104" height="104" fill="#12172e" stroke="#e7c66b" strokeWidth="3" rx="4" />
|
||||||
|
<rect x="14" y="14" width="92" height="92" fill="#0a0d18" stroke="rgba(231,198,107,.35)" strokeWidth="2" />
|
||||||
|
{/* 刻度 */}
|
||||||
|
{[0, 45, 90, 135, 180, 225, 270, 315].map((deg) => (
|
||||||
|
<line
|
||||||
|
key={deg}
|
||||||
|
x1="60"
|
||||||
|
y1="18"
|
||||||
|
x2="60"
|
||||||
|
y2="26"
|
||||||
|
stroke="rgba(231,198,107,.5)"
|
||||||
|
strokeWidth="2"
|
||||||
|
transform={`rotate(${deg} 60 60)`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* 指針 */}
|
||||||
|
<polygon points="60,24 66,58 60,52 54,58" fill="#f3dd96" stroke="#b9912f" strokeWidth="1" />
|
||||||
|
<polygon points="60,96 66,62 60,68 54,62" fill="#6fe0d0" stroke="#3a9e92" strokeWidth="1" />
|
||||||
|
<polygon points="24,60 58,54 52,60 58,66" fill="#8fb8ff" stroke="#5a7ec4" strokeWidth="1" />
|
||||||
|
<polygon points="96,60 62,66 68,60 62,54" fill="#e86a52" stroke="#b84a38" strokeWidth="1" />
|
||||||
|
<circle cx="60" cy="60" r="9" fill="#161b34" stroke="#e7c66b" strokeWidth="2.5" />
|
||||||
|
<circle cx="60" cy="60" r="3" fill="#e7c66b" />
|
||||||
|
{/* 方位字 */}
|
||||||
|
<text x="60" y="20" textAnchor="middle" fill="#e7c66b" fontSize="9" fontFamily="Pixelify Sans, monospace">
|
||||||
|
N
|
||||||
|
</text>
|
||||||
|
<text x="60" y="112" textAnchor="middle" fill="#6fe0d0" fontSize="8" fontFamily="Pixelify Sans, monospace">
|
||||||
|
S
|
||||||
|
</text>
|
||||||
|
<text x="14" y="63" textAnchor="middle" fill="#8fb8ff" fontSize="8" fontFamily="Pixelify Sans, monospace">
|
||||||
|
W
|
||||||
|
</text>
|
||||||
|
<text x="106" y="63" textAnchor="middle" fill="#e86a52" fontSize="8" fontFamily="Pixelify Sans, monospace">
|
||||||
|
E
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import { ColorType, LineSeries, createChart, type IChartApi, type ISeriesApi } from "lightweight-charts";
|
||||||
|
|
||||||
|
export interface EquityPoint {
|
||||||
|
date: string;
|
||||||
|
val: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EquitySeries {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
points: EquityPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTime(date: string) {
|
||||||
|
return (Date.parse(date + "T00:00:00Z") / 1000) as import("lightweight-charts").UTCTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EquityChart({ series, heightClass = "equity-chart" }: { series: EquitySeries[]; heightClass?: string }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const chartRef = useRef<IChartApi | null>(null);
|
||||||
|
const lineRefs = useRef<ISeriesApi<"Line">[]>([]);
|
||||||
|
|
||||||
|
const clean = useMemo(
|
||||||
|
() =>
|
||||||
|
series
|
||||||
|
.map((s) => ({
|
||||||
|
...s,
|
||||||
|
points: (s.points || []).filter((p) => p.date && p.val != null && !Number.isNaN(p.val)),
|
||||||
|
}))
|
||||||
|
.filter((s) => s.points.length > 1),
|
||||||
|
[series],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const chart = createChart(ref.current, {
|
||||||
|
layout: { background: { type: ColorType.Solid, color: "transparent" }, textColor: "#e8ecf4", fontSize: 11 },
|
||||||
|
grid: { vertLines: { color: "rgba(255,255,255,.06)" }, horzLines: { color: "rgba(255,255,255,.06)" } },
|
||||||
|
rightPriceScale: { borderColor: "rgba(231,198,107,.25)" },
|
||||||
|
timeScale: { borderColor: "rgba(231,198,107,.25)", timeVisible: true },
|
||||||
|
autoSize: true,
|
||||||
|
});
|
||||||
|
chartRef.current = chart;
|
||||||
|
lineRefs.current = clean.map((s) =>
|
||||||
|
chart.addSeries(LineSeries, {
|
||||||
|
color: s.color,
|
||||||
|
lineWidth: 2,
|
||||||
|
priceLineVisible: false,
|
||||||
|
lastValueVisible: true,
|
||||||
|
title: s.name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
chart.remove();
|
||||||
|
chartRef.current = null;
|
||||||
|
lineRefs.current = [];
|
||||||
|
};
|
||||||
|
}, [clean.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clean.forEach((s, i) => {
|
||||||
|
const line = lineRefs.current[i];
|
||||||
|
if (!line) return;
|
||||||
|
line.setData(s.points.map((p) => ({ time: toTime(p.date), value: p.val })));
|
||||||
|
});
|
||||||
|
chartRef.current?.timeScale().fitContent();
|
||||||
|
}, [clean]);
|
||||||
|
|
||||||
|
if (!clean.length) return <p className="muted">無權益曲線資料</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="equity-wrap">
|
||||||
|
<div className="candle-legend">
|
||||||
|
{clean.map((s) => (
|
||||||
|
<span key={s.name} className="candle-legend-item">
|
||||||
|
<i style={{ background: s.color }} />
|
||||||
|
{s.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div ref={ref} className={heightClass} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import type { SimilarEra, SimilarDetail } from "../lib/api";
|
||||||
|
import { Tag } from "./ui";
|
||||||
|
import IndicatorCompareBars from "./IndicatorCompareBars";
|
||||||
|
import SeriesChart from "./SeriesChart";
|
||||||
|
|
||||||
|
export default function EraDetailPanel({
|
||||||
|
similar,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
similar: SimilarEra;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open || !similar.detail) return null;
|
||||||
|
const d: SimilarDetail = similar.detail;
|
||||||
|
const title = similar.regimeLabel
|
||||||
|
? `${similar.date}(${similar.regimeLabel})`
|
||||||
|
: similar.date;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="era-overlay" onClick={onClose} role="presentation">
|
||||||
|
<div
|
||||||
|
className="era-panel"
|
||||||
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="era-panel-title"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="era-panel-head">
|
||||||
|
<div>
|
||||||
|
<div className="era-panel-eyebrow">編年史檔案 · 歷史相似期</div>
|
||||||
|
<h2 id="era-panel-title" className="era-panel-title">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<div className="era-panel-meta">
|
||||||
|
相似度距離 <b>{similar.distance?.toFixed(2)}</b>
|
||||||
|
{d.asOfDate && <span> 對照基準日 {d.asOfDate}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="era-close" onClick={onClose} aria-label="關閉">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="era-panel-body">
|
||||||
|
<section className="era-section">
|
||||||
|
<h3>為什麼像?</h3>
|
||||||
|
<p>{d.whySimilar}</p>
|
||||||
|
{(d.topDrivers?.length ?? 0) > 0 && (
|
||||||
|
<div className="era-chips">
|
||||||
|
{d.topDrivers!.map((t) => (
|
||||||
|
<Tag key={t} tone="gold">
|
||||||
|
{t}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="era-section">
|
||||||
|
<h3>指標對照 · 現在 vs 當時</h3>
|
||||||
|
<p className="small muted">橫條代表各指標在長期歷史中的標準化位置(z-score);越接近代表越像。</p>
|
||||||
|
<IndicatorCompareBars rows={d.comparisons || []} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{d.market && (
|
||||||
|
<section className="era-section">
|
||||||
|
<h3>當時股市怎麼走?(S&P 500)</h3>
|
||||||
|
<div className="era-market-grid">
|
||||||
|
<MarketStat label="當時水準" value={d.market.levelAt != null ? String(d.market.levelAt) : "—"} />
|
||||||
|
<MarketStat
|
||||||
|
label="前 3 個月"
|
||||||
|
value={fmtRet(d.market.ret3mBefore)}
|
||||||
|
tone={retTone(d.market.ret3mBefore)}
|
||||||
|
/>
|
||||||
|
<MarketStat
|
||||||
|
label="後 6 個月"
|
||||||
|
value={fmtRet(d.market.ret6mAfter)}
|
||||||
|
tone={retTone(d.market.ret6mAfter)}
|
||||||
|
/>
|
||||||
|
<MarketStat
|
||||||
|
label="後 12 個月"
|
||||||
|
value={fmtRet(d.market.ret12mAfter)}
|
||||||
|
tone={retTone(d.market.ret12mAfter)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{d.market.trendLabel && (
|
||||||
|
<p className="era-trend">
|
||||||
|
一年後走勢歸納:<Tag tone={retTone(d.market.ret12mAfter)}>{d.market.trendLabel}</Tag>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{d.market.chart?.length ? (
|
||||||
|
<div className="chart-panel era-chart">
|
||||||
|
<SeriesChart data={d.market.chart} color="#6fe0d0" />
|
||||||
|
<div className="era-chart-cap small muted">相似期前後約 18 個月走勢(垂直線為相似月份)</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<p className="small muted">歷史表現僅供參照,不代表未來會重複。</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{d.episode && (
|
||||||
|
<section className="era-section era-episode">
|
||||||
|
<h3>歷史案例 · {d.episode.title}</h3>
|
||||||
|
<div className="small muted">{d.episode.period}</div>
|
||||||
|
<p>{d.episode.summary}</p>
|
||||||
|
{d.episode.lesson && (
|
||||||
|
<blockquote className="era-lesson">
|
||||||
|
<span className="era-lesson-k">啟示</span>
|
||||||
|
{d.episode.lesson}
|
||||||
|
</blockquote>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(d.events?.length ?? 0) > 0 && (
|
||||||
|
<section className="era-section">
|
||||||
|
<h3>當時附近的大事件</h3>
|
||||||
|
<ul className="era-events">
|
||||||
|
{d.events!.map((e) => (
|
||||||
|
<li key={e.date + e.label}>
|
||||||
|
<span className="era-ev-date">{e.date}</span>
|
||||||
|
<span className="era-ev-label">{e.label}</span>
|
||||||
|
<span className="era-ev-timing muted small">{e.timing}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarketStat({ label, value, tone }: { label: string; value: string; tone?: "up" | "down" | "cool" }) {
|
||||||
|
return (
|
||||||
|
<div className="era-mstat">
|
||||||
|
<div className="era-mstat-k">{label}</div>
|
||||||
|
<div className={"era-mstat-v" + (tone ? " " + tone : "")}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtRet(v: number | null | undefined) {
|
||||||
|
if (v == null) return "—";
|
||||||
|
return `${v >= 0 ? "+" : ""}${v}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function retTone(v: number | null | undefined): "up" | "down" | "cool" | undefined {
|
||||||
|
if (v == null) return "cool";
|
||||||
|
if (v >= 3) return "up";
|
||||||
|
if (v <= -3) return "down";
|
||||||
|
return "cool";
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,644 @@
|
||||||
|
import { AppIcon } from "./PixelIcons";
|
||||||
|
import { Card, Loading, RefreshButton } from "./ui";
|
||||||
|
import {
|
||||||
|
type AnalystConsensus,
|
||||||
|
type CongressTradeRow,
|
||||||
|
type HolderMeta,
|
||||||
|
type HolderRow,
|
||||||
|
type HoldersPayload,
|
||||||
|
type InstitutionalFlowPayload,
|
||||||
|
type InstitutionalDay,
|
||||||
|
type EtfHolderRow,
|
||||||
|
type NotableFundManagerRow,
|
||||||
|
type NotableHoldersPayload,
|
||||||
|
} from "../lib/api";
|
||||||
|
import { fmtMoneyShort, fmtNum } from "../lib/stockTechnical";
|
||||||
|
|
||||||
|
function fmtShares(n: number | null | undefined) {
|
||||||
|
if (n == null || !Number.isFinite(n)) return "—";
|
||||||
|
const abs = Math.abs(n);
|
||||||
|
const sign = n < 0 ? "-" : "";
|
||||||
|
if (abs >= 1e8) return `${sign}${(abs / 1e8).toFixed(2)}億`;
|
||||||
|
if (abs >= 1e4) return `${sign}${(abs / 1e4).toFixed(1)}萬`;
|
||||||
|
return `${sign}${abs.toLocaleString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso?: string | null) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = iso.slice(0, 10);
|
||||||
|
return d.length === 10 ? d : iso;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlowBar({ day }: { day: InstitutionalDay }) {
|
||||||
|
const rows = [
|
||||||
|
{ key: "foreign", label: "外資", data: day.foreign },
|
||||||
|
{ key: "trust", label: "投信", data: day.trust },
|
||||||
|
{ key: "dealer", label: "自營", data: day.dealer },
|
||||||
|
] as const;
|
||||||
|
const maxAbs = Math.max(
|
||||||
|
...rows.map((r) => Math.abs(r.data.net || 0)),
|
||||||
|
Math.abs(day.total?.net || 0),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="flow-day">
|
||||||
|
<div className="flow-date">{day.date}</div>
|
||||||
|
{rows.map((r) => {
|
||||||
|
const net = r.data.net || 0;
|
||||||
|
const pct = Math.min(100, (Math.abs(net) / maxAbs) * 100);
|
||||||
|
return (
|
||||||
|
<div className="flow-bar-row" key={r.key}>
|
||||||
|
<span className="flow-bar-label">{r.label}</span>
|
||||||
|
<div className="flow-bar-track">
|
||||||
|
<div className={`flow-bar-fill ${net >= 0 ? "up" : "down"}`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className={`flow-bar-val ${net >= 0 ? "up" : "down"}`}>{fmtShares(net)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="flow-total">
|
||||||
|
合計淨買賣:<strong className={day.total.net >= 0 ? "up" : "down"}>{fmtShares(day.total.net)}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FreshnessStrip({ meta, holders, flow }: { meta?: HolderMeta | null; holders?: HoldersPayload | null; flow?: InstitutionalFlowPayload | null }) {
|
||||||
|
const m = meta || holders?.meta || flow?.meta;
|
||||||
|
if (!m && !holders?.cached && !flow?.cached) return null;
|
||||||
|
return (
|
||||||
|
<div className="flow-freshness">
|
||||||
|
<div className="flow-freshness-row">
|
||||||
|
<span className="flow-freshness-label">資料狀態</span>
|
||||||
|
<span className={`flow-freshness-badge ${m?.isPartial ? "partial" : ""}`}>
|
||||||
|
{m?.freshnessLabel || (holders?.cached || flow?.cached ? "快取" : "即時")}
|
||||||
|
</span>
|
||||||
|
{m?.fetchedAt ? <span>抓取 {fmtDate(m.fetchedAt)}</span> : null}
|
||||||
|
{m?.dataAsOf ? <span>申報截至 <strong>{fmtDate(m.dataAsOf)}</strong></span> : null}
|
||||||
|
{m?.sources?.length ? <span>來源 {m.sources.join("、")}</span> : null}
|
||||||
|
</div>
|
||||||
|
{m?.lagNote ? <p className="small muted flow-lag-note">{m.lagNote}</p> : null}
|
||||||
|
{m?.etfScanNote ? <p className="small muted">{m.etfScanNote}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnalystCard({ analyst }: { analyst?: AnalystConsensus | null }) {
|
||||||
|
if (!analyst) return null;
|
||||||
|
const r = analyst.ratings;
|
||||||
|
const total = analyst.ratingTotal || (r ? r.strongBuy + r.buy + r.hold + r.sell + r.strongSell : 0);
|
||||||
|
const segments = r
|
||||||
|
? [
|
||||||
|
{ key: "strongBuy", label: "強買", count: r.strongBuy, cls: "bull-strong" },
|
||||||
|
{ key: "buy", label: "買進", count: r.buy, cls: "bull" },
|
||||||
|
{ key: "hold", label: "持有", count: r.hold, cls: "neutral" },
|
||||||
|
{ key: "sell", label: "賣出", count: r.sell, cls: "bear" },
|
||||||
|
{ key: "strongSell", label: "強賣", count: r.strongSell, cls: "bear-strong" },
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="分析師共識" ico={<AppIcon name="coin" size={22} framed variant="hero" />}>
|
||||||
|
{analyst.unavailable && !r ? (
|
||||||
|
<p className="small muted">分析師評等暫不可用(Yahoo 限流或無覆蓋)。目標價可參考財報分頁。</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="analyst-verdict-row">
|
||||||
|
<span className={`analyst-verdict analyst-verdict--${analyst.tone || "neutral"}`}>{analyst.verdict}</span>
|
||||||
|
{analyst.targetMean != null ? (
|
||||||
|
<span className="analyst-target">
|
||||||
|
目標價 <strong>{fmtMoneyShort(analyst.targetMean)}</strong>
|
||||||
|
{analyst.analystCount != null ? `(${analyst.analystCount} 位分析師)` : ""}
|
||||||
|
{analyst.upsidePct != null ? (
|
||||||
|
<span className={analyst.upsidePct >= 0 ? "up" : "down"}>
|
||||||
|
{" "}
|
||||||
|
{analyst.upsidePct >= 0 ? "+" : ""}
|
||||||
|
{fmtNum(analyst.upsidePct, 1)}% vs 現價
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{total > 0 && r ? (
|
||||||
|
<div className="analyst-rating-bar" aria-label="分析師評等分布">
|
||||||
|
{segments.map((s) =>
|
||||||
|
s.count > 0 ? (
|
||||||
|
<div
|
||||||
|
key={s.key}
|
||||||
|
className={`analyst-rating-seg ${s.cls}`}
|
||||||
|
style={{ flexGrow: s.count }}
|
||||||
|
title={`${s.label} ${s.count}`}
|
||||||
|
>
|
||||||
|
<span>{s.label}</span>
|
||||||
|
<strong>{s.count}</strong>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{analyst.targetLow != null && analyst.targetHigh != null ? (
|
||||||
|
<p className="small muted mt-s">
|
||||||
|
共識目標區間 {fmtMoneyShort(analyst.targetLow)} – {fmtMoneyShort(analyst.targetHigh)}
|
||||||
|
{analyst.source ? ` · ${analyst.source}` : ""}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{analyst.recentActions?.length ? (
|
||||||
|
<div className="analyst-actions-wrap mt">
|
||||||
|
<h4 className="analyst-actions-title">近期分析師動作(機構 / 建議)</h4>
|
||||||
|
<div className="fund-table-scroll">
|
||||||
|
<table className="fin-table compact analyst-actions-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>日期</th>
|
||||||
|
<th>機構</th>
|
||||||
|
<th>建議</th>
|
||||||
|
<th>評等變化</th>
|
||||||
|
<th>目標價</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{analyst.recentActions.slice(0, 18).map((a, i) => (
|
||||||
|
<tr key={`${a.firm}-${a.date}-${i}`}>
|
||||||
|
<td className="muted small">{fmtDate(a.date)}</td>
|
||||||
|
<td className="analyst-firm">{a.firm}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`analyst-action analyst-action--${a.tone || "neutral"}`}>
|
||||||
|
{a.actionLabel || "—"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="muted small">{a.gradeChange || a.toGrade || "—"}</td>
|
||||||
|
<td>{a.priceTarget != null ? fmtMoneyShort(a.priceTarget) : "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p className="small muted mt-s">
|
||||||
|
單筆目標價僅在該次評等公告有揭露時顯示;共識目標價見上方。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : !analyst.unavailable ? (
|
||||||
|
<p className="small muted mt-s">暫無個別機構調升/調降紀錄(Yahoo 限流時可稍後重試)。</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<p className="small muted mt-s">{analyst.disclaimer}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TierBadge({ tier, label }: { tier?: string; label?: string }) {
|
||||||
|
if (!tier || tier === "other") return null;
|
||||||
|
return <span className={`holder-tier holder-tier--${tier}`}>{label || tier}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstitutionTable({ rows, title }: { rows: HolderRow[]; title: string }) {
|
||||||
|
if (!rows.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="flow-inst-block">
|
||||||
|
<h4>{title}</h4>
|
||||||
|
<div className="fund-table-scroll">
|
||||||
|
<table className="fin-table compact flow-inst-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>機構</th>
|
||||||
|
<th>規模</th>
|
||||||
|
<th>占比</th>
|
||||||
|
<th>股數</th>
|
||||||
|
<th>市值</th>
|
||||||
|
<th>申報日</th>
|
||||||
|
<th>變化</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((h, i) => (
|
||||||
|
<tr key={`${h.organization}-${i}`}>
|
||||||
|
<td className="muted">{h.rank ?? i + 1}</td>
|
||||||
|
<td className="flow-inst-name">{h.organization}</td>
|
||||||
|
<td>
|
||||||
|
<TierBadge tier={h.tier} label={h.tierLabel} />
|
||||||
|
</td>
|
||||||
|
<td>{h.pctHeld != null ? `${fmtNum(h.pctHeld, 2)}%` : "—"}</td>
|
||||||
|
<td>{h.shares != null ? fmtShares(h.shares) : "—"}</td>
|
||||||
|
<td>{h.value != null ? fmtMoneyShort(h.value) : "—"}</td>
|
||||||
|
<td className="muted small">{fmtDate(h.reportDate)}</td>
|
||||||
|
<td className={`small ${(h.sharesChangePct ?? 0) >= 0 ? "up" : "down"}`}>
|
||||||
|
{h.sharesChangePct != null
|
||||||
|
? `${h.sharesChangePct >= 0 ? "+" : ""}${fmtNum(h.sharesChangePct, 1)}%`
|
||||||
|
: h.sharesChange || "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TagPills({ tags }: { tags?: string[] }) {
|
||||||
|
if (!tags?.length) return null;
|
||||||
|
return (
|
||||||
|
<span className="notable-tags">
|
||||||
|
{tags.map((t) => (
|
||||||
|
<span key={t} className="notable-tag">
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotableFundTable({ rows }: { rows: NotableFundManagerRow[] }) {
|
||||||
|
return (
|
||||||
|
<div className="fund-table-scroll">
|
||||||
|
<table className="fin-table compact flow-notable-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>經理人</th>
|
||||||
|
<th>機構</th>
|
||||||
|
<th>占比</th>
|
||||||
|
<th>股數</th>
|
||||||
|
<th>市值</th>
|
||||||
|
<th>申報日</th>
|
||||||
|
<th>變化</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((h) => (
|
||||||
|
<tr key={h.id}>
|
||||||
|
<td className="notable-person">
|
||||||
|
<strong>{h.nameZh}</strong>
|
||||||
|
<span className="muted small">{h.nameEn}</span>
|
||||||
|
<TagPills tags={h.tags} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div>{h.fundZh}</div>
|
||||||
|
<span className="muted small">{h.organization}</span>
|
||||||
|
</td>
|
||||||
|
<td>{h.pctHeld != null ? `${fmtNum(h.pctHeld, 2)}%` : "—"}</td>
|
||||||
|
<td>{h.shares != null ? fmtShares(h.shares) : "—"}</td>
|
||||||
|
<td>{h.value != null ? fmtMoneyShort(h.value) : "—"}</td>
|
||||||
|
<td className="muted small">{fmtDate(h.reportDate)}</td>
|
||||||
|
<td className={`small ${(h.sharesChangePct ?? "").toString().startsWith("-") ? "down" : "up"}`}>
|
||||||
|
{h.sharesChangePct || h.sharesChange || "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CongressTradeTable({ rows }: { rows: CongressTradeRow[] }) {
|
||||||
|
return (
|
||||||
|
<div className="fund-table-scroll">
|
||||||
|
<table className="fin-table compact flow-notable-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>議員</th>
|
||||||
|
<th>院別</th>
|
||||||
|
<th>交易</th>
|
||||||
|
<th>金額區間</th>
|
||||||
|
<th>交易日期</th>
|
||||||
|
<th>披露</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((t) => (
|
||||||
|
<tr key={t.id}>
|
||||||
|
<td className="notable-person">
|
||||||
|
{t.link ? (
|
||||||
|
<a href={t.link} target="_blank" rel="noopener noreferrer">
|
||||||
|
<strong>{t.nameZh || t.nameEn}</strong>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<strong>{t.nameZh || t.nameEn}</strong>
|
||||||
|
)}
|
||||||
|
{t.nameEn && t.nameZh !== t.nameEn ? <span className="muted small">{t.nameEn}</span> : null}
|
||||||
|
<TagPills tags={t.tags} />
|
||||||
|
</td>
|
||||||
|
<td className="muted small">{t.chamber === "senate" ? "參議院" : t.chamber === "house" ? "眾議院" : "—"}</td>
|
||||||
|
<td>{t.transactionType || "—"}</td>
|
||||||
|
<td>{t.amountLabel || "—"}</td>
|
||||||
|
<td className="muted small">{fmtDate(t.transactionDate)}</td>
|
||||||
|
<td className="muted small">{fmtDate(t.disclosureDate)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotableHoldersCard({
|
||||||
|
notable,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
market,
|
||||||
|
}: {
|
||||||
|
notable?: NotableHoldersPayload | null;
|
||||||
|
loading?: boolean;
|
||||||
|
error?: boolean;
|
||||||
|
market?: string;
|
||||||
|
}) {
|
||||||
|
if (market === "tw") {
|
||||||
|
return (
|
||||||
|
<Card className="mt" title="名人持股" ico={<AppIcon name="world" size={22} framed variant="hero" />}>
|
||||||
|
<p className="small muted">{notable?.disclaimer || "台股暫不支援名人 13F/國會披露查詢。"}</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fundRows = notable?.fundManagers || [];
|
||||||
|
const congressRows = notable?.congressTrades || [];
|
||||||
|
const catalogFm = notable?.catalog?.fundManagers ?? 18;
|
||||||
|
const catalogCm = notable?.catalog?.congressTracked ?? 8;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mt" title={`名人持股(${notable?.notableCount ?? 0})`} ico={<AppIcon name="world" size={22} framed variant="hero" />}>
|
||||||
|
<p className="small muted mb-s flow-notable-explainer">
|
||||||
|
追蹤 <strong>{catalogFm}</strong> 位知名基金經理人/機構(SEC 13F 季報)與 <strong>{catalogCm}</strong> 位活躍國會議員披露。
|
||||||
|
經理人含巴菲特、達里歐、凱西·伍德、阿克曼、貝瑞等;國會含裴洛西、圖伯維爾等。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading ? <Loading label="掃描名人持股…" /> : null}
|
||||||
|
|
||||||
|
{!loading && fundRows.length ? (
|
||||||
|
<div className="flow-notable-block">
|
||||||
|
<h4>知名基金經理人(13F)</h4>
|
||||||
|
<NotableFundTable rows={fundRows} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && congressRows.length ? (
|
||||||
|
<div className="flow-notable-block">
|
||||||
|
<h4>國會議員交易</h4>
|
||||||
|
<CongressTradeTable rows={congressRows.slice(0, 20)} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && !fundRows.length && !congressRows.length ? (
|
||||||
|
<p className="small muted">
|
||||||
|
{error
|
||||||
|
? "名人持股暫時無法載入,請稍後重試。"
|
||||||
|
: "此標的未在追蹤名單的 13F 前 80 大機構中出現,或尚無國會披露紀錄。"}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{notable?.congressPremiumRequired ? (
|
||||||
|
<p className="small muted mt-s" style={{ color: "var(--gold-soft)" }}>
|
||||||
|
國會交易:FMP 需付費方案。可在設定頁填入 <strong>FINNHUB_API_KEY</strong> 啟用 Finnhub 備援。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{notable?.scanNote ? <p className="small muted mt-s">{notable.scanNote}</p> : null}
|
||||||
|
{notable?.disclaimer ? <p className="small muted mt-s">{notable.disclaimer}</p> : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EtfTable({ rows }: { rows: EtfHolderRow[] }) {
|
||||||
|
return (
|
||||||
|
<div className="fund-table-scroll">
|
||||||
|
<table className="fin-table compact flow-etf-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ETF</th>
|
||||||
|
<th>名稱</th>
|
||||||
|
<th>類別</th>
|
||||||
|
<th>成分權重</th>
|
||||||
|
<th>成分截至</th>
|
||||||
|
<th>來源</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((h, i) => (
|
||||||
|
<tr key={`${h.ticker || h.name}-${i}`}>
|
||||||
|
<td>
|
||||||
|
<strong>{h.ticker || "—"}</strong>
|
||||||
|
</td>
|
||||||
|
<td>{h.name}</td>
|
||||||
|
<td className="muted small">{h.category || "—"}</td>
|
||||||
|
<td>
|
||||||
|
{h.weightPct != null
|
||||||
|
? `${fmtNum(h.weightPct, 2)}%`
|
||||||
|
: h.weightFmt || (h.shares != null ? fmtShares(h.shares) : "—")}
|
||||||
|
</td>
|
||||||
|
<td className="muted small">{fmtDate(h.reportDate) || "月報/季報"}</td>
|
||||||
|
<td className="muted small">
|
||||||
|
{h.source === "institution_etf_hint"
|
||||||
|
? "機構推斷"
|
||||||
|
: h.source === "fmp_asset_exposure"
|
||||||
|
? "FMP 反查"
|
||||||
|
: h.source === "yahoo_top_holdings" || h.source === "nasdaq_etf_holdings"
|
||||||
|
? "成分掃描"
|
||||||
|
: h.source === "yahoo_fund_ownership" || h.source === "yahoo_fund_ownership_direct"
|
||||||
|
? "基金持股"
|
||||||
|
: h.source || "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowPanelProps {
|
||||||
|
flow?: InstitutionalFlowPayload | null;
|
||||||
|
holders?: HoldersPayload | null;
|
||||||
|
notable?: NotableHoldersPayload | null;
|
||||||
|
flowLoading?: boolean;
|
||||||
|
holdersLoading?: boolean;
|
||||||
|
notableLoading?: boolean;
|
||||||
|
flowError?: boolean;
|
||||||
|
holdersError?: boolean;
|
||||||
|
notableError?: boolean;
|
||||||
|
chipRefreshing?: boolean;
|
||||||
|
onRefreshChip?: () => void;
|
||||||
|
aiBase?: { symbol: string; name?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FlowPanel({
|
||||||
|
flow,
|
||||||
|
holders,
|
||||||
|
notable,
|
||||||
|
flowLoading,
|
||||||
|
holdersLoading,
|
||||||
|
notableLoading,
|
||||||
|
flowError,
|
||||||
|
holdersError,
|
||||||
|
notableError,
|
||||||
|
chipRefreshing,
|
||||||
|
onRefreshChip,
|
||||||
|
}: FlowPanelProps) {
|
||||||
|
const analyst = flow?.analyst;
|
||||||
|
const meta = flow?.meta || holders?.meta;
|
||||||
|
const bd = flow?.breakdown || holders?.breakdown;
|
||||||
|
const institutions = holders?.institutions?.length ? holders.institutions : flow?.institutions || [];
|
||||||
|
const etfRows = holders?.etfHolders?.length ? holders.etfHolders : flow?.etfHolders || [];
|
||||||
|
const etfCount = holders?.etfCount ?? flow?.etfCount ?? etfRows.length;
|
||||||
|
|
||||||
|
if (flowLoading && holdersLoading) return <Loading label="載入籌碼…" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="backpack-panel flow-panel">
|
||||||
|
<div className="flow-panel-toolbar">
|
||||||
|
<FreshnessStrip meta={meta} holders={holders} flow={flow} />
|
||||||
|
{onRefreshChip ? (
|
||||||
|
<RefreshButton
|
||||||
|
onClick={onRefreshChip}
|
||||||
|
loading={chipRefreshing || holdersLoading || notableLoading}
|
||||||
|
label="重新整理籌碼"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{flow?.market === "us" ? <AnalystCard analyst={analyst} /> : null}
|
||||||
|
|
||||||
|
{flow?.market === "tw" && flow.days?.length ? (
|
||||||
|
<Card title="三大法人買賣超(台股)" ico={<AppIcon name="flow" size={22} framed variant="hero" />}>
|
||||||
|
{flow.summary ? (
|
||||||
|
<div className="flow-summary">
|
||||||
|
<div>
|
||||||
|
<span>外資</span>
|
||||||
|
<strong className={(flow.summary.foreignNet as number) >= 0 ? "up" : "down"}>
|
||||||
|
{fmtShares(flow.summary.foreignNet as number)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>投信</span>
|
||||||
|
<strong className={(flow.summary.trustNet as number) >= 0 ? "up" : "down"}>
|
||||||
|
{fmtShares(flow.summary.trustNet as number)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>自營</span>
|
||||||
|
<strong className={(flow.summary.dealerNet as number) >= 0 ? "up" : "down"}>
|
||||||
|
{fmtShares(flow.summary.dealerNet as number)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>合計</span>
|
||||||
|
<strong className={(flow.summary.totalNet as number) >= 0 ? "up" : "down"}>
|
||||||
|
{fmtShares(flow.summary.totalNet as number)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="flow-days">
|
||||||
|
{flow.days.map((d) => (
|
||||||
|
<FlowBar key={d.date} day={d} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="small muted">{flow.disclaimer}</p>
|
||||||
|
</Card>
|
||||||
|
) : flow?.market === "us" ? (
|
||||||
|
<Card title="持股結構(美股)" ico={<AppIcon name="flow" size={22} framed variant="hero" />}>
|
||||||
|
{!bd ? (
|
||||||
|
flowError && holdersError ? (
|
||||||
|
<p className="small muted">資料源暫時限流,請稍後重試。</p>
|
||||||
|
) : (
|
||||||
|
<p className="small muted">載入持股結構中…</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="holder-breakdown flow-breakdown">
|
||||||
|
<div>
|
||||||
|
<span>機構持股</span>
|
||||||
|
<strong>{bd.institutionsPct != null ? `${fmtNum(bd.institutionsPct, 1)}%` : "—"}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>內部人</span>
|
||||||
|
<strong>{bd.insidersPct != null ? `${fmtNum(bd.insidersPct, 1)}%` : "—"}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>機構家數</span>
|
||||||
|
<strong>{bd.institutionsCount ?? "—"}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>流通股</span>
|
||||||
|
<strong>{bd.floatShares != null ? fmtShares(bd.floatShares) : "—"}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(holders?.partial || holders?.fallback || flow?.partial) ? (
|
||||||
|
<p className="small muted mt-s" style={{ color: "var(--teal)" }}>
|
||||||
|
{holders?.source === "Nasdaq" || holders?.fallback
|
||||||
|
? "機構名單以 Nasdaq 13F 為主;ETF 成分可能需 Nasdaq/Yahoo 掃描。"
|
||||||
|
: "部分欄位來自快取或備援來源。"}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className="small muted">{flow?.disclaimer || holders?.disclaimer}</p>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className="mt"
|
||||||
|
title={`ETF 間接持有(${etfCount})`}
|
||||||
|
ico={<AppIcon name="cards" size={22} framed variant="hero" />}
|
||||||
|
>
|
||||||
|
<p className="small muted mb-s flow-etf-explainer">
|
||||||
|
ETF 成分為<strong>公開月報/季報</strong>,不是每日持股。系統優先用 FMP 反查(設定頁填 FMP_API_KEY 可取得完整清單,RKLB 約 200+ 檔),
|
||||||
|
並掃描 UFO、NASA、ARKX 等太空/國防主題 ETF,加上 QQQ、IWM、SMH 等大盤/產業 ETF 成分。
|
||||||
|
</p>
|
||||||
|
{etfRows.length ? (
|
||||||
|
<>
|
||||||
|
<EtfTable rows={etfRows.slice(0, 20)} />
|
||||||
|
<p className="small muted mt-s">{holders?.etfDisclaimer || flow?.etfDisclaimer}</p>
|
||||||
|
</>
|
||||||
|
) : holdersLoading ? (
|
||||||
|
<Loading label="掃描 ETF 成分…" />
|
||||||
|
) : holdersError ? (
|
||||||
|
<p className="small muted">ETF 掃描暫時失敗(常見為 Yahoo 429)。機構 13F 仍可參考上方表格。</p>
|
||||||
|
) : (
|
||||||
|
<p className="small muted">未在掃描的熱門 ETF 中找到此股;可稍後重試或查看機構名單中的 Vanguard/BlackRock 等指數基金。</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<NotableHoldersCard
|
||||||
|
notable={notable}
|
||||||
|
loading={notableLoading}
|
||||||
|
error={notableError}
|
||||||
|
market={flow?.market || holders?.market}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{institutions.length || holders?.funds?.length ? (
|
||||||
|
<Card className="mt" title="主要機構持股" ico={<AppIcon name="world" size={22} framed variant="hero" />}>
|
||||||
|
<div className="flow-inst-layout">
|
||||||
|
<InstitutionTable rows={institutions.slice(0, 12)} title="機構(13F 季報)" />
|
||||||
|
{holders?.funds?.length ? (
|
||||||
|
<div className="flow-inst-block">
|
||||||
|
<h4>共同基金</h4>
|
||||||
|
<div className="fund-table-scroll">
|
||||||
|
<table className="fin-table compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>名稱</th>
|
||||||
|
<th>占比</th>
|
||||||
|
<th>申報日</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{holders.funds.slice(0, 8).map((h, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>{h.organization}</td>
|
||||||
|
<td>{h.pctHeld != null ? `${fmtNum(h.pctHeld, 2)}%` : "—"}</td>
|
||||||
|
<td className="muted small">{fmtDate(h.reportDate)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,301 @@
|
||||||
|
import { AppIcon } from "./PixelIcons";
|
||||||
|
import { Card, Tag } from "./ui";
|
||||||
|
import { TermHint } from "./TermHint";
|
||||||
|
import type { FundAnalysis, FundamentalsPayload } from "../lib/api";
|
||||||
|
import { fmtMoneyShort, fmtNum } from "../lib/stockTechnical";
|
||||||
|
|
||||||
|
function fmtMoneyTable(v: number | null | undefined) {
|
||||||
|
if (v == null || Number.isNaN(v)) return "—";
|
||||||
|
const a = Math.abs(v);
|
||||||
|
if (a >= 1e9) return `$${(v / 1e9).toFixed(2)}B`;
|
||||||
|
if (a >= 1e6) return `$${(v / 1e6).toFixed(1)}M`;
|
||||||
|
return `$${v.toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toneTag(tone?: string): "up" | "down" | "gold" {
|
||||||
|
if (tone === "good") return "up";
|
||||||
|
if (tone === "bad") return "down";
|
||||||
|
return "gold";
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCell({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sub?: string | null;
|
||||||
|
tone?: "up" | "down";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="fund-metric-cell">
|
||||||
|
<span className="k">
|
||||||
|
<TermHint term={label} />
|
||||||
|
</span>
|
||||||
|
<span className={`v ${tone || ""}`.trim()}>{value}</span>
|
||||||
|
{sub ? <em className="sub">{sub}</em> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
data?: FundamentalsPayload;
|
||||||
|
aiFocus: { symbol: string; subPage: string; label: string; cardTitle?: string };
|
||||||
|
onRefresh?: () => void;
|
||||||
|
refreshing?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FundAnalysisPanel({ data, aiFocus, onRefresh, refreshing }: Props) {
|
||||||
|
const analysis = data?.analysis;
|
||||||
|
if (!analysis) return null;
|
||||||
|
|
||||||
|
const cash = analysis.cash;
|
||||||
|
const cv = analysis.capexVerdict;
|
||||||
|
const bal = analysis.balanceInsight;
|
||||||
|
const annual = data?.annual?.[0];
|
||||||
|
const sectorNews = analysis.sectorNews;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="fund-layout-2">
|
||||||
|
<Card
|
||||||
|
className="fund-panel-compact"
|
||||||
|
title="現金流解讀 · 錢花去哪"
|
||||||
|
ico={<AppIcon name="coin" size={22} framed variant="hero" />}
|
||||||
|
ai
|
||||||
|
aiFocus={{ ...aiFocus, cardTitle: "現金流解讀" }}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
refreshing={refreshing}
|
||||||
|
>
|
||||||
|
<div className="fund-verdict-row">
|
||||||
|
<Tag tone={toneTag(cv?.tone)}>{cv?.label || "分析中"}</Tag>
|
||||||
|
<Tag tone={toneTag(cv?.worthBuy === "偏正面" ? "good" : cv?.worthBuy === "謹慎" ? "bad" : "gold")}>
|
||||||
|
投資評價:{cv?.worthBuy || "—"}
|
||||||
|
</Tag>
|
||||||
|
{analysis.metrics?.fcfMargin != null ? (
|
||||||
|
<span className="small muted">
|
||||||
|
<TermHint term="FCF" /> {fmtNum(analysis.metrics.fcfMargin, 1)}%
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fin-insight-list">
|
||||||
|
<div className="fin-insight-item">
|
||||||
|
<b>解讀</b> — {analysis.worthSummary || cv?.summary}
|
||||||
|
</div>
|
||||||
|
{analysis.bullets?.map((b, i) => (
|
||||||
|
<div key={i} className="fin-insight-item">
|
||||||
|
{b}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fund-metric-grid mt-s">
|
||||||
|
<MetricCell
|
||||||
|
label="營業現金流"
|
||||||
|
value={fmtMoneyTable(cash?.ocf)}
|
||||||
|
tone={cash?.ocf != null && cash.ocf >= 0 ? "up" : "down"}
|
||||||
|
/>
|
||||||
|
<MetricCell label="資本支出 Capex" value={fmtMoneyTable(cash?.capex)} />
|
||||||
|
<MetricCell
|
||||||
|
label="自由現金流 FCF"
|
||||||
|
value={fmtMoneyTable(cash?.fcf)}
|
||||||
|
tone={cash?.fcf != null && cash.fcf >= 0 ? "up" : "down"}
|
||||||
|
/>
|
||||||
|
<MetricCell label="投資活動" value={fmtMoneyTable(cash?.icf)} />
|
||||||
|
<MetricCell label="融資活動" value={fmtMoneyTable(cash?.fincf)} />
|
||||||
|
<MetricCell label="現金餘額" value={fmtMoneyTable(cash?.cash)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cash?.uses?.length ? (
|
||||||
|
<div className="fin-table-wrap fund-table-scroll mt-s">
|
||||||
|
<table className="fin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>現金去向</th>
|
||||||
|
<th>金額</th>
|
||||||
|
<th>佔 OCF</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{cash.uses.map((u, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="fin-period">{u.label}</td>
|
||||||
|
<td>{fmtMoneyTable(u.amount)}</td>
|
||||||
|
<td>{u.pct != null ? `${fmtNum(u.pct, 0)}%` : "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className="fund-panel-compact"
|
||||||
|
title="資產負債 · 投資痕跡"
|
||||||
|
ico={<AppIcon name="macro" size={22} framed variant="hero" />}
|
||||||
|
ai
|
||||||
|
aiFocus={{ ...aiFocus, cardTitle: "資產負債投資痕跡" }}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
refreshing={refreshing}
|
||||||
|
>
|
||||||
|
<div className="fund-metric-grid">
|
||||||
|
<MetricCell
|
||||||
|
label="廠房設備 PPE"
|
||||||
|
value={fmtMoneyTable(bal?.ppe)}
|
||||||
|
sub={bal?.ppeGrowth != null ? `YoY ${fmtNum(bal.ppeGrowth, 0)}%` : null}
|
||||||
|
/>
|
||||||
|
<MetricCell
|
||||||
|
label="商譽 Goodwill"
|
||||||
|
value={fmtMoneyTable(bal?.goodwill)}
|
||||||
|
sub={bal?.goodwillGrowth != null ? `YoY ${fmtNum(bal.goodwillGrowth, 0)}%` : null}
|
||||||
|
/>
|
||||||
|
<MetricCell label="存貨" value={fmtMoneyTable(bal?.inventory)} />
|
||||||
|
<MetricCell label="應收帳款" value={fmtMoneyTable(bal?.receivables)} />
|
||||||
|
<MetricCell label="總負債" value={fmtMoneyTable(data?.balance?.totalLiabilities as number)} />
|
||||||
|
<MetricCell
|
||||||
|
label="流動比率"
|
||||||
|
value={bal?.currentRatio != null ? `${fmtNum(bal.currentRatio, 2)}x` : "—"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fin-insight-list">
|
||||||
|
<div className="fin-insight-item">
|
||||||
|
<b>怎麼看</b> —
|
||||||
|
{bal?.ppeGrowth != null && bal.ppeGrowth > 10
|
||||||
|
? ` PPE 成長 ${fmtNum(bal.ppeGrowth, 0)}%,偏向投資產能/廠房設備。`
|
||||||
|
: bal?.ppeGrowth != null && bal.ppeGrowth >= 0
|
||||||
|
? " PPE 小幅成長,可能以維護性支出為主。"
|
||||||
|
: " PPE 變化不大或資料不足。"}
|
||||||
|
{bal?.goodwillGrowth != null && bal.goodwillGrowth > 20
|
||||||
|
? ` 商譽大增 ${fmtNum(bal.goodwillGrowth, 0)}%,留意是否靠併購撐成長。`
|
||||||
|
: ""}
|
||||||
|
{analysis.metrics?.ocfToNetIncome != null && analysis.metrics.ocfToNetIncome < 80
|
||||||
|
? ` 營業現金流僅為淨利的 ${fmtNum(analysis.metrics.ocfToNetIncome, 0)}%,獲利含金量偏低。`
|
||||||
|
: analysis.metrics?.ocfToNetIncome != null
|
||||||
|
? ` 營業現金流約為淨利的 ${fmtNum(analysis.metrics.ocfToNetIncome, 0)}%,帳上獲利較有現金支撐。`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sectorNews ? (
|
||||||
|
<Card
|
||||||
|
className="mt"
|
||||||
|
title="產業與市場消息 · 對本股的影響"
|
||||||
|
ico={<AppIcon name="world" size={22} framed variant="hero" />}
|
||||||
|
ai
|
||||||
|
aiFocus={{ ...aiFocus, cardTitle: "產業市場消息" }}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
refreshing={refreshing}
|
||||||
|
>
|
||||||
|
{(sectorNews.sector || sectorNews.industry) ? (
|
||||||
|
<p className="small muted" style={{ margin: "0 0 8px" }}>
|
||||||
|
產業:{sectorNews.industry || "—"} · 板塊:{sectorNews.sector || "—"}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{sectorNews.pulse ? <div className="sector-pulse">{sectorNews.pulse}</div> : null}
|
||||||
|
<div className="fin-insight-item">{sectorNews.summary}</div>
|
||||||
|
{sectorNews.items?.length ? (
|
||||||
|
<div className="sector-news-list">
|
||||||
|
{sectorNews.items.map((n, i) => (
|
||||||
|
<div key={i} className="sector-news-item">
|
||||||
|
{n.url ? (
|
||||||
|
<a href={n.url} target="_blank" rel="noreferrer">
|
||||||
|
{n.title}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<strong style={{ color: "var(--ink)", fontSize: 14 }}>{n.title}</strong>
|
||||||
|
)}
|
||||||
|
<div className="sector-news-meta">
|
||||||
|
{n.date ? <span>{n.date}</span> : null}
|
||||||
|
{n.source ? <span>{n.source}</span> : null}
|
||||||
|
<Tag tone={toneTag(n.impactTone)}>{n.impactLabel}</Tag>
|
||||||
|
</div>
|
||||||
|
<div className="sector-news-impact">{n.impactReason}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="small muted mt-s">近期無明顯產業關鍵字新聞;可至「新聞」分頁查看個股消息。</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{analysis.vendors?.vendors?.length ? (
|
||||||
|
<Card
|
||||||
|
className="mt"
|
||||||
|
title="擴產時誰受益(上下游)"
|
||||||
|
ico={<AppIcon name="world" size={22} framed variant="hero" />}
|
||||||
|
ai
|
||||||
|
aiFocus={{ ...aiFocus, cardTitle: "上下游受益" }}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
refreshing={refreshing}
|
||||||
|
>
|
||||||
|
<div className="fin-insight-item">{analysis.vendors.note}</div>
|
||||||
|
<div className="intel-tags mt-s">
|
||||||
|
{analysis.vendors.vendors.map((v) => (
|
||||||
|
<Tag key={v} tone="cool">
|
||||||
|
{v}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="fin-insight-item mt-s">
|
||||||
|
{annual?.capex != null && Math.abs(annual.capex) > 0
|
||||||
|
? `若 Capex ${fmtMoneyShort(Math.abs(annual.capex))} 用於擴產,設備商、代工與基建供應商通常最先受惠;擴產成功後則回饋下游客戶供貨能力。`
|
||||||
|
: "Capex 資料不足,無法推估供應鏈受惠名單。"}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{(data?.balanceHistory?.length || 0) > 1 ? (
|
||||||
|
<Card
|
||||||
|
className="mt"
|
||||||
|
title="資產負債趨勢(近幾季)"
|
||||||
|
ico={<AppIcon name="chart" size={22} framed variant="hero" />}
|
||||||
|
ai
|
||||||
|
aiFocus={{ ...aiFocus, cardTitle: "資產負債趨勢" }}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
refreshing={refreshing}
|
||||||
|
>
|
||||||
|
<div className="fin-table-wrap fund-table-scroll">
|
||||||
|
<table className="fin-table fund-table-wide">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>期間</th>
|
||||||
|
<th>
|
||||||
|
<TermHint term="總資產" />
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<TermHint term="現金餘額" />
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<TermHint term="廠房設備 PPE" />
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<TermHint term="負債比" />
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(data?.balanceHistory || []).map((b) => (
|
||||||
|
<tr key={b.end}>
|
||||||
|
<td className="fin-period">{b.end || "—"}</td>
|
||||||
|
<td>{fmtMoneyTable(b.totalAssets)}</td>
|
||||||
|
<td>{fmtMoneyTable(b.cash)}</td>
|
||||||
|
<td>{fmtMoneyTable(b.ppe)}</td>
|
||||||
|
<td>{b.debtToAssets != null ? `${fmtNum(b.debtToAssets, 1)}%` : "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
// 純 SVG/DOM 的儀表元件:分數環、區間條(移植自原型 app.js)
|
||||||
|
|
||||||
|
export function ScoreRing({ score, size }: { score: number; size?: number }) {
|
||||||
|
const r = 38;
|
||||||
|
const c = 2 * Math.PI * r;
|
||||||
|
const pct = Math.max(0, Math.min(100, score)) / 100;
|
||||||
|
const color = score >= 66 ? "#6fcf97" : score >= 40 ? "#e7c66b" : "#e86a52";
|
||||||
|
const boxStyle = size
|
||||||
|
? { width: size, height: size, flex: `0 0 ${size}px` as const }
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
|
<div className="score-ring" style={boxStyle}>
|
||||||
|
<svg viewBox="0 0 92 92" className="score-ring-svg" aria-hidden>
|
||||||
|
<circle cx="46" cy="46" r={r} fill="none" stroke="rgba(255,255,255,.1)" strokeWidth="6" />
|
||||||
|
<circle
|
||||||
|
cx="46"
|
||||||
|
cy="46"
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth="6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={`${c * pct} ${c}`}
|
||||||
|
transform="rotate(-90 46 46)"
|
||||||
|
style={{ filter: "drop-shadow(0 0 4px rgba(231,198,107,.35))" }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="num">{Math.round(score)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RangeBar({
|
||||||
|
pct,
|
||||||
|
lowLabel = "低",
|
||||||
|
midLabel = "中位",
|
||||||
|
highLabel = "高",
|
||||||
|
}: {
|
||||||
|
pct: number | null;
|
||||||
|
lowLabel?: string;
|
||||||
|
midLabel?: string;
|
||||||
|
highLabel?: string;
|
||||||
|
}) {
|
||||||
|
const p = pct == null ? null : Math.max(0, Math.min(100, pct));
|
||||||
|
return (
|
||||||
|
<div className="rangebar">
|
||||||
|
<div className="track">
|
||||||
|
<div className="median" style={{ left: "50%" }} />
|
||||||
|
{p != null && <div className="now" style={{ left: `${p}%` }} />}
|
||||||
|
</div>
|
||||||
|
<div className="labels">
|
||||||
|
<span>{lowLabel}</span>
|
||||||
|
<span>{midLabel}</span>
|
||||||
|
<span>{highLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useAI } from "../context/AIContext";
|
||||||
|
import { fetchSkillsForRoute, type AISkill } from "../lib/ai-skills";
|
||||||
|
import { AI_PROVIDER_META, type AIProviderId } from "../lib/ai";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { extractAllStrategyBlocks } from "../lib/strategyImport";
|
||||||
|
import MarkdownMessage from "./MarkdownMessage";
|
||||||
|
import PixelMascot from "./PixelMascot";
|
||||||
|
|
||||||
|
export default function GuideMascot() {
|
||||||
|
const loc = useLocation();
|
||||||
|
const ai = useAI();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [skills, setSkills] = useState<AISkill[]>([]);
|
||||||
|
const [importing, setImporting] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void ai.refreshStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ai.open) void ai.refreshStatus();
|
||||||
|
}, [ai.open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchSkillsForRoute(loc.pathname, ai.focus || undefined).then(setSkills);
|
||||||
|
}, [loc.pathname, ai.focus?.cardTitle, ai.focus?.label, ai.open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={"guide-dock" + (ai.open ? " open" : "")}>
|
||||||
|
{ai.open && (
|
||||||
|
<div className="guide-panel" role="dialog" aria-label="AI 導覽員">
|
||||||
|
<div className="guide-panel-head">
|
||||||
|
<PixelMascot size={44} variant="chat" />
|
||||||
|
<div>
|
||||||
|
<div className="guide-name">金幣貓頭鷹</div>
|
||||||
|
<div className="guide-ctx small muted">{ai.contextLabel}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="guide-close" onClick={ai.closeAI} aria-label="關閉">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!ai.status?.ready && (
|
||||||
|
<div className="guide-setup">
|
||||||
|
<p>尚未設定 AI。請到設定頁填入 <b>Grok</b> 或 <b>OpenCode Go</b> API Key。</p>
|
||||||
|
<Link to="/settings" className="guide-setup-link" onClick={ai.closeAI}>
|
||||||
|
前往 AI 設定 →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="guide-toolbar">
|
||||||
|
<label className="guide-field">
|
||||||
|
<span>Provider</span>
|
||||||
|
<select
|
||||||
|
value={ai.provider}
|
||||||
|
onChange={(e) => ai.setProvider(e.target.value as AIProviderId)}
|
||||||
|
>
|
||||||
|
{(ai.status?.providers || []).map((p) => (
|
||||||
|
<option key={p.id} value={p.id} disabled={!p.hasKey}>
|
||||||
|
{AI_PROVIDER_META[p.id as AIProviderId]?.label || p.id}
|
||||||
|
{!p.hasKey ? "(未設定)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{!ai.status?.providers?.length &&
|
||||||
|
Object.entries(AI_PROVIDER_META).map(([id, m]) => (
|
||||||
|
<option key={id} value={id}>
|
||||||
|
{m.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="button" className="guide-clear" onClick={ai.clearChat}>
|
||||||
|
清除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="guide-chat">
|
||||||
|
{ai.messages.map((msg) => {
|
||||||
|
const blocks = msg.role === "assistant" ? extractAllStrategyBlocks(msg.text) : [];
|
||||||
|
return (
|
||||||
|
<div key={msg.id} className={"guide-msg " + msg.role}>
|
||||||
|
{msg.role === "assistant" && <PixelMascot size={32} variant="chat" />}
|
||||||
|
<div className="guide-bubble-wrap">
|
||||||
|
<div className="guide-bubble">
|
||||||
|
<MarkdownMessage text={msg.text} />
|
||||||
|
</div>
|
||||||
|
{blocks.map((b, i) => (
|
||||||
|
<button
|
||||||
|
key={`${msg.id}-bt-${i}`}
|
||||||
|
type="button"
|
||||||
|
className="guide-strategy-import"
|
||||||
|
disabled={importing != null}
|
||||||
|
onClick={() => {
|
||||||
|
setImporting(`${msg.id}-${i}`);
|
||||||
|
void api
|
||||||
|
.createStrategy({
|
||||||
|
name: b.name,
|
||||||
|
engine: b.engine,
|
||||||
|
params: b.params || {},
|
||||||
|
description: b.description,
|
||||||
|
formula: b.formula,
|
||||||
|
source: b.source || "owl",
|
||||||
|
tags: b.tags || ["貓頭鷹"],
|
||||||
|
})
|
||||||
|
.then(() => qc.invalidateQueries({ queryKey: ["strategies"] }))
|
||||||
|
.finally(() => setImporting(null));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{importing === `${msg.id}-${i}` ? "加入中…" : `+ 加入策略庫:${b.name}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{msg.meta && <div className="guide-meta small muted">{msg.meta}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{skills.length > 0 && (
|
||||||
|
<div className="guide-skills">
|
||||||
|
<div className="guide-skills-k">技能快捷</div>
|
||||||
|
<div className="guide-skill-row">
|
||||||
|
{skills.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
className="guide-skill"
|
||||||
|
disabled={ai.busy}
|
||||||
|
onClick={() => void ai.sendMessage(s.prompt, ai.focus, s.id)}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="guide-compose">
|
||||||
|
<textarea
|
||||||
|
rows={1}
|
||||||
|
value={ai.draft}
|
||||||
|
placeholder="問導覽員…(Enter 送出)"
|
||||||
|
onChange={(e) => ai.setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||||
|
e.preventDefault();
|
||||||
|
void ai.sendMessage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button type="button" className="guide-send" disabled={ai.busy} onClick={() => void ai.sendMessage()}>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="guide-fab"
|
||||||
|
aria-label="開啟 AI 導覽員"
|
||||||
|
onClick={() => ai.openAI()}
|
||||||
|
>
|
||||||
|
<PixelMascot size={58} blink={!ai.open} variant="fab" />
|
||||||
|
<span className="guide-fab-label">問</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import type { SimilarComparison } from "../lib/api";
|
||||||
|
|
||||||
|
/** 現在 vs 歷史相似期的指標對照(橫條圖,非 emoji) */
|
||||||
|
export default function IndicatorCompareBars({ rows }: { rows: SimilarComparison[] }) {
|
||||||
|
if (!rows.length) return <p className="muted small">尚無指標對照資料。</p>;
|
||||||
|
|
||||||
|
const maxZ = Math.max(3, ...rows.flatMap((r) => [Math.abs(r.zNow), Math.abs(r.zThen)]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="compare-bars">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<div className="compare-row" key={r.key}>
|
||||||
|
<div className="compare-head">
|
||||||
|
<span className="compare-label">{r.label}</span>
|
||||||
|
<span className={"compare-closeness " + closenessClass(r.closeness)}>{r.closeness}</span>
|
||||||
|
{r.weightPct != null && <span className="compare-weight">{r.weightPct}% 影響</span>}
|
||||||
|
</div>
|
||||||
|
<div className="compare-values">
|
||||||
|
<span className="compare-now">現在 {r.nowText ?? "—"}</span>
|
||||||
|
<span className="compare-then">當時 {r.thenText ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="compare-tracks">
|
||||||
|
<div className="compare-track">
|
||||||
|
<span className="track-tag">現在</span>
|
||||||
|
<div className="track-bar">
|
||||||
|
<div
|
||||||
|
className="track-fill now"
|
||||||
|
style={{ width: `${(Math.abs(r.zNow) / maxZ) * 100}%`, marginLeft: r.zNow < 0 ? "auto" : undefined }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="compare-track">
|
||||||
|
<span className="track-tag">當時</span>
|
||||||
|
<div className="track-bar">
|
||||||
|
<div
|
||||||
|
className="track-fill then"
|
||||||
|
style={{ width: `${(Math.abs(r.zThen) / maxZ) * 100}%`, marginLeft: r.zThen < 0 ? "auto" : undefined }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closenessClass(c: string) {
|
||||||
|
if (c === "極像") return "up";
|
||||||
|
if (c === "相近") return "cool";
|
||||||
|
if (c === "有差") return "gold";
|
||||||
|
return "down";
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkBreaks from "remark-breaks";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
|
export default function MarkdownMessage({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="markdown-message">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||||
|
components={{
|
||||||
|
a: ({ children, ...props }) => (
|
||||||
|
<a {...props} target="_blank" rel="noreferrer">
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import {
|
||||||
|
ColorType,
|
||||||
|
HistogramSeries,
|
||||||
|
LineSeries,
|
||||||
|
createChart,
|
||||||
|
type IChartApi,
|
||||||
|
type ISeriesApi,
|
||||||
|
type UTCTimestamp,
|
||||||
|
} from "lightweight-charts";
|
||||||
|
import type { IndicatorPoint } from "../lib/technicalIndicators";
|
||||||
|
|
||||||
|
export interface OscLine {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
points: IndicatorPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTime(date: string) {
|
||||||
|
return (Date.parse(date + "T00:00:00Z") / 1000) as UTCTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OscillatorChart({
|
||||||
|
title,
|
||||||
|
lines = [],
|
||||||
|
hist,
|
||||||
|
refLines = [],
|
||||||
|
heightClass = "osc-chart",
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
lines?: OscLine[];
|
||||||
|
hist?: IndicatorPoint[];
|
||||||
|
refLines?: { value: number; color: string; label?: string }[];
|
||||||
|
heightClass?: string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const chartRef = useRef<IChartApi | null>(null);
|
||||||
|
const lineRefs = useRef<ISeriesApi<"Line">[]>([]);
|
||||||
|
const histRef = useRef<ISeriesApi<"Histogram"> | null>(null);
|
||||||
|
|
||||||
|
const hasData = useMemo(
|
||||||
|
() => lines.some((l) => l.points.length > 1) || (hist && hist.length > 1),
|
||||||
|
[lines, hist],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const chart = createChart(ref.current, {
|
||||||
|
layout: { background: { type: ColorType.Solid, color: "transparent" }, textColor: "#9aa8cc", fontSize: 10 },
|
||||||
|
grid: { vertLines: { visible: false }, horzLines: { color: "rgba(255,255,255,.05)" } },
|
||||||
|
rightPriceScale: { borderVisible: false },
|
||||||
|
timeScale: { borderVisible: false, visible: false },
|
||||||
|
crosshair: { vertLine: { visible: false }, horzLine: { visible: false } },
|
||||||
|
autoSize: true,
|
||||||
|
});
|
||||||
|
const h = hist
|
||||||
|
? chart.addSeries(HistogramSeries, { priceFormat: { type: "price", precision: 4, minMove: 0.0001 } })
|
||||||
|
: null;
|
||||||
|
const ls = lines.map((l) =>
|
||||||
|
chart.addSeries(LineSeries, {
|
||||||
|
color: l.color,
|
||||||
|
lineWidth: 1,
|
||||||
|
priceLineVisible: false,
|
||||||
|
lastValueVisible: true,
|
||||||
|
crosshairMarkerRadius: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
chartRef.current = chart;
|
||||||
|
lineRefs.current = ls;
|
||||||
|
histRef.current = h;
|
||||||
|
return () => {
|
||||||
|
chart.remove();
|
||||||
|
chartRef.current = null;
|
||||||
|
lineRefs.current = [];
|
||||||
|
histRef.current = null;
|
||||||
|
};
|
||||||
|
}, [lines.length, !!hist]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chartRef.current) return;
|
||||||
|
lines.forEach((l, i) => {
|
||||||
|
lineRefs.current[i]?.setData(l.points.map((p) => ({ time: toTime(p.date), value: p.value })));
|
||||||
|
});
|
||||||
|
if (histRef.current && hist) {
|
||||||
|
histRef.current.setData(
|
||||||
|
hist.map((p) => ({
|
||||||
|
time: toTime(p.date),
|
||||||
|
value: p.value,
|
||||||
|
color: p.value >= 0 ? "rgba(232,106,82,.45)" : "rgba(111,207,151,.45)",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
chartRef.current.timeScale().fitContent();
|
||||||
|
}, [lines, hist]);
|
||||||
|
|
||||||
|
if (!hasData) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="osc-wrap">
|
||||||
|
<div className="osc-title">
|
||||||
|
{title}
|
||||||
|
{lines.map((l) => (
|
||||||
|
<span key={l.name} className="osc-legend">
|
||||||
|
<i style={{ background: l.color }} />
|
||||||
|
{l.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{refLines.map((r) => (
|
||||||
|
<span key={r.label || r.value} className="osc-ref">
|
||||||
|
{r.label || r.value}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div ref={ref} className={heightClass} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,332 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { AppIcon } from "./PixelIcons";
|
||||||
|
import { Tag, Loading } from "./ui";
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type PatternCatalogItem,
|
||||||
|
type PatternProgressMap,
|
||||||
|
type PatternSignal,
|
||||||
|
} from "../lib/api";
|
||||||
|
import {
|
||||||
|
patternArtIcon,
|
||||||
|
patternBiasLabel,
|
||||||
|
patternBiasTone,
|
||||||
|
patternStatusLabel,
|
||||||
|
} from "../lib/patternAtlas";
|
||||||
|
import { recordPatternScanHitsApi } from "../lib/patternProgress";
|
||||||
|
|
||||||
|
type PatternTab = "detail" | "book" | "scan";
|
||||||
|
|
||||||
|
export default function PatternDetailPanel({
|
||||||
|
pattern,
|
||||||
|
progress,
|
||||||
|
onClose,
|
||||||
|
onProgressChange,
|
||||||
|
initialTab = "detail",
|
||||||
|
initialSymbol = "",
|
||||||
|
}: {
|
||||||
|
pattern: PatternCatalogItem | null;
|
||||||
|
progress: PatternProgressMap;
|
||||||
|
onClose: () => void;
|
||||||
|
onProgressChange: (map: PatternProgressMap) => void;
|
||||||
|
initialTab?: PatternTab;
|
||||||
|
initialSymbol?: string;
|
||||||
|
}) {
|
||||||
|
const open = !!pattern;
|
||||||
|
const [tab, setTab] = useState<PatternTab>("detail");
|
||||||
|
const [symbol, setSymbol] = useState(initialSymbol);
|
||||||
|
const [scan, setScan] = useState<{
|
||||||
|
display?: string;
|
||||||
|
symbol: string;
|
||||||
|
market?: string;
|
||||||
|
signalCount?: number;
|
||||||
|
recentSignals?: PatternSignal[];
|
||||||
|
signals?: PatternSignal[];
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const scanMut = useMutation({
|
||||||
|
mutationFn: async ({ sym, onlyThis }: { sym: string; onlyThis?: boolean }) => {
|
||||||
|
const data = await api.patternScan(sym, "2y", 120);
|
||||||
|
const pid = pattern?.id;
|
||||||
|
const filtered =
|
||||||
|
onlyThis && pid
|
||||||
|
? (data.signals || []).filter((s) => s.patternId === pid).slice(0, 15)
|
||||||
|
: (data.recentSignals || data.signals || []).slice(0, 15);
|
||||||
|
return { ...data, recentSignals: filtered, hitIds: filtered.map((s) => s.patternId).filter(Boolean) as string[] };
|
||||||
|
},
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
setScan(data);
|
||||||
|
const ids = [...new Set(data.hitIds || [])];
|
||||||
|
if (ids.length) {
|
||||||
|
const map = await recordPatternScanHitsApi(ids);
|
||||||
|
onProgressChange(map);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const teach = pattern?.teach || {};
|
||||||
|
const bookPage =
|
||||||
|
teach.bookHtml || (teach.bookRef ? teach.bookRef.replace(/\.md$/i, ".html") : null);
|
||||||
|
const bookPageId = bookPage?.replace(/\.html?$/i, "") || "";
|
||||||
|
const bookQ = useQuery({
|
||||||
|
queryKey: ["pattern-book", bookPageId],
|
||||||
|
queryFn: () => api.patternBookPage(bookPageId),
|
||||||
|
enabled: open && !!bookPageId && tab === "book",
|
||||||
|
staleTime: 3600_000,
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setTab(initialTab);
|
||||||
|
setSymbol(initialSymbol);
|
||||||
|
setScan(null);
|
||||||
|
document.body.classList.add("skill-modal-open");
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
document.body.classList.remove("skill-modal-open");
|
||||||
|
};
|
||||||
|
}, [open, pattern?.id, onClose, initialTab, initialSymbol]);
|
||||||
|
|
||||||
|
if (!open || !pattern) return null;
|
||||||
|
|
||||||
|
const entry = progress[pattern.id];
|
||||||
|
const biasTone = patternBiasTone(pattern.bias);
|
||||||
|
const artIcon = patternArtIcon(pattern.bias, pattern.automatable);
|
||||||
|
const signals = scan?.recentSignals || scan?.signals || [];
|
||||||
|
|
||||||
|
const runScan = (onlyThis = false) => {
|
||||||
|
const sym = symbol.trim();
|
||||||
|
if (!sym) return;
|
||||||
|
scanMut.mutate({ sym, onlyThis });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="skill-modal pat-modal" role="dialog" aria-modal="true" aria-labelledby="pat-card-title">
|
||||||
|
<button type="button" className="skill-modal-backdrop" aria-label="關閉線型卡" onClick={onClose} />
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"skill-modal-panel",
|
||||||
|
"skill-modal-panel--tabbed",
|
||||||
|
"pat-modal-panel",
|
||||||
|
pattern.automatable ? "pat-modal--scan" : "",
|
||||||
|
tab === "book" ? "pat-modal--book" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
>
|
||||||
|
<div className="skill-modal-banner" aria-hidden>
|
||||||
|
<AppIcon name={artIcon} size={32} framed glow variant="hero" />
|
||||||
|
</div>
|
||||||
|
<div className="skill-modal-head">
|
||||||
|
<AppIcon name={artIcon} size={26} framed variant="hero" />
|
||||||
|
<strong>線型招式</strong>
|
||||||
|
<div className="skill-modal-tabs" role="tablist" aria-label="線型卡分頁">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === "detail"}
|
||||||
|
className={"skill-modal-tab" + (tab === "detail" ? " active" : "")}
|
||||||
|
onClick={() => setTab("detail")}
|
||||||
|
>
|
||||||
|
詳情
|
||||||
|
</button>
|
||||||
|
{bookPage ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === "book"}
|
||||||
|
className={"skill-modal-tab" + (tab === "book" ? " active" : "")}
|
||||||
|
onClick={() => setTab("book")}
|
||||||
|
>
|
||||||
|
教材卷軸
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{pattern.automatable ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === "scan"}
|
||||||
|
className={"skill-modal-tab" + (tab === "scan" ? " active" : "")}
|
||||||
|
onClick={() => setTab("scan")}
|
||||||
|
>
|
||||||
|
實戰掃描
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="skill-modal-close" onClick={onClose} aria-label="關閉">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="skill-modal-body pat-modal-body">
|
||||||
|
<div className="pat-detail-head">
|
||||||
|
<h2 id="pat-card-title">{pattern.name}</h2>
|
||||||
|
<div className="pat-detail-tags">
|
||||||
|
<Tag tone={biasTone}>{patternBiasLabel(pattern.bias)}</Tag>
|
||||||
|
{pattern.automatable ? <Tag tone="cool">可掃描</Tag> : <Tag>教學</Tag>}
|
||||||
|
<span className="small muted">{patternStatusLabel(entry)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="small muted pat-detail-meta">
|
||||||
|
{pattern.category}
|
||||||
|
{pattern.timeframe?.length ? ` · ${pattern.timeframe.join(" / ")}` : ""}
|
||||||
|
{pattern.bookPage ? ` · 書本 p.${pattern.bookPage}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "detail" ? (
|
||||||
|
<div className="pat-teach-block">
|
||||||
|
{teach.fullTitle && teach.fullTitle !== pattern.name ? (
|
||||||
|
<p className="small muted">{teach.fullTitle}</p>
|
||||||
|
) : null}
|
||||||
|
<p className="pat-summary">{teach.summary || "尚無摘要。"}</p>
|
||||||
|
<div className="pat-teach-grid">
|
||||||
|
<div>
|
||||||
|
<b>進場參考</b>
|
||||||
|
<p>{teach.entryHint || "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<b>出場參考</b>
|
||||||
|
<p>{teach.exitHint || "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<b>注意</b>
|
||||||
|
<p>{teach.caution || "—"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pat-detail-actions">
|
||||||
|
{bookPage ? (
|
||||||
|
<button type="button" className="btn-ghost sm" onClick={() => setTab("book")}>
|
||||||
|
開啟教材卷軸 →
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{pattern.automatable ? (
|
||||||
|
<button type="button" className="btn-primary sm" onClick={() => setTab("scan")}>
|
||||||
|
實戰掃描
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<Link
|
||||||
|
to={symbol.trim() ? `/research?sym=${encodeURIComponent(symbol.trim().toUpperCase())}` : "/research"}
|
||||||
|
className="btn-ghost sm"
|
||||||
|
>
|
||||||
|
在個股圖表看 →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{tab === "book" && bookPage ? (
|
||||||
|
<div className="pat-book-block">
|
||||||
|
<p className="small muted">
|
||||||
|
教材出處:{bookQ.data?.title || pattern.name}
|
||||||
|
{(bookQ.data?.pageNum || pattern.bookPage)
|
||||||
|
? ` · 第 ${bookQ.data?.pageNum || pattern.bookPage} 頁`
|
||||||
|
: teach.isSupplement || bookQ.data?.isSupplement
|
||||||
|
? ` · ${teach.tocHint || bookQ.data?.tocHint || "教材補遺"}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
{bookQ.isLoading ? <Loading label="載入教材內容…" /> : null}
|
||||||
|
{bookQ.data?.html ? (
|
||||||
|
<iframe
|
||||||
|
className="pat-book-frame"
|
||||||
|
srcDoc={bookQ.data.html}
|
||||||
|
title={pattern.name}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{tab === "scan" && pattern.automatable ? (
|
||||||
|
<div className="pat-scan-block">
|
||||||
|
<p className="small muted">
|
||||||
|
輸入代號掃描近期 K 線,對照書中可偵測的 18 招線型。結果會標記在個股圖表上。
|
||||||
|
</p>
|
||||||
|
<div className="pat-scan-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="pat-scan-input"
|
||||||
|
placeholder="NVDA 或 2330"
|
||||||
|
value={symbol}
|
||||||
|
onChange={(e) => setSymbol(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") runScan(true);
|
||||||
|
}}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary sm"
|
||||||
|
disabled={scanMut.isPending || !symbol.trim()}
|
||||||
|
onClick={() => runScan(true)}
|
||||||
|
>
|
||||||
|
{scanMut.isPending ? "掃描中…" : "掃描此線型"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost sm"
|
||||||
|
disabled={scanMut.isPending || !symbol.trim()}
|
||||||
|
onClick={() => runScan(false)}
|
||||||
|
>
|
||||||
|
全線型掃描
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{scanMut.isPending ? <Loading label="掃描線型…" /> : null}
|
||||||
|
{scan ? (
|
||||||
|
<p className="small muted">
|
||||||
|
{scan.display || scan.symbol}
|
||||||
|
{scan.market ? ` · ${scan.market === "tw" ? "台股" : "美股"}` : ""}
|
||||||
|
{" · "}
|
||||||
|
找到 {scan.signalCount || signals.length} 個訊號
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{signals.length ? (
|
||||||
|
<ul className="pat-signal-list">
|
||||||
|
{signals.map((s, i) => (
|
||||||
|
<li key={`${s.patternId}-${s.date}-${i}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pat-signal-btn"
|
||||||
|
onClick={() => {
|
||||||
|
if (symbol.trim()) {
|
||||||
|
window.location.href = `/research?sym=${encodeURIComponent(symbol.trim().toUpperCase())}`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="pat-signal-name">{s.patternName || s.name}</span>
|
||||||
|
<span className="pat-signal-meta">
|
||||||
|
{s.date}
|
||||||
|
{s.confidence != null ? ` · 信心 ${Math.round(s.confidence * 100)}%` : ""}
|
||||||
|
{s.actionLabel ? ` · ${s.actionLabel}` : ""}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : scan && !scanMut.isPending ? (
|
||||||
|
<p className="muted small">此區間未偵測到符合的線型,可換代號或拉長區間再試。</p>
|
||||||
|
) : null}
|
||||||
|
{symbol.trim() ? (
|
||||||
|
<div className="pat-detail-actions">
|
||||||
|
<Link
|
||||||
|
to={`/research?sym=${encodeURIComponent(symbol.trim().toUpperCase())}`}
|
||||||
|
className="btn-primary sm"
|
||||||
|
>
|
||||||
|
開個股圖表 →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<p className="disclaimer pat-disclaimer">
|
||||||
|
訊號僅供學習與練習,不構成投資建議。書本強調:先釐清原因與趨勢,再依線型行動。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,650 @@
|
||||||
|
/** 八方旅人風格像素圖示(純 SVG,不用 emoji) */
|
||||||
|
import type { ComponentType, ReactNode } from "react";
|
||||||
|
import { ICON_ART } from "../lib/iconAssets";
|
||||||
|
import RpgIcon from "./RpgIcon";
|
||||||
|
|
||||||
|
type IconProps = { size?: number; className?: string };
|
||||||
|
|
||||||
|
function base({ size = 24, className, children }: IconProps & { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} width={size} height={size} viewBox="0 0 32 32" aria-hidden>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconCompass({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<rect x="1" y="1" width="30" height="30" fill="#161b34" stroke="#e7c66b" strokeWidth="2" />
|
||||||
|
<polygon points="16,5 19,15 16,13 13,15" fill="#e7c66b" />
|
||||||
|
<polygon points="16,27 19,17 16,19 13,17" fill="#6fe0d0" />
|
||||||
|
<polygon points="5,16 15,13 13,16 15,19" fill="#8fb8ff" />
|
||||||
|
<polygon points="27,16 17,19 19,16 17,13" fill="#e86a52" />
|
||||||
|
<circle cx="16" cy="16" r="3" fill="#0a0d18" stroke="#e7c66b" strokeWidth="1.5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconMacro({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<rect x="2" y="6" width="28" height="22" fill="#161b34" stroke="#6fe0d0" strokeWidth="2" />
|
||||||
|
<polyline points="6,22 12,16 17,19 26,9" fill="none" stroke="#e7c66b" strokeWidth="2.5" />
|
||||||
|
<circle cx="26" cy="9" r="2.5" fill="#e7c66b" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconFlow({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<rect x="3" y="8" width="10" height="16" fill="#1b2240" stroke="#6fcf97" strokeWidth="2" />
|
||||||
|
<rect x="13" y="12" width="10" height="12" fill="#1b2240" stroke="#e7c66b" strokeWidth="2" />
|
||||||
|
<rect x="23" y="6" width="6" height="18" fill="#1b2240" stroke="#8fb8ff" strokeWidth="2" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconCalendar({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<rect x="4" y="7" width="24" height="22" fill="#161b34" stroke="#e7c66b" strokeWidth="2" />
|
||||||
|
<rect x="4" y="7" width="24" height="7" fill="#242b50" />
|
||||||
|
<rect x="9" y="4" width="3" height="6" fill="#e7c66b" />
|
||||||
|
<rect x="20" y="4" width="3" height="6" fill="#e7c66b" />
|
||||||
|
<rect x="8" y="18" width="4" height="4" fill="#6fcf97" />
|
||||||
|
<rect x="14" y="18" width="4" height="4" fill="#e86a52" />
|
||||||
|
<rect x="20" y="18" width="4" height="4" fill="#8fb8ff" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconChart({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<rect x="2" y="4" width="28" height="24" fill="#161b34" stroke="#8fb8ff" strokeWidth="2" />
|
||||||
|
<rect x="7" y="20" width="4" height="6" fill="#6fcf97" />
|
||||||
|
<rect x="14" y="14" width="4" height="12" fill="#e7c66b" />
|
||||||
|
<rect x="21" y="10" width="4" height="16" fill="#6fe0d0" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconWorld({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<circle cx="16" cy="16" r="13" fill="#161b34" stroke="#e7c66b" strokeWidth="2" />
|
||||||
|
<ellipse cx="16" cy="16" rx="5" ry="13" fill="none" stroke="#6fe0d0" strokeWidth="1.5" />
|
||||||
|
<line x1="3" y1="16" x2="29" y2="16" stroke="#6fe0d0" strokeWidth="1.5" />
|
||||||
|
<path d="M5 11 Q16 8 27 11" fill="none" stroke="#8fb8ff" strokeWidth="1.2" />
|
||||||
|
<path d="M5 21 Q16 24 27 21" fill="none" stroke="#8fb8ff" strokeWidth="1.2" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconCastle({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<rect x="4" y="14" width="24" height="14" fill="#1b2240" stroke="#e7c66b" strokeWidth="2" />
|
||||||
|
<rect x="8" y="8" width="6" height="8" fill="#242b50" stroke="#e7c66b" strokeWidth="1.5" />
|
||||||
|
<rect x="18" y="8" width="6" height="8" fill="#242b50" stroke="#e7c66b" strokeWidth="1.5" />
|
||||||
|
<rect x="13" y="18" width="6" height="10" fill="#0a0d18" stroke="#6fe0d0" strokeWidth="1.5" />
|
||||||
|
<rect x="6" y="6" width="3" height="4" fill="#e7c66b" />
|
||||||
|
<rect x="23" y="6" width="3" height="4" fill="#e7c66b" />
|
||||||
|
<rect x="15" y="4" width="2" height="6" fill="#f3dd96" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconSword({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<rect x="14" y="4" width="4" height="16" fill="#c8d4f0" stroke="#8fb8ff" strokeWidth="1.5" />
|
||||||
|
<rect x="10" y="18" width="12" height="3" fill="#e7c66b" stroke="#b9912f" strokeWidth="1" />
|
||||||
|
<rect x="15" y="21" width="2" height="7" fill="#8b5a2b" stroke="#5c3a18" strokeWidth="1" />
|
||||||
|
<polygon points="16,2 19,6 13,6" fill="#f3dd96" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconScroll({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<rect x="8" y="6" width="16" height="20" fill="#f0e2b8" stroke="#c9a227" strokeWidth="2" />
|
||||||
|
<rect x="6" y="8" width="4" height="16" fill="#e7c66b" stroke="#b9912f" strokeWidth="1" />
|
||||||
|
<rect x="22" y="8" width="4" height="16" fill="#e7c66b" stroke="#b9912f" strokeWidth="1" />
|
||||||
|
<line x1="11" y1="12" x2="21" y2="12" stroke="#8b7355" strokeWidth="1.5" />
|
||||||
|
<line x1="11" y1="16" x2="21" y2="16" stroke="#8b7355" strokeWidth="1.5" />
|
||||||
|
<line x1="11" y1="20" x2="18" y2="20" stroke="#8b7355" strokeWidth="1.5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconCards({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<rect x="6" y="8" width="14" height="18" fill="#fff8ef" stroke="#e7c66b" strokeWidth="2" transform="rotate(-8 13 17)" />
|
||||||
|
<rect x="12" y="6" width="14" height="18" fill="#161b34" stroke="#6fe0d0" strokeWidth="2" />
|
||||||
|
<path d="M15 11 L23 11 M15 15 L21 15" stroke="#6fe0d0" strokeWidth="1.5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconFolder({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<path d="M4 10 H14 L17 7 H28 V26 H4 Z" fill="#242b50" stroke="#e7c66b" strokeWidth="2" />
|
||||||
|
<rect x="6" y="14" width="20" height="10" fill="#1b2240" stroke="rgba(111,224,208,.5)" strokeWidth="1" />
|
||||||
|
<rect x="8" y="16" width="8" height="2" fill="#6fe0d0" />
|
||||||
|
<rect x="8" y="20" width="12" height="2" fill="#8fb8ff" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconWizard({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<polygon points="16,4 24,14 8,14" fill="#4a3f8c" stroke="#8fb8ff" strokeWidth="1.5" />
|
||||||
|
<circle cx="16" cy="18" r="6" fill="#f0d0a8" stroke="#c9a227" strokeWidth="1.5" />
|
||||||
|
<rect x="12" y="23" width="8" height="6" fill="#2a325c" stroke="#6fe0d0" strokeWidth="1.5" />
|
||||||
|
<circle cx="14" cy="17" r="1" fill="#1a1228" />
|
||||||
|
<circle cx="18" cy="17" r="1" fill="#1a1228" />
|
||||||
|
<rect x="22" y="10" width="2" height="8" fill="#6fe0d0" transform="rotate(20 23 14)" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconGear({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<circle cx="16" cy="16" r="6" fill="#1b2240" stroke="#e7c66b" strokeWidth="2" />
|
||||||
|
{[0, 45, 90, 135].map((deg) => (
|
||||||
|
<rect
|
||||||
|
key={deg}
|
||||||
|
x="14"
|
||||||
|
y="3"
|
||||||
|
width="4"
|
||||||
|
height="6"
|
||||||
|
fill="#e7c66b"
|
||||||
|
transform={`rotate(${deg} 16 16)`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<circle cx="16" cy="16" r="2.5" fill="#6fe0d0" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconMenu({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<rect x="5" y="8" width="22" height="3" fill="#e7c66b" />
|
||||||
|
<rect x="5" y="14" width="22" height="3" fill="#6fe0d0" />
|
||||||
|
<rect x="5" y="20" width="22" height="3" fill="#8fb8ff" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconCoin({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<circle cx="16" cy="16" r="12" fill="#c9a227" stroke="#f8e6a8" strokeWidth="2.5" />
|
||||||
|
<circle cx="16" cy="16" r="9" fill="#e7c66b" stroke="#b9912f" strokeWidth="1.5" />
|
||||||
|
<text x="16" y="20" textAnchor="middle" fill="#1a1228" fontSize="11" fontWeight="bold" fontFamily="Pixelify Sans, monospace">
|
||||||
|
G
|
||||||
|
</text>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconMap({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<path d="M4 8 L12 5 L20 8 L28 5 V24 L20 21 L12 24 L4 21 Z" fill="#1b2240" stroke="#e7c66b" strokeWidth="2" />
|
||||||
|
<line x1="12" y1="5" x2="12" y2="24" stroke="rgba(111,224,208,.6)" strokeWidth="1.5" />
|
||||||
|
<line x1="20" y1="8" x2="20" y2="21" stroke="rgba(111,224,208,.6)" strokeWidth="1.5" />
|
||||||
|
<circle cx="18" cy="14" r="2" fill="#e86a52" />
|
||||||
|
<path d="M9 12 L11 10 L13 13" fill="none" stroke="#6fcf97" strokeWidth="1.5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconThermometer({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<rect x="13" y="4" width="6" height="18" fill="#161b34" stroke="#6fe0d0" strokeWidth="2" />
|
||||||
|
<circle cx="16" cy="24" r="5" fill="#e86a52" stroke="#b84a38" strokeWidth="2" />
|
||||||
|
<rect x="15" y="8" width="2" height="12" fill="#e7c66b" />
|
||||||
|
<rect x="15" y="14" width="2" height="6" fill="#6fcf97" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconWeatherFair({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="6" fill="#f3dd96" stroke="#e7c66b" strokeWidth="1.5" />
|
||||||
|
<rect x="6" y="18" width="20" height="8" fill="#8fb8ff" stroke="#6fe0d0" strokeWidth="1.5" rx="2" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconWeatherPartly({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<circle cx="11" cy="11" r="5" fill="#f3dd96" stroke="#e7c66b" strokeWidth="1.5" />
|
||||||
|
<rect x="10" y="16" width="16" height="9" fill="#6fe0d0" stroke="#3a9e92" strokeWidth="1.5" rx="2" />
|
||||||
|
<rect x="4" y="20" width="12" height="7" fill="#8fb8ff" stroke="#5a7ec4" strokeWidth="1.5" rx="2" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconWeatherCloudy({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<rect x="4" y="14" width="24" height="10" fill="#8fb8ff" stroke="#5a7ec4" strokeWidth="2" rx="3" />
|
||||||
|
<rect x="8" y="10" width="16" height="8" fill="#6fe0d0" stroke="#3a9e92" strokeWidth="1.5" rx="2" />
|
||||||
|
<line x1="8" y1="26" x2="24" y2="26" stroke="#6fcf97" strokeWidth="2" strokeDasharray="3 3" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconWeatherStorm({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<rect x="4" y="10" width="24" height="11" fill="#4a5578" stroke="#8fb8ff" strokeWidth="2" rx="3" />
|
||||||
|
<polygon points="14,22 18,22 15,28 20,28 12,28 16,22" fill="#f3dd96" stroke="#e7c66b" strokeWidth="1" />
|
||||||
|
<line x1="6" y1="8" x2="10" y2="12" stroke="#e7c66b" strokeWidth="1.5" />
|
||||||
|
<line x1="22" y1="7" x2="26" y2="11" stroke="#6fe0d0" strokeWidth="1.5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconRates({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<rect x="6" y="8" width="20" height="16" fill="#161b34" stroke="#8fb8ff" strokeWidth="2" />
|
||||||
|
<text x="16" y="20" textAnchor="middle" fill="#e7c66b" fontSize="12" fontFamily="Pixelify Sans, monospace">
|
||||||
|
%
|
||||||
|
</text>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconInflation({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<polyline points="6,24 14,14 20,18 26,6" fill="none" stroke="#e7c66b" strokeWidth="2.5" />
|
||||||
|
<polygon points="26,6 22,10 28,10" fill="#e86a52" />
|
||||||
|
<rect x="4" y="4" width="24" height="24" fill="none" stroke="#6fe0d0" strokeWidth="1.5" opacity=".4" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconLabor({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<rect x="10" y="14" width="12" height="10" fill="#2a325c" stroke="#6fcf97" strokeWidth="2" />
|
||||||
|
<rect x="8" y="10" width="16" height="6" fill="#1b2240" stroke="#e7c66b" strokeWidth="1.5" />
|
||||||
|
<rect x="12" y="6" width="8" height="5" fill="#8b5a2b" stroke="#5c3a18" strokeWidth="1" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconGrowth({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<rect x="5" y="22" width="22" height="4" fill="#242b50" />
|
||||||
|
<rect x="7" y="14" width="5" height="8" fill="#6fcf97" />
|
||||||
|
<rect x="14" y="10" width="5" height="12" fill="#e7c66b" />
|
||||||
|
<rect x="21" y="6" width="5" height="16" fill="#6fe0d0" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconMoney({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<rect x="5" y="10" width="22" height="16" fill="#1b2240" stroke="#e7c66b" strokeWidth="2" />
|
||||||
|
<rect x="8" y="6" width="16" height="6" fill="#242b50" stroke="#e7c66b" strokeWidth="1.5" />
|
||||||
|
<circle cx="16" cy="18" r="4" fill="#c9a227" stroke="#f8e6a8" strokeWidth="1.5" />
|
||||||
|
<text x="16" y="20" textAnchor="middle" fill="#1a1228" fontSize="7" fontFamily="Pixelify Sans, monospace">
|
||||||
|
$
|
||||||
|
</text>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconSentiment({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<rect x="4" y="6" width="24" height="20" fill="#161b34" stroke="#e86a52" strokeWidth="2" />
|
||||||
|
<polyline points="7,20 12,12 17,16 25,8" fill="none" stroke="#6fe0d0" strokeWidth="2" />
|
||||||
|
<circle cx="25" cy="8" r="2" fill="#e7c66b" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconWarning({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<polygon points="16,4 28,26 4,26" fill="#3d2a10" stroke="#e7c66b" strokeWidth="2" />
|
||||||
|
<rect x="14.5" y="12" width="3" height="8" fill="#f3dd96" />
|
||||||
|
<rect x="14.5" y="22" width="3" height="3" fill="#e86a52" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconKey({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<circle cx="11" cy="11" r="6" fill="none" stroke="#e7c66b" strokeWidth="2.5" />
|
||||||
|
<circle cx="11" cy="11" r="2.5" fill="#6fe0d0" />
|
||||||
|
<rect x="15" y="10" width="12" height="3" fill="#e7c66b" />
|
||||||
|
<rect x="22" y="10" width="3" height="6" fill="#e7c66b" />
|
||||||
|
<rect x="18" y="10" width="3" height="5" fill="#e7c66b" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconHammer({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<rect x="6" y="6" width="12" height="8" fill="#8b7355" stroke="#e7c66b" strokeWidth="1.5" transform="rotate(-25 12 10)" />
|
||||||
|
<rect x="14" y="12" width="4" height="14" fill="#8b5a2b" stroke="#5c3a18" strokeWidth="1.5" transform="rotate(15 16 19)" />
|
||||||
|
<rect x="4" y="22" width="10" height="3" fill="#6fe0d0" />
|
||||||
|
<rect x="18" y="24" width="8" height="3" fill="#e86a52" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconHourglass({ size = 24, className }: IconProps) {
|
||||||
|
return base({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<rect x="9" y="4" width="14" height="4" fill="#e7c66b" />
|
||||||
|
<rect x="9" y="24" width="14" height="4" fill="#e7c66b" />
|
||||||
|
<polygon points="10,8 22,8 16,16" fill="#6fe0d0" stroke="#3a9e92" strokeWidth="1" />
|
||||||
|
<polygon points="10,24 22,24 16,16" fill="#8fb8ff" stroke="#5a7ec4" strokeWidth="1" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IconName =
|
||||||
|
| "compass"
|
||||||
|
| "macro"
|
||||||
|
| "flow"
|
||||||
|
| "calendar"
|
||||||
|
| "chart"
|
||||||
|
| "world"
|
||||||
|
| "castle"
|
||||||
|
| "sword"
|
||||||
|
| "scroll"
|
||||||
|
| "cards"
|
||||||
|
| "folder"
|
||||||
|
| "wizard"
|
||||||
|
| "gear"
|
||||||
|
| "menu"
|
||||||
|
| "coin"
|
||||||
|
| "map"
|
||||||
|
| "thermometer"
|
||||||
|
| "weather-fair"
|
||||||
|
| "weather-partly"
|
||||||
|
| "weather-cloudy"
|
||||||
|
| "weather-storm"
|
||||||
|
| "rates"
|
||||||
|
| "inflation"
|
||||||
|
| "labor"
|
||||||
|
| "growth"
|
||||||
|
| "money"
|
||||||
|
| "sentiment"
|
||||||
|
| "warning"
|
||||||
|
| "key"
|
||||||
|
| "hammer"
|
||||||
|
| "hourglass";
|
||||||
|
|
||||||
|
const ICON_MAP: Record<IconName, ComponentType<IconProps>> = {
|
||||||
|
compass: IconCompass,
|
||||||
|
macro: IconMacro,
|
||||||
|
flow: IconFlow,
|
||||||
|
calendar: IconCalendar,
|
||||||
|
chart: IconChart,
|
||||||
|
world: IconWorld,
|
||||||
|
castle: IconCastle,
|
||||||
|
sword: IconSword,
|
||||||
|
scroll: IconScroll,
|
||||||
|
cards: IconCards,
|
||||||
|
folder: IconFolder,
|
||||||
|
wizard: IconWizard,
|
||||||
|
gear: IconGear,
|
||||||
|
menu: IconMenu,
|
||||||
|
coin: IconCoin,
|
||||||
|
map: IconMap,
|
||||||
|
thermometer: IconThermometer,
|
||||||
|
"weather-fair": IconWeatherFair,
|
||||||
|
"weather-partly": IconWeatherPartly,
|
||||||
|
"weather-cloudy": IconWeatherCloudy,
|
||||||
|
"weather-storm": IconWeatherStorm,
|
||||||
|
rates: IconRates,
|
||||||
|
inflation: IconInflation,
|
||||||
|
labor: IconLabor,
|
||||||
|
growth: IconGrowth,
|
||||||
|
money: IconMoney,
|
||||||
|
sentiment: IconSentiment,
|
||||||
|
warning: IconWarning,
|
||||||
|
key: IconKey,
|
||||||
|
hammer: IconHammer,
|
||||||
|
hourglass: IconHourglass,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WeatherIconKey = "fair" | "partly" | "cloudy" | "storm";
|
||||||
|
|
||||||
|
const WEATHER_MAP: Record<WeatherIconKey, IconName> = {
|
||||||
|
fair: "weather-fair",
|
||||||
|
partly: "weather-partly",
|
||||||
|
cloudy: "weather-cloudy",
|
||||||
|
storm: "weather-storm",
|
||||||
|
};
|
||||||
|
|
||||||
|
const GROUP_MAP: Record<string, IconName> = {
|
||||||
|
rates: "rates",
|
||||||
|
inflation: "inflation",
|
||||||
|
labor: "labor",
|
||||||
|
growth: "growth",
|
||||||
|
money: "money",
|
||||||
|
sentiment: "sentiment",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ROUTE_ICON: Record<string, IconName> = {
|
||||||
|
"/": "castle",
|
||||||
|
"/market": "world",
|
||||||
|
"/research": "sword",
|
||||||
|
"/skills": "scroll",
|
||||||
|
"/patterns": "cards",
|
||||||
|
"/journal": "folder",
|
||||||
|
"/profile": "wizard",
|
||||||
|
"/settings": "gear",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AppIcon({
|
||||||
|
name,
|
||||||
|
size = 22,
|
||||||
|
framed = false,
|
||||||
|
glow = false,
|
||||||
|
variant = "default",
|
||||||
|
className,
|
||||||
|
preferArt = true,
|
||||||
|
}: {
|
||||||
|
name: IconName;
|
||||||
|
size?: number;
|
||||||
|
framed?: boolean;
|
||||||
|
glow?: boolean;
|
||||||
|
variant?: "default" | "hero" | "nav";
|
||||||
|
className?: string;
|
||||||
|
/** 有 HD 插畫時優先使用(關閉則強制 SVG) */
|
||||||
|
preferArt?: boolean;
|
||||||
|
}) {
|
||||||
|
const artSrc = preferArt ? ICON_ART[name] : undefined;
|
||||||
|
const Cmp = ICON_MAP[name];
|
||||||
|
if (!Cmp && !artSrc) return null;
|
||||||
|
|
||||||
|
if (framed) {
|
||||||
|
return (
|
||||||
|
<RpgIcon size={size} src={artSrc} glow={glow} variant={variant} className={className}>
|
||||||
|
{Cmp ? <Cmp size={size} /> : null}
|
||||||
|
</RpgIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artSrc) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={artSrc}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
alt=""
|
||||||
|
className={["game-icon-photo", className].filter(Boolean).join(" ")}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Cmp size={size} className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeatherBandIcon({ kind, size = 20 }: { kind: WeatherIconKey; size?: number }) {
|
||||||
|
return <AppIcon name={WEATHER_MAP[kind]} size={size} framed variant="nav" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupIcon({ groupKey, size = 24 }: { groupKey: string; size?: number }) {
|
||||||
|
const name = GROUP_MAP[groupKey] || "chart";
|
||||||
|
return <AppIcon name={name} size={size} framed glow variant="hero" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RouteIcon({ to, size = 20, framed = true }: { to: string; size?: number; framed?: boolean }) {
|
||||||
|
const name = ROUTE_ICON[to] || "compass";
|
||||||
|
return <AppIcon name={name} size={size} framed={framed} variant="nav" />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
/**
|
||||||
|
* 金幣貓頭鷹 — RPG 立繪框(八方旅人風 NPC 頭像)
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Variant = "fab" | "chat" | "idle";
|
||||||
|
|
||||||
|
const OWL_SRC = "/mascot/guide-owl.jpg";
|
||||||
|
|
||||||
|
export default function PixelMascot({
|
||||||
|
size = 56,
|
||||||
|
blink,
|
||||||
|
variant = "fab",
|
||||||
|
}: {
|
||||||
|
size?: number;
|
||||||
|
blink?: boolean;
|
||||||
|
variant?: Variant;
|
||||||
|
}) {
|
||||||
|
const frame = size + (variant === "fab" ? 10 : 6);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={["guide-portrait", `guide-portrait--${variant}`, blink ? "blink" : ""].filter(Boolean).join(" ")}
|
||||||
|
style={{ width: frame, height: frame }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<svg className="guide-portrait-frame" viewBox="0 0 72 72" width={frame} height={frame}>
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="portraitGlow" cx="50%" cy="35%" r="58%">
|
||||||
|
<stop offset="0%" stopColor="rgba(248,230,168,.45)" />
|
||||||
|
<stop offset="100%" stopColor="rgba(10,13,24,0)" />
|
||||||
|
</radialGradient>
|
||||||
|
<clipPath id="portraitClip">
|
||||||
|
<rect x="10" y="8" width="52" height="52" rx="6" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<circle cx="36" cy="34" r="30" fill="url(#portraitGlow)" />
|
||||||
|
{/* 外框 */}
|
||||||
|
<rect x="4" y="4" width="64" height="64" rx="8" fill="#0e1224" stroke="#e7c66b" strokeWidth="3" />
|
||||||
|
<rect x="8" y="7" width="56" height="56" rx="6" fill="#12172e" stroke="rgba(231,198,107,.35)" strokeWidth="2" />
|
||||||
|
{/* 角飾 */}
|
||||||
|
<path d="M8 14 L14 8 M64 14 L58 8 M8 58 L14 64 M64 58 L58 64" stroke="#f3dd96" strokeWidth="2" strokeLinecap="square" />
|
||||||
|
<image
|
||||||
|
href={OWL_SRC}
|
||||||
|
x="10"
|
||||||
|
y="8"
|
||||||
|
width="52"
|
||||||
|
height="52"
|
||||||
|
clipPath="url(#portraitClip)"
|
||||||
|
preserveAspectRatio="xMidYMid slice"
|
||||||
|
className="guide-portrait-img"
|
||||||
|
/>
|
||||||
|
{/* 金幣角標 */}
|
||||||
|
<circle cx="58" cy="56" r="9" fill="#1a1228" stroke="#e7c66b" strokeWidth="2" />
|
||||||
|
<circle cx="58" cy="56" r="6" fill="#c9a227" stroke="#f8e6a8" strokeWidth="1" />
|
||||||
|
<text x="58" y="59" textAnchor="middle" fill="#1a1228" fontSize="8" fontWeight="bold" fontFamily="Pixelify Sans, monospace">
|
||||||
|
G
|
||||||
|
</text>
|
||||||
|
{variant === "fab" && (
|
||||||
|
<>
|
||||||
|
<circle className="guide-sparkle guide-sparkle-a" cx="14" cy="18" r="2" fill="#f8e6a8" />
|
||||||
|
<circle className="guide-sparkle guide-sparkle-b" cx="62" cy="22" r="1.5" fill="#6fe0d0" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
/** 歷史區間切換(走勢圖 / 羅盤共用) */
|
||||||
|
|
||||||
|
export function RangeChips<T extends string>({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
options: { key: T; label: string }[];
|
||||||
|
value: T;
|
||||||
|
onChange: (v: T) => void;
|
||||||
|
hint?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="range-chips-wrap">
|
||||||
|
<div className="range-chips">
|
||||||
|
{options.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.key}
|
||||||
|
type="button"
|
||||||
|
className={"range-chip" + (value === o.key ? " active" : "")}
|
||||||
|
onClick={() => onChange(o.key)}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{hint && <div className="range-chips-hint small muted">{hint}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SERIES_RANGES = [
|
||||||
|
{ key: "1y", label: "1年" },
|
||||||
|
{ key: "5y", label: "5年" },
|
||||||
|
{ key: "10y", label: "10年" },
|
||||||
|
{ key: "max", label: "最遠" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const WV_WINDOWS = [
|
||||||
|
{ key: "10y", label: "10年" },
|
||||||
|
{ key: "20y", label: "20年" },
|
||||||
|
{ key: "max", label: "最遠" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type SeriesRange = (typeof SERIES_RANGES)[number]["key"];
|
||||||
|
export type WvWindow = (typeof WV_WINDOWS)[number]["key"];
|
||||||
|
|
||||||
|
const WV_HINTS: Record<WvWindow, string> = {
|
||||||
|
"10y": "百分位與相似比對:近 10 年",
|
||||||
|
"20y": "百分位與相似比對:近 20 年",
|
||||||
|
max: "百分位與相似比對:資料庫最長歷史(多數指標約 2000 年起;聯準會利率約 2008 年起)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function wvWindowHint(w: WvWindow) {
|
||||||
|
return WV_HINTS[w];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
/** RPG 金框圖示外框(與金幣貓頭鷹立繪同系,支援 HD-2D 插畫) */
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export default function RpgIcon({
|
||||||
|
size = 24,
|
||||||
|
src,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
glow,
|
||||||
|
variant = "default",
|
||||||
|
}: {
|
||||||
|
size?: number;
|
||||||
|
/** HD-2D 插畫路徑(優先於 children) */
|
||||||
|
src?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
glow?: boolean;
|
||||||
|
variant?: "default" | "hero" | "nav";
|
||||||
|
}) {
|
||||||
|
const pad = variant === "hero" ? 12 : variant === "nav" ? 8 : 10;
|
||||||
|
const frame = size + pad;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"rpg-icon",
|
||||||
|
`rpg-icon--${variant}`,
|
||||||
|
glow ? "rpg-icon--glow" : "",
|
||||||
|
src ? "rpg-icon--art" : "",
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
style={{ width: frame, height: frame }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 72 72" width={frame} height={frame} className="rpg-icon-svg">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="rpgIconGlow" cx="50%" cy="35%" r="58%">
|
||||||
|
<stop offset="0%" stopColor="rgba(248,230,168,.5)" />
|
||||||
|
<stop offset="100%" stopColor="rgba(10,13,24,0)" />
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="rpgIconBevel" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="rgba(255,255,255,.12)" />
|
||||||
|
<stop offset="100%" stopColor="rgba(0,0,0,.25)" />
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="rpgIconClip">
|
||||||
|
<rect x="11" y="10" width="50" height="50" rx="6" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
{glow && <circle cx="36" cy="32" r="30" fill="url(#rpgIconGlow)" />}
|
||||||
|
<rect x="4" y="4" width="64" height="64" rx="8" fill="#0e1224" stroke="#e7c66b" strokeWidth="3" />
|
||||||
|
<rect x="8" y="7" width="56" height="56" rx="6" fill="#12172e" stroke="rgba(231,198,107,.35)" strokeWidth="2" />
|
||||||
|
<rect x="11" y="10" width="50" height="50" rx="6" fill="url(#rpgIconBevel)" opacity=".35" />
|
||||||
|
<path
|
||||||
|
d="M8 14 L14 8 M64 14 L58 8 M8 58 L14 64 M64 58 L58 64"
|
||||||
|
stroke="#f3dd96"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="square"
|
||||||
|
/>
|
||||||
|
{src ? (
|
||||||
|
<image
|
||||||
|
href={src}
|
||||||
|
x="11"
|
||||||
|
y="10"
|
||||||
|
width="50"
|
||||||
|
height="50"
|
||||||
|
clipPath="url(#rpgIconClip)"
|
||||||
|
preserveAspectRatio="xMidYMid slice"
|
||||||
|
className="rpg-icon-photo"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{variant === "hero" && (
|
||||||
|
<>
|
||||||
|
<circle className="rpg-sparkle rpg-sparkle-a" cx="14" cy="18" r="2" fill="#f8e6a8" />
|
||||||
|
<circle className="rpg-sparkle rpg-sparkle-b" cx="60" cy="22" r="1.5" fill="#6fe0d0" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
{!src && children ? (
|
||||||
|
<span className="rpg-icon-art" style={{ width: size, height: size }}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
createChart,
|
||||||
|
LineSeries,
|
||||||
|
ColorType,
|
||||||
|
type IChartApi,
|
||||||
|
type ISeriesApi,
|
||||||
|
type LineData,
|
||||||
|
type UTCTimestamp,
|
||||||
|
} from "lightweight-charts";
|
||||||
|
|
||||||
|
function chartPrefs() {
|
||||||
|
const w = window.innerWidth;
|
||||||
|
const compact = w <= 768;
|
||||||
|
const tiny = w <= 480;
|
||||||
|
return {
|
||||||
|
fontSize: tiny ? 9 : compact ? 10 : 11,
|
||||||
|
lineWidth: (tiny ? 1.25 : compact ? 1.5 : 2) as 1 | 2 | 3 | 4,
|
||||||
|
scaleMargins: { top: compact ? 0.16 : 0.14, bottom: compact ? 0.12 : 0.1 },
|
||||||
|
priceScaleWidth: compact ? 40 : 48,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 套用八方旅人色票的折線圖;高度由 CSS 控制,縮小時字體/線寬自動變細。
|
||||||
|
export default function SeriesChart({
|
||||||
|
data,
|
||||||
|
color = "#e7c66b",
|
||||||
|
}: {
|
||||||
|
data: { date: string; val: number }[];
|
||||||
|
height?: number;
|
||||||
|
color?: string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const chartRef = useRef<IChartApi | null>(null);
|
||||||
|
const seriesRef = useRef<ISeriesApi<"Line"> | null>(null);
|
||||||
|
|
||||||
|
const applyPrefs = useCallback(() => {
|
||||||
|
const prefs = chartPrefs();
|
||||||
|
chartRef.current?.applyOptions({
|
||||||
|
layout: { fontSize: prefs.fontSize },
|
||||||
|
rightPriceScale: {
|
||||||
|
scaleMargins: prefs.scaleMargins,
|
||||||
|
minimumWidth: prefs.priceScaleWidth,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
seriesRef.current?.applyOptions({ lineWidth: prefs.lineWidth });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const prefs = chartPrefs();
|
||||||
|
const chart = createChart(ref.current, {
|
||||||
|
height: ref.current.clientHeight || 168,
|
||||||
|
layout: {
|
||||||
|
background: { type: ColorType.Solid, color: "transparent" },
|
||||||
|
textColor: "#8b92b0",
|
||||||
|
fontFamily: "'Noto Sans TC', 'Pixelify Sans', sans-serif",
|
||||||
|
fontSize: prefs.fontSize,
|
||||||
|
attributionLogo: false,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
vertLines: { color: "rgba(231,198,107,.05)" },
|
||||||
|
horzLines: { color: "rgba(231,198,107,.05)" },
|
||||||
|
},
|
||||||
|
rightPriceScale: {
|
||||||
|
borderVisible: false,
|
||||||
|
scaleMargins: prefs.scaleMargins,
|
||||||
|
minimumWidth: prefs.priceScaleWidth,
|
||||||
|
},
|
||||||
|
timeScale: {
|
||||||
|
borderVisible: false,
|
||||||
|
fixLeftEdge: true,
|
||||||
|
fixRightEdge: true,
|
||||||
|
rightOffset: 4,
|
||||||
|
barSpacing: 0.6,
|
||||||
|
},
|
||||||
|
crosshair: {
|
||||||
|
vertLine: { color: "rgba(231,198,107,.25)", width: 1, style: 2 },
|
||||||
|
horzLine: { color: "rgba(231,198,107,.25)", width: 1, style: 2 },
|
||||||
|
},
|
||||||
|
autoSize: true,
|
||||||
|
handleScroll: false,
|
||||||
|
handleScale: false,
|
||||||
|
});
|
||||||
|
const series = chart.addSeries(LineSeries, {
|
||||||
|
color,
|
||||||
|
lineWidth: prefs.lineWidth,
|
||||||
|
priceLineVisible: false,
|
||||||
|
lastValueVisible: true,
|
||||||
|
crosshairMarkerRadius: 3,
|
||||||
|
});
|
||||||
|
chartRef.current = chart;
|
||||||
|
seriesRef.current = series;
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
const h = ref.current?.clientHeight ?? 0;
|
||||||
|
if (h > 0) chart.applyOptions({ height: h });
|
||||||
|
});
|
||||||
|
ro.observe(ref.current);
|
||||||
|
|
||||||
|
const onResize = () => applyPrefs();
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
chart.remove();
|
||||||
|
chartRef.current = null;
|
||||||
|
seriesRef.current = null;
|
||||||
|
};
|
||||||
|
}, [color, applyPrefs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!seriesRef.current) return;
|
||||||
|
const points: LineData[] = data
|
||||||
|
.filter((p) => p.val != null && !Number.isNaN(p.val))
|
||||||
|
.map((p) => ({ time: toTime(p.date), value: p.val }));
|
||||||
|
seriesRef.current.setData(points);
|
||||||
|
chartRef.current?.timeScale().fitContent();
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return <div ref={ref} className="px-chart" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTime(date: string): UTCTimestamp {
|
||||||
|
return (Date.parse(date + "T00:00:00Z") / 1000) as UTCTimestamp;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,358 @@
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { AppIcon } from "./PixelIcons";
|
||||||
|
import SkillCoachChat from "./SkillCoachChat";
|
||||||
|
import SkillDrillHistory from "./SkillDrillHistory";
|
||||||
|
import SkillDrillPanel from "./SkillDrillPanel";
|
||||||
|
import SkillNotesPanel from "./SkillNotesPanel";
|
||||||
|
import { hasCardNotesInIndex, type SkillNotesIndex } from "../lib/skillCardNotes";
|
||||||
|
import { Tag } from "./ui";
|
||||||
|
import {
|
||||||
|
masteryLabel,
|
||||||
|
parsePrincipleCard,
|
||||||
|
RARITY_LABEL,
|
||||||
|
type DrillMistakeBook,
|
||||||
|
type SkillProgressMap,
|
||||||
|
type SkillTreeNode,
|
||||||
|
} from "../lib/skillTree";
|
||||||
|
import type { AIFocus } from "../lib/ai";
|
||||||
|
|
||||||
|
type SkillTab = "detail" | "drill" | "records" | "coach" | "notes";
|
||||||
|
|
||||||
|
export default function SkillCardPanel({
|
||||||
|
node,
|
||||||
|
progress,
|
||||||
|
onClose,
|
||||||
|
onProgressChange,
|
||||||
|
onMistakeBookChange,
|
||||||
|
onNotesChange,
|
||||||
|
onAttemptRecorded,
|
||||||
|
historyRevision = 0,
|
||||||
|
hasOpenMistake = false,
|
||||||
|
notesIndex = {},
|
||||||
|
onNotesIndexChange,
|
||||||
|
initialTab = "detail",
|
||||||
|
}: {
|
||||||
|
node: SkillTreeNode | null;
|
||||||
|
progress: SkillProgressMap;
|
||||||
|
onClose: () => void;
|
||||||
|
onProgressChange: (map: SkillProgressMap) => void;
|
||||||
|
onMistakeBookChange?: (book: DrillMistakeBook) => void;
|
||||||
|
onNotesChange?: () => void;
|
||||||
|
onAttemptRecorded?: () => void;
|
||||||
|
historyRevision?: number;
|
||||||
|
hasOpenMistake?: boolean;
|
||||||
|
notesIndex?: SkillNotesIndex;
|
||||||
|
onNotesIndexChange?: (index: SkillNotesIndex) => void;
|
||||||
|
initialTab?: SkillTab;
|
||||||
|
}) {
|
||||||
|
const open = !!node;
|
||||||
|
const card = node ? parsePrincipleCard(node) : null;
|
||||||
|
const entry = node ? progress[node.principle.id] : undefined;
|
||||||
|
const [tab, setTab] = useState<SkillTab>("detail");
|
||||||
|
const [coachAutoSendToken, setCoachAutoSendToken] = useState(0);
|
||||||
|
const [notesTick, setNotesTick] = useState(0);
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setTab(initialTab);
|
||||||
|
setCoachAutoSendToken(0);
|
||||||
|
}, [open, node?.principle.id, initialTab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
document.body.classList.add("skill-modal-open");
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onCloseRef.current();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
document.body.classList.remove("skill-modal-open");
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open || !node || !card) return null;
|
||||||
|
|
||||||
|
void notesTick;
|
||||||
|
const hasNotes = hasCardNotesInIndex(notesIndex, node.principle.id);
|
||||||
|
|
||||||
|
const aiPrompt = `請用白話解釋這條投資心法,並舉一個我能記得的判斷情境(教學用,非投資建議):\n「${node.principle.title}」\n\n機制摘要:${card.mechanism || "(見正文)"}`;
|
||||||
|
const coachContextHint = [
|
||||||
|
node.principle.title,
|
||||||
|
card.mechanism,
|
||||||
|
card.triggers.length ? `觸發:${card.triggers.slice(0, 4).join(";")}` : "",
|
||||||
|
card.quote ? `引述:${card.quote}` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const aiFocus: AIFocus = {
|
||||||
|
cardTitle: card.cleanTitle,
|
||||||
|
label: card.label,
|
||||||
|
tab: "skills",
|
||||||
|
principleId: node.principle.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCoach = (autoSend = false) => {
|
||||||
|
setTab("coach");
|
||||||
|
if (autoSend) setCoachAutoSendToken((t) => t + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="skill-modal" role="dialog" aria-modal="true" aria-labelledby="skill-card-title">
|
||||||
|
<button type="button" className="skill-modal-backdrop" aria-label="關閉心法卡" onClick={onClose} />
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"skill-modal-panel",
|
||||||
|
"skill-modal-panel--tabbed",
|
||||||
|
tab === "coach" ? "skill-modal-panel--coach" : "",
|
||||||
|
`rar-${card.rarity}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
>
|
||||||
|
<div className="skill-modal-banner" aria-hidden>
|
||||||
|
<AppIcon name={node.artIcon} size={32} framed glow variant="hero" />
|
||||||
|
</div>
|
||||||
|
<div className="skill-modal-head">
|
||||||
|
<AppIcon name={node.artIcon} size={26} framed variant="hero" />
|
||||||
|
<strong>心法卡</strong>
|
||||||
|
<div className="skill-modal-tabs" role="tablist" aria-label="心法卡分頁">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === "detail"}
|
||||||
|
className={"skill-modal-tab" + (tab === "detail" ? " active" : "")}
|
||||||
|
onClick={() => setTab("detail")}
|
||||||
|
>
|
||||||
|
心法詳情
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === "drill"}
|
||||||
|
className={"skill-modal-tab" + (tab === "drill" ? " active" : "")}
|
||||||
|
onClick={() => setTab("drill")}
|
||||||
|
>
|
||||||
|
<AppIcon name="sword" size={14} framed={false} />
|
||||||
|
修煉試煉
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === "records"}
|
||||||
|
className={
|
||||||
|
"skill-modal-tab" +
|
||||||
|
(tab === "records" ? " active" : "") +
|
||||||
|
(hasOpenMistake ? " has-open-mistake" : "")
|
||||||
|
}
|
||||||
|
onClick={() => setTab("records")}
|
||||||
|
>
|
||||||
|
<AppIcon name="scroll" size={14} framed={false} />
|
||||||
|
錯題紀錄
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === "coach"}
|
||||||
|
className={"skill-modal-tab" + (tab === "coach" ? " active" : "")}
|
||||||
|
onClick={() => openCoach(false)}
|
||||||
|
>
|
||||||
|
<AppIcon name="wizard" size={14} framed={false} />
|
||||||
|
教練
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === "notes"}
|
||||||
|
className={"skill-modal-tab" + (tab === "notes" ? " active" : "") + (hasNotes ? " has-notes" : "")}
|
||||||
|
onClick={() => setTab("notes")}
|
||||||
|
>
|
||||||
|
<AppIcon name="folder" size={14} framed={false} />
|
||||||
|
筆記
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="skill-modal-close" aria-label="關閉" onClick={onClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={"skill-modal-pane skill-modal-body" + (tab === "detail" ? " active" : "")}
|
||||||
|
role="tabpanel"
|
||||||
|
hidden={tab !== "detail"}
|
||||||
|
>
|
||||||
|
<div className="skill-card-badges">
|
||||||
|
<span className={`pill-rar rar-${card.rarity}`}>{RARITY_LABEL[card.rarity] || "普通"}</span>
|
||||||
|
{node.core ? (
|
||||||
|
<Tag tone="gold">
|
||||||
|
<span className="tag-icon-inline">
|
||||||
|
<AppIcon name="key" size={14} framed={false} />
|
||||||
|
</span>
|
||||||
|
核心
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
|
<Tag tone={entry?.mastered ? "gold" : undefined}>{masteryLabel(entry)}</Tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="skill-card-title" className="skill-card-title pixel">
|
||||||
|
{card.cleanTitle}
|
||||||
|
</h2>
|
||||||
|
<div className="skill-card-meta">
|
||||||
|
{card.label} · {card.groupName}
|
||||||
|
</div>
|
||||||
|
<p className="skill-card-rarity-reason small muted">評分依據:{node.rarityReason}</p>
|
||||||
|
|
||||||
|
<div className="skill-card-actions">
|
||||||
|
<button type="button" className="btn-primary sm" onClick={() => setTab("drill")}>
|
||||||
|
開始修煉試煉
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-ghost sm" onClick={() => openCoach(true)}>
|
||||||
|
問貓頭鷹教練
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-ghost sm" onClick={() => setTab("notes")}>
|
||||||
|
寫修煉筆記
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-ghost sm" onClick={() => setTab("records")}>
|
||||||
|
錯題紀錄
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="skill-card-section">
|
||||||
|
<div className="skill-card-section-head">
|
||||||
|
<AppIcon name="hammer" size={20} framed variant="hero" />
|
||||||
|
<h3>通用機制</h3>
|
||||||
|
</div>
|
||||||
|
<p className="skill-card-text">{card.mechanism || "(見下方完整說明)"}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{card.triggers.length > 0 && (
|
||||||
|
<section className="skill-card-section">
|
||||||
|
<div className="skill-card-section-head">
|
||||||
|
<AppIcon name="compass" size={20} framed variant="hero" />
|
||||||
|
<h3>觸發訊號</h3>
|
||||||
|
</div>
|
||||||
|
<ul className="skill-card-list">
|
||||||
|
{card.triggers.map((t, i) => (
|
||||||
|
<li key={i}>{t}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{card.quote ? (
|
||||||
|
<blockquote className="editorial skill-card-quote">
|
||||||
|
「{card.quote}」
|
||||||
|
<span className="by">— 關鍵引述(跨情境適用)</span>
|
||||||
|
</blockquote>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{card.instances.length > 0 && (
|
||||||
|
<section className="skill-card-section">
|
||||||
|
<div className="skill-card-section-head">
|
||||||
|
<AppIcon name="folder" size={20} framed variant="hero" />
|
||||||
|
<h3>歷史實例 · {card.instances.length}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="skill-instance-list">
|
||||||
|
{card.instances.map((inst, i) => (
|
||||||
|
<div className="skill-instance card" key={i}>
|
||||||
|
{inst.ep ? <div className="skill-instance-ep">{inst.ep}</div> : null}
|
||||||
|
<div className="skill-instance-text">{inst.text}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="skill-card-section">
|
||||||
|
<div className="skill-card-section-head">
|
||||||
|
<AppIcon name="scroll" size={20} framed variant="hero" />
|
||||||
|
<h3>完整條文</h3>
|
||||||
|
</div>
|
||||||
|
<div className="skill-card-raw">{card.body.replace(/^#\s+.+\n?/m, "").trim()}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="skill-card-foot">
|
||||||
|
*精通需通過修煉試煉(含 K 線圖判),教練評分 95 分以上。僅供學習,不構成投資建議。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"skill-modal-pane skill-modal-body skill-modal-body--drill" + (tab === "drill" ? " active" : "")
|
||||||
|
}
|
||||||
|
role="tabpanel"
|
||||||
|
hidden={tab !== "drill"}
|
||||||
|
>
|
||||||
|
<SkillDrillPanel
|
||||||
|
key={node.principle.id}
|
||||||
|
node={node}
|
||||||
|
progressEntry={entry}
|
||||||
|
onProgressChange={onProgressChange}
|
||||||
|
onMistakeBookChange={onMistakeBookChange}
|
||||||
|
onAttemptRecorded={onAttemptRecorded}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"skill-modal-pane skill-modal-body skill-modal-body--records" +
|
||||||
|
(tab === "records" ? " active" : "")
|
||||||
|
}
|
||||||
|
role="tabpanel"
|
||||||
|
hidden={tab !== "records"}
|
||||||
|
>
|
||||||
|
<SkillDrillHistory
|
||||||
|
principleId={node.principle.id}
|
||||||
|
refreshKey={historyRevision}
|
||||||
|
onMistakeBookChange={onMistakeBookChange}
|
||||||
|
onRetryDrill={() => setTab("drill")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"skill-modal-pane skill-modal-body skill-modal-body--coach" + (tab === "coach" ? " active" : "")
|
||||||
|
}
|
||||||
|
role="tabpanel"
|
||||||
|
hidden={tab !== "coach"}
|
||||||
|
>
|
||||||
|
<SkillCoachChat
|
||||||
|
key={node.principle.id}
|
||||||
|
principleId={node.principle.id}
|
||||||
|
focus={aiFocus}
|
||||||
|
cardTitle={card.cleanTitle}
|
||||||
|
contextHint={coachContextHint}
|
||||||
|
initialQuestion={aiPrompt}
|
||||||
|
autoSendToken={coachAutoSendToken}
|
||||||
|
onNotesIndexChange={onNotesIndexChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"skill-modal-pane skill-modal-body skill-modal-body--notes" + (tab === "notes" ? " active" : "")
|
||||||
|
}
|
||||||
|
role="tabpanel"
|
||||||
|
hidden={tab !== "notes"}
|
||||||
|
>
|
||||||
|
<SkillNotesPanel
|
||||||
|
key={node.principle.id}
|
||||||
|
principleId={node.principle.id}
|
||||||
|
cardTitle={card.cleanTitle}
|
||||||
|
onNotesChange={(index) => {
|
||||||
|
onNotesIndexChange?.(index);
|
||||||
|
setNotesTick((n) => n + 1);
|
||||||
|
onNotesChange?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,242 @@
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useAI } from "../context/AIContext";
|
||||||
|
import { aiApi, type AIFocus } from "../lib/ai";
|
||||||
|
import { fetchSkillsForRoute, type AISkill } from "../lib/ai-skills";
|
||||||
|
import type { ChatMessage } from "../context/AIContext";
|
||||||
|
import {
|
||||||
|
fetchCoachChat,
|
||||||
|
sanitizeCoachMessages,
|
||||||
|
saveCoachChatApi,
|
||||||
|
type SkillNotesIndex,
|
||||||
|
} from "../lib/skillCardNotes";
|
||||||
|
import MarkdownMessage from "./MarkdownMessage";
|
||||||
|
import PixelMascot from "./PixelMascot";
|
||||||
|
|
||||||
|
export default function SkillCoachChat({
|
||||||
|
principleId,
|
||||||
|
focus,
|
||||||
|
cardTitle,
|
||||||
|
contextHint,
|
||||||
|
initialQuestion,
|
||||||
|
autoSendToken = 0,
|
||||||
|
onNotesIndexChange,
|
||||||
|
}: {
|
||||||
|
principleId: string;
|
||||||
|
focus: AIFocus;
|
||||||
|
cardTitle: string;
|
||||||
|
/** 心法摘要,確保後端 context 一定能附上卡片內容 */
|
||||||
|
contextHint?: string;
|
||||||
|
initialQuestion?: string;
|
||||||
|
/** 每次遞增會觸發一次自動解說(按「問貓頭鷹教練」) */
|
||||||
|
autoSendToken?: number;
|
||||||
|
onNotesIndexChange?: (notesIndex: SkillNotesIndex) => void;
|
||||||
|
}) {
|
||||||
|
const ai = useAI();
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [chatLoaded, setChatLoaded] = useState(false);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [skills, setSkills] = useState<AISkill[]>([]);
|
||||||
|
const chatRef = useRef<HTMLDivElement>(null);
|
||||||
|
const lastAutoTokenRef = useRef(0);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setChatLoaded(false);
|
||||||
|
setBusy(false);
|
||||||
|
setDraft("");
|
||||||
|
lastAutoTokenRef.current = 0;
|
||||||
|
void fetchCoachChat(principleId, cardTitle).then((msgs) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setMessages(msgs);
|
||||||
|
setChatLoaded(true);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [principleId, cardTitle]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chatLoaded || busy) return;
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void saveCoachChatApi(principleId, messages, cardTitle).then(({ notesIndex }) => {
|
||||||
|
onNotesIndexChange?.(notesIndex);
|
||||||
|
});
|
||||||
|
}, 450);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [principleId, cardTitle, messages, chatLoaded, busy, onNotesIndexChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
};
|
||||||
|
}, [principleId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void ai.refreshStatus();
|
||||||
|
void fetchSkillsForRoute("/skills", focus).then(setSkills);
|
||||||
|
}, [principleId, focus.cardTitle, focus.label, focus.principleId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
chatRef.current?.scrollTo({ top: chatRef.current.scrollHeight, behavior: "smooth" });
|
||||||
|
}, [messages, busy]);
|
||||||
|
|
||||||
|
const send = async (question?: string, skillId?: string) => {
|
||||||
|
const q = (question ?? draft).trim();
|
||||||
|
if (!q || busy) return;
|
||||||
|
|
||||||
|
if (!ai.status?.ready) {
|
||||||
|
setMessages((m) => [
|
||||||
|
...m,
|
||||||
|
{
|
||||||
|
id: `err-${Date.now()}`,
|
||||||
|
role: "system",
|
||||||
|
text: "尚未設定 AI API Key。請到「設定 → AI Provider」填入 Grok 或 OpenCode Go 金鑰。",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
setDraft("");
|
||||||
|
const typingId = `t-${Date.now()}`;
|
||||||
|
setMessages((m) => [
|
||||||
|
...sanitizeCoachMessages(m),
|
||||||
|
{ id: `u-${Date.now()}`, role: "user", text: q },
|
||||||
|
{ id: typingId, role: "assistant", text: "…", meta: "思考中" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ctx = await aiApi.context(
|
||||||
|
"skills",
|
||||||
|
{ ...focus, principleId, view: "skills", tab: "skills" },
|
||||||
|
{
|
||||||
|
pathname: "/skills",
|
||||||
|
visibleText: contextHint || cardTitle,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
|
||||||
|
const res = await aiApi.chat({
|
||||||
|
provider: ai.provider,
|
||||||
|
model: ai.model || undefined,
|
||||||
|
question: q,
|
||||||
|
context: ctx,
|
||||||
|
skillId,
|
||||||
|
signal: ctrl.signal,
|
||||||
|
});
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
|
||||||
|
setMessages((m) =>
|
||||||
|
m.map((msg) =>
|
||||||
|
msg.id === typingId
|
||||||
|
? {
|
||||||
|
...msg,
|
||||||
|
text: res.text?.trim() || "(AI 沒有回傳文字,請再試一次)",
|
||||||
|
meta: [
|
||||||
|
`${res.provider} · ${res.model}`,
|
||||||
|
res.mcp?.hasLiveData ? `MCP: ${res.mcp.summary || "已附即時資料"}` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · "),
|
||||||
|
}
|
||||||
|
: msg,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
const err = e instanceof Error ? e.message : String(e);
|
||||||
|
setMessages((m) =>
|
||||||
|
m.map((msg) =>
|
||||||
|
msg.id === typingId ? { ...msg, text: `呼叫失敗:${err}`, meta: "錯誤" } : msg,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (!ctrl.signal.aborted) setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoSendToken || autoSendToken === lastAutoTokenRef.current || !initialQuestion) return;
|
||||||
|
lastAutoTokenRef.current = autoSendToken;
|
||||||
|
void send(initialQuestion);
|
||||||
|
}, [autoSendToken, initialQuestion]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="skill-coach-chat" aria-label="貓頭鷹教練對話">
|
||||||
|
<div className="skill-coach-chat-head">
|
||||||
|
<PixelMascot size={48} variant="chat" />
|
||||||
|
<div>
|
||||||
|
<div className="skill-coach-chat-name">金幣貓頭鷹教練</div>
|
||||||
|
<div className="small muted">已附上這張心法卡 · {cardTitle}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!ai.status?.ready && (
|
||||||
|
<div className="skill-coach-setup">
|
||||||
|
<p>尚未設定 AI。請到設定頁填入 API Key 後即可在這裡直接問。</p>
|
||||||
|
<Link to="/settings" className="guide-setup-link">
|
||||||
|
前往 AI 設定 →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="guide-chat skill-coach-messages" ref={chatRef}>
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div key={msg.id} className={"guide-msg " + msg.role}>
|
||||||
|
{msg.role === "assistant" && <PixelMascot size={28} variant="chat" />}
|
||||||
|
<div className="guide-bubble-wrap">
|
||||||
|
<div className="guide-bubble">
|
||||||
|
<MarkdownMessage text={msg.text} />
|
||||||
|
</div>
|
||||||
|
{msg.meta && <div className="guide-meta small muted">{msg.meta}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{skills.length > 0 && (
|
||||||
|
<div className="guide-skills skill-coach-skills">
|
||||||
|
<div className="guide-skills-k">快捷提問</div>
|
||||||
|
<div className="guide-skill-row">
|
||||||
|
{skills.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
className="guide-skill"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void send(s.prompt, s.id)}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="guide-compose skill-coach-compose">
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={draft}
|
||||||
|
placeholder="問這條心法怎麼用…(Enter 送出、Shift+Enter 換行)"
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||||
|
e.preventDefault();
|
||||||
|
void send();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button type="button" className="guide-send" disabled={busy} onClick={() => void send()}>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import CandlestickChart, { type OhlcPoint } from "./CandlestickChart";
|
||||||
|
import { CHART } from "../lib/chartPalette";
|
||||||
|
|
||||||
|
const DRILL_MAS = [{ period: 20, color: CHART.teal, label: "MA20" }];
|
||||||
|
|
||||||
|
function nearestBarDate(data: OhlcPoint[], focusDate: string) {
|
||||||
|
if (!data.length) return null;
|
||||||
|
const exact = data.find((p) => p.date === focusDate);
|
||||||
|
if (exact) return exact.date;
|
||||||
|
const month = data.find((p) => p.date.startsWith(focusDate.slice(0, 7)));
|
||||||
|
if (month) return month.date;
|
||||||
|
const target = new Date(focusDate).getTime();
|
||||||
|
if (Number.isNaN(target)) return null;
|
||||||
|
let best = data[0].date;
|
||||||
|
let bestDiff = Infinity;
|
||||||
|
for (const p of data) {
|
||||||
|
const diff = Math.abs(new Date(p.date).getTime() - target);
|
||||||
|
if (diff < bestDiff) {
|
||||||
|
bestDiff = diff;
|
||||||
|
best = p.date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SkillDrillChart({
|
||||||
|
data,
|
||||||
|
focusDate,
|
||||||
|
markerLabel,
|
||||||
|
symbol,
|
||||||
|
}: {
|
||||||
|
data: OhlcPoint[];
|
||||||
|
focusDate?: string | null;
|
||||||
|
markerLabel?: string | null;
|
||||||
|
symbol: string;
|
||||||
|
}) {
|
||||||
|
const markers = useMemo(() => {
|
||||||
|
if (!focusDate || !data.length) return [];
|
||||||
|
const date = nearestBarDate(data, focusDate);
|
||||||
|
if (!date) return [];
|
||||||
|
return [{ date, type: "watch" as const, label: markerLabel || "EP 情境" }];
|
||||||
|
}, [data, focusDate, markerLabel]);
|
||||||
|
|
||||||
|
if (!data.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="skill-drill-chart-wrap">
|
||||||
|
<div className="skill-drill-chart-meta pixel">{symbol} · 近一年日 K(教學用)</div>
|
||||||
|
<CandlestickChart
|
||||||
|
data={data}
|
||||||
|
mas={DRILL_MAS}
|
||||||
|
showVolume={false}
|
||||||
|
markers={markers}
|
||||||
|
focusDate={focusDate}
|
||||||
|
heightClass="skill-drill-candle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,245 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { AppIcon } from "./PixelIcons";
|
||||||
|
import PixelMascot from "./PixelMascot";
|
||||||
|
import { Loading } from "./ui";
|
||||||
|
import { fetchDrillHistory, resolveMistakeApi, type DrillAttemptRecord } from "../lib/skillProgress";
|
||||||
|
import { MASTERY_SCORE_THRESHOLD } from "../lib/skillDrill";
|
||||||
|
import type { DrillMistakeBook } from "../lib/skillTree";
|
||||||
|
|
||||||
|
function formatWhen(iso: string) {
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString("zh-TW", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortAttempts(rows: DrillAttemptRecord[]) {
|
||||||
|
return [...rows].sort((a, b) => {
|
||||||
|
if (a.openMistake !== b.openMistake) return a.openMistake ? -1 : 1;
|
||||||
|
return new Date(b.attemptedAt).getTime() - new Date(a.attemptedAt).getTime();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNextDrill(answers?: DrillAttemptRecord["answers"]) {
|
||||||
|
if (!answers || typeof answers !== "object") return "";
|
||||||
|
const v = (answers as Record<string, string>)._nextDrill;
|
||||||
|
return typeof v === "string" ? v.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUserAnswers(answers?: DrillAttemptRecord["answers"]) {
|
||||||
|
if (!answers || typeof answers !== "object") return null;
|
||||||
|
const a = answers as Record<string, string>;
|
||||||
|
const rows = [
|
||||||
|
a.step2Reason ? { k: "第 2 關理由", v: a.step2Reason } : null,
|
||||||
|
a.step3Judgment ? { k: "第 3 關判斷", v: a.step3Judgment } : null,
|
||||||
|
a.step3Reasoning ? { k: "紀律說明", v: a.step3Reasoning } : null,
|
||||||
|
].filter(Boolean) as { k: string; v: string }[];
|
||||||
|
return rows.length ? rows : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttemptLogItem({
|
||||||
|
attempt,
|
||||||
|
onResolveAttempt,
|
||||||
|
onRetryDrill,
|
||||||
|
pinned,
|
||||||
|
}: {
|
||||||
|
attempt: DrillAttemptRecord;
|
||||||
|
onResolveAttempt?: (attemptId: number) => void;
|
||||||
|
onRetryDrill?: () => void;
|
||||||
|
pinned?: boolean;
|
||||||
|
}) {
|
||||||
|
const mastered = attempt.masteredPass || attempt.score >= MASTERY_SCORE_THRESHOLD;
|
||||||
|
const wrong = attempt.isMistake || attempt.step1Wrong || attempt.fatalErrors.length > 0;
|
||||||
|
const nextDrill = readNextDrill(attempt.answers);
|
||||||
|
const userDetail = readUserAnswers(attempt.answers);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className={
|
||||||
|
"skill-drill-log-item" +
|
||||||
|
(mastered ? " passed" : "") +
|
||||||
|
(wrong ? " wrong" : "") +
|
||||||
|
(attempt.openMistake ? " open" : "") +
|
||||||
|
(pinned ? " pinned" : "")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="skill-drill-log-meta">
|
||||||
|
<span className={"skill-drill-log-score" + (mastered ? " mastered" : "")}>{attempt.score} 分</span>
|
||||||
|
<span className="skill-drill-log-when small muted">{formatWhen(attempt.attemptedAt)}</span>
|
||||||
|
{attempt.openMistake ? <span className="skill-drill-log-badge open">待補</span> : null}
|
||||||
|
{mastered ? <span className="skill-drill-log-badge">通關</span> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{attempt.answersSummary ? (
|
||||||
|
<div className="skill-drill-log-you">
|
||||||
|
<span className="skill-drill-log-you-k">你的作答</span>
|
||||||
|
<p>{attempt.answersSummary}</p>
|
||||||
|
{userDetail ? (
|
||||||
|
<dl className="skill-drill-log-you-detail">
|
||||||
|
{userDetail.map((row) => (
|
||||||
|
<div key={row.k}>
|
||||||
|
<dt>{row.k}</dt>
|
||||||
|
<dd>{row.v}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="skill-drill-log-coach">
|
||||||
|
<PixelMascot size={32} variant="chat" />
|
||||||
|
<div className="skill-drill-log-coach-body">
|
||||||
|
<div className="skill-drill-log-coach-name">貓頭鷹教練 · 當時回饋</div>
|
||||||
|
<div className="guide-bubble-wrap">
|
||||||
|
<div className="guide-bubble">
|
||||||
|
{attempt.feedback?.trim() || "(此次試煉沒有留下文字回饋)"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{wrong ? (
|
||||||
|
<div className="skill-drill-log-wrong">
|
||||||
|
{attempt.step1Wrong ? <span className="skill-drill-history-tag">第 1 關答錯</span> : null}
|
||||||
|
{attempt.fatalErrors.map((f, i) => (
|
||||||
|
<span className="skill-drill-history-tag fatal" key={i}>
|
||||||
|
{f}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{attempt.weakPoints.length ? (
|
||||||
|
<div className="skill-drill-history-weak small">待加強:{attempt.weakPoints.join("、")}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{nextDrill ? (
|
||||||
|
<p className="skill-drill-log-next small muted">
|
||||||
|
<strong>教練建議下一步:</strong>
|
||||||
|
{nextDrill}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{attempt.openMistake ? (
|
||||||
|
<div className="skill-drill-log-actions">
|
||||||
|
{onRetryDrill ? (
|
||||||
|
<button type="button" className="btn-primary sm" onClick={onRetryDrill}>
|
||||||
|
重練這題
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{onResolveAttempt ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost sm"
|
||||||
|
onClick={() => onResolveAttempt(attempt.id)}
|
||||||
|
>
|
||||||
|
標記已掌握
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SkillDrillHistory({
|
||||||
|
principleId,
|
||||||
|
refreshKey = 0,
|
||||||
|
onMistakeBookChange,
|
||||||
|
onRetryDrill,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
principleId: string;
|
||||||
|
refreshKey?: number;
|
||||||
|
onMistakeBookChange?: (book: DrillMistakeBook) => void;
|
||||||
|
onRetryDrill?: () => void;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const historyQ = useQuery({
|
||||||
|
queryKey: ["skill-drill-history", principleId, refreshKey],
|
||||||
|
queryFn: () => fetchDrillHistory(principleId),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const attempts = useMemo(() => sortAttempts(historyQ.data || []), [historyQ.data]);
|
||||||
|
const openAttempts = attempts.filter((a) => a.openMistake);
|
||||||
|
const pastAttempts = attempts.filter((a) => !a.openMistake);
|
||||||
|
|
||||||
|
const handleResolve = (attemptId: number) => {
|
||||||
|
void resolveMistakeApi(String(attemptId)).then((book) => {
|
||||||
|
onMistakeBookChange?.(book);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skill-drill-history", principleId] });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={"skill-drill-history" + (compact ? " skill-drill-history--compact" : "")}
|
||||||
|
aria-label="修煉紀錄"
|
||||||
|
>
|
||||||
|
<div className="skill-drill-history-head">
|
||||||
|
<AppIcon name="scroll" size={20} framed variant="hero" />
|
||||||
|
<div>
|
||||||
|
<strong>修煉紀錄</strong>
|
||||||
|
<p className="small muted">
|
||||||
|
每次試煉都留在這張卡上,含你的作答與教練當時說的話,方便回頭理解。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{historyQ.isLoading ? (
|
||||||
|
<Loading label="載入修煉紀錄…" />
|
||||||
|
) : historyQ.error ? (
|
||||||
|
<p className="small muted">暫時無法載入修煉紀錄。</p>
|
||||||
|
) : !attempts.length ? (
|
||||||
|
<p className="small muted">尚無試煉紀錄,完成修煉試煉後會出現在這裡。</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{openAttempts.length ? (
|
||||||
|
<div className="skill-drill-log-open-block">
|
||||||
|
<div className="skill-drill-log-open-head">
|
||||||
|
<AppIcon name="warning" size={18} framed={false} />
|
||||||
|
<strong>待補錯題 · {openAttempts.length}</strong>
|
||||||
|
<span className="small muted">還沒解決的會固定在最上面</span>
|
||||||
|
</div>
|
||||||
|
<ol className="skill-drill-log-list">
|
||||||
|
{openAttempts.map((a) => (
|
||||||
|
<AttemptLogItem
|
||||||
|
key={a.id}
|
||||||
|
attempt={a}
|
||||||
|
pinned
|
||||||
|
onResolveAttempt={onMistakeBookChange ? handleResolve : undefined}
|
||||||
|
onRetryDrill={onRetryDrill}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{pastAttempts.length ? (
|
||||||
|
<>
|
||||||
|
{openAttempts.length ? (
|
||||||
|
<div className="skill-drill-log-past-label small muted">過往紀錄</div>
|
||||||
|
) : null}
|
||||||
|
<ol className="skill-drill-log-list">
|
||||||
|
{pastAttempts.map((a) => (
|
||||||
|
<AttemptLogItem key={a.id} attempt={a} />
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,432 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useAI } from "../context/AIContext";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { aiApi } from "../lib/ai";
|
||||||
|
import {
|
||||||
|
computeDrillChartStats,
|
||||||
|
formatChartStatsForCoach,
|
||||||
|
formatChartStatsForUser,
|
||||||
|
pricePointsToOhlc,
|
||||||
|
} from "../lib/skillDrillChart";
|
||||||
|
import { getDrillOverride } from "../lib/skillDrillBank";
|
||||||
|
import {
|
||||||
|
buildDrillPack,
|
||||||
|
formatDrillForAssess,
|
||||||
|
isStep1Correct,
|
||||||
|
MASTERY_SCORE_THRESHOLD,
|
||||||
|
parseSkillAssessResponse,
|
||||||
|
type DrillAnswers,
|
||||||
|
type SkillAssessResult,
|
||||||
|
} from "../lib/skillDrill";
|
||||||
|
import { recordDrillAttemptApi } from "../lib/skillProgress";
|
||||||
|
import {
|
||||||
|
cleanPrincipleTitle,
|
||||||
|
parsePrincipleCard,
|
||||||
|
type DrillMistakeBook,
|
||||||
|
type SkillProgressMap,
|
||||||
|
type SkillTreeNode,
|
||||||
|
} from "../lib/skillTree";
|
||||||
|
import { AppIcon } from "./PixelIcons";
|
||||||
|
import PixelMascot from "./PixelMascot";
|
||||||
|
import SkillDrillChart from "./SkillDrillChart";
|
||||||
|
import { Loading, Tag } from "./ui";
|
||||||
|
|
||||||
|
type DrillStep = 1 | 2 | 3 | "result";
|
||||||
|
|
||||||
|
const EMPTY_ANSWERS: DrillAnswers = {
|
||||||
|
step1Choice: "",
|
||||||
|
step2Action: "",
|
||||||
|
step2Reason: "",
|
||||||
|
step3Judgment: "",
|
||||||
|
step3Reasoning: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SkillDrillPanel({
|
||||||
|
node,
|
||||||
|
progressEntry,
|
||||||
|
onProgressChange,
|
||||||
|
onMistakeBookChange,
|
||||||
|
onAttemptRecorded,
|
||||||
|
}: {
|
||||||
|
node: SkillTreeNode;
|
||||||
|
progressEntry?: SkillProgressMap[string];
|
||||||
|
onProgressChange: (map: SkillProgressMap) => void;
|
||||||
|
onMistakeBookChange?: (book: DrillMistakeBook) => void;
|
||||||
|
onAttemptRecorded?: () => void;
|
||||||
|
}) {
|
||||||
|
const ai = useAI();
|
||||||
|
const card = useMemo(() => parsePrincipleCard(node), [node]);
|
||||||
|
const bankQ = useQuery({
|
||||||
|
queryKey: ["skill-drills"],
|
||||||
|
queryFn: () => api.skillDrills(),
|
||||||
|
staleTime: 3600_000,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
const pack = useMemo(() => {
|
||||||
|
const override = getDrillOverride(bankQ.data, node.principle.id);
|
||||||
|
const distractorTitles = (override?.distractorPrinciples || []).map((id) =>
|
||||||
|
cleanPrincipleTitle(id),
|
||||||
|
);
|
||||||
|
return buildDrillPack(node, card, { override, distractorTitles });
|
||||||
|
}, [node, card, bankQ.data]);
|
||||||
|
|
||||||
|
const [step, setStep] = useState<DrillStep>(1);
|
||||||
|
const [answers, setAnswers] = useState<DrillAnswers>(EMPTY_ANSWERS);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [result, setResult] = useState<SkillAssessResult | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const chartCtx = pack.step3.chart;
|
||||||
|
|
||||||
|
const priceQ = useQuery({
|
||||||
|
queryKey: ["skill-drill-price", chartCtx.symbol, chartCtx.range],
|
||||||
|
queryFn: () => api.price(chartCtx.symbol, chartCtx.range, "1d"),
|
||||||
|
enabled: step === 3,
|
||||||
|
staleTime: 600_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ohlc = useMemo(() => pricePointsToOhlc(priceQ.data?.points ?? []), [priceQ.data?.points]);
|
||||||
|
const chartStats = useMemo(() => computeDrillChartStats(ohlc), [ohlc]);
|
||||||
|
const chartUserNotes = chartStats
|
||||||
|
? formatChartStatsForUser(chartStats, chartCtx.symbolLabel)
|
||||||
|
: "";
|
||||||
|
const chartCoachNotes = formatChartStatsForCoach(chartCtx, chartStats, !!ohlc.length);
|
||||||
|
|
||||||
|
const resetDrill = () => {
|
||||||
|
setStep(1);
|
||||||
|
setAnswers(EMPTY_ANSWERS);
|
||||||
|
setResult(null);
|
||||||
|
setError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const canNext1 = !!answers.step1Choice;
|
||||||
|
const canNext2 = !!answers.step2Action && answers.step2Reason.trim().length >= 8;
|
||||||
|
const canSubmit =
|
||||||
|
answers.step3Judgment.trim().length >= 6 && answers.step3Reasoning.trim().length >= 12;
|
||||||
|
|
||||||
|
const submitAssess = async () => {
|
||||||
|
if (!canSubmit || busy) return;
|
||||||
|
if (!ai.status?.ready) {
|
||||||
|
setError("尚未設定 AI API Key,請先到設定頁填入。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const ctx = await aiApi.context(
|
||||||
|
"skills",
|
||||||
|
{
|
||||||
|
cardTitle: card.cleanTitle,
|
||||||
|
label: card.label,
|
||||||
|
tab: "skills",
|
||||||
|
principleId: node.principle.id,
|
||||||
|
view: "skills",
|
||||||
|
},
|
||||||
|
{ pathname: "/skills" },
|
||||||
|
);
|
||||||
|
const drillText = formatDrillForAssess(pack, answers, chartCoachNotes);
|
||||||
|
const res = await aiApi.skillAssess({
|
||||||
|
provider: ai.provider,
|
||||||
|
model: ai.model || undefined,
|
||||||
|
principleId: node.principle.id,
|
||||||
|
drillAnswers: drillText,
|
||||||
|
context: ctx,
|
||||||
|
});
|
||||||
|
const parsed: SkillAssessResult | null = res.assessment
|
||||||
|
? (res.assessment as unknown as SkillAssessResult)
|
||||||
|
: parseSkillAssessResponse(res.text);
|
||||||
|
if (!parsed) {
|
||||||
|
setError("教練評分解析失敗,請重試。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResult(parsed);
|
||||||
|
setStep("result");
|
||||||
|
|
||||||
|
const step1Wrong = !isStep1Correct(pack, answers);
|
||||||
|
const s1 = pack.step1.options.find((o) => o.id === answers.step1Choice);
|
||||||
|
const s2 = pack.step2.actions.find((a) => a.id === answers.step2Action);
|
||||||
|
const summary = [
|
||||||
|
`第1關:${s1?.label || "—"}${step1Wrong ? "(錯)" : ""}`,
|
||||||
|
`第2關:${s2?.label || "—"}`,
|
||||||
|
`第3關:${answers.step3Judgment.trim() || "—"}`,
|
||||||
|
].join(" · ");
|
||||||
|
|
||||||
|
const saved = await recordDrillAttemptApi({
|
||||||
|
principleId: node.principle.id,
|
||||||
|
principleTitle: node.principle.title,
|
||||||
|
result: parsed,
|
||||||
|
step1Wrong,
|
||||||
|
answersSummary: summary,
|
||||||
|
answers,
|
||||||
|
});
|
||||||
|
onProgressChange(saved.progressMap);
|
||||||
|
onMistakeBookChange?.(saved.openMistakes);
|
||||||
|
onAttemptRecorded?.();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (step === "result" && result) {
|
||||||
|
const mastered = result.score >= MASTERY_SCORE_THRESHOLD && result.fatalErrors.length === 0;
|
||||||
|
return (
|
||||||
|
<section className="skill-drill" aria-label="試煉結果">
|
||||||
|
<div className="skill-drill-result-head">
|
||||||
|
<PixelMascot size={44} variant="chat" />
|
||||||
|
<div>
|
||||||
|
<div className="skill-coach-chat-name">教練評分</div>
|
||||||
|
<div className={"skill-drill-score" + (mastered ? " mastered" : "")}>
|
||||||
|
<span className="skill-drill-score-num">{result.score}</span>
|
||||||
|
<span className="skill-drill-score-unit">/ 100</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{mastered ? (
|
||||||
|
<Tag tone="gold">精通解鎖</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag>{`距精通還差 ${Math.max(0, MASTERY_SCORE_THRESHOLD - result.score)} 分`}</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="skill-drill-feedback">{result.feedback}</p>
|
||||||
|
|
||||||
|
{result.fatalErrors.length > 0 && (
|
||||||
|
<div className="skill-drill-fatal">
|
||||||
|
<strong>致命錯誤</strong>
|
||||||
|
<ul>
|
||||||
|
{result.fatalErrors.map((f, i) => (
|
||||||
|
<li key={i}>{f}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="skill-drill-breakdown">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["mechanism", "機制理解", 25],
|
||||||
|
["trigger", "觸發辨識", 25],
|
||||||
|
["scenario", "情境判斷", 30],
|
||||||
|
["discipline", "紀律風險", 20],
|
||||||
|
] as const
|
||||||
|
).map(([key, label, max]) => {
|
||||||
|
const v = result.breakdown[key];
|
||||||
|
const pct = max ? Math.round((v / max) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div className="skill-drill-bar-row" key={key}>
|
||||||
|
<span className="skill-drill-bar-label">
|
||||||
|
{label} {v}/{max}
|
||||||
|
</span>
|
||||||
|
<div className="bar">
|
||||||
|
<span style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result.weakPoints.length > 0 && (
|
||||||
|
<div className="skill-drill-weak">
|
||||||
|
<div className="skill-drill-weak-k">待加強</div>
|
||||||
|
<ul>
|
||||||
|
{result.weakPoints.map((w, i) => (
|
||||||
|
<li key={i}>{w}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.nextDrill ? (
|
||||||
|
<p className="small muted">下一步:{result.nextDrill}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="skill-drill-actions">
|
||||||
|
<button type="button" className="btn-primary sm" onClick={resetDrill}>
|
||||||
|
{mastered ? "再練一次" : "重考試煉"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="skill-drill" aria-label="修煉試煉">
|
||||||
|
<div className="skill-drill-intro">
|
||||||
|
<AppIcon name="sword" size={22} framed variant="hero" />
|
||||||
|
<div>
|
||||||
|
<strong>修煉試煉</strong>
|
||||||
|
<p className="small muted">
|
||||||
|
三關試煉(含第 3 關 K 線圖判)由教練評分 1–100,{MASTERY_SCORE_THRESHOLD} 分以上才算精通。
|
||||||
|
{progressEntry?.bestScore != null ? ` 目前最高 ${progressEntry.bestScore} 分。` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="skill-drill-steps" aria-label="試煉進度">
|
||||||
|
{([1, 2, 3] as const).map((n) => (
|
||||||
|
<span
|
||||||
|
key={n}
|
||||||
|
className={
|
||||||
|
"skill-drill-step-pill" +
|
||||||
|
(step === n ? " active" : "") +
|
||||||
|
(typeof step === "number" && step > n ? " done" : "")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
第{n}關
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!ai.status?.ready && (
|
||||||
|
<div className="skill-coach-setup">
|
||||||
|
<p>評分需要 AI。請先到設定頁填入 API Key。</p>
|
||||||
|
<Link to="/settings" className="guide-setup-link">
|
||||||
|
前往 AI 設定 →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error ? <p className="skill-drill-error">{error}</p> : null}
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="skill-drill-stage">
|
||||||
|
<h3 className="skill-drill-prompt">{pack.step1.prompt}</h3>
|
||||||
|
<div className="skill-drill-options">
|
||||||
|
{pack.step1.options.map((opt) => (
|
||||||
|
<label key={opt.id} className="skill-drill-option">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="step1"
|
||||||
|
checked={answers.step1Choice === opt.id}
|
||||||
|
onChange={() => setAnswers((a) => ({ ...a, step1Choice: opt.id }))}
|
||||||
|
/>
|
||||||
|
<span>{opt.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary sm"
|
||||||
|
disabled={!canNext1}
|
||||||
|
onClick={() => setStep(2)}
|
||||||
|
>
|
||||||
|
下一關
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="skill-drill-stage">
|
||||||
|
<div className="skill-drill-scenario card pad-sm">
|
||||||
|
<div className="skill-instance-ep">{pack.step2.ep}</div>
|
||||||
|
<p>{pack.step2.scenario}</p>
|
||||||
|
</div>
|
||||||
|
<h3 className="skill-drill-prompt">{pack.step2.prompt}</h3>
|
||||||
|
<div className="skill-drill-options">
|
||||||
|
{pack.step2.actions.map((act) => (
|
||||||
|
<label key={act.id} className="skill-drill-option">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="step2"
|
||||||
|
checked={answers.step2Action === act.id}
|
||||||
|
onChange={() => setAnswers((a) => ({ ...a, step2Action: act.id }))}
|
||||||
|
/>
|
||||||
|
<span>{act.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<label className="skill-drill-field">
|
||||||
|
<span>一句理由(至少 8 字)</span>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
value={answers.step2Reason}
|
||||||
|
onChange={(e) => setAnswers((a) => ({ ...a, step2Reason: e.target.value }))}
|
||||||
|
placeholder="為什麼選這個動作?如何符合這條心法?"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="skill-drill-actions">
|
||||||
|
<button type="button" className="btn-ghost sm" onClick={() => setStep(1)}>
|
||||||
|
上一關
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary sm"
|
||||||
|
disabled={!canNext2}
|
||||||
|
onClick={() => setStep(3)}
|
||||||
|
>
|
||||||
|
下一關
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="skill-drill-stage">
|
||||||
|
<div className="skill-drill-chart-panel card pad-sm">
|
||||||
|
{priceQ.isLoading ? (
|
||||||
|
<Loading label="載入 K 線走勢…" />
|
||||||
|
) : priceQ.error ? (
|
||||||
|
<p className="small muted">K 線暫時無法載入,仍可依文字快照作答。</p>
|
||||||
|
) : ohlc.length ? (
|
||||||
|
<>
|
||||||
|
<SkillDrillChart
|
||||||
|
data={ohlc}
|
||||||
|
symbol={chartCtx.symbolLabel}
|
||||||
|
focusDate={chartCtx.focusDate}
|
||||||
|
markerLabel={chartCtx.markerLabel}
|
||||||
|
/>
|
||||||
|
{chartUserNotes ? (
|
||||||
|
<pre className="skill-drill-chart-stats">{chartUserNotes}</pre>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="small muted">此標的暫無足夠 K 線,請依下方文字快照判斷。</p>
|
||||||
|
)}
|
||||||
|
{chartCtx.eventHint ? (
|
||||||
|
<p className="small muted skill-drill-event-hint">歷史情境提示:{chartCtx.eventHint}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="skill-drill-snapshot card pad-sm">
|
||||||
|
<div className="skill-drill-snapshot-k">心法快照</div>
|
||||||
|
<pre>{pack.step3.snapshot}</pre>
|
||||||
|
</div>
|
||||||
|
<h3 className="skill-drill-prompt">{pack.step3.prompt}</h3>
|
||||||
|
<p className="small muted">{pack.step3.hint}</p>
|
||||||
|
<label className="skill-drill-field">
|
||||||
|
<span>你的判斷</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={answers.step3Judgment}
|
||||||
|
onChange={(e) => setAnswers((a) => ({ ...a, step3Judgment: e.target.value }))}
|
||||||
|
placeholder="例如:觸發跌就買,但先小倉試單"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="skill-drill-field">
|
||||||
|
<span>理由與紀律(至少 12 字)</span>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={answers.step3Reasoning}
|
||||||
|
onChange={(e) => setAnswers((a) => ({ ...a, step3Reasoning: e.target.value }))}
|
||||||
|
placeholder="說明總經/觸發訊號、倉位與停損觀望…"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="skill-drill-actions">
|
||||||
|
<button type="button" className="btn-ghost sm" onClick={() => setStep(2)}>
|
||||||
|
上一關
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary sm"
|
||||||
|
disabled={!canSubmit || busy}
|
||||||
|
onClick={() => void submitAssess()}
|
||||||
|
>
|
||||||
|
{busy ? "教練評分中…" : "送出 · 請教練評分"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { AppIcon } from "./PixelIcons";
|
||||||
|
import { Tag } from "./ui";
|
||||||
|
import { resolveMistakeApi } from "../lib/skillProgress";
|
||||||
|
import {
|
||||||
|
cleanPrincipleTitle,
|
||||||
|
countOpenMistakes,
|
||||||
|
type DrillMistakeBook as MistakeBook,
|
||||||
|
type SkillTreeNode,
|
||||||
|
} from "../lib/skillTree";
|
||||||
|
|
||||||
|
function formatWhen(iso: string) {
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString("zh-TW", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SkillMistakeBook({
|
||||||
|
book,
|
||||||
|
nodeById,
|
||||||
|
onBookChange,
|
||||||
|
onRetry,
|
||||||
|
onViewRecords,
|
||||||
|
}: {
|
||||||
|
book: MistakeBook;
|
||||||
|
nodeById: Map<string, SkillTreeNode>;
|
||||||
|
onBookChange: (book: MistakeBook) => void;
|
||||||
|
onRetry: (node: SkillTreeNode) => void;
|
||||||
|
onViewRecords?: (node: SkillTreeNode) => void;
|
||||||
|
}) {
|
||||||
|
const open = book.filter((m) => !m.resolved);
|
||||||
|
const openCount = countOpenMistakes(book);
|
||||||
|
|
||||||
|
if (!open.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="skill-mistake-book card pad-sm" aria-label="錯題本">
|
||||||
|
<div className="skill-mistake-head">
|
||||||
|
<AppIcon name="folder" size={22} framed variant="hero" />
|
||||||
|
<div>
|
||||||
|
<strong>錯題本</strong>
|
||||||
|
<p className="small muted">
|
||||||
|
只顯示尚未解決的錯題。試煉未達 95 分、有致命錯誤,或第 1 關答錯會收錄;完整歷史請看各卡片的答題紀錄。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{openCount ? <Tag tone="gold">{openCount} 待補</Tag> : <Tag>全部掌握</Tag>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="skill-mistake-list">
|
||||||
|
{open.map((m) => {
|
||||||
|
const node = nodeById.get(m.principleId);
|
||||||
|
const title = cleanPrincipleTitle(m.principleTitle);
|
||||||
|
return (
|
||||||
|
<li className="skill-mistake-item" key={m.id}>
|
||||||
|
<div className="skill-mistake-main">
|
||||||
|
<div className="skill-mistake-title">{title}</div>
|
||||||
|
<div className="skill-mistake-meta small muted">
|
||||||
|
{formatWhen(m.attemptedAt)} · 得分 {m.score}
|
||||||
|
{m.step1Wrong ? " · 第1關答錯" : ""}
|
||||||
|
{m.fatalErrors.length ? " · 致命錯誤" : ""}
|
||||||
|
</div>
|
||||||
|
{m.feedback ? (
|
||||||
|
<div className="skill-mistake-coach small">
|
||||||
|
<span className="skill-mistake-coach-k">教練當時說:</span>
|
||||||
|
{m.feedback}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{m.weakPoints.length ? (
|
||||||
|
<div className="skill-mistake-weak small">
|
||||||
|
待加強:{m.weakPoints.slice(0, 3).join("、")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="skill-mistake-actions">
|
||||||
|
{node && onViewRecords ? (
|
||||||
|
<button type="button" className="btn-ghost sm" onClick={() => onViewRecords(node)}>
|
||||||
|
看紀錄
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{node ? (
|
||||||
|
<button type="button" className="btn-primary sm" onClick={() => onRetry(node)}>
|
||||||
|
重練
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost sm"
|
||||||
|
onClick={() => {
|
||||||
|
void resolveMistakeApi(m.id).then(onBookChange);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
標記已掌握
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { AppIcon } from "./PixelIcons";
|
||||||
|
import {
|
||||||
|
coachLogMessages,
|
||||||
|
fetchCoachChat,
|
||||||
|
fetchPersonalNote,
|
||||||
|
savePersonalNoteApi,
|
||||||
|
type SkillNotesIndex,
|
||||||
|
} from "../lib/skillCardNotes";
|
||||||
|
|
||||||
|
function formatWhen(iso?: string) {
|
||||||
|
if (!iso) return "";
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString("zh-TW", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SkillNotesPanel({
|
||||||
|
principleId,
|
||||||
|
cardTitle,
|
||||||
|
onNotesChange,
|
||||||
|
}: {
|
||||||
|
principleId: string;
|
||||||
|
cardTitle: string;
|
||||||
|
onNotesChange?: (notesIndex: SkillNotesIndex) => void;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [loadedDraft, setLoadedDraft] = useState<string | null>(null);
|
||||||
|
const [savedAt, setSavedAt] = useState<string | undefined>();
|
||||||
|
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||||
|
const [coachMessages, setCoachMessages] = useState<Awaited<ReturnType<typeof fetchCoachChat>>>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const onNotesChangeRef = useRef(onNotesChange);
|
||||||
|
|
||||||
|
const coachLog = useMemo(() => coachLogMessages(coachMessages), [coachMessages]);
|
||||||
|
const dirty = loadedDraft !== null && draft !== loadedDraft;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onNotesChangeRef.current = onNotesChange;
|
||||||
|
}, [onNotesChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setSaveState("idle");
|
||||||
|
setLoadedDraft(null);
|
||||||
|
void Promise.all([fetchPersonalNote(principleId), fetchCoachChat(principleId, cardTitle)])
|
||||||
|
.then(([note, messages]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const text = note?.text || "";
|
||||||
|
setDraft(text);
|
||||||
|
setLoadedDraft(text);
|
||||||
|
setSavedAt(note?.updatedAt);
|
||||||
|
setCoachMessages(messages);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [principleId, cardTitle]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const reloadCoach = (e: Event) => {
|
||||||
|
const pid = (e as CustomEvent<{ principleId?: string }>).detail?.principleId;
|
||||||
|
if (!pid || pid === principleId) {
|
||||||
|
void fetchCoachChat(principleId, cardTitle).then(setCoachMessages);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("skill-coach-chat-updated", reloadCoach);
|
||||||
|
return () => window.removeEventListener("skill-coach-chat-updated", reloadCoach);
|
||||||
|
}, [principleId, cardTitle]);
|
||||||
|
|
||||||
|
const saveDraft = useCallback(
|
||||||
|
async (text: string) => {
|
||||||
|
setSaveState("saving");
|
||||||
|
try {
|
||||||
|
const { note, notesIndex } = await savePersonalNoteApi(principleId, text);
|
||||||
|
setSavedAt(note?.updatedAt);
|
||||||
|
setLoadedDraft(text);
|
||||||
|
setSaveState("saved");
|
||||||
|
onNotesChangeRef.current?.(notesIndex);
|
||||||
|
window.setTimeout(() => setSaveState("idle"), 1500);
|
||||||
|
} catch {
|
||||||
|
setSaveState("error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[principleId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || !dirty) return;
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void saveDraft(draft);
|
||||||
|
}, 600);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [draft, dirty, loading, saveDraft]);
|
||||||
|
|
||||||
|
const clipCoach = (text: string, role: "user" | "assistant") => {
|
||||||
|
const label = role === "user" ? "我問" : "教練說";
|
||||||
|
const block = `【${label}】\n${text.trim()}`;
|
||||||
|
const next = draft.trim() ? `${draft}\n\n---\n\n${block}` : block;
|
||||||
|
setDraft(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="skill-notes" aria-label="心法筆記">
|
||||||
|
<div className="skill-notes-head">
|
||||||
|
<AppIcon name="scroll" size={22} framed variant="hero" />
|
||||||
|
<div>
|
||||||
|
<strong>修煉筆記</strong>
|
||||||
|
<p className="small muted">個人心得與教練對談都儲存在後端資料庫,可一鍵摘錄進筆記。</p>
|
||||||
|
</div>
|
||||||
|
<div className="skill-notes-save">
|
||||||
|
<span className={`small ${saveState === "error" || dirty ? "skill-notes-save-alert" : "muted"}`}>
|
||||||
|
{loading
|
||||||
|
? "載入中…"
|
||||||
|
: saveState === "saving"
|
||||||
|
? "存檔中…"
|
||||||
|
: saveState === "error"
|
||||||
|
? "存檔失敗,請重試"
|
||||||
|
: dirty
|
||||||
|
? "有未存檔變更"
|
||||||
|
: saveState === "saved"
|
||||||
|
? "已寫入資料庫"
|
||||||
|
: savedAt
|
||||||
|
? `已存檔 ${formatWhen(savedAt)}`
|
||||||
|
: "尚未寫入"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary sm"
|
||||||
|
disabled={loading || saveState === "saving" || !dirty}
|
||||||
|
onClick={() => void saveDraft(draft)}
|
||||||
|
>
|
||||||
|
{saveState === "saving" ? "存檔中…" : dirty ? "存檔" : "已存檔"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="skill-notes-field">
|
||||||
|
<span className="skill-notes-label">我的心得</span>
|
||||||
|
<textarea
|
||||||
|
className="skill-notes-textarea"
|
||||||
|
rows={8}
|
||||||
|
value={draft}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="這條心法對你代表什麼?實戰情境、提醒自己的話、和教練討論後的結論…"
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (dirty && saveState !== "saving") void saveDraft(draft);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="skill-notes-coach-log">
|
||||||
|
<div className="skill-notes-coach-head">
|
||||||
|
<AppIcon name="wizard" size={18} framed variant="hero" />
|
||||||
|
<strong>教練對談紀錄</strong>
|
||||||
|
<span className="small muted">{coachLog.length ? `${coachLog.length} 則` : "尚無對話"}</span>
|
||||||
|
</div>
|
||||||
|
{!coachLog.length ? (
|
||||||
|
<p className="small muted skill-notes-empty">到「教練」分頁聊過之後,對話會出現在這裡。</p>
|
||||||
|
) : (
|
||||||
|
<ul className="skill-notes-log-list">
|
||||||
|
{coachLog.map((msg) => (
|
||||||
|
<li className={`skill-notes-log-item role-${msg.role}`} key={msg.id}>
|
||||||
|
<div className="skill-notes-log-role">{msg.role === "user" ? "我" : "教練"}</div>
|
||||||
|
<div className="skill-notes-log-body">
|
||||||
|
<p>{msg.text}</p>
|
||||||
|
{msg.meta && msg.meta !== "教練" && msg.meta !== "思考中" ? (
|
||||||
|
<span className="small muted">{msg.meta}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost sm skill-notes-clip"
|
||||||
|
onClick={() => clipCoach(msg.text, msg.role as "user" | "assistant")}
|
||||||
|
>
|
||||||
|
摘錄
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api, type SocialWatchPayload, type SocialWatchPost } from "../lib/api";
|
||||||
|
import { AppIcon } from "./PixelIcons";
|
||||||
|
import { Card, Loading, ErrorState, Tag, RefreshButton } from "./ui";
|
||||||
|
|
||||||
|
type Lang = "en" | "zh";
|
||||||
|
|
||||||
|
function impactTone(impact?: string): "up" | "down" | "gold" | "cool" {
|
||||||
|
if (impact === "critical") return "down";
|
||||||
|
if (impact === "high") return "gold";
|
||||||
|
if (impact === "medium") return "cool";
|
||||||
|
return "gold";
|
||||||
|
}
|
||||||
|
|
||||||
|
function impactLabel(impact?: string) {
|
||||||
|
if (impact === "critical") return "重大";
|
||||||
|
if (impact === "high") return "高關注";
|
||||||
|
if (impact === "medium") return "留意";
|
||||||
|
return "一般";
|
||||||
|
}
|
||||||
|
|
||||||
|
function platformLabel(p?: string) {
|
||||||
|
if (p === "truth_social") return "Truth Social";
|
||||||
|
if (p === "x") return "X";
|
||||||
|
return p || "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtWhen(iso?: string | null) {
|
||||||
|
if (!iso) return "";
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString("zh-TW", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||||
|
} catch {
|
||||||
|
return iso.slice(0, 16).replace("T", " ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtAgo(iso?: string | null) {
|
||||||
|
if (!iso) return "";
|
||||||
|
try {
|
||||||
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
|
if (diff < 60_000) return "剛剛";
|
||||||
|
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分鐘前`;
|
||||||
|
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小時前`;
|
||||||
|
return fmtWhen(iso);
|
||||||
|
} catch {
|
||||||
|
return fmtWhen(iso);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostRow({ post, lang }: { post: SocialWatchPost; lang: Lang }) {
|
||||||
|
const showZh = lang === "zh";
|
||||||
|
const body = showZh ? post.textZh || post.textEn || post.text : post.textEn || post.text;
|
||||||
|
const alt = showZh && post.textEn && post.textZh && post.textEn !== post.textZh ? post.textEn : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={`social-post social-post--${post.impact || "low"}`}>
|
||||||
|
<header className="social-post-head">
|
||||||
|
<div>
|
||||||
|
<b>{post.authorZh || post.handle}</b>
|
||||||
|
<span className="muted small">
|
||||||
|
@{post.handle} · {platformLabel(post.platform)}
|
||||||
|
{post.roleZh ? ` · ${post.roleZh}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="social-post-badges">
|
||||||
|
<Tag tone={impactTone(post.impact)}>{impactLabel(post.impact)}</Tag>
|
||||||
|
{post.publishedAt ? <time className="small muted">{fmtWhen(post.publishedAt)}</time> : null}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<p className="social-post-body">{body}</p>
|
||||||
|
{alt ? <p className="social-post-alt small muted">原文:{alt}</p> : null}
|
||||||
|
{post.needsTranslation ? <p className="social-post-alt small muted">(翻譯額度已用盡,顯示原文)</p> : null}
|
||||||
|
{post.isMediaOnly && post.media?.length ? (
|
||||||
|
<p className="social-post-alt small muted">
|
||||||
|
含媒體附件 ·{" "}
|
||||||
|
<a href={post.media[0]} target="_blank" rel="noopener noreferrer">
|
||||||
|
開啟媒體
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<footer className="social-post-foot">
|
||||||
|
{post.url ? (
|
||||||
|
<a href={post.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
查看原文
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
{post.metrics?.likes != null ? <span className="small muted">♥ {post.metrics.likes}</span> : null}
|
||||||
|
{post.metrics?.reposts != null ? <span className="small muted">↻ {post.metrics.reposts}</span> : null}
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className?: string;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SocialWatchPanel({ className = "mt", limit = 24 }: Props) {
|
||||||
|
const [lang, setLang] = useState<Lang>("zh");
|
||||||
|
const [filter, setFilter] = useState<string>("all");
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ["social-watch", lang],
|
||||||
|
queryFn: () => api.socialWatch(lang),
|
||||||
|
staleTime: 3 * 60 * 1000,
|
||||||
|
refetchInterval: 4 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleRefresh() {
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
const fresh = await api.socialWatch(lang, true);
|
||||||
|
queryClient.setQueryData(["social-watch", lang], fresh);
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: SocialWatchPayload | undefined = q.data;
|
||||||
|
const accounts = data?.accounts || [];
|
||||||
|
|
||||||
|
const filteredPosts = useMemo(() => {
|
||||||
|
const posts = (data?.posts || []).slice(0, limit);
|
||||||
|
if (filter === "all") return posts;
|
||||||
|
return posts.filter((p) => p.accountId === filter);
|
||||||
|
}, [data?.posts, filter, limit]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={className}
|
||||||
|
title="大人物動態 · 風向標"
|
||||||
|
ico={<AppIcon name="sentiment" size={24} framed variant="hero" />}
|
||||||
|
ai
|
||||||
|
aiFocus={{ cardTitle: "大人物動態", tab: "compass" }}
|
||||||
|
onRefresh={() => void handleRefresh()}
|
||||||
|
refreshing={q.isFetching || refreshing}
|
||||||
|
>
|
||||||
|
<p className="small muted mb-s">
|
||||||
|
追蹤川普 Truth Social、馬斯克/Fed/SEC 等 X 貼文。川普每 4 分鐘自動更新;點「重新整理」強制拉最新。
|
||||||
|
{data?.truthLatestAt ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
川普最新貼文:<strong>{fmtAgo(data.truthLatestAt)}</strong>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="social-watch-toolbar">
|
||||||
|
<div className="social-lang-toggle">
|
||||||
|
<button type="button" className={lang === "zh" ? "active" : ""} onClick={() => setLang("zh")}>
|
||||||
|
中文
|
||||||
|
</button>
|
||||||
|
<button type="button" className={lang === "en" ? "active" : ""} onClick={() => setLang("en")}>
|
||||||
|
原文
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<RefreshButton onClick={() => void handleRefresh()} loading={q.isFetching || refreshing} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="social-account-chips">
|
||||||
|
<button type="button" className={`social-chip ${filter === "all" ? "active" : ""}`} onClick={() => setFilter("all")}>
|
||||||
|
全部 ({data?.postCount ?? 0})
|
||||||
|
</button>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.id}
|
||||||
|
type="button"
|
||||||
|
className={`social-chip ${filter === a.id ? "active" : ""}`}
|
||||||
|
onClick={() => setFilter(a.id)}
|
||||||
|
title={a.error || undefined}
|
||||||
|
>
|
||||||
|
{a.nameZh}
|
||||||
|
{a.postCount ? ` (${a.postCount})` : a.error ? " ⚠" : ""}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{q.isLoading ? <Loading label="抓取社群動態…" /> : null}
|
||||||
|
{q.error ? <ErrorState detail={(q.error as Error)?.message} /> : null}
|
||||||
|
|
||||||
|
{!q.isLoading && !q.error ? (
|
||||||
|
filteredPosts.length ? (
|
||||||
|
<div className="social-post-list">
|
||||||
|
{filteredPosts.map((p) => (
|
||||||
|
<PostRow key={`${p.id}-${lang}`} post={p} lang={lang} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="muted small">暫無貼文(來源可能限流或帳號近期未發文)。</p>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{data?.disclaimer ? <p className="small muted mt-s">{data.disclaimer}</p> : null}
|
||||||
|
{data?.cached ? <p className="small muted">(快取資料)</p> : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { Explain } from "./ui";
|
||||||
|
import { fundTermTip } from "../lib/fundGlossary";
|
||||||
|
|
||||||
|
/** 財報名詞 + ? 說明(沿用全站 Explain,fixed 浮層不撐破版面) */
|
||||||
|
export function TermHint({ term, tip, className }: { term: string; tip?: string; className?: string }) {
|
||||||
|
const text = tip ?? fundTermTip(term);
|
||||||
|
if (!text) return <span className={className}>{term}</span>;
|
||||||
|
return (
|
||||||
|
<span className={`term-label ${className || ""}`.trim()}>
|
||||||
|
<span className="term-label-text">{term}</span>
|
||||||
|
<Explain tip={{ what: text }} label={term} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { Tag } from "./ui";
|
||||||
|
import { AppIcon } from "./PixelIcons";
|
||||||
|
import type { MacroPayload } from "../lib/api";
|
||||||
|
|
||||||
|
/** 殖利率曲線小圖:留白充足、線條偏細,縮小時用 viewBox 等比縮放 */
|
||||||
|
export default function YieldCurve({ yc }: { yc?: MacroPayload["yieldCurve"] }) {
|
||||||
|
if (!yc?.yields?.length || !yc.maturities?.length)
|
||||||
|
return <p className="muted small">暫無殖利率曲線資料。</p>;
|
||||||
|
|
||||||
|
const ys = yc.yields;
|
||||||
|
const labels = yc.maturities;
|
||||||
|
const min = Math.min(...ys, ...(yc.prevYields || []));
|
||||||
|
const max = Math.max(...ys, ...(yc.prevYields || []));
|
||||||
|
const span = max - min || 1;
|
||||||
|
|
||||||
|
const W = 360;
|
||||||
|
const H = 128;
|
||||||
|
const padL = 28;
|
||||||
|
const padR = 20;
|
||||||
|
const padT = 22;
|
||||||
|
const padB = 28;
|
||||||
|
const plotW = W - padL - padR;
|
||||||
|
const plotH = H - padT - padB;
|
||||||
|
|
||||||
|
const x = (i: number) => padL + (plotW * i) / Math.max(1, labels.length - 1);
|
||||||
|
const y = (v: number) => padT + plotH * (1 - (v - min) / span);
|
||||||
|
const path = (arr: number[]) => arr.map((v, i) => `${x(i)},${y(v)}`).join(" ");
|
||||||
|
|
||||||
|
const yTicks = 3;
|
||||||
|
const gridYs = Array.from({ length: yTicks + 1 }, (_, i) => padT + (plotH * i) / yTicks);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chart-panel chart-panel--curve">
|
||||||
|
{yc.inverted && (
|
||||||
|
<div className="chart-panel-tag">
|
||||||
|
<Tag tone="down">
|
||||||
|
<span className="tag-icon-inline">
|
||||||
|
<AppIcon name="warning" size={14} framed={false} />
|
||||||
|
</span>
|
||||||
|
殖利率倒掛
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} className="mini-chart-svg" role="img" aria-label="殖利率曲線">
|
||||||
|
{gridYs.map((gy, i) => (
|
||||||
|
<line
|
||||||
|
key={i}
|
||||||
|
x1={padL}
|
||||||
|
x2={W - padR}
|
||||||
|
y1={gy}
|
||||||
|
y2={gy}
|
||||||
|
stroke="rgba(231,198,107,.06)"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{yc.prevYields?.length === ys.length && (
|
||||||
|
<polyline
|
||||||
|
points={path(yc.prevYields)}
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(174,180,207,.35)"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<polyline
|
||||||
|
points={path(ys)}
|
||||||
|
fill="none"
|
||||||
|
stroke="#6fe0d0"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
{ys.map((v, i) => (
|
||||||
|
<g key={i}>
|
||||||
|
<circle cx={x(i)} cy={y(v)} r="2.5" fill="#e7c66b" stroke="#0c1024" strokeWidth="1" />
|
||||||
|
<text x={x(i)} y={H - 8} fontSize="8" fill="#8b92b0" textAnchor="middle" fontFamily="var(--sans)">
|
||||||
|
{labels[i]}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
<div className="chart-caption">實線=目前,虛線=約一個月前</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,533 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api, type TradeAccount, type TraderCustomConfig } from "../../lib/api";
|
||||||
|
import { aiApi, type AIProviderId } from "../../lib/ai";
|
||||||
|
import { AppIcon } from "../PixelIcons";
|
||||||
|
import { TraderPersonalityPicker } from "./TraderPersonalityPicker";
|
||||||
|
|
||||||
|
export type UniverseMode = "watchlist" | "ai_pick" | "custom";
|
||||||
|
|
||||||
|
export type AccountForm = {
|
||||||
|
name: string;
|
||||||
|
kind: "human" | "ai";
|
||||||
|
cash: string;
|
||||||
|
initialCapital: string;
|
||||||
|
aiEnabled: boolean;
|
||||||
|
simulationEnabled: boolean;
|
||||||
|
simulationIntervalMin: string;
|
||||||
|
simulationMarket: string;
|
||||||
|
universeMode: UniverseMode;
|
||||||
|
universeCustom: string;
|
||||||
|
simulationOnlyMarketHours: boolean;
|
||||||
|
aiProvider: string;
|
||||||
|
aiModel: string;
|
||||||
|
traderPersonality: string;
|
||||||
|
traderConfig: TraderCustomConfig;
|
||||||
|
simulationStrategy: string;
|
||||||
|
note: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_TRADER_CONFIG: TraderCustomConfig = {
|
||||||
|
riskScore: 5,
|
||||||
|
maxPositionPct: 30,
|
||||||
|
maxActionsPerRound: 2,
|
||||||
|
maxOpenPositions: 8,
|
||||||
|
minCashReservePct: 10,
|
||||||
|
observation: {
|
||||||
|
preMarket: ["總經、利率、美元與 VIX", "板塊輪動與候選標的新聞"],
|
||||||
|
intraday: ["持倉盈虧與停損停利", "大盤方向與量價關鍵位"],
|
||||||
|
postMarket: ["是否遵守策略與風控", "今日教訓與明日行動"],
|
||||||
|
},
|
||||||
|
tradingRules: {
|
||||||
|
entry: ["符合自訂策略且理由完整才進場"],
|
||||||
|
exit: ["假設失效或觸及停損時出場"],
|
||||||
|
sizing: ["遵守單檔與持倉檔數上限"],
|
||||||
|
forbidden: ["禁止無理由交易與違反風控"],
|
||||||
|
},
|
||||||
|
paramOverrides: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseUniverse(raw?: string | null): { mode: UniverseMode; custom: string } {
|
||||||
|
const u = String(raw || "watchlist").trim().toLowerCase();
|
||||||
|
if (u === "watchlist") return { mode: "watchlist", custom: "" };
|
||||||
|
if (u === "ai_pick" || u === "ai" || u === "auto") return { mode: "ai_pick", custom: "" };
|
||||||
|
return { mode: "custom", custom: String(raw || "").trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function accountFormFromRecord(a: TradeAccount): AccountForm {
|
||||||
|
const { mode, custom } = parseUniverse(a.simulationUniverse);
|
||||||
|
const savedConfig = a.traderConfig || {};
|
||||||
|
return {
|
||||||
|
name: a.name,
|
||||||
|
kind: a.kind === "ai" ? "ai" : "human",
|
||||||
|
cash: String(a.cash ?? 0),
|
||||||
|
initialCapital: a.initialCapital != null ? String(a.initialCapital) : "",
|
||||||
|
aiEnabled: !!a.aiEnabled,
|
||||||
|
simulationEnabled: !!a.simulationEnabled,
|
||||||
|
simulationIntervalMin: String(a.simulationIntervalMin ?? 30),
|
||||||
|
simulationMarket: a.simulationMarket || "ANY",
|
||||||
|
universeMode: mode,
|
||||||
|
universeCustom: custom,
|
||||||
|
simulationOnlyMarketHours: a.simulationOnlyMarketHours !== false,
|
||||||
|
aiProvider: a.aiProvider || "",
|
||||||
|
aiModel: a.aiModel || "",
|
||||||
|
traderPersonality: a.traderPersonality || "lynch",
|
||||||
|
traderConfig: {
|
||||||
|
...DEFAULT_TRADER_CONFIG,
|
||||||
|
...savedConfig,
|
||||||
|
observation: { ...DEFAULT_TRADER_CONFIG.observation, ...(savedConfig.observation || {}) },
|
||||||
|
tradingRules: { ...DEFAULT_TRADER_CONFIG.tradingRules, ...(savedConfig.tradingRules || {}) },
|
||||||
|
paramOverrides: { ...(savedConfig.paramOverrides || {}) },
|
||||||
|
},
|
||||||
|
simulationStrategy: a.simulationStrategy || "",
|
||||||
|
note: a.note || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function accountSaveBlockReason(form: AccountForm, saving: boolean): string | null {
|
||||||
|
if (saving) return null;
|
||||||
|
if (!form.name.trim()) return "請先填寫帳號名稱";
|
||||||
|
if (form.kind === "ai" && form.simulationEnabled && form.universeMode === "custom" && !form.universeCustom.trim()) {
|
||||||
|
return "自訂標的範圍請至少填一個代號";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canSaveAccountForm(form: AccountForm, saving: boolean): boolean {
|
||||||
|
if (saving) return false;
|
||||||
|
return !accountSaveBlockReason(form, saving);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_ACCOUNT_FORM: AccountForm = {
|
||||||
|
name: "",
|
||||||
|
kind: "human",
|
||||||
|
cash: "0",
|
||||||
|
initialCapital: "",
|
||||||
|
aiEnabled: false,
|
||||||
|
simulationEnabled: false,
|
||||||
|
simulationIntervalMin: "30",
|
||||||
|
simulationMarket: "US",
|
||||||
|
universeMode: "watchlist",
|
||||||
|
universeCustom: "",
|
||||||
|
simulationOnlyMarketHours: true,
|
||||||
|
aiProvider: "",
|
||||||
|
aiModel: "",
|
||||||
|
traderPersonality: "lynch",
|
||||||
|
traderConfig: DEFAULT_TRADER_CONFIG,
|
||||||
|
simulationStrategy: "",
|
||||||
|
note: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
function Hint({ children }: { children: React.ReactNode }) {
|
||||||
|
return <p className="journal-form-hint">{children}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="journal-form-section">
|
||||||
|
<div className="journal-form-section-title">{title}</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountSettingsModal({
|
||||||
|
open,
|
||||||
|
editing,
|
||||||
|
form,
|
||||||
|
saving,
|
||||||
|
onChange,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
onDelete,
|
||||||
|
deleteError,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
editing: TradeAccount | null;
|
||||||
|
form: AccountForm;
|
||||||
|
saving: boolean;
|
||||||
|
onChange: (patch: Partial<AccountForm>) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onDelete?: (opts: { force: boolean }) => void;
|
||||||
|
deleteError?: string | null;
|
||||||
|
}) {
|
||||||
|
const [deleteForce, setDeleteForce] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.classList.toggle("skill-modal-open", open);
|
||||||
|
return () => document.body.classList.remove("skill-modal-open");
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setDeleteForce(false);
|
||||||
|
}, [open, editing?.id]);
|
||||||
|
|
||||||
|
const purgeQ = useQuery({
|
||||||
|
queryKey: ["trade-account-purge", editing?.id],
|
||||||
|
queryFn: () => api.tradeAccountPurgeStats(editing!.id),
|
||||||
|
enabled: open && !!editing?.id,
|
||||||
|
});
|
||||||
|
const purge = purgeQ.data;
|
||||||
|
|
||||||
|
const providersQ = useQuery({
|
||||||
|
queryKey: ["trade-ai-providers"],
|
||||||
|
queryFn: api.tradeAiProviders,
|
||||||
|
enabled: open && form.kind === "ai",
|
||||||
|
});
|
||||||
|
const providers = (providersQ.data?.providers || []).filter((p) => p.hasKey);
|
||||||
|
const activeProviderId = (form.aiProvider || providers[0]?.id || "grok") as AIProviderId;
|
||||||
|
|
||||||
|
const modelsQ = useQuery({
|
||||||
|
queryKey: ["ai-models", activeProviderId],
|
||||||
|
queryFn: () => aiApi.models(activeProviderId),
|
||||||
|
enabled: open && form.kind === "ai" && providers.some((p) => p.id === activeProviderId),
|
||||||
|
staleTime: 120_000,
|
||||||
|
});
|
||||||
|
const modelOptions = modelsQ.data?.models || [];
|
||||||
|
const defaultModel = providers.find((p) => p.id === activeProviderId)?.model || modelOptions[0]?.id || "";
|
||||||
|
|
||||||
|
const effectiveModel = form.aiModel || defaultModel;
|
||||||
|
|
||||||
|
const templatesQ = useQuery({
|
||||||
|
queryKey: ["trader-personality-templates"],
|
||||||
|
queryFn: api.traderPersonalityTemplates,
|
||||||
|
enabled: open && form.kind === "ai" && form.simulationEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || form.kind !== "ai" || !form.simulationEnabled || form.simulationStrategy.trim()) return;
|
||||||
|
const templates = templatesQ.data?.templates;
|
||||||
|
if (!templates?.length) return;
|
||||||
|
const pid = form.traderPersonality || "lynch";
|
||||||
|
const t = templates.find((x) => x.id === pid) || templates[0];
|
||||||
|
if (t) onChange({ traderPersonality: t.id, simulationStrategy: t.strategy });
|
||||||
|
}, [
|
||||||
|
open,
|
||||||
|
form.kind,
|
||||||
|
form.simulationEnabled,
|
||||||
|
form.simulationStrategy,
|
||||||
|
form.traderPersonality,
|
||||||
|
templatesQ.data,
|
||||||
|
onChange,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const saveBlockReason = accountSaveBlockReason(form, saving);
|
||||||
|
const canSave = canSaveAccountForm(form, saving);
|
||||||
|
|
||||||
|
const universeBehavior = useMemo(() => {
|
||||||
|
if (form.universeMode === "watchlist") {
|
||||||
|
return "只在你 App 追蹤清單(watchlist)內找標的;盤中買賣不會超出清單。";
|
||||||
|
}
|
||||||
|
if (form.universeMode === "ai_pick") {
|
||||||
|
return "盤前研究時 AI 依總經、輪動、新聞自選 5–8 檔寫入 watch_focus;盤中只在這個池子裡交易(可含清單外標的)。";
|
||||||
|
}
|
||||||
|
return "只交易你指定的代號(逗號分隔),例如 NVDA,AAPL,2330.TW。";
|
||||||
|
}, [form.universeMode]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="skill-modal" role="dialog" aria-modal="true">
|
||||||
|
<button type="button" className="skill-modal-backdrop" aria-label="關閉" onClick={onClose} />
|
||||||
|
<div className="skill-modal-panel journal-account-modal">
|
||||||
|
<div className="skill-modal-head">
|
||||||
|
<AppIcon name="coin" size={26} framed variant="hero" />
|
||||||
|
<strong>{editing ? "帳號設定" : "新增帳號"}</strong>
|
||||||
|
<button type="button" className="guide-close" onClick={onClose} aria-label="關閉">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="journal-form-body journal-form-scroll">
|
||||||
|
<Section title="基本資料">
|
||||||
|
<div className="settings-field">
|
||||||
|
<label>帳號名稱 *</label>
|
||||||
|
<input value={form.name} onChange={(e) => onChange({ name: e.target.value })} placeholder="我自己 / 巴菲特風格 AI" />
|
||||||
|
</div>
|
||||||
|
<div className="grid g2">
|
||||||
|
<div className="settings-field">
|
||||||
|
<label>類型</label>
|
||||||
|
<select value={form.kind} onChange={(e) => onChange({ kind: e.target.value as "human" | "ai" })}>
|
||||||
|
<option value="human">自己操作</option>
|
||||||
|
<option value="ai">AI 自動投資(比績效、可跟單)</option>
|
||||||
|
</select>
|
||||||
|
<Hint>{form.kind === "ai" ? "AI 帳號會獨立跑盤前/盤中/盤後,與貓頭鷹分開。" : "手動記錄交易與復盤。"}</Hint>
|
||||||
|
</div>
|
||||||
|
<div className="settings-field">
|
||||||
|
<label>現金餘額</label>
|
||||||
|
<input
|
||||||
|
inputMode="decimal"
|
||||||
|
value={form.cash}
|
||||||
|
onChange={(e) => onChange({ cash: e.target.value })}
|
||||||
|
placeholder="用於計算股票/現金比例"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="settings-field">
|
||||||
|
<label>初始本金(選填)</label>
|
||||||
|
<input
|
||||||
|
inputMode="decimal"
|
||||||
|
value={form.initialCapital}
|
||||||
|
onChange={(e) => onChange({ initialCapital: e.target.value })}
|
||||||
|
placeholder="用來算總報酬率"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<label className="journal-mistake-check">
|
||||||
|
<input type="checkbox" checked={form.aiEnabled} onChange={(e) => onChange({ aiEnabled: e.target.checked })} />
|
||||||
|
允許 AI 依市場資料給建議與復盤
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{form.kind === "ai" ? (
|
||||||
|
<>
|
||||||
|
<label className="journal-mistake-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.simulationEnabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
simulationEnabled: e.target.checked,
|
||||||
|
aiEnabled: e.target.checked ? true : form.aiEnabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
啟用 AI 交易員(自動投資模擬)
|
||||||
|
</label>
|
||||||
|
<Hint>
|
||||||
|
交易日固定排程:美東開盤前 30 分鐘研究一次;盤中依設定間隔決策;收盤後復盤一次。週末與休市日不執行這三種分析,但每日產業報告仍固定產出並保存歷史。
|
||||||
|
產業報告會整合分析師共識、國會公開交易披露與政策訊號;未設定 FMP / Finnhub Key 時會明示國會資料缺口。
|
||||||
|
</Hint>
|
||||||
|
|
||||||
|
<Section title="AI 模型">
|
||||||
|
{providers.length === 0 ? (
|
||||||
|
<p className="journal-form-warn">請先在「設定」頁填入 AI API Key,才能選 Provider 與模型。</p>
|
||||||
|
) : null}
|
||||||
|
<div className="grid g2">
|
||||||
|
<div className="settings-field">
|
||||||
|
<label>AI Provider</label>
|
||||||
|
<select
|
||||||
|
value={form.aiProvider || providers[0]?.id || ""}
|
||||||
|
onChange={(e) => onChange({ aiProvider: e.target.value, aiModel: "" })}
|
||||||
|
>
|
||||||
|
<option value="">使用全域預設</option>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Hint>此帳號專用的 AI 後端;留空則跟隨 App 全域設定。</Hint>
|
||||||
|
</div>
|
||||||
|
<div className="settings-field">
|
||||||
|
<label>模型</label>
|
||||||
|
<select
|
||||||
|
value={effectiveModel}
|
||||||
|
disabled={modelsQ.isLoading || modelOptions.length === 0}
|
||||||
|
onChange={(e) => onChange({ aiModel: e.target.value })}
|
||||||
|
>
|
||||||
|
{modelOptions.length === 0 ? (
|
||||||
|
<option value="">{modelsQ.isLoading ? "載入模型中…" : defaultModel || "無可用模型"}</option>
|
||||||
|
) : (
|
||||||
|
modelOptions.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.id}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
<Hint>盤前研究、每日產業報告、盤中決策與盤後復盤都會用此模型。</Hint>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{form.simulationEnabled ? (
|
||||||
|
<Section title="自動投資行為">
|
||||||
|
<div className="grid g2">
|
||||||
|
<div className="settings-field">
|
||||||
|
<label>盤中輪詢間隔</label>
|
||||||
|
<select
|
||||||
|
value={form.simulationIntervalMin}
|
||||||
|
onChange={(e) => onChange({ simulationIntervalMin: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="15">每 15 分鐘</option>
|
||||||
|
<option value="30">每 30 分鐘</option>
|
||||||
|
<option value="60">每 1 小時</option>
|
||||||
|
<option value="120">每 2 小時</option>
|
||||||
|
</select>
|
||||||
|
<Hint>僅在交易時段內,依此間隔自動跑一輪盤中決策(可買賣)。</Hint>
|
||||||
|
</div>
|
||||||
|
<div className="settings-field">
|
||||||
|
<label>交易市場</label>
|
||||||
|
<select
|
||||||
|
value={form.simulationMarket}
|
||||||
|
onChange={(e) => onChange({ simulationMarket: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="US">美股(盤前 9:00–9:30 · 盤中 9:30–16:00 · 盤後 16:00–16:30 ET)</option>
|
||||||
|
<option value="ANY">美股優先,台股盤中亦可</option>
|
||||||
|
<option value="TW">僅台股(9:00–13:30)</option>
|
||||||
|
</select>
|
||||||
|
<Hint>美股盤前與盤後窗口內各只執行一次;定期輪詢只會在盤中執行。</Hint>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-field">
|
||||||
|
<label>標的範圍</label>
|
||||||
|
<select
|
||||||
|
value={form.universeMode}
|
||||||
|
onChange={(e) => onChange({ universeMode: e.target.value as UniverseMode })}
|
||||||
|
>
|
||||||
|
<option value="watchlist">追蹤清單(watchlist)</option>
|
||||||
|
<option value="ai_pick">AI 自選標的</option>
|
||||||
|
<option value="custom">自訂代號清單</option>
|
||||||
|
</select>
|
||||||
|
<Hint>{universeBehavior}</Hint>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.universeMode === "custom" ? (
|
||||||
|
<div className="settings-field">
|
||||||
|
<label>自訂代號(逗號分隔)</label>
|
||||||
|
<input
|
||||||
|
value={form.universeCustom}
|
||||||
|
onChange={(e) => onChange({ universeCustom: e.target.value })}
|
||||||
|
placeholder="NVDA,AAPL,2330.TW"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<TraderPersonalityPicker
|
||||||
|
personalityId={form.traderPersonality}
|
||||||
|
strategyText={form.simulationStrategy}
|
||||||
|
onSelect={(id, strategy, defaults) =>
|
||||||
|
onChange({
|
||||||
|
traderPersonality: id,
|
||||||
|
simulationStrategy: strategy,
|
||||||
|
traderConfig: id === "custom"
|
||||||
|
? { ...form.traderConfig, paramOverrides: form.traderConfig.paramOverrides || {} }
|
||||||
|
: { ...DEFAULT_TRADER_CONFIG, paramOverrides: {} },
|
||||||
|
...(defaults?.simulationIntervalMin
|
||||||
|
? { simulationIntervalMin: String(defaults.simulationIntervalMin) }
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onStrategyChange={(simulationStrategy) => onChange({ simulationStrategy })}
|
||||||
|
customConfig={form.traderConfig}
|
||||||
|
onCustomConfigChange={(traderConfig) => onChange({ traderConfig })}
|
||||||
|
onAccountParamChange={(key, value) => onChange({ [key]: value })}
|
||||||
|
accountParamValues={{ simulationIntervalMin: form.simulationIntervalMin }}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="settings-field">
|
||||||
|
<label>備註</label>
|
||||||
|
<textarea
|
||||||
|
className="skill-notes-textarea"
|
||||||
|
rows={2}
|
||||||
|
value={form.note}
|
||||||
|
onChange={(e) => onChange({ note: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{editing && purge ? (
|
||||||
|
<div className="account-delete-box">
|
||||||
|
<div className="account-delete-head">
|
||||||
|
<strong className="small">刪除帳號</strong>
|
||||||
|
{purge.needsForce ? (
|
||||||
|
<span className="small muted">
|
||||||
|
含 {purge.tradeCount} 筆交易、{purge.runCount} 筆模擬、{purge.memoryCount} 筆 AI 記憶
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="small muted">此帳號無模擬資料,可直接刪除</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{purge.needsForce ? (
|
||||||
|
<label className="account-delete-force small">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={deleteForce}
|
||||||
|
onChange={(e) => setDeleteForce(e.target.checked)}
|
||||||
|
/>
|
||||||
|
一併刪除所有交易、模擬紀錄與復盤記憶(無法復原)
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
{!purge.canDelete && purge.blockReason ? (
|
||||||
|
<p className="small" style={{ color: "var(--crimson)", margin: "6px 0 0" }}>
|
||||||
|
{purge.blockReason}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{deleteError ? (
|
||||||
|
<p className="small" style={{ color: "var(--crimson)", margin: "6px 0 0" }}>
|
||||||
|
{deleteError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="journal-form-footer">
|
||||||
|
{saveBlockReason ? <p className="journal-form-save-hint">{saveBlockReason}</p> : null}
|
||||||
|
<div className="settings-actions">
|
||||||
|
{editing && onDelete && purge?.canDelete ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost danger"
|
||||||
|
disabled={saving || (purge.needsForce && !deleteForce)}
|
||||||
|
onClick={() => onDelete({ force: deleteForce || !purge.needsForce })}
|
||||||
|
>
|
||||||
|
刪除帳號
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<div className="journal-form-actions">
|
||||||
|
<button type="button" className="btn-ghost" disabled={saving} onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-primary" disabled={!canSave} onClick={onSave}>
|
||||||
|
{saving ? "儲存中…" : "儲存"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function accountPayloadFromForm(form: AccountForm) {
|
||||||
|
const num = (s: string) => (s.trim() === "" ? null : Number(s));
|
||||||
|
const initialCapital = num(form.initialCapital);
|
||||||
|
let cash = num(form.cash) ?? 0;
|
||||||
|
if (form.kind === "ai" && form.simulationEnabled && initialCapital && cash === 0) {
|
||||||
|
cash = initialCapital;
|
||||||
|
}
|
||||||
|
const simulationUniverse =
|
||||||
|
form.universeMode === "custom"
|
||||||
|
? form.universeCustom.trim() || "watchlist"
|
||||||
|
: form.universeMode;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: form.name.trim(),
|
||||||
|
kind: form.kind,
|
||||||
|
cash,
|
||||||
|
initialCapital,
|
||||||
|
aiEnabled: form.aiEnabled,
|
||||||
|
simulationEnabled: form.kind === "ai" ? form.simulationEnabled : false,
|
||||||
|
simulationIntervalMin: Number(form.simulationIntervalMin) || 30,
|
||||||
|
simulationMarket: form.simulationMarket || "ANY",
|
||||||
|
simulationUniverse,
|
||||||
|
simulationOnlyMarketHours: form.simulationOnlyMarketHours,
|
||||||
|
aiProvider: form.aiProvider.trim() || null,
|
||||||
|
aiModel: form.aiModel.trim() || null,
|
||||||
|
traderPersonality: form.kind === "ai" && form.simulationEnabled ? form.traderPersonality || "custom" : null,
|
||||||
|
traderConfig: form.kind === "ai" && form.simulationEnabled
|
||||||
|
? form.traderPersonality === "custom"
|
||||||
|
? form.traderConfig
|
||||||
|
: { paramOverrides: form.traderConfig.paramOverrides || {} }
|
||||||
|
: null,
|
||||||
|
simulationStrategy:
|
||||||
|
form.kind === "ai" && form.simulationEnabled ? form.simulationStrategy.trim() || null : null,
|
||||||
|
note: form.note.trim() || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,231 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import type { TradeAccount, TradeDashboardPayload } from "../../lib/api";
|
||||||
|
import { Card } from "../ui";
|
||||||
|
import { AppIcon } from "../PixelIcons";
|
||||||
|
import EquityChart from "../EquityChart";
|
||||||
|
import { CHART } from "../../lib/chartPalette";
|
||||||
|
import { SimulationPanel } from "./SimulationPanel";
|
||||||
|
import { PositionBookPanel } from "./PositionBookPanel";
|
||||||
|
|
||||||
|
function fmtMoney(v: number | null | undefined) {
|
||||||
|
if (v == null || Number.isNaN(v)) return "—";
|
||||||
|
const sign = v > 0 ? "+" : v < 0 ? "-" : "";
|
||||||
|
return `${sign}$${Math.abs(v).toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPct(v: number | null | undefined, digits = 1) {
|
||||||
|
if (v == null || Number.isNaN(v)) return "—";
|
||||||
|
return `${v > 0 ? "+" : ""}${v.toFixed(digits)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AllocationBar({ label, pct, color, value }: { label: string; pct: number; color: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="journal-alloc-row">
|
||||||
|
<div className="journal-alloc-head">
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="muted small">
|
||||||
|
{fmtPct(pct)} · {value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="journal-alloc-track">
|
||||||
|
<div className="journal-alloc-fill" style={{ width: `${Math.min(100, Math.max(0, pct))}%`, background: color }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JournalDashboard({
|
||||||
|
dash,
|
||||||
|
accounts,
|
||||||
|
accountId,
|
||||||
|
onAccountChange,
|
||||||
|
onEditAccount,
|
||||||
|
onAddAccount,
|
||||||
|
onAiReview,
|
||||||
|
aiLoading,
|
||||||
|
onSimulatePhase,
|
||||||
|
simLoading,
|
||||||
|
}: {
|
||||||
|
dash: TradeDashboardPayload;
|
||||||
|
accounts: TradeAccount[];
|
||||||
|
accountId: number;
|
||||||
|
onAccountChange: (id: number) => void;
|
||||||
|
onEditAccount: () => void;
|
||||||
|
onAddAccount: () => void;
|
||||||
|
onAiReview?: () => void;
|
||||||
|
aiLoading?: boolean;
|
||||||
|
onSimulatePhase?: (phase: string) => void;
|
||||||
|
simLoading?: boolean;
|
||||||
|
}) {
|
||||||
|
const annualChart = useMemo(
|
||||||
|
() =>
|
||||||
|
dash.annualPnl.map((y) => ({
|
||||||
|
date: `${y.year}-12-31`,
|
||||||
|
val: y.pnl,
|
||||||
|
})),
|
||||||
|
[dash.annualPnl],
|
||||||
|
);
|
||||||
|
|
||||||
|
const account = dash.account;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="journal-account-bar">
|
||||||
|
<div className="journal-account-tabs">
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.id}
|
||||||
|
type="button"
|
||||||
|
className={`journal-account-tab${a.id === accountId ? " active" : ""}`}
|
||||||
|
style={{ "--acc-color": a.color || "var(--gold)" } as React.CSSProperties}
|
||||||
|
onClick={() => onAccountChange(a.id)}
|
||||||
|
>
|
||||||
|
<span className="journal-account-dot" />
|
||||||
|
{a.name}
|
||||||
|
{a.kind === "ai" ? <span className="journal-account-badge">AI</span> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button type="button" className="journal-account-tab add" onClick={onAddAccount}>
|
||||||
|
+ 帳號
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="journal-account-actions">
|
||||||
|
<button type="button" className="btn-ghost sm" onClick={onEditAccount}>
|
||||||
|
現金 / 設定
|
||||||
|
</button>
|
||||||
|
{account.aiEnabled && onAiReview ? (
|
||||||
|
<button type="button" className="btn-primary sm" disabled={aiLoading} onClick={onAiReview}>
|
||||||
|
{aiLoading ? "分析中…" : "AI 今日復盤"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid g4">
|
||||||
|
<div className="card kpi">
|
||||||
|
<div
|
||||||
|
className="k-v"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
dash.totalPnl > 0 ? "var(--emerald)" : dash.totalPnl < 0 ? "var(--crimson)" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fmtMoney(dash.totalPnl)}
|
||||||
|
</div>
|
||||||
|
<div className="k-l">總損益</div>
|
||||||
|
<div className="k-d small muted">
|
||||||
|
已實現 {fmtMoney(dash.realizedPnl)}
|
||||||
|
{dash.unrealizedPnl ? ` · 未實現 ${fmtMoney(dash.unrealizedPnl)}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card kpi">
|
||||||
|
<div className="k-v">{fmtMoney(dash.allocation.total)}</div>
|
||||||
|
<div className="k-l">總資產</div>
|
||||||
|
<div className="k-d small muted">
|
||||||
|
股票 {fmtPct(dash.allocation.stockPct, 0)} · 現金 {fmtPct(dash.allocation.cashPct, 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card kpi">
|
||||||
|
<div
|
||||||
|
className="k-v"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
dash.returnPct != null && dash.returnPct >= 0
|
||||||
|
? "var(--emerald)"
|
||||||
|
: dash.returnPct != null
|
||||||
|
? "var(--crimson)"
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dash.returnPct != null ? fmtPct(dash.returnPct) : "—"}
|
||||||
|
</div>
|
||||||
|
<div className="k-l">總報酬率</div>
|
||||||
|
<div className="k-d small muted">
|
||||||
|
{account.initialCapital != null ? `本金 $${account.initialCapital.toLocaleString()}` : "可設初始本金"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card kpi">
|
||||||
|
<div className="k-v" style={{ color: "var(--emerald)" }}>
|
||||||
|
{dash.stats.winRate != null ? `${dash.stats.winRate.toFixed(0)}%` : "—"}
|
||||||
|
</div>
|
||||||
|
<div className="k-l">勝率</div>
|
||||||
|
<div className="k-d small muted">{dash.stats.closed} 筆已平倉</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{account.kind === "ai" ? (
|
||||||
|
<SimulationPanel
|
||||||
|
account={account}
|
||||||
|
onRunPhase={(p) => onSimulatePhase?.(p)}
|
||||||
|
running={simLoading}
|
||||||
|
returnPct={dash.returnPct}
|
||||||
|
totalPnl={dash.totalPnl}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid g2 mt">
|
||||||
|
<Card title="專業倉位簿" ico={<AppIcon name="coin" size={22} framed variant="nav" />}>
|
||||||
|
{account.kind === "ai" && account.simulationEnabled ? (
|
||||||
|
<p className="small muted" style={{ marginBottom: 8 }}>
|
||||||
|
持倉報價與盈虧每分鐘刷新;含停損、目標、風險%、組合熱度與性格風控對照。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<AllocationBar
|
||||||
|
label="股票持倉"
|
||||||
|
pct={dash.allocation.stockPct}
|
||||||
|
color="var(--sky)"
|
||||||
|
value={fmtMoney(dash.allocation.stockValue)}
|
||||||
|
/>
|
||||||
|
<AllocationBar
|
||||||
|
label="現金"
|
||||||
|
pct={dash.allocation.cashPct}
|
||||||
|
color="var(--gold)"
|
||||||
|
value={fmtMoney(dash.allocation.cash)}
|
||||||
|
/>
|
||||||
|
<PositionBookPanel book={dash.positionBook} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="年度損益" ico={<AppIcon name="chart" size={22} framed variant="nav" />}>
|
||||||
|
{annualChart.length === 0 ? (
|
||||||
|
<p className="muted small">尚無已平倉紀錄,平倉後會依出場年份彙總。</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<EquityChart
|
||||||
|
series={[{ name: "年度損益", color: CHART.gold, points: annualChart }]}
|
||||||
|
heightClass="equity-chart equity-chart-sm"
|
||||||
|
/>
|
||||||
|
<div className="dl mt-s">
|
||||||
|
{dash.annualPnl.map((y) => (
|
||||||
|
<div className="row" key={y.year}>
|
||||||
|
<span className="k">{y.year}</span>
|
||||||
|
<span
|
||||||
|
className="v"
|
||||||
|
style={{ color: y.pnl >= 0 ? "var(--emerald)" : "var(--crimson)", fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
{fmtMoney(y.pnl)} · {y.trades} 筆
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dash.equityCurve.length > 1 ? (
|
||||||
|
<Card title="累積損益走勢" ico={<AppIcon name="flow" size={22} framed variant="nav" />} className="mt">
|
||||||
|
<EquityChart
|
||||||
|
series={[{ name: "累積損益", color: CHART.teal, points: dash.equityCurve }]}
|
||||||
|
heightClass="equity-chart"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{account.kind !== "ai" && !account.aiEnabled ? (
|
||||||
|
<p className="small muted mt">
|
||||||
|
此帳號未啟用 AI。在「現金 / 設定」中可開啟 AI 復盤,或新增 AI 模擬帳號比較績效。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
import type { PositionBookPayload } from "../../lib/api";
|
||||||
|
|
||||||
|
function fmtMoney(v: number | null | undefined) {
|
||||||
|
if (v == null || Number.isNaN(v)) return "—";
|
||||||
|
const sign = v > 0 ? "+" : v < 0 ? "-" : "";
|
||||||
|
return `${sign}$${Math.abs(v).toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPct(v: number | null | undefined, digits = 1) {
|
||||||
|
if (v == null || Number.isNaN(v)) return "—";
|
||||||
|
return `${v > 0 ? "+" : ""}${v.toFixed(digits)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPrice(v: number | null | undefined) {
|
||||||
|
if (v == null || Number.isNaN(v)) return "—";
|
||||||
|
return `$${v.toLocaleString(undefined, { maximumFractionDigits: 2 })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(status: string) {
|
||||||
|
if (status === "at_stop" || status === "overweight") return "danger";
|
||||||
|
if (status === "near_stop" || status === "underwater") return "warn";
|
||||||
|
if (status === "profitable") return "up";
|
||||||
|
return "cool";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PositionBookPanel({ book }: { book?: PositionBookPayload | null }) {
|
||||||
|
if (!book) return null;
|
||||||
|
const { summary, limits, positions, sectorBreakdown, alerts } = book;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="position-book">
|
||||||
|
<div className="position-book-summary">
|
||||||
|
<div className="position-book-kpi">
|
||||||
|
<span className="k">持倉檔數</span>
|
||||||
|
<span className="v">
|
||||||
|
{summary.openCount}
|
||||||
|
<em> / {limits.maxOpenPositions}</em>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="position-book-kpi">
|
||||||
|
<span className="k">最大單檔</span>
|
||||||
|
<span className={`v${summary.maxWeightPct > limits.maxPositionPct ? " over" : ""}`}>
|
||||||
|
{fmtPct(summary.maxWeightPct, 1)}
|
||||||
|
<em> ≤{limits.maxPositionPct}%</em>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="position-book-kpi">
|
||||||
|
<span className="k">前三集中度</span>
|
||||||
|
<span className="v">{fmtPct(summary.top3ConcentrationPct, 1)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="position-book-kpi">
|
||||||
|
<span className="k">組合風險熱度</span>
|
||||||
|
<span className="v" title="各倉停損風險加總佔總資產">
|
||||||
|
{fmtPct(summary.portfolioHeatPct, 1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="position-book-kpi">
|
||||||
|
<span className="k">現金保留</span>
|
||||||
|
<span className={`v${summary.cashPct < limits.minCashReservePct ? " under" : ""}`}>
|
||||||
|
{fmtPct(summary.cashPct, 1)}
|
||||||
|
<em> ≥{limits.minCashReservePct}%</em>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="position-book-kpi">
|
||||||
|
<span className="k">可用現金</span>
|
||||||
|
<span className="v">{fmtMoney(summary.buyingPower)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{alerts?.length ? (
|
||||||
|
<div className="position-book-alerts">
|
||||||
|
{alerts.map((a) => (
|
||||||
|
<div key={`${a.code}-${a.message}`} className={`position-book-alert position-book-alert--${a.level}`}>
|
||||||
|
{a.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{sectorBreakdown?.length ? (
|
||||||
|
<div className="position-book-sectors small muted">
|
||||||
|
產業:
|
||||||
|
{sectorBreakdown.map((s) => (
|
||||||
|
<span key={s.sector} className="position-book-sector-chip">
|
||||||
|
{s.sector} {s.weightPct.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{positions.length === 0 ? (
|
||||||
|
<p className="small muted mt-s">目前無持倉。AI 盤中買進後會顯示完整倉位表。</p>
|
||||||
|
) : (
|
||||||
|
<div className="position-book-table-wrap mt-s">
|
||||||
|
<table className="position-book-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>標的</th>
|
||||||
|
<th>張數</th>
|
||||||
|
<th>現價</th>
|
||||||
|
<th>市值</th>
|
||||||
|
<th>佔比</th>
|
||||||
|
<th>浮盈虧</th>
|
||||||
|
<th>停損</th>
|
||||||
|
<th>目標</th>
|
||||||
|
<th>風險%</th>
|
||||||
|
<th>R:R</th>
|
||||||
|
<th>狀態</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{positions.map((p) => (
|
||||||
|
<tr key={p.symbol}>
|
||||||
|
<td>
|
||||||
|
<strong>{p.symbol}</strong>
|
||||||
|
{p.sector ? <span className="position-book-sector">{p.sector}</span> : null}
|
||||||
|
{p.holdDays != null ? <span className="position-book-hold">{p.holdDays} 天</span> : null}
|
||||||
|
</td>
|
||||||
|
<td>{p.shares ?? "—"}</td>
|
||||||
|
<td>{fmtPrice(p.price)}</td>
|
||||||
|
<td>{fmtMoney(p.value)}</td>
|
||||||
|
<td>{p.weightPct != null ? `${p.weightPct.toFixed(1)}%` : "—"}</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
p.unrealizedPnl != null
|
||||||
|
? p.unrealizedPnl >= 0
|
||||||
|
? "var(--emerald)"
|
||||||
|
: "var(--crimson)"
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.unrealizedPnl != null ? fmtMoney(p.unrealizedPnl) : "—"}
|
||||||
|
{p.unrealizedPct != null ? ` (${fmtPct(p.unrealizedPct)})` : ""}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{fmtPrice(p.stopPrice)}
|
||||||
|
{p.distanceToStopPct != null ? (
|
||||||
|
<span className="position-book-dist"> -{p.distanceToStopPct}%</span>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{fmtPrice(p.targetPrice)}
|
||||||
|
{p.distanceToTargetPct != null ? (
|
||||||
|
<span className="position-book-dist"> +{p.distanceToTargetPct}%</span>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td>{p.riskPct != null ? `${p.riskPct.toFixed(1)}%` : "—"}</td>
|
||||||
|
<td>{p.riskReward != null ? `${p.riskReward}:1` : "—"}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`position-book-status position-book-status--${statusClass(p.status)}`}>
|
||||||
|
{p.statusLabel}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||