0518
This commit is contained in:
parent
139d634fba
commit
3350e994e4
1
back/app/.cache/lof_cache.json
Normal file
1
back/app/.cache/lof_cache.json
Normal file
File diff suppressed because one or more lines are too long
0
back/app/__init__.py
Normal file
0
back/app/__init__.py
Normal file
122
back/app/cache.py
Normal file
122
back/app/cache.py
Normal 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
24
back/app/main.py
Normal 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)
|
||||
0
back/app/routers/__init__.py
Normal file
0
back/app/routers/__init__.py
Normal file
464
back/app/routers/lof.py
Normal file
464
back/app/routers/lof.py
Normal 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:
|
||||
# QDII:T日价格对比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}
|
||||
0
back/app/services/__init__.py
Normal file
0
back/app/services/__init__.py
Normal file
308
back/app/services/fetcher.py
Normal file
308
back/app/services/fetcher.py
Normal 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.loads(C 实现)解析
|
||||
# 比 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
|
||||
0
back/app/utils/__init__.py
Normal file
0
back/app/utils/__init__.py
Normal file
26
back/app/utils/formatters.py
Normal file
26
back/app/utils/formatters.py
Normal 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}"
|
||||
242
back/main.py
242
back/main.py
@ -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
5
back/run.py
Normal 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)
|
||||
1
front/vite-project/.gitignore
vendored
1
front/vite-project/.gitignore
vendored
@ -9,6 +9,7 @@ lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
.npm-cache
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" class="dark">
|
||||
<head>
|
||||
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
<title>LOF 基金监控</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
9
front/vite-project/jsconfig.json
Normal file
9
front/vite-project/jsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
323
front/vite-project/package-lock.json
generated
323
front/vite-project/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -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>
|
||||
183
front/vite-project/src/components/HistoryDetail.vue
Normal file
183
front/vite-project/src/components/HistoryDetail.vue
Normal 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">← 返回看板</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>
|
||||
404
front/vite-project/src/components/RealTimePremium.vue
Normal file
404
front/vite-project/src/components/RealTimePremium.vue
Normal 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>
|
||||
@ -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')
|
||||
|
||||
15
front/vite-project/src/router.js
Normal file
15
front/vite-project/src/router.js
Normal 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
|
||||
@ -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;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
/* ---- Direct forced overrides (for elements that don't inherit CSS vars) ---- */
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
.el-select .el-input__wrapper {
|
||||
background-color: var(--bg-elevated) !important;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.el-checkbox__inner {
|
||||
background-color: var(--bg-elevated) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user