Compare commits

..
110 changed files with 12075 additions and 10216 deletions
+4 -88
View File
@@ -1,91 +1,7 @@
from __future__ import annotations
"""Compatibility alias for the canonical review-assistant implementation."""
import json
import urllib.error
import urllib.request
from collections.abc import Iterator
from typing import Any
import sys
from llm_stream import OpenAIStreamAccumulator
from backend.features.review import agent as _implementation
class ReviewAssistantError(RuntimeError):
pass
def stream_review_assistant(
context: dict[str, Any],
question: str,
history: list[dict[str, str]],
api_key: str,
base_url: str,
model: str,
timeout: int = 120,
) -> Iterator[str]:
if not api_key or not model:
raise ReviewAssistantError("智能解读服务尚未配置。")
messages = [{"role": "system", "content": _system_prompt(context)}]
messages.extend(history[-12:])
messages.append({"role": "user", "content": question})
request = urllib.request.Request(
f"{base_url.rstrip('/')}/chat/completions",
data=json.dumps(
{"model": model, "messages": messages, "stream": True}, ensure_ascii=False
).encode("utf-8"),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"User-Agent": "XiaobaiReviewWeb/1.0",
"Accept": "text/event-stream",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
yielded = False
accumulator = OpenAIStreamAccumulator()
for raw_line in response:
line = raw_line.decode("utf-8", errors="replace").strip()
if not line or line.startswith(":"):
continue
if line.startswith("data:"):
line = line[5:].strip()
if line == "[DONE]":
break
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
choices = payload.get("choices") or []
if not choices:
continue
choice = choices[0] or {}
content = accumulator.feed(choice)
if content:
yielded = True
yield str(content)
if not yielded:
raise ReviewAssistantError("智能解读未返回有效内容。")
except urllib.error.HTTPError as exc:
raise ReviewAssistantError(f"智能解读服务暂不可用({exc.code})。") from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise ReviewAssistantError("智能解读连接中断,请稍后重试。") from exc
def _system_prompt(context: dict[str, Any]) -> str:
context_json = json.dumps(context, ensure_ascii=False, separators=(",", ":"))
return f"""
你是“小白复盘”的统一复盘助手。你负责把网页中已经存在的市场统计、策略跟踪、提醒、复盘笔记和手工交易日志连接起来,帮助用户复盘和形成下一步观察计划。
最高优先级规则:
1. 只能使用下方“网页复盘数据”,数据缺失就明确说明,不得补造行情、交易或胜率。
2. 不自动下单,不声称已执行任何操作,不修改策略、提醒、笔记或交易日志。
3. 不承诺收益,不给无条件买卖指令。建议必须写成条件、失效条件和风险边界。
4. 区分市场事实、用户记录和你的推断。引用数字时写明数据日期。
5. 优先结合用户自己的策略跟踪与交易日志寻找可验证的重复模式;样本不足时明确标注。
6. 使用中文,先直接回答,再给数据依据和下一步观察。避免空泛口号,不展示模型、接口或内部工程信息。
7. 控制在 800 个中文字符以内,除非用户明确要求展开。
网页复盘数据:
{context_json}
""".strip()
sys.modules[__name__] = _implementation
+6 -292
View File
@@ -11,7 +11,6 @@ from http.server import BaseHTTPRequestHandler
from typing import Any
from urllib.parse import parse_qs, unquote, urlparse
from assistant_agent import ReviewAssistantError, stream_review_assistant
from api_access import ROUTES
from backend.bootstrap.container import build_application_container
from backend.bootstrap.settings import load_runtime_settings
@@ -21,14 +20,14 @@ from backend.llm.http import LLMHttpMixin
from backend.llm.service import LLMServiceMixin
from backend.features.market import ChartDataError, MarketServiceMixin
from backend.features.heaven import HeavenHttpMixin, HeavenServiceMixin, build_personal_field
from backend.features.alerts import AlertHttpMixin, AlertServiceMixin
from backend.features.review import ReviewHttpMixin, ReviewServiceMixin
from backend.bootstrap.config import (
DATA_DIR,
MENTOR_SKILLS_DIR,
PRIVATE_MENTOR_SKILLS_DIR,
TOKEN_PATTERN,
normalize_date,
tushare_code,
validate_stock_code,
validate_text,
)
from database import ReviewDatabase
@@ -79,6 +78,8 @@ class DashboardService(
ScreenerServiceMixin,
MentorServiceMixin,
HeavenServiceMixin,
AlertServiceMixin,
ReviewServiceMixin,
LLMServiceMixin,
):
def __init__(self) -> None:
@@ -501,209 +502,6 @@ class DashboardService(
return ""
def alert_center(self, status: str = "all", as_of: str = "") -> dict[str, Any]:
tracking = self.strategy_tracking.list_tracking(self.current_user_id, 12)
self.alert_service.sync_strategy_tracking(self.current_user_id, tracking)
return self.alert_service.list_alerts(
self.current_user_id, status, as_of
)
def create_alert(self, payload: dict[str, Any]) -> dict[str, Any]:
alert_id = self.alert_service.create_manual(self.current_user_id, payload)
return {"id": alert_id, **self.alert_center()}
def mark_alert_read(self, alert_id: int) -> dict[str, Any]:
self.alert_service.mark_read(self.current_user_id, alert_id)
return self.alert_center()
def mark_all_alerts_read(self, as_of: str = "") -> dict[str, Any]:
compact_date = self.alert_service.calendar_date(as_of or date.today().isoformat())
self.alert_service.mark_all_read(self.current_user_id, compact_date)
return self.alert_center(as_of=compact_date)
def delete_alert(self, alert_id: int) -> dict[str, Any]:
deleted = self.alert_service.delete(self.current_user_id, alert_id)
return {"deleted": deleted, **self.alert_center()}
def trade_entries(
self, start_date: str = "", end_date: str = "", code: str = ""
) -> dict[str, Any]:
return self.trade_journal.list_entries(
self.current_user_id, start_date, end_date, code
)
def review_watchlist(self, trade_date: str) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
items = self.database.list_watchlist(self.current_user_id)
if not items:
return {"items": [], "trade_date": normalized_date}
resolved_date = normalized_date
if self.configured:
try:
client = self._tushare_client()
resolved_date, _ = client.resolve_trade_context(normalized_date)
history = self.database.watchlist_price_history(
[str(item["code"]) for item in items], resolved_date
)
missing_codes = [
str(item["code"]) for item in items
if len(history.get(str(item["code"])) or []) < 6
]
start_date = (
datetime.strptime(resolved_date, "%Y%m%d") - timedelta(days=24)
).strftime("%Y%m%d")
for code in missing_codes:
rows = client.query(
"daily",
{
"ts_code": tushare_code(code),
"start_date": start_date,
"end_date": resolved_date,
},
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
)
if rows:
self.database.upsert_daily_bars(rows)
if missing_codes:
history = self.database.watchlist_price_history(
[str(item["code"]) for item in items], resolved_date
)
except (TushareError, ValueError):
history = self.database.watchlist_price_history(
[str(item["code"]) for item in items], resolved_date
)
else:
history = self.database.watchlist_price_history(
[str(item["code"]) for item in items], resolved_date
)
auction_scores: dict[str, Any] = {}
try:
auction = self.auction_center(normalized_date, False)
auction_scores = {
str(row.get("code") or ""): row.get("attention_score")
for row in (auction.get("watchlist_rows") or [])
if row.get("available", True)
}
except (TushareError, ValueError):
pass
enriched = []
for item in items:
code = str(item.get("code") or "")
bars = history.get(code) or []
latest = bars[-1] if bars else {}
close = float(latest.get("close") or 0)
base_close = float(bars[-6].get("close") or 0) if len(bars) >= 6 else 0
enriched.append(
{
**item,
"change": (
round(float(latest.get("pct_chg") or 0), 2) if latest else None
),
"return_5d": (
round((close / base_close - 1) * 100, 2)
if close > 0 and base_close > 0 else None
),
"attention_score": auction_scores.get(code),
"market_date": str(latest.get("trade_date") or ""),
}
)
return {"items": enriched, "trade_date": resolved_date}
def save_trade_entry(self, payload: dict[str, Any]) -> dict[str, Any]:
trade_id = self.trade_journal.save(self.current_user_id, payload)
return {"id": trade_id, **self.trade_entries()}
def delete_trade_entry(self, trade_id: int) -> dict[str, Any]:
deleted = self.trade_journal.delete(self.current_user_id, trade_id)
return {"deleted": deleted, **self.trade_entries()}
def assistant_messages(self) -> list[dict[str, Any]]:
return self.database.list_assistant_messages(self.current_user_id)
def clear_assistant_messages(self) -> int:
return self.database.delete_assistant_messages(self.current_user_id)
def assistant_stream(self, payload: dict[str, Any]):
question = validate_text(payload.get("question"), "问题", 2000, required=True)
trade_date = normalize_date(
str(payload.get("trade_date") or date.today().isoformat())
)
context = self._assistant_context(trade_date)
history = [
{"role": item["role"], "content": str(item["content"])[:4000]}
for item in self.assistant_messages()[-12:]
if item.get("role") in {"user", "assistant"}
]
def generate():
answer_parts: list[str] = []
events = self.llm_gateway.stream(
"assistant",
"review-assistant-v1",
lambda profile: stream_review_assistant(
context,
question,
history,
profile.api_key,
profile.base_url,
profile.model,
),
(ReviewAssistantError,),
)
for event in events:
if event.kind == "delta":
chunk = str(event.value or "")
answer_parts.append(chunk)
yield chunk
elif event.kind == "complete":
self.database.save_assistant_exchange(
self.current_user_id,
question,
"".join(answer_parts).strip(),
trade_date,
)
return generate()
def _assistant_context(self, trade_date: str) -> dict[str, Any]:
dashboard = self.get_dashboard(trade_date)
actual_date = normalize_date(
str((dashboard.get("meta") or {}).get("trade_date") or trade_date)
)
sentiment = self.sentiment_history(actual_date, 10)
tracking = self.strategy_tracking.list_tracking(self.current_user_id, 5)
alerts = self.alert_service.list_alerts(
self.current_user_id, "all", date.today().isoformat()
)
trades = self.trade_journal.list_entries(
self.current_user_id, end_date=actual_date
)
return {
"data_date": actual_date,
"market": {
"overview": dashboard.get("overview") or {},
"top_sectors": (dashboard.get("sectors") or [])[:8],
"limit_performance": dashboard.get("limit_performance") or {},
"sentiment_history": (sentiment.get("rows") or [])[-10:],
},
"personal": {
"watchlist": self.database.list_watchlist(self.current_user_id)[:30],
"review_notes": self.database.list_notes(
self.current_user_id, scope="daily"
)[:10],
"strategy_tracking": {
"summary": tracking.get("summary") or {},
"batches": (tracking.get("batches") or [])[:5],
},
"alerts": (alerts.get("items") or [])[:20],
"trade_summary": trades.get("summary") or {},
"trade_entries": (trades.get("items") or [])[:30],
},
}
SERVICE = DashboardService()
@@ -714,6 +512,8 @@ class RequestHandler(
SystemHttpMixin,
MentorHttpMixin,
HeavenHttpMixin,
AlertHttpMixin,
ReviewHttpMixin,
LLMHttpMixin,
HttpTransportMixin,
BaseHTTPRequestHandler,
@@ -1256,43 +1056,7 @@ class RequestHandler(
return
self.send_json({"error": "Not found"}, HTTPStatus.NOT_FOUND)
def save_alert(self) -> None:
try:
body = self.read_json_body()
self.send_json({"ok": True, **SERVICE.create_alert(body)}, HTTPStatus.CREATED)
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def save_trade_entry(self) -> None:
try:
body = self.read_json_body()
self.send_json({"ok": True, **SERVICE.save_trade_entry(body)}, HTTPStatus.CREATED)
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def stream_assistant_chat(self) -> None:
try:
body = self.read_json_body()
stream = SERVICE.assistant_stream(body)
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
self.send_header("Cache-Control", "no-cache, no-transform")
self.send_header("X-Accel-Buffering", "no")
self.send_header("Connection", "close")
self.end_headers()
try:
for chunk in stream:
self._write_stream_event({"type": "delta", "content": chunk})
self._write_stream_event({"type": "done"})
except (ValueError, ReviewAssistantError) as exc:
self._write_stream_event({"type": "error", "error": str(exc)})
except (BrokenPipeError, ConnectionResetError):
pass
finally:
self.close_connection = True
def _write_stream_event(self, payload: dict[str, Any]) -> None:
self.wfile.write(
@@ -1301,56 +1065,6 @@ class RequestHandler(
self.wfile.flush()
def save_watchlist(self) -> None:
try:
body = self.read_json_body()
code = validate_stock_code(str(body.get("code", "")))
name = validate_text(body.get("name"), "股票名称", 30, required=True)
sector = validate_text(body.get("sector"), "所属板块", 50)
color = str(body.get("color") or "red")
if color not in {"red", "blue", "green", "amber"}:
raise ValueError("标记颜色不支持。")
remark = validate_text(body.get("remark"), "跟踪备注", 240)
SERVICE.database.save_watchlist(
SERVICE.current_user_id, code, name, sector, color, remark
)
self.send_json(
{
"ok": True,
"items": SERVICE.database.list_watchlist(SERVICE.current_user_id),
}
)
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def save_note(self) -> None:
try:
body = self.read_json_body()
code = str(body.get("code") or "").strip()
if code:
code = validate_stock_code(code)
stock_name = validate_text(body.get("stock_name"), "股票名称", 30)
trade_date = normalize_date(str(body.get("trade_date") or date.today().isoformat()))
summary = validate_text(body.get("summary"), "盘面摘要", 500)
content = validate_text(body.get("content"), "复盘内容", 5000)
plan = validate_text(body.get("plan"), "明日计划", 2000)
if not summary and not content and not plan:
raise ValueError("每日复盘内容不能全部为空。")
raw_id = body.get("id")
note_id = int(raw_id) if raw_id else None
saved_id = SERVICE.database.save_note(
SERVICE.current_user_id,
code,
stock_name,
trade_date,
content,
plan,
note_id,
summary=summary,
)
self.send_json({"ok": True, "id": saved_id})
except (ValueError, TypeError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def save_reason(self) -> None:
try:
+1 -1
View File
@@ -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"
+17 -2
View File
@@ -1,3 +1,18 @@
from .service import AlertService
from .facade import AlertServiceMixin
from .http import AlertHttpMixin
from .repository import AlertRepositoryMixin
__all__ = ["AlertService"]
__all__ = [
"AlertHttpMixin",
"AlertRepositoryMixin",
"AlertService",
"AlertServiceMixin",
]
def __getattr__(name: str):
if name == "AlertService":
from .service import AlertService
return AlertService
raise AttributeError(name)
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from datetime import date
from typing import Any
class AlertServiceMixin:
def alert_center(self, status: str = "all", as_of: str = "") -> dict[str, Any]:
tracking = self.strategy_tracking.list_tracking(self.current_user_id, 12)
self.alert_service.sync_strategy_tracking(self.current_user_id, tracking)
return self.alert_service.list_alerts(
self.current_user_id, status, as_of
)
def create_alert(self, payload: dict[str, Any]) -> dict[str, Any]:
alert_id = self.alert_service.create_manual(self.current_user_id, payload)
return {"id": alert_id, **self.alert_center()}
def mark_alert_read(self, alert_id: int) -> dict[str, Any]:
self.alert_service.mark_read(self.current_user_id, alert_id)
return self.alert_center()
def mark_all_alerts_read(self, as_of: str = "") -> dict[str, Any]:
compact_date = self.alert_service.calendar_date(as_of or date.today().isoformat())
self.alert_service.mark_all_read(self.current_user_id, compact_date)
return self.alert_center(as_of=compact_date)
def delete_alert(self, alert_id: int) -> dict[str, Any]:
deleted = self.alert_service.delete(self.current_user_id, alert_id)
return {"deleted": deleted, **self.alert_center()}
+16
View File
@@ -0,0 +1,16 @@
from __future__ import annotations
import json
from http import HTTPStatus
class AlertHttpMixin:
def save_alert(self) -> None:
try:
body = self.read_json_body()
self.send_json(
{"ok": True, **self.application_service.create_alert(body)},
HTTPStatus.CREATED,
)
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
class AlertRepositoryMixin:
def save_alert(
self,
user_id: int,
kind: str,
title: str,
content: str,
available_date: str,
code: str,
dedupe_key: str,
) -> int:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
connection.execute(
"""
INSERT INTO alerts
(user_id, kind, title, content, available_date, code, dedupe_key,
is_read, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
ON CONFLICT(user_id, dedupe_key) DO UPDATE SET
title=excluded.title, content=excluded.content,
available_date=excluded.available_date, updated_at=excluded.updated_at
""",
(
int(user_id), kind, title, content, available_date, code,
dedupe_key, now, now,
),
)
row = connection.execute(
"SELECT id FROM alerts WHERE user_id = ? AND dedupe_key = ?",
(int(user_id), dedupe_key),
).fetchone()
return int(row["id"])
def list_alerts(
self, user_id: int, as_of: str, unread_only: bool = False, limit: int = 100
) -> list[dict[str, Any]]:
with self.connect() as connection:
if unread_only:
rows = connection.execute(
"""
SELECT id, kind, title, content, available_date, code, is_read,
created_at, updated_at, read_at
FROM alerts
WHERE user_id = ? AND available_date <= ? AND is_read = 0
ORDER BY available_date DESC, id DESC LIMIT ?
""",
(int(user_id), as_of, max(1, min(300, int(limit)))),
).fetchall()
else:
rows = connection.execute(
"""
SELECT id, kind, title, content, available_date, code, is_read,
created_at, updated_at, read_at
FROM alerts WHERE user_id = ?
ORDER BY CASE WHEN available_date > ? THEN 0 ELSE 1 END,
is_read, available_date, id DESC LIMIT ?
""",
(int(user_id), as_of, max(1, min(300, int(limit)))),
).fetchall()
return [{**dict(row), "is_read": bool(row["is_read"])} for row in rows]
def count_unread_alerts(self, user_id: int, as_of: str) -> int:
with self.connect() as connection:
row = connection.execute(
"""
SELECT COUNT(*) AS total FROM alerts
WHERE user_id = ? AND available_date <= ? AND is_read = 0
""",
(int(user_id), as_of),
).fetchone()
return int(row["total"] if row else 0)
def mark_alert_read(self, user_id: int, alert_id: int) -> bool:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
cursor = connection.execute(
"""
UPDATE alerts SET is_read = 1, read_at = ?, updated_at = ?
WHERE id = ? AND user_id = ?
""",
(now, now, int(alert_id), int(user_id)),
)
return cursor.rowcount > 0
def mark_all_alerts_read(self, user_id: int, as_of: str) -> int:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
cursor = connection.execute(
"""
UPDATE alerts SET is_read = 1, read_at = ?, updated_at = ?
WHERE user_id = ? AND available_date <= ? AND is_read = 0
""",
(now, now, int(user_id), as_of),
)
return int(cursor.rowcount)
def delete_alert(self, user_id: int, alert_id: int) -> bool:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM alerts WHERE id = ? AND user_id = ?",
(int(alert_id), int(user_id)),
)
return cursor.rowcount > 0
+22 -2
View File
@@ -1,3 +1,23 @@
from .trade_journal import EMOTIONS, TRADE_ACTIONS, TradeJournalService
from .agent import ReviewAssistantError, stream_review_assistant
from .http import ReviewHttpMixin
from .repository import ReviewRepositoryMixin
from .service import ReviewServiceMixin
__all__ = ["EMOTIONS", "TRADE_ACTIONS", "TradeJournalService"]
__all__ = [
"EMOTIONS",
"ReviewAssistantError",
"ReviewHttpMixin",
"ReviewRepositoryMixin",
"ReviewServiceMixin",
"TRADE_ACTIONS",
"TradeJournalService",
"stream_review_assistant",
]
def __getattr__(name: str):
if name in {"EMOTIONS", "TRADE_ACTIONS", "TradeJournalService"}:
from . import trade_journal
return getattr(trade_journal, name)
raise AttributeError(name)
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
import json
import urllib.error
import urllib.request
from collections.abc import Iterator
from typing import Any
from llm_stream import OpenAIStreamAccumulator
class ReviewAssistantError(RuntimeError):
pass
def stream_review_assistant(
context: dict[str, Any],
question: str,
history: list[dict[str, str]],
api_key: str,
base_url: str,
model: str,
timeout: int = 120,
) -> Iterator[str]:
if not api_key or not model:
raise ReviewAssistantError("智能解读服务尚未配置。")
messages = [{"role": "system", "content": _system_prompt(context)}]
messages.extend(history[-12:])
messages.append({"role": "user", "content": question})
request = urllib.request.Request(
f"{base_url.rstrip('/')}/chat/completions",
data=json.dumps(
{"model": model, "messages": messages, "stream": True}, ensure_ascii=False
).encode("utf-8"),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"User-Agent": "XiaobaiReviewWeb/1.0",
"Accept": "text/event-stream",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
yielded = False
accumulator = OpenAIStreamAccumulator()
for raw_line in response:
line = raw_line.decode("utf-8", errors="replace").strip()
if not line or line.startswith(":"):
continue
if line.startswith("data:"):
line = line[5:].strip()
if line == "[DONE]":
break
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
choices = payload.get("choices") or []
if not choices:
continue
choice = choices[0] or {}
content = accumulator.feed(choice)
if content:
yielded = True
yield str(content)
if not yielded:
raise ReviewAssistantError("智能解读未返回有效内容。")
except urllib.error.HTTPError as exc:
raise ReviewAssistantError(f"智能解读服务暂不可用({exc.code})。") from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise ReviewAssistantError("智能解读连接中断,请稍后重试。") from exc
def _system_prompt(context: dict[str, Any]) -> str:
context_json = json.dumps(context, ensure_ascii=False, separators=(",", ":"))
return f"""
你是“小白复盘”的统一复盘助手。你负责把网页中已经存在的市场统计、策略跟踪、提醒、复盘笔记和手工交易日志连接起来,帮助用户复盘和形成下一步观察计划。
最高优先级规则:
1. 只能使用下方“网页复盘数据”,数据缺失就明确说明,不得补造行情、交易或胜率。
2. 不自动下单,不声称已执行任何操作,不修改策略、提醒、笔记或交易日志。
3. 不承诺收益,不给无条件买卖指令。建议必须写成条件、失效条件和风险边界。
4. 区分市场事实、用户记录和你的推断。引用数字时写明数据日期。
5. 优先结合用户自己的策略跟踪与交易日志寻找可验证的重复模式;样本不足时明确标注。
6. 使用中文,先直接回答,再给数据依据和下一步观察。避免空泛口号,不展示模型、接口或内部工程信息。
7. 控制在 800 个中文字符以内,除非用户明确要求展开。
网页复盘数据:
{context_json}
""".strip()
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
import json
from datetime import date
from http import HTTPStatus
from backend.bootstrap.config import normalize_date, validate_stock_code, validate_text
from backend.features.review.agent import ReviewAssistantError
class ReviewHttpMixin:
def save_trade_entry(self) -> None:
try:
body = self.read_json_body()
self.send_json(
{"ok": True, **self.application_service.save_trade_entry(body)},
HTTPStatus.CREATED,
)
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def stream_assistant_chat(self) -> None:
try:
body = self.read_json_body()
stream = self.application_service.assistant_stream(body)
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
self.send_header("Cache-Control", "no-cache, no-transform")
self.send_header("X-Accel-Buffering", "no")
self.send_header("Connection", "close")
self.end_headers()
try:
for chunk in stream:
self._write_stream_event({"type": "delta", "content": chunk})
self._write_stream_event({"type": "done"})
except (ValueError, ReviewAssistantError) as exc:
self._write_stream_event({"type": "error", "error": str(exc)})
except (BrokenPipeError, ConnectionResetError):
pass
finally:
self.close_connection = True
def save_watchlist(self) -> None:
try:
body = self.read_json_body()
code = validate_stock_code(str(body.get("code", "")))
name = validate_text(body.get("name"), "股票名称", 30, required=True)
sector = validate_text(body.get("sector"), "所属板块", 50)
color = str(body.get("color") or "red")
if color not in {"red", "blue", "green", "amber"}:
raise ValueError("标记颜色不支持。")
remark = validate_text(body.get("remark"), "跟踪备注", 240)
service = self.application_service
service.database.save_watchlist(
service.current_user_id, code, name, sector, color, remark
)
self.send_json(
{
"ok": True,
"items": service.database.list_watchlist(service.current_user_id),
}
)
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def save_note(self) -> None:
try:
body = self.read_json_body()
code = str(body.get("code") or "").strip()
if code:
code = validate_stock_code(code)
stock_name = validate_text(body.get("stock_name"), "股票名称", 30)
trade_date = normalize_date(str(body.get("trade_date") or date.today().isoformat()))
summary = validate_text(body.get("summary"), "盘面摘要", 500)
content = validate_text(body.get("content"), "复盘内容", 5000)
plan = validate_text(body.get("plan"), "明日计划", 2000)
if not summary and not content and not plan:
raise ValueError("每日复盘内容不能全部为空。")
raw_id = body.get("id")
note_id = int(raw_id) if raw_id else None
service = self.application_service
saved_id = service.database.save_note(
service.current_user_id,
code,
stock_name,
trade_date,
content,
plan,
note_id,
summary=summary,
)
self.send_json({"ok": True, "id": saved_id})
except (ValueError, TypeError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
+281
View File
@@ -0,0 +1,281 @@
from __future__ import annotations
import json
from datetime import datetime
from typing import Any
class ReviewRepositoryMixin:
def list_watchlist(self, user_id: int) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"""
SELECT code, name, sector, color, remark, created_at, updated_at
FROM watchlist WHERE user_id = ? ORDER BY updated_at DESC, code
""",
(int(user_id),),
).fetchall()
return [dict(row) for row in rows]
def save_watchlist(
self, user_id: int, code: str, name: str, sector: str, color: str,
remark: str | None = None,
) -> None:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
existing = connection.execute(
"SELECT remark FROM watchlist WHERE user_id = ? AND code = ?",
(int(user_id), code),
).fetchone()
saved_remark = (
str(existing["remark"] or "") if remark is None and existing else str(remark or "")
)
connection.execute(
"""
INSERT INTO watchlist
(user_id, code, name, sector, color, remark, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, code) DO UPDATE SET
name = excluded.name,
sector = excluded.sector,
color = excluded.color,
remark = excluded.remark,
updated_at = excluded.updated_at
""",
(int(user_id), code, name, sector, color, saved_remark, now, now),
)
def watchlist_price_history(
self, codes: list[str], end_date: str, limit_per_code: int = 6
) -> dict[str, list[dict[str, Any]]]:
result: dict[str, list[dict[str, Any]]] = {}
if not codes:
return result
with self.connect() as connection:
for code in codes:
rows = connection.execute(
"""
SELECT trade_date, ts_code, close, pct_chg
FROM daily_bars
WHERE substr(ts_code, 1, 6) = ? AND trade_date <= ?
ORDER BY trade_date DESC LIMIT ?
""",
(str(code), end_date, int(limit_per_code)),
).fetchall()
result[str(code)] = [dict(row) for row in reversed(rows)]
return result
def delete_watchlist(self, user_id: int, code: str) -> bool:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM watchlist WHERE user_id = ? AND code = ?",
(int(user_id), code),
)
return cursor.rowcount > 0
def list_notes(
self,
user_id: int,
code: str = "",
trade_date: str = "",
scope: str = "all",
) -> list[dict[str, Any]]:
clauses: list[str] = ["user_id = ?"]
parameters: list[Any] = [int(user_id)]
if scope == "daily":
clauses.append("code = ''")
elif scope == "stock":
clauses.append("code <> ''")
if code:
clauses.append("code = ?")
parameters.append(code)
if trade_date:
clauses.append("trade_date = ?")
parameters.append(trade_date)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
with self.connect() as connection:
rows = connection.execute(
f"""
SELECT id, code, stock_name, trade_date, summary, content, plan, created_at, updated_at
FROM review_notes {where}
ORDER BY trade_date DESC, updated_at DESC, id DESC LIMIT 200
""",
parameters,
).fetchall()
return [dict(row) for row in rows]
def save_note(
self,
user_id: int,
code: str,
stock_name: str,
trade_date: str,
content: str,
plan: str,
note_id: int | None = None,
summary: str = "",
) -> int:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
if note_id:
cursor = connection.execute(
"""
UPDATE review_notes
SET code = ?, stock_name = ?, trade_date = ?, summary = ?, content = ?, plan = ?, updated_at = ?
WHERE id = ? AND user_id = ?
""",
(code, stock_name, trade_date, summary, content, plan, now, note_id, int(user_id)),
)
if cursor.rowcount == 0:
raise ValueError("复盘笔记不存在。")
return note_id
cursor = connection.execute(
"""
INSERT INTO review_notes
(user_id, code, stock_name, trade_date, summary, content, plan, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(int(user_id), code, stock_name, trade_date, summary, content, plan, now, now),
)
return int(cursor.lastrowid)
def delete_note(self, user_id: int, note_id: int) -> bool:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM review_notes WHERE id = ? AND user_id = ?",
(note_id, int(user_id)),
)
return cursor.rowcount > 0
def save_trade_entry(
self,
user_id: int,
trade_date: str,
code: str,
name: str,
action: str,
price: float,
quantity: int,
position_pct: float,
pnl_amount: float | None,
pnl_pct: float | None,
thesis: str,
execution: str,
emotion: str,
tags: list[str],
trade_id: int | None = None,
) -> int:
now = datetime.now().astimezone().isoformat(timespec="seconds")
tags_json = json.dumps(tags, ensure_ascii=False, separators=(",", ":"))
with self.connect() as connection:
if trade_id:
cursor = connection.execute(
"""
UPDATE trade_entries SET
trade_date=?, code=?, name=?, action=?, price=?, quantity=?,
position_pct=?, pnl_amount=?, pnl_pct=?, thesis=?, execution=?,
emotion=?, tags=?, updated_at=?
WHERE id=? AND user_id=?
""",
(
trade_date, code, name, action, price, quantity, position_pct,
pnl_amount, pnl_pct, thesis, execution, emotion, tags_json, now,
int(trade_id), int(user_id),
),
)
if cursor.rowcount == 0:
raise ValueError("交易记录不存在或无权修改。")
return int(trade_id)
cursor = connection.execute(
"""
INSERT INTO trade_entries
(user_id, trade_date, code, name, action, price, quantity,
position_pct, pnl_amount, pnl_pct, thesis, execution, emotion,
tags, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
int(user_id), trade_date, code, name, action, price, quantity,
position_pct, pnl_amount, pnl_pct, thesis, execution, emotion,
tags_json, now, now,
),
)
return int(cursor.lastrowid)
def list_trade_entries(
self, user_id: int, start_date: str = "", end_date: str = "", code: str = "",
limit: int = 300,
) -> list[dict[str, Any]]:
clauses = ["user_id = ?"]
parameters: list[Any] = [int(user_id)]
if start_date:
clauses.append("trade_date >= ?")
parameters.append(start_date)
if end_date:
clauses.append("trade_date <= ?")
parameters.append(end_date)
if code:
clauses.append("code = ?")
parameters.append(code)
parameters.append(max(1, min(1000, int(limit))))
with self.connect() as connection:
rows = connection.execute(
f"""
SELECT * FROM trade_entries WHERE {' AND '.join(clauses)}
ORDER BY trade_date DESC, id DESC LIMIT ?
""",
parameters,
).fetchall()
return [dict(row) for row in rows]
def delete_trade_entry(self, user_id: int, trade_id: int) -> bool:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM trade_entries WHERE id = ? AND user_id = ?",
(int(trade_id), int(user_id)),
)
return cursor.rowcount > 0
def save_assistant_exchange(
self, user_id: int, question: str, answer: str, context_date: str
) -> None:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
connection.executemany(
"""
INSERT INTO assistant_messages
(user_id, role, content, context_date, created_at)
VALUES (?, ?, ?, ?, ?)
""",
[
(int(user_id), "user", question, context_date, now),
(int(user_id), "assistant", answer, context_date, now),
],
)
connection.execute(
"""
DELETE FROM assistant_messages WHERE user_id = ? AND id NOT IN (
SELECT id FROM assistant_messages
WHERE user_id = ? ORDER BY id DESC LIMIT 200
)
""",
(int(user_id), int(user_id)),
)
def list_assistant_messages(self, user_id: int, limit: int = 100) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"""
SELECT role, content, context_date, created_at FROM assistant_messages
WHERE user_id = ? ORDER BY id DESC LIMIT ?
""",
(int(user_id), max(1, min(200, int(limit)))),
).fetchall()
return [dict(row) for row in reversed(rows)]
def delete_assistant_messages(self, user_id: int) -> int:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM assistant_messages WHERE user_id = ?", (int(user_id),)
)
return int(cursor.rowcount)
+188
View File
@@ -0,0 +1,188 @@
from __future__ import annotations
from datetime import date, datetime, timedelta
from typing import Any
from backend.bootstrap.config import normalize_date, tushare_code, validate_text
from backend.data.providers.tushare_client import TushareError
from backend.features.review.agent import ReviewAssistantError, stream_review_assistant
class ReviewServiceMixin:
def trade_entries(
self, start_date: str = "", end_date: str = "", code: str = ""
) -> dict[str, Any]:
return self.trade_journal.list_entries(
self.current_user_id, start_date, end_date, code
)
def review_watchlist(self, trade_date: str) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
items = self.database.list_watchlist(self.current_user_id)
if not items:
return {"items": [], "trade_date": normalized_date}
resolved_date = normalized_date
if self.configured:
try:
client = self._tushare_client()
resolved_date, _ = client.resolve_trade_context(normalized_date)
history = self.database.watchlist_price_history(
[str(item["code"]) for item in items], resolved_date
)
missing_codes = [
str(item["code"]) for item in items
if len(history.get(str(item["code"])) or []) < 6
]
start_date = (
datetime.strptime(resolved_date, "%Y%m%d") - timedelta(days=24)
).strftime("%Y%m%d")
for code in missing_codes:
rows = client.query(
"daily",
{
"ts_code": tushare_code(code),
"start_date": start_date,
"end_date": resolved_date,
},
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
)
if rows:
self.database.upsert_daily_bars(rows)
if missing_codes:
history = self.database.watchlist_price_history(
[str(item["code"]) for item in items], resolved_date
)
except (TushareError, ValueError):
history = self.database.watchlist_price_history(
[str(item["code"]) for item in items], resolved_date
)
else:
history = self.database.watchlist_price_history(
[str(item["code"]) for item in items], resolved_date
)
auction_scores: dict[str, Any] = {}
try:
auction = self.auction_center(normalized_date, False)
auction_scores = {
str(row.get("code") or ""): row.get("attention_score")
for row in (auction.get("watchlist_rows") or [])
if row.get("available", True)
}
except (TushareError, ValueError):
pass
enriched = []
for item in items:
code = str(item.get("code") or "")
bars = history.get(code) or []
latest = bars[-1] if bars else {}
close = float(latest.get("close") or 0)
base_close = float(bars[-6].get("close") or 0) if len(bars) >= 6 else 0
enriched.append(
{
**item,
"change": (
round(float(latest.get("pct_chg") or 0), 2) if latest else None
),
"return_5d": (
round((close / base_close - 1) * 100, 2)
if close > 0 and base_close > 0 else None
),
"attention_score": auction_scores.get(code),
"market_date": str(latest.get("trade_date") or ""),
}
)
return {"items": enriched, "trade_date": resolved_date}
def save_trade_entry(self, payload: dict[str, Any]) -> dict[str, Any]:
trade_id = self.trade_journal.save(self.current_user_id, payload)
return {"id": trade_id, **self.trade_entries()}
def delete_trade_entry(self, trade_id: int) -> dict[str, Any]:
deleted = self.trade_journal.delete(self.current_user_id, trade_id)
return {"deleted": deleted, **self.trade_entries()}
def assistant_messages(self) -> list[dict[str, Any]]:
return self.database.list_assistant_messages(self.current_user_id)
def clear_assistant_messages(self) -> int:
return self.database.delete_assistant_messages(self.current_user_id)
def assistant_stream(self, payload: dict[str, Any]):
question = validate_text(payload.get("question"), "问题", 2000, required=True)
trade_date = normalize_date(
str(payload.get("trade_date") or date.today().isoformat())
)
context = self._assistant_context(trade_date)
history = [
{"role": item["role"], "content": str(item["content"])[:4000]}
for item in self.assistant_messages()[-12:]
if item.get("role") in {"user", "assistant"}
]
def generate():
answer_parts: list[str] = []
events = self.llm_gateway.stream(
"assistant",
"review-assistant-v1",
lambda profile: stream_review_assistant(
context,
question,
history,
profile.api_key,
profile.base_url,
profile.model,
),
(ReviewAssistantError,),
)
for event in events:
if event.kind == "delta":
chunk = str(event.value or "")
answer_parts.append(chunk)
yield chunk
elif event.kind == "complete":
self.database.save_assistant_exchange(
self.current_user_id,
question,
"".join(answer_parts).strip(),
trade_date,
)
return generate()
def _assistant_context(self, trade_date: str) -> dict[str, Any]:
dashboard = self.get_dashboard(trade_date)
actual_date = normalize_date(
str((dashboard.get("meta") or {}).get("trade_date") or trade_date)
)
sentiment = self.sentiment_history(actual_date, 10)
tracking = self.strategy_tracking.list_tracking(self.current_user_id, 5)
alerts = self.alert_service.list_alerts(
self.current_user_id, "all", date.today().isoformat()
)
trades = self.trade_journal.list_entries(
self.current_user_id, end_date=actual_date
)
return {
"data_date": actual_date,
"market": {
"overview": dashboard.get("overview") or {},
"top_sectors": (dashboard.get("sectors") or [])[:8],
"limit_performance": dashboard.get("limit_performance") or {},
"sentiment_history": (sentiment.get("rows") or [])[-10:],
},
"personal": {
"watchlist": self.database.list_watchlist(self.current_user_id)[:30],
"review_notes": self.database.list_notes(
self.current_user_id, scope="daily"
)[:10],
"strategy_tracking": {
"summary": tracking.get("summary") or {},
"batches": (tracking.get("batches") or [])[:5],
},
"alerts": (alerts.get("items") or [])[:20],
"trade_summary": trades.get("summary") or {},
"trade_entries": (trades.get("items") or [])[:30],
},
}
+4 -383
View File
@@ -8,6 +8,7 @@ from typing import Any
from backend.database import MIGRATIONS, MigrationRunner, SQLiteConnectionFactory
from backend.features.accounts.repository import AccountRepositoryMixin
from backend.features.alerts.repository import AlertRepositoryMixin
from backend.features.auction.repository import AuctionRepositoryMixin
from backend.features.dragon_tiger.repository import DragonTigerRepositoryMixin
from backend.features.heaven.repository import HeavenRepositoryMixin
@@ -15,6 +16,7 @@ from backend.features.market.repository import MarketRepositoryMixin
from backend.features.mentor.repository import MentorRepositoryMixin
from backend.features.pools.repository import PoolRepositoryMixin
from backend.features.popularity.repository import PopularityRepositoryMixin
from backend.features.review.repository import ReviewRepositoryMixin
from backend.features.screener.repository import ScreenerRepositoryMixin
from backend.features.system.repository import SystemSettingsRepositoryMixin
from backend.llm.repository import LLMAuditRepositoryMixin
@@ -22,6 +24,7 @@ from backend.llm.repository import LLMAuditRepositoryMixin
class ReviewDatabase(
AccountRepositoryMixin,
AlertRepositoryMixin,
AuctionRepositoryMixin,
DragonTigerRepositoryMixin,
HeavenRepositoryMixin,
@@ -29,6 +32,7 @@ class ReviewDatabase(
MentorRepositoryMixin,
PoolRepositoryMixin,
PopularityRepositoryMixin,
ReviewRepositoryMixin,
ScreenerRepositoryMixin,
SystemSettingsRepositoryMixin,
LLMAuditRepositoryMixin,
@@ -662,147 +666,6 @@ class ReviewDatabase(
MigrationRunner().apply(connection, MIGRATIONS)
def list_watchlist(self, user_id: int) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"""
SELECT code, name, sector, color, remark, created_at, updated_at
FROM watchlist WHERE user_id = ? ORDER BY updated_at DESC, code
""",
(int(user_id),),
).fetchall()
return [dict(row) for row in rows]
def save_watchlist(
self, user_id: int, code: str, name: str, sector: str, color: str,
remark: str | None = None,
) -> None:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
existing = connection.execute(
"SELECT remark FROM watchlist WHERE user_id = ? AND code = ?",
(int(user_id), code),
).fetchone()
saved_remark = (
str(existing["remark"] or "") if remark is None and existing else str(remark or "")
)
connection.execute(
"""
INSERT INTO watchlist
(user_id, code, name, sector, color, remark, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, code) DO UPDATE SET
name = excluded.name,
sector = excluded.sector,
color = excluded.color,
remark = excluded.remark,
updated_at = excluded.updated_at
""",
(int(user_id), code, name, sector, color, saved_remark, now, now),
)
def watchlist_price_history(
self, codes: list[str], end_date: str, limit_per_code: int = 6
) -> dict[str, list[dict[str, Any]]]:
result: dict[str, list[dict[str, Any]]] = {}
if not codes:
return result
with self.connect() as connection:
for code in codes:
rows = connection.execute(
"""
SELECT trade_date, ts_code, close, pct_chg
FROM daily_bars
WHERE substr(ts_code, 1, 6) = ? AND trade_date <= ?
ORDER BY trade_date DESC LIMIT ?
""",
(str(code), end_date, int(limit_per_code)),
).fetchall()
result[str(code)] = [dict(row) for row in reversed(rows)]
return result
def delete_watchlist(self, user_id: int, code: str) -> bool:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM watchlist WHERE user_id = ? AND code = ?",
(int(user_id), code),
)
return cursor.rowcount > 0
def list_notes(
self,
user_id: int,
code: str = "",
trade_date: str = "",
scope: str = "all",
) -> list[dict[str, Any]]:
clauses: list[str] = ["user_id = ?"]
parameters: list[Any] = [int(user_id)]
if scope == "daily":
clauses.append("code = ''")
elif scope == "stock":
clauses.append("code <> ''")
if code:
clauses.append("code = ?")
parameters.append(code)
if trade_date:
clauses.append("trade_date = ?")
parameters.append(trade_date)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
with self.connect() as connection:
rows = connection.execute(
f"""
SELECT id, code, stock_name, trade_date, summary, content, plan, created_at, updated_at
FROM review_notes {where}
ORDER BY trade_date DESC, updated_at DESC, id DESC LIMIT 200
""",
parameters,
).fetchall()
return [dict(row) for row in rows]
def save_note(
self,
user_id: int,
code: str,
stock_name: str,
trade_date: str,
content: str,
plan: str,
note_id: int | None = None,
summary: str = "",
) -> int:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
if note_id:
cursor = connection.execute(
"""
UPDATE review_notes
SET code = ?, stock_name = ?, trade_date = ?, summary = ?, content = ?, plan = ?, updated_at = ?
WHERE id = ? AND user_id = ?
""",
(code, stock_name, trade_date, summary, content, plan, now, note_id, int(user_id)),
)
if cursor.rowcount == 0:
raise ValueError("复盘笔记不存在。")
return note_id
cursor = connection.execute(
"""
INSERT INTO review_notes
(user_id, code, stock_name, trade_date, summary, content, plan, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(int(user_id), code, stock_name, trade_date, summary, content, plan, now, now),
)
return int(cursor.lastrowid)
def delete_note(self, user_id: int, note_id: int) -> bool:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM review_notes WHERE id = ? AND user_id = ?",
(note_id, int(user_id)),
)
return cursor.rowcount > 0
def list_sector_phase_overrides(self) -> dict[str, str]:
with self.connect() as connection:
@@ -832,10 +695,6 @@ class ReviewDatabase(
(name,),
)
return cursor.rowcount > 0
def list_wencai_saved_queries(
self, user_id: int, limit: int = 30
) -> list[dict[str, Any]]:
@@ -885,241 +744,3 @@ class ReviewDatabase(
(int(query_id), int(user_id)),
)
return cursor.rowcount > 0
def save_alert(
self,
user_id: int,
kind: str,
title: str,
content: str,
available_date: str,
code: str,
dedupe_key: str,
) -> int:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
connection.execute(
"""
INSERT INTO alerts
(user_id, kind, title, content, available_date, code, dedupe_key,
is_read, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
ON CONFLICT(user_id, dedupe_key) DO UPDATE SET
title=excluded.title, content=excluded.content,
available_date=excluded.available_date, updated_at=excluded.updated_at
""",
(
int(user_id), kind, title, content, available_date, code,
dedupe_key, now, now,
),
)
row = connection.execute(
"SELECT id FROM alerts WHERE user_id = ? AND dedupe_key = ?",
(int(user_id), dedupe_key),
).fetchone()
return int(row["id"])
def list_alerts(
self, user_id: int, as_of: str, unread_only: bool = False, limit: int = 100
) -> list[dict[str, Any]]:
with self.connect() as connection:
if unread_only:
rows = connection.execute(
"""
SELECT id, kind, title, content, available_date, code, is_read,
created_at, updated_at, read_at
FROM alerts
WHERE user_id = ? AND available_date <= ? AND is_read = 0
ORDER BY available_date DESC, id DESC LIMIT ?
""",
(int(user_id), as_of, max(1, min(300, int(limit)))),
).fetchall()
else:
rows = connection.execute(
"""
SELECT id, kind, title, content, available_date, code, is_read,
created_at, updated_at, read_at
FROM alerts WHERE user_id = ?
ORDER BY CASE WHEN available_date > ? THEN 0 ELSE 1 END,
is_read, available_date, id DESC LIMIT ?
""",
(int(user_id), as_of, max(1, min(300, int(limit)))),
).fetchall()
return [{**dict(row), "is_read": bool(row["is_read"])} for row in rows]
def count_unread_alerts(self, user_id: int, as_of: str) -> int:
with self.connect() as connection:
row = connection.execute(
"""
SELECT COUNT(*) AS total FROM alerts
WHERE user_id = ? AND available_date <= ? AND is_read = 0
""",
(int(user_id), as_of),
).fetchone()
return int(row["total"] if row else 0)
def mark_alert_read(self, user_id: int, alert_id: int) -> bool:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
cursor = connection.execute(
"""
UPDATE alerts SET is_read = 1, read_at = ?, updated_at = ?
WHERE id = ? AND user_id = ?
""",
(now, now, int(alert_id), int(user_id)),
)
return cursor.rowcount > 0
def mark_all_alerts_read(self, user_id: int, as_of: str) -> int:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
cursor = connection.execute(
"""
UPDATE alerts SET is_read = 1, read_at = ?, updated_at = ?
WHERE user_id = ? AND available_date <= ? AND is_read = 0
""",
(now, now, int(user_id), as_of),
)
return int(cursor.rowcount)
def delete_alert(self, user_id: int, alert_id: int) -> bool:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM alerts WHERE id = ? AND user_id = ?",
(int(alert_id), int(user_id)),
)
return cursor.rowcount > 0
def save_trade_entry(
self,
user_id: int,
trade_date: str,
code: str,
name: str,
action: str,
price: float,
quantity: int,
position_pct: float,
pnl_amount: float | None,
pnl_pct: float | None,
thesis: str,
execution: str,
emotion: str,
tags: list[str],
trade_id: int | None = None,
) -> int:
now = datetime.now().astimezone().isoformat(timespec="seconds")
tags_json = json.dumps(tags, ensure_ascii=False, separators=(",", ":"))
with self.connect() as connection:
if trade_id:
cursor = connection.execute(
"""
UPDATE trade_entries SET
trade_date=?, code=?, name=?, action=?, price=?, quantity=?,
position_pct=?, pnl_amount=?, pnl_pct=?, thesis=?, execution=?,
emotion=?, tags=?, updated_at=?
WHERE id=? AND user_id=?
""",
(
trade_date, code, name, action, price, quantity, position_pct,
pnl_amount, pnl_pct, thesis, execution, emotion, tags_json, now,
int(trade_id), int(user_id),
),
)
if cursor.rowcount == 0:
raise ValueError("交易记录不存在或无权修改。")
return int(trade_id)
cursor = connection.execute(
"""
INSERT INTO trade_entries
(user_id, trade_date, code, name, action, price, quantity,
position_pct, pnl_amount, pnl_pct, thesis, execution, emotion,
tags, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
int(user_id), trade_date, code, name, action, price, quantity,
position_pct, pnl_amount, pnl_pct, thesis, execution, emotion,
tags_json, now, now,
),
)
return int(cursor.lastrowid)
def list_trade_entries(
self, user_id: int, start_date: str = "", end_date: str = "", code: str = "",
limit: int = 300,
) -> list[dict[str, Any]]:
clauses = ["user_id = ?"]
parameters: list[Any] = [int(user_id)]
if start_date:
clauses.append("trade_date >= ?")
parameters.append(start_date)
if end_date:
clauses.append("trade_date <= ?")
parameters.append(end_date)
if code:
clauses.append("code = ?")
parameters.append(code)
parameters.append(max(1, min(1000, int(limit))))
with self.connect() as connection:
rows = connection.execute(
f"""
SELECT * FROM trade_entries WHERE {' AND '.join(clauses)}
ORDER BY trade_date DESC, id DESC LIMIT ?
""",
parameters,
).fetchall()
return [dict(row) for row in rows]
def delete_trade_entry(self, user_id: int, trade_id: int) -> bool:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM trade_entries WHERE id = ? AND user_id = ?",
(int(trade_id), int(user_id)),
)
return cursor.rowcount > 0
def save_assistant_exchange(
self, user_id: int, question: str, answer: str, context_date: str
) -> None:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
connection.executemany(
"""
INSERT INTO assistant_messages
(user_id, role, content, context_date, created_at)
VALUES (?, ?, ?, ?, ?)
""",
[
(int(user_id), "user", question, context_date, now),
(int(user_id), "assistant", answer, context_date, now),
],
)
connection.execute(
"""
DELETE FROM assistant_messages WHERE user_id = ? AND id NOT IN (
SELECT id FROM assistant_messages
WHERE user_id = ? ORDER BY id DESC LIMIT 200
)
""",
(int(user_id), int(user_id)),
)
def list_assistant_messages(self, user_id: int, limit: int = 100) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"""
SELECT role, content, context_date, created_at FROM assistant_messages
WHERE user_id = ? ORDER BY id DESC LIMIT ?
""",
(int(user_id), max(1, min(200, int(limit)))),
).fetchall()
return [dict(row) for row in reversed(rows)]
def delete_assistant_messages(self, user_id: int) -> int:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM assistant_messages WHERE user_id = ?", (int(user_id),)
)
return int(cursor.rowcount)
+1943
View File
File diff suppressed because it is too large Load Diff
@@ -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>
+272
View File
@@ -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 */
+375
View File
@@ -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 */
File diff suppressed because it is too large Load Diff
+96
View File
@@ -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 */
File diff suppressed because it is too large Load Diff
+497
View File
@@ -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 */
+409
View File
@@ -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 */
+82
View File
@@ -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 */
+699
View File
@@ -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 */
+173
View File
@@ -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 */
File diff suppressed because it is too large Load Diff
+316
View File
@@ -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 */
+108
View File
@@ -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 */
+149
View File
@@ -0,0 +1,149 @@
/* 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 commonReviewColumns() {
return [["股票代码", "code"], ["股票名称", "name"], ["状态", "status"], ["涨跌幅%", "change"],
["价格", "price"], ["所属板块", "sector"], ["原因", "reason"], ["首次触板", "first_time"],
["最后触板", "last_time"], ["开板次数", "open_times"], ["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"]];
}
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 */
+3 -1
View File
@@ -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",
},
});
-9283
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -1,4 +0,0 @@
window.XiaobaiPageModules.register("auction", ["auctionView"], {
enter: ["loadAuction"],
leave: ["clearAuction"],
});
-3
View File
@@ -1,3 +0,0 @@
window.XiaobaiPageModules.register("dragon_tiger", ["dragonView"], {
enter: ["loadDragonTiger"],
});
-4
View File
@@ -1,4 +0,0 @@
window.XiaobaiPageModules.register("heaven", ["heavenView"], {
enter: ["loadHeaven"],
leave: ["stopHeaven"],
});
-1
View File
@@ -1 +0,0 @@
window.XiaobaiPageModules.register("ladder", ["ladderView"]);
-3
View File
@@ -1,3 +0,0 @@
window.XiaobaiPageModules.register("mentor", ["mentorView"], {
enter: ["loadMentor"],
});
-7
View File
@@ -1,7 +0,0 @@
window.XiaobaiPageModules.register("pools", [
"limitPool",
"brokenView",
"downView",
"yesterdayView",
"performanceView",
]);
-3
View File
@@ -1,3 +0,0 @@
window.XiaobaiPageModules.register("popularity", ["popularityView"], {
enter: ["loadPopularity"],
});
-3
View File
@@ -1,3 +0,0 @@
window.XiaobaiPageModules.register("review", ["reviewWorkspaceView"], {
enter: ["loadReview"],
});
-3
View File
@@ -1,3 +0,0 @@
window.XiaobaiPageModules.register("rotation", ["rotationView"], {
enter: ["loadRotation"],
});
-5
View File
@@ -1,5 +0,0 @@
window.XiaobaiPageModules.register("screener", ["screenerView"], {
enter: ["loadScreener"],
});
window.XiaobaiPageModules.register("screener", ["screenerTrackingView"]);
-3
View File
@@ -1,3 +0,0 @@
window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], {
enter: ["loadSentiment"],
});
-3
View File
@@ -1,3 +0,0 @@
window.XiaobaiPageModules.register("themes", ["themeLibraryView"], {
enter: ["loadThemes"],
});
+1
View File
@@ -0,0 +1 @@
"""Test support package for the preserved application."""
+75
View File
@@ -0,0 +1,75 @@
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,
)
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}"
)
if len(content.splitlines(keepends=True)) != end - start + 1:
raise AssertionError(f"app.js line count changed in range {start}-{end}")
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 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,
)
+13 -13
View File
@@ -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:
+8 -6
View File
@@ -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)
+11 -9
View File
@@ -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 (
+3 -2
View File
@@ -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,100 @@
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_moved_asset_matches,
assert_page_prefix_matches,
reassembled_frontend_runtime,
)
class FrontendPreservationSliceTests(unittest.TestCase):
def test_split_runtime_reassembles_to_the_exact_original(self) -> None:
self.assertEqual(
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:
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"),
("heaven-loading.js", "heaven-loading.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,13 @@ import hashlib
import unittest
from pathlib import Path
from tests.preservation_helpers import (
ORIGINAL_STATIC,
assert_moved_asset_matches,
assert_page_prefix_matches,
reassembled_frontend_runtime,
)
APP_ROOT = Path(__file__).resolve().parents[1]
ORIGINAL_ROOT = APP_ROOT.parent
@@ -73,19 +80,17 @@ 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"),
)
self.assertEqual(
reassembled_frontend_runtime(),
(ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"),
)
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__":
+17 -14
View File
@@ -13,6 +13,11 @@ 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 (
ORIGINAL_STATIC,
assert_moved_asset_matches,
reassembled_frontend_runtime,
)
APP_ROOT = Path(__file__).resolve().parents[1]
@@ -146,21 +151,19 @@ 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:
self.assertEqual(
reassembled_frontend_runtime(),
(ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"),
)
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,12 @@ from pathlib import Path
import market_insights
from backend.features.market import insights as canonical_insights
from tests.preservation_helpers import (
ORIGINAL_STATIC,
assert_moved_asset_matches,
assert_page_prefix_matches,
reassembled_frontend_runtime,
)
APP_ROOT = Path(__file__).resolve().parents[1]
@@ -174,21 +180,22 @@ 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"),
)
self.assertEqual(
reassembled_frontend_runtime(),
(ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"),
)
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__":
@@ -0,0 +1,211 @@
from __future__ import annotations
import ast
import hashlib
import unittest
from pathlib import Path
import assistant_agent
from backend.features.review import agent as canonical_agent
APP_ROOT = Path(__file__).resolve().parents[1]
ORIGINAL_ROOT = APP_ROOT.parent
ALERT_SERVICE_METHODS = {
"alert_center",
"create_alert",
"mark_alert_read",
"mark_all_alerts_read",
"delete_alert",
}
REVIEW_SERVICE_METHODS = {
"trade_entries",
"review_watchlist",
"save_trade_entry",
"delete_trade_entry",
"assistant_messages",
"clear_assistant_messages",
"assistant_stream",
"_assistant_context",
}
ALERT_REPOSITORY_METHODS = {
"save_alert",
"list_alerts",
"count_unread_alerts",
"mark_alert_read",
"mark_all_alerts_read",
"delete_alert",
}
REVIEW_REPOSITORY_METHODS = {
"list_watchlist",
"save_watchlist",
"watchlist_price_history",
"delete_watchlist",
"list_notes",
"save_note",
"delete_note",
"save_trade_entry",
"list_trade_entries",
"delete_trade_entry",
"save_assistant_exchange",
"list_assistant_messages",
"delete_assistant_messages",
}
ALERT_HTTP_METHODS = {"save_alert"}
REVIEW_HTTP_METHODS = {
"save_trade_entry",
"stream_assistant_chat",
"save_watchlist",
"save_note",
}
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def class_methods(path: Path, class_name: str) -> dict[str, str]:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
owner = next(
node
for node in tree.body
if isinstance(node, ast.ClassDef) and node.name == class_name
)
return {
node.name: ast.dump(node, include_attributes=False)
for node in owner.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
}
def top_level_definition(path: Path, name: str) -> str:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
node = next(
item
for item in tree.body
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
and item.name == name
)
return ast.dump(node, include_attributes=False)
class ReviewAlertsSliceSourceEquivalenceTests(unittest.TestCase):
def assert_methods_equal(
self,
original_path: Path,
original_class: str,
migrated_path: Path,
migrated_class: str,
expected: set[str],
) -> None:
original = class_methods(original_path, original_class)
migrated = class_methods(migrated_path, migrated_class)
self.assertEqual(set(migrated), expected)
for name in sorted(expected):
self.assertEqual(migrated[name], original[name], name)
def test_review_assistant_agent_is_an_exact_file(self) -> None:
self.assertEqual(
sha256(ORIGINAL_ROOT / "assistant_agent.py"),
sha256(APP_ROOT / "backend" / "features" / "review" / "agent.py"),
)
def test_review_assistant_compatibility_module_is_canonical(self) -> None:
self.assertIs(assistant_agent, canonical_agent)
def test_alert_and_review_service_methods_are_exact_original_ast(self) -> None:
self.assert_methods_equal(
ORIGINAL_ROOT / "server.py",
"DashboardService",
APP_ROOT / "backend" / "features" / "alerts" / "facade.py",
"AlertServiceMixin",
ALERT_SERVICE_METHODS,
)
self.assert_methods_equal(
ORIGINAL_ROOT / "server.py",
"DashboardService",
APP_ROOT / "backend" / "features" / "review" / "service.py",
"ReviewServiceMixin",
REVIEW_SERVICE_METHODS,
)
def test_alert_and_review_repository_methods_are_exact_original_ast(self) -> None:
self.assert_methods_equal(
ORIGINAL_ROOT / "database.py",
"ReviewDatabase",
APP_ROOT / "backend" / "features" / "alerts" / "repository.py",
"AlertRepositoryMixin",
ALERT_REPOSITORY_METHODS,
)
self.assert_methods_equal(
ORIGINAL_ROOT / "database.py",
"ReviewDatabase",
APP_ROOT / "backend" / "features" / "review" / "repository.py",
"ReviewRepositoryMixin",
REVIEW_REPOSITORY_METHODS,
)
def test_existing_alert_and_trade_journal_services_preserve_original_classes(self) -> None:
self.assertEqual(
top_level_definition(
ORIGINAL_ROOT / "backend" / "features" / "alerts" / "service.py",
"AlertService",
),
top_level_definition(
APP_ROOT / "backend" / "features" / "alerts" / "service.py",
"AlertService",
),
)
self.assertEqual(
top_level_definition(
ORIGINAL_ROOT / "backend" / "features" / "review" / "trade_journal.py",
"TradeJournalService",
),
top_level_definition(
APP_ROOT / "backend" / "features" / "review" / "trade_journal.py",
"TradeJournalService",
),
)
def test_original_classes_no_longer_duplicate_moved_methods(self) -> None:
remaining_service = class_methods(
APP_ROOT / "backend" / "application.py", "DashboardService"
)
remaining_database = class_methods(APP_ROOT / "database.py", "ReviewDatabase")
remaining_http = class_methods(
APP_ROOT / "backend" / "application.py", "RequestHandler"
)
self.assertTrue(ALERT_SERVICE_METHODS.isdisjoint(remaining_service))
self.assertTrue(REVIEW_SERVICE_METHODS.isdisjoint(remaining_service))
self.assertTrue(ALERT_REPOSITORY_METHODS.isdisjoint(remaining_database))
self.assertTrue(REVIEW_REPOSITORY_METHODS.isdisjoint(remaining_database))
self.assertTrue(ALERT_HTTP_METHODS.isdisjoint(remaining_http))
self.assertTrue(REVIEW_HTTP_METHODS.isdisjoint(remaining_http))
def test_http_mixins_preserve_all_endpoints_without_global_service(self) -> None:
alerts = class_methods(
APP_ROOT / "backend" / "features" / "alerts" / "http.py",
"AlertHttpMixin",
)
review = class_methods(
APP_ROOT / "backend" / "features" / "review" / "http.py",
"ReviewHttpMixin",
)
self.assertEqual(set(alerts), ALERT_HTTP_METHODS)
self.assertEqual(set(review), REVIEW_HTTP_METHODS)
for path in (
APP_ROOT / "backend" / "features" / "alerts" / "http.py",
APP_ROOT / "backend" / "features" / "review" / "http.py",
):
source = path.read_text(encoding="utf-8")
self.assertNotIn("SERVICE.", source)
self.assertIn("self.application_service.", source)
if __name__ == "__main__":
unittest.main()
+12 -11
View File
@@ -11,6 +11,12 @@ 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 (
ORIGINAL_STATIC,
assert_moved_asset_matches,
assert_page_prefix_matches,
reassembled_frontend_runtime,
)
APP_ROOT = Path(__file__).resolve().parents[1]
@@ -182,17 +188,12 @@ 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,
)
self.assertEqual(
reassembled_frontend_runtime(),
(ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"),
)
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,12 @@ from pathlib import Path
import sentiment_engine
from backend.features.sentiment import engine as canonical_engine
from tests.preservation_helpers import (
ORIGINAL_STATIC,
assert_moved_asset_matches,
assert_page_prefix_matches,
reassembled_frontend_runtime,
)
APP_ROOT = Path(__file__).resolve().parents[1]
@@ -95,19 +101,17 @@ 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"),
)
self.assertEqual(
reassembled_frontend_runtime(),
(ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8"),
)
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__":
+10 -8
View File
@@ -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)
+32 -4
View File
@@ -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 = {
+184
View File
@@ -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()
@@ -0,0 +1,60 @@
# 切片 09:我的复盘、自选、笔记、交易日志、提醒与复盘助手
> 基线:`b3df070`(切片 08
> 回档标签:`xiaobai-preservation-slice-09-20260731`
> 结论:源码、API、数据库、账户隔离、真实页面及全量回归通过;最终视觉仍等待全站人工验收
## 1. 原实现归位
本切片只机械移动原版复盘、提醒和复盘助手实现,没有从`next/`取用代码,也没有修改自选、笔记、交易日志、提醒、会员灰化、LLM 上下文或流式协议。
| 原位置 | 新的唯一实现位置 | 兼容方式 |
|---|---|---|
| `assistant_agent.py` | `app/backend/features/review/agent.py` | 根级模块指向同一模块对象 |
| `DashboardService`提醒方法 | `app/backend/features/alerts/facade.py` | `AlertServiceMixin` |
| `DashboardService`复盘方法 | `app/backend/features/review/service.py` | `ReviewServiceMixin` |
| `ReviewDatabase`提醒方法 | `app/backend/features/alerts/repository.py` | `AlertRepositoryMixin` |
| `ReviewDatabase`复盘方法 | `app/backend/features/review/repository.py` | `ReviewRepositoryMixin` |
| 五个 POST/流式入口 | `app/backend/features/alerts/http.py``review/http.py` | HTTP Mixin |
原版已经存在的`AlertService``TradeJournalService`、Repository Port 与 SQLite Adapter 继续保留为唯一底层实现。本切片没有制造第二套提醒或交易日志服务。
## 2. 源码等价与必要适配
- `assistant_agent.py`与正式 Agent 文件 SHA-256 一致,根级兼容模块与正式 Agent 是同一模块对象。
- 5 个提醒编排方法、8 个复盘编排方法、6 个提醒 Repository 方法和 13 个复盘 Repository 方法均与原版无位置信息 AST 一致。
- 五个 HTTP 方法只把全局`SERVICE`改为 Mixin 的`self.application_service`;状态码、字段、异常和流式事件写法不变。
- `backend.features.alerts``backend.features.review`仅对旧底层服务采用延迟导出,避免`database -> feature package -> sqlite adapter -> database`初始化循环;外部`from ... import AlertService/TradeJournalService`接口不变。
- 公共`RequestHandler._write_stream_event`继续保留,因为问师和复盘助手共同使用,且现有测试直接覆盖;统一传输层留到切片 10。
## 3. API 与数据库差分
- 原版`8790`和迁移版`8791`使用正式数据库的两个临时副本,未写正式数据库。
- 固定日期下自选、全部复盘笔记、交易日志、提醒中心及复盘助手历史共 5 个鉴权接口,状态码与业务 JSON 完全一致。
- 两版均为 62 个 schema 对象;`watchlist``review_notes``trade_entries``alerts``assistant_messages``users``system_settings``llm_usage`逐行一致。
- 真实浏览器在迁移版临时副本中新增一条交易记录,弹窗正确关闭并显示短提示“交易记录已保存”;没有写正式数据库。
- 未调用真实`/api/assistant/chat`,避免外部模型波动和 LLM 用量写入;Agent、流式累积与权限路径由源码等价和单元测试覆盖。
## 4. 真实浏览器检查
- 1920×1080 检查我的复盘完整布局,自选 6 只、交易日志、每日复盘三个独立输入框和最近复盘均可访问,无横向溢出。
- 添加自选与交易日志弹窗居中可用;交易日志保存后没有出现超长空弹窗。
- 提醒中心正常展示筛选、新建提醒和提醒记录。
- 管理员会员账号显示完整可用的复盘助手;临时普通账号显示相同界面、顶部“复盘助手仅对会员开放”,下方快捷问题、输入框和发送按钮均灰化禁用。
- 临时普通账号自选和交易日志均为空,证明用户私有数据没有串到管理员账号。
- 夜间模式背景为`rgb(18, 20, 22)`,1920×1080 页面无横向溢出,控制台无错误。
- 截图`review-workspace-night-1920x1080.png` SHA-256`bc803305a391930a0aafd68062bab6de86eca14612799eebfda220cf2ad0798e`
## 5. 自动验证与保留边界
| 验证 | 结果 |
|---|---:|
| 原版`python -m unittest discover -s tests -q` | 231 项通过 |
| 迁移版`python -m unittest discover -s tests -q` | 287 项通过 |
| `python -m unittest tests.test_preservation_slice_review_alerts -q` | 7 项通过 |
| `npx.cmd playwright test --reporter=dot` | 45 项通过(2.2 分钟) |
| `git diff --check` | 通过 |
- 前端 DOM、页面 JS、CSS 与移动端行为未改动,统一归位延至切片 10。
- `demo_data.py``static/heaven-loading.js`、五个疑似无引用前端函数和`wencai_saved_queries`继续保留到切片 11 试删。
- 没有修改`next/`、正式数据库、Docker/NAS 或`8765`服务。
@@ -0,0 +1,60 @@
{
"all_equal": true,
"endpoints": [
{
"name": "review watchlist",
"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": "all review notes",
"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": "trade journal through fixed date",
"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": "alert center through fixed date",
"method": "GET",
"endpoint": "/api/alerts?status=all&as_of=2026-07-30",
"original_status": 200,
"migrated_status": 200,
"original_sha256": "8807772f2b611ea8b95033aa19cae84f08b08e92d76fff8e3cd5b79966ea04f4",
"migrated_sha256": "8807772f2b611ea8b95033aa19cae84f08b08e92d76fff8e3cd5b79966ea04f4",
"equal": true,
"first_difference": null
},
{
"name": "review assistant history",
"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,32 @@
[
{
"name": "review watchlist",
"method": "GET",
"endpoint": "/api/watchlist?trade_date=2026-07-30",
"payload": null
},
{
"name": "all review notes",
"method": "GET",
"endpoint": "/api/notes?scope=all",
"payload": null
},
{
"name": "trade journal through fixed date",
"method": "GET",
"endpoint": "/api/trades?end_date=2026-07-30",
"payload": null
},
{
"name": "alert center through fixed date",
"method": "GET",
"endpoint": "/api/alerts?status=all&as_of=2026-07-30",
"payload": null
},
{
"name": "review assistant history",
"method": "GET",
"endpoint": "/api/assistant/messages",
"payload": null
}
]
@@ -0,0 +1,75 @@
{
"all_equal": true,
"schema": {
"object_count": 62,
"original_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1",
"migrated_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1",
"equal": true
},
"tables": [
{
"table": "watchlist",
"original_count": 6,
"migrated_count": 6,
"original_sha256": "070161f5cdcc33addf289f11cb0ae54bfc0d669a39426fc33398b64512dba2fe",
"migrated_sha256": "070161f5cdcc33addf289f11cb0ae54bfc0d669a39426fc33398b64512dba2fe",
"equal": true
},
{
"table": "review_notes",
"original_count": 3,
"migrated_count": 3,
"original_sha256": "86ed485c2ce922e3434f8ac742d38431ef430b5c22024cacb6f64f0e8e71ee98",
"migrated_sha256": "86ed485c2ce922e3434f8ac742d38431ef430b5c22024cacb6f64f0e8e71ee98",
"equal": true
},
{
"table": "trade_entries",
"original_count": 0,
"migrated_count": 0,
"original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
"migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
"equal": true
},
{
"table": "alerts",
"original_count": 2,
"migrated_count": 2,
"original_sha256": "37b46fb4a754cf9c6179b1be88eed7f5a5398ddfc87160f41ded622ffc313c88",
"migrated_sha256": "37b46fb4a754cf9c6179b1be88eed7f5a5398ddfc87160f41ded622ffc313c88",
"equal": true
},
{
"table": "assistant_messages",
"original_count": 0,
"migrated_count": 0,
"original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
"migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
"equal": true
},
{
"table": "users",
"original_count": 3,
"migrated_count": 3,
"original_sha256": "4a0f135bef8ebae454693d3f40e1157d84d814d18d849f33e2e760ed1d8a7f89",
"migrated_sha256": "4a0f135bef8ebae454693d3f40e1157d84d814d18d849f33e2e760ed1d8a7f89",
"equal": true
},
{
"table": "system_settings",
"original_count": 1,
"migrated_count": 1,
"original_sha256": "86d333a5c7feaf7111cd79b2db29513326a9b0d3781607f78bf633579e2ca8e9",
"migrated_sha256": "86d333a5c7feaf7111cd79b2db29513326a9b0d3781607f78bf633579e2ca8e9",
"equal": true
},
{
"table": "llm_usage",
"original_count": 72,
"migrated_count": 72,
"original_sha256": "03a070316a125cef904bbfb2bb06b06e792242d5713142e70354402c741393c3",
"migrated_sha256": "03a070316a125cef904bbfb2bb06b06e792242d5713142e70354402c741393c3",
"equal": true
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

@@ -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": []
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Some files were not shown because too many files have changed in this diff Show More