From f4b2d7152aac789113ce92345e84031f65086811 Mon Sep 17 00:00:00 2001 From: leefer Date: Tue, 28 Jul 2026 16:38:56 +0800 Subject: [PATCH] feat: integrate iFinD data and refine intelligent workspaces --- .env.example | 4 + REDESIGN_PLAN.md | 7 + chart_data_provider.py | 229 +++++ database.py | 101 ++ ifind_client.py | 385 ++++++++ market_insights.py | 124 ++- server.py | 677 +++++++++++-- static/app.js | 787 +++++++++++---- static/design-system.css | 134 ++- static/heaven-loading-v2.js | 723 ++++++++++++++ static/index.html | 325 ++++--- static/redesign-v2.css | 246 ++++- static/theme.css | 1281 +++++++++++++++++++++++++ static/wentian-v2.css | 1086 +++++++++++++++++++++ tests/e2e/app-shell.spec.js | 226 ++++- tests/test_account_data_boundaries.py | 42 + tests/test_dashboard_cache.py | 111 +++ tests/test_frontend_contract.py | 49 +- tests/test_hot_money_profiles.py | 73 ++ tests/test_ifind_client.py | 54 ++ tests/test_ifind_features.py | 154 +++ tushare_client.py | 52 + 22 files changed, 6481 insertions(+), 389 deletions(-) create mode 100644 ifind_client.py create mode 100644 static/heaven-loading-v2.js create mode 100644 static/theme.css create mode 100644 static/wentian-v2.css create mode 100644 tests/test_dashboard_cache.py create mode 100644 tests/test_hot_money_profiles.py create mode 100644 tests/test_ifind_client.py create mode 100644 tests/test_ifind_features.py diff --git a/.env.example b/.env.example index b8ceab5..faf7291 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,10 @@ APP_ENCRYPTION_KEY= # the system settings; all accounts use the same backend market snapshot. TUSHARE_TOKEN=your_tushare_token_here +# Optional iFinD HTTP credential. The backend exchanges it for a short-lived +# access token and never exposes either token to browsers. +IFIND_REFRESH_TOKEN=your_ifind_refresh_token_here + # Initial platform member models (OpenAI-compatible). After first launch these # are encrypted into system settings and used only by admins and active members. LLM_PRIMARY_BASE_URL=https://api.openai.com/v1 diff --git a/REDESIGN_PLAN.md b/REDESIGN_PLAN.md index a29ccb6..e7f80d7 100644 --- a/REDESIGN_PLAN.md +++ b/REDESIGN_PLAN.md @@ -54,3 +54,10 @@ - 阶段 17:已完成并通过用户验收 - 阶段 18:已完成,等待用户验收 - 阶段 19:阶段 18 经用户验收后开始 + +## 阶段 19 补充验收项 + +- 按用户指定,在后续工作序列第 7 步(第 19 阶段)统一治理原生弹窗生命周期:禁止多弹窗重叠;打开、加载失败与关闭状态必须可恢复;所有弹窗保留可见关闭入口并支持 Escape;回归“空白长弹窗无法关闭、只能刷新恢复”的历史问题。 +- 统一当前并存的多套历史设计令牌与页面局部规范:以全站设计规范为唯一基线,合并重复令牌,移除废弃、重复及页面内硬编码的颜色规则,避免日间与夜间主题各自出现局部失配。 +- 建立搜索框、分段筛选、元数据标签、信息卡、表格与模态弹窗的统一组件契约;逐页清理绕过共享契约的局部实现,并对桌面/移动、日间/夜间四种组合执行视觉回归。 +- 问天页面当前滚动条消失与内容高度不足问题不在夜间模式补丁中临时叠加规则,随问天重塑统一修正页面高度、滚动容器和三阶段布局。 diff --git a/chart_data_provider.py b/chart_data_provider.py index e427d0b..7ce259d 100644 --- a/chart_data_provider.py +++ b/chart_data_provider.py @@ -8,9 +8,12 @@ import urllib.error import urllib.parse import urllib.request from dataclasses import dataclass +from datetime import datetime, timedelta from threading import Lock from typing import Any, ClassVar +from ifind_client import IfindError, IfindHttpClient + class ChartDataError(RuntimeError): pass @@ -30,6 +33,201 @@ INDEX_SECIDS = { } +class MarketChartClient: + """Prefer iFinD for display charts and retain Eastmoney as a last resort.""" + + def __init__(self, ifind: IfindHttpClient, fallback: "EastmoneyChartClient") -> None: + self.ifind = ifind + self.fallback = fallback + + def stock_intraday(self, code: str) -> dict[str, Any]: + normalized = str(code or "").strip() + if not re.fullmatch(r"\d{6}", normalized): + raise ChartDataError("Invalid stock code") + ifind_code = _stock_market_code(normalized) + try: + return self._ifind_intraday(ifind_code, "stock", normalized) + except (IfindError, ChartDataError): + return self.fallback.stock_intraday(normalized) + + def stock_daily(self, code: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]: + normalized = str(code or "").strip() + if not re.fullmatch(r"\d{6}", normalized): + raise ChartDataError("Invalid stock code") + return self._ifind_daily(_stock_market_code(normalized), end_date, limit) + + def index_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]: + normalized = str(identifier or "").strip().upper() + if normalized not in INDEX_SECIDS: + raise ChartDataError("Unsupported index") + return self._ifind_daily(normalized, end_date, limit) + + def board_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]: + normalized = str(identifier or "").strip().upper() + if not normalized: + raise ChartDataError("Invalid board code") + return self._ifind_daily(normalized, end_date, limit) + + def index_intraday(self, identifier: str) -> dict[str, Any]: + normalized = str(identifier or "").strip().upper() + if normalized not in INDEX_SECIDS: + raise ChartDataError("Unsupported index") + try: + return self._ifind_intraday(normalized, "index", normalized) + except (IfindError, ChartDataError): + return self.fallback.index_intraday(normalized) + + def board_intraday(self, identifier: str, name: str = "") -> dict[str, Any]: + normalized = str(identifier or "").strip().upper() + try: + return self._ifind_intraday(normalized, "board", normalized, name) + except (IfindError, ChartDataError): + return self.fallback.board_intraday(normalized, name) + + def _ifind_intraday( + self, + ifind_code: str, + entity_type: str, + identifier: str, + name: str = "", + ) -> dict[str, Any]: + if not self.ifind.configured: + raise ChartDataError("iFinD is not configured") + now = datetime.now().astimezone() + rows: list[dict[str, Any]] = [] + for offset in range(0, 8): + candidate = now.date() - timedelta(days=offset) + if candidate.weekday() >= 5: + continue + display_date = candidate.isoformat() + rows = self.ifind.intraday( + ifind_code, + f"{display_date} 09:30:00", + f"{display_date} 15:00:00", + cache_ttl=20 if offset == 0 else 6 * 60 * 60, + ) + if rows: + break + points = [point for row in rows if (point := _ifind_point(row))] + if not points: + raise ChartDataError("No iFinD intraday chart data returned") + latest_date = points[-1]["date"] + points = [point for point in points if point["date"] == latest_date] + previous_close = self._previous_close(ifind_code, latest_date, points[0]["open"]) + return { + "entity_type": entity_type, + "identifier": identifier, + "name": name, + "code": identifier, + "trade_date": latest_date, + "previous_close": previous_close, + "points": points, + "source": "ifind", + } + + def _ifind_daily( + self, ifind_code: str, end_date: str, limit: int + ) -> list[dict[str, Any]]: + if not self.ifind.configured: + raise ChartDataError("iFinD is not configured") + compact_end = str(end_date or "").replace("-", "") + if not re.fullmatch(r"\d{8}", compact_end): + raise ChartDataError("Invalid chart end date") + end = datetime.strptime(compact_end, "%Y%m%d") + start = (end - timedelta(days=max(190, limit * 3))).strftime("%Y%m%d") + try: + rows = self.ifind.history( + ifind_code, + ["open", "high", "low", "close", "volume", "amount"], + start, + compact_end, + cache_ttl=300, + ) + except IfindError as exc: + raise ChartDataError("No iFinD daily chart data returned") from exc + normalized = [] + for row in rows: + stamp = str(row.get("time") or "").strip() + trade_date = stamp[:10] + close = _number(row.get("close")) + if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", trade_date) or close <= 0: + continue + normalized.append( + { + "trade_date": trade_date, + "open": _number(row.get("open")), + "high": _number(row.get("high")), + "low": _number(row.get("low")), + "close": close, + "volume": _number(row.get("volume")), + "amount_billion": _number(row.get("amount")) / 100_000_000, + } + ) + normalized.sort(key=lambda row: row["trade_date"]) + for index, row in enumerate(normalized): + previous = normalized[index - 1]["close"] if index > 0 else 0 + row["change"] = round((row["close"] / previous - 1) * 100, 4) if previous else 0.0 + + today = datetime.now().astimezone().strftime("%Y%m%d") + if compact_end == today: + try: + quote_rows = self.ifind.real_time( + ifind_code, + ["open", "high", "low", "latest", "preClose", "volume", "amount"], + cache_ttl=10, + ) + quote = quote_rows[0] if quote_rows else {} + latest = _number(quote.get("latest")) + previous = _number(quote.get("preClose")) + if latest > 0: + realtime = { + "trade_date": end.strftime("%Y-%m-%d"), + "open": _number(quote.get("open")) or latest, + "high": _number(quote.get("high")) or latest, + "low": _number(quote.get("low")) or latest, + "close": latest, + "change": round((latest / previous - 1) * 100, 4) if previous else 0.0, + "volume": _number(quote.get("volume")), + "amount_billion": _number(quote.get("amount")) / 100_000_000, + "realtime": True, + } + if normalized and normalized[-1]["trade_date"] == realtime["trade_date"]: + normalized[-1] = realtime + else: + normalized.append(realtime) + except IfindError: + pass + if not normalized: + raise ChartDataError("No iFinD daily chart data returned") + return normalized[-max(20, min(180, int(limit))):] + + def _previous_close(self, code: str, trade_date: str, fallback: float) -> float: + today = datetime.now().astimezone().date().isoformat() + if trade_date == today: + try: + quote = self.ifind.real_time(code, ["preClose"], cache_ttl=20) + value = _number((quote[0] if quote else {}).get("preClose")) + if value > 0: + return value + except IfindError: + pass + end = datetime.strptime(trade_date, "%Y-%m-%d") + try: + rows = self.ifind.history( + code, + ["close"], + (end - timedelta(days=12)).strftime("%Y%m%d"), + end.strftime("%Y%m%d"), + cache_ttl=6 * 60 * 60, + ) + closes = [_number(row.get("close")) for row in rows if _number(row.get("close")) > 0] + if len(closes) >= 2: + return closes[-2] + except IfindError: + pass + return fallback + + @dataclass class EastmoneyChartClient: """Isolated display-only minute chart source. @@ -225,6 +423,37 @@ def _parse_trend(raw: Any) -> dict[str, Any] | None: } +def _ifind_point(row: dict[str, Any]) -> dict[str, Any] | None: + stamp = str(row.get("time") or "").strip() + if " " not in stamp: + return None + trade_date, trade_time = stamp.split(" ", 1) + close = _number(row.get("close")) + if close <= 0: + return None + return { + "date": trade_date, + "time": trade_time[:5], + "open": _number(row.get("open")), + "close": close, + "high": _number(row.get("high")), + "low": _number(row.get("low")), + "volume": _number(row.get("volume")), + "amount": _number(row.get("amount")), + "average": _number(row.get("avgPrice")), + } + + +def _stock_market_code(code: str) -> str: + if code.startswith(("4", "8", "9")): + suffix = "BJ" + elif code.startswith("6"): + suffix = "SH" + else: + suffix = "SZ" + return f"{code}.{suffix}" + + def _number(value: Any) -> float: try: return float(value or 0) diff --git a/database.py b/database.py index 13af575..67e9b11 100644 --- a/database.py +++ b/database.py @@ -319,6 +319,21 @@ class ReviewDatabase: CREATE INDEX IF NOT EXISTS idx_mentor_preferences_user_order ON mentor_preferences(user_id, pinned DESC, sort_order, mentor_id); + CREATE TABLE IF NOT EXISTS wencai_saved_queries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + title TEXT NOT NULL, + query TEXT NOT NULL, + search_type TEXT NOT NULL DEFAULT 'stock', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(user_id, query, search_type), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_wencai_saved_queries_user + ON wencai_saved_queries(user_id, updated_at DESC, id DESC); + CREATE TABLE IF NOT EXISTS strategy_tracks ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, @@ -1730,7 +1745,10 @@ class ReviewDatabase: result.setdefault("meta", {}).update( { "run_id": int(row["id"]), + "trade_date": str(row["trade_date"] or ""), + "regime": str(row["regime"] or ""), "mode": str(row["mode"] or "smart"), + "strategy_name": str(row["strategy_name"] or ""), "created_at": row["created_at"], } ) @@ -1780,6 +1798,39 @@ class ReviewDatabase: results[mode] = payload return results + def latest_screener_context_runs( + self, user_id: int, trade_date: str, limit: int = 60, + ) -> list[dict[str, Any]]: + safe_limit = max(1, min(120, int(limit))) + with self.connect() as connection: + rows = connection.execute( + """ + WITH ranked AS ( + SELECT id, trade_date, regime, mode, strategy_name, result, created_at, + ROW_NUMBER() OVER ( + PARTITION BY + mode, + CASE WHEN mode = 'smart' THEN regime ELSE '' END, + CASE WHEN mode IN ('smart', 'curated') THEN strategy_name ELSE '' END + ORDER BY id DESC + ) AS context_rank + FROM screener_runs + WHERE user_id = ? AND trade_date <= ? + ) + SELECT id, trade_date, regime, mode, strategy_name, result, created_at + FROM ranked + WHERE context_rank = 1 + ORDER BY id DESC + LIMIT ? + """, + (int(user_id), trade_date, safe_limit), + ).fetchall() + return [ + payload + for row in rows + if (payload := self._screener_run_payload(row)) is not None + ] + def get_screener_run(self, user_id: int, run_id: int) -> dict[str, Any] | None: with self.connect() as connection: row = connection.execute( @@ -1902,6 +1953,56 @@ class ReviewDatabase: values, ) + def list_wencai_saved_queries( + self, user_id: int, limit: int = 30 + ) -> list[dict[str, Any]]: + with self.connect() as connection: + rows = connection.execute( + """ + SELECT id, title, query, search_type, created_at, updated_at + FROM wencai_saved_queries + WHERE user_id = ? + ORDER BY updated_at DESC, id DESC LIMIT ? + """, + (int(user_id), max(1, min(100, int(limit)))), + ).fetchall() + return [dict(row) for row in rows] + + def save_wencai_query( + self, user_id: int, title: str, query: str, search_type: str = "stock" + ) -> int: + now = datetime.now().astimezone().isoformat(timespec="seconds") + with self.connect() as connection: + connection.execute( + """ + INSERT INTO wencai_saved_queries + (user_id, title, query, search_type, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, query, search_type) DO UPDATE SET + title = excluded.title, + updated_at = excluded.updated_at + """, + (int(user_id), title, query, search_type, now, now), + ) + row = connection.execute( + """ + SELECT id FROM wencai_saved_queries + WHERE user_id = ? AND query = ? AND search_type = ? + """, + (int(user_id), query, search_type), + ).fetchone() + if not row: + raise ValueError("问财条件保存失败。") + return int(row["id"]) + + def delete_wencai_saved_query(self, user_id: int, query_id: int) -> bool: + with self.connect() as connection: + cursor = connection.execute( + "DELETE FROM wencai_saved_queries WHERE id = ? AND user_id = ?", + (int(query_id), int(user_id)), + ) + return cursor.rowcount > 0 + def save_strategy_tracks( self, user_id: int, diff --git a/ifind_client.py b/ifind_client.py new file mode 100644 index 0000000..2b30fd9 --- /dev/null +++ b/ifind_client.py @@ -0,0 +1,385 @@ +from __future__ import annotations + +import copy +import json +import threading +import time +import urllib.error +import urllib.request +from datetime import datetime, timedelta +from typing import Any + + +class IfindError(RuntimeError): + pass + + +class IfindHttpClient: + BASE_URL = "https://quantapi.51ifind.com/api/v1" + AUTH_ENDPOINT = "get_access_token" + AUTH_ERROR_CODES = {-1302, -1303, -1304, -4302, -4303} + + def __init__( + self, + refresh_token: str = "", + access_token: str = "", + timeout: int = 15, + ) -> None: + self.timeout = max(3, int(timeout)) + self._refresh_token = str(refresh_token or "").strip() + self._access_token = str(access_token or "").strip() + self._access_expires_at: datetime | None = None + self._token_lock = threading.Lock() + self._cache_lock = threading.Lock() + self._cache: dict[str, dict[str, Any]] = {} + + @property + def configured(self) -> bool: + return bool(self._refresh_token or self._access_token) + + def set_credentials(self, refresh_token: str, access_token: str = "") -> None: + refresh_token = str(refresh_token or "").strip() + access_token = str(access_token or "").strip() + with self._token_lock: + refresh_changed = refresh_token != self._refresh_token + self._refresh_token = refresh_token + if access_token or refresh_changed: + self._access_token = access_token + self._access_expires_at = None + if refresh_changed: + with self._cache_lock: + self._cache.clear() + + def status(self) -> dict[str, Any]: + return { + "configured": self.configured, + "access_ready": bool(self._access_token), + "access_expires_at": ( + self._access_expires_at.isoformat(timespec="seconds") + if self._access_expires_at + else "" + ), + } + + def test_connection(self) -> dict[str, Any]: + payload = self.real_time( + "000001.SH", + ["open", "high", "low", "latest", "preClose"], + cache_ttl=0, + ) + return { + "ok": bool(payload), + "sample_time": str(payload[0].get("time") or "") if payload else "", + } + + def real_time( + self, + codes: str | list[str], + indicators: list[str], + cache_ttl: int = 10, + ) -> list[dict[str, Any]]: + code_text = self._codes(codes) + payload = self._request( + "real_time_quotation", + {"codes": code_text, "indicators": ",".join(indicators)}, + cache_key=f"rq:{code_text}:{','.join(indicators)}", + cache_ttl=cache_ttl, + ) + return self._table_rows(payload) + + def history( + self, + codes: str | list[str], + indicators: list[str], + start_date: str, + end_date: str, + cache_ttl: int = 300, + ) -> list[dict[str, Any]]: + code_text = self._codes(codes) + payload = self._request( + "cmd_history_quotation", + { + "codes": code_text, + "indicators": ",".join(indicators), + "startdate": self._display_date(start_date), + "enddate": self._display_date(end_date), + "functionpara": {"CPS": "forward1", "Fill": "Omit"}, + }, + cache_key=f"hq:{code_text}:{start_date}:{end_date}:{','.join(indicators)}", + cache_ttl=cache_ttl, + ) + return self._table_rows(payload) + + def intraday( + self, + code: str, + start_time: str, + end_time: str, + cache_ttl: int = 20, + ) -> list[dict[str, Any]]: + indicators = ["open", "high", "low", "close", "volume", "amount", "avgPrice"] + payload = self._request( + "high_frequency", + { + "codes": self._codes(code), + "indicators": ",".join(indicators), + "starttime": start_time, + "endtime": end_time, + "functionpara": { + "CPS": "forward1", + "Fill": "Previous", + "Timeformat": "LocalTime", + "Interval": "1", + "Limitstart": "09:30:00", + "Limitend": "15:00:00", + }, + }, + cache_key=f"hf:{code}:{start_time}:{end_time}", + cache_ttl=cache_ttl, + ) + return self._table_rows(payload) + + def snapshots( + self, + codes: str | list[str], + indicators: list[str], + start_time: str, + end_time: str, + cache_ttl: int = 8, + ) -> list[dict[str, Any]]: + code_text = self._codes(codes) + payload = self._request( + "snap_shot", + { + "codes": code_text, + "indicators": ",".join(indicators), + "starttime": start_time, + "endtime": end_time, + }, + cache_key=f"ss:{code_text}:{start_time}:{end_time}:{','.join(indicators)}", + cache_ttl=cache_ttl, + ) + return self._table_rows(payload) + + def wencai(self, query: str, search_type: str = "stock", cache_ttl: int = 300) -> list[dict[str, Any]]: + normalized = " ".join(str(query or "").split()) + if not normalized: + raise IfindError("问财查询不能为空。") + payload = self._request( + "smart_stock_picking", + {"searchstring": normalized, "searchtype": search_type}, + cache_key=f"wc:{search_type}:{normalized}", + cache_ttl=cache_ttl, + ) + return self._table_rows(payload) + + def report_query( + self, + codes: str | list[str], + begin_date: str, + end_date: str, + cache_ttl: int = 300, + ) -> list[dict[str, Any]]: + code_text = self._codes(codes) + payload = self._request( + "report_query", + { + "codes": code_text, + "beginrDate": self._display_date(begin_date), + "endrDate": self._display_date(end_date), + "outputpara": ( + "reportDate:Y,thscode:Y,secName:Y,ctime:Y," + "reportTitle:Y,pdfURL:Y,seq:Y" + ), + }, + cache_key=f"report:{code_text}:{begin_date}:{end_date}", + cache_ttl=cache_ttl, + ) + return self._table_rows(payload) + + def _request( + self, + endpoint: str, + body: dict[str, Any], + cache_key: str = "", + cache_ttl: int = 0, + ) -> dict[str, Any]: + if not self.configured: + raise IfindError("iFinD 尚未配置。") + if cache_key and cache_ttl > 0: + cached = self._cached(cache_key, cache_ttl) + if cached is not None: + return cached + + payload = self._post(endpoint, body, self._ensure_access_token()) + if self._is_auth_error(payload) and self._refresh_token: + self._invalidate_access_token() + payload = self._post(endpoint, body, self._ensure_access_token(force=True)) + self._validate_payload(payload) + if cache_key and cache_ttl > 0: + with self._cache_lock: + self._cache[cache_key] = { + "created_at": time.time(), + "payload": copy.deepcopy(payload), + } + return payload + + def _ensure_access_token(self, force: bool = False) -> str: + with self._token_lock: + now = datetime.now().astimezone().replace(tzinfo=None) + token_valid = bool(self._access_token) and ( + self._access_expires_at is None + or self._access_expires_at > now + timedelta(minutes=2) + ) + if token_valid and not force: + return self._access_token + if not self._refresh_token: + if self._access_token: + return self._access_token + raise IfindError("iFinD Refresh Token 尚未配置。") + payload = self._post(self.AUTH_ENDPOINT, {}, "", self._refresh_token) + self._validate_payload(payload) + data = payload.get("data") or {} + token = str(data.get("access_token") or "").strip() + if not token: + raise IfindError("iFinD 未返回 Access Token。") + expires_at = self._parse_datetime(data.get("expired_time")) + self._access_token = token + self._access_expires_at = expires_at + return token + + def _post( + self, + endpoint: str, + body: dict[str, Any], + access_token: str, + refresh_token: str = "", + ) -> dict[str, Any]: + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "XiaobaiReviewWeb/1.0", + "ifindlang": "cn", + } + if access_token: + headers["access_token"] = access_token + if refresh_token: + headers["refresh_token"] = refresh_token + request = urllib.request.Request( + f"{self.BASE_URL}/{endpoint}", + data=json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8"), + headers=headers, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=self.timeout) as response: + payload = json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail = "" + try: + detail_payload = json.loads(exc.read().decode("utf-8", errors="replace")) + detail = str(detail_payload.get("errmsg") or detail_payload.get("message") or "") + except (json.JSONDecodeError, OSError): + pass + raise IfindError(f"iFinD HTTP {exc.code}{f':{detail[:160]}' if detail else ''}") from exc + except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc: + raise IfindError("iFinD 数据请求失败。") from exc + if not isinstance(payload, dict): + raise IfindError("iFinD 返回格式不正确。") + return payload + + def _cached(self, key: str, ttl: int) -> dict[str, Any] | None: + with self._cache_lock: + cached = self._cache.get(key) + if not cached: + return None + if time.time() - float(cached.get("created_at") or 0) > ttl: + self._cache.pop(key, None) + return None + return copy.deepcopy(cached["payload"]) + + def _invalidate_access_token(self) -> None: + with self._token_lock: + self._access_token = "" + self._access_expires_at = None + + @classmethod + def _validate_payload(cls, payload: dict[str, Any]) -> None: + try: + error_code = int(payload.get("errorcode") or 0) + except (TypeError, ValueError): + error_code = -1 + if error_code != 0: + message = str(payload.get("errmsg") or "未知错误") + raise IfindError(f"iFinD 返回错误:{message[:200]}") + + @classmethod + def _is_auth_error(cls, payload: dict[str, Any]) -> bool: + try: + error_code = int(payload.get("errorcode") or 0) + except (TypeError, ValueError): + error_code = 0 + message = str(payload.get("errmsg") or "").casefold() + return error_code in cls.AUTH_ERROR_CODES or "token" in message or "鉴权" in message + + @staticmethod + def _table_rows(payload: dict[str, Any]) -> list[dict[str, Any]]: + tables = payload.get("tables") or [] + if isinstance(tables, dict): + tables = [tables] + rows: list[dict[str, Any]] = [] + for block in tables if isinstance(tables, list) else []: + if not isinstance(block, dict): + continue + table = block.get("table") or {} + if not isinstance(table, dict): + continue + times = block.get("time") or [] + codes = block.get("thscode") or block.get("thscodes") or [] + if isinstance(codes, str): + codes = [codes] + lengths = [len(value) for value in table.values() if isinstance(value, list)] + row_count = max(lengths or [len(times) if isinstance(times, list) else 0, 1 if table else 0]) + for index in range(row_count): + row: dict[str, Any] = {} + if isinstance(times, list) and index < len(times): + row["time"] = times[index] + if codes: + row["thscode"] = codes[index] if index < len(codes) else codes[0] + for field, values in table.items(): + if isinstance(values, list): + row[field] = values[index] if index < len(values) else None + elif index == 0: + row[field] = values + rows.append(row) + return rows + + @staticmethod + def _codes(codes: str | list[str]) -> str: + if isinstance(codes, list): + values = [str(code or "").strip().upper() for code in codes] + else: + values = [part.strip().upper() for part in str(codes or "").split(",")] + values = [value for value in values if value] + if not values: + raise IfindError("iFinD 证券代码不能为空。") + if len(values) > 100: + raise IfindError("iFinD 单次证券代码过多。") + return ",".join(values) + + @staticmethod + def _display_date(value: str) -> str: + compact = str(value or "").replace("-", "") + if len(compact) != 8 or not compact.isdigit(): + raise IfindError("iFinD 日期格式不正确。") + return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}" + + @staticmethod + def _parse_datetime(value: Any) -> datetime | None: + text = str(value or "").strip() + if not text: + return None + try: + return datetime.fromisoformat(text) + except ValueError: + return None diff --git a/market_insights.py b/market_insights.py index 5512fb1..aa6dc35 100644 --- a/market_insights.py +++ b/market_insights.py @@ -7,6 +7,7 @@ from statistics import median from typing import Any, Callable from database import ReviewDatabase +from ifind_client import IfindError, IfindHttpClient from tushare_client import TushareClient, TushareError @@ -36,10 +37,12 @@ class MarketInsightsService: database: ReviewDatabase, client: TushareClient, now_provider: Callable[[], datetime] | None = None, + ifind: IfindHttpClient | None = None, ) -> None: self.database = database self.client = client self._now_provider = now_provider or (lambda: datetime.now(CHINA_TIMEZONE)) + self.ifind = ifind def _trade_context(self, requested_date: str) -> tuple[str, str]: """Resolve trading dates without making cached feature pages depend on Tushare uptime.""" @@ -607,6 +610,108 @@ class MarketInsightsService: personalized["watchlist_missing_count"] = missing return personalized + def _dynamic_auction_rows( + self, + trade_date: str, + baseline_date: str, + user_id: int, + ) -> list[dict[str, Any]]: + if not self.ifind or not self.ifind.configured: + return [] + master = self._stock_master() + placeholders = [ + { + "code": str(item.get("code") or ts_code.split(".")[0]), + "ts_code": ts_code, + "name": str(item.get("name") or "--"), + "sector": str(item.get("industry") or "其他"), + } + for ts_code, item in master.items() + ] + candidates, _, _ = self._auction_candidates(placeholders, baseline_date) + selected_codes = { + str(item.get("ts_code") or "") + for item in candidates + if item.get("ts_code") + } + if user_id: + watched = {str(item.get("code") or "") for item in self.database.list_watchlist(user_id)} + selected_codes.update( + ts_code for ts_code in master if ts_code.split(".")[0] in watched + ) + selected_codes.discard("") + if not selected_codes: + return [] + + display_date = _display_date(trade_date) + now = self._now_provider() + if now.tzinfo is None: + now = now.replace(tzinfo=CHINA_TIMEZONE) + else: + now = now.astimezone(CHINA_TIMEZONE) + end_time = min(now.time().replace(tzinfo=None), dt_time(9, 25)) + end_stamp = f"{display_date} {end_time.strftime('%H:%M:%S')}" + start_stamp = f"{display_date} 09:15:00" + snapshot_rows: list[dict[str, Any]] = [] + ordered_codes = sorted(selected_codes) + try: + for index in range(0, len(ordered_codes), 80): + snapshot_rows.extend( + self.ifind.snapshots( + ordered_codes[index:index + 80], + [ + "latest", "volume", "amount", "preClose", + "bid1", "bidSize1", "ask1", "askSize1", + ], + start_stamp, + end_stamp, + cache_ttl=8, + ) + ) + except IfindError: + return [] + + latest: dict[str, dict[str, Any]] = {} + for row in snapshot_rows: + ts_code = str(row.get("thscode") or "") + if ts_code and _number(row.get("latest")) > 0: + latest[ts_code] = row + prior_factors = { + str(item.get("ts_code") or ""): item + for item in self.database.auction_factors_for_date(baseline_date) + } + normalized = [] + for ts_code, row in latest.items(): + price = _number(row.get("latest")) + pre_close = _number(row.get("preClose")) + volume = _number(row.get("volume")) + bid_size = _number(row.get("bidSize1")) + ask_size = _number(row.get("askSize1")) + if volume <= 0 and bid_size > 0 and ask_size > 0: + volume = min(bid_size, ask_size) + amount = _number(row.get("amount")) + if amount <= 0 and price > 0 and volume > 0: + amount = price * volume + prior_volume = _number((prior_factors.get(ts_code) or {}).get("vol")) + normalized.append( + { + "ts_code": ts_code, + "trade_date": trade_date, + "vol": volume, + "price": price, + "amount": amount, + "pre_close": pre_close, + "turnover_rate": 0, + "volume_ratio": volume / prior_volume if prior_volume > 0 else 0, + "float_share": 0, + "bid_size1": bid_size, + "ask_size1": ask_size, + "snapshot_time": str(row.get("time") or ""), + "dynamic": True, + } + ) + return normalized + def auction_center( self, requested_date: str, @@ -616,10 +721,11 @@ class MarketInsightsService: trade_date, previous_date = self._trade_context(requested_date) session = self._auction_session(requested_date, trade_date) phase = str(session["phase"]) - data_date = previous_date if phase in {"pending", "observing"} else trade_date + dynamic = phase == "observing" and bool(self.ifind and self.ifind.configured) + data_date = previous_date if phase == "pending" or (phase == "observing" and not dynamic) else trade_date carried_forward = data_date != trade_date cache_key = data_date - if not force: + if not force and not dynamic: cached = self.database.get_data_snapshot("auction_center_v5", cache_key) if cached: result = copy.deepcopy(cached) @@ -634,10 +740,13 @@ class MarketInsightsService: } return self._with_auction_watchlist(result, data_date, user_id) - try: - rows = self.client.query("stk_auction", {"trade_date": data_date}) - except TushareError: - rows = self.database.auction_factors_for_date(data_date) + if dynamic: + rows = self._dynamic_auction_rows(data_date, previous_date, user_id) + else: + try: + rows = self.client.query("stk_auction", {"trade_date": data_date}) + except TushareError: + rows = self.database.auction_factors_for_date(data_date) if not rows: return { "meta": { @@ -798,7 +907,8 @@ class MarketInsightsService: "one_price_rows": one_price_rows, "rows": candidates, } - self.database.save_data_snapshot("auction_center_v5", cache_key, "market", result) + if not dynamic: + self.database.save_data_snapshot("auction_center_v5", cache_key, "market", result) return self._with_auction_watchlist(result, data_date, user_id) def _theme_directory(self) -> list[dict[str, Any]]: diff --git a/server.py b/server.py index f66f8a2..42b037e 100644 --- a/server.py +++ b/server.py @@ -19,7 +19,7 @@ from urllib.parse import parse_qs, unquote, urlparse from alert_service import AlertService from assistant_agent import ReviewAssistantError, stream_review_assistant from api_access import required_role -from chart_data_provider import ChartDataError, EastmoneyChartClient +from chart_data_provider import ChartDataError, EastmoneyChartClient, MarketChartClient from app_config import ( DATA_DIR, MENTOR_SKILLS_DIR, @@ -50,6 +50,7 @@ from heaven_engine import ( build_personal_field, hexagram_from_lines, ) +from ifind_client import IfindError, IfindHttpClient from llm_strategy import LLMCompilerError, compile_strategy_with_llm, test_llm_connection from mentor_agent import MentorAgentError, MentorSkillRegistry, stream_with_mentor from market_insights import MarketInsightsService @@ -76,6 +77,8 @@ from tushare_client import TushareClient, TushareError, _sector_coverage_issue LEGACY_SECRET_KEYS = { "TUSHARE_TOKEN", + "IFIND_REFRESH_TOKEN", + "IFIND_ACCESS_TOKEN", "LLM_API_KEY", "LLM_BASE_URL", "LLM_MODEL", @@ -104,12 +107,51 @@ THS_SEARCH_TYPES = { "N": ("theme", "概念题材"), } +MENTOR_DATA_PROFILES = { + "emotion": { + "kobe92-perspective", "niepanchongsheng-perspective", + "chaojiyangjia-perspective", "tuixuechaogu-perspective", + "chenxiaoqun-perspective", "zhiyechaoshou-perspective", + }, + "first_board": { + "beijingchaojia-perspective", "chuangshiji-perspective", + "xuxiang-perspective", "foshanwuyingjiao-perspective", + }, + "leader": { + "zhaolaoge-perspective", "fangxinxia-perspective", + "xiaoe-perspective", "sunge-perspective", "liuyizhonglu-perspective", + }, + "trend": { + "zhangdetao-perspective", "zhangmengzhu-perspective", + "zuoshouxinyi-perspective", + }, + "low_absorption": { + "qiaobangzhu-perspective", "asking-perspective", + "longfeihu-perspective", "ruihexian-perspective", + }, + "macro": {"shuipi-perspective"}, +} + +MENTOR_INDEX_UNIVERSE = ( + ("000001.SH", "上证指数"), ("399001.SZ", "深证成指"), + ("399006.SZ", "创业板指"), ("000016.SH", "上证50"), + ("000300.SH", "沪深300"), ("000905.SH", "中证500"), + ("000852.SH", "中证1000"), ("932000.CSI", "中证2000"), +) + +MENTOR_ETF_UNIVERSE = ( + ("510050.SH", "上证50ETF"), ("510300.SH", "沪深300ETF"), + ("510500.SH", "中证500ETF"), ("512100.SH", "中证1000ETF"), +) + class DashboardService: def __init__(self) -> None: load_local_env() environment_credentials = { "tushare_token": os.environ.get("TUSHARE_TOKEN", "").strip(), + "ifind_refresh_token": os.environ.get("IFIND_REFRESH_TOKEN", "").strip(), + "ifind_access_token": os.environ.get("IFIND_ACCESS_TOKEN", "").strip(), "platform_llm_primary_api_key": os.environ.get( "LLM_PRIMARY_API_KEY", os.environ.get("LLM_API_KEY", "") ).strip(), @@ -133,15 +175,20 @@ class DashboardService: self.sync_lock = threading.Lock() self.auth_lock = threading.Lock() self.system_lock = threading.Lock() + self._ifind_event_lock = threading.Lock() self._request_context = threading.local() self._system_credentials = self._load_system_credentials(environment_credentials) + self.ifind = IfindHttpClient( + str(self._system_credentials.get("ifind_refresh_token") or ""), + str(self._system_credentials.get("ifind_access_token") or ""), + ) self.screener = ScreenerEngine(self.database) self.strategy_tracking = StrategyTrackingService(self.database) self.alert_service = AlertService(self.database) self.trade_journal = TradeJournalService(self.database) self.mentor_skills = MentorSkillRegistry(MENTOR_SKILLS_DIR, PRIVATE_MENTOR_SKILLS_DIR) self.realtime_aggregator = WebRealtimeAggregator() - self.chart_data = EastmoneyChartClient() + self.chart_data = MarketChartClient(self.ifind, EastmoneyChartClient()) self.screener.ensure_builtin_strategies() self._background_stop = threading.Event() self._background_thread = threading.Thread( @@ -162,6 +209,8 @@ class DashboardService: first_personal = self.vault.decrypt_json(first_encrypted) if first_encrypted else {} defaults = { "tushare_token": environment.get("tushare_token") or first_personal.get("tushare_token") or "", + "ifind_refresh_token": environment.get("ifind_refresh_token") or "", + "ifind_access_token": environment.get("ifind_access_token") or "", "platform_llm_primary_api_key": environment.get("platform_llm_primary_api_key") or first_personal.get("llm_primary_api_key") or "", "platform_llm_primary_base_url": environment.get("platform_llm_primary_base_url") or first_personal.get("llm_primary_base_url") or "https://api.openai.com/v1", "platform_llm_primary_model": environment.get("platform_llm_primary_model") or first_personal.get("llm_primary_model") or "", @@ -208,6 +257,11 @@ class DashboardService: with self.system_lock: self.database.save_system_setting("credentials", self.vault.encrypt_json(credentials)) self._system_credentials = dict(credentials) + if hasattr(self, "ifind"): + self.ifind.set_credentials( + str(credentials.get("ifind_refresh_token") or ""), + str(credentials.get("ifind_access_token") or ""), + ) @property def configured(self) -> bool: @@ -514,6 +568,7 @@ class DashboardService: return { "data": { "configured": self.configured, + "ifind": self.ifind.status(), "background_refresh_enabled": bool( self._system_credentials.get("background_refresh_enabled", True) ), @@ -538,6 +593,16 @@ class DashboardService: token = str(payload.get("tushare_token") or current.get("tushare_token") or "").strip() if token and not TOKEN_PATTERN.fullmatch(token): raise ValueError("Tushare Token 格式不正确。") + ifind_refresh_token = str( + payload.get("ifind_refresh_token") + or current.get("ifind_refresh_token") + or "" + ).strip() + if ifind_refresh_token and ( + len(ifind_refresh_token) > 2048 + or any(character.isspace() for character in ifind_refresh_token) + ): + raise ValueError("iFinD Refresh Token 格式不正确。") existing_models = { str(item.get("id") or ""): item for item in current.get("llm_models") or [] @@ -600,6 +665,7 @@ class DashboardService: current.update( { "tushare_token": token, + "ifind_refresh_token": ifind_refresh_token, "llm_models": models, "primary_model_id": primary_model_id, "fallback_model_id": fallback_model_id, @@ -868,11 +934,51 @@ class DashboardService: snapshot.setdefault("meta", {}).update( {"realtime": False, "market_status": "closed"} ) - snapshot = self._enrich_dashboard_sentiment(snapshot, normalized_date) + if not self._dashboard_sentiment_ready(snapshot): + snapshot = self._enrich_dashboard_sentiment(snapshot, normalized_date) snapshot.setdefault("meta", {})["requested_date"] = self._display_compact_date(normalized_date) return self._apply_reason_overrides(self._with_storage(snapshot, cached=True)) + resolved = self.database.get_data_snapshot( + "dashboard_request_v1", normalized_date + ) + if resolved and str((resolved.get("meta") or {}).get("source") or "") != "demo": + resolved = copy.deepcopy(resolved) + resolved.setdefault("meta", {})["requested_date"] = self._display_compact_date( + normalized_date + ) + return self._apply_reason_overrides( + self._with_storage(resolved, cached=True) + ) + if datetime.strptime(normalized_date, "%Y%m%d").weekday() >= 5: + previous = self.database.get_latest_real_snapshot(normalized_date) + if previous: + carried = self._carry_dashboard( + previous, + normalized_date, + "非交易日沿用最近交易日收盘行情", + ) + self.database.save_data_snapshot( + "dashboard_request_v1", normalized_date, "sqlite", carried + ) + return self._apply_reason_overrides( + self._with_storage(carried, cached=True) + ) return self.sync_dashboard(normalized_date) + @staticmethod + def _dashboard_sentiment_ready(dashboard: dict[str, Any]) -> bool: + overview = dashboard.get("overview") or {} + return all( + key in overview + for key in ( + "sentiment_score", + "sentiment_label", + "sentiment_phase", + "sentiment_direction", + "sentiment_components", + ) + ) + @staticmethod def _display_compact_date(compact: str) -> str: return f"{compact[:4]}-{compact[4:6]}-{compact[6:8]}" @@ -945,6 +1051,17 @@ class DashboardService: str(dashboard.get("meta", {}).get("trade_date") or normalized_date) ) self.database.save_snapshot(actual_date, source, dashboard) + if actual_date != normalized_date: + dashboard.setdefault("meta", {}).update( + { + "carried_forward": True, + "realtime": False, + "market_status": "closed", + } + ) + self.database.save_data_snapshot( + "dashboard_request_v1", normalized_date, source, dashboard + ) self.database.finish_sync( sync_id, "success", @@ -1073,7 +1190,11 @@ class DashboardService: def _market_insights(self) -> MarketInsightsService: if not self.configured: raise ValueError("行情数据尚未配置。") - return MarketInsightsService(self.database, TushareClient(self.token)) + return MarketInsightsService( + self.database, + TushareClient(self.token), + ifind=self.ifind, + ) def auction_center(self, trade_date: str, force: bool = False) -> dict[str, Any]: return self._market_insights().auction_center( @@ -1089,6 +1210,30 @@ class DashboardService: def popularity(self, trade_date: str, force: bool = False) -> dict[str, Any]: return self._market_insights().popularity(normalize_date(trade_date), force) + @staticmethod + def _ifind_field(row: dict[str, Any], tokens: tuple[str, ...]) -> Any: + for key, value in row.items(): + label = str(key or "") + if any(token.casefold() == label.casefold() for token in tokens): + return value + for key, value in row.items(): + label = str(key or "") + if any(token in label for token in tokens): + return value + return None + + @classmethod + def _ifind_row_code(cls, row: dict[str, Any]) -> str: + value = cls._ifind_field(row, ("股票代码", "证券代码", "代码", "thscode")) + match = re.search(r"(? dict[str, Any]: normalized_date = normalize_date(trade_date) regime = self.screener.detect_regime(normalized_date) @@ -1150,6 +1295,9 @@ class DashboardService: "latest_results": self.database.latest_screener_runs( self.current_user_id, normalized_date ), + "recent_results": self.database.latest_screener_context_runs( + self.current_user_id, normalized_date + ), # Kept during the client transition for compatibility with older frontends. "latest_result": self.database.latest_screener_run( self.current_user_id, normalized_date, "smart" @@ -1578,7 +1726,7 @@ class DashboardService: skill = self.mentor_skills.get_skill( mentor_id, include_private=self.membership()["is_admin"] ) - context = self._build_mentor_context(trade_date, question) + context = self._build_mentor_context(trade_date, question, skill) source = self.ensure_llm_access("mentor") profiles = [] @@ -2990,7 +3138,9 @@ class DashboardService: history.append({"role": item["role"], "content": content}) return history - def _build_mentor_context(self, trade_date: str, question: str) -> dict[str, Any]: + def _build_mentor_context( + self, trade_date: str, question: str, skill: Any | None = None + ) -> dict[str, Any]: dashboard = self.get_dashboard(trade_date) data_trade_date = normalize_date( str(dashboard.get("meta", {}).get("trade_date") or trade_date) @@ -3009,6 +3159,10 @@ class DashboardService: if code in codes or (len(name) >= 2 and name in question): if not any(item.get("code") == code for item in matched_rows): matched_rows.append(row) + for row in matched_rows: + code = str(row.get("code") or "") + if code and code not in codes: + codes.append(code) stock_details = [] for code in codes[:2]: try: @@ -3023,8 +3177,17 @@ class DashboardService: except Exception as exc: stock_details.append({"code": code, "error": str(exc)}) + skill_id = str(getattr(skill, "skill_id", "") or "") + profile = next( + ( + profile_name + for profile_name, skill_ids in MENTOR_DATA_PROFILES.items() + if skill_id in skill_ids + ), + "balanced", + ) dragon_tiger = None - if codes or any(keyword in question for keyword in ("龙虎榜", "席位", "机构", "游资")): + if any(keyword in question for keyword in ("龙虎榜", "席位", "机构", "游资")): try: dragon_payload = self.get_dragon_tiger(data_trade_date) rows = list(dragon_payload.get("rows") or []) @@ -3042,45 +3205,189 @@ class DashboardService: except Exception as exc: dragon_tiger = {"error": str(exc)} - return { + context: dict[str, Any] = { "data_trade_date": data_trade_date, - "source": dashboard.get("meta", {}).get("source"), - "notice": dashboard.get("meta", {}).get("notice") or "", + "data_profile": profile, "overview": dashboard.get("overview") or {}, "market_regime": regime, "recent_market_history": self.database.snapshot_summaries(data_trade_date, 10), - "limit_ladder": dashboard.get("ladders") or [], - "limit_performance": dashboard.get("limit_performance") or [], - "hot_sectors": (dashboard.get("sectors") or [])[:20], - "sector_rotation": (dashboard.get("sector_rotation") or [])[:20], - "limit_up_stocks": sorted( - limits, - key=lambda row: ( - float(row.get("streak") or 0), - float(row.get("amount_billion") or 0), - ), - reverse=True, - )[:30], - "broken_stocks": sorted( - broken, - key=lambda row: float(row.get("amount_billion") or 0), - reverse=True, - )[:20], - "limit_down_stocks": sorted( - down_limits, - key=lambda row: float(row.get("amount_billion") or 0), - reverse=True, - )[:25], - "yesterday_limit_performance": sorted( - yesterday_limits, - key=lambda row: float(row.get("change") or 0), - reverse=True, - )[:25], "question_matched_stocks": matched_rows[:10], "stock_details": stock_details, - "dragon_tiger": dragon_tiger, } + ordered_limits = sorted( + limits, + key=lambda row: ( + float(row.get("streak") or 0), + float(row.get("amount_billion") or 0), + ), + reverse=True, + ) + if profile in {"emotion", "balanced"}: + context.update( + { + "limit_ladder": dashboard.get("ladders") or [], + "limit_performance": dashboard.get("limit_performance") or [], + "hot_sectors": (dashboard.get("sectors") or [])[:15], + "sector_rotation": (dashboard.get("sector_rotation") or [])[:15], + "limit_up_stocks": ordered_limits[:30], + "broken_stocks": sorted( + broken, + key=lambda row: float(row.get("amount_billion") or 0), + reverse=True, + )[:20], + "limit_down_stocks": down_limits[:20], + "yesterday_limit_performance": sorted( + yesterday_limits, + key=lambda row: float(row.get("change") or 0), + reverse=True, + )[:20], + } + ) + elif profile == "first_board": + context.update( + { + "first_board_environment": { + "seal_rate": (dashboard.get("overview") or {}).get("seal_rate"), + "broken_count": len(broken), + "first_boards": [row for row in ordered_limits if int(row.get("streak") or 1) == 1][:35], + "broken_stocks": sorted( + broken, + key=lambda row: float(row.get("amount_billion") or 0), + reverse=True, + )[:30], + }, + "hot_sectors": (dashboard.get("sectors") or [])[:12], + } + ) + elif profile == "leader": + context.update( + { + "limit_ladder": dashboard.get("ladders") or [], + "multi_board_leaders": [ + row for row in ordered_limits if int(row.get("streak") or 0) >= 2 + ][:25], + "hot_sectors": (dashboard.get("sectors") or [])[:12], + "sector_rotation": (dashboard.get("sector_rotation") or [])[:12], + } + ) + try: + popularity = self.popularity(data_trade_date) + context["popularity_core"] = { + "consensus": [ + row for row in (popularity.get("combined") or []) + if row.get("dual_source") + ][:10], + "ths": (popularity.get("ths") or [])[:10], + "eastmoney": (popularity.get("dc") or [])[:10], + } + except Exception: + context["popularity_core"] = {"unavailable": True} + elif profile == "trend": + context.update( + { + "index_momentum": self._mentor_market_matrix( + data_trade_date, MENTOR_INDEX_UNIVERSE + ), + "sector_rotation": (dashboard.get("sector_rotation") or [])[:20], + "hot_sectors": (dashboard.get("sectors") or [])[:20], + "market_breadth": { + key: (dashboard.get("overview") or {}).get(key) + for key in ("up_count", "down_count", "flat_count", "amount_billion") + }, + } + ) + elif profile == "low_absorption": + context.update( + { + "yesterday_limit_performance": sorted( + yesterday_limits, + key=lambda row: float(row.get("change") or 0), + reverse=True, + )[:35], + "broken_stocks": broken[:20], + "hot_sectors": (dashboard.get("sectors") or [])[:12], + } + ) + elif profile == "macro": + context.update( + { + "broad_indexes": self._mentor_market_matrix( + data_trade_date, MENTOR_INDEX_UNIVERSE + ), + "core_etfs": self._mentor_market_matrix( + data_trade_date, MENTOR_ETF_UNIVERSE + ), + "market_style": { + "amount_billion": (dashboard.get("overview") or {}).get("amount_billion"), + "breadth": { + "up": (dashboard.get("overview") or {}).get("up_count"), + "down": (dashboard.get("overview") or {}).get("down_count"), + }, + "top_sectors": (dashboard.get("sectors") or [])[:15], + }, + "unavailable_data": [ + "政策原文与隔夜资讯尚未接入", + "汇率、利率和商品宏观序列当前不可用", + ], + } + ) + if dragon_tiger is not None: + context["dragon_tiger"] = dragon_tiger + return context + + def _mentor_market_matrix( + self, trade_date: str, universe: tuple[tuple[str, str], ...] + ) -> list[dict[str, Any]]: + ifind = getattr(self, "ifind", None) + if not ifind or not ifind.configured: + return [] + end = datetime.strptime(trade_date, "%Y%m%d") + start = (end - timedelta(days=45)).strftime("%Y%m%d") + names = {code: name for code, name in universe} + try: + rows = ifind.history( + list(names), ["close", "volume", "amount"], start, trade_date, cache_ttl=600 + ) + except IfindError: + return [] + grouped: dict[str, list[dict[str, Any]]] = {} + for row in rows: + code = str(row.get("thscode") or "").upper() + if code in names: + grouped.setdefault(code, []).append(row) + result = [] + for code, name in universe: + series = sorted(grouped.get(code, []), key=lambda row: str(row.get("time") or "")) + closes = [] + for row in series: + try: + close = float(row.get("close") or 0) + except (TypeError, ValueError): + continue + if close > 0: + closes.append(close) + if not closes: + continue + def period_return(days: int) -> float | None: + if len(closes) <= days or closes[-days - 1] <= 0: + return None + return round((closes[-1] / closes[-days - 1] - 1) * 100, 2) + previous = closes[-2] if len(closes) > 1 else 0 + result.append( + { + "code": code, + "name": name, + "close": round(closes[-1], 3), + "change": round((closes[-1] / previous - 1) * 100, 2) if previous else None, + "return_5d": period_return(5), + "return_10d": period_return(10), + "return_20d": period_return(20), + "latest_amount": series[-1].get("amount") if series else None, + } + ) + return result + def run_screener(self, payload: dict[str, Any]) -> dict[str, Any]: trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat())) regime = str(payload.get("regime") or "") @@ -3118,6 +3425,65 @@ class DashboardService: ) return result + def get_hot_money_profiles(self, force: bool = False) -> dict[str, Any]: + cache_kind = "hot_money_profiles_v1" + cache_key = "directory" + cached = self.database.get_data_snapshot(cache_kind, cache_key) + if cached and not force: + cached["meta"] = {**cached.get("meta", {}), "cached": True} + return cached + if self.configured: + try: + payload = TushareClient(self.token).hot_money_profiles() + except TushareError: + if cached: + cached["meta"] = { + **cached.get("meta", {}), + "cached": True, + "stale": True, + "notice": "名录暂未完成更新,当前展示最近一次收录结果。", + } + return cached + return { + "meta": { + "source": "unavailable", + "status": "unavailable", + "schema_version": 1, + "cached": False, + "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "notice": "游资名录暂不可用,请稍后重试。", + }, + "summary": { + "profile_count": 0, + "described_count": 0, + "organization_count": 0, + }, + "profiles": [], + } + payload["meta"]["cached"] = False + if payload.get("meta", {}).get("status") == "success": + self.database.save_data_snapshot(cache_kind, cache_key, "tushare", payload) + return payload + if cached: + cached["meta"] = {**cached.get("meta", {}), "cached": True} + return cached + return { + "meta": { + "source": "unavailable", + "status": "unavailable", + "schema_version": 1, + "cached": False, + "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "notice": "游资名录暂不可用,请联系管理员检查行情配置。", + }, + "summary": { + "profile_count": 0, + "described_count": 0, + "organization_count": 0, + }, + "profiles": [], + } + def get_dragon_tiger(self, trade_date: str, force: bool = False) -> dict[str, Any]: normalized_date = normalize_date(trade_date) cache_kind = "hot_money_detail_v3" @@ -3400,6 +3766,12 @@ class DashboardService: } for row in rows[-90:] ] + try: + chart_series = self.chart_data.board_daily(identifier, resolved_date, 90) + if chart_series: + series = chart_series + except (AttributeError, ChartDataError): + pass latest = series[-1] if series else {} snapshot_is_current = str(snapshot.get("trade_date") or "").replace("-", "") == resolved_date change = float( @@ -3407,6 +3779,8 @@ class DashboardService: if snapshot_is_current and snapshot.get("change") is not None else latest.get("change") or 0 ) + if latest.get("realtime"): + change = float(latest.get("change") or 0) turnover_rate = float( snapshot.get("turnover_rate") if snapshot_is_current and snapshot.get("turnover_rate") is not None @@ -3492,6 +3866,21 @@ class DashboardService: } for row in rows[-90:] ] + try: + chart_series = self.chart_data.index_daily(str(basic["id"]), resolved_date, 90) + if chart_series: + series = chart_series + except (AttributeError, ChartDataError): + pass + latest = series[-1] if series else {} + latest_close = float(latest.get("close") or current.get("close") or 0) + latest_change = float(latest.get("change") or current.get("pct_chg") or 0) + + def series_return(days: int) -> float: + if len(series) <= days: + return 0.0 + previous = float(series[-days - 1].get("close") or 0) + return (latest_close / previous - 1) * 100 if previous > 0 else 0.0 return { "meta": { "trade_date": self._display_compact_date(str(current.get("trade_date") or resolved_date)), @@ -3500,14 +3889,14 @@ class DashboardService: "entity": { **basic, "type_label": SEARCH_TYPE_LABELS["index"], - "value": float(current.get("close") or 0), - "change": float(current.get("pct_chg") or 0), + "value": latest_close, + "change": latest_change, }, "series": series, "metrics": [ - {"label": "涨跌幅", "value": round(float(current.get("pct_chg") or 0), 2), "unit": "%", "tone": "change"}, - {"label": "近5日", "value": round(float(current.get("return_5d") or 0), 2), "unit": "%", "tone": "change"}, - {"label": "近20日", "value": round(float(current.get("return_20d") or 0), 2), "unit": "%", "tone": "change"}, + {"label": "涨跌幅", "value": round(latest_change, 2), "unit": "%", "tone": "change"}, + {"label": "近5日", "value": round(series_return(5), 2), "unit": "%", "tone": "change"}, + {"label": "近20日", "value": round(series_return(20), 2), "unit": "%", "tone": "change"}, {"label": "成交额", "value": round(float(current.get("amount_billion") or 0), 2), "unit": "亿"}, ], } @@ -3584,22 +3973,30 @@ class DashboardService: self, payload: dict[str, Any], code: str, requested_date: str ) -> dict[str, Any]: result = copy.deepcopy(payload) + try: + result["prices"] = self.chart_data.stock_daily(code, requested_date, 90) + result["meta"] = {**(result.get("meta") or {}), "chart_source": "market_chart"} + except (AttributeError, ChartDataError): + pass actual_date = self._stock_detail_bar_date(result) if actual_date: result["meta"] = { **(result.get("meta") or {}), "trade_date": f"{actual_date[:4]}-{actual_date[4:6]}-{actual_date[6:]}", } - if self.configured: - client = TushareClient(self.token) - now = datetime.now().astimezone() - today = now.strftime("%Y%m%d") - should_merge = ( - requested_date == today - and actual_date < today - and now.time().replace(tzinfo=None) >= dt_time(9, 15) - ) - if should_merge: + now = datetime.now().astimezone() + today = now.strftime("%Y%m%d") + should_merge = ( + requested_date == today + and actual_date <= today + and now.time().replace(tzinfo=None) >= dt_time(9, 15) + ) + if should_merge: + quote = self._ifind_realtime_stock_quote(code) + if quote: + self._merge_realtime_stock_detail(result, quote, requested_date) + elif self.configured and actual_date < today: + client = TushareClient(self.token) try: resolved_date, _ = client.resolve_trade_context(requested_date) if resolved_date == today: @@ -3609,6 +4006,42 @@ class DashboardService: pass return self._enrich_stock_detail(result) + def _ifind_realtime_stock_quote(self, code: str) -> dict[str, Any] | None: + ifind = getattr(self, "ifind", None) + if not ifind or not ifind.configured: + return None + try: + rows = ifind.real_time( + tushare_code(code), + [ + "open", "high", "low", "latest", "preClose", + "volume", "amount", "turnoverRatio", + ], + cache_ttl=10, + ) + except IfindError: + return None + row = rows[0] if rows else {} + price = float(row.get("latest") or 0) + previous_close = float(row.get("preClose") or 0) + if price <= 0: + return None + change = (price / previous_close - 1) * 100 if previous_close > 0 else 0.0 + stock = self._stock_identity(code, date.today().strftime("%Y%m%d")) + return { + "name": stock[0], + "sector": stock[1], + "price": price, + "open": float(row.get("open") or price), + "high": float(row.get("high") or price), + "low": float(row.get("low") or price), + "change": round(change, 4), + "volume": float(row.get("volume") or 0), + "volume_unit": "lots", + "amount_billion": float(row.get("amount") or 0) / 100_000_000, + "turnover_rate": float(row.get("turnoverRatio") or 0), + } + @staticmethod def _merge_realtime_stock_detail( payload: dict[str, Any], quote: dict[str, Any], trade_date: str @@ -3621,7 +4054,7 @@ class DashboardService: "low": quote["low"], "close": quote["price"], "change": quote["change"], - "volume": quote["volume"] / 100, + "volume": quote["volume"] if quote.get("volume_unit") == "lots" else quote["volume"] / 100, "amount_billion": quote["amount_billion"], "realtime": True, } @@ -3654,7 +4087,9 @@ class DashboardService: self, code: str, trade_date: str, force: bool = False ) -> dict[str, Any]: code = validate_stock_code(code) - detail = self.get_stock_detail(code, trade_date, force) + # Hover previews deliberately follow the latest market day, independent + # from the review date selected by the page. + detail = self.get_stock_detail(code, date.today().strftime("%Y%m%d"), force) detail_meta = detail.get("meta") or {} resolved_date = str(detail_meta.get("trade_date") or trade_date) intraday_points: list[dict[str, Any]] = [] @@ -3758,6 +4193,11 @@ class DashboardService: def _apply_reason_overrides(self, dashboard: dict[str, Any]) -> dict[str, Any]: trade_date = str(dashboard.get("meta", {}).get("trade_date", "")).replace("-", "") + enrichment = self.database.get_data_snapshot("ifind_event_enrichment_v1", trade_date) + if enrichment: + self._merge_ifind_event_enrichment(dashboard, enrichment) + else: + self._schedule_ifind_event_enrichment(trade_date) overrides = self.database.reason_overrides(trade_date) if not overrides: return dashboard @@ -3768,6 +4208,122 @@ class DashboardService: row["reason_source"] = "manual" return dashboard + def _schedule_ifind_event_enrichment(self, trade_date: str) -> None: + ifind = getattr(self, "ifind", None) + if not ifind or not ifind.configured or not re.fullmatch(r"\d{8}", trade_date): + return + now = datetime.now().astimezone() + if trade_date == now.strftime("%Y%m%d") and now.time().replace(tzinfo=None) < dt_time(15, 0): + return + thread = threading.Thread( + target=self._refresh_ifind_event_enrichment, + args=(trade_date,), + name=f"ifind-event-{trade_date}", + daemon=True, + ) + thread.start() + + def _refresh_ifind_event_enrichment(self, trade_date: str) -> None: + if not self._ifind_event_lock.acquire(blocking=False): + return + try: + if self.database.get_data_snapshot("ifind_event_enrichment_v1", trade_date): + return + ifind = getattr(self, "ifind", None) + if not ifind or not ifind.configured: + return + current = datetime.strptime(trade_date, "%Y%m%d") + display_date = f"{current.year}年{current.month}月{current.day}日" + requests = { + "limits": ( + f"{display_date}涨停股票,股票代码、股票简称、涨停原因、" + "首次涨停时间、最终涨停时间、开板次数" + ), + "broken": ( + f"{display_date}曾涨停但收盘未涨停的股票,股票代码、股票简称、" + "涨停原因、首次涨停时间、开板次数" + ), + "down_limits": ( + f"{display_date}跌停股票,股票代码、股票简称、跌停原因" + ), + } + result: dict[str, Any] = { + "trade_date": trade_date, + "generated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "limits": {}, "broken": {}, "down_limits": {}, "partial": False, + } + for kind, query in requests.items(): + try: + rows = ifind.wencai(query, "stock", cache_ttl=900) + except IfindError: + result["partial"] = True + continue + for raw in rows: + code = self._ifind_row_code(raw) + if not code: + continue + reason_tokens = ( + ("跌停原因", "风险线索", "原因") + if kind == "down_limits" + else ("涨停原因类别", "涨停原因", "触板逻辑", "原因") + ) + reason = str(self._ifind_field(raw, reason_tokens) or "").strip() + first_time = self._normalize_ifind_event_time( + self._ifind_field(raw, ("首次涨停时间", "首次触板时间", "首次封板时间")) + ) + last_time = self._normalize_ifind_event_time( + self._ifind_field(raw, ("最终涨停时间", "最后涨停时间", "最后封板时间")) + ) + open_times = self._ifind_field(raw, ("开板次数", "打开涨停次数")) + try: + open_count = max(0, int(float(open_times))) if open_times not in (None, "") else None + except (TypeError, ValueError): + open_count = None + result[kind][code] = { + "reason": reason, + "first_time": first_time, + "last_time": last_time, + "open_times": open_count, + } + if any(result[kind] for kind in ("limits", "broken", "down_limits")): + self.database.save_data_snapshot( + "ifind_event_enrichment_v1", trade_date, "ifind", result + ) + finally: + self._ifind_event_lock.release() + + @staticmethod + def _normalize_ifind_event_time(value: Any) -> str: + text = str(value or "").strip() + match = re.search(r"(?:^|\s)(\d{1,2}:\d{2}(?::\d{2})?)(?:$|\s)", text) + if not match: + match = re.search(r"(? None: + for kind in ("limits", "broken", "down_limits"): + records = enrichment.get(kind) or {} + for row in dashboard.get(kind) or []: + event = records.get(str(row.get("code") or "")) or {} + reason = str(event.get("reason") or "").strip() + if reason: + row["reason"] = reason + row["reason_source"] = "market_event" + if event.get("first_time"): + row["first_time"] = event["first_time"] + if event.get("last_time"): + row["last_time"] = event["last_time"] + if event.get("open_times") is not None: + row["open_times"] = event["open_times"] + def _apply_seat_aliases(self, payload: dict[str, Any]) -> dict[str, Any]: aliases = self.database.list_seat_aliases() result = dict(payload) @@ -4094,6 +4650,17 @@ class RequestHandler(BaseHTTPRequestHandler): except ValueError as exc: self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) return + if parsed.path == "/api/dragon-tiger/profiles": + query = parse_qs(parsed.query) + try: + self.send_json( + SERVICE.get_hot_money_profiles( + query.get("force", ["0"])[0] == "1" + ) + ) + except ValueError as exc: + self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + return if parsed.path == "/api/search": query = parse_qs(parsed.query) search_query = query.get("q", [""])[0] diff --git a/static/app.js b/static/app.js index 2fd1ac5..41794df 100644 --- a/static/app.js +++ b/static/app.js @@ -17,6 +17,9 @@ const HEART_BREATH_PREPARE_MS = 1_000; const HEART_BREATH_CYCLE_MS = HEART_BREATH_INHALE_MS + HEART_BREATH_HOLD_MS + HEART_BREATH_EXHALE_MS; const HEART_BREATH_ACTIVE_MS = HEART_BREATH_CYCLE_MS * 5; const HEART_BREATH_TOTAL_MS = HEART_BREATH_PREPARE_MS + HEART_BREATH_ACTIVE_MS; +const THEME_STORAGE_KEY = "xiaobaiTheme"; +let activeThemeTransition = null; +let themeSwitchSequence = 0; const state = { user: null, @@ -40,9 +43,13 @@ const state = { yesterdaySortDirection: "desc", activeView: "limitPool", dragonTiger: null, + dragonViewMode: "daily", dragonFilter: "all", dragonQuery: "", selectedDragonTraderId: "", + hotMoneyProfiles: null, + hotMoneyProfileQuery: "", + selectedHotMoneyProfileId: "", rotationHistory: null, rotationHistoryKey: "", rotationSelectedSector: "", @@ -101,6 +108,7 @@ const state = { screenerRunningMode: "", screenerResults: { smart: null, curated: null, quant: null }, screenerResultContexts: { smart: null, curated: null, quant: null }, + screenerResultStore: {}, screenerTracking: null, screenerMode: ["smart", "curated", "quant"].includes(localStorage.getItem("xiaobaiScreenerMode")) ? localStorage.getItem("xiaobaiScreenerMode") @@ -205,6 +213,7 @@ let heartHoldAnimationFrame = 0; let heartCastingBusy = false; let heartDustAnimationFrame = 0; let heartDustParticles = []; +let heartIncenseAnimation = null; const heartCoinRotations = [0, 0, 0]; const MARKET_VIEWS = new Set([ "limitPool", @@ -294,7 +303,116 @@ window.addEventListener("resize", () => { document.addEventListener("DOMContentLoaded", initialize); +function syncThemeControl() { + const theme = document.documentElement.dataset.theme === "dark" ? "dark" : "light"; + const button = document.querySelector("#themeToggle"); + if (!button) return; + const dark = theme === "dark"; + const label = dark ? "切换到日间模式" : "切换到夜间模式"; + button.title = label; + button.setAttribute("aria-label", label); + button.setAttribute("aria-pressed", String(dark)); + button.querySelector("i")?.setAttribute("data-lucide", dark ? "sun" : "moon"); +} + +function clearThemeTransitionEffects() { + document.querySelectorAll(".row-enter, .row-pending, .view-entering").forEach((element) => { + element.classList.remove("row-enter", "row-pending", "view-entering"); + element.style.removeProperty("--row-delay"); + }); +} + +function redrawThemeSensitiveVisuals() { + if (!elements.stockPreview.hidden && state.stockPreviewPayload) { + selectStockPreviewChart(state.stockPreviewChart); + } + if (elements.stockDialog.open) { + if (state.stockDetailChartMode === "intraday" && state.stockDetailIntraday?.points?.length) { + drawIntradayCanvas( + elements.priceChart, + state.stockDetailIntraday.points, + [], + state.stockDetailIntraday.meta?.previous_close, + ); + } else if (state.stockDetail?.prices) drawPriceChart(state.stockDetail.prices); + } + if (elements.entityDetailDialog.open) { + if (state.entityDetailChartMode === "intraday" && state.entityDetailIntraday?.points?.length) { + drawIntradayCanvas( + elements.entityDetailChart, + state.entityDetailIntraday.points, + [], + state.entityDetailIntraday.meta?.previous_close, + ); + } else if (state.entityDetailPayload?.series) { + drawEntityDetailChart(state.entityDetailPayload.series); + } + } + if (state.activeView === "sentimentCycleView" && state.sentimentHistory) { + drawSentimentTrendChart(state.sentimentHistory.rows || []); + } + if (state.activeView === "themeLibraryView" && state.themeDetail?.series) { + drawEntityDetailChart(state.themeDetail.series, elements.themeDetailChart); + } + if (state.activeView === "heavenView") { + if (state.heavenPanel === "fortune" && state.heavenSetup?.field) { + renderQiFieldCanvas(state.heavenSetup.field.balance || [], { intro: false }); + drawQiUseConnections(false); + } + if (state.heavenPanel === "heart") startHeartDust(); + } +} + +function commitTheme(normalized, persist) { + document.documentElement.dataset.theme = normalized; + document.documentElement.style.colorScheme = normalized; + if (persist) { + try { + localStorage.setItem(THEME_STORAGE_KEY, normalized); + } catch (_error) { + // The selected theme still applies for the current page when storage is unavailable. + } + } + syncThemeControl(); + refreshIcons(); + redrawThemeSensitiveVisuals(); +} + +function applyTheme(theme, persist = true) { + const normalized = theme === "dark" ? "dark" : "light"; + const root = document.documentElement; + if (root.dataset.theme === normalized) { + commitTheme(normalized, persist); + return; + } + const sequence = ++themeSwitchSequence; + activeThemeTransition?.skipTransition?.(); + clearThemeTransitionEffects(); + root.classList.add("theme-switching"); + + const update = () => commitTheme(normalized, persist); + const finish = () => { + if (sequence !== themeSwitchSequence) return; + clearThemeTransitionEffects(); + root.classList.remove("theme-switching"); + activeThemeTransition = null; + }; + const reducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + if (!reducedMotion && typeof document.startViewTransition === "function") { + activeThemeTransition = document.startViewTransition(update); + activeThemeTransition.finished.then(finish, finish); + return; + } + update(); + requestAnimationFrame(() => requestAnimationFrame(finish)); +} + +function toggleTheme() { + applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark"); +} + async function initialize() { + syncThemeControl(); refreshIcons(); initializeApplicationShell(); const searchParams = new URLSearchParams(window.location.search); @@ -435,7 +553,15 @@ function bindEvents() { button.addEventListener("click", () => selectAuthMode(button.dataset.authMode)); }); document.querySelector("#authForm").addEventListener("submit", submitAuthForm); - document.querySelector("#refreshButton").addEventListener("click", () => loadDashboard(false)); + document.querySelector("#refreshButton").addEventListener("click", async (event) => { + const button = event.currentTarget; + button.disabled = true; + try { + await loadDashboard(false, false, false); + } finally { + button.disabled = false; + } + }); document.querySelector("#syncButton").addEventListener("click", startAdminRefresh); elements.tradeDate.addEventListener("change", () => { state.dashboardRequestSequence += 1; @@ -482,6 +608,7 @@ function bindEvents() { toggleHeaderCommandMenu(); }); document.querySelector("#globalSearchButton").addEventListener("click", openGlobalSearch); + document.querySelector("#themeToggle").addEventListener("click", toggleTheme); document.querySelector("#alertButton").addEventListener("click", openAlerts); document.querySelector("#assistantButton").addEventListener("click", openReviewAssistant); document.querySelector("#closeAssistantDialog").addEventListener("click", () => elements.assistantDialog.close()); @@ -719,10 +846,19 @@ function bindEvents() { renderPopularityTable(); }); }); - document.querySelector("#dragonRefreshButton").addEventListener("click", () => loadDragonTiger(true)); + document.querySelector("#dragonRefreshButton").addEventListener("click", () => { + if (state.dragonViewMode === "profiles") loadHotMoneyProfiles(true); + else loadDragonTiger(true); + }); document.querySelector("#dragonEmptyRefreshButton").addEventListener("click", () => loadDragonTiger(true)); document.querySelector("#dragonPreviousButton").addEventListener("click", () => shiftDate(-1)); - document.querySelector("#dragonExportButton").addEventListener("click", exportDragonTiger); + document.querySelector("#dragonExportButton").addEventListener("click", () => { + if (state.dragonViewMode === "profiles") exportHotMoneyProfiles(); + else exportDragonTiger(); + }); + document.querySelectorAll("[data-dragon-view-mode]").forEach((button) => { + button.addEventListener("click", () => selectDragonViewMode(button.dataset.dragonViewMode)); + }); document.querySelector("#dragonSearch").addEventListener("input", (event) => { state.dragonQuery = event.target.value.trim().toLowerCase(); renderDragonTraderList(); @@ -736,6 +872,16 @@ function bindEvents() { renderDragonTraderList(); }); }); + document.querySelector("#hotMoneyProfileSearch").addEventListener("input", (event) => { + state.hotMoneyProfileQuery = event.target.value.trim().toLocaleLowerCase("zh-CN"); + renderHotMoneyProfiles(); + }); + document.querySelector("#hotMoneyProfileList").addEventListener("click", (event) => { + const button = event.target.closest("[data-hot-money-profile]"); + if (!button) return; + state.selectedHotMoneyProfileId = button.dataset.hotMoneyProfile; + renderHotMoneyProfiles(); + }); document.querySelector("#journalForm").addEventListener("submit", saveJournal); document.querySelector("#journalDate").addEventListener("change", populateJournalForm); document.querySelector("#openWatchlistDialog").addEventListener("click", () => openWatchlistDialog()); @@ -899,49 +1045,22 @@ function bindEvents() { document.querySelector("#membershipSettingsForm").addEventListener("submit", saveMembershipSettings); document.querySelector("#addPlatformModel").addEventListener("click", addPlatformModel); document.querySelector("#adminRefreshButton").addEventListener("click", startAdminRefresh); - window.addEventListener("resize", () => { - if (elements.stockDialog.open) { - if (state.stockDetailChartMode === "intraday" && state.stockDetailIntraday?.points?.length) { - drawIntradayCanvas( - elements.priceChart, - state.stockDetailIntraday.points, - [], - state.stockDetailIntraday.meta?.previous_close, - ); - } else if (state.stockDetail?.prices) drawPriceChart(state.stockDetail.prices); - } - if (elements.entityDetailDialog.open) { - if (state.entityDetailChartMode === "intraday" && state.entityDetailIntraday?.points?.length) { - drawIntradayCanvas( - elements.entityDetailChart, - state.entityDetailIntraday.points, - [], - state.entityDetailIntraday.meta?.previous_close, - ); - } else if (state.entityDetailPayload?.series) { - drawEntityDetailChart(state.entityDetailPayload.series); - } - } - if (state.activeView === "sentimentCycleView" && state.sentimentHistory) { - drawSentimentTrendChart(state.sentimentHistory.rows || []); - } - if (state.activeView === "themeLibraryView" && state.themeDetail?.series) { - drawEntityDetailChart(state.themeDetail.series, elements.themeDetailChart); - } - }); + window.addEventListener("resize", redrawThemeSensitiveVisuals); initializeAutoTableSorting(); } -async function loadDashboard(force = false, background = false) { +async function loadDashboard(force = false, background = false, showOverlay = true) { const requestedDate = elements.tradeDate.value; if (state.dashboardLoading && state.dashboardRequestDate === requestedDate) return; state.dashboardLoading = true; state.dashboardRequestDate = requestedDate; const requestSequence = ++state.dashboardRequestSequence; if (force) stockPreviewCache.clear(); - if (!background) { - setLoading(true, "正在读取本地复盘数据"); - setStatus("正在读取复盘数据"); + if (!background && showOverlay) { + setLoading(true, "正在加载市场数据"); + setStatus("正在加载市场数据"); + } else if (!background) { + setStatus("正在刷新行情"); } try { const query = new URLSearchParams({ trade_date: elements.tradeDate.value }); @@ -963,7 +1082,7 @@ async function loadDashboard(force = false, background = false) { if (requestSequence === state.dashboardRequestSequence) { state.dashboardLoading = false; state.dashboardRequestDate = ""; - if (!background) setLoading(false); + if (!background && showOverlay) setLoading(false); updateDateButtons(); } } @@ -1199,8 +1318,11 @@ function drawSentimentTrendChart(rows, progress = 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; @@ -1212,13 +1334,13 @@ function drawSentimentTrendChart(rows, progress = 1) { context.textBaseline = "middle"; for (let score = 0; score <= 100; score += 20) { const lineY = y(score); - context.strokeStyle = score === 40 || score === 80 ? "#ccd7de" : "#e6ebef"; + 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 = "#758590"; + context.fillStyle = palette.axis; context.fillText(String(score), padding.left - 8, lineY); } @@ -1232,9 +1354,9 @@ function drawSentimentTrendChart(rows, progress = 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 = "rgba(224, 69, 54, .05)"; + context.fillStyle = palette.alertArea; context.fillRect(startX, padding.top, width - padding.right - startX, chartHeight); - context.fillStyle = "#e04536"; + context.fillStyle = palette.up; context.font = '10px "Microsoft YaHei UI", sans-serif'; context.textAlign = "center"; context.textBaseline = "top"; @@ -1251,7 +1373,7 @@ function drawSentimentTrendChart(rows, progress = 1) { if (index === 0) context.moveTo(x(index), y(score)); else context.lineTo(x(index), y(score)); }); - context.strokeStyle = "#d1d5db"; + context.strokeStyle = palette.movingAverage; context.lineWidth = 1.5; context.setLineDash([5, 4]); context.stroke(); @@ -1267,7 +1389,7 @@ function drawSentimentTrendChart(rows, progress = 1) { context.lineTo(x(rows.length - 1), padding.top + chartHeight); context.lineTo(x(0), padding.top + chartHeight); context.closePath(); - context.fillStyle = "rgba(37, 99, 235, .07)"; + context.fillStyle = palette.area; context.fill(); context.beginPath(); @@ -1277,7 +1399,7 @@ function drawSentimentTrendChart(rows, progress = 1) { if (index === 0) context.moveTo(pointX, pointY); else context.lineTo(pointX, pointY); }); - context.strokeStyle = "#1268c4"; + context.strokeStyle = palette.line; context.lineWidth = 2.5; context.lineJoin = "round"; context.lineCap = "round"; @@ -1286,9 +1408,9 @@ function drawSentimentTrendChart(rows, progress = 1) { 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) ? "#e04536" : row.phase === "修复" ? "#f59e0b" : "#2563eb"; + context.fillStyle = ["退潮", "冰点"].includes(row.phase) ? palette.up : row.phase === "修复" ? palette.repair : palette.line; context.fill(); - context.strokeStyle = "#fff"; + context.strokeStyle = palette.background; context.lineWidth = 1.5; context.stroke(); }); @@ -1297,7 +1419,7 @@ function drawSentimentTrendChart(rows, progress = 1) { const labelStep = Math.max(1, Math.ceil(rows.length / 6)); context.textAlign = "center"; context.textBaseline = "top"; - context.fillStyle = "#758590"; + 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); @@ -2451,6 +2573,128 @@ function renderPopularityTable() { document.querySelector("#popularityEmpty").hidden = rows.length > 0; } +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]) => `${label}${value}`).join(""); + + const list = document.querySelector("#hotMoneyProfileList"); + list.innerHTML = visible.length ? visible.map((profile, index) => ` + `).join("") : ` +
+ + ${profiles.length ? "没有符合条件的游资档案" : "游资名录暂不可用"} +
`; + + const detail = document.querySelector("#hotMoneyProfileDetail"); + if (!selected) { + detail.innerHTML = ` +
+ + ${profiles.length ? "选择一位游资查看档案" : "暂无可展示的游资档案"} +
`; + } else { + const organizations = selected.organizations || []; + detail.innerHTML = ` +
+ ${escapeHtml(selected.name.slice(0, 2))} +
+ 游资档案 +

${escapeHtml(selected.name)}

+ ${organizations.length ? `关联 ${organizations.length} 个公开席位` : "暂无关联席位"} +
+
+
+

人物简介

+

${escapeHtml(selected.description || "名录暂未收录该游资的公开简介。")}

+
+
+
+

关联营业部

+ ${organizations.length} 个 +
+
+ ${organizations.length ? organizations.map((organization) => ` + ${escapeHtml(organization)} + `).join("") : '

名录暂未收录关联营业部。

'} +
+
+ ${payload.meta?.notice ? `

${escapeHtml(payload.meta.notice)}

` : ""}`; + } + refreshIcons(); +} + async function loadDragonTiger(force = false) { const requestedDate = elements.tradeDate.value; if ( @@ -2486,14 +2730,16 @@ function renderDragonTiger() { const payload = state.dragonTiger; if (!payload) return; const summary = payload.summary || {}; - setText("dragonDateLabel", `数据日期 ${payload.meta.trade_date}`); + 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); - document.querySelector("#dragonEmptyState").hidden = !showEmptyState; - document.querySelector("#dragonDailyContent").hidden = showEmptyState; + 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 || "该交易日"} 暂无龙虎榜明细`); @@ -3167,33 +3413,75 @@ function currentScreenerStrategy(mode) { return null; } -function screenerResultMatchesSelection(mode) { - const result = state.screenerResults[mode]; - const context = state.screenerResultContexts[mode]; - if (!result || !context) return false; - if (mode === "quant") return true; +function screenerResultContext(mode, result, { regime, strategyId = null, strategyName = "" } = {}) { + const normalizedMode = ["smart", "curated", "quant"].includes(mode) ? mode : "smart"; + return { + mode: normalizedMode, + regime: regime || result?.meta?.regime || state.selectedRegime, + strategyName: strategyName || result?.meta?.strategy_name || "", + strategyKey: screenerStrategyKey( + strategyId, + strategyName || result?.meta?.strategy_name || "", + ), + }; +} + +function screenerResultKey(context) { + if (!context) return ""; + if (context.mode === "quant") return "quant"; + if (context.mode === "curated") return JSON.stringify(["curated", context.strategyKey]); + return JSON.stringify(["smart", context.regime, context.strategyKey]); +} + +function selectedScreenerResultKey(mode) { + const normalizedMode = ["smart", "curated", "quant"].includes(mode) ? mode : "smart"; + if (normalizedMode === "quant") return "quant"; const strategy = currentScreenerStrategy(mode); - if (!strategy || (mode === "smart" && context.regime !== state.selectedRegime)) return false; - return context.strategyKey === screenerStrategyKey(strategy.id, strategy.name); + if (!strategy) return ""; + return screenerResultKey(screenerResultContext(normalizedMode, null, { + regime: state.selectedRegime, + strategyId: strategy.id, + strategyName: strategy.name, + })); +} + +function activeScreenerResultEntry(mode = state.screenerMode) { + const key = selectedScreenerResultKey(mode); + return key ? state.screenerResultStore[key] || null : null; +} + +function screenerResultMatchesSelection(mode) { + return Boolean(activeScreenerResultEntry(mode)); } function activeScreenerResult(mode = state.screenerMode) { - return screenerResultMatchesSelection(mode) ? state.screenerResults[mode] : null; + return activeScreenerResultEntry(mode)?.result || null; } function activeScreenerResultContext(mode = state.screenerMode) { - return activeScreenerResult(mode) ? state.screenerResultContexts[mode] : null; + return activeScreenerResultEntry(mode)?.context || null; } -function setScreenerResult(mode, result, { regime, strategyId = null, strategyName = "" } = {}) { +function storeScreenerResult( + mode, + result, + { regime, strategyId = null, strategyName = "" } = {}, + updateLatest = true, +) { const normalizedMode = ["smart", "curated", "quant"].includes(mode) ? mode : "smart"; - state.screenerResults[normalizedMode] = result || null; - state.screenerResultContexts[normalizedMode] = result ? { - mode: normalizedMode, - regime: regime || result.meta?.regime || state.selectedRegime, - strategyName: strategyName || result.meta?.strategy_name || "", - strategyKey: screenerStrategyKey(strategyId, strategyName || result.meta?.strategy_name || ""), - } : null; + const context = result + ? screenerResultContext(normalizedMode, result, { regime, strategyId, strategyName }) + : null; + const key = screenerResultKey(context); + if (key && result) state.screenerResultStore[key] = { result, context }; + if (updateLatest) { + state.screenerResults[normalizedMode] = result || null; + state.screenerResultContexts[normalizedMode] = context; + } +} + +function setScreenerResult(mode, result, options = {}) { + storeScreenerResult(mode, result, options, true); } function applyScreenerSetup(payload, requestKey) { @@ -3201,6 +3489,7 @@ function applyScreenerSetup(payload, requestKey) { if (dateChanged) { state.screenerResults = { smart: null, curated: null, quant: null }; state.screenerResultContexts = { smart: null, curated: null, quant: null }; + state.screenerResultStore = {}; } state.screenerSetup = payload; state.screenerSetupKey = requestKey; @@ -3232,6 +3521,19 @@ function applyScreenerSetup(payload, requestKey) { )?.id || curatedStrategies[0]?.id || 0; } + for (const result of [...(payload.recent_results || [])].reverse()) { + const mode = ["smart", "curated", "quant"].includes(result.meta?.mode) + ? result.meta.mode + : "smart"; + const strategies = mode === "curated" ? curatedStrategies : smartStrategies; + const strategy = strategies.find((item) => item.name === result.meta?.strategy_name); + storeScreenerResult(mode, result, { + regime: result.meta?.regime || payload.regime.id, + strategyId: strategy?.id, + strategyName: result.meta?.strategy_name || strategy?.name || "", + }, false); + } + for (const mode of ["smart", "curated", "quant"]) { if (state.screenerResults[mode] || !latestResults[mode]) continue; const result = latestResults[mode]; @@ -4203,14 +4505,7 @@ function renderMentorBadges(mentor, expanded = false) { } const grade = mentor.evidence?.grade; if (grade) { - const label = expanded && mentor.evidence?.label ? `${grade} · ${mentor.evidence.label}` : grade; - badges.push(`${escapeHtml(label)}`); - } - const score = mentor.quality?.score; - const total = mentor.quality?.total; - if (Number.isInteger(score) && Number.isInteger(total)) { - const conditional = mentor.quality?.status === "conditional"; - badges.push(`${score}/${total}`); + badges.push(`${escapeHtml(grade)}`); } return badges.join(""); } @@ -4582,8 +4877,20 @@ function resetHeavenCalibration() { function selectHeavenPanel(panel, updateUrl = false) { state.heavenPanel = panel; + if (state.heavenSetup) { + const calendarDate = state.heavenSetup.calendar_date || state.heavenSetup.trade_date; + const dateLabel = panel === "trend" + ? (calendarDate === state.heavenSetup.trade_date + ? `行情 ${displayCompactDate(state.heavenSetup.trade_date)}` + : `行情 ${displayCompactDate(state.heavenSetup.trade_date)} · 历法 ${displayCompactDate(calendarDate)}`) + : `历法 ${displayCompactDate(calendarDate)}`; + setText("heavenDataDate", dateLabel); + } document.querySelectorAll("[data-heaven-panel]").forEach((button) => { - button.classList.toggle("active", button.dataset.heavenPanel === panel); + const active = button.dataset.heavenPanel === panel; + button.classList.toggle("active", active); + button.classList.toggle("on", active); + button.setAttribute("aria-current", active ? "page" : "false"); }); document.querySelectorAll(".heaven-panel").forEach((item) => { item.classList.toggle("active-heaven-panel", item.id === `heaven${capitalize(panel)}Panel`); @@ -4599,8 +4906,6 @@ function selectHeavenPanel(panel, updateUrl = false) { } if (panel === "heart") { initializeHeartAtmosphere(); - showHeartRitualCurtain(); - startHeartDust(); setHeartLamp(state.heartStage); } else { stopHeartDust(); @@ -4633,13 +4938,7 @@ function showHeartRitualCurtain() { function renderHeavenWorkspace() { const setup = state.heavenSetup; if (!setup) return; - const calendarDate = setup.calendar_date || setup.trade_date; - setText( - "heavenDataDate", - calendarDate === setup.trade_date - ? `数据日期 ${displayCompactDate(setup.trade_date)}` - : `行情 ${displayCompactDate(setup.trade_date)} · 历法 ${displayCompactDate(calendarDate)}`, - ); + initializeWentianV2Atmosphere(); renderMarketHexagram(setup.chart); renderFivePhaseField(setup.field); renderPersonalFortune(); @@ -4647,6 +4946,88 @@ function renderHeavenWorkspace() { selectHeavenPanel(state.heavenPanel); } +function buildWentianStars(id, count) { + const element = document.getElementById(id); + if (!element || element.children.length) return; + element.innerHTML = Array.from({ length: count }, () => { + const size = (Math.random() * 1.6 + 0.8).toFixed(1); + return ``; + }).join(""); +} + +function buildWentianBagua(svg) { + if (!svg || svg.children.length) return; + const trigrams = ["乾", "兑", "离", "震", "巽", "坎", "艮", "坤"]; + let characters = ""; + let ticks = ""; + for (let index = 0; index < 8; index += 1) { + const angle = (index * 45 - 90) * Math.PI / 180; + const x = 150 + 129 * Math.cos(angle); + const y = 150 + 129 * Math.sin(angle); + characters += `${trigrams[index]}`; + } + for (let index = 0; index < 24; index += 1) { + const angle = (index * 15 - 90) * Math.PI / 180; + ticks += ``; + } + svg.innerHTML = `${characters}${ticks}`; +} + +function buildWentianFortuneOrbit(svg) { + if (!svg || svg.children.length) return; + const sixQi = ["厥阴木", "少阴火", "少阳火", "太阴土", "阳明金", "太阳水"]; + const movements = ["木运", "火运", "土运", "金运", "水运"]; + const polarText = (items, radius, fontSize, offset = -90) => items.map((label, index) => { + const degrees = offset + index * 360 / items.length; + const angle = degrees * Math.PI / 180; + const x = 150 + radius * Math.cos(angle); + const y = 150 + radius * Math.sin(angle); + return `${label}`; + }).join(""); + const ticks = Array.from({ length: 30 }, (_, index) => { + const angle = (index * 12 - 90) * Math.PI / 180; + const inner = index % 5 === 0 ? 96 : 101; + return ``; + }).join(""); + svg.innerHTML = `${polarText(sixQi, 132, 8.5)}${ticks}${polarText(movements, 70, 10)}五运六气`; +} + +function initializeWentianV2Atmosphere() { + buildWentianStars("stars", 90); + buildWentianStars("fortuneStars", 100); + buildWentianStars("heartStars", 110); + buildWentianBagua(document.querySelector("#baguaSvg")); + buildWentianFortuneOrbit(document.querySelector("#fortuneBagua")); + buildWentianBagua(document.querySelector("#heartBagua")); +} + +function renderCompactHexagrams(hexagram) { + const original = document.querySelector("#heavenOriginalHexLines"); + const changed = document.querySelector("#heavenChangedHexLines"); + if (!original || !changed) return; + if (!hexagram?.lines?.length) { + original.innerHTML = ""; + changed.innerHTML = ""; + setText("heavenOriginalHexName", "待定"); + setText("heavenChangedHexName", "待定"); + setText("heavenOriginalHexDetail", "六爻尚未齐备"); + setText("heavenChangedHexDetail", "待动爻化变"); + return; + } + const values = hexagram.lines.map((line) => number(line.value)); + const changedValues = values.map((value) => value === 6 ? 7 : value === 9 ? 8 : value); + const lines = (items, showMoving) => [...items].reverse().map((value) => { + const moving = showMoving && [6, 9].includes(value); + return `
${value % 2 ? "" : ""}
`; + }).join(""); + original.innerHTML = lines(values, true); + changed.innerHTML = lines(changedValues, false); + setText("heavenOriginalHexName", hexagram.name || "--"); + setText("heavenChangedHexName", hexagram.transformed?.name || "--"); + setText("heavenOriginalHexDetail", `${hexagram.outer_trigram || "--"}上 · ${hexagram.inner_trigram || "--"}下`); + setText("heavenChangedHexDetail", `${hexagram.transformed?.outer_trigram || "--"}上 · ${hexagram.transformed?.inner_trigram || "--"}下`); +} + function cancelHeavenPerformance() { heavenPerformanceToken += 1; state.heavenPerformanceActive = ""; @@ -4908,10 +5289,9 @@ function renderMarketHexagram(chart) { setText("heavenStockName", chart.stock.name || "--"); setText( "heavenStockSector", - chart.sector_taxonomy === "sw_l2" - ? `申万二级 · ${chart.sector}` - : chart.sector || "--", + chart.sector || "--", ); + setText("heavenStockTaxonomy", chart.sector_taxonomy === "sw_l2" ? "申万二级 ·" : "所属行业 ·"); renderHeavenLineChecks(chart); const interpretButton = document.querySelector("#interpretTrendButton"); @@ -4938,6 +5318,7 @@ function renderMarketHexagram(chart) { setText("marketMovementSummary", (chart.quality?.issues || []).join(";") || "等待有效行情数据"); setText("heavenMomentumScore", "--"); setText("heavenMomentumLabel", "数据未齐"); + renderCompactHexagrams(null); scoreMeter?.setAttribute("aria-valuenow", "0"); if (scoreNeedle) scoreNeedle.style.setProperty("--momentum-position", "50%"); document.querySelector("#marketHexagramLines").innerHTML = ""; @@ -4962,6 +5343,7 @@ function renderMarketHexagram(chart) { setText("marketHexagramName", `${chart.hexagram.outer_trigram}上${chart.hexagram.inner_trigram}下 · ${chart.hexagram.name}`); setText("marketTransformedName", chart.hexagram.transformed.name); + renderCompactHexagrams(chart.hexagram); setText("marketHexagramText", chart.hexagram.text); setText("marketMovementSummary", `${chart.movement.label}。${chart.movement.explanation}`); setText("heavenMomentumScore", `${chart.momentum_score > 0 ? "+" : ""}${chart.momentum_score}`); @@ -5082,7 +5464,24 @@ function renderQiFieldCanvas(balance, options = {}) { draw(performance.now()); } +function wentianClimateVerdict(field) { + const balance = field?.balance || []; + const dominant = balance[0]?.element; + const secondary = balance[1]?.element; + const tertiary = balance[2]?.element; + const pair = [dominant, secondary].filter(Boolean).sort().join(""); + const primary = { + 木火: "风火相煽", 木土: "风湿相搏", 木金: "风燥相激", 木水: "风寒相薄", + 土火: "湿热交蒸", 火金: "燥热相煽", 水火: "寒热相争", 土金: "燥湿相搏", + 土水: "寒湿交织", 水金: "寒燥相参", + }[pair] || ({ 木: "风木疏展", 火: "热火升明", 土: "湿滞偏重", 金: "燥金肃降", 水: "寒水潜藏" }[dominant] || "气机交会"); + const following = { 木: "风象暗动", 火: "热象内蕴", 土: "湿滞内结", 金: "燥气相参", 水: "寒意潜行" }[tertiary] + || ({ 木: "风象相随", 火: "热象相随", 土: "湿象相随", 金: "燥象相随", 水: "寒象相随" }[secondary] || "诸气相参"); + return `${primary} · ${following}`; +} + function renderFivePhaseField(field) { + if (!field) return; setText("fortuneLunarDate", `${field.date} · ${field.lunar_date}`); setText("fortunePillars", `${field.pillars.year}年 · ${field.pillars.month}月 · ${field.pillars.day}日`); const metrics = [ @@ -5109,7 +5508,7 @@ function renderFivePhaseField(field) { `).join(""); const human = field.human_field || {}; const dominantPhase = (field.balance || [])[0]; - setText("qiClimateKeyword", dominantPhase ? `${dominantPhase.element}气偏显` : "气场待察"); + setText("qiClimateKeyword", wentianClimateVerdict(field)); setText("qiClimateTone", (human.emotional_tendency || [])[0] || "留意当下身心反应"); setText("humanFieldSummary", human.summary || "--"); setText("humanEmotionList", (human.emotional_tendency || []).join(";") || "--"); @@ -5140,11 +5539,29 @@ function renderFivePhaseField(field) { setText("phaseSectorTitle", "五行行业归属"); setText("phaseSectorContext", "传统取象 · 手动归类优先"); renderQiUseMap(field); + renderFortuneSectorCatalog(field); renderSectorPhaseOverrides(state.heavenSetup?.sector_phase_overrides || []); setText("fortuneNotice", field.notice); renderHeavenInterpretation("fortune", state.heavenInterpretations.fortune); } +function renderFortuneSectorCatalog(field) { + const container = document.querySelector("#fortuneSectorGroups"); + if (!container) return; + const phaseOrder = new Map((field.balance || []).map((item, index) => [item.element, index])); + const canonical = { 木: 0, 火: 1, 土: 2, 金: 3, 水: 4 }; + const catalog = [...(field.sector_catalog || [])].sort((left, right) => ( + (canonical[left.element] ?? phaseOrder.get(left.element) ?? 99) + - (canonical[right.element] ?? phaseOrder.get(right.element) ?? 99) + )); + container.innerHTML = catalog.map((group) => ` +
+
${escapeHtml(group.element)}属性${number(group.count || group.industries?.length)} 类
+
    ${(group.industries || []).map((item) => `
  • ${escapeHtml(item.name)}
  • `).join("")}
+
+ `).join("") || '

行业五行归类尚未建立

'; +} + function renderQiUseMap(field) { const sourceContainer = document.querySelector("#qiUseSources"); const sectorContainer = document.querySelector("#phaseSectorList"); @@ -5329,43 +5746,20 @@ function renderPersonalFortune() { container.hidden = false; const tenGods = personal.ten_god_tendency || { favorable: [], caution: [] }; const elementTendency = personal.balance_tendency || { favorable: [], caution: [] }; - const preferenceTags = (items, emptyText = "--") => items.length - ? items.map((item) => `${escapeHtml(item)}`).join("") - : emptyText; + const preferenceTags = (items) => (items || []).map((item) => `${escapeHtml(item)}`).join("") || "--"; container.innerHTML = `
日主 - ${escapeHtml(personal.day_master.stem)} - ${escapeHtml(personal.day_master.element)} - ${escapeHtml(personal.day_master.strength)} + ${escapeHtml(personal.day_master?.stem || "--")} + ${escapeHtml(personal.day_master?.element || "--")} + ${escapeHtml(personal.day_master?.strength || "")}
-
- 十神喜恶 -
偏宜

${tenGods.favorable.map((item) => `${escapeHtml(item)}`).join("") || "--"}

-
偏慎

${tenGods.caution.map((item) => `${escapeHtml(item)}`).join("") || "--"}

-
-
- 五行喜忌 -
偏喜

${preferenceTags(elementTendency.favorable || [])}

-
偏忌

${preferenceTags(elementTendency.caution || [])}

-
+
十神喜恶
偏宜

${preferenceTags(tenGods.favorable)}

偏慎

${preferenceTags(tenGods.caution)}

+
五行喜忌
偏喜

${preferenceTags(elementTendency.favorable)}

偏忌

${preferenceTags(elementTendency.caution)}

-
-
- 当前作用 · 流年 ${escapeHtml(personal.current.ten_gods.year.stem)} · 流月 ${escapeHtml(personal.current.ten_gods.month.stem)} · 流日 ${escapeHtml(personal.current.ten_gods.day.stem)} - ${escapeHtml(personal.current.tone)} -

${escapeHtml(personal.current.operation_note)}

- ${escapeHtml(personal.balance_tendency.method)} -
-
- 查看个人五行结构 -
- ${personal.element_balance.map((item) => `
${escapeHtml(item.element)}${number(item.percent)}%
`).join("")} -
-
- `; + `; } function renderHexagramLines(containerId, lines, includeEvidence = false) { @@ -5770,9 +6164,19 @@ async function startHeartBreathing() { if (!await transitionHeartStage("breathing")) return; state.heartBreathingEndsAt = Date.now() + HEART_BREATH_TOTAL_MS; const ember = document.querySelector("#heartIncenseEmber"); + heartIncenseAnimation?.cancel(); ember?.classList.remove("is-burning"); if (ember) void ember.offsetWidth; ember?.classList.add("is-burning"); + heartIncenseAnimation = ember?.animate( + [{ top: "0%" }, { top: "100%" }], + { + duration: HEART_BREATH_ACTIVE_MS, + delay: HEART_BREATH_PREPARE_MS, + easing: "linear", + fill: "forwards", + }, + ) || null; updateBreathingDisplay(); state.heartTimer = setInterval(() => { state.heartSeconds = Math.max(0, Math.ceil((state.heartBreathingEndsAt - Date.now()) / 1000)); @@ -5934,7 +6338,6 @@ async function animateHeartCoins(results) { const duration = motionEnabled() ? 1500 + index * 160 : 10; const delay = motionEnabled() ? index * 150 : 0; coin.dataset.face = results[index] ? "front" : "back"; - coin.querySelector(".front").textContent = "字"; const spin = inner.animate( [{ transform: `rotateY(${current}deg)` }, { transform: `rotateY(${target}deg)` }], { duration, delay, easing: "cubic-bezier(.25,.55,.3,1)", fill: "forwards" }, @@ -5997,6 +6400,9 @@ function renderHeartStage() { document.querySelectorAll(".heart-stage").forEach((stage) => stage.classList.remove("is-leaving")); const activeStage = document.querySelector(`#${stageMap[state.heartStage]}`); activeStage.classList.add("active-heart-stage"); + document.querySelectorAll("[data-heart-step]").forEach((step) => { + step.classList.toggle("active", step.dataset.heartStep === state.heartStage); + }); setHeartLamp(state.heartStage); activateHeartRises(activeStage); if (state.heartStage === "breathing") { @@ -6032,7 +6438,7 @@ function renderHeartCasting() { rows.push(`
${LINE_POSITIONS_CLIENT[index]} - ${value ? hexagramLineGraphic(value) : ''} + ${value ? hexagramLineGraphic(value) : ''}
${value ? `${lineValueName(value)} · ${value}` : "未得"}
`); @@ -6047,12 +6453,6 @@ function renderHeartReveal() { setText("heartHexagramText", hexagram.text); renderHexagramLines("heartHexagramLines", hexagram.lines, false); document.querySelector("#heartHexagramLines").querySelectorAll(".hexagram-line-row").forEach((row) => row.classList.add("heart-reveal-line")); - document.querySelector("#heartLineTexts").innerHTML = hexagram.lines.map((line) => ` - - `).join(""); document.querySelector("#heartReveal").classList.remove("is-sequence-ready", "is-title-ready", "is-thought-typing", "is-thought-ready"); const prompt = document.querySelector("#heartFirstThoughtPrompt"); prompt.dataset.fullText = "看见卦象与爻辞后,心里升起的第一念是什么?"; @@ -6158,7 +6558,6 @@ function resetHeartCoins() { inner.style.transform = ""; coin.style.transform = ""; coin.dataset.face = ""; - coin.querySelector(".front").textContent = "观"; coin.querySelector(".heart-coin-ring").classList.remove("is-bursting"); }); const shell = document.querySelector(".heart-hexagram-shell"); @@ -6174,6 +6573,9 @@ async function resetHeartRitual() { state.heartThrows = []; state.heartHexagram = null; state.heavenInterpretations.heart = ""; + heartIncenseAnimation?.cancel(); + heartIncenseAnimation = null; + document.querySelector("#heartIncenseEmber")?.classList.remove("is-burning"); updateHeavenInterpretationControls(); state.heartRevealToken += 1; heartCastingBusy = false; @@ -6434,13 +6836,32 @@ function regimeLabel(regime) { return state.screenerSetup?.regimes?.find((item) => item.id === regime)?.label || regime; } -const CHART_BACKGROUND = "#fbfcfd"; -const CHART_UP_COLOR = "#c93f45"; -const CHART_DOWN_COLOR = "#087a55"; +function currentChartPalette() { + const style = getComputedStyle(document.documentElement); + const color = (token, fallback) => style.getPropertyValue(token).trim() || fallback; + return { + background: color("--chart-background", "#fbfcfd"), + grid: color("--chart-grid", "#e2e8ec"), + axis: color("--chart-axis", "#6c7983"), + zero: color("--chart-zero", "#aeb7c1"), + line: color("--chart-line", "#1d65c1"), + average: color("--chart-average", "#b7791f"), + up: color("--chart-up", "#c93f45"), + down: color("--chart-down", "#087a55"), + upVolume: color("--chart-up-volume", "rgba(201, 63, 69, .58)"), + downVolume: color("--chart-down-volume", "rgba(8, 122, 85, .58)"), + area: color("--chart-area", "rgba(37, 99, 235, .07)"), + alertArea: color("--chart-alert-area", "rgba(224, 69, 54, .05)"), + movingAverage: color("--chart-moving-average", "#d1d5db"), + repair: color("--chart-repair", "#f59e0b"), + ma10: color("--chart-ma-10", "#a76500"), + ma20: color("--chart-ma-20", "#626c78"), + }; +} -function drawCandlestick(context, x, item, priceY, candleWidth) { +function drawCandlestick(context, x, item, priceY, candleWidth, palette = currentChartPalette()) { const rising = number(item.close) >= number(item.open); - const color = rising ? CHART_UP_COLOR : CHART_DOWN_COLOR; + const color = rising ? palette.up : palette.down; const highY = priceY(item.high); const lowY = priceY(item.low); const openY = priceY(item.open); @@ -6461,7 +6882,7 @@ function drawCandlestick(context, x, item, priceY, candleWidth) { const bodyLeft = x - candleWidth / 2; if (rising) { - context.fillStyle = CHART_BACKGROUND; + context.fillStyle = palette.background; context.fillRect(bodyLeft, bodyTop, candleWidth, bodyHeight); context.strokeStyle = color; context.strokeRect(bodyLeft, bodyTop, candleWidth, bodyHeight); @@ -6485,9 +6906,10 @@ function drawPriceChart(prices) { 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 = CHART_BACKGROUND; + context.fillStyle = palette.background; context.fillRect(0, 0, width, height); const left = 48; @@ -6509,8 +6931,8 @@ function drawPriceChart(prices) { const step = plotWidth / prices.length; const candleWidth = clamp(step * 0.62, 2, 8); - context.strokeStyle = "#e2e8ec"; - context.fillStyle = "#6c7983"; + context.strokeStyle = palette.grid; + context.fillStyle = palette.axis; context.font = "11px Microsoft YaHei"; context.textAlign = "right"; for (let line = 0; line <= 4; line += 1) { @@ -6524,7 +6946,7 @@ function drawPriceChart(prices) { prices.forEach((item, index) => { const x = left + step * index + step / 2; - const color = drawCandlestick(context, x, item, priceY, candleWidth); + const color = drawCandlestick(context, x, item, priceY, candleWidth, palette); const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight; context.fillStyle = color; context.globalAlpha = 0.75; @@ -6533,7 +6955,7 @@ function drawPriceChart(prices) { }); context.textAlign = "center"; - context.fillStyle = "#6c7983"; + context.fillStyle = palette.axis; const labelIndexes = [0, Math.floor((prices.length - 1) / 2), prices.length - 1]; labelIndexes.forEach((index) => { const x = left + step * index + step / 2; @@ -6547,9 +6969,10 @@ function clearPriceChart(message) { const rect = canvas.getBoundingClientRect(); canvas.width = Math.max(320, Math.round(rect.width)); canvas.height = Math.max(220, Math.round(rect.height)); - context.fillStyle = "#fbfcfd"; + const palette = currentChartPalette(); + context.fillStyle = palette.background; context.fillRect(0, 0, canvas.width, canvas.height); - context.fillStyle = "#647380"; + context.fillStyle = palette.axis; context.font = "13px Microsoft YaHei"; context.textAlign = "center"; context.fillText(message, canvas.width / 2, canvas.height / 2); @@ -6564,17 +6987,19 @@ function prepareStockPreviewCanvas() { 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 = CHART_BACKGROUND; + context.fillStyle = palette.background; context.fillRect(0, 0, width, height); context.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei UI", sans-serif'; - return { canvas, context, width, height }; + return { canvas, context, width, height, palette }; } function drawPreviewGrid(context, width, top, bottom, left, right, maximum, range) { - context.strokeStyle = "#e7ebef"; - context.fillStyle = "#74808d"; + const palette = currentChartPalette(); + context.strokeStyle = palette.grid; + context.fillStyle = palette.axis; context.textAlign = "right"; context.lineWidth = 1; for (let line = 0; line <= 3; line += 1) { @@ -6607,9 +7032,10 @@ function drawIntradayCanvas(canvas, points, dailyPrices = [], referenceClose = 0 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 = "#ffffff"; + context.fillStyle = palette.background; context.fillRect(0, 0, width, height); context.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei UI", sans-serif'; const left = 45; @@ -6639,17 +7065,17 @@ function drawIntradayCanvas(canvas, points, dailyPrices = [], referenceClose = 0 context.save(); context.setLineDash([4, 4]); - context.strokeStyle = "#aeb7c1"; + context.strokeStyle = palette.zero; context.beginPath(); context.moveTo(left, priceY(previousClose)); context.lineTo(width - right, priceY(previousClose)); context.stroke(); context.restore(); - context.fillStyle = "#74808d"; + context.fillStyle = palette.axis; context.textAlign = "right"; context.fillText("0.00%", width - right, priceY(previousClose) - 4); - context.strokeStyle = "#1d65c1"; + context.strokeStyle = palette.line; context.lineWidth = 1.7; context.beginPath(); points.forEach((point, index) => { @@ -6662,7 +7088,7 @@ function drawIntradayCanvas(canvas, points, dailyPrices = [], referenceClose = 0 const averages = points.map((point) => number(point.average)).filter((value) => value > 0); if (averages.length) { - context.strokeStyle = "#b7791f"; + context.strokeStyle = palette.average; context.lineWidth = 1.25; context.beginPath(); let averageStarted = false; @@ -6684,11 +7110,11 @@ function drawIntradayCanvas(canvas, points, dailyPrices = [], referenceClose = 0 points.forEach((point, index) => { const x = pointX(index); const barHeight = number(point.volume) / maxVolume * volumeHeight; - context.fillStyle = number(point.close) >= number(point.open) ? "rgba(201,63,69,.58)" : "rgba(8,122,85,.58)"; + context.fillStyle = number(point.close) >= number(point.open) ? palette.upVolume : palette.downVolume; context.fillRect(x - barWidth / 2, height - bottom - barHeight, barWidth, barHeight); }); - context.fillStyle = "#74808d"; + context.fillStyle = palette.axis; context.textAlign = "center"; [ { offset: 0, label: "09:30" }, @@ -6713,7 +7139,7 @@ function drawIntradayPreviewChart(points, dailyPrices, referenceClose = 0) { } function drawDailyPreviewChart(prices) { - const { context, width, height } = prepareStockPreviewCanvas(); + const { context, width, height, palette } = prepareStockPreviewCanvas(); const visible = prices.slice(-45); const visibleStart = prices.length - visible.length; const left = 45; @@ -6738,7 +7164,7 @@ function drawDailyPreviewChart(prices) { const maxVolume = Math.max(...visible.map((item) => number(item.volume)), 1); visible.forEach((item, index) => { const x = left + step * index + step / 2; - const color = drawCandlestick(context, x, item, priceY, candleWidth); + const color = drawCandlestick(context, x, item, priceY, candleWidth, palette); const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight; context.fillStyle = color; context.globalAlpha = 0.62; @@ -6747,9 +7173,9 @@ function drawDailyPreviewChart(prices) { }); const movingAverages = [ - { days: 5, color: "#1d65c1" }, - { days: 10, color: "#a76500" }, - { days: 20, color: "#626c78" }, + { days: 5, color: palette.line }, + { days: 10, color: palette.ma10 }, + { days: 20, color: palette.ma20 }, ]; movingAverages.forEach(({ days, color }) => { context.strokeStyle = color; @@ -6776,7 +7202,7 @@ function drawDailyPreviewChart(prices) { context.fillStyle = color; context.fillText(`MA${days}`, left + index * 42, 12); }); - context.fillStyle = "#74808d"; + context.fillStyle = palette.axis; context.textAlign = "center"; [0, Math.floor((visible.length - 1) / 2), visible.length - 1].forEach((index) => { const x = left + step * index + step / 2; @@ -6838,7 +7264,8 @@ function findStockFallback(code) { ...(state.dashboard?.down_limits || []), ...(state.dashboard?.yesterday_limits || []), ]; - const screenerRows = Object.values(state.screenerResults).flatMap((result) => result?.candidates || []); + const screenerRows = Object.values(state.screenerResultStore) + .flatMap((entry) => entry?.result?.candidates || []); const dragonRows = (state.dragonTiger?.traders || []).flatMap((trader) => trader.operations || []); const auctionRows = state.auctionData?.rows || []; const themeRows = state.themeDetail?.members || []; @@ -7626,9 +8053,10 @@ function drawEntityDetailChart(series, canvas = elements.entityDetailChart) { 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 = CHART_BACKGROUND; + context.fillStyle = palette.background; context.fillRect(0, 0, width, height); const left = 48; const right = 12; @@ -7645,8 +8073,8 @@ function drawEntityDetailChart(series, canvas = elements.entityDetailChart) { const priceY = (value) => top + (maximum - value) / range * (priceBottom - top); const step = plotWidth / candles.length; const candleWidth = clamp(step * 0.62, 2, 8); - context.strokeStyle = "#e2e8ec"; - context.fillStyle = "#6c7983"; + context.strokeStyle = palette.grid; + context.fillStyle = palette.axis; context.font = "11px Microsoft YaHei"; context.textAlign = "right"; for (let line = 0; line <= 4; line += 1) { @@ -7660,7 +8088,7 @@ function drawEntityDetailChart(series, canvas = elements.entityDetailChart) { candles.forEach((item, index) => { const x = left + step * index + step / 2; - const color = drawCandlestick(context, x, item, priceY, candleWidth); + const color = drawCandlestick(context, x, item, priceY, candleWidth, palette); const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight; context.fillStyle = color; context.globalAlpha = 0.72; @@ -7669,7 +8097,7 @@ function drawEntityDetailChart(series, canvas = elements.entityDetailChart) { }); context.textAlign = "center"; - context.fillStyle = "#6c7983"; + context.fillStyle = palette.axis; [0, Math.floor((candles.length - 1) / 2), candles.length - 1].forEach((index) => { const x = left + step * index + step / 2; context.fillText(String(candles[index].trade_date || "").slice(5), x, height - 5); @@ -7683,9 +8111,10 @@ function clearEntityDetailChart(message, canvas = elements.entityDetailChart) { canvas.width = width; canvas.height = height; const context = canvas.getContext("2d"); - context.fillStyle = "#fbfcfd"; + const palette = currentChartPalette(); + context.fillStyle = palette.background; context.fillRect(0, 0, width, height); - context.fillStyle = "#647380"; + context.fillStyle = palette.axis; context.font = "13px Microsoft YaHei"; context.textAlign = "center"; context.fillText(message, width / 2, height / 2); @@ -8114,12 +8543,14 @@ async function openAdminSettings(refreshOnly = false) { try { const payload = await apiRequest("/api/admin/settings"); const data = payload.data || {}; + const ifind = data.ifind || {}; const llm = payload.llm || {}; const membership = payload.membership || {}; - status.textContent = `公共行情${data.configured ? "已配置" : "未配置"} · ${number(data.snapshot_dates)} 个交易日 · ${number(data.snapshot_records)} 条记录`; + status.textContent = `Tushare ${data.configured ? "已配置" : "未配置"} · iFinD ${ifind.configured ? "已配置" : "未配置"} · ${number(data.snapshot_dates)} 个交易日`; status.classList.toggle("connected", Boolean(data.configured)); setText("systemDataStatus", data.background_refresh_enabled ? "后台刷新已启用" : "后台刷新已暂停"); document.querySelector("#systemTokenInput").value = ""; + document.querySelector("#systemIfindTokenInput").value = ""; document.querySelector("#systemBackgroundRefresh").checked = Boolean(data.background_refresh_enabled); document.querySelector("#memberDailyLimit").value = number(membership.member_daily_limit) || 50; renderModelPool(llm.models || [], llm.primary_model_id || "", llm.fallback_model_id || ""); @@ -8259,9 +8690,11 @@ async function saveMarketSettings(event) { try { await apiRequest("/api/admin/settings", "POST", { tushare_token: document.querySelector("#systemTokenInput").value.trim(), + ifind_refresh_token: document.querySelector("#systemIfindTokenInput").value.trim(), background_refresh_enabled: document.querySelector("#systemBackgroundRefresh").checked, }); document.querySelector("#systemTokenInput").value = ""; + document.querySelector("#systemIfindTokenInput").value = ""; showToast("行情配置已保存"); await openAdminSettings(true); } catch (error) { @@ -8432,6 +8865,24 @@ function exportDragonTiger() { ]); } +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"], diff --git a/static/design-system.css b/static/design-system.css index 6fb1909..26d2594 100644 --- a/static/design-system.css +++ b/static/design-system.css @@ -29,6 +29,10 @@ --col-text:220px; --right-rail-wide:372px; --pool-table-max-height:calc(var(--content-height) - var(--topbar-height) - var(--page-pad-y) - var(--page-pad-y) - var(--card-gap)); + --sentiment-history-max-height:510px; + --sentiment-chart-compact-height:clamp(180px,20dvh,250px); + --sentiment-chart-compact-canvas-height:calc(var(--sentiment-chart-compact-height) - 18px); + --sentiment-history-min-height:220px; --primary-share:1.45fr; --secondary-share:.75fr; --mobile-nav-height:58px; @@ -40,6 +44,7 @@ --space-4:4px; --font-aux:10.5px; } + *{box-sizing:border-box;margin:0;padding:0} html,body{height:100%} body{ @@ -689,10 +694,28 @@ tbody tr.clickable{cursor:pointer} } #auctionView .auction-tabs-v2{padding-right:0} -/* Match the temperature header with the right-aligned range values below it. */ -#sentimentView .sentiment-stage-guide-head > span:nth-child(3){ - padding-right:12px; +/* Keep the stage range label and its current value on one left-aligned axis. */ +#sentimentCycleView .sentiment-stage-guide-head > span:nth-child(3), +#sentimentCycleView .sentiment-stage-guide-grid article .stage-range{ + text-align:left; +} + +/* Sentiment history: numeric columns align right; categorical columns align centrally. */ +#sentimentCycleView .sentiment-history-table tbody td.number{ text-align:right; + font-variant-numeric:tabular-nums; +} + +/* The history table owns its scrolling; the fixed status bar must not cover its final row. */ +#sentimentCycleView .sentiment-history-frame{ + max-height:var(--sentiment-history-max-height); + overflow:auto; +} +#sentimentCycleView .sentiment-history-table .sentiment-history-columns th:nth-child(3), +#sentimentCycleView .sentiment-history-table .sentiment-history-columns th:nth-child(4), +#sentimentCycleView .sentiment-history-table tbody td:nth-child(3), +#sentimentCycleView .sentiment-history-table tbody td:nth-child(4){ + text-align:center; } /* Strategy cards select on direct click; the condition dialog stays viewport-centered. */ @@ -746,6 +769,54 @@ tbody tr.clickable{cursor:pointer} :is(#limitPool,#brokenView,#downView,#yesterdayView) .tbl-wrap{max-height:var(--pool-table-max-height);overflow:auto} @media (min-width:721px){ + body:is( + [data-active-view="sentimentCycleView"], + [data-active-view="yesterdayView"] + ) .app-main{ + height:var(--workspace-height); + min-height:0; + display:flex; + flex-direction:column; + overflow:hidden; + } + + body:is( + [data-active-view="sentimentCycleView"], + [data-active-view="yesterdayView"] + ) .overview-strip{flex:0 0 auto} + + #sentimentCycleView.active-view, + #yesterdayView.active-view{ + min-height:0; + flex:1 1 auto; + display:flex; + flex-direction:column; + overflow:hidden; + } + + #sentimentCycleView > :is(.sentiment-cycle-toolbar,#sentimentHistoryNotice,.sentiment-cycle-analysis,.sentiment-detail-toolbar), + #yesterdayView > .yesterday-page-head, + #yesterdayView .yesterday-result-summary{flex:0 0 auto} + + #sentimentCycleView .sentiment-history-frame{ + min-height:var(--sentiment-history-min-height); + max-height:none; + flex:1 1 auto; + } + + #yesterdayView .yesterday-table-card{ + min-height:0; + flex:1 1 auto; + display:flex; + flex-direction:column; + } + + #yesterdayView .yesterday-table-scroll{ + min-height:0; + max-height:none; + flex:1 1 auto; + } + body:is( [data-active-view="auctionView"], [data-active-view="themeLibraryView"], @@ -845,7 +916,64 @@ tbody tr.clickable{cursor:pointer} #mentorView .mentor-messages{min-height:0;overflow:auto} } +@media (min-width:721px) and (max-height:1100px){ + #sentimentCycleView .sentiment-chart-shell{height:var(--sentiment-chart-compact-height)} + #sentimentCycleView .sentiment-chart-shell canvas{height:var(--sentiment-chart-compact-canvas-height)} + #sentimentCycleView .sentiment-phase-block{gap:12px;padding:10px 12px} + #sentimentCycleView .sentiment-current-phase-badge{padding:8px 12px} + #sentimentCycleView .sentiment-phase-advice{margin-top:4px;padding:4px 8px;line-height:1.4} + #sentimentCycleView .sentiment-feedback-strip > span{padding:4px 10px} + #sentimentCycleView .sentiment-component-list{padding:4px 16px 8px} + #sentimentCycleView .sentiment-component-item{padding:4px 0} + #sentimentCycleView .sentiment-component-item small{display:none} +} + +/* Full-page workspaces: at desktop sizes the page, rather than an inner card, + owns vertical scrolling. This keeps dense 1080p screens usable without + shrinking the primary content. */ +@media (min-width:721px){ + :root body:is( + [data-active-view="sentimentCycleView"], + [data-active-view="rotationView"], + [data-active-view="screenerView"] + ) .app-main{ + height:var(--workspace-height); + min-height:0; + display:block; + overflow-x:hidden; + overflow-y:auto; + } + + :root #sentimentCycleView.active-view, + :root #rotationView.active-view, + :root #screenerView.active-view{ + height:auto; + min-height:0; + display:block; + overflow:visible; + } + + :root #sentimentCycleView .sentiment-history-frame, + :root #rotationView .rotation-history, + :root #rotationView .rotation-table-frame, + :root #screenerView .screener-result-frame{ + max-height:none; + overflow:visible; + } + + :root #rotationView .rotation-trajectory-card, + :root #rotationView .rotation-detail-card{ + min-height:0; + margin-top:var(--card-gap); + display:block; + overflow:visible; + } + + :root #rotationView .rotation-page-head{margin-bottom:0} +} + @media (max-width:720px), (max-width:1023px) and (max-height:600px){ + :root{--sentiment-history-max-height:min(480px,calc(100dvh - 210px))} html,body{width:100%;min-width:var(--mobile-min-width)} body,body.sidebar-collapsed{display:block;padding-bottom:var(--mobile-nav-height)} .main{width:100%;min-width:0;margin-left:0} diff --git a/static/heaven-loading-v2.js b/static/heaven-loading-v2.js new file mode 100644 index 0000000..68d12b8 --- /dev/null +++ b/static/heaven-loading-v2.js @@ -0,0 +1,723 @@ +(function exposeHeavenLoading(global) { + "use strict"; + + // Theme palettes share the original animation geometry and timing. + const LOADING_PALETTES = { + dark: { + paper: "#05060d", + paperCenter: "#10142a", + paperMiddle: "#0b0e1e", + nodeText: "#f7e3b4", + ink: "#e6c37a", + inkBright: "#f7e3b4", + gold: "#e6c37a", + goldBright: "#f7e3b4", + cinnabar: "#d8564a", + dim: "rgba(216,205,180,0.55)", + particles: ["#e6c37a", "#d8564a", "#6d7fa8"], + }, + light: { + paper: "#eef1f4", + paperCenter: "#fffefa", + paperMiddle: "#f4f2eb", + nodeText: "#493a20", + ink: "#8a641d", + inkBright: "#624612", + gold: "#946b1d", + goldBright: "#765315", + cinnabar: "#b94f46", + dim: "rgba(52,58,67,0.62)", + particles: ["#946b1d", "#b94f46", "#73859c"], + }, + }; + let PAPER; + let PAPER_CENTER; + let PAPER_MIDDLE; + let NODE_TEXT; + let INK; + let INK_BRIGHT; + let GOLD; + let GOLD_BRIGHT; + let CINNABAR; + let DIM; + let PARTICLE_COLORS; + const applyLoadingPalette = () => { + const theme = document.documentElement.dataset.theme === "light" ? "light" : "dark"; + const palette = LOADING_PALETTES[theme]; + PAPER = palette.paper; + PAPER_CENTER = palette.paperCenter; + PAPER_MIDDLE = palette.paperMiddle; + NODE_TEXT = palette.nodeText; + INK = palette.ink; + INK_BRIGHT = palette.inkBright; + GOLD = palette.gold; + GOLD_BRIGHT = palette.goldBright; + CINNABAR = palette.cinnabar; + DIM = palette.dim; + PARTICLE_COLORS = palette.particles; + return theme; + }; + applyLoadingPalette(); + const SERIF = '"Noto Serif SC","Songti SC","STSong","SimSun",serif'; + const ELEMENT_COLORS = { + 木: "#4f7a4a", + 火: "#b3483d", + 土: "#96702c", + 金: "#70685b", + 水: "#496d92", + }; + const QI6 = [ + { name: "厥阴风木", element: "木" }, + { name: "少阴君火", element: "火" }, + { name: "少阳相火", element: "火" }, + { name: "太阴湿土", element: "土" }, + { name: "阳明燥金", element: "金" }, + { name: "太阳寒水", element: "水" }, + ]; + const STEP_RANGES = ["大寒 — 春分", "春分 — 小满", "小满 — 大暑", "大暑 — 秋分", "秋分 — 小雪", "小雪 — 大寒"]; + const TRIGRAMS = [ + { name: "乾", bits: [1, 1, 1], angle: -90 }, + { name: "兑", bits: [1, 1, 0], angle: -135 }, + { name: "离", bits: [1, 0, 1], angle: 180 }, + { name: "震", bits: [1, 0, 0], angle: 135 }, + { name: "巽", bits: [0, 1, 1], angle: -45 }, + { name: "坎", bits: [0, 1, 0], angle: 0 }, + { name: "艮", bits: [0, 0, 1], angle: 45 }, + { name: "坤", bits: [0, 0, 0], angle: 90 }, + ]; + const SIXIANG = [ + { name: "太阳", bits: [1, 1], dx: 0, dy: -1 }, + { name: "少阴", bits: [1, 0], dx: 1, dy: 0 }, + { name: "太阴", bits: [0, 0], dx: 0, dy: 1 }, + { name: "少阳", bits: [0, 1], dx: -1, dy: 0 }, + ]; + const HEXAGRAM_NAMES = [ + "坤", "剥", "比", "观", "豫", "晋", "萃", "否", "谦", "艮", "蹇", "渐", "小过", "旅", "咸", "遁", + "师", "蒙", "坎", "涣", "解", "未济", "困", "讼", "升", "蛊", "井", "巽", "恒", "鼎", "大过", "姤", + "复", "颐", "屯", "益", "震", "噬嗑", "随", "无妄", "明夷", "贲", "既济", "家人", "丰", "革", "同人", "临", + "损", "节", "中孚", "归妹", "睽", "兑", "履", "泰", "大畜", "需", "小畜", "大壮", "大有", "夬", "乾", + ]; + const HEX_TOTAL = 12500; + const FORTUNE_TOTAL = 12800; + const HEX_STAGES = [ + [0, 1800, "太 极", "无极而太极,动而生阳"], + [1800, 3300, "两 仪", "一阴一阳之谓道"], + [3300, 4700, "四 象", "阴阳消长,太少相生"], + [4700, 6800, "八 卦", "天地定位,山泽通气"], + [6800, 10800, "六 十 四 卦", "卦者挂也,悬物象以示人"], + [10800, HEX_TOTAL, "归 一", "万物负阴而抱阳,冲气以为和"], + ]; + const clamp01 = (value) => Math.max(0, Math.min(1, value)); + const smooth = (start, end, value) => { + const progress = clamp01((value - start) / Math.max(1, end - start)); + return progress * progress * (3 - 2 * progress); + }; + const easeOut = (value) => 1 - Math.pow(1 - clamp01(value), 3); + const hexBits = (index) => Array.from({ length: 6 }, (_, bit) => (index >> (5 - bit)) & 1); + const point = (cx, cy, radius, degrees) => { + const radians = degrees * Math.PI / 180; + return [cx + Math.cos(radians) * radius, cy + Math.sin(radians) * radius]; + }; + + class HeavenLoadingCanvas { + constructor(canvas) { + this.canvas = canvas; + this.context = canvas.getContext("2d"); + this.width = 0; + this.height = 0; + this.dpr = 1; + this.scene = "hexagram"; + this.data = {}; + this.startedAt = 0; + this.frameId = 0; + this.running = false; + this.completingAt = 0; + this.completionResolve = null; + this.completionTimer = 0; + this.resizeObserver = new ResizeObserver(() => this.resize()); + this.reducedMotion = global.matchMedia("(prefers-reduced-motion: reduce)").matches; + this.theme = document.documentElement.dataset.theme || "dark"; + this.stars = this.createStars(this.reducedMotion ? 48 : 150); + } + + createStars(count) { + let seed = 24681357; + const random = () => { + seed = (seed * 1664525 + 1013904223) >>> 0; + return seed / 4294967296; + }; + return Array.from({ length: count }, () => ({ + x: random(), + y: random(), + radius: 0.3 + random() * 1.3, + phase: random() * Math.PI * 2, + speed: 0.00015 + random() * 0.0004, + colorIndex: Math.floor(random() * PARTICLE_COLORS.length), + })); + } + + start(scene, data = {}) { + this.theme = applyLoadingPalette(); + const nextScene = scene === "fortune" ? "fortune" : "hexagram"; + if (this.running && this.scene === nextScene) { + this.data = data; + return; + } + this.stop(); + this.scene = nextScene; + this.data = data; + this.startedAt = performance.now(); + this.running = true; + this.canvas.dataset.scene = this.scene; + this.canvas.dataset.running = "true"; + this.canvas.dataset.looping = "true"; + this.resizeObserver.observe(this.canvas); + this.resize(); + if (this.reducedMotion) { + this.draw(this.scene === "fortune" ? 10950 : 10600, performance.now()); + } else { + this.frameId = requestAnimationFrame((now) => this.frame(now)); + } + } + + complete() { + if (!this.running || this.reducedMotion) { + this.stop(); + return Promise.resolve(); + } + if (this.completionResolve) return this.completionPromise; + this.completingAt = performance.now(); + this.completionPromise = new Promise((resolve) => { this.completionResolve = resolve; }); + this.completionTimer = global.setTimeout(() => this.stop(), 2200); + return this.completionPromise; + } + + stop() { + if (this.frameId) cancelAnimationFrame(this.frameId); + this.frameId = 0; + this.running = false; + this.completingAt = 0; + if (this.completionTimer) global.clearTimeout(this.completionTimer); + this.completionTimer = 0; + this.resizeObserver.disconnect(); + this.canvas.dataset.running = "false"; + this.canvas.dataset.looping = "false"; + if (this.completionResolve) this.completionResolve(); + this.completionResolve = null; + this.completionPromise = null; + } + + resize() { + const rect = this.canvas.getBoundingClientRect(); + const width = Math.max(1, Math.round(rect.width)); + const height = Math.max(1, Math.round(rect.height)); + if (width === this.width && height === this.height) return; + this.width = width; + this.height = height; + this.dpr = Math.min(global.devicePixelRatio || 1, 2); + this.canvas.width = Math.round(width * this.dpr); + this.canvas.height = Math.round(height * this.dpr); + this.context.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + if (this.running && this.reducedMotion) { + this.draw(this.scene === "fortune" ? 10950 : 10600, performance.now()); + } + } + + frame(now) { + if (!this.running) return; + if (this.completingAt) { + const duration = this.scene === "fortune" ? 1800 : 1700; + const progress = clamp01((now - this.completingAt) / duration); + this.drawCompletion(progress, now); + if (progress >= 1) { + this.stop(); + return; + } + } else { + const total = this.scene === "fortune" ? FORTUNE_TOTAL : HEX_TOTAL; + const elapsed = Math.max(0, now - this.startedAt); + const timeline = elapsed % total; + this.canvas.dataset.cycle = String(Math.floor(elapsed / total)); + this.draw(timeline, now); + } + this.frameId = requestAnimationFrame((time) => this.frame(time)); + } + + draw(time, now) { + if (this.width <= 1 || this.height <= 1) return; + this.drawBackground(now); + if (this.scene === "fortune") this.drawFortune(time, now); + else this.drawHexagram(time, now); + } + + drawBackground(now) { + const currentTheme = document.documentElement.dataset.theme || "dark"; + if (currentTheme !== this.theme) this.theme = applyLoadingPalette(); + const { context: ctx, width, height } = this; + const cx = width / 2; + const cy = height * 0.4; + const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(width, height) * 0.75); + gradient.addColorStop(0, PAPER_CENTER); + gradient.addColorStop(0.52, PAPER_MIDDLE); + gradient.addColorStop(1, PAPER); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, width, height); + for (const star of this.stars) { + const twinkle = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(star.phase + now * 0.0012)); + const alpha = twinkle * 0.5; + ctx.globalAlpha = alpha; + ctx.fillStyle = PARTICLE_COLORS[star.colorIndex]; + const y = ((star.y + now * star.speed) % 1) * height; + ctx.fillRect(star.x * width, y, star.radius, star.radius); + } + ctx.globalAlpha = 1; + } + + label(text, x, y, size, color = INK, alpha = 1, weight = "", maxWidth) { + if (!text || alpha <= 0) return; + const ctx = this.context; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = color; + ctx.font = `${weight ? `${weight} ` : ""}${size}px ${SERIF}`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + if (maxWidth) ctx.fillText(text, x, y, maxWidth); + else ctx.fillText(text, x, y); + ctx.restore(); + } + + node(x, y, radius, color, alpha = 1, glow = 0) { + const ctx = this.context; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = color; + ctx.shadowColor = color; + ctx.shadowBlur = glow; + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + + line(x1, y1, x2, y2, color, alpha = 1, width = 1) { + const ctx = this.context; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.strokeStyle = color; + ctx.lineWidth = width; + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); + ctx.restore(); + } + + curvedArrow(x1, y1, x2, y2, mx, my, color, alpha) { + if (alpha <= 0) return; + const ctx = this.context; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.strokeStyle = color; + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.quadraticCurveTo(mx, my, x2, y2); + ctx.stroke(); + const angle = Math.atan2(y2 - my, x2 - mx); + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(x2, y2); + ctx.lineTo(x2 - 7 * Math.cos(angle - 0.42), y2 - 7 * Math.sin(angle - 0.42)); + ctx.lineTo(x2 - 7 * Math.cos(angle + 0.42), y2 - 7 * Math.sin(angle + 0.42)); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + } + + drawYao(cx, cy, width, lineWidth, yang, alpha, glow = 0) { + const ctx = this.context; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = INK; + ctx.shadowColor = GOLD; + ctx.shadowBlur = glow; + if (yang) { + ctx.fillRect(cx - width / 2, cy - lineWidth / 2, width, lineWidth); + } else { + const gap = width * 0.18; + ctx.fillRect(cx - width / 2, cy - lineWidth / 2, (width - gap) / 2, lineWidth); + ctx.fillRect(cx + gap / 2, cy - lineWidth / 2, (width - gap) / 2, lineWidth); + } + ctx.restore(); + } + + drawGua(cx, cy, width, lineWidth, bits, alpha, glow = 0) { + const gap = lineWidth * 1.7; + const top = cy - (bits.length - 1) * gap / 2; + bits.forEach((bit, index) => { + this.drawYao(cx, top + (bits.length - 1 - index) * gap, width, lineWidth, bit === 1, alpha, glow); + }); + } + + stageAlpha(time, start, end, fade = 300, hold = false) { + const enter = smooth(start, start + fade, time); + return hold ? enter : enter * (1 - smooth(end - fade, end, time)); + } + + fortuneStages() { + const sixQi = this.data.sixQi || {}; + const pillar = this.data.yearPillar || "岁运"; + const movement = this.data.movement || "中运合参"; + const sitian = sixQi.sitian || "司天气候"; + return [ + [0, 2100, "五 运", "木火土金水,五运相袭,周而复始"], + [2100, 3900, "十 干 化 运", "甲己土 · 乙庚金 · 丙辛水 · 丁壬木 · 戊癸火"], + [3900, 5800, "十 二 支 化 气", "子午少阴 · 丑未太阴 · 寅申少阳 · 卯酉阳明 · 辰戌太阳 · 巳亥厥阴"], + [5800, 7900, "六 气 环 布", "风寒暑湿燥火,分主六步,以应岁时"], + [7900, 11000, "岁 运 合 参", `${pillar}年 · 中运${movement} · ${sitian}司天`], + [11000, FORTUNE_TOTAL, "归 一", "谨守病机,无失气宜"], + ]; + } + + drawFooter(time, now, total, stages, scene) { + const { context: ctx, width, height } = this; + const stage = [...stages].reverse().find((item) => time >= item[0]) || stages[0]; + const labelAlpha = smooth(stage[0], stage[0] + 300, time) + * (1 - smooth(stage[1] - 250, stage[1], time)); + this.label(stage[2], width / 2, height - 108, 19, GOLD, 0.55 + 0.45 * labelAlpha, "600"); + this.label(stage[3], width / 2, height - 84, 12.5, DIM, (0.4 + 0.4 * labelAlpha) * (scene === "fortune" ? 0.85 : 0.8), "", width - 32); + + const baseSlotWidth = 34; + const baseSlotHeight = 5; + const baseSlotGap = 12; + const baseTotalWidth = baseSlotWidth * 6 + baseSlotGap * 5; + const fit = Math.min(1, (width - 28) / baseTotalWidth); + const slotWidth = baseSlotWidth * fit; + const slotHeight = baseSlotHeight * fit; + const slotGap = baseSlotGap * fit; + const totalWidth = slotWidth * 6 + slotGap * 5; + const filled = Math.min(6, Math.floor(time / (total / 6))); + for (let index = 0; index < 6; index += 1) { + const x = width / 2 - totalWidth / 2 + index * (slotWidth + slotGap); + const y = height - 56; + const color = scene === "fortune" ? ELEMENT_COLORS[QI6[index].element] : GOLD; + ctx.save(); + ctx.globalAlpha = 0.16; + ctx.strokeStyle = GOLD; + ctx.lineWidth = 1; + ctx.strokeRect(x, y, slotWidth, slotHeight); + ctx.restore(); + if (index < filled) { + ctx.save(); + ctx.globalAlpha = 0.9; + ctx.fillStyle = color; + ctx.shadowColor = color; + ctx.shadowBlur = 8; + ctx.fillRect(x, y, slotWidth, slotHeight); + ctx.restore(); + } else if (index === filled) { + ctx.save(); + ctx.globalAlpha = 0.35 + 0.3 * Math.sin(now / 200); + ctx.fillStyle = color; + const progress = (time % (total / 6)) / (total / 6); + ctx.fillRect(x, y, slotWidth * progress, slotHeight); + ctx.restore(); + } + } + const dots = ".".repeat(1 + Math.floor(now / 450) % 3); + const loadingText = scene === "fortune" ? "推 演 运 气 · 加 载 中" : "推 演 天 机 · 加 载 中"; + this.label(`${loadingText}${dots}`, width / 2, height - 32, 13, GOLD, 0.75); + } + + drawTrigramRing(cx, cy, radius, width, lineWidth, alpha, now, entering, time) { + const ctx = this.context; + ctx.save(); + ctx.globalAlpha = alpha * 0.13; + ctx.strokeStyle = GOLD; + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + const breath = 1 + 0.006 * Math.sin(now / 620); + TRIGRAMS.forEach((trigram, index) => { + const progress = entering ? easeOut((time - 4700 - index * 130) / 700) : 1; + if (progress <= 0) return; + const [x, y] = point(cx, cy, radius * breath * progress, trigram.angle); + this.drawGua(x, y, width, lineWidth, trigram.bits, alpha * progress, alpha * progress * 8); + const nameAlpha = entering ? alpha * clamp01((time - 4700 - index * 130 - 480) / 500) : alpha; + this.label(trigram.name, x, y + lineWidth * 5.2, 13, GOLD, nameAlpha * (0.55 + 0.2 * Math.sin(now / 700 + index))); + }); + } + + drawHexagram(time, now) { + const { width, height } = this; + const cx = width / 2; + const cy = height * 0.4; + const scale = Math.min(width, Math.max(1, height - 150)); + if (time < 1800) { + const alpha = this.stageAlpha(time, 0, 1800); + this.node(cx, cy, 5.5 * (1 + 0.12 * Math.sin(now / 260)), GOLD_BRIGHT, alpha, 34); + for (let ring = 0; ring < 3; ring += 1) { + const progress = ((now / 1500) + ring / 3) % 1; + const ctx = this.context; + ctx.save(); + ctx.globalAlpha = (1 - progress) * 0.22 * alpha; + ctx.strokeStyle = GOLD; + ctx.beginPath(); + ctx.arc(cx, cy, 8 + progress * scale * 0.13, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + } + } + if (time >= 1800 && time < 3300) { + const alpha = this.stageAlpha(time, 1800, 3300); + const progress = easeOut((time - 1850) / 850); + const yaoWidth = scale * 0.19 * progress; + const yaoLine = Math.max(scale * 0.013, 5); + this.drawYao(cx, cy - yaoLine * 2.6, yaoWidth, yaoLine, true, alpha, 14); + this.drawYao(cx, cy + yaoLine * 2.6, yaoWidth, yaoLine, false, alpha, 14); + this.node(cx, cy, 4, GOLD_BRIGHT, alpha * (1 - progress) * 0.9); + } + if (time >= 3300 && time < 4700) { + const alpha = this.stageAlpha(time, 3300, 4700); + const distance = scale * 0.085; + const yaoWidth = Math.max(scale * 0.055, 28); + const yaoLine = Math.max(scale * 0.009, 3.5); + SIXIANG.forEach((symbol, index) => { + const progress = easeOut((time - 3330 - index * 160) / 520); + if (progress <= 0) return; + const x = cx + symbol.dx * distance; + const y = cy + symbol.dy * distance; + this.drawGua(x, y, yaoWidth * progress, yaoLine, symbol.bits, alpha * progress, 10); + this.label(symbol.name, x, y + yaoLine * 5.4, 12, GOLD, alpha * progress * 0.55); + }); + } + const trigramRadius = scale * 0.215; + const trigramWidth = Math.max(scale * 0.052, 26); + const trigramLine = Math.max(scale * 0.0075, 3); + if (time >= 4700 && time < 6800) { + this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth, trigramLine, this.stageAlpha(time, 4700, 6800), now, true, time); + } + if (time >= 6800 && time < 10800) { + const alpha = this.stageAlpha(time, 6800, 10800, 350); + this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth * 0.85, trigramLine * 0.85, alpha * 0.42, now, false, time); + const ringRadius = scale * 0.365; + const hexWidth = Math.max(scale * 0.026, 13); + const hexLine = Math.max(scale * 0.0042, 1.6); + const count = Math.floor(clamp01((time - 7000) / 3600) * 64); + for (let index = 0; index < 64; index += 1) { + const [x, y] = point(cx, cy, ringRadius, -90 + index * 360 / 64); + this.node(x, y, 1.4, GOLD, alpha * 0.14); + if (index < count) { + const freshness = Math.max(0, 1 - (count - 1 - index) / 5); + if (freshness > 0) { + const ctx = this.context; + const gradient = ctx.createLinearGradient(cx, cy, x, y); + gradient.addColorStop(0, "rgba(230,195,122,0)"); + gradient.addColorStop(1, GOLD); + this.line(cx, cy, x, y, gradient, alpha * freshness * 0.35); + } + this.drawGua(x, y, hexWidth, hexLine, hexBits(index), alpha * (0.55 + 0.45 * freshness), freshness * 9); + } + } + if (count > 0) { + const current = count - 1; + const popTime = clamp01((time - (7000 + current * 3600 / 64)) / 130); + const pop = 1 + 0.22 * (1 - popTime); + this.drawGua(cx, cy - scale * 0.028, scale * 0.085 * pop, Math.max(scale * 0.011, 4.5), hexBits(current), alpha, 16); + this.label(HEXAGRAM_NAMES[current], cx, cy + scale * 0.062, Math.max(20, scale * 0.042), GOLD_BRIGHT, alpha, "600"); + this.label(`第 ${current + 1} 卦`, cx, cy + scale * 0.105, 13, GOLD, alpha * 0.55); + } + } + if (time >= 10800) { + const alpha = this.stageAlpha(time, 10800, HEX_TOTAL, 420); + const progress = easeOut((time - 10850) / 1150); + const radius = scale * 0.365 * (1 - progress); + for (let index = 0; index < 64 && radius >= 8; index += 1) { + const [x, y] = point(cx, cy, radius, -90 + index * 360 / 64); + this.drawGua(x, y, Math.max(scale * 0.026, 13), Math.max(scale * 0.0042, 1.6), hexBits(index), (1 - progress) * 0.7 * alpha); + } + this.node(cx, cy, 3 + progress * 6, GOLD_BRIGHT, alpha * (0.3 + 0.7 * progress), 12 + progress * 40); + } + this.drawFooter(time, now, HEX_TOTAL, HEX_STAGES, "hexagram"); + } + + drawFortune(time, now) { + const { width, height } = this; + const cx = width / 2; + const cy = height * 0.4; + const scale = Math.min(width, Math.max(1, height - 150)); + if (time < 2100) this.drawFiveMovements(time, now, cx, cy, scale); + if (time >= 2100 && time < 3900) this.drawStems(time, cx, cy, scale); + if (time >= 3900 && time < 5800) this.drawBranches(time, cx, cy, scale); + if (time >= 5800 && time < 7900) this.drawSixQi(time, now, cx, cy, scale); + if (time >= 7900 && time < 11000) this.drawAnnualQi(time, now, cx, cy, scale); + if (time >= 11000) { + const alpha = this.stageAlpha(time, 11000, FORTUNE_TOTAL, 420); + const progress = easeOut((time - 11050) / 1200); + const radius = scale * 0.30 * (1 - progress); + QI6.forEach((qi, index) => { + const [x, y] = point(cx, cy, radius, -90 + index * 60); + if (radius > 8) this.node(x, y, Math.max(scale * 0.011, 6), ELEMENT_COLORS[qi.element], (1 - progress) * 0.8 * alpha, 8); + }); + this.node(cx, cy, 3 + progress * 6, GOLD_BRIGHT, alpha * (0.3 + 0.7 * progress), 12 + progress * 40); + } + this.drawFooter(time, now, FORTUNE_TOTAL, this.fortuneStages(), "fortune"); + } + + drawFiveMovements(time, now, cx, cy, scale) { + const alpha = this.stageAlpha(time, 0, 2100); + const radius = scale * 0.17; + const nodeRadius = Math.max(scale * 0.018, 9); + const elements = [ + ["木", 180], ["火", -90], ["金", 0], ["水", 90], ["土", null], + ]; + const positions = {}; + this.node(cx, cy, 5 + 1.5 * Math.sin(now / 260), GOLD_BRIGHT, alpha * (1 - easeOut((time - 200) / 800)), 30); + elements.forEach(([element, degrees], index) => { + const progress = easeOut((time - 500 - index * 170) / 500); + if (progress <= 0) return; + const x = degrees === null ? cx : cx + Math.cos(degrees * Math.PI / 180) * radius * progress; + const y = degrees === null ? cy : cy + Math.sin(degrees * Math.PI / 180) * radius * progress; + positions[element] = [x, y]; + this.node(x, y, nodeRadius * progress, ELEMENT_COLORS[element], alpha * progress, 16); + this.label(element, x, y + 0.5, Math.round(nodeRadius * 1.15), NODE_TEXT, alpha * progress, "600"); + const direction = element === "土" ? "中央土" : { 木: "东方木", 火: "南方火", 金: "西方金", 水: "北方水" }[element]; + this.label(direction, x, y + nodeRadius + 14, 12, ELEMENT_COLORS[element], alpha * progress * 0.75); + }); + const order = ["木", "火", "土", "金", "水"]; + order.forEach((element, index) => { + const from = positions[element]; + const to = positions[order[(index + 1) % order.length]]; + if (!from || !to) return; + const progress = smooth(1450 + index * 130, 1700 + index * 130, time); + const mx = (from[0] + to[0]) / 2 + (cx - (from[0] + to[0]) / 2) * 0.25; + const my = (from[1] + to[1]) / 2 + (cy - (from[1] + to[1]) / 2) * 0.25; + this.curvedArrow(from[0], from[1], to[0], to[1], mx, my, GOLD, alpha * progress * 0.4); + }); + } + + drawStems(time, cx, cy, scale) { + const alpha = this.stageAlpha(time, 2100, 3900); + const stems = "甲乙丙丁戊己庚辛壬癸"; + const movements = ["土", "金", "水", "木", "火"]; + const radius = scale * 0.30; + for (let index = 0; index < 10; index += 1) { + const progress = smooth(2150 + index * 90, 2450 + index * 90, time); + if (progress <= 0) continue; + const [x, y] = point(cx, cy, radius, -90 + index * 36); + const element = movements[index % 5]; + this.node(x, y, 3, ELEMENT_COLORS[element], alpha * progress, 8); + this.label(stems[index], x, y - 14, 15, ELEMENT_COLORS[element], alpha * progress, "600"); + } + for (let index = 0; index < 5; index += 1) { + const progress = smooth(3150 + index * 110, 3450 + index * 110, time); + const angle = -90 + index * 36; + const [x1, y1] = point(cx, cy, radius, angle); + const [x2, y2] = point(cx, cy, radius, -90 + (index + 5) * 36); + this.line(x1, y1, x2, y2, ELEMENT_COLORS[movements[index]], alpha * progress * 0.45); + const [labelX, labelY] = point(cx, cy, scale * 0.055, angle + 90); + this.label(movements[index], labelX, labelY, 16, ELEMENT_COLORS[movements[index]], alpha * progress, "600"); + } + } + + drawBranches(time, cx, cy, scale) { + const alpha = this.stageAlpha(time, 3900, 5800); + const branches = "子丑寅卯辰巳午未申酉戌亥"; + const qiNames = ["少阴君火", "太阴湿土", "少阳相火", "阳明燥金", "太阳寒水", "厥阴风木"]; + const radius = scale * 0.31; + const branchAngle = (index) => -90 + ((index - 6 + 12) % 12) * 30; + for (let index = 0; index < 12; index += 1) { + const progress = smooth(3950 + index * 70, 4220 + index * 70, time); + const [x, y] = point(cx, cy, radius, branchAngle(index)); + this.node(x, y, 2.5, GOLD, alpha * progress, 6); + this.label(branches[index], x, y - 13, 14, GOLD, alpha * progress * 0.9); + } + qiNames.forEach((name, index) => { + const progress = smooth(4900 + index * 130, 5200 + index * 130, time); + const [x1, y1] = point(cx, cy, radius, branchAngle(index)); + const [x2, y2] = point(cx, cy, radius, branchAngle(index + 6)); + const element = QI6.find((item) => item.name === name)?.element || "土"; + this.line(x1, y1, x2, y2, ELEMENT_COLORS[element], alpha * progress * 0.4); + const [labelX, labelY] = point(cx, cy, radius + scale * 0.055, branchAngle(index)); + this.label(name, labelX, labelY, 12, ELEMENT_COLORS[element], alpha * progress, "600"); + }); + } + + drawSixQi(time, now, cx, cy, scale) { + const alpha = this.stageAlpha(time, 5800, 7900); + const radius = scale * 0.27; + const drift = now * 0.004; + const ctx = this.context; + ctx.save(); + ctx.globalAlpha = alpha * 0.13; + ctx.strokeStyle = GOLD; + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + QI6.forEach((qi, index) => { + const progress = easeOut((time - 5850 - index * 180) / 550); + const [x, y] = point(cx, cy, radius * progress, -90 + index * 60 + drift); + const nodeRadius = Math.max(scale * 0.015, 8) * progress; + this.node(x, y, nodeRadius, ELEMENT_COLORS[qi.element], alpha * progress, 14); + this.label(qi.name, x, y - nodeRadius - 12, 13, ELEMENT_COLORS[qi.element], alpha * progress, "600"); + this.label(["初之气", "二之气", "三之气", "四之气", "五之气", "终之气"][index], x, y + nodeRadius + 12, 10.5, DIM, alpha * progress * 0.9); + }); + this.node(cx, cy, 4 + Math.sin(now / 300), GOLD_BRIGHT, alpha * 0.9, 24); + } + + drawAnnualQi(time, now, cx, cy, scale) { + const alpha = this.stageAlpha(time, 7900, 11000, 350); + const sixQi = this.data.sixQi || {}; + const pillar = this.data.yearPillar || "岁运"; + const movement = this.data.movement || "中运合参"; + const sitian = sixQi.sitian || "司天气候"; + const zaiquan = sixQi.zaiquan || "在泉气化"; + const currentStep = Math.max(1, Math.min(6, Number(sixQi.step) || 1)); + const qiElement = (name) => QI6.find((item) => item.name === name)?.element || "土"; + const movementElement = ["木", "火", "土", "金", "水"].find((element) => movement.includes(element)) || "土"; + this.label("司 天", cx, cy - scale * 0.212, 11, DIM, alpha * smooth(7950, 8450, time)); + this.label(sitian, cx, cy - scale * 0.178, 17, ELEMENT_COLORS[qiElement(sitian)], alpha * smooth(7950, 8450, time), "600"); + this.label(zaiquan, cx, cy + scale * 0.178, 17, ELEMENT_COLORS[qiElement(zaiquan)], alpha * smooth(8200, 8700, time), "600"); + this.label("在 泉", cx, cy + scale * 0.212, 11, DIM, alpha * smooth(8200, 8700, time)); + this.label(pillar, cx, cy - scale * 0.012, Math.max(22, scale * 0.052), GOLD_BRIGHT, alpha * smooth(8500, 9100, time), "600"); + this.label(`${pillar}年 · 中运${movement}`, cx, cy + scale * 0.052, 14, ELEMENT_COLORS[movementElement], alpha * smooth(8500, 9100, time), "600", scale * 0.62); + const radius = scale * 0.30; + QI6.forEach((qi, index) => { + const progress = smooth(9200 + index * 260, 9480 + index * 260, time); + const [x, y] = point(cx, cy, radius, -90 + index * 60); + const current = index + 1 === currentStep; + const pulse = current ? 0.5 + 0.5 * Math.sin(now / 230) : 0; + this.node(x, y, Math.max(scale * 0.011, 6) + (current ? 2.5 : 0), ELEMENT_COLORS[qi.element], alpha * progress, 12 + pulse * 14); + if (current) { + const ctx = this.context; + ctx.save(); + ctx.globalAlpha = alpha * (0.35 + pulse * 0.35); + ctx.strokeStyle = CINNABAR; + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.arc(x, y, Math.max(scale * 0.02, 11) + pulse * 3, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + this.label("当今", x, y - Math.max(scale * 0.038, 21), 10.5, CINNABAR, alpha * progress, "600"); + } + const stepName = `${index + 1 === 6 ? "终" : ["初", "二", "三", "四", "五"][index]}之气`; + this.label(`${stepName} · ${qi.name}`, x, y + Math.max(scale * 0.03, 17), 11.5, current ? GOLD_BRIGHT : ELEMENT_COLORS[qi.element], alpha * progress * (current ? 1 : 0.85), current ? "600" : ""); + if (current) this.label(STEP_RANGES[index], x, y + Math.max(scale * 0.052, 33), 10, DIM, alpha * progress); + }); + } + + drawCompletion(progress, now) { + this.drawBackground(now); + if (this.scene === "fortune") { + this.drawFortune(11000 + progress * (FORTUNE_TOTAL - 11000), now); + } else { + this.drawHexagram(10800 + progress * (HEX_TOTAL - 10800), now); + } + } + } + + global.HeavenLoadingCanvas = HeavenLoadingCanvas; +})(window); diff --git a/static/index.html b/static/index.html index e3cb7c1..b295f5c 100644 --- a/static/index.html +++ b/static/index.html @@ -3,12 +3,26 @@ - + 小白复盘 + - - + + + +
@@ -47,6 +61,7 @@ + @@ -498,40 +513,49 @@
-
+
-
-
-

问天

- -- +
+
+

问 天

+ --
-
-
- - - -
-

遇事不决可问春风,春风不语即随本心

- +
观天之道 · 执天之行
+ + +

遇事不决可问春风,春风不语即随本心

+
- +
- +
-
请输入股票代码或股票名称
-
-
--

--

+
--

--

- - + +
-
-
- -
- 壹 · 天 -
-
今日气候

--

- -- -
-

--

-
+
+ + +
+ 壹 · 天 +

今日气候

+

气机待察

+ -- +

--

-
-

五行流转

强弱 · 心性 · 流向
-
-
-
-
-

三层气机

--
-
-
-
-
-
容易生起--
-
判断偏差--
-
操作惯性--
-
风险与制衡--
-
-
-
-

个人影响

日主、十神喜恶、五行喜忌与当日作用
-
- 当前账号尚未设置个人命理资料 - -
- -
-
-
叁 · 用

五行行业归属

传统取象 · 手动归类优先
-
-
- -
-
-
-
- -
次要信息

历法细目与归类管理

- 展开查看推演依据 - -
-
-
-

历法细目

中运、司天、在泉与节气定位
-
+
+
+
叁 · 人

个人合参

+
当前账号尚未设置个人命理资料
+ +
+ +
+
+ + 五行取象五行对应行业 + 展开查看全部行业 + +

+
- - -
- - +
+ 静心 + 呼吸 + 起卦 + 察念 + 解卦
- -
+
+ + + +
+ + +
+
观心 · 一 -

把所问之事留在心里

+

把所问之事留在心里

只问一事,不必说出来。

心里默念它发生的对象与时间。

不求一个喜欢的答案,只看自己真正担心什么。

-
遇事不决可问春风,春风不语即随本心
+
遇事不决可问春风,春风不语即随本心
- +
观心 · 二
-

放松片刻,准备呼吸

+

放松片刻,准备呼吸

- +
-

从初爻起

0 / 6
+

从初爻起

0 / 6
观心 · 三
-
-
-
+
+
+
-

心中默念所问之事,然后掷出初爻

+

心中默念所问之事,然后掷出初爻

- +
@@ -740,18 +744,17 @@
观心 · 四 -

先不解卦

+

先不解卦

看见卦象与爻辞后,心里升起的第一念是什么?

不要修饰,也不必记录。只需看见它。

-
-
观心 · 五

解卦

--
+
观心 · 五

解卦

--

--

@@ -759,7 +762,8 @@
-

一念既察,卦只是镜。

+

一念既察,卦只是镜。

+

观心用于观察念头与执着,不用于替代交易计划或预测涨跌。

@@ -1305,8 +1309,8 @@
- - + +
@@ -1350,6 +1354,36 @@
+ +
@@ -1466,7 +1500,7 @@ - +
问天 · 观势

解读

@@ -1786,6 +1820,7 @@

公共行情

待检查
+

所有用户读取同一份后台快照,页面不会随后台任务自动重绘。

@@ -1837,7 +1872,7 @@ - - + + diff --git a/static/redesign-v2.css b/static/redesign-v2.css index 43fab73..35e2ae9 100644 --- a/static/redesign-v2.css +++ b/static/redesign-v2.css @@ -23,6 +23,30 @@ --r2-card: #fff; --r2-radius: 10px; --r2-shadow: 0 1px 2px rgba(16, 24, 40, .05); + /* Dragon profile component tokens. */ + --dragon-profile-list-width: 340px; + --dragon-profile-detail-min-height: 460px; + --dragon-profile-list-max-height: 320px; + --dragon-profile-row-min-height: 64px; + --dragon-profile-row-avatar-size: 36px; + --dragon-profile-avatar-size: 72px; + --dragon-profile-control-height: 33px; + --dragon-profile-gap: 12px; + --dragon-profile-panel-padding: 16px; + --dragon-profile-row-padding: 10px 12px; + --dragon-profile-title-font: 18px; + --dragon-profile-name-font: 13px; + --dragon-profile-body-font: 12px; + --dragon-profile-meta-font: 11px; + --dragon-profile-transition: 160ms ease; + --dragon-profile-border-width: 1px; + --dragon-profile-focus-width: 2px; + --dragon-profile-focus-offset: -2px; + --dragon-profile-radius-inset: 2px; + --dragon-profile-body-line-height: 1.75; + --dragon-profile-icon-stroke: 1; + --dragon-profile-weight-strong: 750; + --dragon-profile-weight-semibold: 600; } html, @@ -3582,7 +3606,7 @@ body.sidebar-collapsed .status-bar { left: 64px; } overflow: hidden; border: 1px solid var(--r2-line); border-radius: var(--r2-radius); - background: #fff; + background: var(--r2-card); box-shadow: var(--r2-shadow); } @@ -4483,6 +4507,226 @@ body.sidebar-collapsed .status-bar { left: 64px; } .dragon-search-v2 { transition: none; } } +/* Dragon profile directory: the master list is authoritative data, while the + detail pane only expands fields present in the public directory. */ +.hot-money-profiles-v2 { + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: var(--dragon-profile-gap); + overflow: hidden; +} +.hot-money-profiles-v2[hidden] { display: none; } + +.hot-money-profile-toolbar-v2 { + min-height: calc(var(--dragon-profile-control-height) + var(--dragon-profile-gap)); + display: flex; + align-items: center; + gap: var(--dragon-profile-gap); + padding: calc(var(--dragon-profile-gap) / 2) var(--dragon-profile-gap); + border: var(--dragon-profile-border-width) solid var(--r2-line); + border-radius: var(--r2-radius); + background: var(--r2-card); + box-shadow: var(--r2-shadow); +} +.hot-money-profile-toolbar-v2 .dragon-filter-copy-v2 { flex: 1 1 auto; } +.hot-money-profile-search-v2 { height: var(--dragon-profile-control-height); } + +.hot-money-profile-summary-v2 { + display: flex; + align-items: center; + gap: var(--dragon-profile-gap); +} +.hot-money-profile-summary-v2 > span { + display: grid; + grid-template-columns: auto auto; + align-items: baseline; + gap: calc(var(--dragon-profile-gap) / 2); + white-space: nowrap; +} +.hot-money-profile-summary-v2 small { + color: var(--r2-faint); + font-size: var(--dragon-profile-meta-font); +} +.hot-money-profile-summary-v2 strong { + color: var(--r2-ink); + font-size: var(--dragon-profile-name-font); + font-variant-numeric: tabular-nums; +} + +.hot-money-profile-workspace-v2 { + min-width: 0; + min-height: 0; + display: grid; + grid-template-columns: var(--dragon-profile-list-width) minmax(0, 1fr); + gap: var(--dragon-profile-gap); +} +.hot-money-profile-directory-v2, +.hot-money-profile-detail-v2 { + min-width: 0; + min-height: 0; + overflow: hidden; + border: var(--dragon-profile-border-width) solid var(--r2-line); + border-radius: var(--r2-radius); + background: var(--r2-card); + box-shadow: var(--r2-shadow); +} +.hot-money-profile-directory-v2 { + display: flex; + flex-direction: column; +} +.hot-money-profile-directory-head-v2 { + min-height: var(--dragon-profile-control-height); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--dragon-profile-gap); + border-bottom: var(--dragon-profile-border-width) solid var(--r2-line-soft); +} +.hot-money-profile-directory-head-v2 strong { + color: var(--r2-ink); + font-size: var(--dragon-profile-body-font); +} +.hot-money-profile-directory-head-v2 span { + color: var(--r2-faint); + font-size: var(--dragon-profile-meta-font); + font-variant-numeric: tabular-nums; +} +.hot-money-profile-list-v2 { + min-height: 0; + flex: 1 1 auto; + overflow-y: auto; + overscroll-behavior: contain; +} +.hot-money-profile-row-v2 { + width: 100%; + min-height: var(--dragon-profile-row-min-height); + display: grid; + grid-template-columns: auto var(--dragon-profile-row-avatar-size) minmax(0, 1fr) auto; + align-items: center; + gap: calc(var(--dragon-profile-gap) / 2); + padding: var(--dragon-profile-row-padding); + border: 0; + border-bottom: var(--dragon-profile-border-width) solid var(--r2-line-soft); + background: transparent; + text-align: left; + transition: background-color var(--dragon-profile-transition), color var(--dragon-profile-transition); +} +.hot-money-profile-row-v2:hover { background: var(--r2-bg); } +.hot-money-profile-row-v2.selected { background: var(--r2-blue-soft); } +.hot-money-profile-row-v2:focus-visible { outline: var(--dragon-profile-focus-width) solid var(--r2-blue); outline-offset: var(--dragon-profile-focus-offset); } +.hot-money-profile-index-v2 { + color: var(--r2-faint); + font-size: var(--dragon-profile-meta-font); + font-variant-numeric: tabular-nums; +} +.hot-money-profile-monogram-v2, +.hot-money-profile-avatar-v2 { + display: grid; + place-items: center; + border-radius: var(--r2-radius); + background: var(--r2-blue-soft); + color: var(--r2-blue); + font-weight: var(--dragon-profile-weight-strong); +} +.hot-money-profile-monogram-v2 { + width: var(--dragon-profile-row-avatar-size); + height: var(--dragon-profile-row-avatar-size); + font-size: var(--dragon-profile-meta-font); +} +.hot-money-profile-row-copy-v2 { min-width: 0; } +.hot-money-profile-row-copy-v2 strong, +.hot-money-profile-row-copy-v2 small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.hot-money-profile-row-copy-v2 strong { color: var(--r2-ink); font-size: var(--dragon-profile-name-font); } +.hot-money-profile-row-copy-v2 small { margin-top: calc(var(--dragon-profile-gap) / 4); color: var(--r2-faint); font-size: var(--dragon-profile-meta-font); } +.hot-money-profile-seat-count-v2 { + color: var(--r2-sub); + font-size: var(--dragon-profile-meta-font); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.hot-money-profile-detail-v2 { + min-height: var(--dragon-profile-detail-min-height); + display: flex; + flex-direction: column; + overflow-y: auto; +} +.hot-money-profile-detail-head-v2 { + display: flex; + align-items: center; + gap: var(--dragon-profile-gap); + padding: var(--dragon-profile-panel-padding); + border-bottom: var(--dragon-profile-border-width) solid var(--r2-line-soft); +} +.hot-money-profile-avatar-v2 { + width: var(--dragon-profile-avatar-size); + height: var(--dragon-profile-avatar-size); + flex: 0 0 var(--dragon-profile-avatar-size); + font-size: var(--dragon-profile-title-font); +} +.hot-money-profile-detail-head-v2 > div { min-width: 0; } +.hot-money-profile-detail-head-v2 small, +.hot-money-profile-detail-head-v2 span { display: block; color: var(--r2-faint); font-size: var(--dragon-profile-meta-font); } +.hot-money-profile-detail-head-v2 h3 { margin: calc(var(--dragon-profile-gap) / 3) 0; color: var(--r2-ink); font-size: var(--dragon-profile-title-font); } +.hot-money-profile-section-v2 { padding: var(--dragon-profile-panel-padding); border-bottom: var(--dragon-profile-border-width) solid var(--r2-line-soft); } +.hot-money-profile-section-v2 h4 { margin: 0 0 calc(var(--dragon-profile-gap) / 2); color: var(--r2-ink); font-size: var(--dragon-profile-name-font); } +.hot-money-profile-section-v2 p { margin: 0; color: var(--r2-sub); font-size: var(--dragon-profile-body-font); line-height: var(--dragon-profile-body-line-height); white-space: pre-line; } +.hot-money-profile-section-v2 p.is-empty { color: var(--r2-faint); } +.hot-money-profile-section-title-v2 { display: flex; align-items: center; justify-content: space-between; } +.hot-money-profile-section-title-v2 span { color: var(--r2-faint); font-size: var(--dragon-profile-meta-font); } +.hot-money-profile-organizations-v2 { display: grid; gap: calc(var(--dragon-profile-gap) / 2); } +.hot-money-profile-organizations-v2 > span { + min-height: var(--dragon-profile-control-height); + display: flex; + align-items: center; + gap: calc(var(--dragon-profile-gap) / 2); + padding: 0 var(--dragon-profile-gap); + border: var(--dragon-profile-border-width) solid var(--r2-line-soft); + border-radius: calc(var(--r2-radius) - var(--dragon-profile-radius-inset)); + background: var(--r2-bg); + color: var(--r2-ink); + font-size: var(--dragon-profile-body-font); +} +.hot-money-profile-organizations-v2 .lucide { width: var(--dragon-profile-name-font); height: var(--dragon-profile-name-font); color: var(--r2-sub); } +.hot-money-profile-notice-v2 { margin: auto var(--dragon-profile-panel-padding) var(--dragon-profile-panel-padding); color: var(--r2-amber); font-size: var(--dragon-profile-meta-font); } +.hot-money-profile-empty-v2, +.hot-money-profile-list-empty-v2 { + min-height: var(--dragon-profile-detail-min-height); + display: grid; + place-items: center; + align-content: center; + gap: calc(var(--dragon-profile-gap) / 2); + color: var(--r2-faint); + text-align: center; +} +.hot-money-profile-list-empty-v2 { min-height: var(--dragon-profile-row-min-height); } +.hot-money-profile-empty-v2 .lucide, +.hot-money-profile-list-empty-v2 .lucide { width: var(--dragon-profile-avatar-size); height: var(--dragon-profile-avatar-size); stroke-width: var(--dragon-profile-icon-stroke); } +.hot-money-profile-list-empty-v2 .lucide { width: var(--dragon-profile-row-avatar-size); height: var(--dragon-profile-row-avatar-size); } +.hot-money-profile-empty-v2 strong, +.hot-money-profile-list-empty-v2 span { font-size: var(--dragon-profile-body-font); font-weight: var(--dragon-profile-weight-semibold); } + +@media (min-width: 721px) { + body[data-active-view="dragonView"] .hot-money-profiles-v2 { flex: 1 1 auto; } +} + +@media (max-width: 720px) { + .hot-money-profiles-v2 { overflow: visible; } + .hot-money-profile-toolbar-v2 { align-items: stretch; flex-direction: column; } + .hot-money-profile-summary-v2 { justify-content: space-between; } + .hot-money-profile-search-v2 { width: 100%; } + .hot-money-profile-workspace-v2 { grid-template-columns: minmax(0, 1fr); } + .hot-money-profile-list-v2 { max-height: var(--dragon-profile-list-max-height); } + .hot-money-profile-detail-v2 { min-height: 0; } + .hot-money-profile-detail-head-v2 { align-items: flex-start; } +} + +@media (prefers-reduced-motion: reduce) { + .hot-money-profile-row-v2 { transition: none; } +} + /* Stage 15: screener rebuilt from the approved reference layout. */ #screenerView { --scr-blue: #2563eb; diff --git a/static/theme.css b/static/theme.css new file mode 100644 index 0000000..a53b9d4 --- /dev/null +++ b/static/theme.css @@ -0,0 +1,1281 @@ +/* Runtime theme layer. Existing page geometry stays untouched; only semantic color tokens change. */ +:root { + --chart-background: #fbfcfd; + --chart-grid: #e2e8ec; + --chart-axis: #6c7983; + --chart-zero: #aeb7c1; + --chart-line: #1d65c1; + --chart-average: #b7791f; + --chart-up: #c93f45; + --chart-down: #087a55; + --chart-up-volume: rgba(201, 63, 69, .58); + --chart-down-volume: rgba(8, 122, 85, .58); + --chart-area: rgba(37, 99, 235, .07); + --chart-alert-area: rgba(224, 69, 54, .05); + --chart-moving-average: #d1d5db; + --chart-repair: #f59e0b; + --chart-ma-10: #a76500; + --chart-ma-20: #626c78; +} + +/* Theme changes are captured as one page-level transition. Descendant effects are + suppressed briefly so tables and cards cannot repaint on separate timelines. */ +:root.theme-switching *, +:root.theme-switching *::before, +:root.theme-switching *::after { + animation: none !important; + transition: none !important; +} + +::view-transition-old(root) { + animation: theme-fade-out var(--motion-medium) ease both; +} + +::view-transition-new(root) { + animation: theme-fade-in var(--motion-medium) ease both; +} + +@keyframes theme-fade-out { + to { opacity: 0; } +} + +@keyframes theme-fade-in { + from { opacity: 0; } +} + +:root[data-theme="dark"] { + color-scheme: dark; + --canvas: #121416; + --surface: #1b1e21; + --surface-muted: #202428; + --surface-subtle: #24282d; + --border: #343a40; + --border-strong: #474f57; + --text-primary: #e8eaed; + --text-secondary: #adb5bd; + --text-tertiary: #7f8993; + --action: #6ca8e8; + --action-hover: #8bbcf0; + --action-soft: #23364a; + --market-up: #f06d73; + --market-up-soft: #40262a; + --market-down: #43bc8a; + --market-down-soft: #1d382f; + --warning-color: #e2ad58; + --warning-soft: #3d3220; + /* Bridge every historical design-token layer to the same dark palette. */ + --xb-gray-25: var(--surface); + --xb-gray-50: var(--surface-muted); + --xb-gray-100: var(--canvas); + --xb-gray-200: var(--border); + --xb-gray-300: var(--border-strong); + --xb-gray-500: var(--text-secondary); + --xb-gray-700: #cbd1d7; + --xb-gray-900: var(--text-primary); + --xb-blue-50: var(--action-soft); + --xb-blue-100: #294866; + --xb-blue-500: var(--action); + --xb-blue-600: var(--action-hover); + --xb-red-50: var(--market-up-soft); + --xb-red-500: var(--market-up); + --xb-green-50: var(--market-down-soft); + --xb-green-500: var(--market-down); + --xb-amber-50: var(--warning-soft); + --xb-amber-500: var(--warning-color); + --surface-canvas: var(--canvas); + --surface-raised: var(--surface); + --surface-selected: var(--action-soft); + --card-bg: var(--surface); + --card-border: var(--border); + --primary: var(--action); + --primary-hover: var(--action-hover); + --danger: var(--market-up); + --success: var(--market-down); + --warning: var(--warning-color); + --r2-blue: var(--action); + --r2-blue-dark: var(--action-hover); + --r2-blue-soft: var(--action-soft); + --r2-blue-line: #42698e; + --r2-up: var(--market-up); + --r2-up-soft: var(--market-up-soft); + --r2-down: var(--market-down); + --r2-down-soft: var(--market-down-soft); + --r2-amber: var(--warning-color); + --r2-amber-soft: var(--warning-soft); + --r2-ink: var(--text-primary); + --r2-sub: var(--text-secondary); + --r2-faint: var(--text-tertiary); + --r2-line: var(--border); + --r2-line-soft: #2a2f34; + --r2-bg: var(--canvas); + --r2-card: var(--surface); + --r2-shadow: var(--shadow-soft); + --bg: var(--canvas); + --card: var(--surface); + --ink: var(--text-primary); + --sub: var(--text-secondary); + --faint: var(--text-tertiary); + --line: var(--border); + --line-soft: #2a2f34; + --line-strong: var(--border-strong); + --text: var(--text-primary); + --text-muted: var(--text-secondary); + --blue: var(--action); + --blue-d: var(--action-hover); + --blue-dark: var(--action-hover); + --blue-soft: var(--action-soft); + --blue-line: #42698e; + --up: var(--market-up); + --up-soft: var(--market-up-soft); + --down: var(--market-down); + --down-soft: var(--market-down-soft); + --coral: var(--market-up); + --coral-soft: var(--market-up-soft); + --green: var(--market-down); + --green-soft: var(--market-down-soft); + --amber: var(--warning-color); + --amber-soft: var(--warning-soft); + --dialog-ink: var(--text-primary); + --dialog-line: var(--border); + --heaven-paper: #191a18; + --heaven-paper-soft: #20211e; + --heaven-ink: #e5e0d4; + --heaven-muted: #aaa497; + --heaven-rule: #3d3a34; + --chart-background: #181b1e; + --chart-grid: #30363c; + --chart-axis: #a3adb6; + --chart-zero: #68737d; + --chart-line: #6ca8e8; + --chart-average: #e2ad58; + --chart-up: #f06d73; + --chart-down: #43bc8a; + --chart-up-volume: rgba(240, 109, 115, .52); + --chart-down-volume: rgba(67, 188, 138, .52); + --chart-area: rgba(108, 168, 232, .12); + --chart-alert-area: rgba(240, 109, 115, .09); + --chart-moving-average: #69737d; + --chart-repair: #e2ad58; + --chart-ma-10: #d39a45; + --chart-ma-20: #9aa5af; + --on-action: #101418; + --warning-line: #6d5a38; + --warning-line-strong: #66502d; + --control-shadow: 0 1px 3px rgba(0, 0, 0, .3); + --dialog-backdrop: rgba(0, 0, 0, .62); + --ladder-level-1: #2d2426; + --ladder-level-2: #2b2822; + --ladder-level-3: #252a2d; + --ladder-level-4: #20282b; + --ladder-level-5: #202428; + --heat-strong-bg: #304f7a; + --heat-strong-ink: #f2f6fb; + --heat-warm-bg: #2b405f; + --heat-warm-ink: #dfeaf7; + --heat-mild-bg: #293440; + --heat-mild-ink: #c7d2dc; + --heaven-field-bg: #23241f; + --shadow-soft: 0 1px 2px rgba(0, 0, 0, .28), 0 8px 24px rgba(0, 0, 0, .16); + --shadow: 0 18px 50px rgba(0, 0, 0, .46); +} + +:root[data-theme="dark"] :is(html, body, .main, .app-main) { + background: var(--canvas); + color: var(--text-primary); +} + +:root[data-theme="dark"] :is(.sidebar, .module-nav, .topbar, .app-header, .overview-strip, .statusbar, .status-bar) { + border-color: var(--border); + background: var(--surface); + color: var(--text-primary); + box-shadow: none; +} + +:root[data-theme="dark"] .app-header { + background: color-mix(in srgb, var(--surface) 96%, transparent); +} + +:root[data-theme="dark"] :is(.sidebar-brand, .sidebar .brand, .nav-group, .sidebar-collapse-button, .header-date-group) { + border-color: var(--line-soft); + background-color: var(--surface); +} + +:root[data-theme="dark"] :is(.module-tab, .sidebar-collapse-button, .nav-group-label) { + color: var(--text-secondary); +} + +:root[data-theme="dark"] .module-tab:hover { + background: var(--surface-muted); + color: var(--text-primary); +} + +:root[data-theme="dark"] .module-tab.active { + background: var(--action-soft); + color: var(--action); +} + +:root[data-theme="dark"] :is(.icon-button, .button, .btn, .tbtn, .date-input, .datepick, .search, .sidebar-collapse-button, input, textarea, select) { + border-color: var(--border); + background-color: var(--surface); + color: var(--text-primary); +} + +:root[data-theme="dark"] :is(input, textarea, select)::placeholder { + color: var(--text-tertiary); +} + +:root[data-theme="dark"] :is(.icon-button, .button, .btn, .tbtn):hover:not(:disabled) { + border-color: var(--blue-line); + background-color: var(--surface-muted); + color: var(--action); +} + +:root[data-theme="dark"] :is(.button.primary, .btn.primary, .tbtn.primary) { + border-color: var(--action); + background: var(--action); + color: var(--on-action); +} + +:root[data-theme="dark"] :is(.button.primary, .btn.primary, .tbtn.primary):hover:not(:disabled) { + border-color: var(--action-hover); + background: var(--action-hover); + color: var(--on-action); +} + +:root[data-theme="dark"] .theme-toggle { + color: var(--text-secondary); +} + +:root[data-theme="dark"] .theme-toggle[aria-pressed="true"] { + border-color: var(--warning-line); + background: var(--warning-soft); + color: var(--warning-color); +} + +:root[data-theme="dark"] :is(.header-command-group, .account-dropdown) { + border-color: var(--border); + background: var(--surface); + color: var(--text-primary); + box-shadow: var(--shadow); +} + +:root[data-theme="dark"] :is(.account-dropdown-head, .account-dropdown-separator) { + border-color: var(--line-soft); +} + +:root[data-theme="dark"] :is(.account-role-badge, #maxHeight, .pool-state-tag) { + border-color: var(--border); + background: var(--surface-subtle); + color: var(--text-secondary); +} + +:root[data-theme="dark"] :is(.account-role-badge.vip-role-badge, .pool-state-tag.one-word) { + border-color: var(--warning-line); + background: var(--warning-soft); + color: var(--warning-color); +} + +:root[data-theme="dark"] .account-role-badge.admin-role-badge { + border-color: var(--blue-line); + background: var(--action-soft); + color: var(--action); +} + +:root[data-theme="dark"] :is(.overview-strip[data-overview-expanded="true"], .overview-strip[data-overview-expanded="true"] .metric, .overview-strip[data-overview-expanded="true"] .sentiment-block) { + border-color: var(--line-soft); + background: var(--surface); +} + +:root[data-theme="dark"] :is(.workspace-view.page:not(#heavenView), .page) { + background: transparent; + color: var(--text-primary); +} + +:root[data-theme="dark"] :is( + .card, + .panel, + .metric-card, + .sentiment-panel, + .sentiment-stage-guide, + .sentiment-history-card, + .pool-card, + .performance-stage-card, + .breadth-card, + .market-ladder-tier, + .market-ladder-insight-card, + .rotation-trajectory-card, + .rotation-detail-card, + .auction-primary-card, + .auction-side-card, + .theme-directory-card-v2, + .theme-market-card-v2, + .theme-members-card-v2, + .popularity-table-card-v2, + .dragon-trader-detail, + .screener-workspace-card, + .curated-strategy-card, + .quant-factor-card, + .mentor-sidebar, + .mentor-chat-panel, + .review-panel, + .review-card +) { + border-color: var(--border); + background-color: var(--surface); + color: var(--text-primary); + box-shadow: var(--shadow-soft); +} + +/* Several migrated pages use id-scoped white shells with stronger selectors. */ +:root[data-theme="dark"] :is( + #limitPool, + #brokenView, + #downView, + #yesterdayView, + #performanceView, + #sentimentCycleView, + #ladderView, + #rotationView, + #auctionView, + #themeLibraryView, + #popularityView, + #dragonView, + #screenerView, + #mentorView, + #reviewWorkspaceView +) :is(.table-frame, .tbl-wrap, .redesigned-card, .panel, .card) { + border-color: var(--border); + background-color: var(--surface); + color: var(--text-primary); +} + +:root[data-theme="dark"] :is( + .card-h, + .panel-header, + .sentiment-panel-header, + .pool-card-header, + .performance-stage-card header, + .rotation-card-head, + .auction-card-head-v2, + .theme-card-head-v2, + .popularity-card-head-v2, + .dragon-detail-header, + .screener-card-head, + .mentor-page-header, + .review-section-heading +) { + border-color: var(--line-soft); + background-color: transparent; + color: var(--text-primary); +} + +:root[data-theme="dark"] :is(.sub, .muted, .dtag, .table-muted, small, .empty-state, .empty-box, .auxiliary-copy) { + color: var(--text-secondary); +} + +:root[data-theme="dark"] :is(.dtag, .tag.neu, .screener-soft-label) { + border-color: var(--border); + background: var(--surface-muted); + color: var(--text-secondary); +} + +:root[data-theme="dark"] :is(.seg, .segmented, .filter-segment, .market-feature-segments) { + border-color: var(--border); + background: var(--surface-muted); +} + +:root[data-theme="dark"] :is(.seg button.on, .seg button.active, .segment.active, .filter-segment button.active) { + background: var(--surface-subtle); + color: var(--text-primary); + box-shadow: var(--control-shadow); +} + +:root[data-theme="dark"] :is(table, .data-table, .tbl) { + color: var(--text-primary); +} + +:root[data-theme="dark"] :is(table thead th, .data-table thead th, .tbl thead th) { + border-color: var(--border); + background: var(--surface-muted); + color: var(--text-secondary); +} + +:root[data-theme="dark"] :is(table tbody td, .data-table tbody td, .tbl tbody td) { + border-color: var(--line-soft); + background-color: transparent; +} + +:root[data-theme="dark"] :is(table tbody tr:hover td, .data-table tbody tr:hover td, .tbl tbody tr:hover td) { + background: var(--action-soft); +} + +:root[data-theme="dark"] :is(dialog, .stock-dialog, .settings-dialog, .global-search-dialog, .curated-detail-dialog, .strategy-drawer) { + border-color: var(--border-strong); + background: var(--surface); + color: var(--text-primary); + box-shadow: var(--shadow); +} + +:root[data-theme="dark"] dialog::backdrop { + background: var(--dialog-backdrop); +} + +:root[data-theme="dark"] dialog kbd { + border-color: var(--border); + background: var(--surface-subtle); + color: var(--text-secondary); +} + +:root[data-theme="dark"] .settings-dialog :is(.dialog-header h2, .settings-section-heading h3) { + color: var(--text-primary); +} + +:root[data-theme="dark"] .settings-dialog :is( + .form-field > span, + .settings-section-heading > span, + .form-hint, + .dialog-eyebrow +) { + color: var(--text-secondary); +} + +:root[data-theme="dark"] .settings-dialog .connection-status.connected { + border-color: var(--market-down); + background: var(--market-down-soft); + color: var(--market-down); +} + +:root[data-theme="dark"] .settings-dialog .dialog-header .icon-button { + color: var(--text-secondary); +} + +:root[data-theme="dark"] :is(.dialog-header, .global-search-head, .settings-section, .detail-section, .stock-chart-section) { + border-color: var(--line-soft); + background-color: var(--surface); +} + +:root[data-theme="dark"] :is(.global-search-results, .assistant-messages, .trade-log-form, .alerts-toolbar, .admin-section-picker) { + border-color: var(--line-soft); + background: var(--surface-muted); +} + +:root[data-theme="dark"] :is(.global-search-result:hover, .global-search-result.is-active) { + background: var(--action-soft); + color: var(--action); +} + +:root[data-theme="dark"] :is(.global-search-result-icon, .membership-status-grid > div, .model-row) { + border-color: var(--border); + background: var(--surface-subtle); +} + +/* Market cycle and pool pages. */ +:root[data-theme="dark"] :is(.sentiment-cycle-toolbar, .sentiment-history-toolbar, .pool-toolbar, .tbl-tools) { + border-color: var(--line-soft); + background: var(--surface); +} + +:root[data-theme="dark"] :is(.sentiment-current-card, .sentiment-score-card, .sentiment-stage-guide-grid article) { + border-color: var(--border); + background: var(--surface-subtle); +} + +:root[data-theme="dark"] .sentiment-stage-guide-grid article.current { + border-color: var(--warning-color); + background: var(--warning-soft); +} + +:root[data-theme="dark"] :is(.sentiment-warning, .screener-warning, .auction-phase-notice) { + border-color: var(--warning-line-strong); + background: var(--warning-soft); + color: var(--warning-color); +} + +:root[data-theme="dark"] :is(.res-sum, .res-sum .cell, .pool-summary-card, .performance-card) { + border-color: var(--line-soft); + background: var(--surface); +} + +:root[data-theme="dark"] .res-sum .cell:hover, +:root[data-theme="dark"] .res-sum .cell.on { + background: var(--action-soft); +} + +/* Ladder, rotation and auction retain distinct semantic levels without bright paper blocks. */ +:root[data-theme="dark"] .market-ladder-tier:nth-child(1) { background: var(--ladder-level-1); } +:root[data-theme="dark"] .market-ladder-tier:nth-child(2) { background: var(--ladder-level-2); } +:root[data-theme="dark"] .market-ladder-tier:nth-child(3) { background: var(--ladder-level-3); } +:root[data-theme="dark"] .market-ladder-tier:nth-child(4) { background: var(--ladder-level-4); } +:root[data-theme="dark"] .market-ladder-tier:nth-child(5) { background: var(--ladder-level-5); } + +:root[data-theme="dark"] :is(.market-ladder-stock, .rotation-day, .rotation-table-frame, .auction-table-frame-v2) { + border-color: var(--border); + background: var(--surface-subtle); +} + +:root[data-theme="dark"] .rotation-sector-chip.heat-strong { background: var(--heat-strong-bg); color: var(--heat-strong-ink); } +:root[data-theme="dark"] .rotation-sector-chip.heat-warm { background: var(--heat-warm-bg); color: var(--heat-warm-ink); } +:root[data-theme="dark"] .rotation-sector-chip.heat-mild { background: var(--heat-mild-bg); color: var(--heat-mild-ink); } + +:root[data-theme="dark"] :is(.auction-tabs-v2, .auction-summary-v2, .auction-theme-row, .auction-amount-day, .auction-news-entry) { + border-color: var(--line-soft); + background: var(--surface); +} + +:root[data-theme="dark"] :is(.auction-dataset-button.active, .auction-expectation-controls button.active) { + background: var(--surface-subtle); + color: var(--action); +} + +/* Theme library, popularity and dragon-tiger. */ +:root[data-theme="dark"] :is(.theme-summary-v2, .theme-directory-labels-v2, .theme-detail-metrics-v2, .theme-chart-heading-v2) { + border-color: var(--line-soft); + background: var(--surface-muted); +} + +:root[data-theme="dark"] .theme-directory-item-v2:hover, +:root[data-theme="dark"] .theme-directory-item-v2.active { + background: var(--action-soft); +} + +:root[data-theme="dark"] #popularityView .popularity-glance-v2 { + background: var(--canvas); +} + +:root[data-theme="dark"] #popularityView .popularity-glance-v2 article { + border-color: var(--border); + background: var(--surface); +} + +:root[data-theme="dark"] .dragon-trader-card { + border-color: color-mix(in srgb, var(--card-accent) 54%, var(--border)); + background: linear-gradient(155deg, var(--surface-subtle), color-mix(in srgb, var(--card-accent) 13%, var(--surface))); + color: var(--text-primary); +} + +:root[data-theme="dark"] :is(.dragon-card-copy strong, .dragon-card-copy q, .dragon-card-stats small) { + color: var(--text-secondary); +} + +/* Screener, mentor and review workspaces. */ +:root[data-theme="dark"] :is(.screener-tabs, .screener-progress, .screener-result-frame, .screener-pipeline, .screener-backtest-strip) { + border-color: var(--line-soft); + background: var(--surface); +} + +:root[data-theme="dark"] :is(.curated-strategy-card:hover, .curated-strategy-card.selected, .quant-factor-row:hover) { + border-color: var(--blue-line); + background: var(--action-soft); +} + +:root[data-theme="dark"] :is(.strategy-drawer-sidebar, .strategy-sidebar, .strategy-drawer-content) { + border-color: var(--line-soft); + background: var(--surface-muted); +} + +:root[data-theme="dark"] :is(.mentor-option, .mentor-message-content, .assistant-message-content, .review-watchlist-card, .daily-review-form) { + border-color: var(--border); + background: var(--surface-subtle); + color: var(--text-primary); +} + +:root[data-theme="dark"] .mentor-option:hover, +:root[data-theme="dark"] .mentor-option.active { + background: var(--action-soft); +} + +:root[data-theme="dark"] :is(.mentor-composer, .assistant-form, .review-form, .trade-log-summary) { + border-color: var(--line-soft); + background: var(--surface); +} + +/* Question-to-Heaven keeps its visual identity while following the selected luminance. */ +:root[data-theme="dark"] #heavenView, +:root[data-theme="dark"] #heavenView .heaven-panel, +:root[data-theme="dark"] #heavenView .heart-stage, +:root[data-theme="dark"] .heaven-reading-dialog { + border-color: var(--heaven-rule); + background-color: var(--heaven-paper); + color: var(--heaven-ink); +} + +:root[data-theme="dark"] #heavenView :is(.heaven-subnav, .heaven-card, .heaven-calibration-panel, .heart-ritual-card) { + border-color: var(--heaven-rule); + background-color: var(--heaven-paper-soft); + color: var(--heaven-ink); +} + +:root[data-theme="dark"] #heavenView :is(input, select, textarea, .heaven-field-control) { + border-color: var(--heaven-rule); + background: var(--heaven-field-bg); + color: var(--heaven-ink); +} + +:root[data-theme="dark"] :is(.price-chart, .stock-preview-chart, .entity-detail-chart, .theme-chart-shell-v2) { + border-color: var(--border); + background: var(--chart-background); +} + +/* Page-level controls that still carry prototype-local light fills. */ +:root[data-theme="dark"] :is(#auctionView, #screenerView) { + --action: #6ca8e8; + --action-hover: #8bbcf0; + --action-soft: #23364a; + --border: #343a40; + --border-strong: #474f57; + --line: var(--border); + --line-strong: var(--border-strong); + --surface-muted: #202428; + border-color: var(--border); + background: var(--canvas); + color: var(--text-primary); +} + +:root[data-theme="dark"] #sentimentCycleView :is( + .sentiment-current-tag, + .sentiment-auto-tag, + .sentiment-stage-guide-head, + .sentiment-detail-toolbar, + .section-toolbar +) { + border-color: var(--border); + background: var(--surface-muted); + color: var(--text-secondary); +} + +:root[data-theme="dark"] #sentimentCycleView .sentiment-component-track { + background: var(--surface-subtle); +} + +:root[data-theme="dark"] #sentimentCycleView .sentiment-phase-badge.phase-ice, +:root[data-theme="dark"] #sentimentCycleView .sentiment-phase-badge.phase-repair { + background: var(--action-soft); + color: var(--action); +} + +:root[data-theme="dark"] #sentimentCycleView .sentiment-phase-badge.phase-fermentation { + background: var(--market-down-soft); + color: var(--market-down); +} + +:root[data-theme="dark"] #sentimentCycleView .sentiment-phase-badge.phase-climax { + background: var(--market-up-soft); + color: var(--market-up); +} + +:root[data-theme="dark"] #sentimentCycleView .sentiment-phase-badge.phase-divergence { + background: var(--warning-soft); + color: var(--warning-color); +} + +:root[data-theme="dark"] #sentimentCycleView .sentiment-phase-badge.phase-retreat { + background: var(--surface-subtle); + color: var(--text-secondary); +} + +/* Keep the final history row clear of the frame's horizontal scrollbar. */ +#sentimentCycleView .sentiment-history-frame { + margin-bottom: var(--card-gap); + padding-bottom: var(--card-gap); +} + +:root[data-theme="dark"] :is( + #brokenView .pool-search-field, + #downView .pool-search-field, + #yesterdayView .pool-search-field +) { + border-color: var(--border); + background: var(--surface); +} + +:root[data-theme="dark"] :is( + #brokenView .pool-search-field input, + #downView .pool-search-field input, + #yesterdayView .pool-search-field input +) { + background: transparent; + color: var(--text-primary); +} + +:root[data-theme="dark"] #yesterdayView :is(.yesterday-table-card, .yesterday-summary-cell) { + border-color: var(--border); + background: var(--surface); + color: var(--text-primary); +} + +:root[data-theme="dark"] #yesterdayView .yesterday-summary-cell:hover, +:root[data-theme="dark"] #yesterdayView .yesterday-summary-cell.active { + background: var(--action-soft); +} + +:root[data-theme="dark"] #yesterdayView .yesterday-outcome-tag.fail { + border-color: var(--border); + background: var(--surface-muted); + color: var(--text-secondary); +} + +:root[data-theme="dark"] #performanceView :is( + .performance-status-tag.is-neutral, + .performance-date-tag, + .performance-stage-track, + .performance-width-bar +) { + border-color: var(--border); + background: var(--surface-muted); + color: var(--text-secondary); +} + +:root[data-theme="dark"] #performanceView :is( + .performance-empty-state, + .performance-panel-card, + .market-breadth-panel +) { + border-color: var(--border); + background: var(--surface); + color: var(--text-primary); +} + +:root[data-theme="dark"] #ladderView :is(.ladder-sort-segment, .market-ladder-board) { + border-color: var(--border); + background: var(--surface); + color: var(--text-primary); +} + +:root[data-theme="dark"] #ladderView .ladder-sort-segment { + background: var(--surface-muted); +} + +:root[data-theme="dark"] #ladderView .ladder-sort-segment button.active { + background: var(--surface-subtle); + color: var(--text-primary); + box-shadow: var(--control-shadow); +} + +:root[data-theme="dark"] #ladderView .market-ladder-label { + border-color: var(--line-soft); + background: color-mix(in srgb, var(--tier-color) 12%, var(--surface-subtle)); +} + +:root[data-theme="dark"] #ladderView .market-ladder-tier.is-gap .market-ladder-label { + background: var(--surface-muted); +} + +:root[data-theme="dark"] #ladderView :is( + .market-ladder-insight-card > header span, + .market-ladder-pyramid-row > i, + .market-ladder-rate-list > div > i +) { + background: var(--surface-muted); + color: var(--text-secondary); +} + +:root[data-theme="dark"] #rotationView :is( + .rotation-order-control, + .rotation-export-button, + .rotation-top-tag, + .rotation-trajectory-card, + .rotation-detail-card, + .rotation-day, + .rotation-day > header, + .rotation-day-sectors +) { + border-color: var(--border); + background: var(--surface); + color: var(--text-primary); +} + +:root[data-theme="dark"] #rotationView :is(.rotation-order-control, .rotation-day > header, .rotation-top-tag) { + background: var(--surface-muted); + color: var(--text-secondary); +} + +:root[data-theme="dark"] #rotationView .rotation-day-sectors { + background: var(--surface-subtle); +} + +:root[data-theme="dark"] #rotationView .rotation-order-control button.active { + background: var(--surface-subtle); + color: var(--text-primary); + box-shadow: var(--control-shadow); +} + +:root[data-theme="dark"] #rotationView .rotation-sector-chip.heat-strong { + border-color: var(--blue-line); + background: var(--heat-strong-bg); + color: var(--heat-strong-ink); +} + +:root[data-theme="dark"] #rotationView .rotation-sector-chip.heat-warm { + border-color: var(--border-strong); + background: var(--heat-warm-bg); + color: var(--heat-warm-ink); +} + +:root[data-theme="dark"] #rotationView .rotation-sector-chip.heat-mild { + border-color: var(--border); + background: var(--heat-mild-bg); + color: var(--heat-mild-ink); +} + +:root[data-theme="dark"] #rotationView :is( + .rotation-rank, + .rotation-table thead th, + .trend-flat +) { + border-color: var(--border); + background: var(--surface-muted); + color: var(--text-secondary); +} + +:root[data-theme="dark"] #rotationView .rotation-table tbody tr:hover td { + background: var(--action-soft); +} + +:root[data-theme="dark"] #rotationView :is( + .rotation-swatch.warm, + .trend-tag.trend-cool +) { + border-color: var(--blue-line); + background: var(--action-soft); + color: var(--action); +} + +:root[data-theme="dark"] #rotationView :is( + .rotation-swatch.mild, + .strength-cell +) { + border-color: var(--border); + background: var(--surface-muted); +} + +:root[data-theme="dark"] #auctionView :is( + .auction-phase-notice-v2, + .auction-export-button, + .auction-refresh-button, + .auction-filter-segments, + .auction-table-v2 thead th +) { + border-color: var(--border); + background: var(--surface-muted); + color: var(--text-secondary); +} + +:root[data-theme="dark"] #auctionView .auction-phase-notice-v2[data-phase="selection"] { + border-color: var(--warning-line); + background: var(--warning-soft); +} + +:root[data-theme="dark"] #auctionView .auction-phase-notice-v2[data-phase="finalized"] { + border-color: var(--market-down-soft); + background: var(--market-down-soft); +} + +:root[data-theme="dark"] #auctionView :is(.auction-filter-segments button.active, .auction-source-tags-v2 b) { + background: var(--surface-subtle); + color: var(--text-primary); +} + +:root[data-theme="dark"] #auctionView .auction-source-tags-v2 b:nth-child(n + 2) { + background: var(--action-soft); + color: var(--action); +} + +:root[data-theme="dark"] #auctionView .auction-table-v2 tbody tr:hover td { + background: var(--action-soft); +} + +:root[data-theme="dark"] #auctionView :is(.auction-expectation.matched, .auction-card-tag) { + background: var(--surface-subtle); + color: var(--text-secondary); +} + +:root[data-theme="dark"] #auctionView :is(.auction-theme-status.steady, .auction-theme-status.strong) { + background: var(--action-soft); + color: var(--action); +} + +:root[data-theme="dark"] #auctionView .auction-amount-trend-v2 small { + background: var(--surface); + color: var(--text-secondary); +} + +:root[data-theme="dark"] #themeLibraryView :is( + .theme-search-v2, + .theme-summary-v2, + .theme-detail-empty-v2, + #themeResultCount, + #themeDetailCode +) { + border-color: var(--border); + background: var(--surface-muted); + color: var(--text-secondary); +} + +:root[data-theme="dark"] #popularityView .popularity-source-tabs-v2, +:root[data-theme="dark"] #dragonView :is(.dragon-view-tabs-v2, .dragon-segments-v2) { + border-color: var(--border); + background: var(--surface-muted); +} + +:root[data-theme="dark"] #popularityView .popularity-source-tabs-v2 button.active, +:root[data-theme="dark"] #dragonView :is(.dragon-view-tabs-v2 button.active, .dragon-filter-v2.active) { + background: var(--surface-subtle); + color: var(--text-primary); + box-shadow: var(--control-shadow); +} + +:root[data-theme="dark"] #dragonView :is( + .dragon-summary-v2, + .dragon-filterbar-v2, + .dragon-stage-heading-v2, + .dragon-empty-state-v2, + .dragon-table-v2 thead th +) { + border-color: var(--border); + background: var(--surface); + color: var(--text-primary); +} + +:root[data-theme="dark"] #dragonView .dragon-table-v2 thead th { + background: var(--surface-muted); + color: var(--text-secondary); +} + +:root[data-theme="dark"] #dragonView thead th { + border-color: var(--border); + background: var(--surface-muted) !important; + color: var(--text-secondary); +} + +:root[data-theme="dark"] #screenerView :is( + .screener-mode-tabs, + .screener-stepper, + .regime-selector, + .regime-option, + .screener-runbar, + .screener-results-view, + .result-toolbar +) { + border-color: var(--border); + background: var(--surface); + color: var(--text-primary); +} + +:root[data-theme="dark"] #screenerView :is(.screener-soft-label, .neutral) { + background: var(--surface-muted); + color: var(--text-secondary); +} + +:root[data-theme="dark"] #screenerView :is(.regime-summary, .regime-option.active) { + border-color: var(--market-up-soft); + background: var(--market-up-soft); + color: var(--market-up); +} + +:root[data-theme="dark"] #screenerView :is(.regime-advice, .screener-backtest-strip) { + border-color: var(--warning-line); + background: var(--warning-soft); + color: var(--warning-color); +} + +:root[data-theme="dark"] #screenerView .screener-tracking-entry { + border-color: var(--blue-line); + background: var(--action-soft); + color: var(--action); +} + +:root[data-theme="dark"] #screenerView :is(.count-badge, .screener-result-source, b.neutral) { + background: var(--surface-muted); + color: var(--text-secondary); +} + +:root[data-theme="dark"] #screenerView .screener-strategy-title b:not(.neutral) { + background: var(--market-up-soft) !important; + color: var(--market-up) !important; +} + +:root[data-theme="dark"] #screenerView .screener-strategy-title b.neutral { + background: var(--surface-muted) !important; + color: var(--text-secondary) !important; +} + +:root[data-theme="dark"] #screenerView .screener-card-heading h3 > span { + color: var(--action); +} + +:root[data-theme="dark"] #screenerView :is(.regime-evidence-line, .reason-column) { + color: var(--text-secondary); +} + +:root[data-theme="dark"] #screenerView .risk-cell { + color: var(--warning-color); +} + +:root[data-theme="dark"] #screenerView .table-action { + color: var(--action); +} + +:root[data-theme="dark"] #screenerView #regimeLabel { + color: var(--market-up); +} + +:root[data-theme="dark"] #screenerView :is(thead th, tbody td) { + border-color: var(--line-soft); + background-color: transparent !important; +} + +:root[data-theme="dark"] #screenerView thead th { + background-color: var(--surface-muted) !important; + color: var(--text-secondary); +} + +:root[data-theme="dark"] #screenerView :is( + .curated-library-heading, + .curated-library-controls, + .curated-search, + .curated-category-filters button, + .curated-strategy-card, + .curated-strategy-rank, + .curated-card-tags em, + .curated-card-actions button, + .quant-builder-pane, + .quant-summary-pane, + .quant-panel-heading, + .quant-rule-row, + .quant-weight-control, + .quant-weight-status, + .quant-summary-block +) { + border-color: var(--border); + background: var(--surface); + color: var(--text-primary); +} + +:root[data-theme="dark"] #screenerView :is( + .curated-category-filters button.active, + .curated-strategy-card.active, + .curated-card-tags em, + .curated-strategy-rank, + .quant-weight-status +) { + background: var(--surface-muted); +} + +:root[data-theme="dark"] #screenerView :is( + .quant-rule-row select, + .quant-rule-row input, + .quant-universe-grid input, + .quant-universe-grid select +) { + border-color: var(--border); + background: var(--surface-subtle); + color: var(--text-primary); +} + +:root[data-theme="dark"] #screenerView :is( + #curatedStrategyCount, + .quant-panel-heading .button, + .quant-summary-pane > header, + .quant-summary-pane > header .button, + .quant-weight-status > div +) { + border-color: var(--border); + background: var(--surface-muted); + color: var(--text-secondary); +} + +:root[data-theme="dark"] #mentorView { + --mentor-blue: var(--action); + --mentor-blue-dark: var(--action-hover); + --mentor-blue-soft: var(--action-soft); + --mentor-blue-line: var(--blue-line); + --mentor-line: var(--border); + --mentor-line-soft: var(--line-soft); + --mentor-ink: var(--text-primary); + --mentor-sub: var(--text-secondary); + --mentor-faint: var(--text-tertiary); +} + +:root[data-theme="dark"] #mentorView :is( + .mentor-evidence-filters, + .mentor-directory-content, + .mentor-directory-heading, + .mentor-search-field, + .mentor-chat-panel, + .mentor-composer, + .mentor-option +) { + border-color: var(--border); + background: var(--surface); + color: var(--text-primary); +} + +:root[data-theme="dark"] #mentorView :is(.mentor-evidence-filters button.active, .mentor-option.active) { + background: var(--action-soft); + color: var(--action); +} + +:root[data-theme="dark"] #mentorView :is(.mentor-badge.quality, .mentor-badge.private, .mentor-pin-button) { + background: var(--surface-muted); + color: var(--text-secondary); +} + +:root[data-theme="dark"] #mentorView .mentor-badge.grade-a { + background: var(--market-down-soft); + color: var(--market-down); +} + +:root[data-theme="dark"] #mentorView .mentor-badge.grade-b { + background: var(--action-soft); + color: var(--action); +} + +:root[data-theme="dark"] #mentorView .mentor-badge.grade-c { + background: var(--warning-soft); + color: var(--warning-color); +} + +:root[data-theme="dark"] #mentorView :is( + .mentor-chat-header, + .mentor-messages, + .mentor-quick-prompts, + .mentor-chat-form, + .mentor-disclaimer, + .mentor-quick-prompts button, + #mentorQuestion +) { + border-color: var(--border); + background: var(--surface); + color: var(--text-primary); +} + +:root[data-theme="dark"] #mentorView :is(.mentor-messages, .mentor-empty-mark) { + background: var(--surface-muted); +} + +:root[data-theme="dark"] #mentorView .mentor-message { + border-color: var(--line-soft); + background: var(--surface-subtle); + box-shadow: none; +} + +:root[data-theme="dark"] #mentorView .mentor-message.user { + border-color: var(--blue-line); + background: var(--action-soft); +} + +:root[data-theme="dark"] #reviewWorkspaceView { + --review-blue: var(--action); + --review-blue-dark: var(--action-hover); + --review-blue-soft: var(--action-soft); + --review-blue-line: var(--blue-line); + --review-line: var(--border); + --review-line-soft: var(--line-soft); + --review-ink: var(--text-primary); + --review-sub: var(--text-secondary); + --review-faint: var(--text-tertiary); +} + +:root[data-theme="dark"] #reviewWorkspaceView :is( + .review-history-toggle, + .review-card-heading, + .review-count-tag, + .review-add-watch, + .trade-log-summary, + .workspace-table-frame thead th +) { + border-color: var(--border); + background: var(--surface-muted); + color: var(--text-secondary); +} + +:root[data-theme="dark"] #reviewWorkspaceView .review-add-watch { + border-color: var(--blue-line); + background: var(--action-soft); + color: var(--action); +} + +:root[data-theme="dark"] #reviewWorkspaceView :is(thead th, tbody td) { + border-color: var(--line-soft); + background-color: transparent !important; +} + +:root[data-theme="dark"] #reviewWorkspaceView thead th { + background-color: var(--surface-muted) !important; + color: var(--text-secondary); +} + +:root[data-theme="dark"] #reviewWorkspaceView :is( + .journal-form, + .journal-form input, + .journal-form textarea, + #journalDate +) { + border-color: var(--border); + background: var(--surface); + color: var(--text-primary); +} + +:root[data-theme="dark"] #reviewWorkspaceView :is(.notes-history-section, .notes-history) { + border-color: var(--border); + background: var(--surface); + color: var(--text-primary); +} + +:root[data-theme="dark"] :is( + .watchlist-search-control, + .watchlist-search-results, + .watchlist-search-results button, + .watchlist-selection +) { + border-color: var(--border); + background: var(--surface); + color: var(--text-primary); +} + +:root[data-theme="dark"] .watchlist-search-control:focus-within { + border-color: var(--blue-line); + box-shadow: var(--control-shadow); +} + +:root[data-theme="dark"] .watchlist-search-results button:hover { + background: var(--action-soft); +} + +:root[data-theme="dark"] .account-settings-dialog :is( + .membership-comparison, + .membership-comparison > div, + .membership-comparison-head +) { + border-color: var(--border); + background: var(--surface); + color: var(--text-primary); +} + +:root[data-theme="dark"] .account-settings-dialog .membership-comparison > .membership-comparison-head { + background: var(--surface-muted); + color: var(--text-secondary); +} + +/* Wentian v2 owns its complete palette in wentian-v2.css. Keeping the former + paper-theme overrides here would repaint its controls and ritual stages. */ + +:root[data-theme="dark"] :is(.loading-overlay, .auth-gate) { + background: color-mix(in srgb, var(--canvas) 92%, transparent); +} + +:root[data-theme="dark"] :is(.loading-card, .auth-shell) { + border-color: var(--border-strong); + background: var(--surface); + color: var(--text-primary); + box-shadow: var(--shadow); +} + +@media (prefers-reduced-motion: reduce) { + .theme-toggle svg { transition: none; } + + ::view-transition-old(root), + ::view-transition-new(root) { + animation: none; + } +} diff --git a/static/wentian-v2.css b/static/wentian-v2.css new file mode 100644 index 0000000..4256201 --- /dev/null +++ b/static/wentian-v2.css @@ -0,0 +1,1086 @@ +#heavenView { + color-scheme: dark; + --wt-bg: #0b1120; + --wt-panel: #0d1526; + --wt-panel-raised: #101a30; + --wt-text: #d8d2bd; + --wt-paper: #e8e2cd; + --wt-gold: #c9a55c; + --wt-gold-bright: #e3c887; + --wt-cinnabar: #d76b61; + --wt-green: #7ea87e; + --wt-line: rgba(201, 165, 92, 0.18); + --wt-line-soft: rgba(255, 255, 255, 0.06); + --wt-muted: rgba(216, 210, 189, 0.55); + --wt-faint: rgba(216, 210, 189, 0.35); + --wt-stage-bg: radial-gradient(1200px 500px at 50% -10%, #16223f 0%, #0b1120 60%); + --wt-fortune-stage-bg: radial-gradient(900px 440px at 32% 44%, #16223f 0%, #0b1120 66%); + --wt-heart-stage-bg: radial-gradient(1000px 520px at 50% 18%, #16223f 0%, #0b1120 65%); + --wt-stage-veil: rgba(8, 14, 27, 0.28); + --wt-surface: rgba(13, 21, 38, 0.72); + --wt-surface-raised: rgba(16, 26, 48, 0.78); + --wt-surface-soft: rgba(13, 21, 38, 0.55); + --wt-control-bg: rgba(255, 255, 255, 0.045); + --wt-control-border: rgba(201, 165, 92, 0.25); + --wt-control-hover: rgba(201, 165, 92, 0.09); + --wt-button-border: rgba(255, 255, 255, 0.14); + --wt-panel-gradient: linear-gradient(160deg, rgba(16, 26, 48, 0.9), rgba(13, 21, 38, 0.9)); + --wt-star: #e8e2cd; + --wt-hexagram-ink: #cfc8ad; + --wt-track: rgba(255, 255, 255, 0.07); + --wt-notice-bg: rgba(215, 107, 97, 0.08); + --wt-notice-border: rgba(215, 107, 97, 0.35); + --wt-notice-text: #efb0a9; + --wt-loading-bg: rgba(13, 21, 38, 0.96); + --wt-backdrop: rgba(4, 8, 16, 0.72); + --wt-dialog-shadow: 0 30px 90px rgba(0, 0, 0, 0.48); + --wt-focus-ring: rgba(201, 165, 92, 0.12); + --wt-gold-glow: rgba(230, 195, 122, 0.42); + --wt-soft-gold: rgba(201, 165, 92, 0.08); + --wt-whisper: rgba(216, 210, 189, 0.13); + --wt-breath-center: radial-gradient(circle, rgba(230, 195, 122, 0.6), rgba(201, 165, 92, 0.06) 65%, transparent 70%); + --wt-breath-shadow: 0 0 32px rgba(201, 165, 92, 0.28); + --wt-cast-rest: rgba(255, 255, 255, 0.03); + --wt-coin-hole: #0b1120; + --wt-metal-text: #e3ddc9; + --wt-history-bg: rgba(255, 255, 255, 0.02); + --ease-out: cubic-bezier(.2, .75, .3, 1); + --heaven-serif: "Kaiti SC", "STKaiti", "KaiTi", "STSong", "SimSun", serif; +} + +/* Wentian daytime palette: cool paper, ink typography and restrained bronze. + The animation geometry is shared with night mode; only material and light + semantics change. */ +:root[data-theme="light"] #heavenView { + color-scheme: light; + --wt-bg: #f4f5f7; + --wt-panel: #ffffff; + --wt-panel-raised: #fbfaf6; + --wt-text: #343a43; + --wt-paper: #202934; + --wt-gold: #946b1d; + --wt-gold-bright: #765315; + --wt-cinnabar: #b94f46; + --wt-green: #4f7b5a; + --wt-line: rgba(132, 101, 39, 0.22); + --wt-line-soft: rgba(52, 58, 67, 0.09); + --wt-muted: rgba(52, 58, 67, 0.74); + --wt-faint: rgba(52, 58, 67, 0.52); + --wt-stage-bg: radial-gradient(1200px 500px at 50% -10%, #ffffff 0%, #f4f2eb 58%, #eef1f4 100%); + --wt-fortune-stage-bg: radial-gradient(900px 440px at 32% 44%, #fffefa 0%, #f3f1e9 58%, #edf1f4 100%); + --wt-heart-stage-bg: radial-gradient(1000px 520px at 50% 18%, #fffefa 0%, #f3f1e9 56%, #edf1f4 100%); + --wt-stage-veil: rgba(255, 255, 255, 0.34); + --wt-surface: rgba(255, 255, 255, 0.78); + --wt-surface-raised: rgba(248, 247, 242, 0.9); + --wt-surface-soft: rgba(247, 246, 241, 0.74); + --wt-control-bg: rgba(255, 255, 255, 0.76); + --wt-control-border: rgba(132, 101, 39, 0.26); + --wt-control-hover: rgba(148, 107, 29, 0.08); + --wt-button-border: rgba(52, 58, 67, 0.16); + --wt-panel-gradient: linear-gradient(160deg, rgba(255, 255, 255, 0.94), rgba(246, 244, 237, 0.94)); + --wt-star: #8e7440; + --wt-hexagram-ink: #645d4d; + --wt-track: rgba(52, 58, 67, 0.1); + --wt-notice-bg: rgba(185, 79, 70, 0.07); + --wt-notice-border: rgba(185, 79, 70, 0.28); + --wt-notice-text: #914039; + --wt-loading-bg: rgba(255, 255, 255, 0.96); + --wt-backdrop: rgba(41, 47, 56, 0.26); + --wt-dialog-shadow: 0 18px 52px rgba(31, 41, 55, 0.18); + --wt-focus-ring: rgba(148, 107, 29, 0.13); + --wt-gold-glow: rgba(148, 107, 29, 0.2); + --wt-soft-gold: rgba(148, 107, 29, 0.08); + --wt-whisper: rgba(52, 58, 67, 0.2); + --wt-breath-center: radial-gradient(circle, rgba(180, 130, 36, 0.42), rgba(148, 107, 29, 0.05) 65%, transparent 70%); + --wt-breath-shadow: 0 0 32px rgba(148, 107, 29, 0.16); + --wt-cast-rest: rgba(52, 58, 67, 0.05); + --wt-coin-hole: #343127; + --wt-metal-text: #6d6658; + --wt-history-bg: #fbfaf6; +} + +@media (min-width: 721px) { + :root[data-theme="light"] body[data-active-view="heavenView"] { + --wt-bg: #f4f5f7; + } + + :root[data-theme="light"] body[data-active-view="heavenView"] .app-main, + :root[data-theme="light"] body[data-active-view="heavenView"] #heavenView.heaven-shell { + background: var(--wt-bg); + } +} + +:root[data-theme="light"] #heavenView .wt-head .verse { + color: var(--wt-faint); +} + +:root[data-theme="light"] #heavenView .wt-tabs .wt-tab { + color: var(--wt-muted); +} + +:root[data-theme="light"] #heavenView .wt-tabs .wt-tab small { + color: var(--wt-faint); +} + +:root[data-theme="light"] #heavenView .wt-tabs .wt-tab.on { + color: var(--wt-gold-bright); +} + +:root[data-theme="light"] #heavenView .wt-empty { + color: var(--wt-muted); +} + +:root[data-theme="light"] #heavenView .wt-empty .big { + color: var(--wt-gold); +} + +:root[data-theme="light"] #heavenView .wt-stage .stars i, +:root[data-theme="light"] #heavenView .fortune-stage > .stars i, +:root[data-theme="light"] #heavenView .heart-stage-shell > .stars i { + opacity: 0.18; +} + +:root[data-theme="light"] #heavenView .bagua { + color: var(--wt-gold); + opacity: 0.075; +} + +:root[data-theme="light"] #heavenView .fortune-bagua { + opacity: 0.08; +} + +:root[data-theme="light"] #heavenView .heart-bagua { + opacity: 0.045; +} + +:root[data-theme="light"] #heavenView .heart-whispers span { + color: var(--wt-whisper); +} + +:root[data-theme="light"] #heavenView .heart-breath-ripple > i { + background: var(--wt-breath-center); + box-shadow: var(--wt-breath-shadow); +} + +:root[data-theme="light"] #heavenView .heart-cast-button { + background: conic-gradient(var(--wt-gold) var(--hold-progress), var(--wt-cast-rest) 0); +} + +:root[data-theme="light"] #heavenView .heart-coin-face::after { + background: var(--wt-coin-hole); +} + +:root[data-theme="light"] #heavenView .phase-text-metal { + color: var(--wt-metal-text); +} + +:root[data-theme="light"] #heavenView :is(input, select, textarea)::placeholder { + color: var(--wt-faint); +} + +:root[data-theme="light"] .heaven-reading-dialog.wentian-v2-dialog { + color-scheme: light; + --wt-panel: #ffffff; + --wt-text: #343a43; + --wt-paper: #202934; + --wt-gold: #946b1d; + --wt-gold-bright: #765315; + --wt-cinnabar: #b94f46; + --wt-line: rgba(132, 101, 39, 0.22); + --wt-line-soft: rgba(52, 58, 67, 0.09); + --wt-muted: rgba(52, 58, 67, 0.74); + --wt-faint: rgba(52, 58, 67, 0.52); + --wt-notice-bg: rgba(185, 79, 70, 0.07); + --wt-notice-border: rgba(185, 79, 70, 0.28); + --wt-notice-text: #914039; + --wt-backdrop: rgba(41, 47, 56, 0.26); + --wt-dialog-shadow: 0 18px 52px rgba(31, 41, 55, 0.18); + --wt-soft-gold: rgba(148, 107, 29, 0.08); + --wt-history-bg: #fbfaf6; + background: var(--wt-panel); + box-shadow: var(--wt-dialog-shadow); +} + +:root[data-theme="light"] .heaven-reading-dialog.wentian-v2-dialog::backdrop { + background: var(--wt-backdrop); +} + +:root[data-theme="light"] .wentian-v2-dialog .heaven-reading-history-list-wrap { + background: var(--wt-history-bg); +} + +:root[data-theme="light"] .wentian-v2-dialog .heaven-reading-history-item:hover, +:root[data-theme="light"] .wentian-v2-dialog .heaven-reading-history-item.active { + background: var(--wt-soft-gold); +} + +/* Production integration: legacy selectors in styles.css use panel IDs and + therefore outrank the transplanted prototype classes. Keep these resets + outside @scope so the v2 visual surface is authoritative. */ +#heavenView #heavenTrendPanel .heaven-controls { + border-color: transparent; + background: transparent; + color: var(--wt-text); +} +#heavenView #heavenTrendPanel .heaven-trend-layout { + background: var(--wt-stage-veil); +} +#heavenView #heavenTrendPanel .form-field input { + border-color: var(--wt-control-border); + background: var(--wt-control-bg); + color: var(--wt-paper); +} +#heavenView #heavenTrendPanel .hexagram-board { + border-color: var(--wt-line); + background: var(--wt-surface); + color: var(--wt-text); +} +#heavenView #heavenTrendPanel .trend-reading-panel { + background: var(--wt-surface-raised); + color: var(--wt-text); +} +#heavenView #heavenFortunePanel .qi-time-field input { + border-color: var(--wt-control-border); + background: var(--wt-control-bg); + color: var(--wt-paper); +} +#heavenView #heavenFortunePanel .heaven-footnote { + margin: 9px 2px 0; + padding: 0; + border: 0; + background: transparent; + color: var(--wt-faint); +} +#heavenView #heavenFortunePanel .personal-fortune-panel::before { + content: none; + display: none; +} +#heavenView #heavenHeartPanel .heart-breath-ripple { + width: 180px; + height: 180px; + position: relative; + inset: auto; + z-index: auto; + transform: none; +} +#heavenView #heavenHeartPanel .heart-breath-ripple span, +#heavenView #heavenHeartPanel .heart-breath-ripple > i { + position:absolute; + inset:50%; + animation: none; +} +#heavenView #heavenHeartPanel .heart-breath-ripple span { + border:1px solid rgba(201,165,92,.32); + box-shadow:none; + opacity:1; + transform:translate(-50%,-50%) scale(.38); +} +#heavenView #heavenHeartPanel .heart-breath-ripple span:nth-child(1) { + width:100%; + height:100%; +} +#heavenView #heavenHeartPanel .heart-breath-ripple span:nth-child(2) { + width:76%; + height:76%; +} +#heavenView #heavenHeartPanel .heart-breath-ripple span:nth-child(3) { + width:52%; + height:52%; +} +#heavenView #heavenHeartPanel .heart-breath-ripple > i { + width:30%; + height:30%; + border:0; + background:radial-gradient(circle,rgba(230,195,122,.6),rgba(201,165,92,.06) 65%,transparent 70%); + box-shadow:0 0 32px rgba(201,165,92,.28); + transform:translate(-50%,-50%) scale(.38); +} +#heavenView #heavenHeartPanel .breathing-scene[data-phase="inhale"] .heart-breath-ripple span, +#heavenView #heavenHeartPanel .breathing-scene[data-phase="inhale"] .heart-breath-ripple > i, +#heavenView #heavenHeartPanel .breathing-scene[data-phase="hold"] .heart-breath-ripple span, +#heavenView #heavenHeartPanel .breathing-scene[data-phase="hold"] .heart-breath-ripple > i { + transform:translate(-50%,-50%) scale(1); +} +#heavenView #heavenHeartPanel .breathing-scene[data-phase="inhale"] .heart-breath-ripple span, +#heavenView #heavenHeartPanel .breathing-scene[data-phase="inhale"] .heart-breath-ripple > i { + transition-duration:3s; +} +#heavenView #heavenHeartPanel .breathing-scene[data-phase="exhale"] .heart-breath-ripple span, +#heavenView #heavenHeartPanel .breathing-scene[data-phase="exhale"] .heart-breath-ripple > i { + transform:translate(-50%,-50%) scale(.38); + transition-duration:4s; +} +#heavenView #heavenHeartPanel .breathing-phase { + inset:50% auto auto 50%; + transform:translate(-50%,-50%); +} +#heavenView #heavenHeartPanel .heart-coin-face { + padding: 0; +} +#heavenView #heavenHeartPanel .heart-coin-face::before { + width: auto; + height: auto; + inset: 9px; + border: 1px solid rgba(65,42,11,.5); + border-radius: 50%; + background: transparent; + box-shadow: 0 0 0 1px rgba(239,205,113,.25); + transform: none; +} +#heavenView #heavenHeartPanel .heart-coin-face::after { + width: 27%; + height: 27%; + inset: 36.5% auto auto 36.5%; + border-radius: 1px; + background: var(--wt-bg); + transform: none; +} +#heavenView #heavenHeartPanel .heart-return-button { + position: absolute; + inset: 14px auto auto 14px; + z-index: 8; + margin: 0; + color: var(--wt-text); +} +#heavenView #heavenHeartPanel .heart-return-button + .heart-stage-inner, +#heavenView #heavenHeartPanel .heart-return-button + .heart-casting-layout, +#heavenView #heavenHeartPanel .heart-return-button + .heart-reveal-layout { + min-height: 610px; +} +#heavenView #heavenHeartPanel .heart-stage-inner > h3 { + color: var(--wt-paper); +} +#heavenView #heavenHeartPanel .heart-toolbar-controls .button, +#heavenView #heavenHeartPanel #restartHeartButton { + color: var(--wt-text); +} +#heavenView #heavenHeartPanel .heart-hexagram-shell, +#heavenView #heavenHeartPanel .heart-reveal-board { + padding: 56px 30px 28px; + border-color: var(--wt-line); + background: var(--wt-stage-veil); + color: var(--wt-text); +} +#heavenView #heavenHeartPanel .casting-action-panel, +#heavenView #heavenHeartPanel .heart-first-thought { + background: var(--wt-surface-soft); +} + +@scope (#heavenView) { + +* { box-sizing: border-box; } +html { min-width: 320px; background: var(--wt-bg); } +body { margin: 0; background: var(--wt-bg); color: var(--wt-text); font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif; } +button, input { font: inherit; } +button { cursor: pointer; } +[hidden] { display: none !important; } + +.wt-serif { font-family: "Kaiti SC", "STKaiti", "KaiTi", "STSong", "SimSun", serif; } +.heaven-shell { width: min(1180px, calc(100% - 32px)); min-height: 100vh; margin: 0 auto; padding: 24px 0 42px; } + +/* Directly transplanted from 界面优化/wentian.html. */ +.wt-head { padding: 12px 8px 0; text-align: center; } +.wt-title-line { display: flex; align-items: baseline; justify-content: center; gap: 12px; } +.wt-title-line h1 { margin: 0; color: var(--wt-paper); font-size: 30px; font-weight: 700; letter-spacing: 14px; text-indent: 14px; } +.wt-title-line > span { color: var(--wt-faint); font-size: 11px; } +.wt-head .verse { margin-top: 8px; color: rgba(216,210,189,.45); font-size: 12.5px; letter-spacing: 4px; } +.wt-tabs { display: flex; justify-content: center; gap: 34px; margin-top: 20px; } +.wt-tabs .wt-tab { position: relative; padding: 8px 4px; border: 0; background: transparent; color: rgba(216,210,189,.5); font-size: 15px; letter-spacing: 3px; transition: color .2s; } +.wt-tabs .wt-tab small { display: block; margin-top: 3px; color: rgba(216,210,189,.3); font-family: inherit; font-size: 10px; letter-spacing: 1px; } +.wt-tabs .wt-tab::after { content: ""; position: absolute; bottom: -2px; left: 50%; width: 0; height: 1.5px; background: var(--wt-gold); transform: translateX(-50%); transition: all .25s; } +.wt-tabs .wt-tab.on { color: var(--wt-gold-bright); } +.wt-tabs .wt-tab.on::after { width: 100%; } +.wt-tabs .wt-tab:disabled { cursor: default; } +.wt-tabs .wt-tab:focus { outline: none; } +.wt-tabs .wt-tab:focus-visible { box-shadow: 0 2px 0 rgba(201,165,92,.45); } + +.heaven-proverb { margin: 8px 0 0; padding: 10px 2px; border-bottom: 1px solid var(--wt-line); color: var(--wt-muted); font-size: 12px; font-weight: 700; text-align: right; } +.inline-notice { margin: 12px 0 0; padding: 10px 13px; border: 1px solid var(--wt-notice-border); background: var(--wt-notice-bg); color: var(--wt-notice-text); font-size: 12px; line-height: 1.6; } + +.heaven-panel { margin-top: 12px; } +.heaven-controls { display: flex; align-items: flex-end; gap: 12px; } +.heaven-stock-query { min-width: 0; display: grid; grid-template-columns: minmax(260px, 1fr) minmax(260px, .9fr); gap: 10px; flex: 1; } +.form-field { display: grid; gap: 6px; min-width: 0; } +.form-field > span { color: var(--wt-faint); font-size: 11px; } +.form-field input, .heaven-manual-field input, .heaven-manual-field select { + width: 100%; min-height: 38px; padding: 8px 11px; border: 1px solid var(--wt-control-border); border-radius: 7px; + outline: none; background: var(--wt-control-bg); color: var(--wt-paper); +} +.form-field input:focus, .heaven-manual-field input:focus, .heaven-manual-field select:focus { border-color: var(--wt-gold); box-shadow: 0 0 0 2px var(--wt-focus-ring); } +.heaven-stock-identity { height: 38px; min-width: 0; display: flex; align-self: end; align-items: center; justify-content: flex-start; gap: .55em; padding: 0 4px; white-space: nowrap; } +.heaven-stock-identity > span, +.heaven-stock-identity > strong { overflow: hidden; font-size: 12.5px; line-height: 1; text-align: left; text-overflow: ellipsis; } +.heaven-stock-identity > span { flex: 0 0 auto; color: var(--wt-muted); font-weight: 400; } +.heaven-stock-identity > strong { flex: 0 1 auto; color: var(--wt-gold-bright); font-weight: 700; } +.heaven-trend-actions { display: flex; gap: 8px; } +.button { min-height: 38px; padding: 8px 15px; border: 1px solid var(--wt-button-border); border-radius: 7px; background: var(--wt-control-bg); color: var(--wt-text); transition: border-color .2s, background .2s, transform .2s; } +.button:hover:not(:disabled) { border-color: var(--wt-gold); background: var(--wt-control-hover); transform: translateY(-1px); } +.button.primary { border-color: var(--wt-gold); background: var(--wt-gold); color: #1a1408; font-weight: 700; } +.button.primary:hover:not(:disabled) { background: #d9b96e; } +.button:disabled { cursor: not-allowed; opacity: .38; } +.cast-hint { margin: 9px auto 0; color: var(--wt-faint); font-size: 11px; letter-spacing: 1.5px; text-align: center; } + +/* Direct star field and bagua source from 界面优化/assets/style.css. */ +.wt-stage { position: relative; min-height: 250px; overflow: hidden; margin-top: 12px; border: 1px solid var(--wt-line); border-radius: 14px; background: var(--wt-stage-bg); } +.wt-stage .stars { position: absolute; inset: 0; pointer-events: none; } +.wt-stage .stars i { position: absolute; border-radius: 50%; background: var(--wt-star); opacity: .15; animation: wt-tw 4s ease-in-out infinite alternate; } +@keyframes wt-tw { from { opacity: .05; } to { opacity: .5; } } +.bagua { position: absolute; top: 50%; left: 50%; opacity: .10; pointer-events: none; transform: translate(-50%,-50%); } +.bagua .ring { transform-origin: 150px 150px; animation: wt-spin 140s linear infinite; } +.bagua .ring2 { transform-origin: 150px 150px; animation: wt-spin 200s linear infinite reverse; } +@keyframes wt-spin { to { transform: rotate(360deg); } } +.wt-empty { position: relative; z-index: 2; padding: 66px 20px; color: rgba(216,210,189,.4); text-align: center; } +.wt-empty .big { margin-bottom: 12px; color: rgba(227,200,135,.5); font-size: 34px; letter-spacing: 10px; } +.wt-empty p { margin: 0; font-size: 12.5px; letter-spacing: 2px; line-height: 2; } + +.stage-in { position: relative; z-index: 2; display: grid; grid-template-columns: minmax(540px, 1.35fr) minmax(330px, .82fr); min-height: 560px; background: var(--wt-stage-veil); backdrop-filter: blur(1px); } +.hexagram-board { min-width: 0; padding: 24px 28px 22px; border-right: 1px solid var(--wt-line); background: var(--wt-surface); } +.hexagram-heading { min-height: 66px; display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; padding-bottom: 13px; border-bottom: 1px solid var(--wt-line); } +.metric-label { color: var(--wt-faint); font-size: 11px; letter-spacing: 1.5px; } +.hexagram-heading h3 { margin: 7px 0 0; color: var(--wt-paper); font-family: var(--heaven-serif); font-size: 27px; font-weight: 650; } +.hexagram-change { text-align: right; } +.hexagram-change span { display: block; color: var(--wt-faint); font-size: 11px; } +.hexagram-change strong { display: block; margin-top: 8px; color: var(--wt-gold-bright); font-family: var(--heaven-serif); font-size: 18px; font-weight: 600; } +.hexagram-lines { display: grid; gap: 0; margin-top: 10px; } +.talent-line-group { display: grid; grid-template-columns: 34px minmax(0,1fr); gap: 14px; padding: 15px 0; border-bottom: 1px dashed var(--wt-line); animation: heaven-group-enter 440ms var(--ease-out) both; animation-delay: var(--group-delay); } +.talent-line-group:last-child { border-bottom: 0; } +.talent-seal { width: 30px; height: 30px; display: grid; place-items: center; margin-top: 9px; border: 1px solid rgba(201,165,92,.35); border-radius: 2px; color: var(--wt-muted); font-family: var(--heaven-serif); } +.talent-line-content > p { margin: 0 0 5px 8px; color: var(--wt-faint); font-family: var(--heaven-serif); font-size: 11px; } +.hexagram-line-row { min-height: 48px; display: grid; grid-template-columns: 42px 150px minmax(0,1fr); align-items: center; gap: 10px; padding: 5px 8px; border-left: 2px solid transparent; } +.hexagram-line-row.moving { border-left-color: var(--wt-cinnabar); background: linear-gradient(90deg, rgba(215,107,97,.09), transparent 78%); } +.hexagram-position { color: var(--wt-muted); font-family: var(--heaven-serif); font-size: 12px; } +.hex-line { display: flex; align-items: center; justify-content: center; gap: 10px; position: relative; } +.hex-line i { width: 62px; height: 7px; border-radius: 1px; background: var(--wt-hexagram-ink); transform-origin: center; animation: heaven-line-draw 520ms var(--ease-out) both; } +.hex-line.yang-line i { width: 134px; } +.hex-line b { position: absolute; right: -4px; color: var(--wt-cinnabar); font-size: 15px; } +.hexagram-line-detail { min-width: 0; } +.hexagram-line-detail strong, .hexagram-line-detail small { display: block; } +.hexagram-line-detail strong { color: var(--wt-paper); font-family: var(--heaven-serif); font-size: 12.5px; font-weight: 600; } +.hexagram-line-detail small { overflow: hidden; margin-top: 3px; color: var(--wt-muted); font-size: 10.5px; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; } +.hexagram-text { margin: 10px 0 0; padding: 15px 4px 0; border-top: 1px solid var(--wt-line); color: var(--wt-muted); font-family: var(--heaven-serif); font-size: 14px; line-height: 1.9; } +.market-movement-summary { margin: 10px 0 0; padding: 10px 13px; border-left: 2px solid var(--wt-cinnabar); background: rgba(215,107,97,.09); color: var(--wt-muted); font-size: 12px; line-height: 1.7; } + +.trend-reading-panel { min-width: 0; padding: 22px 24px; background: var(--wt-surface-raised); } +.trend-score-line { min-height: 90px; display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 10px; } +.trend-score-line strong { display: block; margin-top: 6px; color: var(--wt-paper); font-family: var(--heaven-serif); font-size: 48px; font-variant-numeric: tabular-nums; } +.trend-score-line > span { margin-top: 10px; padding: 5px 10px; border: 1px solid var(--wt-cinnabar); border-radius: 2px; color: var(--wt-cinnabar); font-family: var(--heaven-serif); font-size: 14px; transform: rotate(-3deg); } +.trend-score-meter { padding: 4px 0 16px; border-bottom: 1px solid var(--wt-line); } +.trend-score-track { height: 4px; position: relative; border-radius: 2px; background: linear-gradient(90deg, rgba(78,126,101,.65), rgba(216,210,189,.12) 50%, rgba(215,107,97,.58)); } +.trend-score-track i { width: 9px; height: 9px; position: absolute; top: 50%; left: var(--momentum-position, 50%); border: 2px solid var(--wt-panel); border-radius: 50%; background: var(--wt-paper); transform: translate(-50%,-50%); } +.trend-score-marks { display: flex; justify-content: space-between; margin-top: 8px; color: var(--wt-faint); font-size: 9.5px; } +.three-talent-readings { display: grid; gap: 0; } +.talent-reading { padding: 15px 0; border-bottom: 1px solid var(--wt-line-soft); } +.talent-reading > strong { color: var(--wt-paper); font-family: var(--heaven-serif); font-size: 13px; } +.talent-reading > span { margin-left: 8px; color: var(--wt-muted); font-size: 11px; } +.talent-reading > small { display: block; margin-top: 5px; color: var(--wt-faint); font-size: 10px; } +.talent-balance { display: grid; grid-template-columns: 52px minmax(0,1fr); gap: 5px 8px; margin-top: 9px; } +.talent-balance small { color: var(--wt-faint); font-size: 10px; } +.talent-balance i { height: 3px; align-self: center; position: relative; border-radius: 2px; background: var(--wt-track); } +.talent-balance b { width: var(--talent-value); height: 100%; display: block; border-radius: inherit; background: var(--wt-gold); } +.heaven-hex-transition { min-height: 190px; display: grid; grid-template-columns: minmax(118px,1fr) 74px minmax(118px,1fr); align-items: center; gap: 10px; margin-top: 14px; padding: 16px 2px 4px; border-top: 1px solid var(--wt-line); } +.compact-hex-figure { min-width: 0; margin: 0; text-align: center; } +.compact-hex-lines { width: min(100%,136px); display: flex; flex-direction: column; gap: 7px; margin: 0 auto; } +.compact-hex-line { height: 8px; display: flex; justify-content: center; gap: 10px; position: relative; } +.compact-hex-line i { width: 57px; display: block; border-radius: 2px; background: var(--wt-hexagram-ink); } +.compact-hex-line.yang i { width: 124px; } +.compact-hex-line.moving i { background: var(--wt-gold-bright); box-shadow: 0 0 10px var(--wt-gold-glow); } +.compact-hex-line em { position: absolute; right: -2px; top: 50%; color: var(--wt-gold-bright); font-family: var(--heaven-serif); font-size: 13px; font-style: normal; transform: translateY(-50%); } +.compact-hex-figure figcaption { margin-top: 12px; } +.compact-hex-figure figcaption strong, +.compact-hex-figure figcaption small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.compact-hex-figure figcaption strong { color: var(--wt-paper); font-family: var(--heaven-serif); font-size: 14px; font-weight: 600; } +.compact-hex-figure figcaption strong b { color: var(--wt-gold-bright); font-weight: 600; } +.compact-hex-figure figcaption small { margin-top: 4px; color: var(--wt-faint); font-family: var(--heaven-serif); font-size: 10px; } +.compact-hex-change { display: grid; justify-items: center; gap: 5px; color: var(--wt-gold); font-family: var(--heaven-serif); text-align: center; } +.compact-hex-change i { font-size: 24px; font-style: normal; } +.compact-hex-change span { font-size: 11px; letter-spacing: 1px; white-space: nowrap; } + +.heaven-calibration-panel { margin-top: 14px; border: 1px solid var(--wt-line); border-radius: 9px; background: var(--wt-panel-gradient); } +.heaven-calibration-panel > summary { cursor: pointer; list-style: none; } +.heaven-calibration-panel > summary::-webkit-details-marker { display: none; } +.heaven-calibration-heading { min-height: 74px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 14px 18px; } +.heaven-calibration-heading h3 { margin: 4px 0 0; color: var(--wt-paper); font-family: var(--heaven-serif); font-size: 19px; } +.heaven-calibration-summary { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 10px; color: var(--wt-faint); font-size: 10px; } +.heaven-calibration-summary > span { display: inline-flex; align-items: center; gap: 5px; } +.heaven-calibration-summary > strong { min-width: 86px; padding-left: 10px; border-left: 1px solid var(--wt-line); color: var(--wt-muted); text-align: right; } +.heaven-calibration-summary > strong.is-passed { color: var(--wt-green); } +.heaven-calibration-summary > strong.is-failed { color: var(--wt-cinnabar); } +.heaven-calibration-summary > strong.is-manual { color: var(--wt-gold-bright); } +.fold-label { color: var(--wt-gold); font-weight: 500; } +.heaven-calibration-panel[open] .fold-label { font-size: 0; } +.heaven-calibration-panel[open] .fold-label::after { content: "收起"; font-size: 10px; } +.calibration-body { padding: 0 18px 18px; border-top: 1px solid var(--wt-line-soft); } +.calibration-body > p { margin: 13px 0; color: var(--wt-faint); font-size: 11px; } +.heaven-line-checks { border-top: 1px solid var(--wt-line); } +.heaven-line-check { border-bottom: 1px solid var(--wt-line-soft); background: rgba(255,255,255,.018); } +.heaven-line-check.is-failed { background: rgba(215,107,97,.04); } +.heaven-line-check.is-manual { background: rgba(201,165,92,.04); } +.heaven-line-check > summary { min-height: 58px; display: grid; grid-template-columns: 94px minmax(0,1fr) 112px 18px; align-items: center; gap: 14px; padding: 8px 10px; cursor: pointer; list-style: none; } +.heaven-check-state { display: inline-flex; align-items: center; gap: 6px; color: var(--wt-muted); font-size: 11px; } +.status-dot { width: 7px; height: 7px; display: inline-block; border-radius: 50%; background: var(--wt-faint); } +.status-dot.passed { background: var(--wt-green); } +.status-dot.failed { background: var(--wt-cinnabar); } +.status-dot.manual { background: var(--wt-gold); } +.heaven-check-name strong, .heaven-check-name small, .heaven-check-result b, .heaven-check-result small { display: block; } +.heaven-check-name strong { color: var(--wt-paper); font-size: 12px; } +.heaven-check-name small, .heaven-check-result small { margin-top: 3px; color: var(--wt-faint); font-size: 10px; } +.heaven-check-result { text-align: right; } +.heaven-check-result b { color: var(--wt-gold-bright); font-family: var(--heaven-serif); font-size: 12px; } +.heaven-line-check-body { padding: 4px 10px 16px 118px; } +.heaven-check-reasons, .heaven-check-evidence { margin: 0 0 10px; padding-left: 18px; color: #d3918a; font-size: 11px; line-height: 1.7; } +.heaven-manual-fields { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; } +.heaven-manual-field { display: grid; grid-template-columns: minmax(110px,1fr) minmax(130px,.8fr); align-items: center; gap: 10px; padding: 8px; border: 1px solid var(--wt-line-soft); } +.heaven-manual-field > span:first-child { color: var(--wt-muted); font-size: 11px; } +.heaven-manual-field small { display: block; margin-top: 2px; color: var(--wt-faint); font-size: 9px; } +.heaven-field-control { display: flex; align-items: center; gap: 5px; } +.heaven-field-control b { color: var(--wt-faint); font-size: 10px; } +.calibration-note-field { margin-top: 12px; } +.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; } + +.heaven-data-loading .heaven-panel { opacity: .45; pointer-events: none; } +.heaven-data-loading::after { content: "汇集天 · 人 · 地数据"; position: fixed; top: 48%; left: 50%; z-index: 20; padding: 10px 16px; border: 1px solid var(--wt-line); border-radius: 7px; background: var(--wt-loading-bg); color: var(--wt-muted); font-family: var(--heaven-serif); font-size: 12px; letter-spacing: .16em; transform: translate(-50%,-50%); } + +/* Production deterministic line-reveal sequence, retained verbatim in behavior. */ +.heaven-performance-pending .talent-line-group { opacity: .34; transform: translateY(7px); animation: none; transition: opacity 520ms var(--ease-out), transform 520ms var(--ease-out); } +.heaven-performance-pending .talent-line-group.is-ready { opacity: 1; transform: translateY(0); } +.heaven-performance-pending .talent-seal { filter: grayscale(1); opacity: .4; transition: color 420ms ease,border-color 420ms ease,filter 420ms ease,opacity 420ms ease,box-shadow 420ms ease; } +.heaven-performance-pending .talent-line-group.is-ready .talent-seal { border-color: rgba(201,165,92,.7); color: var(--wt-gold-bright); filter: none; opacity: 1; box-shadow: 0 0 18px rgba(201,165,92,.12); } +.heaven-performance-pending .hexagram-line-row .hex-line i { opacity: .12; filter: blur(3px); transform: scaleX(.16); animation: none; transition: opacity 620ms var(--ease-out),filter 620ms var(--ease-out),transform 620ms var(--ease-out); } +.heaven-performance-pending .hexagram-line-row .hexagram-line-detail, +.heaven-performance-pending .hexagram-line-row .hexagram-position, +.heaven-performance-pending .hexagram-line-row .hex-line b { opacity: 0; filter: blur(4px); transition: opacity 520ms ease,filter 520ms ease; } +.heaven-performance-pending .hexagram-line-row.is-ready .hex-line i { opacity: 1; filter: blur(0); transform: scaleX(1); } +.heaven-performance-pending .hexagram-line-row.is-ready .hexagram-line-detail, +.heaven-performance-pending .hexagram-line-row.is-ready .hexagram-position, +.heaven-performance-pending .hexagram-line-row.is-ready .hex-line b { opacity: 1; filter: blur(0); } +.heaven-performance-pending #marketHexagramName, +.heaven-performance-pending #marketTransformedName, +.heaven-performance-pending .hexagram-change, +.heaven-performance-pending .trend-score-line > *, +.heaven-performance-pending .trend-score-meter, +.heaven-performance-pending .market-movement-summary { opacity: 0; filter: blur(8px); transform: translateY(6px); transition: opacity 800ms var(--ease-out),filter 800ms var(--ease-out),transform 800ms var(--ease-out); } +.performance-title-ready #marketHexagramName, +.performance-change-ready #marketTransformedName, +.performance-change-ready .hexagram-change, +.performance-score-ready .trend-score-line > *, +.performance-score-ready .trend-score-meter, +.performance-text-ready .market-movement-summary { opacity: 1; filter: blur(0); transform: translateY(0); } +.heaven-performance-pending #heavenMomentumNeedle { left: 50%; opacity: 0; } +.performance-score-ready #heavenMomentumNeedle { left: var(--momentum-position,50%); opacity: 1; transition: left 1.5s cubic-bezier(.18,.85,.3,1.22),opacity 300ms ease; } +.heaven-performance-pending .talent-reading { opacity: 0; transform: translateY(6px); transition: opacity 500ms ease,transform 500ms var(--ease-out); } +.heaven-performance-pending .talent-reading.is-ready { opacity: 1; transform: none; } +.heaven-performance-pending .heaven-hex-transition { opacity: 0; filter: blur(5px); transform: translateY(5px); transition: opacity 680ms ease,filter 680ms ease,transform 680ms var(--ease-out); } +.performance-change-ready .heaven-hex-transition { opacity: 1; filter: blur(0); transform: none; } +.heaven-typing::after { content: ""; display: inline-block; width: 1px; height: 1em; margin-left: 3px; background: currentColor; vertical-align: -.1em; animation: blink .8s steps(1) infinite; } +@keyframes heaven-line-draw { from { opacity: .2; transform: scaleX(.15); } to { opacity: 1; transform: scaleX(1); } } +@keyframes heaven-group-enter { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } +@keyframes blink { 50% { opacity: 0; } } + +.heaven-reading-dialog, .login-dialog { padding: 0; border: 1px solid var(--wt-line); border-radius: 12px; background: var(--wt-panel); color: var(--wt-text); box-shadow: 0 30px 90px rgba(0,0,0,.48); } +.heaven-reading-dialog { width: min(1080px,calc(100vw - 24px)); height: min(760px,calc(100dvh - 24px)); grid-template-rows: auto auto minmax(0,1fr); } +.heaven-reading-dialog[open] { display: grid; } +.heaven-reading-dialog::backdrop, .login-dialog::backdrop { background: rgba(4,8,16,.72); backdrop-filter: blur(4px); } +.dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 15px 18px; border-bottom: 1px solid var(--wt-line); } +.dialog-header h2 { margin: 3px 0 0; color: var(--wt-paper); font-family: var(--heaven-serif); font-size: 20px; } +.dialog-eyebrow { color: var(--wt-gold); font-size: 10px; letter-spacing: 1.5px; } +.icon-button { width: 34px; height: 34px; border: 1px solid var(--wt-line); border-radius: 50%; background: transparent; color: var(--wt-muted); font-size: 22px; } +.heaven-reading-tabs { display: flex; padding: 0 18px; border-bottom: 1px solid var(--wt-line); } +.heaven-reading-tabs button { padding: 11px 2px 9px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--wt-faint); } +.heaven-reading-tabs button + button { margin-left: 24px; } +.heaven-reading-tabs button.active { border-bottom-color: var(--wt-gold); color: var(--wt-gold-bright); } +.heaven-reading-current { min-height: 0; overflow-y: auto; padding: 22px 28px 28px; } +.heaven-reading-loading { height: 100%; min-height: 0; } +.heaven-reading-loading canvas { width: 100%; height: 100%; min-height: 0; display: block; } +.empty-state { padding: 48px 20px; color: var(--wt-faint); text-align: center; } +.heaven-reading-result > header, .heaven-reading-history-detail > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; } +.heaven-reading-result > header span, .heaven-reading-history-detail > header span { color: var(--wt-gold); font-family: var(--heaven-serif); font-size: 12px; font-weight: 700; } +.heaven-reading-result h3, .heaven-reading-history-detail h3 { margin: 5px 0 0; color: var(--wt-paper); font-family: var(--heaven-serif); font-size: 22px; font-weight: 600; } +.heaven-reading-result time, .heaven-reading-history-detail time { color: var(--wt-faint); font-size: 11px; } +.heaven-reading-result > p, .heaven-reading-history-detail > p { color: var(--wt-muted); font-size: 12px; } +.heaven-reading-answer { margin-top: 22px; color: var(--wt-text); font-family: var(--heaven-serif); font-size: 15px; line-height: 1.95; } +.mentor-answer-heading { display: block; margin: 20px 0 7px; color: var(--wt-gold-bright); font-size: 16px; } +.mentor-answer-paragraph { margin: 0 0 10px; } +.mentor-answer-list { margin: 0 0 12px; padding-left: 24px; } +.heaven-reading-history { min-height: 0; display: grid; grid-template-columns: 270px minmax(0,1fr); overflow: hidden; } +.heaven-reading-history-list-wrap { overflow-y: auto; border-right: 1px solid var(--wt-line); background: rgba(255,255,255,.02); } +.heaven-reading-history-heading { display: flex; justify-content: space-between; padding: 15px 16px 10px; color: var(--wt-faint); font-size: 11px; } +.heaven-reading-history-item { width: 100%; display: grid; gap: 4px; padding: 13px 16px; border: 0; border-top: 1px solid var(--wt-line); background: transparent; color: var(--wt-text); text-align: left; } +.heaven-reading-history-item:hover, .heaven-reading-history-item.active { background: rgba(201,165,92,.08); box-shadow: inset 3px 0 var(--wt-gold); } +.heaven-reading-history-item span { overflow: hidden; font-size: 13px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } +.heaven-reading-history-item small, .heaven-reading-history-item time { color: var(--wt-faint); font-size: 10px; } +.heaven-reading-history-detail { min-width: 0; overflow-y: auto; padding: 24px 28px; } + +.login-dialog { width: min(390px,calc(100vw - 32px)); } +.login-dialog form { display: grid; gap: 14px; padding: 26px; } +.login-dialog h2 { margin: 0 0 6px; color: var(--wt-paper); font-size: 24px; } +.login-dialog .button { width: 100%; margin-top: 4px; } +.toast { position: fixed; right: 20px; bottom: 20px; z-index: 100; padding: 10px 14px; border: 1px solid var(--wt-line); border-radius: 7px; background: rgba(13,21,38,.96); color: var(--wt-paper); box-shadow: 0 12px 30px rgba(0,0,0,.3); font-size: 12px; } + +@media (max-width: 960px) { + .heaven-shell { width: min(100% - 20px, 760px); } + .heaven-controls { align-items: stretch; flex-direction: column; } + .heaven-stock-query { grid-template-columns: 1fr; } + .heaven-trend-actions { justify-content: flex-end; } + .stage-in { grid-template-columns: 1fr; } + .hexagram-board { border-right: 0; border-bottom: 1px solid var(--wt-line); } + .wt-stage .bagua { width: 440px; height: 440px; } +} + +@media (max-width: 640px) { + .heaven-shell { width: 100%; padding: 16px 10px 28px; } + .wt-title-line { display: grid; gap: 5px; } + .wt-tabs { gap: 16px; } + .wt-tabs .wt-tab { font-size: 13px; letter-spacing: 2px; } + .heaven-proverb { text-align: center; } + .heaven-trend-actions { display: grid; grid-template-columns: repeat(3,1fr); } + .button { padding-inline: 8px; } + .wt-empty { padding: 55px 14px; } + .wt-empty .big { font-size: 28px; } + .wt-empty p { font-size: 11px; letter-spacing: 1px; } + .hexagram-board, .trend-reading-panel { padding: 18px 13px; } + .hexagram-heading { min-height: auto; } + .hexagram-heading h3 { font-size: 20px; } + .hexagram-line-row { grid-template-columns: 34px 104px minmax(0,1fr); gap: 5px; padding-inline: 2px; } + .hex-line i { width: 42px; } + .hex-line.yang-line i { width: 94px; } + .hexagram-line-detail small { white-space: normal; } + .heaven-calibration-heading { align-items: flex-start; flex-direction: column; } + .heaven-calibration-summary { justify-content: flex-start; } + .heaven-line-check > summary { grid-template-columns: 78px minmax(0,1fr) 18px; gap: 8px; } + .heaven-check-result { grid-column: 2; text-align: left; } + .heaven-line-check-body { padding-left: 10px; } + .heaven-manual-fields { grid-template-columns: 1fr; } + .heaven-manual-field { grid-template-columns: 1fr; } + .heaven-reading-dialog { width: calc(100vw - 12px); height: calc(100dvh - 12px); } + .heaven-reading-current { padding: 18px 15px; } + .heaven-reading-history { grid-template-columns: 1fr; overflow-y: auto; } + .heaven-reading-history-list-wrap { max-height: 210px; border-right: 0; border-bottom: 1px solid var(--wt-line); } + .heaven-reading-history-detail { overflow: visible; padding: 18px 15px; } + .heaven-hex-transition { min-height: 172px; grid-template-columns: minmax(96px,1fr) 48px minmax(96px,1fr); gap: 5px; } + .compact-hex-lines { width: min(100%,112px); } + .compact-hex-line { gap: 8px; } + .compact-hex-line i { width: 47px; } + .compact-hex-line.yang i { width: 102px; } + .compact-hex-change span { font-size: 10px; white-space: normal; } +} + +} + +@media (min-width: 721px) { + body[data-active-view="heavenView"] { + --wt-bg: #0b1120; + } + + body[data-active-view="heavenView"] .overview-strip { + display: flex; + flex: 0 0 var(--summary-height); + } + + :root body[data-active-view="heavenView"] .app-main { + height: var(--workspace-height); + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--wt-bg); + } + + body[data-active-view="heavenView"] #heavenView.heaven-shell { + width: 100%; + max-width: none; + min-height: 0; + flex: 1 1 auto; + margin: 0; + padding: var(--page-pad-y) var(--page-pad-x); + overflow-x: hidden; + overflow-y: auto; + background: var(--wt-bg); + color: var(--wt-text); + } + + body[data-active-view="heavenView"] #heavenView.heaven-shell > * { + width: min(var(--table-wide), 100%); + margin-right: auto; + margin-left: auto; + } + + body[data-active-view="heavenView"] #heavenView.heaven-shell > .heaven-panel { + background: transparent; + } + + body[data-active-view="heavenView"] #heavenView #heavenHeartPanel { + width: min(var(--table-wide), 100%) !important; + max-width: var(--table-wide) !important; + min-height: 0; + margin-right: auto !important; + margin-left: auto !important; + background: transparent; + color: var(--wt-text); + } + + body[data-active-view="heavenView"] #heavenView #heavenHeartPanel .heart-stage, + body[data-active-view="heavenView"] #heavenView #heavenHeartPanel .heart-stage-inner { + min-height: 610px; + background-color: transparent; + background-image: none; + } +} + +#heavenView #heavenFortunePanel .qi-framework-layers { + display: block; + margin-top: 8px; + border: 0; +} + +#heavenView #heavenFortunePanel .fortune-basics .qi-framework-panel { padding: 0; } +#heavenView #heavenFortunePanel .fortune-basics .workspace-heading { + min-height: 0; + margin: 0; + padding: 0 0 11px; +} + +#heavenView #heavenFortunePanel .qi-framework-layer, +#heavenView #heavenFortunePanel .qi-framework-layer:nth-child(2), +#heavenView #heavenFortunePanel .qi-framework-layer:nth-child(3), +#heavenView #heavenFortunePanel .qi-framework-layer:last-child { + min-height: 0; + grid-template-columns: 72px 70px minmax(0, 1fr); + gap: 9px; + padding: 11px 0; + border-right: 0; + border-bottom: 1px solid var(--wt-line-soft); +} + +#heavenView #heavenFortunePanel .qi-framework-layer:last-child { border-bottom: 0; } +#heavenView #heavenFortunePanel .qi-framework-layer > span { color: var(--wt-faint); font-size: 10px; font-weight: 400; } +#heavenView #heavenFortunePanel .qi-framework-layer > strong { margin: 0; color: var(--wt-gold-bright); font-size: 14px; } +#heavenView #heavenFortunePanel .qi-framework-layer > small { min-height: 0; margin: 0; color: var(--wt-muted); font-size: 10px; line-height: 1.6; } +#heavenView #heavenFortunePanel .qi-framework-layer > div { + width: auto; + height: 3px; + grid-column: 2 / -1; + margin: 0; + border-radius: 2px; +} + +#heavenView #heavenFortunePanel .personal-fortune-panel { + margin-top: 14px; + padding: 14px 0 0; + border: 0; + background: transparent; +} + +#heavenView #heavenFortunePanel .personal-fortune-result { + display: block; + margin-top: 0; + border: 0; +} + +#heavenView #heavenFortunePanel .personal-primary-grid { + grid-template-columns: 76px minmax(0, 1fr); + gap: 12px; + margin-top: 12px; + border: 0; +} + +#heavenView #heavenFortunePanel .personal-preferences { + grid-template-columns: 1fr; + grid-template-rows: auto auto; + align-content: center; + gap: 10px; +} + +#heavenView #heavenFortunePanel .personal-preferences > section { + min-height: 0; + display: block; + padding: 0; + border: 0; +} + +#heavenView #heavenFortunePanel .personal-day-master { + min-height: 118px; + padding: 0; + border-right: 1px solid var(--wt-line); +} + +.heaven-reading-dialog.wentian-v2-dialog { + --wt-panel: #0d1526; + --wt-text: #d8d2bd; + --wt-paper: #e8e2cd; + --wt-gold: #c9a55c; + --wt-gold-bright: #e3c887; + --wt-cinnabar: #d76b61; + --wt-line: rgba(201, 165, 92, 0.18); + --wt-line-soft: rgba(255, 255, 255, 0.06); + --wt-muted: rgba(216, 210, 189, 0.55); + --wt-faint: rgba(216, 210, 189, 0.35); + --wt-notice-bg: rgba(215, 107, 97, 0.08); + --wt-notice-border: rgba(215, 107, 97, 0.35); + --wt-notice-text: #efb0a9; + --wt-backdrop: rgba(4, 8, 16, 0.72); + --wt-dialog-shadow: 0 30px 90px rgba(0, 0, 0, 0.48); + --wt-soft-gold: rgba(201, 165, 92, 0.08); + --wt-history-bg: rgba(255, 255, 255, 0.02); + --heaven-serif: "Kaiti SC", "STKaiti", "KaiTi", "STSong", "SimSun", serif; + width: min(1080px, calc(100vw - 24px)); + height: min(760px, calc(100dvh - 24px)); + max-height: calc(100dvh - 24px); + padding: 0; + grid-template-rows: auto auto minmax(0, 1fr); + border: 1px solid var(--wt-line); + border-radius: 12px; + background: var(--wt-panel); + color: var(--wt-text); + box-shadow: var(--wt-dialog-shadow); +} + +.heaven-reading-dialog.wentian-v2-dialog[open] { display: grid; } +.heaven-reading-dialog.wentian-v2-dialog::backdrop { background: var(--wt-backdrop); backdrop-filter: blur(4px); } +.wentian-v2-dialog .dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 15px 18px; border-bottom: 1px solid var(--wt-line); } +.wentian-v2-dialog .dialog-header h2 { margin: 3px 0 0; color: var(--wt-paper); font-family: var(--heaven-serif); font-size: 20px; } +.wentian-v2-dialog .dialog-eyebrow { color: var(--wt-gold); font-size: 10px; letter-spacing: 1.5px; } +.wentian-v2-dialog .icon-button { width: 34px; height: 34px; border: 1px solid var(--wt-line); border-radius: 50%; background: transparent; color: var(--wt-muted); } +.wentian-v2-dialog .heaven-reading-tabs { display: flex; padding: 0 18px; border-bottom: 1px solid var(--wt-line); } +.wentian-v2-dialog .heaven-reading-tabs button { padding: 11px 2px 9px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--wt-faint); } +.wentian-v2-dialog .heaven-reading-tabs button + button { margin-left: 24px; } +.wentian-v2-dialog .heaven-reading-tabs button.active { border-bottom-color: var(--wt-gold); color: var(--wt-gold-bright); } +.wentian-v2-dialog .heaven-reading-current { min-height: 0; max-height: none; overflow-y: auto; padding: 22px 28px 28px; } +.wentian-v2-dialog .heaven-reading-loading { height: 100%; min-height: 0; } +.wentian-v2-dialog .heaven-reading-loading canvas { width: 100%; height: 100%; min-height: 0; display: block; } +.wentian-v2-dialog .empty-state { padding: 48px 20px; color: var(--wt-faint); text-align: center; } +.wentian-v2-dialog .heaven-reading-result > header, +.wentian-v2-dialog .heaven-reading-history-detail > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; } +.wentian-v2-dialog .heaven-reading-result > header span, +.wentian-v2-dialog .heaven-reading-history-detail > header span { color: var(--wt-gold); font-family: var(--heaven-serif); font-size: 12px; font-weight: 700; } +.wentian-v2-dialog .heaven-reading-result h3, +.wentian-v2-dialog .heaven-reading-history-detail h3 { margin: 5px 0 0; color: var(--wt-paper); font-family: var(--heaven-serif); font-size: 22px; font-weight: 600; } +.wentian-v2-dialog .heaven-reading-result time, +.wentian-v2-dialog .heaven-reading-history-detail time { color: var(--wt-faint); font-size: 11px; } +.wentian-v2-dialog .heaven-reading-result > p, +.wentian-v2-dialog .heaven-reading-history-detail > p { color: var(--wt-muted); font-size: 12px; } +.wentian-v2-dialog .heaven-reading-answer { margin-top: 22px; color: var(--wt-text); font-family: var(--heaven-serif); font-size: 15px; line-height: 1.95; } +.wentian-v2-dialog .mentor-answer-heading { display: block; margin: 20px 0 7px; color: var(--wt-gold-bright); font-size: 16px; } +.wentian-v2-dialog .mentor-answer-paragraph { margin: 0 0 10px; } +.wentian-v2-dialog .mentor-answer-list { margin: 0 0 12px; padding-left: 24px; } +.wentian-v2-dialog .heaven-reading-history { min-height: 0; max-height: none; display: grid; grid-template-columns: 270px minmax(0, 1fr); overflow: hidden; } +.wentian-v2-dialog .heaven-reading-history-list-wrap { overflow-y: auto; border-right: 1px solid var(--wt-line); background: var(--wt-history-bg); } +.wentian-v2-dialog .heaven-reading-history-heading { display: flex; justify-content: space-between; padding: 15px 16px 10px; color: var(--wt-faint); font-size: 11px; } +.wentian-v2-dialog .heaven-reading-history-item { width: 100%; display: grid; gap: 4px; padding: 13px 16px; border: 0; border-top: 1px solid var(--wt-line); background: transparent; color: var(--wt-text); text-align: left; } +.wentian-v2-dialog .heaven-reading-history-item:hover, +.wentian-v2-dialog .heaven-reading-history-item.active { background: rgba(201, 165, 92, 0.08); box-shadow: inset 3px 0 var(--wt-gold); } +.wentian-v2-dialog .heaven-reading-history-item span { overflow: hidden; font-size: 13px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } +.wentian-v2-dialog .heaven-reading-history-item small, +.wentian-v2-dialog .heaven-reading-history-item time { color: var(--wt-faint); font-size: 10px; } +.wentian-v2-dialog .heaven-reading-history-detail { min-width: 0; overflow-y: auto; padding: 24px 28px; } + +@media (max-width: 640px) { + body[data-active-view="heavenView"] #heavenView.heaven-shell { width: 100%; padding: 16px 10px 28px; } + .heaven-reading-dialog.wentian-v2-dialog { width: calc(100vw - 12px); height: calc(100dvh - 12px); max-height: calc(100dvh - 12px); } + .wentian-v2-dialog .heaven-reading-current { padding: 18px 15px; } + .wentian-v2-dialog .heaven-reading-history { grid-template-columns: 1fr; overflow-y: auto; } + .wentian-v2-dialog .heaven-reading-history-list-wrap { max-height: 210px; border-right: 0; border-bottom: 1px solid var(--wt-line); } + .wentian-v2-dialog .heaven-reading-history-detail { overflow: visible; padding: 18px 15px; } +} + +@media (prefers-reduced-motion: reduce) { + #heavenView *, #heavenView *::before, #heavenView *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } +} + +@scope (#heavenView) { + +/* Fortune and heart keep production behavior while sharing the transplanted night sky. */ +.heaven-panel:not(.active-heaven-panel) { display: none; } +.fortune-heading { min-height: 64px; display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; padding: 0 2px 10px; } +.fortune-calendar-heading h3 { margin: 5px 0 0; color: var(--wt-paper); font-size: 19px; font-weight: 600; } +.fortune-heading-actions { display: flex; align-items: flex-end; gap: 8px; } +.qi-time-field { display: grid; gap: 5px; color: var(--wt-faint); font-size: 10px; } +.qi-time-field input { min-height: 38px; padding: 7px 10px; border: 1px solid var(--wt-control-border); border-radius: 7px; outline: 0; background: var(--wt-control-bg); color: var(--wt-paper); color-scheme: inherit; } +.fortune-stage { min-height: 570px; display: grid; grid-template-columns: minmax(0,1.55fr) minmax(350px,.78fr); position: relative; overflow: hidden; border: 1px solid var(--wt-line); border-radius: 14px; background: var(--wt-fortune-stage-bg); } +.fortune-stage > .stars, .heart-stage-shell > .stars { position: absolute; inset: 0; pointer-events: none; } +.fortune-stage > .stars i, .heart-stage-shell > .stars i { position: absolute; border-radius: 50%; background: var(--wt-paper); opacity: .15; animation: wt-tw 4s ease-in-out infinite alternate; } +.fortune-bagua { left: 32%; width: 620px; height: 620px; opacity: .055; } +.qi-climate-panel { min-width: 0; display: grid; align-content: center; justify-items: center; position: relative; z-index: 2; padding: 56px 52px; border-right: 1px solid var(--wt-line); text-align: center; } +.qi-climate-panel::before, .qi-climate-panel::after { content: ""; position: absolute; border-radius: 50%; filter: blur(34px); opacity: .2; } +.qi-climate-panel::before { width: 250px; height: 250px; top: 22%; left: 19%; background: var(--wt-gold); } +.qi-climate-panel::after { width: 190px; height: 190px; right: 18%; bottom: 18%; background: var(--wt-cinnabar); } +.qi-section-mark, .heart-stage-index { position: relative; z-index: 1; color: var(--wt-cinnabar); font-family: var(--heaven-serif); font-size: 11px; letter-spacing: 2px; } +.qi-climate-caption { position: relative; z-index: 1; margin: 18px 0 0; color: var(--wt-faint); font-size: 12px; letter-spacing: 5px; } +.qi-climate-panel h3 { position: relative; z-index: 1; margin: 14px 0 0; color: var(--wt-paper); font-size: 48px; font-weight: 500; letter-spacing: 5px; } +.qi-climate-panel > strong { position: relative; z-index: 1; margin-top: 18px; color: var(--wt-gold-bright); font-size: 16px; font-weight: 500; letter-spacing: 2px; } +.human-field-summary { max-width: 650px; position: relative; z-index: 1; margin: 20px auto 0; color: var(--wt-muted); font-size: 12px; line-height: 2; } +.fortune-basics { min-width: 0; position: relative; z-index: 2; padding: 16px; background: var(--wt-surface); backdrop-filter: blur(5px); } +.workspace-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 11px; border-bottom: 1px solid var(--wt-line); } +.workspace-heading > div > span { color: var(--wt-cinnabar); font-family: var(--heaven-serif); font-size: 10px; letter-spacing: 1px; } +.workspace-heading h3 { margin: 3px 0 0; color: var(--wt-paper); font-size: 17px; font-weight: 600; } +.text-fold-button { padding: 5px 0; border: 0; background: transparent; color: var(--wt-faint); font-size: 10px; } +.qi-framework-principle { margin: 10px 0 0; color: var(--wt-faint); font-size: 10px; line-height: 1.65; } +.qi-framework-layers { margin-top: 8px; } +.qi-framework-layers.is-folded { display: none; } +.qi-framework-layer { display: grid; grid-template-columns: 72px 70px minmax(0,1fr); align-items: center; gap: 9px; padding: 11px 0; border-bottom: 1px solid var(--wt-line-soft); } +.qi-framework-layer:last-child { border-bottom: 0; } +.qi-framework-layer > span { color: var(--wt-faint); font-size: 10px; } +.qi-framework-layer > strong { color: var(--wt-gold-bright); font-family: var(--heaven-serif); font-size: 14px; } +.qi-framework-layer > small { color: var(--wt-muted); font-size: 10px; line-height: 1.6; } +.qi-layer-balance { grid-column: 2 / -1; height: 3px; display: flex; overflow: hidden; border-radius: 2px; background: var(--wt-track); } +.qi-layer-balance i { width: var(--qi-segment); height: 100%; display: block; } +.phase-wood { background: #6e9f75; }.phase-fire { background: #d76b61; }.phase-earth { background: #c99a4d; }.phase-metal { background: #d8d2bd; }.phase-water { background: #688eba; } +.phase-text-wood { color: #7fb88a; }.phase-text-fire { color: #e07b70; }.phase-text-earth { color: #d9ad60; }.phase-text-metal { color: #e3ddc9; }.phase-text-water { color: #7fa3cd; } +.personal-fortune-panel { margin-top: 14px; padding: 14px 0 0; border: 0; border-radius: 0; background: transparent; } +.personal-profile-empty { padding: 30px 12px; color: var(--wt-faint); font-size: 11px; text-align: center; } +.personal-primary-grid { display: grid; grid-template-columns: 76px minmax(0,1fr); gap: 12px; margin-top: 12px; } +.personal-day-master { display: grid; place-items: center; align-content: center; min-height: 118px; border-right: 1px solid var(--wt-line); } +.personal-day-master > span { color: var(--wt-faint); font-size: 10px; } +.personal-day-master-character { margin-top: 7px; font-family: var(--heaven-serif); font-size: 35px; line-height: 1; } +.personal-day-master-element { margin-top: 5px; font-size: 11px; } +.personal-day-master small { margin-top: 5px; color: var(--wt-faint); font-size: 9px; } +.personal-preferences { display: grid; align-content: center; gap: 10px; } +.personal-preferences section > span { display: block; margin-bottom: 5px; color: var(--wt-faint); font-size: 10px; } +.personal-preference-line { display: flex; align-items: baseline; gap: 8px; margin: 3px 0; } +.personal-preference-line strong { width: 30px; color: var(--wt-muted); font-size: 9px; } +.personal-preference-line p { display: flex; flex-wrap: wrap; gap: 5px; margin: 0; } +.personal-preference-line em { padding: 2px 5px; border: 1px solid var(--wt-line); border-radius: 4px; color: var(--wt-paper); font-size: 9px; font-style: normal; } +.fortune-sector-catalog { width: 100%; margin-top: 12px; overflow: hidden; border: 1px solid var(--wt-line); border-radius: 10px; background: var(--wt-surface); } +.fortune-sector-catalog > summary { min-height: 56px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 16px; cursor: pointer; list-style: none; } +.fortune-sector-catalog > summary::-webkit-details-marker { display: none; } +.fortune-sector-catalog > summary > span:first-child { display: grid; gap: 3px; } +.fortune-sector-catalog > summary small { color: var(--wt-cinnabar); font-size: 9px; letter-spacing: 1px; } +.fortune-sector-catalog > summary strong { color: var(--wt-paper); font-size: 15px; font-weight: 600; } +.fortune-sector-summary-hint { display: flex; align-items: center; gap: 8px; color: var(--wt-faint); font-size: 10px; } +.fortune-sector-summary-hint i { font-size: 14px; font-style: normal; transition: transform .35s ease; } +.fortune-sector-catalog[open] .fortune-sector-summary-hint i { transform: rotate(180deg); } +.fortune-sector-groups { display: grid; grid-template-columns: repeat(5,minmax(0,1fr)); border-top: 1px solid var(--wt-line); } +.fortune-sector-group { min-width: 0; padding: 14px; border-right: 1px solid var(--wt-line-soft); } +.fortune-sector-group:last-child { border-right: 0; } +.fortune-sector-group header { display: grid; grid-template-columns: 7px auto 1fr; align-items: center; gap: 7px; padding-bottom: 10px; border-bottom: 1px solid var(--wt-line-soft); } +.fortune-sector-group header i { width: 7px; height: 7px; border-radius: 50%; } +.fortune-sector-group header strong { font-size: 12px; } +.fortune-sector-group header small { color: var(--wt-faint); font-size: 10px; text-align: right; } +.fortune-sector-group ul { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0 0; padding: 0; list-style: none; } +.fortune-sector-group li { padding: 3px 6px; border: 1px solid var(--wt-line-soft); border-radius: 4px; color: var(--wt-muted); font-size: 11px; line-height: 1.5; } +.heart-journey { max-width: 640px; display: flex; align-items: center; justify-content: center; margin: 2px auto 12px; padding: 10px 16px; border: 1px solid var(--wt-line); border-radius: 9px; background: var(--wt-surface-raised); } +.heart-journey span { display: flex; align-items: center; gap: 6px; color: var(--wt-faint); font-family: var(--heaven-serif); font-size: 11px; white-space: nowrap; } +.heart-journey span i { width: 22px; height: 22px; display: grid; place-items: center; border: 1px solid var(--wt-line); border-radius: 50%; font-style: normal; } +.heart-journey span.active { color: var(--wt-gold-bright); } +.heart-journey span.active i { border-color: var(--wt-gold); background: rgba(201,165,92,.1); } +.heart-journey b { width: 46px; height: 1px; margin: 0 8px; background: var(--wt-line); } +.heart-stage-shell { min-height: 610px; position: relative; overflow: hidden; border: 1px solid var(--wt-line); border-radius: 14px; background: var(--wt-heart-stage-bg); } +.heart-bagua { width: 760px; height: 760px; opacity: .035; } +.heart-toolbar-controls { display: flex; gap: 7px; position: absolute; top: 14px; right: 14px; z-index: 9; } +.heart-toolbar-controls .button { min-height: 32px; padding: 5px 10px; font-size: 10px; } +.heart-whispers span { position: absolute; top: var(--whisper-y); left: var(--whisper-x); z-index: 1; color: rgba(216,210,189,.13); font-family: var(--heaven-serif); font-size: 12px; letter-spacing: 3px; writing-mode: vertical-rl; animation: heart-whisper var(--whisper-duration) ease-in-out var(--whisper-delay) infinite alternate; } +@keyframes heart-whisper { from { opacity: .18; transform: translateY(8px); } to { opacity: .68; transform: translateY(-8px); } } +.heart-stage { min-height: 610px; display: none; position: relative; z-index: 3; opacity: 0; } +.heart-stage.active-heart-stage { display: block; opacity: 1; animation: heart-stage-enter 1.15s ease both; } +.heart-stage.is-leaving { animation: heart-stage-leave 1.05s ease both; } +@keyframes heart-stage-enter { from { opacity: 0; filter: blur(6px); transform: translateY(10px); } to { opacity: 1; filter: none; transform: none; } } +@keyframes heart-stage-leave { to { opacity: 0; filter: blur(7px); transform: translateY(-7px); } } +.heart-stage-inner { min-height: 610px; display: grid; align-content: center; justify-items: center; padding: 56px 22px; text-align: center; } +.heart-stage-inner > h3 { margin: 20px 0 0; color: var(--wt-paper); font-size: 27px; font-weight: 500; letter-spacing: 4px; } +.heart-guidance { margin-top: 22px; color: var(--wt-muted); font-size: 12px; line-height: 1.8; } +.heart-guidance p { margin: 5px 0; } +.heart-motto { margin: 28px 0 26px; padding-top: 18px; border-top: 1px solid var(--wt-line); color: var(--wt-gold-bright); font-size: 15px; font-weight: 700; letter-spacing: 2px; } +.heart-rise { opacity: 0; filter: blur(5px); transform: translateY(12px); transition: opacity 1.2s ease,filter 1.2s ease,transform 1.2s ease; } +.heart-rise.is-visible { opacity: 1; filter: none; transform: none; } +.heart-return-button { position: absolute; top: 14px; left: 14px; z-index: 8; min-height: 32px; padding: 5px 10px; font-size: 10px; } +.breathing-stage { grid-template-rows: auto 220px auto auto; align-content: center; } +.breathing-scene { width: 220px; height: 220px; display: grid; place-items: center; position: relative; margin-top: 12px; } +.heart-breath-ripple { width: 180px; height: 180px; position: relative; } +.heart-breath-ripple span, .heart-breath-ripple > i { position: absolute; inset: 50%; border: 1px solid rgba(201,165,92,.32); border-radius: 50%; transform: translate(-50%,-50%) scale(.38); transition: transform 3s ease-in-out,opacity 2s ease; } +.heart-breath-ripple span:nth-child(1) { width: 100%; height: 100%; }.heart-breath-ripple span:nth-child(2) { width: 76%; height: 76%; }.heart-breath-ripple span:nth-child(3) { width: 52%; height: 52%; } +.heart-breath-ripple > i { width: 30%; height: 30%; border: 0; background: radial-gradient(circle,rgba(230,195,122,.6),rgba(201,165,92,.06) 65%,transparent 70%); box-shadow: 0 0 32px rgba(201,165,92,.28); } +.breathing-scene[data-phase="inhale"] .heart-breath-ripple span, .breathing-scene[data-phase="inhale"] .heart-breath-ripple > i { transform: translate(-50%,-50%) scale(1); transition-duration: 3s; } +.breathing-scene[data-phase="hold"] .heart-breath-ripple span, .breathing-scene[data-phase="hold"] .heart-breath-ripple > i { transform: translate(-50%,-50%) scale(1); } +.breathing-scene[data-phase="exhale"] .heart-breath-ripple span, .breathing-scene[data-phase="exhale"] .heart-breath-ripple > i { transform: translate(-50%,-50%) scale(.38); transition-duration: 4s; } +.breathing-phase { position: absolute; z-index: 2; color: var(--wt-paper); font-family: var(--heaven-serif); font-size: 22px; font-weight: 500; } +.breathing-stage > h3 { margin: 2px 0 0; font-size: 17px; letter-spacing: 2px; } +.breathing-stage #beginCastingButton { margin-top: 44px; } +.heart-incense { width: 2px; height: 270px; position: absolute; top: 50%; right: 7%; margin: 0; border-radius: 2px; background: linear-gradient(180deg,rgba(201,168,106,.05),rgba(201,168,106,.38)); transform: translateY(-50%); } +.heart-incense::after { content: "一炷香"; display: block; position: absolute; top: calc(100% + 14px); left: 50%; color: var(--wt-faint); font-family: var(--heaven-serif); font-size: 10px; letter-spacing: .22em; white-space: nowrap; transform: translateX(-50%); } +.heart-incense i { width: 10px; height: 10px; position: absolute; top: 0; left: 50%; margin: -5px 0 0 -5px; border-radius: 50%; background: radial-gradient(circle,#ffd9a0 0,#e08840 45%,transparent 75%); box-shadow: 0 0 14px 4px rgba(255,180,90,.35); transform: none; } +.heart-incense i::after { content: ""; width: 8px; height: 22px; position: absolute; bottom: 5px; left: 50%; border-radius: 50%; background: rgba(216,210,189,.18); filter: blur(4px); opacity: 0; transform: translateX(-50%); } +.heart-incense i.is-burning { animation: heart-incense-glow 1.8s ease-in-out infinite; } +.heart-incense i.is-burning::after { animation: heart-incense-smoke 2.2s ease-out infinite; } +@keyframes heart-incense-glow { 0%,100% { box-shadow: 0 0 10px 3px rgba(255,180,90,.26); } 50% { box-shadow: 0 0 18px 6px rgba(255,180,90,.5); } } +@keyframes heart-incense-smoke { 0% { opacity: 0; transform: translate(-50%,0) scale(.65); } 28% { opacity: .55; } 100% { opacity: 0; transform: translate(-70%,-28px) scale(1.2); } } +.heart-casting-layout, .heart-reveal-layout { min-height: 610px; display: grid; grid-template-columns: minmax(0,1.15fr) minmax(360px,.85fr); } +.heart-hexagram-shell, .heart-reveal-board { padding: 56px 30px 28px; border-right: 1px solid var(--wt-line); background: var(--wt-stage-veil); } +.casting-action-panel, .heart-first-thought { display: grid; align-content: center; justify-items: center; padding: 54px 24px; text-align: center; background: var(--wt-surface-soft); } +.heart-coins { display: flex; gap: 22px; margin: 42px 0 30px; perspective: 900px; } +.heart-coin { width: 82px; height: 82px; position: relative; border-radius: 50%; transform-style: preserve-3d; filter: drop-shadow(0 9px 12px rgba(0,0,0,.24)); } +.heart-coin-inner { width: 100%; height: 100%; position: relative; transform-style: preserve-3d; } +.heart-coin-face { position: absolute; inset: 0; border: 1px solid #e2c070; border-radius: 50%; backface-visibility: hidden; background: radial-gradient(circle at 34% 26%,#e1c36f 0,#bd8f34 36%,#80591d 76%,#4c3414 100%); color: #3c290b; box-shadow: inset 0 0 0 3px rgba(67,43,12,.42),inset 0 0 0 7px rgba(240,205,112,.22),inset -5px -7px 12px rgba(55,34,8,.34),inset 5px 6px 10px rgba(255,228,139,.22); } +.heart-coin-face::before { content: ""; position: absolute; inset: 9px; border: 1px solid rgba(65,42,11,.5); border-radius: 50%; box-shadow: 0 0 0 1px rgba(239,205,113,.25); } +.heart-coin-face::after { content: ""; width: 27%; height: 27%; position: absolute; top: 36.5%; left: 36.5%; border-radius: 1px; background: #0b1120; box-shadow: inset 0 0 0 2px #3d290d,0 0 0 2px rgba(231,191,91,.58),0 2px 4px rgba(0,0,0,.38); } +.heart-coin-face.front { background: radial-gradient(circle at 34% 26%,#ecd27e 0,#c99b3d 37%,#855d20 76%,#4c3414 100%); } +.heart-coin-face.back { transform: rotateY(180deg); } +.heart-coin-face.back { background: radial-gradient(circle at 66% 28%,#d9b85f 0,#ad7f2d 42%,#6e4a18 78%,#3e2a12 100%); } +.coin-hole { display: none; } +.coin-glyph { position: absolute; z-index: 2; color: rgba(55,34,8,.9); font-family: var(--heaven-serif); font-size: 14px; font-weight: 700; font-style: normal; line-height: 1; text-shadow: 0 1px rgba(255,224,130,.3); } +.coin-glyph-top { top: 8px; left: 50%; transform: translateX(-50%); } +.coin-glyph-right { top: 50%; right: 9px; transform: translateY(-50%); } +.coin-glyph-bottom { bottom: 8px; left: 50%; transform: translateX(-50%); } +.coin-glyph-left { top: 50%; left: 9px; transform: translateY(-50%); } +.heart-coin-face.back .coin-glyph { color: rgba(58,37,12,.8); font-size: 13px; } +.heart-coin-ring { position: absolute; inset: -7px; border: 1px solid rgba(201,165,92,.25); border-radius: 50%; opacity: 0; } +.heart-coin-ring.is-bursting { animation: coin-ring .7s ease-out; } +@keyframes coin-ring { from { opacity: .8; transform: scale(.7); } to { opacity: 0; transform: scale(1.45); } } +.heart-coin.is-shaking { animation: coin-shake .12s linear infinite alternate; } +@keyframes coin-shake { to { transform: translate(2px,-2px) rotate(2deg); } } +.casting-action-panel > h3, .heart-first-thought > h3 { color: var(--wt-paper); font-size: 18px; font-weight: 500; } +.heart-cast-button { --hold-progress:0turn; width: 100px; height: 100px; display: grid; place-items: center; position: relative; margin-top: 18px; border: 1px solid var(--wt-line); border-radius: 50%; background: conic-gradient(var(--wt-gold) var(--hold-progress),rgba(255,255,255,.03) 0); color: var(--wt-paper); } +.heart-cast-button::before { content:""; position:absolute; inset:4px; border-radius:50%; background:var(--wt-panel); } +.heart-cast-button span { position: relative; z-index: 2; font-family: var(--heaven-serif); line-height: 1.5; } +#heavenHeartPanel .heart-yao-empty { min-height: 7px; } +.heart-line-texts { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 6px; position: static; margin: 0; padding: 12px 18px 16px; } +.heart-line-text { min-width: 0; padding: 8px 10px; border: 1px solid var(--wt-line-soft); background: var(--wt-surface-raised); color: var(--wt-muted); text-align: left; } +.heart-line-text strong { color: var(--wt-paper); font-size: 10px; }.heart-line-text p { display: none; margin: 5px 0 0; font-size: 9px; line-height: 1.5; }.heart-line-text:hover p,.heart-line-text.is-inspected p { display: block; } +.heart-first-thought p { max-width: 320px; color: var(--wt-muted); font-size: 12px; line-height: 2; } +.heart-interpretation-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 48px 34px 18px; border-bottom: 1px solid var(--wt-line); } +.heart-interpretation-heading h3 { margin: 6px 0 0; color: var(--wt-paper); font-size: 25px; }.heart-interpretation-heading small { color: var(--wt-gold); } +.heart-read-actions { display: flex; gap: 8px; } +.heart-read-guaci { margin: 18px 34px; color: var(--wt-muted); font-family: var(--heaven-serif); font-size: 13px; } +.heart-read-layout { display: grid; grid-template-columns: 300px minmax(0,1fr); gap: 28px; padding: 0 34px 30px; } +.heart-read-lines, .heart-read-texts { display: grid; align-content: start; gap: 4px; } +.heart-read-line { min-height: 46px; display: grid; grid-template-columns: 40px 170px; align-items: center; color: var(--wt-faint); }.heart-read-line .hex-line i { height: 6px; } +.heart-read-text { min-height: 46px; padding: 7px 10px; border-left: 2px solid transparent; }.heart-read-text.moving { border-left-color: var(--wt-cinnabar); background: rgba(215,107,97,.06); }.heart-read-text strong { color: var(--wt-paper); font-size: 11px; }.heart-read-text p { margin: 4px 0 0; color: var(--wt-muted); font-size: 10px; } +.heart-read-motto { margin: 4px 34px; color: var(--wt-gold); text-align: right; } +.heaven-footnote { margin: 9px 2px 0; color: var(--wt-faint); font-size: 10px; text-align: right; } + +@media (max-width: 960px) { + .fortune-stage { grid-template-columns: 1fr; } + .qi-climate-panel { min-height: 440px; border-right: 0; border-bottom: 1px solid var(--wt-line); } + .fortune-bagua { left: 50%; } + .heart-casting-layout, .heart-reveal-layout { grid-template-columns: 1fr; } + .heart-hexagram-shell, .heart-reveal-board { border-right: 0; border-bottom: 1px solid var(--wt-line); } + .heart-line-texts { position: relative; right: auto; bottom: auto; left: auto; padding: 14px; } + .fortune-sector-groups { grid-template-columns: repeat(2,minmax(0,1fr)); } + .fortune-sector-group:nth-child(2n) { border-right: 0; } + .fortune-sector-group { border-bottom: 1px solid var(--wt-line-soft); } +} +@media (max-width: 640px) { + .fortune-heading { align-items: stretch; flex-direction: column; } + .fortune-heading-actions { display: grid; grid-template-columns: 1fr auto auto; } + .fortune-stage { min-height: 0; } + .qi-climate-panel { min-height: 390px; padding: 44px 20px; } + .qi-climate-panel h3 { font-size: 34px; } + .fortune-basics { padding: 10px; } + .qi-framework-layer { grid-template-columns: 62px 60px minmax(0,1fr); } + .heart-journey { overflow-x: auto; justify-content: flex-start; } + .heart-journey b { width: 18px; margin-inline: 4px; } + .heart-stage-shell,.heart-stage,.heart-stage-inner { min-height: 560px; } + .breathing-stage #beginCastingButton { margin-top: 36px; } + .heart-incense { right: 8%; height: 230px; } + .heart-incense::after { display: block; } + .heart-coins { gap: 9px; }.heart-coin { width: 68px; height: 68px; } + .coin-glyph { font-size: 11px; } + .coin-glyph-top { top: 7px; }.coin-glyph-right { right: 7px; }.coin-glyph-bottom { bottom: 7px; }.coin-glyph-left { left: 7px; } + .heart-hexagram-shell,.heart-reveal-board,.casting-action-panel,.heart-first-thought { padding-inline: 14px; } + .heart-line-texts { grid-template-columns: 1fr; } + .fortune-sector-groups { grid-template-columns: 1fr; } + .fortune-sector-group { border-right: 0; } + .heart-read-layout { grid-template-columns: 1fr; padding-inline: 14px; } + .heart-interpretation-heading { align-items: stretch; flex-direction: column; padding-inline: 14px; } + .heart-read-actions { display: grid; grid-template-columns: 1fr 1fr; } +} + +} diff --git a/tests/e2e/app-shell.spec.js b/tests/e2e/app-shell.spec.js index 8543aa4..ce7bbf8 100644 --- a/tests/e2e/app-shell.spec.js +++ b/tests/e2e/app-shell.spec.js @@ -133,7 +133,13 @@ async function mockApplication(page, authSession = session(), options = {}) { const url = new URL(route.request().url()); let payload = { ok: true }; if (url.pathname === "/api/auth/me") payload = authSession; - else if (url.pathname === "/api/dashboard") payload = dashboard; + else if (url.pathname === "/api/dashboard") { + options.dashboardRequests = (options.dashboardRequests || 0) + 1; + if (options.dashboardDelay) { + await new Promise((resolve) => setTimeout(resolve, options.dashboardDelay)); + } + payload = dashboard; + } else if (url.pathname === "/api/stock/002141/preview") { payload = { meta: { trade_date: "2026-07-23", intraday_trade_date: "2026-07-24", realtime: true, intraday_notice: "" }, @@ -233,6 +239,17 @@ async function mockApplication(page, authSession = session(), options = {}) { items: [{ role: "assistant", content: "Review evidence before forming a conclusion.", context_date: "20260722" }], }; } else if (url.pathname === "/api/search") payload = { groups: { stocks: [{ id: "002141", code: "002141", name: "Test Stock", type: "stock", industry: "Test Sector" }], sectors: [], themes: [], indices: [] } }; + else if (url.pathname === "/api/dragon-tiger/profiles") { + payload = { + meta: { status: "success", source: "tushare", cached: true }, + summary: { profile_count: 3, described_count: 2, organization_count: 4 }, + profiles: [ + { id: "hot-money-profile-1", name: "赵老哥", description: "聚焦市场核心标的。", organizations: ["华泰证券浙江分公司", "银河证券绍兴"], organization_count: 2 }, + { id: "hot-money-profile-2", name: "炒股养家", description: "重视情绪与风险收益比。", organizations: ["华鑫证券上海宛平南路"], organization_count: 1 }, + { id: "hot-money-profile-3", name: "作手新一", description: "", organizations: ["国泰海通证券南京太平南路"], organization_count: 1 }, + ], + }; + } else if (url.pathname === "/api/dragon-tiger") { payload = { meta: { trade_date: "2026-07-22", requested_date: "2026-07-22", status: "empty", source: "tushare" }, @@ -336,15 +353,25 @@ async function mockApplication(page, authSession = session(), options = {}) { }, ], }; + if (options.additionalScreenerRegimes) { + payload.regimes.push(...options.additionalScreenerRegimes); + } + if (options.additionalScreenerStrategies) { + payload.strategies.push(...options.additionalScreenerStrategies); + } if (options.latestScreenerResults) { payload.latest_results = options.latestScreenerResults; payload.latest_result = options.latestScreenerResults.smart || null; } + if (options.recentScreenerResults) { + payload.recent_results = options.recentScreenerResults; + } } else if (url.pathname === "/api/screener/run") { const body = route.request().postDataJSON(); options.screenerRunBodies = [...(options.screenerRunBodies || []), body]; + const runResult = options.screenerRunResult?.(body); payload = { - result: options.latestScreenerResults?.[body.mode] || { + result: runResult || options.latestScreenerResults?.[body.mode] || { meta: { run_id: 99, trade_date: "20260722", @@ -357,6 +384,9 @@ async function mockApplication(page, authSession = session(), options = {}) { backtest: null, }, }; + if (options.recentScreenerResults) { + options.recentScreenerResults.unshift(payload.result); + } } else if (url.pathname === "/api/screener/tracking") { if (route.request().method() === "POST") { const body = route.request().postDataJSON(); @@ -446,6 +476,61 @@ test("admin shell opens every primary workspace and global search", async ({ pag await expect(page.locator("#globalSearchInput")).toBeFocused(); }); +test("manual refresh stays in place without reopening the full-page loader", async ({ page }) => { + const options = { dashboardDelay: 350 }; + await mockApplication(page, session(), options); + await page.goto("/index.html"); + await expect(page.locator("#loadingOverlay")).toBeHidden(); + + await page.locator("#refreshButton").click(); + + await expect(page.locator("#refreshButton")).toBeDisabled(); + await expect(page.locator("#loadingOverlay")).toBeHidden(); + await expect(page.locator("#statusText")).toContainText("刷新"); + await expect(page.locator("#refreshButton")).toBeEnabled(); + expect(options.dashboardRequests).toBe(2); +}); + +test("night mode covers the application shell and persists across reloads", async ({ page }) => { + await mockApplication(page, session("admin", true)); + await page.addInitScript(() => { + if (sessionStorage.getItem("themeTestReady")) return; + localStorage.removeItem("xiaobaiTheme"); + sessionStorage.setItem("themeTestReady", "1"); + }); + await page.goto("/index.html"); + + await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); + await expect(page.locator("#themeToggle")).toHaveAttribute("aria-pressed", "false"); + await page.locator("#themeToggle").click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await expect(page.locator("#themeToggle")).toHaveAttribute("aria-label", "切换到日间模式"); + await expect(page.locator("#themeToggle")).toHaveAttribute("aria-pressed", "true"); + + const darkSurfaces = await page.evaluate(() => { + const color = (selector) => getComputedStyle(document.querySelector(selector)).backgroundColor; + return { + body: color("body"), + sidebar: color(".sidebar"), + topbar: color(".topbar"), + tableHead: color("#limitTable thead th"), + }; + }); + expect(new Set(Object.values(darkSurfaces)).has("rgb(255, 255, 255)")).toBe(false); + + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await expect(page.locator("#themeToggle")).toHaveAttribute("aria-pressed", "true"); + await page.keyboard.press("Control+K"); + await expect(page.locator("#globalSearchDialog")).toBeVisible(); + expect(await page.locator("#globalSearchDialog").evaluate((dialog) => getComputedStyle(dialog).backgroundColor)).not.toBe("rgb(255, 255, 255)"); + await page.locator("#closeGlobalSearch").click(); + + await page.locator("#themeToggle").click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); + await expect(page.locator("#themeToggle")).toHaveAttribute("aria-label", "切换到夜间模式"); +}); + test("collapsed overview and sentiment decision layout keep a single current reading", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await mockApplication(page, session("user", true)); @@ -1241,7 +1326,32 @@ test("dragon-tiger redesign keeps the merged empty state and independent card hi descriptionSize: 13, }); + await page.locator("#dragonProfilesButton").click(); + await expect(page.locator("#dragonProfilesContent")).toBeVisible(); + await expect(page.locator("#dragonDailyContent")).toBeHidden(); + await expect(page.locator("#hotMoneyProfileSummary > span")).toHaveCount(3); + await expect(page.locator("#hotMoneyProfileList .hot-money-profile-row-v2")).toHaveCount(3); + await expect(page.locator("#hotMoneyProfileDetail")).toContainText("赵老哥"); + await expect(page.locator("#hotMoneyProfileDetail")).toContainText("华泰证券浙江分公司"); + await page.locator("#hotMoneyProfileSearch").fill("宛平南路"); + await expect(page.locator("#hotMoneyProfileList .hot-money-profile-row-v2")).toHaveCount(1); + await expect(page.locator("#hotMoneyProfileDetail")).toContainText("炒股养家"); + await page.locator("#hotMoneyProfileSearch").fill(""); + await page.locator('[data-hot-money-profile="hot-money-profile-3"]').click(); + await expect(page.locator("#hotMoneyProfileDetail")).toContainText("名录暂未收录该游资的公开简介"); + await page.setViewportSize({ width: 390, height: 844 }); + const profileMobile = await page.evaluate(() => { + const list = document.querySelector("#hotMoneyProfileList").getBoundingClientRect(); + const detail = document.querySelector("#hotMoneyProfileDetail").getBoundingClientRect(); + return { + pageFits: document.documentElement.scrollWidth <= window.innerWidth + 1, + detailBelowList: detail.top >= list.bottom - 1, + }; + }); + expect(profileMobile).toEqual({ pageFits: true, detailBelowList: true }); + await page.locator("#dragonDailyButton").click(); + const mobile = await page.evaluate(() => ({ pageFits: document.documentElement.scrollWidth <= window.innerWidth + 1, operationsScroll: document.querySelector("#dragonTraderDetail .trader-operations").scrollWidth > document.querySelector("#dragonTraderDetail .trader-operations").clientWidth + 1, @@ -1954,6 +2064,93 @@ test("screener stage completion follows its execution context and mode results s await expect(page.locator("#screenerTableBody")).not.toContainText("量化结果"); }); +test("screener keeps results for each stage and curated strategy across switching and reload", async ({ page }) => { + const formula = { + meta: { library: "smart" }, universe: {}, filters: [], + score: [{ field: "relative_strength", weight: 1, direction: "desc" }], + limit: 10, min_score: 0.5, + }; + const options = { + recentScreenerResults: [], + additionalScreenerRegimes: [{ id: "retreat", label: "Retreat" }], + additionalScreenerStrategies: [ + { + id: 3, name: "Retreat Defense", description: "Retreat-stage strategy", + regimes: ["retreat"], builtin: true, data_ready: true, missing_data: [], formula, + }, + { + id: 4, name: "Quality B", description: "Second curated strategy", + regimes: ["repair"], builtin: true, data_ready: true, missing_data: [], + formula: { + ...formula, + meta: { library: "curated", category: "Quality", quality: "A", frequency: "Monthly", risk: "Low" }, + }, + }, + ], + }; + options.screenerRunResult = (body) => { + const candidateName = body.mode === "smart" + ? body.regime === "retreat" ? "Smart Retreat" : "Smart Repair" + : body.strategy_name === "Quality B" ? "Curated B" : "Curated A"; + return { + meta: { + run_id: 100 + options.recentScreenerResults.length, + trade_date: "20260722", + regime: body.regime, + strategy_name: body.strategy_name, + mode: body.mode, + }, + candidates: [{ + code: `60000${options.recentScreenerResults.length + 1}`, + name: candidateName, + sector: "Test Sector", + score_display: 80, + historical_probability: 50, + probability_samples: 20, + pct_chg: 1, + return_5d: 2, + volume_ratio_5d: 1.2, + sector_strength: 70, + reason: "Context result", + risk_flags: [], + }], + disclaimer: "Historical statistics do not predict future returns.", + backtest: null, + }; + }; + + await mockApplication(page, session("user", true), options); + await page.goto("/index.html?view=screenerView"); + + await page.locator("#screenerRunButton").click(); + await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair"); + await page.locator('[data-regime="retreat"]').click(); + await page.locator("#screenerRunButton").click(); + await expect(page.locator("#screenerTableBody")).toContainText("Smart Retreat"); + await page.locator('[data-regime="repair"]').click(); + await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair"); + await page.locator('[data-regime="retreat"]').click(); + await expect(page.locator("#screenerTableBody")).toContainText("Smart Retreat"); + + await page.locator('[data-screener-mode="curated"]').click(); + await page.locator('[data-curated-run="2"]').click(); + await expect(page.locator("#screenerTableBody")).toContainText("Curated A"); + await page.locator('[data-curated-run="4"]').click(); + await expect(page.locator("#screenerTableBody")).toContainText("Curated B"); + await page.locator('[data-curated-strategy="2"] .curated-card-description').click(); + await expect(page.locator("#screenerTableBody")).toContainText("Curated A"); + + await page.reload(); + await page.locator('[data-screener-mode="smart"]').click(); + await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair"); + await page.locator('[data-regime="retreat"]').click(); + await expect(page.locator("#screenerTableBody")).toContainText("Smart Retreat"); + await page.locator('[data-screener-mode="curated"]').click(); + await expect(page.locator("#screenerTableBody")).toContainText("Curated A"); + await page.locator('[data-curated-strategy="4"] .curated-card-description').click(); + await expect(page.locator("#screenerTableBody")).toContainText("Curated B"); +}); + test("screener tracking is an internal page populated only by manual candidate actions", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await mockApplication(page, session("user", true)); @@ -2048,8 +2245,8 @@ test("heaven workspace actions remain compact and do not overlap", async ({ page await page.goto("/index.html"); await page.locator('[data-view="heavenView"]').first().click(); - const titleSize = Number.parseFloat(await page.locator("#heavenView .heaven-toolbar h2").evaluate((element) => getComputedStyle(element).fontSize)); - expect(titleSize).toBeLessThanOrEqual(22); + const titleSize = Number.parseFloat(await page.locator("#heavenView .wt-title-line h1").evaluate((element) => getComputedStyle(element).fontSize)); + expect(titleSize).toBeLessThanOrEqual(32); const trendButtons = page.locator(".heaven-trend-actions .button"); await expect(trendButtons).toHaveCount(3); @@ -2075,7 +2272,8 @@ test("heaven workspace actions remain compact and do not overlap", async ({ page openHeavenReading("fortune", { loading: false }); }); const readingDialog = await page.locator("#heavenReadingDialog").boundingBox(); - expect(readingDialog.width).toBeLessThanOrEqual(920); + expect(readingDialog.width).toBeLessThanOrEqual(1120); + expect(readingDialog.width / readingDialog.height).toBeGreaterThan(1.4); expect(readingDialog.height).toBeLessThanOrEqual(820); const readingHeader = await page.locator("#heavenReadingDialog .dialog-header").boundingBox(); const readingTabs = await page.locator("#heavenReadingDialog .heaven-reading-tabs").boundingBox(); @@ -2113,7 +2311,9 @@ test("heaven workspace controls fit a narrow viewport", async ({ page }) => { expect(Math.abs(firstFortuneButton.y - secondFortuneButton.y)).toBeLessThanOrEqual(1); await page.locator('[data-heaven-panel="heart"]').click(); const heartControls = await page.locator(".heart-toolbar-controls").boundingBox(); - expect(heartControls.width).toBeLessThanOrEqual(80); + const heartPanel = await page.locator("#heavenHeartPanel").boundingBox(); + expect(heartControls.width).toBeLessThanOrEqual(heartPanel.width); + expect(heartControls.x).toBeGreaterThanOrEqual(heartPanel.x - 1); expect(heartControls.x + heartControls.width).toBeLessThanOrEqual(375); expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1); }); @@ -2128,7 +2328,7 @@ test("mentor directory exposes evidence filters and private owner metadata", asy await expect(page.locator("#mentorList .mentor-option")).toHaveCount(22); await expect(page.locator('#mentorList [data-mentor-id="private-owner"] .mentor-badge.private')).toContainText("仅自己"); await expect(page.locator('#mentorList [data-mentor-id="source-a"] .mentor-badge.grade-a')).toHaveText("A"); - await expect(page.locator('#mentorList [data-mentor-id="source-c"] .mentor-badge.quality')).toHaveText("5/6"); + await expect(page.locator('#mentorList [data-mentor-id="source-c"] .mentor-badge.quality')).toHaveCount(0); await page.locator("#mentorSearchInput").fill("行为推演"); await expect(page.locator("#mentorList .mentor-option")).toHaveCount(1); @@ -2183,6 +2383,18 @@ test("mentor pins, custom order and streamed replies work together", async ({ pa await expect(answer.locator(".mentor-answer-list li")).toHaveCount(2); await expect(answer.locator("br")).toHaveCount(0); await expect(page.locator("#mentorMessages .assistant-stream-caret")).toHaveCount(0); + await page.locator("#themeToggle").click(); + const darkMessageStyle = await answer.evaluate((element) => { + const style = getComputedStyle(element); + return { + background: style.backgroundColor, + border: style.borderTopColor, + shadow: style.boxShadow, + }; + }); + expect(darkMessageStyle.background).not.toBe("rgb(255, 255, 255)"); + expect(darkMessageStyle.border).not.toBe("rgb(255, 255, 255)"); + expect(darkMessageStyle.shadow).toBe("none"); }); test("mobile mentor directory opens as a searchable selector and hides private mentors", async ({ page }) => { diff --git a/tests/test_account_data_boundaries.py b/tests/test_account_data_boundaries.py index ff60374..b7b879a 100644 --- a/tests/test_account_data_boundaries.py +++ b/tests/test_account_data_boundaries.py @@ -79,6 +79,48 @@ class AccountDataBoundaryTests(unittest.TestCase): self.database.latest_screener_runs(self.second["id"], "20260722"), {} ) + def test_latest_screener_context_runs_keep_each_stage_and_strategy(self): + runs = [ + ("smart", "repair", "Repair", "600001"), + ("smart", "repair", "Repair", "600002"), + ("smart", "retreat", "Retreat", "600003"), + ("curated", "repair", "Dividend", "600004"), + ("curated", "repair", "Momentum", "600005"), + ("quant", "repair", "Custom quant", "600006"), + ("quant", "repair", "Custom quant", "600007"), + ] + for mode, regime, strategy, code in runs: + self.database.save_screener_run( + self.first["id"], "20260722", regime, strategy, FORMULA, + {"candidates": [{"code": code}], "meta": {}}, mode, + ) + + results = self.database.latest_screener_context_runs( + self.first["id"], "20260722" + ) + by_context = { + ( + item["meta"]["mode"], + item["meta"]["regime"] if item["meta"]["mode"] == "smart" else "", + item["meta"]["strategy_name"] if item["meta"]["mode"] != "quant" else "", + ): item["candidates"][0]["code"] + for item in results + } + + self.assertEqual(by_context, { + ("smart", "repair", "Repair"): "600002", + ("smart", "retreat", "Retreat"): "600003", + ("curated", "", "Dividend"): "600004", + ("curated", "", "Momentum"): "600005", + ("quant", "", ""): "600007", + }) + self.assertEqual( + self.database.latest_screener_context_runs( + self.second["id"], "20260722" + ), + [], + ) + def test_mentor_messages_are_scoped_by_user_mentor_and_date(self): self.database.save_mentor_exchange( self.first["id"], "mentor-a", "20260721", "怎么看?", "先看承接。", "20260721" diff --git a/tests/test_dashboard_cache.py b/tests/test_dashboard_cache.py new file mode 100644 index 0000000..0fd5882 --- /dev/null +++ b/tests/test_dashboard_cache.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import copy +import unittest + +from server import DashboardService + + +class SnapshotDatabase: + def __init__(self, snapshot, latest=None): + self.snapshot = snapshot + self.latest = latest + self.aliases = {} + + def get_snapshot(self, _trade_date): + return copy.deepcopy(self.snapshot) + + def reason_overrides(self, _trade_date): + return {} + + def get_data_snapshot(self, kind, cache_key): + return copy.deepcopy(self.aliases.get((kind, cache_key))) + + def save_data_snapshot(self, kind, cache_key, _source, payload): + self.aliases[(kind, cache_key)] = copy.deepcopy(payload) + + def get_latest_real_snapshot(self, _trade_date, strictly_before=False): + return copy.deepcopy(self.latest) + + +class DashboardCacheTests(unittest.TestCase): + def service(self, snapshot): + service = object.__new__(DashboardService) + service.database = SnapshotDatabase(snapshot) + return service + + def test_cached_dashboard_skips_sentiment_rebuild_when_fields_are_complete(self): + snapshot = { + "meta": {"source": "tushare", "trade_date": "2026-07-22"}, + "overview": { + "sentiment_score": 32, + "sentiment_label": "weak", + "sentiment_phase": "retreat", + "sentiment_direction": "cooling", + "sentiment_components": {}, + }, + } + service = self.service(snapshot) + service._enrich_dashboard_sentiment = lambda *_args: self.fail( + "complete cached sentiment must not be rebuilt" + ) + + payload = service.get_dashboard("2026-07-22") + + self.assertTrue(payload["meta"]["cached"]) + self.assertEqual(payload["overview"]["sentiment_score"], 32) + + def test_cached_dashboard_rebuilds_legacy_snapshot_missing_sentiment(self): + snapshot = { + "meta": {"source": "tushare", "trade_date": "2026-07-22"}, + "overview": {"limit_up_count": 20}, + } + service = self.service(snapshot) + calls = [] + + def enrich(payload, trade_date): + calls.append(trade_date) + payload["overview"].update({ + "sentiment_score": 20, + "sentiment_label": "weak", + "sentiment_phase": "ice", + "sentiment_direction": "cooling", + "sentiment_components": {}, + }) + return payload + + service._enrich_dashboard_sentiment = enrich + + payload = service.get_dashboard("2026-07-22") + + self.assertEqual(calls, ["20260722"]) + self.assertEqual(payload["overview"]["sentiment_phase"], "ice") + + def test_weekend_dashboard_reuses_latest_close_without_external_sync(self): + latest = { + "meta": {"source": "tushare", "trade_date": "2026-07-24"}, + "overview": { + "sentiment_score": 32, + "sentiment_label": "weak", + "sentiment_phase": "retreat", + "sentiment_direction": "cooling", + "sentiment_components": {}, + }, + } + service = object.__new__(DashboardService) + service.database = SnapshotDatabase(None, latest) + service.sync_dashboard = lambda *_args: self.fail( + "weekend refresh must not call the external synchronization path" + ) + + first = service.get_dashboard("2026-07-25") + service.database.latest = None + second = service.get_dashboard("2026-07-25") + + self.assertTrue(first["meta"]["carried_forward"]) + self.assertEqual(first["meta"]["trade_date"], "2026-07-24") + self.assertEqual(second["meta"]["requested_date"], "2026-07-25") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_frontend_contract.py b/tests/test_frontend_contract.py index bfd4862..7a32216 100644 --- a/tests/test_frontend_contract.py +++ b/tests/test_frontend_contract.py @@ -24,6 +24,8 @@ class FrontendContractTests(unittest.TestCase): cls.html = (STATIC_DIR / "index.html").read_text(encoding="utf-8") cls.script = (STATIC_DIR / "app.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") collector = IdCollector() collector.feed(cls.html) cls.ids = collector.ids @@ -62,6 +64,13 @@ class FrontendContractTests(unittest.TestCase): ): self.assertIn(field, (STATIC_DIR.parent / "screener.py").read_text(encoding="utf-8")) + def test_wencai_workspace_is_not_exposed_and_mentor_hides_internal_quality_score(self): + self.assertNotIn('id="wencaiView"', self.html) + self.assertNotIn('data-view="wencaiView"', self.html) + for endpoint in ("/api/wencai", "/api/wencai/query", "/api/wencai/saved"): + self.assertNotIn(endpoint, self.script) + self.assertNotIn("${score}/${total}", self.script) + def test_auction_navigation_and_frontend_pools_follow_product_order(self): rotation = self.html.index('data-view="rotationView"') auction = self.html.index('data-view="auctionView"') @@ -123,7 +132,7 @@ class FrontendContractTests(unittest.TestCase): def test_shared_ui_core_loads_before_application(self): self.assertLess( self.html.index('