This commit is contained in:
etoai 2026-05-18 09:08:31 +08:00
parent 139d634fba
commit 3350e994e4
24 changed files with 2122 additions and 1114 deletions

File diff suppressed because one or more lines are too long

0
back/app/__init__.py Normal file
View File

122
back/app/cache.py Normal file
View File

@ -0,0 +1,122 @@
"""缓存状态管理模块"""
import json
import os
import pandas as pd
CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".cache")
CACHE_FILE = os.path.join(CACHE_DIR, "lof_cache.json")
PURCHASE_CACHE_FILE = os.path.join(CACHE_DIR, "purchase_cache.json")
# LOF 实时数据缓存stale-while-revalidate
cache_data = {"data": [], "large": [], "small": [], "time": None}
# 净值/限额数据缓存(变化频率低,缓存 5 分钟)
purchase_cache = {"data": None, "time": None}
PURCHASE_CACHE_TTL_SECONDS = 300 # 5 分钟
def _ensure_cache_dir():
os.makedirs(CACHE_DIR, exist_ok=True)
def _save_to_disk(data: dict, has_valid_data: bool = False):
"""将缓存写入磁盘文件"""
try:
_ensure_cache_dir()
payload = {
"data": data.get("data", []),
"large": data.get("large", []),
"small": data.get("small", []),
"time": str(pd.Timestamp.now()),
"valid": has_valid_data
}
with open(CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False)
except Exception:
pass
def _load_from_disk() -> dict | None:
"""从磁盘文件加载缓存"""
try:
if os.path.exists(CACHE_FILE):
with open(CACHE_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
# 只返回标记为有效的数据
if data.get("valid") and data.get("large"):
return data
except Exception:
pass
return None
def update_purchase_cache(data: pd.DataFrame) -> None:
"""更新净值/限额缓存"""
global purchase_cache
purchase_cache = {"data": data, "time": pd.Timestamp.now()}
def get_purchase_cache_age() -> float:
"""获取净值/限额缓存已存在的秒数"""
if purchase_cache["time"] is None:
return float("inf")
return (pd.Timestamp.now() - purchase_cache["time"]).total_seconds()
def is_purchase_cache_valid() -> bool:
"""判断净值/限额缓存是否仍然有效"""
if purchase_cache["data"] is None or purchase_cache["time"] is None:
return False
return get_purchase_cache_age() < PURCHASE_CACHE_TTL_SECONDS
def get_purchase_cache_data() -> pd.DataFrame:
"""获取缓存的净值/限额数据副本"""
return purchase_cache["data"].copy()
def update_cache_data(large_data: list, small_data: list, has_valid_data: bool = False) -> None:
"""更新 LOF 实时数据缓存(内存 + 磁盘)"""
global cache_data
cache_data = {
"data": large_data + small_data,
"large": large_data,
"small": small_data,
"time": pd.Timestamp.now()
}
# 写入磁盘,标记是否为有效数据
_save_to_disk(cache_data, has_valid_data=has_valid_data)
def get_cached_lof_data() -> dict | None:
"""获取缓存的 LOF 实时数据(仅大基金)"""
# 优先读内存
if cache_data["large"]:
return {
"data": cache_data["large"],
"hasMore": len(cache_data["small"]) > 0,
"time": cache_data["time"]
}
# 内存无缓存,尝试读磁盘
disk = _load_from_disk()
if disk and disk.get("large"):
cache_data["data"] = disk["data"]
cache_data["large"] = disk["large"]
cache_data["small"] = disk.get("small", [])
cache_data["time"] = disk.get("time")
return {
"data": disk["large"],
"hasMore": len(disk.get("small", [])) > 0,
"time": disk.get("time")
}
return None
def get_cached_small_data() -> list:
"""获取缓存的剩余小基金数据"""
if cache_data["small"]:
return cache_data["small"]
disk = _load_from_disk()
if disk:
return disk.get("small", [])
return []

24
back/app/main.py Normal file
View File

@ -0,0 +1,24 @@
"""FastAPI 应用入口:创建 app、配置中间件、挂载路由"""
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.routers.lof import router as lof_router
# 配置日志
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
app = FastAPI()
# 解决跨域,让 Vue 能调用
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 挂载路由
app.include_router(lof_router)

View File

464
back/app/routers/lof.py Normal file
View File

@ -0,0 +1,464 @@
"""LOF 基金相关 API 路由"""
import concurrent.futures
import logging
import re
import threading
import time
import pandas as pd
import requests
from fastapi import APIRouter, Query
from app.cache import update_cache_data, get_cached_lof_data
from app.services.fetcher import (
fetch_spot_data,
fetch_purchase_data,
fetch_estimate_data,
fetch_ths_kline,
fetch_em_kline,
)
from app.utils.formatters import format_limit, format_amount
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/lof", tags=["LOF"])
# 小基金数据缓存(规模 < 3000万主请求返回大基金后前端通过剩余端点获取
remaining_cache = {"data": [], "lock": threading.Lock()}
# 后台刷新锁,避免同一时间多个请求同时触发刷新
_refresh_lock = threading.Lock()
# 基金类型缓存
_fund_type_cache: dict[str, str] = {}
def get_fund_type(fund_code: str) -> str:
"""获取基金类型(如 QDII、商品、混合型等带缓存"""
if fund_code in _fund_type_cache:
return _fund_type_cache[fund_code]
try:
url = f"https://fundf10.eastmoney.com/jbgk_{fund_code}.html"
r = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
r.raise_for_status()
m = re.search(r"基金类型</th>\s*<td>([^<]+)</td>", r.text)
if m:
fund_type = m.group(1).strip()
_fund_type_cache[fund_code] = fund_type
logger.info("基金 %s 类型:%s", fund_code, fund_type)
return fund_type
except Exception as e:
logger.warning("获取基金 %s 类型失败:%s", fund_code, e)
return ""
def fetch_em_realtime(fund_code: str) -> dict | None:
"""获取东方财富实时数据最新价、成交量、成交额、f84"""
try:
secid_prefix = "1" if fund_code.startswith(("5", "6", "9")) else "0"
secid = f"{secid_prefix}.{fund_code}"
url = "https://push2.eastmoney.com/api/qt/stock/get"
params = {"secid": secid, "fields": "f43,f47,f48,f84"}
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://quote.eastmoney.com/",
}
r = requests.get(url, params=params, headers=headers, timeout=10)
r.raise_for_status()
d = r.json().get("data", {})
return {
"price": float(d.get("f43", 0)) / 1000,
"volume": float(d.get("f47", 0)), # 手
"turnover": float(d.get("f48", 0)), # 元
"f84": float(d.get("f84", 0)), # 股
}
except Exception as e:
logger.warning("东方财富实时数据获取失败:%s", e)
return None
@router.get("/history")
def get_lof_history(
fund_code: str = Query(..., description="基金代码"),
fund_name: str = Query("", description="基金名称"),
):
"""获取 LOF 基金历史数据(价格 + 净值 + 溢价率 + 成交额 + 场内份额)
主数据源同花顺 K-line换手率基于场内份额可直接算出场内份额
备用数据源东方财富 K-line
净值数据东方财富 lsjz 接口
"""
try:
logger.info("获取基金 %s 历史数据...", fund_code)
# 1. 获取历史行情(优先同花顺,备用东方财富)
price_df = fetch_ths_kline(fund_code)
source = "ths"
secid = None
if price_df is None:
logger.warning("同花顺获取失败,尝试东方财富备用...")
secid_prefix = "1" if fund_code.startswith(("5", "6", "9")) else "0"
secid = f"{secid_prefix}.{fund_code}"
price_df = fetch_em_kline(fund_code, secid)
source = "em"
if price_df is None:
return {"code": 404, "msg": f"未找到基金 {fund_code} 的历史价格数据"}
rt_data = None
# 用东方财富实时数据校准最新一天(同花顺 year.js 最新一天可能缓存未更新)
if source == "ths" and not price_df.empty:
try:
rt_data = fetch_em_realtime(fund_code)
if rt_data:
last_idx = price_df.index[-1]
last_date = price_df.loc[last_idx, "date"]
today = pd.Timestamp.now().normalize()
# 只有同花顺最新一天是今天,才用实时数据校准(盘中缓存数据可能异常)
if last_date == today:
ths_price = price_df.loc[last_idx, "price"]
ths_turnover = price_df.loc[last_idx, "turnover"]
price_diff = abs(ths_price - rt_data["price"]) / ths_price if ths_price > 0 else 0
turnover_diff = abs(ths_turnover - rt_data["turnover"] / 10000) / (ths_turnover or 1)
if price_diff > 0.001 or turnover_diff > 0.5:
logger.info(
"基金 %s 同花顺最新一天数据异常,用东方财富校准: 价格 %.3f->%.3f, 成交额 %.2f->%.2f",
fund_code, ths_price, rt_data["price"], ths_turnover, rt_data["turnover"] / 10000,
)
price_df.loc[last_idx, "price"] = rt_data["price"]
price_df.loc[last_idx, "volume"] = rt_data["volume"]
price_df.loc[last_idx, "turnover"] = round(rt_data["turnover"] / 10000, 2)
except Exception as e:
logger.warning("实时数据校准失败:%s", e)
# 份额数据与日终结算一致延后一天显示T日收盘后结算T+1日公布
price_df["share_volume"] = price_df["share_volume"].shift(1)
# 同花顺数据源:对最新一天的 share_volume 用 f84 校准/填充
# 同花顺换手率只保留3位小数低换手率基金如161226份额计算误差可达~40万份
# f84 是东方财富实时总份额对LOF基金通常≈场内份额误差<1%,可用来校准最新一天
if source == "ths" and rt_data and rt_data.get("f84"):
try:
last_idx = price_df.index[-1]
f84_share = round(rt_data["f84"] / 10000, 2)
current_share = price_df.loc[last_idx, "share_volume"]
if pd.isna(current_share):
price_df.loc[last_idx, "share_volume"] = f84_share
logger.info("基金 %s 最新一天份额用 f84 填充: %.2f", fund_code, f84_share)
elif current_share > 0 and abs(current_share - f84_share) / current_share < 0.05:
price_df.loc[last_idx, "share_volume"] = f84_share
logger.info("基金 %s 最新一天份额用 f84 校准: %.2f -> %.2f", fund_code, current_share, f84_share)
except Exception as e:
logger.warning("f84 填充份额失败:%s", e)
# 如果是东方财富数据源,用 f84 填充最新一天 NaN share_volume
if source == "em":
try:
qt_url = "https://push2.eastmoney.com/api/qt/stock/get"
qt_params = {"secid": secid, "fields": "f84"}
qt_headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://quote.eastmoney.com/",
}
r = requests.get(qt_url, params=qt_params, headers=qt_headers, timeout=10)
r.raise_for_status()
qt_data = r.json()
f84 = qt_data.get("data", {}).get("f84")
if f84 is not None:
real_time_share = round(float(f84) / 10000, 2)
last_idx = price_df.index[-1]
if not price_df.empty and pd.isna(price_df.loc[last_idx, "share_volume"]):
price_df.loc[last_idx, "share_volume"] = real_time_share
logger.info("基金 %s 份额用 f84 填充:%.2f (万份)", fund_code, real_time_share)
except Exception as e:
logger.warning("实时份额校准失败:%s", e)
# 计算场内新增和份额涨幅
price_df["change_amount"] = (price_df["share_volume"] - price_df["share_volume"].shift(1)).round(2)
price_df["change_pct"] = ((price_df["change_amount"] / price_df["share_volume"].shift(1)) * 100).round(3)
# 2. 获取历史净值(东方财富 lsjz 接口)
nav_url = "https://api.fund.eastmoney.com/f10/lsjz"
nav_params = {
"fundCode": fund_code,
"pageIndex": "1",
"pageSize": "120",
"startDate": price_df["date"].min().strftime("%Y-%m-%d"),
"endDate": price_df["date"].max().strftime("%Y-%m-%d"),
}
nav_headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36",
"Referer": "https://fund.eastmoney.com/",
}
nav_rows = []
total_count = 0
page = 1
while True:
nav_params["pageIndex"] = str(page)
r = requests.get(nav_url, params=nav_params, headers=nav_headers, timeout=30)
r.raise_for_status()
nav_json = r.json()
if page == 1:
total_count = nav_json.get("TotalCount", 0)
nav_list = nav_json.get("Data", {}).get("LSJZList", [])
if not nav_list:
break
for item in nav_list:
dwjz = item.get("DWJZ", "")
nav_rows.append({
"nav_date": item["FSRQ"],
"nav": float(dwjz) if dwjz else None,
})
page += 1
if len(nav_rows) >= total_count:
break
nav_df = pd.DataFrame(nav_rows) if nav_rows else pd.DataFrame(columns=["nav_date", "nav"])
nav_df["nav_date"] = pd.to_datetime(nav_df["nav_date"])
# 3. 合并价格和净值
# 判断基金类型决定净值匹配策略QDII 净值延迟,用 T-1非 QDII 用当天
fund_type = get_fund_type(fund_code)
is_qdii = "QDII" in fund_type
nav_df = nav_df.sort_values("nav_date").dropna(subset=["nav"])
if is_qdii:
# QDIIT日价格对比T-1日净值净值公布延迟
price_df["prev_date"] = price_df["date"].shift(1)
merged = price_df.merge(
nav_df[["nav_date", "nav"]],
left_on="prev_date",
right_on="nav_date",
how="left"
)
merged["nav_date"] = merged["prev_date"].apply(
lambda x: x.strftime("%Y-%m-%d") if pd.notna(x) else None
)
else:
# 非 QDII商品、混合型等T日价格对比T日净值当天有就显示没有就空
merged = price_df.merge(
nav_df[["nav_date", "nav"]],
left_on="date",
right_on="nav_date",
how="left"
)
merged["nav_date"] = merged["date"].apply(
lambda x: x.strftime("%Y-%m-%d") if pd.notna(x) else None
)
# 4. 计算溢价率
merged["premium_rate"] = (
(merged["price"] - merged["nav"]) / merged["nav"] * 100
).round(2)
# 5. 格式化输出
merged["date"] = merged["date"].dt.strftime("%Y-%m-%d")
result = merged[["date", "price", "nav_date", "nav", "premium_rate",
"turnover", "share_volume", "change_amount", "change_pct"]].copy()
result.columns = ["date", "price", "navDate", "nav", "premiumRate",
"turnover", "shareVolume", "changeAmount", "changePct"]
# 处理 NaN确保 float NaN 也被替换为 None避免 JSON 序列化失败)
result = result.where(pd.notnull(result), None)
result = result.replace({pd.NA: None, float('nan'): None})
# 逐行逐列确保彻底清除 NaN
data = []
for record in result.to_dict(orient="records"):
clean = {}
for k, v in record.items():
if isinstance(v, float) and (v != v): # NaN check
clean[k] = None
else:
clean[k] = v
data.append(clean)
data.reverse() # 最新的在前
logger.info("基金 %s 历史数据返回成功,共 %d", fund_code, len(data))
return {"code": 200, "data": data, "fundCode": fund_code, "fundName": fund_name}
except Exception as e:
logger.exception("获取历史数据失败")
return {"code": 500, "msg": f"获取历史数据失败:{str(e)}"}
@router.get("")
def get_lof_data():
"""获取 LOF 实时数据 + 溢价率 + 限额
采用 stale-while-revalidate 策略
1. 有缓存 立即返回毫秒级后台异步刷新
2. 无缓存 等待首次获取 5-8
"""
# 检查是否有缓存
cached = get_cached_lof_data()
if cached is not None:
# 尝试后台异步刷新(非阻塞)
if _refresh_lock.acquire(blocking=False):
try:
threading.Thread(target=_refresh_data_background, daemon=True).start()
except Exception:
_refresh_lock.release()
logger.info("返回缓存数据,缓存时间:%s", cached["time"])
return {
"code": 200,
"data": cached["data"],
"hasMore": cached.get("hasMore", False)
}
# 无缓存:同步等待首次获取
logger.info("首次启动,同步获取 LOF 数据...")
result = _do_fetch_data()
return result
def _refresh_data_background():
"""后台刷新数据"""
try:
logger.info("后台刷新 LOF 数据...")
result = _do_fetch_data()
logger.info("后台刷新完成")
except Exception as e:
logger.error("后台刷新失败:%s", e)
finally:
_refresh_lock.release()
def _do_fetch_data():
"""核心数据获取逻辑"""
try:
logger.info("开始获取 LOF 数据...")
# 1. 并行获取三个数据源(串行→并行,总耗时从 ~9s 降至 ~3-4s
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
future_spot = executor.submit(fetch_spot_data)
future_purchase = executor.submit(fetch_purchase_data)
future_estimate = executor.submit(fetch_estimate_data)
spot = future_spot.result(timeout=30)
logger.info("LOF 实时数据获取成功,共 %d", len(spot))
purchase = future_purchase.result(timeout=30)
logger.info("基金净值/限额数据获取成功,共 %d", len(purchase))
estimate = future_estimate.result(timeout=30)
logger.info("基金估算净值获取成功仅LOF%d", len(estimate))
# 1.5 提取 LOF 代码列表,提前过滤以减少后续合并计算量
lof_codes = set(spot["代码"].astype(str).tolist())
purchase = purchase[purchase["基金代码"].astype(str).isin(lof_codes)]
estimate = estimate[estimate["基金代码"].astype(str).isin(lof_codes)]
logger.info("过滤后:净值/限额 %d 条,估算净值 %d", len(purchase), len(estimate))
# 2. 检查数据有效性:如果所有价格都是无效值,可能是非交易时段 API 返回 '-'
# 此时应保留已有缓存,避免用无效数据覆盖
spot_prices = pd.to_numeric(spot["最新价"], errors="coerce")
valid_count = spot_prices.notna().sum()
has_valid_data = valid_count >= 10
if not has_valid_data:
cached = get_cached_lof_data()
if cached is not None:
logger.warning("有效价格数据仅 %d 条,保留缓存", valid_count)
return {"code": 200, "data": cached["data"], "hasMore": cached.get("hasMore", False)}
logger.warning("有效价格数据仅 %d 条,无缓存可用,仍使用当前数据", valid_count)
# 3. 合并数据
df = spot.merge(
purchase,
left_on="代码",
right_on="基金代码",
how="left"
).merge(
estimate,
left_on="代码",
right_on="基金代码",
how="left"
)
# 3.5 非交易时段处理API 返回的最新价/涨跌幅为无效值
# 用昨收(昨日收盘价)代替最新价,确保溢价率等数据可正常显示
df["最新价"] = pd.to_numeric(df["最新价"], errors="coerce")
df["昨收"] = pd.to_numeric(df["昨收"], errors="coerce")
mask_invalid = df["最新价"].isna() | (df["最新价"] == 0)
if mask_invalid.any():
df.loc[mask_invalid, "最新价"] = df.loc[mask_invalid, "昨收"]
df.loc[mask_invalid, "涨跌幅"] = 0
# 4. 计算溢价率
df["溢价率"] = (
(df["最新价"] - df["最新净值/万份收益"])
/ df["最新净值/万份收益"]
* 100
).round(2)
df["估算溢价率"] = (
(df["最新价"] - df["估算净值"])
/ df["估算净值"]
* 100
).round(2)
# 5. 按基金规模拆分大基金≥3000万优先返回小基金后台缓存
LARGE_FUND_THRESHOLD = 30000000
large_mask = (df["总市值"] >= LARGE_FUND_THRESHOLD) | (df["总市值"].isna()) | (df["总市值"] <= 0)
df_large = df[large_mask].copy()
df_small = df[~large_mask].copy()
def format_df(part: pd.DataFrame) -> list[dict]:
part["限额"] = part["日累计限定金额"].apply(format_limit)
part["总市值_格式化"] = part["总市值"].apply(format_amount)
part["成交额_格式化"] = part["成交额"].apply(format_amount)
part = part[[
"代码", "名称", "最新价", "涨跌幅",
"最新净值/万份收益", "估算净值", "溢价率", "估算溢价率",
"限额", "申购状态",
"总市值_格式化", "成交量", "成交额_格式化"
]]
part.columns = [
"fundCode", "fundName", "tradePrice", "increaseRate",
"netValue", "estimateValue", "premiumRate", "estimatePremiumRate",
"purchaseLimit", "purchaseStatus",
"fundSize", "volume", "turnover"
]
part = part.replace({pd.NA: "-"})
part = part.where(pd.notnull(part), "-")
return part.to_dict(orient="records")
large_data = format_df(df_large)
small_data = format_df(df_small)
with remaining_cache["lock"]:
remaining_cache["data"] = small_data
update_cache_data(large_data, small_data, has_valid_data=has_valid_data)
logger.info("数据返回成功,大基金 %d 条,小基金 %d", len(large_data), len(small_data))
return {"code": 200, "data": large_data, "hasMore": len(small_data) > 0}
except concurrent.futures.TimeoutError:
logger.error("请求数据源超时(超过 30 秒)")
cached = get_cached_lof_data()
if cached:
logger.info("返回缓存数据,缓存时间:%s", cached["time"])
return {"code": 200, "data": cached["data"], "cached": True}
return {"code": 500, "msg": "数据获取超时,请稍后重试"}
except Exception as e:
logger.exception("数据获取失败")
cached = get_cached_lof_data()
if cached:
logger.info("返回缓存数据,缓存时间:%s", cached["time"])
return {"code": 200, "data": cached["data"], "cached": True}
return {"code": 500, "msg": f"数据获取失败:{str(e)}"}
@router.get("/remaining")
def get_remaining_data():
"""获取剩余的小基金数据(规模 < 3000万主数据返回后前端异步获取"""
# 优先返回刚获取的小基金数据
with remaining_cache["lock"]:
if remaining_cache["data"]:
data = remaining_cache["data"]
remaining_cache["data"] = []
return {"code": 200, "data": data}
# 后备:返回缓存中的小基金数据
from app.cache import get_cached_small_data
small = get_cached_small_data()
return {"code": 200, "data": small}

View File

View File

@ -0,0 +1,308 @@
"""外部数据获取服务模块"""
import akshare as ak
import pandas as pd
import logging
import requests
import json
import re
import time
from datetime import datetime
from app.cache import (
is_purchase_cache_valid,
get_purchase_cache_data,
update_purchase_cache,
)
logger = logging.getLogger(__name__)
def fetch_spot_data():
"""获取 LOF 实时交易数据(直接请求东方财富接口,绕过 akshare 失效域名)"""
url = "https://push2delay.eastmoney.com/api/qt/clist/get"
base_params = {
"pn": "1",
"pz": "500",
"po": "1",
"np": "1",
"ut": "bd1d9ddb04089700cf9c27f6f7426281",
"fltt": "2",
"invt": "2",
"wbp2u": "|0|0|0|web",
"fid": "f3",
"fs": "b:MK0404,b:MK0405,b:MK0406,b:MK0407",
"fields": "f1,f2,f3,f4,f5,f6,f7,f8,f9,f10,f12,f13,f14,f15,f16,f17,f18,f20,f21,f23,f24,f25,f22,f11,f62,f128,f136,f115,f152",
}
# 获取第一页
r = requests.get(url, params=base_params, timeout=30)
r.raise_for_status()
data_json = r.json()
per_page_num = len(data_json["data"]["diff"])
total_page = (data_json["data"]["total"] + per_page_num - 1) // per_page_num
temp_list = [pd.DataFrame(data_json["data"]["diff"])]
# 获取剩余页面
for page in range(2, total_page + 1):
params = base_params.copy()
params["pn"] = str(page)
r = requests.get(url, params=params, timeout=30)
r.raise_for_status()
data_json = r.json()
temp_list.append(pd.DataFrame(data_json["data"]["diff"]))
temp_df = pd.concat(temp_list, ignore_index=True)
temp_df.rename(
columns={
"f12": "代码",
"f14": "名称",
"f2": "最新价",
"f4": "涨跌额",
"f3": "涨跌幅",
"f5": "成交量",
"f6": "成交额",
"f17": "开盘价",
"f15": "最高价",
"f16": "最低价",
"f18": "昨收",
"f20": "总市值",
},
inplace=True,
)
# 数值类型转换
numeric_cols = ["最新价", "涨跌额", "涨跌幅", "成交量", "成交额", "开盘价", "最高价", "最低价", "昨收", "总市值"]
for col in numeric_cols:
if col in temp_df.columns:
temp_df[col] = pd.to_numeric(temp_df[col], errors="coerce")
return temp_df
def fetch_purchase_data():
"""获取基金净值和限额信息(直接调用东方财富 API用 json.loads 替代 demjson 解析)"""
# 检查缓存是否有效
if is_purchase_cache_valid():
elapsed = (pd.Timestamp.now() - _get_purchase_cache_time()).total_seconds()
logger.info("使用缓存的净值/限额数据,缓存已 %.0f", elapsed)
return get_purchase_cache_data()
url = "https://fund.eastmoney.com/Data/Fund_JJJZ_Data.aspx"
params = {
"t": "8",
"page": "1,50000",
"js": "reData",
"sort": "fcode,asc",
}
req_headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36",
"Referer": "https://fund.eastmoney.com/",
}
try:
r = requests.get(url, params=params, headers=req_headers, timeout=30)
r.raise_for_status()
data_text = r.text
# 去除 JS 包装 var reData=...; → 纯 JSON
clean_text = data_text.strip()
if clean_text.startswith("var reData="):
clean_text = clean_text[len("var reData="):]
clean_text = clean_text.rstrip(";")
# 给无引号的 key 加双引号,变成合法 JSON再用 json.loadsC 实现)解析
# 比 akshare 用的 demjson.decode纯 Python快 ~150 倍
valid_json = re.sub(r'([{,]\s*)(\w+)\s*:', r'\1"\2":', clean_text)
data_json = json.loads(valid_json)
temp_df = pd.DataFrame(data_json["datas"])
# datas 列顺序0基金代码 1基金简称 2基金类型 3最新净值 4净值时间 5申购状态 6赎回状态
# 7下一开放日 8购买起点 9日累计限定金额 10- 11- 12手续费
result = temp_df.iloc[:, [0, 3, 9, 5]].copy()
result.columns = ["基金代码", "最新净值/万份收益", "日累计限定金额", "申购状态"]
result["最新净值/万份收益"] = pd.to_numeric(result["最新净值/万份收益"], errors="coerce")
result["日累计限定金额"] = pd.to_numeric(result["日累计限定金额"], errors="coerce")
except Exception as e:
logger.warning("直接解析净值/限额数据失败:%s,回退到 akshare", e)
df = ak.fund_purchase_em()
result = df[["基金代码", "最新净值/万份收益", "日累计限定金额", "申购状态"]]
# 更新缓存
update_purchase_cache(result)
return result.copy()
def _get_purchase_cache_time():
"""获取缓存时间(内部使用,避免循环导入)"""
from app.cache import purchase_cache
return purchase_cache["time"]
def fetch_estimate_data():
"""获取基金实时估算净值(全量获取后过滤 LOF确保不遗漏跨分类基金"""
try:
url = "https://api.fund.eastmoney.com/FundGuZhi/GetFundGZList"
params = {
"type": "1", # 全部类型,避免 LOF 基金被归到其他分类而遗漏
"sort": "3",
"orderType": "desc",
"canbuy": "0",
"pageIndex": "1",
"pageSize": "50000",
"_": str(int(pd.Timestamp.now().timestamp() * 1000)),
}
req_headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36",
"Referer": "https://fund.eastmoney.com/",
}
r = requests.get(url, params=params, headers=req_headers, timeout=30)
r.raise_for_status()
json_data = r.json()
data_list = json_data["Data"]["list"]
if not data_list:
logger.warning("估算净值返回空数据")
return pd.DataFrame(columns=["基金代码", "估算净值"])
temp_df = pd.DataFrame(data_list)
# API 返回 30 列只取列0=基金代码列20=估算净值
result = temp_df.iloc[:, [0, 20]].copy()
result.columns = ["基金代码", "估算净值"]
result["估算净值"] = pd.to_numeric(result["估算净值"], errors="coerce")
return result
except Exception as e:
logger.warning("获取估算净值失败:%s,回退到 akshare", e)
try:
df = ak.fund_value_estimation_em()
estimate_col = [c for c in df.columns if "估算数据-估算值" in c]
if not estimate_col:
return pd.DataFrame(columns=["基金代码", "估算净值"])
df = df.rename(columns={estimate_col[0]: "估算净值"})
df["估算净值"] = pd.to_numeric(df["估算净值"], errors="coerce")
return df[["基金代码", "估算净值"]].copy()
except Exception as e2:
logger.warning("akshare 估算净值也失败:%s", e2)
return pd.DataFrame(columns=["基金代码", "估算净值"])
def fetch_ths_kline(fund_code: str, max_days: int = 120) -> pd.DataFrame | None:
"""获取同花顺 K-line 数据(主数据源)。
同花顺换手率基于场内份额可直接算出准确的场内份额
返回字段: date, price, volume(), turnover(万元), share_volume(万份)
"""
current_year = datetime.now().year
years = [current_year, current_year - 1]
ths_headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Referer": f"http://stockpage.10jqka.com.cn/{fund_code}/",
}
all_rows = []
for year in years:
url = f"http://d.10jqka.com.cn/v6/line/hs_{fund_code}/01/{year}.js"
try:
r = requests.get(url, headers=ths_headers, timeout=30)
r.raise_for_status()
content = r.text
start = content.find("{")
end = content.rfind("}")
if start == -1 or end == -1:
continue
data = json.loads(content[start:end + 1])
data_str = data.get("data", "")
if not data_str:
continue
for day_str in data_str.split(";"):
parts = day_str.split(",")
if len(parts) < 8:
continue
date_raw = parts[0]
date = f"{date_raw[:4]}-{date_raw[4:6]}-{date_raw[6:]}"
vol_shares = float(parts[5])
turnover_rate = float(parts[7])
# 同花顺换手率基于场内份额:换手率(%) = 成交量(股) / 场内份额(股) * 100
# => 场内份额(万份) = 成交量(股) / (换手率(%) / 100) / 10000
share_volume = round(vol_shares / turnover_rate / 100, 2) if turnover_rate > 0 else None
all_rows.append({
"date": date,
"price": float(parts[4]),
"volume": round(vol_shares / 100, 2), # 手
"turnover": round(float(parts[6]) / 10000, 2), # 万元
"share_volume": share_volume,
})
except Exception as e:
logger.warning("同花顺 %s 年数据获取失败:%s", year, e)
if not all_rows:
return None
df = pd.DataFrame(all_rows)
df["date"] = pd.to_datetime(df["date"])
df = df.sort_values("date").reset_index(drop=True)
return df.tail(max_days).reset_index(drop=True)
def fetch_em_kline(fund_code: str, secid: str, max_days: int = 120) -> pd.DataFrame | None:
"""获取东方财富 K-line 数据(备用数据源)。
东方财富换手率基于总份额算出的 share_volume 为总份额
返回字段: date, price, volume(), turnover(万元), share_volume(万份)
"""
kline_url = "https://push2his.eastmoney.com/api/qt/stock/kline/get"
kline_params = {
"secid": secid,
"fields1": "f1,f2,f3,f4,f5,f6,f7,f8,f9,f10,f11,f12,f13",
"fields2": "f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61",
"klt": "101",
"fqt": "0",
"end": "20500101",
"lmt": str(max_days),
}
kline_headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Referer": f"https://quote.eastmoney.com/{secid.replace('.', '')}.html",
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Connection": "keep-alive",
}
last_err = None
session = requests.Session()
for attempt in range(5):
try:
if attempt > 0:
session = requests.Session()
time.sleep(1 + attempt * 0.5)
r = session.get(kline_url, params=kline_params, headers=kline_headers, timeout=30)
r.raise_for_status()
kline_data = r.json()
kline_list = kline_data.get("data", {}).get("klines", [])
if kline_list:
break
except Exception as e:
last_err = e
logger.warning("东方财富 K 线请求失败(尝试 %d/5%s", attempt + 1, e)
if attempt < 4:
time.sleep(2 ** attempt)
session.close()
if not kline_list:
logger.error("东方财富 K 线全部失败:%s", last_err)
return None
price_rows = []
for item in kline_list:
parts = item.split(",")
turnover_rate = float(parts[10]) if len(parts) > 10 and parts[10] else 0
volume_lots = float(parts[5]) if len(parts) > 5 and parts[5] else 0
# 东方财富换手率基于总份额,算出的 share_volume 为总份额
share_volume = round(volume_lots / turnover_rate, 2) if turnover_rate > 0 else None
price_rows.append({
"date": parts[0],
"price": float(parts[2]),
"volume": volume_lots,
"turnover": round(float(parts[6]) / 10000, 2) if len(parts) > 6 and parts[6] else None,
"share_volume": share_volume,
})
df = pd.DataFrame(price_rows)
df["date"] = pd.to_datetime(df["date"])
df = df.sort_values("date").reset_index(drop=True)
return df

View File

View File

@ -0,0 +1,26 @@
"""格式化工具函数"""
import pandas as pd
def format_limit(value):
"""格式化限额显示"""
if pd.isna(value):
return "-"
if value == 0:
return "-"
if value >= 1e8:
return "不限"
if value < 10000:
return f"{value:.0f}元/日"
return f"{value / 10000:.0f}万/日"
def format_amount(value):
"""格式化金额:成交额/总市值"""
if pd.isna(value):
return "-"
if value >= 1e8:
return f"{value / 1e8:.2f}亿"
if value >= 1e4:
return f"{value / 1e4:.2f}"
return f"{value:.0f}"

View File

@ -1,242 +0,0 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import akshare as ak
import pandas as pd
import logging
import concurrent.futures
import requests
# 配置日志
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
app = FastAPI()
# 缓存最近一次成功的数据
cache_data = {"data": [], "time": None}
# 解决跨域,让 Vue 能调用
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
def format_limit(value):
"""格式化限额显示"""
if pd.isna(value):
return "-"
if value == 0:
return "-"
if value >= 1e8:
return "不限"
if value < 10000:
return f"{value:.0f}元/日"
return f"{value / 10000:.0f}万/日"
def format_amount(value):
"""格式化金额:成交额/总市值"""
if pd.isna(value):
return "-"
if value >= 1e8:
return f"{value / 1e8:.2f}亿"
if value >= 1e4:
return f"{value / 1e4:.2f}"
return f"{value:.0f}"
def fetch_spot_data():
"""获取 LOF 实时交易数据(直接请求东方财富接口,绕过 akshare 失效域名)"""
url = "https://push2delay.eastmoney.com/api/qt/clist/get"
base_params = {
"pn": "1",
"pz": "100",
"po": "1",
"np": "1",
"ut": "bd1d9ddb04089700cf9c27f6f7426281",
"fltt": "2",
"invt": "2",
"wbp2u": "|0|0|0|web",
"fid": "f3",
"fs": "b:MK0404,b:MK0405,b:MK0406,b:MK0407",
"fields": "f1,f2,f3,f4,f5,f6,f7,f8,f9,f10,f12,f13,f14,f15,f16,f17,f18,f20,f21,f23,f24,f25,f22,f11,f62,f128,f136,f115,f152",
}
# 获取第一页
r = requests.get(url, params=base_params, timeout=30)
r.raise_for_status()
data_json = r.json()
per_page_num = len(data_json["data"]["diff"])
total_page = (data_json["data"]["total"] + per_page_num - 1) // per_page_num
temp_list = [pd.DataFrame(data_json["data"]["diff"])]
# 获取剩余页面
for page in range(2, total_page + 1):
params = base_params.copy()
params["pn"] = str(page)
r = requests.get(url, params=params, timeout=30)
r.raise_for_status()
data_json = r.json()
temp_list.append(pd.DataFrame(data_json["data"]["diff"]))
temp_df = pd.concat(temp_list, ignore_index=True)
temp_df.rename(
columns={
"f12": "代码",
"f14": "名称",
"f2": "最新价",
"f4": "涨跌额",
"f3": "涨跌幅",
"f5": "成交量",
"f6": "成交额",
"f17": "开盘价",
"f15": "最高价",
"f16": "最低价",
"f18": "昨收",
"f20": "总市值",
},
inplace=True,
)
# 数值类型转换
numeric_cols = ["最新价", "涨跌额", "涨跌幅", "成交量", "成交额", "开盘价", "最高价", "最低价", "昨收", "总市值"]
for col in numeric_cols:
if col in temp_df.columns:
temp_df[col] = pd.to_numeric(temp_df[col], errors="coerce")
return temp_df
def fetch_purchase_data():
"""获取基金净值和限额信息"""
df = ak.fund_purchase_em()
return df[["基金代码", "最新净值/万份收益", "日累计限定金额", "申购状态"]]
def fetch_estimate_data():
"""获取基金实时估算净值(东方财富估值数据)"""
try:
df = ak.fund_value_estimation_em()
# 列名格式如2026-05-07-估算数据-估算值,每天日期会变,需要模糊匹配
estimate_col = [c for c in df.columns if "估算数据-估算值" in c]
if not estimate_col:
logger.warning("未找到估算净值列,返回空数据")
return pd.DataFrame(columns=["基金代码", "估算净值"])
df = df.rename(columns={estimate_col[0]: "估算净值"})
df["估算净值"] = pd.to_numeric(df["估算净值"], errors="coerce")
return df[["基金代码", "估算净值"]].copy()
except Exception as e:
logger.warning("获取估算净值失败:%s", e)
return pd.DataFrame(columns=["基金代码", "估算净值"])
@app.get("/api/lof")
def get_lof_data():
"""获取 LOF 实时数据 + 溢价率 + 限额"""
global cache_data
try:
logger.info("开始获取 LOF 数据...")
# 1. 获取 LOF 实时交易数据(带 30 秒超时)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(fetch_spot_data)
spot = future.result(timeout=30)
logger.info("LOF 实时数据获取成功,共 %d", len(spot))
# 2. 获取基金净值和限额信息(带 30 秒超时)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(fetch_purchase_data)
purchase = future.result(timeout=30)
logger.info("基金净值/限额数据获取成功,共 %d", len(purchase))
# 2.5 获取实时估算净值(带 30 秒超时)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(fetch_estimate_data)
estimate = future.result(timeout=30)
logger.info("基金估算净值获取成功,共 %d", len(estimate))
# 3. 合并数据
df = spot.merge(
purchase,
left_on="代码",
right_on="基金代码",
how="left"
).merge(
estimate,
left_on="代码",
right_on="基金代码",
how="left"
)
# 4. 计算溢价率
# 静态溢价率:基于最新公布的收盘净值(通常是昨日)
df["溢价率"] = (
(df["最新价"] - df["最新净值/万份收益"])
/ df["最新净值/万份收益"]
* 100
).round(2)
# 动态溢价率(估算溢价率):基于实时估算净值,交易时间内更真实
df["估算溢价率"] = (
(df["最新价"] - df["估算净值"])
/ df["估算净值"]
* 100
).round(2)
# 5. 格式化限额
df["限额"] = df["日累计限定金额"].apply(format_limit)
# 6. 格式化总市值和成交额
df["总市值_格式化"] = df["总市值"].apply(format_amount)
df["成交额_格式化"] = df["成交额"].apply(format_amount)
# 7. 只保留需要的字段
df = df[[
"代码", "名称", "最新价", "涨跌幅",
"最新净值/万份收益", "估算净值", "溢价率", "估算溢价率",
"限额", "申购状态",
"总市值_格式化", "成交量", "成交额_格式化"
]]
# 8. 格式化字段名(给前端用)
df.columns = [
"fundCode",
"fundName",
"tradePrice",
"increaseRate",
"netValue",
"estimateValue",
"premiumRate",
"estimatePremiumRate",
"purchaseLimit",
"purchaseStatus",
"fundSize",
"volume",
"turnover"
]
# 8. 处理 NaN 值,避免 JSON 序列化失败
df = df.replace({pd.NA: "-"})
df = df.where(pd.notnull(df), "-")
# 9. 转成 JSON 格式
data = df.to_dict(orient="records")
cache_data = {"data": data, "time": pd.Timestamp.now()}
logger.info("数据返回成功,共 %d", len(data))
return {"code": 200, "data": data}
except concurrent.futures.TimeoutError:
logger.error("请求 akshare 数据源超时(超过 30 秒)")
if cache_data["data"]:
logger.info("返回缓存数据,缓存时间:%s", cache_data["time"])
return {"code": 200, "data": cache_data["data"], "cached": True}
return {"code": 500, "msg": "数据获取超时,请稍后重试"}
except Exception as e:
logger.exception("数据获取失败")
if cache_data["data"]:
logger.info("返回缓存数据,缓存时间:%s", cache_data["time"])
return {"code": 200, "data": cache_data["data"], "cached": True}
return {"code": 500, "msg": f"数据获取失败:{str(e)}"}

5
back/run.py Normal file
View File

@ -0,0 +1,5 @@
"""uvicorn 启动入口"""
import uvicorn
if __name__ == "__main__":
uvicorn.run("app.main:app", reload=True, host="0.0.0.0", port=8000)

View File

@ -9,6 +9,7 @@ lerna-debug.log*
node_modules
dist
.npm-cache
dist-ssr
*.local

View File

@ -1,13 +1,16 @@
<!doctype html>
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LOF 基金实时溢价监控</title>
<title>LOF 基金监控</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"]
}

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,8 @@
"dependencies": {
"axios": "^1.16.0",
"element-plus": "^2.13.7",
"vue": "^3.5.32"
"vue": "^3.5.32",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.6",

View File

@ -1,428 +1,106 @@
<template>
<div class="login-overlay" v-if="!authenticated">
<div class="login-card">
<div class="login-icon">🔒</div>
<div v-if="isAuthenticated" class="app-layout">
<RouterView v-slot="{ Component }">
<keep-alive include="RealTimePremium">
<component :is="Component" />
</keep-alive>
</RouterView>
</div>
<div v-else class="access-gate">
<div class="access-card">
<div class="lock-icon">🔒</div>
<p class="access-tip">请输入访问口令</p>
<el-input
v-model="password"
type="password"
placeholder="请输入密码"
show-password
@keyup.enter="verifyPassword"
placeholder="请输入口令"
size="large"
@keyup.enter="verifyPassword"
/>
<el-button type="primary" size="large" @click="verifyPassword" :loading="checking" class="login-btn">
验证
<el-button
type="primary"
size="large"
class="access-btn"
@click="verifyPassword"
>
进入系统
</el-button>
<p class="login-error" v-if="errorMsg">{{ errorMsg }}</p>
</div>
</div>
<div class="lof-app" v-else>
<div class="header">
<div class="title">
<h2>LOF 基金实时溢价监控</h2>
<span class="update-time">数据更新时间{{ lastUpdateTime }}</span>
</div>
<div class="tool-bar">
<div class="refresh">
<el-button @click="manualRefresh" :loading="loading" type="primary" :icon="Refresh" size="default">
手动刷新
</el-button>
</div>
<div class="sort">
<span class="sort-label">排序依据</span>
<el-button @click="toggleSortField" :disabled="loading" size="small">
{{ sortField === 'premiumRate' ? '按昨日净值溢价' : '按实时估算溢价' }}
</el-button>
<el-button @click="toggleSort" :disabled="loading" size="small">
{{ sortType === 'desc' ? '▼ 从高到低' : '▲ 从低到高' }}
</el-button>
</div>
</div>
</div>
<div class="filter-bar">
<el-input v-model="searchCode" placeholder="基金代码" clearable />
<el-input v-model="searchName" placeholder="基金名称" clearable />
<el-select v-model="filterStatus" placeholder="申购状态" clearable>
<el-option v-for="s in statusOptions" :key="s" :label="s" :value="s" />
</el-select>
<el-button @click="resetFilter" :icon="RefreshRight" size="default">重置</el-button>
</div>
<div class="table-wrapper">
<el-table :data="displayList" v-loading="loading" border>
<el-table-column prop="fundCode" label="基金代码" align="center" min-width="100" />
<el-table-column prop="fundName" label="基金名称" align="center" min-width="140" />
<el-table-column prop="tradePrice" label="场内价格" align="center" min-width="90" />
<el-table-column prop="netValue" label="场外净值(昨日)" align="center" min-width="120" />
<el-table-column prop="estimateValue" label="估算净值(实时)" align="center" min-width="120" />
<el-table-column label="涨跌幅" align="center" min-width="80">
<template #default="{ row }">
<span :class="getRateClass(row.increaseRate)">
{{ row.increaseRate }}%
</span>
</template>
</el-table-column>
<el-table-column label="溢价率(昨日)" align="center" min-width="110">
<template #default="{ row }">
<span :class="getRateClass(row.premiumRate)">
{{ row.premiumRate }}%
</span>
</template>
</el-table-column>
<el-table-column label="溢价率(实时)" align="center" min-width="110">
<template #default="{ row }">
<span :class="getRateClass(row.estimatePremiumRate)">
{{ row.estimatePremiumRate }}%
</span>
</template>
</el-table-column>
<el-table-column prop="purchaseLimit" label="日限额" align="center" min-width="80" />
<el-table-column label="申购状态" align="center" min-width="100">
<template #default="{ row }">
<el-tag :type="row.purchaseStatus === '暂停申购' ? 'danger' : row.purchaseStatus === '开放申购' ? 'success' : 'info'" size="small" style="white-space: nowrap;">
{{ row.purchaseStatus }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="fundSize" label="基金规模" align="center" min-width="100" />
<el-table-column prop="turnover" label="成交额" align="center" min-width="80" />
</el-table>
<p v-if="error" class="error-msg">口令错误请重试</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { Refresh, RefreshRight } from '@element-plus/icons-vue'
<script setup name="myApp">
import { ref } from 'vue'
const API_URL = '/api/lof'
const ACCESS_PASSWORD = '88'
const STORAGE_KEY = 'lof_access_granted'
const authenticated = ref(sessionStorage.getItem('auth') === 'true')
const password = ref('')
const checking = ref(false)
const errorMsg = ref('')
const error = ref(false)
const isAuthenticated = ref(sessionStorage.getItem(STORAGE_KEY) === 'true')
function verifyPassword() {
if (checking.value) return
checking.value = true
errorMsg.value = ''
setTimeout(() => {
if (password.value === '88') {
authenticated.value = true
sessionStorage.setItem('auth', 'true')
password.value = ''
if (password.value === ACCESS_PASSWORD) {
isAuthenticated.value = true
error.value = false
sessionStorage.setItem(STORAGE_KEY, 'true')
} else {
errorMsg.value = '密码错误,请重试'
error.value = true
password.value = ''
}
checking.value = false
}, 300)
}
const fundList = ref([])
const loading = ref(false)
const sortType = ref('desc')
const sortField = ref('premiumRate')
const searchCode = ref('')
const searchName = ref('')
const filterStatus = ref('')
const lastUpdateTime = ref('')
function formatTime(date) {
const pad = n => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
const statusOptions = computed(() => {
const set = new Set(fundList.value.map(i => i.purchaseStatus).filter(Boolean))
return Array.from(set).sort()
})
const displayList = computed(() => {
let list = [...fundList.value]
if (searchCode.value) {
list = list.filter(i => String(i.fundCode).includes(searchCode.value.trim()))
}
if (searchName.value) {
list = list.filter(i => String(i.fundName).includes(searchName.value.trim()))
}
if (filterStatus.value) {
list = list.filter(i => i.purchaseStatus === filterStatus.value)
}
list.sort((a, b) => {
const field = sortField.value
const pa = parseFloat(a[field]) || 0
const pb = parseFloat(b[field]) || 0
return sortType.value === 'desc' ? pb - pa : pa - pb
})
return list
})
function resetFilter() {
searchCode.value = ''
searchName.value = ''
filterStatus.value = ''
}
async function fetchData() {
if (loading.value) return
loading.value = true
try {
const res = await axios.get(API_URL)
if (res.data.code === 200) {
fundList.value = res.data.data
lastUpdateTime.value = formatTime(new Date())
} else {
ElMessage.error(res.data.msg || '数据获取失败')
}
} catch (err) {
ElMessage.error('请求失败:请确认 Python 后端已启动')
} finally {
loading.value = false
}
}
function manualRefresh() {
fetchData()
}
function toggleSort() {
sortType.value = sortType.value === 'desc' ? 'asc' : 'desc'
}
function toggleSortField() {
sortField.value = sortField.value === 'premiumRate' ? 'estimatePremiumRate' : 'premiumRate'
}
function getRateClass(rate) {
const num = parseFloat(rate) || 0
if (num > 0) return 'rate-up'
if (num < 0) return 'rate-down'
return 'rate-zero'
}
onMounted(() => {
if (authenticated.value) {
fetchData()
}
})
watch(authenticated, (val) => {
if (val) {
fetchData()
}
})
</script>
<style scoped>
.login-overlay {
position: fixed;
inset: 0;
background: #0d1117;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
.app-layout {
min-height: 100vh;
background: var(--bg-primary);
}
.login-card {
width: 360px;
max-width: 90vw;
padding: 40px 32px;
background: #161b22;
border: 1px solid #30363d;
border-radius: 12px;
.access-gate {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: var(--bg-primary);
padding: 16px;
}
.access-card {
background: var(--bg-secondary);
border-radius: 16px;
padding: 48px 40px;
width: 400px;
max-width: 100%;
border: 1px solid var(--border-color);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
text-align: center;
}
.login-icon {
font-size: 48px;
margin-bottom: 16px;
.lock-icon {
font-size: 56px;
margin-bottom: 8px;
}
.login-card h2 {
color: #e6edf3;
font-size: 18px;
font-weight: 700;
margin: 0 0 8px 0;
.access-tip {
margin: 0 0 24px;
color: var(--text-secondary);
font-size: 14px;
}
.login-desc {
color: #8b949e;
font-size: 13px;
margin: 0 0 24px 0;
}
.login-btn {
width: 100%;
.access-btn {
margin-top: 16px;
width: 100%;
}
.login-error {
color: #f85149;
font-size: 13px;
margin: 12px 0 0 0;
.error-msg {
color: var(--danger) !important;
margin-top: 12px !important;
margin-bottom: 0 !important;
font-size: 13px !important;
}
@media (max-width: 480px) {
.login-card {
padding: 32px 20px;
.access-card {
padding: 32px 24px;
}
.login-card h2 {
font-size: 16px;
}
}
.lof-app {
max-width: 1400px;
margin: 0 auto;
padding: 20px 16px;
min-height: 100vh;
background: #0d1117;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
gap: 16px;
}
.title h2 {
color: #e6edf3;
font-size: 22px;
font-weight: 700;
margin: 0 0 4px 0;
}
.update-time {
font-size: 12px;
color: #8b949e;
}
.tool-bar {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.sort {
display: flex;
align-items: center;
gap: 6px;
}
.sort-label {
font-size: 13px;
color: #8b949e;
white-space: nowrap;
}
.filter-bar {
display: flex;
gap: 10px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.filter-bar .el-input {
width: 150px;
}
.filter-bar .el-select {
width: 130px;
}
.table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border-radius: 8px;
border: 1px solid #30363d;
}
.table-wrapper :deep(.el-table) {
min-width: 900px;
}
.rate-up {
color: #f85149;
font-weight: bold;
}
.rate-down {
color: #3fb950;
font-weight: bold;
}
.rate-zero {
color: #8b949e;
}
@media (max-width: 768px) {
.lof-app {
padding: 12px 8px;
}
.header {
flex-direction: column;
gap: 12px;
}
.title h2 {
font-size: 18px;
}
.tool-bar {
width: 100%;
}
.filter-bar .el-input {
width: calc(50% - 5px);
}
.filter-bar .el-select {
width: calc(50% - 5px);
}
.filter-bar {
gap: 8px;
}
}
@media (max-width: 480px) {
.lof-app {
padding: 8px 4px;
}
.title h2 {
font-size: 16px;
}
.update-time {
font-size: 11px;
}
.filter-bar .el-input {
width: 100%;
}
.filter-bar .el-select {
width: 100%;
}
.tool-bar {
gap: 8px;
}
.sort {
gap: 4px;
.lock-icon {
font-size: 40px;
}
}
</style>

View File

@ -0,0 +1,183 @@
<template>
<div class="history-app">
<div class="history-header">
<div class="header-left">
<h2>{{ fundName }} ({{ fundCode }}) 历史数据</h2>
</div>
<a class="back-link" @click="goBack">&larr; 返回看板</a>
</div>
<el-table :data="historyList" v-loading="loading" border height="calc(100vh - 200px)" style="width: 100%">
<el-table-column prop="date" label="价格日期" align="center" min-width="110" />
<el-table-column prop="price" label="收盘价" align="center" min-width="90" />
<el-table-column prop="navDate" label="净值日期" align="center" min-width="110" />
<el-table-column prop="nav" label="净值" align="center" min-width="90" />
<el-table-column label="溢价率" align="center" min-width="100">
<template #default="{ row }">
<span :class="getRateClass(row.premiumRate)">
{{ formatPremium(row.premiumRate) }}
</span>
</template>
</el-table-column>
<el-table-column prop="turnover" label="成交额(万元)" align="center" min-width="120" />
<el-table-column prop="shareVolume" label="场内份额(万份)" align="center" min-width="130" />
<el-table-column label="场内新增(万份)" align="center" min-width="120">
<template #default="{ row }">
<span :class="getRateClass(row.changeAmount)">
{{ row.changeAmount != null ? formatChange(row.changeAmount) : '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="份额涨幅" align="center" min-width="100">
<template #default="{ row }">
<span :class="getRateClass(row.changePct)">
{{ row.changePct != null ? formatPct(row.changePct) : '-' }}
</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup name="history">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import axios from 'axios'
import { ElMessage } from 'element-plus'
const API_URL = 'http://127.0.0.1:8000/api/lof/history'
const route = useRoute()
const router = useRouter()
const fundCode = ref(route.query.fundCode || '')
const fundName = ref(route.query.fundName || '')
const historyList = ref([])
const loading = ref(false)
function goBack() {
router.push('/')
}
async function fetchHistory() {
if (!fundCode.value) {
ElMessage.error('缺少基金代码参数')
return
}
loading.value = true
try {
const res = await axios.get(API_URL, {
params: { fund_code: fundCode.value, fund_name: fundName.value }
})
if (res.data.code === 200) {
historyList.value = res.data.data
fundName.value = res.data.fundName || fundName.value
} else {
ElMessage.error(res.data.msg || '获取历史数据失败')
}
} catch (err) {
ElMessage.error('请求失败:请确认 Python 后端已启动')
} finally {
loading.value = false
}
}
function formatPremium(rate) {
if (rate == null) return '-'
const num = parseFloat(rate)
if (isNaN(num)) return '-'
return (num > 0 ? '+' : '') + num + '%'
}
function formatPct(val) {
if (val == null) return '-'
const num = parseFloat(val)
if (isNaN(num)) return '-'
return (num > 0 ? '+' : '') + num.toFixed(3) + '%'
}
function formatChange(val) {
if (val == null) return '-'
const num = parseFloat(val)
if (isNaN(num)) return '-'
return (num > 0 ? '+' : '') + num.toFixed(2)
}
function getRateClass(rate) {
const num = parseFloat(rate) || 0
if (num > 0) return 'rate-up'
if (num < 0) return 'rate-down'
return 'rate-zero'
}
onMounted(() => {
fetchHistory()
})
</script>
<style scoped>
.history-app {
padding: 24px;
box-sizing: border-box;
width: 100%;
max-width: 1600px;
margin: 0 auto;
overflow-x: auto;
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
background: var(--bg-elevated);
border: 1px solid var(--border-color);
padding: 16px 24px;
border-radius: 8px;
gap: 12px;
}
.history-header h2 {
margin: 0;
font-size: 20px;
color: var(--text-primary);
}
.back-link {
color: var(--accent);
cursor: pointer;
font-size: 14px;
text-decoration: none;
transition: color 0.2s;
white-space: nowrap;
flex-shrink: 0;
}
.back-link:hover {
color: #66b1ff;
}
.rate-up {
color: var(--rate-up);
font-weight: bold;
}
.rate-down {
color: var(--rate-down);
font-weight: bold;
}
.rate-zero {
color: var(--text-muted);
}
@media (max-width: 768px) {
.history-app {
padding: 12px;
}
.history-header {
flex-direction: column;
align-items: flex-start;
}
.history-header h2 {
font-size: 16px;
word-break: break-all;
}
.back-link {
align-self: flex-end;
}
}
</style>

View File

@ -0,0 +1,404 @@
<template>
<div class="lof-app">
<div class="header">
<div class="title">
<span class="update-time">数据更新时间{{ lastUpdateTime }}</span>
</div>
<div class="tool-bar">
<el-button @click="manualRefresh" :loading="loading" type="primary" :icon="Refresh">
手动刷新
</el-button>
</div>
</div>
<div class="filter-bar">
<el-input v-model="searchCode" placeholder="基金代码" clearable class="filter-input filter-input-sm" />
<el-input v-model="searchName" placeholder="基金名称" clearable class="filter-input" />
<el-select v-model="filterStatus" placeholder="申购状态" clearable class="filter-select">
<el-option v-for="s in statusOptions" :key="s" :label="s" :value="s" />
</el-select>
<el-checkbox v-model="filterByScale" class="filter-checkbox">规模3000万</el-checkbox>
<el-radio-group v-model="showOnlyFavorites" size="small" class="filter-radio">
<el-radio-button :label="false">全部基金</el-radio-button>
<el-radio-button :label="true">我的关注</el-radio-button>
</el-radio-group>
<el-button @click="resetFilter" :icon="RefreshRight" class="filter-reset-btn">重置</el-button>
</div>
<el-table :data="displayList" v-loading="loading" height="calc(100vh - 180px)" border :default-sort="{ prop: 'premiumRate', order: 'descending' }" style="width: 100%">
<el-table-column prop="fundCode" label="基金代码" align="center" min-width="100">
<template #default="{ row }">
<a class="fund-code-link" @click="goHistory(row)">{{ row.fundCode }}</a>
</template>
</el-table-column>
<el-table-column prop="fundName" label="基金名称" align="center" min-width="130" />
<el-table-column prop="tradePrice" label="场内价格" align="center" min-width="110" sortable :sort-method="(a, b) => numericSort(a, b, 'tradePrice')" />
<el-table-column prop="netValue" label="场外净值(昨日)" align="center" min-width="140" sortable :sort-method="(a, b) => numericSort(a, b, 'netValue')" />
<el-table-column prop="estimateValue" label="估算净值(实时)" align="center" min-width="140" sortable :sort-method="(a, b) => numericSort(a, b, 'estimateValue')" />
<el-table-column prop="increaseRate" label="涨跌幅" align="center" min-width="110" sortable :sort-method="(a, b) => numericSort(a, b, 'increaseRate')">
<template #default="{ row }">
<span :class="getRateClass(row.increaseRate)">
{{ row.increaseRate }}%
</span>
</template>
</el-table-column>
<el-table-column prop="premiumRate" label="溢价率(昨日)" align="center" min-width="130" sortable :sort-method="(a, b) => numericSort(a, b, 'premiumRate')">
<template #default="{ row }">
<span :class="getRateClass(row.premiumRate)">
{{ row.premiumRate }}%
</span>
</template>
</el-table-column>
<el-table-column prop="estimatePremiumRate" label="溢价率(实时)" align="center" min-width="130" sortable :sort-method="(a, b) => numericSort(a, b, 'estimatePremiumRate')">
<template #default="{ row }">
<span :class="getRateClass(row.estimatePremiumRate)">
{{ row.estimatePremiumRate }}%
</span>
</template>
</el-table-column>
<el-table-column prop="purchaseLimit" label="日限额" align="center" min-width="110" sortable :sort-method="(a, b) => numericSort(a, b, 'purchaseLimit')" />
<el-table-column label="申购状态" align="center" width="120">
<template #default="{ row }">
<el-tag :type="row.purchaseStatus === '暂停申购' ? 'danger' : row.purchaseStatus === '开放申购' ? 'success' : 'info'" size="small" style="white-space: nowrap;">
{{ row.purchaseStatus }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="fundSize" label="基金规模" align="center" min-width="110" sortable :sort-method="(a, b) => numericSort(a, b, 'fundSize')" />
<el-table-column prop="turnover" label="成交额" align="center" min-width="110" sortable :sort-method="(a, b) => numericSort(a, b, 'turnover')" />
<el-table-column label="关注" align="center" width="70" fixed="right">
<template #default="{ row }">
<el-switch
:model-value="favorites.has(row.fundCode)"
@change="(val) => toggleFavorite(row.fundCode, val)"
/>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { Refresh, RefreshRight } from '@element-plus/icons-vue'
//
const API_URL = 'http://127.0.0.1:8000/api/lof'
// ========== IndexedDB ==========
const DB_NAME = 'lof-monitor'
const DB_VERSION = 1
const STORE_NAME = 'favorites'
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
request.onupgradeneeded = (event) => {
const db = event.target.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'fundCode' })
}
}
})
}
async function addFavoriteDB(fundCode) {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
const request = store.put({ fundCode, time: Date.now() })
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}
async function removeFavoriteDB(fundCode) {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
const request = store.delete(fundCode)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}
async function getAllFavoritesDB() {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
const request = store.getAll()
request.onsuccess = () => resolve(request.result.map(item => item.fundCode))
request.onerror = () => reject(request.error)
})
}
// ========== IndexedDB ==========
const fundList = ref([])
const loading = ref(false)
const searchCode = ref('')
const searchName = ref('')
const filterStatus = ref('')
const lastUpdateTime = ref('')
const favorites = ref(new Set())
const showOnlyFavorites = ref(false)
const filterByScale = ref(true)
const router = useRouter()
function parseFundSize(sizeStr) {
if (!sizeStr || sizeStr === '-') return 0
if (sizeStr.endsWith('亿')) {
return parseFloat(sizeStr) * 1e8
}
if (sizeStr.endsWith('万')) {
return parseFloat(sizeStr) * 1e4
}
return parseFloat(sizeStr) || 0
}
function goHistory(row) {
router.push({ path: '/history', query: { fundCode: row.fundCode, fundName: row.fundName } })
}
function formatTime(date) {
const pad = n => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
//
const statusOptions = computed(() => {
const set = new Set(fundList.value.map(i => i.purchaseStatus).filter(Boolean))
return Array.from(set).sort()
})
//
const displayList = computed(() => {
let list = [...fundList.value]
if (showOnlyFavorites.value) {
list = list.filter(i => favorites.value.has(i.fundCode))
}
if (searchCode.value) {
list = list.filter(i => String(i.fundCode).includes(searchCode.value.trim()))
}
if (searchName.value) {
list = list.filter(i => String(i.fundName).includes(searchName.value.trim()))
}
if (filterStatus.value) {
list = list.filter(i => i.purchaseStatus === filterStatus.value)
}
if (filterByScale.value) {
list = list.filter(i => parseFundSize(i.fundSize) >= 30000000)
}
return list
})
function resetFilter() {
searchCode.value = ''
searchName.value = ''
filterStatus.value = ''
showOnlyFavorites.value = false
filterByScale.value = true
}
//
async function fetchData() {
if (loading.value) return
loading.value = true
try {
const res = await axios.get(API_URL)
if (res.data.code === 200) {
fundList.value = res.data.data
lastUpdateTime.value = formatTime(new Date())
if (res.data.hasMore) {
try {
const rem = await axios.get(`${API_URL}/remaining`)
if (rem.data.code === 200 && rem.data.data.length > 0) {
fundList.value = [...fundList.value, ...rem.data.data]
}
} catch (e) {
//
}
}
} else {
ElMessage.error(res.data.msg || '数据获取失败')
}
} catch (err) {
ElMessage.error('请求失败:请确认 Python 后端已启动')
} finally {
loading.value = false
}
}
//
function manualRefresh() {
fetchData()
}
// el-table 使
function numericSort(a, b, prop) {
const va = parseFloat(a[prop]) || 0
const vb = parseFloat(b[prop]) || 0
return va - vb
}
//
function getRateClass(rate) {
const num = parseFloat(rate) || 0
if (num > 0) return 'rate-up'
if (num < 0) return 'rate-down'
return 'rate-zero'
}
async function loadFavorites() {
try {
const codes = await getAllFavoritesDB()
favorites.value = new Set(codes)
} catch (e) {
console.error('加载收藏失败', e)
}
}
async function toggleFavorite(fundCode, isChecked) {
try {
if (isChecked) {
await addFavoriteDB(fundCode)
favorites.value.add(fundCode)
} else {
await removeFavoriteDB(fundCode)
favorites.value.delete(fundCode)
}
} catch (e) {
console.error('收藏操作失败', e)
ElMessage.error('收藏操作失败')
}
}
onMounted(() => {
loadFavorites()
fetchData()
})
</script>
<style scoped>
.lof-app {
padding: 24px;
box-sizing: border-box;
width: 100%;
max-width: 1600px;
margin: 0 auto;
overflow-x: auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
gap: 12px;
}
.title {
display: flex;
flex-direction: column;
gap: 4px;
flex-shrink: 0;
}
.update-time {
font-size: 13px;
color: var(--text-muted);
text-align: left;
white-space: nowrap;
}
.tool-bar {
display: flex;
gap: 12px;
flex-shrink: 0;
}
.filter-bar {
display: flex;
gap: 10px;
margin-bottom: 12px;
flex-wrap: wrap;
align-items: center;
}
.filter-input {
width: 200px;
}
.filter-input-sm {
width: 160px;
}
.filter-select {
width: 140px;
}
.rate-up {
color: var(--rate-up);
font-weight: bold;
}
.rate-down {
color: var(--rate-down);
font-weight: bold;
}
.rate-zero {
color: var(--text-muted);
}
.fund-code-link {
color: var(--accent);
cursor: pointer;
text-decoration: none;
}
.fund-code-link:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
.lof-app {
padding: 12px;
}
.header {
flex-direction: column;
align-items: flex-start;
}
.tool-bar {
width: 100%;
}
.tool-bar .el-button {
width: 100%;
}
.filter-bar {
flex-direction: column;
align-items: stretch;
}
.filter-input,
.filter-input-sm,
.filter-select {
width: 100%;
}
.filter-checkbox {
display: flex;
justify-content: center;
}
.filter-radio {
display: flex;
width: 100%;
}
.filter-radio .el-radio-button {
flex: 1;
}
.filter-radio .el-radio-button__inner {
width: 100%;
justify-content: center;
}
.filter-reset-btn {
width: 100%;
}
.update-time {
white-space: normal;
}
}
</style>

View File

@ -1,8 +1,8 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import './style.css'
import router from './router'
import App from './App.vue'
createApp(App).use(ElementPlus).mount('#app')
createApp(App).use(router).use(ElementPlus).mount('#app')

View File

@ -0,0 +1,15 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
/* 重定向到realTimePremium应该是 路由显示/ 而不是 /realTimePremium */
{ path: '/', component: () => import('./components/RealTimePremium.vue') },
{ path: '/realTimePremium', component: () => import('./components/RealTimePremium.vue') },
{ path: '/history', component: () => import('./components/HistoryDetail.vue') },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router

View File

@ -1,27 +1,142 @@
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #0d1117;
--bg-secondary: #161b22;
--border: #30363d;
--code-bg: #1f2028;
--accent: #58a6ff;
--accent-bg: rgba(88, 166, 255, 0.15);
--accent-border: rgba(88, 166, 255, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow: rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
--bg-primary: #0f0f1a;
--bg-secondary: #1a1a2e;
--bg-elevated: #22223a;
--bg-hover: #2a2a44;
--border-color: #2e2e4a;
--text-primary: #e0e0e8;
--text-secondary: #9898b0;
--text-muted: #6a6a80;
--accent: #409eff;
--accent-hover: #66b1ff;
--accent-light: rgba(64, 158, 255, 0.15);
--danger: #f56c6c;
--success: #67c23a;
--warning: #e6a23c;
--rate-up: #f53f3f;
--rate-down: #00b853;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
--el-bg-color: var(--bg-primary);
--el-bg-color-page: var(--bg-primary);
--el-bg-color-overlay: var(--bg-elevated);
--el-border-color: var(--border-color);
--el-border-color-light: var(--border-color);
--el-border-color-lighter: var(--border-color);
--el-border-color-extra-light: var(--border-color);
--el-text-color-primary: var(--text-primary);
--el-text-color-regular: var(--text-secondary);
--el-text-color-secondary: var(--text-muted);
--el-fill-color: var(--bg-elevated);
--el-fill-color-light: var(--bg-elevated);
--el-fill-color-lighter: var(--bg-elevated);
--el-fill-color-blank: var(--bg-secondary);
--el-color-primary: var(--accent);
--el-color-primary-light-3: rgba(64, 158, 255, 0.7);
--el-color-primary-light-5: rgba(64, 158, 255, 0.5);
--el-color-primary-light-7: rgba(64, 158, 255, 0.3);
--el-color-primary-light-8: rgba(64, 158, 255, 0.2);
--el-color-primary-light-9: rgba(64, 158, 255, 0.1);
--el-color-primary-dark-2: #337ecc;
--el-color-success: var(--success);
--el-color-warning: var(--warning);
--el-color-danger: var(--danger);
--el-color-info: var(--text-muted);
--el-color-white: var(--text-primary);
--el-color-black: #000;
--el-mask-color: rgba(0, 0, 0, 0.6);
--el-mask-color-extra-light: rgba(0, 0, 0, 0.3);
font: 16px/145% var(--sans);
letter-spacing: 0.18px;
--el-table-border-color: var(--border-color);
--el-table-border: 1px solid var(--border-color);
--el-table-header-bg-color: var(--bg-elevated);
--el-table-header-text-color: var(--text-primary);
--el-table-row-bg-color: var(--bg-secondary);
--el-table-tr-bg-color: var(--bg-secondary);
--el-table-current-row-bg-color: var(--bg-hover);
--el-table-hover-tr-bg-color: var(--bg-hover);
--el-input-bg-color: var(--bg-elevated);
--el-input-border-color: var(--border-color);
--el-input-text-color: var(--text-primary);
--el-input-placeholder-color: var(--text-muted);
--el-input-hover-border-color: var(--accent);
--el-input-focus-border-color: var(--accent);
--el-input-clear-hover-color: var(--text-secondary);
--el-select-bg-color: var(--bg-elevated);
--el-select-input-focus-border-color: var(--accent);
--el-select-border-color-hover: var(--accent);
--el-select-dropdown-bg-color: var(--bg-elevated);
--el-checkbox-bg-color: var(--bg-elevated);
--el-checkbox-border-color: var(--border-color);
--el-checkbox-checked-bg-color: var(--accent);
--el-checkbox-checked-border-color: var(--accent);
--el-checkbox-text-color: var(--text-primary);
--el-checkbox-input-border-color-hover: var(--accent);
--el-radio-text-color: var(--text-primary);
--el-radio-input-bg-color: var(--bg-elevated);
--el-radio-input-border-color: var(--border-color);
--el-radio-input-border-color-hover: var(--accent);
--el-radio-checked-text-color: var(--accent);
--el-radio-button-bg-color: var(--bg-elevated);
--el-radio-button-text-color: var(--text-secondary);
--el-radio-button-checked-bg-color: var(--accent);
--el-radio-button-checked-text-color: #fff;
--el-radio-button-checked-border-color: var(--accent);
--el-switch-off-color: var(--bg-hover);
--el-switch-on-color: var(--accent);
--el-switch-border-color: var(--border-color);
--el-button-bg-color: var(--bg-elevated);
--el-button-border-color: var(--border-color);
--el-button-hover-bg-color: var(--bg-hover);
--el-button-hover-border-color: var(--accent);
--el-button-text-color: var(--text-primary);
--el-button-disabled-bg-color: var(--bg-secondary);
--el-button-disabled-border-color: var(--border-color);
--el-button-disabled-text-color: var(--text-muted);
--el-tag-bg-color: var(--bg-elevated);
--el-tag-border-color: var(--border-color);
--el-tag-text-color: var(--text-primary);
--el-pagination-bg-color: transparent;
--el-pagination-text-color: var(--text-secondary);
--el-pagination-button-bg-color: var(--bg-elevated);
--el-pagination-button-color: var(--text-secondary);
--el-pagination-button-disabled-bg-color: var(--bg-secondary);
--el-pagination-button-disabled-color: var(--text-muted);
--el-pagination-hover-color: var(--accent);
--el-dialog-bg-color: var(--bg-secondary);
--el-dialog-border-color: var(--border-color);
--el-dialog-title-text-color: var(--text-primary);
--el-dialog-content-text-color: var(--text-secondary);
--el-message-bg-color: var(--bg-elevated);
--el-message-border-color: var(--border-color);
--el-message-text-color: var(--text-primary);
--el-message-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
--el-card-bg-color: var(--bg-secondary);
--el-card-border-color: var(--border-color);
--el-collapse-header-bg-color: var(--bg-secondary);
--el-collapse-content-bg-color: var(--bg-primary);
--el-collapse-border-color: var(--border-color);
--el-collapse-header-text-color: var(--text-primary);
--el-collapse-content-text-color: var(--text-secondary);
--el-popper-bg-color: var(--bg-elevated);
--el-popper-border-color: var(--border-color);
font-family: system-ui, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
color-scheme: dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@ -32,257 +147,244 @@
box-sizing: border-box;
}
body {
min-height: 100vh;
background: var(--bg);
color: var(--text);
}
#app {
min-height: 100vh;
}
body {
margin: 0;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
a {
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#app {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
/* ---- Direct forced overrides (for elements that don't inherit CSS vars) ---- */
li {
flex: 1 1 calc(50% - 8px);
.el-input__wrapper {
background-color: var(--bg-elevated) !important;
box-shadow: 0 0 0 1px var(--border-color) inset !important;
}
.el-input__wrapper:hover {
box-shadow: 0 0 0 1px var(--accent) inset !important;
}
.el-input__wrapper.is-focus {
box-shadow: 0 0 0 1px var(--accent) inset !important;
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
.el-select .el-input__wrapper {
background-color: var(--bg-elevated) !important;
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
.el-select-dropdown {
background-color: var(--bg-elevated) !important;
border: 1px solid var(--border-color) !important;
}
.el-select-dropdown__item {
color: var(--text-secondary) !important;
}
.el-select-dropdown__item.hover {
background-color: var(--bg-hover) !important;
}
.el-select-dropdown__item.selected {
color: var(--accent) !important;
background-color: var(--accent-light) !important;
}
.el-select-dropdown__wrap {
background-color: var(--bg-elevated);
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
.el-popper {
background: var(--bg-elevated) !important;
border: 1px solid var(--border-color) !important;
}
.el-popper .el-popper__arrow::before {
background: var(--bg-elevated) !important;
border-color: var(--border-color) !important;
}
&::before {
left: 0;
border-left-color: var(--border);
.el-checkbox__inner {
background-color: var(--bg-elevated) !important;
border-color: var(--border-color) !important;
}
&::after {
right: 0;
border-right-color: var(--border);
.el-checkbox__input.is-checked .el-checkbox__inner {
background-color: var(--accent) !important;
border-color: var(--accent) !important;
}
.el-radio__inner {
background-color: var(--bg-elevated) !important;
border-color: var(--border-color) !important;
}
.el-radio-button__inner {
background-color: var(--bg-elevated) !important;
color: var(--text-secondary) !important;
border-color: var(--border-color) !important;
}
.el-radio-button__original-radio:checked + .el-radio-button__inner {
background-color: var(--accent) !important;
color: #fff !important;
border-color: var(--accent) !important;
}
.el-radio-button:not(:first-child) .el-radio-button__inner {
border-left-color: var(--border-color) !important;
}
.el-button--primary {
--el-button-bg-color: var(--accent) !important;
--el-button-border-color: var(--accent) !important;
--el-button-text-color: #fff !important;
--el-button-hover-bg-color: var(--accent-hover) !important;
--el-button-hover-border-color: var(--accent-hover) !important;
}
.el-table__body td.el-table__cell {
background-color: var(--bg-secondary);
}
.el-table th.el-table__cell {
background-color: var(--bg-elevated) !important;
}
.el-table tr {
background-color: var(--bg-secondary);
}
.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
background-color: #1e1e34;
}
.el-table__body tr:hover > td.el-table__cell {
background-color: var(--bg-hover) !important;
}
.el-table__inner-wrapper::before {
display: none;
}
.el-table__empty-text {
color: var(--text-muted) !important;
}
.el-tag--danger {
--el-tag-bg-color: #3d1f1f !important;
--el-tag-border-color: #5c3030 !important;
--el-tag-text-color: #f56c6c !important;
}
.el-tag--success {
--el-tag-bg-color: #1a3a1a !important;
--el-tag-border-color: #2a5a2a !important;
--el-tag-text-color: #67c23a !important;
}
.el-tag--info {
--el-tag-bg-color: var(--bg-elevated) !important;
--el-tag-border-color: var(--border-color) !important;
--el-tag-text-color: var(--text-secondary) !important;
}
.el-tag--warning {
--el-tag-bg-color: #3d2e15 !important;
--el-tag-border-color: #5c4420 !important;
--el-tag-text-color: #e6a23c !important;
}
.el-pagination button {
background-color: var(--bg-elevated) !important;
color: var(--text-secondary) !important;
}
.el-pager li {
background-color: var(--bg-elevated) !important;
color: var(--text-secondary) !important;
}
.el-pager li.active {
color: var(--accent) !important;
}
.el-pagination .el-input__wrapper {
background-color: var(--bg-elevated) !important;
}
.el-loading-mask {
background-color: rgba(15, 15, 26, 0.8) !important;
}
.el-message {
background-color: var(--bg-elevated) !important;
border-color: var(--border-color) !important;
}
.el-notification {
background-color: var(--bg-elevated) !important;
border-color: var(--border-color) !important;
color: var(--text-primary) !important;
}
.el-dialog__body {
background-color: var(--bg-secondary);
color: var(--text-secondary);
}
.el-dialog__header {
background-color: var(--bg-secondary);
}
.el-message-box__header {
background-color: var(--bg-secondary);
}
.el-message-box__message {
color: var(--text-secondary);
}
.el-tooltip__popper {
background: var(--bg-elevated) !important;
border: 1px solid var(--border-color) !important;
color: var(--text-primary) !important;
}
.el-tooltip__popper .popper__arrow::after {
border-top-color: var(--bg-elevated) !important;
}
.el-input-number__decrease,
.el-input-number__increase {
background-color: var(--bg-elevated) !important;
color: var(--text-secondary) !important;
border-color: var(--border-color) !important;
}
.el-input-number__decrease:hover,
.el-input-number__increase:hover {
background-color: var(--bg-hover) !important;
}
.el-scrollbar__bar {
background-color: transparent;
}
.el-scrollbar__thumb {
background-color: var(--bg-hover) !important;
}
.el-table-filter {
background-color: var(--bg-elevated) !important;
border-color: var(--border-color) !important;
}
.el-table-filter__list li {
color: var(--text-secondary) !important;
}
.el-table-filter__list li:hover {
background-color: var(--bg-hover) !important;
}
.el-dropdown-menu {
background-color: var(--bg-elevated) !important;
border: 1px solid var(--border-color) !important;
}
.el-dropdown-menu__item {
color: var(--text-secondary) !important;
}
.el-dropdown-menu__item:hover {
background-color: var(--bg-hover) !important;
color: var(--text-primary) !important;
}
.el-popconfirm {
background-color: var(--bg-elevated) !important;
color: var(--text-primary) !important;
}

View File

@ -1,15 +1,8 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
base: '/',
plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
},
},
})