migration: audit uncertain code and prepare handoff

This commit is contained in:
leefer
2026-07-31 16:48:13 +08:00
parent dec3cd1236
commit faac60b1a6
23 changed files with 979 additions and 1166 deletions
-406
View File
@@ -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,
},
}
-4
View File
@@ -1708,10 +1708,6 @@ function membershipDateDisplay(value) {
/* PRESERVATION-SOURCE-END app.js:8427-8904 */ /* PRESERVATION-SOURCE-END app.js:8427-8904 */
/* PRESERVATION-SOURCE-BEGIN app.js:9052-9283 */ /* PRESERVATION-SOURCE-BEGIN app.js:9052-9283 */
function outcomeClass(outcome) {
return { "晋级": "outcome-advance", "炸板": "outcome-broken", "跌停": "outcome-down", "断板": "outcome-open" }[outcome] || "outcome-open";
}
function trendClass(trend) { function trendClass(trend) {
return { "升温": "trend-hot", "降温": "trend-cool", "新进": "trend-new", "持平": "trend-flat" }[trend] || "trend-flat"; return { "升温": "trend-hot", "降温": "trend-cool", "新进": "trend-new", "持平": "trend-flat" }[trend] || "trend-flat";
} }
-672
View File
@@ -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);
-17
View File
@@ -149,23 +149,6 @@ function selectHeavenPanel(panel, updateUrl = false) {
} }
} }
function showHeartRitualCurtain() {
const curtain = document.querySelector("#heartRitualCurtain");
if (!curtain || curtain.classList.contains("is-visible")) return;
if (state.heartCurtainTimer) clearTimeout(state.heartCurtainTimer);
document.querySelectorAll(".heart-stage.active-heart-stage .heart-rise").forEach((item) => item.classList.remove("is-visible"));
curtain.classList.remove("is-leaving");
curtain.classList.add("is-visible");
state.heartCurtainTimer = setTimeout(() => {
curtain.classList.add("is-leaving");
activateHeartRises(document.querySelector(".heart-stage.active-heart-stage"));
state.heartCurtainTimer = setTimeout(() => {
curtain.classList.remove("is-visible", "is-leaving");
state.heartCurtainTimer = null;
}, motionEnabled() ? 1450 : 10);
}, motionEnabled() ? 3000 : 20);
}
function renderHeavenWorkspace() { function renderHeavenWorkspace() {
const setup = state.heavenSetup; const setup = state.heavenSetup;
if (!setup) return; if (!setup) return;
-11
View File
@@ -52,10 +52,6 @@ function activeScreenerResultEntry(mode = state.screenerMode) {
return key ? state.screenerResultStore[key] || null : null; return key ? state.screenerResultStore[key] || null : null;
} }
function screenerResultMatchesSelection(mode) {
return Boolean(activeScreenerResultEntry(mode));
}
function activeScreenerResult(mode = state.screenerMode) { function activeScreenerResult(mode = state.screenerMode) {
return activeScreenerResultEntry(mode)?.result || null; return activeScreenerResultEntry(mode)?.result || null;
} }
@@ -654,13 +650,6 @@ function updateBacktestTaskStatus() {
renderScreenerProgress(); renderScreenerProgress();
} }
function selectRegime(regime) {
state.selectedRegime = regime;
const recommended = state.screenerSetup.strategies.find((item) => item.formula?.meta?.library !== "curated" && item.regimes.includes(regime));
if (recommended) state.selectedStrategy = recommended;
renderScreenerSetup();
}
function renderStrategyList() { function renderStrategyList() {
const list = document.querySelector("#strategyList"); const list = document.querySelector("#strategyList");
const strategies = state.screenerSetup.strategies.filter((item) => !item.builtin && item.formula?.meta?.library !== "curated"); const strategies = state.screenerSetup.strategies.filter((item) => !item.builtin && item.formula?.meta?.library !== "curated");
-6
View File
@@ -116,12 +116,6 @@ function exportHotMoneyProfiles() {
); );
} }
function commonReviewColumns() {
return [["股票代码", "code"], ["股票名称", "name"], ["状态", "status"], ["涨跌幅%", "change"],
["价格", "price"], ["所属板块", "sector"], ["原因", "reason"], ["首次触板", "first_time"],
["最后触板", "last_time"], ["开板次数", "open_times"], ["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"]];
}
function exportRows(label, rows, columns) { function exportRows(label, rows, columns) {
const headers = columns.map(([header]) => header); const headers = columns.map(([header]) => header);
const data = rows.map((row) => columns.map(([, key]) => row[key] ?? "")); const data = rows.map((row) => columns.map(([, key]) => row[key] ?? ""));
+31 -2
View File
@@ -17,6 +17,16 @@ SOURCE_RANGE = re.compile(
re.DOTALL, 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: def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest() return hashlib.sha256(path.read_bytes()).hexdigest()
@@ -38,8 +48,6 @@ def reassembled_frontend_runtime() -> str:
raise AssertionError( raise AssertionError(
f"app.js source coverage gap: expected line {next_line}, got {start}" f"app.js source coverage gap: expected line {next_line}, got {start}"
) )
if len(content.splitlines(keepends=True)) != end - start + 1:
raise AssertionError(f"app.js line count changed in range {start}-{end}")
assembled.append(content) assembled.append(content)
next_line = end + 1 next_line = end + 1
@@ -53,6 +61,27 @@ def reassembled_frontend_runtime() -> str:
return "".join(assembled) 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( def assert_moved_asset_matches(
testcase, testcase,
original_relative: str, original_relative: str,
+50
View File
@@ -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()
@@ -7,18 +7,15 @@ from backend.bootstrap.config import APP_DIR, STATIC_DIR
from tests.preservation_helpers import ( from tests.preservation_helpers import (
FRONTEND_ROOT, FRONTEND_ROOT,
ORIGINAL_STATIC, ORIGINAL_STATIC,
assert_frontend_runtime_matches_audited_baseline,
assert_moved_asset_matches, assert_moved_asset_matches,
assert_page_prefix_matches, assert_page_prefix_matches,
reassembled_frontend_runtime,
) )
class FrontendPreservationSliceTests(unittest.TestCase): class FrontendPreservationSliceTests(unittest.TestCase):
def test_split_runtime_reassembles_to_the_exact_original(self) -> None: def test_split_runtime_matches_original_except_audited_retirements(self) -> None:
self.assertEqual( assert_frontend_runtime_matches_audited_baseline(self)
reassembled_frontend_runtime(),
(ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"),
)
def test_index_diff_is_limited_to_asset_relocation_and_split_loading(self) -> None: def test_index_diff_is_limited_to_asset_relocation_and_split_loading(self) -> None:
migrated = (FRONTEND_ROOT / "index.html").read_text(encoding="utf-8") migrated = (FRONTEND_ROOT / "index.html").read_text(encoding="utf-8")
@@ -64,7 +61,6 @@ class FrontendPreservationSliceTests(unittest.TestCase):
for original, migrated in ( for original, migrated in (
("ui-core.js", "shared/ui-core.js"), ("ui-core.js", "shared/ui-core.js"),
("heaven-loading-v2.js", "pages/heaven/loading-v2.js"), ("heaven-loading-v2.js", "pages/heaven/loading-v2.js"),
("heaven-loading.js", "heaven-loading.js"),
("vendor/lucide.min.js", "vendor/lucide.min.js"), ("vendor/lucide.min.js", "vendor/lucide.min.js"),
("pages.config.js", "pages.config.js"), ("pages.config.js", "pages.config.js"),
("pages/runtime.js", "pages/runtime.js"), ("pages/runtime.js", "pages/runtime.js"),
@@ -6,10 +6,9 @@ import unittest
from pathlib import Path from pathlib import Path
from tests.preservation_helpers import ( from tests.preservation_helpers import (
ORIGINAL_STATIC, assert_frontend_runtime_matches_audited_baseline,
assert_moved_asset_matches, assert_moved_asset_matches,
assert_page_prefix_matches, assert_page_prefix_matches,
reassembled_frontend_runtime,
) )
@@ -84,10 +83,7 @@ class LadderRotationSliceSourceEquivalenceTests(unittest.TestCase):
sha256(APP_ROOT / "config/api.config.json"), sha256(APP_ROOT / "config/api.config.json"),
sha256(ORIGINAL_ROOT / "config/api.config.json"), sha256(ORIGINAL_ROOT / "config/api.config.json"),
) )
self.assertEqual( assert_frontend_runtime_matches_audited_baseline(self)
reassembled_frontend_runtime(),
(ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"),
)
assert_moved_asset_matches(self, "styles.css", "styles/styles.css") assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
for page in ("pages/ladder/page.js", "pages/rotation/page.js"): for page in ("pages/ladder/page.js", "pages/rotation/page.js"):
assert_page_prefix_matches(self, page) assert_page_prefix_matches(self, page)
+2 -6
View File
@@ -14,9 +14,8 @@ from backend.data.providers import ifind_client as canonical_ifind
from backend.data.providers import tushare_client as canonical_tushare from backend.data.providers import tushare_client as canonical_tushare
from backend.features.market import charts from backend.features.market import charts
from tests.preservation_helpers import ( from tests.preservation_helpers import (
ORIGINAL_STATIC, assert_frontend_runtime_matches_audited_baseline,
assert_moved_asset_matches, assert_moved_asset_matches,
reassembled_frontend_runtime,
) )
@@ -152,10 +151,7 @@ class MarketSliceSourceEquivalenceTests(unittest.TestCase):
) )
def test_relocated_frontend_preserves_original_market_runtime_and_styles(self) -> None: def test_relocated_frontend_preserves_original_market_runtime_and_styles(self) -> None:
self.assertEqual( assert_frontend_runtime_matches_audited_baseline(self)
reassembled_frontend_runtime(),
(ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"),
)
for original, migrated in ( for original, migrated in (
("styles.css", "styles/styles.css"), ("styles.css", "styles/styles.css"),
("renovation.css", "styles/renovation.css"), ("renovation.css", "styles/renovation.css"),
@@ -8,10 +8,9 @@ from pathlib import Path
import market_insights import market_insights
from backend.features.market import insights as canonical_insights from backend.features.market import insights as canonical_insights
from tests.preservation_helpers import ( from tests.preservation_helpers import (
ORIGINAL_STATIC, assert_frontend_runtime_matches_audited_baseline,
assert_moved_asset_matches, assert_moved_asset_matches,
assert_page_prefix_matches, assert_page_prefix_matches,
reassembled_frontend_runtime,
) )
@@ -184,10 +183,7 @@ class MarketInsightsSliceSourceEquivalenceTests(unittest.TestCase):
sha256(APP_ROOT / "config/api.config.json"), sha256(APP_ROOT / "config/api.config.json"),
sha256(ORIGINAL_ROOT / "config/api.config.json"), sha256(ORIGINAL_ROOT / "config/api.config.json"),
) )
self.assertEqual( assert_frontend_runtime_matches_audited_baseline(self)
reassembled_frontend_runtime(),
(ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"),
)
assert_moved_asset_matches(self, "styles.css", "styles/styles.css") assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
for page in ( for page in (
"pages/auction/page.js", "pages/auction/page.js",
@@ -12,10 +12,9 @@ import strategy_tracking
from backend.features.screener import compiler, engine, strategies, tracking from backend.features.screener import compiler, engine, strategies, tracking
from backend.features.screener import service as screener_service from backend.features.screener import service as screener_service
from tests.preservation_helpers import ( from tests.preservation_helpers import (
ORIGINAL_STATIC, assert_frontend_runtime_matches_audited_baseline,
assert_moved_asset_matches, assert_moved_asset_matches,
assert_page_prefix_matches, assert_page_prefix_matches,
reassembled_frontend_runtime,
) )
@@ -188,10 +187,7 @@ class ScreenerSliceSourceEquivalenceTests(unittest.TestCase):
) )
def test_screener_frontend_assets_are_unchanged(self) -> None: def test_screener_frontend_assets_are_unchanged(self) -> None:
self.assertEqual( assert_frontend_runtime_matches_audited_baseline(self)
reassembled_frontend_runtime(),
(ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"),
)
assert_moved_asset_matches(self, "styles.css", "styles/styles.css") assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
assert_page_prefix_matches(self, "pages/screener/page.js") assert_page_prefix_matches(self, "pages/screener/page.js")
@@ -8,10 +8,9 @@ from pathlib import Path
import sentiment_engine import sentiment_engine
from backend.features.sentiment import engine as canonical_engine from backend.features.sentiment import engine as canonical_engine
from tests.preservation_helpers import ( from tests.preservation_helpers import (
ORIGINAL_STATIC, assert_frontend_runtime_matches_audited_baseline,
assert_moved_asset_matches, assert_moved_asset_matches,
assert_page_prefix_matches, assert_page_prefix_matches,
reassembled_frontend_runtime,
) )
@@ -105,10 +104,7 @@ class SentimentPoolSliceSourceEquivalenceTests(unittest.TestCase):
sha256(APP_ROOT / "config/api.config.json"), sha256(APP_ROOT / "config/api.config.json"),
sha256(ORIGINAL_ROOT / "config/api.config.json"), sha256(ORIGINAL_ROOT / "config/api.config.json"),
) )
self.assertEqual( assert_frontend_runtime_matches_audited_baseline(self)
reassembled_frontend_runtime(),
(ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"),
)
assert_moved_asset_matches(self, "styles.css", "styles/styles.css") assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
for page in ("pages/sentiment/page.js", "pages/pools/page.js"): for page in ("pages/sentiment/page.js", "pages/pools/page.js"):
assert_page_prefix_matches(self, page) assert_page_prefix_matches(self, page)
+2
View File
@@ -7,5 +7,7 @@
3. [`保真迁移状态.json`](保真迁移状态.json):机器可读当前状态和下一步。 3. [`保真迁移状态.json`](保真迁移状态.json):机器可读当前状态和下一步。
4. [`保真迁移账本.md`](保真迁移账本.md):连续检查点、资产处置和决策记录。 4. [`保真迁移账本.md`](保真迁移账本.md):连续检查点、资产处置和决策记录。
5. [`next失败冻结记录.md`](next失败冻结记录.md):失败实现的隔离边界。 5. [`next失败冻结记录.md`](next失败冻结记录.md):失败实现的隔离边界。
6. [`人工维护与本地切换指南.md`](人工维护与本地切换指南.md):迁移版目录、验证、数据边界、人工验收、切换与回退。
7. [`evidence/slice-11/README.md`](evidence/slice-11/README.md):当前候选的试删审计和全量自动验收结果。
`重建迁移章程.md``next/`内阶段文档均是失败过程历史记录,不再指导后续实施。 `重建迁移章程.md``next/`内阶段文档均是失败过程历史记录,不再指导后续实施。
@@ -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重置覆盖
正式数据或用户未提交的代码。
+12 -6
View File
@@ -1,7 +1,7 @@
{ {
"schema_version": 1, "schema_version": 1,
"updated_at": "2026-07-31T12:30:00+08:00", "updated_at": "2026-07-31T16:10:00+08:00",
"status": "active", "status": "awaiting_manual_acceptance",
"migration_mode": "behavior_preserving_source_migration", "migration_mode": "behavior_preserving_source_migration",
"source_of_truth": "current_original_webapp_runtime_and_source", "source_of_truth": "current_original_webapp_runtime_and_source",
"source_root": ".", "source_root": ".",
@@ -11,8 +11,9 @@
], ],
"current_slice": "slice-11-uncertain-code-audit-final-acceptance-handoff", "current_slice": "slice-11-uncertain-code-audit-final-acceptance-handoff",
"last_completed_slice": "slice-10-frontend-shell-pages-components-css-mobile", "last_completed_slice": "slice-10-frontend-shell-pages-components-css-mobile",
"last_checkpoint": "xiaobai-preservation-slice-10-20260731", "last_automated_slice": "slice-11-uncertain-code-audit-final-acceptance-handoff",
"next_action": "commit_and_push_slice_10_then_audit_each_uncertain_asset_with_independent_evidence_and_retain_any_asset_not_proven_safe_to_remove", "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": [ "authoritative_documents": [
"AGENTS.md", "AGENTS.md",
"docs/migration/原版保真迁移总纲.md", "docs/migration/原版保真迁移总纲.md",
@@ -27,7 +28,8 @@
"preserve_visual_interaction_animation_calculation_and_data_semantics", "preserve_visual_interaction_animation_calculation_and_data_semantics",
"require_old_new_differential_evidence_for_every_slice", "require_old_new_differential_evidence_for_every_slice",
"keep_original_runtime_available", "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": { "approved_decisions": {
"target_directory_name": "app", "target_directory_name": "app",
@@ -35,5 +37,9 @@
"migration_slice_order": "approved_2026-07-30", "migration_slice_order": "approved_2026-07-30",
"execution_mode": "autonomous_until_complete" "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"
]
} }
+19 -5
View File
@@ -1,6 +1,6 @@
# 小白复盘保真迁移账本 # 小白复盘保真迁移账本
> 当前状态:正式迁移,切片10“前端Shell、页面、样式与移动端职责归位”已完成 > 当前状态:切片11自动验收完成,等待用户人工验收;正式切换尚未执行
本账本是上下文恢复和人工审计的连续记录。任何迁移提交必须在同一提交中更新本文件及 本账本是上下文恢复和人工审计的连续记录。任何迁移提交必须在同一提交中更新本文件及
`保真迁移状态.json` `保真迁移状态.json`
@@ -30,6 +30,7 @@
| 2026-07-31 | `xiaobai-preservation-slice-08-20260731` | 问天、观势、观气与观心原实现归位 | 自动、API、数据库、动画与浏览器差分通过,进入切片09 | | 2026-07-31 | `xiaobai-preservation-slice-08-20260731` | 问天、观势、观气与观心原实现归位 | 自动、API、数据库、动画与浏览器差分通过,进入切片09 |
| 2026-07-31 | `xiaobai-preservation-slice-09-20260731` | 复盘、自选、交易日志、提醒与复盘助手原实现归位 | 自动、API、数据库、账户隔离与浏览器差分通过,进入切片10 | | 2026-07-31 | `xiaobai-preservation-slice-09-20260731` | 复盘、自选、交易日志、提醒与复盘助手原实现归位 | 自动、API、数据库、账户隔离与浏览器差分通过,进入切片10 |
| 2026-07-31 | `xiaobai-preservation-slice-10-20260731` | 前端Shell、页面、CSS、动画与移动端职责归位 | 源码重组、自动、API、数据库、桌面、夜间、移动端与动画差分通过,进入切片11 | | 2026-07-31 | `xiaobai-preservation-slice-10-20260731` | 前端Shell、页面、CSS、动画与移动端职责归位 | 源码重组、自动、API、数据库、桌面、夜间、移动端与动画差分通过,进入切片11 |
| 2026-07-31 | `xiaobai-preservation-slice-11-candidate-20260731` | 不确定代码审计、全量验收与人工交接准备 | 自动、API、数据库和浏览器验收通过;等待人工确认,未切换部署 |
## 资产处置登记 ## 资产处置登记
@@ -39,10 +40,10 @@
|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|
| 原版运行源文件(排除`next/`、日志、缓存、构建产物和正式数据库) | 运行资产 | 全站 | 原样保留后逐项移动 | `app/` | 388项受控资产哈希一致;231项Python与45项Playwright测试通过 | 已复制 | | 原版运行源文件(排除`next/`、日志、缓存、构建产物和正式数据库) | 运行资产 | 全站 | 原样保留后逐项移动 | `app/` | 388项受控资产哈希一致;231项Python与45项Playwright测试通过 | 已复制 |
| `next/` | 失败实现 | 无正式消费者 | 原样保留但禁止迁移 | - | 用户冻结决定 | 已冻结 | | `next/` | 失败实现 | 无正式消费者 | 原样保留但禁止迁移 | - | 用户冻结决定 | 已冻结 |
| `demo_data.py` | 疑似旧兼容代码 | 未发现运行导入 | 待定 | 待删隔离账本 | 静态扫描不足以授权删除 | 保留 | | `demo_data.py` | 旧演示代码 | 无运行、动态、配置或兼容消费者 | 候选试删 | 切片10标签可单项恢复 | 全量自动、API、数据库与浏览器验收通过 | 待人工确认 |
| `static/heaven-loading.js` | 疑似旧动画 | 当前页面加载`heaven-loading-v2.js` | 待定 | 待删隔离账本 | 仍需动画运行覆盖 | 保留 | | `static/heaven-loading.js` | 旧动画 | 页面加载`heaven-loading-v2.js` | 候选试删 | 切片10标签可单项恢复 | 问天三页加载动画与45项Playwright通过 | 待人工确认 |
| `commonReviewColumns`等5个前端函数 | 疑似无引用符号 | 未发现静态调用 | 待定 | 待删隔离账本 | 仍需动态注册与浏览器覆盖 | 保留 | | `commonReviewColumns`等5个前端函数 | 无引用符号 | 定义外引用为0 | 候选试删 | 切片10标签可按原行号恢复 | 审计白名单比较、24个JS语法和浏览器流程通过 | 待人工确认 |
| `wencai_saved_queries`及其方法 | 历史兼容数据 | 当前无前端入口 | 待定 | 数据库兼容区 | 不允许在迁移期破坏旧库 | 保留 | | `wencai_saved_queries`及其方法 | 历史兼容数据 | 旧库兼容与用户隔离 | 原样保留 | `app/backend/database/`兼容区 | schema及关键表差分一致;清理契约持续保护 | 保留 |
| 现有7层CSS | 视觉运行资产 | 全部页面和主题 | 原样保留后逐页归档 | `app/frontend/` | 必须通过截图与计算样式差分 | 保留 | | 现有7层CSS | 视觉运行资产 | 全部页面和主题 | 原样保留后逐页归档 | `app/frontend/` | 必须通过截图与计算样式差分 | 保留 |
| `sentiment_engine.py` | 情绪周期计算 | 总览、轮动、选股 | 移动并保留兼容别名 | `app/backend/features/sentiment/engine.py` | 文件哈希与原版一致;248项Python与45项Playwright通过 | 已移动 | | `sentiment_engine.py` | 情绪周期计算 | 总览、轮动、选股 | 移动并保留兼容别名 | `app/backend/features/sentiment/engine.py` | 文件哈希与原版一致;248项Python与45项Playwright通过 | 已移动 |
| `DashboardService`情绪及股池原因方法 | 业务服务 | 情绪页、五类股池、涨停表现 | 按职责机械移动 | `app/backend/features/sentiment/``app/backend/features/pools/` | 8个方法AST与原版一致;真实API完全一致 | 已移动 | | `DashboardService`情绪及股池原因方法 | 业务服务 | 情绪页、五类股池、涨停表现 | 按职责机械移动 | `app/backend/features/sentiment/``app/backend/features/pools/` | 8个方法AST与原版一致;真实API完全一致 | 已移动 |
@@ -189,6 +190,19 @@
- 回档:标签`xiaobai-preservation-slice-10-20260731` - 回档:标签`xiaobai-preservation-slice-10-20260731`
- 完整证据:`docs/migration/evidence/slice-10/README.md` - 完整证据:`docs/migration/evidence/slice-10/README.md`
自动验收完成、等待人工确认的切片:`slice-11-uncertain-code-audit-final-acceptance-handoff`
- 原版基线:提交`dec3cd12365df5e7d4c391369f14c457cf56d7d9`,即切片10回档点。
- 审计范围:`demo_data.py`、旧问天加载动画、5个无引用前端函数及`wencai_saved_queries`兼容责任。
- 处置:前3类进入可单项回档的候选试删;`wencai_saved_queries`表和方法因旧库兼容与用户隔离继续保留。
- 保护方式:全部历史前端源码测试统一使用审计白名单比较,只允许登记的原`app.js`行号缺失,其他差异仍会失败。
- API与数据库:21个全站只读API全部一致;62个schema对象和21张关键表逐行一致。
- 验收:原版231项、迁移版298项Python测试、24个JavaScript语法检查和45项Playwright(1.6分钟)通过;桌面日间/夜间、390×844五个移动入口和问天三页真实检查通过。
- 部署边界:未改正式数据库、`.env``8765`、Docker或NAS;原版仍是正式运行基线。
- 候选回档:标签`xiaobai-preservation-slice-11-candidate-20260731`;试删最终结论仍等待用户人工确认。
- 完整证据:`docs/migration/evidence/slice-11/README.md`
- 维护交接:`docs/migration/人工维护与本地切换指南.md`
## 决策记录 ## 决策记录
| 日期 | 决策 | 原因 | | 日期 | 决策 | 原因 |