Spaces:
Running
Running
Commit ·
b72deed
1
Parent(s): 6c4b505
feat: market tabs (台股/美股/基金), autocomplete search, favorites filter tabs
Browse filesFrontend:
- Header: add 台股🇹🇼/美股🇺🇸/基金📊 market tabs + autocomplete dropdown (350ms debounce)
- FavoritesPage: add market filter tabs (全部/台股/美股/基金) with counts
- App: add market state, pass market to Header and store in favorites on add
- useFirestoreFavorites: persist market field in Firestore
- stockApi: add market param to searchStocks
Backend:
- data/fund_fetcher.py: MoneyDJ NAV fetcher with hardcoded list
(PIM91/PIM90/ALBL2/ALBL4/PIM95/FLZI4/TLZ64)
- data/fetcher.py: route fund codes through fund_fetcher before yfinance
- routers/stock.py: use search_funds for fund market search
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- data/fetcher.py +12 -0
- data/fund_fetcher.py +274 -0
- frontend/src/App.tsx +364 -0
- frontend/src/api/stockApi.ts +99 -0
- frontend/src/components/CandlestickChart.tsx +152 -0
- frontend/src/components/ChartCard.tsx +21 -0
- frontend/src/components/FavoritesPage.tsx +201 -0
- frontend/src/components/Header.tsx +267 -0
- frontend/src/components/Login.tsx +203 -0
- frontend/src/components/MACDChart.tsx +111 -0
- frontend/src/components/PredictionPanel.tsx +184 -0
- frontend/src/components/RSIChart.tsx +96 -0
- frontend/src/components/Sidebar.tsx +75 -0
- frontend/src/components/VolumeChart.tsx +71 -0
- frontend/src/firebase.ts +31 -0
- frontend/src/hooks/useFirestoreFavorites.ts +64 -0
- frontend/src/index.css +5 -0
- frontend/src/main.tsx +8 -0
- routers/stock.py +4 -1
- static/assets/{index-DpNIV5k-.css → index-BnFVDQ43.css} +1 -1
- static/assets/{index-DsCDWin_.js → index-BrxmzIxi.js} +0 -0
- static/index.html +2 -2
data/fetcher.py
CHANGED
|
@@ -10,6 +10,8 @@ from typing import Optional
|
|
| 10 |
import pandas as pd
|
| 11 |
import yfinance as yf
|
| 12 |
|
|
|
|
|
|
|
| 13 |
logger = logging.getLogger(__name__)
|
| 14 |
|
| 15 |
|
|
@@ -122,6 +124,9 @@ def fetch_history(stock_no: str, months: int = 24) -> pd.DataFrame:
|
|
| 122 |
date (datetime.date), open, high, low, close, volume (float)
|
| 123 |
Sorted ascending by date, no duplicates.
|
| 124 |
"""
|
|
|
|
|
|
|
|
|
|
| 125 |
if is_us_ticker(stock_no):
|
| 126 |
ticker = stock_no.upper().strip()
|
| 127 |
period = f"{months}mo"
|
|
@@ -229,6 +234,9 @@ def get_stock_info(stock_no: str) -> dict:
|
|
| 229 |
Return basic stock info: name, exchange, stock_no.
|
| 230 |
Uses yfinance Ticker.info for metadata, with exchange auto-detection fallback.
|
| 231 |
"""
|
|
|
|
|
|
|
|
|
|
| 232 |
if is_us_ticker(stock_no):
|
| 233 |
ticker = stock_no.upper().strip()
|
| 234 |
try:
|
|
@@ -273,6 +281,10 @@ def get_stock_info(stock_no: str) -> dict:
|
|
| 273 |
def get_current_price(stock_no: str) -> Optional[float]:
|
| 274 |
"""Return the most recent closing price."""
|
| 275 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
if is_us_ticker(stock_no):
|
| 277 |
ticker = stock_no.upper().strip()
|
| 278 |
t = yf.Ticker(ticker)
|
|
|
|
| 10 |
import pandas as pd
|
| 11 |
import yfinance as yf
|
| 12 |
|
| 13 |
+
from data.fund_fetcher import is_moneydj_fund, fetch_fund_history, get_fund_info as _get_fund_info_moneydj
|
| 14 |
+
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
| 17 |
|
|
|
|
| 124 |
date (datetime.date), open, high, low, close, volume (float)
|
| 125 |
Sorted ascending by date, no duplicates.
|
| 126 |
"""
|
| 127 |
+
if is_moneydj_fund(stock_no):
|
| 128 |
+
return fetch_fund_history(stock_no, months)
|
| 129 |
+
|
| 130 |
if is_us_ticker(stock_no):
|
| 131 |
ticker = stock_no.upper().strip()
|
| 132 |
period = f"{months}mo"
|
|
|
|
| 234 |
Return basic stock info: name, exchange, stock_no.
|
| 235 |
Uses yfinance Ticker.info for metadata, with exchange auto-detection fallback.
|
| 236 |
"""
|
| 237 |
+
if is_moneydj_fund(stock_no):
|
| 238 |
+
return _get_fund_info_moneydj(stock_no)
|
| 239 |
+
|
| 240 |
if is_us_ticker(stock_no):
|
| 241 |
ticker = stock_no.upper().strip()
|
| 242 |
try:
|
|
|
|
| 281 |
def get_current_price(stock_no: str) -> Optional[float]:
|
| 282 |
"""Return the most recent closing price."""
|
| 283 |
try:
|
| 284 |
+
if is_moneydj_fund(stock_no):
|
| 285 |
+
info = _get_fund_info_moneydj(stock_no)
|
| 286 |
+
return info.get("current_price")
|
| 287 |
+
|
| 288 |
if is_us_ticker(stock_no):
|
| 289 |
ticker = stock_no.upper().strip()
|
| 290 |
t = yf.Ticker(ticker)
|
data/fund_fetcher.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MoneyDJ 基金爬蟲 — 支援台灣境外基金代號 (如 PIM91)
|
| 3 |
+
取得歷史 NAV 淨值,轉為 OHLCV 格式供 ML 模型使用
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import re
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
import time
|
| 10 |
+
|
| 11 |
+
import requests
|
| 12 |
+
import pandas as pd
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
# MoneyDJ 代號格式: 2~5 字母 + 2~3 數字 (e.g. PIM91, JPM12, ALBL4, TLZ64)
|
| 18 |
+
_MONEYDJ_RE = re.compile(r'^[A-Za-z]{2,5}\d{2,3}$')
|
| 19 |
+
|
| 20 |
+
# Hardcoded known funds (fallback names when MoneyDJ unreachable)
|
| 21 |
+
KNOWN_FUNDS: dict[str, str] = {
|
| 22 |
+
"PIM91": "PIMCO 全球債券基金",
|
| 23 |
+
"PIM90": "PIMCO 投資級債券基金",
|
| 24 |
+
"PIM95": "PIMCO 高收益債券基金",
|
| 25 |
+
"ALBL2": "聯博全球高收益債券基金",
|
| 26 |
+
"ALBL4": "聯博美元短期債券基金",
|
| 27 |
+
"FLZI4": "富達美元利率基金",
|
| 28 |
+
"TLZ64": "富達美元利率基金 II",
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
_BROWSER_HEADERS = {
|
| 32 |
+
"User-Agent": (
|
| 33 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
| 34 |
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
| 35 |
+
"Chrome/124.0.0.0 Safari/537.36"
|
| 36 |
+
),
|
| 37 |
+
"Accept-Language": "zh-TW,zh;q=0.9,en;q=0.8",
|
| 38 |
+
"Accept-Encoding": "gzip, deflate, br",
|
| 39 |
+
"Connection": "keep-alive",
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def is_moneydj_fund(code: str) -> bool:
|
| 44 |
+
"""Return True if code looks like a MoneyDJ fund code (e.g. PIM91)."""
|
| 45 |
+
return bool(_MONEYDJ_RE.match(code.strip()))
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _session() -> requests.Session:
|
| 49 |
+
s = requests.Session()
|
| 50 |
+
s.headers.update(_BROWSER_HEADERS)
|
| 51 |
+
s.verify = False # MoneyDJ SSL cert missing Subject Key Identifier
|
| 52 |
+
return s
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ---------------------------------------------------------------------------
|
| 56 |
+
# Fund info
|
| 57 |
+
# ---------------------------------------------------------------------------
|
| 58 |
+
|
| 59 |
+
def get_fund_info(fund_code: str) -> dict:
|
| 60 |
+
"""Return fund name and basic info from MoneyDJ (with hardcoded fallback)."""
|
| 61 |
+
code = fund_code.upper().strip()
|
| 62 |
+
fallback_name = KNOWN_FUNDS.get(code, code)
|
| 63 |
+
base = {"stock_no": code, "name": fallback_name, "exchange": "基金", "current_price": None}
|
| 64 |
+
try:
|
| 65 |
+
s = _session()
|
| 66 |
+
url = f"https://www.moneydj.com/funddj/ya/yp010000.djhtm?a={code}"
|
| 67 |
+
resp = s.get(url, timeout=10)
|
| 68 |
+
resp.encoding = "utf-8"
|
| 69 |
+
m = re.search(r"<title>\s*(.*?)\s*(?:\||-|–).*?</title>", resp.text, re.IGNORECASE | re.DOTALL)
|
| 70 |
+
if m:
|
| 71 |
+
name = m.group(1).strip()
|
| 72 |
+
if name and name != code:
|
| 73 |
+
base["name"] = name
|
| 74 |
+
if base["name"] == code:
|
| 75 |
+
m2 = re.search(r'<h1[^>]*>\s*(.*?)\s*</h1>', resp.text, re.IGNORECASE | re.DOTALL)
|
| 76 |
+
if m2:
|
| 77 |
+
base["name"] = re.sub(r'<[^>]+>', '', m2.group(1)).strip()
|
| 78 |
+
|
| 79 |
+
# Try to get current NAV
|
| 80 |
+
nav_m = re.search(r'最新淨值[^>]*>.*?(\d[\d,]*\.\d+)', resp.text, re.DOTALL)
|
| 81 |
+
if nav_m:
|
| 82 |
+
base["current_price"] = float(nav_m.group(1).replace(",", ""))
|
| 83 |
+
except Exception as exc:
|
| 84 |
+
logger.warning("get_fund_info failed for %s: %s", code, exc)
|
| 85 |
+
return base
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ---------------------------------------------------------------------------
|
| 89 |
+
# Historical NAV
|
| 90 |
+
# ---------------------------------------------------------------------------
|
| 91 |
+
|
| 92 |
+
def fetch_fund_history(fund_code: str, months: int = 24) -> pd.DataFrame:
|
| 93 |
+
"""
|
| 94 |
+
Fetch historical NAV from MoneyDJ.
|
| 95 |
+
NAV is a single daily price → open/high/low/close all set to NAV.
|
| 96 |
+
volume = 1 (placeholder, avoids division-by-zero in volume_ratio).
|
| 97 |
+
"""
|
| 98 |
+
code = fund_code.upper().strip()
|
| 99 |
+
s = _session()
|
| 100 |
+
fund_url = f"https://www.moneydj.com/funddj/ya/yp010000.djhtm?a={code}"
|
| 101 |
+
|
| 102 |
+
try:
|
| 103 |
+
s.get(fund_url, timeout=10)
|
| 104 |
+
time.sleep(0.3)
|
| 105 |
+
except Exception:
|
| 106 |
+
pass
|
| 107 |
+
|
| 108 |
+
df = _try_fundhis_axd(code, s, fund_url)
|
| 109 |
+
if df is not None and not df.empty:
|
| 110 |
+
return _trim(df, months)
|
| 111 |
+
|
| 112 |
+
df = _scrape_html_table(code, s, months)
|
| 113 |
+
if df is not None and not df.empty:
|
| 114 |
+
return _trim(df, months)
|
| 115 |
+
|
| 116 |
+
raise ValueError(
|
| 117 |
+
f"無法取得基金 {code} 的淨值資料。"
|
| 118 |
+
"請確認代號是否正確(MoneyDJ 格式,如 PIM91)。"
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _try_fundhis_axd(code: str, s: requests.Session, referer: str) -> pd.DataFrame | None:
|
| 123 |
+
url = "https://www.moneydj.com/InfoSVC/FundHis.axd"
|
| 124 |
+
try:
|
| 125 |
+
resp = s.get(
|
| 126 |
+
url,
|
| 127 |
+
params={"a": code},
|
| 128 |
+
headers={
|
| 129 |
+
"Referer": referer,
|
| 130 |
+
"Accept": "*/*",
|
| 131 |
+
"X-Requested-With": "XMLHttpRequest",
|
| 132 |
+
},
|
| 133 |
+
timeout=15,
|
| 134 |
+
)
|
| 135 |
+
resp.raise_for_status()
|
| 136 |
+
m = re.search(r"BehaviorF\.His1\s*=\s*(\[[\s\S]*?\]);", resp.text)
|
| 137 |
+
if not m or m.group(1).strip() == "[]":
|
| 138 |
+
return None
|
| 139 |
+
raw = json.loads(m.group(1))
|
| 140 |
+
return _parse_nav_rows(raw, code)
|
| 141 |
+
except Exception as exc:
|
| 142 |
+
logger.warning("FundHis.axd error for %s: %s", code, exc)
|
| 143 |
+
return None
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _scrape_html_table(code: str, s: requests.Session, months: int) -> pd.DataFrame | None:
|
| 147 |
+
urls_to_try = [
|
| 148 |
+
f"https://www.moneydj.com/funddj/ya/YP011.djhtm?a={code}",
|
| 149 |
+
f"https://www.moneydj.com/funddj/yb/YP012.djhtm?a={code}&b={months}M",
|
| 150 |
+
]
|
| 151 |
+
for url in urls_to_try:
|
| 152 |
+
try:
|
| 153 |
+
resp = s.get(url, timeout=15)
|
| 154 |
+
resp.encoding = "utf-8"
|
| 155 |
+
records = _parse_html_nav(resp.text)
|
| 156 |
+
if records:
|
| 157 |
+
df = pd.DataFrame(records)
|
| 158 |
+
df = df.sort_values("date").reset_index(drop=True)
|
| 159 |
+
df = df.drop_duplicates(subset=["date"])
|
| 160 |
+
return df
|
| 161 |
+
except Exception as exc:
|
| 162 |
+
logger.warning("HTML scrape failed for %s: %s", code, exc)
|
| 163 |
+
return None
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _parse_html_nav(html: str) -> list[dict]:
|
| 167 |
+
records = []
|
| 168 |
+
date_re = re.compile(r'(\d{4})[/\-](\d{1,2})[/\-](\d{1,2})')
|
| 169 |
+
nav_re = re.compile(r'^[\d,]+\.\d+$')
|
| 170 |
+
cells = re.findall(r'<td[^>]*>(.*?)</td>', html, re.IGNORECASE | re.DOTALL)
|
| 171 |
+
texts = [re.sub(r'<[^>]+>', '', c).strip() for c in cells]
|
| 172 |
+
i = 0
|
| 173 |
+
while i < len(texts) - 1:
|
| 174 |
+
dm = date_re.match(texts[i])
|
| 175 |
+
if dm:
|
| 176 |
+
nav_text = texts[i + 1].replace(',', '')
|
| 177 |
+
nm = nav_re.match(nav_text)
|
| 178 |
+
if nm:
|
| 179 |
+
try:
|
| 180 |
+
date = datetime(int(dm.group(1)), int(dm.group(2)), int(dm.group(3))).date()
|
| 181 |
+
nav = float(nav_text)
|
| 182 |
+
records.append({
|
| 183 |
+
"date": date,
|
| 184 |
+
"open": nav, "high": nav, "low": nav, "close": nav,
|
| 185 |
+
"volume": 1.0,
|
| 186 |
+
})
|
| 187 |
+
i += 2
|
| 188 |
+
continue
|
| 189 |
+
except ValueError:
|
| 190 |
+
pass
|
| 191 |
+
i += 1
|
| 192 |
+
return records
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _parse_nav_rows(raw: list, code: str) -> pd.DataFrame:
|
| 196 |
+
records = []
|
| 197 |
+
for row in raw:
|
| 198 |
+
if not row or len(row) < 2:
|
| 199 |
+
continue
|
| 200 |
+
try:
|
| 201 |
+
date_str = str(row[0]).strip()
|
| 202 |
+
for fmt in ("%Y/%m/%d", "%Y%m%d", "%Y-%m-%d"):
|
| 203 |
+
try:
|
| 204 |
+
date = datetime.strptime(date_str, fmt).date()
|
| 205 |
+
break
|
| 206 |
+
except ValueError:
|
| 207 |
+
continue
|
| 208 |
+
else:
|
| 209 |
+
continue
|
| 210 |
+
nav = float(str(row[1]).replace(",", ""))
|
| 211 |
+
records.append({
|
| 212 |
+
"date": date,
|
| 213 |
+
"open": nav, "high": nav, "low": nav, "close": nav,
|
| 214 |
+
"volume": 1.0,
|
| 215 |
+
})
|
| 216 |
+
except (ValueError, TypeError, IndexError):
|
| 217 |
+
continue
|
| 218 |
+
if not records:
|
| 219 |
+
raise ValueError(f"No NAV data parsed for {code}")
|
| 220 |
+
df = pd.DataFrame(records)
|
| 221 |
+
df = df.sort_values("date").reset_index(drop=True)
|
| 222 |
+
df = df.drop_duplicates(subset=["date"])
|
| 223 |
+
return df
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def _trim(df: pd.DataFrame, months: int) -> pd.DataFrame:
|
| 227 |
+
from dateutil.relativedelta import relativedelta
|
| 228 |
+
cutoff = (datetime.today() - relativedelta(months=months)).date()
|
| 229 |
+
return df[df["date"] >= cutoff].reset_index(drop=True)
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
# ---------------------------------------------------------------------------
|
| 233 |
+
# Fund search
|
| 234 |
+
# ---------------------------------------------------------------------------
|
| 235 |
+
|
| 236 |
+
def search_funds(query: str) -> list[dict]:
|
| 237 |
+
"""Search known funds + MoneyDJ by name or code."""
|
| 238 |
+
q = query.strip().upper()
|
| 239 |
+
results = []
|
| 240 |
+
|
| 241 |
+
# Search hardcoded known funds first
|
| 242 |
+
for code, name in KNOWN_FUNDS.items():
|
| 243 |
+
if q in code or q.lower() in name.lower():
|
| 244 |
+
results.append({"stock_no": code, "name": name, "exchange": "基金"})
|
| 245 |
+
|
| 246 |
+
# Direct code lookup via MoneyDJ
|
| 247 |
+
if is_moneydj_fund(q) and not any(r["stock_no"] == q for r in results):
|
| 248 |
+
try:
|
| 249 |
+
info = get_fund_info(q)
|
| 250 |
+
results.append({"stock_no": info["stock_no"], "name": info["name"], "exchange": "基金"})
|
| 251 |
+
except Exception:
|
| 252 |
+
pass
|
| 253 |
+
|
| 254 |
+
# Name search via MoneyDJ search page
|
| 255 |
+
if not results:
|
| 256 |
+
try:
|
| 257 |
+
s = _session()
|
| 258 |
+
url = "https://www.moneydj.com/funddjx/fundsearch.xdjhtm"
|
| 259 |
+
resp = s.get(url, params={"SearchType": "MatchAll", "Key": query.strip()}, timeout=10)
|
| 260 |
+
resp.encoding = "utf-8"
|
| 261 |
+
pattern = re.compile(
|
| 262 |
+
r'yp010000\.djhtm\?a=([A-Z0-9]+)[^>]*>([^<]+)<', re.IGNORECASE
|
| 263 |
+
)
|
| 264 |
+
for m in pattern.finditer(resp.text):
|
| 265 |
+
code = m.group(1).strip()
|
| 266 |
+
name = m.group(2).strip()
|
| 267 |
+
if name and code:
|
| 268 |
+
results.append({"stock_no": code, "name": name, "exchange": "基金"})
|
| 269 |
+
if len(results) >= 10:
|
| 270 |
+
break
|
| 271 |
+
except Exception as exc:
|
| 272 |
+
logger.warning("Fund search error: %s", exc)
|
| 273 |
+
|
| 274 |
+
return results[:10]
|
frontend/src/App.tsx
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useCallback, useEffect } from 'react'
|
| 2 |
+
import { TrendingUp, Activity, BarChart2, Loader2, Star } from 'lucide-react'
|
| 3 |
+
import { onAuthStateChanged, signOut, User as FirebaseUser } from 'firebase/auth'
|
| 4 |
+
import { auth, firebaseReady } from './firebase'
|
| 5 |
+
import {
|
| 6 |
+
fetchStockHistory,
|
| 7 |
+
fetchPrediction,
|
| 8 |
+
fetchStockInfo,
|
| 9 |
+
OHLCVPoint,
|
| 10 |
+
Prediction,
|
| 11 |
+
StockInfo,
|
| 12 |
+
} from './api/stockApi'
|
| 13 |
+
|
| 14 |
+
// Components
|
| 15 |
+
import { CandlestickChart } from './components/CandlestickChart'
|
| 16 |
+
import { VolumeChart } from './components/VolumeChart'
|
| 17 |
+
import { RSIChart } from './components/RSIChart'
|
| 18 |
+
import { MACDChart } from './components/MACDChart'
|
| 19 |
+
import { PredictionPanel } from './components/PredictionPanel'
|
| 20 |
+
import { ChartCard } from './components/ChartCard'
|
| 21 |
+
import { Login } from './components/Login'
|
| 22 |
+
import { Sidebar } from './components/Sidebar'
|
| 23 |
+
import { Header, Market } from './components/Header'
|
| 24 |
+
import { FavoritesPage, FavoriteItem } from './components/FavoritesPage'
|
| 25 |
+
import { useFirestoreFavorites } from './hooks/useFirestoreFavorites'
|
| 26 |
+
|
| 27 |
+
// ---------------------------------------------------------------------------
|
| 28 |
+
// Types
|
| 29 |
+
// ---------------------------------------------------------------------------
|
| 30 |
+
|
| 31 |
+
interface AppState {
|
| 32 |
+
loading: boolean
|
| 33 |
+
predicting: boolean
|
| 34 |
+
error: string | null
|
| 35 |
+
predictionError: string | null
|
| 36 |
+
stockInfo: StockInfo | null
|
| 37 |
+
history: OHLCVPoint[]
|
| 38 |
+
prediction: Prediction | null
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// ---------------------------------------------------------------------------
|
| 42 |
+
// localStorage fallback (used when Firebase is not configured)
|
| 43 |
+
// ---------------------------------------------------------------------------
|
| 44 |
+
|
| 45 |
+
const LS_SESSION = 'stock_predictor_session'
|
| 46 |
+
const LS_USERS = 'stock_predictor_users'
|
| 47 |
+
|
| 48 |
+
function lsGetUsers(): Array<{ username: string; password: string }> {
|
| 49 |
+
try {
|
| 50 |
+
const raw = localStorage.getItem(LS_USERS)
|
| 51 |
+
const users = raw ? JSON.parse(raw) : []
|
| 52 |
+
if (!users.find((u: any) => u.username === 'admin')) {
|
| 53 |
+
users.unshift({ username: 'admin', password: 'stock123' })
|
| 54 |
+
localStorage.setItem(LS_USERS, JSON.stringify(users))
|
| 55 |
+
}
|
| 56 |
+
return users
|
| 57 |
+
} catch { return [{ username: 'admin', password: 'stock123' }] }
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function lsFavKey(username: string) { return `favorites_${username}` }
|
| 61 |
+
|
| 62 |
+
function useLsFavorites(username: string | null) {
|
| 63 |
+
const [favorites, setFavorites] = useState<FavoriteItem[]>(() => {
|
| 64 |
+
if (!username) return []
|
| 65 |
+
try { return JSON.parse(localStorage.getItem(lsFavKey(username)) ?? '[]') } catch { return [] }
|
| 66 |
+
})
|
| 67 |
+
|
| 68 |
+
const persist = (u: string, favs: FavoriteItem[]) =>
|
| 69 |
+
localStorage.setItem(lsFavKey(u), JSON.stringify(favs))
|
| 70 |
+
|
| 71 |
+
const toggleFavorite = (id: string, name?: string, market?: Market) => {
|
| 72 |
+
if (!username) return
|
| 73 |
+
setFavorites(prev => {
|
| 74 |
+
const next = prev.find(f => f.id === id)
|
| 75 |
+
? prev.filter(f => f.id !== id)
|
| 76 |
+
: [...prev, { id, name: name ?? id, market }]
|
| 77 |
+
persist(username, next)
|
| 78 |
+
return next
|
| 79 |
+
})
|
| 80 |
+
}
|
| 81 |
+
const removeFavorite = (id: string) => {
|
| 82 |
+
if (!username) return
|
| 83 |
+
setFavorites(prev => {
|
| 84 |
+
const next = prev.filter(f => f.id !== id)
|
| 85 |
+
persist(username, next)
|
| 86 |
+
return next
|
| 87 |
+
})
|
| 88 |
+
}
|
| 89 |
+
const updateNote = (id: string, note: string) => {
|
| 90 |
+
if (!username) return
|
| 91 |
+
setFavorites(prev => {
|
| 92 |
+
const next = prev.map(f => f.id === id ? { ...f, note } : f)
|
| 93 |
+
persist(username, next)
|
| 94 |
+
return next
|
| 95 |
+
})
|
| 96 |
+
}
|
| 97 |
+
return { favorites, toggleFavorite, removeFavorite, updateNote }
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// ---------------------------------------------------------------------------
|
| 101 |
+
// Main App
|
| 102 |
+
// ---------------------------------------------------------------------------
|
| 103 |
+
|
| 104 |
+
export default function App() {
|
| 105 |
+
// --- Auth state ---
|
| 106 |
+
const [firebaseUser, setFirebaseUser] = useState<FirebaseUser | null | undefined>(
|
| 107 |
+
firebaseReady ? undefined : null
|
| 108 |
+
)
|
| 109 |
+
const [lsUser, setLsUser] = useState<string | null>(() =>
|
| 110 |
+
firebaseReady ? null : localStorage.getItem(LS_SESSION)
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
const isLoggedIn = firebaseReady ? !!firebaseUser : !!lsUser
|
| 114 |
+
const uid = firebaseReady ? (firebaseUser?.uid ?? null) : null
|
| 115 |
+
const displayName = firebaseReady
|
| 116 |
+
? (firebaseUser?.displayName || firebaseUser?.email || '使用者')
|
| 117 |
+
: lsUser ?? ''
|
| 118 |
+
const photoURL = firebaseUser?.photoURL ?? undefined
|
| 119 |
+
|
| 120 |
+
useEffect(() => {
|
| 121 |
+
if (!firebaseReady) return
|
| 122 |
+
return onAuthStateChanged(auth, setFirebaseUser)
|
| 123 |
+
}, [])
|
| 124 |
+
|
| 125 |
+
const handleLogout = () => {
|
| 126 |
+
if (firebaseReady) {
|
| 127 |
+
signOut(auth)
|
| 128 |
+
} else {
|
| 129 |
+
localStorage.removeItem(LS_SESSION)
|
| 130 |
+
setLsUser(null)
|
| 131 |
+
}
|
| 132 |
+
setView('main')
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
const handleLsLogin = (username: string) => {
|
| 136 |
+
localStorage.setItem(LS_SESSION, username)
|
| 137 |
+
setLsUser(username)
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
const handleLsRegister = (username: string, password: string): string | null => {
|
| 141 |
+
const users = lsGetUsers()
|
| 142 |
+
if (users.find(u => u.username === username)) return '此帳號名稱已被使用'
|
| 143 |
+
if (password.length < 4) return '密碼至少需要 4 個字元'
|
| 144 |
+
users.push({ username, password })
|
| 145 |
+
localStorage.setItem(LS_USERS, JSON.stringify(users))
|
| 146 |
+
return null
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
const verifyLsLogin = (username: string, password: string): boolean => {
|
| 150 |
+
const users = lsGetUsers()
|
| 151 |
+
return !!users.find(u => u.username === username && u.password === password)
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
// --- Favorites ---
|
| 155 |
+
const firestore = useFirestoreFavorites(firebaseReady ? uid : null)
|
| 156 |
+
const ls = useLsFavorites(firebaseReady ? null : lsUser)
|
| 157 |
+
const { favorites, toggleFavorite, removeFavorite, updateNote } =
|
| 158 |
+
firebaseReady ? firestore : ls
|
| 159 |
+
|
| 160 |
+
// --- Market + View + search state ---
|
| 161 |
+
const [market, setMarket] = useState<Market>('tw')
|
| 162 |
+
const [view, setView] = useState<'main' | 'favorites'>('main')
|
| 163 |
+
const [input, setInput] = useState('')
|
| 164 |
+
const [months, setMonths] = useState(6)
|
| 165 |
+
const [state, setState] = useState<AppState>({
|
| 166 |
+
loading: false, predicting: false,
|
| 167 |
+
error: null, predictionError: null,
|
| 168 |
+
stockInfo: null, history: [], prediction: null,
|
| 169 |
+
})
|
| 170 |
+
|
| 171 |
+
const handleSearch = useCallback(async (stockNo: string, monthsCount: number) => {
|
| 172 |
+
const sno = stockNo.trim()
|
| 173 |
+
if (!sno) return
|
| 174 |
+
setInput(sno)
|
| 175 |
+
setView('main')
|
| 176 |
+
setState(s => ({ ...s, loading: true, predicting: true, error: null, predictionError: null, history: [], prediction: null }))
|
| 177 |
+
|
| 178 |
+
let info: StockInfo | null = null
|
| 179 |
+
let history: OHLCVPoint[] = []
|
| 180 |
+
let historyError: string | null = null
|
| 181 |
+
try {
|
| 182 |
+
const [infoRes, historyRes] = await Promise.allSettled([
|
| 183 |
+
fetchStockInfo(sno),
|
| 184 |
+
fetchStockHistory(sno, monthsCount),
|
| 185 |
+
])
|
| 186 |
+
if (infoRes.status === 'fulfilled') info = infoRes.value
|
| 187 |
+
if (historyRes.status === 'fulfilled') history = historyRes.value
|
| 188 |
+
else historyError = (historyRes.reason as Error)?.message ?? 'Failed to fetch history'
|
| 189 |
+
} catch (err: any) { historyError = err?.message ?? 'Unknown error' }
|
| 190 |
+
|
| 191 |
+
setState(s => ({ ...s, loading: false, stockInfo: info, history, error: historyError }))
|
| 192 |
+
|
| 193 |
+
try {
|
| 194 |
+
const pred = await fetchPrediction(sno)
|
| 195 |
+
setState(s => ({ ...s, prediction: pred, predicting: false }))
|
| 196 |
+
} catch (err: any) {
|
| 197 |
+
const msg = err?.response?.data?.detail ?? err?.message ?? 'Prediction failed'
|
| 198 |
+
setState(s => ({ ...s, predictionError: msg, predicting: false }))
|
| 199 |
+
}
|
| 200 |
+
}, [])
|
| 201 |
+
|
| 202 |
+
const onSubmit = (e: React.FormEvent) => { e.preventDefault(); handleSearch(input, months) }
|
| 203 |
+
|
| 204 |
+
// ---------------------------------------------------------------------------
|
| 205 |
+
// Auth gate
|
| 206 |
+
// ---------------------------------------------------------------------------
|
| 207 |
+
|
| 208 |
+
if (firebaseUser === undefined) {
|
| 209 |
+
return (
|
| 210 |
+
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
|
| 211 |
+
<Loader2 size={32} className="animate-spin text-blue-400" />
|
| 212 |
+
</div>
|
| 213 |
+
)
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
if (!isLoggedIn) {
|
| 217 |
+
return (
|
| 218 |
+
<Login
|
| 219 |
+
mode={firebaseReady ? 'firebase' : 'local'}
|
| 220 |
+
onLsLogin={verifyLsLogin}
|
| 221 |
+
onLsLoginSuccess={handleLsLogin}
|
| 222 |
+
onLsRegister={handleLsRegister}
|
| 223 |
+
/>
|
| 224 |
+
)
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
// ---------------------------------------------------------------------------
|
| 228 |
+
// Main UI
|
| 229 |
+
// ---------------------------------------------------------------------------
|
| 230 |
+
|
| 231 |
+
const { loading, predicting, error, predictionError, stockInfo, history, prediction } = state
|
| 232 |
+
const hasData = history.length > 0
|
| 233 |
+
const isFav = stockInfo && favorites.some(f => f.id === stockInfo.stock_no)
|
| 234 |
+
|
| 235 |
+
return (
|
| 236 |
+
<div className="min-h-screen bg-gray-950 text-gray-100 font-sans">
|
| 237 |
+
<Header
|
| 238 |
+
input={input} setInput={setInput}
|
| 239 |
+
months={months} setMonths={setMonths}
|
| 240 |
+
loading={loading}
|
| 241 |
+
onSubmit={onSubmit}
|
| 242 |
+
onLogout={handleLogout}
|
| 243 |
+
currentUser={displayName}
|
| 244 |
+
userPhotoURL={photoURL}
|
| 245 |
+
view={view} onViewChange={setView}
|
| 246 |
+
favoritesCount={favorites.length}
|
| 247 |
+
market={market}
|
| 248 |
+
onMarketChange={setMarket}
|
| 249 |
+
onSelect={(stockNo) => handleSearch(stockNo, months)}
|
| 250 |
+
/>
|
| 251 |
+
|
| 252 |
+
{view === 'favorites' ? (
|
| 253 |
+
<FavoritesPage
|
| 254 |
+
favorites={favorites}
|
| 255 |
+
onView={(id) => { handleSearch(id, months); setView('main') }}
|
| 256 |
+
onRemove={removeFavorite}
|
| 257 |
+
onUpdateNote={updateNote}
|
| 258 |
+
/>
|
| 259 |
+
) : (
|
| 260 |
+
<div className="max-w-7xl mx-auto px-4 py-6 grid grid-cols-1 lg:grid-cols-4 gap-6">
|
| 261 |
+
<Sidebar
|
| 262 |
+
favorites={favorites}
|
| 263 |
+
onSearch={handleSearch}
|
| 264 |
+
onToggleFavorite={(id) => toggleFavorite(id)}
|
| 265 |
+
onViewFavorites={() => setView('favorites')}
|
| 266 |
+
months={months}
|
| 267 |
+
/>
|
| 268 |
+
|
| 269 |
+
<main className="lg:col-span-3 space-y-6">
|
| 270 |
+
{stockInfo && (
|
| 271 |
+
<div className="flex flex-wrap items-center gap-3">
|
| 272 |
+
<h2 className="text-2xl font-black text-white">
|
| 273 |
+
{stockInfo.stock_no}
|
| 274 |
+
{stockInfo.name && stockInfo.name !== stockInfo.stock_no && (
|
| 275 |
+
<span className="ml-2 text-slate-300 font-semibold text-xl">{stockInfo.name}</span>
|
| 276 |
+
)}
|
| 277 |
+
</h2>
|
| 278 |
+
<button
|
| 279 |
+
onClick={() => toggleFavorite(stockInfo.stock_no, stockInfo.name, market)}
|
| 280 |
+
className={`p-2 rounded-full transition-colors ${isFav ? 'text-yellow-500' : 'text-slate-600 hover:text-slate-400'}`}
|
| 281 |
+
title={isFav ? '從最愛移除' : '加入最愛'}
|
| 282 |
+
>
|
| 283 |
+
<Star size={24} fill={isFav ? 'currentColor' : 'none'} />
|
| 284 |
+
</button>
|
| 285 |
+
<span className="text-xs bg-slate-700 text-slate-300 rounded px-2 py-0.5">{stockInfo.exchange}</span>
|
| 286 |
+
{stockInfo.current_price != null && (
|
| 287 |
+
<span className="text-2xl font-bold text-green-400 ml-auto">
|
| 288 |
+
{market === 'tw' ? 'NT$' : 'USD'} {stockInfo.current_price.toFixed(2)}
|
| 289 |
+
</span>
|
| 290 |
+
)}
|
| 291 |
+
</div>
|
| 292 |
+
)}
|
| 293 |
+
|
| 294 |
+
{error && (
|
| 295 |
+
<div className="bg-red-900/30 border border-red-700 rounded-xl p-4 text-red-300 text-sm">
|
| 296 |
+
<strong>資料載入失敗:</strong> {error}
|
| 297 |
+
</div>
|
| 298 |
+
)}
|
| 299 |
+
|
| 300 |
+
{loading && (
|
| 301 |
+
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
| 302 |
+
<Loader2 size={40} className="animate-spin text-blue-400" />
|
| 303 |
+
<p className="text-slate-400 text-sm">
|
| 304 |
+
{market === 'fund' ? '正在從 MoneyDJ 取得基金淨值...' : '正在從 TPEX/TWSE 取得資料...'}
|
| 305 |
+
</p>
|
| 306 |
+
</div>
|
| 307 |
+
)}
|
| 308 |
+
|
| 309 |
+
{hasData && (
|
| 310 |
+
<div className="space-y-4">
|
| 311 |
+
<ChartCard title="價格走勢 & 均線" subtitle="Price & Moving Averages (MA5/MA20/MA60) + Bollinger Bands" icon={<TrendingUp size={16} className="text-blue-400" />}>
|
| 312 |
+
<CandlestickChart data={history} />
|
| 313 |
+
</ChartCard>
|
| 314 |
+
<ChartCard title="成交量" subtitle="Volume" icon={<BarChart2 size={16} className="text-green-400" />}>
|
| 315 |
+
<VolumeChart data={history} />
|
| 316 |
+
</ChartCard>
|
| 317 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
| 318 |
+
<ChartCard title="RSI (14)" subtitle="Relative Strength Index — 30 oversold / 70 overbought" icon={<Activity size={16} className="text-purple-400" />}>
|
| 319 |
+
<RSIChart data={history} />
|
| 320 |
+
</ChartCard>
|
| 321 |
+
<ChartCard title="MACD (12/26/9)" subtitle="MACD Line · Signal Line · Histogram" icon={<Activity size={16} className="text-orange-400" />}>
|
| 322 |
+
<MACDChart data={history} />
|
| 323 |
+
</ChartCard>
|
| 324 |
+
</div>
|
| 325 |
+
</div>
|
| 326 |
+
)}
|
| 327 |
+
|
| 328 |
+
{predicting && !prediction && (
|
| 329 |
+
<div className="bg-slate-900 border border-slate-700 rounded-xl p-6 flex items-center gap-4">
|
| 330 |
+
<Loader2 size={24} className="animate-spin text-blue-400 flex-shrink-0" />
|
| 331 |
+
<div>
|
| 332 |
+
<p className="text-white font-medium">正在訓練 ML 模型...</p>
|
| 333 |
+
<p className="text-slate-400 text-sm mt-0.5">Training RandomForest model (this may take 15–60 seconds)</p>
|
| 334 |
+
</div>
|
| 335 |
+
</div>
|
| 336 |
+
)}
|
| 337 |
+
|
| 338 |
+
{predictionError && (
|
| 339 |
+
<div className="bg-red-900/30 border border-red-700 rounded-xl p-4 text-red-300 text-sm">
|
| 340 |
+
<strong>預測失敗:</strong> {predictionError}
|
| 341 |
+
</div>
|
| 342 |
+
)}
|
| 343 |
+
|
| 344 |
+
{prediction && stockInfo && (
|
| 345 |
+
<PredictionPanel prediction={prediction} stockName={`${stockInfo.stock_no} ${stockInfo.name ?? ''}`} />
|
| 346 |
+
)}
|
| 347 |
+
|
| 348 |
+
{!loading && !hasData && !error && !state.stockInfo && (
|
| 349 |
+
<div className="flex flex-col items-center justify-center py-24 gap-4 text-center">
|
| 350 |
+
<TrendingUp size={52} className="text-slate-700" />
|
| 351 |
+
<h3 className="text-xl font-semibold text-slate-500">輸入代號開始分析</h3>
|
| 352 |
+
<p className="text-slate-600 text-sm">支援台股、美股、境外基金 (MoneyDJ 代號)</p>
|
| 353 |
+
</div>
|
| 354 |
+
)}
|
| 355 |
+
</main>
|
| 356 |
+
</div>
|
| 357 |
+
)}
|
| 358 |
+
|
| 359 |
+
<footer className="mt-16 border-t border-slate-800 py-6 text-center text-xs text-slate-600">
|
| 360 |
+
<p>資料來源: TWSE / TPEX / Yahoo Finance / MoneyDJ | 本系統僅供學術研究用途,不構成投資建議</p>
|
| 361 |
+
</footer>
|
| 362 |
+
</div>
|
| 363 |
+
)
|
| 364 |
+
}
|
frontend/src/api/stockApi.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios from 'axios'
|
| 2 |
+
|
| 3 |
+
// ---------------------------------------------------------------------------
|
| 4 |
+
// Interfaces
|
| 5 |
+
// ---------------------------------------------------------------------------
|
| 6 |
+
|
| 7 |
+
export interface OHLCVPoint {
|
| 8 |
+
date: string
|
| 9 |
+
open: number
|
| 10 |
+
high: number
|
| 11 |
+
low: number
|
| 12 |
+
close: number
|
| 13 |
+
volume: number
|
| 14 |
+
ma5?: number
|
| 15 |
+
ma10?: number
|
| 16 |
+
ma20?: number
|
| 17 |
+
ma60?: number
|
| 18 |
+
rsi?: number
|
| 19 |
+
macd?: number
|
| 20 |
+
macd_signal?: number
|
| 21 |
+
macd_hist?: number
|
| 22 |
+
k?: number
|
| 23 |
+
d?: number
|
| 24 |
+
bb_upper?: number
|
| 25 |
+
bb_lower?: number
|
| 26 |
+
bb_middle?: number
|
| 27 |
+
bb_pct_b?: number
|
| 28 |
+
volume_ma20?: number
|
| 29 |
+
volume_ratio?: number
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export interface Prediction {
|
| 33 |
+
signal: 'BUY' | 'SELL' | 'HOLD'
|
| 34 |
+
signal_probability: number
|
| 35 |
+
predicted_price: number
|
| 36 |
+
predicted_change_pct: number
|
| 37 |
+
sell_target: number
|
| 38 |
+
stop_loss: number
|
| 39 |
+
confidence: 'HIGH' | 'MEDIUM' | 'LOW'
|
| 40 |
+
current_price: number
|
| 41 |
+
training_accuracy: number
|
| 42 |
+
disclaimer: string
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
export interface StockInfo {
|
| 46 |
+
stock_no: string
|
| 47 |
+
name: string
|
| 48 |
+
current_price: number | null
|
| 49 |
+
exchange: string
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
export interface SearchResult {
|
| 53 |
+
stock_no: string
|
| 54 |
+
name: string
|
| 55 |
+
exchange: string
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
// ---------------------------------------------------------------------------
|
| 59 |
+
// API functions
|
| 60 |
+
// ---------------------------------------------------------------------------
|
| 61 |
+
|
| 62 |
+
// In production (Vercel), VITE_API_URL points to the Render backend.
|
| 63 |
+
// In local dev, Vite proxy forwards /api → localhost:8000.
|
| 64 |
+
const api = axios.create({
|
| 65 |
+
baseURL: import.meta.env.VITE_API_URL ?? '',
|
| 66 |
+
})
|
| 67 |
+
|
| 68 |
+
export async function fetchStockHistory(
|
| 69 |
+
stockNo: string,
|
| 70 |
+
months: number = 6
|
| 71 |
+
): Promise<OHLCVPoint[]> {
|
| 72 |
+
const res = await api.get<{ data: OHLCVPoint[] }>(
|
| 73 |
+
`/api/stock/${stockNo}/history`,
|
| 74 |
+
{ params: { months } }
|
| 75 |
+
)
|
| 76 |
+
return res.data.data
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
export async function fetchPrediction(
|
| 80 |
+
stockNo: string
|
| 81 |
+
): Promise<Prediction> {
|
| 82 |
+
const res = await api.get<{ prediction: Prediction }>(
|
| 83 |
+
`/api/stock/${stockNo}/predict`
|
| 84 |
+
)
|
| 85 |
+
return res.data.prediction
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
export async function fetchStockInfo(stockNo: string): Promise<StockInfo> {
|
| 89 |
+
const res = await api.get<StockInfo>(`/api/stock/${stockNo}/info`)
|
| 90 |
+
return res.data
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
export async function searchStocks(query: string, market: 'tw' | 'us' | 'fund' = 'tw'): Promise<SearchResult[]> {
|
| 94 |
+
const res = await api.get<{ results: SearchResult[] }>(
|
| 95 |
+
`/api/stock/search`,
|
| 96 |
+
{ params: { q: query, market } }
|
| 97 |
+
)
|
| 98 |
+
return res.data.results
|
| 99 |
+
}
|
frontend/src/components/CandlestickChart.tsx
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useMemo } from 'react'
|
| 2 |
+
import {
|
| 3 |
+
ComposedChart,
|
| 4 |
+
Line,
|
| 5 |
+
Area,
|
| 6 |
+
XAxis,
|
| 7 |
+
YAxis,
|
| 8 |
+
CartesianGrid,
|
| 9 |
+
Tooltip,
|
| 10 |
+
Legend,
|
| 11 |
+
ResponsiveContainer,
|
| 12 |
+
} from 'recharts'
|
| 13 |
+
import { OHLCVPoint } from '../api/stockApi'
|
| 14 |
+
|
| 15 |
+
interface Props {
|
| 16 |
+
data: OHLCVPoint[]
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
// Show every Nth date label to avoid crowding
|
| 20 |
+
function tickFormatter(value: string, index: number, total: number): string {
|
| 21 |
+
const step = Math.max(1, Math.floor(total / 10))
|
| 22 |
+
if (index % step === 0) return value
|
| 23 |
+
return ''
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
const CustomTooltip = ({ active, payload, label }: any) => {
|
| 27 |
+
if (!active || !payload?.length) return null
|
| 28 |
+
const d: OHLCVPoint = payload[0]?.payload
|
| 29 |
+
if (!d) return null
|
| 30 |
+
return (
|
| 31 |
+
<div className="bg-slate-800 border border-slate-600 rounded p-3 text-xs space-y-1 shadow-lg">
|
| 32 |
+
<p className="text-slate-300 font-semibold">{label}</p>
|
| 33 |
+
<p>O: <span className="text-white">{d.open?.toFixed(2)}</span></p>
|
| 34 |
+
<p>H: <span className="text-green-400">{d.high?.toFixed(2)}</span></p>
|
| 35 |
+
<p>L: <span className="text-red-400">{d.low?.toFixed(2)}</span></p>
|
| 36 |
+
<p>C: <span className="text-yellow-300 font-bold">{d.close?.toFixed(2)}</span></p>
|
| 37 |
+
{d.ma5 != null && <p>MA5: <span className="text-yellow-400">{d.ma5.toFixed(2)}</span></p>}
|
| 38 |
+
{d.ma20 != null && <p>MA20: <span className="text-blue-400">{d.ma20.toFixed(2)}</span></p>}
|
| 39 |
+
{d.ma60 != null && <p>MA60: <span className="text-red-400">{d.ma60.toFixed(2)}</span></p>}
|
| 40 |
+
</div>
|
| 41 |
+
)
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export const CandlestickChart: React.FC<Props> = ({ data }) => {
|
| 45 |
+
const len = data.length
|
| 46 |
+
|
| 47 |
+
// Build Bollinger band area data: pairs of [lower, upper] for Area fill
|
| 48 |
+
const chartData = useMemo(
|
| 49 |
+
() =>
|
| 50 |
+
data.map((d) => ({
|
| 51 |
+
...d,
|
| 52 |
+
bb_band: d.bb_upper != null && d.bb_lower != null
|
| 53 |
+
? [d.bb_lower, d.bb_upper]
|
| 54 |
+
: [null, null],
|
| 55 |
+
})),
|
| 56 |
+
[data]
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
const allPrices = data.flatMap((d) => [d.low, d.high, d.bb_upper, d.bb_lower].filter(Boolean) as number[])
|
| 60 |
+
const yMin = Math.floor(Math.min(...allPrices) * 0.995)
|
| 61 |
+
const yMax = Math.ceil(Math.max(...allPrices) * 1.005)
|
| 62 |
+
|
| 63 |
+
return (
|
| 64 |
+
<ResponsiveContainer width="100%" height={320}>
|
| 65 |
+
<ComposedChart data={chartData} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
|
| 66 |
+
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
| 67 |
+
<XAxis
|
| 68 |
+
dataKey="date"
|
| 69 |
+
tick={{ fill: '#94a3b8', fontSize: 11 }}
|
| 70 |
+
tickLine={false}
|
| 71 |
+
tickFormatter={(val, idx) => tickFormatter(val, idx, len)}
|
| 72 |
+
interval={0}
|
| 73 |
+
/>
|
| 74 |
+
<YAxis
|
| 75 |
+
domain={[yMin, yMax]}
|
| 76 |
+
tick={{ fill: '#94a3b8', fontSize: 11 }}
|
| 77 |
+
tickLine={false}
|
| 78 |
+
tickFormatter={(v) => v.toFixed(0)}
|
| 79 |
+
width={55}
|
| 80 |
+
/>
|
| 81 |
+
<Tooltip content={<CustomTooltip />} />
|
| 82 |
+
<Legend
|
| 83 |
+
wrapperStyle={{ fontSize: 12, paddingTop: 4 }}
|
| 84 |
+
formatter={(value) => <span style={{ color: '#94a3b8' }}>{value}</span>}
|
| 85 |
+
/>
|
| 86 |
+
|
| 87 |
+
{/* Bollinger Band fill — rendered first so it's behind price lines */}
|
| 88 |
+
<Area
|
| 89 |
+
dataKey="bb_upper"
|
| 90 |
+
stroke="#334155"
|
| 91 |
+
strokeWidth={1}
|
| 92 |
+
fill="#1e293b"
|
| 93 |
+
fillOpacity={0.5}
|
| 94 |
+
dot={false}
|
| 95 |
+
legendType="none"
|
| 96 |
+
name="BB Upper"
|
| 97 |
+
isAnimationActive={false}
|
| 98 |
+
/>
|
| 99 |
+
<Area
|
| 100 |
+
dataKey="bb_lower"
|
| 101 |
+
stroke="#334155"
|
| 102 |
+
strokeWidth={1}
|
| 103 |
+
fill="#0f172a"
|
| 104 |
+
fillOpacity={1}
|
| 105 |
+
dot={false}
|
| 106 |
+
legendType="none"
|
| 107 |
+
name="BB Lower"
|
| 108 |
+
isAnimationActive={false}
|
| 109 |
+
/>
|
| 110 |
+
|
| 111 |
+
{/* Close price as thick line */}
|
| 112 |
+
<Line
|
| 113 |
+
dataKey="close"
|
| 114 |
+
stroke="#e2e8f0"
|
| 115 |
+
strokeWidth={2}
|
| 116 |
+
dot={false}
|
| 117 |
+
name="Close"
|
| 118 |
+
isAnimationActive={false}
|
| 119 |
+
/>
|
| 120 |
+
|
| 121 |
+
{/* Moving averages */}
|
| 122 |
+
<Line
|
| 123 |
+
dataKey="ma5"
|
| 124 |
+
stroke="#facc15"
|
| 125 |
+
strokeWidth={1.5}
|
| 126 |
+
dot={false}
|
| 127 |
+
name="MA5"
|
| 128 |
+
isAnimationActive={false}
|
| 129 |
+
connectNulls
|
| 130 |
+
/>
|
| 131 |
+
<Line
|
| 132 |
+
dataKey="ma20"
|
| 133 |
+
stroke="#60a5fa"
|
| 134 |
+
strokeWidth={1.5}
|
| 135 |
+
dot={false}
|
| 136 |
+
name="MA20"
|
| 137 |
+
isAnimationActive={false}
|
| 138 |
+
connectNulls
|
| 139 |
+
/>
|
| 140 |
+
<Line
|
| 141 |
+
dataKey="ma60"
|
| 142 |
+
stroke="#f87171"
|
| 143 |
+
strokeWidth={1.5}
|
| 144 |
+
dot={false}
|
| 145 |
+
name="MA60"
|
| 146 |
+
isAnimationActive={false}
|
| 147 |
+
connectNulls
|
| 148 |
+
/>
|
| 149 |
+
</ComposedChart>
|
| 150 |
+
</ResponsiveContainer>
|
| 151 |
+
)
|
| 152 |
+
}
|
frontend/src/components/ChartCard.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react'
|
| 2 |
+
|
| 3 |
+
interface ChartCardProps {
|
| 4 |
+
title: string
|
| 5 |
+
subtitle: string
|
| 6 |
+
icon: React.ReactNode
|
| 7 |
+
children: React.ReactNode
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export const ChartCard: React.FC<ChartCardProps> = ({ title, subtitle, icon, children }) => (
|
| 11 |
+
<div className="bg-slate-900 border border-slate-800 rounded-xl p-4 space-y-3">
|
| 12 |
+
<div className="flex items-center gap-2">
|
| 13 |
+
{icon}
|
| 14 |
+
<div>
|
| 15 |
+
<h3 className="text-sm font-semibold text-white leading-tight">{title}</h3>
|
| 16 |
+
<p className="text-xs text-slate-500 leading-tight">{subtitle}</p>
|
| 17 |
+
</div>
|
| 18 |
+
</div>
|
| 19 |
+
{children}
|
| 20 |
+
</div>
|
| 21 |
+
)
|
frontend/src/components/FavoritesPage.tsx
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react'
|
| 2 |
+
import { Star, Trash2, ExternalLink, RefreshCw, FileText } from 'lucide-react'
|
| 3 |
+
import { fetchStockInfo, StockInfo } from '../api/stockApi'
|
| 4 |
+
import type { Market } from './Header'
|
| 5 |
+
|
| 6 |
+
export interface FavoriteItem {
|
| 7 |
+
id: string
|
| 8 |
+
name: string
|
| 9 |
+
note?: string
|
| 10 |
+
market?: Market
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
const MARKET_FILTER_TABS: { key: Market | 'all'; label: string; flag: string }[] = [
|
| 14 |
+
{ key: 'all', label: '全部', flag: '⭐' },
|
| 15 |
+
{ key: 'tw', label: '台股', flag: '🇹🇼' },
|
| 16 |
+
{ key: 'us', label: '美股', flag: '🇺🇸' },
|
| 17 |
+
{ key: 'fund', label: '基金', flag: '📊' },
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
interface FavoritesPageProps {
|
| 21 |
+
favorites: FavoriteItem[]
|
| 22 |
+
onView: (id: string) => void
|
| 23 |
+
onRemove: (id: string) => void
|
| 24 |
+
onUpdateNote: (id: string, note: string) => void
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const FavoriteCard: React.FC<{
|
| 28 |
+
fav: FavoriteItem
|
| 29 |
+
onView: (id: string) => void
|
| 30 |
+
onRemove: (id: string) => void
|
| 31 |
+
onUpdateNote: (id: string, note: string) => void
|
| 32 |
+
}> = ({ fav, onView, onRemove, onUpdateNote }) => {
|
| 33 |
+
const [info, setInfo] = useState<StockInfo | null>(null)
|
| 34 |
+
const [fetching, setFetching] = useState(false)
|
| 35 |
+
const [note, setNote] = useState(fav.note ?? '')
|
| 36 |
+
|
| 37 |
+
const loadInfo = async () => {
|
| 38 |
+
setFetching(true)
|
| 39 |
+
try {
|
| 40 |
+
const data = await fetchStockInfo(fav.id)
|
| 41 |
+
setInfo(data)
|
| 42 |
+
} catch { /* ignore */ }
|
| 43 |
+
setFetching(false)
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
useEffect(() => { loadInfo() }, [fav.id])
|
| 47 |
+
|
| 48 |
+
const market = fav.market ?? 'tw'
|
| 49 |
+
const exchangeStyle =
|
| 50 |
+
market === 'fund'
|
| 51 |
+
? 'bg-yellow-900/50 text-yellow-300'
|
| 52 |
+
: market === 'us'
|
| 53 |
+
? 'bg-green-900/50 text-green-300'
|
| 54 |
+
: info?.exchange === 'TPEX'
|
| 55 |
+
? 'bg-purple-900/50 text-purple-300'
|
| 56 |
+
: 'bg-blue-900/50 text-blue-300'
|
| 57 |
+
|
| 58 |
+
const marketLabel = info?.exchange ?? (market === 'fund' ? '基金' : market === 'us' ? 'US' : '台股')
|
| 59 |
+
|
| 60 |
+
const pricePrefix = market === 'fund' ? 'USD ' : market === 'us' ? 'USD ' : 'NT$ '
|
| 61 |
+
|
| 62 |
+
return (
|
| 63 |
+
<div className="bg-slate-900 border border-slate-800 hover:border-slate-600 rounded-xl p-4 space-y-3 transition-colors flex flex-col">
|
| 64 |
+
{/* Top row */}
|
| 65 |
+
<div className="flex items-start justify-between">
|
| 66 |
+
<div className="space-y-0.5">
|
| 67 |
+
<div className="flex items-center gap-2 flex-wrap">
|
| 68 |
+
<span className="font-mono font-bold text-white text-lg leading-none">{fav.id}</span>
|
| 69 |
+
<span className={`text-xs rounded px-1.5 py-0.5 font-medium ${exchangeStyle}`}>
|
| 70 |
+
{marketLabel}
|
| 71 |
+
</span>
|
| 72 |
+
</div>
|
| 73 |
+
<p className="text-slate-300 text-sm truncate max-w-[160px]">
|
| 74 |
+
{info?.name || fav.name}
|
| 75 |
+
</p>
|
| 76 |
+
</div>
|
| 77 |
+
<div className="flex items-center gap-0.5 flex-shrink-0 ml-2">
|
| 78 |
+
<button onClick={loadInfo} disabled={fetching} className="p-1.5 text-slate-500 hover:text-slate-300 transition-colors rounded" title="重新整理價格">
|
| 79 |
+
<RefreshCw size={13} className={fetching ? 'animate-spin' : ''} />
|
| 80 |
+
</button>
|
| 81 |
+
<button onClick={() => onRemove(fav.id)} className="p-1.5 text-slate-500 hover:text-red-400 transition-colors rounded" title="從最愛移除">
|
| 82 |
+
<Trash2 size={13} />
|
| 83 |
+
</button>
|
| 84 |
+
</div>
|
| 85 |
+
</div>
|
| 86 |
+
|
| 87 |
+
{/* Price row */}
|
| 88 |
+
<div className="flex items-center justify-between">
|
| 89 |
+
<div>
|
| 90 |
+
{fetching ? (
|
| 91 |
+
<span className="text-slate-600 text-sm">載入中...</span>
|
| 92 |
+
) : info?.current_price != null ? (
|
| 93 |
+
<span className="text-xl font-bold text-green-400">
|
| 94 |
+
{pricePrefix}{info.current_price.toFixed(2)}
|
| 95 |
+
</span>
|
| 96 |
+
) : (
|
| 97 |
+
<span className="text-slate-600 text-sm">— 無報價 —</span>
|
| 98 |
+
)}
|
| 99 |
+
</div>
|
| 100 |
+
<button
|
| 101 |
+
onClick={() => onView(fav.id)}
|
| 102 |
+
className="flex items-center gap-1.5 bg-blue-600/20 hover:bg-blue-600/40 text-blue-400 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors"
|
| 103 |
+
>
|
| 104 |
+
<ExternalLink size={12} />
|
| 105 |
+
查看分析
|
| 106 |
+
</button>
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
{/* Note */}
|
| 110 |
+
<div className="relative mt-auto">
|
| 111 |
+
<FileText size={12} className="absolute left-2.5 top-2.5 text-slate-600 pointer-events-none" />
|
| 112 |
+
<textarea
|
| 113 |
+
value={note}
|
| 114 |
+
onChange={e => setNote(e.target.value)}
|
| 115 |
+
onBlur={() => onUpdateNote(fav.id, note)}
|
| 116 |
+
placeholder="備註 (失焦自動儲存)..."
|
| 117 |
+
rows={2}
|
| 118 |
+
className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-7 pr-3 py-2 text-xs text-slate-300 resize-none focus:outline-none focus:border-slate-500 placeholder-slate-600"
|
| 119 |
+
/>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
)
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
export const FavoritesPage: React.FC<FavoritesPageProps> = ({
|
| 126 |
+
favorites,
|
| 127 |
+
onView,
|
| 128 |
+
onRemove,
|
| 129 |
+
onUpdateNote,
|
| 130 |
+
}) => {
|
| 131 |
+
const [activeTab, setActiveTab] = useState<Market | 'all'>('all')
|
| 132 |
+
|
| 133 |
+
const filtered = activeTab === 'all'
|
| 134 |
+
? favorites
|
| 135 |
+
: favorites.filter(f => (f.market ?? 'tw') === activeTab)
|
| 136 |
+
|
| 137 |
+
const countByMarket = (m: Market | 'all') =>
|
| 138 |
+
m === 'all' ? favorites.length : favorites.filter(f => (f.market ?? 'tw') === m).length
|
| 139 |
+
|
| 140 |
+
return (
|
| 141 |
+
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
| 142 |
+
<div className="flex items-center gap-3">
|
| 143 |
+
<Star className="text-yellow-500" size={22} fill="currentColor" />
|
| 144 |
+
<h2 className="text-2xl font-bold text-white">我的最愛</h2>
|
| 145 |
+
<span className="bg-slate-800 text-slate-400 text-xs rounded-full px-2.5 py-0.5 font-medium">
|
| 146 |
+
{favorites.length} 支
|
| 147 |
+
</span>
|
| 148 |
+
</div>
|
| 149 |
+
|
| 150 |
+
{/* Market filter tabs */}
|
| 151 |
+
<div className="flex items-center gap-1 bg-slate-900 border border-slate-800 rounded-xl p-1 w-fit">
|
| 152 |
+
{MARKET_FILTER_TABS.map(tab => {
|
| 153 |
+
const count = countByMarket(tab.key)
|
| 154 |
+
return (
|
| 155 |
+
<button
|
| 156 |
+
key={tab.key}
|
| 157 |
+
onClick={() => setActiveTab(tab.key)}
|
| 158 |
+
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
| 159 |
+
activeTab === tab.key
|
| 160 |
+
? 'bg-slate-700 text-white shadow-sm'
|
| 161 |
+
: 'text-slate-400 hover:text-white'
|
| 162 |
+
}`}
|
| 163 |
+
>
|
| 164 |
+
<span>{tab.flag}</span>
|
| 165 |
+
<span>{tab.label}</span>
|
| 166 |
+
{count > 0 && (
|
| 167 |
+
<span className={`text-[10px] font-bold rounded-full min-w-[16px] h-4 px-1 flex items-center justify-center ${
|
| 168 |
+
activeTab === tab.key ? 'bg-blue-500 text-white' : 'bg-slate-700 text-slate-300'
|
| 169 |
+
}`}>
|
| 170 |
+
{count}
|
| 171 |
+
</span>
|
| 172 |
+
)}
|
| 173 |
+
</button>
|
| 174 |
+
)
|
| 175 |
+
})}
|
| 176 |
+
</div>
|
| 177 |
+
|
| 178 |
+
{filtered.length === 0 ? (
|
| 179 |
+
<div className="flex flex-col items-center justify-center py-28 gap-4 text-center">
|
| 180 |
+
<Star size={52} className="text-slate-700" />
|
| 181 |
+
<p className="text-slate-400 font-medium">
|
| 182 |
+
{activeTab === 'all' ? '尚未加入任何股票' : `尚未加入任何${MARKET_FILTER_TABS.find(t => t.key === activeTab)?.label}項目`}
|
| 183 |
+
</p>
|
| 184 |
+
<p className="text-slate-600 text-sm">在股票分析頁面點擊 ★ 按鈕即可加入最愛</p>
|
| 185 |
+
</div>
|
| 186 |
+
) : (
|
| 187 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
| 188 |
+
{filtered.map(fav => (
|
| 189 |
+
<FavoriteCard
|
| 190 |
+
key={fav.id}
|
| 191 |
+
fav={fav}
|
| 192 |
+
onView={onView}
|
| 193 |
+
onRemove={onRemove}
|
| 194 |
+
onUpdateNote={onUpdateNote}
|
| 195 |
+
/>
|
| 196 |
+
))}
|
| 197 |
+
</div>
|
| 198 |
+
)}
|
| 199 |
+
</div>
|
| 200 |
+
)
|
| 201 |
+
}
|
frontend/src/components/Header.tsx
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useRef, useEffect, useCallback } from 'react'
|
| 2 |
+
import { Search, TrendingUp, Loader2, LogOut, Star, BarChart2, User, X } from 'lucide-react'
|
| 3 |
+
import { searchStocks, SearchResult } from '../api/stockApi'
|
| 4 |
+
|
| 5 |
+
export type Market = 'tw' | 'us' | 'fund'
|
| 6 |
+
|
| 7 |
+
const MARKET_TABS: { key: Market; label: string; flag: string }[] = [
|
| 8 |
+
{ key: 'tw', label: '台股', flag: '🇹🇼' },
|
| 9 |
+
{ key: 'us', label: '美股', flag: '🇺🇸' },
|
| 10 |
+
{ key: 'fund', label: '基金', flag: '📊' },
|
| 11 |
+
]
|
| 12 |
+
|
| 13 |
+
const MARKET_PLACEHOLDER: Record<Market, string> = {
|
| 14 |
+
tw: '台股代號 (e.g. 7856)',
|
| 15 |
+
us: '美股代號 (e.g. AAPL)',
|
| 16 |
+
fund: '基金代號 (e.g. PIM91)',
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
interface HeaderProps {
|
| 20 |
+
input: string
|
| 21 |
+
setInput: (val: string) => void
|
| 22 |
+
months: number
|
| 23 |
+
setMonths: (val: number) => void
|
| 24 |
+
loading: boolean
|
| 25 |
+
onSubmit: (e: React.FormEvent) => void
|
| 26 |
+
onLogout: () => void
|
| 27 |
+
currentUser: string
|
| 28 |
+
userPhotoURL?: string
|
| 29 |
+
view: 'main' | 'favorites'
|
| 30 |
+
onViewChange: (v: 'main' | 'favorites') => void
|
| 31 |
+
favoritesCount: number
|
| 32 |
+
market: Market
|
| 33 |
+
onMarketChange: (m: Market) => void
|
| 34 |
+
onSelect: (stockNo: string) => void
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export const Header: React.FC<HeaderProps> = ({
|
| 38 |
+
input, setInput,
|
| 39 |
+
months, setMonths,
|
| 40 |
+
loading,
|
| 41 |
+
onSubmit,
|
| 42 |
+
onLogout,
|
| 43 |
+
currentUser,
|
| 44 |
+
userPhotoURL,
|
| 45 |
+
view, onViewChange,
|
| 46 |
+
favoritesCount,
|
| 47 |
+
market, onMarketChange,
|
| 48 |
+
onSelect,
|
| 49 |
+
}) => {
|
| 50 |
+
const [suggestions, setSuggestions] = useState<SearchResult[]>([])
|
| 51 |
+
const [showDrop, setShowDrop] = useState(false)
|
| 52 |
+
const [searching, setSearching] = useState(false)
|
| 53 |
+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
| 54 |
+
const wrapperRef = useRef<HTMLDivElement>(null)
|
| 55 |
+
|
| 56 |
+
// Close dropdown when clicking outside
|
| 57 |
+
useEffect(() => {
|
| 58 |
+
const handler = (e: MouseEvent) => {
|
| 59 |
+
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
| 60 |
+
setShowDrop(false)
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
document.addEventListener('mousedown', handler)
|
| 64 |
+
return () => document.removeEventListener('mousedown', handler)
|
| 65 |
+
}, [])
|
| 66 |
+
|
| 67 |
+
const fetchSuggestions = useCallback(async (q: string, mkt: Market) => {
|
| 68 |
+
if (!q.trim()) { setSuggestions([]); setShowDrop(false); return }
|
| 69 |
+
// For TW: need at least 1 char; for US/fund: 1 char
|
| 70 |
+
if (q.length < 1) return
|
| 71 |
+
setSearching(true)
|
| 72 |
+
try {
|
| 73 |
+
const results = await searchStocks(q, mkt)
|
| 74 |
+
setSuggestions(results)
|
| 75 |
+
setShowDrop(results.length > 0)
|
| 76 |
+
} catch {
|
| 77 |
+
setSuggestions([])
|
| 78 |
+
setShowDrop(false)
|
| 79 |
+
} finally {
|
| 80 |
+
setSearching(false)
|
| 81 |
+
}
|
| 82 |
+
}, [])
|
| 83 |
+
|
| 84 |
+
const handleInputChange = (val: string) => {
|
| 85 |
+
setInput(val)
|
| 86 |
+
if (debounceRef.current) clearTimeout(debounceRef.current)
|
| 87 |
+
debounceRef.current = setTimeout(() => fetchSuggestions(val, market), 350)
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
const handleSelect = (stockNo: string) => {
|
| 91 |
+
setInput(stockNo)
|
| 92 |
+
setSuggestions([])
|
| 93 |
+
setShowDrop(false)
|
| 94 |
+
onSelect(stockNo)
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
const handleMarketChange = (m: Market) => {
|
| 98 |
+
onMarketChange(m)
|
| 99 |
+
setSuggestions([])
|
| 100 |
+
setShowDrop(false)
|
| 101 |
+
setInput('')
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
const handleFormSubmit = (e: React.FormEvent) => {
|
| 105 |
+
setShowDrop(false)
|
| 106 |
+
onSubmit(e)
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
const exchangeColor: Record<string, string> = {
|
| 110 |
+
TWSE: 'text-blue-400', TPEX: 'text-purple-400',
|
| 111 |
+
基金: 'text-yellow-400', MoneyDJ: 'text-yellow-400',
|
| 112 |
+
NYSE: 'text-green-400', NASDAQ: 'text-green-400',
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
return (
|
| 116 |
+
<header className="bg-slate-900 border-b border-slate-800 sticky top-0 z-20">
|
| 117 |
+
<div className="max-w-7xl mx-auto px-4 py-3 flex flex-col sm:flex-row items-start sm:items-center gap-3">
|
| 118 |
+
|
| 119 |
+
{/* Brand */}
|
| 120 |
+
<div className="flex items-center gap-2 mr-2 flex-shrink-0">
|
| 121 |
+
<TrendingUp className="text-blue-400" size={22} />
|
| 122 |
+
<h1 className="text-lg font-bold tracking-tight text-white whitespace-nowrap hidden sm:block">台股 ML 預測</h1>
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
{/* View nav (分析 / 最愛) */}
|
| 126 |
+
<nav className="flex items-center gap-1 flex-shrink-0">
|
| 127 |
+
<button
|
| 128 |
+
onClick={() => onViewChange('main')}
|
| 129 |
+
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
| 130 |
+
view === 'main' ? 'bg-blue-600/20 text-blue-400' : 'text-slate-400 hover:text-white hover:bg-slate-800'
|
| 131 |
+
}`}
|
| 132 |
+
>
|
| 133 |
+
<BarChart2 size={14} /> 分析
|
| 134 |
+
</button>
|
| 135 |
+
<button
|
| 136 |
+
onClick={() => onViewChange('favorites')}
|
| 137 |
+
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
| 138 |
+
view === 'favorites' ? 'bg-yellow-500/20 text-yellow-400' : 'text-slate-400 hover:text-white hover:bg-slate-800'
|
| 139 |
+
}`}
|
| 140 |
+
>
|
| 141 |
+
<Star size={14} fill={view === 'favorites' ? 'currentColor' : 'none'} />
|
| 142 |
+
最愛
|
| 143 |
+
{favoritesCount > 0 && (
|
| 144 |
+
<span className="bg-yellow-500 text-black text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center leading-none">
|
| 145 |
+
{favoritesCount > 9 ? '9+' : favoritesCount}
|
| 146 |
+
</span>
|
| 147 |
+
)}
|
| 148 |
+
</button>
|
| 149 |
+
</nav>
|
| 150 |
+
|
| 151 |
+
{/* Market tabs + search (only on main view) */}
|
| 152 |
+
{view === 'main' && (
|
| 153 |
+
<div className="flex items-center gap-2 flex-1">
|
| 154 |
+
{/* Market tabs */}
|
| 155 |
+
<div className="flex items-center bg-slate-800 rounded-lg p-0.5 flex-shrink-0">
|
| 156 |
+
{MARKET_TABS.map(tab => (
|
| 157 |
+
<button
|
| 158 |
+
key={tab.key}
|
| 159 |
+
onClick={() => handleMarketChange(tab.key)}
|
| 160 |
+
className={`flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium transition-colors ${
|
| 161 |
+
market === tab.key
|
| 162 |
+
? 'bg-slate-700 text-white shadow-sm'
|
| 163 |
+
: 'text-slate-400 hover:text-white'
|
| 164 |
+
}`}
|
| 165 |
+
>
|
| 166 |
+
<span>{tab.flag}</span>
|
| 167 |
+
<span className="hidden sm:inline">{tab.label}</span>
|
| 168 |
+
</button>
|
| 169 |
+
))}
|
| 170 |
+
</div>
|
| 171 |
+
|
| 172 |
+
{/* Search with autocomplete */}
|
| 173 |
+
<form onSubmit={handleFormSubmit} className="flex items-center gap-2 flex-1">
|
| 174 |
+
<div className="relative flex-1 sm:flex-none" ref={wrapperRef}>
|
| 175 |
+
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none z-10" />
|
| 176 |
+
<input
|
| 177 |
+
type="text"
|
| 178 |
+
value={input}
|
| 179 |
+
onChange={(e) => handleInputChange(e.target.value)}
|
| 180 |
+
onFocus={() => suggestions.length > 0 && setShowDrop(true)}
|
| 181 |
+
placeholder={MARKET_PLACEHOLDER[market]}
|
| 182 |
+
className="bg-slate-800 border border-slate-600 rounded-lg pl-8 pr-7 py-2 text-sm w-full sm:w-52 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 text-white placeholder-slate-500"
|
| 183 |
+
/>
|
| 184 |
+
{input && (
|
| 185 |
+
<button
|
| 186 |
+
type="button"
|
| 187 |
+
onClick={() => { setInput(''); setSuggestions([]); setShowDrop(false) }}
|
| 188 |
+
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white"
|
| 189 |
+
>
|
| 190 |
+
<X size={12} />
|
| 191 |
+
</button>
|
| 192 |
+
)}
|
| 193 |
+
|
| 194 |
+
{/* Autocomplete dropdown */}
|
| 195 |
+
{showDrop && (
|
| 196 |
+
<div className="absolute top-full left-0 right-0 mt-1 bg-slate-800 border border-slate-700 rounded-lg shadow-xl z-50 max-h-64 overflow-y-auto">
|
| 197 |
+
{searching && (
|
| 198 |
+
<div className="px-3 py-2 text-xs text-slate-500 flex items-center gap-2">
|
| 199 |
+
<Loader2 size={12} className="animate-spin" /> 搜尋中...
|
| 200 |
+
</div>
|
| 201 |
+
)}
|
| 202 |
+
{suggestions.map((s) => (
|
| 203 |
+
<button
|
| 204 |
+
key={s.stock_no}
|
| 205 |
+
type="button"
|
| 206 |
+
onClick={() => handleSelect(s.stock_no)}
|
| 207 |
+
className="w-full px-3 py-2 text-left hover:bg-slate-700 flex items-center justify-between gap-2 transition-colors"
|
| 208 |
+
>
|
| 209 |
+
<div className="flex items-center gap-2 min-w-0">
|
| 210 |
+
<span className="font-mono font-bold text-white text-sm flex-shrink-0">{s.stock_no}</span>
|
| 211 |
+
<span className="text-slate-300 text-xs truncate">{s.name}</span>
|
| 212 |
+
</div>
|
| 213 |
+
<span className={`text-[10px] font-medium flex-shrink-0 ${exchangeColor[s.exchange] ?? 'text-slate-400'}`}>
|
| 214 |
+
{s.exchange}
|
| 215 |
+
</span>
|
| 216 |
+
</button>
|
| 217 |
+
))}
|
| 218 |
+
</div>
|
| 219 |
+
)}
|
| 220 |
+
</div>
|
| 221 |
+
|
| 222 |
+
<select
|
| 223 |
+
value={months}
|
| 224 |
+
onChange={(e) => setMonths(Number(e.target.value))}
|
| 225 |
+
className="bg-slate-800 border border-slate-600 rounded-lg px-2 py-2 text-sm focus:outline-none focus:border-blue-500 text-white flex-shrink-0"
|
| 226 |
+
>
|
| 227 |
+
<option value={3}>3個月</option>
|
| 228 |
+
<option value={6}>6個月</option>
|
| 229 |
+
<option value={12}>12個月</option>
|
| 230 |
+
<option value={24}>24個月</option>
|
| 231 |
+
</select>
|
| 232 |
+
|
| 233 |
+
<button
|
| 234 |
+
type="submit"
|
| 235 |
+
disabled={loading}
|
| 236 |
+
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-800 disabled:cursor-not-allowed text-white rounded-lg px-4 py-2 text-sm font-medium transition-colors flex-shrink-0"
|
| 237 |
+
>
|
| 238 |
+
{loading ? <Loader2 size={14} className="animate-spin" /> : <Search size={14} />}
|
| 239 |
+
<span className="hidden sm:inline">查詢</span>
|
| 240 |
+
</button>
|
| 241 |
+
</form>
|
| 242 |
+
</div>
|
| 243 |
+
)}
|
| 244 |
+
|
| 245 |
+
{/* User avatar + logout */}
|
| 246 |
+
<div className="flex items-center gap-2 sm:ml-auto flex-shrink-0">
|
| 247 |
+
{userPhotoURL ? (
|
| 248 |
+
<img src={userPhotoURL} alt={currentUser} className="w-7 h-7 rounded-full border border-slate-600 object-cover" referrerPolicy="no-referrer" />
|
| 249 |
+
) : (
|
| 250 |
+
<div className="w-7 h-7 rounded-full bg-slate-700 flex items-center justify-center">
|
| 251 |
+
<User size={14} className="text-slate-400" />
|
| 252 |
+
</div>
|
| 253 |
+
)}
|
| 254 |
+
<span className="hidden sm:inline text-xs font-medium text-slate-300 max-w-[100px] truncate">{currentUser}</span>
|
| 255 |
+
<button
|
| 256 |
+
onClick={onLogout}
|
| 257 |
+
className="p-2 text-slate-400 hover:text-white transition-colors rounded-lg hover:bg-slate-800"
|
| 258 |
+
title="登出"
|
| 259 |
+
type="button"
|
| 260 |
+
>
|
| 261 |
+
<LogOut size={18} />
|
| 262 |
+
</button>
|
| 263 |
+
</div>
|
| 264 |
+
</div>
|
| 265 |
+
</header>
|
| 266 |
+
)
|
| 267 |
+
}
|
frontend/src/components/Login.tsx
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react'
|
| 2 |
+
import { Lock, Mail, TrendingUp, UserPlus, LogIn, User } from 'lucide-react'
|
| 3 |
+
import { signInWithPopup, signInWithEmailAndPassword, createUserWithEmailAndPassword, updateProfile } from 'firebase/auth'
|
| 4 |
+
import { auth, googleProvider, firebaseReady } from '../firebase'
|
| 5 |
+
|
| 6 |
+
// Google "G" SVG icon
|
| 7 |
+
const GoogleIcon = () => (
|
| 8 |
+
<svg viewBox="0 0 24 24" width="18" height="18">
|
| 9 |
+
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
| 10 |
+
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
| 11 |
+
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"/>
|
| 12 |
+
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
| 13 |
+
</svg>
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
interface LoginProps {
|
| 17 |
+
mode: 'firebase' | 'local'
|
| 18 |
+
onLsLogin: (username: string, password: string) => boolean
|
| 19 |
+
onLsLoginSuccess: (username: string) => void
|
| 20 |
+
onLsRegister: (username: string, password: string) => string | null
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export const Login: React.FC<LoginProps> = ({ mode, onLsLogin, onLsLoginSuccess, onLsRegister }) => {
|
| 24 |
+
const [tab, setTab] = useState<'login' | 'register'>('login')
|
| 25 |
+
const [displayName, setDisplayName] = useState('')
|
| 26 |
+
const [emailOrUser, setEmailOrUser] = useState('')
|
| 27 |
+
const [password, setPassword] = useState('')
|
| 28 |
+
const [confirm, setConfirm] = useState('')
|
| 29 |
+
const [error, setError] = useState('')
|
| 30 |
+
const [regSuccess, setRegSuccess] = useState(false)
|
| 31 |
+
const [loading, setLoading] = useState(false)
|
| 32 |
+
|
| 33 |
+
const switchTab = (t: 'login' | 'register') => {
|
| 34 |
+
setTab(t); setError(''); setRegSuccess(false); setPassword(''); setConfirm('')
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// ── Firebase: Google popup ────────────────────────────────────────────────
|
| 38 |
+
const handleGoogle = async () => {
|
| 39 |
+
setError(''); setLoading(true)
|
| 40 |
+
try { await signInWithPopup(auth, googleProvider) }
|
| 41 |
+
catch (e: any) { setError(friendlyFirebaseError(e.code)) }
|
| 42 |
+
setLoading(false)
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// ── Firebase: email/password ──────────────────────────────────────────────
|
| 46 |
+
const handleFirebaseSubmit = async (e: React.FormEvent) => {
|
| 47 |
+
e.preventDefault(); setError('')
|
| 48 |
+
if (!emailOrUser || !password) { setError('請填寫所有欄位'); return }
|
| 49 |
+
setLoading(true)
|
| 50 |
+
try {
|
| 51 |
+
if (tab === 'register') {
|
| 52 |
+
if (password.length < 6) { setError('密碼至少需要 6 個字元'); setLoading(false); return }
|
| 53 |
+
if (password !== confirm) { setError('兩次密碼不一致'); setLoading(false); return }
|
| 54 |
+
const cred = await createUserWithEmailAndPassword(auth, emailOrUser, password)
|
| 55 |
+
if (displayName.trim()) await updateProfile(cred.user, { displayName: displayName.trim() })
|
| 56 |
+
setRegSuccess(true); switchTab('login')
|
| 57 |
+
} else {
|
| 58 |
+
await signInWithEmailAndPassword(auth, emailOrUser, password)
|
| 59 |
+
}
|
| 60 |
+
} catch (e: any) { setError(friendlyFirebaseError(e.code)) }
|
| 61 |
+
setLoading(false)
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// ── localStorage fallback ─────────────────────────────────────────────────
|
| 65 |
+
const handleLocalSubmit = (e: React.FormEvent) => {
|
| 66 |
+
e.preventDefault(); setError('')
|
| 67 |
+
const u = emailOrUser.trim()
|
| 68 |
+
if (!u || !password) { setError('請填寫所有欄位'); return }
|
| 69 |
+
if (tab === 'register') {
|
| 70 |
+
if (password !== confirm) { setError('兩次密碼不一致'); return }
|
| 71 |
+
const err = onLsRegister(u, password)
|
| 72 |
+
if (err) { setError(err); return }
|
| 73 |
+
setRegSuccess(true); switchTab('login')
|
| 74 |
+
} else {
|
| 75 |
+
if (!onLsLogin(u, password)) { setError('帳號或密碼錯誤'); return }
|
| 76 |
+
onLsLoginSuccess(u)
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
const isFirebase = mode === 'firebase'
|
| 81 |
+
|
| 82 |
+
return (
|
| 83 |
+
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
|
| 84 |
+
<div className="w-full max-w-sm space-y-6">
|
| 85 |
+
{/* Brand */}
|
| 86 |
+
<div className="text-center space-y-1">
|
| 87 |
+
<div className="flex items-center justify-center gap-2 mb-2">
|
| 88 |
+
<TrendingUp className="text-blue-400" size={28} />
|
| 89 |
+
<h1 className="text-2xl font-bold text-white">台股 ML 預測系統</h1>
|
| 90 |
+
</div>
|
| 91 |
+
<p className="text-slate-500 text-sm">AI 技術分析 · RandomForest 預測</p>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-8 shadow-2xl space-y-5">
|
| 95 |
+
{/* Tabs */}
|
| 96 |
+
<div className="flex bg-slate-800 rounded-lg p-1 gap-1">
|
| 97 |
+
{(['login', 'register'] as const).map(t => (
|
| 98 |
+
<button key={t} type="button" onClick={() => switchTab(t)}
|
| 99 |
+
className={`flex-1 flex items-center justify-center gap-1.5 py-2 rounded-md text-sm font-medium transition-colors ${
|
| 100 |
+
tab === t ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white'
|
| 101 |
+
}`}>
|
| 102 |
+
{t === 'login' ? <><LogIn size={14} /> 登入</> : <><UserPlus size={14} /> 註冊</>}
|
| 103 |
+
</button>
|
| 104 |
+
))}
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
{/* Google button — only when Firebase is configured */}
|
| 108 |
+
{isFirebase && (
|
| 109 |
+
<>
|
| 110 |
+
<button type="button" onClick={handleGoogle} disabled={loading}
|
| 111 |
+
className="w-full flex items-center justify-center gap-2.5 bg-white hover:bg-gray-100 disabled:bg-gray-200 text-gray-800 font-medium py-2.5 rounded-lg transition-colors text-sm">
|
| 112 |
+
<GoogleIcon />
|
| 113 |
+
使用 Google 帳號{tab === 'register' ? '註冊' : '登入'}
|
| 114 |
+
</button>
|
| 115 |
+
<div className="flex items-center gap-3">
|
| 116 |
+
<div className="flex-1 h-px bg-slate-700" />
|
| 117 |
+
<span className="text-xs text-slate-500">或用電子郵件</span>
|
| 118 |
+
<div className="flex-1 h-px bg-slate-700" />
|
| 119 |
+
</div>
|
| 120 |
+
</>
|
| 121 |
+
)}
|
| 122 |
+
|
| 123 |
+
{regSuccess && (
|
| 124 |
+
<div className="bg-green-900/30 border border-green-700 rounded-lg px-3 py-2 text-green-400 text-sm text-center">
|
| 125 |
+
帳號建立成功!請登入
|
| 126 |
+
</div>
|
| 127 |
+
)}
|
| 128 |
+
|
| 129 |
+
{/* Form */}
|
| 130 |
+
<form onSubmit={isFirebase ? handleFirebaseSubmit : handleLocalSubmit} className="space-y-3">
|
| 131 |
+
{tab === 'register' && isFirebase && (
|
| 132 |
+
<div className="relative">
|
| 133 |
+
<User size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
| 134 |
+
<input type="text" value={displayName} onChange={e => setDisplayName(e.target.value)}
|
| 135 |
+
placeholder="顯示名稱(選填)"
|
| 136 |
+
className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-8 pr-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 placeholder-slate-500" />
|
| 137 |
+
</div>
|
| 138 |
+
)}
|
| 139 |
+
|
| 140 |
+
<div className="relative">
|
| 141 |
+
{isFirebase
|
| 142 |
+
? <Mail size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
| 143 |
+
: <User size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
| 144 |
+
}
|
| 145 |
+
<input
|
| 146 |
+
type={isFirebase ? 'email' : 'text'}
|
| 147 |
+
value={emailOrUser}
|
| 148 |
+
onChange={e => setEmailOrUser(e.target.value)}
|
| 149 |
+
placeholder={isFirebase ? '電子郵件' : '帳號'}
|
| 150 |
+
autoFocus autoComplete="username"
|
| 151 |
+
className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-8 pr-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 placeholder-slate-500"
|
| 152 |
+
/>
|
| 153 |
+
</div>
|
| 154 |
+
|
| 155 |
+
<div className="relative">
|
| 156 |
+
<Lock size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
| 157 |
+
<input type="password" value={password} onChange={e => setPassword(e.target.value)}
|
| 158 |
+
placeholder="密碼" autoComplete={tab === 'register' ? 'new-password' : 'current-password'}
|
| 159 |
+
className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-8 pr-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 placeholder-slate-500" />
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
+
{tab === 'register' && (
|
| 163 |
+
<div className="relative">
|
| 164 |
+
<Lock size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
| 165 |
+
<input type="password" value={confirm} onChange={e => setConfirm(e.target.value)}
|
| 166 |
+
placeholder="確認密碼" autoComplete="new-password"
|
| 167 |
+
className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-8 pr-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 placeholder-slate-500" />
|
| 168 |
+
</div>
|
| 169 |
+
)}
|
| 170 |
+
|
| 171 |
+
{error && <p className="text-red-400 text-xs">{error}</p>}
|
| 172 |
+
|
| 173 |
+
<button type="submit" disabled={loading}
|
| 174 |
+
className="w-full bg-blue-600 hover:bg-blue-500 disabled:bg-blue-800 disabled:cursor-not-allowed text-white font-bold py-2.5 rounded-lg transition-colors text-sm">
|
| 175 |
+
{loading ? '處理中...'
|
| 176 |
+
: tab === 'login' ? (isFirebase ? '電子郵件登入' : '確認登入')
|
| 177 |
+
: '建立帳號'}
|
| 178 |
+
</button>
|
| 179 |
+
</form>
|
| 180 |
+
|
| 181 |
+
</div>
|
| 182 |
+
</div>
|
| 183 |
+
</div>
|
| 184 |
+
)
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
function friendlyFirebaseError(code: string): string {
|
| 188 |
+
const map: Record<string, string> = {
|
| 189 |
+
'auth/user-not-found': '找不到此帳號',
|
| 190 |
+
'auth/wrong-password': '密碼錯誤',
|
| 191 |
+
'auth/invalid-credential': '帳號或密碼錯誤',
|
| 192 |
+
'auth/email-already-in-use': '此電子郵件已被使用',
|
| 193 |
+
'auth/invalid-email': '電子郵件格式不正確',
|
| 194 |
+
'auth/weak-password': '密碼強度不足(至少 6 字元)',
|
| 195 |
+
'auth/popup-closed-by-user': '登入視窗已關閉,請再試一次',
|
| 196 |
+
'auth/network-request-failed': '網路連線錯誤,請稍後再試',
|
| 197 |
+
'auth/too-many-requests': '嘗試次數過多,請稍後再試',
|
| 198 |
+
}
|
| 199 |
+
return map[code] ?? `登入失敗 (${code})`
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
// suppress unused warning — firebaseReady is imported for side-effect (init guard)
|
| 203 |
+
void firebaseReady
|
frontend/src/components/MACDChart.tsx
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react'
|
| 2 |
+
import {
|
| 3 |
+
ComposedChart,
|
| 4 |
+
Bar,
|
| 5 |
+
Line,
|
| 6 |
+
Cell,
|
| 7 |
+
XAxis,
|
| 8 |
+
YAxis,
|
| 9 |
+
CartesianGrid,
|
| 10 |
+
Tooltip,
|
| 11 |
+
Legend,
|
| 12 |
+
ReferenceLine,
|
| 13 |
+
ResponsiveContainer,
|
| 14 |
+
} from 'recharts'
|
| 15 |
+
import { OHLCVPoint } from '../api/stockApi'
|
| 16 |
+
|
| 17 |
+
interface Props {
|
| 18 |
+
data: OHLCVPoint[]
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const CustomTooltip = ({ active, payload, label }: any) => {
|
| 22 |
+
if (!active || !payload?.length) return null
|
| 23 |
+
const d: OHLCVPoint = payload[0]?.payload
|
| 24 |
+
return (
|
| 25 |
+
<div className="bg-slate-800 border border-slate-600 rounded p-2 text-xs shadow-lg">
|
| 26 |
+
<p className="text-slate-300">{label}</p>
|
| 27 |
+
{d.macd != null && (
|
| 28 |
+
<p>MACD: <span className="text-blue-400">{d.macd.toFixed(3)}</span></p>
|
| 29 |
+
)}
|
| 30 |
+
{d.macd_signal != null && (
|
| 31 |
+
<p>Signal: <span className="text-orange-400">{d.macd_signal.toFixed(3)}</span></p>
|
| 32 |
+
)}
|
| 33 |
+
{d.macd_hist != null && (
|
| 34 |
+
<p>
|
| 35 |
+
Hist:{' '}
|
| 36 |
+
<span className={d.macd_hist >= 0 ? 'text-green-400' : 'text-red-400'}>
|
| 37 |
+
{d.macd_hist.toFixed(3)}
|
| 38 |
+
</span>
|
| 39 |
+
</p>
|
| 40 |
+
)}
|
| 41 |
+
</div>
|
| 42 |
+
)
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
export const MACDChart: React.FC<Props> = ({ data }) => {
|
| 46 |
+
const len = data.length
|
| 47 |
+
|
| 48 |
+
return (
|
| 49 |
+
<ResponsiveContainer width="100%" height={180}>
|
| 50 |
+
<ComposedChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 0 }}>
|
| 51 |
+
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
| 52 |
+
<XAxis
|
| 53 |
+
dataKey="date"
|
| 54 |
+
tick={{ fill: '#94a3b8', fontSize: 10 }}
|
| 55 |
+
tickLine={false}
|
| 56 |
+
tickFormatter={(val, idx) => {
|
| 57 |
+
const step = Math.max(1, Math.floor(len / 10))
|
| 58 |
+
return idx % step === 0 ? val : ''
|
| 59 |
+
}}
|
| 60 |
+
interval={0}
|
| 61 |
+
/>
|
| 62 |
+
<YAxis
|
| 63 |
+
tick={{ fill: '#94a3b8', fontSize: 10 }}
|
| 64 |
+
tickLine={false}
|
| 65 |
+
tickFormatter={(v) => v.toFixed(1)}
|
| 66 |
+
width={40}
|
| 67 |
+
/>
|
| 68 |
+
<Tooltip content={<CustomTooltip />} />
|
| 69 |
+
<Legend
|
| 70 |
+
wrapperStyle={{ fontSize: 12 }}
|
| 71 |
+
formatter={(value) => <span style={{ color: '#94a3b8' }}>{value}</span>}
|
| 72 |
+
/>
|
| 73 |
+
|
| 74 |
+
<ReferenceLine y={0} stroke="#475569" strokeWidth={1} />
|
| 75 |
+
|
| 76 |
+
{/* MACD Histogram */}
|
| 77 |
+
<Bar dataKey="macd_hist" name="Histogram" isAnimationActive={false} maxBarSize={4}>
|
| 78 |
+
{data.map((d, i) => (
|
| 79 |
+
<Cell
|
| 80 |
+
key={i}
|
| 81 |
+
fill={(d.macd_hist ?? 0) >= 0 ? '#22c55e' : '#ef4444'}
|
| 82 |
+
fillOpacity={0.7}
|
| 83 |
+
/>
|
| 84 |
+
))}
|
| 85 |
+
</Bar>
|
| 86 |
+
|
| 87 |
+
{/* MACD Line */}
|
| 88 |
+
<Line
|
| 89 |
+
dataKey="macd"
|
| 90 |
+
stroke="#60a5fa"
|
| 91 |
+
strokeWidth={1.5}
|
| 92 |
+
dot={false}
|
| 93 |
+
name="MACD"
|
| 94 |
+
isAnimationActive={false}
|
| 95 |
+
connectNulls
|
| 96 |
+
/>
|
| 97 |
+
|
| 98 |
+
{/* Signal Line */}
|
| 99 |
+
<Line
|
| 100 |
+
dataKey="macd_signal"
|
| 101 |
+
stroke="#fb923c"
|
| 102 |
+
strokeWidth={1.5}
|
| 103 |
+
dot={false}
|
| 104 |
+
name="Signal"
|
| 105 |
+
isAnimationActive={false}
|
| 106 |
+
connectNulls
|
| 107 |
+
/>
|
| 108 |
+
</ComposedChart>
|
| 109 |
+
</ResponsiveContainer>
|
| 110 |
+
)
|
| 111 |
+
}
|
frontend/src/components/PredictionPanel.tsx
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react'
|
| 2 |
+
import { TrendingUp, TrendingDown, Minus, AlertTriangle } from 'lucide-react'
|
| 3 |
+
import { Prediction } from '../api/stockApi'
|
| 4 |
+
|
| 5 |
+
interface Props {
|
| 6 |
+
prediction: Prediction
|
| 7 |
+
stockName: string
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
const signalConfig = {
|
| 11 |
+
BUY: {
|
| 12 |
+
label: 'BUY',
|
| 13 |
+
labelZh: '買入',
|
| 14 |
+
bg: 'bg-green-500/10',
|
| 15 |
+
border: 'border-green-500',
|
| 16 |
+
text: 'text-green-400',
|
| 17 |
+
badgeBg: 'bg-green-600',
|
| 18 |
+
icon: TrendingUp,
|
| 19 |
+
},
|
| 20 |
+
SELL: {
|
| 21 |
+
label: 'SELL',
|
| 22 |
+
labelZh: '賣出',
|
| 23 |
+
bg: 'bg-red-500/10',
|
| 24 |
+
border: 'border-red-500',
|
| 25 |
+
text: 'text-red-400',
|
| 26 |
+
badgeBg: 'bg-red-600',
|
| 27 |
+
icon: TrendingDown,
|
| 28 |
+
},
|
| 29 |
+
HOLD: {
|
| 30 |
+
label: 'HOLD',
|
| 31 |
+
labelZh: '持觀望',
|
| 32 |
+
bg: 'bg-yellow-500/10',
|
| 33 |
+
border: 'border-yellow-500',
|
| 34 |
+
text: 'text-yellow-400',
|
| 35 |
+
badgeBg: 'bg-yellow-600',
|
| 36 |
+
icon: Minus,
|
| 37 |
+
},
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
const confidenceColor = {
|
| 41 |
+
HIGH: 'text-green-400',
|
| 42 |
+
MEDIUM: 'text-yellow-400',
|
| 43 |
+
LOW: 'text-red-400',
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
const confidenceZh = {
|
| 47 |
+
HIGH: '高信心',
|
| 48 |
+
MEDIUM: '中信心',
|
| 49 |
+
LOW: '低信心',
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
export const PredictionPanel: React.FC<Props> = ({ prediction, stockName }) => {
|
| 53 |
+
const cfg = signalConfig[prediction.signal]
|
| 54 |
+
const Icon = cfg.icon
|
| 55 |
+
const probPct = Math.round(prediction.signal_probability * 100)
|
| 56 |
+
const changePositive = prediction.predicted_change_pct >= 0
|
| 57 |
+
|
| 58 |
+
return (
|
| 59 |
+
<div className={`rounded-xl border-2 ${cfg.border} ${cfg.bg} p-5 space-y-4`}>
|
| 60 |
+
{/* Header */}
|
| 61 |
+
<div className="flex items-center justify-between">
|
| 62 |
+
<div>
|
| 63 |
+
<h2 className="text-lg font-bold text-white">{stockName} — ML 預測信號</h2>
|
| 64 |
+
<p className="text-xs text-slate-400 mt-0.5">預測期間:未來 5 個交易日</p>
|
| 65 |
+
</div>
|
| 66 |
+
<div className={`flex items-center gap-2 px-4 py-2 rounded-lg ${cfg.badgeBg} shadow-lg`}>
|
| 67 |
+
<Icon size={20} className="text-white" />
|
| 68 |
+
<span className="text-white font-black text-xl tracking-wider">{cfg.label}</span>
|
| 69 |
+
<span className="text-white/80 text-sm">({cfg.labelZh})</span>
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
{/* Price grid */}
|
| 74 |
+
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
| 75 |
+
<PriceCard
|
| 76 |
+
label="目前價格"
|
| 77 |
+
labelEn="Current Price"
|
| 78 |
+
value={`NT$ ${prediction.current_price.toFixed(2)}`}
|
| 79 |
+
valueClass="text-white"
|
| 80 |
+
/>
|
| 81 |
+
<PriceCard
|
| 82 |
+
label="預測價格 (5日)"
|
| 83 |
+
labelEn="Predicted Price"
|
| 84 |
+
value={`NT$ ${prediction.predicted_price.toFixed(2)}`}
|
| 85 |
+
sub={
|
| 86 |
+
<span className={changePositive ? 'text-green-400' : 'text-red-400'}>
|
| 87 |
+
{changePositive ? '+' : ''}{prediction.predicted_change_pct.toFixed(2)}%
|
| 88 |
+
</span>
|
| 89 |
+
}
|
| 90 |
+
valueClass={changePositive ? 'text-green-400' : 'text-red-400'}
|
| 91 |
+
/>
|
| 92 |
+
<PriceCard
|
| 93 |
+
label="建議賣出目標"
|
| 94 |
+
labelEn="Sell Target"
|
| 95 |
+
value={`NT$ ${prediction.sell_target.toFixed(2)}`}
|
| 96 |
+
valueClass="text-green-300"
|
| 97 |
+
/>
|
| 98 |
+
<PriceCard
|
| 99 |
+
label="停損價位"
|
| 100 |
+
labelEn="Stop Loss"
|
| 101 |
+
value={`NT$ ${prediction.stop_loss.toFixed(2)}`}
|
| 102 |
+
valueClass="text-red-300"
|
| 103 |
+
/>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
{/* Signal probability bar */}
|
| 107 |
+
<div className="space-y-1.5">
|
| 108 |
+
<div className="flex justify-between items-center text-xs">
|
| 109 |
+
<span className="text-slate-400">買入機率 (Buy Probability)</span>
|
| 110 |
+
<span className={`font-bold ${cfg.text}`}>{probPct}%</span>
|
| 111 |
+
</div>
|
| 112 |
+
<div className="h-2.5 bg-slate-700 rounded-full overflow-hidden">
|
| 113 |
+
<div
|
| 114 |
+
className={`h-full rounded-full transition-all duration-700 ${
|
| 115 |
+
probPct >= 60 ? 'bg-green-500' : probPct <= 35 ? 'bg-red-500' : 'bg-yellow-500'
|
| 116 |
+
}`}
|
| 117 |
+
style={{ width: `${probPct}%` }}
|
| 118 |
+
/>
|
| 119 |
+
</div>
|
| 120 |
+
<div className="flex justify-between text-xs text-slate-500">
|
| 121 |
+
<span>強力賣出 (Strong Sell)</span>
|
| 122 |
+
<span>強力買入 (Strong Buy)</span>
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
{/* Confidence + accuracy */}
|
| 127 |
+
<div className="flex flex-wrap gap-3 text-sm">
|
| 128 |
+
<div className="flex items-center gap-1.5 bg-slate-800 rounded-lg px-3 py-1.5">
|
| 129 |
+
<span className="text-slate-400">信心度:</span>
|
| 130 |
+
<span className={`font-bold ${confidenceColor[prediction.confidence]}`}>
|
| 131 |
+
{prediction.confidence} ({confidenceZh[prediction.confidence]})
|
| 132 |
+
</span>
|
| 133 |
+
</div>
|
| 134 |
+
<div className="flex items-center gap-1.5 bg-slate-800 rounded-lg px-3 py-1.5">
|
| 135 |
+
<span className="text-slate-400">模型準確率:</span>
|
| 136 |
+
<span className="text-slate-200 font-semibold">
|
| 137 |
+
{(prediction.training_accuracy * 100).toFixed(1)}%
|
| 138 |
+
</span>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
{/* Disclaimer */}
|
| 143 |
+
<div className="flex items-start gap-2 bg-slate-900/70 rounded-lg p-3 border border-slate-700">
|
| 144 |
+
<AlertTriangle size={14} className="text-yellow-400 mt-0.5 flex-shrink-0" />
|
| 145 |
+
<p className="text-xs text-slate-400 leading-relaxed">
|
| 146 |
+
<span className="text-yellow-400 font-semibold">免責聲明:</span>
|
| 147 |
+
本系統所有預測訊號僅供學術研究與教育用途,不構成任何投資建議。
|
| 148 |
+
機器學習模型無法保證未來績效,投資人應自行判斷並承擔風險。
|
| 149 |
+
{' '}
|
| 150 |
+
<span className="text-slate-500 italic">
|
| 151 |
+
(ML signals are for educational purposes only and do NOT constitute financial advice.)
|
| 152 |
+
</span>
|
| 153 |
+
</p>
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
)
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
// ---------------------------------------------------------------------------
|
| 160 |
+
// Helper sub-component
|
| 161 |
+
// ---------------------------------------------------------------------------
|
| 162 |
+
|
| 163 |
+
interface PriceCardProps {
|
| 164 |
+
label: string
|
| 165 |
+
labelEn: string
|
| 166 |
+
value: string
|
| 167 |
+
valueClass?: string
|
| 168 |
+
sub?: React.ReactNode
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
const PriceCard: React.FC<PriceCardProps> = ({
|
| 172 |
+
label,
|
| 173 |
+
labelEn,
|
| 174 |
+
value,
|
| 175 |
+
valueClass = 'text-white',
|
| 176 |
+
sub,
|
| 177 |
+
}) => (
|
| 178 |
+
<div className="bg-slate-900/60 rounded-lg p-3 space-y-1 border border-slate-700">
|
| 179 |
+
<p className="text-xs text-slate-400 leading-tight">{label}</p>
|
| 180 |
+
<p className="text-xs text-slate-500">{labelEn}</p>
|
| 181 |
+
<p className={`text-base font-bold ${valueClass} leading-tight`}>{value}</p>
|
| 182 |
+
{sub && <div className="text-xs font-semibold">{sub}</div>}
|
| 183 |
+
</div>
|
| 184 |
+
)
|
frontend/src/components/RSIChart.tsx
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react'
|
| 2 |
+
import {
|
| 3 |
+
ComposedChart,
|
| 4 |
+
Line,
|
| 5 |
+
XAxis,
|
| 6 |
+
YAxis,
|
| 7 |
+
CartesianGrid,
|
| 8 |
+
Tooltip,
|
| 9 |
+
ReferenceLine,
|
| 10 |
+
ReferenceArea,
|
| 11 |
+
ResponsiveContainer,
|
| 12 |
+
} from 'recharts'
|
| 13 |
+
import { OHLCVPoint } from '../api/stockApi'
|
| 14 |
+
|
| 15 |
+
interface Props {
|
| 16 |
+
data: OHLCVPoint[]
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const CustomTooltip = ({ active, payload, label }: any) => {
|
| 20 |
+
if (!active || !payload?.length) return null
|
| 21 |
+
const rsi: number | undefined = payload[0]?.value
|
| 22 |
+
const zone =
|
| 23 |
+
rsi == null ? '' : rsi >= 70 ? ' (Overbought)' : rsi <= 30 ? ' (Oversold)' : ''
|
| 24 |
+
return (
|
| 25 |
+
<div className="bg-slate-800 border border-slate-600 rounded p-2 text-xs shadow-lg">
|
| 26 |
+
<p className="text-slate-300">{label}</p>
|
| 27 |
+
<p>
|
| 28 |
+
RSI:{' '}
|
| 29 |
+
<span
|
| 30 |
+
className={
|
| 31 |
+
rsi == null
|
| 32 |
+
? 'text-white'
|
| 33 |
+
: rsi >= 70
|
| 34 |
+
? 'text-red-400'
|
| 35 |
+
: rsi <= 30
|
| 36 |
+
? 'text-green-400'
|
| 37 |
+
: 'text-white'
|
| 38 |
+
}
|
| 39 |
+
>
|
| 40 |
+
{rsi?.toFixed(1)}
|
| 41 |
+
{zone}
|
| 42 |
+
</span>
|
| 43 |
+
</p>
|
| 44 |
+
</div>
|
| 45 |
+
)
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export const RSIChart: React.FC<Props> = ({ data }) => {
|
| 49 |
+
const len = data.length
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
<ResponsiveContainer width="100%" height={180}>
|
| 53 |
+
<ComposedChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 0 }}>
|
| 54 |
+
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
| 55 |
+
<XAxis
|
| 56 |
+
dataKey="date"
|
| 57 |
+
tick={{ fill: '#94a3b8', fontSize: 10 }}
|
| 58 |
+
tickLine={false}
|
| 59 |
+
tickFormatter={(val, idx) => {
|
| 60 |
+
const step = Math.max(1, Math.floor(len / 10))
|
| 61 |
+
return idx % step === 0 ? val : ''
|
| 62 |
+
}}
|
| 63 |
+
interval={0}
|
| 64 |
+
/>
|
| 65 |
+
<YAxis
|
| 66 |
+
domain={[0, 100]}
|
| 67 |
+
tick={{ fill: '#94a3b8', fontSize: 10 }}
|
| 68 |
+
tickLine={false}
|
| 69 |
+
ticks={[0, 30, 50, 70, 100]}
|
| 70 |
+
width={32}
|
| 71 |
+
/>
|
| 72 |
+
<Tooltip content={<CustomTooltip />} />
|
| 73 |
+
|
| 74 |
+
{/* Overbought background zone */}
|
| 75 |
+
<ReferenceArea y1={70} y2={100} fill="#ef444422" fillOpacity={0.4} />
|
| 76 |
+
{/* Oversold background zone */}
|
| 77 |
+
<ReferenceArea y1={0} y2={30} fill="#22c55e22" fillOpacity={0.4} />
|
| 78 |
+
|
| 79 |
+
{/* Reference lines */}
|
| 80 |
+
<ReferenceLine y={70} stroke="#ef4444" strokeDasharray="4 4" strokeWidth={1} />
|
| 81 |
+
<ReferenceLine y={30} stroke="#22c55e" strokeDasharray="4 4" strokeWidth={1} />
|
| 82 |
+
<ReferenceLine y={50} stroke="#475569" strokeDasharray="2 4" strokeWidth={1} />
|
| 83 |
+
|
| 84 |
+
<Line
|
| 85 |
+
dataKey="rsi"
|
| 86 |
+
stroke="#a78bfa"
|
| 87 |
+
strokeWidth={1.5}
|
| 88 |
+
dot={false}
|
| 89 |
+
name="RSI"
|
| 90 |
+
isAnimationActive={false}
|
| 91 |
+
connectNulls
|
| 92 |
+
/>
|
| 93 |
+
</ComposedChart>
|
| 94 |
+
</ResponsiveContainer>
|
| 95 |
+
)
|
| 96 |
+
}
|
frontend/src/components/Sidebar.tsx
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react'
|
| 2 |
+
import { Star, Trash2, ChevronRight } from 'lucide-react'
|
| 3 |
+
|
| 4 |
+
interface FavoriteItem {
|
| 5 |
+
id: string
|
| 6 |
+
name: string
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
interface SidebarProps {
|
| 10 |
+
favorites: FavoriteItem[]
|
| 11 |
+
onSearch: (id: string, months: number) => void
|
| 12 |
+
onToggleFavorite: (id: string) => void
|
| 13 |
+
onViewFavorites: () => void
|
| 14 |
+
months: number
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export const Sidebar: React.FC<SidebarProps> = ({
|
| 18 |
+
favorites,
|
| 19 |
+
onSearch,
|
| 20 |
+
onToggleFavorite,
|
| 21 |
+
onViewFavorites,
|
| 22 |
+
months,
|
| 23 |
+
}) => (
|
| 24 |
+
<aside className="lg:col-span-1 space-y-4">
|
| 25 |
+
<div className="bg-slate-900 border border-slate-800 rounded-xl p-4">
|
| 26 |
+
<div className="flex items-center justify-between mb-4">
|
| 27 |
+
<div className="flex items-center gap-2">
|
| 28 |
+
<Star className="text-yellow-500" size={16} fill="currentColor" />
|
| 29 |
+
<h2 className="font-bold text-white text-sm">我的最愛</h2>
|
| 30 |
+
</div>
|
| 31 |
+
{favorites.length > 0 && (
|
| 32 |
+
<button
|
| 33 |
+
onClick={onViewFavorites}
|
| 34 |
+
className="flex items-center gap-0.5 text-xs text-slate-500 hover:text-blue-400 transition-colors"
|
| 35 |
+
>
|
| 36 |
+
全部 <ChevronRight size={12} />
|
| 37 |
+
</button>
|
| 38 |
+
)}
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
{favorites.length === 0 ? (
|
| 42 |
+
<p className="text-xs text-slate-600 italic">尚未加入任何股票</p>
|
| 43 |
+
) : (
|
| 44 |
+
<div className="space-y-1">
|
| 45 |
+
{favorites.map((fav) => (
|
| 46 |
+
<div key={fav.id} className="flex items-center justify-between group py-1">
|
| 47 |
+
<button
|
| 48 |
+
onClick={() => onSearch(fav.id, months)}
|
| 49 |
+
className="text-sm text-slate-300 hover:text-blue-400 transition-colors flex-1 text-left truncate"
|
| 50 |
+
>
|
| 51 |
+
<span className="font-mono font-bold mr-2 text-white">{fav.id}</span>
|
| 52 |
+
<span className="text-xs text-slate-400">{fav.name}</span>
|
| 53 |
+
</button>
|
| 54 |
+
<button
|
| 55 |
+
onClick={() => onToggleFavorite(fav.id)}
|
| 56 |
+
className="opacity-0 group-hover:opacity-100 p-1 text-slate-600 hover:text-red-400 transition-all flex-shrink-0"
|
| 57 |
+
title="移除"
|
| 58 |
+
>
|
| 59 |
+
<Trash2 size={13} />
|
| 60 |
+
</button>
|
| 61 |
+
</div>
|
| 62 |
+
))}
|
| 63 |
+
|
| 64 |
+
<button
|
| 65 |
+
onClick={onViewFavorites}
|
| 66 |
+
className="w-full mt-2 pt-2 border-t border-slate-800 flex items-center justify-center gap-1 text-xs text-slate-500 hover:text-yellow-400 transition-colors py-1"
|
| 67 |
+
>
|
| 68 |
+
<Star size={11} />
|
| 69 |
+
管理我的最愛
|
| 70 |
+
</button>
|
| 71 |
+
</div>
|
| 72 |
+
)}
|
| 73 |
+
</div>
|
| 74 |
+
</aside>
|
| 75 |
+
)
|
frontend/src/components/VolumeChart.tsx
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react'
|
| 2 |
+
import {
|
| 3 |
+
BarChart,
|
| 4 |
+
Bar,
|
| 5 |
+
XAxis,
|
| 6 |
+
YAxis,
|
| 7 |
+
CartesianGrid,
|
| 8 |
+
Tooltip,
|
| 9 |
+
Cell,
|
| 10 |
+
ResponsiveContainer,
|
| 11 |
+
} from 'recharts'
|
| 12 |
+
import { OHLCVPoint } from '../api/stockApi'
|
| 13 |
+
|
| 14 |
+
interface Props {
|
| 15 |
+
data: OHLCVPoint[]
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function formatVolume(v: number): string {
|
| 19 |
+
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`
|
| 20 |
+
if (v >= 1_000) return `${(v / 1_000).toFixed(0)}K`
|
| 21 |
+
return String(v)
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const CustomTooltip = ({ active, payload, label }: any) => {
|
| 25 |
+
if (!active || !payload?.length) return null
|
| 26 |
+
const vol: number = payload[0]?.value
|
| 27 |
+
return (
|
| 28 |
+
<div className="bg-slate-800 border border-slate-600 rounded p-2 text-xs shadow-lg">
|
| 29 |
+
<p className="text-slate-300">{label}</p>
|
| 30 |
+
<p className="text-white font-semibold">Vol: {formatVolume(vol)}</p>
|
| 31 |
+
</div>
|
| 32 |
+
)
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export const VolumeChart: React.FC<Props> = ({ data }) => {
|
| 36 |
+
const len = data.length
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<ResponsiveContainer width="100%" height={160}>
|
| 40 |
+
<BarChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 0 }}>
|
| 41 |
+
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
| 42 |
+
<XAxis
|
| 43 |
+
dataKey="date"
|
| 44 |
+
tick={{ fill: '#94a3b8', fontSize: 10 }}
|
| 45 |
+
tickLine={false}
|
| 46 |
+
tickFormatter={(val, idx) => {
|
| 47 |
+
const step = Math.max(1, Math.floor(len / 10))
|
| 48 |
+
return idx % step === 0 ? val : ''
|
| 49 |
+
}}
|
| 50 |
+
interval={0}
|
| 51 |
+
/>
|
| 52 |
+
<YAxis
|
| 53 |
+
tick={{ fill: '#94a3b8', fontSize: 10 }}
|
| 54 |
+
tickLine={false}
|
| 55 |
+
tickFormatter={formatVolume}
|
| 56 |
+
width={48}
|
| 57 |
+
/>
|
| 58 |
+
<Tooltip content={<CustomTooltip />} />
|
| 59 |
+
<Bar dataKey="volume" isAnimationActive={false} maxBarSize={6}>
|
| 60 |
+
{data.map((d, i) => (
|
| 61 |
+
<Cell
|
| 62 |
+
key={i}
|
| 63 |
+
fill={d.close >= d.open ? '#22c55e' : '#ef4444'}
|
| 64 |
+
fillOpacity={0.8}
|
| 65 |
+
/>
|
| 66 |
+
))}
|
| 67 |
+
</Bar>
|
| 68 |
+
</BarChart>
|
| 69 |
+
</ResponsiveContainer>
|
| 70 |
+
)
|
| 71 |
+
}
|
frontend/src/firebase.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { initializeApp, FirebaseApp } from 'firebase/app'
|
| 2 |
+
import { getAuth, GoogleAuthProvider, Auth } from 'firebase/auth'
|
| 3 |
+
import { getFirestore, Firestore } from 'firebase/firestore'
|
| 4 |
+
|
| 5 |
+
const apiKey = import.meta.env.VITE_FIREBASE_API_KEY as string | undefined
|
| 6 |
+
const authDomain = import.meta.env.VITE_FIREBASE_AUTH_DOMAIN as string | undefined
|
| 7 |
+
const projectId = import.meta.env.VITE_FIREBASE_PROJECT_ID as string | undefined
|
| 8 |
+
|
| 9 |
+
// True only when all required env vars are present
|
| 10 |
+
export const firebaseReady = !!(apiKey && authDomain && projectId)
|
| 11 |
+
|
| 12 |
+
let app: FirebaseApp | null = null
|
| 13 |
+
let _auth: Auth | null = null
|
| 14 |
+
let _db: Firestore | null = null
|
| 15 |
+
|
| 16 |
+
if (firebaseReady) {
|
| 17 |
+
app = initializeApp({
|
| 18 |
+
apiKey,
|
| 19 |
+
authDomain,
|
| 20 |
+
projectId,
|
| 21 |
+
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET as string | undefined,
|
| 22 |
+
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID as string | undefined,
|
| 23 |
+
appId: import.meta.env.VITE_FIREBASE_APP_ID as string | undefined,
|
| 24 |
+
})
|
| 25 |
+
_auth = getAuth(app)
|
| 26 |
+
_db = getFirestore(app)
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export const auth = _auth!
|
| 30 |
+
export const db = _db!
|
| 31 |
+
export const googleProvider = new GoogleAuthProvider()
|
frontend/src/hooks/useFirestoreFavorites.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react'
|
| 2 |
+
import {
|
| 3 |
+
collection,
|
| 4 |
+
doc,
|
| 5 |
+
onSnapshot,
|
| 6 |
+
setDoc,
|
| 7 |
+
deleteDoc,
|
| 8 |
+
updateDoc,
|
| 9 |
+
serverTimestamp,
|
| 10 |
+
} from 'firebase/firestore'
|
| 11 |
+
import { db } from '../firebase'
|
| 12 |
+
import { FavoriteItem } from '../components/FavoritesPage'
|
| 13 |
+
import type { Market } from '../components/Header'
|
| 14 |
+
|
| 15 |
+
// Firestore path: users/{uid}/favorites/{stockId}
|
| 16 |
+
|
| 17 |
+
export function useFirestoreFavorites(uid: string | null) {
|
| 18 |
+
const [favorites, setFavorites] = useState<FavoriteItem[]>([])
|
| 19 |
+
|
| 20 |
+
// Real-time listener — updates automatically across tabs/devices
|
| 21 |
+
useEffect(() => {
|
| 22 |
+
if (!uid) { setFavorites([]); return }
|
| 23 |
+
const ref = collection(db, 'users', uid, 'favorites')
|
| 24 |
+
const unsub = onSnapshot(ref, (snap) => {
|
| 25 |
+
const items: FavoriteItem[] = snap.docs.map(d => d.data() as FavoriteItem)
|
| 26 |
+
// Sort by addedAt descending
|
| 27 |
+
items.sort((a, b) => ((b as any).addedAt?.seconds ?? 0) - ((a as any).addedAt?.seconds ?? 0))
|
| 28 |
+
setFavorites(items)
|
| 29 |
+
})
|
| 30 |
+
return unsub
|
| 31 |
+
}, [uid])
|
| 32 |
+
|
| 33 |
+
const addFavorite = async (id: string, name: string, market?: Market) => {
|
| 34 |
+
if (!uid) return
|
| 35 |
+
await setDoc(doc(db, 'users', uid, 'favorites', id), {
|
| 36 |
+
id,
|
| 37 |
+
name,
|
| 38 |
+
market: market ?? 'tw',
|
| 39 |
+
note: '',
|
| 40 |
+
addedAt: serverTimestamp(),
|
| 41 |
+
})
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
const removeFavorite = async (id: string) => {
|
| 45 |
+
if (!uid) return
|
| 46 |
+
await deleteDoc(doc(db, 'users', uid, 'favorites', id))
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
const toggleFavorite = async (id: string, name?: string, market?: Market) => {
|
| 50 |
+
const exists = favorites.find(f => f.id === id)
|
| 51 |
+
if (exists) {
|
| 52 |
+
await removeFavorite(id)
|
| 53 |
+
} else {
|
| 54 |
+
await addFavorite(id, name ?? id, market)
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
const updateNote = async (id: string, note: string) => {
|
| 59 |
+
if (!uid) return
|
| 60 |
+
await updateDoc(doc(db, 'users', uid, 'favorites', id), { note })
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
return { favorites, toggleFavorite, removeFavorite, updateNote }
|
| 64 |
+
}
|
frontend/src/index.css
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
body { @apply bg-gray-950 text-gray-100; }
|
frontend/src/main.tsx
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react'
|
| 2 |
+
import ReactDOM from 'react-dom/client'
|
| 3 |
+
import App from './App'
|
| 4 |
+
import './index.css'
|
| 5 |
+
|
| 6 |
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
| 7 |
+
<React.StrictMode><App /></React.StrictMode>
|
| 8 |
+
)
|
routers/stock.py
CHANGED
|
@@ -18,6 +18,7 @@ from data.fetcher import (
|
|
| 18 |
detect_exchange,
|
| 19 |
is_us_ticker,
|
| 20 |
)
|
|
|
|
| 21 |
from indicators.technical import add_all_indicators
|
| 22 |
from models.predictor import StockPredictor
|
| 23 |
|
|
@@ -77,7 +78,9 @@ def _get_predictor(stock_no: str, df_with_indicators) -> StockPredictor:
|
|
| 77 |
async def search_endpoint(q: str = Query(..., min_length=1), market: str = Query(default="tw")):
|
| 78 |
"""Search stocks by name or number. Returns up to 20 matches."""
|
| 79 |
try:
|
| 80 |
-
if market
|
|
|
|
|
|
|
| 81 |
results = search_us(q)
|
| 82 |
else:
|
| 83 |
results = search_stocks(q)
|
|
|
|
| 18 |
detect_exchange,
|
| 19 |
is_us_ticker,
|
| 20 |
)
|
| 21 |
+
from data.fund_fetcher import search_funds
|
| 22 |
from indicators.technical import add_all_indicators
|
| 23 |
from models.predictor import StockPredictor
|
| 24 |
|
|
|
|
| 78 |
async def search_endpoint(q: str = Query(..., min_length=1), market: str = Query(default="tw")):
|
| 79 |
"""Search stocks by name or number. Returns up to 20 matches."""
|
| 80 |
try:
|
| 81 |
+
if market == "fund":
|
| 82 |
+
results = search_funds(q)
|
| 83 |
+
elif market == "us":
|
| 84 |
results = search_us(q)
|
| 85 |
else:
|
| 86 |
results = search_stocks(q)
|
static/assets/{index-DpNIV5k-.css → index-BnFVDQ43.css}
RENAMED
|
@@ -1 +1 @@
|
|
| 1 |
-
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.pointer-events-none{pointer-events:none}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.left-0{left:0}.left-2\.5{left:.625rem}.left-3{left:.75rem}.top-0{top:0}.top-1\/2{top:50%}.top-2\.5{top:.625rem}.top-full{top:100%}.z-10{z-index:10}.z-50{z-index:50}.mx-auto{margin-left:auto;margin-right:auto}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.ml-2{margin-left:.5rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mr-auto{margin-right:auto}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-16{margin-top:4rem}.mt-2{margin-top:.5rem}.mt-auto{margin-top:auto}.flex{display:flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-2\.5{height:.625rem}.h-4{height:1rem}.h-7{height:1.75rem}.h-full{height:100%}.h-px{height:1px}.max-h-72{max-height:18rem}.min-h-screen{min-height:100vh}.w-12{width:3rem}.w-4{width:1rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-full{width:100%}.max-w-7xl{max-width:80rem}.max-w-\[120px\]{max-width:120px}.max-w-\[160px\]{max-width:160px}.max-w-sm{max-width:24rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.resize-none{resize:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-green-500{--tw-border-opacity: 1;border-color:rgb(34 197 94 / var(--tw-border-opacity, 1))}.border-green-700{--tw-border-opacity: 1;border-color:rgb(21 128 61 / var(--tw-border-opacity, 1))}.border-red-500{--tw-border-opacity: 1;border-color:rgb(239 68 68 / var(--tw-border-opacity, 1))}.border-red-600{--tw-border-opacity: 1;border-color:rgb(220 38 38 / var(--tw-border-opacity, 1))}.border-red-700{--tw-border-opacity: 1;border-color:rgb(185 28 28 / var(--tw-border-opacity, 1))}.border-slate-600{--tw-border-opacity: 1;border-color:rgb(71 85 105 / var(--tw-border-opacity, 1))}.border-slate-700{--tw-border-opacity: 1;border-color:rgb(51 65 85 / var(--tw-border-opacity, 1))}.border-slate-800{--tw-border-opacity: 1;border-color:rgb(30 41 59 / var(--tw-border-opacity, 1))}.border-yellow-500{--tw-border-opacity: 1;border-color:rgb(234 179 8 / var(--tw-border-opacity, 1))}.bg-blue-600{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1))}.bg-blue-600\/20{background-color:#2563eb33}.bg-blue-900\/50{background-color:#1e3a8a80}.bg-gray-950{--tw-bg-opacity: 1;background-color:rgb(3 7 18 / var(--tw-bg-opacity, 1))}.bg-green-500{--tw-bg-opacity: 1;background-color:rgb(34 197 94 / var(--tw-bg-opacity, 1))}.bg-green-500\/10{background-color:#22c55e1a}.bg-green-600{--tw-bg-opacity: 1;background-color:rgb(22 163 74 / var(--tw-bg-opacity, 1))}.bg-green-900\/30{background-color:#14532d4d}.bg-green-900\/50{background-color:#14532d80}.bg-indigo-600{--tw-bg-opacity: 1;background-color:rgb(79 70 229 / var(--tw-bg-opacity, 1))}.bg-purple-900\/50{background-color:#581c8780}.bg-red-500{--tw-bg-opacity: 1;background-color:rgb(239 68 68 / var(--tw-bg-opacity, 1))}.bg-red-500\/10{background-color:#ef44441a}.bg-red-600{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.bg-red-900\/30{background-color:#7f1d1d4d}.bg-red-900\/40{background-color:#7f1d1d66}.bg-red-900\/50{background-color:#7f1d1d80}.bg-slate-700{--tw-bg-opacity: 1;background-color:rgb(51 65 85 / var(--tw-bg-opacity, 1))}.bg-slate-800{--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity, 1))}.bg-slate-900{--tw-bg-opacity: 1;background-color:rgb(15 23 42 / var(--tw-bg-opacity, 1))}.bg-slate-900\/60{background-color:#0f172a99}.bg-slate-900\/70{background-color:#0f172ab3}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.bg-yellow-500{--tw-bg-opacity: 1;background-color:rgb(234 179 8 / var(--tw-bg-opacity, 1))}.bg-yellow-500\/10{background-color:#eab3081a}.bg-yellow-500\/20{background-color:#eab30833}.bg-yellow-600{--tw-bg-opacity: 1;background-color:rgb(202 138 4 / var(--tw-bg-opacity, 1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-24{padding-top:6rem;padding-bottom:6rem}.py-28{padding-top:7rem;padding-bottom:7rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-0{padding-bottom:0}.pb-2{padding-bottom:.5rem}.pb-3{padding-bottom:.75rem}.pl-7{padding-left:1.75rem}.pl-8{padding-left:2rem}.pr-3{padding-right:.75rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji"}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.italic{font-style:italic}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-tight{line-height:1.25}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-black{--tw-text-opacity: 1;color:rgb(0 0 0 / var(--tw-text-opacity, 1))}.text-blue-300{--tw-text-opacity: 1;color:rgb(147 197 253 / var(--tw-text-opacity, 1))}.text-blue-400{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.text-gray-100{--tw-text-opacity: 1;color:rgb(243 244 246 / var(--tw-text-opacity, 1))}.text-gray-800{--tw-text-opacity: 1;color:rgb(31 41 55 / var(--tw-text-opacity, 1))}.text-green-300{--tw-text-opacity: 1;color:rgb(134 239 172 / var(--tw-text-opacity, 1))}.text-green-400{--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.text-indigo-400{--tw-text-opacity: 1;color:rgb(129 140 248 / var(--tw-text-opacity, 1))}.text-orange-400{--tw-text-opacity: 1;color:rgb(251 146 60 / var(--tw-text-opacity, 1))}.text-purple-300{--tw-text-opacity: 1;color:rgb(216 180 254 / var(--tw-text-opacity, 1))}.text-purple-400{--tw-text-opacity: 1;color:rgb(192 132 252 / var(--tw-text-opacity, 1))}.text-red-300{--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-slate-200{--tw-text-opacity: 1;color:rgb(226 232 240 / var(--tw-text-opacity, 1))}.text-slate-300{--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.text-slate-400{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.text-slate-500{--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity, 1))}.text-slate-600{--tw-text-opacity: 1;color:rgb(71 85 105 / var(--tw-text-opacity, 1))}.text-slate-700{--tw-text-opacity: 1;color:rgb(51 65 85 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.text-white\/80{color:#fffc}.text-yellow-300{--tw-text-opacity: 1;color:rgb(253 224 71 / var(--tw-text-opacity, 1))}.text-yellow-400{--tw-text-opacity: 1;color:rgb(250 204 21 / var(--tw-text-opacity, 1))}.text-yellow-500{--tw-text-opacity: 1;color:rgb(234 179 8 / var(--tw-text-opacity, 1))}.placeholder-slate-500::-moz-placeholder{--tw-placeholder-opacity: 1;color:rgb(100 116 139 / var(--tw-placeholder-opacity, 1))}.placeholder-slate-500::placeholder{--tw-placeholder-opacity: 1;color:rgb(100 116 139 / var(--tw-placeholder-opacity, 1))}.placeholder-slate-600::-moz-placeholder{--tw-placeholder-opacity: 1;color:rgb(71 85 105 / var(--tw-placeholder-opacity, 1))}.placeholder-slate-600::placeholder{--tw-placeholder-opacity: 1;color:rgb(71 85 105 / var(--tw-placeholder-opacity, 1))}.opacity-0{opacity:0}.shadow-2xl{--tw-shadow: 0 25px 50px -12px rgb(0 0 0 / .25);--tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-700{transition-duration:.7s}body{--tw-bg-opacity: 1;background-color:rgb(3 7 18 / var(--tw-bg-opacity, 1));--tw-text-opacity: 1;color:rgb(243 244 246 / var(--tw-text-opacity, 1))}.hover\:border-slate-600:hover{--tw-border-opacity: 1;border-color:rgb(71 85 105 / var(--tw-border-opacity, 1))}.hover\:bg-blue-500:hover{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity, 1))}.hover\:bg-blue-600\/40:hover{background-color:#2563eb66}.hover\:bg-gray-100:hover{--tw-bg-opacity: 1;background-color:rgb(243 244 246 / var(--tw-bg-opacity, 1))}.hover\:bg-indigo-500:hover{--tw-bg-opacity: 1;background-color:rgb(99 102 241 / var(--tw-bg-opacity, 1))}.hover\:bg-slate-700:hover{--tw-bg-opacity: 1;background-color:rgb(51 65 85 / var(--tw-bg-opacity, 1))}.hover\:bg-slate-800:hover{--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity, 1))}.hover\:bg-slate-800\/50:hover{background-color:#1e293b80}.hover\:text-blue-400:hover{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.hover\:text-red-400:hover{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.hover\:text-slate-300:hover{--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.hover\:text-slate-400:hover{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.hover\:text-white:hover{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.hover\:text-yellow-400:hover{--tw-text-opacity: 1;color:rgb(250 204 21 / var(--tw-text-opacity, 1))}.focus\:border-blue-500:focus{--tw-border-opacity: 1;border-color:rgb(59 130 246 / var(--tw-border-opacity, 1))}.focus\:border-slate-500:focus{--tw-border-opacity: 1;border-color:rgb(100 116 139 / var(--tw-border-opacity, 1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-1:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity: 1;--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:bg-blue-800:disabled{--tw-bg-opacity: 1;background-color:rgb(30 64 175 / var(--tw-bg-opacity, 1))}.disabled\:bg-gray-200:disabled{--tw-bg-opacity: 1;background-color:rgb(229 231 235 / var(--tw-bg-opacity, 1))}.disabled\:bg-indigo-800:disabled{--tw-bg-opacity: 1;background-color:rgb(55 48 163 / var(--tw-bg-opacity, 1))}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width: 640px){.sm\:ml-2{margin-left:.5rem}.sm\:inline{display:inline}.sm\:w-48{width:12rem}.sm\:w-auto{width:auto}.sm\:flex-none{flex:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}}@media (min-width: 1024px){.lg\:col-span-1{grid-column:span 1 / span 1}.lg\:col-span-3{grid-column:span 3 / span 3}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}}@media (min-width: 1280px){.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}
|
|
|
|
| 1 |
+
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.pointer-events-none{pointer-events:none}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.left-0{left:0}.left-2\.5{left:.625rem}.left-3{left:.75rem}.right-0{right:0}.right-2{right:.5rem}.top-0{top:0}.top-1\/2{top:50%}.top-2\.5{top:.625rem}.top-full{top:100%}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.mx-auto{margin-left:auto;margin-right:auto}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.ml-2{margin-left:.5rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-16{margin-top:4rem}.mt-2{margin-top:.5rem}.mt-auto{margin-top:auto}.flex{display:flex}.grid{display:grid}.hidden{display:none}.h-2\.5{height:.625rem}.h-4{height:1rem}.h-7{height:1.75rem}.h-full{height:100%}.h-px{height:1px}.max-h-64{max-height:16rem}.min-h-screen{min-height:100vh}.w-4{width:1rem}.w-7{width:1.75rem}.w-fit{width:-moz-fit-content;width:fit-content}.w-full{width:100%}.min-w-0{min-width:0px}.min-w-\[16px\]{min-width:16px}.max-w-7xl{max-width:80rem}.max-w-\[100px\]{max-width:100px}.max-w-\[160px\]{max-width:160px}.max-w-sm{max-width:24rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.resize-none{resize:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-green-500{--tw-border-opacity: 1;border-color:rgb(34 197 94 / var(--tw-border-opacity, 1))}.border-green-700{--tw-border-opacity: 1;border-color:rgb(21 128 61 / var(--tw-border-opacity, 1))}.border-red-500{--tw-border-opacity: 1;border-color:rgb(239 68 68 / var(--tw-border-opacity, 1))}.border-red-700{--tw-border-opacity: 1;border-color:rgb(185 28 28 / var(--tw-border-opacity, 1))}.border-slate-600{--tw-border-opacity: 1;border-color:rgb(71 85 105 / var(--tw-border-opacity, 1))}.border-slate-700{--tw-border-opacity: 1;border-color:rgb(51 65 85 / var(--tw-border-opacity, 1))}.border-slate-800{--tw-border-opacity: 1;border-color:rgb(30 41 59 / var(--tw-border-opacity, 1))}.border-yellow-500{--tw-border-opacity: 1;border-color:rgb(234 179 8 / var(--tw-border-opacity, 1))}.bg-blue-500{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity, 1))}.bg-blue-600{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1))}.bg-blue-600\/20{background-color:#2563eb33}.bg-blue-900\/50{background-color:#1e3a8a80}.bg-gray-950{--tw-bg-opacity: 1;background-color:rgb(3 7 18 / var(--tw-bg-opacity, 1))}.bg-green-500{--tw-bg-opacity: 1;background-color:rgb(34 197 94 / var(--tw-bg-opacity, 1))}.bg-green-500\/10{background-color:#22c55e1a}.bg-green-600{--tw-bg-opacity: 1;background-color:rgb(22 163 74 / var(--tw-bg-opacity, 1))}.bg-green-900\/30{background-color:#14532d4d}.bg-green-900\/50{background-color:#14532d80}.bg-purple-900\/50{background-color:#581c8780}.bg-red-500{--tw-bg-opacity: 1;background-color:rgb(239 68 68 / var(--tw-bg-opacity, 1))}.bg-red-500\/10{background-color:#ef44441a}.bg-red-600{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.bg-red-900\/30{background-color:#7f1d1d4d}.bg-slate-700{--tw-bg-opacity: 1;background-color:rgb(51 65 85 / var(--tw-bg-opacity, 1))}.bg-slate-800{--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity, 1))}.bg-slate-900{--tw-bg-opacity: 1;background-color:rgb(15 23 42 / var(--tw-bg-opacity, 1))}.bg-slate-900\/60{background-color:#0f172a99}.bg-slate-900\/70{background-color:#0f172ab3}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.bg-yellow-500{--tw-bg-opacity: 1;background-color:rgb(234 179 8 / var(--tw-bg-opacity, 1))}.bg-yellow-500\/10{background-color:#eab3081a}.bg-yellow-500\/20{background-color:#eab30833}.bg-yellow-600{--tw-bg-opacity: 1;background-color:rgb(202 138 4 / var(--tw-bg-opacity, 1))}.bg-yellow-900\/50{background-color:#713f1280}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-24{padding-top:6rem;padding-bottom:6rem}.py-28{padding-top:7rem;padding-bottom:7rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.pl-7{padding-left:1.75rem}.pl-8{padding-left:2rem}.pr-3{padding-right:.75rem}.pr-7{padding-right:1.75rem}.pt-2{padding-top:.5rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji"}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.italic{font-style:italic}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-tight{line-height:1.25}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-black{--tw-text-opacity: 1;color:rgb(0 0 0 / var(--tw-text-opacity, 1))}.text-blue-300{--tw-text-opacity: 1;color:rgb(147 197 253 / var(--tw-text-opacity, 1))}.text-blue-400{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.text-gray-100{--tw-text-opacity: 1;color:rgb(243 244 246 / var(--tw-text-opacity, 1))}.text-gray-800{--tw-text-opacity: 1;color:rgb(31 41 55 / var(--tw-text-opacity, 1))}.text-green-300{--tw-text-opacity: 1;color:rgb(134 239 172 / var(--tw-text-opacity, 1))}.text-green-400{--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.text-orange-400{--tw-text-opacity: 1;color:rgb(251 146 60 / var(--tw-text-opacity, 1))}.text-purple-300{--tw-text-opacity: 1;color:rgb(216 180 254 / var(--tw-text-opacity, 1))}.text-purple-400{--tw-text-opacity: 1;color:rgb(192 132 252 / var(--tw-text-opacity, 1))}.text-red-300{--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-slate-200{--tw-text-opacity: 1;color:rgb(226 232 240 / var(--tw-text-opacity, 1))}.text-slate-300{--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.text-slate-400{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.text-slate-500{--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity, 1))}.text-slate-600{--tw-text-opacity: 1;color:rgb(71 85 105 / var(--tw-text-opacity, 1))}.text-slate-700{--tw-text-opacity: 1;color:rgb(51 65 85 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.text-white\/80{color:#fffc}.text-yellow-300{--tw-text-opacity: 1;color:rgb(253 224 71 / var(--tw-text-opacity, 1))}.text-yellow-400{--tw-text-opacity: 1;color:rgb(250 204 21 / var(--tw-text-opacity, 1))}.text-yellow-500{--tw-text-opacity: 1;color:rgb(234 179 8 / var(--tw-text-opacity, 1))}.placeholder-slate-500::-moz-placeholder{--tw-placeholder-opacity: 1;color:rgb(100 116 139 / var(--tw-placeholder-opacity, 1))}.placeholder-slate-500::placeholder{--tw-placeholder-opacity: 1;color:rgb(100 116 139 / var(--tw-placeholder-opacity, 1))}.placeholder-slate-600::-moz-placeholder{--tw-placeholder-opacity: 1;color:rgb(71 85 105 / var(--tw-placeholder-opacity, 1))}.placeholder-slate-600::placeholder{--tw-placeholder-opacity: 1;color:rgb(71 85 105 / var(--tw-placeholder-opacity, 1))}.opacity-0{opacity:0}.shadow-2xl{--tw-shadow: 0 25px 50px -12px rgb(0 0 0 / .25);--tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-700{transition-duration:.7s}body{--tw-bg-opacity: 1;background-color:rgb(3 7 18 / var(--tw-bg-opacity, 1));--tw-text-opacity: 1;color:rgb(243 244 246 / var(--tw-text-opacity, 1))}.hover\:border-slate-600:hover{--tw-border-opacity: 1;border-color:rgb(71 85 105 / var(--tw-border-opacity, 1))}.hover\:bg-blue-500:hover{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity, 1))}.hover\:bg-blue-600\/40:hover{background-color:#2563eb66}.hover\:bg-gray-100:hover{--tw-bg-opacity: 1;background-color:rgb(243 244 246 / var(--tw-bg-opacity, 1))}.hover\:bg-slate-700:hover{--tw-bg-opacity: 1;background-color:rgb(51 65 85 / var(--tw-bg-opacity, 1))}.hover\:bg-slate-800:hover{--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity, 1))}.hover\:text-blue-400:hover{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.hover\:text-red-400:hover{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.hover\:text-slate-300:hover{--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.hover\:text-slate-400:hover{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.hover\:text-white:hover{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.hover\:text-yellow-400:hover{--tw-text-opacity: 1;color:rgb(250 204 21 / var(--tw-text-opacity, 1))}.focus\:border-blue-500:focus{--tw-border-opacity: 1;border-color:rgb(59 130 246 / var(--tw-border-opacity, 1))}.focus\:border-slate-500:focus{--tw-border-opacity: 1;border-color:rgb(100 116 139 / var(--tw-border-opacity, 1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-1:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity: 1;--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:bg-blue-800:disabled{--tw-bg-opacity: 1;background-color:rgb(30 64 175 / var(--tw-bg-opacity, 1))}.disabled\:bg-gray-200:disabled{--tw-bg-opacity: 1;background-color:rgb(229 231 235 / var(--tw-bg-opacity, 1))}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width: 640px){.sm\:ml-auto{margin-left:auto}.sm\:block{display:block}.sm\:inline{display:inline}.sm\:w-52{width:13rem}.sm\:flex-none{flex:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}}@media (min-width: 1024px){.lg\:col-span-1{grid-column:span 1 / span 1}.lg\:col-span-3{grid-column:span 3 / span 3}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width: 1280px){.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}
|
static/assets/{index-DsCDWin_.js → index-BrxmzIxi.js}
RENAMED
|
The diff for this file is too large to render.
See raw diff
|
|
|
static/index.html
CHANGED
|
@@ -4,8 +4,8 @@
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>台股預測系統</title>
|
| 7 |
-
<script type="module" crossorigin src="/assets/index-
|
| 8 |
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
<div id="root"></div>
|
|
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>台股預測系統</title>
|
| 7 |
+
<script type="module" crossorigin src="/assets/index-BrxmzIxi.js"></script>
|
| 8 |
+
<link rel="stylesheet" crossorigin href="/assets/index-BnFVDQ43.css">
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
<div id="root"></div>
|