init project

This commit is contained in:
王性驊 2026-06-21 20:28:06 +00:00
commit 5b850edcca
124 changed files with 32638 additions and 0 deletions

14
.dockerignore Normal file
View File

@ -0,0 +1,14 @@
node_modules
dist
.git
.gitignore
.env
.env.*
!.env.example
data.db
data.db-*
*.log
.DS_Store
archive
docker-data
terminals

28
.env.example Normal file
View File

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

25
.gitignore vendored Normal file
View File

@ -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
*~

41
AI_SETUP.md Normal file
View File

@ -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` | 同上 + 分頁(板塊/日曆)|

39
Dockerfile Normal file
View File

@ -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"]

42
README.md Normal file
View File

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

15
config/ai/agent.md Normal file
View File

@ -0,0 +1,15 @@
你是「金幣貓頭鷹」—— 投資 RPG 世界的導覽員 NPC。
## 人設
- 語氣像 JRPG 裡的智者商人:友善、略帶俏皮,偶爾用遊戲比喻(關卡、裝備、地圖、任務)。
- 用繁體中文;句子短、好讀,避免官腔。
- 你是教學夥伴,不是財顧;結尾必要時提醒「僅供學習,不構成投資建議」。
## 回答原則
1. **有附帶頁面資料時**:優先根據 JSON 上下文回答,不捏造數字或事件。
2. **資料不足**:直接說缺什麼,建議使用者看畫面上哪張卡片或哪個分頁。
3. **一般聊天**:自然對話即可;投資問題給教學性說明。
4. **不要聲稱已即時上網查詢**;若啟用了 MCP 工具且後端有回傳工具結果,才可引用那些結果。
## 技能快捷
使用者可能點選「技能快捷」按鈕;那些是預設任務,請照任務意圖回答。

11
config/ai/mcp.json Normal file
View File

@ -0,0 +1,11 @@
{
"enabled": [
"macroscope",
"stock-scanner",
"openinsider",
"context7",
"yfinance",
"alphavantage"
],
"note": "啟用的 MCP 會由後端真實呼叫,結果附在每次對話中。"
}

View File

@ -0,0 +1 @@
[]

View File

@ -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"
}

85
config/ai/skills.json Normal file
View File

@ -0,0 +1,85 @@
{
"routes": {
"/": [
{
"id": "brief",
"label": "今日簡報",
"prompt": "幫我做一份 30 秒可讀的今日基地簡報。"
},
{
"id": "risk",
"label": "最大風險",
"prompt": "從附帶資料看,現在最值得留意的 12 個逆風是什麼?"
}
],
"/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
data/finance.db Normal file
View File

1
data/knowledge.json Normal file

File diff suppressed because one or more lines are too long

1
data/notes.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -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"
}
}
]
}

2045
data/patterns/catalog.json Normal file

File diff suppressed because it is too large Load Diff

1435
data/skill-drills.json Normal file

File diff suppressed because it is too large Load Diff

54
docker-compose.yml Normal file
View File

@ -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:

17
docker/entrypoint.sh Executable file
View File

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

19
index.html Normal file
View File

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

37
nginx/nginx.conf Normal file
View File

@ -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;
}
}

5390
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
package.json Normal file
View File

@ -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"
}
}

BIN
public/icons/calendar.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

BIN
public/icons/cards.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

BIN
public/icons/castle.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

BIN
public/icons/chart.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

BIN
public/icons/coin.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

BIN
public/icons/compass.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

BIN
public/icons/flow.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

BIN
public/icons/folder.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

BIN
public/icons/gear.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

BIN
public/icons/growth.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

BIN
public/icons/hammer.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

BIN
public/icons/hourglass.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

BIN
public/icons/inflation.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

BIN
public/icons/key.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

BIN
public/icons/labor.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

BIN
public/icons/macro.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

BIN
public/icons/map.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

BIN
public/icons/money.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

BIN
public/icons/rates.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

BIN
public/icons/scroll.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

BIN
public/icons/sentiment.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

BIN
public/icons/sword.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

BIN
public/icons/warning.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

BIN
public/icons/wizard.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

BIN
public/icons/world.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

BIN
public/mascot/guide-owl.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

175
scripts/build-knowledge.mjs Normal file
View File

@ -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`);

View File

@ -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();

View File

@ -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();

46
scripts/deploy.sh Executable file
View File

@ -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#*@}/"

46
scripts/dev-all.mjs Normal file
View File

@ -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");

94
scripts/remote-bootstrap.sh Executable file
View File

@ -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}"

View File

@ -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();

2438
server.js Normal file

File diff suppressed because it is too large Load Diff

31
src/App.tsx Normal file
View File

@ -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>
);
}

View File

@ -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 PromptUser PromptContext 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}
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

248
src/components/Chrome.tsx Normal file
View File

@ -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>
);
}

View File

@ -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 4AD</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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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";
}

View File

@ -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+
UFONASAARKX / ETF QQQIWMSMH / 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>
);
}

View File

@ -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}
</>
);
}

58
src/components/Gauges.tsx Normal file
View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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";
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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" />;
}

View File

@ -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>
);
}

View File

@ -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];
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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 1100{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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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 SocialFedSEC 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>
);
}

View File

@ -0,0 +1,14 @@
import { Explain } from "./ui";
import { fundTermTip } from "../lib/fundGlossary";
/** 財報名詞 + ? 說明(沿用全站 Explainfixed 浮層不撐破版面) */
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>
);
}

View File

@ -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>
);
}

View File

@ -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 依總經、輪動、新聞自選 58 檔寫入 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:009:30 · 9:3016:00 · 16:0016:30 ET</option>
<option value="ANY"></option>
<option value="TW">9:0013: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,
};
}

View File

@ -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}
</>
);
}

View File

@ -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>
);
}

Some files were not shown because too many files have changed in this diff Show More