Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faac60b1a6 | ||
|
|
dec3cd1236 |
@@ -9,7 +9,7 @@ from typing import Any
|
||||
|
||||
|
||||
APP_DIR = Path(__file__).resolve().parents[2]
|
||||
STATIC_DIR = APP_DIR / "static"
|
||||
STATIC_DIR = APP_DIR / "frontend"
|
||||
DATA_DIR = APP_DIR / "data"
|
||||
ENV_FILE = APP_DIR / ".env"
|
||||
MENTOR_SKILLS_DIR = APP_DIR / "游资skills"
|
||||
|
||||
@@ -1,406 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
import math
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from backend.features.sentiment.engine import apply_sentiment_to_dashboard
|
||||
|
||||
|
||||
DEMO_LIMITS = [
|
||||
("600664", "哈药股份", 4.94, 10.02, "医药", "创新药+医药流通", "09:25:00", "09:25:00", 0, 5, 11.78, 14.65, 26458),
|
||||
("603580", "艾艾精工", 40.84, 9.99, "机器人", "实控人变更+机器人", "09:25:01", "09:25:01", 0, 3, 0.11, 0.53, 27190),
|
||||
("600785", "新华百货", 9.32, 10.04, "零售", "新零售+股权转让", "10:32:33", "10:32:33", 2, 2, 9.57, 29.44, 4285),
|
||||
("002739", "万达电影", 10.32, 10.02, "文化传媒", "影视院线+AI视频", "09:30:33", "09:30:33", 0, 2, 3.95, 217.94, 25014),
|
||||
("000504", "南华生物", 9.36, 9.99, "医药", "细胞医疗+中报预增", "09:39:18", "09:39:18", 1, 2, 8.73, 30.89, 1962),
|
||||
("000676", "智度股份", 6.22, 10.09, "端侧AI", "AI营销+端侧AI", "09:46:45", "09:46:45", 0, 2, 6.61, 78.36, 10368),
|
||||
("600162", "香江控股", 2.78, 9.88, "房地产", "房地产+地产链", "09:30:57", "09:30:57", 1, 2, 10.56, 90.86, 4540),
|
||||
("002365", "永安药业", 13.18, 10.02, "医药", "医药+宠物经济", "09:33:24", "09:33:24", 0, 2, 10.52, 38.84, 8277),
|
||||
("000566", "海南海药", 5.67, 10.10, "脑机接口", "创新药+脑机接口", "11:01:12", "11:03:48", 2, 2, 22.75, 73.56, 8769),
|
||||
("002632", "道明光学", 9.63, 10.06, "端侧AI", "AI手机+反光材料", "09:25:00", "09:25:00", 0, 1, 2.63, 60.15, 13417),
|
||||
("000892", "欢瑞世纪", 3.87, 9.94, "文化传媒", "短剧+AI应用", "09:34:57", "09:34:57", 0, 1, 10.80, 37.96, 5635),
|
||||
("603496", "恒为科技", 25.08, 10.00, "云计算", "算力+华为", "09:58:12", "10:46:30", 1, 1, 7.65, 80.31, 16611),
|
||||
("603327", "福蓉科技", 8.57, 10.01, "端侧AI", "AI手机+消费电子", "09:30:02", "09:30:02", 0, 1, 7.02, 77.84, 7784),
|
||||
("300968", "格林精密", 10.24, 20.00, "端侧AI", "折叠屏+AI眼镜", "09:36:33", "09:36:33", 0, 1, 20.06, 48.23, 7850),
|
||||
("002045", "国光电器", 8.34, 10.03, "消费电子", "音响电声+AI眼镜", "09:37:45", "09:37:45", 0, 1, 7.11, 66.04, 4517),
|
||||
("600203", "福日电子", 11.92, 9.96, "消费电子", "华为产业链+机器人", "09:45:03", "09:45:03", 0, 1, 12.04, 105.50, 10554),
|
||||
("002881", "美格智能", 39.05, 10.00, "端侧AI", "物理AI+算力模组", "10:07:42", "10:07:42", 0, 1, 14.32, 128.20, 4299),
|
||||
]
|
||||
|
||||
|
||||
DEMO_BROKEN = [
|
||||
("002141", "贤丰控股", 5.91, 5.35, "PCB板", "PCB板+资产重组", "09:37:03", "14:56:24", 3, 18.95, 61.05),
|
||||
("002432", "九安医疗", 72.00, 7.48, "医药", "业绩增长+AI应用", "10:53:00", "14:09:45", 5, 14.13, 335.00),
|
||||
("002980", "华盛昌", 107.37, 5.12, "光通信", "光通信+仪器仪表", "09:59:18", "14:38:36", 1, 17.94, 108.75),
|
||||
("603725", "天安新材", 14.08, 7.40, "机器人", "机器人+新材料", "09:36:34", "14:37:19", 5, 13.58, 42.92),
|
||||
("603127", "昭衍新药", 53.25, 5.20, "医药", "创新药+CRO", "10:35:49", "10:46:55", 2, 19.56, 335.66),
|
||||
("002261", "拓维信息", 29.95, 6.47, "云计算", "算力+华为", "10:48:15", "10:53:54", 3, 12.04, 343.26),
|
||||
("603893", "瑞芯微", 222.24, 5.58, "国产芯片", "国产芯片+端侧AI", "09:55:26", "13:31:14", 1, 7.60, 939.80),
|
||||
("603103", "横店影视", 14.94, 5.21, "文化传媒", "影视院线+暑期档", "13:01:06", "13:01:51", 1, 2.79, 94.75),
|
||||
]
|
||||
|
||||
|
||||
DEMO_DOWN = [
|
||||
("603683", "晶华新材", 25.56, -10.00, "新材料", "高位股风险释放", 4.41, 173.67, 1),
|
||||
("603928", "兴业股份", 12.34, -9.99, "化工", "连续上涨后补跌", 11.96, 42.04, 4),
|
||||
("000988", "华工科技", 130.69, -10.00, "光通信", "高位成交放大", 6.28, 1313.42, 1),
|
||||
("603137", "恒尚节能", 32.05, -10.00, "建筑", "昨日涨停断板", 1.48, 58.63, 1),
|
||||
("603115", "海星股份", 81.06, -10.00, "有色金属", "板块退潮", 3.02, 196.08, 1),
|
||||
("605376", "博迁新材", 166.02, -10.00, "新材料", "资金兑现", 5.35, 434.31, 1),
|
||||
("003020", "立方制药", 19.72, -10.00, "医药", "医药分化", 22.88, 45.00, 1),
|
||||
("605255", "天普股份", 78.47, -10.00, "汽车零部件", "连板失败", 2.12, 105.21, 1),
|
||||
("002123", "梦网科技", 7.68, -9.96, "通信", "板块调整", 1.39, 61.86, 2),
|
||||
("603713", "密尔克卫", 64.80, -10.00, "物流", "业绩预期调整", 3.99, 103.43, 1),
|
||||
]
|
||||
|
||||
|
||||
def _stock_rows() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"code": code,
|
||||
"ts_code": code,
|
||||
"name": name,
|
||||
"price": price,
|
||||
"change": change,
|
||||
"sector": sector,
|
||||
"reason": reason,
|
||||
"first_time": first_time,
|
||||
"last_time": last_time,
|
||||
"open_times": open_times,
|
||||
"streak": streak,
|
||||
"turnover_rate": turnover,
|
||||
"amount_billion": amount,
|
||||
"seal_amount_million": seal,
|
||||
"float_mv_billion": round(amount * 3.2, 1),
|
||||
"status": "涨停",
|
||||
}
|
||||
for code, name, price, change, sector, reason, first_time, last_time,
|
||||
open_times, streak, turnover, amount, seal in DEMO_LIMITS
|
||||
]
|
||||
|
||||
|
||||
def _broken_rows() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"code": code,
|
||||
"ts_code": code,
|
||||
"name": name,
|
||||
"price": price,
|
||||
"change": change,
|
||||
"sector": sector,
|
||||
"reason": reason,
|
||||
"first_time": first_time,
|
||||
"last_time": last_time,
|
||||
"open_times": open_times,
|
||||
"streak": 1,
|
||||
"turnover_rate": turnover,
|
||||
"amount_billion": amount,
|
||||
"seal_amount_million": 0,
|
||||
"float_mv_billion": round(amount * 3.5, 1),
|
||||
"status": "炸板",
|
||||
}
|
||||
for code, name, price, change, sector, reason, first_time, last_time,
|
||||
open_times, turnover, amount in DEMO_BROKEN
|
||||
]
|
||||
|
||||
|
||||
def _down_rows() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"code": code,
|
||||
"ts_code": code,
|
||||
"name": name,
|
||||
"price": price,
|
||||
"change": change,
|
||||
"sector": sector,
|
||||
"reason": reason,
|
||||
"first_time": "--",
|
||||
"last_time": "--",
|
||||
"open_times": 0,
|
||||
"streak": streak,
|
||||
"turnover_rate": turnover,
|
||||
"amount_billion": amount,
|
||||
"seal_amount_million": 0,
|
||||
"float_mv_billion": round(amount * 4.1, 1),
|
||||
"status": "跌停",
|
||||
}
|
||||
for code, name, price, change, sector, reason, turnover, amount, streak in DEMO_DOWN
|
||||
]
|
||||
|
||||
|
||||
def _ladders(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
result = []
|
||||
for level in sorted({row["streak"] for row in rows}, reverse=True):
|
||||
stocks = [row for row in rows if row["streak"] == level]
|
||||
result.append(
|
||||
{
|
||||
"level": level,
|
||||
"label": "首板" if level == 1 else f"{level}板",
|
||||
"count": len(stocks),
|
||||
"stocks": stocks,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _sectors(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
counts = Counter(row["sector"] for row in rows)
|
||||
result = []
|
||||
for name, count in counts.most_common():
|
||||
stocks = [row for row in rows if row["sector"] == name]
|
||||
result.append(
|
||||
{
|
||||
"name": name,
|
||||
"count": count,
|
||||
"strength": min(99, 48 + count * 9 + max(row["streak"] for row in stocks) * 4),
|
||||
"amount_billion": round(sum(row["amount_billion"] for row in stocks), 1),
|
||||
"leader": max(stocks, key=lambda row: (row["streak"], row["amount_billion"]))["name"],
|
||||
"change": round(sum(row["change"] for row in stocks) / count, 2),
|
||||
"max_streak": max(row["streak"] for row in stocks),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _yesterday_rows(current: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
current_map = {row["code"]: row for row in current}
|
||||
definitions = [
|
||||
("600664", "哈药股份", 4, 10.02, "晋级"),
|
||||
("603580", "艾艾精工", 2, 9.99, "晋级"),
|
||||
("600785", "新华百货", 1, 10.04, "晋级"),
|
||||
("002739", "万达电影", 1, 10.02, "晋级"),
|
||||
("000504", "南华生物", 1, 9.99, "晋级"),
|
||||
("000676", "智度股份", 1, 10.09, "晋级"),
|
||||
("603127", "昭衍新药", 1, 5.20, "炸板"),
|
||||
("002432", "九安医疗", 2, 7.48, "炸板"),
|
||||
("001388", "信通电子", 3, -5.33, "断板"),
|
||||
("605255", "天普股份", 2, -10.00, "跌停"),
|
||||
("600403", "大有能源", 1, -6.75, "断板"),
|
||||
("002185", "华天科技", 1, -10.00, "跌停"),
|
||||
("600829", "人民同泰", 1, 2.30, "断板"),
|
||||
("600844", "金煤科技", 1, 1.18, "断板"),
|
||||
]
|
||||
rows = []
|
||||
for code, name, prior_streak, current_change, outcome in definitions:
|
||||
current_row = current_map.get(code, {})
|
||||
rows.append(
|
||||
{
|
||||
"code": code,
|
||||
"name": name,
|
||||
"prior_streak": prior_streak,
|
||||
"current_streak": current_row.get("streak", 0),
|
||||
"current_change": current_change,
|
||||
"current_price": current_row.get("price", 0),
|
||||
"sector": current_row.get("sector", "其他"),
|
||||
"reason": current_row.get("reason", "昨日涨停股表现跟踪"),
|
||||
"outcome": outcome,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _performance(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
result = []
|
||||
for level in sorted({row["prior_streak"] for row in rows}, reverse=True):
|
||||
group = [row for row in rows if row["prior_streak"] == level]
|
||||
advanced = sum(row["outcome"] == "晋级" for row in group)
|
||||
positive = sum(row["current_change"] > 0 for row in group)
|
||||
result.append(
|
||||
{
|
||||
"level": level,
|
||||
"label": "昨日首板" if level == 1 else f"昨日{level}板",
|
||||
"count": len(group),
|
||||
"advanced": advanced,
|
||||
"advance_rate": round(advanced / len(group) * 100, 1),
|
||||
"positive_rate": round(positive / len(group) * 100, 1),
|
||||
"average_change": round(sum(row["current_change"] for row in group) / len(group), 2),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _rotation(sectors: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
previous_counts = {
|
||||
"端侧AI": 7,
|
||||
"医药": 5,
|
||||
"文化传媒": 1,
|
||||
"消费电子": 1,
|
||||
"机器人": 3,
|
||||
"房地产": 2,
|
||||
"零售": 0,
|
||||
"云计算": 2,
|
||||
"脑机接口": 1,
|
||||
}
|
||||
result = []
|
||||
for index, sector in enumerate(sectors, start=1):
|
||||
previous = previous_counts.get(sector["name"], 0)
|
||||
delta = sector["count"] - previous
|
||||
result.append(
|
||||
{
|
||||
**sector,
|
||||
"rank": index,
|
||||
"previous_count": previous,
|
||||
"delta": delta,
|
||||
"trend": "升温" if delta > 0 else "降温" if delta < 0 else "持平",
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def build_demo_dashboard(trade_date: str, notice: str = "") -> dict[str, Any]:
|
||||
limits = _stock_rows()
|
||||
broken = _broken_rows()
|
||||
down_limits = _down_rows()
|
||||
ladders = _ladders(limits)
|
||||
sectors = _sectors(limits)
|
||||
yesterday = _yesterday_rows(limits)
|
||||
dashboard = {
|
||||
"meta": {
|
||||
"trade_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}",
|
||||
"previous_trade_date": "2026-07-16",
|
||||
"source": "demo",
|
||||
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"notice": notice or "当前展示演示数据,配置 Tushare Token 后可读取真实行情。",
|
||||
},
|
||||
"overview": {
|
||||
"up_count": 2344,
|
||||
"down_count": 2695,
|
||||
"flat_count": 33,
|
||||
"limit_up_count": 41,
|
||||
"limit_down_count": 3,
|
||||
"broken_count": 25,
|
||||
"amount_billion": 24035.6,
|
||||
"seal_rate": 62.1,
|
||||
},
|
||||
"limits": limits,
|
||||
"broken": broken,
|
||||
"down_limits": down_limits,
|
||||
"yesterday_limits": yesterday,
|
||||
"limit_performance": _performance(yesterday),
|
||||
"ladders": ladders,
|
||||
"sectors": sectors,
|
||||
"sector_rotation": _rotation(sectors),
|
||||
}
|
||||
return apply_sentiment_to_dashboard(dashboard)
|
||||
|
||||
|
||||
def build_demo_dragon_tiger(trade_date: str, notice: str = "") -> dict[str, Any]:
|
||||
stocks = _stock_rows()[:10]
|
||||
seat_names = [
|
||||
"机构专用",
|
||||
"沪股通专用",
|
||||
"深股通专用",
|
||||
"中信证券股份有限公司上海分公司",
|
||||
"国泰海通证券股份有限公司南京太平南路证券营业部",
|
||||
]
|
||||
rows = []
|
||||
for index, stock in enumerate(stocks):
|
||||
buy = round(86.5 - index * 6.3, 2)
|
||||
sell = round(22.8 + index * 3.1, 2)
|
||||
net = round(buy - sell, 2)
|
||||
institutions = [
|
||||
{
|
||||
"seat_name": seat_names[index % len(seat_names)],
|
||||
"buy_million": buy,
|
||||
"sell_million": sell,
|
||||
"net_buy_million": net,
|
||||
},
|
||||
{
|
||||
"seat_name": seat_names[(index + 2) % len(seat_names)],
|
||||
"buy_million": round(buy * 0.42, 2),
|
||||
"sell_million": round(sell * 0.65, 2),
|
||||
"net_buy_million": round(buy * 0.42 - sell * 0.65, 2),
|
||||
},
|
||||
]
|
||||
rows.append(
|
||||
{
|
||||
"code": stock["code"],
|
||||
"ts_code": stock["code"] + (".SH" if stock["code"].startswith("6") else ".SZ"),
|
||||
"name": stock["name"],
|
||||
"price": stock["price"],
|
||||
"change": stock["change"],
|
||||
"turnover_rate": stock["turnover_rate"],
|
||||
"amount_billion": stock["amount_billion"],
|
||||
"buy_million": buy,
|
||||
"sell_million": sell,
|
||||
"net_buy_million": net,
|
||||
"net_rate": round(net / max(buy + sell, 1) * 100, 2),
|
||||
"reason": "日涨幅偏离值达到7%" if index % 2 == 0 else "连续三个交易日涨幅偏离值累计达到20%",
|
||||
"institutions": institutions,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"meta": {
|
||||
"trade_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}",
|
||||
"source": "demo",
|
||||
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"notice": notice or "龙虎榜当前展示演示数据。",
|
||||
},
|
||||
"summary": {
|
||||
"stock_count": len(rows),
|
||||
"institution_count": sum(len(row["institutions"]) for row in rows),
|
||||
"net_buy_million": round(sum(row["net_buy_million"] for row in rows), 2),
|
||||
"positive_count": sum(row["net_buy_million"] > 0 for row in rows),
|
||||
},
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
def build_demo_stock_detail(
|
||||
code: str,
|
||||
trade_date: str,
|
||||
name: str = "示例股票",
|
||||
industry: str = "其他",
|
||||
notice: str = "",
|
||||
) -> dict[str, Any]:
|
||||
end = datetime.strptime(trade_date, "%Y%m%d")
|
||||
seed = sum(ord(character) for character in code)
|
||||
base = 8 + seed % 45
|
||||
prices = []
|
||||
close = float(base)
|
||||
for index in range(90):
|
||||
day = end - timedelta(days=(89 - index))
|
||||
drift = math.sin((index + seed) / 6) * 0.018 + 0.002
|
||||
open_price = close * (1 + math.sin(index * 1.7) * 0.006)
|
||||
close = max(1, close * (1 + drift))
|
||||
high = max(open_price, close) * (1.012 + (index % 3) * 0.002)
|
||||
low = min(open_price, close) * (0.988 - (index % 2) * 0.002)
|
||||
prices.append(
|
||||
{
|
||||
"trade_date": day.strftime("%Y-%m-%d"),
|
||||
"open": round(open_price, 2),
|
||||
"high": round(high, 2),
|
||||
"low": round(low, 2),
|
||||
"close": round(close, 2),
|
||||
"change": round((close / open_price - 1) * 100, 2),
|
||||
"volume": 180000 + (index % 11) * 26000 + seed * 10,
|
||||
"amount_billion": round(1.8 + (index % 9) * 0.36, 2),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"meta": {
|
||||
"trade_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}",
|
||||
"source": "demo",
|
||||
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"notice": notice or "个股详情当前展示演示数据。",
|
||||
},
|
||||
"stock": {
|
||||
"code": code,
|
||||
"ts_code": code + (".SH" if code.startswith("6") else ".SZ"),
|
||||
"name": name,
|
||||
"industry": industry,
|
||||
"area": "--",
|
||||
"market": "主板",
|
||||
"list_date": "--",
|
||||
"price": prices[-1]["close"],
|
||||
"change": prices[-1]["change"],
|
||||
},
|
||||
"prices": prices,
|
||||
"moneyflow": {
|
||||
"net_million": 18.62,
|
||||
"large_million": 31.48,
|
||||
"medium_million": -4.12,
|
||||
"small_million": -8.74,
|
||||
},
|
||||
}
|
||||
@@ -18,12 +18,12 @@
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/shared/tokens.css?v=20260729-1">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
<link rel="stylesheet" href="/renovation.css?v=20260725-5">
|
||||
<link rel="stylesheet" href="/redesign-v2.css?v=20260728-1">
|
||||
<link rel="stylesheet" href="/design-system.css?v=20260728-4">
|
||||
<link rel="stylesheet" href="/theme.css?v=20260728-2">
|
||||
<link rel="stylesheet" href="/wentian-v2.css?v=20260728-7">
|
||||
<link rel="stylesheet" href="/styles/styles.css">
|
||||
<link rel="stylesheet" href="/styles/renovation.css?v=20260725-5">
|
||||
<link rel="stylesheet" href="/styles/redesign-v2.css?v=20260728-1">
|
||||
<link rel="stylesheet" href="/styles/design-system.css?v=20260728-4">
|
||||
<link rel="stylesheet" href="/styles/theme.css?v=20260728-2">
|
||||
<link rel="stylesheet" href="/pages/heaven/page.css?v=20260728-7">
|
||||
</head>
|
||||
<body>
|
||||
<section id="authGate" class="auth-gate" aria-label="账号登录">
|
||||
@@ -1865,12 +1865,13 @@
|
||||
<div id="toast" class="toast" role="status" hidden></div>
|
||||
|
||||
<script src="/vendor/lucide.min.js" defer></script>
|
||||
<script src="/ui-core.js" defer></script>
|
||||
<script src="/shared/ui-core.js" defer></script>
|
||||
<script src="/shared/components.js?v=20260729-1" defer></script>
|
||||
<script src="/pages.config.js?v=20260729-1" defer></script>
|
||||
<script src="/pages/runtime.js?v=20260729-1" defer></script>
|
||||
<script src="/pages/sentiment/page.js?v=20260729-1" defer></script>
|
||||
<script src="/pages/pools/page.js?v=20260729-1" defer></script>
|
||||
<script src="/pages/market/runtime.js?v=20260731-1" defer></script>
|
||||
<script src="/pages/ladder/page.js?v=20260729-1" defer></script>
|
||||
<script src="/pages/rotation/page.js?v=20260729-1" defer></script>
|
||||
<script src="/pages/auction/page.js?v=20260729-1" defer></script>
|
||||
@@ -1884,7 +1885,8 @@
|
||||
<script src="/shared/state.js?v=20260729-1" defer></script>
|
||||
<script src="/shared/api.js?v=20260729-1" defer></script>
|
||||
<script src="/shared/shell.js?v=20260729-1" defer></script>
|
||||
<script src="/heaven-loading-v2.js?v=20260728-2" defer></script>
|
||||
<script src="/shared/export.js?v=20260731-1" defer></script>
|
||||
<script src="/pages/heaven/loading-v2.js?v=20260728-2" defer></script>
|
||||
<script src="/app.js?v=20260729-6" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,272 @@
|
||||
window.XiaobaiPageModules.register("auction", ["auctionView"], {
|
||||
enter: ["loadAuction"],
|
||||
leave: ["clearAuction"],
|
||||
});
|
||||
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:2217-2481 */
|
||||
async function loadAuctionCenter(force = false) {
|
||||
if (state.auctionLoading) return;
|
||||
state.auctionLoading = true;
|
||||
const button = document.querySelector("#auctionRefreshButton");
|
||||
button.disabled = true;
|
||||
setText("auctionDateLabel", "正在读取竞价数据");
|
||||
try {
|
||||
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
|
||||
if (force) query.set("force", "1");
|
||||
state.auctionData = await apiRequest(`/api/auction?${query}`);
|
||||
renderAuctionCenter();
|
||||
scheduleAuctionTransition(state.auctionData.meta || {});
|
||||
} catch (error) {
|
||||
document.querySelector("#auctionSummary").innerHTML = "";
|
||||
document.querySelector("#auctionThemeCarry").innerHTML = "";
|
||||
document.querySelector("#auctionNewThemes").innerHTML = "";
|
||||
document.querySelector("#auctionAmountTrend").innerHTML = "";
|
||||
document.querySelector("#auctionAmountCompare").innerHTML = "";
|
||||
document.querySelector("#auctionTableBody").innerHTML = "";
|
||||
document.querySelector("#auctionEmpty").hidden = false;
|
||||
setText("auctionDateLabel", error.message || "竞价数据暂不可用");
|
||||
showToast(error.message || "竞价数据加载失败");
|
||||
} finally {
|
||||
state.auctionLoading = false;
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAuctionCenter() {
|
||||
const payload = state.auctionData;
|
||||
if (!payload) return;
|
||||
const summary = payload.summary || {};
|
||||
renderAuctionPhase(payload.meta || {});
|
||||
setText(
|
||||
"auctionDateLabel",
|
||||
`${payload.meta?.carried_forward ? "最近有效竞价" : "竞价日期"} ${payload.meta?.trade_date || "--"}`,
|
||||
);
|
||||
document.querySelector("#auctionSummary").innerHTML = [
|
||||
["竞价覆盖", `${formatNumber(summary.stock_count, 0)} 只`, ""],
|
||||
["重点异动", `${formatNumber(summary.focus_count, 0)} 只`, "up"],
|
||||
["竞价一字", `${formatNumber(summary.one_price_count, 0)} 只`, ""],
|
||||
["竞价成交额", `${formatNumber(summary.amount_billion, 2)} 亿`, ""],
|
||||
].map(([label, value, tone]) => `<div><span>${label}</span><strong class="${tone}">${value}</strong></div>`).join("");
|
||||
setText("auctionFocusCount", number(summary.focus_count));
|
||||
setText("auctionAllCount", number(summary.candidate_count));
|
||||
setText("auctionOnePriceCount", number(summary.one_price_count));
|
||||
setText("auctionWatchlistCount", number(payload.watchlist_rows?.length));
|
||||
renderAuctionInsights(payload);
|
||||
renderAuctionTable();
|
||||
}
|
||||
|
||||
function renderAuctionInsights(payload) {
|
||||
const themes = payload.themes || {};
|
||||
const carry = themes.carry || [];
|
||||
const tone = { "强承接": "strong", "有承接": "steady", "分歧": "mixed", "承接弱": "weak" };
|
||||
setText("auctionThemeBaseline", `基于 ${payload.candidate_meta?.baseline_date || "--"}`);
|
||||
document.querySelector("#auctionThemeCarry").innerHTML = carry.length
|
||||
? carry.map((item) => `
|
||||
<div class="auction-theme-row">
|
||||
<strong class="auction-theme-name">${escapeHtml(item.name)}</strong>
|
||||
<span class="auction-theme-info">${escapeHtml(item.leader || "--")} · 昨日 ${number(item.prior_limit_count)} 只涨停</span>
|
||||
<span class="auction-theme-status ${tone[item.status] || "mixed"}">${escapeHtml(item.status)}</span>
|
||||
<span class="auction-theme-median ${item.median_change == null ? "" : changeClass(item.median_change)}">${item.median_change == null ? "暂无有效候选" : `${signed(item.median_change)}%`}<small>中位</small></span>
|
||||
</div>`).join("")
|
||||
: '<div class="auction-inline-empty">暂无昨日强势题材基线</div>';
|
||||
|
||||
const newThemes = themes.new_themes || [];
|
||||
document.querySelector("#auctionNewThemes").innerHTML = newThemes.length
|
||||
? newThemes.map((item) => `<span title="${escapeHtml((item.leaders || []).join("、"))}">${escapeHtml(item.name)} <strong>${number(item.stock_count)}</strong></span>`).join("")
|
||||
: '<small>尚未形成多股共振的新线索</small>';
|
||||
|
||||
const history = payload.amount_history || [];
|
||||
const maximum = Math.max(...history.map((item) => number(item.amount_billion)), 1);
|
||||
const priorFive = history.slice(Math.max(0, history.length - 6), Math.max(0, history.length - 1));
|
||||
const fiveDayAverage = priorFive.length
|
||||
? priorFive.reduce((sum, item) => sum + number(item.amount_billion), 0) / priorFive.length
|
||||
: null;
|
||||
document.querySelector("#auctionAmountTrend").innerHTML = history.length
|
||||
? history.map((item, index) => {
|
||||
const height = Math.max(8, number(item.amount_billion) / maximum * 100);
|
||||
const current = index === history.length - 1 ? " current" : "";
|
||||
return `<div class="auction-amount-day${current}" title="${escapeHtml(item.trade_date)} · ${formatNumber(item.amount_billion, 2)} 亿 · ${number(item.stock_count)} 只">
|
||||
<span style="height:${height.toFixed(1)}%"></span><small>${escapeHtml(String(item.trade_date || "").slice(5))}</small>
|
||||
</div>`;
|
||||
}).join("") + (fiveDayAverage === null ? "" : `<div class="auction-amount-average" style="bottom:${(20 + Math.min(fiveDayAverage / maximum, 1) * 82).toFixed(1)}px"><small>5日均 ${formatNumber(fiveDayAverage, 1)}</small></div>`)
|
||||
: '<div class="auction-inline-empty">历史竞价量能尚未形成</div>';
|
||||
setText("auctionAmountValue", `${formatNumber(payload.summary?.amount_billion, 2)} 亿`);
|
||||
const comparison = [
|
||||
["较昨日", payload.summary?.amount_change_previous],
|
||||
["较5日均值", payload.summary?.amount_change_5d],
|
||||
];
|
||||
document.querySelector("#auctionAmountCompare").innerHTML = comparison.map(([label, value]) => `
|
||||
<span>${label}<strong class="${value == null ? "" : changeClass(value)}">${value == null ? "--" : `${signed(value)}%`}</strong></span>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderAuctionPhase(meta) {
|
||||
const phase = meta.phase || "archive";
|
||||
const available = Boolean(meta.available);
|
||||
const copy = {
|
||||
pending: ["竞价尚未开始", "9:15 进入观察期,9:25 读取最终竞价结果。", "下一阶段 09:15"],
|
||||
observing: ["竞价观察期", "此阶段先观察盘前变化,系统将在 9:25 自动读取最终结果。", "09:25 定格"],
|
||||
selection: available
|
||||
? ["竞价筛选窗口", "最终竞价结果已经定格,请在 9:30 前完成筛选。", "有效至 09:30"]
|
||||
: ["等待最终竞价", "9:25 数据尚未到达,系统正在自动重试。", "即将更新"],
|
||||
finalized: ["今日竞价已定格", "9:30 后停止更新,仅保留用于复盘、回测与智能选股。", "已冻结"],
|
||||
archive: ["历史竞价归档", "当前展示所选交易日的最终竞价结果。", "归档数据"],
|
||||
}[phase] || ["竞价状态", "当前竞价状态待确认。", "--"];
|
||||
const notice = document.querySelector("#auctionPhaseNotice");
|
||||
notice.dataset.phase = phase;
|
||||
setText("auctionPhaseTitle", copy[0]);
|
||||
setText("auctionPhaseDetail", copy[1]);
|
||||
setText("auctionPhaseTime", copy[2]);
|
||||
const refresh = document.querySelector("#auctionRefreshButton");
|
||||
refresh.hidden = phase !== "selection";
|
||||
refresh.disabled = state.auctionLoading;
|
||||
}
|
||||
|
||||
function clearAuctionTimer() {
|
||||
if (state.auctionTimer) clearTimeout(state.auctionTimer);
|
||||
state.auctionTimer = null;
|
||||
}
|
||||
|
||||
function scheduleAuctionTransition(meta) {
|
||||
clearAuctionTimer();
|
||||
if (state.activeView !== "auctionView") return;
|
||||
let delay = 0;
|
||||
if (["selection", "finalized"].includes(meta.phase) && !meta.available) {
|
||||
delay = 10_000;
|
||||
} else if (meta.next_transition_at) {
|
||||
const transitionAt = new Date(meta.next_transition_at).getTime();
|
||||
if (Number.isFinite(transitionAt)) delay = Math.max(800, transitionAt - Date.now() + 500);
|
||||
}
|
||||
if (!delay) return;
|
||||
state.auctionTimer = setTimeout(() => {
|
||||
state.auctionTimer = null;
|
||||
if (state.activeView === "auctionView") loadAuctionCenter(true);
|
||||
}, Math.min(delay, 2_147_000_000));
|
||||
}
|
||||
|
||||
function renderAuctionTable() {
|
||||
const rows = currentAuctionRows();
|
||||
const columns = auctionColumns();
|
||||
const head = document.querySelector("#auctionTableHead");
|
||||
head.innerHTML = columns.map((column) => {
|
||||
const sorted = column.sortKey === state.auctionSortKey;
|
||||
const arrow = !column.sortKey ? "" : `<span class="arr">${sorted ? (state.auctionSortDirection === "desc" ? "▼" : "▲") : "↕"}</span>`;
|
||||
return `<th class="${column.numeric ? "number num " : ""}${column.sortKey ? "sortable " : ""}${sorted ? "sorted" : ""}"${column.sortKey ? ` data-auction-sort="${column.sortKey}"` : ""}>${column.label}${arrow}</th>`;
|
||||
}).join("");
|
||||
const body = document.querySelector("#auctionTableBody");
|
||||
body.innerHTML = rows.map((row) => `<tr data-code="${escapeHtml(row.code)}">${columns.map((column) => renderAuctionCell(row, column.key)).join("")}</tr>`).join("");
|
||||
bindStockRows(body);
|
||||
const datasetCopy = {
|
||||
focus: ["重点异动", "优先查看市场核心与显著预期差"],
|
||||
onePrice: ["竞价一字", "竞价封于当日真实涨停价,不参与普通异动评分"],
|
||||
watchlist: ["我的自选", "仅展示当前账号关注标的的竞价反馈"],
|
||||
all: ["全部候选", "昨日涨停、炸板与热榜前20候选"],
|
||||
}[state.auctionDataset] || ["竞价异动", ""];
|
||||
setText("auctionWorkspaceTitle", datasetCopy[0]);
|
||||
setText("auctionWorkspaceSubtitle", datasetCopy[1]);
|
||||
document.querySelector("#auctionExpectationControls").hidden = state.auctionDataset === "onePrice";
|
||||
const empty = document.querySelector("#auctionEmpty");
|
||||
const phase = state.auctionData?.meta?.phase || "archive";
|
||||
empty.textContent = phase === "selection" && !state.auctionData?.meta?.available
|
||||
? "正在等待 9:25 最终竞价数据"
|
||||
: state.auctionDataset === "watchlist"
|
||||
? "当前账号还没有可观察的自选股"
|
||||
: state.auctionDataset === "onePrice"
|
||||
? "当前没有竞价封于涨停价的股票"
|
||||
: "没有符合条件的竞价候选";
|
||||
empty.hidden = rows.length > 0;
|
||||
}
|
||||
|
||||
function currentAuctionRows() {
|
||||
const datasets = {
|
||||
focus: state.auctionData?.focus_rows || [],
|
||||
onePrice: state.auctionData?.one_price_rows || [],
|
||||
watchlist: state.auctionData?.watchlist_rows || [],
|
||||
all: state.auctionData?.rows || [],
|
||||
};
|
||||
let rows = [...(datasets[state.auctionDataset] || [])];
|
||||
const filter = state.auctionFilter;
|
||||
const labels = { above: "超预期", matched: "符合预期", below: "低于预期" };
|
||||
if (labels[filter]) rows = rows.filter((item) => item.expectation === labels[filter]);
|
||||
if (state.auctionQuery) {
|
||||
rows = rows.filter((item) => `${item.code} ${item.name} ${item.sector}`.toLocaleLowerCase("zh-CN").includes(state.auctionQuery));
|
||||
}
|
||||
const key = state.auctionSortKey;
|
||||
const direction = state.auctionSortDirection === "asc" ? 1 : -1;
|
||||
if (key) {
|
||||
rows.sort((left, right) => {
|
||||
const leftValue = left[key];
|
||||
const rightValue = right[key];
|
||||
if (leftValue == null && rightValue == null) return 0;
|
||||
if (leftValue == null) return 1;
|
||||
if (rightValue == null) return -1;
|
||||
const result = typeof leftValue === "number" || typeof rightValue === "number"
|
||||
? number(leftValue) - number(rightValue)
|
||||
: String(leftValue).localeCompare(String(rightValue), "zh-CN", { numeric: true });
|
||||
return result * direction;
|
||||
});
|
||||
}
|
||||
return rows.slice(0, 300);
|
||||
}
|
||||
|
||||
function auctionColumns() {
|
||||
const base = [
|
||||
{ key: "stock", label: "股票" },
|
||||
{ key: "context", label: "方向与来源" },
|
||||
{ key: "identity", label: "市场身份" },
|
||||
];
|
||||
const metrics = [
|
||||
{ key: "score", label: "关注分", numeric: true, sortKey: "attention_score" },
|
||||
{ key: "expectation", label: "预期判断" },
|
||||
{ key: "change", label: "竞价涨幅(%)", numeric: true, sortKey: "change" },
|
||||
{ key: "amount", label: "竞价额(百万)", numeric: true, sortKey: "amount_million" },
|
||||
{ key: "volume", label: "量比", numeric: true, sortKey: "volume_ratio" },
|
||||
];
|
||||
return state.auctionDataset === "onePrice" ? [...base, ...metrics.slice(2)] : [...base, ...metrics];
|
||||
}
|
||||
|
||||
function renderAuctionCell(row, key) {
|
||||
const unavailable = row.available === false;
|
||||
const onePrice = Boolean(row.is_one_price);
|
||||
const expectationTone = { "超预期": "above", "符合预期": "matched", "低于预期": "below" };
|
||||
if (key === "stock") return `<td><span class="auction-stock-cell-v2"><strong class="sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>`;
|
||||
if (key === "context") return `<td><span class="auction-context-cell-v2"><strong>${escapeHtml(row.sector || "其他")}</strong>${renderAuctionSources(row.source_label || (state.auctionDataset === "watchlist" ? "我的自选" : "全市场"))}</span></td>`;
|
||||
if (key === "identity") return `<td>${renderAuctionCoreTags(row.core_tags)}</td>`;
|
||||
if (unavailable) return key === "expectation"
|
||||
? '<td><span class="table-muted">暂无竞价</span></td>'
|
||||
: `<td class="${["score", "change", "amount", "volume"].includes(key) ? "number num" : ""}"></td>`;
|
||||
if (key === "score") return `<td class="number num auction-score">${onePrice ? "" : formatNumber(row.attention_score, 1)}</td>`;
|
||||
if (key === "expectation") {
|
||||
const tag = onePrice
|
||||
? '<span class="auction-one-price-tag">竞价一字</span>'
|
||||
: `<span class="auction-expectation ${expectationTone[row.expectation] || "matched"}">${escapeHtml(row.expectation || "符合预期")}</span>`;
|
||||
return `<td>${tag}</td>`;
|
||||
}
|
||||
if (key === "change") return `<td class="number num ${changeClass(row.change)}">${signed(row.change)}</td>`;
|
||||
if (key === "amount") return `<td class="number num">${formatNumber(row.amount_million, 2)}</td>`;
|
||||
if (key === "volume") return `<td class="number num auction-volume-ratio">${formatNumber(row.volume_ratio, 2)}</td>`;
|
||||
return "<td></td>";
|
||||
}
|
||||
|
||||
function renderAuctionSources(value) {
|
||||
const sources = String(value || "").split(/[·、/]/).map((item) => item.trim()).filter(Boolean).slice(0, 3);
|
||||
return `<small class="auction-source-tags-v2">${sources.map((source) => `<b>${escapeHtml(source)}</b>`).join("")}</small>`;
|
||||
}
|
||||
|
||||
function renderAuctionCoreTags(tags) {
|
||||
const values = Array.isArray(tags) ? tags : [];
|
||||
return values.length
|
||||
? `<span class="auction-core-tags">${values.slice(0, 2).map((tag) => `<b>${escapeHtml(tag)}</b>`).join("")}</span>`
|
||||
: '<span class="auction-identity-empty" aria-label="无市场身份"></span>';
|
||||
}
|
||||
|
||||
function exportAuctionRows() {
|
||||
const rows = currentAuctionRows();
|
||||
exportRows("集合竞价", rows, [
|
||||
["股票代码", "code"], ["股票名称", "name"], ["行业", "sector"], ["来源", "source_label"],
|
||||
["市场身份", "core_tags"], ["关注分", "attention_score"], ["预期判断", "expectation"],
|
||||
["竞价涨幅%", "change"], ["竞价额百万", "amount_million"], ["量比", "volume_ratio"],
|
||||
]);
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:2217-2481 */
|
||||
@@ -0,0 +1,375 @@
|
||||
window.XiaobaiPageModules.register("dragon_tiger", ["dragonView"], {
|
||||
enter: ["loadDragonTiger"],
|
||||
});
|
||||
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:2660-3028 */
|
||||
function selectDragonViewMode(mode) {
|
||||
state.dragonViewMode = mode === "profiles" ? "profiles" : "daily";
|
||||
document.querySelectorAll("[data-dragon-view-mode]").forEach((button) => {
|
||||
const active = button.dataset.dragonViewMode === state.dragonViewMode;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-pressed", String(active));
|
||||
});
|
||||
if (state.dragonViewMode === "profiles") {
|
||||
document.querySelector("#dragonDailyContent").hidden = true;
|
||||
document.querySelector("#dragonEmptyState").hidden = true;
|
||||
document.querySelector("#dragonProfilesContent").hidden = false;
|
||||
if (state.hotMoneyProfiles) renderHotMoneyProfiles();
|
||||
else loadHotMoneyProfiles();
|
||||
} else {
|
||||
document.querySelector("#dragonProfilesContent").hidden = true;
|
||||
if (state.dragonTiger) renderDragonTiger();
|
||||
else loadDragonTiger();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHotMoneyProfiles(force = false) {
|
||||
if (!force && state.hotMoneyProfiles) {
|
||||
renderHotMoneyProfiles();
|
||||
return;
|
||||
}
|
||||
setStatus("正在加载游资档案");
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
if (force) query.set("force", "1");
|
||||
const suffix = query.size ? `?${query}` : "";
|
||||
state.hotMoneyProfiles = await apiRequest(`/api/dragon-tiger/profiles${suffix}`);
|
||||
renderHotMoneyProfiles();
|
||||
const count = number(state.hotMoneyProfiles.summary?.profile_count);
|
||||
setStatus(`游资档案已加载 · 共 ${count} 位`);
|
||||
} catch (error) {
|
||||
showToast(error.message || "游资档案加载失败");
|
||||
setStatus("游资档案加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
function renderHotMoneyProfiles() {
|
||||
const payload = state.hotMoneyProfiles;
|
||||
if (!payload) return;
|
||||
const profiles = payload.profiles || [];
|
||||
const summary = payload.summary || {};
|
||||
const query = state.hotMoneyProfileQuery;
|
||||
const visible = profiles.filter((profile) => {
|
||||
if (!query) return true;
|
||||
return [profile.name, profile.description, ...(profile.organizations || [])]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase("zh-CN")
|
||||
.includes(query);
|
||||
});
|
||||
if (!visible.some((profile) => profile.id === state.selectedHotMoneyProfileId)) {
|
||||
state.selectedHotMoneyProfileId = visible[0]?.id || "";
|
||||
}
|
||||
const selected = visible.find((profile) => profile.id === state.selectedHotMoneyProfileId) || null;
|
||||
|
||||
setText("dragonDateLabel", `收录 ${number(summary.profile_count)} 位`);
|
||||
setText("hotMoneyProfileResultCount", query ? `${visible.length} / ${profiles.length} 位` : `${profiles.length} 位`);
|
||||
document.querySelector("#hotMoneyProfileSummary").innerHTML = [
|
||||
["收录游资", number(summary.profile_count)],
|
||||
["已有简介", number(summary.described_count)],
|
||||
["关联席位", number(summary.organization_count)],
|
||||
].map(([label, value]) => `<span><small>${label}</small><strong>${value}</strong></span>`).join("");
|
||||
|
||||
const list = document.querySelector("#hotMoneyProfileList");
|
||||
list.innerHTML = visible.length ? visible.map((profile, index) => `
|
||||
<button class="hot-money-profile-row-v2 ${profile.id === state.selectedHotMoneyProfileId ? "selected" : ""}"
|
||||
type="button" role="option" aria-selected="${profile.id === state.selectedHotMoneyProfileId}"
|
||||
data-hot-money-profile="${escapeHtml(profile.id)}">
|
||||
<span class="hot-money-profile-index-v2">${String(index + 1).padStart(2, "0")}</span>
|
||||
<span class="hot-money-profile-monogram-v2">${escapeHtml(profile.name.slice(0, 2))}</span>
|
||||
<span class="hot-money-profile-row-copy-v2">
|
||||
<strong>${escapeHtml(profile.name)}</strong>
|
||||
<small>${escapeHtml(profile.description || "暂未收录简介")}</small>
|
||||
</span>
|
||||
<span class="hot-money-profile-seat-count-v2">${number(profile.organization_count)} 席</span>
|
||||
</button>`).join("") : `
|
||||
<div class="hot-money-profile-list-empty-v2">
|
||||
<i data-lucide="search-x" aria-hidden="true"></i>
|
||||
<span>${profiles.length ? "没有符合条件的游资档案" : "游资名录暂不可用"}</span>
|
||||
</div>`;
|
||||
|
||||
const detail = document.querySelector("#hotMoneyProfileDetail");
|
||||
if (!selected) {
|
||||
detail.innerHTML = `
|
||||
<div class="hot-money-profile-empty-v2">
|
||||
<i data-lucide="contact" aria-hidden="true"></i>
|
||||
<strong>${profiles.length ? "选择一位游资查看档案" : "暂无可展示的游资档案"}</strong>
|
||||
</div>`;
|
||||
} else {
|
||||
const organizations = selected.organizations || [];
|
||||
detail.innerHTML = `
|
||||
<header class="hot-money-profile-detail-head-v2">
|
||||
<span class="hot-money-profile-avatar-v2">${escapeHtml(selected.name.slice(0, 2))}</span>
|
||||
<div>
|
||||
<small>游资档案</small>
|
||||
<h3>${escapeHtml(selected.name)}</h3>
|
||||
<span>${organizations.length ? `关联 ${organizations.length} 个公开席位` : "暂无关联席位"}</span>
|
||||
</div>
|
||||
</header>
|
||||
<section class="hot-money-profile-section-v2">
|
||||
<h4>人物简介</h4>
|
||||
<p class="${selected.description ? "" : "is-empty"}">${escapeHtml(selected.description || "名录暂未收录该游资的公开简介。")}</p>
|
||||
</section>
|
||||
<section class="hot-money-profile-section-v2 hot-money-profile-org-section-v2">
|
||||
<div class="hot-money-profile-section-title-v2">
|
||||
<h4>关联营业部</h4>
|
||||
<span>${organizations.length} 个</span>
|
||||
</div>
|
||||
<div class="hot-money-profile-organizations-v2">
|
||||
${organizations.length ? organizations.map((organization) => `
|
||||
<span><i data-lucide="building-2" aria-hidden="true"></i>${escapeHtml(organization)}</span>
|
||||
`).join("") : '<p class="is-empty">名录暂未收录关联营业部。</p>'}
|
||||
</div>
|
||||
</section>
|
||||
${payload.meta?.notice ? `<p class="hot-money-profile-notice-v2">${escapeHtml(payload.meta.notice)}</p>` : ""}`;
|
||||
}
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
async function loadDragonTiger(force = false) {
|
||||
const requestedDate = elements.tradeDate.value;
|
||||
if (
|
||||
!force
|
||||
&& ["success", "empty", "partial", "unavailable"].includes(state.dragonTiger?.meta?.status)
|
||||
&& (state.dragonTiger?.meta?.requested_date || state.dragonTiger?.meta?.trade_date) === requestedDate
|
||||
) {
|
||||
renderDragonTiger();
|
||||
return;
|
||||
}
|
||||
setStatus("正在加载龙虎榜");
|
||||
try {
|
||||
const query = new URLSearchParams({ trade_date: requestedDate });
|
||||
if (force) query.set("force", "1");
|
||||
const payload = await apiRequest(`/api/dragon-tiger?${query}`);
|
||||
state.dragonTiger = payload;
|
||||
renderDragonTiger();
|
||||
const statusLabel = payload.meta.status === "error"
|
||||
? "龙虎榜数据暂不可用"
|
||||
: payload.meta.status === "empty"
|
||||
? "当日暂无公开游资明细"
|
||||
: payload.meta.status === "partial"
|
||||
? "当日有龙虎榜,暂无命名游资明细"
|
||||
: payload.meta.status === "unavailable" ? "龙虎榜数据暂不可用" : "龙虎榜明细";
|
||||
setStatus(`${statusLabel} · 龙虎榜已加载`);
|
||||
} catch (error) {
|
||||
showToast(error.message || "龙虎榜加载失败");
|
||||
setStatus("龙虎榜加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
function renderDragonTiger() {
|
||||
const payload = state.dragonTiger;
|
||||
if (!payload) return;
|
||||
const summary = payload.summary || {};
|
||||
if (state.dragonViewMode === "daily") setText("dragonDateLabel", `数据日期 ${payload.meta.trade_date}`);
|
||||
const status = payload.meta?.status || "empty";
|
||||
const hasRecognizedTraders = (payload.traders || []).some((item) => item.identity_type === "trader" && item.recognized !== false);
|
||||
const showEmptyState = !hasRecognizedTraders
|
||||
&& !(payload.unclassified_seats || []).length
|
||||
&& ["empty", "error", "unavailable"].includes(status);
|
||||
const dailyVisible = state.dragonViewMode === "daily";
|
||||
document.querySelector("#dragonProfilesContent").hidden = dailyVisible;
|
||||
document.querySelector("#dragonEmptyState").hidden = !dailyVisible || !showEmptyState;
|
||||
document.querySelector("#dragonDailyContent").hidden = !dailyVisible || showEmptyState;
|
||||
if (showEmptyState) {
|
||||
const unavailable = ["error", "unavailable"].includes(status);
|
||||
setText("dragonEmptyTitle", unavailable ? "龙虎榜数据暂不可用" : `${payload.meta?.trade_date || "该交易日"} 暂无龙虎榜明细`);
|
||||
setText("dragonEmptyDescription", unavailable
|
||||
? "当前数据暂未完成更新,可稍后重新检查或查看前一交易日。"
|
||||
: "龙虎榜明细通常在交易日盘后陆续披露,可稍后刷新或查看前一交易日。");
|
||||
}
|
||||
document.querySelector("#dragonSummary").innerHTML = [
|
||||
["上榜游资", `${number(summary.trader_count)} 位`, ""],
|
||||
["操作明细", `${number(summary.operation_count)} 条`, ""],
|
||||
["席位净买入", formatMoneyMillion(summary.seat_net_buy_million), changeClass(summary.seat_net_buy_million)],
|
||||
["活跃股票", `${number(summary.active_stock_count)} 只`, ""],
|
||||
].map(([label, value, className]) => `<div class="dragon-metric"><span>${label}</span><strong class="${className}">${value}</strong></div>`).join("");
|
||||
|
||||
renderDragonTraderList();
|
||||
renderUnclassifiedSeats();
|
||||
}
|
||||
|
||||
function renderDragonTraderList() {
|
||||
const payload = state.dragonTiger;
|
||||
if (!payload) return;
|
||||
let traders = [...(payload.traders || [])].filter((item) => item.identity_type === "trader" && item.recognized !== false);
|
||||
if (state.dragonFilter === "buy") traders = traders.filter((item) => number(item.net_buy_million) > 0);
|
||||
if (state.dragonFilter === "sell") traders = traders.filter((item) => number(item.net_buy_million) < 0);
|
||||
if (state.dragonFilter === "unclassified") traders = [];
|
||||
if (state.dragonQuery) {
|
||||
traders = traders.filter((item) => {
|
||||
const searchable = [
|
||||
item.name,
|
||||
...(item.operations || []).flatMap((operation) => [operation.code, operation.name, operation.seat_name]),
|
||||
].join(" ").toLowerCase();
|
||||
return searchable.includes(state.dragonQuery);
|
||||
});
|
||||
}
|
||||
|
||||
const container = document.querySelector("#dragonTraderList");
|
||||
let emptyMessage = "没有符合当前条件的游资操作";
|
||||
if (!Array.isArray(payload.traders)) emptyMessage = "龙虎榜数据格式暂不可用,请稍后重试";
|
||||
else if (["error", "unavailable"].includes(payload.meta?.status)) emptyMessage = "龙虎榜数据暂不可用,请稍后重试";
|
||||
else if (payload.meta?.status === "empty") emptyMessage = "该交易日暂无游资每日明细";
|
||||
else if (payload.meta?.status === "partial") emptyMessage = `当日有 ${number(payload.summary?.official_stock_count)} 只股票上榜,但暂无可识别的游资明细`;
|
||||
if (!traders.some((item) => item.id === state.selectedDragonTraderId)) {
|
||||
state.selectedDragonTraderId = traders[0]?.id || "";
|
||||
}
|
||||
const cardMarkup = traders.map((trader, index) => {
|
||||
const description = trader.description || `${number(trader.stock_count)} 只股票,${number(trader.operation_count)} 笔操作`;
|
||||
return `
|
||||
<article class="dragon-trader-card dealing ${trader.id === state.selectedDragonTraderId ? "selected" : ""}" data-dragon-card="${escapeHtml(trader.id)}" aria-hidden="true" style="--deal-delay:${Math.min(index * 38, 650)}ms">
|
||||
<span class="dragon-card-rank">${String(index + 1).padStart(2, "0")}</span>
|
||||
<span class="dragon-card-monogram">${escapeHtml(trader.name.slice(0, 2))}</span>
|
||||
<span class="dragon-card-copy"><strong>${escapeHtml(trader.name)}</strong><q title="${escapeHtml(description)}">${escapeHtml(description)}</q></span>
|
||||
<span class="dragon-card-stats"><small>${number(trader.stock_count)} 股 · ${number(trader.operation_count)} 笔</small><b class="${changeClass(trader.net_buy_million)}">${formatMoneyMillion(trader.net_buy_million)}</b></span>
|
||||
</article>`;
|
||||
}).join("");
|
||||
const hitZoneMarkup = traders.map((trader) => `
|
||||
<button type="button" class="dragon-card-hit-zone" data-dragon-trader="${escapeHtml(trader.id)}" aria-label="查看 ${escapeHtml(trader.name)} 当日操作" aria-pressed="${trader.id === state.selectedDragonTraderId}"></button>
|
||||
`).join("");
|
||||
container.innerHTML = traders.length
|
||||
? `${cardMarkup}<div class="dragon-card-hit-layer">${hitZoneMarkup}</div>`
|
||||
: emptyStateHtml(state.dragonFilter === "unclassified" ? "待归类席位请在下方管理" : emptyMessage, { className: "dragon-empty" });
|
||||
container.querySelectorAll("[data-dragon-card]").forEach((card) => {
|
||||
card.addEventListener("animationend", () => card.classList.remove("dealing"), { once: true });
|
||||
});
|
||||
container.querySelectorAll("[data-dragon-trader]").forEach((hitZone) => {
|
||||
const setHovered = (hovered) => {
|
||||
container.querySelector(`[data-dragon-card="${CSS.escape(hitZone.dataset.dragonTrader)}"]`)?.classList.toggle("hovered", hovered);
|
||||
};
|
||||
hitZone.addEventListener("pointerenter", () => setHovered(true));
|
||||
hitZone.addEventListener("pointerleave", () => setHovered(false));
|
||||
hitZone.addEventListener("focus", () => setHovered(true));
|
||||
hitZone.addEventListener("blur", () => setHovered(false));
|
||||
hitZone.addEventListener("click", () => {
|
||||
state.selectedDragonTraderId = hitZone.dataset.dragonTrader;
|
||||
container.querySelectorAll("[data-dragon-card]").forEach((card) => {
|
||||
card.classList.toggle("selected", card.dataset.dragonCard === state.selectedDragonTraderId);
|
||||
});
|
||||
container.querySelectorAll("[data-dragon-trader]").forEach((item) => {
|
||||
item.setAttribute("aria-pressed", String(item.dataset.dragonTrader === state.selectedDragonTraderId));
|
||||
});
|
||||
renderDragonTraderDetail(traders.find((item) => item.id === state.selectedDragonTraderId));
|
||||
});
|
||||
});
|
||||
requestAnimationFrame(() => layoutDragonCards(container));
|
||||
renderDragonTraderDetail(traders.find((item) => item.id === state.selectedDragonTraderId));
|
||||
}
|
||||
|
||||
function layoutDragonCards(container = document.querySelector("#dragonTraderList")) {
|
||||
if (!container) return;
|
||||
const cards = [...container.querySelectorAll(".dragon-trader-card")];
|
||||
const hitZones = [...container.querySelectorAll(".dragon-card-hit-zone")];
|
||||
if (!cards.length) return;
|
||||
const compact = window.innerWidth <= 720;
|
||||
const cardWidth = compact ? 148 : 176;
|
||||
const available = Math.max(cardWidth, container.clientWidth - (compact ? 30 : 72));
|
||||
const spread = Math.min(available - cardWidth, compact ? 310 : 1050);
|
||||
const step = cards.length > 1 ? Math.min(cardWidth + 14, spread / (cards.length - 1)) : 0;
|
||||
const center = (cards.length - 1) / 2;
|
||||
container.style.setProperty("--dragon-card-width", `${cardWidth}px`);
|
||||
cards.forEach((card, index) => {
|
||||
const x = (index - center) * step;
|
||||
card.style.setProperty("--card-x", `${x.toFixed(2)}px`);
|
||||
card.style.setProperty("--card-rotation", "0deg");
|
||||
card.style.setProperty("--card-y", "0px");
|
||||
card.style.zIndex = String(index + 1);
|
||||
const hitZone = hitZones[index];
|
||||
if (hitZone) {
|
||||
const zoneWidth = index === cards.length - 1 ? cardWidth : Math.max(18, step);
|
||||
hitZone.style.left = `calc(50% + ${(x - cardWidth / 2).toFixed(2)}px)`;
|
||||
hitZone.style.width = `${zoneWidth.toFixed(2)}px`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderDragonTraderDetail(trader) {
|
||||
const container = document.querySelector("#dragonTraderDetail");
|
||||
if (!trader) {
|
||||
container.hidden = true;
|
||||
renderEmptyState(container, "选择一位游资查看操作明细", { className: "dragon-empty" });
|
||||
return;
|
||||
}
|
||||
container.hidden = false;
|
||||
container.innerHTML = `
|
||||
<header class="dragon-detail-header">
|
||||
<div><span>当日操作明细</span><h3>${escapeHtml(trader.name)}</h3><p>${escapeHtml(trader.description || "按当日公开龙虎榜席位汇总")}</p></div>
|
||||
<dl><div><dt>买入</dt><dd class="up">${formatMoneyMillion(trader.buy_million)}</dd></div><div><dt>卖出</dt><dd class="down">${formatMoneyMillion(trader.sell_million)}</dd></div><div><dt>净额</dt><dd class="${changeClass(trader.net_buy_million)}">${formatMoneyMillion(trader.net_buy_million)}</dd></div></dl>
|
||||
</header>
|
||||
<div class="trader-operations table-frame tbl-wrap">
|
||||
<table class="data-table tbl dragon-operation-table">
|
||||
<colgroup><col class="dragon-col-index"><col class="dragon-col-stock"><col class="dragon-col-direction"><col class="dragon-col-number"><col class="dragon-col-number"><col class="dragon-col-number"><col class="dragon-col-number"><col class="dragon-col-seat"><col class="dragon-col-reason"></colgroup>
|
||||
<thead><tr><th class="row-number num">序号</th><th>股票</th><th>方向</th><th class="number num">涨幅(%)</th><th class="number num">买入(百万)</th><th class="number num">卖出(百万)</th><th class="number num">净额(百万)</th><th>关联席位</th><th class="reason-column">标签 / 上榜原因</th></tr></thead>
|
||||
<tbody>${(trader.operations || []).map((operation, index) => `
|
||||
<tr data-code="${escapeHtml(operation.code)}">
|
||||
<td class="row-number num">${index + 1}</td>
|
||||
<td><strong class="sname">${escapeHtml(operation.name)}</strong><span class="scode">${escapeHtml(operation.code)}</span></td>
|
||||
<td><span class="direction-label ${changeClass(operation.net_buy_million)}">${escapeHtml(operation.direction)}</span></td>
|
||||
<td class="number num ${operation.change == null ? "" : changeClass(operation.change)}">${operation.change == null ? "" : signed(operation.change)}</td>
|
||||
<td class="number num">${operation.buy_million == null ? "" : formatNumber(operation.buy_million, 2)}</td>
|
||||
<td class="number num">${operation.sell_million == null ? "" : formatNumber(operation.sell_million, 2)}</td>
|
||||
<td class="number num ${operation.net_buy_million == null ? "" : changeClass(operation.net_buy_million)}">${operation.net_buy_million == null ? "" : signed(operation.net_buy_million)}</td>
|
||||
<td class="seat-cell" title="${escapeHtml(operation.seat_name)}">${escapeHtml(operation.seat_name)}</td>
|
||||
<td class="reason-column" title="${escapeHtml([operation.tag, operation.reason].filter((item) => item && item !== "--").join(" · "))}">${escapeHtml(operation.tag && operation.tag !== "--" ? operation.tag : operation.reason && operation.reason !== "--" ? operation.reason : "")}</td>
|
||||
</tr>`).join("")}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
bindStockRows(container);
|
||||
markAutoSortableHeaders(container);
|
||||
}
|
||||
|
||||
function renderUnclassifiedSeats() {
|
||||
const seats = state.dragonTiger?.unclassified_seats || [];
|
||||
const canManage = state.user?.role === "admin";
|
||||
document.querySelector("#dragonUnclassifiedSection").hidden = !canManage || seats.length === 0;
|
||||
document.querySelector("#dragonUnclassifiedFilter").hidden = !canManage || seats.length === 0;
|
||||
if (!seats.length && state.dragonFilter === "unclassified") {
|
||||
state.dragonFilter = "all";
|
||||
document.querySelectorAll("[data-dragon-filter]").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.dragonFilter === "all");
|
||||
});
|
||||
renderDragonTraderList();
|
||||
}
|
||||
setText("unclassifiedCount", `${seats.length} 个`);
|
||||
const list = document.querySelector("#unclassifiedSeatList");
|
||||
list.innerHTML = seats.map((seat, index) => `
|
||||
<form class="unclassified-seat-row" data-unclassified-index="${index}">
|
||||
<span class="unclassified-seat-name" title="${escapeHtml(seat.seat_name)}">${escapeHtml(seat.seat_name)}</span>
|
||||
<span class="unclassified-seat-stats">${number(seat.operation_count)} 笔 · ${number(seat.stock_count)} 股</span>
|
||||
<strong class="${changeClass(seat.net_buy_million)}">${formatMoneyMillion(seat.net_buy_million)}</strong>
|
||||
<input type="text" maxlength="50" placeholder="输入游资名" aria-label="${escapeHtml(seat.seat_name)}的游资名" required>
|
||||
<button class="button" type="submit">归类</button>
|
||||
</form>
|
||||
`).join("") || emptyStateHtml("当前席位均已归类");
|
||||
list.querySelectorAll(".unclassified-seat-row").forEach((form) => {
|
||||
form.addEventListener("submit", saveSeatAlias);
|
||||
});
|
||||
}
|
||||
|
||||
function dragonIdentityLabel(type) {
|
||||
return { trader: "游资", institution: "机构", channel: "通道", unclassified: "待归类" }[type] || "席位";
|
||||
}
|
||||
|
||||
async function saveSeatAlias(event) {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const seat = state.dragonTiger?.unclassified_seats?.[number(form.dataset.unclassifiedIndex)];
|
||||
const alias = form.querySelector("input").value.trim();
|
||||
if (!seat || !alias) {
|
||||
showToast("请输入游资名");
|
||||
return;
|
||||
}
|
||||
const button = form.querySelector("button");
|
||||
button.disabled = true;
|
||||
try {
|
||||
await apiRequest("/api/seat-aliases", "POST", { seat_name: seat.seat_name, alias });
|
||||
state.dragonTiger = null;
|
||||
await loadDragonTiger();
|
||||
showToast(`已将席位归类为 ${alias}`);
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:2660-3028 */
|
||||
@@ -0,0 +1,96 @@
|
||||
window.XiaobaiPageModules.register("ladder", ["ladderView"]);
|
||||
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:2125-2216 */
|
||||
function renderLadderMini(ladders) {
|
||||
const container = document.querySelector("#ladderMini");
|
||||
const highest = ladders.length ? Math.max(...ladders.map((item) => number(item.level))) : 0;
|
||||
setText("maxHeight", highest ? `最高 ${highest} 板` : "暂无");
|
||||
container.innerHTML = ladders.slice(0, 5).map((group) => {
|
||||
const allNames = group.stocks.map((stock) => stock.name).filter(Boolean);
|
||||
const visibleNames = allNames.slice(0, 3).join("、");
|
||||
const suffix = allNames.length > 3 ? ` <em>等 ${number(group.count)} 只</em>` : "";
|
||||
return `<div class="pool-side-group">
|
||||
<div><strong>${escapeHtml(group.label)}</strong><small>${number(group.count)} 只</small></div>
|
||||
<p title="${escapeHtml(allNames.join("、"))}">${escapeHtml(visibleNames || "--")}${suffix}</p>
|
||||
</div>`;
|
||||
}).join("") || emptyStateHtml("暂无梯队数据");
|
||||
}
|
||||
|
||||
function renderSectorMini(sectors) {
|
||||
document.querySelector("#sectorMini").innerHTML = sectors.slice(0, 7).map((sector) => `
|
||||
<div class="pool-hot-row"><strong title="${escapeHtml(sector.name)}">${escapeHtml(sector.name)}</strong><span>${number(sector.count)}</span></div>
|
||||
`).join("") || emptyStateHtml("暂无板块数据");
|
||||
}
|
||||
|
||||
function renderLadderBoard(ladders) {
|
||||
const container = document.querySelector("#ladderBoard");
|
||||
const insights = document.querySelector("#ladderInsights");
|
||||
const ordered = [...ladders].sort((left, right) => number(right.level) - number(left.level));
|
||||
const maxLevel = ordered.length ? Math.max(...ordered.map((group) => number(group.level))) : 0;
|
||||
const topVisibleLevel = Math.max(5, maxLevel);
|
||||
const groupMap = new Map(ordered.map((group) => [number(group.level), group]));
|
||||
const displayGroups = Array.from({ length: topVisibleLevel }, (_, index) => {
|
||||
const level = topVisibleLevel - index;
|
||||
return groupMap.get(level) || { level, label: level === 1 ? "首板" : level === 5 && maxLevel < 5 ? "5板+" : `${level}板`, count: 0, stocks: [] };
|
||||
});
|
||||
const total = ordered.reduce((sum, group) => sum + number(group.count), 0);
|
||||
const spaceStocks = ordered.find((group) => number(group.level) === maxLevel)?.stocks || [];
|
||||
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
|
||||
const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
|
||||
setText("ladderDateRange", `数据日期 ${currentDate}`);
|
||||
container.innerHTML = displayGroups.map((group) => {
|
||||
const level = number(group.level);
|
||||
const limit = level === 1 || level === 2 ? 8 : 99;
|
||||
const expanded = state.expandedLadderLevels.has(level);
|
||||
const groupStocks = [...(group.stocks || [])].sort((left, right) => {
|
||||
if (state.ladderSortMode === "open") {
|
||||
return number(left.open_times) - number(right.open_times)
|
||||
|| String(left.first_time || "99:99:99").localeCompare(String(right.first_time || "99:99:99"));
|
||||
}
|
||||
return String(left.first_time || "99:99:99").localeCompare(String(right.first_time || "99:99:99"));
|
||||
});
|
||||
const stocks = expanded ? groupStocks : groupStocks.slice(0, limit);
|
||||
const remaining = Math.max(0, groupStocks.length - stocks.length);
|
||||
const label = group.label || (level === 1 ? "首板" : level === 5 && maxLevel < 5 ? "5板+" : `${level}板`);
|
||||
const color = { 1: "#2563eb", 2: "#16a34a", 3: "#d97706", 4: "#e04536" }[level] || "#9ca3af";
|
||||
return `
|
||||
<section class="market-ladder-tier ${number(group.count) ? "" : "is-gap"}" data-ladder-level-card="${level}">
|
||||
<div class="market-ladder-label" style="--tier-color:${color}"><div class="market-ladder-level"><span class="market-ladder-dot"></span>${escapeHtml(label)}</div><div class="market-ladder-count">${number(group.count)} 只</div>${number(group.count) && level > 1 ? `<div class="market-ladder-rate">${escapeHtml(label)} · <b>${formatNumber(number(group.count) / Math.max(number(groupMap.get(level - 1)?.count), 1) * 100, 1)}%</b></div>` : ""}</div>
|
||||
<div class="market-ladder-stocks">${stocks.length ? stocks.map((stock) => {
|
||||
const onePrice = String(stock.first_time || "").startsWith("09:25") && number(stock.open_times) === 0;
|
||||
const broken = number(stock.open_times) >= 6;
|
||||
const amount = number(stock.seal_amount_million) ? `封单 ${formatNumber(stock.seal_amount_million, 0)} 万` : `成交 ${formatNumber(stock.amount_billion, 1)} 亿`;
|
||||
return `<button type="button" class="market-ladder-stock" data-code="${escapeHtml(stock.code)}" aria-label="查看 ${escapeHtml(stock.name)} ${escapeHtml(stock.code)}详情">
|
||||
<span class="market-ladder-stock-first"><strong>${escapeHtml(stock.name)}</strong><small class="stock-code">${escapeHtml(stock.code)}</small><span class="market-ladder-tags">${onePrice ? '<em class="market-ladder-tag one-price">一字</em>' : ""}${broken ? `<em class="market-ladder-tag broken">烂板×${number(stock.open_times)}</em>` : ""}</span></span>
|
||||
<span class="market-ladder-stock-second"><b>${escapeHtml(stock.sector || stock.reason || "其他")}</b><small>${stock.first_time && stock.first_time !== "--" ? escapeHtml(stock.first_time) : "时间待校正"}</small><small>${amount}</small></span>
|
||||
</button>`;
|
||||
}).join("") : `<div class="market-ladder-gap-note">${level >= maxLevel ? `断层 · ${escapeHtml(label)}及以上空缺` : "该层暂时空缺"}</div>`}${groupStocks.length > limit ? `<button class="market-ladder-more" type="button" data-ladder-level="${level}">${expanded ? "收起" : `展开剩余 ${remaining} 只`}<i data-lucide="chevron-${expanded ? "up" : "down"}"></i></button>` : ""}</div>
|
||||
</section>`;
|
||||
}).join("");
|
||||
const structureRows = displayGroups.filter((group) => number(group.count) || number(group.level) <= maxLevel + 1);
|
||||
const maxCount = Math.max(1, ...structureRows.map((group) => number(group.count)));
|
||||
const rateRows = (state.dashboard?.limit_performance || []).map((row) => ({
|
||||
label: `${row.label || (number(row.level) === 1 ? "昨日首板" : `昨日${number(row.level)}板`)} → 今日`,
|
||||
value: clamp(number(row.advance_rate), 0, 100),
|
||||
}));
|
||||
const previousMax = Math.max(0, ...(state.dashboard?.yesterday_limits || []).map((row) => number(row.prior_streak)));
|
||||
const spaceChange = previousMax && maxLevel < previousMax ? `较昨日 ${previousMax} 板 ↓ 空间压缩` : previousMax && maxLevel > previousMax ? `较昨日 ${previousMax} 板 ↑ 高度抬升` : "高度与昨日接近";
|
||||
const spaceNote = maxLevel >= 5 ? "高位梯队仍有辨识度,重点观察承接而非单看高度。" : maxLevel >= 3 ? "空间位于中段,梯队延续性比绝对高度更重要。" : "高度受到压缩,先观察首板向二板的结构修复。";
|
||||
const strongestGroup = structureRows.reduce((best, group) => number(group.count) > number(best?.count) ? group : best, structureRows[0]);
|
||||
insights.innerHTML = `
|
||||
<section class="market-ladder-insight-card market-ladder-apex-card"><header><h3>空间板</h3><span>市场高度</span></header><div class="market-ladder-apex"><div><strong>${maxLevel ? `${maxLevel} 板` : "--"}</strong><em>${escapeHtml(spaceChange)}</em></div><p>${spaceStocks.length ? spaceStocks.map((stock) => `<b>${escapeHtml(stock.name)}</b>(${escapeHtml(stock.sector || "其他")})`).join(" · ") : "暂无空间板"}</p></div><p>${spaceNote}</p></section>
|
||||
<section class="market-ladder-insight-card"><header><h3>梯队结构</h3><span>完整度</span></header><div class="market-ladder-pyramid">${structureRows.map((group) => `<div class="market-ladder-pyramid-row ${number(group.count) ? "" : "is-gap"}"><span>${escapeHtml(group.label || `${number(group.level)}板`)}</span><i><b style="width:${Math.max(number(group.count) ? 8 : 100, number(group.count) / maxCount * 100)}%"></b></i><strong>${number(group.count) ? `${number(group.count)} 只` : "断层"}</strong></div>`).join("")}</div><p>断层越少,梯队从低位向高位传导越连贯。当前腰部为 <b>${escapeHtml(strongestGroup?.label || "--")}</b>。</p></section>
|
||||
<section class="market-ladder-insight-card"><header><h3>晋级率参考</h3><span>昨日梯队 → 今日</span></header><div class="market-ladder-rate-list">${rateRows.length ? rateRows.map((row) => `<div><span>${escapeHtml(row.label)}</span><i><b class="${row.value === 0 ? "is-zero" : row.value < 20 ? "is-low" : ""}" style="width:${Math.max(row.value, row.value > 0 ? 2 : 0)}%"></b></i><strong class="${row.value === 0 ? "is-zero" : row.value < 20 ? "is-low" : ""}">${formatNumber(row.value, 1)}%</strong></div>`).join("") : '<div class="empty-state">暂无可比梯队</div>'}</div><small class="market-ladder-source">数据来自“涨停表现”页 · 昨日梯队样本</small></section>`;
|
||||
container.querySelectorAll("[data-ladder-level]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const level = number(button.dataset.ladderLevel);
|
||||
if (state.expandedLadderLevels.has(level)) state.expandedLadderLevels.delete(level);
|
||||
else state.expandedLadderLevels.add(level);
|
||||
renderLadderBoard(state.dashboard?.ladders || []);
|
||||
});
|
||||
});
|
||||
bindStockRows(container);
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:2125-2216 */
|
||||
@@ -0,0 +1,497 @@
|
||||
window.XiaobaiPageModules.register("mentor", ["mentorView"], {
|
||||
enter: ["loadMentor"],
|
||||
});
|
||||
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:4337-4827 */
|
||||
async function loadMentorSetup(force = false) {
|
||||
const requestedDate = elements.tradeDate.value.replaceAll("-", "");
|
||||
if (!force && state.mentorSetup?.requestedDate === requestedDate) {
|
||||
renderMentorWorkspace();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
|
||||
const payload = await apiRequest(`/api/mentors/setup?${query}`);
|
||||
payload.requestedDate = requestedDate;
|
||||
if (!payload.preferences_configured) {
|
||||
payload.mentors.sort((first, second) => {
|
||||
if (Boolean(first.private) !== Boolean(second.private)) return first.private ? -1 : 1;
|
||||
return String(first.name || "").localeCompare(String(second.name || ""), "zh-CN");
|
||||
});
|
||||
payload.mentors.forEach((mentor, index) => { mentor.sort_order = index; });
|
||||
}
|
||||
state.mentorSetup = payload;
|
||||
const selectedExists = payload.mentors.some((item) => item.id === state.selectedMentorId);
|
||||
state.selectedMentorId = selectedExists ? state.selectedMentorId : payload.mentors[0]?.id || "";
|
||||
state.mentorMessages = await loadMentorMessages();
|
||||
renderMentorWorkspace();
|
||||
} catch (error) {
|
||||
showMentorNotice(error.message || "问师模块加载失败");
|
||||
showToast(error.message || "问师模块加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
function renderMentorWorkspace() {
|
||||
const setup = state.mentorSetup;
|
||||
if (!setup) return;
|
||||
const selected = setup.mentors.find((item) => item.id === state.selectedMentorId) || null;
|
||||
setText("mentorDataDate", `数据日期 ${displayCompactDate(setup.trade_date)}`);
|
||||
setText("activeMentorName", selected?.name || "--");
|
||||
setText("mobileActiveMentorName", selected?.name || "选择思维模型");
|
||||
document.querySelector("#activeMentorBadges").innerHTML = selected ? renderMentorBadges(selected, true) : "";
|
||||
setText("activeMentorEvidence", selected?.evidence?.note || selected?.description || "--");
|
||||
document.querySelector("#activeMentorFocus").innerHTML = (selected?.focus || []).slice(0, 4)
|
||||
.map((item) => `<span>${escapeHtml(item)}</span>`).join("");
|
||||
renderMentorDirectory();
|
||||
renderMentorMessages();
|
||||
}
|
||||
|
||||
function renderMentorDirectory() {
|
||||
const mentors = state.mentorSetup?.mentors || [];
|
||||
const query = state.mentorQuery;
|
||||
const filtered = mentors.filter((mentor) => {
|
||||
if (state.mentorSortMode) return true;
|
||||
if (state.mentorGrade !== "all" && mentor.evidence?.grade !== state.mentorGrade) return false;
|
||||
if (!query) return true;
|
||||
const haystack = [
|
||||
mentor.name,
|
||||
mentor.description,
|
||||
mentor.tagline,
|
||||
mentor.evidence?.label,
|
||||
mentor.evidence?.note,
|
||||
...(mentor.focus || []),
|
||||
].filter(Boolean).join(" ").toLocaleLowerCase("zh-CN");
|
||||
return haystack.includes(query);
|
||||
});
|
||||
setText("mentorCount", filtered.length === mentors.length ? `${mentors.length} 位` : `${filtered.length} / ${mentors.length} 位`);
|
||||
const sortToggle = document.querySelector("#mentorSortToggle");
|
||||
sortToggle.classList.toggle("active", state.mentorSortMode);
|
||||
sortToggle.setAttribute("aria-pressed", String(state.mentorSortMode));
|
||||
sortToggle.querySelector("span").textContent = state.mentorSortMode ? "完成" : "整理";
|
||||
document.querySelector("#mentorSortHint").hidden = !state.mentorSortMode;
|
||||
document.querySelector("#mentorSearchInput").disabled = state.mentorSortMode;
|
||||
document.querySelectorAll("[data-mentor-grade]").forEach((button) => {
|
||||
button.disabled = state.mentorSortMode;
|
||||
});
|
||||
const container = document.querySelector("#mentorList");
|
||||
container.classList.toggle("is-sorting", state.mentorSortMode);
|
||||
container.innerHTML = filtered.map((mentor) => {
|
||||
const group = mentors.filter((item) => Boolean(item.pinned) === Boolean(mentor.pinned));
|
||||
const groupIndex = group.findIndex((item) => item.id === mentor.id);
|
||||
return `
|
||||
<article class="mentor-option ${mentor.id === state.selectedMentorId ? "active" : ""} ${mentor.pinned ? "is-pinned" : ""}"
|
||||
data-mentor-card="${escapeHtml(mentor.id)}" draggable="${state.mentorSortMode && !state.mentorSavingPreferences}">
|
||||
<button type="button" class="mentor-option-main" data-mentor-id="${escapeHtml(mentor.id)}" aria-pressed="${mentor.id === state.selectedMentorId}" ${state.mentorLoading ? "disabled" : ""}>
|
||||
<span class="mentor-option-copy">
|
||||
<span class="mentor-option-heading">
|
||||
<strong>${escapeHtml(mentor.name)}</strong>
|
||||
<span class="mentor-option-badges">${renderMentorBadges(mentor)}</span>
|
||||
</span>
|
||||
<em title="${escapeHtml(mentor.description || "")}">${escapeHtml(mentor.description || mentor.tagline || "思维模型")}</em>
|
||||
<span class="mentor-option-meta">
|
||||
${mentor.evidence?.label ? `<span class="mentor-evidence-source" title="${escapeHtml(mentor.evidence?.note || "素材说明")}">${escapeHtml(mentor.evidence.label)}</span>` : ""}
|
||||
${(mentor.focus || []).slice(0, 2).map((item) => `<span>#${escapeHtml(item)}</span>`).join("")}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<span class="mentor-option-tools">
|
||||
<button type="button" class="mentor-pin-button ${mentor.pinned ? "active" : ""}" data-mentor-pin="${escapeHtml(mentor.id)}"
|
||||
aria-label="${mentor.pinned ? "取消置顶" : "置顶"}${escapeHtml(mentor.name)}" title="${mentor.pinned ? "取消置顶" : "置顶"}" ${state.mentorSavingPreferences ? "disabled" : ""}>
|
||||
<i data-lucide="pin"></i>
|
||||
</button>
|
||||
${state.mentorSortMode ? `
|
||||
<button type="button" class="mentor-order-button" data-mentor-move="up" data-mentor-target="${escapeHtml(mentor.id)}" aria-label="上移${escapeHtml(mentor.name)}" title="上移" ${groupIndex <= 0 || state.mentorSavingPreferences ? "disabled" : ""}><i data-lucide="chevron-up"></i></button>
|
||||
<button type="button" class="mentor-order-button" data-mentor-move="down" data-mentor-target="${escapeHtml(mentor.id)}" aria-label="下移${escapeHtml(mentor.name)}" title="下移" ${groupIndex >= group.length - 1 || state.mentorSavingPreferences ? "disabled" : ""}><i data-lucide="chevron-down"></i></button>
|
||||
` : ""}
|
||||
</span>
|
||||
</article>
|
||||
`;
|
||||
}).join("");
|
||||
document.querySelector("#mentorListEmpty").hidden = filtered.length > 0;
|
||||
document.querySelectorAll("[data-mentor-id]").forEach((button) => {
|
||||
button.addEventListener("click", () => selectMentor(button.dataset.mentorId));
|
||||
});
|
||||
document.querySelectorAll("[data-mentor-pin]").forEach((button) => {
|
||||
button.addEventListener("click", () => toggleMentorPin(button.dataset.mentorPin));
|
||||
});
|
||||
document.querySelectorAll("[data-mentor-move]").forEach((button) => {
|
||||
button.addEventListener("click", () => moveMentor(button.dataset.mentorTarget, button.dataset.mentorMove));
|
||||
});
|
||||
document.querySelectorAll("[data-mentor-card]").forEach((card) => {
|
||||
card.addEventListener("dragstart", handleMentorDragStart);
|
||||
card.addEventListener("dragover", handleMentorDragOver);
|
||||
card.addEventListener("drop", handleMentorDrop);
|
||||
card.addEventListener("dragend", clearMentorDragState);
|
||||
});
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
function toggleMentorSortMode() {
|
||||
state.mentorSortMode = !state.mentorSortMode;
|
||||
if (state.mentorSortMode) {
|
||||
state.mentorQuery = "";
|
||||
state.mentorGrade = "all";
|
||||
document.querySelector("#mentorSearchInput").value = "";
|
||||
document.querySelectorAll("[data-mentor-grade]").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.mentorGrade === "all");
|
||||
});
|
||||
}
|
||||
renderMentorDirectory();
|
||||
}
|
||||
|
||||
async function toggleMentorPin(mentorId) {
|
||||
if (state.mentorSavingPreferences) return;
|
||||
const mentors = state.mentorSetup?.mentors || [];
|
||||
const index = mentors.findIndex((item) => item.id === mentorId);
|
||||
if (index < 0) return;
|
||||
const [mentor] = mentors.splice(index, 1);
|
||||
mentor.pinned = !mentor.pinned;
|
||||
if (mentor.pinned) {
|
||||
mentors.unshift(mentor);
|
||||
} else {
|
||||
const firstUnpinned = mentors.findIndex((item) => !item.pinned);
|
||||
mentors.splice(firstUnpinned < 0 ? mentors.length : firstUnpinned, 0, mentor);
|
||||
}
|
||||
normalizeMentorOrder();
|
||||
renderMentorWorkspace();
|
||||
await persistMentorPreferences();
|
||||
}
|
||||
|
||||
async function moveMentor(mentorId, direction) {
|
||||
if (state.mentorSavingPreferences) return;
|
||||
const mentors = state.mentorSetup?.mentors || [];
|
||||
const index = mentors.findIndex((item) => item.id === mentorId);
|
||||
if (index < 0) return;
|
||||
const step = direction === "up" ? -1 : 1;
|
||||
const targetIndex = index + step;
|
||||
if (targetIndex < 0 || targetIndex >= mentors.length) return;
|
||||
if (Boolean(mentors[index].pinned) !== Boolean(mentors[targetIndex].pinned)) return;
|
||||
[mentors[index], mentors[targetIndex]] = [mentors[targetIndex], mentors[index]];
|
||||
normalizeMentorOrder();
|
||||
renderMentorDirectory();
|
||||
await persistMentorPreferences();
|
||||
}
|
||||
|
||||
function handleMentorDragStart(event) {
|
||||
if (!state.mentorSortMode || state.mentorSavingPreferences) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
state.mentorDragId = event.currentTarget.dataset.mentorCard || "";
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", state.mentorDragId);
|
||||
event.currentTarget.classList.add("is-dragging");
|
||||
}
|
||||
|
||||
function handleMentorDragOver(event) {
|
||||
const source = state.mentorSetup?.mentors.find((item) => item.id === state.mentorDragId);
|
||||
const target = state.mentorSetup?.mentors.find((item) => item.id === event.currentTarget.dataset.mentorCard);
|
||||
if (!source || !target || Boolean(source.pinned) !== Boolean(target.pinned)) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
event.currentTarget.classList.add("is-drag-over");
|
||||
}
|
||||
|
||||
async function handleMentorDrop(event) {
|
||||
event.preventDefault();
|
||||
const sourceId = state.mentorDragId || event.dataTransfer.getData("text/plain");
|
||||
const targetId = event.currentTarget.dataset.mentorCard || "";
|
||||
clearMentorDragState();
|
||||
if (!sourceId || !targetId || sourceId === targetId) return;
|
||||
const mentors = state.mentorSetup?.mentors || [];
|
||||
const sourceIndex = mentors.findIndex((item) => item.id === sourceId);
|
||||
const targetIndex = mentors.findIndex((item) => item.id === targetId);
|
||||
if (sourceIndex < 0 || targetIndex < 0) return;
|
||||
if (Boolean(mentors[sourceIndex].pinned) !== Boolean(mentors[targetIndex].pinned)) return;
|
||||
const [mentor] = mentors.splice(sourceIndex, 1);
|
||||
const insertionIndex = mentors.findIndex((item) => item.id === targetId);
|
||||
mentors.splice(insertionIndex, 0, mentor);
|
||||
normalizeMentorOrder();
|
||||
renderMentorDirectory();
|
||||
await persistMentorPreferences();
|
||||
}
|
||||
|
||||
function clearMentorDragState() {
|
||||
state.mentorDragId = "";
|
||||
document.querySelectorAll(".mentor-option.is-dragging, .mentor-option.is-drag-over").forEach((item) => {
|
||||
item.classList.remove("is-dragging", "is-drag-over");
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeMentorOrder() {
|
||||
(state.mentorSetup?.mentors || []).forEach((mentor, index) => {
|
||||
mentor.sort_order = index;
|
||||
});
|
||||
}
|
||||
|
||||
async function persistMentorPreferences() {
|
||||
const mentors = state.mentorSetup?.mentors || [];
|
||||
state.mentorSavingPreferences = true;
|
||||
renderMentorDirectory();
|
||||
try {
|
||||
await apiRequest("/api/mentors/preferences", "POST", {
|
||||
order: mentors.map((item) => item.id),
|
||||
pinned: mentors.filter((item) => item.pinned).map((item) => item.id),
|
||||
});
|
||||
} catch (error) {
|
||||
showToast(error.message || "问师顺序保存失败");
|
||||
await loadMentorSetup(true);
|
||||
} finally {
|
||||
state.mentorSavingPreferences = false;
|
||||
renderMentorDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
function renderMentorBadges(mentor, expanded = false) {
|
||||
const badges = [];
|
||||
if (mentor.private) {
|
||||
badges.push('<span class="mentor-badge private" title="仅管理员本人可见"><i data-lucide="lock-keyhole"></i>仅自己</span>');
|
||||
}
|
||||
const grade = mentor.evidence?.grade;
|
||||
if (grade) {
|
||||
badges.push(`<span class="mentor-badge evidence grade-${escapeHtml(grade.toLowerCase())}" title="${escapeHtml(mentor.evidence?.note || "素材等级")}">${escapeHtml(grade)}</span>`);
|
||||
}
|
||||
return badges.join("");
|
||||
}
|
||||
|
||||
function toggleMentorDirectory(open) {
|
||||
const mobileOpen = Boolean(open) && window.innerWidth <= 720;
|
||||
state.mentorDirectoryOpen = mobileOpen;
|
||||
const sidebar = document.querySelector("#mentorView .mentor-sidebar");
|
||||
const backdrop = document.querySelector("#mentorDirectoryBackdrop");
|
||||
const toggle = document.querySelector("#mentorDirectoryToggle");
|
||||
sidebar.classList.toggle("is-open", mobileOpen);
|
||||
backdrop.hidden = !mobileOpen;
|
||||
toggle.setAttribute("aria-expanded", String(mobileOpen));
|
||||
document.body.classList.toggle("mentor-directory-open", mobileOpen);
|
||||
if (mobileOpen) requestAnimationFrame(() => document.querySelector("#mentorSearchInput").focus());
|
||||
}
|
||||
|
||||
async function selectMentor(mentorId) {
|
||||
if (mentorId === state.selectedMentorId) {
|
||||
toggleMentorDirectory(false);
|
||||
return;
|
||||
}
|
||||
state.selectedMentorId = mentorId;
|
||||
state.mentorMessages = [];
|
||||
hideMentorNotice();
|
||||
renderMentorWorkspace();
|
||||
toggleMentorDirectory(false);
|
||||
state.mentorMessages = await loadMentorMessages();
|
||||
renderMentorMessages();
|
||||
}
|
||||
|
||||
function renderMentorMessages() {
|
||||
const container = document.querySelector("#mentorMessages");
|
||||
const selected = state.mentorSetup?.mentors.find((item) => item.id === state.selectedMentorId);
|
||||
if (!state.mentorMessages.length && !state.mentorLoading) {
|
||||
container.innerHTML = `
|
||||
<div class="mentor-empty-state">
|
||||
<span class="mentor-empty-mark" aria-hidden="true"><i data-lucide="messages-square"></i></span>
|
||||
<strong>向「${escapeHtml(selected?.name || "问师")}」请教</strong>
|
||||
<p>${escapeHtml(selected?.tagline || selected?.description || "选择一个问题开始对话")}</p>
|
||||
</div>
|
||||
`;
|
||||
refreshIcons();
|
||||
} else {
|
||||
container.innerHTML = state.mentorMessages.map((message) => `
|
||||
<article class="mentor-message ${message.role} ${message.error ? "is-error" : ""}">
|
||||
<div class="mentor-message-label">${message.role === "user" ? "我" : escapeHtml(selected?.name || "问师")}</div>
|
||||
<div class="mentor-message-content">${message.role === "assistant" ? formatMentorAnswer(message.content) : escapeHtml(message.content)}</div>
|
||||
${message.streaming ? '<span class="assistant-stream-caret" aria-hidden="true"></span>' : ""}
|
||||
${message.meta && !message.streaming ? `<small>${escapeHtml(message.meta)}</small>` : ""}
|
||||
</article>
|
||||
`).join("");
|
||||
if (state.mentorLoading && !state.mentorMessages.some((message) => message.streaming)) {
|
||||
container.insertAdjacentHTML("beforeend", `
|
||||
<article class="mentor-message assistant loading-message">
|
||||
<div class="mentor-message-label">${escapeHtml(selected?.name || "问师")}</div>
|
||||
<p>正在读取复盘数据并推演...</p>
|
||||
</article>
|
||||
`);
|
||||
}
|
||||
}
|
||||
document.querySelector("#clearMentorChatButton").disabled = !state.mentorMessages.length || state.mentorLoading;
|
||||
document.querySelector("#mentorQuestion").disabled = state.mentorLoading || !state.selectedMentorId;
|
||||
document.querySelector("#sendMentorQuestion").disabled = state.mentorLoading || !state.selectedMentorId;
|
||||
document.querySelector("#mentorSortToggle").disabled = state.mentorLoading;
|
||||
requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
|
||||
}
|
||||
|
||||
async function sendMentorQuestion(event) {
|
||||
event.preventDefault();
|
||||
if (state.mentorLoading || !state.selectedMentorId) return;
|
||||
const input = document.querySelector("#mentorQuestion");
|
||||
const question = input.value.trim();
|
||||
if (!question) return;
|
||||
const history = state.mentorMessages.slice(-6).map((item) => ({
|
||||
role: item.role,
|
||||
content: item.content.slice(0, 3500),
|
||||
}));
|
||||
state.mentorMessages.push({ role: "user", content: question });
|
||||
const responseMessage = { role: "assistant", content: "", streaming: true, meta: "" };
|
||||
state.mentorMessages.push(responseMessage);
|
||||
input.value = "";
|
||||
state.mentorLoading = true;
|
||||
state.mentorController = new AbortController();
|
||||
hideMentorNotice();
|
||||
renderMentorMessages();
|
||||
renderMentorDirectory();
|
||||
setStatus("问师正在读取复盘数据");
|
||||
try {
|
||||
await streamMentorRequest(
|
||||
{
|
||||
mentor_id: state.selectedMentorId,
|
||||
trade_date: elements.tradeDate.value,
|
||||
question,
|
||||
history,
|
||||
},
|
||||
state.mentorController.signal,
|
||||
(chunk) => {
|
||||
responseMessage.content += chunk;
|
||||
scheduleMentorRender();
|
||||
},
|
||||
(meta) => {
|
||||
responseMessage.meta = `${displayCompactDate(meta.data_trade_date || elements.tradeDate.value)} · 回答完成`;
|
||||
if (meta.notice) showMentorNotice(meta.notice);
|
||||
},
|
||||
);
|
||||
responseMessage.streaming = false;
|
||||
setStatus("问师回答完成");
|
||||
} catch (error) {
|
||||
responseMessage.streaming = false;
|
||||
responseMessage.error = true;
|
||||
if (!responseMessage.content) {
|
||||
state.mentorMessages = state.mentorMessages.filter((item) => item !== responseMessage);
|
||||
}
|
||||
showMentorNotice(error.message || "问师回答失败");
|
||||
showToast(error.message || "问师回答失败");
|
||||
setStatus("问师回答失败");
|
||||
} finally {
|
||||
state.mentorLoading = false;
|
||||
state.mentorController = null;
|
||||
renderMentorMessages();
|
||||
renderMentorDirectory();
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
let mentorRenderFrame = 0;
|
||||
|
||||
function scheduleMentorRender() {
|
||||
if (mentorRenderFrame) return;
|
||||
mentorRenderFrame = requestAnimationFrame(() => {
|
||||
mentorRenderFrame = 0;
|
||||
renderMentorMessages();
|
||||
});
|
||||
}
|
||||
|
||||
async function streamMentorRequest(body, signal, onDelta, onMeta) {
|
||||
await window.XiaobaiAPI.streamNdjson("/api/mentors/chat", {
|
||||
method: "POST",
|
||||
body,
|
||||
signal,
|
||||
errorMessage: "问师暂不可用",
|
||||
onEvent: (event) => {
|
||||
if (event.type === "delta") onDelta(String(event.content || ""));
|
||||
if (event.type === "meta") onMeta(event);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function useMentorQuickPrompt(prompt) {
|
||||
const input = document.querySelector("#mentorQuestion");
|
||||
input.value = prompt || "";
|
||||
input.focus();
|
||||
}
|
||||
|
||||
async function clearMentorConversation() {
|
||||
if (!state.mentorMessages.length || !window.confirm("确定清空当前老师的对话记录吗?")) return;
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
mentor_id: state.selectedMentorId,
|
||||
trade_date: state.mentorSetup?.trade_date || elements.tradeDate.value,
|
||||
});
|
||||
await apiRequest(`/api/mentors/messages?${query}`, "DELETE");
|
||||
state.mentorMessages = [];
|
||||
hideMentorNotice();
|
||||
renderMentorMessages();
|
||||
} catch (error) {
|
||||
showToast(error.message || "对话记录清空失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMentorMessages() {
|
||||
if (!state.selectedMentorId) return [];
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
mentor_id: state.selectedMentorId,
|
||||
trade_date: state.mentorSetup?.trade_date || elements.tradeDate.value,
|
||||
});
|
||||
const payload = await apiRequest(`/api/mentors/messages?${query}`);
|
||||
return (payload.items || []).filter(
|
||||
(item) => ["user", "assistant"].includes(item?.role) && typeof item.content === "string",
|
||||
).slice(-100);
|
||||
} catch (error) {
|
||||
showMentorNotice(error.message || "对话记录加载失败");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function showMentorNotice(message) {
|
||||
const notice = document.querySelector("#mentorNotice");
|
||||
notice.textContent = message;
|
||||
notice.hidden = false;
|
||||
}
|
||||
|
||||
function hideMentorNotice() {
|
||||
document.querySelector("#mentorNotice").hidden = true;
|
||||
}
|
||||
|
||||
function formatMentorAnswer(content) {
|
||||
const blocks = [];
|
||||
let listType = "";
|
||||
let listItems = [];
|
||||
const flushList = () => {
|
||||
if (!listItems.length) return;
|
||||
blocks.push(`<${listType} class="mentor-answer-list">${listItems.map((item) => `<li>${item}</li>`).join("")}</${listType}>`);
|
||||
listItems = [];
|
||||
listType = "";
|
||||
};
|
||||
String(content || "").replace(/\r\n?/g, "\n").replace(/\n{3,}/g, "\n\n").split("\n").forEach((rawLine) => {
|
||||
const line = rawLine.trim();
|
||||
if (!line) {
|
||||
flushList();
|
||||
return;
|
||||
}
|
||||
const heading = line.match(/^#{1,3}\s+(.+)$/);
|
||||
const bullet = line.match(/^[-*]\s+(.+)$/);
|
||||
const ordered = line.match(/^\d+[.、]\s*(.+)$/);
|
||||
if (heading) {
|
||||
flushList();
|
||||
blocks.push(`<strong class="mentor-answer-heading">${formatMentorInline(escapeHtml(heading[1]))}</strong>`);
|
||||
} else if (/^-{3,}$/.test(line)) {
|
||||
flushList();
|
||||
blocks.push('<span class="mentor-answer-rule"></span>');
|
||||
} else if (line.startsWith("> ")) {
|
||||
flushList();
|
||||
blocks.push(`<span class="mentor-answer-quote">${formatMentorInline(escapeHtml(line.slice(2)))}</span>`);
|
||||
} else if (bullet || ordered) {
|
||||
const nextType = bullet ? "ul" : "ol";
|
||||
if (listType && listType !== nextType) flushList();
|
||||
listType = nextType;
|
||||
listItems.push(formatMentorInline(escapeHtml((bullet || ordered)[1])));
|
||||
} else {
|
||||
flushList();
|
||||
blocks.push(`<p class="mentor-answer-paragraph">${formatMentorInline(escapeHtml(line))}</p>`);
|
||||
}
|
||||
});
|
||||
flushList();
|
||||
return blocks.join("");
|
||||
}
|
||||
|
||||
function formatMentorInline(content) {
|
||||
return content.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:4337-4827 */
|
||||
@@ -0,0 +1,409 @@
|
||||
window.XiaobaiPageModules.register("pools", [
|
||||
"limitPool",
|
||||
"brokenView",
|
||||
"downView",
|
||||
"yesterdayView",
|
||||
"performanceView",
|
||||
]);
|
||||
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:1517-1915 */
|
||||
function getVisibleStocks() {
|
||||
if (!state.dashboard) return [];
|
||||
let rows = [...(state.dashboard.limits || [])];
|
||||
if (state.filter === "1") rows = rows.filter((row) => number(row.streak) === 1);
|
||||
if (state.filter === "2") rows = rows.filter((row) => number(row.streak) === 2);
|
||||
if (state.filter === "3") rows = rows.filter((row) => number(row.streak) >= 3);
|
||||
if (state.query) {
|
||||
rows = rows.filter((row) => {
|
||||
const haystack = `${row.code} ${row.name} ${row.sector} ${row.reason}`.toLowerCase();
|
||||
return haystack.includes(state.query);
|
||||
});
|
||||
}
|
||||
return rows.sort((left, right) => compareRows(left, right));
|
||||
}
|
||||
|
||||
function renderLimitTable() {
|
||||
if (!state.dashboard) return;
|
||||
const rows = getVisibleStocks();
|
||||
const allRows = state.dashboard.limits || [];
|
||||
const body = document.querySelector("#limitTableBody");
|
||||
body.innerHTML = rows.map((row, index) => `
|
||||
<tr data-code="${escapeHtml(row.code)}">
|
||||
<td class="row-number num muted">${index + 1}</td>
|
||||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
|
||||
<td class="number num"><span class="pool-streak-tag tag red">${streakLabel(row.streak)}</span></td>
|
||||
<td class="number num up">${signed(row.change)}</td>
|
||||
<td class="number num">${formatNumber(row.price, 2)}</td>
|
||||
<td>${escapeHtml(row.sector || "其他")}</td>
|
||||
<td class="number num muted">${escapeHtml(row.first_time || "")}</td>
|
||||
<td class="number num muted">${escapeHtml(row.last_time || "")}</td>
|
||||
<td class="number num">${limitOpenState(row)}</td>
|
||||
<td class="number num">${formatNumber(row.turnover_rate, 2)}</td>
|
||||
<td class="number num">${formatNumber(row.amount_billion, 2)}</td>
|
||||
<td class="number num">${formatLimitSealAmount(row.seal_amount_million)}</td>
|
||||
<td class="pool-reason-cell" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "")}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
bindStockRows(body);
|
||||
setText("resultCount", `${rows.length} 只`);
|
||||
setText("limitPoolSubtitle", `${allRows.length} 只 · 数据日期 ${displayCompactDate(state.dashboard.meta?.trade_date || elements.tradeDate.value)}`);
|
||||
setText("limitAllCount", allRows.length);
|
||||
setText("limitFirstCount", allRows.filter((row) => number(row.streak) === 1).length);
|
||||
setText("limitSecondCount", allRows.filter((row) => number(row.streak) === 2).length);
|
||||
setText("limitThreePlusCount", allRows.filter((row) => number(row.streak) >= 3).length);
|
||||
document.querySelector("#emptyState").hidden = rows.length !== 0;
|
||||
updateSortHeaders();
|
||||
}
|
||||
|
||||
function limitOpenState(row) {
|
||||
const openTimes = number(row.open_times);
|
||||
const firstTime = String(row.first_time || "");
|
||||
if (firstTime.startsWith("09:25") && openTimes === 0) return '<span class="pool-state-tag one-word">一字</span>';
|
||||
if (openTimes >= 6) return `<span class="pool-state-tag broken">烂板×${openTimes}</span>`;
|
||||
return String(openTimes);
|
||||
}
|
||||
|
||||
function formatLimitSealAmount(value) {
|
||||
const amount = number(value);
|
||||
if (!amount) return "";
|
||||
return Math.round(amount).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
function renderBrokenTable(rows) {
|
||||
const visibleRows = getVisibleBrokenRows(rows);
|
||||
setText("brokenCount", `${rows.length} 只`);
|
||||
setText("brokenMeta", ` · 触及涨停后未能封住 · 数据日期 ${displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value)}`);
|
||||
const body = document.querySelector("#brokenTableBody");
|
||||
body.innerHTML = visibleRows.map((row, index) => `
|
||||
<tr data-code="${escapeHtml(row.code)}">
|
||||
<td class="row-number num muted">${index + 1}</td>
|
||||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
|
||||
<td class="number num ${changeClass(row.change)}" data-sort-value="${number(row.change)}">${signed(row.change)}</td>
|
||||
<td class="number num broken-limit-gap" data-sort-value="${row.limitGap}">${formatNumber(row.limitGap, 2)}</td>
|
||||
<td class="number num">${formatNumber(row.price, 2)}</td>
|
||||
<td>${escapeHtml(row.sector || "其他")}</td>
|
||||
<td class="number num muted">${escapeHtml(row.first_time || "")}</td>
|
||||
<td class="number num" data-sort-value="${number(row.open_times)}">${brokenOpenState(row)}</td>
|
||||
<td class="number num">${formatNumber(row.turnover_rate, 2)}</td>
|
||||
<td class="number num">${formatNumber(row.amount_billion, 2)}</td>
|
||||
<td class="pool-reason-cell" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "")}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
bindStockRows(body);
|
||||
document.querySelector("#brokenEmptyState").hidden = visibleRows.length !== 0;
|
||||
updateBrokenSortHeaders();
|
||||
}
|
||||
|
||||
function getVisibleBrokenRows(rows = state.dashboard?.broken || []) {
|
||||
let visibleRows = rows.map((row) => ({ ...row, limitGap: brokenLimitGap(row) }));
|
||||
if (state.brokenQuery) {
|
||||
visibleRows = visibleRows.filter((row) => `${row.code} ${row.name} ${row.sector}`.toLowerCase().includes(state.brokenQuery));
|
||||
}
|
||||
if (!state.brokenSortKey) return visibleRows;
|
||||
return visibleRows.sort((left, right) => {
|
||||
const result = number(left[state.brokenSortKey]) - number(right[state.brokenSortKey]);
|
||||
return state.brokenSortDirection === "asc" ? result : -result;
|
||||
});
|
||||
}
|
||||
|
||||
function brokenLimitRate(row) {
|
||||
const name = String(row.name || "").toUpperCase();
|
||||
const code = String(row.code || "").replace(/\D/g, "");
|
||||
if (name.includes("ST")) return 10;
|
||||
if (/^(300|301|688|689)/.test(code)) return 20;
|
||||
if (/^(4|8|92)/.test(code)) return 30;
|
||||
return 10;
|
||||
}
|
||||
|
||||
function brokenLimitGap(row) {
|
||||
return Math.max(0, brokenLimitRate(row) - number(row.change));
|
||||
}
|
||||
|
||||
function brokenOpenState(row) {
|
||||
const openTimes = number(row.open_times);
|
||||
return openTimes >= 6
|
||||
? `<span class="broken-repeat-tag">反复炸 ×${openTimes}</span>`
|
||||
: String(openTimes);
|
||||
}
|
||||
|
||||
function changeBrokenSort(key) {
|
||||
if (state.brokenSortKey === key) state.brokenSortDirection = state.brokenSortDirection === "asc" ? "desc" : "asc";
|
||||
else {
|
||||
state.brokenSortKey = key;
|
||||
state.brokenSortDirection = "desc";
|
||||
}
|
||||
renderBrokenTable(state.dashboard?.broken || []);
|
||||
}
|
||||
|
||||
function updateBrokenSortHeaders() {
|
||||
document.querySelectorAll("#brokenTable th[data-broken-sort]").forEach((header) => {
|
||||
header.classList.remove("sort-asc", "sort-desc", "sorted");
|
||||
header.setAttribute("aria-sort", "none");
|
||||
if (header.dataset.brokenSort === state.brokenSortKey) {
|
||||
header.classList.add(state.brokenSortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted");
|
||||
header.setAttribute("aria-sort", state.brokenSortDirection === "asc" ? "ascending" : "descending");
|
||||
}
|
||||
const arrow = header.querySelector(".arr");
|
||||
if (arrow) arrow.textContent = header.classList.contains("sorted") ? (state.brokenSortDirection === "asc" ? "▲" : "▼") : "↕";
|
||||
});
|
||||
}
|
||||
|
||||
function renderDownTable(rows) {
|
||||
const visibleRows = getVisibleDownRows(rows);
|
||||
setText("downCount", `${rows.length} 只`);
|
||||
setText("downMeta", ` · 观察退潮、高位风险与亏钱效应 · 数据日期 ${displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value)}`);
|
||||
renderDownSectorCluster(rows);
|
||||
const body = document.querySelector("#downTableBody");
|
||||
body.innerHTML = visibleRows.map((row, index) => `
|
||||
<tr data-code="${escapeHtml(row.code)}">
|
||||
<td class="row-number num muted">${index + 1}</td>
|
||||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
|
||||
<td class="number num down" data-sort-value="${number(row.change)}">${signed(row.change)}</td>
|
||||
<td class="number num">${formatNumber(row.price, 2)}</td>
|
||||
<td>${escapeHtml(row.sector || "其他")}</td>
|
||||
<td class="number num">${formatNumber(row.turnover_rate, 2)}</td>
|
||||
<td class="number num">${formatNumber(row.amount_billion, 2)}</td>
|
||||
<td class="number num">${number(row.streak) > 0 ? number(row.streak) : ""}</td>
|
||||
<td class="pool-reason-cell" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "")}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
bindStockRows(body);
|
||||
document.querySelector("#downEmptyState").hidden = visibleRows.length !== 0;
|
||||
updateDownSortHeaders();
|
||||
}
|
||||
|
||||
function getVisibleDownRows(rows = state.dashboard?.down_limits || []) {
|
||||
let visibleRows = [...rows];
|
||||
if (state.downQuery) {
|
||||
visibleRows = visibleRows.filter((row) => `${row.code} ${row.name} ${row.sector}`.toLowerCase().includes(state.downQuery));
|
||||
}
|
||||
if (!state.downSortKey) return visibleRows;
|
||||
return visibleRows.sort((left, right) => {
|
||||
const result = number(left[state.downSortKey]) - number(right[state.downSortKey]);
|
||||
return state.downSortDirection === "asc" ? result : -result;
|
||||
});
|
||||
}
|
||||
|
||||
function renderDownSectorCluster(rows) {
|
||||
const counts = new Map();
|
||||
rows.forEach((row) => {
|
||||
const sector = String(row.sector || "其他").trim() || "其他";
|
||||
if (sector === "其他") return;
|
||||
counts.set(sector, (counts.get(sector) || 0) + 1);
|
||||
});
|
||||
const cluster = [...counts.entries()].sort((left, right) => right[1] - left[1])[0];
|
||||
const element = document.querySelector("#downSectorCluster");
|
||||
element.hidden = !cluster || cluster[1] < 2;
|
||||
element.textContent = cluster && cluster[1] >= 2 ? `${cluster[0]}集中跌停 ×${cluster[1]}` : "";
|
||||
}
|
||||
|
||||
function changeDownSort(key) {
|
||||
if (state.downSortKey === key) state.downSortDirection = state.downSortDirection === "asc" ? "desc" : "asc";
|
||||
else {
|
||||
state.downSortKey = key;
|
||||
state.downSortDirection = "asc";
|
||||
}
|
||||
renderDownTable(state.dashboard?.down_limits || []);
|
||||
}
|
||||
|
||||
function updateDownSortHeaders() {
|
||||
document.querySelectorAll("#downTable th[data-down-sort]").forEach((header) => {
|
||||
header.classList.remove("sort-asc", "sort-desc", "sorted");
|
||||
header.setAttribute("aria-sort", "none");
|
||||
if (header.dataset.downSort === state.downSortKey) {
|
||||
header.classList.add(state.downSortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted");
|
||||
header.setAttribute("aria-sort", state.downSortDirection === "asc" ? "ascending" : "descending");
|
||||
}
|
||||
const arrow = header.querySelector(".arr");
|
||||
if (arrow) arrow.textContent = header.classList.contains("sorted") ? (state.downSortDirection === "asc" ? "▲" : "▼") : "↕";
|
||||
});
|
||||
}
|
||||
|
||||
function renderYesterdayTable(rows) {
|
||||
const visibleRows = getVisibleYesterdayRows(rows);
|
||||
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
|
||||
const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
|
||||
setText("yesterdayCount", `${rows.length} 只`);
|
||||
setText("yesterdayMeta", ` · 昨日 ${previousDate} → 今日 ${currentDate}`);
|
||||
renderYesterdaySummary(rows);
|
||||
const body = document.querySelector("#yesterdayTableBody");
|
||||
body.innerHTML = visibleRows.map((row, index) => `
|
||||
<tr data-code="${escapeHtml(row.code)}">
|
||||
<td class="row-number num muted">${index + 1}</td>
|
||||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
|
||||
<td class="number num">${number(row.prior_streak)}</td>
|
||||
<td class="number num ${changeClass(row.current_change)}">${signed(row.current_change)}</td>
|
||||
<td><span class="yesterday-outcome-tag ${yesterdayOutcomeClass(row.outcome)}">${escapeHtml(row.outcome)}</span></td>
|
||||
<td class="number num">${number(row.current_streak) ? `<span class="yesterday-height-tag">${number(row.current_streak)}</span>` : ""}</td>
|
||||
<td>${escapeHtml(row.sector || "其他")}</td>
|
||||
<td class="pool-reason-cell" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "")}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
bindStockRows(body);
|
||||
document.querySelector("#yesterdayEmptyState").hidden = visibleRows.length !== 0;
|
||||
updateYesterdayControls();
|
||||
}
|
||||
|
||||
function getVisibleYesterdayRows(rows = state.dashboard?.yesterday_limits || []) {
|
||||
let visibleRows = rows.filter((row) => {
|
||||
if (state.yesterdayFilter === "advance") return row.outcome === "晋级";
|
||||
if (state.yesterdayFilter === "positive") return number(row.current_change) > 0;
|
||||
if (state.yesterdayFilter === "fail") return row.outcome === "断板";
|
||||
if (state.yesterdayFilter === "risk") return ["炸板", "跌停"].includes(row.outcome);
|
||||
return true;
|
||||
});
|
||||
if (state.yesterdayQuery) {
|
||||
visibleRows = visibleRows.filter((row) => `${row.code} ${row.name} ${row.sector}`.toLowerCase().includes(state.yesterdayQuery));
|
||||
}
|
||||
if (!state.yesterdaySortKey) return visibleRows;
|
||||
return visibleRows.sort((left, right) => {
|
||||
const result = number(left[state.yesterdaySortKey]) - number(right[state.yesterdaySortKey]);
|
||||
return state.yesterdaySortDirection === "asc" ? result : -result;
|
||||
});
|
||||
}
|
||||
|
||||
function renderYesterdaySummary(rows) {
|
||||
const total = rows.length;
|
||||
const advance = rows.filter((row) => row.outcome === "晋级").length;
|
||||
const positive = rows.filter((row) => number(row.current_change) > 0).length;
|
||||
const fail = rows.filter((row) => row.outcome === "断板").length;
|
||||
const risk = rows.filter((row) => ["炸板", "跌停"].includes(row.outcome)).length;
|
||||
const rate = (value) => total ? value / total * 100 : 0;
|
||||
setText("yesterdayAllCount", total);
|
||||
setText("yesterdayAdvanceCount", advance);
|
||||
setText("yesterdayAdvanceRate", `晋级率 ${formatNumber(rate(advance), 1)}%`);
|
||||
setText("yesterdayPositiveCount", positive);
|
||||
setText("yesterdayPositiveRate", `兑现率 ${formatNumber(rate(positive), 1)}%`);
|
||||
setText("yesterdayFailCount", fail);
|
||||
setText("yesterdayFailRate", `占 ${formatNumber(rate(fail), 1)}%`);
|
||||
setText("yesterdayRiskCount", risk);
|
||||
setText("yesterdayRiskRate", `亏钱效应 ${formatNumber(rate(risk), 1)}%`);
|
||||
}
|
||||
|
||||
function yesterdayOutcomeClass(outcome) {
|
||||
return { "晋级": "advance", "断板": "fail", "炸板": "broken", "跌停": "down" }[outcome] || "fail";
|
||||
}
|
||||
|
||||
function changeYesterdaySort(key) {
|
||||
if (state.yesterdaySortKey === key) state.yesterdaySortDirection = state.yesterdaySortDirection === "asc" ? "desc" : "asc";
|
||||
else {
|
||||
state.yesterdaySortKey = key;
|
||||
state.yesterdaySortDirection = "desc";
|
||||
}
|
||||
renderYesterdayTable(state.dashboard?.yesterday_limits || []);
|
||||
}
|
||||
|
||||
function updateYesterdayControls() {
|
||||
document.querySelectorAll("[data-yesterday-filter]").forEach((button) => {
|
||||
const active = button.dataset.yesterdayFilter === state.yesterdayFilter;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-pressed", String(active));
|
||||
});
|
||||
document.querySelectorAll("#yesterdayTable th[data-yesterday-sort]").forEach((header) => {
|
||||
header.classList.remove("sort-asc", "sort-desc", "sorted");
|
||||
header.setAttribute("aria-sort", "none");
|
||||
if (header.dataset.yesterdaySort === state.yesterdaySortKey) {
|
||||
header.classList.add(state.yesterdaySortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted");
|
||||
header.setAttribute("aria-sort", state.yesterdaySortDirection === "asc" ? "ascending" : "descending");
|
||||
}
|
||||
const arrow = header.querySelector(".arr");
|
||||
if (arrow) arrow.textContent = header.classList.contains("sorted") ? (state.yesterdaySortDirection === "asc" ? "▲" : "▼") : "↕";
|
||||
});
|
||||
}
|
||||
|
||||
function renderPerformance(rows) {
|
||||
rows = normalizePerformanceRows(rows);
|
||||
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
|
||||
const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
|
||||
setText("performanceDateRange", `昨日 ${previousDate} → 今日 ${currentDate}`);
|
||||
document.querySelector("#performanceCards").innerHTML = rows.map((row) => `
|
||||
<article class="performance-stage-card" title="收红 ${formatNumber(row.positive_rate, 1)}% · 平均涨幅 ${signed(row.average_change)}%"
|
||||
aria-label="${escapeHtml(row.label)},晋级率 ${formatNumber(row.advance_rate, 1)}%,晋级 ${number(row.advanced)} 只,共 ${number(row.count)} 只,收红率 ${formatNumber(row.positive_rate, 1)}%,平均涨幅 ${signed(row.average_change)}%">
|
||||
<div class="performance-stage-label"><span>${escapeHtml(row.label)} → 今日</span><i class="performance-status-tag ${performanceRateState(row.advance_rate).className}">${performanceRateState(row.advance_rate).label}</i></div>
|
||||
<strong class="performance-stage-rate ${performanceRateState(row.advance_rate).className}">${formatNumber(row.advance_rate, 1)}%</strong>
|
||||
<span class="performance-stage-count">晋级 ${number(row.advanced)} / 共 ${number(row.count)} 只</span>
|
||||
<div class="performance-stage-track" aria-hidden="true"><i class="${performanceRateState(row.advance_rate).className}" style="width:${Math.max(number(row.advance_rate), number(row.advance_rate) > 0 ? 2 : 0)}%"></i></div>
|
||||
</article>
|
||||
`).join("") || '<div class="performance-empty-state">暂无昨日涨停统计</div>';
|
||||
renderPerformanceConclusion(rows);
|
||||
renderMarketBreadth(state.dashboard?.overview || {});
|
||||
}
|
||||
|
||||
function normalizePerformanceRows(rows) {
|
||||
const groups = new Map();
|
||||
(rows || []).forEach((row) => {
|
||||
const level = Math.max(1, number(row.level));
|
||||
const displayLevel = Math.min(level, 5);
|
||||
const group = groups.get(displayLevel) || {
|
||||
level: displayLevel,
|
||||
label: displayLevel === 1 ? "昨日首板" : displayLevel === 5 ? "昨日5板+" : `昨日${displayLevel}板`,
|
||||
count: 0,
|
||||
advanced: 0,
|
||||
positive: 0,
|
||||
changeTotal: 0,
|
||||
};
|
||||
const count = number(row.count);
|
||||
group.count += count;
|
||||
group.advanced += number(row.advanced);
|
||||
group.positive += count * number(row.positive_rate) / 100;
|
||||
group.changeTotal += count * number(row.average_change);
|
||||
groups.set(displayLevel, group);
|
||||
});
|
||||
return [...groups.values()]
|
||||
.sort((left, right) => right.level - left.level)
|
||||
.map((group) => ({
|
||||
level: group.level,
|
||||
label: group.label,
|
||||
count: group.count,
|
||||
advanced: group.advanced,
|
||||
advance_rate: group.count ? group.advanced / group.count * 100 : 0,
|
||||
positive_rate: group.count ? group.positive / group.count * 100 : 0,
|
||||
average_change: group.count ? group.changeTotal / group.count : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function performanceRateState(rate) {
|
||||
const value = number(rate);
|
||||
if (value === 0) return { label: "失效", className: "is-neutral" };
|
||||
if (value < 20) return { label: "危险", className: "is-warning" };
|
||||
return { label: "活跃", className: "is-active" };
|
||||
}
|
||||
|
||||
function renderPerformanceConclusion(rows) {
|
||||
const container = document.querySelector("#performanceConclusion");
|
||||
if (!rows.length) {
|
||||
container.innerHTML = '<div class="empty-state">暂无昨日梯队数据,暂不生成结论</div>';
|
||||
return;
|
||||
}
|
||||
const sorted = [...rows].sort((left, right) => number(right.level) - number(left.level));
|
||||
const highRows = sorted.filter((row) => number(row.level) >= 4);
|
||||
const highAdvanced = highRows.reduce((total, row) => total + number(row.advanced), 0);
|
||||
const highSamples = highRows.map((row) => escapeHtml(row.label)).join("、");
|
||||
const strongest = [...rows].sort((left, right) => (
|
||||
number(right.advance_rate) - number(left.advance_rate) || number(right.level) - number(left.level)
|
||||
))[0];
|
||||
const firstBoard = rows.find((row) => number(row.level) === 1);
|
||||
const overview = state.dashboard?.overview || {};
|
||||
const phase = overview.sentiment_phase || "观察";
|
||||
const up = number(overview.up_count);
|
||||
const down = number(overview.down_count);
|
||||
const breadthRate = up + down > 0 ? up / (up + down) * 100 : 50;
|
||||
const stance = breadthRate < 25 ? "宜守不宜攻" : breadthRate < 45 ? "控制仓位,聚焦核心" : "保持精选,跟随强势梯队";
|
||||
const highText = highRows.length
|
||||
? `高位晋级率<b class="${highAdvanced ? "up" : "is-neutral"}">${highAdvanced ? "仍有承接" : "全线失效"}</b>:${highSamples}${highAdvanced ? `共晋级 ${highAdvanced} 只` : "今日均未晋级"};`
|
||||
: "高位梯队暂无昨日样本,空间信号仍待确认;";
|
||||
const strongestText = strongest
|
||||
? `<b>${escapeHtml(strongest.label)}</b>晋级率最高,为 <b class="up">${formatNumber(strongest.advance_rate, 1)}%</b>(${number(strongest.advanced)} 只晋级 / 共 ${number(strongest.count)} 只);`
|
||||
: "暂无相对占优梯队;";
|
||||
const firstBoardText = firstBoard
|
||||
? `首板基数 ${number(firstBoard.count)} 只,晋级率 <b class="${performanceRateState(firstBoard.advance_rate).className}">${formatNumber(firstBoard.advance_rate, 1)}%</b>,低位接力${number(firstBoard.advance_rate) < 20 ? "胜率偏低" : "仍有活跃度"};`
|
||||
: "首板梯队暂无有效样本;";
|
||||
container.innerHTML = `
|
||||
<div>· ${highText}</div>
|
||||
<div>· ${strongestText}</div>
|
||||
<div>· ${firstBoardText}</div>
|
||||
<div>· 结论:<b>${stance}</b>,当前情绪周期「${escapeHtml(phase)}」。</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:1517-1915 */
|
||||
@@ -0,0 +1,82 @@
|
||||
window.XiaobaiPageModules.register("popularity", ["popularityView"], {
|
||||
enter: ["loadPopularity"],
|
||||
});
|
||||
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:2584-2659 */
|
||||
async function loadPopularity(force = false) {
|
||||
if (state.popularityLoading) return;
|
||||
state.popularityLoading = true;
|
||||
const button = document.querySelector("#popularityRefreshButton");
|
||||
button.disabled = true;
|
||||
setText("popularityDateLabel", "正在读取人气榜");
|
||||
try {
|
||||
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
|
||||
if (force) query.set("force", "1");
|
||||
state.popularityData = await apiRequest(`/api/popularity?${query}`);
|
||||
renderPopularity();
|
||||
} catch (error) {
|
||||
setText("popularityDateLabel", error.message || "人气榜暂不可用");
|
||||
document.querySelector("#popularityTableBody").innerHTML = "";
|
||||
document.querySelector("#popularityEmpty").hidden = false;
|
||||
showToast(error.message || "人气榜加载失败");
|
||||
} finally {
|
||||
state.popularityLoading = false;
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPopularity() {
|
||||
const payload = state.popularityData;
|
||||
if (!payload) return;
|
||||
const summary = payload.summary || {};
|
||||
setText("popularityDateLabel", `${payload.meta?.carried_forward ? "最近有效榜单" : "榜单日期"} ${payload.meta?.trade_date || "--"}`);
|
||||
const topNames = (rows) => (rows || []).slice(0, 3).map((item) => item.name).filter(Boolean).join(" · ") || "--";
|
||||
document.querySelector("#popularitySummary").innerHTML = [
|
||||
["同花顺热度 Top3", topNames(payload.ths), `共 ${number(summary.ths_count)} 只上榜`],
|
||||
["东方财富热度 Top3", topNames(payload.dc), `共 ${number(summary.dc_count)} 只上榜`],
|
||||
["双榜共识", `${number(summary.dual_count)} 只`, "同时进入两榜,共识度更高"],
|
||||
].map(([label, value, detail], index) => `<article class="${index === 2 ? "consensus" : ""}"><span>${label}</span><strong>${escapeHtml(value)}</strong><small>${escapeHtml(detail)}</small></article>`).join("");
|
||||
renderPopularityTable();
|
||||
}
|
||||
|
||||
function renderPopularityTable() {
|
||||
const source = state.popularitySource;
|
||||
let rows = [...(state.popularityData?.[source] || [])];
|
||||
if (state.popularityQuery) {
|
||||
rows = rows.filter((item) => `${item.code} ${item.name} ${(item.concepts || []).join(" ")}`.toLocaleLowerCase("zh-CN").includes(state.popularityQuery));
|
||||
}
|
||||
const combined = source === "combined";
|
||||
const sourceName = source === "ths" ? "同花顺" : source === "dc" ? "东方财富" : "双榜综合";
|
||||
setText("popularityTableTitle", `${sourceName}榜`);
|
||||
setText("popularityTableNote", combined ? "按双榜排名综合排序 · 已隐藏重复的榜单状态" : "按榜单名次排序 · 状态显示是否同时进入另一榜");
|
||||
const headers = [
|
||||
["排名", "number num"], ["股票", ""], ["最新价(元)", "number num"], ["涨跌幅(%)", "number num"],
|
||||
...(source !== "dc" ? [["同花顺", "number num"]] : []),
|
||||
...(source !== "ths" ? [["东方财富", "number num"]] : []),
|
||||
["排名变化", "number num"], ["热门概念", ""], ...(!combined ? [["榜单状态", ""]] : []),
|
||||
];
|
||||
document.querySelector("#popularityTableHead").innerHTML = headers.map(([label, className]) => `<th scope="col" class="${className}">${label}</th>`).join("");
|
||||
const body = document.querySelector("#popularityTableBody");
|
||||
body.innerHTML = rows.map((row, index) => {
|
||||
const thsRank = source === "ths" ? row.rank : row.ths_rank;
|
||||
const dcRank = source === "dc" ? row.rank : row.dc_rank;
|
||||
const move = row.rank_change;
|
||||
const movement = move === null || move === undefined ? "新" : number(move) > 0 ? `↑${number(move)}` : number(move) < 0 ? `↓${Math.abs(number(move))}` : "持平";
|
||||
return `<tr data-code="${escapeHtml(row.code)}">
|
||||
<td class="number num popularity-rank-v2"><b>${index + 1}</b>${index < 3 ? '<span>热</span>' : ""}</td>
|
||||
<td><div class="popularity-stock-v2"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><span class="stock-code scode">${escapeHtml(row.code)}</span></div></td>
|
||||
<td class="number num">${row.price == null ? "" : formatNumber(row.price, 2)}</td>
|
||||
<td class="number num ${row.change == null ? "" : changeClass(row.change)}">${row.change == null ? "" : signed(row.change)}</td>
|
||||
${source !== "dc" ? `<td class="number num popularity-list-rank-v2">${thsRank ? number(thsRank) : ""}</td>` : ""}
|
||||
${source !== "ths" ? `<td class="number num popularity-list-rank-v2">${dcRank ? number(dcRank) : ""}</td>` : ""}
|
||||
<td class="number num popularity-movement-v2 ${number(move) > 0 ? "up" : number(move) < 0 ? "down" : ""}">${movement}</td>
|
||||
<td class="popularity-concepts-v2" title="${escapeHtml((row.concepts || []).join("、"))}">${escapeHtml((row.concepts || []).slice(0, 3).join("、"))}</td>
|
||||
${!combined ? `<td><span class="popularity-source-tag-v2 ${row.dual_source ? "dual" : ""}">${row.dual_source ? "双榜共识" : "单榜入选"}</span></td>` : ""}
|
||||
</tr>`;
|
||||
}).join("");
|
||||
bindStockRows(body);
|
||||
markAutoSortableHeaders(body.closest("table"));
|
||||
document.querySelector("#popularityEmpty").hidden = rows.length > 0;
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:2584-2659 */
|
||||
@@ -0,0 +1,699 @@
|
||||
window.XiaobaiPageModules.register("review", ["reviewWorkspaceView"], {
|
||||
enter: ["loadReview"],
|
||||
});
|
||||
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:3029-3470 */
|
||||
async function loadReviewWorkspace() {
|
||||
try {
|
||||
const [watchlistPayload, notesPayload, tradesPayload] = await Promise.all([
|
||||
apiRequest(`/api/watchlist?trade_date=${encodeURIComponent(elements.tradeDate.value)}`),
|
||||
apiRequest("/api/notes?scope=daily"),
|
||||
apiRequest("/api/trades"),
|
||||
]);
|
||||
state.watchlist = watchlistPayload.items || [];
|
||||
state.notes = notesPayload.items || [];
|
||||
state.tradeEntries = tradesPayload.items || [];
|
||||
state.tradeSummary = tradesPayload.summary || {};
|
||||
setText("reviewDataDate", displayCompactDate(elements.tradeDate.value));
|
||||
renderWatchlist();
|
||||
renderNotesHistory(state.notes, document.querySelector("#notesHistory"), false);
|
||||
setText("notesCount", `${state.notes.length} 条`);
|
||||
renderTradeLog();
|
||||
populateJournalForm();
|
||||
} catch (error) {
|
||||
showToast(error.message || "我的复盘加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
function renderWatchlist() {
|
||||
setText("watchlistCount", `${state.watchlist.length} 只`);
|
||||
const body = document.querySelector("#watchlistTableBody");
|
||||
body.innerHTML = state.watchlist.map((item) => `
|
||||
<tr data-code="${escapeHtml(item.code)}"><td><span class="review-watch-mark ${escapeHtml(item.color)}" title="${escapeHtml(item.color)}">★</span></td>
|
||||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(item.name)}</strong><small class="stock-code scode">${escapeHtml(item.code)}</small></span></td>
|
||||
<td>${escapeHtml(item.sector || "其他")}</td>
|
||||
<td class="number num ${item.change == null ? "" : changeClass(item.change)}">${formatWatchMetric(item.change)}</td>
|
||||
<td class="number num ${item.return_5d == null ? "" : changeClass(item.return_5d)}">${formatWatchMetric(item.return_5d)}</td>
|
||||
<td class="number num"><strong class="watch-attention-score">${item.attention_score == null ? "" : formatNumber(item.attention_score, 1)}</strong></td>
|
||||
<td><span class="watch-remark" title="${escapeHtml(item.remark || "尚未填写跟踪备注")}">${escapeHtml(item.remark || "尚未填写")}</span></td>
|
||||
<td><span class="review-row-actions"><button class="table-action" type="button" data-watch-remark="${escapeHtml(item.code)}">备注</button>
|
||||
<button class="table-action down" type="button" data-watch-delete="${escapeHtml(item.code)}" aria-label="移除 ${escapeHtml(item.name)}">移除</button></span></td></tr>
|
||||
`).join("");
|
||||
document.querySelector("#watchlistEmpty").hidden = state.watchlist.length > 0;
|
||||
body.querySelectorAll("[data-watch-remark]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const item = state.watchlist.find((row) => row.code === button.dataset.watchRemark);
|
||||
openWatchlistDialog(item);
|
||||
});
|
||||
});
|
||||
body.querySelectorAll("[data-watch-delete]").forEach((button) => {
|
||||
button.addEventListener("click", () => removeWatchlist(button.dataset.watchDelete));
|
||||
});
|
||||
bindStockRows(body);
|
||||
}
|
||||
|
||||
function formatWatchMetric(value) {
|
||||
if (value == null || !Number.isFinite(Number(value))) return "";
|
||||
return signed(value);
|
||||
}
|
||||
|
||||
function openWatchlistDialog(item = null) {
|
||||
clearTimeout(watchlistSearchTimer);
|
||||
state.watchlistSelection = item ? {
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
sector: item.sector || "其他",
|
||||
color: item.color || "red",
|
||||
} : null;
|
||||
state.watchlistSearchResults = [];
|
||||
setText("watchlistDialogTitle", item ? "编辑跟踪备注" : "添加自选");
|
||||
document.querySelector("#watchlistRemark").value = item?.remark || "";
|
||||
document.querySelector("#watchlistSearchInput").value = "";
|
||||
document.querySelector("#watchlistSearchResults").innerHTML = "";
|
||||
syncWatchlistSelection(Boolean(item));
|
||||
openModalDialog(elements.watchlistDialog);
|
||||
requestAnimationFrame(() => (item ? document.querySelector("#watchlistRemark") : document.querySelector("#watchlistSearchInput")).focus());
|
||||
}
|
||||
|
||||
function closeWatchlistDialog() {
|
||||
clearTimeout(watchlistSearchTimer);
|
||||
if (elements.watchlistDialog.open) elements.watchlistDialog.close();
|
||||
}
|
||||
|
||||
function clearWatchlistSelection() {
|
||||
state.watchlistSelection = null;
|
||||
syncWatchlistSelection(false);
|
||||
document.querySelector("#watchlistSearchInput").focus();
|
||||
}
|
||||
|
||||
function syncWatchlistSelection(editing = false) {
|
||||
const item = state.watchlistSelection;
|
||||
document.querySelector("#watchlistSearchField").hidden = Boolean(item);
|
||||
document.querySelector("#watchlistSelection").hidden = !item;
|
||||
document.querySelector("#changeWatchlistSelection").hidden = editing;
|
||||
document.querySelector("#saveWatchlist").disabled = !item;
|
||||
if (!item) return;
|
||||
setText("watchlistSelectionName", item.name || "--");
|
||||
setText("watchlistSelectionCode", item.code || "--");
|
||||
setText("watchlistSelectionSector", item.sector || "其他");
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
function scheduleWatchlistSearch() {
|
||||
clearTimeout(watchlistSearchTimer);
|
||||
const query = document.querySelector("#watchlistSearchInput").value.trim();
|
||||
if (!query) {
|
||||
document.querySelector("#watchlistSearchResults").innerHTML = "";
|
||||
return;
|
||||
}
|
||||
document.querySelector("#watchlistSearchResults").innerHTML = '<div class="watchlist-search-status">正在查找股票</div>';
|
||||
watchlistSearchTimer = setTimeout(() => runWatchlistSearch(query), 160);
|
||||
}
|
||||
|
||||
async function runWatchlistSearch(query) {
|
||||
const sequence = ++state.watchlistSearchRequestSequence;
|
||||
try {
|
||||
const params = new URLSearchParams({ q: query, trade_date: elements.tradeDate.value });
|
||||
const payload = await apiRequest(`/api/search?${params}`);
|
||||
if (sequence !== state.watchlistSearchRequestSequence) return;
|
||||
state.watchlistSearchResults = payload.groups?.stocks || [];
|
||||
document.querySelector("#watchlistSearchResults").innerHTML = state.watchlistSearchResults.map((item, index) => `
|
||||
<button type="button" data-watchlist-result="${index}"><span><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.industry || "其他")}</small></span><b>${escapeHtml(item.code)}</b></button>
|
||||
`).join("") || '<div class="watchlist-search-status">没有找到匹配的股票</div>';
|
||||
} catch (error) {
|
||||
document.querySelector("#watchlistSearchResults").innerHTML = `<div class="watchlist-search-status">${escapeHtml(error.message || "搜索失败")}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function handleWatchlistSearchResult(event) {
|
||||
const button = event.target.closest("[data-watchlist-result]");
|
||||
if (!button) return;
|
||||
const item = state.watchlistSearchResults[number(button.dataset.watchlistResult)];
|
||||
if (!item) return;
|
||||
state.watchlistSelection = {
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
sector: item.industry || "其他",
|
||||
color: "red",
|
||||
};
|
||||
syncWatchlistSelection(false);
|
||||
}
|
||||
|
||||
async function saveWatchlistFromDialog(event) {
|
||||
event.preventDefault();
|
||||
const item = state.watchlistSelection;
|
||||
if (!item) return;
|
||||
const button = document.querySelector("#saveWatchlist");
|
||||
button.disabled = true;
|
||||
try {
|
||||
await apiRequest("/api/watchlist", "POST", {
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
sector: item.sector || "其他",
|
||||
color: item.color || "red",
|
||||
remark: document.querySelector("#watchlistRemark").value.trim(),
|
||||
});
|
||||
closeWatchlistDialog();
|
||||
await loadReviewWorkspace();
|
||||
showToast(state.watchlist.some((row) => row.code === item.code) ? "自选跟踪已保存" : "已加入自选");
|
||||
} catch (error) {
|
||||
showToast(error.message || "自选保存失败");
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActiveWatchlist() {
|
||||
const stock = state.activeStock;
|
||||
if (!stock?.code) return;
|
||||
const isWatched = Boolean(state.stockDetail?.stock?.watchlist || state.watchlist.some((item) => item.code === stock.code));
|
||||
try {
|
||||
if (isWatched) {
|
||||
await apiRequest(`/api/watchlist/${stock.code}`, "DELETE");
|
||||
state.watchlist = state.watchlist.filter((item) => item.code !== stock.code);
|
||||
if (state.stockDetail?.stock) state.stockDetail.stock.watchlist = null;
|
||||
showToast("已移出自选");
|
||||
} else {
|
||||
const payload = await apiRequest("/api/watchlist", "POST", {
|
||||
code: stock.code,
|
||||
name: stock.name || "--",
|
||||
sector: stock.sector || "其他",
|
||||
color: "red",
|
||||
});
|
||||
state.watchlist = payload.items || state.watchlist;
|
||||
if (state.stockDetail?.stock) state.stockDetail.stock.watchlist = state.watchlist.find((item) => item.code === stock.code);
|
||||
showToast("已加入自选");
|
||||
}
|
||||
updateWatchButton();
|
||||
renderWatchlist();
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function updateWatchButton() {
|
||||
const code = state.activeStock?.code;
|
||||
const watched = Boolean(state.stockDetail?.stock?.watchlist || state.watchlist.some((item) => item.code === code));
|
||||
setText("watchStockButton", watched ? "移出自选" : "加入自选");
|
||||
}
|
||||
|
||||
async function removeWatchlist(code) {
|
||||
try {
|
||||
await apiRequest(`/api/watchlist/${code}`, "DELETE");
|
||||
state.watchlist = state.watchlist.filter((item) => item.code !== code);
|
||||
renderWatchlist();
|
||||
showToast("已移出自选");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveJournal(event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await apiRequest("/api/notes", "POST", {
|
||||
trade_date: document.querySelector("#journalDate").value,
|
||||
id: state.editingDailyNoteId || undefined,
|
||||
summary: document.querySelector("#journalSummary").value,
|
||||
content: document.querySelector("#journalContent").value,
|
||||
plan: document.querySelector("#journalPlan").value,
|
||||
});
|
||||
await loadReviewWorkspace();
|
||||
showToast("每日复盘已保存");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function populateJournalForm() {
|
||||
const selectedDate = document.querySelector("#journalDate").value.replaceAll("-", "");
|
||||
const note = state.notes.find((item) => String(item.trade_date).replaceAll("-", "") === selectedDate);
|
||||
state.editingDailyNoteId = number(note?.id);
|
||||
document.querySelector("#journalSummary").value = note?.summary || "";
|
||||
document.querySelector("#journalContent").value = note?.content || "";
|
||||
document.querySelector("#journalPlan").value = note?.plan || "";
|
||||
}
|
||||
|
||||
function openTradeLogDialog() {
|
||||
resetTradeLogForm();
|
||||
openModalDialog(elements.tradeLogDialog);
|
||||
requestAnimationFrame(() => document.querySelector("#tradeLogCode").focus());
|
||||
}
|
||||
|
||||
function closeTradeLogDialog() {
|
||||
if (elements.tradeLogDialog.open) elements.tradeLogDialog.close();
|
||||
else resetTradeLogForm();
|
||||
}
|
||||
|
||||
async function saveTradeLog(event) {
|
||||
event.preventDefault();
|
||||
const button = document.querySelector("#saveTradeLog");
|
||||
button.disabled = true;
|
||||
try {
|
||||
const payload = await apiRequest("/api/trades", "POST", {
|
||||
id: state.editingTradeId || undefined,
|
||||
trade_date: document.querySelector("#tradeLogDate").value,
|
||||
code: document.querySelector("#tradeLogCode").value.trim(),
|
||||
name: document.querySelector("#tradeLogName").value.trim(),
|
||||
action: document.querySelector("#tradeLogAction").value,
|
||||
price: document.querySelector("#tradeLogPrice").value,
|
||||
quantity: document.querySelector("#tradeLogQuantity").value,
|
||||
position_pct: document.querySelector("#tradeLogPosition").value,
|
||||
pnl_amount: document.querySelector("#tradeLogPnlAmount").value,
|
||||
pnl_pct: document.querySelector("#tradeLogPnlPct").value,
|
||||
emotion: document.querySelector("#tradeLogEmotion").value,
|
||||
tags: document.querySelector("#tradeLogTags").value,
|
||||
thesis: document.querySelector("#tradeLogThesis").value,
|
||||
execution: document.querySelector("#tradeLogExecution").value,
|
||||
});
|
||||
state.tradeEntries = payload.items || [];
|
||||
state.tradeSummary = payload.summary || {};
|
||||
renderTradeLog();
|
||||
closeTradeLogDialog();
|
||||
showToast("交易记录已保存");
|
||||
} catch (error) {
|
||||
showToast(error.message || "交易记录保存失败");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetTradeLogForm() {
|
||||
state.editingTradeId = 0;
|
||||
document.querySelector("#tradeLogForm").reset();
|
||||
document.querySelector("#tradeLogDate").value = elements.tradeDate.value || todayString();
|
||||
document.querySelector("#tradeLogQuantity").value = "0";
|
||||
document.querySelector("#tradeLogPosition").value = "0";
|
||||
setText("tradeLogDialogTitle", "交易日志");
|
||||
setText("saveTradeLog", "保存交易");
|
||||
}
|
||||
|
||||
function editTradeLog(id) {
|
||||
const item = state.tradeEntries.find((entry) => number(entry.id) === id);
|
||||
if (!item) return;
|
||||
state.editingTradeId = id;
|
||||
document.querySelector("#tradeLogDate").value = displayCompactDate(item.trade_date);
|
||||
document.querySelector("#tradeLogCode").value = item.code;
|
||||
document.querySelector("#tradeLogName").value = item.name;
|
||||
document.querySelector("#tradeLogAction").value = item.action;
|
||||
document.querySelector("#tradeLogPrice").value = item.price;
|
||||
document.querySelector("#tradeLogQuantity").value = item.quantity;
|
||||
document.querySelector("#tradeLogPosition").value = item.position_pct;
|
||||
document.querySelector("#tradeLogPnlAmount").value = item.pnl_amount ?? "";
|
||||
document.querySelector("#tradeLogPnlPct").value = item.pnl_pct ?? "";
|
||||
document.querySelector("#tradeLogEmotion").value = item.emotion;
|
||||
document.querySelector("#tradeLogTags").value = (item.tags || []).join(", ");
|
||||
document.querySelector("#tradeLogThesis").value = item.thesis || "";
|
||||
document.querySelector("#tradeLogExecution").value = item.execution || "";
|
||||
setText("tradeLogDialogTitle", "编辑交易日志");
|
||||
setText("saveTradeLog", "保存修改");
|
||||
openModalDialog(elements.tradeLogDialog);
|
||||
requestAnimationFrame(() => document.querySelector("#tradeLogCode").focus());
|
||||
}
|
||||
|
||||
async function handleTradeLogAction(event) {
|
||||
const button = event.target.closest("[data-trade-action]");
|
||||
if (!button) return;
|
||||
const id = number(button.dataset.tradeId);
|
||||
if (button.dataset.tradeAction === "edit") {
|
||||
editTradeLog(id);
|
||||
return;
|
||||
}
|
||||
if (!window.confirm("确定删除这条交易记录吗?")) return;
|
||||
try {
|
||||
const payload = await apiRequest(`/api/trades/${id}`, "DELETE");
|
||||
state.tradeEntries = payload.items || [];
|
||||
state.tradeSummary = payload.summary || {};
|
||||
if (state.editingTradeId === id) resetTradeLogForm();
|
||||
renderTradeLog();
|
||||
showToast("交易记录已删除");
|
||||
} catch (error) {
|
||||
showToast(error.message || "交易记录删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
function renderTradeLog() {
|
||||
const summary = state.tradeSummary || {};
|
||||
setText("tradeLogCount", `${state.tradeEntries.length} 条`);
|
||||
document.querySelector("#tradeLogSummary").innerHTML = [
|
||||
["记录", `${number(summary.total)} 条`],
|
||||
["已实现", `${number(summary.realized)} 条`],
|
||||
["胜率", summary.win_rate == null ? "--" : `${formatNumber(summary.win_rate, 1)}%`],
|
||||
["累计盈亏", summary.pnl_amount == null ? "--" : `${number(summary.pnl_amount) > 0 ? "+" : ""}${formatNumber(summary.pnl_amount, 2)}`],
|
||||
["平均仓位", summary.average_position == null ? "--" : `${formatNumber(summary.average_position, 1)}%`],
|
||||
].map(([label, value]) => `<div><span>${label}</span><strong>${value}</strong></div>`).join("");
|
||||
document.querySelector("#tradeLogEmpty").hidden = state.tradeEntries.length > 0;
|
||||
document.querySelector("#tradeLogTableBody").innerHTML = state.tradeEntries.map((item) => `
|
||||
<tr data-code="${escapeHtml(item.code)}">
|
||||
<td>${displayCompactDate(item.trade_date)}</td>
|
||||
<td><span class="stock-cell"><strong class="sname">${escapeHtml(item.name)}</strong><small class="stock-code scode">${escapeHtml(item.code)}</small></span></td>
|
||||
<td><span class="trade-action trade-action-${escapeHtml(item.action)}">${escapeHtml(item.action_label)}</span></td>
|
||||
<td class="number num">${item.position_pct == null ? "" : formatNumber(item.position_pct, 1)}</td>
|
||||
<td class="number num ${item.pnl_pct == null ? "" : changeClass(item.pnl_pct)}">${item.pnl_pct == null ? "" : signed(item.pnl_pct)}</td>
|
||||
<td class="number num ${item.pnl_amount == null ? "" : changeClass(item.pnl_amount)}">${item.pnl_amount == null ? "" : signed(item.pnl_amount)}</td>
|
||||
<td><span class="trade-emotion">${escapeHtml(item.emotion_label)}</span><div class="trade-tags">${(item.tags || []).map((tag) => `<em>${escapeHtml(tag)}</em>`).join("")}</div></td>
|
||||
<td class="trade-copy" title="交易逻辑:${escapeHtml(item.thesis || "")};执行复核:${escapeHtml(item.execution || "")}"><strong>${escapeHtml(item.thesis || "")}</strong><small>${escapeHtml(item.execution || "尚未填写执行复核")}</small></td>
|
||||
<td><div class="trade-row-actions"><button class="table-action" type="button" data-trade-action="edit" data-trade-id="${number(item.id)}">编辑</button><button class="table-action down" type="button" data-trade-action="delete" data-trade-id="${number(item.id)}">删除</button></div></td>
|
||||
</tr>
|
||||
`).join("");
|
||||
bindStockRows(document.querySelector("#tradeLogTableBody"));
|
||||
}
|
||||
|
||||
async function saveStockNote(event) {
|
||||
event.preventDefault();
|
||||
if (!state.activeStock?.code) return;
|
||||
try {
|
||||
await apiRequest("/api/notes", "POST", {
|
||||
code: state.activeStock.code,
|
||||
stock_name: state.activeStock.name || "--",
|
||||
trade_date: elements.tradeDate.value,
|
||||
content: document.querySelector("#stockNoteContent").value,
|
||||
plan: document.querySelector("#stockNotePlan").value,
|
||||
});
|
||||
document.querySelector("#stockNoteContent").value = "";
|
||||
document.querySelector("#stockNotePlan").value = "";
|
||||
const payload = await apiRequest(`/api/notes?scope=stock&code=${encodeURIComponent(state.activeStock.code)}`);
|
||||
state.stockDetail.notes = payload.items || [];
|
||||
renderStockNotes(state.stockDetail.notes);
|
||||
showToast("个股笔记已保存");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveReasonOverride(event) {
|
||||
event.preventDefault();
|
||||
if (!state.activeStock?.code) return;
|
||||
const reason = document.querySelector("#reasonInput").value.trim();
|
||||
try {
|
||||
await apiRequest("/api/reasons", "POST", {
|
||||
trade_date: elements.tradeDate.value,
|
||||
code: state.activeStock.code,
|
||||
reason,
|
||||
});
|
||||
state.activeStock.reason = reason;
|
||||
for (const key of ["limits", "broken", "down_limits"]) {
|
||||
const row = state.dashboard?.[key]?.find((item) => item.code === state.activeStock.code);
|
||||
if (row) row.reason = reason;
|
||||
}
|
||||
setText("detailReason", reason);
|
||||
renderDashboard();
|
||||
showToast("事件逻辑已修订");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderMoneyflow(flow) {
|
||||
for (const [id, value] of [["flowNet", flow.net_million], ["flowLarge", flow.large_million], ["flowMedium", flow.medium_million], ["flowSmall", flow.small_million]]) {
|
||||
const element = document.getElementById(id);
|
||||
element.textContent = formatMoneyMillion(value);
|
||||
element.className = changeClass(value);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStockNotes(notes) {
|
||||
renderNotesHistory(notes, document.querySelector("#stockNotes"), true);
|
||||
}
|
||||
|
||||
function renderNotesHistory(notes, container, compact) {
|
||||
container.innerHTML = notes.map((note) => `
|
||||
<article class="note-row">
|
||||
<div><time>${displayCompactDate(note.trade_date)}</time>${note.stock_name ? `<small>${escapeHtml(note.stock_name)}</small>` : ""}</div>
|
||||
${!compact ? `<div class="note-block note-summary"><strong>盘面</strong><p>${escapeHtml(note.summary || "--")}</p></div>` : ""}
|
||||
<div class="note-block"><strong>复盘</strong><p>${escapeHtml(note.content || "--")}</p></div>
|
||||
<div class="note-block"><strong>计划</strong><p>${escapeHtml(note.plan || "--")}</p></div>
|
||||
<button class="table-action down" type="button" data-note-delete="${number(note.id)}">删除</button>
|
||||
</article>
|
||||
`).join("") || emptyStateHtml("暂无复盘记录");
|
||||
container.querySelectorAll("[data-note-delete]").forEach((button) => {
|
||||
button.addEventListener("click", () => deleteNote(number(button.dataset.noteDelete), compact));
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteNote(noteId, compact) {
|
||||
try {
|
||||
await apiRequest(`/api/notes/${noteId}`, "DELETE");
|
||||
if (compact && state.activeStock) {
|
||||
state.stockDetail.notes = state.stockDetail.notes.filter((note) => number(note.id) !== noteId);
|
||||
renderStockNotes(state.stockDetail.notes);
|
||||
} else {
|
||||
await loadReviewWorkspace();
|
||||
}
|
||||
showToast("笔记已删除");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:3029-3470 */
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:7720-7968 */
|
||||
async function loadAlerts(openDialog = false) {
|
||||
try {
|
||||
const query = new URLSearchParams({ status: state.alertFilter, as_of: todayString() });
|
||||
const payload = await apiRequest(`/api/alerts?${query}`);
|
||||
state.alerts = payload.items || [];
|
||||
state.alertUnreadCount = number(payload.unread_count);
|
||||
renderAlerts();
|
||||
if (openDialog) openModalDialog(elements.alertsDialog);
|
||||
} catch (error) {
|
||||
if (openDialog) showToast(error.message || "提醒加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
function openAlerts() {
|
||||
toggleHeaderCommandMenu(false);
|
||||
toggleAccountDropdown(false);
|
||||
document.querySelector("#alertDate").value ||= todayString();
|
||||
openModalDialog(elements.alertsDialog);
|
||||
loadAlerts();
|
||||
}
|
||||
|
||||
function openStockReminder() {
|
||||
const stock = state.activeStock || {};
|
||||
document.querySelector("#alertTitle").value = `${stock.name || stock.code || "个股"}观察提醒`;
|
||||
document.querySelector("#alertCode").value = stock.code || "";
|
||||
document.querySelector("#alertDate").value = todayString();
|
||||
if (elements.stockDialog.open) elements.stockDialog.close();
|
||||
openAlerts();
|
||||
document.querySelector("#alertContent").focus();
|
||||
}
|
||||
|
||||
function selectAlertFilter(filter) {
|
||||
state.alertFilter = filter === "unread" ? "unread" : "all";
|
||||
document.querySelectorAll("[data-alert-filter]").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.alertFilter === state.alertFilter);
|
||||
});
|
||||
loadAlerts();
|
||||
}
|
||||
|
||||
async function saveAlert(event) {
|
||||
event.preventDefault();
|
||||
const button = event.currentTarget.querySelector("button[type='submit']");
|
||||
button.disabled = true;
|
||||
try {
|
||||
const payload = await apiRequest("/api/alerts", "POST", {
|
||||
title: document.querySelector("#alertTitle").value.trim(),
|
||||
remind_date: document.querySelector("#alertDate").value,
|
||||
code: document.querySelector("#alertCode").value.trim(),
|
||||
content: document.querySelector("#alertContent").value.trim(),
|
||||
});
|
||||
event.currentTarget.reset();
|
||||
document.querySelector("#alertDate").value = todayString();
|
||||
state.alertFilter = "all";
|
||||
state.alerts = payload.items || [];
|
||||
state.alertUnreadCount = number(payload.unread_count);
|
||||
renderAlerts();
|
||||
showToast("提醒已保存");
|
||||
} catch (error) {
|
||||
showToast(error.message || "提醒保存失败");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllAlertsRead() {
|
||||
try {
|
||||
await apiRequest("/api/alerts/read-all", "POST", { as_of: todayString() });
|
||||
await loadAlerts();
|
||||
} catch (error) {
|
||||
showToast(error.message || "提醒状态更新失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAlertAction(event) {
|
||||
const button = event.target.closest("[data-alert-action]");
|
||||
if (!button) return;
|
||||
const id = number(button.dataset.alertId);
|
||||
if (!id) return;
|
||||
try {
|
||||
if (button.dataset.alertAction === "delete") {
|
||||
await apiRequest(`/api/alerts/${id}`, "DELETE");
|
||||
} else {
|
||||
await apiRequest(`/api/alerts/${id}/read`, "POST", {});
|
||||
}
|
||||
await loadAlerts();
|
||||
} catch (error) {
|
||||
showToast(error.message || "提醒操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
function renderAlerts() {
|
||||
const badge = document.querySelector("#alertBadge");
|
||||
badge.hidden = state.alertUnreadCount <= 0;
|
||||
badge.textContent = state.alertUnreadCount > 99 ? "99+" : String(state.alertUnreadCount);
|
||||
document.querySelector("#alertButton").classList.toggle("has-alerts", state.alertUnreadCount > 0);
|
||||
setText("alertListCount", `${state.alerts.length} 条`);
|
||||
document.querySelectorAll("[data-alert-filter]").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.alertFilter === state.alertFilter);
|
||||
});
|
||||
document.querySelector("#markAllAlertsRead").disabled = state.alertUnreadCount <= 0;
|
||||
const container = document.querySelector("#alertList");
|
||||
container.innerHTML = state.alerts.map((item) => {
|
||||
const upcoming = !item.due;
|
||||
const kindLabel = item.kind === "manual" ? "自定提醒" : item.kind === "strategy_t5" ? "跟踪完成" : "策略反馈";
|
||||
return `<article class="alert-item ${item.is_read ? "is-read" : "is-unread"} ${upcoming ? "is-upcoming" : ""}">
|
||||
<div class="alert-item-icon"><i data-lucide="${upcoming ? "calendar-clock" : item.kind === "manual" ? "bell" : "chart-no-axes-combined"}"></i></div>
|
||||
<div class="alert-item-copy">
|
||||
<div><span>${escapeHtml(kindLabel)}</span><time>${displayCompactDate(item.available_date)}</time></div>
|
||||
<strong>${escapeHtml(item.title)}</strong>
|
||||
${item.content ? `<p>${escapeHtml(item.content)}</p>` : ""}
|
||||
${item.code ? `<button class="stock-preview-trigger alert-stock-link" type="button" data-code="${escapeHtml(item.code)}">${escapeHtml(item.code)}</button>` : ""}
|
||||
</div>
|
||||
<div class="alert-item-actions">
|
||||
${!item.is_read && !upcoming ? `<button class="icon-button" type="button" data-alert-action="read" data-alert-id="${number(item.id)}" title="标为已读" aria-label="标为已读"><i data-lucide="check"></i></button>` : ""}
|
||||
<button class="icon-button" type="button" data-alert-action="delete" data-alert-id="${number(item.id)}" title="删除提醒" aria-label="删除提醒"><i data-lucide="trash-2"></i></button>
|
||||
</div>
|
||||
</article>`;
|
||||
}).join("") || emptyStateHtml("暂无提醒");
|
||||
bindStockRows(container);
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
async function openReviewAssistant() {
|
||||
toggleHeaderCommandMenu(false);
|
||||
toggleAccountDropdown(false);
|
||||
openModalDialog(elements.assistantDialog);
|
||||
updateAssistantControls();
|
||||
if (!hasMemberAccess()) {
|
||||
document.querySelector("#closeAssistantDialog").focus();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = await apiRequest("/api/assistant/messages");
|
||||
state.assistantMessages = payload.items || [];
|
||||
renderAssistantMessages();
|
||||
} catch (error) {
|
||||
showToast(error.message || "对话记录加载失败");
|
||||
}
|
||||
document.querySelector("#assistantQuestion").focus();
|
||||
}
|
||||
|
||||
function useAssistantPrompt(prompt) {
|
||||
const input = document.querySelector("#assistantQuestion");
|
||||
input.value = prompt;
|
||||
input.focus();
|
||||
}
|
||||
|
||||
async function sendAssistantQuestion(event) {
|
||||
event.preventDefault();
|
||||
if (state.assistantLoading) return;
|
||||
const input = document.querySelector("#assistantQuestion");
|
||||
const question = input.value.trim();
|
||||
if (!question) return;
|
||||
input.value = "";
|
||||
state.assistantMessages.push({ role: "user", content: question, context_date: elements.tradeDate.value.replaceAll("-", "") });
|
||||
state.assistantMessages.push({ role: "assistant", content: "", streaming: true, context_date: elements.tradeDate.value.replaceAll("-", "") });
|
||||
state.assistantLoading = true;
|
||||
state.assistantController = new AbortController();
|
||||
updateAssistantControls();
|
||||
renderAssistantMessages();
|
||||
try {
|
||||
await streamAssistantRequest(question, state.assistantController.signal, (chunk) => {
|
||||
const message = state.assistantMessages.at(-1);
|
||||
if (message?.role === "assistant") message.content += chunk;
|
||||
scheduleAssistantRender();
|
||||
});
|
||||
const message = state.assistantMessages.at(-1);
|
||||
if (message) message.streaming = false;
|
||||
setStatus("复盘助手回答完成");
|
||||
} catch (error) {
|
||||
const message = state.assistantMessages.at(-1);
|
||||
if (message?.role === "assistant") {
|
||||
message.streaming = false;
|
||||
message.error = true;
|
||||
if (!message.content) message.content = error.name === "AbortError" ? "已停止生成。" : error.message || "回答失败,请稍后重试。";
|
||||
}
|
||||
if (error.name !== "AbortError") showToast(error.message || "复盘助手回答失败");
|
||||
} finally {
|
||||
state.assistantLoading = false;
|
||||
state.assistantController = null;
|
||||
updateAssistantControls();
|
||||
renderAssistantMessages();
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function streamAssistantRequest(question, signal, onDelta) {
|
||||
await window.XiaobaiAPI.streamNdjson("/api/assistant/chat", {
|
||||
method: "POST",
|
||||
body: { question, trade_date: elements.tradeDate.value },
|
||||
signal,
|
||||
errorMessage: "复盘助手暂不可用",
|
||||
onEvent: (event) => {
|
||||
if (event.type === "delta") onDelta(String(event.content || ""));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function stopAssistantResponse() {
|
||||
state.assistantController?.abort();
|
||||
}
|
||||
|
||||
async function clearAssistantConversation() {
|
||||
if (state.assistantLoading || !state.assistantMessages.length) return;
|
||||
if (!window.confirm("确定清空复盘助手的对话记录吗?")) return;
|
||||
try {
|
||||
await apiRequest("/api/assistant/messages", "DELETE");
|
||||
state.assistantMessages = [];
|
||||
renderAssistantMessages();
|
||||
} catch (error) {
|
||||
showToast(error.message || "对话记录清空失败");
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAssistantRender() {
|
||||
if (assistantRenderFrame) return;
|
||||
assistantRenderFrame = requestAnimationFrame(() => {
|
||||
assistantRenderFrame = 0;
|
||||
renderAssistantMessages();
|
||||
});
|
||||
}
|
||||
|
||||
function renderAssistantMessages() {
|
||||
const container = document.querySelector("#assistantMessages");
|
||||
container.innerHTML = state.assistantMessages.map((message) => `
|
||||
<article class="assistant-message ${message.role} ${message.error ? "is-error" : ""}">
|
||||
<div class="assistant-message-label">${message.role === "user" ? "我" : "复盘助手"}${message.context_date ? `<time>${displayCompactDate(message.context_date)}</time>` : ""}</div>
|
||||
<div class="assistant-message-content">${message.role === "assistant" ? (message.content ? formatMentorAnswer(message.content) : '<span class="assistant-thinking">正在整理复盘数据</span>') : escapeHtml(message.content)}</div>
|
||||
${message.streaming ? '<span class="assistant-stream-caret" aria-hidden="true"></span>' : ""}
|
||||
</article>
|
||||
`).join("") || emptyStateHtml("可以从市场、策略或自己的交易记录开始复盘");
|
||||
updateAssistantControls();
|
||||
requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
|
||||
}
|
||||
|
||||
function updateAssistantControls() {
|
||||
const unlocked = hasMemberAccess();
|
||||
elements.assistantDialog.classList.toggle("member-locked", !unlocked);
|
||||
document.querySelector("#assistantMemberGate").hidden = unlocked;
|
||||
document.querySelector("#assistantMemberContent").setAttribute("aria-disabled", String(!unlocked));
|
||||
document.querySelector("#assistantQuestion").disabled = !unlocked || state.assistantLoading;
|
||||
document.querySelector("#sendAssistant").disabled = !unlocked || state.assistantLoading;
|
||||
document.querySelector("#stopAssistant").hidden = !unlocked || !state.assistantLoading;
|
||||
document.querySelector("#clearAssistantMessages").disabled = !unlocked || state.assistantLoading || !state.assistantMessages.length;
|
||||
document.querySelectorAll("[data-assistant-prompt]").forEach((button) => {
|
||||
button.disabled = !unlocked || state.assistantLoading;
|
||||
});
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:7720-7968 */
|
||||
@@ -0,0 +1,173 @@
|
||||
window.XiaobaiPageModules.register("rotation", ["rotationView"], {
|
||||
enter: ["loadRotation"],
|
||||
});
|
||||
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:1958-2124 */
|
||||
async function loadRotationHistory(force = false) {
|
||||
if (!state.dashboard || state.rotationLoading) return;
|
||||
const key = `${elements.tradeDate.value}:9`;
|
||||
if (!force && state.rotationHistoryKey === key && state.rotationHistory) {
|
||||
renderRotationHistory();
|
||||
return;
|
||||
}
|
||||
state.rotationLoading = true;
|
||||
const container = document.querySelector("#rotationHistory");
|
||||
renderEmptyState(container, "正在读取轮动历史");
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
trade_date: elements.tradeDate.value,
|
||||
});
|
||||
state.rotationHistory = await apiRequest(`/api/rotation/history?${query}`);
|
||||
state.rotationHistoryKey = key;
|
||||
renderRotationHistory();
|
||||
} catch (error) {
|
||||
renderEmptyState(container, error.message || "轮动历史加载失败");
|
||||
showToast(error.message || "轮动历史加载失败");
|
||||
} finally {
|
||||
state.rotationLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderRotationHistory() {
|
||||
const rows = state.rotationHistory?.rows || [];
|
||||
const selected = state.rotationSelectedSector;
|
||||
const container = document.querySelector("#rotationHistory");
|
||||
const tracker = document.querySelector("#rotationTracker");
|
||||
if (!rows.length) {
|
||||
renderEmptyState(container, "尚无连续交易日的板块数据");
|
||||
setText("rotationHistoryRange", "暂无轮动历史");
|
||||
tracker.hidden = true;
|
||||
return;
|
||||
}
|
||||
const chronological = [...rows]
|
||||
.sort((left, right) => String(left.trade_date).localeCompare(String(right.trade_date)))
|
||||
.slice(-9);
|
||||
const displayRows = state.rotationOrder === "latest" ? [...chronological].reverse() : chronological;
|
||||
document.querySelectorAll("[data-rotation-order]").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.rotationOrder === state.rotationOrder);
|
||||
});
|
||||
setText(
|
||||
"rotationHistoryRange",
|
||||
`最近 ${chronological.length} 个交易日 · ${displayCompactDate(chronological[0].trade_date)} → ${displayCompactDate(chronological[chronological.length - 1].trade_date)} · ${state.rotationOrder === "latest" ? "由近到远,左侧为最新交易日" : "由远到近,右侧为最新交易日"}`,
|
||||
);
|
||||
setText("rotationSelectionHint", selected ? `已联动高亮 ${selected}` : "点击任意板块追踪其连续性");
|
||||
if (selected) {
|
||||
const sequence = displayRows.map((day) => {
|
||||
const sector = (day.sectors || []).find((item) => item.name === selected);
|
||||
return { tradeDate: day.trade_date, sector };
|
||||
});
|
||||
const appearances = sequence.filter((item) => item.sector);
|
||||
const bestRank = appearances.length ? Math.min(...appearances.map((item) => number(item.sector.rank))) : 0;
|
||||
tracker.hidden = false;
|
||||
const continuity = appearances.length >= 3 ? "主线候选" : appearances.length === 1 ? "单日异动,持续性待验证" : "间断活跃";
|
||||
tracker.innerHTML = `
|
||||
<div class="rotation-tracker-copy"><strong>${escapeHtml(selected)}</strong><span>近 9 日在榜 <b>${appearances.length}</b> 天 · 最高排名 <b>#${bestRank || "--"}</b> · ${continuity}</span></div>
|
||||
<div class="rotation-tracker-spark" aria-label="${escapeHtml(selected)}九日强度轨迹">
|
||||
${sequence.map((item) => item.sector
|
||||
? `<span style="--spark-height:${Math.max(18, clamp(number(item.sector.strength), 0, 100))}%" title="${escapeHtml(displayCompactDate(item.tradeDate))} · 第 ${number(item.sector.rank)} 名 · 强度 ${formatNumber(item.sector.strength, 0)}"><i></i><small>#${number(item.sector.rank)}</small></span>`
|
||||
: `<span class="missing" title="${escapeHtml(displayCompactDate(item.tradeDate))} · 未上榜"><i></i><small>--</small></span>`).join("")}
|
||||
</div>
|
||||
<button class="rotation-track-cancel" type="button">取消追踪</button>`;
|
||||
tracker.querySelector(".rotation-track-cancel").addEventListener("click", () => {
|
||||
state.rotationSelectedSector = "";
|
||||
state.rotationSelectedDate = "";
|
||||
renderRotationHistory();
|
||||
loadRotationMembers("");
|
||||
});
|
||||
} else {
|
||||
tracker.hidden = true;
|
||||
tracker.innerHTML = "";
|
||||
}
|
||||
container.classList.toggle("tracking", Boolean(selected));
|
||||
const latestTradeDate = chronological[chronological.length - 1].trade_date;
|
||||
container.innerHTML = displayRows.map((day) => {
|
||||
const hasSelected = selected && (day.sectors || []).some((sector) => sector.name === selected);
|
||||
return `
|
||||
<article class="rotation-day ${selected ? "has-selection" : ""} ${hasSelected ? "selected-day" : ""} ${day.trade_date === latestTradeDate ? "latest-day" : ""}">
|
||||
<header><time>${escapeHtml(displayCompactDate(day.trade_date).slice(5))}</time><span>${(day.sectors || []).length} 个热点</span></header>
|
||||
<div class="rotation-day-sectors">${(day.sectors || []).map((sector) => {
|
||||
const strength = clamp(number(sector.strength), 0, 100);
|
||||
const heatClass = strength >= 90 ? "heat-strong" : strength >= 70 ? "heat-warm" : "heat-mild";
|
||||
return `
|
||||
<button type="button" class="rotation-sector-chip ${heatClass} ${selected === sector.name ? "selected" : ""}" data-rotation-sector="${escapeHtml(sector.name)}" data-rotation-date="${escapeHtml(day.trade_date)}">
|
||||
<span class="rotation-rank rank-${Math.min(number(sector.rank), 4)}">${number(sector.rank)}</span><strong>${escapeHtml(sector.name)}</strong><small><b>${number(sector.count)}</b> 家 · ${formatNumber(sector.strength, 0)}</small>
|
||||
<span class="rotation-cell-tooltip">${escapeHtml(displayCompactDate(day.trade_date).slice(5))} · 第 ${number(sector.rank)} 名 · 涨停 ${number(sector.count)} 家 · 强度 ${formatNumber(sector.strength, 0)}</span>
|
||||
</button>`;
|
||||
}).join("")}</div>
|
||||
</article>`;
|
||||
}).join("");
|
||||
container.querySelectorAll("[data-rotation-sector]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const clickedSector = button.dataset.rotationSector;
|
||||
const clickedDate = button.dataset.rotationDate;
|
||||
const isSameSelection = clickedSector === state.rotationSelectedSector
|
||||
&& clickedDate === state.rotationSelectedDate;
|
||||
state.rotationSelectedSector = isSameSelection ? "" : clickedSector;
|
||||
state.rotationSelectedDate = isSameSelection ? "" : clickedDate;
|
||||
renderRotationHistory();
|
||||
loadRotationMembers(state.rotationSelectedSector);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadRotationMembers(sector, force = false) {
|
||||
if (!sector) {
|
||||
state.rotationMembers = null;
|
||||
state.rotationMembersKey = "";
|
||||
renderRotationMembers();
|
||||
return;
|
||||
}
|
||||
const memberDate = state.rotationSelectedDate || elements.tradeDate.value;
|
||||
const key = `${memberDate}:${sector}`;
|
||||
if (!force && state.rotationMembersKey === key && state.rotationMembers) {
|
||||
renderRotationMembers();
|
||||
return;
|
||||
}
|
||||
state.rotationMembersLoading = true;
|
||||
renderRotationMembers();
|
||||
try {
|
||||
const query = new URLSearchParams({ trade_date: memberDate, sector });
|
||||
state.rotationMembers = await apiRequest(`/api/rotation/members?${query}`);
|
||||
state.rotationMembersKey = key;
|
||||
} catch (error) {
|
||||
state.rotationMembers = { error: error.message || "成分股加载失败", rows: [] };
|
||||
state.rotationMembersKey = key;
|
||||
} finally {
|
||||
state.rotationMembersLoading = false;
|
||||
renderRotationMembers();
|
||||
}
|
||||
}
|
||||
|
||||
function renderRotationMembers() {
|
||||
const body = document.querySelector("#rotationTableBody");
|
||||
const empty = document.querySelector("#rotationMembersEmpty");
|
||||
if (state.rotationMembersLoading) {
|
||||
body.innerHTML = "";
|
||||
empty.textContent = `正在核验${state.rotationSelectedSector}成分股`;
|
||||
empty.hidden = false;
|
||||
return;
|
||||
}
|
||||
const payload = state.rotationMembers;
|
||||
const rows = payload?.rows || [];
|
||||
if (!state.rotationSelectedSector || !payload || payload.error || !rows.length) {
|
||||
body.innerHTML = "";
|
||||
empty.textContent = payload?.error || (state.rotationSelectedSector ? "该板块暂无可用成分行情" : "点击上方任意板块查看成分股");
|
||||
empty.hidden = false;
|
||||
setText("rotationDetailTitle", "板块成分股");
|
||||
setText("rotationDetailMeta", state.rotationSelectedSector || "--");
|
||||
return;
|
||||
}
|
||||
empty.hidden = true;
|
||||
setText("rotationDetailTitle", `${payload.meta?.sector_name || state.rotationSelectedSector}成分股`);
|
||||
setText("rotationDetailMeta", `${displayCompactDate(payload.meta?.trade_date)} · ${number(payload.meta?.quoted_count)} / ${number(payload.meta?.member_count)} 只`);
|
||||
body.innerHTML = rows.map((row, index) => `
|
||||
<tr data-code="${escapeHtml(row.code)}"><td class="number num muted">${index + 1}</td><td class="stock-code">${escapeHtml(row.code)}</td><td class="stock-name">${escapeHtml(row.name)}</td>
|
||||
<td class="number num ${row.quoted ? changeClass(row.change) : "muted"}" data-sort-value="${row.quoted ? number(row.change) : -999}">${row.quoted ? signed(row.change) : ""}</td>
|
||||
<td class="number num">${row.quoted ? formatNumber(row.open, 2) : ""}</td><td class="number num">${row.quoted ? formatNumber(row.close, 2) : ""}</td>
|
||||
<td class="number num" data-sort-value="${number(row.amount_billion)}">${row.quoted ? formatNumber(row.amount_billion, 2) : ""}</td><td>${row.quoted ? "正常交易" : "当日无行情"}</td></tr>
|
||||
`).join("");
|
||||
animateRows(body);
|
||||
bindStockRows(body);
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:1958-2124 */
|
||||
@@ -0,0 +1,316 @@
|
||||
window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], {
|
||||
enter: ["loadSentiment"],
|
||||
});
|
||||
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:1207-1516 */
|
||||
async function loadSentimentHistory(force = false) {
|
||||
if (!state.dashboard || state.sentimentLoading) return;
|
||||
const key = `${elements.tradeDate.value}:${state.sentimentRange}`;
|
||||
if (!force && state.sentimentHistoryKey === key && state.sentimentHistory) {
|
||||
renderSentimentHistory();
|
||||
return;
|
||||
}
|
||||
state.sentimentLoading = true;
|
||||
const notice = document.querySelector("#sentimentHistoryNotice");
|
||||
notice.hidden = true;
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
trade_date: elements.tradeDate.value,
|
||||
limit: String(state.sentimentRange),
|
||||
});
|
||||
state.sentimentHistory = await apiRequest(`/api/sentiment/history?${query}`);
|
||||
state.sentimentHistoryKey = key;
|
||||
renderSentimentHistory();
|
||||
} catch (error) {
|
||||
notice.textContent = error.message || "情绪周期数据加载失败";
|
||||
notice.hidden = false;
|
||||
showToast(notice.textContent);
|
||||
} finally {
|
||||
state.sentimentLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSentimentHistory() {
|
||||
const payload = state.sentimentHistory;
|
||||
if (!payload) return;
|
||||
const rows = payload.rows || [];
|
||||
const latest = rows[rows.length - 1];
|
||||
const body = document.querySelector("#sentimentHistoryBody");
|
||||
const empty = document.querySelector("#sentimentHistoryEmpty");
|
||||
empty.hidden = rows.length > 0;
|
||||
body.innerHTML = [...rows].reverse().map((row) => {
|
||||
return `
|
||||
<tr class="${row.trade_date === latest?.trade_date ? "latest-row" : ""}">
|
||||
<td class="sentiment-date-cell">${escapeHtml(displayCompactDate(row.trade_date))}</td>
|
||||
<td class="number sentiment-score-cell ${sentimentScoreClass(row.score)}">${number(row.score)}</td>
|
||||
<td><span class="sentiment-phase-badge ${sentimentPhaseClass(row.phase)}">${escapeHtml(row.phase)}</span></td>
|
||||
<td><span class="sentiment-direction ${trendClass(row.direction)}">${escapeHtml(row.direction)}</span></td>
|
||||
<td class="number">${number(row.limit_up_count)}</td>
|
||||
<td class="number">${number(row.first_board_count)}</td>
|
||||
<td class="number">${number(row.second_board_count)}</td>
|
||||
<td class="number">${number(row.three_plus_count)}</td>
|
||||
<td class="number">${number(row.max_height)}板</td>
|
||||
<td class="number">${number(row.broken_count)}</td>
|
||||
<td class="number">${number(row.limit_down_count)}</td>
|
||||
<td class="number">${number(row.previous_limit_count)}</td>
|
||||
<td class="number">${number(row.previous_positive_count)}</td>
|
||||
<td class="number">${formatNumber(row.previous_positive_rate, 1)}%</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
if (!latest) {
|
||||
setText("sentimentHistoryDateRange", "暂无历史数据");
|
||||
return;
|
||||
}
|
||||
setText(
|
||||
"sentimentHistoryDateRange",
|
||||
`${displayCompactDate(rows[0].trade_date)} 至 ${displayCompactDate(latest.trade_date)}`,
|
||||
);
|
||||
setText("sentimentCycleScore", number(latest.score));
|
||||
setText("sentimentCycleLabel", latest.label);
|
||||
setText("sentimentCycleDate", displayCompactDate(latest.trade_date));
|
||||
setText("sentimentCyclePhase", latest.phase);
|
||||
setText("sentimentCycleDirection", latest.direction);
|
||||
const dayChange = number(latest.day_change);
|
||||
const confidence = sentimentPhaseConfidence(latest);
|
||||
setText("sentimentPhaseConfidence", `置信度 ${confidence}%`);
|
||||
setText("sentimentDayChange", `${dayChange > 0 ? "+" : ""}${formatNumber(dayChange, 1)}`);
|
||||
setText("sentimentSealRate", `${formatNumber(latest.seal_rate, 1)}%`);
|
||||
setText("sentimentLimitUp", number(latest.limit_up_count));
|
||||
setText("sentimentBroken", number(latest.broken_count));
|
||||
setText("sentimentPhaseAdvice", sentimentPhaseAdvice(latest.phase));
|
||||
setText("sentimentCurrentTag", `当前 ${number(latest.score)} · ${latest.phase}`);
|
||||
setText("sentimentComponentSummary", `五维加权 → 温度 ${number(latest.score)}`);
|
||||
setText("sentimentPeriodNote", `近 ${state.sentimentRange} 个交易日,当前展示 ${rows.length} 日`);
|
||||
const changeElement = document.querySelector("#sentimentDayChange");
|
||||
changeElement.className = changeClass(dayChange);
|
||||
setText("sentimentPreviousPositive", `${number(latest.previous_positive_count)} / ${number(latest.previous_limit_count)} 只`);
|
||||
setText("sentimentPreviousAverage", `红盘率 ${formatNumber(latest.previous_positive_rate, 1)}% · 平均 ${signed(latest.average_previous_change)}%`);
|
||||
setText("sentimentHistoryDays", `${number(payload.available_days)} 个交易日`);
|
||||
setText("sentimentNormalization", `${latest.normalization} · 当前展示 ${rows.length} 日`);
|
||||
const marker = document.querySelector("#sentimentCycleScoreMarker");
|
||||
marker.className = `sentiment-current-phase-badge ${sentimentPhaseClass(latest.phase)}`;
|
||||
document.querySelector("#sentimentComponentList").innerHTML = Object.values(latest.components || {}).map((item) => `
|
||||
<article class="sentiment-component-item">
|
||||
<div class="sentiment-component-main">
|
||||
<strong>${escapeHtml(item.label)}</strong>
|
||||
<div class="sentiment-component-track" aria-hidden="true"><i data-component-score="${clamp(item.score, 0, 100)}" style="width:0%"></i></div>
|
||||
<b>${formatNumber(item.score, 1)} <em>× ${number(item.weight)}%</em></b>
|
||||
</div>
|
||||
<small>${escapeHtml(item.summary)}</small>
|
||||
</article>
|
||||
`).join("");
|
||||
requestAnimationFrame(() => {
|
||||
animateSentimentComponents();
|
||||
animateSentimentTrendChart(rows);
|
||||
bindSentimentChartTooltip(rows);
|
||||
});
|
||||
animateRows(body);
|
||||
}
|
||||
|
||||
function animateSentimentComponents() {
|
||||
document.querySelectorAll("#sentimentComponentList [data-component-score]").forEach((bar, index) => {
|
||||
const width = `${number(bar.dataset.componentScore)}%`;
|
||||
if (!motionEnabled()) {
|
||||
bar.style.width = width;
|
||||
return;
|
||||
}
|
||||
setTimeout(() => { bar.style.width = width; }, index * 70);
|
||||
});
|
||||
}
|
||||
|
||||
function animateSentimentTrendChart(rows) {
|
||||
if (sentimentChartAnimationFrame) cancelAnimationFrame(sentimentChartAnimationFrame);
|
||||
if (!motionEnabled()) {
|
||||
drawSentimentTrendChart(rows, 1);
|
||||
return;
|
||||
}
|
||||
const startedAt = performance.now();
|
||||
const duration = 780;
|
||||
const frame = (now) => {
|
||||
const rawProgress = Math.min(1, (now - startedAt) / duration);
|
||||
const progress = 1 - (1 - rawProgress) ** 3;
|
||||
drawSentimentTrendChart(rows, progress);
|
||||
if (rawProgress < 1) sentimentChartAnimationFrame = requestAnimationFrame(frame);
|
||||
else sentimentChartAnimationFrame = null;
|
||||
};
|
||||
sentimentChartAnimationFrame = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
function drawSentimentTrendChart(rows, progress = 1) {
|
||||
const canvas = document.querySelector("#sentimentTrendChart");
|
||||
if (!canvas || !rows.length || state.activeView !== "sentimentCycleView") return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (!rect.width) return;
|
||||
const width = Math.max(320, rect.width);
|
||||
const height = Math.max(220, rect.height);
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.round(width * ratio);
|
||||
canvas.height = Math.round(height * ratio);
|
||||
const context = canvas.getContext("2d");
|
||||
const palette = currentChartPalette();
|
||||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.fillStyle = palette.background;
|
||||
context.fillRect(0, 0, width, height);
|
||||
const padding = { top: 18, right: 18, bottom: 34, left: 42 };
|
||||
const chartWidth = width - padding.left - padding.right;
|
||||
const chartHeight = height - padding.top - padding.bottom;
|
||||
const x = (index) => padding.left + (rows.length === 1 ? chartWidth / 2 : index / (rows.length - 1) * chartWidth);
|
||||
const y = (score) => padding.top + (100 - clamp(score, 0, 100)) / 100 * chartHeight;
|
||||
|
||||
context.font = '10px "Microsoft YaHei UI", sans-serif';
|
||||
context.textAlign = "right";
|
||||
context.textBaseline = "middle";
|
||||
for (let score = 0; score <= 100; score += 20) {
|
||||
const lineY = y(score);
|
||||
context.strokeStyle = score === 40 || score === 80 ? palette.zero : palette.grid;
|
||||
context.lineWidth = 1;
|
||||
context.beginPath();
|
||||
context.moveTo(padding.left, lineY);
|
||||
context.lineTo(width - padding.right, lineY);
|
||||
context.stroke();
|
||||
context.fillStyle = palette.axis;
|
||||
context.fillText(String(score), padding.left - 8, lineY);
|
||||
}
|
||||
|
||||
context.save();
|
||||
context.beginPath();
|
||||
context.rect(padding.left - 6, padding.top - 8, (chartWidth + 12) * clamp(progress, 0, 1), chartHeight + 18);
|
||||
context.clip();
|
||||
|
||||
const finalPhase = rows[rows.length - 1]?.phase;
|
||||
let phaseStart = rows.length - 1;
|
||||
while (phaseStart > 0 && rows[phaseStart - 1]?.phase === finalPhase) phaseStart -= 1;
|
||||
if (["退潮", "冰点"].includes(finalPhase)) {
|
||||
const startX = phaseStart === 0 ? padding.left : (x(phaseStart - 1) + x(phaseStart)) / 2;
|
||||
context.fillStyle = palette.alertArea;
|
||||
context.fillRect(startX, padding.top, width - padding.right - startX, chartHeight);
|
||||
context.fillStyle = palette.up;
|
||||
context.font = '10px "Microsoft YaHei UI", sans-serif';
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "top";
|
||||
context.fillText(finalPhase, (startX + width - padding.right) / 2, padding.top + 4);
|
||||
}
|
||||
|
||||
const movingAverage = rows.map((_row, index) => {
|
||||
const start = Math.max(0, index - 4);
|
||||
const sample = rows.slice(start, index + 1);
|
||||
return sample.reduce((sum, item) => sum + number(item.score), 0) / sample.length;
|
||||
});
|
||||
context.beginPath();
|
||||
movingAverage.forEach((score, index) => {
|
||||
if (index === 0) context.moveTo(x(index), y(score));
|
||||
else context.lineTo(x(index), y(score));
|
||||
});
|
||||
context.strokeStyle = palette.movingAverage;
|
||||
context.lineWidth = 1.5;
|
||||
context.setLineDash([5, 4]);
|
||||
context.stroke();
|
||||
context.setLineDash([]);
|
||||
|
||||
context.beginPath();
|
||||
rows.forEach((row, index) => {
|
||||
const pointX = x(index);
|
||||
const pointY = y(row.score);
|
||||
if (index === 0) context.moveTo(pointX, pointY);
|
||||
else context.lineTo(pointX, pointY);
|
||||
});
|
||||
context.lineTo(x(rows.length - 1), padding.top + chartHeight);
|
||||
context.lineTo(x(0), padding.top + chartHeight);
|
||||
context.closePath();
|
||||
context.fillStyle = palette.area;
|
||||
context.fill();
|
||||
|
||||
context.beginPath();
|
||||
rows.forEach((row, index) => {
|
||||
const pointX = x(index);
|
||||
const pointY = y(row.score);
|
||||
if (index === 0) context.moveTo(pointX, pointY);
|
||||
else context.lineTo(pointX, pointY);
|
||||
});
|
||||
context.strokeStyle = palette.line;
|
||||
context.lineWidth = 2.5;
|
||||
context.lineJoin = "round";
|
||||
context.lineCap = "round";
|
||||
context.stroke();
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
context.beginPath();
|
||||
context.arc(x(index), y(row.score), index === rows.length - 1 ? 4.5 : 3, 0, Math.PI * 2);
|
||||
context.fillStyle = ["退潮", "冰点"].includes(row.phase) ? palette.up : row.phase === "修复" ? palette.repair : palette.line;
|
||||
context.fill();
|
||||
context.strokeStyle = palette.background;
|
||||
context.lineWidth = 1.5;
|
||||
context.stroke();
|
||||
});
|
||||
context.restore();
|
||||
|
||||
const labelStep = Math.max(1, Math.ceil(rows.length / 6));
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "top";
|
||||
context.fillStyle = palette.axis;
|
||||
rows.forEach((row, index) => {
|
||||
if (index % labelStep !== 0 && index !== rows.length - 1) return;
|
||||
const dateText = displayCompactDate(row.trade_date).slice(5);
|
||||
context.fillText(dateText, x(index), height - padding.bottom + 10);
|
||||
});
|
||||
}
|
||||
|
||||
function bindSentimentChartTooltip(rows) {
|
||||
const canvas = document.querySelector("#sentimentTrendChart");
|
||||
const tooltip = document.querySelector("#sentimentChartTooltip");
|
||||
if (!canvas || !tooltip || !rows.length) return;
|
||||
canvas.onmousemove = (event) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const padding = { left: 42, right: 18 };
|
||||
const chartWidth = Math.max(1, rect.width - padding.left - padding.right);
|
||||
const relativeX = clamp(event.clientX - rect.left - padding.left, 0, chartWidth);
|
||||
const index = rows.length === 1 ? 0 : Math.round(relativeX / chartWidth * (rows.length - 1));
|
||||
const row = rows[index];
|
||||
tooltip.innerHTML = `${escapeHtml(displayCompactDate(row.trade_date))} · 温度 <b>${number(row.score)}</b> · ${escapeHtml(row.phase)}`;
|
||||
tooltip.hidden = false;
|
||||
const targetLeft = padding.left + (rows.length === 1 ? chartWidth / 2 : index / (rows.length - 1) * chartWidth);
|
||||
tooltip.style.left = `${clamp(targetLeft + 10, 8, rect.width - tooltip.offsetWidth - 8)}px`;
|
||||
tooltip.style.top = `${clamp(event.clientY - rect.top - 34, 8, rect.height - 34)}px`;
|
||||
};
|
||||
canvas.onmouseleave = () => { tooltip.hidden = true; };
|
||||
}
|
||||
|
||||
function sentimentScoreClass(score) {
|
||||
const value = number(score);
|
||||
return value >= 60 ? "score-strong" : value < 40 ? "score-weak" : "score-neutral";
|
||||
}
|
||||
|
||||
function sentimentPhaseClass(phase) {
|
||||
return {
|
||||
"冰点": "phase-ice",
|
||||
"修复": "phase-repair",
|
||||
"发酵": "phase-fermentation",
|
||||
"高潮": "phase-climax",
|
||||
"分化": "phase-divergence",
|
||||
"退潮": "phase-retreat",
|
||||
}[phase] || "phase-divergence";
|
||||
}
|
||||
|
||||
function sentimentPhaseConfidence(row) {
|
||||
const explicit = number(row?.confidence || row?.phase_confidence);
|
||||
if (explicit > 0) return Math.round(clamp(explicit, 0, 100));
|
||||
const historyEvidence = Math.min(12, number(row?.history_days) * 0.6);
|
||||
const movementEvidence = Math.min(18, Math.abs(number(row?.day_change)) * 0.8);
|
||||
return Math.round(clamp(62 + historyEvidence + movementEvidence, 60, 92));
|
||||
}
|
||||
|
||||
function sentimentPhaseAdvice(phase) {
|
||||
return {
|
||||
"冰点": "情绪处于极弱区,先观察风险释放,允许没有候选结果。",
|
||||
"修复": "风险开始收敛,关注率先转强的核心,小仓验证修复强度。",
|
||||
"发酵": "主线与梯队正在形成,优先跟随核心,避免偏离主线。",
|
||||
"高潮": "情绪与一致性已处高位,聚焦核心并主动降低后排暴露。",
|
||||
"分化": "强弱开始分层,关注承接与回流,淘汰失去辨识度的方向。",
|
||||
"退潮": "情绪指标继续走弱。",
|
||||
}[phase] || "市场结构尚未形成清晰阶段,保持观察并等待确认。";
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:1207-1516 */
|
||||
@@ -0,0 +1,108 @@
|
||||
window.XiaobaiPageModules.register("themes", ["themeLibraryView"], {
|
||||
enter: ["loadThemes"],
|
||||
});
|
||||
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:2482-2583 */
|
||||
async function loadThemeLibrary(force = false) {
|
||||
if (state.themeLoading) return;
|
||||
state.themeLoading = true;
|
||||
const button = document.querySelector("#themeRefreshButton");
|
||||
button.disabled = true;
|
||||
setText("themeDateLabel", "正在整理题材库");
|
||||
try {
|
||||
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
|
||||
if (force) query.set("force", "1");
|
||||
state.themeLibrary = await apiRequest(`/api/themes?${query}`);
|
||||
renderThemeLibrary();
|
||||
const available = (state.themeLibrary.items || []).some((item) => item.code === state.selectedThemeCode);
|
||||
if (!available) state.selectedThemeCode = "";
|
||||
const initialCode = state.selectedThemeCode || state.themeLibrary.items?.[0]?.code || "";
|
||||
if (initialCode) await selectTheme(initialCode, true);
|
||||
} catch (error) {
|
||||
setText("themeDateLabel", error.message || "题材数据暂不可用");
|
||||
renderEmptyState("themeDirectory", error.message || "题材数据加载失败");
|
||||
showToast(error.message || "题材数据加载失败");
|
||||
} finally {
|
||||
state.themeLoading = false;
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderThemeLibrary() {
|
||||
const payload = state.themeLibrary;
|
||||
if (!payload) return;
|
||||
const summary = payload.summary || {};
|
||||
setText("themeDateLabel", `${payload.meta?.carried_forward ? "最近有效行情" : "行情日期"} ${payload.meta?.trade_date || "--"}`);
|
||||
document.querySelector("#themeSummary").innerHTML = [
|
||||
["收录题材", number(summary.theme_count), "个", ""],
|
||||
["当日上涨", number(summary.up_count), "个", "up"],
|
||||
["当日下跌", number(summary.down_count), "个", "down"],
|
||||
["人气题材", number(summary.hot_count), "个", "warning"],
|
||||
].map(([label, value, unit, tone]) => `<div><span>${label}</span><strong class="${tone}">${value}<small>${unit}</small></strong></div>`).join("");
|
||||
renderThemeDirectory();
|
||||
}
|
||||
|
||||
function renderThemeDirectory() {
|
||||
let items = [...(state.themeLibrary?.items || [])];
|
||||
if (state.themeQuery) {
|
||||
items = items.filter((item) => `${item.code} ${item.name}`.toLocaleLowerCase("zh-CN").includes(state.themeQuery));
|
||||
}
|
||||
setText("themeResultCount", `${items.length} 个`);
|
||||
document.querySelector("#themeDirectory").innerHTML = items.map((item, index) => {
|
||||
const active = item.code === state.selectedThemeCode;
|
||||
return `
|
||||
<button type="button" class="theme-directory-item-v2 ${active ? "active" : ""}" data-theme-code="${escapeHtml(item.code)}" aria-pressed="${active}">
|
||||
<span class="theme-rank-v2">${index + 1}</span>
|
||||
<span class="theme-directory-copy-v2"><strong class="market-preview-trigger" data-market-preview-type="theme" data-market-preview-id="${escapeHtml(item.code)}" title="悬停预览题材行情">${escapeHtml(item.name)}</strong><small>${number(item.member_count)} 只成分${item.hot_rank ? ` · 人气第 ${number(item.hot_rank)}` : ""}</small></span>
|
||||
<b class="${changeClass(item.change)}">${item.has_quote ? `${signed(item.change)}%` : "--"}</b>
|
||||
</button>`;
|
||||
}).join("") || emptyStateHtml("没有匹配的题材");
|
||||
}
|
||||
|
||||
async function selectTheme(code, keepSelection = false) {
|
||||
if (!code) return;
|
||||
state.selectedThemeCode = code;
|
||||
if (!keepSelection) renderThemeDirectory();
|
||||
document.querySelector("#themeDetailEmpty").hidden = false;
|
||||
document.querySelector("#themeDetailContent").hidden = true;
|
||||
setText("themeDetailEmpty", "正在读取题材详情");
|
||||
try {
|
||||
const query = new URLSearchParams({ code, trade_date: elements.tradeDate.value });
|
||||
state.themeDetail = await apiRequest(`/api/themes/detail?${query}`);
|
||||
renderThemeDetail();
|
||||
} catch (error) {
|
||||
setText("themeDetailEmpty", error.message || "题材详情加载失败");
|
||||
showToast(error.message || "题材详情加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
function renderThemeDetail() {
|
||||
const payload = state.themeDetail;
|
||||
if (!payload) return;
|
||||
const theme = payload.theme || {};
|
||||
const summary = payload.summary || {};
|
||||
document.querySelector("#themeDetailEmpty").hidden = true;
|
||||
document.querySelector("#themeDetailContent").hidden = false;
|
||||
setText("themeDetailName", theme.name || "--");
|
||||
setText("themeDetailCode", `${theme.code || "--"} · ${payload.meta?.trade_date || "--"}`);
|
||||
setText("themeDetailChange", `${signed(theme.change)}%`);
|
||||
document.querySelector("#themeDetailChange").className = changeClass(theme.change);
|
||||
document.querySelector("#themeDetailMetrics").innerHTML = [
|
||||
["成分股", `${number(summary.member_count)} 只`, ""],
|
||||
["有行情", `${number(summary.quoted_count)} 只`, ""],
|
||||
["上涨", `${number(summary.up_count)} 只`, "up"],
|
||||
["下跌", `${number(summary.down_count)} 只`, "down"],
|
||||
["换手率", `${formatNumber(theme.turnover_rate, 2)}%`, ""],
|
||||
].map(([label, value, tone]) => `<div><span>${label}</span><strong class="${tone}">${value}</strong></div>`).join("");
|
||||
setText("themeMemberCount", `有行情 ${number(summary.quoted_count)} / ${number(summary.member_count)}`);
|
||||
const body = document.querySelector("#themeMemberTableBody");
|
||||
body.innerHTML = (payload.members || []).map((row, index) => `
|
||||
<tr data-code="${escapeHtml(row.code)}"><td class="row-number num muted">${index + 1}</td>
|
||||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
|
||||
<td class="number num ${changeClass(row.change)}">${row.has_quote ? signed(row.change) : ""}</td>
|
||||
<td class="number num">${row.has_quote ? formatNumber(row.price, 2) : ""}</td><td class="number num">${row.has_quote ? formatNumber(row.amount_billion, 2) : ""}</td></tr>`).join("");
|
||||
bindStockRows(body);
|
||||
renderThemeDirectory();
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:2482-2583 */
|
||||
@@ -0,0 +1,143 @@
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:8905-9051 */
|
||||
function exportStocks() {
|
||||
exportRows("涨停池", getVisibleStocks(), [
|
||||
["股票代码", "code"], ["股票名称", "name"], ["连板", "streak"], ["涨幅%", "change"],
|
||||
["价格", "price"], ["所属板块", "sector"], ["涨停原因", "reason"], ["首封", "first_time"],
|
||||
["最后封板", "last_time"], ["开板次数", "open_times"], ["换手率%", "turnover_rate"],
|
||||
["成交额亿", "amount_billion"], ["封单额万", "seal_amount_million"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportBroken() {
|
||||
exportRows("炸板池", getVisibleBrokenRows(), [
|
||||
["股票代码", "code"], ["股票名称", "name"], ["现价涨幅%", "change"], ["距涨停%", "limitGap"],
|
||||
["价格", "price"], ["所属板块", "sector"], ["首次触板", "first_time"], ["开板次数", "open_times"],
|
||||
["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportDown() {
|
||||
exportRows("跌停板", getVisibleDownRows(), [
|
||||
["股票代码", "code"], ["股票名称", "name"], ["跌幅%", "change"], ["价格", "price"],
|
||||
["所属板块", "sector"], ["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportYesterday() {
|
||||
exportRows("昨日涨停", getVisibleYesterdayRows(), [
|
||||
["股票代码", "code"], ["股票名称", "name"], ["昨日高度", "prior_streak"],
|
||||
["今日涨幅%", "current_change"], ["今日结果", "outcome"], ["当前高度", "current_streak"],
|
||||
["所属板块", "sector"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportLadder() {
|
||||
const rows = (state.dashboard?.ladders || []).flatMap((group) => (group.stocks || []).map((stock) => ({
|
||||
level: group.label || group.level,
|
||||
...stock,
|
||||
})));
|
||||
exportRows("市场天梯", rows, [
|
||||
["梯队", "level"], ["股票代码", "code"], ["股票名称", "name"], ["所属板块", "sector"],
|
||||
["封板时间", "first_time"], ["开板次数", "open_times"], ["封单额万", "seal_amount_million"], ["成交额亿", "amount_billion"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportRotation() {
|
||||
const sectorMap = new Map((state.dashboard?.sectors || []).map((sector) => [sector.name, sector]));
|
||||
const rows = (state.dashboard?.sector_rotation || []).map((row) => ({
|
||||
...row,
|
||||
average_change: sectorMap.get(row.name)?.change ?? 0,
|
||||
}));
|
||||
exportRows("板块轮动", rows, [
|
||||
["排名", "rank"], ["板块", "name"], ["趋势", "trend"], ["今日涨停", "count"],
|
||||
["昨日涨停", "previous_count"], ["变化", "delta"], ["强度", "strength"],
|
||||
["最高板", "max_streak"], ["平均涨幅%", "average_change"],
|
||||
["领涨股", "leader"], ["涨停股成交额亿", "amount_billion"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportSentimentHistory() {
|
||||
const rows = state.sentimentHistory?.rows || [];
|
||||
if (!rows.length) {
|
||||
showToast("暂无可导出的情绪周期数据");
|
||||
return;
|
||||
}
|
||||
const exportRowsData = rows.map((row) => ({
|
||||
...row,
|
||||
breadth_score: row.components?.breadth?.score,
|
||||
limit_ecology_score: row.components?.limit_ecology?.score,
|
||||
profit_effect_score: row.components?.profit_effect?.score,
|
||||
ladder_structure_score: row.components?.ladder_structure?.score,
|
||||
liquidity_score: row.components?.liquidity?.score,
|
||||
}));
|
||||
exportRows("情绪周期", exportRowsData, [
|
||||
["交易日", "trade_date"], ["情绪温度", "score"], ["周期阶段", "phase"], ["方向", "direction"],
|
||||
["涨停", "limit_up_count"], ["首板", "first_board_count"], ["二板", "second_board_count"],
|
||||
["三板以上", "three_plus_count"], ["连板高度", "max_height"], ["炸板", "broken_count"],
|
||||
["跌停", "limit_down_count"], ["昨日涨停", "previous_limit_count"],
|
||||
["昨日涨停红盘", "previous_positive_count"], ["昨日涨停红盘率%", "previous_positive_rate"],
|
||||
["市场宽度", "breadth_score"], ["涨停生态", "limit_ecology_score"],
|
||||
["赚钱效应", "profit_effect_score"], ["连板结构", "ladder_structure_score"],
|
||||
["成交活跃度", "liquidity_score"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportDragonTiger() {
|
||||
const rows = (state.dragonTiger?.traders || []).flatMap((trader) => (
|
||||
(trader.operations || []).map((operation) => ({
|
||||
trader_name: trader.name,
|
||||
identity_type: dragonIdentityLabel(trader.identity_type),
|
||||
...operation,
|
||||
}))
|
||||
));
|
||||
exportRows("游资龙虎榜", rows, [
|
||||
["游资或席位", "trader_name"], ["身份", "identity_type"], ["股票代码", "code"],
|
||||
["股票名称", "name"], ["方向", "direction"], ["涨幅%", "change"],
|
||||
["买入百万元", "buy_million"], ["卖出百万元", "sell_million"], ["净额百万元", "net_buy_million"],
|
||||
["关联席位", "seat_name"], ["上榜原因", "reason"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportHotMoneyProfiles() {
|
||||
const rows = state.hotMoneyProfiles?.profiles || [];
|
||||
if (!rows.length) {
|
||||
showToast("暂无可导出的游资档案");
|
||||
return;
|
||||
}
|
||||
downloadCsv(
|
||||
`游资档案-${todayString()}.csv`,
|
||||
["游资名称", "简介", "关联营业部", "席位数量"],
|
||||
rows.map((profile) => [
|
||||
profile.name,
|
||||
profile.description,
|
||||
(profile.organizations || []).join(";"),
|
||||
number(profile.organization_count),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function exportRows(label, rows, columns) {
|
||||
const headers = columns.map(([header]) => header);
|
||||
const data = rows.map((row) => columns.map(([, key]) => row[key] ?? ""));
|
||||
downloadCsv(`${label}-${state.dashboard.meta.trade_date}.csv`, headers, data);
|
||||
}
|
||||
|
||||
function downloadCsv(filename, headers, rows) {
|
||||
const lines = [headers, ...rows].map((row) => row.map(csvCell).join(","));
|
||||
const blob = new Blob(["\ufeff", lines.join("\r\n")], { type: "text/csv;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast(`已导出 ${rows.length} 条数据`);
|
||||
}
|
||||
|
||||
function csvCell(value) {
|
||||
let text = String(value ?? "");
|
||||
if (/^[=+\-@]/.test(text)) text = `'${text}`;
|
||||
return `"${text.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:8905-9051 */
|
||||
@@ -13,9 +13,11 @@ module.exports = defineConfig({
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
webServer: {
|
||||
command: "python -m http.server 8876 --bind 127.0.0.1 --directory static",
|
||||
command: "python -m http.server 8876 --bind 127.0.0.1 --directory frontend",
|
||||
url: "http://127.0.0.1:8876/index.html",
|
||||
reuseExistingServer: true,
|
||||
timeout: 15_000,
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,672 +0,0 @@
|
||||
(function exposeHeavenLoading(global) {
|
||||
"use strict";
|
||||
|
||||
const PAPER = "#fdfcf8";
|
||||
const PAPER_CENTER = "#f1e8d9";
|
||||
const NODE_TEXT = "#fffaf0";
|
||||
const INK = "#68493d";
|
||||
const INK_BRIGHT = "#963f37";
|
||||
const GOLD = "#80533e";
|
||||
const GOLD_BRIGHT = "#b64e43";
|
||||
const CINNABAR = "#b94038";
|
||||
const DIM = "rgba(68,57,49,0.62)";
|
||||
const PARTICLE_COLORS = ["#a94b42", "#456b62", "#506b85"];
|
||||
const SERIF = '"Noto Serif SC","Songti SC","STSong","SimSun",serif';
|
||||
const ELEMENT_COLORS = {
|
||||
木: "#4f7a4a",
|
||||
火: "#b3483d",
|
||||
土: "#96702c",
|
||||
金: "#70685b",
|
||||
水: "#496d92",
|
||||
};
|
||||
const QI6 = [
|
||||
{ name: "厥阴风木", element: "木" },
|
||||
{ name: "少阴君火", element: "火" },
|
||||
{ name: "少阳相火", element: "火" },
|
||||
{ name: "太阴湿土", element: "土" },
|
||||
{ name: "阳明燥金", element: "金" },
|
||||
{ name: "太阳寒水", element: "水" },
|
||||
];
|
||||
const STEP_RANGES = ["大寒 — 春分", "春分 — 小满", "小满 — 大暑", "大暑 — 秋分", "秋分 — 小雪", "小雪 — 大寒"];
|
||||
const TRIGRAMS = [
|
||||
{ name: "乾", bits: [1, 1, 1], angle: -90 },
|
||||
{ name: "兑", bits: [1, 1, 0], angle: -135 },
|
||||
{ name: "离", bits: [1, 0, 1], angle: 180 },
|
||||
{ name: "震", bits: [1, 0, 0], angle: 135 },
|
||||
{ name: "巽", bits: [0, 1, 1], angle: -45 },
|
||||
{ name: "坎", bits: [0, 1, 0], angle: 0 },
|
||||
{ name: "艮", bits: [0, 0, 1], angle: 45 },
|
||||
{ name: "坤", bits: [0, 0, 0], angle: 90 },
|
||||
];
|
||||
const SIXIANG = [
|
||||
{ name: "太阳", bits: [1, 1], dx: 0, dy: -1 },
|
||||
{ name: "少阴", bits: [1, 0], dx: 1, dy: 0 },
|
||||
{ name: "太阴", bits: [0, 0], dx: 0, dy: 1 },
|
||||
{ name: "少阳", bits: [0, 1], dx: -1, dy: 0 },
|
||||
];
|
||||
const HEXAGRAM_NAMES = [
|
||||
"坤", "剥", "比", "观", "豫", "晋", "萃", "否", "谦", "艮", "蹇", "渐", "小过", "旅", "咸", "遁",
|
||||
"师", "蒙", "坎", "涣", "解", "未济", "困", "讼", "升", "蛊", "井", "巽", "恒", "鼎", "大过", "姤",
|
||||
"复", "颐", "屯", "益", "震", "噬嗑", "随", "无妄", "明夷", "贲", "既济", "家人", "丰", "革", "同人", "临",
|
||||
"损", "节", "中孚", "归妹", "睽", "兑", "履", "泰", "大畜", "需", "小畜", "大壮", "大有", "夬", "乾",
|
||||
];
|
||||
const HEX_TOTAL = 12500;
|
||||
const FORTUNE_TOTAL = 12800;
|
||||
const HEX_STAGES = [
|
||||
[0, 1800, "太 极", "无极而太极,动而生阳"],
|
||||
[1800, 3300, "两 仪", "一阴一阳之谓道"],
|
||||
[3300, 4700, "四 象", "阴阳消长,太少相生"],
|
||||
[4700, 6800, "八 卦", "天地定位,山泽通气"],
|
||||
[6800, 10800, "六 十 四 卦", "卦者挂也,悬物象以示人"],
|
||||
[10800, HEX_TOTAL, "归 一", "万物负阴而抱阳,冲气以为和"],
|
||||
];
|
||||
const clamp01 = (value) => Math.max(0, Math.min(1, value));
|
||||
const smooth = (start, end, value) => {
|
||||
const progress = clamp01((value - start) / Math.max(1, end - start));
|
||||
return progress * progress * (3 - 2 * progress);
|
||||
};
|
||||
const easeOut = (value) => 1 - Math.pow(1 - clamp01(value), 3);
|
||||
const hexBits = (index) => Array.from({ length: 6 }, (_, bit) => (index >> (5 - bit)) & 1);
|
||||
const point = (cx, cy, radius, degrees) => {
|
||||
const radians = degrees * Math.PI / 180;
|
||||
return [cx + Math.cos(radians) * radius, cy + Math.sin(radians) * radius];
|
||||
};
|
||||
|
||||
class HeavenLoadingCanvas {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.context = canvas.getContext("2d");
|
||||
this.width = 0;
|
||||
this.height = 0;
|
||||
this.dpr = 1;
|
||||
this.scene = "hexagram";
|
||||
this.data = {};
|
||||
this.startedAt = 0;
|
||||
this.frameId = 0;
|
||||
this.running = false;
|
||||
this.completingAt = 0;
|
||||
this.completionResolve = null;
|
||||
this.completionTimer = 0;
|
||||
this.resizeObserver = new ResizeObserver(() => this.resize());
|
||||
this.reducedMotion = global.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
this.stars = this.createStars(this.reducedMotion ? 48 : 150);
|
||||
}
|
||||
|
||||
createStars(count) {
|
||||
let seed = 24681357;
|
||||
const random = () => {
|
||||
seed = (seed * 1664525 + 1013904223) >>> 0;
|
||||
return seed / 4294967296;
|
||||
};
|
||||
return Array.from({ length: count }, () => ({
|
||||
x: random(),
|
||||
y: random(),
|
||||
radius: 0.3 + random() * 1.3,
|
||||
phase: random() * Math.PI * 2,
|
||||
speed: 0.00015 + random() * 0.0004,
|
||||
colorIndex: Math.floor(random() * PARTICLE_COLORS.length),
|
||||
}));
|
||||
}
|
||||
|
||||
start(scene, data = {}) {
|
||||
const nextScene = scene === "fortune" ? "fortune" : "hexagram";
|
||||
if (this.running && this.scene === nextScene) {
|
||||
this.data = data;
|
||||
return;
|
||||
}
|
||||
this.stop();
|
||||
this.scene = nextScene;
|
||||
this.data = data;
|
||||
this.startedAt = performance.now();
|
||||
this.running = true;
|
||||
this.canvas.dataset.scene = this.scene;
|
||||
this.canvas.dataset.running = "true";
|
||||
this.canvas.dataset.looping = "true";
|
||||
this.resizeObserver.observe(this.canvas);
|
||||
this.resize();
|
||||
if (this.reducedMotion) {
|
||||
this.draw(this.scene === "fortune" ? 10950 : 10600, performance.now());
|
||||
} else {
|
||||
this.frameId = requestAnimationFrame((now) => this.frame(now));
|
||||
}
|
||||
}
|
||||
|
||||
complete() {
|
||||
if (!this.running || this.reducedMotion) {
|
||||
this.stop();
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (this.completionResolve) return this.completionPromise;
|
||||
this.completingAt = performance.now();
|
||||
this.completionPromise = new Promise((resolve) => { this.completionResolve = resolve; });
|
||||
this.completionTimer = global.setTimeout(() => this.stop(), 2200);
|
||||
return this.completionPromise;
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.frameId) cancelAnimationFrame(this.frameId);
|
||||
this.frameId = 0;
|
||||
this.running = false;
|
||||
this.completingAt = 0;
|
||||
if (this.completionTimer) global.clearTimeout(this.completionTimer);
|
||||
this.completionTimer = 0;
|
||||
this.resizeObserver.disconnect();
|
||||
this.canvas.dataset.running = "false";
|
||||
this.canvas.dataset.looping = "false";
|
||||
if (this.completionResolve) this.completionResolve();
|
||||
this.completionResolve = null;
|
||||
this.completionPromise = null;
|
||||
}
|
||||
|
||||
resize() {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const width = Math.max(1, Math.round(rect.width));
|
||||
const height = Math.max(1, Math.round(rect.height));
|
||||
if (width === this.width && height === this.height) return;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.dpr = Math.min(global.devicePixelRatio || 1, 2);
|
||||
this.canvas.width = Math.round(width * this.dpr);
|
||||
this.canvas.height = Math.round(height * this.dpr);
|
||||
this.context.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
||||
if (this.running && this.reducedMotion) {
|
||||
this.draw(this.scene === "fortune" ? 10950 : 10600, performance.now());
|
||||
}
|
||||
}
|
||||
|
||||
frame(now) {
|
||||
if (!this.running) return;
|
||||
if (this.completingAt) {
|
||||
const duration = this.scene === "fortune" ? 1800 : 1700;
|
||||
const progress = clamp01((now - this.completingAt) / duration);
|
||||
this.drawCompletion(progress, now);
|
||||
if (progress >= 1) {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const total = this.scene === "fortune" ? FORTUNE_TOTAL : HEX_TOTAL;
|
||||
const elapsed = Math.max(0, now - this.startedAt);
|
||||
const timeline = elapsed % total;
|
||||
this.canvas.dataset.cycle = String(Math.floor(elapsed / total));
|
||||
this.draw(timeline, now);
|
||||
}
|
||||
this.frameId = requestAnimationFrame((time) => this.frame(time));
|
||||
}
|
||||
|
||||
draw(time, now) {
|
||||
if (this.width <= 1 || this.height <= 1) return;
|
||||
this.drawBackground(now);
|
||||
if (this.scene === "fortune") this.drawFortune(time, now);
|
||||
else this.drawHexagram(time, now);
|
||||
}
|
||||
|
||||
drawBackground(now) {
|
||||
const { context: ctx, width, height } = this;
|
||||
const cx = width / 2;
|
||||
const cy = height * 0.44;
|
||||
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(width, height) * 0.75);
|
||||
gradient.addColorStop(0, PAPER_CENTER);
|
||||
gradient.addColorStop(0.52, "#faf7ef");
|
||||
gradient.addColorStop(1, PAPER);
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
for (const star of this.stars) {
|
||||
const twinkle = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(star.phase + now * 0.0012));
|
||||
const alpha = twinkle * 0.5;
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = PARTICLE_COLORS[star.colorIndex];
|
||||
const y = ((star.y + now * star.speed) % 1) * height;
|
||||
ctx.fillRect(star.x * width, y, star.radius, star.radius);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
label(text, x, y, size, color = INK, alpha = 1, weight = "", maxWidth) {
|
||||
if (!text || alpha <= 0) return;
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = `${weight ? `${weight} ` : ""}${size}px ${SERIF}`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
if (maxWidth) ctx.fillText(text, x, y, maxWidth);
|
||||
else ctx.fillText(text, x, y);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
node(x, y, radius, color, alpha = 1, glow = 0) {
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = color;
|
||||
ctx.shadowColor = color;
|
||||
ctx.shadowBlur = glow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
line(x1, y1, x2, y2, color, alpha = 1, width = 1) {
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = width;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
curvedArrow(x1, y1, x2, y2, mx, my, color, alpha) {
|
||||
if (alpha <= 0) return;
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.quadraticCurveTo(mx, my, x2, y2);
|
||||
ctx.stroke();
|
||||
const angle = Math.atan2(y2 - my, x2 - mx);
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x2, y2);
|
||||
ctx.lineTo(x2 - 7 * Math.cos(angle - 0.42), y2 - 7 * Math.sin(angle - 0.42));
|
||||
ctx.lineTo(x2 - 7 * Math.cos(angle + 0.42), y2 - 7 * Math.sin(angle + 0.42));
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawYao(cx, cy, width, lineWidth, yang, alpha, glow = 0) {
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = INK;
|
||||
ctx.shadowColor = GOLD;
|
||||
ctx.shadowBlur = glow;
|
||||
if (yang) {
|
||||
ctx.fillRect(cx - width / 2, cy - lineWidth / 2, width, lineWidth);
|
||||
} else {
|
||||
const gap = width * 0.18;
|
||||
ctx.fillRect(cx - width / 2, cy - lineWidth / 2, (width - gap) / 2, lineWidth);
|
||||
ctx.fillRect(cx + gap / 2, cy - lineWidth / 2, (width - gap) / 2, lineWidth);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawGua(cx, cy, width, lineWidth, bits, alpha, glow = 0) {
|
||||
const gap = lineWidth * 1.7;
|
||||
const top = cy - (bits.length - 1) * gap / 2;
|
||||
bits.forEach((bit, index) => {
|
||||
this.drawYao(cx, top + (bits.length - 1 - index) * gap, width, lineWidth, bit === 1, alpha, glow);
|
||||
});
|
||||
}
|
||||
|
||||
stageAlpha(time, start, end, fade = 300, hold = false) {
|
||||
const enter = smooth(start, start + fade, time);
|
||||
return hold ? enter : enter * (1 - smooth(end - fade, end, time));
|
||||
}
|
||||
|
||||
fortuneStages() {
|
||||
const sixQi = this.data.sixQi || {};
|
||||
const pillar = this.data.yearPillar || "岁运";
|
||||
const movement = this.data.movement || "中运合参";
|
||||
const sitian = sixQi.sitian || "司天气候";
|
||||
return [
|
||||
[0, 2100, "五 运", "木火土金水,五运相袭,周而复始"],
|
||||
[2100, 3900, "十 干 化 运", "甲己土 · 乙庚金 · 丙辛水 · 丁壬木 · 戊癸火"],
|
||||
[3900, 5800, "十 二 支 化 气", "子午少阴 · 丑未太阴 · 寅申少阳 · 卯酉阳明 · 辰戌太阳 · 巳亥厥阴"],
|
||||
[5800, 7900, "六 气 环 布", "风寒暑湿燥火,分主六步,以应岁时"],
|
||||
[7900, 11000, "岁 运 合 参", `${pillar}年 · 中运${movement} · ${sitian}司天`],
|
||||
[11000, FORTUNE_TOTAL, "归 一", "谨守病机,无失气宜"],
|
||||
];
|
||||
}
|
||||
|
||||
drawFooter(time, now, total, stages, scene) {
|
||||
const { context: ctx, width, height } = this;
|
||||
const stage = [...stages].reverse().find((item) => time >= item[0]) || stages[0];
|
||||
const labelAlpha = smooth(stage[0], stage[0] + 300, time)
|
||||
* (1 - smooth(stage[1] - 250, stage[1], time));
|
||||
this.label(stage[2], width / 2, height - 108, 19, GOLD, 0.55 + 0.45 * labelAlpha, "600");
|
||||
this.label(stage[3], width / 2, height - 84, 12.5, DIM, (0.4 + 0.4 * labelAlpha) * (scene === "fortune" ? 0.85 : 0.8), "", width - 32);
|
||||
|
||||
const baseSlotWidth = 34;
|
||||
const baseSlotHeight = 5;
|
||||
const baseSlotGap = 12;
|
||||
const baseTotalWidth = baseSlotWidth * 6 + baseSlotGap * 5;
|
||||
const fit = Math.min(1, (width - 28) / baseTotalWidth);
|
||||
const slotWidth = baseSlotWidth * fit;
|
||||
const slotHeight = baseSlotHeight * fit;
|
||||
const slotGap = baseSlotGap * fit;
|
||||
const totalWidth = slotWidth * 6 + slotGap * 5;
|
||||
const filled = Math.min(6, Math.floor(time / (total / 6)));
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
const x = width / 2 - totalWidth / 2 + index * (slotWidth + slotGap);
|
||||
const y = height - 56;
|
||||
const color = scene === "fortune" ? ELEMENT_COLORS[QI6[index].element] : GOLD;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.16;
|
||||
ctx.strokeStyle = GOLD;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x, y, slotWidth, slotHeight);
|
||||
ctx.restore();
|
||||
if (index < filled) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.9;
|
||||
ctx.fillStyle = color;
|
||||
ctx.shadowColor = color;
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.fillRect(x, y, slotWidth, slotHeight);
|
||||
ctx.restore();
|
||||
} else if (index === filled) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.35 + 0.3 * Math.sin(now / 200);
|
||||
ctx.fillStyle = color;
|
||||
const progress = (time % (total / 6)) / (total / 6);
|
||||
ctx.fillRect(x, y, slotWidth * progress, slotHeight);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
const dots = ".".repeat(1 + Math.floor(now / 450) % 3);
|
||||
const loadingText = scene === "fortune" ? "推 演 运 气 · 加 载 中" : "推 演 天 机 · 加 载 中";
|
||||
this.label(`${loadingText}${dots}`, width / 2, height - 32, 13, GOLD, 0.75);
|
||||
}
|
||||
|
||||
drawTrigramRing(cx, cy, radius, width, lineWidth, alpha, now, entering, time) {
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha * 0.13;
|
||||
ctx.strokeStyle = GOLD;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
const breath = 1 + 0.006 * Math.sin(now / 620);
|
||||
TRIGRAMS.forEach((trigram, index) => {
|
||||
const progress = entering ? easeOut((time - 4700 - index * 130) / 700) : 1;
|
||||
if (progress <= 0) return;
|
||||
const [x, y] = point(cx, cy, radius * breath * progress, trigram.angle);
|
||||
this.drawGua(x, y, width, lineWidth, trigram.bits, alpha * progress, alpha * progress * 8);
|
||||
const nameAlpha = entering ? alpha * clamp01((time - 4700 - index * 130 - 480) / 500) : alpha;
|
||||
this.label(trigram.name, x, y + lineWidth * 5.2, 13, GOLD, nameAlpha * (0.55 + 0.2 * Math.sin(now / 700 + index)));
|
||||
});
|
||||
}
|
||||
|
||||
drawHexagram(time, now) {
|
||||
const { width, height } = this;
|
||||
const cx = width / 2;
|
||||
const cy = height * 0.44;
|
||||
const scale = Math.min(width, height);
|
||||
if (time < 1800) {
|
||||
const alpha = this.stageAlpha(time, 0, 1800);
|
||||
this.node(cx, cy, 5.5 * (1 + 0.12 * Math.sin(now / 260)), GOLD_BRIGHT, alpha, 34);
|
||||
for (let ring = 0; ring < 3; ring += 1) {
|
||||
const progress = ((now / 1500) + ring / 3) % 1;
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = (1 - progress) * 0.22 * alpha;
|
||||
ctx.strokeStyle = GOLD;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 8 + progress * scale * 0.13, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
if (time >= 1800 && time < 3300) {
|
||||
const alpha = this.stageAlpha(time, 1800, 3300);
|
||||
const progress = easeOut((time - 1850) / 850);
|
||||
const yaoWidth = scale * 0.19 * progress;
|
||||
const yaoLine = Math.max(scale * 0.013, 5);
|
||||
this.drawYao(cx, cy - yaoLine * 2.6, yaoWidth, yaoLine, true, alpha, 14);
|
||||
this.drawYao(cx, cy + yaoLine * 2.6, yaoWidth, yaoLine, false, alpha, 14);
|
||||
this.node(cx, cy, 4, GOLD_BRIGHT, alpha * (1 - progress) * 0.9);
|
||||
}
|
||||
if (time >= 3300 && time < 4700) {
|
||||
const alpha = this.stageAlpha(time, 3300, 4700);
|
||||
const distance = scale * 0.085;
|
||||
const yaoWidth = Math.max(scale * 0.055, 28);
|
||||
const yaoLine = Math.max(scale * 0.009, 3.5);
|
||||
SIXIANG.forEach((symbol, index) => {
|
||||
const progress = easeOut((time - 3330 - index * 160) / 520);
|
||||
if (progress <= 0) return;
|
||||
const x = cx + symbol.dx * distance;
|
||||
const y = cy + symbol.dy * distance;
|
||||
this.drawGua(x, y, yaoWidth * progress, yaoLine, symbol.bits, alpha * progress, 10);
|
||||
this.label(symbol.name, x, y + yaoLine * 5.4, 12, GOLD, alpha * progress * 0.55);
|
||||
});
|
||||
}
|
||||
const trigramRadius = scale * 0.215;
|
||||
const trigramWidth = Math.max(scale * 0.052, 26);
|
||||
const trigramLine = Math.max(scale * 0.0075, 3);
|
||||
if (time >= 4700 && time < 6800) {
|
||||
this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth, trigramLine, this.stageAlpha(time, 4700, 6800), now, true, time);
|
||||
}
|
||||
if (time >= 6800 && time < 10800) {
|
||||
const alpha = this.stageAlpha(time, 6800, 10800, 350);
|
||||
this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth * 0.85, trigramLine * 0.85, alpha * 0.42, now, false, time);
|
||||
const ringRadius = scale * 0.365;
|
||||
const hexWidth = Math.max(scale * 0.026, 13);
|
||||
const hexLine = Math.max(scale * 0.0042, 1.6);
|
||||
const count = Math.floor(clamp01((time - 7000) / 3600) * 64);
|
||||
for (let index = 0; index < 64; index += 1) {
|
||||
const [x, y] = point(cx, cy, ringRadius, -90 + index * 360 / 64);
|
||||
this.node(x, y, 1.4, GOLD, alpha * 0.14);
|
||||
if (index < count) {
|
||||
const freshness = Math.max(0, 1 - (count - 1 - index) / 5);
|
||||
if (freshness > 0) {
|
||||
const ctx = this.context;
|
||||
const gradient = ctx.createLinearGradient(cx, cy, x, y);
|
||||
gradient.addColorStop(0, "rgba(128,83,62,0)");
|
||||
gradient.addColorStop(1, GOLD);
|
||||
this.line(cx, cy, x, y, gradient, alpha * freshness * 0.35);
|
||||
}
|
||||
this.drawGua(x, y, hexWidth, hexLine, hexBits(index), alpha * (0.55 + 0.45 * freshness), freshness * 9);
|
||||
}
|
||||
}
|
||||
if (count > 0) {
|
||||
const current = count - 1;
|
||||
const popTime = clamp01((time - (7000 + current * 3600 / 64)) / 130);
|
||||
const pop = 1 + 0.22 * (1 - popTime);
|
||||
this.drawGua(cx, cy - scale * 0.028, scale * 0.085 * pop, Math.max(scale * 0.011, 4.5), hexBits(current), alpha, 16);
|
||||
this.label(HEXAGRAM_NAMES[current], cx, cy + scale * 0.062, Math.max(20, scale * 0.042), GOLD_BRIGHT, alpha, "600");
|
||||
this.label(`第 ${current + 1} 卦`, cx, cy + scale * 0.105, 13, GOLD, alpha * 0.55);
|
||||
}
|
||||
}
|
||||
if (time >= 10800) {
|
||||
const alpha = this.stageAlpha(time, 10800, HEX_TOTAL, 420);
|
||||
const progress = easeOut((time - 10850) / 1150);
|
||||
const radius = scale * 0.365 * (1 - progress);
|
||||
for (let index = 0; index < 64 && radius >= 8; index += 1) {
|
||||
const [x, y] = point(cx, cy, radius, -90 + index * 360 / 64);
|
||||
this.drawGua(x, y, Math.max(scale * 0.026, 13), Math.max(scale * 0.0042, 1.6), hexBits(index), (1 - progress) * 0.7 * alpha);
|
||||
}
|
||||
this.node(cx, cy, 3 + progress * 6, GOLD_BRIGHT, alpha * (0.3 + 0.7 * progress), 12 + progress * 40);
|
||||
}
|
||||
this.drawFooter(time, now, HEX_TOTAL, HEX_STAGES, "hexagram");
|
||||
}
|
||||
|
||||
drawFortune(time, now) {
|
||||
const { width, height } = this;
|
||||
const cx = width / 2;
|
||||
const cy = height * 0.44;
|
||||
const scale = Math.min(width, height);
|
||||
if (time < 2100) this.drawFiveMovements(time, now, cx, cy, scale);
|
||||
if (time >= 2100 && time < 3900) this.drawStems(time, cx, cy, scale);
|
||||
if (time >= 3900 && time < 5800) this.drawBranches(time, cx, cy, scale);
|
||||
if (time >= 5800 && time < 7900) this.drawSixQi(time, now, cx, cy, scale);
|
||||
if (time >= 7900 && time < 11000) this.drawAnnualQi(time, now, cx, cy, scale);
|
||||
if (time >= 11000) {
|
||||
const alpha = this.stageAlpha(time, 11000, FORTUNE_TOTAL, 420);
|
||||
const progress = easeOut((time - 11050) / 1200);
|
||||
const radius = scale * 0.30 * (1 - progress);
|
||||
QI6.forEach((qi, index) => {
|
||||
const [x, y] = point(cx, cy, radius, -90 + index * 60);
|
||||
if (radius > 8) this.node(x, y, Math.max(scale * 0.011, 6), ELEMENT_COLORS[qi.element], (1 - progress) * 0.8 * alpha, 8);
|
||||
});
|
||||
this.node(cx, cy, 3 + progress * 6, GOLD_BRIGHT, alpha * (0.3 + 0.7 * progress), 12 + progress * 40);
|
||||
}
|
||||
this.drawFooter(time, now, FORTUNE_TOTAL, this.fortuneStages(), "fortune");
|
||||
}
|
||||
|
||||
drawFiveMovements(time, now, cx, cy, scale) {
|
||||
const alpha = this.stageAlpha(time, 0, 2100);
|
||||
const radius = scale * 0.17;
|
||||
const nodeRadius = Math.max(scale * 0.018, 9);
|
||||
const elements = [
|
||||
["木", 180], ["火", -90], ["金", 0], ["水", 90], ["土", null],
|
||||
];
|
||||
const positions = {};
|
||||
this.node(cx, cy, 5 + 1.5 * Math.sin(now / 260), GOLD_BRIGHT, alpha * (1 - easeOut((time - 200) / 800)), 30);
|
||||
elements.forEach(([element, degrees], index) => {
|
||||
const progress = easeOut((time - 500 - index * 170) / 500);
|
||||
if (progress <= 0) return;
|
||||
const x = degrees === null ? cx : cx + Math.cos(degrees * Math.PI / 180) * radius * progress;
|
||||
const y = degrees === null ? cy : cy + Math.sin(degrees * Math.PI / 180) * radius * progress;
|
||||
positions[element] = [x, y];
|
||||
this.node(x, y, nodeRadius * progress, ELEMENT_COLORS[element], alpha * progress, 16);
|
||||
this.label(element, x, y + 0.5, Math.round(nodeRadius * 1.15), NODE_TEXT, alpha * progress, "600");
|
||||
const direction = element === "土" ? "中央土" : { 木: "东方木", 火: "南方火", 金: "西方金", 水: "北方水" }[element];
|
||||
this.label(direction, x, y + nodeRadius + 14, 12, ELEMENT_COLORS[element], alpha * progress * 0.75);
|
||||
});
|
||||
const order = ["木", "火", "土", "金", "水"];
|
||||
order.forEach((element, index) => {
|
||||
const from = positions[element];
|
||||
const to = positions[order[(index + 1) % order.length]];
|
||||
if (!from || !to) return;
|
||||
const progress = smooth(1450 + index * 130, 1700 + index * 130, time);
|
||||
const mx = (from[0] + to[0]) / 2 + (cx - (from[0] + to[0]) / 2) * 0.25;
|
||||
const my = (from[1] + to[1]) / 2 + (cy - (from[1] + to[1]) / 2) * 0.25;
|
||||
this.curvedArrow(from[0], from[1], to[0], to[1], mx, my, GOLD, alpha * progress * 0.4);
|
||||
});
|
||||
}
|
||||
|
||||
drawStems(time, cx, cy, scale) {
|
||||
const alpha = this.stageAlpha(time, 2100, 3900);
|
||||
const stems = "甲乙丙丁戊己庚辛壬癸";
|
||||
const movements = ["土", "金", "水", "木", "火"];
|
||||
const radius = scale * 0.30;
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
const progress = smooth(2150 + index * 90, 2450 + index * 90, time);
|
||||
if (progress <= 0) continue;
|
||||
const [x, y] = point(cx, cy, radius, -90 + index * 36);
|
||||
const element = movements[index % 5];
|
||||
this.node(x, y, 3, ELEMENT_COLORS[element], alpha * progress, 8);
|
||||
this.label(stems[index], x, y - 14, 15, ELEMENT_COLORS[element], alpha * progress, "600");
|
||||
}
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
const progress = smooth(3150 + index * 110, 3450 + index * 110, time);
|
||||
const angle = -90 + index * 36;
|
||||
const [x1, y1] = point(cx, cy, radius, angle);
|
||||
const [x2, y2] = point(cx, cy, radius, -90 + (index + 5) * 36);
|
||||
this.line(x1, y1, x2, y2, ELEMENT_COLORS[movements[index]], alpha * progress * 0.45);
|
||||
const [labelX, labelY] = point(cx, cy, scale * 0.055, angle + 90);
|
||||
this.label(movements[index], labelX, labelY, 16, ELEMENT_COLORS[movements[index]], alpha * progress, "600");
|
||||
}
|
||||
}
|
||||
|
||||
drawBranches(time, cx, cy, scale) {
|
||||
const alpha = this.stageAlpha(time, 3900, 5800);
|
||||
const branches = "子丑寅卯辰巳午未申酉戌亥";
|
||||
const qiNames = ["少阴君火", "太阴湿土", "少阳相火", "阳明燥金", "太阳寒水", "厥阴风木"];
|
||||
const radius = scale * 0.31;
|
||||
const branchAngle = (index) => -90 + ((index - 6 + 12) % 12) * 30;
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
const progress = smooth(3950 + index * 70, 4220 + index * 70, time);
|
||||
const [x, y] = point(cx, cy, radius, branchAngle(index));
|
||||
this.node(x, y, 2.5, GOLD, alpha * progress, 6);
|
||||
this.label(branches[index], x, y - 13, 14, GOLD, alpha * progress * 0.9);
|
||||
}
|
||||
qiNames.forEach((name, index) => {
|
||||
const progress = smooth(4900 + index * 130, 5200 + index * 130, time);
|
||||
const [x1, y1] = point(cx, cy, radius, branchAngle(index));
|
||||
const [x2, y2] = point(cx, cy, radius, branchAngle(index + 6));
|
||||
const element = QI6.find((item) => item.name === name)?.element || "土";
|
||||
this.line(x1, y1, x2, y2, ELEMENT_COLORS[element], alpha * progress * 0.4);
|
||||
const [labelX, labelY] = point(cx, cy, radius + scale * 0.055, branchAngle(index));
|
||||
this.label(name, labelX, labelY, 12, ELEMENT_COLORS[element], alpha * progress, "600");
|
||||
});
|
||||
}
|
||||
|
||||
drawSixQi(time, now, cx, cy, scale) {
|
||||
const alpha = this.stageAlpha(time, 5800, 7900);
|
||||
const radius = scale * 0.27;
|
||||
const drift = now * 0.004;
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha * 0.13;
|
||||
ctx.strokeStyle = GOLD;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
QI6.forEach((qi, index) => {
|
||||
const progress = easeOut((time - 5850 - index * 180) / 550);
|
||||
const [x, y] = point(cx, cy, radius * progress, -90 + index * 60 + drift);
|
||||
const nodeRadius = Math.max(scale * 0.015, 8) * progress;
|
||||
this.node(x, y, nodeRadius, ELEMENT_COLORS[qi.element], alpha * progress, 14);
|
||||
this.label(qi.name, x, y - nodeRadius - 12, 13, ELEMENT_COLORS[qi.element], alpha * progress, "600");
|
||||
this.label(["初之气", "二之气", "三之气", "四之气", "五之气", "终之气"][index], x, y + nodeRadius + 12, 10.5, DIM, alpha * progress * 0.9);
|
||||
});
|
||||
this.node(cx, cy, 4 + Math.sin(now / 300), GOLD_BRIGHT, alpha * 0.9, 24);
|
||||
}
|
||||
|
||||
drawAnnualQi(time, now, cx, cy, scale) {
|
||||
const alpha = this.stageAlpha(time, 7900, 11000, 350);
|
||||
const sixQi = this.data.sixQi || {};
|
||||
const pillar = this.data.yearPillar || "岁运";
|
||||
const movement = this.data.movement || "中运合参";
|
||||
const sitian = sixQi.sitian || "司天气候";
|
||||
const zaiquan = sixQi.zaiquan || "在泉气化";
|
||||
const currentStep = Math.max(1, Math.min(6, Number(sixQi.step) || 1));
|
||||
const qiElement = (name) => QI6.find((item) => item.name === name)?.element || "土";
|
||||
const movementElement = ["木", "火", "土", "金", "水"].find((element) => movement.includes(element)) || "土";
|
||||
this.label("司 天", cx, cy - scale * 0.212, 11, DIM, alpha * smooth(7950, 8450, time));
|
||||
this.label(sitian, cx, cy - scale * 0.178, 17, ELEMENT_COLORS[qiElement(sitian)], alpha * smooth(7950, 8450, time), "600");
|
||||
this.label(zaiquan, cx, cy + scale * 0.178, 17, ELEMENT_COLORS[qiElement(zaiquan)], alpha * smooth(8200, 8700, time), "600");
|
||||
this.label("在 泉", cx, cy + scale * 0.212, 11, DIM, alpha * smooth(8200, 8700, time));
|
||||
this.label(pillar, cx, cy - scale * 0.012, Math.max(22, scale * 0.052), GOLD_BRIGHT, alpha * smooth(8500, 9100, time), "600");
|
||||
this.label(`${pillar}年 · 中运${movement}`, cx, cy + scale * 0.052, 14, ELEMENT_COLORS[movementElement], alpha * smooth(8500, 9100, time), "600", scale * 0.62);
|
||||
const radius = scale * 0.30;
|
||||
QI6.forEach((qi, index) => {
|
||||
const progress = smooth(9200 + index * 260, 9480 + index * 260, time);
|
||||
const [x, y] = point(cx, cy, radius, -90 + index * 60);
|
||||
const current = index + 1 === currentStep;
|
||||
const pulse = current ? 0.5 + 0.5 * Math.sin(now / 230) : 0;
|
||||
this.node(x, y, Math.max(scale * 0.011, 6) + (current ? 2.5 : 0), ELEMENT_COLORS[qi.element], alpha * progress, 12 + pulse * 14);
|
||||
if (current) {
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha * (0.35 + pulse * 0.35);
|
||||
ctx.strokeStyle = CINNABAR;
|
||||
ctx.lineWidth = 1.2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, Math.max(scale * 0.02, 11) + pulse * 3, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
this.label("当今", x, y - Math.max(scale * 0.038, 21), 10.5, CINNABAR, alpha * progress, "600");
|
||||
}
|
||||
const stepName = `${index + 1 === 6 ? "终" : ["初", "二", "三", "四", "五"][index]}之气`;
|
||||
this.label(`${stepName} · ${qi.name}`, x, y + Math.max(scale * 0.03, 17), 11.5, current ? GOLD_BRIGHT : ELEMENT_COLORS[qi.element], alpha * progress * (current ? 1 : 0.85), current ? "600" : "");
|
||||
if (current) this.label(STEP_RANGES[index], x, y + Math.max(scale * 0.052, 33), 10, DIM, alpha * progress);
|
||||
});
|
||||
}
|
||||
|
||||
drawCompletion(progress, now) {
|
||||
this.drawBackground(now);
|
||||
if (this.scene === "fortune") {
|
||||
this.drawFortune(11000 + progress * (FORTUNE_TOTAL - 11000), now);
|
||||
} else {
|
||||
this.drawHexagram(10800 + progress * (HEX_TOTAL - 10800), now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
global.HeavenLoadingCanvas = HeavenLoadingCanvas;
|
||||
})(window);
|
||||
@@ -1,4 +0,0 @@
|
||||
window.XiaobaiPageModules.register("auction", ["auctionView"], {
|
||||
enter: ["loadAuction"],
|
||||
leave: ["clearAuction"],
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
window.XiaobaiPageModules.register("dragon_tiger", ["dragonView"], {
|
||||
enter: ["loadDragonTiger"],
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
window.XiaobaiPageModules.register("heaven", ["heavenView"], {
|
||||
enter: ["loadHeaven"],
|
||||
leave: ["stopHeaven"],
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
window.XiaobaiPageModules.register("ladder", ["ladderView"]);
|
||||
@@ -1,3 +0,0 @@
|
||||
window.XiaobaiPageModules.register("mentor", ["mentorView"], {
|
||||
enter: ["loadMentor"],
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
window.XiaobaiPageModules.register("pools", [
|
||||
"limitPool",
|
||||
"brokenView",
|
||||
"downView",
|
||||
"yesterdayView",
|
||||
"performanceView",
|
||||
]);
|
||||
@@ -1,3 +0,0 @@
|
||||
window.XiaobaiPageModules.register("popularity", ["popularityView"], {
|
||||
enter: ["loadPopularity"],
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
window.XiaobaiPageModules.register("review", ["reviewWorkspaceView"], {
|
||||
enter: ["loadReview"],
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
window.XiaobaiPageModules.register("rotation", ["rotationView"], {
|
||||
enter: ["loadRotation"],
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
window.XiaobaiPageModules.register("screener", ["screenerView"], {
|
||||
enter: ["loadScreener"],
|
||||
});
|
||||
|
||||
window.XiaobaiPageModules.register("screener", ["screenerTrackingView"]);
|
||||
@@ -1,3 +0,0 @@
|
||||
window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], {
|
||||
enter: ["loadSentiment"],
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
window.XiaobaiPageModules.register("themes", ["themeLibraryView"], {
|
||||
enter: ["loadThemes"],
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
"""Test support package for the preserved application."""
|
||||
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
ORIGINAL_ROOT = APP_ROOT.parent
|
||||
FRONTEND_ROOT = APP_ROOT / "frontend"
|
||||
ORIGINAL_STATIC = ORIGINAL_ROOT / "static"
|
||||
|
||||
SOURCE_RANGE = re.compile(
|
||||
r"/\* PRESERVATION-SOURCE-BEGIN app\.js:(\d+)-(\d+) \*/\n"
|
||||
r"(.*?)"
|
||||
r"/\* PRESERVATION-SOURCE-END app\.js:\1-\2 \*/\n?",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
# These exact original app.js line ranges were retired in slice 11 after the
|
||||
# definition-only symbols passed static, runtime, and compatibility review.
|
||||
RETIRED_FRONTEND_SOURCE_RANGES = (
|
||||
(3537, 3540),
|
||||
(4139, 4145),
|
||||
(4973, 4989),
|
||||
(9022, 9027),
|
||||
(9052, 9055),
|
||||
)
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def reassembled_frontend_runtime() -> str:
|
||||
chunks: dict[tuple[int, int], str] = {}
|
||||
for path in FRONTEND_ROOT.rglob("*.js"):
|
||||
for match in SOURCE_RANGE.finditer(path.read_text(encoding="utf-8")):
|
||||
source_range = (int(match.group(1)), int(match.group(2)))
|
||||
if source_range in chunks:
|
||||
raise AssertionError(f"duplicate app.js source range: {source_range}")
|
||||
chunks[source_range] = match.group(3)
|
||||
|
||||
assembled: list[str] = []
|
||||
next_line = 1
|
||||
for (start, end), content in sorted(chunks.items()):
|
||||
if start != next_line:
|
||||
raise AssertionError(
|
||||
f"app.js source coverage gap: expected line {next_line}, got {start}"
|
||||
)
|
||||
assembled.append(content)
|
||||
next_line = end + 1
|
||||
|
||||
original_line_count = len(
|
||||
(ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8").splitlines()
|
||||
)
|
||||
if next_line != original_line_count + 1:
|
||||
raise AssertionError(
|
||||
f"app.js source coverage ended at {next_line - 1}, expected {original_line_count}"
|
||||
)
|
||||
return "".join(assembled)
|
||||
|
||||
|
||||
def original_runtime_after_audited_retirements() -> str:
|
||||
lines = (ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8").splitlines(
|
||||
keepends=True
|
||||
)
|
||||
retired = {
|
||||
line_number
|
||||
for start, end in RETIRED_FRONTEND_SOURCE_RANGES
|
||||
for line_number in range(start, end + 1)
|
||||
}
|
||||
return "".join(
|
||||
line for line_number, line in enumerate(lines, start=1) if line_number not in retired
|
||||
)
|
||||
|
||||
|
||||
def assert_frontend_runtime_matches_audited_baseline(testcase) -> None:
|
||||
testcase.assertEqual(
|
||||
reassembled_frontend_runtime(),
|
||||
original_runtime_after_audited_retirements(),
|
||||
)
|
||||
|
||||
|
||||
def assert_moved_asset_matches(
|
||||
testcase,
|
||||
original_relative: str,
|
||||
frontend_relative: str | None = None,
|
||||
) -> None:
|
||||
target_relative = frontend_relative or original_relative
|
||||
testcase.assertEqual(
|
||||
sha256(FRONTEND_ROOT / target_relative),
|
||||
sha256(ORIGINAL_STATIC / original_relative),
|
||||
original_relative,
|
||||
)
|
||||
|
||||
|
||||
def assert_page_prefix_matches(testcase, page_relative: str) -> None:
|
||||
original = (ORIGINAL_STATIC / page_relative).read_text(encoding="utf-8")
|
||||
migrated = (FRONTEND_ROOT / page_relative).read_text(encoding="utf-8")
|
||||
testcase.assertTrue(
|
||||
migrated.startswith(original.rstrip("\n") + "\n\n"),
|
||||
page_relative,
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from database import ReviewDatabase
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
FRONTEND_ROOT = APP_ROOT / "frontend"
|
||||
|
||||
|
||||
class CleanupContractTests(unittest.TestCase):
|
||||
def test_retired_files_stay_absent(self) -> None:
|
||||
self.assertFalse((APP_ROOT / "demo_data.py").exists())
|
||||
self.assertFalse((FRONTEND_ROOT / "heaven-loading.js").exists())
|
||||
|
||||
def test_only_active_heaven_loading_asset_is_loaded(self) -> None:
|
||||
html = (FRONTEND_ROOT / "index.html").read_text(encoding="utf-8")
|
||||
self.assertIn('src="/pages/heaven/loading-v2.js', html)
|
||||
self.assertNotIn('src="/heaven-loading.js', html)
|
||||
|
||||
def test_audited_definition_only_functions_stay_absent(self) -> None:
|
||||
runtime = "\n".join(
|
||||
path.read_text(encoding="utf-8")
|
||||
for path in FRONTEND_ROOT.rglob("*.js")
|
||||
if "vendor" not in path.parts
|
||||
)
|
||||
for symbol in (
|
||||
"commonReviewColumns",
|
||||
"outcomeClass",
|
||||
"screenerResultMatchesSelection",
|
||||
"selectRegime",
|
||||
"showHeartRitualCurtain",
|
||||
):
|
||||
with self.subTest(symbol=symbol):
|
||||
self.assertNotIn(symbol, runtime)
|
||||
|
||||
def test_wencai_history_compatibility_is_retained(self) -> None:
|
||||
for method in (
|
||||
"list_wencai_saved_queries",
|
||||
"save_wencai_query",
|
||||
"delete_wencai_saved_query",
|
||||
):
|
||||
with self.subTest(method=method):
|
||||
self.assertTrue(hasattr(ReviewDatabase, method))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,14 +6,14 @@ from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STATIC = ROOT / "static"
|
||||
STATIC = ROOT / "frontend"
|
||||
TOKENS = STATIC / "shared" / "tokens.css"
|
||||
LEGACY_STYLESHEETS = (
|
||||
"styles.css",
|
||||
"renovation.css",
|
||||
"redesign-v2.css",
|
||||
"design-system.css",
|
||||
"theme.css",
|
||||
"styles/styles.css",
|
||||
"styles/renovation.css",
|
||||
"styles/redesign-v2.css",
|
||||
"styles/design-system.css",
|
||||
"styles/theme.css",
|
||||
)
|
||||
|
||||
|
||||
@@ -26,12 +26,12 @@ class CssGovernanceTests(unittest.TestCase):
|
||||
def test_token_layer_loads_before_application_styles(self) -> None:
|
||||
expected_order = (
|
||||
"/shared/tokens.css",
|
||||
"/styles.css",
|
||||
"/renovation.css",
|
||||
"/redesign-v2.css",
|
||||
"/design-system.css",
|
||||
"/theme.css",
|
||||
"/wentian-v2.css",
|
||||
"/styles/styles.css",
|
||||
"/styles/renovation.css",
|
||||
"/styles/redesign-v2.css",
|
||||
"/styles/design-system.css",
|
||||
"/styles/theme.css",
|
||||
"/pages/heaven/page.css",
|
||||
)
|
||||
positions = [self.html.index(path) for path in expected_order]
|
||||
self.assertEqual(positions, sorted(positions))
|
||||
@@ -63,7 +63,7 @@ class CssGovernanceTests(unittest.TestCase):
|
||||
|
||||
def test_wentian_tokens_remain_isolated(self) -> None:
|
||||
self.assertNotRegex(self.tokens, r"--wt-[a-z0-9-]+\s*:")
|
||||
wentian = (STATIC / "wentian-v2.css").read_text(encoding="utf-8")
|
||||
wentian = (STATIC / "pages" / "heaven" / "page.css").read_text(encoding="utf-8")
|
||||
self.assertRegex(wentian, r"--wt-[a-z0-9-]+\s*:")
|
||||
|
||||
def test_compatibility_aliases_cover_historical_layers(self) -> None:
|
||||
|
||||
@@ -5,9 +5,11 @@ import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from tests.preservation_helpers import reassembled_frontend_runtime
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STATIC = ROOT / "static"
|
||||
STATIC = ROOT / "frontend"
|
||||
|
||||
|
||||
class FrontendBoundaryTests(unittest.TestCase):
|
||||
@@ -22,7 +24,7 @@ class FrontendBoundaryTests(unittest.TestCase):
|
||||
|
||||
def test_shared_dependencies_load_before_application(self) -> None:
|
||||
html = (STATIC / "index.html").read_text(encoding="utf-8")
|
||||
ui_position = html.index('/ui-core.js')
|
||||
ui_position = html.index('/shared/ui-core.js')
|
||||
components_position = html.index('/shared/components.js')
|
||||
pages_position = html.index('/pages.config.js')
|
||||
runtime_position = html.index('/pages/runtime.js')
|
||||
@@ -40,7 +42,7 @@ class FrontendBoundaryTests(unittest.TestCase):
|
||||
self.assertLess(shell_position, app_position)
|
||||
|
||||
def test_application_state_is_created_through_shared_boundary(self) -> None:
|
||||
app = (STATIC / "app.js").read_text(encoding="utf-8")
|
||||
app = reassembled_frontend_runtime()
|
||||
self.assertIn("const state = window.XiaobaiState.create({", app)
|
||||
self.assertNotIn("const state = {", app)
|
||||
|
||||
@@ -70,7 +72,7 @@ class FrontendBoundaryTests(unittest.TestCase):
|
||||
self.assertEqual(actual, expected)
|
||||
|
||||
def test_shell_owns_navigation_and_page_mounting(self) -> None:
|
||||
app = (STATIC / "app.js").read_text(encoding="utf-8")
|
||||
app = reassembled_frontend_runtime()
|
||||
shell = (STATIC / "shared" / "shell.js").read_text(encoding="utf-8")
|
||||
self.assertNotIn("function syncNavigationState", app)
|
||||
self.assertNotIn("function initializeApplicationShell", app)
|
||||
@@ -109,7 +111,7 @@ class FrontendBoundaryTests(unittest.TestCase):
|
||||
self.assertEqual(actual, expected)
|
||||
|
||||
def test_page_lifecycle_is_owned_outside_application_monolith(self) -> None:
|
||||
app = (STATIC / "app.js").read_text(encoding="utf-8")
|
||||
app = reassembled_frontend_runtime()
|
||||
runtime = (STATIC / "pages" / "runtime.js").read_text(encoding="utf-8")
|
||||
start = app.index("function openView(")
|
||||
end = app.index("\nfunction initializeAutoTableSorting", start)
|
||||
@@ -122,7 +124,7 @@ class FrontendBoundaryTests(unittest.TestCase):
|
||||
|
||||
def test_shared_empty_state_component_is_used_by_multiple_features(self) -> None:
|
||||
components = (STATIC / "shared" / "components.js").read_text(encoding="utf-8")
|
||||
app = (STATIC / "app.js").read_text(encoding="utf-8")
|
||||
app = reassembled_frontend_runtime()
|
||||
self.assertIn("function emptyStateHtml(message, options = {})", components)
|
||||
self.assertIn("function renderEmptyState(target, message, options = {})", components)
|
||||
self.assertGreaterEqual(app.count("renderEmptyState("), 8)
|
||||
|
||||
@@ -5,8 +5,10 @@ import unittest
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
from tests.preservation_helpers import reassembled_frontend_runtime
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parents[1] / "static"
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parents[1] / "frontend"
|
||||
|
||||
|
||||
class IdCollector(HTMLParser):
|
||||
@@ -22,11 +24,11 @@ class FrontendContractTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
|
||||
cls.script = (STATIC_DIR / "app.js").read_text(encoding="utf-8")
|
||||
cls.script = reassembled_frontend_runtime()
|
||||
cls.shell = (STATIC_DIR / "shared" / "shell.js").read_text(encoding="utf-8")
|
||||
cls.ui_core = (STATIC_DIR / "ui-core.js").read_text(encoding="utf-8")
|
||||
cls.design_system = (STATIC_DIR / "design-system.css").read_text(encoding="utf-8")
|
||||
cls.theme = (STATIC_DIR / "theme.css").read_text(encoding="utf-8")
|
||||
cls.ui_core = (STATIC_DIR / "shared" / "ui-core.js").read_text(encoding="utf-8")
|
||||
cls.design_system = (STATIC_DIR / "styles" / "design-system.css").read_text(encoding="utf-8")
|
||||
cls.theme = (STATIC_DIR / "styles" / "theme.css").read_text(encoding="utf-8")
|
||||
cls.tokens = (STATIC_DIR / "shared" / "tokens.css").read_text(encoding="utf-8")
|
||||
collector = IdCollector()
|
||||
collector.feed(cls.html)
|
||||
@@ -158,7 +160,7 @@ class FrontendContractTests(unittest.TestCase):
|
||||
self.assertIn("必需数据已完整,本日没有股票同时满足", self.script)
|
||||
|
||||
def test_dialogs_and_dark_table_hover_have_shared_safety_constraints(self):
|
||||
redesign = (STATIC_DIR / "redesign-v2.css").read_text(encoding="utf-8")
|
||||
redesign = (STATIC_DIR / "styles" / "redesign-v2.css").read_text(encoding="utf-8")
|
||||
self.assertIn(".settings-dialog:not(.heaven-reading-dialog)[open] { margin: auto; }", redesign)
|
||||
self.assertIn("max-height: min(760px, calc(100dvh - 28px));", redesign)
|
||||
self.assertIn('#reviewWorkspaceView .data-table tbody tr:hover td', self.theme)
|
||||
@@ -166,8 +168,8 @@ class FrontendContractTests(unittest.TestCase):
|
||||
self.assertIn('#screenerView .screener-result-frame tbody tr:hover td:last-child', self.theme)
|
||||
|
||||
def test_global_toast_has_one_owner_and_cannot_stretch_between_insets(self):
|
||||
styles = (STATIC_DIR / "styles.css").read_text(encoding="utf-8")
|
||||
wentian = (STATIC_DIR / "wentian-v2.css").read_text(encoding="utf-8")
|
||||
styles = (STATIC_DIR / "styles" / "styles.css").read_text(encoding="utf-8")
|
||||
wentian = (STATIC_DIR / "pages" / "heaven" / "page.css").read_text(encoding="utf-8")
|
||||
self.assertIn("#toast.toast {", styles)
|
||||
self.assertIn("top: auto;", styles)
|
||||
self.assertIn("left: auto;", styles)
|
||||
@@ -183,7 +185,7 @@ class FrontendContractTests(unittest.TestCase):
|
||||
|
||||
def test_shared_ui_core_loads_before_application(self):
|
||||
self.assertLess(
|
||||
self.html.index('<script src="/ui-core.js"'),
|
||||
self.html.index('<script src="/shared/ui-core.js"'),
|
||||
self.html.index('<script src="/app.js'),
|
||||
)
|
||||
for function_name in (
|
||||
|
||||
@@ -4,6 +4,7 @@ import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from server import DashboardService
|
||||
from tests.preservation_helpers import reassembled_frontend_runtime
|
||||
|
||||
|
||||
class SearchDatabaseStub:
|
||||
@@ -77,9 +78,9 @@ class GlobalSearchTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_frontend_reuses_full_stock_detail_and_renders_market_daily_k(self):
|
||||
static_dir = Path(__file__).resolve().parents[1] / "static"
|
||||
static_dir = Path(__file__).resolve().parents[1] / "frontend"
|
||||
html = (static_dir / "index.html").read_text(encoding="utf-8")
|
||||
script = (static_dir / "app.js").read_text(encoding="utf-8")
|
||||
script = reassembled_frontend_runtime()
|
||||
|
||||
self.assertIn('id="globalSearchButton"', html)
|
||||
self.assertIn('id="globalSearchDialog"', html)
|
||||
|
||||
@@ -23,7 +23,7 @@ class AccountSliceStructureTests(unittest.TestCase):
|
||||
|
||||
def test_runtime_paths_still_point_at_app_root(self) -> None:
|
||||
self.assertEqual(APP_DIR, Path(__file__).resolve().parents[1])
|
||||
self.assertEqual(STATIC_DIR, APP_DIR / "static")
|
||||
self.assertEqual(STATIC_DIR, APP_DIR / "frontend")
|
||||
|
||||
def test_account_persistence_and_http_transport_have_single_owners(self) -> None:
|
||||
for method in (
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
|
||||
from backend.bootstrap.config import APP_DIR, STATIC_DIR
|
||||
from tests.preservation_helpers import (
|
||||
FRONTEND_ROOT,
|
||||
ORIGINAL_STATIC,
|
||||
assert_frontend_runtime_matches_audited_baseline,
|
||||
assert_moved_asset_matches,
|
||||
assert_page_prefix_matches,
|
||||
)
|
||||
|
||||
|
||||
class FrontendPreservationSliceTests(unittest.TestCase):
|
||||
def test_split_runtime_matches_original_except_audited_retirements(self) -> None:
|
||||
assert_frontend_runtime_matches_audited_baseline(self)
|
||||
|
||||
def test_index_diff_is_limited_to_asset_relocation_and_split_loading(self) -> None:
|
||||
migrated = (FRONTEND_ROOT / "index.html").read_text(encoding="utf-8")
|
||||
restored = migrated
|
||||
for current, original in (
|
||||
("/styles/styles.css", "/styles.css"),
|
||||
("/styles/renovation.css", "/renovation.css"),
|
||||
("/styles/redesign-v2.css", "/redesign-v2.css"),
|
||||
("/styles/design-system.css", "/design-system.css"),
|
||||
("/styles/theme.css", "/theme.css"),
|
||||
("/pages/heaven/page.css", "/wentian-v2.css"),
|
||||
("/shared/ui-core.js", "/ui-core.js"),
|
||||
("/pages/heaven/loading-v2.js", "/heaven-loading-v2.js"),
|
||||
):
|
||||
restored = restored.replace(current, original)
|
||||
restored = restored.replace(
|
||||
' <script src="/pages/market/runtime.js?v=20260731-1" defer></script>\n',
|
||||
"",
|
||||
)
|
||||
restored = restored.replace(
|
||||
' <script src="/shared/export.js?v=20260731-1" defer></script>\n',
|
||||
"",
|
||||
)
|
||||
self.assertEqual(
|
||||
restored,
|
||||
(ORIGINAL_STATIC / "index.html").read_text(encoding="utf-8"),
|
||||
)
|
||||
|
||||
def test_complete_stylesheet_stack_is_byte_identical_after_relocation(self) -> None:
|
||||
for original, migrated in (
|
||||
("shared/tokens.css", "shared/tokens.css"),
|
||||
("styles.css", "styles/styles.css"),
|
||||
("renovation.css", "styles/renovation.css"),
|
||||
("redesign-v2.css", "styles/redesign-v2.css"),
|
||||
("design-system.css", "styles/design-system.css"),
|
||||
("theme.css", "styles/theme.css"),
|
||||
("wentian-v2.css", "pages/heaven/page.css"),
|
||||
):
|
||||
with self.subTest(asset=original):
|
||||
assert_moved_asset_matches(self, original, migrated)
|
||||
|
||||
def test_shared_vendor_and_animation_assets_are_byte_identical(self) -> None:
|
||||
for original, migrated in (
|
||||
("ui-core.js", "shared/ui-core.js"),
|
||||
("heaven-loading-v2.js", "pages/heaven/loading-v2.js"),
|
||||
("vendor/lucide.min.js", "vendor/lucide.min.js"),
|
||||
("pages.config.js", "pages.config.js"),
|
||||
("pages/runtime.js", "pages/runtime.js"),
|
||||
("shared/api.js", "shared/api.js"),
|
||||
("shared/components.js", "shared/components.js"),
|
||||
("shared/shell.js", "shared/shell.js"),
|
||||
("shared/state.js", "shared/state.js"),
|
||||
):
|
||||
with self.subTest(asset=original):
|
||||
assert_moved_asset_matches(self, original, migrated)
|
||||
|
||||
def test_original_page_registration_prefixes_are_preserved(self) -> None:
|
||||
for path in sorted((ORIGINAL_STATIC / "pages").glob("*/page.js")):
|
||||
relative = path.relative_to(ORIGINAL_STATIC).as_posix()
|
||||
with self.subTest(page=relative):
|
||||
assert_page_prefix_matches(self, relative)
|
||||
|
||||
def test_frontend_is_the_only_served_static_root(self) -> None:
|
||||
self.assertEqual(STATIC_DIR, APP_DIR / "frontend")
|
||||
self.assertFalse((APP_DIR / "static").exists())
|
||||
|
||||
def test_shared_api_remains_the_only_fetch_exit(self) -> None:
|
||||
consumers = []
|
||||
for path in FRONTEND_ROOT.rglob("*.js"):
|
||||
if "vendor" in path.parts:
|
||||
continue
|
||||
if re.search(r"\bfetch\s*\(", path.read_text(encoding="utf-8")):
|
||||
consumers.append(path.relative_to(FRONTEND_ROOT).as_posix())
|
||||
self.assertEqual(consumers, ["shared/api.js"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -5,6 +5,12 @@ import hashlib
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from tests.preservation_helpers import (
|
||||
assert_frontend_runtime_matches_audited_baseline,
|
||||
assert_moved_asset_matches,
|
||||
assert_page_prefix_matches,
|
||||
)
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
ORIGINAL_ROOT = APP_ROOT.parent
|
||||
@@ -73,19 +79,14 @@ class LadderRotationSliceSourceEquivalenceTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_api_and_frontend_assets_are_unchanged(self) -> None:
|
||||
for relative in (
|
||||
"config/api.config.json",
|
||||
"static/index.html",
|
||||
"static/app.js",
|
||||
"static/styles.css",
|
||||
"static/pages/ladder/page.js",
|
||||
"static/pages/rotation/page.js",
|
||||
):
|
||||
self.assertEqual(
|
||||
sha256(APP_ROOT / relative),
|
||||
sha256(ORIGINAL_ROOT / relative),
|
||||
relative,
|
||||
)
|
||||
self.assertEqual(
|
||||
sha256(APP_ROOT / "config/api.config.json"),
|
||||
sha256(ORIGINAL_ROOT / "config/api.config.json"),
|
||||
)
|
||||
assert_frontend_runtime_matches_audited_baseline(self)
|
||||
assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
|
||||
for page in ("pages/ladder/page.js", "pages/rotation/page.js"):
|
||||
assert_page_prefix_matches(self, page)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -13,6 +13,10 @@ from backend.data import realtime
|
||||
from backend.data.providers import ifind_client as canonical_ifind
|
||||
from backend.data.providers import tushare_client as canonical_tushare
|
||||
from backend.features.market import charts
|
||||
from tests.preservation_helpers import (
|
||||
assert_frontend_runtime_matches_audited_baseline,
|
||||
assert_moved_asset_matches,
|
||||
)
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -146,21 +150,16 @@ class MarketSliceSourceEquivalenceTests(unittest.TestCase):
|
||||
top_level_definitions(APP_ROOT / "backend/features/market/charts.py"),
|
||||
)
|
||||
|
||||
def test_unchanged_frontend_assets_match_the_original(self) -> None:
|
||||
for relative in (
|
||||
"index.html",
|
||||
"app.js",
|
||||
"styles.css",
|
||||
"renovation.css",
|
||||
"redesign-v2.css",
|
||||
"theme.css",
|
||||
"wentian-v2.css",
|
||||
def test_relocated_frontend_preserves_original_market_runtime_and_styles(self) -> None:
|
||||
assert_frontend_runtime_matches_audited_baseline(self)
|
||||
for original, migrated in (
|
||||
("styles.css", "styles/styles.css"),
|
||||
("renovation.css", "styles/renovation.css"),
|
||||
("redesign-v2.css", "styles/redesign-v2.css"),
|
||||
("theme.css", "styles/theme.css"),
|
||||
("wentian-v2.css", "pages/heaven/page.css"),
|
||||
):
|
||||
self.assertEqual(
|
||||
sha256(APP_ROOT / "static" / relative),
|
||||
sha256(ORIGINAL_ROOT / "static" / relative),
|
||||
relative,
|
||||
)
|
||||
assert_moved_asset_matches(self, original, migrated)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -7,6 +7,11 @@ from pathlib import Path
|
||||
|
||||
import market_insights
|
||||
from backend.features.market import insights as canonical_insights
|
||||
from tests.preservation_helpers import (
|
||||
assert_frontend_runtime_matches_audited_baseline,
|
||||
assert_moved_asset_matches,
|
||||
assert_page_prefix_matches,
|
||||
)
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -174,21 +179,19 @@ class MarketInsightsSliceSourceEquivalenceTests(unittest.TestCase):
|
||||
self.assertEqual(migrated[name], original[name], name)
|
||||
|
||||
def test_api_and_frontend_assets_are_unchanged(self) -> None:
|
||||
for relative in (
|
||||
"config/api.config.json",
|
||||
"static/index.html",
|
||||
"static/app.js",
|
||||
"static/styles.css",
|
||||
"static/pages/auction/page.js",
|
||||
"static/pages/themes/page.js",
|
||||
"static/pages/popularity/page.js",
|
||||
"static/pages/dragon-tiger/page.js",
|
||||
self.assertEqual(
|
||||
sha256(APP_ROOT / "config/api.config.json"),
|
||||
sha256(ORIGINAL_ROOT / "config/api.config.json"),
|
||||
)
|
||||
assert_frontend_runtime_matches_audited_baseline(self)
|
||||
assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
|
||||
for page in (
|
||||
"pages/auction/page.js",
|
||||
"pages/themes/page.js",
|
||||
"pages/popularity/page.js",
|
||||
"pages/dragon-tiger/page.js",
|
||||
):
|
||||
self.assertEqual(
|
||||
sha256(APP_ROOT / relative),
|
||||
sha256(ORIGINAL_ROOT / relative),
|
||||
relative,
|
||||
)
|
||||
assert_page_prefix_matches(self, page)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -11,6 +11,11 @@ import screener
|
||||
import strategy_tracking
|
||||
from backend.features.screener import compiler, engine, strategies, tracking
|
||||
from backend.features.screener import service as screener_service
|
||||
from tests.preservation_helpers import (
|
||||
assert_frontend_runtime_matches_audited_baseline,
|
||||
assert_moved_asset_matches,
|
||||
assert_page_prefix_matches,
|
||||
)
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -182,17 +187,9 @@ class ScreenerSliceSourceEquivalenceTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_screener_frontend_assets_are_unchanged(self) -> None:
|
||||
for relative in (
|
||||
"static/index.html",
|
||||
"static/app.js",
|
||||
"static/styles.css",
|
||||
"static/pages/screener/page.js",
|
||||
):
|
||||
self.assertEqual(
|
||||
sha256(APP_ROOT / relative),
|
||||
sha256(ORIGINAL_ROOT / relative),
|
||||
relative,
|
||||
)
|
||||
assert_frontend_runtime_matches_audited_baseline(self)
|
||||
assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
|
||||
assert_page_prefix_matches(self, "pages/screener/page.js")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -7,6 +7,11 @@ from pathlib import Path
|
||||
|
||||
import sentiment_engine
|
||||
from backend.features.sentiment import engine as canonical_engine
|
||||
from tests.preservation_helpers import (
|
||||
assert_frontend_runtime_matches_audited_baseline,
|
||||
assert_moved_asset_matches,
|
||||
assert_page_prefix_matches,
|
||||
)
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -95,19 +100,14 @@ class SentimentPoolSliceSourceEquivalenceTests(unittest.TestCase):
|
||||
self.assertIs(sentiment_engine, canonical_engine)
|
||||
|
||||
def test_api_and_frontend_assets_are_unchanged(self) -> None:
|
||||
for relative in (
|
||||
"config/api.config.json",
|
||||
"static/index.html",
|
||||
"static/app.js",
|
||||
"static/styles.css",
|
||||
"static/pages/sentiment/page.js",
|
||||
"static/pages/pools/page.js",
|
||||
):
|
||||
self.assertEqual(
|
||||
sha256(APP_ROOT / relative),
|
||||
sha256(ORIGINAL_ROOT / relative),
|
||||
relative,
|
||||
)
|
||||
self.assertEqual(
|
||||
sha256(APP_ROOT / "config/api.config.json"),
|
||||
sha256(ORIGINAL_ROOT / "config/api.config.json"),
|
||||
)
|
||||
assert_frontend_runtime_matches_audited_baseline(self)
|
||||
assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
|
||||
for page in ("pages/sentiment/page.js", "pages/pools/page.js"):
|
||||
assert_page_prefix_matches(self, page)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -75,13 +75,15 @@ def code_hotspots() -> list[dict[str, Any]]:
|
||||
"screener.py",
|
||||
"market_insights.py",
|
||||
"tushare_client.py",
|
||||
"static/index.html",
|
||||
"static/app.js",
|
||||
"static/styles.css",
|
||||
"static/redesign-v2.css",
|
||||
"static/renovation.css",
|
||||
"static/theme.css",
|
||||
"static/wentian-v2.css",
|
||||
"frontend/index.html",
|
||||
"frontend/app.js",
|
||||
"frontend/styles/styles.css",
|
||||
"frontend/styles/redesign-v2.css",
|
||||
"frontend/styles/renovation.css",
|
||||
"frontend/styles/theme.css",
|
||||
"frontend/pages/heaven/page.css",
|
||||
"frontend/pages/heaven/page.js",
|
||||
"frontend/pages/market/runtime.js",
|
||||
]
|
||||
rows = []
|
||||
for name in candidates:
|
||||
@@ -97,7 +99,7 @@ def code_hotspots() -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
def build() -> dict[str, Any]:
|
||||
html = source("static/index.html")
|
||||
html = source("frontend/index.html")
|
||||
server = source("server.py")
|
||||
database = source("database.py")
|
||||
pages = page_inventory(html)
|
||||
|
||||
@@ -27,9 +27,21 @@ def schema(connection: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def table_rows(connection: sqlite3.Connection, table: str) -> list[dict[str, Any]]:
|
||||
def table_rows(
|
||||
connection: sqlite3.Connection,
|
||||
table: str,
|
||||
excluded_columns: set[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
quoted = '"' + table.replace('"', '""') + '"'
|
||||
rows = [dict(row) for row in connection.execute(f"SELECT * FROM {quoted}").fetchall()]
|
||||
excluded_columns = excluded_columns or set()
|
||||
rows = [
|
||||
{
|
||||
key: value
|
||||
for key, value in dict(row).items()
|
||||
if key not in excluded_columns
|
||||
}
|
||||
for row in connection.execute(f"SELECT * FROM {quoted}").fetchall()
|
||||
]
|
||||
return sorted(rows, key=lambda row: json.dumps(row, ensure_ascii=False, sort_keys=True, default=str))
|
||||
|
||||
|
||||
@@ -38,9 +50,23 @@ def main() -> None:
|
||||
parser.add_argument("--original", type=Path, required=True)
|
||||
parser.add_argument("--migrated", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument(
|
||||
"--exclude-column",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="TABLE.COLUMN",
|
||||
help="exclude a nondeterministic column from one table comparison",
|
||||
)
|
||||
parser.add_argument("tables", nargs="+")
|
||||
args = parser.parse_args()
|
||||
|
||||
excluded_by_table: dict[str, set[str]] = {}
|
||||
for item in args.exclude_column:
|
||||
table, separator, column = item.partition(".")
|
||||
if not separator or not table or not column:
|
||||
parser.error("--exclude-column must use TABLE.COLUMN")
|
||||
excluded_by_table.setdefault(table, set()).add(column)
|
||||
|
||||
original = sqlite3.connect(args.original)
|
||||
migrated = sqlite3.connect(args.migrated)
|
||||
original.row_factory = sqlite3.Row
|
||||
@@ -51,8 +77,9 @@ def main() -> None:
|
||||
tables = []
|
||||
all_equal = original_schema == migrated_schema
|
||||
for table in args.tables:
|
||||
original_rows = table_rows(original, table)
|
||||
migrated_rows = table_rows(migrated, table)
|
||||
excluded_columns = excluded_by_table.get(table, set())
|
||||
original_rows = table_rows(original, table, excluded_columns)
|
||||
migrated_rows = table_rows(migrated, table, excluded_columns)
|
||||
equal = original_rows == migrated_rows
|
||||
all_equal = all_equal and equal
|
||||
tables.append(
|
||||
@@ -63,6 +90,7 @@ def main() -> None:
|
||||
"original_sha256": digest(original_rows),
|
||||
"migrated_sha256": digest(migrated_rows),
|
||||
"equal": equal,
|
||||
"excluded_columns": sorted(excluded_columns),
|
||||
}
|
||||
)
|
||||
result = {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||||
ORIGINAL_STATIC = REPOSITORY_ROOT / "static"
|
||||
FRONTEND_ROOT = REPOSITORY_ROOT / "app" / "frontend"
|
||||
EVIDENCE_PATH = (
|
||||
REPOSITORY_ROOT
|
||||
/ "docs"
|
||||
/ "migration"
|
||||
/ "evidence"
|
||||
/ "slice-10"
|
||||
/ "frontend-source-map.json"
|
||||
)
|
||||
EXPECTED_SOURCE_SHA256 = "c020d0bc68f8a96b352a0ffa62c913a0ea3f5ca0afc41f70969f833e447166a6"
|
||||
|
||||
RUNTIME_RANGES: dict[str, list[tuple[int, int]]] = {
|
||||
"pages/sentiment/page.js": [(1207, 1516)],
|
||||
"pages/pools/page.js": [(1517, 1915)],
|
||||
"pages/market/runtime.js": [(1916, 1957), (6893, 7719), (7969, 8426)],
|
||||
"pages/rotation/page.js": [(1958, 2124)],
|
||||
"pages/ladder/page.js": [(2125, 2216)],
|
||||
"pages/auction/page.js": [(2217, 2481)],
|
||||
"pages/themes/page.js": [(2482, 2583)],
|
||||
"pages/popularity/page.js": [(2584, 2659)],
|
||||
"pages/dragon-tiger/page.js": [(2660, 3028)],
|
||||
"pages/review/page.js": [(3029, 3470), (7720, 7968)],
|
||||
"pages/screener/page.js": [(3490, 4336), (6668, 6892)],
|
||||
"pages/mentor/page.js": [(4337, 4827)],
|
||||
"pages/heaven/page.js": [(4828, 6667)],
|
||||
"shared/export.js": [(8905, 9051)],
|
||||
}
|
||||
|
||||
|
||||
def digest(content: str) -> str:
|
||||
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def marker(start: int, end: int, kind: str) -> str:
|
||||
return f"/* PRESERVATION-SOURCE-{kind} app.js:{start}-{end} */"
|
||||
|
||||
|
||||
def wrapped_chunk(lines: list[str], start: int, end: int) -> str:
|
||||
content = "".join(lines[start - 1 : end])
|
||||
return f"{marker(start, end, 'BEGIN')}\n{content}{marker(start, end, 'END')}\n"
|
||||
|
||||
|
||||
def complement_ranges(total: int, moved: set[int]) -> list[tuple[int, int]]:
|
||||
ranges: list[tuple[int, int]] = []
|
||||
start = 0
|
||||
for number in range(1, total + 1):
|
||||
if number in moved:
|
||||
if start:
|
||||
ranges.append((start, number - 1))
|
||||
start = 0
|
||||
elif not start:
|
||||
start = number
|
||||
if start:
|
||||
ranges.append((start, total))
|
||||
return ranges
|
||||
|
||||
|
||||
def original_prefix(relative: str) -> str:
|
||||
source = ORIGINAL_STATIC / relative
|
||||
if not source.is_file():
|
||||
return ""
|
||||
return source.read_text(encoding="utf-8").rstrip("\n") + "\n\n"
|
||||
|
||||
|
||||
def extract_written_chunks(paths: list[Path]) -> dict[tuple[int, int], str]:
|
||||
pattern = re.compile(
|
||||
r"/\* PRESERVATION-SOURCE-BEGIN app\.js:(\d+)-(\d+) \*/\n"
|
||||
r"(.*?)"
|
||||
r"/\* PRESERVATION-SOURCE-END app\.js:\1-\2 \*/\n?",
|
||||
re.DOTALL,
|
||||
)
|
||||
chunks: dict[tuple[int, int], str] = {}
|
||||
for path in paths:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
for match in pattern.finditer(content):
|
||||
key = (int(match.group(1)), int(match.group(2)))
|
||||
if key in chunks:
|
||||
raise RuntimeError(f"Duplicate preserved range {key}")
|
||||
chunks[key] = match.group(3)
|
||||
return chunks
|
||||
|
||||
|
||||
def main() -> None:
|
||||
source_path = ORIGINAL_STATIC / "app.js"
|
||||
target_path = FRONTEND_ROOT / "app.js"
|
||||
source_bytes = source_path.read_bytes()
|
||||
source_sha256 = hashlib.sha256(source_bytes).hexdigest()
|
||||
if source_sha256 != EXPECTED_SOURCE_SHA256:
|
||||
raise RuntimeError(
|
||||
f"Original app.js changed: expected {EXPECTED_SOURCE_SHA256}, got {source_sha256}"
|
||||
)
|
||||
if not target_path.is_file():
|
||||
raise RuntimeError(f"Missing target runtime: {target_path}")
|
||||
if hashlib.sha256(target_path.read_bytes()).hexdigest() != EXPECTED_SOURCE_SHA256:
|
||||
raise RuntimeError("Target app.js is not the exact pre-split original")
|
||||
|
||||
source = source_bytes.decode("utf-8")
|
||||
lines = source.splitlines(keepends=True)
|
||||
moved_lines: set[int] = set()
|
||||
for ranges in RUNTIME_RANGES.values():
|
||||
for start, end in ranges:
|
||||
overlap = moved_lines.intersection(range(start, end + 1))
|
||||
if overlap:
|
||||
raise RuntimeError(f"Overlapping source ranges at line {min(overlap)}")
|
||||
moved_lines.update(range(start, end + 1))
|
||||
|
||||
core_ranges = complement_ranges(len(lines), moved_lines)
|
||||
target_path.write_text(
|
||||
"\n".join(wrapped_chunk(lines, start, end).rstrip("\n") for start, end in core_ranges)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
written_paths = [target_path]
|
||||
for relative, ranges in RUNTIME_RANGES.items():
|
||||
path = FRONTEND_ROOT / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
chunks = "\n".join(
|
||||
wrapped_chunk(lines, start, end).rstrip("\n") for start, end in ranges
|
||||
)
|
||||
path.write_text(original_prefix(relative) + chunks + "\n", encoding="utf-8")
|
||||
written_paths.append(path)
|
||||
|
||||
extracted = extract_written_chunks(written_paths)
|
||||
expected_ranges = {
|
||||
source_range for ranges in RUNTIME_RANGES.values() for source_range in ranges
|
||||
} | set(core_ranges)
|
||||
if set(extracted) != expected_ranges:
|
||||
raise RuntimeError("Written source ranges do not cover the original runtime exactly")
|
||||
|
||||
reassembled = [""] * len(lines)
|
||||
for (start, end), content in extracted.items():
|
||||
chunk_lines = content.splitlines(keepends=True)
|
||||
if len(chunk_lines) != end - start + 1:
|
||||
raise RuntimeError(f"Line count changed in preserved range {start}-{end}")
|
||||
reassembled[start - 1 : end] = chunk_lines
|
||||
reassembled_source = "".join(reassembled)
|
||||
if reassembled_source != source:
|
||||
raise RuntimeError("Split runtime cannot be reassembled byte-for-byte")
|
||||
|
||||
manifest = {
|
||||
"source": "static/app.js",
|
||||
"source_sha256": source_sha256,
|
||||
"source_line_count": len(lines),
|
||||
"reassembled_sha256": digest(reassembled_source),
|
||||
"all_source_lines_preserved": True,
|
||||
"core": {
|
||||
"target": "app/frontend/app.js",
|
||||
"ranges": [{"start": start, "end": end} for start, end in core_ranges],
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"target": f"app/frontend/{relative}",
|
||||
"ranges": [
|
||||
{
|
||||
"start": start,
|
||||
"end": end,
|
||||
"sha256": digest("".join(lines[start - 1 : end])),
|
||||
}
|
||||
for start, end in ranges
|
||||
],
|
||||
}
|
||||
for relative, ranges in RUNTIME_RANGES.items()
|
||||
],
|
||||
}
|
||||
EVIDENCE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
EVIDENCE_PATH.write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
print(json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -7,5 +7,7 @@
|
||||
3. [`保真迁移状态.json`](保真迁移状态.json):机器可读当前状态和下一步。
|
||||
4. [`保真迁移账本.md`](保真迁移账本.md):连续检查点、资产处置和决策记录。
|
||||
5. [`next失败冻结记录.md`](next失败冻结记录.md):失败实现的隔离边界。
|
||||
6. [`人工维护与本地切换指南.md`](人工维护与本地切换指南.md):迁移版目录、验证、数据边界、人工验收、切换与回退。
|
||||
7. [`evidence/slice-11/README.md`](evidence/slice-11/README.md):当前候选的试删审计和全量自动验收结果。
|
||||
|
||||
`重建迁移章程.md`及`next/`内阶段文档均是失败过程历史记录,不再指导后续实施。
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# 切片 10:前端 Shell、页面、样式与移动端职责归位
|
||||
|
||||
> 基线:`38de3de`(切片 09)
|
||||
> 回档标签:`xiaobai-preservation-slice-10-20260731`
|
||||
> 结论:源码、静态资产、API、数据库、桌面/移动页面及动画差分通过;最终视觉仍等待全站人工验收
|
||||
|
||||
## 1. 原实现归位
|
||||
|
||||
本切片只移动和拆分原版前端源码,没有从`next/`取用代码,也没有重写页面、样式、动画、交互或响应式规则。
|
||||
|
||||
| 原位置 | 新位置 | 处理方式 |
|
||||
|---|---|---|
|
||||
| `static/index.html` | `app/frontend/index.html` | 原 DOM 骨架,仅调整静态资源路径和拆分脚本加载顺序 |
|
||||
| `static/app.js` | `app/frontend/app.js`、`pages/*/page.js`、`pages/market/runtime.js`、`shared/export.js` | 按连续源码行机械拆分 |
|
||||
| `static/styles.css`等公共样式 | `app/frontend/styles/` | 字节级移动 |
|
||||
| `static/wentian-v2.css` | `app/frontend/pages/heaven/page.css` | 字节级移动 |
|
||||
| `static/ui-core.js` | `app/frontend/shared/ui-core.js` | 字节级移动 |
|
||||
| `static/heaven-loading-v2.js` | `app/frontend/pages/heaven/loading-v2.js` | 字节级移动 |
|
||||
| `static/pages/`、`shared/`、`vendor/` | `app/frontend/`同职责目录 | 字节级移动 |
|
||||
|
||||
`app/backend/bootstrap/config.py`现在只把静态根目录从`app/static/`切换到`app/frontend/`。原`app/static/`已不再存在,避免形成两套可被误改的前端实现。
|
||||
|
||||
## 2. 源码与静态资产等价
|
||||
|
||||
- 原`static/app.js`共 9,283 行,SHA-256 为`c020d0bc68f8a96b352a0ffa62c913a0ea3f5ca0afc41f70969f833e447166a6`。
|
||||
- 所有拆分文件中的保真源码片段按原行号重组后,SHA-256仍为同一值,9,283行无遗漏、无重排、无内容变化。
|
||||
- 七层原版样式、问天样式、加载动画、共享脚本、页面注册脚本及Lucide供应商文件在移动后保持字节一致。
|
||||
- `index.html`的差异仅限资源新路径和两个机械拆分脚本入口;反向替换后与原版逐字符一致。
|
||||
- 浏览器请求仍只有`shared/api.js`一个`fetch`出口。
|
||||
- 完整行号映射见`frontend-source-map.json`;拆分工具为`app/tools/split_frontend_runtime.py`,可重复验证但不用于运行时构建。
|
||||
|
||||
## 3. API 与数据库差分
|
||||
|
||||
- 同一管理员账号和同一固定日期下,对原版与迁移版执行21个全站只读请求。
|
||||
- 请求覆盖账号、行情、情绪、轮动、竞价、题材、人气、龙虎榜、选股、问师、问天、复盘与提醒;状态码和规范化业务JSON全部一致。
|
||||
- 两个数据库副本均包含62个schema对象,schema哈希一致。
|
||||
- 21张关键表的记录数、顺序和规范化内容逐行一致;`screener_strategies`仅排除每次初始化可能更新的`updated_at`。
|
||||
- 完整结果见`api-requests.json`、`api-diff.json`和`database-diff.json`,两份差分文件的`all_equal`均为`true`。
|
||||
|
||||
## 4. 真实浏览器差分
|
||||
|
||||
- 1920×1080日间模式检查情绪周期、集合竞价、智能选股、观势、观气、观心介绍及观心呼吸。
|
||||
- 1920×1080夜间模式检查情绪周期的背景、字体、表格、几何和横向溢出。
|
||||
- 390×844检查情绪周期与观势:移动Shell、底部五入口、页面纵向滚动、无横向溢出及问天特效均一致。
|
||||
- 观势星空节点90个、观气100个、观心110个;三页八卦节点均为2个。
|
||||
- 观势与观气保留`wt-tw`和`wt-spin`动画;观心呼吸保留`heart-incense-burn`、`heart-incense-glow`及动态波纹。
|
||||
- 观气五行行业为5组且默认折叠;观心介绍到呼吸流程可正常进入。
|
||||
- 原版和迁移版检查流程均未产生新增控制台error或warn。
|
||||
- 动画帧、焦点框和动态状态文字属于采样瞬时状态,因此截图文件哈希不要求相同;可见布局几何、计算样式、节点、动画名称和交互结果必须一致,本次均通过。
|
||||
- 两个服务使用相同主机名、不同端口时会共享并覆盖登录Cookie;曾导致迁移版切页被误判为失效。逐服务重新登录后行为一致,该问题属于并行验收环境限制,不是产品回归。
|
||||
- 机器可读记录见`browser-acceptance.json`,截图均保存在本目录。
|
||||
|
||||
## 5. 自动验证与保留边界
|
||||
|
||||
| 验证 | 结果 |
|
||||
|---|---:|
|
||||
| 前端专项测试 | 85项通过 |
|
||||
| 原版`python -m unittest discover -s tests -q` | 231项通过 |
|
||||
| 迁移版`python -m unittest discover -s tests -q` | 294项通过 |
|
||||
| `npx.cmd playwright test --reporter=dot` | 45项通过(2.3分钟) |
|
||||
| 全部拆分后JavaScript执行`node --check` | 通过 |
|
||||
| `git diff --check` | 通过 |
|
||||
|
||||
- `demo_data.py`、`frontend/heaven-loading.js`、五个疑似无引用前端函数和`wencai_saved_queries`继续保留到切片11逐项审计。
|
||||
- 没有修改`next/`、正式数据库、Docker/NAS或`8765`服务。
|
||||
- 自动差分只能证明已检查范围一致;依据迁移总纲,未经用户最终人工确认不得宣称全站视觉已经完全等价。
|
||||
@@ -0,0 +1,236 @@
|
||||
{
|
||||
"all_equal": true,
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "当前账号",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/auth/me",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "33e0c08463d5f14a142840f894d92985135c6ce2bee22c93c4dde34dc433cf5b",
|
||||
"migrated_sha256": "33e0c08463d5f14a142840f894d92985135c6ce2bee22c93c4dde34dc433cf5b",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "账号状态",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/account/status",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "a1f81967a58dae0947191fe1a7465c87e70403339e36897329de94c541272e05",
|
||||
"migrated_sha256": "a1f81967a58dae0947191fe1a7465c87e70403339e36897329de94c541272e05",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "全市场总览",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/dashboard?trade_date=2026-07-30",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "565730fb75cec6f6758321074dfeba7bff19db6611476d511f9bc7257a5870f5",
|
||||
"migrated_sha256": "565730fb75cec6f6758321074dfeba7bff19db6611476d511f9bc7257a5870f5",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "情绪周期历史",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/sentiment/history?trade_date=2026-07-30&limit=9",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "1a577fb859a2fd06749169e1d0f08f78b51c5217b55506c7903487a956b6192b",
|
||||
"migrated_sha256": "1a577fb859a2fd06749169e1d0f08f78b51c5217b55506c7903487a956b6192b",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "板块轮动历史",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/rotation/history?trade_date=2026-07-30&limit=9",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "eaf3ac41f479c0f57f161546f197c1321721b4a8390486a916232fe6ea1d3d37",
|
||||
"migrated_sha256": "eaf3ac41f479c0f57f161546f197c1321721b4a8390486a916232fe6ea1d3d37",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "集合竞价",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/auction?trade_date=2026-07-30",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "bd75b100485c0afa0cdddc73d1880160eea8c0b8c91f556245c665d591285ed6",
|
||||
"migrated_sha256": "bd75b100485c0afa0cdddc73d1880160eea8c0b8c91f556245c665d591285ed6",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "题材库",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/themes?trade_date=2026-07-30",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "f45e218d9bff5191445fe45b03fc62f3d9c440b8353404ae4b54901c20929552",
|
||||
"migrated_sha256": "f45e218d9bff5191445fe45b03fc62f3d9c440b8353404ae4b54901c20929552",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "人气热榜",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/popularity?trade_date=2026-07-30",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "69987a80f02ac02bf9ff49b3a0a99782385f1933c643c9e3dbb5a75a1e20dd29",
|
||||
"migrated_sha256": "69987a80f02ac02bf9ff49b3a0a99782385f1933c643c9e3dbb5a75a1e20dd29",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "龙虎榜",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/dragon-tiger?trade_date=2026-07-30",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "173baf4e1f0b56678aef3f74276f29af49271ea34c89c0c18283bfe27f5cf2a6",
|
||||
"migrated_sha256": "173baf4e1f0b56678aef3f74276f29af49271ea34c89c0c18283bfe27f5cf2a6",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "游资档案",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/dragon-tiger/profiles",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "dfb1e534c018ec12fd8d8ee0e7fe0f234e73dc7715d7f0a948f74ef8d12d779a",
|
||||
"migrated_sha256": "dfb1e534c018ec12fd8d8ee0e7fe0f234e73dc7715d7f0a948f74ef8d12d779a",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "智能选股工作区",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/screener/setup?trade_date=2026-07-30",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "e33a10e94accda4948c2af53887ea97736697f588ee1a92f561aa5c4bb28c2fd",
|
||||
"migrated_sha256": "e33a10e94accda4948c2af53887ea97736697f588ee1a92f561aa5c4bb28c2fd",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "策略持续跟踪",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/screener/tracking?limit=12",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "f4476f53152b6a2ce01f2918da5999246cfd0ad7d0b71e80fdea8fa59c7456c2",
|
||||
"migrated_sha256": "f4476f53152b6a2ce01f2918da5999246cfd0ad7d0b71e80fdea8fa59c7456c2",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "问师模型库",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/mentors/setup?trade_date=2026-07-30",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "0ff7794fe45f49ab980221fa855b64c70d5c9b83c78ef86edc041e713490b626",
|
||||
"migrated_sha256": "0ff7794fe45f49ab980221fa855b64c70d5c9b83c78ef86edc041e713490b626",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "问师历史",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/mentors/messages?mentor_id=kobe92-perspective&trade_date=20260730",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1",
|
||||
"migrated_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "问天初始化",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/heaven/setup?trade_date=2026-07-30",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "c814c2004c7dd672c397887725af000c150693a50900bb0ce1aa94599af1a3c8",
|
||||
"migrated_sha256": "c814c2004c7dd672c397887725af000c150693a50900bb0ce1aa94599af1a3c8",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "问天历史",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/heaven/readings?mode=trend&limit=20",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "2aa83ea177bd5cb2351ac4f3a60fdcd17e02cfc14b5753e157e515f03fc2c6c1",
|
||||
"migrated_sha256": "2aa83ea177bd5cb2351ac4f3a60fdcd17e02cfc14b5753e157e515f03fc2c6c1",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "自选追踪",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/watchlist?trade_date=2026-07-30",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "29c001cc6859312fd90082d0fb83818d263827e28ec21157b94c415b71c45078",
|
||||
"migrated_sha256": "29c001cc6859312fd90082d0fb83818d263827e28ec21157b94c415b71c45078",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "全部复盘笔记",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/notes?scope=all",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "a2a1f08610d017de0e46080a1268f72b734eeb80f9cada311d7e03eb1416656a",
|
||||
"migrated_sha256": "a2a1f08610d017de0e46080a1268f72b734eeb80f9cada311d7e03eb1416656a",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "交易日志",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/trades?end_date=2026-07-30",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "77287b34046e22748e94e61ead841e157f6b63f08de1b2fe61f5d715b2e989a4",
|
||||
"migrated_sha256": "77287b34046e22748e94e61ead841e157f6b63f08de1b2fe61f5d715b2e989a4",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "提醒中心",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/alerts?status=all&as_of=2026-07-30",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "495a1f83ed661913807899e0955ef1b89193c30bc9a5be0587f7f03d60411d95",
|
||||
"migrated_sha256": "495a1f83ed661913807899e0955ef1b89193c30bc9a5be0587f7f03d60411d95",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "复盘助手历史",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/assistant/messages",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1",
|
||||
"migrated_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
[
|
||||
{"name": "当前账号", "method": "GET", "endpoint": "/api/auth/me", "exclude_paths": ["$.csrf_token"]},
|
||||
{"name": "账号状态", "method": "GET", "endpoint": "/api/account/status"},
|
||||
{"name": "全市场总览", "method": "GET", "endpoint": "/api/dashboard?trade_date=2026-07-30"},
|
||||
{"name": "情绪周期历史", "method": "GET", "endpoint": "/api/sentiment/history?trade_date=2026-07-30&limit=9"},
|
||||
{"name": "板块轮动历史", "method": "GET", "endpoint": "/api/rotation/history?trade_date=2026-07-30&limit=9"},
|
||||
{"name": "集合竞价", "method": "GET", "endpoint": "/api/auction?trade_date=2026-07-30", "exclude_paths": ["$.meta.updated_at"]},
|
||||
{"name": "题材库", "method": "GET", "endpoint": "/api/themes?trade_date=2026-07-30"},
|
||||
{"name": "人气热榜", "method": "GET", "endpoint": "/api/popularity?trade_date=2026-07-30"},
|
||||
{"name": "龙虎榜", "method": "GET", "endpoint": "/api/dragon-tiger?trade_date=2026-07-30"},
|
||||
{"name": "游资档案", "method": "GET", "endpoint": "/api/dragon-tiger/profiles"},
|
||||
{
|
||||
"name": "智能选股工作区",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/screener/setup?trade_date=2026-07-30",
|
||||
"sort_lists": {"$.strategies": "id"},
|
||||
"exclude_paths": ["$.strategies[].updated_at"]
|
||||
},
|
||||
{"name": "策略持续跟踪", "method": "GET", "endpoint": "/api/screener/tracking?limit=12"},
|
||||
{"name": "问师模型库", "method": "GET", "endpoint": "/api/mentors/setup?trade_date=2026-07-30"},
|
||||
{"name": "问师历史", "method": "GET", "endpoint": "/api/mentors/messages?mentor_id=kobe92-perspective&trade_date=20260730"},
|
||||
{"name": "问天初始化", "method": "GET", "endpoint": "/api/heaven/setup?trade_date=2026-07-30"},
|
||||
{"name": "问天历史", "method": "GET", "endpoint": "/api/heaven/readings?mode=trend&limit=20"},
|
||||
{"name": "自选追踪", "method": "GET", "endpoint": "/api/watchlist?trade_date=2026-07-30"},
|
||||
{"name": "全部复盘笔记", "method": "GET", "endpoint": "/api/notes?scope=all"},
|
||||
{"name": "交易日志", "method": "GET", "endpoint": "/api/trades?end_date=2026-07-30"},
|
||||
{"name": "提醒中心", "method": "GET", "endpoint": "/api/alerts?status=all&as_of=2026-07-30"},
|
||||
{"name": "复盘助手历史", "method": "GET", "endpoint": "/api/assistant/messages"}
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"tested_at": "2026-07-31T12:00:00+08:00",
|
||||
"result": "passed",
|
||||
"environments": {
|
||||
"original": "temporary_original_runtime",
|
||||
"migrated": "temporary_app_runtime",
|
||||
"account": "administrator",
|
||||
"database": "isolated_copies"
|
||||
},
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"theme": "light",
|
||||
"views": [
|
||||
"sentiment",
|
||||
"auction",
|
||||
"screener",
|
||||
"heaven_trend",
|
||||
"heaven_fortune",
|
||||
"heaven_heart_intro",
|
||||
"heaven_heart_breath"
|
||||
],
|
||||
"equal": true
|
||||
},
|
||||
{
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"theme": "dark",
|
||||
"views": [
|
||||
"sentiment"
|
||||
],
|
||||
"equal": true
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844,
|
||||
"theme": "light",
|
||||
"views": [
|
||||
"sentiment",
|
||||
"heaven_trend"
|
||||
],
|
||||
"equal": true
|
||||
}
|
||||
],
|
||||
"heaven_animation_contract": {
|
||||
"trend_star_nodes": 90,
|
||||
"fortune_star_nodes": 100,
|
||||
"heart_star_nodes": 110,
|
||||
"bagua_nodes_per_view": 2,
|
||||
"trend_and_fortune_animations": [
|
||||
"wt-tw",
|
||||
"wt-spin"
|
||||
],
|
||||
"heart_breath_animations": [
|
||||
"heart-incense-burn",
|
||||
"heart-incense-glow"
|
||||
],
|
||||
"fortune_industry_groups": 5,
|
||||
"fortune_industries_collapsed_by_default": true,
|
||||
"equal": true
|
||||
},
|
||||
"mobile_contract": {
|
||||
"primary_navigation_items": 5,
|
||||
"horizontal_overflow": false,
|
||||
"vertical_page_scroll": true,
|
||||
"heaven_trend_geometry": {
|
||||
"active": {
|
||||
"height": 723,
|
||||
"width": 374,
|
||||
"x": 8,
|
||||
"y": 50
|
||||
},
|
||||
"header": {
|
||||
"width": 390,
|
||||
"height": 50
|
||||
},
|
||||
"bottom_navigation": {
|
||||
"width": 390,
|
||||
"height": 58
|
||||
}
|
||||
},
|
||||
"equal": true
|
||||
},
|
||||
"console": {
|
||||
"new_errors": 0,
|
||||
"new_warnings": 0
|
||||
},
|
||||
"screenshot_policy": "Dynamic animation frames, focus outlines, and live status text may change pixel hashes. Acceptance compares visible geometry, computed styles, DOM state, animation names, interaction results, and overflow behavior.",
|
||||
"known_test_environment_constraint": "Original and migrated services on the same hostname share cookies across ports. Each service must be logged in separately immediately before comparison.",
|
||||
"all_equal": true
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
{
|
||||
"all_equal": true,
|
||||
"schema": {
|
||||
"object_count": 62,
|
||||
"original_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1",
|
||||
"migrated_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1",
|
||||
"equal": true
|
||||
},
|
||||
"tables": [
|
||||
{
|
||||
"table": "users",
|
||||
"original_count": 3,
|
||||
"migrated_count": 3,
|
||||
"original_sha256": "4a0f135bef8ebae454693d3f40e1157d84d814d18d849f33e2e760ed1d8a7f89",
|
||||
"migrated_sha256": "4a0f135bef8ebae454693d3f40e1157d84d814d18d849f33e2e760ed1d8a7f89",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "system_settings",
|
||||
"original_count": 1,
|
||||
"migrated_count": 1,
|
||||
"original_sha256": "86d333a5c7feaf7111cd79b2db29513326a9b0d3781607f78bf633579e2ca8e9",
|
||||
"migrated_sha256": "86d333a5c7feaf7111cd79b2db29513326a9b0d3781607f78bf633579e2ca8e9",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "watchlist",
|
||||
"original_count": 6,
|
||||
"migrated_count": 6,
|
||||
"original_sha256": "070161f5cdcc33addf289f11cb0ae54bfc0d669a39426fc33398b64512dba2fe",
|
||||
"migrated_sha256": "070161f5cdcc33addf289f11cb0ae54bfc0d669a39426fc33398b64512dba2fe",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "review_notes",
|
||||
"original_count": 3,
|
||||
"migrated_count": 3,
|
||||
"original_sha256": "86ed485c2ce922e3434f8ac742d38431ef430b5c22024cacb6f64f0e8e71ee98",
|
||||
"migrated_sha256": "86ed485c2ce922e3434f8ac742d38431ef430b5c22024cacb6f64f0e8e71ee98",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "trade_entries",
|
||||
"original_count": 0,
|
||||
"migrated_count": 0,
|
||||
"original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "alerts",
|
||||
"original_count": 2,
|
||||
"migrated_count": 2,
|
||||
"original_sha256": "40b1ee5678b32f29ae356c1b30958e66138d34afd2aa6b8364fa02c23df66553",
|
||||
"migrated_sha256": "40b1ee5678b32f29ae356c1b30958e66138d34afd2aa6b8364fa02c23df66553",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "assistant_messages",
|
||||
"original_count": 0,
|
||||
"migrated_count": 0,
|
||||
"original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "mentor_messages",
|
||||
"original_count": 28,
|
||||
"migrated_count": 28,
|
||||
"original_sha256": "b906b776d575ff6fe459b2aa0e8b396267feed36d3d85f7edd7947b284482aeb",
|
||||
"migrated_sha256": "b906b776d575ff6fe459b2aa0e8b396267feed36d3d85f7edd7947b284482aeb",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "mentor_preferences",
|
||||
"original_count": 45,
|
||||
"migrated_count": 45,
|
||||
"original_sha256": "c19c7d05e6745e4f1787d1eed5f39d7e555ec24f8e490b585c19158edc2505ec",
|
||||
"migrated_sha256": "c19c7d05e6745e4f1787d1eed5f39d7e555ec24f8e490b585c19158edc2505ec",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "heaven_readings",
|
||||
"original_count": 31,
|
||||
"migrated_count": 31,
|
||||
"original_sha256": "e6cfc86f010fb182d7522bb99e5d268e826f93896035172120df51a011590951",
|
||||
"migrated_sha256": "e6cfc86f010fb182d7522bb99e5d268e826f93896035172120df51a011590951",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "user_birth_profiles",
|
||||
"original_count": 1,
|
||||
"migrated_count": 1,
|
||||
"original_sha256": "2a74d4b2edd3a730a46f17fb6ab3926575c42662c1d968643b0d54586ab68b13",
|
||||
"migrated_sha256": "2a74d4b2edd3a730a46f17fb6ab3926575c42662c1d968643b0d54586ab68b13",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "sector_phase_overrides",
|
||||
"original_count": 0,
|
||||
"migrated_count": 0,
|
||||
"original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "screener_strategies",
|
||||
"original_count": 36,
|
||||
"migrated_count": 36,
|
||||
"original_sha256": "872846dcb746446bda37723e63db737a2a0cf0540eb6a1da264012248887f4c5",
|
||||
"migrated_sha256": "872846dcb746446bda37723e63db737a2a0cf0540eb6a1da264012248887f4c5",
|
||||
"equal": true,
|
||||
"excluded_columns": [
|
||||
"updated_at"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "screener_runs",
|
||||
"original_count": 250,
|
||||
"migrated_count": 250,
|
||||
"original_sha256": "9f58ff2f85e5b85878153f7694161620d77cb9b0f351754f2974a8a0cc8ad8fb",
|
||||
"migrated_sha256": "9f58ff2f85e5b85878153f7694161620d77cb9b0f351754f2974a8a0cc8ad8fb",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "strategy_tracks",
|
||||
"original_count": 16,
|
||||
"migrated_count": 16,
|
||||
"original_sha256": "f0639b1386b69c92fd815f929864b3b2457a6bf4ca515bee2dca87e9ea29e8c4",
|
||||
"migrated_sha256": "f0639b1386b69c92fd815f929864b3b2457a6bf4ca515bee2dca87e9ea29e8c4",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "auction_factors",
|
||||
"original_count": 511914,
|
||||
"migrated_count": 511914,
|
||||
"original_sha256": "3d0470787adaf7c4cf5f15f5ad9fa1d67c8fcd4807285ac264eeacdcf5054cd1",
|
||||
"migrated_sha256": "3d0470787adaf7c4cf5f15f5ad9fa1d67c8fcd4807285ac264eeacdcf5054cd1",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "popularity_factors",
|
||||
"original_count": 232,
|
||||
"migrated_count": 232,
|
||||
"original_sha256": "3f4c61a13ceaa1ed9ecb28f86241a8a478a6757d6a44b46f432f73c0226bba7f",
|
||||
"migrated_sha256": "3f4c61a13ceaa1ed9ecb28f86241a8a478a6757d6a44b46f432f73c0226bba7f",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "seat_aliases",
|
||||
"original_count": 0,
|
||||
"migrated_count": 0,
|
||||
"original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "reason_overrides",
|
||||
"original_count": 0,
|
||||
"migrated_count": 0,
|
||||
"original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "llm_usage",
|
||||
"original_count": 72,
|
||||
"migrated_count": 72,
|
||||
"original_sha256": "03a070316a125cef904bbfb2bb06b06e792242d5713142e70354402c741393c3",
|
||||
"migrated_sha256": "03a070316a125cef904bbfb2bb06b06e792242d5713142e70354402c741393c3",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "wencai_saved_queries",
|
||||
"original_count": 0,
|
||||
"migrated_count": 0,
|
||||
"original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 91 KiB |
@@ -0,0 +1,190 @@
|
||||
{
|
||||
"source": "static/app.js",
|
||||
"source_sha256": "c020d0bc68f8a96b352a0ffa62c913a0ea3f5ca0afc41f70969f833e447166a6",
|
||||
"source_line_count": 9283,
|
||||
"reassembled_sha256": "c020d0bc68f8a96b352a0ffa62c913a0ea3f5ca0afc41f70969f833e447166a6",
|
||||
"all_source_lines_preserved": true,
|
||||
"core": {
|
||||
"target": "app/frontend/app.js",
|
||||
"ranges": [
|
||||
{
|
||||
"start": 1,
|
||||
"end": 1206
|
||||
},
|
||||
{
|
||||
"start": 3471,
|
||||
"end": 3489
|
||||
},
|
||||
{
|
||||
"start": 8427,
|
||||
"end": 8904
|
||||
},
|
||||
{
|
||||
"start": 9052,
|
||||
"end": 9283
|
||||
}
|
||||
]
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"target": "app/frontend/pages/sentiment/page.js",
|
||||
"ranges": [
|
||||
{
|
||||
"start": 1207,
|
||||
"end": 1516,
|
||||
"sha256": "70a884362b3f97ceb28e2f3a83a70ce26e61c79e23bfeee62b301b9068f2b69c"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "app/frontend/pages/pools/page.js",
|
||||
"ranges": [
|
||||
{
|
||||
"start": 1517,
|
||||
"end": 1915,
|
||||
"sha256": "ff8623609c1ef6eb793deaef2b627746727a4768962711d1712e35833b4f58c3"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "app/frontend/pages/market/runtime.js",
|
||||
"ranges": [
|
||||
{
|
||||
"start": 1916,
|
||||
"end": 1957,
|
||||
"sha256": "703dafd0417926df8572a885ffa32a79ee16346019f0e14fcee9e03ad1f1baf7"
|
||||
},
|
||||
{
|
||||
"start": 6893,
|
||||
"end": 7719,
|
||||
"sha256": "a670986ac2099a181f5f348cd48bc4777da0d4709b3ac79239067ff5666261fb"
|
||||
},
|
||||
{
|
||||
"start": 7969,
|
||||
"end": 8426,
|
||||
"sha256": "467409c2cfc06ee96bb7f422be55f79920c3a9936229b2234dd8f78b537e7f26"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "app/frontend/pages/rotation/page.js",
|
||||
"ranges": [
|
||||
{
|
||||
"start": 1958,
|
||||
"end": 2124,
|
||||
"sha256": "33f536213db33be0934527f13527712a9d521b5f89a5784b42887c5bf8631f2f"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "app/frontend/pages/ladder/page.js",
|
||||
"ranges": [
|
||||
{
|
||||
"start": 2125,
|
||||
"end": 2216,
|
||||
"sha256": "2be84071921910acbec2328f1f10dd8565420ea03a1930ff255040bf99d679a8"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "app/frontend/pages/auction/page.js",
|
||||
"ranges": [
|
||||
{
|
||||
"start": 2217,
|
||||
"end": 2481,
|
||||
"sha256": "7f16901ae8769b9570693211022538feb02f2cda4bffe6fa95ba6ce60c10b012"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "app/frontend/pages/themes/page.js",
|
||||
"ranges": [
|
||||
{
|
||||
"start": 2482,
|
||||
"end": 2583,
|
||||
"sha256": "c134d762bda669483f0c094efae9281919b68b2ffb83c9457670bc9013ce1515"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "app/frontend/pages/popularity/page.js",
|
||||
"ranges": [
|
||||
{
|
||||
"start": 2584,
|
||||
"end": 2659,
|
||||
"sha256": "db72e42385e4a4bd4314ab471b27c1bb804f13650ee463f0843d783b0ba50bff"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "app/frontend/pages/dragon-tiger/page.js",
|
||||
"ranges": [
|
||||
{
|
||||
"start": 2660,
|
||||
"end": 3028,
|
||||
"sha256": "b33ccad80ce997bddd75e4025eec35aa246fa5e9f5ca9ccd5f17e587885274cd"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "app/frontend/pages/review/page.js",
|
||||
"ranges": [
|
||||
{
|
||||
"start": 3029,
|
||||
"end": 3470,
|
||||
"sha256": "10dba1966b32941cb28ac46d1cb1b20ae74ea271fd7be22a23b46d290ece80ef"
|
||||
},
|
||||
{
|
||||
"start": 7720,
|
||||
"end": 7968,
|
||||
"sha256": "3af64079f997fabbf0ff4023088eb62f94c0091a823bd6d65c1f9e485d1c5ad8"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "app/frontend/pages/screener/page.js",
|
||||
"ranges": [
|
||||
{
|
||||
"start": 3490,
|
||||
"end": 4336,
|
||||
"sha256": "76dfdd5610453f0abb5899baba9d751b5f597450ec656bf10580ce91447a68b9"
|
||||
},
|
||||
{
|
||||
"start": 6668,
|
||||
"end": 6892,
|
||||
"sha256": "a7d0fa98b07516169a8e46c2ae2f6efbaeab651887c479919210835f0877533e"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "app/frontend/pages/mentor/page.js",
|
||||
"ranges": [
|
||||
{
|
||||
"start": 4337,
|
||||
"end": 4827,
|
||||
"sha256": "b2067c062fb6cb539dd6402074d02f1ae65903957106d7ebf8d63bc5487f244c"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "app/frontend/pages/heaven/page.js",
|
||||
"ranges": [
|
||||
{
|
||||
"start": 4828,
|
||||
"end": 6667,
|
||||
"sha256": "57bd47c239c4ee9302c7cd7a1f86a5d0932df6065148a34bafe0c49bef644cb1"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "app/frontend/shared/export.js",
|
||||
"ranges": [
|
||||
{
|
||||
"start": 8905,
|
||||
"end": 9051,
|
||||
"sha256": "17b867189087615a4414da4f9db3862306e05cb527c25c794beb9c4c74b583ab"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# 切片 11:不确定代码审计、全量验收与交接准备
|
||||
|
||||
> 基线:`dec3cd12365df5e7d4c391369f14c457cf56d7d9`
|
||||
> 候选回档标签:`xiaobai-preservation-slice-11-candidate-20260731`
|
||||
> 当前结论:自动验收完成,等待用户人工验收;尚未执行正式切换、Docker或NAS部署
|
||||
|
||||
## 1. 本切片做了什么
|
||||
|
||||
本切片不增加功能、不调整视觉,也不继续拆分业务代码。它逐项审计切片10保留的待定资产,
|
||||
把有完整证据的无效实现放入可单独回档的试删候选,并为人工接管和后续切换准备文档。
|
||||
|
||||
试删候选:
|
||||
|
||||
- `app/demo_data.py`:没有运行导入、动态注册、数据库或配置责任的旧演示数据构造器。
|
||||
- `app/frontend/heaven-loading.js`:页面未加载的旧问天动画;正式入口继续使用
|
||||
`frontend/pages/heaven/loading-v2.js`。
|
||||
- 五个只有定义、没有消费者的前端函数:`commonReviewColumns`、`outcomeClass`、
|
||||
`screenerResultMatchesSelection`、`selectRegime`、`showHeartRitualCurtain`。
|
||||
|
||||
明确保留:
|
||||
|
||||
- `wencai_saved_queries`表及三个Repository方法。它们承担历史数据库兼容和用户隔离责任,
|
||||
当前库为空不能证明其他部署库也为空。
|
||||
- 与试删函数相邻但证据不足的DOM、状态字段和CSS。没有为追求行数继续连带删除。
|
||||
|
||||
逐项指纹、引用扫描、责任判断和恢复位置见`uncertain-code-audit.md`。
|
||||
|
||||
## 2. 自动验证结果
|
||||
|
||||
| 验证 | 结果 |
|
||||
|---|---:|
|
||||
| 原版`python -m unittest discover -s tests -q` | 231项通过 |
|
||||
| 迁移版`python -m unittest discover -s tests -q` | 298项通过 |
|
||||
| 历史切片与前端/清理专项复核 | 通过 |
|
||||
| 迁移版JavaScript语法检查 | 24个文件通过 |
|
||||
| `npx.cmd playwright test --reporter=dot` | 45项通过(1.6分钟) |
|
||||
| 固定只读API差分 | 21个端点全部一致 |
|
||||
| 数据库差分 | 62个schema对象、21张关键表全部一致 |
|
||||
| `git diff --check` | 通过 |
|
||||
|
||||
早期切片的五个前端源码测试原本要求与未试删的`static/app.js`逐字符一致。本切片把它们统一到
|
||||
`preservation_helpers.assert_frontend_runtime_matches_audited_baseline`:只允许审计登记的五段原始
|
||||
行号被删除,任何其他新增、删除、重排或内容变化仍会使全部历史测试失败。
|
||||
|
||||
完整API和数据库结果分别见`api-diff.json`与`database-diff.json`,二者`all_equal`均为`true`。
|
||||
|
||||
## 3. 真实浏览器验收
|
||||
|
||||
- 桌面端复核情绪周期、智能选股三个工作区、问天日间/夜间和加载资源;控制台无新增
|
||||
error或warn,页面无横向溢出。
|
||||
- 问天只加载`/pages/heaven/loading-v2.js`,未请求已试删的`/heaven-loading.js`。
|
||||
- 观势、观气、观心星空与八卦节点、原动画名称和可见行为保持不变。
|
||||
- `390x844`逐一点击涨停池、智能选股、问师、问天、我的复盘五个移动入口;底部导航固定,
|
||||
页面均可完整纵向滚动且没有文档级横向溢出。
|
||||
- 观势、观气、观心三页均复核;观气滚动到底后“个人合参”和“五行对应行业”可达。
|
||||
- 同账号、同数据库快照、同主题和同视口下,原版与迁移版问天布局和计算样式一致。
|
||||
|
||||
机器可读记录见`browser-acceptance.json`。截图和更广的桌面/移动基线继续沿用切片10证据目录。
|
||||
|
||||
## 4. 数据与部署边界
|
||||
|
||||
- API/数据库比较使用两个隔离副本:`slice11-final-original`与`slice11-final-migrated`。
|
||||
- 没有写入根目录正式`data/review.db`,没有修改`.env`或私有Skill。
|
||||
- 没有占用`8765`,没有执行Docker构建、NAS测试或服务器切换。
|
||||
- 原版根目录仍是正式运行基线;`app/`只是等待人工验收的候选。
|
||||
- `next/`保持冻结,没有作为代码、样式、测试或文档来源。
|
||||
|
||||
## 5. 回档与最终确认
|
||||
|
||||
切片11提交和标签只代表“候选可验收”,不代表删除已被永久确认。人工验收前可按
|
||||
`uncertain-code-audit.md`从切片10标签单独恢复任一候选;不需要回退其他已通过迁移切片。
|
||||
|
||||
用户人工确认后才允许:
|
||||
|
||||
1. 把试删候选状态改为“确认废弃”。
|
||||
2. 建立最终完成标签。
|
||||
3. 决定本地或Docker正式切换时间。
|
||||
4. 另行制定根目录兼容外壳和旧实现的清理计划。
|
||||
|
||||
人工维护、验证、切换和回退步骤见`docs/migration/人工维护与本地切换指南.md`。
|
||||
@@ -0,0 +1,236 @@
|
||||
{
|
||||
"all_equal": true,
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "当前账号",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/auth/me",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "33e0c08463d5f14a142840f894d92985135c6ce2bee22c93c4dde34dc433cf5b",
|
||||
"migrated_sha256": "33e0c08463d5f14a142840f894d92985135c6ce2bee22c93c4dde34dc433cf5b",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "账号状态",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/account/status",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "094530da01cbc633de4e2a09d1b3a58689ed9743d2ab2207c495e6c44781fc1e",
|
||||
"migrated_sha256": "094530da01cbc633de4e2a09d1b3a58689ed9743d2ab2207c495e6c44781fc1e",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "全市场总览",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/dashboard?trade_date=2026-07-30",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "565730fb75cec6f6758321074dfeba7bff19db6611476d511f9bc7257a5870f5",
|
||||
"migrated_sha256": "565730fb75cec6f6758321074dfeba7bff19db6611476d511f9bc7257a5870f5",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "情绪周期历史",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/sentiment/history?trade_date=2026-07-30&limit=9",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "1a577fb859a2fd06749169e1d0f08f78b51c5217b55506c7903487a956b6192b",
|
||||
"migrated_sha256": "1a577fb859a2fd06749169e1d0f08f78b51c5217b55506c7903487a956b6192b",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "板块轮动历史",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/rotation/history?trade_date=2026-07-30&limit=9",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "eaf3ac41f479c0f57f161546f197c1321721b4a8390486a916232fe6ea1d3d37",
|
||||
"migrated_sha256": "eaf3ac41f479c0f57f161546f197c1321721b4a8390486a916232fe6ea1d3d37",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "集合竞价",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/auction?trade_date=2026-07-30",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "5a6b643ab1d7e2285ae5667bca12e6ed887ba4aa7512ba989e0dcfb2032e755b",
|
||||
"migrated_sha256": "5a6b643ab1d7e2285ae5667bca12e6ed887ba4aa7512ba989e0dcfb2032e755b",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "题材库",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/themes?trade_date=2026-07-30",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "f45e218d9bff5191445fe45b03fc62f3d9c440b8353404ae4b54901c20929552",
|
||||
"migrated_sha256": "f45e218d9bff5191445fe45b03fc62f3d9c440b8353404ae4b54901c20929552",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "人气热榜",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/popularity?trade_date=2026-07-30",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "69987a80f02ac02bf9ff49b3a0a99782385f1933c643c9e3dbb5a75a1e20dd29",
|
||||
"migrated_sha256": "69987a80f02ac02bf9ff49b3a0a99782385f1933c643c9e3dbb5a75a1e20dd29",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "龙虎榜",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/dragon-tiger?trade_date=2026-07-30",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "9d19029f655813ed40a25deb0b3250423a40ae0ee779638ec90f85333f859c6f",
|
||||
"migrated_sha256": "9d19029f655813ed40a25deb0b3250423a40ae0ee779638ec90f85333f859c6f",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "游资档案",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/dragon-tiger/profiles",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "dfb1e534c018ec12fd8d8ee0e7fe0f234e73dc7715d7f0a948f74ef8d12d779a",
|
||||
"migrated_sha256": "dfb1e534c018ec12fd8d8ee0e7fe0f234e73dc7715d7f0a948f74ef8d12d779a",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "智能选股工作区",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/screener/setup?trade_date=2026-07-30",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "e33a10e94accda4948c2af53887ea97736697f588ee1a92f561aa5c4bb28c2fd",
|
||||
"migrated_sha256": "e33a10e94accda4948c2af53887ea97736697f588ee1a92f561aa5c4bb28c2fd",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "策略持续跟踪",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/screener/tracking?limit=12",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "f4476f53152b6a2ce01f2918da5999246cfd0ad7d0b71e80fdea8fa59c7456c2",
|
||||
"migrated_sha256": "f4476f53152b6a2ce01f2918da5999246cfd0ad7d0b71e80fdea8fa59c7456c2",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "问师模型库",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/mentors/setup?trade_date=2026-07-30",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "0ff7794fe45f49ab980221fa855b64c70d5c9b83c78ef86edc041e713490b626",
|
||||
"migrated_sha256": "0ff7794fe45f49ab980221fa855b64c70d5c9b83c78ef86edc041e713490b626",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "问师历史",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/mentors/messages?mentor_id=kobe92-perspective&trade_date=20260730",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1",
|
||||
"migrated_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "问天初始化",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/heaven/setup?trade_date=2026-07-30",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "c814c2004c7dd672c397887725af000c150693a50900bb0ce1aa94599af1a3c8",
|
||||
"migrated_sha256": "c814c2004c7dd672c397887725af000c150693a50900bb0ce1aa94599af1a3c8",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "问天历史",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/heaven/readings?mode=trend&limit=20",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "2aa83ea177bd5cb2351ac4f3a60fdcd17e02cfc14b5753e157e515f03fc2c6c1",
|
||||
"migrated_sha256": "2aa83ea177bd5cb2351ac4f3a60fdcd17e02cfc14b5753e157e515f03fc2c6c1",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "自选追踪",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/watchlist?trade_date=2026-07-30",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "29c001cc6859312fd90082d0fb83818d263827e28ec21157b94c415b71c45078",
|
||||
"migrated_sha256": "29c001cc6859312fd90082d0fb83818d263827e28ec21157b94c415b71c45078",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "全部复盘笔记",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/notes?scope=all",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "a2a1f08610d017de0e46080a1268f72b734eeb80f9cada311d7e03eb1416656a",
|
||||
"migrated_sha256": "a2a1f08610d017de0e46080a1268f72b734eeb80f9cada311d7e03eb1416656a",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "交易日志",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/trades?end_date=2026-07-30",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "77287b34046e22748e94e61ead841e157f6b63f08de1b2fe61f5d715b2e989a4",
|
||||
"migrated_sha256": "77287b34046e22748e94e61ead841e157f6b63f08de1b2fe61f5d715b2e989a4",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "提醒中心",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/alerts?status=all&as_of=2026-07-30",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "01e8f169fbc8700313dbdf8379e682b78d7b372cfbee556e1d68f46cd6cbeecf",
|
||||
"migrated_sha256": "01e8f169fbc8700313dbdf8379e682b78d7b372cfbee556e1d68f46cd6cbeecf",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
},
|
||||
{
|
||||
"name": "复盘助手历史",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/assistant/messages",
|
||||
"original_status": 200,
|
||||
"migrated_status": 200,
|
||||
"original_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1",
|
||||
"migrated_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1",
|
||||
"equal": true,
|
||||
"first_difference": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"tested_at": "2026-07-31T16:10:00+08:00",
|
||||
"runtime": {
|
||||
"url": "http://127.0.0.1:8797/",
|
||||
"root": "app",
|
||||
"database": "app/data/backups/slice11-final-migrated/review.db",
|
||||
"production_data_modified": false
|
||||
},
|
||||
"overall": true,
|
||||
"desktop": {
|
||||
"checked": true,
|
||||
"views": [
|
||||
"sentimentCycleView",
|
||||
"screenerView",
|
||||
"heavenView"
|
||||
],
|
||||
"themes": [
|
||||
"light",
|
||||
"dark"
|
||||
],
|
||||
"console_errors": 0,
|
||||
"console_warnings": 0,
|
||||
"horizontal_overflow": false
|
||||
},
|
||||
"mobile": {
|
||||
"viewport": [
|
||||
390,
|
||||
844
|
||||
],
|
||||
"fixed_navigation": {
|
||||
"selector": ".module-nav",
|
||||
"position": "fixed",
|
||||
"height": 58,
|
||||
"bottom_offset": 0,
|
||||
"primary_tab_count": 5
|
||||
},
|
||||
"views": [
|
||||
{
|
||||
"id": "limitPool",
|
||||
"document_height": 1607,
|
||||
"horizontal_overflow": false,
|
||||
"vertical_content_reachable": true
|
||||
},
|
||||
{
|
||||
"id": "screenerView",
|
||||
"document_height": 1555,
|
||||
"horizontal_overflow": false,
|
||||
"vertical_content_reachable": true
|
||||
},
|
||||
{
|
||||
"id": "mentorView",
|
||||
"document_height": 961,
|
||||
"horizontal_overflow": false,
|
||||
"vertical_content_reachable": true
|
||||
},
|
||||
{
|
||||
"id": "heavenView",
|
||||
"document_height": 844,
|
||||
"horizontal_overflow": false,
|
||||
"vertical_content_reachable": true
|
||||
},
|
||||
{
|
||||
"id": "reviewWorkspaceView",
|
||||
"document_height": 1475,
|
||||
"horizontal_overflow": false,
|
||||
"vertical_content_reachable": true
|
||||
}
|
||||
],
|
||||
"heaven_panels": [
|
||||
{
|
||||
"id": "trend",
|
||||
"document_height": 844,
|
||||
"vertical_content_reachable": true
|
||||
},
|
||||
{
|
||||
"id": "fortune",
|
||||
"document_height": 1730,
|
||||
"vertical_content_reachable": true,
|
||||
"bottom_content_checked": "五行对应行业"
|
||||
},
|
||||
{
|
||||
"id": "heart",
|
||||
"document_height": 1156,
|
||||
"vertical_content_reachable": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"preservation_comparison": {
|
||||
"same_account": true,
|
||||
"same_database_snapshot": true,
|
||||
"same_theme": "dark",
|
||||
"same_mobile_viewport": [
|
||||
390,
|
||||
844
|
||||
],
|
||||
"original_and_migrated_layout_equal": true,
|
||||
"theme_toggle_icon_note": "原版和迁移版都保留切换后图标需重载才同步的既有行为;相同加载序列下结果一致。"
|
||||
},
|
||||
"manual_acceptance_required": true
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
{
|
||||
"all_equal": true,
|
||||
"schema": {
|
||||
"object_count": 62,
|
||||
"original_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1",
|
||||
"migrated_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1",
|
||||
"equal": true
|
||||
},
|
||||
"tables": [
|
||||
{
|
||||
"table": "users",
|
||||
"original_count": 3,
|
||||
"migrated_count": 3,
|
||||
"original_sha256": "4a0f135bef8ebae454693d3f40e1157d84d814d18d849f33e2e760ed1d8a7f89",
|
||||
"migrated_sha256": "4a0f135bef8ebae454693d3f40e1157d84d814d18d849f33e2e760ed1d8a7f89",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "system_settings",
|
||||
"original_count": 1,
|
||||
"migrated_count": 1,
|
||||
"original_sha256": "86d333a5c7feaf7111cd79b2db29513326a9b0d3781607f78bf633579e2ca8e9",
|
||||
"migrated_sha256": "86d333a5c7feaf7111cd79b2db29513326a9b0d3781607f78bf633579e2ca8e9",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "watchlist",
|
||||
"original_count": 6,
|
||||
"migrated_count": 6,
|
||||
"original_sha256": "070161f5cdcc33addf289f11cb0ae54bfc0d669a39426fc33398b64512dba2fe",
|
||||
"migrated_sha256": "070161f5cdcc33addf289f11cb0ae54bfc0d669a39426fc33398b64512dba2fe",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "review_notes",
|
||||
"original_count": 3,
|
||||
"migrated_count": 3,
|
||||
"original_sha256": "86ed485c2ce922e3434f8ac742d38431ef430b5c22024cacb6f64f0e8e71ee98",
|
||||
"migrated_sha256": "86ed485c2ce922e3434f8ac742d38431ef430b5c22024cacb6f64f0e8e71ee98",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "trade_entries",
|
||||
"original_count": 0,
|
||||
"migrated_count": 0,
|
||||
"original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "alerts",
|
||||
"original_count": 2,
|
||||
"migrated_count": 2,
|
||||
"original_sha256": "a496857f915d8ba0870274455182a2d7f180a931836b7078e9676dc0e7bbe430",
|
||||
"migrated_sha256": "a496857f915d8ba0870274455182a2d7f180a931836b7078e9676dc0e7bbe430",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "assistant_messages",
|
||||
"original_count": 0,
|
||||
"migrated_count": 0,
|
||||
"original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "mentor_messages",
|
||||
"original_count": 28,
|
||||
"migrated_count": 28,
|
||||
"original_sha256": "b906b776d575ff6fe459b2aa0e8b396267feed36d3d85f7edd7947b284482aeb",
|
||||
"migrated_sha256": "b906b776d575ff6fe459b2aa0e8b396267feed36d3d85f7edd7947b284482aeb",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "mentor_preferences",
|
||||
"original_count": 45,
|
||||
"migrated_count": 45,
|
||||
"original_sha256": "c19c7d05e6745e4f1787d1eed5f39d7e555ec24f8e490b585c19158edc2505ec",
|
||||
"migrated_sha256": "c19c7d05e6745e4f1787d1eed5f39d7e555ec24f8e490b585c19158edc2505ec",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "heaven_readings",
|
||||
"original_count": 31,
|
||||
"migrated_count": 31,
|
||||
"original_sha256": "e6cfc86f010fb182d7522bb99e5d268e826f93896035172120df51a011590951",
|
||||
"migrated_sha256": "e6cfc86f010fb182d7522bb99e5d268e826f93896035172120df51a011590951",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "user_birth_profiles",
|
||||
"original_count": 1,
|
||||
"migrated_count": 1,
|
||||
"original_sha256": "2a74d4b2edd3a730a46f17fb6ab3926575c42662c1d968643b0d54586ab68b13",
|
||||
"migrated_sha256": "2a74d4b2edd3a730a46f17fb6ab3926575c42662c1d968643b0d54586ab68b13",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "sector_phase_overrides",
|
||||
"original_count": 0,
|
||||
"migrated_count": 0,
|
||||
"original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "screener_strategies",
|
||||
"original_count": 36,
|
||||
"migrated_count": 36,
|
||||
"original_sha256": "872846dcb746446bda37723e63db737a2a0cf0540eb6a1da264012248887f4c5",
|
||||
"migrated_sha256": "872846dcb746446bda37723e63db737a2a0cf0540eb6a1da264012248887f4c5",
|
||||
"equal": true,
|
||||
"excluded_columns": [
|
||||
"updated_at"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "screener_runs",
|
||||
"original_count": 250,
|
||||
"migrated_count": 250,
|
||||
"original_sha256": "9f58ff2f85e5b85878153f7694161620d77cb9b0f351754f2974a8a0cc8ad8fb",
|
||||
"migrated_sha256": "9f58ff2f85e5b85878153f7694161620d77cb9b0f351754f2974a8a0cc8ad8fb",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "strategy_tracks",
|
||||
"original_count": 16,
|
||||
"migrated_count": 16,
|
||||
"original_sha256": "f0639b1386b69c92fd815f929864b3b2457a6bf4ca515bee2dca87e9ea29e8c4",
|
||||
"migrated_sha256": "f0639b1386b69c92fd815f929864b3b2457a6bf4ca515bee2dca87e9ea29e8c4",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "auction_factors",
|
||||
"original_count": 511914,
|
||||
"migrated_count": 511914,
|
||||
"original_sha256": "3d0470787adaf7c4cf5f15f5ad9fa1d67c8fcd4807285ac264eeacdcf5054cd1",
|
||||
"migrated_sha256": "3d0470787adaf7c4cf5f15f5ad9fa1d67c8fcd4807285ac264eeacdcf5054cd1",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "popularity_factors",
|
||||
"original_count": 232,
|
||||
"migrated_count": 232,
|
||||
"original_sha256": "3f4c61a13ceaa1ed9ecb28f86241a8a478a6757d6a44b46f432f73c0226bba7f",
|
||||
"migrated_sha256": "3f4c61a13ceaa1ed9ecb28f86241a8a478a6757d6a44b46f432f73c0226bba7f",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "seat_aliases",
|
||||
"original_count": 0,
|
||||
"migrated_count": 0,
|
||||
"original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "reason_overrides",
|
||||
"original_count": 0,
|
||||
"migrated_count": 0,
|
||||
"original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "llm_usage",
|
||||
"original_count": 72,
|
||||
"migrated_count": 72,
|
||||
"original_sha256": "03a070316a125cef904bbfb2bb06b06e792242d5713142e70354402c741393c3",
|
||||
"migrated_sha256": "03a070316a125cef904bbfb2bb06b06e792242d5713142e70354402c741393c3",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
},
|
||||
{
|
||||
"table": "wencai_saved_queries",
|
||||
"original_count": 0,
|
||||
"migrated_count": 0,
|
||||
"original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"equal": true,
|
||||
"excluded_columns": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# 切片 11:不确定代码审计日志
|
||||
|
||||
> 审计基线:`dec3cd12365df5e7d4c391369f14c457cf56d7d9`
|
||||
> 恢复标签:`xiaobai-preservation-slice-10-20260731`
|
||||
> 原则:只有静态引用、动态注册、运行路径和兼容责任同时排除后才试删;其余继续保留
|
||||
> 当前状态:试删后的自动、API、数据库和浏览器验收已通过,等待用户人工确认
|
||||
|
||||
本日志记录试删前的原位置、内容指纹、证据、决定和恢复方式。试删不等于永久废弃;自动回归通过后仍需用户在迁移版真实页面完成人工验收,才能把删除结论视为最终确认。
|
||||
|
||||
## 1. `app/demo_data.py`
|
||||
|
||||
- 类型:旧演示行情构造器,376行、17,199字节。
|
||||
- SHA-256:`fb69682d499993534e7ee029989b35cf512c455eec49e07d7b09658cddd9e028`。
|
||||
- 静态引用:运行代码、配置、启动入口、Docker、页面及后台任务均没有导入或调用`DEMO_LIMITS`、`build_demo_dashboard`、`build_demo_dragon_tiger`或`build_demo_stock_detail`。
|
||||
- 动态/注册路径:没有模块名字符串、插件注册或反射加载。
|
||||
- 产品事实:`app/README.md`明确主行情不再回退演示数据;行情服务和Repository只负责排除历史`source=demo`缓存,未依赖本文件。
|
||||
- 兼容责任:不参与数据库schema、历史记录解释或配置读取。
|
||||
- 决定:进入试删。
|
||||
- 恢复:从切片10标签恢复`app/demo_data.py`。
|
||||
|
||||
## 2. `app/frontend/heaven-loading.js`
|
||||
|
||||
- 类型:旧版问天加载动画,642行、30,563字节。
|
||||
- SHA-256:`ee914762b893a89f745e9e705cac6840c1dbd6c5b68dc7a47620b8c4db3615c7`。
|
||||
- 静态引用:`app/frontend/index.html`只加载`/pages/heaven/loading-v2.js`,没有加载本文件。
|
||||
- 动态/注册路径:没有脚本清单、页面注册表或运行时代码引用旧路径;新旧文件虽然都导出`window.HeavenLoadingCanvas`,但浏览器只能执行v2。
|
||||
- 运行证据:切片10已覆盖观势、观气和观心解读加载动画,v2节点、动画名及可见行为与原版基线一致。
|
||||
- 兼容责任:不是数据库、配置或历史数据资产。
|
||||
- 决定:进入试删。
|
||||
- 恢复:从切片10标签恢复该文件。
|
||||
|
||||
## 3. 五个疑似无引用前端函数
|
||||
|
||||
全前端非供应商JavaScript逐词扫描后,下列符号都只有定义本身一次;HTML、页面注册表、事件绑定、字符串查找、测试入口和其他脚本均无消费者:
|
||||
|
||||
| 函数 | 位置 | 原用途线索 | 决定 |
|
||||
|---|---|---|---|
|
||||
| `commonReviewColumns` | `frontend/shared/export.js` | 旧通用导出列定义;当前每个导出入口均显式传列 | 试删 |
|
||||
| `outcomeClass` | `frontend/app.js` | 旧昨日涨停结果样式映射;当前渲染不调用 | 试删 |
|
||||
| `screenerResultMatchesSelection` | `frontend/pages/screener/page.js` | 旧选股结果存在性包装;已由`activeScreenerResultEntry`直接承担 | 试删 |
|
||||
| `selectRegime` | `frontend/pages/screener/page.js` | 旧阶段手动切换;当前阶段由盘后数据写入且无按钮绑定 | 试删 |
|
||||
| `showHeartRitualCurtain` | `frontend/pages/heaven/page.js` | 旧观心幕帘过场;当前流程直接调用`activateHeartRises` | 试删 |
|
||||
|
||||
本次只删除这5个函数,不连带删除`selectedRegime`、`heartRitualCurtain`隐藏节点、`heartCurtainTimer`或相关CSS。后者与仍在使用的选股状态、观心节点和复合选择器相邻,尚不足以证明可独立删除,默认保留。
|
||||
|
||||
恢复:从切片10标签按上述文件恢复对应函数;源码映射可通过`docs/migration/evidence/slice-10/frontend-source-map.json`定位。
|
||||
|
||||
## 4. `wencai_saved_queries`及其方法
|
||||
|
||||
- 类型:历史兼容数据表及用户隔离Repository方法。
|
||||
- 当前入口:问财前端和`/api/wencai*`已按用户决定取消;iFinD的`wencai()`能力仍由股池原因补全使用,但不依赖保存查询表。
|
||||
- 保留责任:数据库初始化仍创建表和用户索引;`list_wencai_saved_queries`、`save_wencai_query`、`delete_wencai_saved_query`保持已有数据可读取;`test_ifind_features`明确验证跨用户隔离。
|
||||
- 风险:当前数据库表为空不能证明所有用户历史库均为空;删除会改变62项schema契约并破坏旧库兼容。
|
||||
- 决定:保留,不试删。只有未来正式数据库迁移、导出/归档和用户批准同时具备时才允许移除。
|
||||
|
||||
## 5. 试删验收门槛
|
||||
|
||||
每次试删后必须同时满足:
|
||||
|
||||
1. 全前端符号与资源引用扫描没有悬空消费者。
|
||||
2. 全部JavaScript通过`node --check`。
|
||||
3. 原版231项测试继续通过,迁移版全量测试通过。
|
||||
4. 21个固定只读API与数据库schema/关键表差分继续一致;明确批准删除的运行外文件不应改变API或数据库。
|
||||
5. 45项Playwright通过,问天加载动画与智能选股流程无回归。
|
||||
6. 用户使用迁移版完成最终人工页面验收。
|
||||
|
||||
任一项失败时,按本日志指向的切片10标签恢复对应候选,不把其他已通过候选一起回退。
|
||||
|
||||
## 6. 试删后结果
|
||||
|
||||
- 五个前端符号在全部非供应商JavaScript中的剩余引用均为0。
|
||||
- 运行代码中没有`demo_data`导入;页面只请求`/pages/heaven/loading-v2.js`。
|
||||
- 24个现存前端JavaScript文件通过`node --check`。
|
||||
- 原版231项、迁移版298项Python测试和45项Playwright全部通过。
|
||||
- 21个只读API、62个schema对象和21张关键表继续与原版一致。
|
||||
- 桌面日间/夜间及390x844移动端真实页面检查通过,问天三页和观气底部内容可达。
|
||||
- `wencai_saved_queries`及三个方法保持存在,并由清理契约测试持续保护。
|
||||
|
||||
因此上述删除保持“候选试删”状态;只有用户人工验收后才改为“确认废弃”。
|
||||
@@ -0,0 +1,154 @@
|
||||
# 小白复盘人工维护与本地切换指南
|
||||
|
||||
> 适用目录:`webapp/app/`
|
||||
> 当前状态:迁移候选已完成自动验收,尚未获得最终人工验收,不得替换正式部署
|
||||
|
||||
## 1. 先确认哪个版本是正式版本
|
||||
|
||||
- 当前正式基线仍是`webapp/`根目录及根目录`data/review.db`。
|
||||
- `webapp/app/`是保真迁移候选,运行代码来自原版的移动、机械拆分和去重,不是重新开发。
|
||||
- `webapp/next/`是已否决的冻结版本,禁止部署、继续开发或复制实现。
|
||||
- 用户人工验收前,不要删除根级原版,不要让`app/`写入正式数据库,也不要修改NAS容器。
|
||||
|
||||
发生产品含义冲突时,依次以用户明确决定、原版真实运行、原版源码与数据、产品规格书为准。
|
||||
|
||||
## 2. 迁移版目录怎么找代码
|
||||
|
||||
```text
|
||||
app/
|
||||
server.py 进程兼容入口;正式实现装配在backend/
|
||||
backend/bootstrap/ 路径、环境、配置、依赖组装和HTTP服务器
|
||||
backend/http/ 鉴权、路由元数据、响应与静态资源传输
|
||||
backend/features/ 按产品领域组织的服务和Repository
|
||||
backend/data/ 数据网关、质量策略、实时聚合和供应商适配
|
||||
backend/database/ SQLite连接、schema和组合Repository
|
||||
backend/jobs/ 后台任务定义、状态、调度与重试
|
||||
backend/llm/ 所有模型调用、流式传输、额度与审计边界
|
||||
frontend/shared/ API、状态、Shell、组件和跨页能力
|
||||
frontend/pages/ 每个产品页面的原版行为;问天样式也在对应目录
|
||||
frontend/styles/ 原版七层样式和共享令牌
|
||||
config/ 页面、功能、API、任务、字段和数据质量注册表
|
||||
tests/ 单元、边界、保真差分和浏览器回归
|
||||
tools/ 清单、API/数据库差分和保真运行工具
|
||||
data/ 运行数据库、私有Skill和备份;不提交Git
|
||||
游资skills/ 可公开的问师Skill
|
||||
```
|
||||
|
||||
根级`app/*.py`多数是兼容导入壳。维护业务时先到`backend/features/<领域>/`找正式实现,
|
||||
不要在兼容壳中新增第二套逻辑。所有浏览器网络请求必须继续经过
|
||||
`frontend/shared/api.js`,所有LLM调用必须继续经过`backend/llm/`。
|
||||
|
||||
## 3. 本地隔离启动
|
||||
|
||||
不要直接拿正式数据库做迁移验收。先创建一个目录并用SQLite backup API生成一致副本,或使用
|
||||
`app/data/backups/`中专门的验收副本。然后在`webapp`根目录运行:
|
||||
|
||||
```powershell
|
||||
python -u app\tools\run_preservation_runtime.py `
|
||||
--runtime-root app `
|
||||
--data-dir app\data\backups\manual-acceptance `
|
||||
--port 8797
|
||||
```
|
||||
|
||||
浏览器打开`http://127.0.0.1:8797/`。该命令不占用正式`8765`,并把数据库、私有Skill和
|
||||
运行写入限制在指定测试目录。验收完成后先停止该进程,再处理测试副本。
|
||||
|
||||
## 4. 每次改动的最低流程
|
||||
|
||||
1. 阅读`AGENTS.md`、保真迁移状态、迁移账本和对应领域测试。
|
||||
2. 从`config/pages.config.json`与`features.config.json`确认页面、功能和权限边界。
|
||||
3. 只修改一个完整领域路径;不要同时在兼容壳和正式模块写实现。
|
||||
4. 新增API时同步检查`config/api.config.json`及`backend/http/`的权限元数据。
|
||||
5. 用户私有表必须包含并按`user_id`查询,补充跨账号隔离测试。
|
||||
6. 行情字段必须登记来源、时间、单位、复权、新鲜度和降级规则,不允许静默换源。
|
||||
7. 先跑领域测试,再跑下面的全量门槛,最后用真实浏览器检查桌面、夜间和移动端。
|
||||
8. 更新迁移/维护文档后再提交;一个可回档节点只包含一个可以独立解释的改动。
|
||||
|
||||
## 5. 全量验证命令
|
||||
|
||||
原版基线:
|
||||
|
||||
```powershell
|
||||
cd webapp
|
||||
python -m unittest discover -s tests -q
|
||||
```
|
||||
|
||||
迁移版:
|
||||
|
||||
```powershell
|
||||
cd webapp\app
|
||||
python -m unittest discover -s tests -q
|
||||
npx.cmd playwright test --reporter=dot
|
||||
```
|
||||
|
||||
JavaScript和Git差异:
|
||||
|
||||
```powershell
|
||||
cd webapp\app
|
||||
$files = Get-ChildItem frontend -Recurse -Filter *.js
|
||||
foreach ($file in $files) { node --check $file.FullName }
|
||||
cd ..
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Playwright需要能够启动本机无头Edge。若测试停在浏览器启动前且没有`msedge`进程,先检查执行
|
||||
环境是否禁止GUI/无头浏览器进程;这不是页面失败,不要通过删除测试或延长产品超时绕过。
|
||||
|
||||
API和数据库差分工具:
|
||||
|
||||
```text
|
||||
app/tools/compare_preservation_apis.py
|
||||
app/tools/compare_preservation_databases.py
|
||||
```
|
||||
|
||||
运行前先查看脚本参数,并使用同一时点生成的原版/迁移版数据库副本。任何`all_equal=false`都应
|
||||
阻止提交和切换。
|
||||
|
||||
## 6. 数据、密钥和私有内容
|
||||
|
||||
- SQLite数据库与`.env`中的`APP_ENCRYPTION_KEY`必须成对备份;密钥丢失后不能恢复加密字段。
|
||||
- `data/private-mentor-skills/`只属于管理员本机/服务器,不进入Git和Docker镜像。
|
||||
- 不要用文件管理器复制正在写入的`review.db`;使用SQLite backup API或停服后复制。
|
||||
- 不要把Tushare、iFinD、LLM Token、账号密码、数据库副本或私有Skill提交到仓库。
|
||||
- 切换期间只有一个数据库可以成为写入主库,禁止让原版与迁移版长期各写一份后再人工合并。
|
||||
|
||||
## 7. 人工验收清单
|
||||
|
||||
- 用同一账号、日期和主题对照原版与迁移版全部页面。
|
||||
- 检查日间/夜间、1080P/4K、390像素移动端和浏览器缩放后的滚动与弹窗。
|
||||
- 检查图表悬浮、股票/板块/题材/指数详情、全局搜索和日期切换。
|
||||
- 检查选股三个工作区、策略跟踪、刷新后结果保持和候选来源隔离。
|
||||
- 检查问师流式回答只出现一次、置顶排序、历史和会员限制。
|
||||
- 检查问天三页全部过场、呼吸/铜钱、加载动画、历史和解读结果。
|
||||
- 使用两个账号检查自选、笔记、交易日志、提醒和对话互不可见。
|
||||
- 检查系统管理、会员期限、模型池、数据回补和后台任务状态。
|
||||
|
||||
人工验收发现差异时,记录页面、账号、日期、主题、视口、输入和截图;先对照原版复现,再判断
|
||||
是迁移回归还是原版既有问题。
|
||||
|
||||
## 8. 获得批准后的本地切换方案
|
||||
|
||||
以下只是准备步骤,本次迁移没有执行:
|
||||
|
||||
1. 停止原版和迁移版进程,确认没有后台任务继续写库。
|
||||
2. 对根目录正式数据库执行SQLite一致性备份,同时备份`.env`和私有Skill。
|
||||
3. 把同一份最新正式数据恢复到`app/data/`,保持原`APP_ENCRYPTION_KEY`不变。
|
||||
4. 在非`8765`端口启动`app/server.py`并完成健康、登录、关键页面和写入冒烟测试。
|
||||
5. 记录切换提交、数据库备份位置和启动时间后,才把正式入口指向`app/`。
|
||||
6. 切换观察期内保留根级原版和切换前数据库,只允许迁移版写主库。
|
||||
|
||||
Docker/NAS切换应以`app/`作为构建上下文,另行执行构建、卷挂载、权限、健康检查和回退演练。
|
||||
本轮没有进行这些操作。
|
||||
|
||||
## 9. 回退方案
|
||||
|
||||
若切换后出现问题:
|
||||
|
||||
1. 立即停止迁移版,避免继续写库。
|
||||
2. 保存故障日志和当前数据库副本用于调查。
|
||||
3. 恢复切换前成对备份的`review.db`与`.env`。
|
||||
4. 从切换记录指定的原版提交重新启动根级`server.py`。
|
||||
5. 验证登录、健康接口、最近交易日、私有数据和模型配置后恢复使用。
|
||||
|
||||
切片11试删可从`xiaobai-preservation-slice-10-20260731`单项恢复;不要用破坏性的Git重置覆盖
|
||||
正式数据或用户未提交的代码。
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"updated_at": "2026-07-31T11:52:29+08:00",
|
||||
"status": "active",
|
||||
"updated_at": "2026-07-31T16:10:00+08:00",
|
||||
"status": "awaiting_manual_acceptance",
|
||||
"migration_mode": "behavior_preserving_source_migration",
|
||||
"source_of_truth": "current_original_webapp_runtime_and_source",
|
||||
"source_root": ".",
|
||||
@@ -9,10 +9,11 @@
|
||||
"failed_roots": [
|
||||
"next"
|
||||
],
|
||||
"current_slice": "slice-10-frontend-shell-pages-components-css-mobile",
|
||||
"last_completed_slice": "slice-09-review-watchlist-notes-journal-alerts-assistant",
|
||||
"last_checkpoint": "xiaobai-preservation-slice-09-20260731",
|
||||
"next_action": "capture_frontend_asset_dependency_map_then_relocate_original_shell_pages_components_css_and_mobile_responsibilities_without_runtime_changes",
|
||||
"current_slice": "slice-11-uncertain-code-audit-final-acceptance-handoff",
|
||||
"last_completed_slice": "slice-10-frontend-shell-pages-components-css-mobile",
|
||||
"last_automated_slice": "slice-11-uncertain-code-audit-final-acceptance-handoff",
|
||||
"last_checkpoint": "xiaobai-preservation-slice-11-candidate-20260731",
|
||||
"next_action": "user_acceptance_on_local_port_8797_then_confirm_or_restore_each_trial_retirement; do_not_switch_docker_or_nas_before_approval",
|
||||
"authoritative_documents": [
|
||||
"AGENTS.md",
|
||||
"docs/migration/原版保真迁移总纲.md",
|
||||
@@ -27,7 +28,8 @@
|
||||
"preserve_visual_interaction_animation_calculation_and_data_semantics",
|
||||
"require_old_new_differential_evidence_for_every_slice",
|
||||
"keep_original_runtime_available",
|
||||
"do_not_delete_uncertain_code"
|
||||
"do_not_make_trial_retirements_permanent_before_user_acceptance",
|
||||
"do_not_switch_docker_or_nas_before_user_approval"
|
||||
],
|
||||
"approved_decisions": {
|
||||
"target_directory_name": "app",
|
||||
@@ -35,5 +37,9 @@
|
||||
"migration_slice_order": "approved_2026-07-30",
|
||||
"execution_mode": "autonomous_until_complete"
|
||||
},
|
||||
"open_decisions": []
|
||||
"open_decisions": [
|
||||
"user_acceptance_of_visual_interaction_and_animation_equivalence",
|
||||
"final_confirmation_or_restore_of_slice_11_trial_retirements",
|
||||
"local_and_docker_switch_timing"
|
||||
]
|
||||
}
|
||||
|
||||