Compare commits

..
137 changed files with 13642 additions and 11037 deletions
+64 -29
View File
@@ -1,40 +1,75 @@
# Architecture
# Candidate architecture
The normative governance contract is documented in
`docs/governance/architecture-standard.md`. This file describes the currently deployed
shape; the standard defines the target boundaries and the rules applied during migration.
`app/` is the behavior-preserving modular source tree accepted by the user on 2026-08-01.
The original `webapp/` runtime remains the deployment rollback baseline until an explicitly
approved switch. `next/` is a rejected, frozen implementation and is not a source for this
directory.
The application intentionally keeps a small deployment footprint: one Python process, one
SQLite database, and a build-free browser client. The internal boundaries are nevertheless
explicit so new features do not bypass account isolation or data-quality rules.
The application deliberately remains a modular monolith: one Python process, one SQLite WAL
database, and a build-free HTML/CSS/JavaScript client. The migration changed source ownership
and imports, not the technology stack or observable product behavior.
## Backend boundaries
## Runtime path
- `server.py`: application services and HTTP request/response wiring.
- `api_access.py`: the single authorization policy for authenticated, member, and admin APIs.
- `app_config.py`: runtime paths, local environment loading, and shared input validation.
- `database.py`: SQLite schema, migrations, and persistence operations.
- `tushare_client.py` and `realtime_aggregator.py`: external market-data adapters.
- `sentiment_engine.py`, `screener.py`, and `heaven_engine.py`: deterministic domain logic.
- `mentor_agent.py`, `heaven_agent.py`, and `llm_strategy.py`: bounded LLM adapters.
```text
browser
-> frontend/shared/api.js
-> backend HTTP transport and feature HTTP mixins
-> feature services
-> repositories / DataGateway / LLMGateway
-> SQLite / market providers / model providers
## Data ownership
background scheduler
-> backend/jobs
-> the same feature services and repositories
```
Public market snapshots, stock factors, built-in strategies, limit-up reasons, seat aliases,
and sector-element mappings are shared. Only administrators can modify shared knowledge.
## Source ownership
Watchlists, review notes, custom strategies, screener runs, mentor conversations, birth data,
alerts, trading journals, and assistant conversations are owned by a user ID and must be
queried with that ID. LLM features additionally require active membership.
- `server.py` is the stable command/import facade. Runtime composition lives in
`backend/application.py` and `backend/bootstrap/`.
- `backend/http/` owns common authentication, request IDs, responses, static delivery, and
error normalization. Feature-specific transport handlers live beside their feature.
Exact POST endpoints that only delegate to one of those handlers use the explicit maps in
`backend/application.py`; endpoints with path parameters, body handling, or special error
semantics remain visible control flow in `RequestHandler`.
- `backend/features/<feature>/` owns the mechanically moved service, repository, HTTP, agent,
or deterministic calculation code for that product area.
- `backend/data/` owns provider construction, source policy, provenance, units, freshness,
coverage, display-versus-calculation eligibility, and shared numeric normalization policies.
- `backend/database/` owns connection management, ordered migrations, and narrow repository
adapters. Root `database.py` remains the legacy schema/composition anchor and combines the
feature repository mixins; do not add feature queries to it.
- `backend/jobs/` owns job definitions, locks, retries, idempotency, and persisted run state.
- `backend/llm/` owns model selection, membership/quota checks, fallback, provider transport,
streaming rules, and call audit. Feature agents only prepare messages and interpret
feature-specific results.
- `frontend/shared/` is the only browser API/state/Shell/component boundary.
- `frontend/pages/` owns page-local behavior. The original runtime was split mechanically;
source markers and preservation tests prove that the pieces reassemble to the audited
original, apart from explicitly registered trial retirements.
- `frontend/styles/`, `frontend/shared/tokens.css`, and the Wentian page stylesheet preserve
the approved cascade and light/dark/mobile behavior.
- `config/` is the versioned registry for pages, features, APIs, datasets, quality rules,
jobs, and the generated candidate architecture inventory.
## Data integrity
Root modules such as `screener.py`, `tushare_client.py`, and `mentor_agent.py` are compatibility
aliases to canonical modules. They contain no second implementation and remain only because
the original public import surface is part of the preservation contract.
Production reads never synthesize market prices. A failed live request may use the latest real
snapshot at or before the requested date. When no real snapshot exists, the API reports that
the data is unavailable. Demo builders remain test fixtures only.
## Non-negotiable maintenance rules
## Change contract
1. Preserve account ownership in every user-private query and test it with two accounts.
2. Browser requests go through `frontend/shared/api.js`; provider calls go through the data
boundary; model calls go through `backend/llm/`.
3. Calculation datasets fail closed when required source, date, unit, freshness, or coverage
evidence is missing. Display fallbacks do not silently enter calculations.
4. Do not implement logic in both a root compatibility module and a canonical module.
5. Do not remove compatibility or uncertain code without reference scanning, old/new
differential evidence, browser checks, and manual acceptance.
6. Run `python tools/verify_baseline.py` for every change and add `--e2e` when runtime or
frontend behavior can be affected.
New endpoints must be added to `api_access.required_role` when they need member or admin
access. New user-owned tables must include `user_id`, an ownership index, and cross-account
tests. API payload compatibility is protected by the Python and Playwright suites.
The authoritative migration constraints and handoff procedure are in
`../docs/migration/原版保真迁移总纲.md` and
`../docs/migration/人工维护与本地切换指南.md`.
+9 -5
View File
@@ -2,23 +2,27 @@
一个面向 A 股盘后复盘的本地 Web 工作台。后端使用 Python 访问 Tushare Pro,前端不依赖构建工具。
本目录是从原版源码逐项移动、机械拆分并完成差分验证与用户人工验收的模块化正式源码,
不是依据规格书重新开发的第二套产品。正式部署切换前,`webapp/`根目录继续作为当前部署与
回档基线;冻结的`next/`不得用于部署或后续开发。目录职责见[ARCHITECTURE.md](ARCHITECTURE.md)。
当前包含集合竞价、涨停池、炸板池、跌停板、昨日涨停、涨停表现、市场天梯、板块轮动、题材库、人气热榜、龙虎榜和个人复盘工作区。交易日快照与同步记录保存在本地 SQLite 数据库 `data/review.db`
集合竞价中心采用盘前生命周期:9:15 前显示预告,9:15–9:25 明确等待最终竞价,9:25–9:30 自动读取并重试最终竞价筛选,9:30 后停止更新并冻结为复盘归档。当前 Tushare 只提供 9:25 最终竞价快照,不将其表述为动态虚拟撮合行情。
第三阶段加入了机构席位、席位别名、个股复权日 K、资金流、自选股、涨停原因修订、个股笔记、每日复盘和历史数据回补。
股票代码在桌面端悬停后会显示分时与日 K 快速预览,默认优先展示日 K;移动端点击代码后从底部打开预览面板。股票详情以及板块、题材、指数详情均可在日 K 与最新分时之间切换。日 K 复用个股详情缓存;分时使用隔离的东方财富分钟图表源和短时内存缓存,只负责展示,不写入主行情、不参与情绪、选股或问天计算。图表源不可用时界面会明确显示“分时不可用”,不会使用日 K 数据模拟分时走势。
股票代码在桌面端悬停后会显示分时与日 K 快速预览,默认优先展示日 K;移动端点击代码后从底部打开预览面板。股票详情以及板块、题材、指数详情均可在日 K 与最新分时之间切换。日 K 复用个股详情缓存;分时优先使用 iFinD,东方财富仅作隔离的展示兜底,并使用短时内存缓存。图表数据不写入主行情、不参与情绪、选股或问天计算不可用时明确显示“分时不可用”,不会用日 K 模拟分时走势。
智能选股模块包含 45 日全市场因子库、六阶段市场识别、七套内置策略、受控公式 DSL、自然语言策略编译、候选排名和滚动回测。竞价涨幅、竞价成交额、竞价换手率与竞价量比随因子数据一并同步,可用于自定义公式和历史回测。首次使用需在页面点击“同步因子数据”。未配置 LLM 时使用本地策略模板;配置兼容 API 后自动切换为主模型编译,主模型失败时自动使用辅助模型,两者均支持独立连通性测试
智能选股包含六阶段盘后候选、29 套精选策略、自定义公式 DSL、自然语言公式编译、候选排名和滚动回测。阶段与精选策略在当日行情更新后由后台确定性计算;自定义选股由用户手动执行,LLM 只负责编译自然语言条件,不参与候选筛选。竞价、估值、财务、资金、人气和席位等字段按已登记的数据可用性进入因子库,缺失时明确显示覆盖问题
每次选股结果会自动进入五交易日持续跟踪,展示 T+1 开盘/收盘、T+3、T+5、最大涨幅与最大回撤。提醒中心支持手工日期提醒,并在策略首日反馈和五日跟踪完成时生成账号私有的站内提醒。
候选只有经用户手动加入后才进入五交易日持续跟踪,展示 T+1 开盘/收盘、T+3、T+5、最大涨幅与最大回撤。提醒中心支持手工日期提醒,并在策略首日反馈和五日跟踪完成时生成账号私有的站内提醒。
问师模块会读取当前复盘、近十日市场情绪、涨跌停、昨日反馈、板块轮动、市场阶段、龙虎榜和指定个股数据,再按选中的游资思维 Skill 进行单师对话。对话记录按账号、老师和交易日期保存在服务端;主模型不可用时自动切换辅助模型。
新增公开问师角色时,在 `游资skills` 下增加一个包含 `SKILL.md` 的独立目录,并在 `游资skills/mentor_catalog.json` 中登记素材等级与结构质检。管理员私有角色放在 `data/private-mentor-skills`,该目录不进入 Git 或 Docker 镜像,且只会出现在管理员的问师列表中。系统会从 Skill 的 frontmatter、一级标题、核心模型和引用语中自动生成角色信息,无需修改注册代码。
问天模块包含三个相互独立的部分:观势以市场数据生成三才六爻,用于观察“势”,行情缺失或自动取象明显偏差时可显式手动校准六爻,人工结果与自动来源严格区分;观气依据干支、精确节气、五运六气及客主加临关系观察“运”,行业五行仅作传统取象归类;观心通过30秒静心、六次三枚铜钱起卦、察念和解卦完成一次不输入问题的问心仪式。卦象、干支、节气与气机关系均由本地确定性程序计算,LLM只负责解释,不参与起卦或改动结果。
问天模块包含三个相互独立的部分:观势以市场数据生成三才六爻,用于观察“势”,行情缺失或自动取象明显偏差时可显式手动校准六爻,人工结果与自动来源严格区分;观气依据干支、精确节气、五运六气及客主加临关系观察“运”,行业五行仅作传统取象归类;观心先准备1秒,再完成5轮“吸3秒、顿2秒、呼4秒”,随后以六次三枚铜钱起卦、察念和解卦完成一次不输入问题的问心仪式。卦象、干支、节气与气机关系均由本地确定性程序计算,LLM只负责解释,不参与起卦或改动结果。
问天模块使用项目本地的 `lunar-python` 计算历法,并使用 `data/iching_zh.json` 中的固定六十四卦、卦辞和爻辞。第三方授权见 `THIRD_PARTY_NOTICES.md`
@@ -27,7 +31,7 @@
## 启动
```powershell
cd webapp
cd webapp\app
python -m pip install -r requirements.txt
python server.py
```
+43 -79
View File
@@ -507,6 +507,40 @@ class DashboardService(
SERVICE = DashboardService()
PUBLIC_POST_HANDLERS = {
"/api/auth/register": "auth_register",
"/api/auth/login": "auth_login",
}
AUTHENTICATED_POST_HANDLERS = {
"/api/auth/logout": "auth_logout",
"/api/account/birth-profile": "save_birth_profile",
"/api/account/password": "change_password",
"/api/alerts": "save_alert",
"/api/trades": "save_trade_entry",
"/api/assistant/chat": "stream_assistant_chat",
"/api/admin/settings": "save_system_settings",
"/api/admin/settings/test": "test_system_llm_settings",
"/api/admin/membership": "save_membership",
"/api/admin/refresh": "start_background_refresh",
"/api/watchlist": "save_watchlist",
"/api/notes": "save_note",
"/api/reasons": "save_reason",
"/api/seat-aliases": "save_seat_alias",
"/api/heaven/sector-phases": "save_sector_phase_override",
"/api/backfill": "backfill_data",
"/api/screener/sync": "sync_screener_data",
"/api/screener/compile": "compile_screener_strategy",
"/api/screener/strategies": "save_screener_strategy",
"/api/screener/run": "run_screener",
"/api/screener/tracking/refresh": "refresh_screener_tracking",
"/api/mentors/chat": "stream_mentor_chat",
"/api/heaven/hexagram": "heaven_hexagram",
"/api/heaven/personal": "heaven_personal",
"/api/heaven/interpret": "heaven_interpret",
}
class RequestHandler(
AccountHttpMixin,
SystemHttpMixin,
@@ -522,6 +556,13 @@ class RequestHandler(
application_service = SERVICE
route_registry = ROUTES
def _dispatch_named_handler(self, path: str, handlers: dict[str, str]) -> bool:
handler_name = handlers.get(path)
if handler_name is None:
return False
getattr(self, handler_name)()
return True
def do_GET(self) -> None:
parsed = urlparse(self.path)
if parsed.path == "/api/health":
@@ -864,24 +905,13 @@ class RequestHandler(
def do_POST(self) -> None:
parsed = urlparse(self.path)
if parsed.path == "/api/auth/register":
self.auth_register()
return
if parsed.path == "/api/auth/login":
self.auth_login()
if self._dispatch_named_handler(parsed.path, PUBLIC_POST_HANDLERS):
return
if not self.require_auth() or not self.require_csrf():
return
if not self.require_access("POST", parsed.path):
return
if parsed.path == "/api/auth/logout":
self.auth_logout()
return
if parsed.path == "/api/account/birth-profile":
self.save_birth_profile()
return
if parsed.path == "/api/account/password":
self.change_password()
if self._dispatch_named_handler(parsed.path, AUTHENTICATED_POST_HANDLERS):
return
alert_read_match = re.fullmatch(r"/api/alerts/(\d+)/read", parsed.path)
if alert_read_match:
@@ -895,57 +925,6 @@ class RequestHandler(
{"ok": True, **SERVICE.mark_all_alerts_read(str(body.get("as_of") or ""))}
)
return
if parsed.path == "/api/alerts":
self.save_alert()
return
if parsed.path == "/api/trades":
self.save_trade_entry()
return
if parsed.path == "/api/assistant/chat":
self.stream_assistant_chat()
return
if parsed.path == "/api/admin/settings":
self.save_system_settings()
return
if parsed.path == "/api/admin/settings/test":
self.test_system_llm_settings()
return
if parsed.path == "/api/admin/membership":
self.save_membership()
return
if parsed.path == "/api/admin/refresh":
self.start_background_refresh()
return
if parsed.path == "/api/watchlist":
self.save_watchlist()
return
if parsed.path == "/api/notes":
self.save_note()
return
if parsed.path == "/api/reasons":
self.save_reason()
return
if parsed.path == "/api/seat-aliases":
self.save_seat_alias()
return
if parsed.path == "/api/heaven/sector-phases":
self.save_sector_phase_override()
return
if parsed.path == "/api/backfill":
self.backfill_data()
return
if parsed.path == "/api/screener/sync":
self.sync_screener_data()
return
if parsed.path == "/api/screener/compile":
self.compile_screener_strategy()
return
if parsed.path == "/api/screener/strategies":
self.save_screener_strategy()
return
if parsed.path == "/api/screener/run":
self.run_screener()
return
if parsed.path == "/api/screener/tracking":
try:
result = SERVICE.add_screener_tracking(self.read_json_body())
@@ -953,9 +932,6 @@ class RequestHandler(
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/screener/tracking/refresh":
self.refresh_screener_tracking()
return
if parsed.path == "/api/mentors/preferences":
try:
result = SERVICE.save_mentor_preferences(self.read_json_body())
@@ -963,18 +939,6 @@ class RequestHandler(
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/mentors/chat":
self.stream_mentor_chat()
return
if parsed.path == "/api/heaven/hexagram":
self.heaven_hexagram()
return
if parsed.path == "/api/heaven/personal":
self.heaven_personal()
return
if parsed.path == "/api/heaven/interpret":
self.heaven_interpret()
return
self.send_json({"error": "Not found"}, HTTPStatus.NOT_FOUND)
def do_DELETE(self) -> None:
+1 -1
View File
@@ -9,7 +9,7 @@ from typing import Any
APP_DIR = Path(__file__).resolve().parents[2]
STATIC_DIR = APP_DIR / "static"
STATIC_DIR = APP_DIR / "frontend"
DATA_DIR = APP_DIR / "data"
ENV_FILE = APP_DIR / ".env"
MENTOR_SKILLS_DIR = APP_DIR / "游资skills"
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
import math
from typing import Any
def finite_number(value: Any, default: float = 0.0) -> float:
try:
number = float(value)
return number if math.isfinite(number) else default
except (TypeError, ValueError):
return default
def non_nan_number(value: Any, default: float = 0.0) -> float:
try:
number = float(value)
return number if number == number else default
except (TypeError, ValueError):
return default
+1 -8
View File
@@ -11,6 +11,7 @@ from datetime import datetime, time as dt_time, timedelta
from threading import Lock
from typing import Any, ClassVar
from backend.data.numbers import finite_number as _number
from backend.features.sentiment.engine import apply_sentiment_to_dashboard
@@ -1812,14 +1813,6 @@ class TushareClient:
}
def _number(value: Any, default: float = 0.0) -> float:
try:
number = float(value)
return number if math.isfinite(number) else default
except (TypeError, ValueError):
return default
def _text(value: Any) -> str:
if isinstance(value, (list, tuple, set)):
return "".join(str(item).strip() for item in value if str(item).strip())
+20 -50
View File
@@ -1,11 +1,10 @@
from __future__ import annotations
import json
import time
import urllib.error
import urllib.request
from typing import Any
from backend.llm import transport as llm_transport
class HeavenAgentError(RuntimeError):
pass
@@ -24,45 +23,33 @@ def interpret_heaven(
if not api_key or not model:
raise HeavenAgentError("LLM API Key 或模型尚未配置。")
system_prompt = _system_prompt(mode)
payload = json.dumps(
messages = [
{"role": "system", "content": system_prompt},
{
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": json.dumps(context, ensure_ascii=False, separators=(",", ":")),
},
],
"stream": False,
"role": "user",
"content": json.dumps(context, ensure_ascii=False, separators=(",", ":")),
},
ensure_ascii=False,
).encode("utf-8")
request = urllib.request.Request(
f"{base_url.rstrip('/')}/chat/completions",
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"User-Agent": "XiaobaiReviewWeb/0.7",
},
method="POST",
)
started = time.perf_counter()
]
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
result = json.loads(response.read().decode("utf-8"))
answer = str(result["choices"][0]["message"]["content"]).strip()
result = llm_transport.chat_completion(
api_key=api_key,
base_url=base_url,
model=model,
messages=messages,
timeout=timeout,
user_agent="XiaobaiReviewWeb/0.7",
)
answer = str(result.content).strip()
if not answer:
raise KeyError("empty response")
except urllib.error.HTTPError as exc:
raise HeavenAgentError(_http_error_message(exc)) from exc
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc:
except llm_transport.OpenAIHTTPError as exc:
raise HeavenAgentError(exc.describe("问天模型调用失败")) from exc
except (llm_transport.OpenAITransportError, KeyError) as exc:
raise HeavenAgentError(f"问天模型调用失败:{exc}") from exc
return {
"answer": answer,
"model": model,
"latency_ms": round((time.perf_counter() - started) * 1000),
"latency_ms": result.latency_ms,
}
@@ -99,20 +86,3 @@ def _system_prompt(mode: str) -> str:
全文控制在180至350个中文字符。只写一句卦意;一小段动爻与之卦;最后三句极短的问心句。
不要重述六条爻辞,不猜用户未说出口的问题,不以吉凶二字替代思考,不给出股票涨跌预测。语气安静、克制,越短越有余味。
""".strip()
def _http_error_message(exc: urllib.error.HTTPError) -> str:
detail = ""
try:
payload = json.loads(exc.read().decode("utf-8", errors="replace"))
error = payload.get("error")
if isinstance(error, dict):
detail = str(error.get("message") or error.get("code") or "")
elif error:
detail = str(error)
elif payload.get("message"):
detail = str(payload["message"])
except (json.JSONDecodeError, OSError):
detail = ""
suffix = f"{detail[:300]}" if detail else ""
return f"问天模型调用失败(HTTP {exc.code}{suffix}"
+1 -10
View File
@@ -12,6 +12,7 @@ from datetime import datetime, time as dt_time, timedelta
from threading import Lock
from typing import Any, ClassVar
from backend.bootstrap.config import tushare_code as _stock_market_code
from backend.data.providers.ifind_client import IfindError, IfindHttpClient
@@ -475,16 +476,6 @@ def _ifind_point(row: dict[str, Any]) -> dict[str, Any] | None:
}
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)
+1 -8
View File
@@ -6,6 +6,7 @@ from datetime import datetime, time as dt_time, timedelta, timezone
from statistics import median
from typing import TYPE_CHECKING, Any, Callable
from backend.data.numbers import non_nan_number as _number
from backend.data.providers.ifind_client import IfindError, IfindHttpClient
from backend.data.providers.tushare_client import TushareClient, TushareError
@@ -16,14 +17,6 @@ if TYPE_CHECKING:
CHINA_TIMEZONE = timezone(timedelta(hours=8))
def _number(value: Any, default: float = 0.0) -> float:
try:
number = float(value)
return number if number == number else default
except (TypeError, ValueError):
return default
def _display_date(value: str) -> str:
text = str(value or "").replace("-", "")
if len(text) != 8:
+14 -63
View File
@@ -3,14 +3,12 @@ from __future__ import annotations
import json
import re
import time
import urllib.error
import urllib.request
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from llm_stream import OpenAIStreamAccumulator
from backend.llm import transport as llm_transport
class MentorAgentError(RuntimeError):
@@ -195,50 +193,20 @@ def stream_with_mentor(
messages = [{"role": "system", "content": system_prompt}]
messages.extend(history[-10:])
messages.append({"role": "user", "content": question})
payload = json.dumps(
{"model": model, "messages": messages, "stream": True},
ensure_ascii=False,
).encode("utf-8")
request = urllib.request.Request(
f"{base_url.rstrip('/')}/chat/completions",
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"User-Agent": "XiaobaiReviewWeb/0.6",
"Accept": "text/event-stream",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
yielded = False
accumulator = OpenAIStreamAccumulator()
for raw_line in response:
line = raw_line.decode("utf-8", errors="replace").strip()
if not line or line.startswith(":"):
continue
if line.startswith("data:"):
line = line[5:].strip()
if line == "[DONE]":
break
try:
result = json.loads(line)
except json.JSONDecodeError:
continue
choices = result.get("choices") or []
if not choices:
continue
choice = choices[0] or {}
content = accumulator.feed(choice)
if content:
yielded = True
yield str(content)
if not yielded:
raise MentorAgentError("问师模型未返回有效内容。")
except urllib.error.HTTPError as exc:
raise MentorAgentError(_http_error_message(exc)) from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
yield from llm_transport.stream_chat_completion(
api_key=api_key,
base_url=base_url,
model=model,
messages=messages,
timeout=timeout,
user_agent="XiaobaiReviewWeb/0.6",
)
except llm_transport.OpenAIEmptyResponseError as exc:
raise MentorAgentError("问师模型未返回有效内容。") from exc
except llm_transport.OpenAIHTTPError as exc:
raise MentorAgentError(exc.describe("问师模型调用失败")) from exc
except llm_transport.OpenAITransportError as exc:
raise MentorAgentError(f"问师模型调用失败:{exc}") from exc
@@ -298,20 +266,3 @@ def _parse_frontmatter(content: str) -> dict[str, str]:
def _first_sentence(text: str) -> str:
compact = " ".join(line.strip() for line in text.splitlines() if line.strip())
return re.split(r"[。;]", compact, maxsplit=1)[0].strip()
def _http_error_message(exc: urllib.error.HTTPError) -> str:
detail = ""
try:
payload = json.loads(exc.read().decode("utf-8", errors="replace"))
error = payload.get("error")
if isinstance(error, dict):
detail = str(error.get("message") or error.get("code") or "")
elif error:
detail = str(error)
elif payload.get("message"):
detail = str(payload["message"])
except (json.JSONDecodeError, OSError):
detail = ""
suffix = f"{detail[:300]}" if detail else ""
return f"问师模型调用失败(HTTP {exc.code}{suffix}"
+13 -43
View File
@@ -1,12 +1,10 @@
from __future__ import annotations
import json
import urllib.error
import urllib.request
from collections.abc import Iterator
from typing import Any
from llm_stream import OpenAIStreamAccumulator
from backend.llm import transport as llm_transport
class ReviewAssistantError(RuntimeError):
@@ -27,48 +25,20 @@ def stream_review_assistant(
messages = [{"role": "system", "content": _system_prompt(context)}]
messages.extend(history[-12:])
messages.append({"role": "user", "content": question})
request = urllib.request.Request(
f"{base_url.rstrip('/')}/chat/completions",
data=json.dumps(
{"model": model, "messages": messages, "stream": True}, ensure_ascii=False
).encode("utf-8"),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"User-Agent": "XiaobaiReviewWeb/1.0",
"Accept": "text/event-stream",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
yielded = False
accumulator = OpenAIStreamAccumulator()
for raw_line in response:
line = raw_line.decode("utf-8", errors="replace").strip()
if not line or line.startswith(":"):
continue
if line.startswith("data:"):
line = line[5:].strip()
if line == "[DONE]":
break
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
choices = payload.get("choices") or []
if not choices:
continue
choice = choices[0] or {}
content = accumulator.feed(choice)
if content:
yielded = True
yield str(content)
if not yielded:
raise ReviewAssistantError("智能解读未返回有效内容。")
except urllib.error.HTTPError as exc:
yield from llm_transport.stream_chat_completion(
api_key=api_key,
base_url=base_url,
model=model,
messages=messages,
timeout=timeout,
user_agent="XiaobaiReviewWeb/1.0",
)
except llm_transport.OpenAIEmptyResponseError as exc:
raise ReviewAssistantError("智能解读未返回有效内容。") from exc
except llm_transport.OpenAIHTTPError as exc:
raise ReviewAssistantError(f"智能解读服务暂不可用({exc.code})。") from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
except llm_transport.OpenAITransportError as exc:
raise ReviewAssistantError("智能解读连接中断,请稍后重试。") from exc
+27 -73
View File
@@ -1,11 +1,9 @@
from __future__ import annotations
import json
import time
import urllib.error
import urllib.request
from typing import Any
from backend.llm import transport as llm_transport
from screener import FACTOR_FIELDS, REGIMES
@@ -21,39 +19,25 @@ def test_llm_connection(
) -> dict[str, Any]:
if not api_key or not model:
raise LLMCompilerError("API Key 或模型未配置。")
endpoint = f"{base_url.rstrip('/')}/chat/completions"
payload = json.dumps(
{
"model": model,
"messages": [{"role": "user", "content": "只回复 OK"}],
"stream": False,
},
ensure_ascii=False,
).encode("utf-8")
request = urllib.request.Request(
endpoint,
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"User-Agent": "XiaobaiReviewWeb/0.5",
},
method="POST",
)
started = time.perf_counter()
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
result = json.loads(response.read().decode("utf-8"))
reply = str(result["choices"][0]["message"]["content"]).strip()
except urllib.error.HTTPError as exc:
raise LLMCompilerError(_http_error_message(exc)) from exc
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc:
result = llm_transport.chat_completion(
api_key=api_key,
base_url=base_url,
model=model,
messages=[{"role": "user", "content": "只回复 OK"}],
timeout=timeout,
user_agent="XiaobaiReviewWeb/0.5",
)
reply = str(result.content).strip()
except llm_transport.OpenAIHTTPError as exc:
raise LLMCompilerError(exc.describe("模型连接测试失败")) from exc
except llm_transport.OpenAITransportError as exc:
raise LLMCompilerError(f"模型连接测试失败:{exc}") from exc
return {
"ok": True,
"model": model,
"reply": reply[:100],
"latency_ms": round((time.perf_counter() - started) * 1000),
"latency_ms": result.latency_ms,
}
@@ -67,7 +51,6 @@ def compile_strategy_with_llm(
) -> dict[str, Any]:
if not api_key or not model:
raise LLMCompilerError("尚未配置 LLM API Key 或模型。")
endpoint = f"{base_url.rstrip('/')}/chat/completions"
schema = {
"name": "策略名称",
"description": "策略说明",
@@ -90,57 +73,28 @@ def compile_strategy_with_llm(
"退潮和冰点策略必须提高门槛并允许结果为空。"
f"严格遵循以下结构:{json.dumps(schema, ensure_ascii=False)}"
)
payload = json.dumps(
{
"model": model,
"messages": [
try:
result = llm_transport.chat_completion(
api_key=api_key,
base_url=base_url,
model=model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt[:3000]},
],
"stream": False,
},
ensure_ascii=False,
).encode("utf-8")
request = urllib.request.Request(
endpoint,
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"User-Agent": "XiaobaiReviewWeb/0.4",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
result = json.loads(response.read().decode("utf-8"))
content = result["choices"][0]["message"]["content"].strip()
timeout=timeout,
user_agent="XiaobaiReviewWeb/0.4",
)
content = result.content.strip()
if content.startswith("```"):
content = content.strip("`")
if content.startswith("json"):
content = content[4:].strip()
compiled = json.loads(content)
except urllib.error.HTTPError as exc:
raise LLMCompilerError(_http_error_message(exc).replace("模型连接测试", "LLM 策略编译")) from exc
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc:
except llm_transport.OpenAIHTTPError as exc:
raise LLMCompilerError(exc.describe("LLM 策略编译失败")) from exc
except (llm_transport.OpenAITransportError, json.JSONDecodeError) as exc:
raise LLMCompilerError(f"LLM 策略编译失败:{exc}") from exc
compiled["compiler"] = "llm"
compiled["model"] = model
return compiled
def _http_error_message(exc: urllib.error.HTTPError) -> str:
detail = ""
try:
payload = json.loads(exc.read().decode("utf-8", errors="replace"))
error = payload.get("error")
if isinstance(error, dict):
detail = str(error.get("message") or error.get("code") or "")
elif error:
detail = str(error)
elif payload.get("message"):
detail = str(payload["message"])
except (json.JSONDecodeError, OSError):
detail = ""
suffix = f"{detail[:300]}" if detail else ""
return f"模型连接测试失败(HTTP {exc.code}{suffix}"
+1 -8
View File
@@ -9,6 +9,7 @@ from datetime import datetime, timedelta
from typing import Any
from advanced_strategies import ADVANCED_CURATED_STRATEGIES
from backend.data.numbers import finite_number as _number
from database import ReviewDatabase
from backend.features.sentiment.engine import build_sentiment_history, latest_contiguous_history
from tushare_client import TushareClient, TushareError
@@ -2201,13 +2202,5 @@ def _regime_reason(regime: str) -> str:
}.get(regime, "市场阶段待确认。")
def _number(value: Any, default: float = 0.0) -> float:
try:
number = float(value)
return number if math.isfinite(number) else default
except (TypeError, ValueError):
return default
def _display_date(value: str) -> str:
return f"{value[:4]}-{value[4:6]}-{value[6:8]}" if len(value) == 8 else value
+2 -8
View File
@@ -4,6 +4,8 @@ from copy import deepcopy
from statistics import mean, median
from typing import Any
from backend.data.numbers import non_nan_number as _number
COMPONENT_WEIGHTS = {
"breadth": 20,
@@ -16,14 +18,6 @@ COMPONENT_WEIGHTS = {
SENTIMENT_ENGINE_VERSION = 2
def _number(value: Any, default: float = 0.0) -> float:
try:
number = float(value)
return number if number == number else default
except (TypeError, ValueError):
return default
def _clamp(value: float, lower: float = 0.0, upper: float = 100.0) -> float:
return min(upper, max(lower, value))
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
import json
import time
import urllib.error
import urllib.request
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Any
from .stream import OpenAIStreamAccumulator
class OpenAITransportError(RuntimeError):
pass
class OpenAIHTTPError(OpenAITransportError):
def __init__(self, code: int, detail: str = "") -> None:
super().__init__(f"HTTP {code}")
self.code = code
self.detail = detail
def describe(self, label: str) -> str:
suffix = f"{self.detail[:300]}" if self.detail else ""
return f"{label}HTTP {self.code}{suffix}"
class OpenAIEmptyResponseError(OpenAITransportError):
pass
@dataclass(frozen=True)
class OpenAIChatCompletion:
content: Any
latency_ms: int
def chat_completion(
*,
api_key: str,
base_url: str,
model: str,
messages: list[dict[str, Any]],
timeout: int,
user_agent: str,
) -> OpenAIChatCompletion:
request = _request(api_key, base_url, model, messages, user_agent, stream=False)
started = time.perf_counter()
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
result = json.loads(response.read().decode("utf-8"))
content = result["choices"][0]["message"]["content"]
except urllib.error.HTTPError as exc:
raise OpenAIHTTPError(exc.code, _http_error_detail(exc)) from exc
except (
urllib.error.URLError,
TimeoutError,
json.JSONDecodeError,
KeyError,
IndexError,
) as exc:
raise OpenAITransportError(str(exc)) from exc
return OpenAIChatCompletion(
content=content,
latency_ms=round((time.perf_counter() - started) * 1000),
)
def stream_chat_completion(
*,
api_key: str,
base_url: str,
model: str,
messages: list[dict[str, Any]],
timeout: int,
user_agent: str,
) -> Iterator[str]:
request = _request(api_key, base_url, model, messages, user_agent, stream=True)
yielded = False
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
accumulator = OpenAIStreamAccumulator()
for raw_line in response:
line = raw_line.decode("utf-8", errors="replace").strip()
if not line or line.startswith(":"):
continue
if line.startswith("data:"):
line = line[5:].strip()
if line == "[DONE]":
break
try:
result = json.loads(line)
except json.JSONDecodeError:
continue
choices = result.get("choices") or []
if not choices:
continue
content = accumulator.feed(choices[0] or {})
if content:
yielded = True
yield str(content)
except urllib.error.HTTPError as exc:
raise OpenAIHTTPError(exc.code, _http_error_detail(exc)) from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise OpenAITransportError(str(exc)) from exc
if not yielded:
raise OpenAIEmptyResponseError("empty response")
def _request(
api_key: str,
base_url: str,
model: str,
messages: list[dict[str, Any]],
user_agent: str,
*,
stream: bool,
) -> urllib.request.Request:
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"User-Agent": user_agent,
}
if stream:
headers["Accept"] = "text/event-stream"
return urllib.request.Request(
f"{base_url.rstrip('/')}/chat/completions",
data=json.dumps(
{"model": model, "messages": messages, "stream": stream},
ensure_ascii=False,
).encode("utf-8"),
headers=headers,
method="POST",
)
def _http_error_detail(exc: urllib.error.HTTPError) -> str:
try:
payload = json.loads(exc.read().decode("utf-8", errors="replace"))
error = payload.get("error")
if isinstance(error, dict):
return str(error.get("message") or error.get("code") or "")
if error:
return str(error)
return str(payload.get("message") or "")
except (json.JSONDecodeError, OSError):
return ""
+11 -6
View File
@@ -1,12 +1,15 @@
# Governance Registries
These registries describe the approved product surface during architecture migration.
These registries describe the approved product surface of the modular preservation candidate.
- `pages.config.json`: primary page identity, navigation group, access expectation, scrolling,
and mobile composition policy.
- `features.config.json`: feature ownership, backend access class, data scope, and availability.
- `api.config.json`: transitional inventory of current routes, generated from `server.py` and
assigned to a feature owner.
- `api.config.json`: current routes generated from the preserved `server.py` API surface, with
one feature owner and backend access class per route. Its dispatcher implementation lives in
`backend/application.py`.
- `architecture-inventory.json`: generated inventory of candidate pages, routes, tables,
providers, model entry points, CSS layers, and remaining code hotspots.
- `data-fields.config.json`: canonical data products, provider eligibility, intended use, and
known blocked datasets.
- `data-quality.config.json`: freshness, coverage, units, adjustment, point-in-time, and
@@ -14,13 +17,15 @@ These registries describe the approved product surface during architecture migra
- `jobs.config.json`: background schedules, dependencies, lock keys, retry policy, timeouts,
and output versions.
During Stage 04 these files are contract inputs, not runtime replacements. Backend access in
`api_access.py` remains authoritative until the HTTP governance phase switches it atomically.
Frontend visibility remains a presentation concern and never grants backend access.
The registries are governance contracts, not substitutes for runtime authorization. Backend
access in `backend/http/routes.py` is authoritative; frontend visibility is only a presentation
concern and never grants access.
Regenerate the transitional API inventory after a route change:
```shell
python tools/build_api_registry.py
python tools/build_api_registry.py --check
python tools/build_architecture_inventory.py
python tools/build_architecture_inventory.py --check
```
+356
View File
@@ -0,0 +1,356 @@
{
"schema_version": 1,
"captured_from": "app accepted modular runtime",
"runtime": {
"http_server": "http.server.ThreadingHTTPServer",
"application_processes": 1,
"database": "SQLite WAL",
"frontend": "build-free HTML/CSS/JavaScript",
"container_port": 8765
},
"counts": {
"primary_pages": 16,
"api_exact_paths": 53,
"api_prefixes": 0,
"api_patterns": 11,
"database_tables": 36
},
"pages": [
{
"id": "sentimentCycleView",
"title": "情绪周期"
},
{
"id": "limitPool",
"title": "涨停池"
},
{
"id": "brokenView",
"title": "炸板池"
},
{
"id": "downView",
"title": "跌停板"
},
{
"id": "yesterdayView",
"title": "昨日涨停"
},
{
"id": "performanceView",
"title": "涨停表现"
},
{
"id": "ladderView",
"title": "市场天梯"
},
{
"id": "rotationView",
"title": "板块轮动"
},
{
"id": "auctionView",
"title": "集合竞价"
},
{
"id": "themeLibraryView",
"title": "题材库"
},
{
"id": "popularityView",
"title": "人气热榜"
},
{
"id": "dragonView",
"title": "龙虎榜"
},
{
"id": "screenerView",
"title": "智能选股"
},
{
"id": "mentorView",
"title": "问师"
},
{
"id": "heavenView",
"title": "问天"
},
{
"id": "reviewWorkspaceView",
"title": "我的复盘"
}
],
"api": {
"exact": [
"/api/account/birth-profile",
"/api/account/password",
"/api/account/status",
"/api/admin/membership",
"/api/admin/refresh",
"/api/admin/settings",
"/api/admin/settings/test",
"/api/alerts",
"/api/alerts/read-all",
"/api/assistant/chat",
"/api/assistant/messages",
"/api/auction",
"/api/auth/login",
"/api/auth/logout",
"/api/auth/me",
"/api/auth/register",
"/api/backfill",
"/api/chart/intraday",
"/api/dashboard",
"/api/dragon-tiger",
"/api/dragon-tiger/profiles",
"/api/health",
"/api/heaven/hexagram",
"/api/heaven/interpret",
"/api/heaven/personal",
"/api/heaven/readings",
"/api/heaven/sector-phases",
"/api/heaven/setup",
"/api/mentors/chat",
"/api/mentors/messages",
"/api/mentors/preferences",
"/api/mentors/setup",
"/api/notes",
"/api/popularity",
"/api/realtime-aggregate/health",
"/api/reasons",
"/api/rotation/history",
"/api/rotation/members",
"/api/screener/compile",
"/api/screener/run",
"/api/screener/setup",
"/api/screener/strategies",
"/api/screener/sync",
"/api/screener/tracking",
"/api/screener/tracking/refresh",
"/api/search",
"/api/search/detail",
"/api/seat-aliases",
"/api/sentiment/history",
"/api/themes",
"/api/themes/detail",
"/api/trades",
"/api/watchlist"
],
"prefixes": [],
"patterns": [
"/api/alerts/(\\d+)",
"/api/alerts/(\\d+)/read",
"/api/heaven/readings/(\\d+)",
"/api/heaven/sector-phases/(.+)",
"/api/notes/(\\d+)",
"/api/screener/strategies/(\\d+)",
"/api/screener/tracking/(\\d+)",
"/api/stock/(\\d{6})",
"/api/stock/(\\d{6})/preview",
"/api/trades/(\\d+)",
"/api/watchlist/(\\d{6})"
]
},
"database_tables": [
"users",
"user_sessions",
"user_credentials",
"user_birth_profiles",
"system_settings",
"llm_usage",
"dashboard_snapshots",
"sync_runs",
"data_snapshots",
"watchlist",
"review_notes",
"reason_overrides",
"seat_aliases",
"sector_phase_overrides",
"stock_master",
"daily_bars",
"benchmark_bars",
"daily_indicators",
"fundamental_indicators",
"moneyflow_daily",
"auction_factors",
"earnings_events",
"popularity_factors",
"lhb_institution_daily",
"screener_strategies",
"screener_runs",
"mentor_messages",
"mentor_preferences",
"wencai_saved_queries",
"strategy_tracks",
"alerts",
"trade_entries",
"assistant_messages",
"heaven_readings",
"job_runs",
"schema_migrations"
],
"background_job_methods": [
"_background_refresh_tick"
],
"external_data_adapters": [
{
"provider": "tushare",
"path": "backend/data/providers/tushare_client.py",
"runtime_role": "primary deterministic market data"
},
{
"provider": "ifind",
"path": "backend/data/providers/ifind_client.py",
"runtime_role": "realtime, charts, snapshots, enrichment"
},
{
"provider": "eastmoney",
"path": "backend/features/market/charts.py",
"runtime_role": "display chart fallback"
},
{
"provider": "eastmoney",
"path": "backend/data/realtime.py",
"runtime_role": "isolated realtime observation"
},
{
"provider": "tencent",
"path": "backend/data/realtime.py",
"runtime_role": "index observation fallback"
}
],
"numeric_normalization": [
{
"function": "finite_number",
"path": "backend/data/numbers.py"
},
{
"function": "non_nan_number",
"path": "backend/data/numbers.py"
}
],
"llm_entrypoints": [
{
"function": "stream_with_mentor",
"path": "backend/features/mentor/agent.py"
},
{
"function": "interpret_heaven",
"path": "backend/features/heaven/agent.py"
},
{
"function": "stream_review_assistant",
"path": "backend/features/review/agent.py"
},
{
"function": "compile_strategy_with_llm",
"path": "backend/features/screener/compiler.py"
},
{
"function": "test_llm_connection",
"path": "backend/features/screener/compiler.py"
}
],
"llm_transport": [
{
"function": "chat_completion",
"path": "backend/llm/transport.py"
},
{
"function": "stream_chat_completion",
"path": "backend/llm/transport.py"
}
],
"css_layers": [
"/shared/tokens.css?v=20260729-1",
"/styles/styles.css",
"/styles/renovation.css?v=20260725-5",
"/styles/redesign-v2.css?v=20260728-1",
"/styles/design-system.css?v=20260728-4",
"/styles/theme.css?v=20260728-2",
"/pages/heaven/page.css?v=20260728-7"
],
"code_hotspots": [
{
"path": "frontend/styles/styles.css",
"bytes": 361776,
"lines": 15465
},
{
"path": "frontend/styles/redesign-v2.css",
"bytes": 263539,
"lines": 8570
},
{
"path": "frontend/index.html",
"bytes": 135019,
"lines": 1892
},
{
"path": "backend/features/screener/engine.py",
"bytes": 108394,
"lines": 2206
},
{
"path": "backend/data/providers/tushare_client.py",
"bytes": 94171,
"lines": 2168
},
{
"path": "frontend/app.js",
"bytes": 89213,
"lines": 1939
},
{
"path": "frontend/pages/heaven/page.js",
"bytes": 86493,
"lines": 1830
},
{
"path": "frontend/styles/renovation.css",
"bytes": 83949,
"lines": 1553
},
{
"path": "frontend/pages/heaven/page.css",
"bytes": 73222,
"lines": 1084
},
{
"path": "backend/features/heaven/service.py",
"bytes": 63123,
"lines": 1303
},
{
"path": "backend/features/market/insights.py",
"bytes": 57998,
"lines": 1307
},
{
"path": "frontend/pages/market/runtime.js",
"bytes": 55720,
"lines": 1333
},
{
"path": "backend/features/heaven/engine.py",
"bytes": 51670,
"lines": 1181
},
{
"path": "backend/application.py",
"bytes": 48749,
"lines": 1129
},
{
"path": "frontend/styles/theme.css",
"bytes": 36427,
"lines": 1253
},
{
"path": "database.py",
"bytes": 33284,
"lines": 746
}
]
}
-406
View File
@@ -1,406 +0,0 @@
from __future__ import annotations
from collections import Counter
import math
from datetime import datetime, timedelta
from typing import Any
from backend.features.sentiment.engine import apply_sentiment_to_dashboard
DEMO_LIMITS = [
("600664", "哈药股份", 4.94, 10.02, "医药", "创新药+医药流通", "09:25:00", "09:25:00", 0, 5, 11.78, 14.65, 26458),
("603580", "艾艾精工", 40.84, 9.99, "机器人", "实控人变更+机器人", "09:25:01", "09:25:01", 0, 3, 0.11, 0.53, 27190),
("600785", "新华百货", 9.32, 10.04, "零售", "新零售+股权转让", "10:32:33", "10:32:33", 2, 2, 9.57, 29.44, 4285),
("002739", "万达电影", 10.32, 10.02, "文化传媒", "影视院线+AI视频", "09:30:33", "09:30:33", 0, 2, 3.95, 217.94, 25014),
("000504", "南华生物", 9.36, 9.99, "医药", "细胞医疗+中报预增", "09:39:18", "09:39:18", 1, 2, 8.73, 30.89, 1962),
("000676", "智度股份", 6.22, 10.09, "端侧AI", "AI营销+端侧AI", "09:46:45", "09:46:45", 0, 2, 6.61, 78.36, 10368),
("600162", "香江控股", 2.78, 9.88, "房地产", "房地产+地产链", "09:30:57", "09:30:57", 1, 2, 10.56, 90.86, 4540),
("002365", "永安药业", 13.18, 10.02, "医药", "医药+宠物经济", "09:33:24", "09:33:24", 0, 2, 10.52, 38.84, 8277),
("000566", "海南海药", 5.67, 10.10, "脑机接口", "创新药+脑机接口", "11:01:12", "11:03:48", 2, 2, 22.75, 73.56, 8769),
("002632", "道明光学", 9.63, 10.06, "端侧AI", "AI手机+反光材料", "09:25:00", "09:25:00", 0, 1, 2.63, 60.15, 13417),
("000892", "欢瑞世纪", 3.87, 9.94, "文化传媒", "短剧+AI应用", "09:34:57", "09:34:57", 0, 1, 10.80, 37.96, 5635),
("603496", "恒为科技", 25.08, 10.00, "云计算", "算力+华为", "09:58:12", "10:46:30", 1, 1, 7.65, 80.31, 16611),
("603327", "福蓉科技", 8.57, 10.01, "端侧AI", "AI手机+消费电子", "09:30:02", "09:30:02", 0, 1, 7.02, 77.84, 7784),
("300968", "格林精密", 10.24, 20.00, "端侧AI", "折叠屏+AI眼镜", "09:36:33", "09:36:33", 0, 1, 20.06, 48.23, 7850),
("002045", "国光电器", 8.34, 10.03, "消费电子", "音响电声+AI眼镜", "09:37:45", "09:37:45", 0, 1, 7.11, 66.04, 4517),
("600203", "福日电子", 11.92, 9.96, "消费电子", "华为产业链+机器人", "09:45:03", "09:45:03", 0, 1, 12.04, 105.50, 10554),
("002881", "美格智能", 39.05, 10.00, "端侧AI", "物理AI+算力模组", "10:07:42", "10:07:42", 0, 1, 14.32, 128.20, 4299),
]
DEMO_BROKEN = [
("002141", "贤丰控股", 5.91, 5.35, "PCB板", "PCB板+资产重组", "09:37:03", "14:56:24", 3, 18.95, 61.05),
("002432", "九安医疗", 72.00, 7.48, "医药", "业绩增长+AI应用", "10:53:00", "14:09:45", 5, 14.13, 335.00),
("002980", "华盛昌", 107.37, 5.12, "光通信", "光通信+仪器仪表", "09:59:18", "14:38:36", 1, 17.94, 108.75),
("603725", "天安新材", 14.08, 7.40, "机器人", "机器人+新材料", "09:36:34", "14:37:19", 5, 13.58, 42.92),
("603127", "昭衍新药", 53.25, 5.20, "医药", "创新药+CRO", "10:35:49", "10:46:55", 2, 19.56, 335.66),
("002261", "拓维信息", 29.95, 6.47, "云计算", "算力+华为", "10:48:15", "10:53:54", 3, 12.04, 343.26),
("603893", "瑞芯微", 222.24, 5.58, "国产芯片", "国产芯片+端侧AI", "09:55:26", "13:31:14", 1, 7.60, 939.80),
("603103", "横店影视", 14.94, 5.21, "文化传媒", "影视院线+暑期档", "13:01:06", "13:01:51", 1, 2.79, 94.75),
]
DEMO_DOWN = [
("603683", "晶华新材", 25.56, -10.00, "新材料", "高位股风险释放", 4.41, 173.67, 1),
("603928", "兴业股份", 12.34, -9.99, "化工", "连续上涨后补跌", 11.96, 42.04, 4),
("000988", "华工科技", 130.69, -10.00, "光通信", "高位成交放大", 6.28, 1313.42, 1),
("603137", "恒尚节能", 32.05, -10.00, "建筑", "昨日涨停断板", 1.48, 58.63, 1),
("603115", "海星股份", 81.06, -10.00, "有色金属", "板块退潮", 3.02, 196.08, 1),
("605376", "博迁新材", 166.02, -10.00, "新材料", "资金兑现", 5.35, 434.31, 1),
("003020", "立方制药", 19.72, -10.00, "医药", "医药分化", 22.88, 45.00, 1),
("605255", "天普股份", 78.47, -10.00, "汽车零部件", "连板失败", 2.12, 105.21, 1),
("002123", "梦网科技", 7.68, -9.96, "通信", "板块调整", 1.39, 61.86, 2),
("603713", "密尔克卫", 64.80, -10.00, "物流", "业绩预期调整", 3.99, 103.43, 1),
]
def _stock_rows() -> list[dict[str, Any]]:
return [
{
"code": code,
"ts_code": code,
"name": name,
"price": price,
"change": change,
"sector": sector,
"reason": reason,
"first_time": first_time,
"last_time": last_time,
"open_times": open_times,
"streak": streak,
"turnover_rate": turnover,
"amount_billion": amount,
"seal_amount_million": seal,
"float_mv_billion": round(amount * 3.2, 1),
"status": "涨停",
}
for code, name, price, change, sector, reason, first_time, last_time,
open_times, streak, turnover, amount, seal in DEMO_LIMITS
]
def _broken_rows() -> list[dict[str, Any]]:
return [
{
"code": code,
"ts_code": code,
"name": name,
"price": price,
"change": change,
"sector": sector,
"reason": reason,
"first_time": first_time,
"last_time": last_time,
"open_times": open_times,
"streak": 1,
"turnover_rate": turnover,
"amount_billion": amount,
"seal_amount_million": 0,
"float_mv_billion": round(amount * 3.5, 1),
"status": "炸板",
}
for code, name, price, change, sector, reason, first_time, last_time,
open_times, turnover, amount in DEMO_BROKEN
]
def _down_rows() -> list[dict[str, Any]]:
return [
{
"code": code,
"ts_code": code,
"name": name,
"price": price,
"change": change,
"sector": sector,
"reason": reason,
"first_time": "--",
"last_time": "--",
"open_times": 0,
"streak": streak,
"turnover_rate": turnover,
"amount_billion": amount,
"seal_amount_million": 0,
"float_mv_billion": round(amount * 4.1, 1),
"status": "跌停",
}
for code, name, price, change, sector, reason, turnover, amount, streak in DEMO_DOWN
]
def _ladders(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
result = []
for level in sorted({row["streak"] for row in rows}, reverse=True):
stocks = [row for row in rows if row["streak"] == level]
result.append(
{
"level": level,
"label": "首板" if level == 1 else f"{level}",
"count": len(stocks),
"stocks": stocks,
}
)
return result
def _sectors(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
counts = Counter(row["sector"] for row in rows)
result = []
for name, count in counts.most_common():
stocks = [row for row in rows if row["sector"] == name]
result.append(
{
"name": name,
"count": count,
"strength": min(99, 48 + count * 9 + max(row["streak"] for row in stocks) * 4),
"amount_billion": round(sum(row["amount_billion"] for row in stocks), 1),
"leader": max(stocks, key=lambda row: (row["streak"], row["amount_billion"]))["name"],
"change": round(sum(row["change"] for row in stocks) / count, 2),
"max_streak": max(row["streak"] for row in stocks),
}
)
return result
def _yesterday_rows(current: list[dict[str, Any]]) -> list[dict[str, Any]]:
current_map = {row["code"]: row for row in current}
definitions = [
("600664", "哈药股份", 4, 10.02, "晋级"),
("603580", "艾艾精工", 2, 9.99, "晋级"),
("600785", "新华百货", 1, 10.04, "晋级"),
("002739", "万达电影", 1, 10.02, "晋级"),
("000504", "南华生物", 1, 9.99, "晋级"),
("000676", "智度股份", 1, 10.09, "晋级"),
("603127", "昭衍新药", 1, 5.20, "炸板"),
("002432", "九安医疗", 2, 7.48, "炸板"),
("001388", "信通电子", 3, -5.33, "断板"),
("605255", "天普股份", 2, -10.00, "跌停"),
("600403", "大有能源", 1, -6.75, "断板"),
("002185", "华天科技", 1, -10.00, "跌停"),
("600829", "人民同泰", 1, 2.30, "断板"),
("600844", "金煤科技", 1, 1.18, "断板"),
]
rows = []
for code, name, prior_streak, current_change, outcome in definitions:
current_row = current_map.get(code, {})
rows.append(
{
"code": code,
"name": name,
"prior_streak": prior_streak,
"current_streak": current_row.get("streak", 0),
"current_change": current_change,
"current_price": current_row.get("price", 0),
"sector": current_row.get("sector", "其他"),
"reason": current_row.get("reason", "昨日涨停股表现跟踪"),
"outcome": outcome,
}
)
return rows
def _performance(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
result = []
for level in sorted({row["prior_streak"] for row in rows}, reverse=True):
group = [row for row in rows if row["prior_streak"] == level]
advanced = sum(row["outcome"] == "晋级" for row in group)
positive = sum(row["current_change"] > 0 for row in group)
result.append(
{
"level": level,
"label": "昨日首板" if level == 1 else f"昨日{level}",
"count": len(group),
"advanced": advanced,
"advance_rate": round(advanced / len(group) * 100, 1),
"positive_rate": round(positive / len(group) * 100, 1),
"average_change": round(sum(row["current_change"] for row in group) / len(group), 2),
}
)
return result
def _rotation(sectors: list[dict[str, Any]]) -> list[dict[str, Any]]:
previous_counts = {
"端侧AI": 7,
"医药": 5,
"文化传媒": 1,
"消费电子": 1,
"机器人": 3,
"房地产": 2,
"零售": 0,
"云计算": 2,
"脑机接口": 1,
}
result = []
for index, sector in enumerate(sectors, start=1):
previous = previous_counts.get(sector["name"], 0)
delta = sector["count"] - previous
result.append(
{
**sector,
"rank": index,
"previous_count": previous,
"delta": delta,
"trend": "升温" if delta > 0 else "降温" if delta < 0 else "持平",
}
)
return result
def build_demo_dashboard(trade_date: str, notice: str = "") -> dict[str, Any]:
limits = _stock_rows()
broken = _broken_rows()
down_limits = _down_rows()
ladders = _ladders(limits)
sectors = _sectors(limits)
yesterday = _yesterday_rows(limits)
dashboard = {
"meta": {
"trade_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}",
"previous_trade_date": "2026-07-16",
"source": "demo",
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": notice or "当前展示演示数据,配置 Tushare Token 后可读取真实行情。",
},
"overview": {
"up_count": 2344,
"down_count": 2695,
"flat_count": 33,
"limit_up_count": 41,
"limit_down_count": 3,
"broken_count": 25,
"amount_billion": 24035.6,
"seal_rate": 62.1,
},
"limits": limits,
"broken": broken,
"down_limits": down_limits,
"yesterday_limits": yesterday,
"limit_performance": _performance(yesterday),
"ladders": ladders,
"sectors": sectors,
"sector_rotation": _rotation(sectors),
}
return apply_sentiment_to_dashboard(dashboard)
def build_demo_dragon_tiger(trade_date: str, notice: str = "") -> dict[str, Any]:
stocks = _stock_rows()[:10]
seat_names = [
"机构专用",
"沪股通专用",
"深股通专用",
"中信证券股份有限公司上海分公司",
"国泰海通证券股份有限公司南京太平南路证券营业部",
]
rows = []
for index, stock in enumerate(stocks):
buy = round(86.5 - index * 6.3, 2)
sell = round(22.8 + index * 3.1, 2)
net = round(buy - sell, 2)
institutions = [
{
"seat_name": seat_names[index % len(seat_names)],
"buy_million": buy,
"sell_million": sell,
"net_buy_million": net,
},
{
"seat_name": seat_names[(index + 2) % len(seat_names)],
"buy_million": round(buy * 0.42, 2),
"sell_million": round(sell * 0.65, 2),
"net_buy_million": round(buy * 0.42 - sell * 0.65, 2),
},
]
rows.append(
{
"code": stock["code"],
"ts_code": stock["code"] + (".SH" if stock["code"].startswith("6") else ".SZ"),
"name": stock["name"],
"price": stock["price"],
"change": stock["change"],
"turnover_rate": stock["turnover_rate"],
"amount_billion": stock["amount_billion"],
"buy_million": buy,
"sell_million": sell,
"net_buy_million": net,
"net_rate": round(net / max(buy + sell, 1) * 100, 2),
"reason": "日涨幅偏离值达到7%" if index % 2 == 0 else "连续三个交易日涨幅偏离值累计达到20%",
"institutions": institutions,
}
)
return {
"meta": {
"trade_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}",
"source": "demo",
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": notice or "龙虎榜当前展示演示数据。",
},
"summary": {
"stock_count": len(rows),
"institution_count": sum(len(row["institutions"]) for row in rows),
"net_buy_million": round(sum(row["net_buy_million"] for row in rows), 2),
"positive_count": sum(row["net_buy_million"] > 0 for row in rows),
},
"rows": rows,
}
def build_demo_stock_detail(
code: str,
trade_date: str,
name: str = "示例股票",
industry: str = "其他",
notice: str = "",
) -> dict[str, Any]:
end = datetime.strptime(trade_date, "%Y%m%d")
seed = sum(ord(character) for character in code)
base = 8 + seed % 45
prices = []
close = float(base)
for index in range(90):
day = end - timedelta(days=(89 - index))
drift = math.sin((index + seed) / 6) * 0.018 + 0.002
open_price = close * (1 + math.sin(index * 1.7) * 0.006)
close = max(1, close * (1 + drift))
high = max(open_price, close) * (1.012 + (index % 3) * 0.002)
low = min(open_price, close) * (0.988 - (index % 2) * 0.002)
prices.append(
{
"trade_date": day.strftime("%Y-%m-%d"),
"open": round(open_price, 2),
"high": round(high, 2),
"low": round(low, 2),
"close": round(close, 2),
"change": round((close / open_price - 1) * 100, 2),
"volume": 180000 + (index % 11) * 26000 + seed * 10,
"amount_billion": round(1.8 + (index % 9) * 0.36, 2),
}
)
return {
"meta": {
"trade_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}",
"source": "demo",
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": notice or "个股详情当前展示演示数据。",
},
"stock": {
"code": code,
"ts_code": code + (".SH" if code.startswith("6") else ".SZ"),
"name": name,
"industry": industry,
"area": "--",
"market": "主板",
"list_date": "--",
"price": prices[-1]["close"],
"change": prices[-1]["change"],
},
"prices": prices,
"moneyflow": {
"net_million": 18.62,
"large_million": 31.48,
"medium_million": -4.12,
"small_million": -8.74,
},
}
+1939
View File
File diff suppressed because it is too large Load Diff
@@ -18,12 +18,12 @@
})();
</script>
<link rel="stylesheet" href="/shared/tokens.css?v=20260729-1">
<link rel="stylesheet" href="/styles.css">
<link rel="stylesheet" href="/renovation.css?v=20260725-5">
<link rel="stylesheet" href="/redesign-v2.css?v=20260728-1">
<link rel="stylesheet" href="/design-system.css?v=20260728-4">
<link rel="stylesheet" href="/theme.css?v=20260728-2">
<link rel="stylesheet" href="/wentian-v2.css?v=20260728-7">
<link rel="stylesheet" href="/styles/styles.css">
<link rel="stylesheet" href="/styles/renovation.css?v=20260725-5">
<link rel="stylesheet" href="/styles/redesign-v2.css?v=20260728-1">
<link rel="stylesheet" href="/styles/design-system.css?v=20260728-4">
<link rel="stylesheet" href="/styles/theme.css?v=20260728-2">
<link rel="stylesheet" href="/pages/heaven/page.css?v=20260728-7">
</head>
<body>
<section id="authGate" class="auth-gate" aria-label="账号登录">
@@ -1865,12 +1865,13 @@
<div id="toast" class="toast" role="status" hidden></div>
<script src="/vendor/lucide.min.js" defer></script>
<script src="/ui-core.js" defer></script>
<script src="/shared/ui-core.js" defer></script>
<script src="/shared/components.js?v=20260729-1" defer></script>
<script src="/pages.config.js?v=20260729-1" defer></script>
<script src="/pages/runtime.js?v=20260729-1" defer></script>
<script src="/pages/sentiment/page.js?v=20260729-1" defer></script>
<script src="/pages/pools/page.js?v=20260729-1" defer></script>
<script src="/pages/market/runtime.js?v=20260731-1" defer></script>
<script src="/pages/ladder/page.js?v=20260729-1" defer></script>
<script src="/pages/rotation/page.js?v=20260729-1" defer></script>
<script src="/pages/auction/page.js?v=20260729-1" defer></script>
@@ -1884,7 +1885,8 @@
<script src="/shared/state.js?v=20260729-1" defer></script>
<script src="/shared/api.js?v=20260729-1" defer></script>
<script src="/shared/shell.js?v=20260729-1" defer></script>
<script src="/heaven-loading-v2.js?v=20260728-2" defer></script>
<script src="/shared/export.js?v=20260731-1" defer></script>
<script src="/pages/heaven/loading-v2.js?v=20260728-2" defer></script>
<script src="/app.js?v=20260729-6" defer></script>
</body>
</html>
+272
View File
@@ -0,0 +1,272 @@
window.XiaobaiPageModules.register("auction", ["auctionView"], {
enter: ["loadAuction"],
leave: ["clearAuction"],
});
/* PRESERVATION-SOURCE-BEGIN app.js:2217-2481 */
async function loadAuctionCenter(force = false) {
if (state.auctionLoading) return;
state.auctionLoading = true;
const button = document.querySelector("#auctionRefreshButton");
button.disabled = true;
setText("auctionDateLabel", "正在读取竞价数据");
try {
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
if (force) query.set("force", "1");
state.auctionData = await apiRequest(`/api/auction?${query}`);
renderAuctionCenter();
scheduleAuctionTransition(state.auctionData.meta || {});
} catch (error) {
document.querySelector("#auctionSummary").innerHTML = "";
document.querySelector("#auctionThemeCarry").innerHTML = "";
document.querySelector("#auctionNewThemes").innerHTML = "";
document.querySelector("#auctionAmountTrend").innerHTML = "";
document.querySelector("#auctionAmountCompare").innerHTML = "";
document.querySelector("#auctionTableBody").innerHTML = "";
document.querySelector("#auctionEmpty").hidden = false;
setText("auctionDateLabel", error.message || "竞价数据暂不可用");
showToast(error.message || "竞价数据加载失败");
} finally {
state.auctionLoading = false;
button.disabled = false;
}
}
function renderAuctionCenter() {
const payload = state.auctionData;
if (!payload) return;
const summary = payload.summary || {};
renderAuctionPhase(payload.meta || {});
setText(
"auctionDateLabel",
`${payload.meta?.carried_forward ? "最近有效竞价" : "竞价日期"} ${payload.meta?.trade_date || "--"}`,
);
document.querySelector("#auctionSummary").innerHTML = [
["竞价覆盖", `${formatNumber(summary.stock_count, 0)}`, ""],
["重点异动", `${formatNumber(summary.focus_count, 0)}`, "up"],
["竞价一字", `${formatNumber(summary.one_price_count, 0)}`, ""],
["竞价成交额", `${formatNumber(summary.amount_billion, 2)} 亿`, ""],
].map(([label, value, tone]) => `<div><span>${label}</span><strong class="${tone}">${value}</strong></div>`).join("");
setText("auctionFocusCount", number(summary.focus_count));
setText("auctionAllCount", number(summary.candidate_count));
setText("auctionOnePriceCount", number(summary.one_price_count));
setText("auctionWatchlistCount", number(payload.watchlist_rows?.length));
renderAuctionInsights(payload);
renderAuctionTable();
}
function renderAuctionInsights(payload) {
const themes = payload.themes || {};
const carry = themes.carry || [];
const tone = { "强承接": "strong", "有承接": "steady", "分歧": "mixed", "承接弱": "weak" };
setText("auctionThemeBaseline", `基于 ${payload.candidate_meta?.baseline_date || "--"}`);
document.querySelector("#auctionThemeCarry").innerHTML = carry.length
? carry.map((item) => `
<div class="auction-theme-row">
<strong class="auction-theme-name">${escapeHtml(item.name)}</strong>
<span class="auction-theme-info">${escapeHtml(item.leader || "--")} · 昨日 ${number(item.prior_limit_count)} 只涨停</span>
<span class="auction-theme-status ${tone[item.status] || "mixed"}">${escapeHtml(item.status)}</span>
<span class="auction-theme-median ${item.median_change == null ? "" : changeClass(item.median_change)}">${item.median_change == null ? "暂无有效候选" : `${signed(item.median_change)}%`}<small>中位</small></span>
</div>`).join("")
: '<div class="auction-inline-empty">暂无昨日强势题材基线</div>';
const newThemes = themes.new_themes || [];
document.querySelector("#auctionNewThemes").innerHTML = newThemes.length
? newThemes.map((item) => `<span title="${escapeHtml((item.leaders || []).join("、"))}">${escapeHtml(item.name)} <strong>${number(item.stock_count)}</strong></span>`).join("")
: '<small>尚未形成多股共振的新线索</small>';
const history = payload.amount_history || [];
const maximum = Math.max(...history.map((item) => number(item.amount_billion)), 1);
const priorFive = history.slice(Math.max(0, history.length - 6), Math.max(0, history.length - 1));
const fiveDayAverage = priorFive.length
? priorFive.reduce((sum, item) => sum + number(item.amount_billion), 0) / priorFive.length
: null;
document.querySelector("#auctionAmountTrend").innerHTML = history.length
? history.map((item, index) => {
const height = Math.max(8, number(item.amount_billion) / maximum * 100);
const current = index === history.length - 1 ? " current" : "";
return `<div class="auction-amount-day${current}" title="${escapeHtml(item.trade_date)} · ${formatNumber(item.amount_billion, 2)} 亿 · ${number(item.stock_count)} 只">
<span style="height:${height.toFixed(1)}%"></span><small>${escapeHtml(String(item.trade_date || "").slice(5))}</small>
</div>`;
}).join("") + (fiveDayAverage === null ? "" : `<div class="auction-amount-average" style="bottom:${(20 + Math.min(fiveDayAverage / maximum, 1) * 82).toFixed(1)}px"><small>5日均 ${formatNumber(fiveDayAverage, 1)}</small></div>`)
: '<div class="auction-inline-empty">历史竞价量能尚未形成</div>';
setText("auctionAmountValue", `${formatNumber(payload.summary?.amount_billion, 2)} 亿`);
const comparison = [
["较昨日", payload.summary?.amount_change_previous],
["较5日均值", payload.summary?.amount_change_5d],
];
document.querySelector("#auctionAmountCompare").innerHTML = comparison.map(([label, value]) => `
<span>${label}<strong class="${value == null ? "" : changeClass(value)}">${value == null ? "--" : `${signed(value)}%`}</strong></span>
`).join("");
}
function renderAuctionPhase(meta) {
const phase = meta.phase || "archive";
const available = Boolean(meta.available);
const copy = {
pending: ["竞价尚未开始", "9:15 进入观察期,9:25 读取最终竞价结果。", "下一阶段 09:15"],
observing: ["竞价观察期", "此阶段先观察盘前变化,系统将在 9:25 自动读取最终结果。", "09:25 定格"],
selection: available
? ["竞价筛选窗口", "最终竞价结果已经定格,请在 9:30 前完成筛选。", "有效至 09:30"]
: ["等待最终竞价", "9:25 数据尚未到达,系统正在自动重试。", "即将更新"],
finalized: ["今日竞价已定格", "9:30 后停止更新,仅保留用于复盘、回测与智能选股。", "已冻结"],
archive: ["历史竞价归档", "当前展示所选交易日的最终竞价结果。", "归档数据"],
}[phase] || ["竞价状态", "当前竞价状态待确认。", "--"];
const notice = document.querySelector("#auctionPhaseNotice");
notice.dataset.phase = phase;
setText("auctionPhaseTitle", copy[0]);
setText("auctionPhaseDetail", copy[1]);
setText("auctionPhaseTime", copy[2]);
const refresh = document.querySelector("#auctionRefreshButton");
refresh.hidden = phase !== "selection";
refresh.disabled = state.auctionLoading;
}
function clearAuctionTimer() {
if (state.auctionTimer) clearTimeout(state.auctionTimer);
state.auctionTimer = null;
}
function scheduleAuctionTransition(meta) {
clearAuctionTimer();
if (state.activeView !== "auctionView") return;
let delay = 0;
if (["selection", "finalized"].includes(meta.phase) && !meta.available) {
delay = 10_000;
} else if (meta.next_transition_at) {
const transitionAt = new Date(meta.next_transition_at).getTime();
if (Number.isFinite(transitionAt)) delay = Math.max(800, transitionAt - Date.now() + 500);
}
if (!delay) return;
state.auctionTimer = setTimeout(() => {
state.auctionTimer = null;
if (state.activeView === "auctionView") loadAuctionCenter(true);
}, Math.min(delay, 2_147_000_000));
}
function renderAuctionTable() {
const rows = currentAuctionRows();
const columns = auctionColumns();
const head = document.querySelector("#auctionTableHead");
head.innerHTML = columns.map((column) => {
const sorted = column.sortKey === state.auctionSortKey;
const arrow = !column.sortKey ? "" : `<span class="arr">${sorted ? (state.auctionSortDirection === "desc" ? "▼" : "▲") : "↕"}</span>`;
return `<th class="${column.numeric ? "number num " : ""}${column.sortKey ? "sortable " : ""}${sorted ? "sorted" : ""}"${column.sortKey ? ` data-auction-sort="${column.sortKey}"` : ""}>${column.label}${arrow}</th>`;
}).join("");
const body = document.querySelector("#auctionTableBody");
body.innerHTML = rows.map((row) => `<tr data-code="${escapeHtml(row.code)}">${columns.map((column) => renderAuctionCell(row, column.key)).join("")}</tr>`).join("");
bindStockRows(body);
const datasetCopy = {
focus: ["重点异动", "优先查看市场核心与显著预期差"],
onePrice: ["竞价一字", "竞价封于当日真实涨停价,不参与普通异动评分"],
watchlist: ["我的自选", "仅展示当前账号关注标的的竞价反馈"],
all: ["全部候选", "昨日涨停、炸板与热榜前20候选"],
}[state.auctionDataset] || ["竞价异动", ""];
setText("auctionWorkspaceTitle", datasetCopy[0]);
setText("auctionWorkspaceSubtitle", datasetCopy[1]);
document.querySelector("#auctionExpectationControls").hidden = state.auctionDataset === "onePrice";
const empty = document.querySelector("#auctionEmpty");
const phase = state.auctionData?.meta?.phase || "archive";
empty.textContent = phase === "selection" && !state.auctionData?.meta?.available
? "正在等待 9:25 最终竞价数据"
: state.auctionDataset === "watchlist"
? "当前账号还没有可观察的自选股"
: state.auctionDataset === "onePrice"
? "当前没有竞价封于涨停价的股票"
: "没有符合条件的竞价候选";
empty.hidden = rows.length > 0;
}
function currentAuctionRows() {
const datasets = {
focus: state.auctionData?.focus_rows || [],
onePrice: state.auctionData?.one_price_rows || [],
watchlist: state.auctionData?.watchlist_rows || [],
all: state.auctionData?.rows || [],
};
let rows = [...(datasets[state.auctionDataset] || [])];
const filter = state.auctionFilter;
const labels = { above: "超预期", matched: "符合预期", below: "低于预期" };
if (labels[filter]) rows = rows.filter((item) => item.expectation === labels[filter]);
if (state.auctionQuery) {
rows = rows.filter((item) => `${item.code} ${item.name} ${item.sector}`.toLocaleLowerCase("zh-CN").includes(state.auctionQuery));
}
const key = state.auctionSortKey;
const direction = state.auctionSortDirection === "asc" ? 1 : -1;
if (key) {
rows.sort((left, right) => {
const leftValue = left[key];
const rightValue = right[key];
if (leftValue == null && rightValue == null) return 0;
if (leftValue == null) return 1;
if (rightValue == null) return -1;
const result = typeof leftValue === "number" || typeof rightValue === "number"
? number(leftValue) - number(rightValue)
: String(leftValue).localeCompare(String(rightValue), "zh-CN", { numeric: true });
return result * direction;
});
}
return rows.slice(0, 300);
}
function auctionColumns() {
const base = [
{ key: "stock", label: "股票" },
{ key: "context", label: "方向与来源" },
{ key: "identity", label: "市场身份" },
];
const metrics = [
{ key: "score", label: "关注分", numeric: true, sortKey: "attention_score" },
{ key: "expectation", label: "预期判断" },
{ key: "change", label: "竞价涨幅(%", numeric: true, sortKey: "change" },
{ key: "amount", label: "竞价额(百万)", numeric: true, sortKey: "amount_million" },
{ key: "volume", label: "量比", numeric: true, sortKey: "volume_ratio" },
];
return state.auctionDataset === "onePrice" ? [...base, ...metrics.slice(2)] : [...base, ...metrics];
}
function renderAuctionCell(row, key) {
const unavailable = row.available === false;
const onePrice = Boolean(row.is_one_price);
const expectationTone = { "超预期": "above", "符合预期": "matched", "低于预期": "below" };
if (key === "stock") return `<td><span class="auction-stock-cell-v2"><strong class="sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>`;
if (key === "context") return `<td><span class="auction-context-cell-v2"><strong>${escapeHtml(row.sector || "其他")}</strong>${renderAuctionSources(row.source_label || (state.auctionDataset === "watchlist" ? "我的自选" : "全市场"))}</span></td>`;
if (key === "identity") return `<td>${renderAuctionCoreTags(row.core_tags)}</td>`;
if (unavailable) return key === "expectation"
? '<td><span class="table-muted">暂无竞价</span></td>'
: `<td class="${["score", "change", "amount", "volume"].includes(key) ? "number num" : ""}"></td>`;
if (key === "score") return `<td class="number num auction-score">${onePrice ? "" : formatNumber(row.attention_score, 1)}</td>`;
if (key === "expectation") {
const tag = onePrice
? '<span class="auction-one-price-tag">竞价一字</span>'
: `<span class="auction-expectation ${expectationTone[row.expectation] || "matched"}">${escapeHtml(row.expectation || "符合预期")}</span>`;
return `<td>${tag}</td>`;
}
if (key === "change") return `<td class="number num ${changeClass(row.change)}">${signed(row.change)}</td>`;
if (key === "amount") return `<td class="number num">${formatNumber(row.amount_million, 2)}</td>`;
if (key === "volume") return `<td class="number num auction-volume-ratio">${formatNumber(row.volume_ratio, 2)}</td>`;
return "<td></td>";
}
function renderAuctionSources(value) {
const sources = String(value || "").split(/[·、/]/).map((item) => item.trim()).filter(Boolean).slice(0, 3);
return `<small class="auction-source-tags-v2">${sources.map((source) => `<b>${escapeHtml(source)}</b>`).join("")}</small>`;
}
function renderAuctionCoreTags(tags) {
const values = Array.isArray(tags) ? tags : [];
return values.length
? `<span class="auction-core-tags">${values.slice(0, 2).map((tag) => `<b>${escapeHtml(tag)}</b>`).join("")}</span>`
: '<span class="auction-identity-empty" aria-label="无市场身份"></span>';
}
function exportAuctionRows() {
const rows = currentAuctionRows();
exportRows("集合竞价", rows, [
["股票代码", "code"], ["股票名称", "name"], ["行业", "sector"], ["来源", "source_label"],
["市场身份", "core_tags"], ["关注分", "attention_score"], ["预期判断", "expectation"],
["竞价涨幅%", "change"], ["竞价额百万", "amount_million"], ["量比", "volume_ratio"],
]);
}
/* PRESERVATION-SOURCE-END app.js:2217-2481 */
+375
View File
@@ -0,0 +1,375 @@
window.XiaobaiPageModules.register("dragon_tiger", ["dragonView"], {
enter: ["loadDragonTiger"],
});
/* PRESERVATION-SOURCE-BEGIN app.js:2660-3028 */
function selectDragonViewMode(mode) {
state.dragonViewMode = mode === "profiles" ? "profiles" : "daily";
document.querySelectorAll("[data-dragon-view-mode]").forEach((button) => {
const active = button.dataset.dragonViewMode === state.dragonViewMode;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
if (state.dragonViewMode === "profiles") {
document.querySelector("#dragonDailyContent").hidden = true;
document.querySelector("#dragonEmptyState").hidden = true;
document.querySelector("#dragonProfilesContent").hidden = false;
if (state.hotMoneyProfiles) renderHotMoneyProfiles();
else loadHotMoneyProfiles();
} else {
document.querySelector("#dragonProfilesContent").hidden = true;
if (state.dragonTiger) renderDragonTiger();
else loadDragonTiger();
}
}
async function loadHotMoneyProfiles(force = false) {
if (!force && state.hotMoneyProfiles) {
renderHotMoneyProfiles();
return;
}
setStatus("正在加载游资档案");
try {
const query = new URLSearchParams();
if (force) query.set("force", "1");
const suffix = query.size ? `?${query}` : "";
state.hotMoneyProfiles = await apiRequest(`/api/dragon-tiger/profiles${suffix}`);
renderHotMoneyProfiles();
const count = number(state.hotMoneyProfiles.summary?.profile_count);
setStatus(`游资档案已加载 · 共 ${count}`);
} catch (error) {
showToast(error.message || "游资档案加载失败");
setStatus("游资档案加载失败");
}
}
function renderHotMoneyProfiles() {
const payload = state.hotMoneyProfiles;
if (!payload) return;
const profiles = payload.profiles || [];
const summary = payload.summary || {};
const query = state.hotMoneyProfileQuery;
const visible = profiles.filter((profile) => {
if (!query) return true;
return [profile.name, profile.description, ...(profile.organizations || [])]
.join(" ")
.toLocaleLowerCase("zh-CN")
.includes(query);
});
if (!visible.some((profile) => profile.id === state.selectedHotMoneyProfileId)) {
state.selectedHotMoneyProfileId = visible[0]?.id || "";
}
const selected = visible.find((profile) => profile.id === state.selectedHotMoneyProfileId) || null;
setText("dragonDateLabel", `收录 ${number(summary.profile_count)}`);
setText("hotMoneyProfileResultCount", query ? `${visible.length} / ${profiles.length}` : `${profiles.length}`);
document.querySelector("#hotMoneyProfileSummary").innerHTML = [
["收录游资", number(summary.profile_count)],
["已有简介", number(summary.described_count)],
["关联席位", number(summary.organization_count)],
].map(([label, value]) => `<span><small>${label}</small><strong>${value}</strong></span>`).join("");
const list = document.querySelector("#hotMoneyProfileList");
list.innerHTML = visible.length ? visible.map((profile, index) => `
<button class="hot-money-profile-row-v2 ${profile.id === state.selectedHotMoneyProfileId ? "selected" : ""}"
type="button" role="option" aria-selected="${profile.id === state.selectedHotMoneyProfileId}"
data-hot-money-profile="${escapeHtml(profile.id)}">
<span class="hot-money-profile-index-v2">${String(index + 1).padStart(2, "0")}</span>
<span class="hot-money-profile-monogram-v2">${escapeHtml(profile.name.slice(0, 2))}</span>
<span class="hot-money-profile-row-copy-v2">
<strong>${escapeHtml(profile.name)}</strong>
<small>${escapeHtml(profile.description || "暂未收录简介")}</small>
</span>
<span class="hot-money-profile-seat-count-v2">${number(profile.organization_count)} 席</span>
</button>`).join("") : `
<div class="hot-money-profile-list-empty-v2">
<i data-lucide="search-x" aria-hidden="true"></i>
<span>${profiles.length ? "没有符合条件的游资档案" : "游资名录暂不可用"}</span>
</div>`;
const detail = document.querySelector("#hotMoneyProfileDetail");
if (!selected) {
detail.innerHTML = `
<div class="hot-money-profile-empty-v2">
<i data-lucide="contact" aria-hidden="true"></i>
<strong>${profiles.length ? "选择一位游资查看档案" : "暂无可展示的游资档案"}</strong>
</div>`;
} else {
const organizations = selected.organizations || [];
detail.innerHTML = `
<header class="hot-money-profile-detail-head-v2">
<span class="hot-money-profile-avatar-v2">${escapeHtml(selected.name.slice(0, 2))}</span>
<div>
<small>游资档案</small>
<h3>${escapeHtml(selected.name)}</h3>
<span>${organizations.length ? `关联 ${organizations.length} 个公开席位` : "暂无关联席位"}</span>
</div>
</header>
<section class="hot-money-profile-section-v2">
<h4>人物简介</h4>
<p class="${selected.description ? "" : "is-empty"}">${escapeHtml(selected.description || "名录暂未收录该游资的公开简介。")}</p>
</section>
<section class="hot-money-profile-section-v2 hot-money-profile-org-section-v2">
<div class="hot-money-profile-section-title-v2">
<h4>关联营业部</h4>
<span>${organizations.length} 个</span>
</div>
<div class="hot-money-profile-organizations-v2">
${organizations.length ? organizations.map((organization) => `
<span><i data-lucide="building-2" aria-hidden="true"></i>${escapeHtml(organization)}</span>
`).join("") : '<p class="is-empty">名录暂未收录关联营业部。</p>'}
</div>
</section>
${payload.meta?.notice ? `<p class="hot-money-profile-notice-v2">${escapeHtml(payload.meta.notice)}</p>` : ""}`;
}
refreshIcons();
}
async function loadDragonTiger(force = false) {
const requestedDate = elements.tradeDate.value;
if (
!force
&& ["success", "empty", "partial", "unavailable"].includes(state.dragonTiger?.meta?.status)
&& (state.dragonTiger?.meta?.requested_date || state.dragonTiger?.meta?.trade_date) === requestedDate
) {
renderDragonTiger();
return;
}
setStatus("正在加载龙虎榜");
try {
const query = new URLSearchParams({ trade_date: requestedDate });
if (force) query.set("force", "1");
const payload = await apiRequest(`/api/dragon-tiger?${query}`);
state.dragonTiger = payload;
renderDragonTiger();
const statusLabel = payload.meta.status === "error"
? "龙虎榜数据暂不可用"
: payload.meta.status === "empty"
? "当日暂无公开游资明细"
: payload.meta.status === "partial"
? "当日有龙虎榜,暂无命名游资明细"
: payload.meta.status === "unavailable" ? "龙虎榜数据暂不可用" : "龙虎榜明细";
setStatus(`${statusLabel} · 龙虎榜已加载`);
} catch (error) {
showToast(error.message || "龙虎榜加载失败");
setStatus("龙虎榜加载失败");
}
}
function renderDragonTiger() {
const payload = state.dragonTiger;
if (!payload) return;
const summary = payload.summary || {};
if (state.dragonViewMode === "daily") setText("dragonDateLabel", `数据日期 ${payload.meta.trade_date}`);
const status = payload.meta?.status || "empty";
const hasRecognizedTraders = (payload.traders || []).some((item) => item.identity_type === "trader" && item.recognized !== false);
const showEmptyState = !hasRecognizedTraders
&& !(payload.unclassified_seats || []).length
&& ["empty", "error", "unavailable"].includes(status);
const dailyVisible = state.dragonViewMode === "daily";
document.querySelector("#dragonProfilesContent").hidden = dailyVisible;
document.querySelector("#dragonEmptyState").hidden = !dailyVisible || !showEmptyState;
document.querySelector("#dragonDailyContent").hidden = !dailyVisible || showEmptyState;
if (showEmptyState) {
const unavailable = ["error", "unavailable"].includes(status);
setText("dragonEmptyTitle", unavailable ? "龙虎榜数据暂不可用" : `${payload.meta?.trade_date || "该交易日"} 暂无龙虎榜明细`);
setText("dragonEmptyDescription", unavailable
? "当前数据暂未完成更新,可稍后重新检查或查看前一交易日。"
: "龙虎榜明细通常在交易日盘后陆续披露,可稍后刷新或查看前一交易日。");
}
document.querySelector("#dragonSummary").innerHTML = [
["上榜游资", `${number(summary.trader_count)}`, ""],
["操作明细", `${number(summary.operation_count)}`, ""],
["席位净买入", formatMoneyMillion(summary.seat_net_buy_million), changeClass(summary.seat_net_buy_million)],
["活跃股票", `${number(summary.active_stock_count)}`, ""],
].map(([label, value, className]) => `<div class="dragon-metric"><span>${label}</span><strong class="${className}">${value}</strong></div>`).join("");
renderDragonTraderList();
renderUnclassifiedSeats();
}
function renderDragonTraderList() {
const payload = state.dragonTiger;
if (!payload) return;
let traders = [...(payload.traders || [])].filter((item) => item.identity_type === "trader" && item.recognized !== false);
if (state.dragonFilter === "buy") traders = traders.filter((item) => number(item.net_buy_million) > 0);
if (state.dragonFilter === "sell") traders = traders.filter((item) => number(item.net_buy_million) < 0);
if (state.dragonFilter === "unclassified") traders = [];
if (state.dragonQuery) {
traders = traders.filter((item) => {
const searchable = [
item.name,
...(item.operations || []).flatMap((operation) => [operation.code, operation.name, operation.seat_name]),
].join(" ").toLowerCase();
return searchable.includes(state.dragonQuery);
});
}
const container = document.querySelector("#dragonTraderList");
let emptyMessage = "没有符合当前条件的游资操作";
if (!Array.isArray(payload.traders)) emptyMessage = "龙虎榜数据格式暂不可用,请稍后重试";
else if (["error", "unavailable"].includes(payload.meta?.status)) emptyMessage = "龙虎榜数据暂不可用,请稍后重试";
else if (payload.meta?.status === "empty") emptyMessage = "该交易日暂无游资每日明细";
else if (payload.meta?.status === "partial") emptyMessage = `当日有 ${number(payload.summary?.official_stock_count)} 只股票上榜,但暂无可识别的游资明细`;
if (!traders.some((item) => item.id === state.selectedDragonTraderId)) {
state.selectedDragonTraderId = traders[0]?.id || "";
}
const cardMarkup = traders.map((trader, index) => {
const description = trader.description || `${number(trader.stock_count)} 只股票,${number(trader.operation_count)} 笔操作`;
return `
<article class="dragon-trader-card dealing ${trader.id === state.selectedDragonTraderId ? "selected" : ""}" data-dragon-card="${escapeHtml(trader.id)}" aria-hidden="true" style="--deal-delay:${Math.min(index * 38, 650)}ms">
<span class="dragon-card-rank">${String(index + 1).padStart(2, "0")}</span>
<span class="dragon-card-monogram">${escapeHtml(trader.name.slice(0, 2))}</span>
<span class="dragon-card-copy"><strong>${escapeHtml(trader.name)}</strong><q title="${escapeHtml(description)}">${escapeHtml(description)}</q></span>
<span class="dragon-card-stats"><small>${number(trader.stock_count)} 股 · ${number(trader.operation_count)} 笔</small><b class="${changeClass(trader.net_buy_million)}">${formatMoneyMillion(trader.net_buy_million)}</b></span>
</article>`;
}).join("");
const hitZoneMarkup = traders.map((trader) => `
<button type="button" class="dragon-card-hit-zone" data-dragon-trader="${escapeHtml(trader.id)}" aria-label="查看 ${escapeHtml(trader.name)} 当日操作" aria-pressed="${trader.id === state.selectedDragonTraderId}"></button>
`).join("");
container.innerHTML = traders.length
? `${cardMarkup}<div class="dragon-card-hit-layer">${hitZoneMarkup}</div>`
: emptyStateHtml(state.dragonFilter === "unclassified" ? "待归类席位请在下方管理" : emptyMessage, { className: "dragon-empty" });
container.querySelectorAll("[data-dragon-card]").forEach((card) => {
card.addEventListener("animationend", () => card.classList.remove("dealing"), { once: true });
});
container.querySelectorAll("[data-dragon-trader]").forEach((hitZone) => {
const setHovered = (hovered) => {
container.querySelector(`[data-dragon-card="${CSS.escape(hitZone.dataset.dragonTrader)}"]`)?.classList.toggle("hovered", hovered);
};
hitZone.addEventListener("pointerenter", () => setHovered(true));
hitZone.addEventListener("pointerleave", () => setHovered(false));
hitZone.addEventListener("focus", () => setHovered(true));
hitZone.addEventListener("blur", () => setHovered(false));
hitZone.addEventListener("click", () => {
state.selectedDragonTraderId = hitZone.dataset.dragonTrader;
container.querySelectorAll("[data-dragon-card]").forEach((card) => {
card.classList.toggle("selected", card.dataset.dragonCard === state.selectedDragonTraderId);
});
container.querySelectorAll("[data-dragon-trader]").forEach((item) => {
item.setAttribute("aria-pressed", String(item.dataset.dragonTrader === state.selectedDragonTraderId));
});
renderDragonTraderDetail(traders.find((item) => item.id === state.selectedDragonTraderId));
});
});
requestAnimationFrame(() => layoutDragonCards(container));
renderDragonTraderDetail(traders.find((item) => item.id === state.selectedDragonTraderId));
}
function layoutDragonCards(container = document.querySelector("#dragonTraderList")) {
if (!container) return;
const cards = [...container.querySelectorAll(".dragon-trader-card")];
const hitZones = [...container.querySelectorAll(".dragon-card-hit-zone")];
if (!cards.length) return;
const compact = window.innerWidth <= 720;
const cardWidth = compact ? 148 : 176;
const available = Math.max(cardWidth, container.clientWidth - (compact ? 30 : 72));
const spread = Math.min(available - cardWidth, compact ? 310 : 1050);
const step = cards.length > 1 ? Math.min(cardWidth + 14, spread / (cards.length - 1)) : 0;
const center = (cards.length - 1) / 2;
container.style.setProperty("--dragon-card-width", `${cardWidth}px`);
cards.forEach((card, index) => {
const x = (index - center) * step;
card.style.setProperty("--card-x", `${x.toFixed(2)}px`);
card.style.setProperty("--card-rotation", "0deg");
card.style.setProperty("--card-y", "0px");
card.style.zIndex = String(index + 1);
const hitZone = hitZones[index];
if (hitZone) {
const zoneWidth = index === cards.length - 1 ? cardWidth : Math.max(18, step);
hitZone.style.left = `calc(50% + ${(x - cardWidth / 2).toFixed(2)}px)`;
hitZone.style.width = `${zoneWidth.toFixed(2)}px`;
}
});
}
function renderDragonTraderDetail(trader) {
const container = document.querySelector("#dragonTraderDetail");
if (!trader) {
container.hidden = true;
renderEmptyState(container, "选择一位游资查看操作明细", { className: "dragon-empty" });
return;
}
container.hidden = false;
container.innerHTML = `
<header class="dragon-detail-header">
<div><span>当日操作明细</span><h3>${escapeHtml(trader.name)}</h3><p>${escapeHtml(trader.description || "按当日公开龙虎榜席位汇总")}</p></div>
<dl><div><dt>买入</dt><dd class="up">${formatMoneyMillion(trader.buy_million)}</dd></div><div><dt>卖出</dt><dd class="down">${formatMoneyMillion(trader.sell_million)}</dd></div><div><dt>净额</dt><dd class="${changeClass(trader.net_buy_million)}">${formatMoneyMillion(trader.net_buy_million)}</dd></div></dl>
</header>
<div class="trader-operations table-frame tbl-wrap">
<table class="data-table tbl dragon-operation-table">
<colgroup><col class="dragon-col-index"><col class="dragon-col-stock"><col class="dragon-col-direction"><col class="dragon-col-number"><col class="dragon-col-number"><col class="dragon-col-number"><col class="dragon-col-number"><col class="dragon-col-seat"><col class="dragon-col-reason"></colgroup>
<thead><tr><th class="row-number num">序号</th><th>股票</th><th>方向</th><th class="number num">涨幅(%</th><th class="number num">买入(百万)</th><th class="number num">卖出(百万)</th><th class="number num">净额(百万)</th><th>关联席位</th><th class="reason-column">标签 / 上榜原因</th></tr></thead>
<tbody>${(trader.operations || []).map((operation, index) => `
<tr data-code="${escapeHtml(operation.code)}">
<td class="row-number num">${index + 1}</td>
<td><strong class="sname">${escapeHtml(operation.name)}</strong><span class="scode">${escapeHtml(operation.code)}</span></td>
<td><span class="direction-label ${changeClass(operation.net_buy_million)}">${escapeHtml(operation.direction)}</span></td>
<td class="number num ${operation.change == null ? "" : changeClass(operation.change)}">${operation.change == null ? "" : signed(operation.change)}</td>
<td class="number num">${operation.buy_million == null ? "" : formatNumber(operation.buy_million, 2)}</td>
<td class="number num">${operation.sell_million == null ? "" : formatNumber(operation.sell_million, 2)}</td>
<td class="number num ${operation.net_buy_million == null ? "" : changeClass(operation.net_buy_million)}">${operation.net_buy_million == null ? "" : signed(operation.net_buy_million)}</td>
<td class="seat-cell" title="${escapeHtml(operation.seat_name)}">${escapeHtml(operation.seat_name)}</td>
<td class="reason-column" title="${escapeHtml([operation.tag, operation.reason].filter((item) => item && item !== "--").join(" · "))}">${escapeHtml(operation.tag && operation.tag !== "--" ? operation.tag : operation.reason && operation.reason !== "--" ? operation.reason : "")}</td>
</tr>`).join("")}</tbody>
</table>
</div>`;
bindStockRows(container);
markAutoSortableHeaders(container);
}
function renderUnclassifiedSeats() {
const seats = state.dragonTiger?.unclassified_seats || [];
const canManage = state.user?.role === "admin";
document.querySelector("#dragonUnclassifiedSection").hidden = !canManage || seats.length === 0;
document.querySelector("#dragonUnclassifiedFilter").hidden = !canManage || seats.length === 0;
if (!seats.length && state.dragonFilter === "unclassified") {
state.dragonFilter = "all";
document.querySelectorAll("[data-dragon-filter]").forEach((button) => {
button.classList.toggle("active", button.dataset.dragonFilter === "all");
});
renderDragonTraderList();
}
setText("unclassifiedCount", `${seats.length}`);
const list = document.querySelector("#unclassifiedSeatList");
list.innerHTML = seats.map((seat, index) => `
<form class="unclassified-seat-row" data-unclassified-index="${index}">
<span class="unclassified-seat-name" title="${escapeHtml(seat.seat_name)}">${escapeHtml(seat.seat_name)}</span>
<span class="unclassified-seat-stats">${number(seat.operation_count)} 笔 · ${number(seat.stock_count)} 股</span>
<strong class="${changeClass(seat.net_buy_million)}">${formatMoneyMillion(seat.net_buy_million)}</strong>
<input type="text" maxlength="50" placeholder="输入游资名" aria-label="${escapeHtml(seat.seat_name)}的游资名" required>
<button class="button" type="submit">归类</button>
</form>
`).join("") || emptyStateHtml("当前席位均已归类");
list.querySelectorAll(".unclassified-seat-row").forEach((form) => {
form.addEventListener("submit", saveSeatAlias);
});
}
function dragonIdentityLabel(type) {
return { trader: "游资", institution: "机构", channel: "通道", unclassified: "待归类" }[type] || "席位";
}
async function saveSeatAlias(event) {
event.preventDefault();
const form = event.currentTarget;
const seat = state.dragonTiger?.unclassified_seats?.[number(form.dataset.unclassifiedIndex)];
const alias = form.querySelector("input").value.trim();
if (!seat || !alias) {
showToast("请输入游资名");
return;
}
const button = form.querySelector("button");
button.disabled = true;
try {
await apiRequest("/api/seat-aliases", "POST", { seat_name: seat.seat_name, alias });
state.dragonTiger = null;
await loadDragonTiger();
showToast(`已将席位归类为 ${alias}`);
} catch (error) {
showToast(error.message);
button.disabled = false;
}
}
/* PRESERVATION-SOURCE-END app.js:2660-3028 */
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
window.XiaobaiPageModules.register("ladder", ["ladderView"]);
/* PRESERVATION-SOURCE-BEGIN app.js:2125-2216 */
function renderLadderMini(ladders) {
const container = document.querySelector("#ladderMini");
const highest = ladders.length ? Math.max(...ladders.map((item) => number(item.level))) : 0;
setText("maxHeight", highest ? `最高 ${highest}` : "暂无");
container.innerHTML = ladders.slice(0, 5).map((group) => {
const allNames = group.stocks.map((stock) => stock.name).filter(Boolean);
const visibleNames = allNames.slice(0, 3).join("、");
const suffix = allNames.length > 3 ? ` <em>等 ${number(group.count)} 只</em>` : "";
return `<div class="pool-side-group">
<div><strong>${escapeHtml(group.label)}</strong><small>${number(group.count)} 只</small></div>
<p title="${escapeHtml(allNames.join("、"))}">${escapeHtml(visibleNames || "--")}${suffix}</p>
</div>`;
}).join("") || emptyStateHtml("暂无梯队数据");
}
function renderSectorMini(sectors) {
document.querySelector("#sectorMini").innerHTML = sectors.slice(0, 7).map((sector) => `
<div class="pool-hot-row"><strong title="${escapeHtml(sector.name)}">${escapeHtml(sector.name)}</strong><span>${number(sector.count)}</span></div>
`).join("") || emptyStateHtml("暂无板块数据");
}
function renderLadderBoard(ladders) {
const container = document.querySelector("#ladderBoard");
const insights = document.querySelector("#ladderInsights");
const ordered = [...ladders].sort((left, right) => number(right.level) - number(left.level));
const maxLevel = ordered.length ? Math.max(...ordered.map((group) => number(group.level))) : 0;
const topVisibleLevel = Math.max(5, maxLevel);
const groupMap = new Map(ordered.map((group) => [number(group.level), group]));
const displayGroups = Array.from({ length: topVisibleLevel }, (_, index) => {
const level = topVisibleLevel - index;
return groupMap.get(level) || { level, label: level === 1 ? "首板" : level === 5 && maxLevel < 5 ? "5板+" : `${level}`, count: 0, stocks: [] };
});
const total = ordered.reduce((sum, group) => sum + number(group.count), 0);
const spaceStocks = ordered.find((group) => number(group.level) === maxLevel)?.stocks || [];
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
setText("ladderDateRange", `数据日期 ${currentDate}`);
container.innerHTML = displayGroups.map((group) => {
const level = number(group.level);
const limit = level === 1 || level === 2 ? 8 : 99;
const expanded = state.expandedLadderLevels.has(level);
const groupStocks = [...(group.stocks || [])].sort((left, right) => {
if (state.ladderSortMode === "open") {
return number(left.open_times) - number(right.open_times)
|| String(left.first_time || "99:99:99").localeCompare(String(right.first_time || "99:99:99"));
}
return String(left.first_time || "99:99:99").localeCompare(String(right.first_time || "99:99:99"));
});
const stocks = expanded ? groupStocks : groupStocks.slice(0, limit);
const remaining = Math.max(0, groupStocks.length - stocks.length);
const label = group.label || (level === 1 ? "首板" : level === 5 && maxLevel < 5 ? "5板+" : `${level}`);
const color = { 1: "#2563eb", 2: "#16a34a", 3: "#d97706", 4: "#e04536" }[level] || "#9ca3af";
return `
<section class="market-ladder-tier ${number(group.count) ? "" : "is-gap"}" data-ladder-level-card="${level}">
<div class="market-ladder-label" style="--tier-color:${color}"><div class="market-ladder-level"><span class="market-ladder-dot"></span>${escapeHtml(label)}</div><div class="market-ladder-count">${number(group.count)} 只</div>${number(group.count) && level > 1 ? `<div class="market-ladder-rate">${escapeHtml(label)} · <b>${formatNumber(number(group.count) / Math.max(number(groupMap.get(level - 1)?.count), 1) * 100, 1)}%</b></div>` : ""}</div>
<div class="market-ladder-stocks">${stocks.length ? stocks.map((stock) => {
const onePrice = String(stock.first_time || "").startsWith("09:25") && number(stock.open_times) === 0;
const broken = number(stock.open_times) >= 6;
const amount = number(stock.seal_amount_million) ? `封单 ${formatNumber(stock.seal_amount_million, 0)}` : `成交 ${formatNumber(stock.amount_billion, 1)} 亿`;
return `<button type="button" class="market-ladder-stock" data-code="${escapeHtml(stock.code)}" aria-label="查看 ${escapeHtml(stock.name)} ${escapeHtml(stock.code)}详情">
<span class="market-ladder-stock-first"><strong>${escapeHtml(stock.name)}</strong><small class="stock-code">${escapeHtml(stock.code)}</small><span class="market-ladder-tags">${onePrice ? '<em class="market-ladder-tag one-price">一字</em>' : ""}${broken ? `<em class="market-ladder-tag broken">烂板×${number(stock.open_times)}</em>` : ""}</span></span>
<span class="market-ladder-stock-second"><b>${escapeHtml(stock.sector || stock.reason || "其他")}</b><small>${stock.first_time && stock.first_time !== "--" ? escapeHtml(stock.first_time) : "时间待校正"}</small><small>${amount}</small></span>
</button>`;
}).join("") : `<div class="market-ladder-gap-note">${level >= maxLevel ? `断层 · ${escapeHtml(label)}及以上空缺` : "该层暂时空缺"}</div>`}${groupStocks.length > limit ? `<button class="market-ladder-more" type="button" data-ladder-level="${level}">${expanded ? "收起" : `展开剩余 ${remaining} 只`}<i data-lucide="chevron-${expanded ? "up" : "down"}"></i></button>` : ""}</div>
</section>`;
}).join("");
const structureRows = displayGroups.filter((group) => number(group.count) || number(group.level) <= maxLevel + 1);
const maxCount = Math.max(1, ...structureRows.map((group) => number(group.count)));
const rateRows = (state.dashboard?.limit_performance || []).map((row) => ({
label: `${row.label || (number(row.level) === 1 ? "昨日首板" : `昨日${number(row.level)}`)} → 今日`,
value: clamp(number(row.advance_rate), 0, 100),
}));
const previousMax = Math.max(0, ...(state.dashboard?.yesterday_limits || []).map((row) => number(row.prior_streak)));
const spaceChange = previousMax && maxLevel < previousMax ? `较昨日 ${previousMax} 板 ↓ 空间压缩` : previousMax && maxLevel > previousMax ? `较昨日 ${previousMax} 板 ↑ 高度抬升` : "高度与昨日接近";
const spaceNote = maxLevel >= 5 ? "高位梯队仍有辨识度,重点观察承接而非单看高度。" : maxLevel >= 3 ? "空间位于中段,梯队延续性比绝对高度更重要。" : "高度受到压缩,先观察首板向二板的结构修复。";
const strongestGroup = structureRows.reduce((best, group) => number(group.count) > number(best?.count) ? group : best, structureRows[0]);
insights.innerHTML = `
<section class="market-ladder-insight-card market-ladder-apex-card"><header><h3>空间板</h3><span>市场高度</span></header><div class="market-ladder-apex"><div><strong>${maxLevel ? `${maxLevel}` : "--"}</strong><em>${escapeHtml(spaceChange)}</em></div><p>${spaceStocks.length ? spaceStocks.map((stock) => `<b>${escapeHtml(stock.name)}</b>${escapeHtml(stock.sector || "其他")}`).join(" · ") : "暂无空间板"}</p></div><p>${spaceNote}</p></section>
<section class="market-ladder-insight-card"><header><h3>梯队结构</h3><span>完整度</span></header><div class="market-ladder-pyramid">${structureRows.map((group) => `<div class="market-ladder-pyramid-row ${number(group.count) ? "" : "is-gap"}"><span>${escapeHtml(group.label || `${number(group.level)}`)}</span><i><b style="width:${Math.max(number(group.count) ? 8 : 100, number(group.count) / maxCount * 100)}%"></b></i><strong>${number(group.count) ? `${number(group.count)}` : "断层"}</strong></div>`).join("")}</div><p>断层越少,梯队从低位向高位传导越连贯。当前腰部为 <b>${escapeHtml(strongestGroup?.label || "--")}</b>。</p></section>
<section class="market-ladder-insight-card"><header><h3>晋级率参考</h3><span>昨日梯队 → 今日</span></header><div class="market-ladder-rate-list">${rateRows.length ? rateRows.map((row) => `<div><span>${escapeHtml(row.label)}</span><i><b class="${row.value === 0 ? "is-zero" : row.value < 20 ? "is-low" : ""}" style="width:${Math.max(row.value, row.value > 0 ? 2 : 0)}%"></b></i><strong class="${row.value === 0 ? "is-zero" : row.value < 20 ? "is-low" : ""}">${formatNumber(row.value, 1)}%</strong></div>`).join("") : '<div class="empty-state">暂无可比梯队</div>'}</div><small class="market-ladder-source">数据来自“涨停表现”页 · 昨日梯队样本</small></section>`;
container.querySelectorAll("[data-ladder-level]").forEach((button) => {
button.addEventListener("click", () => {
const level = number(button.dataset.ladderLevel);
if (state.expandedLadderLevels.has(level)) state.expandedLadderLevels.delete(level);
else state.expandedLadderLevels.add(level);
renderLadderBoard(state.dashboard?.ladders || []);
});
});
bindStockRows(container);
refreshIcons();
}
/* PRESERVATION-SOURCE-END app.js:2125-2216 */
File diff suppressed because it is too large Load Diff
+497
View File
@@ -0,0 +1,497 @@
window.XiaobaiPageModules.register("mentor", ["mentorView"], {
enter: ["loadMentor"],
});
/* PRESERVATION-SOURCE-BEGIN app.js:4337-4827 */
async function loadMentorSetup(force = false) {
const requestedDate = elements.tradeDate.value.replaceAll("-", "");
if (!force && state.mentorSetup?.requestedDate === requestedDate) {
renderMentorWorkspace();
return;
}
try {
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
const payload = await apiRequest(`/api/mentors/setup?${query}`);
payload.requestedDate = requestedDate;
if (!payload.preferences_configured) {
payload.mentors.sort((first, second) => {
if (Boolean(first.private) !== Boolean(second.private)) return first.private ? -1 : 1;
return String(first.name || "").localeCompare(String(second.name || ""), "zh-CN");
});
payload.mentors.forEach((mentor, index) => { mentor.sort_order = index; });
}
state.mentorSetup = payload;
const selectedExists = payload.mentors.some((item) => item.id === state.selectedMentorId);
state.selectedMentorId = selectedExists ? state.selectedMentorId : payload.mentors[0]?.id || "";
state.mentorMessages = await loadMentorMessages();
renderMentorWorkspace();
} catch (error) {
showMentorNotice(error.message || "问师模块加载失败");
showToast(error.message || "问师模块加载失败");
}
}
function renderMentorWorkspace() {
const setup = state.mentorSetup;
if (!setup) return;
const selected = setup.mentors.find((item) => item.id === state.selectedMentorId) || null;
setText("mentorDataDate", `数据日期 ${displayCompactDate(setup.trade_date)}`);
setText("activeMentorName", selected?.name || "--");
setText("mobileActiveMentorName", selected?.name || "选择思维模型");
document.querySelector("#activeMentorBadges").innerHTML = selected ? renderMentorBadges(selected, true) : "";
setText("activeMentorEvidence", selected?.evidence?.note || selected?.description || "--");
document.querySelector("#activeMentorFocus").innerHTML = (selected?.focus || []).slice(0, 4)
.map((item) => `<span>${escapeHtml(item)}</span>`).join("");
renderMentorDirectory();
renderMentorMessages();
}
function renderMentorDirectory() {
const mentors = state.mentorSetup?.mentors || [];
const query = state.mentorQuery;
const filtered = mentors.filter((mentor) => {
if (state.mentorSortMode) return true;
if (state.mentorGrade !== "all" && mentor.evidence?.grade !== state.mentorGrade) return false;
if (!query) return true;
const haystack = [
mentor.name,
mentor.description,
mentor.tagline,
mentor.evidence?.label,
mentor.evidence?.note,
...(mentor.focus || []),
].filter(Boolean).join(" ").toLocaleLowerCase("zh-CN");
return haystack.includes(query);
});
setText("mentorCount", filtered.length === mentors.length ? `${mentors.length}` : `${filtered.length} / ${mentors.length}`);
const sortToggle = document.querySelector("#mentorSortToggle");
sortToggle.classList.toggle("active", state.mentorSortMode);
sortToggle.setAttribute("aria-pressed", String(state.mentorSortMode));
sortToggle.querySelector("span").textContent = state.mentorSortMode ? "完成" : "整理";
document.querySelector("#mentorSortHint").hidden = !state.mentorSortMode;
document.querySelector("#mentorSearchInput").disabled = state.mentorSortMode;
document.querySelectorAll("[data-mentor-grade]").forEach((button) => {
button.disabled = state.mentorSortMode;
});
const container = document.querySelector("#mentorList");
container.classList.toggle("is-sorting", state.mentorSortMode);
container.innerHTML = filtered.map((mentor) => {
const group = mentors.filter((item) => Boolean(item.pinned) === Boolean(mentor.pinned));
const groupIndex = group.findIndex((item) => item.id === mentor.id);
return `
<article class="mentor-option ${mentor.id === state.selectedMentorId ? "active" : ""} ${mentor.pinned ? "is-pinned" : ""}"
data-mentor-card="${escapeHtml(mentor.id)}" draggable="${state.mentorSortMode && !state.mentorSavingPreferences}">
<button type="button" class="mentor-option-main" data-mentor-id="${escapeHtml(mentor.id)}" aria-pressed="${mentor.id === state.selectedMentorId}" ${state.mentorLoading ? "disabled" : ""}>
<span class="mentor-option-copy">
<span class="mentor-option-heading">
<strong>${escapeHtml(mentor.name)}</strong>
<span class="mentor-option-badges">${renderMentorBadges(mentor)}</span>
</span>
<em title="${escapeHtml(mentor.description || "")}">${escapeHtml(mentor.description || mentor.tagline || "思维模型")}</em>
<span class="mentor-option-meta">
${mentor.evidence?.label ? `<span class="mentor-evidence-source" title="${escapeHtml(mentor.evidence?.note || "素材说明")}">${escapeHtml(mentor.evidence.label)}</span>` : ""}
${(mentor.focus || []).slice(0, 2).map((item) => `<span>#${escapeHtml(item)}</span>`).join("")}
</span>
</span>
</button>
<span class="mentor-option-tools">
<button type="button" class="mentor-pin-button ${mentor.pinned ? "active" : ""}" data-mentor-pin="${escapeHtml(mentor.id)}"
aria-label="${mentor.pinned ? "取消置顶" : "置顶"}${escapeHtml(mentor.name)}" title="${mentor.pinned ? "取消置顶" : "置顶"}" ${state.mentorSavingPreferences ? "disabled" : ""}>
<i data-lucide="pin"></i>
</button>
${state.mentorSortMode ? `
<button type="button" class="mentor-order-button" data-mentor-move="up" data-mentor-target="${escapeHtml(mentor.id)}" aria-label="上移${escapeHtml(mentor.name)}" title="上移" ${groupIndex <= 0 || state.mentorSavingPreferences ? "disabled" : ""}><i data-lucide="chevron-up"></i></button>
<button type="button" class="mentor-order-button" data-mentor-move="down" data-mentor-target="${escapeHtml(mentor.id)}" aria-label="下移${escapeHtml(mentor.name)}" title="下移" ${groupIndex >= group.length - 1 || state.mentorSavingPreferences ? "disabled" : ""}><i data-lucide="chevron-down"></i></button>
` : ""}
</span>
</article>
`;
}).join("");
document.querySelector("#mentorListEmpty").hidden = filtered.length > 0;
document.querySelectorAll("[data-mentor-id]").forEach((button) => {
button.addEventListener("click", () => selectMentor(button.dataset.mentorId));
});
document.querySelectorAll("[data-mentor-pin]").forEach((button) => {
button.addEventListener("click", () => toggleMentorPin(button.dataset.mentorPin));
});
document.querySelectorAll("[data-mentor-move]").forEach((button) => {
button.addEventListener("click", () => moveMentor(button.dataset.mentorTarget, button.dataset.mentorMove));
});
document.querySelectorAll("[data-mentor-card]").forEach((card) => {
card.addEventListener("dragstart", handleMentorDragStart);
card.addEventListener("dragover", handleMentorDragOver);
card.addEventListener("drop", handleMentorDrop);
card.addEventListener("dragend", clearMentorDragState);
});
refreshIcons();
}
function toggleMentorSortMode() {
state.mentorSortMode = !state.mentorSortMode;
if (state.mentorSortMode) {
state.mentorQuery = "";
state.mentorGrade = "all";
document.querySelector("#mentorSearchInput").value = "";
document.querySelectorAll("[data-mentor-grade]").forEach((button) => {
button.classList.toggle("active", button.dataset.mentorGrade === "all");
});
}
renderMentorDirectory();
}
async function toggleMentorPin(mentorId) {
if (state.mentorSavingPreferences) return;
const mentors = state.mentorSetup?.mentors || [];
const index = mentors.findIndex((item) => item.id === mentorId);
if (index < 0) return;
const [mentor] = mentors.splice(index, 1);
mentor.pinned = !mentor.pinned;
if (mentor.pinned) {
mentors.unshift(mentor);
} else {
const firstUnpinned = mentors.findIndex((item) => !item.pinned);
mentors.splice(firstUnpinned < 0 ? mentors.length : firstUnpinned, 0, mentor);
}
normalizeMentorOrder();
renderMentorWorkspace();
await persistMentorPreferences();
}
async function moveMentor(mentorId, direction) {
if (state.mentorSavingPreferences) return;
const mentors = state.mentorSetup?.mentors || [];
const index = mentors.findIndex((item) => item.id === mentorId);
if (index < 0) return;
const step = direction === "up" ? -1 : 1;
const targetIndex = index + step;
if (targetIndex < 0 || targetIndex >= mentors.length) return;
if (Boolean(mentors[index].pinned) !== Boolean(mentors[targetIndex].pinned)) return;
[mentors[index], mentors[targetIndex]] = [mentors[targetIndex], mentors[index]];
normalizeMentorOrder();
renderMentorDirectory();
await persistMentorPreferences();
}
function handleMentorDragStart(event) {
if (!state.mentorSortMode || state.mentorSavingPreferences) {
event.preventDefault();
return;
}
state.mentorDragId = event.currentTarget.dataset.mentorCard || "";
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", state.mentorDragId);
event.currentTarget.classList.add("is-dragging");
}
function handleMentorDragOver(event) {
const source = state.mentorSetup?.mentors.find((item) => item.id === state.mentorDragId);
const target = state.mentorSetup?.mentors.find((item) => item.id === event.currentTarget.dataset.mentorCard);
if (!source || !target || Boolean(source.pinned) !== Boolean(target.pinned)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
event.currentTarget.classList.add("is-drag-over");
}
async function handleMentorDrop(event) {
event.preventDefault();
const sourceId = state.mentorDragId || event.dataTransfer.getData("text/plain");
const targetId = event.currentTarget.dataset.mentorCard || "";
clearMentorDragState();
if (!sourceId || !targetId || sourceId === targetId) return;
const mentors = state.mentorSetup?.mentors || [];
const sourceIndex = mentors.findIndex((item) => item.id === sourceId);
const targetIndex = mentors.findIndex((item) => item.id === targetId);
if (sourceIndex < 0 || targetIndex < 0) return;
if (Boolean(mentors[sourceIndex].pinned) !== Boolean(mentors[targetIndex].pinned)) return;
const [mentor] = mentors.splice(sourceIndex, 1);
const insertionIndex = mentors.findIndex((item) => item.id === targetId);
mentors.splice(insertionIndex, 0, mentor);
normalizeMentorOrder();
renderMentorDirectory();
await persistMentorPreferences();
}
function clearMentorDragState() {
state.mentorDragId = "";
document.querySelectorAll(".mentor-option.is-dragging, .mentor-option.is-drag-over").forEach((item) => {
item.classList.remove("is-dragging", "is-drag-over");
});
}
function normalizeMentorOrder() {
(state.mentorSetup?.mentors || []).forEach((mentor, index) => {
mentor.sort_order = index;
});
}
async function persistMentorPreferences() {
const mentors = state.mentorSetup?.mentors || [];
state.mentorSavingPreferences = true;
renderMentorDirectory();
try {
await apiRequest("/api/mentors/preferences", "POST", {
order: mentors.map((item) => item.id),
pinned: mentors.filter((item) => item.pinned).map((item) => item.id),
});
} catch (error) {
showToast(error.message || "问师顺序保存失败");
await loadMentorSetup(true);
} finally {
state.mentorSavingPreferences = false;
renderMentorDirectory();
}
}
function renderMentorBadges(mentor, expanded = false) {
const badges = [];
if (mentor.private) {
badges.push('<span class="mentor-badge private" title="仅管理员本人可见"><i data-lucide="lock-keyhole"></i>仅自己</span>');
}
const grade = mentor.evidence?.grade;
if (grade) {
badges.push(`<span class="mentor-badge evidence grade-${escapeHtml(grade.toLowerCase())}" title="${escapeHtml(mentor.evidence?.note || "素材等级")}">${escapeHtml(grade)}</span>`);
}
return badges.join("");
}
function toggleMentorDirectory(open) {
const mobileOpen = Boolean(open) && window.innerWidth <= 720;
state.mentorDirectoryOpen = mobileOpen;
const sidebar = document.querySelector("#mentorView .mentor-sidebar");
const backdrop = document.querySelector("#mentorDirectoryBackdrop");
const toggle = document.querySelector("#mentorDirectoryToggle");
sidebar.classList.toggle("is-open", mobileOpen);
backdrop.hidden = !mobileOpen;
toggle.setAttribute("aria-expanded", String(mobileOpen));
document.body.classList.toggle("mentor-directory-open", mobileOpen);
if (mobileOpen) requestAnimationFrame(() => document.querySelector("#mentorSearchInput").focus());
}
async function selectMentor(mentorId) {
if (mentorId === state.selectedMentorId) {
toggleMentorDirectory(false);
return;
}
state.selectedMentorId = mentorId;
state.mentorMessages = [];
hideMentorNotice();
renderMentorWorkspace();
toggleMentorDirectory(false);
state.mentorMessages = await loadMentorMessages();
renderMentorMessages();
}
function renderMentorMessages() {
const container = document.querySelector("#mentorMessages");
const selected = state.mentorSetup?.mentors.find((item) => item.id === state.selectedMentorId);
if (!state.mentorMessages.length && !state.mentorLoading) {
container.innerHTML = `
<div class="mentor-empty-state">
<span class="mentor-empty-mark" aria-hidden="true"><i data-lucide="messages-square"></i></span>
<strong>向「${escapeHtml(selected?.name || "问师")}」请教</strong>
<p>${escapeHtml(selected?.tagline || selected?.description || "选择一个问题开始对话")}</p>
</div>
`;
refreshIcons();
} else {
container.innerHTML = state.mentorMessages.map((message) => `
<article class="mentor-message ${message.role} ${message.error ? "is-error" : ""}">
<div class="mentor-message-label">${message.role === "user" ? "我" : escapeHtml(selected?.name || "问师")}</div>
<div class="mentor-message-content">${message.role === "assistant" ? formatMentorAnswer(message.content) : escapeHtml(message.content)}</div>
${message.streaming ? '<span class="assistant-stream-caret" aria-hidden="true"></span>' : ""}
${message.meta && !message.streaming ? `<small>${escapeHtml(message.meta)}</small>` : ""}
</article>
`).join("");
if (state.mentorLoading && !state.mentorMessages.some((message) => message.streaming)) {
container.insertAdjacentHTML("beforeend", `
<article class="mentor-message assistant loading-message">
<div class="mentor-message-label">${escapeHtml(selected?.name || "问师")}</div>
<p>正在读取复盘数据并推演...</p>
</article>
`);
}
}
document.querySelector("#clearMentorChatButton").disabled = !state.mentorMessages.length || state.mentorLoading;
document.querySelector("#mentorQuestion").disabled = state.mentorLoading || !state.selectedMentorId;
document.querySelector("#sendMentorQuestion").disabled = state.mentorLoading || !state.selectedMentorId;
document.querySelector("#mentorSortToggle").disabled = state.mentorLoading;
requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
}
async function sendMentorQuestion(event) {
event.preventDefault();
if (state.mentorLoading || !state.selectedMentorId) return;
const input = document.querySelector("#mentorQuestion");
const question = input.value.trim();
if (!question) return;
const history = state.mentorMessages.slice(-6).map((item) => ({
role: item.role,
content: item.content.slice(0, 3500),
}));
state.mentorMessages.push({ role: "user", content: question });
const responseMessage = { role: "assistant", content: "", streaming: true, meta: "" };
state.mentorMessages.push(responseMessage);
input.value = "";
state.mentorLoading = true;
state.mentorController = new AbortController();
hideMentorNotice();
renderMentorMessages();
renderMentorDirectory();
setStatus("问师正在读取复盘数据");
try {
await streamMentorRequest(
{
mentor_id: state.selectedMentorId,
trade_date: elements.tradeDate.value,
question,
history,
},
state.mentorController.signal,
(chunk) => {
responseMessage.content += chunk;
scheduleMentorRender();
},
(meta) => {
responseMessage.meta = `${displayCompactDate(meta.data_trade_date || elements.tradeDate.value)} · 回答完成`;
if (meta.notice) showMentorNotice(meta.notice);
},
);
responseMessage.streaming = false;
setStatus("问师回答完成");
} catch (error) {
responseMessage.streaming = false;
responseMessage.error = true;
if (!responseMessage.content) {
state.mentorMessages = state.mentorMessages.filter((item) => item !== responseMessage);
}
showMentorNotice(error.message || "问师回答失败");
showToast(error.message || "问师回答失败");
setStatus("问师回答失败");
} finally {
state.mentorLoading = false;
state.mentorController = null;
renderMentorMessages();
renderMentorDirectory();
input.focus();
}
}
let mentorRenderFrame = 0;
function scheduleMentorRender() {
if (mentorRenderFrame) return;
mentorRenderFrame = requestAnimationFrame(() => {
mentorRenderFrame = 0;
renderMentorMessages();
});
}
async function streamMentorRequest(body, signal, onDelta, onMeta) {
await window.XiaobaiAPI.streamNdjson("/api/mentors/chat", {
method: "POST",
body,
signal,
errorMessage: "问师暂不可用",
onEvent: (event) => {
if (event.type === "delta") onDelta(String(event.content || ""));
if (event.type === "meta") onMeta(event);
},
});
}
function useMentorQuickPrompt(prompt) {
const input = document.querySelector("#mentorQuestion");
input.value = prompt || "";
input.focus();
}
async function clearMentorConversation() {
if (!state.mentorMessages.length || !window.confirm("确定清空当前老师的对话记录吗?")) return;
try {
const query = new URLSearchParams({
mentor_id: state.selectedMentorId,
trade_date: state.mentorSetup?.trade_date || elements.tradeDate.value,
});
await apiRequest(`/api/mentors/messages?${query}`, "DELETE");
state.mentorMessages = [];
hideMentorNotice();
renderMentorMessages();
} catch (error) {
showToast(error.message || "对话记录清空失败");
}
}
async function loadMentorMessages() {
if (!state.selectedMentorId) return [];
try {
const query = new URLSearchParams({
mentor_id: state.selectedMentorId,
trade_date: state.mentorSetup?.trade_date || elements.tradeDate.value,
});
const payload = await apiRequest(`/api/mentors/messages?${query}`);
return (payload.items || []).filter(
(item) => ["user", "assistant"].includes(item?.role) && typeof item.content === "string",
).slice(-100);
} catch (error) {
showMentorNotice(error.message || "对话记录加载失败");
return [];
}
}
function showMentorNotice(message) {
const notice = document.querySelector("#mentorNotice");
notice.textContent = message;
notice.hidden = false;
}
function hideMentorNotice() {
document.querySelector("#mentorNotice").hidden = true;
}
function formatMentorAnswer(content) {
const blocks = [];
let listType = "";
let listItems = [];
const flushList = () => {
if (!listItems.length) return;
blocks.push(`<${listType} class="mentor-answer-list">${listItems.map((item) => `<li>${item}</li>`).join("")}</${listType}>`);
listItems = [];
listType = "";
};
String(content || "").replace(/\r\n?/g, "\n").replace(/\n{3,}/g, "\n\n").split("\n").forEach((rawLine) => {
const line = rawLine.trim();
if (!line) {
flushList();
return;
}
const heading = line.match(/^#{1,3}\s+(.+)$/);
const bullet = line.match(/^[-*]\s+(.+)$/);
const ordered = line.match(/^\d+[.、]\s*(.+)$/);
if (heading) {
flushList();
blocks.push(`<strong class="mentor-answer-heading">${formatMentorInline(escapeHtml(heading[1]))}</strong>`);
} else if (/^-{3,}$/.test(line)) {
flushList();
blocks.push('<span class="mentor-answer-rule"></span>');
} else if (line.startsWith("> ")) {
flushList();
blocks.push(`<span class="mentor-answer-quote">${formatMentorInline(escapeHtml(line.slice(2)))}</span>`);
} else if (bullet || ordered) {
const nextType = bullet ? "ul" : "ol";
if (listType && listType !== nextType) flushList();
listType = nextType;
listItems.push(formatMentorInline(escapeHtml((bullet || ordered)[1])));
} else {
flushList();
blocks.push(`<p class="mentor-answer-paragraph">${formatMentorInline(escapeHtml(line))}</p>`);
}
});
flushList();
return blocks.join("");
}
function formatMentorInline(content) {
return content.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
}
/* PRESERVATION-SOURCE-END app.js:4337-4827 */
+409
View File
@@ -0,0 +1,409 @@
window.XiaobaiPageModules.register("pools", [
"limitPool",
"brokenView",
"downView",
"yesterdayView",
"performanceView",
]);
/* PRESERVATION-SOURCE-BEGIN app.js:1517-1915 */
function getVisibleStocks() {
if (!state.dashboard) return [];
let rows = [...(state.dashboard.limits || [])];
if (state.filter === "1") rows = rows.filter((row) => number(row.streak) === 1);
if (state.filter === "2") rows = rows.filter((row) => number(row.streak) === 2);
if (state.filter === "3") rows = rows.filter((row) => number(row.streak) >= 3);
if (state.query) {
rows = rows.filter((row) => {
const haystack = `${row.code} ${row.name} ${row.sector} ${row.reason}`.toLowerCase();
return haystack.includes(state.query);
});
}
return rows.sort((left, right) => compareRows(left, right));
}
function renderLimitTable() {
if (!state.dashboard) return;
const rows = getVisibleStocks();
const allRows = state.dashboard.limits || [];
const body = document.querySelector("#limitTableBody");
body.innerHTML = rows.map((row, index) => `
<tr data-code="${escapeHtml(row.code)}">
<td class="row-number num muted">${index + 1}</td>
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
<td class="number num"><span class="pool-streak-tag tag red">${streakLabel(row.streak)}</span></td>
<td class="number num up">${signed(row.change)}</td>
<td class="number num">${formatNumber(row.price, 2)}</td>
<td>${escapeHtml(row.sector || "其他")}</td>
<td class="number num muted">${escapeHtml(row.first_time || "")}</td>
<td class="number num muted">${escapeHtml(row.last_time || "")}</td>
<td class="number num">${limitOpenState(row)}</td>
<td class="number num">${formatNumber(row.turnover_rate, 2)}</td>
<td class="number num">${formatNumber(row.amount_billion, 2)}</td>
<td class="number num">${formatLimitSealAmount(row.seal_amount_million)}</td>
<td class="pool-reason-cell" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "")}</td>
</tr>
`).join("");
bindStockRows(body);
setText("resultCount", `${rows.length}`);
setText("limitPoolSubtitle", `${allRows.length} 只 · 数据日期 ${displayCompactDate(state.dashboard.meta?.trade_date || elements.tradeDate.value)}`);
setText("limitAllCount", allRows.length);
setText("limitFirstCount", allRows.filter((row) => number(row.streak) === 1).length);
setText("limitSecondCount", allRows.filter((row) => number(row.streak) === 2).length);
setText("limitThreePlusCount", allRows.filter((row) => number(row.streak) >= 3).length);
document.querySelector("#emptyState").hidden = rows.length !== 0;
updateSortHeaders();
}
function limitOpenState(row) {
const openTimes = number(row.open_times);
const firstTime = String(row.first_time || "");
if (firstTime.startsWith("09:25") && openTimes === 0) return '<span class="pool-state-tag one-word">一字</span>';
if (openTimes >= 6) return `<span class="pool-state-tag broken">烂板×${openTimes}</span>`;
return String(openTimes);
}
function formatLimitSealAmount(value) {
const amount = number(value);
if (!amount) return "";
return Math.round(amount).toLocaleString("zh-CN");
}
function renderBrokenTable(rows) {
const visibleRows = getVisibleBrokenRows(rows);
setText("brokenCount", `${rows.length}`);
setText("brokenMeta", ` · 触及涨停后未能封住 · 数据日期 ${displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value)}`);
const body = document.querySelector("#brokenTableBody");
body.innerHTML = visibleRows.map((row, index) => `
<tr data-code="${escapeHtml(row.code)}">
<td class="row-number num muted">${index + 1}</td>
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
<td class="number num ${changeClass(row.change)}" data-sort-value="${number(row.change)}">${signed(row.change)}</td>
<td class="number num broken-limit-gap" data-sort-value="${row.limitGap}">${formatNumber(row.limitGap, 2)}</td>
<td class="number num">${formatNumber(row.price, 2)}</td>
<td>${escapeHtml(row.sector || "其他")}</td>
<td class="number num muted">${escapeHtml(row.first_time || "")}</td>
<td class="number num" data-sort-value="${number(row.open_times)}">${brokenOpenState(row)}</td>
<td class="number num">${formatNumber(row.turnover_rate, 2)}</td>
<td class="number num">${formatNumber(row.amount_billion, 2)}</td>
<td class="pool-reason-cell" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "")}</td>
</tr>
`).join("");
bindStockRows(body);
document.querySelector("#brokenEmptyState").hidden = visibleRows.length !== 0;
updateBrokenSortHeaders();
}
function getVisibleBrokenRows(rows = state.dashboard?.broken || []) {
let visibleRows = rows.map((row) => ({ ...row, limitGap: brokenLimitGap(row) }));
if (state.brokenQuery) {
visibleRows = visibleRows.filter((row) => `${row.code} ${row.name} ${row.sector}`.toLowerCase().includes(state.brokenQuery));
}
if (!state.brokenSortKey) return visibleRows;
return visibleRows.sort((left, right) => {
const result = number(left[state.brokenSortKey]) - number(right[state.brokenSortKey]);
return state.brokenSortDirection === "asc" ? result : -result;
});
}
function brokenLimitRate(row) {
const name = String(row.name || "").toUpperCase();
const code = String(row.code || "").replace(/\D/g, "");
if (name.includes("ST")) return 10;
if (/^(300|301|688|689)/.test(code)) return 20;
if (/^(4|8|92)/.test(code)) return 30;
return 10;
}
function brokenLimitGap(row) {
return Math.max(0, brokenLimitRate(row) - number(row.change));
}
function brokenOpenState(row) {
const openTimes = number(row.open_times);
return openTimes >= 6
? `<span class="broken-repeat-tag">反复炸 ×${openTimes}</span>`
: String(openTimes);
}
function changeBrokenSort(key) {
if (state.brokenSortKey === key) state.brokenSortDirection = state.brokenSortDirection === "asc" ? "desc" : "asc";
else {
state.brokenSortKey = key;
state.brokenSortDirection = "desc";
}
renderBrokenTable(state.dashboard?.broken || []);
}
function updateBrokenSortHeaders() {
document.querySelectorAll("#brokenTable th[data-broken-sort]").forEach((header) => {
header.classList.remove("sort-asc", "sort-desc", "sorted");
header.setAttribute("aria-sort", "none");
if (header.dataset.brokenSort === state.brokenSortKey) {
header.classList.add(state.brokenSortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted");
header.setAttribute("aria-sort", state.brokenSortDirection === "asc" ? "ascending" : "descending");
}
const arrow = header.querySelector(".arr");
if (arrow) arrow.textContent = header.classList.contains("sorted") ? (state.brokenSortDirection === "asc" ? "▲" : "▼") : "↕";
});
}
function renderDownTable(rows) {
const visibleRows = getVisibleDownRows(rows);
setText("downCount", `${rows.length}`);
setText("downMeta", ` · 观察退潮、高位风险与亏钱效应 · 数据日期 ${displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value)}`);
renderDownSectorCluster(rows);
const body = document.querySelector("#downTableBody");
body.innerHTML = visibleRows.map((row, index) => `
<tr data-code="${escapeHtml(row.code)}">
<td class="row-number num muted">${index + 1}</td>
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
<td class="number num down" data-sort-value="${number(row.change)}">${signed(row.change)}</td>
<td class="number num">${formatNumber(row.price, 2)}</td>
<td>${escapeHtml(row.sector || "其他")}</td>
<td class="number num">${formatNumber(row.turnover_rate, 2)}</td>
<td class="number num">${formatNumber(row.amount_billion, 2)}</td>
<td class="number num">${number(row.streak) > 0 ? number(row.streak) : ""}</td>
<td class="pool-reason-cell" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "")}</td>
</tr>
`).join("");
bindStockRows(body);
document.querySelector("#downEmptyState").hidden = visibleRows.length !== 0;
updateDownSortHeaders();
}
function getVisibleDownRows(rows = state.dashboard?.down_limits || []) {
let visibleRows = [...rows];
if (state.downQuery) {
visibleRows = visibleRows.filter((row) => `${row.code} ${row.name} ${row.sector}`.toLowerCase().includes(state.downQuery));
}
if (!state.downSortKey) return visibleRows;
return visibleRows.sort((left, right) => {
const result = number(left[state.downSortKey]) - number(right[state.downSortKey]);
return state.downSortDirection === "asc" ? result : -result;
});
}
function renderDownSectorCluster(rows) {
const counts = new Map();
rows.forEach((row) => {
const sector = String(row.sector || "其他").trim() || "其他";
if (sector === "其他") return;
counts.set(sector, (counts.get(sector) || 0) + 1);
});
const cluster = [...counts.entries()].sort((left, right) => right[1] - left[1])[0];
const element = document.querySelector("#downSectorCluster");
element.hidden = !cluster || cluster[1] < 2;
element.textContent = cluster && cluster[1] >= 2 ? `${cluster[0]}集中跌停 ×${cluster[1]}` : "";
}
function changeDownSort(key) {
if (state.downSortKey === key) state.downSortDirection = state.downSortDirection === "asc" ? "desc" : "asc";
else {
state.downSortKey = key;
state.downSortDirection = "asc";
}
renderDownTable(state.dashboard?.down_limits || []);
}
function updateDownSortHeaders() {
document.querySelectorAll("#downTable th[data-down-sort]").forEach((header) => {
header.classList.remove("sort-asc", "sort-desc", "sorted");
header.setAttribute("aria-sort", "none");
if (header.dataset.downSort === state.downSortKey) {
header.classList.add(state.downSortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted");
header.setAttribute("aria-sort", state.downSortDirection === "asc" ? "ascending" : "descending");
}
const arrow = header.querySelector(".arr");
if (arrow) arrow.textContent = header.classList.contains("sorted") ? (state.downSortDirection === "asc" ? "▲" : "▼") : "↕";
});
}
function renderYesterdayTable(rows) {
const visibleRows = getVisibleYesterdayRows(rows);
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
setText("yesterdayCount", `${rows.length}`);
setText("yesterdayMeta", ` · 昨日 ${previousDate} → 今日 ${currentDate}`);
renderYesterdaySummary(rows);
const body = document.querySelector("#yesterdayTableBody");
body.innerHTML = visibleRows.map((row, index) => `
<tr data-code="${escapeHtml(row.code)}">
<td class="row-number num muted">${index + 1}</td>
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
<td class="number num">${number(row.prior_streak)}</td>
<td class="number num ${changeClass(row.current_change)}">${signed(row.current_change)}</td>
<td><span class="yesterday-outcome-tag ${yesterdayOutcomeClass(row.outcome)}">${escapeHtml(row.outcome)}</span></td>
<td class="number num">${number(row.current_streak) ? `<span class="yesterday-height-tag">${number(row.current_streak)}</span>` : ""}</td>
<td>${escapeHtml(row.sector || "其他")}</td>
<td class="pool-reason-cell" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "")}</td>
</tr>
`).join("");
bindStockRows(body);
document.querySelector("#yesterdayEmptyState").hidden = visibleRows.length !== 0;
updateYesterdayControls();
}
function getVisibleYesterdayRows(rows = state.dashboard?.yesterday_limits || []) {
let visibleRows = rows.filter((row) => {
if (state.yesterdayFilter === "advance") return row.outcome === "晋级";
if (state.yesterdayFilter === "positive") return number(row.current_change) > 0;
if (state.yesterdayFilter === "fail") return row.outcome === "断板";
if (state.yesterdayFilter === "risk") return ["炸板", "跌停"].includes(row.outcome);
return true;
});
if (state.yesterdayQuery) {
visibleRows = visibleRows.filter((row) => `${row.code} ${row.name} ${row.sector}`.toLowerCase().includes(state.yesterdayQuery));
}
if (!state.yesterdaySortKey) return visibleRows;
return visibleRows.sort((left, right) => {
const result = number(left[state.yesterdaySortKey]) - number(right[state.yesterdaySortKey]);
return state.yesterdaySortDirection === "asc" ? result : -result;
});
}
function renderYesterdaySummary(rows) {
const total = rows.length;
const advance = rows.filter((row) => row.outcome === "晋级").length;
const positive = rows.filter((row) => number(row.current_change) > 0).length;
const fail = rows.filter((row) => row.outcome === "断板").length;
const risk = rows.filter((row) => ["炸板", "跌停"].includes(row.outcome)).length;
const rate = (value) => total ? value / total * 100 : 0;
setText("yesterdayAllCount", total);
setText("yesterdayAdvanceCount", advance);
setText("yesterdayAdvanceRate", `晋级率 ${formatNumber(rate(advance), 1)}%`);
setText("yesterdayPositiveCount", positive);
setText("yesterdayPositiveRate", `兑现率 ${formatNumber(rate(positive), 1)}%`);
setText("yesterdayFailCount", fail);
setText("yesterdayFailRate", `${formatNumber(rate(fail), 1)}%`);
setText("yesterdayRiskCount", risk);
setText("yesterdayRiskRate", `亏钱效应 ${formatNumber(rate(risk), 1)}%`);
}
function yesterdayOutcomeClass(outcome) {
return { "晋级": "advance", "断板": "fail", "炸板": "broken", "跌停": "down" }[outcome] || "fail";
}
function changeYesterdaySort(key) {
if (state.yesterdaySortKey === key) state.yesterdaySortDirection = state.yesterdaySortDirection === "asc" ? "desc" : "asc";
else {
state.yesterdaySortKey = key;
state.yesterdaySortDirection = "desc";
}
renderYesterdayTable(state.dashboard?.yesterday_limits || []);
}
function updateYesterdayControls() {
document.querySelectorAll("[data-yesterday-filter]").forEach((button) => {
const active = button.dataset.yesterdayFilter === state.yesterdayFilter;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
document.querySelectorAll("#yesterdayTable th[data-yesterday-sort]").forEach((header) => {
header.classList.remove("sort-asc", "sort-desc", "sorted");
header.setAttribute("aria-sort", "none");
if (header.dataset.yesterdaySort === state.yesterdaySortKey) {
header.classList.add(state.yesterdaySortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted");
header.setAttribute("aria-sort", state.yesterdaySortDirection === "asc" ? "ascending" : "descending");
}
const arrow = header.querySelector(".arr");
if (arrow) arrow.textContent = header.classList.contains("sorted") ? (state.yesterdaySortDirection === "asc" ? "▲" : "▼") : "↕";
});
}
function renderPerformance(rows) {
rows = normalizePerformanceRows(rows);
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
setText("performanceDateRange", `昨日 ${previousDate} → 今日 ${currentDate}`);
document.querySelector("#performanceCards").innerHTML = rows.map((row) => `
<article class="performance-stage-card" title="收红 ${formatNumber(row.positive_rate, 1)}% · 平均涨幅 ${signed(row.average_change)}%"
aria-label="${escapeHtml(row.label)},晋级率 ${formatNumber(row.advance_rate, 1)}%,晋级 ${number(row.advanced)} 只,共 ${number(row.count)} 只,收红率 ${formatNumber(row.positive_rate, 1)}%,平均涨幅 ${signed(row.average_change)}%">
<div class="performance-stage-label"><span>${escapeHtml(row.label)} → 今日</span><i class="performance-status-tag ${performanceRateState(row.advance_rate).className}">${performanceRateState(row.advance_rate).label}</i></div>
<strong class="performance-stage-rate ${performanceRateState(row.advance_rate).className}">${formatNumber(row.advance_rate, 1)}%</strong>
<span class="performance-stage-count">晋级 ${number(row.advanced)} / 共 ${number(row.count)} 只</span>
<div class="performance-stage-track" aria-hidden="true"><i class="${performanceRateState(row.advance_rate).className}" style="width:${Math.max(number(row.advance_rate), number(row.advance_rate) > 0 ? 2 : 0)}%"></i></div>
</article>
`).join("") || '<div class="performance-empty-state">暂无昨日涨停统计</div>';
renderPerformanceConclusion(rows);
renderMarketBreadth(state.dashboard?.overview || {});
}
function normalizePerformanceRows(rows) {
const groups = new Map();
(rows || []).forEach((row) => {
const level = Math.max(1, number(row.level));
const displayLevel = Math.min(level, 5);
const group = groups.get(displayLevel) || {
level: displayLevel,
label: displayLevel === 1 ? "昨日首板" : displayLevel === 5 ? "昨日5板+" : `昨日${displayLevel}`,
count: 0,
advanced: 0,
positive: 0,
changeTotal: 0,
};
const count = number(row.count);
group.count += count;
group.advanced += number(row.advanced);
group.positive += count * number(row.positive_rate) / 100;
group.changeTotal += count * number(row.average_change);
groups.set(displayLevel, group);
});
return [...groups.values()]
.sort((left, right) => right.level - left.level)
.map((group) => ({
level: group.level,
label: group.label,
count: group.count,
advanced: group.advanced,
advance_rate: group.count ? group.advanced / group.count * 100 : 0,
positive_rate: group.count ? group.positive / group.count * 100 : 0,
average_change: group.count ? group.changeTotal / group.count : 0,
}));
}
function performanceRateState(rate) {
const value = number(rate);
if (value === 0) return { label: "失效", className: "is-neutral" };
if (value < 20) return { label: "危险", className: "is-warning" };
return { label: "活跃", className: "is-active" };
}
function renderPerformanceConclusion(rows) {
const container = document.querySelector("#performanceConclusion");
if (!rows.length) {
container.innerHTML = '<div class="empty-state">暂无昨日梯队数据,暂不生成结论</div>';
return;
}
const sorted = [...rows].sort((left, right) => number(right.level) - number(left.level));
const highRows = sorted.filter((row) => number(row.level) >= 4);
const highAdvanced = highRows.reduce((total, row) => total + number(row.advanced), 0);
const highSamples = highRows.map((row) => escapeHtml(row.label)).join("、");
const strongest = [...rows].sort((left, right) => (
number(right.advance_rate) - number(left.advance_rate) || number(right.level) - number(left.level)
))[0];
const firstBoard = rows.find((row) => number(row.level) === 1);
const overview = state.dashboard?.overview || {};
const phase = overview.sentiment_phase || "观察";
const up = number(overview.up_count);
const down = number(overview.down_count);
const breadthRate = up + down > 0 ? up / (up + down) * 100 : 50;
const stance = breadthRate < 25 ? "宜守不宜攻" : breadthRate < 45 ? "控制仓位,聚焦核心" : "保持精选,跟随强势梯队";
const highText = highRows.length
? `高位晋级率<b class="${highAdvanced ? "up" : "is-neutral"}">${highAdvanced ? "仍有承接" : "全线失效"}</b>${highSamples}${highAdvanced ? `共晋级 ${highAdvanced}` : "今日均未晋级"}`
: "高位梯队暂无昨日样本,空间信号仍待确认;";
const strongestText = strongest
? `<b>${escapeHtml(strongest.label)}</b>晋级率最高,为 <b class="up">${formatNumber(strongest.advance_rate, 1)}%</b>${number(strongest.advanced)} 只晋级 / 共 ${number(strongest.count)} 只);`
: "暂无相对占优梯队;";
const firstBoardText = firstBoard
? `首板基数 ${number(firstBoard.count)} 只,晋级率 <b class="${performanceRateState(firstBoard.advance_rate).className}">${formatNumber(firstBoard.advance_rate, 1)}%</b>,低位接力${number(firstBoard.advance_rate) < 20 ? "胜率偏低" : "仍有活跃度"}`
: "首板梯队暂无有效样本;";
container.innerHTML = `
<div>· ${highText}</div>
<div>· ${strongestText}</div>
<div>· ${firstBoardText}</div>
<div>· 结论:<b>${stance}</b>,当前情绪周期「${escapeHtml(phase)}」。</div>
`;
}
/* PRESERVATION-SOURCE-END app.js:1517-1915 */
+82
View File
@@ -0,0 +1,82 @@
window.XiaobaiPageModules.register("popularity", ["popularityView"], {
enter: ["loadPopularity"],
});
/* PRESERVATION-SOURCE-BEGIN app.js:2584-2659 */
async function loadPopularity(force = false) {
if (state.popularityLoading) return;
state.popularityLoading = true;
const button = document.querySelector("#popularityRefreshButton");
button.disabled = true;
setText("popularityDateLabel", "正在读取人气榜");
try {
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
if (force) query.set("force", "1");
state.popularityData = await apiRequest(`/api/popularity?${query}`);
renderPopularity();
} catch (error) {
setText("popularityDateLabel", error.message || "人气榜暂不可用");
document.querySelector("#popularityTableBody").innerHTML = "";
document.querySelector("#popularityEmpty").hidden = false;
showToast(error.message || "人气榜加载失败");
} finally {
state.popularityLoading = false;
button.disabled = false;
}
}
function renderPopularity() {
const payload = state.popularityData;
if (!payload) return;
const summary = payload.summary || {};
setText("popularityDateLabel", `${payload.meta?.carried_forward ? "最近有效榜单" : "榜单日期"} ${payload.meta?.trade_date || "--"}`);
const topNames = (rows) => (rows || []).slice(0, 3).map((item) => item.name).filter(Boolean).join(" · ") || "--";
document.querySelector("#popularitySummary").innerHTML = [
["同花顺热度 Top3", topNames(payload.ths), `${number(summary.ths_count)} 只上榜`],
["东方财富热度 Top3", topNames(payload.dc), `${number(summary.dc_count)} 只上榜`],
["双榜共识", `${number(summary.dual_count)}`, "同时进入两榜,共识度更高"],
].map(([label, value, detail], index) => `<article class="${index === 2 ? "consensus" : ""}"><span>${label}</span><strong>${escapeHtml(value)}</strong><small>${escapeHtml(detail)}</small></article>`).join("");
renderPopularityTable();
}
function renderPopularityTable() {
const source = state.popularitySource;
let rows = [...(state.popularityData?.[source] || [])];
if (state.popularityQuery) {
rows = rows.filter((item) => `${item.code} ${item.name} ${(item.concepts || []).join(" ")}`.toLocaleLowerCase("zh-CN").includes(state.popularityQuery));
}
const combined = source === "combined";
const sourceName = source === "ths" ? "同花顺" : source === "dc" ? "东方财富" : "双榜综合";
setText("popularityTableTitle", `${sourceName}`);
setText("popularityTableNote", combined ? "按双榜排名综合排序 · 已隐藏重复的榜单状态" : "按榜单名次排序 · 状态显示是否同时进入另一榜");
const headers = [
["排名", "number num"], ["股票", ""], ["最新价(元)", "number num"], ["涨跌幅(%", "number num"],
...(source !== "dc" ? [["同花顺", "number num"]] : []),
...(source !== "ths" ? [["东方财富", "number num"]] : []),
["排名变化", "number num"], ["热门概念", ""], ...(!combined ? [["榜单状态", ""]] : []),
];
document.querySelector("#popularityTableHead").innerHTML = headers.map(([label, className]) => `<th scope="col" class="${className}">${label}</th>`).join("");
const body = document.querySelector("#popularityTableBody");
body.innerHTML = rows.map((row, index) => {
const thsRank = source === "ths" ? row.rank : row.ths_rank;
const dcRank = source === "dc" ? row.rank : row.dc_rank;
const move = row.rank_change;
const movement = move === null || move === undefined ? "新" : number(move) > 0 ? `${number(move)}` : number(move) < 0 ? `${Math.abs(number(move))}` : "持平";
return `<tr data-code="${escapeHtml(row.code)}">
<td class="number num popularity-rank-v2"><b>${index + 1}</b>${index < 3 ? '<span>热</span>' : ""}</td>
<td><div class="popularity-stock-v2"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><span class="stock-code scode">${escapeHtml(row.code)}</span></div></td>
<td class="number num">${row.price == null ? "" : formatNumber(row.price, 2)}</td>
<td class="number num ${row.change == null ? "" : changeClass(row.change)}">${row.change == null ? "" : signed(row.change)}</td>
${source !== "dc" ? `<td class="number num popularity-list-rank-v2">${thsRank ? number(thsRank) : ""}</td>` : ""}
${source !== "ths" ? `<td class="number num popularity-list-rank-v2">${dcRank ? number(dcRank) : ""}</td>` : ""}
<td class="number num popularity-movement-v2 ${number(move) > 0 ? "up" : number(move) < 0 ? "down" : ""}">${movement}</td>
<td class="popularity-concepts-v2" title="${escapeHtml((row.concepts || []).join("、"))}">${escapeHtml((row.concepts || []).slice(0, 3).join("、"))}</td>
${!combined ? `<td><span class="popularity-source-tag-v2 ${row.dual_source ? "dual" : ""}">${row.dual_source ? "双榜共识" : "单榜入选"}</span></td>` : ""}
</tr>`;
}).join("");
bindStockRows(body);
markAutoSortableHeaders(body.closest("table"));
document.querySelector("#popularityEmpty").hidden = rows.length > 0;
}
/* PRESERVATION-SOURCE-END app.js:2584-2659 */
+699
View File
@@ -0,0 +1,699 @@
window.XiaobaiPageModules.register("review", ["reviewWorkspaceView"], {
enter: ["loadReview"],
});
/* PRESERVATION-SOURCE-BEGIN app.js:3029-3470 */
async function loadReviewWorkspace() {
try {
const [watchlistPayload, notesPayload, tradesPayload] = await Promise.all([
apiRequest(`/api/watchlist?trade_date=${encodeURIComponent(elements.tradeDate.value)}`),
apiRequest("/api/notes?scope=daily"),
apiRequest("/api/trades"),
]);
state.watchlist = watchlistPayload.items || [];
state.notes = notesPayload.items || [];
state.tradeEntries = tradesPayload.items || [];
state.tradeSummary = tradesPayload.summary || {};
setText("reviewDataDate", displayCompactDate(elements.tradeDate.value));
renderWatchlist();
renderNotesHistory(state.notes, document.querySelector("#notesHistory"), false);
setText("notesCount", `${state.notes.length}`);
renderTradeLog();
populateJournalForm();
} catch (error) {
showToast(error.message || "我的复盘加载失败");
}
}
function renderWatchlist() {
setText("watchlistCount", `${state.watchlist.length}`);
const body = document.querySelector("#watchlistTableBody");
body.innerHTML = state.watchlist.map((item) => `
<tr data-code="${escapeHtml(item.code)}"><td><span class="review-watch-mark ${escapeHtml(item.color)}" title="${escapeHtml(item.color)}">★</span></td>
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(item.name)}</strong><small class="stock-code scode">${escapeHtml(item.code)}</small></span></td>
<td>${escapeHtml(item.sector || "其他")}</td>
<td class="number num ${item.change == null ? "" : changeClass(item.change)}">${formatWatchMetric(item.change)}</td>
<td class="number num ${item.return_5d == null ? "" : changeClass(item.return_5d)}">${formatWatchMetric(item.return_5d)}</td>
<td class="number num"><strong class="watch-attention-score">${item.attention_score == null ? "" : formatNumber(item.attention_score, 1)}</strong></td>
<td><span class="watch-remark" title="${escapeHtml(item.remark || "尚未填写跟踪备注")}">${escapeHtml(item.remark || "尚未填写")}</span></td>
<td><span class="review-row-actions"><button class="table-action" type="button" data-watch-remark="${escapeHtml(item.code)}">备注</button>
<button class="table-action down" type="button" data-watch-delete="${escapeHtml(item.code)}" aria-label="移除 ${escapeHtml(item.name)}">移除</button></span></td></tr>
`).join("");
document.querySelector("#watchlistEmpty").hidden = state.watchlist.length > 0;
body.querySelectorAll("[data-watch-remark]").forEach((button) => {
button.addEventListener("click", () => {
const item = state.watchlist.find((row) => row.code === button.dataset.watchRemark);
openWatchlistDialog(item);
});
});
body.querySelectorAll("[data-watch-delete]").forEach((button) => {
button.addEventListener("click", () => removeWatchlist(button.dataset.watchDelete));
});
bindStockRows(body);
}
function formatWatchMetric(value) {
if (value == null || !Number.isFinite(Number(value))) return "";
return signed(value);
}
function openWatchlistDialog(item = null) {
clearTimeout(watchlistSearchTimer);
state.watchlistSelection = item ? {
code: item.code,
name: item.name,
sector: item.sector || "其他",
color: item.color || "red",
} : null;
state.watchlistSearchResults = [];
setText("watchlistDialogTitle", item ? "编辑跟踪备注" : "添加自选");
document.querySelector("#watchlistRemark").value = item?.remark || "";
document.querySelector("#watchlistSearchInput").value = "";
document.querySelector("#watchlistSearchResults").innerHTML = "";
syncWatchlistSelection(Boolean(item));
openModalDialog(elements.watchlistDialog);
requestAnimationFrame(() => (item ? document.querySelector("#watchlistRemark") : document.querySelector("#watchlistSearchInput")).focus());
}
function closeWatchlistDialog() {
clearTimeout(watchlistSearchTimer);
if (elements.watchlistDialog.open) elements.watchlistDialog.close();
}
function clearWatchlistSelection() {
state.watchlistSelection = null;
syncWatchlistSelection(false);
document.querySelector("#watchlistSearchInput").focus();
}
function syncWatchlistSelection(editing = false) {
const item = state.watchlistSelection;
document.querySelector("#watchlistSearchField").hidden = Boolean(item);
document.querySelector("#watchlistSelection").hidden = !item;
document.querySelector("#changeWatchlistSelection").hidden = editing;
document.querySelector("#saveWatchlist").disabled = !item;
if (!item) return;
setText("watchlistSelectionName", item.name || "--");
setText("watchlistSelectionCode", item.code || "--");
setText("watchlistSelectionSector", item.sector || "其他");
refreshIcons();
}
function scheduleWatchlistSearch() {
clearTimeout(watchlistSearchTimer);
const query = document.querySelector("#watchlistSearchInput").value.trim();
if (!query) {
document.querySelector("#watchlistSearchResults").innerHTML = "";
return;
}
document.querySelector("#watchlistSearchResults").innerHTML = '<div class="watchlist-search-status">正在查找股票</div>';
watchlistSearchTimer = setTimeout(() => runWatchlistSearch(query), 160);
}
async function runWatchlistSearch(query) {
const sequence = ++state.watchlistSearchRequestSequence;
try {
const params = new URLSearchParams({ q: query, trade_date: elements.tradeDate.value });
const payload = await apiRequest(`/api/search?${params}`);
if (sequence !== state.watchlistSearchRequestSequence) return;
state.watchlistSearchResults = payload.groups?.stocks || [];
document.querySelector("#watchlistSearchResults").innerHTML = state.watchlistSearchResults.map((item, index) => `
<button type="button" data-watchlist-result="${index}"><span><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.industry || "其他")}</small></span><b>${escapeHtml(item.code)}</b></button>
`).join("") || '<div class="watchlist-search-status">没有找到匹配的股票</div>';
} catch (error) {
document.querySelector("#watchlistSearchResults").innerHTML = `<div class="watchlist-search-status">${escapeHtml(error.message || "搜索失败")}</div>`;
}
}
function handleWatchlistSearchResult(event) {
const button = event.target.closest("[data-watchlist-result]");
if (!button) return;
const item = state.watchlistSearchResults[number(button.dataset.watchlistResult)];
if (!item) return;
state.watchlistSelection = {
code: item.code,
name: item.name,
sector: item.industry || "其他",
color: "red",
};
syncWatchlistSelection(false);
}
async function saveWatchlistFromDialog(event) {
event.preventDefault();
const item = state.watchlistSelection;
if (!item) return;
const button = document.querySelector("#saveWatchlist");
button.disabled = true;
try {
await apiRequest("/api/watchlist", "POST", {
code: item.code,
name: item.name,
sector: item.sector || "其他",
color: item.color || "red",
remark: document.querySelector("#watchlistRemark").value.trim(),
});
closeWatchlistDialog();
await loadReviewWorkspace();
showToast(state.watchlist.some((row) => row.code === item.code) ? "自选跟踪已保存" : "已加入自选");
} catch (error) {
showToast(error.message || "自选保存失败");
button.disabled = false;
}
}
async function toggleActiveWatchlist() {
const stock = state.activeStock;
if (!stock?.code) return;
const isWatched = Boolean(state.stockDetail?.stock?.watchlist || state.watchlist.some((item) => item.code === stock.code));
try {
if (isWatched) {
await apiRequest(`/api/watchlist/${stock.code}`, "DELETE");
state.watchlist = state.watchlist.filter((item) => item.code !== stock.code);
if (state.stockDetail?.stock) state.stockDetail.stock.watchlist = null;
showToast("已移出自选");
} else {
const payload = await apiRequest("/api/watchlist", "POST", {
code: stock.code,
name: stock.name || "--",
sector: stock.sector || "其他",
color: "red",
});
state.watchlist = payload.items || state.watchlist;
if (state.stockDetail?.stock) state.stockDetail.stock.watchlist = state.watchlist.find((item) => item.code === stock.code);
showToast("已加入自选");
}
updateWatchButton();
renderWatchlist();
} catch (error) {
showToast(error.message);
}
}
function updateWatchButton() {
const code = state.activeStock?.code;
const watched = Boolean(state.stockDetail?.stock?.watchlist || state.watchlist.some((item) => item.code === code));
setText("watchStockButton", watched ? "移出自选" : "加入自选");
}
async function removeWatchlist(code) {
try {
await apiRequest(`/api/watchlist/${code}`, "DELETE");
state.watchlist = state.watchlist.filter((item) => item.code !== code);
renderWatchlist();
showToast("已移出自选");
} catch (error) {
showToast(error.message);
}
}
async function saveJournal(event) {
event.preventDefault();
try {
await apiRequest("/api/notes", "POST", {
trade_date: document.querySelector("#journalDate").value,
id: state.editingDailyNoteId || undefined,
summary: document.querySelector("#journalSummary").value,
content: document.querySelector("#journalContent").value,
plan: document.querySelector("#journalPlan").value,
});
await loadReviewWorkspace();
showToast("每日复盘已保存");
} catch (error) {
showToast(error.message);
}
}
function populateJournalForm() {
const selectedDate = document.querySelector("#journalDate").value.replaceAll("-", "");
const note = state.notes.find((item) => String(item.trade_date).replaceAll("-", "") === selectedDate);
state.editingDailyNoteId = number(note?.id);
document.querySelector("#journalSummary").value = note?.summary || "";
document.querySelector("#journalContent").value = note?.content || "";
document.querySelector("#journalPlan").value = note?.plan || "";
}
function openTradeLogDialog() {
resetTradeLogForm();
openModalDialog(elements.tradeLogDialog);
requestAnimationFrame(() => document.querySelector("#tradeLogCode").focus());
}
function closeTradeLogDialog() {
if (elements.tradeLogDialog.open) elements.tradeLogDialog.close();
else resetTradeLogForm();
}
async function saveTradeLog(event) {
event.preventDefault();
const button = document.querySelector("#saveTradeLog");
button.disabled = true;
try {
const payload = await apiRequest("/api/trades", "POST", {
id: state.editingTradeId || undefined,
trade_date: document.querySelector("#tradeLogDate").value,
code: document.querySelector("#tradeLogCode").value.trim(),
name: document.querySelector("#tradeLogName").value.trim(),
action: document.querySelector("#tradeLogAction").value,
price: document.querySelector("#tradeLogPrice").value,
quantity: document.querySelector("#tradeLogQuantity").value,
position_pct: document.querySelector("#tradeLogPosition").value,
pnl_amount: document.querySelector("#tradeLogPnlAmount").value,
pnl_pct: document.querySelector("#tradeLogPnlPct").value,
emotion: document.querySelector("#tradeLogEmotion").value,
tags: document.querySelector("#tradeLogTags").value,
thesis: document.querySelector("#tradeLogThesis").value,
execution: document.querySelector("#tradeLogExecution").value,
});
state.tradeEntries = payload.items || [];
state.tradeSummary = payload.summary || {};
renderTradeLog();
closeTradeLogDialog();
showToast("交易记录已保存");
} catch (error) {
showToast(error.message || "交易记录保存失败");
} finally {
button.disabled = false;
}
}
function resetTradeLogForm() {
state.editingTradeId = 0;
document.querySelector("#tradeLogForm").reset();
document.querySelector("#tradeLogDate").value = elements.tradeDate.value || todayString();
document.querySelector("#tradeLogQuantity").value = "0";
document.querySelector("#tradeLogPosition").value = "0";
setText("tradeLogDialogTitle", "交易日志");
setText("saveTradeLog", "保存交易");
}
function editTradeLog(id) {
const item = state.tradeEntries.find((entry) => number(entry.id) === id);
if (!item) return;
state.editingTradeId = id;
document.querySelector("#tradeLogDate").value = displayCompactDate(item.trade_date);
document.querySelector("#tradeLogCode").value = item.code;
document.querySelector("#tradeLogName").value = item.name;
document.querySelector("#tradeLogAction").value = item.action;
document.querySelector("#tradeLogPrice").value = item.price;
document.querySelector("#tradeLogQuantity").value = item.quantity;
document.querySelector("#tradeLogPosition").value = item.position_pct;
document.querySelector("#tradeLogPnlAmount").value = item.pnl_amount ?? "";
document.querySelector("#tradeLogPnlPct").value = item.pnl_pct ?? "";
document.querySelector("#tradeLogEmotion").value = item.emotion;
document.querySelector("#tradeLogTags").value = (item.tags || []).join(", ");
document.querySelector("#tradeLogThesis").value = item.thesis || "";
document.querySelector("#tradeLogExecution").value = item.execution || "";
setText("tradeLogDialogTitle", "编辑交易日志");
setText("saveTradeLog", "保存修改");
openModalDialog(elements.tradeLogDialog);
requestAnimationFrame(() => document.querySelector("#tradeLogCode").focus());
}
async function handleTradeLogAction(event) {
const button = event.target.closest("[data-trade-action]");
if (!button) return;
const id = number(button.dataset.tradeId);
if (button.dataset.tradeAction === "edit") {
editTradeLog(id);
return;
}
if (!window.confirm("确定删除这条交易记录吗?")) return;
try {
const payload = await apiRequest(`/api/trades/${id}`, "DELETE");
state.tradeEntries = payload.items || [];
state.tradeSummary = payload.summary || {};
if (state.editingTradeId === id) resetTradeLogForm();
renderTradeLog();
showToast("交易记录已删除");
} catch (error) {
showToast(error.message || "交易记录删除失败");
}
}
function renderTradeLog() {
const summary = state.tradeSummary || {};
setText("tradeLogCount", `${state.tradeEntries.length}`);
document.querySelector("#tradeLogSummary").innerHTML = [
["记录", `${number(summary.total)}`],
["已实现", `${number(summary.realized)}`],
["胜率", summary.win_rate == null ? "--" : `${formatNumber(summary.win_rate, 1)}%`],
["累计盈亏", summary.pnl_amount == null ? "--" : `${number(summary.pnl_amount) > 0 ? "+" : ""}${formatNumber(summary.pnl_amount, 2)}`],
["平均仓位", summary.average_position == null ? "--" : `${formatNumber(summary.average_position, 1)}%`],
].map(([label, value]) => `<div><span>${label}</span><strong>${value}</strong></div>`).join("");
document.querySelector("#tradeLogEmpty").hidden = state.tradeEntries.length > 0;
document.querySelector("#tradeLogTableBody").innerHTML = state.tradeEntries.map((item) => `
<tr data-code="${escapeHtml(item.code)}">
<td>${displayCompactDate(item.trade_date)}</td>
<td><span class="stock-cell"><strong class="sname">${escapeHtml(item.name)}</strong><small class="stock-code scode">${escapeHtml(item.code)}</small></span></td>
<td><span class="trade-action trade-action-${escapeHtml(item.action)}">${escapeHtml(item.action_label)}</span></td>
<td class="number num">${item.position_pct == null ? "" : formatNumber(item.position_pct, 1)}</td>
<td class="number num ${item.pnl_pct == null ? "" : changeClass(item.pnl_pct)}">${item.pnl_pct == null ? "" : signed(item.pnl_pct)}</td>
<td class="number num ${item.pnl_amount == null ? "" : changeClass(item.pnl_amount)}">${item.pnl_amount == null ? "" : signed(item.pnl_amount)}</td>
<td><span class="trade-emotion">${escapeHtml(item.emotion_label)}</span><div class="trade-tags">${(item.tags || []).map((tag) => `<em>${escapeHtml(tag)}</em>`).join("")}</div></td>
<td class="trade-copy" title="交易逻辑:${escapeHtml(item.thesis || "")};执行复核:${escapeHtml(item.execution || "")}"><strong>${escapeHtml(item.thesis || "")}</strong><small>${escapeHtml(item.execution || "尚未填写执行复核")}</small></td>
<td><div class="trade-row-actions"><button class="table-action" type="button" data-trade-action="edit" data-trade-id="${number(item.id)}">编辑</button><button class="table-action down" type="button" data-trade-action="delete" data-trade-id="${number(item.id)}">删除</button></div></td>
</tr>
`).join("");
bindStockRows(document.querySelector("#tradeLogTableBody"));
}
async function saveStockNote(event) {
event.preventDefault();
if (!state.activeStock?.code) return;
try {
await apiRequest("/api/notes", "POST", {
code: state.activeStock.code,
stock_name: state.activeStock.name || "--",
trade_date: elements.tradeDate.value,
content: document.querySelector("#stockNoteContent").value,
plan: document.querySelector("#stockNotePlan").value,
});
document.querySelector("#stockNoteContent").value = "";
document.querySelector("#stockNotePlan").value = "";
const payload = await apiRequest(`/api/notes?scope=stock&code=${encodeURIComponent(state.activeStock.code)}`);
state.stockDetail.notes = payload.items || [];
renderStockNotes(state.stockDetail.notes);
showToast("个股笔记已保存");
} catch (error) {
showToast(error.message);
}
}
async function saveReasonOverride(event) {
event.preventDefault();
if (!state.activeStock?.code) return;
const reason = document.querySelector("#reasonInput").value.trim();
try {
await apiRequest("/api/reasons", "POST", {
trade_date: elements.tradeDate.value,
code: state.activeStock.code,
reason,
});
state.activeStock.reason = reason;
for (const key of ["limits", "broken", "down_limits"]) {
const row = state.dashboard?.[key]?.find((item) => item.code === state.activeStock.code);
if (row) row.reason = reason;
}
setText("detailReason", reason);
renderDashboard();
showToast("事件逻辑已修订");
} catch (error) {
showToast(error.message);
}
}
function renderMoneyflow(flow) {
for (const [id, value] of [["flowNet", flow.net_million], ["flowLarge", flow.large_million], ["flowMedium", flow.medium_million], ["flowSmall", flow.small_million]]) {
const element = document.getElementById(id);
element.textContent = formatMoneyMillion(value);
element.className = changeClass(value);
}
}
function renderStockNotes(notes) {
renderNotesHistory(notes, document.querySelector("#stockNotes"), true);
}
function renderNotesHistory(notes, container, compact) {
container.innerHTML = notes.map((note) => `
<article class="note-row">
<div><time>${displayCompactDate(note.trade_date)}</time>${note.stock_name ? `<small>${escapeHtml(note.stock_name)}</small>` : ""}</div>
${!compact ? `<div class="note-block note-summary"><strong>盘面</strong><p>${escapeHtml(note.summary || "--")}</p></div>` : ""}
<div class="note-block"><strong>复盘</strong><p>${escapeHtml(note.content || "--")}</p></div>
<div class="note-block"><strong>计划</strong><p>${escapeHtml(note.plan || "--")}</p></div>
<button class="table-action down" type="button" data-note-delete="${number(note.id)}">删除</button>
</article>
`).join("") || emptyStateHtml("暂无复盘记录");
container.querySelectorAll("[data-note-delete]").forEach((button) => {
button.addEventListener("click", () => deleteNote(number(button.dataset.noteDelete), compact));
});
}
async function deleteNote(noteId, compact) {
try {
await apiRequest(`/api/notes/${noteId}`, "DELETE");
if (compact && state.activeStock) {
state.stockDetail.notes = state.stockDetail.notes.filter((note) => number(note.id) !== noteId);
renderStockNotes(state.stockDetail.notes);
} else {
await loadReviewWorkspace();
}
showToast("笔记已删除");
} catch (error) {
showToast(error.message);
}
}
/* PRESERVATION-SOURCE-END app.js:3029-3470 */
/* PRESERVATION-SOURCE-BEGIN app.js:7720-7968 */
async function loadAlerts(openDialog = false) {
try {
const query = new URLSearchParams({ status: state.alertFilter, as_of: todayString() });
const payload = await apiRequest(`/api/alerts?${query}`);
state.alerts = payload.items || [];
state.alertUnreadCount = number(payload.unread_count);
renderAlerts();
if (openDialog) openModalDialog(elements.alertsDialog);
} catch (error) {
if (openDialog) showToast(error.message || "提醒加载失败");
}
}
function openAlerts() {
toggleHeaderCommandMenu(false);
toggleAccountDropdown(false);
document.querySelector("#alertDate").value ||= todayString();
openModalDialog(elements.alertsDialog);
loadAlerts();
}
function openStockReminder() {
const stock = state.activeStock || {};
document.querySelector("#alertTitle").value = `${stock.name || stock.code || "个股"}观察提醒`;
document.querySelector("#alertCode").value = stock.code || "";
document.querySelector("#alertDate").value = todayString();
if (elements.stockDialog.open) elements.stockDialog.close();
openAlerts();
document.querySelector("#alertContent").focus();
}
function selectAlertFilter(filter) {
state.alertFilter = filter === "unread" ? "unread" : "all";
document.querySelectorAll("[data-alert-filter]").forEach((button) => {
button.classList.toggle("active", button.dataset.alertFilter === state.alertFilter);
});
loadAlerts();
}
async function saveAlert(event) {
event.preventDefault();
const button = event.currentTarget.querySelector("button[type='submit']");
button.disabled = true;
try {
const payload = await apiRequest("/api/alerts", "POST", {
title: document.querySelector("#alertTitle").value.trim(),
remind_date: document.querySelector("#alertDate").value,
code: document.querySelector("#alertCode").value.trim(),
content: document.querySelector("#alertContent").value.trim(),
});
event.currentTarget.reset();
document.querySelector("#alertDate").value = todayString();
state.alertFilter = "all";
state.alerts = payload.items || [];
state.alertUnreadCount = number(payload.unread_count);
renderAlerts();
showToast("提醒已保存");
} catch (error) {
showToast(error.message || "提醒保存失败");
} finally {
button.disabled = false;
}
}
async function markAllAlertsRead() {
try {
await apiRequest("/api/alerts/read-all", "POST", { as_of: todayString() });
await loadAlerts();
} catch (error) {
showToast(error.message || "提醒状态更新失败");
}
}
async function handleAlertAction(event) {
const button = event.target.closest("[data-alert-action]");
if (!button) return;
const id = number(button.dataset.alertId);
if (!id) return;
try {
if (button.dataset.alertAction === "delete") {
await apiRequest(`/api/alerts/${id}`, "DELETE");
} else {
await apiRequest(`/api/alerts/${id}/read`, "POST", {});
}
await loadAlerts();
} catch (error) {
showToast(error.message || "提醒操作失败");
}
}
function renderAlerts() {
const badge = document.querySelector("#alertBadge");
badge.hidden = state.alertUnreadCount <= 0;
badge.textContent = state.alertUnreadCount > 99 ? "99+" : String(state.alertUnreadCount);
document.querySelector("#alertButton").classList.toggle("has-alerts", state.alertUnreadCount > 0);
setText("alertListCount", `${state.alerts.length}`);
document.querySelectorAll("[data-alert-filter]").forEach((button) => {
button.classList.toggle("active", button.dataset.alertFilter === state.alertFilter);
});
document.querySelector("#markAllAlertsRead").disabled = state.alertUnreadCount <= 0;
const container = document.querySelector("#alertList");
container.innerHTML = state.alerts.map((item) => {
const upcoming = !item.due;
const kindLabel = item.kind === "manual" ? "自定提醒" : item.kind === "strategy_t5" ? "跟踪完成" : "策略反馈";
return `<article class="alert-item ${item.is_read ? "is-read" : "is-unread"} ${upcoming ? "is-upcoming" : ""}">
<div class="alert-item-icon"><i data-lucide="${upcoming ? "calendar-clock" : item.kind === "manual" ? "bell" : "chart-no-axes-combined"}"></i></div>
<div class="alert-item-copy">
<div><span>${escapeHtml(kindLabel)}</span><time>${displayCompactDate(item.available_date)}</time></div>
<strong>${escapeHtml(item.title)}</strong>
${item.content ? `<p>${escapeHtml(item.content)}</p>` : ""}
${item.code ? `<button class="stock-preview-trigger alert-stock-link" type="button" data-code="${escapeHtml(item.code)}">${escapeHtml(item.code)}</button>` : ""}
</div>
<div class="alert-item-actions">
${!item.is_read && !upcoming ? `<button class="icon-button" type="button" data-alert-action="read" data-alert-id="${number(item.id)}" title="标为已读" aria-label="标为已读"><i data-lucide="check"></i></button>` : ""}
<button class="icon-button" type="button" data-alert-action="delete" data-alert-id="${number(item.id)}" title="删除提醒" aria-label="删除提醒"><i data-lucide="trash-2"></i></button>
</div>
</article>`;
}).join("") || emptyStateHtml("暂无提醒");
bindStockRows(container);
refreshIcons();
}
async function openReviewAssistant() {
toggleHeaderCommandMenu(false);
toggleAccountDropdown(false);
openModalDialog(elements.assistantDialog);
updateAssistantControls();
if (!hasMemberAccess()) {
document.querySelector("#closeAssistantDialog").focus();
return;
}
try {
const payload = await apiRequest("/api/assistant/messages");
state.assistantMessages = payload.items || [];
renderAssistantMessages();
} catch (error) {
showToast(error.message || "对话记录加载失败");
}
document.querySelector("#assistantQuestion").focus();
}
function useAssistantPrompt(prompt) {
const input = document.querySelector("#assistantQuestion");
input.value = prompt;
input.focus();
}
async function sendAssistantQuestion(event) {
event.preventDefault();
if (state.assistantLoading) return;
const input = document.querySelector("#assistantQuestion");
const question = input.value.trim();
if (!question) return;
input.value = "";
state.assistantMessages.push({ role: "user", content: question, context_date: elements.tradeDate.value.replaceAll("-", "") });
state.assistantMessages.push({ role: "assistant", content: "", streaming: true, context_date: elements.tradeDate.value.replaceAll("-", "") });
state.assistantLoading = true;
state.assistantController = new AbortController();
updateAssistantControls();
renderAssistantMessages();
try {
await streamAssistantRequest(question, state.assistantController.signal, (chunk) => {
const message = state.assistantMessages.at(-1);
if (message?.role === "assistant") message.content += chunk;
scheduleAssistantRender();
});
const message = state.assistantMessages.at(-1);
if (message) message.streaming = false;
setStatus("复盘助手回答完成");
} catch (error) {
const message = state.assistantMessages.at(-1);
if (message?.role === "assistant") {
message.streaming = false;
message.error = true;
if (!message.content) message.content = error.name === "AbortError" ? "已停止生成。" : error.message || "回答失败,请稍后重试。";
}
if (error.name !== "AbortError") showToast(error.message || "复盘助手回答失败");
} finally {
state.assistantLoading = false;
state.assistantController = null;
updateAssistantControls();
renderAssistantMessages();
input.focus();
}
}
async function streamAssistantRequest(question, signal, onDelta) {
await window.XiaobaiAPI.streamNdjson("/api/assistant/chat", {
method: "POST",
body: { question, trade_date: elements.tradeDate.value },
signal,
errorMessage: "复盘助手暂不可用",
onEvent: (event) => {
if (event.type === "delta") onDelta(String(event.content || ""));
},
});
}
function stopAssistantResponse() {
state.assistantController?.abort();
}
async function clearAssistantConversation() {
if (state.assistantLoading || !state.assistantMessages.length) return;
if (!window.confirm("确定清空复盘助手的对话记录吗?")) return;
try {
await apiRequest("/api/assistant/messages", "DELETE");
state.assistantMessages = [];
renderAssistantMessages();
} catch (error) {
showToast(error.message || "对话记录清空失败");
}
}
function scheduleAssistantRender() {
if (assistantRenderFrame) return;
assistantRenderFrame = requestAnimationFrame(() => {
assistantRenderFrame = 0;
renderAssistantMessages();
});
}
function renderAssistantMessages() {
const container = document.querySelector("#assistantMessages");
container.innerHTML = state.assistantMessages.map((message) => `
<article class="assistant-message ${message.role} ${message.error ? "is-error" : ""}">
<div class="assistant-message-label">${message.role === "user" ? "我" : "复盘助手"}${message.context_date ? `<time>${displayCompactDate(message.context_date)}</time>` : ""}</div>
<div class="assistant-message-content">${message.role === "assistant" ? (message.content ? formatMentorAnswer(message.content) : '<span class="assistant-thinking">正在整理复盘数据</span>') : escapeHtml(message.content)}</div>
${message.streaming ? '<span class="assistant-stream-caret" aria-hidden="true"></span>' : ""}
</article>
`).join("") || emptyStateHtml("可以从市场、策略或自己的交易记录开始复盘");
updateAssistantControls();
requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
}
function updateAssistantControls() {
const unlocked = hasMemberAccess();
elements.assistantDialog.classList.toggle("member-locked", !unlocked);
document.querySelector("#assistantMemberGate").hidden = unlocked;
document.querySelector("#assistantMemberContent").setAttribute("aria-disabled", String(!unlocked));
document.querySelector("#assistantQuestion").disabled = !unlocked || state.assistantLoading;
document.querySelector("#sendAssistant").disabled = !unlocked || state.assistantLoading;
document.querySelector("#stopAssistant").hidden = !unlocked || !state.assistantLoading;
document.querySelector("#clearAssistantMessages").disabled = !unlocked || state.assistantLoading || !state.assistantMessages.length;
document.querySelectorAll("[data-assistant-prompt]").forEach((button) => {
button.disabled = !unlocked || state.assistantLoading;
});
}
/* PRESERVATION-SOURCE-END app.js:7720-7968 */
+173
View File
@@ -0,0 +1,173 @@
window.XiaobaiPageModules.register("rotation", ["rotationView"], {
enter: ["loadRotation"],
});
/* PRESERVATION-SOURCE-BEGIN app.js:1958-2124 */
async function loadRotationHistory(force = false) {
if (!state.dashboard || state.rotationLoading) return;
const key = `${elements.tradeDate.value}:9`;
if (!force && state.rotationHistoryKey === key && state.rotationHistory) {
renderRotationHistory();
return;
}
state.rotationLoading = true;
const container = document.querySelector("#rotationHistory");
renderEmptyState(container, "正在读取轮动历史");
try {
const query = new URLSearchParams({
trade_date: elements.tradeDate.value,
});
state.rotationHistory = await apiRequest(`/api/rotation/history?${query}`);
state.rotationHistoryKey = key;
renderRotationHistory();
} catch (error) {
renderEmptyState(container, error.message || "轮动历史加载失败");
showToast(error.message || "轮动历史加载失败");
} finally {
state.rotationLoading = false;
}
}
function renderRotationHistory() {
const rows = state.rotationHistory?.rows || [];
const selected = state.rotationSelectedSector;
const container = document.querySelector("#rotationHistory");
const tracker = document.querySelector("#rotationTracker");
if (!rows.length) {
renderEmptyState(container, "尚无连续交易日的板块数据");
setText("rotationHistoryRange", "暂无轮动历史");
tracker.hidden = true;
return;
}
const chronological = [...rows]
.sort((left, right) => String(left.trade_date).localeCompare(String(right.trade_date)))
.slice(-9);
const displayRows = state.rotationOrder === "latest" ? [...chronological].reverse() : chronological;
document.querySelectorAll("[data-rotation-order]").forEach((button) => {
button.classList.toggle("active", button.dataset.rotationOrder === state.rotationOrder);
});
setText(
"rotationHistoryRange",
`最近 ${chronological.length} 个交易日 · ${displayCompactDate(chronological[0].trade_date)}${displayCompactDate(chronological[chronological.length - 1].trade_date)} · ${state.rotationOrder === "latest" ? "由近到远,左侧为最新交易日" : "由远到近,右侧为最新交易日"}`,
);
setText("rotationSelectionHint", selected ? `已联动高亮 ${selected}` : "点击任意板块追踪其连续性");
if (selected) {
const sequence = displayRows.map((day) => {
const sector = (day.sectors || []).find((item) => item.name === selected);
return { tradeDate: day.trade_date, sector };
});
const appearances = sequence.filter((item) => item.sector);
const bestRank = appearances.length ? Math.min(...appearances.map((item) => number(item.sector.rank))) : 0;
tracker.hidden = false;
const continuity = appearances.length >= 3 ? "主线候选" : appearances.length === 1 ? "单日异动,持续性待验证" : "间断活跃";
tracker.innerHTML = `
<div class="rotation-tracker-copy"><strong>${escapeHtml(selected)}</strong><span>近 9 日在榜 <b>${appearances.length}</b> 天 · 最高排名 <b>#${bestRank || "--"}</b> · ${continuity}</span></div>
<div class="rotation-tracker-spark" aria-label="${escapeHtml(selected)}九日强度轨迹">
${sequence.map((item) => item.sector
? `<span style="--spark-height:${Math.max(18, clamp(number(item.sector.strength), 0, 100))}%" title="${escapeHtml(displayCompactDate(item.tradeDate))} · 第 ${number(item.sector.rank)} 名 · 强度 ${formatNumber(item.sector.strength, 0)}"><i></i><small>#${number(item.sector.rank)}</small></span>`
: `<span class="missing" title="${escapeHtml(displayCompactDate(item.tradeDate))} · 未上榜"><i></i><small>--</small></span>`).join("")}
</div>
<button class="rotation-track-cancel" type="button">取消追踪</button>`;
tracker.querySelector(".rotation-track-cancel").addEventListener("click", () => {
state.rotationSelectedSector = "";
state.rotationSelectedDate = "";
renderRotationHistory();
loadRotationMembers("");
});
} else {
tracker.hidden = true;
tracker.innerHTML = "";
}
container.classList.toggle("tracking", Boolean(selected));
const latestTradeDate = chronological[chronological.length - 1].trade_date;
container.innerHTML = displayRows.map((day) => {
const hasSelected = selected && (day.sectors || []).some((sector) => sector.name === selected);
return `
<article class="rotation-day ${selected ? "has-selection" : ""} ${hasSelected ? "selected-day" : ""} ${day.trade_date === latestTradeDate ? "latest-day" : ""}">
<header><time>${escapeHtml(displayCompactDate(day.trade_date).slice(5))}</time><span>${(day.sectors || []).length} 个热点</span></header>
<div class="rotation-day-sectors">${(day.sectors || []).map((sector) => {
const strength = clamp(number(sector.strength), 0, 100);
const heatClass = strength >= 90 ? "heat-strong" : strength >= 70 ? "heat-warm" : "heat-mild";
return `
<button type="button" class="rotation-sector-chip ${heatClass} ${selected === sector.name ? "selected" : ""}" data-rotation-sector="${escapeHtml(sector.name)}" data-rotation-date="${escapeHtml(day.trade_date)}">
<span class="rotation-rank rank-${Math.min(number(sector.rank), 4)}">${number(sector.rank)}</span><strong>${escapeHtml(sector.name)}</strong><small><b>${number(sector.count)}</b> 家 · ${formatNumber(sector.strength, 0)}</small>
<span class="rotation-cell-tooltip">${escapeHtml(displayCompactDate(day.trade_date).slice(5))} · 第 ${number(sector.rank)} 名 · 涨停 ${number(sector.count)} 家 · 强度 ${formatNumber(sector.strength, 0)}</span>
</button>`;
}).join("")}</div>
</article>`;
}).join("");
container.querySelectorAll("[data-rotation-sector]").forEach((button) => {
button.addEventListener("click", () => {
const clickedSector = button.dataset.rotationSector;
const clickedDate = button.dataset.rotationDate;
const isSameSelection = clickedSector === state.rotationSelectedSector
&& clickedDate === state.rotationSelectedDate;
state.rotationSelectedSector = isSameSelection ? "" : clickedSector;
state.rotationSelectedDate = isSameSelection ? "" : clickedDate;
renderRotationHistory();
loadRotationMembers(state.rotationSelectedSector);
});
});
}
async function loadRotationMembers(sector, force = false) {
if (!sector) {
state.rotationMembers = null;
state.rotationMembersKey = "";
renderRotationMembers();
return;
}
const memberDate = state.rotationSelectedDate || elements.tradeDate.value;
const key = `${memberDate}:${sector}`;
if (!force && state.rotationMembersKey === key && state.rotationMembers) {
renderRotationMembers();
return;
}
state.rotationMembersLoading = true;
renderRotationMembers();
try {
const query = new URLSearchParams({ trade_date: memberDate, sector });
state.rotationMembers = await apiRequest(`/api/rotation/members?${query}`);
state.rotationMembersKey = key;
} catch (error) {
state.rotationMembers = { error: error.message || "成分股加载失败", rows: [] };
state.rotationMembersKey = key;
} finally {
state.rotationMembersLoading = false;
renderRotationMembers();
}
}
function renderRotationMembers() {
const body = document.querySelector("#rotationTableBody");
const empty = document.querySelector("#rotationMembersEmpty");
if (state.rotationMembersLoading) {
body.innerHTML = "";
empty.textContent = `正在核验${state.rotationSelectedSector}成分股`;
empty.hidden = false;
return;
}
const payload = state.rotationMembers;
const rows = payload?.rows || [];
if (!state.rotationSelectedSector || !payload || payload.error || !rows.length) {
body.innerHTML = "";
empty.textContent = payload?.error || (state.rotationSelectedSector ? "该板块暂无可用成分行情" : "点击上方任意板块查看成分股");
empty.hidden = false;
setText("rotationDetailTitle", "板块成分股");
setText("rotationDetailMeta", state.rotationSelectedSector || "--");
return;
}
empty.hidden = true;
setText("rotationDetailTitle", `${payload.meta?.sector_name || state.rotationSelectedSector}成分股`);
setText("rotationDetailMeta", `${displayCompactDate(payload.meta?.trade_date)} · ${number(payload.meta?.quoted_count)} / ${number(payload.meta?.member_count)}`);
body.innerHTML = rows.map((row, index) => `
<tr data-code="${escapeHtml(row.code)}"><td class="number num muted">${index + 1}</td><td class="stock-code">${escapeHtml(row.code)}</td><td class="stock-name">${escapeHtml(row.name)}</td>
<td class="number num ${row.quoted ? changeClass(row.change) : "muted"}" data-sort-value="${row.quoted ? number(row.change) : -999}">${row.quoted ? signed(row.change) : ""}</td>
<td class="number num">${row.quoted ? formatNumber(row.open, 2) : ""}</td><td class="number num">${row.quoted ? formatNumber(row.close, 2) : ""}</td>
<td class="number num" data-sort-value="${number(row.amount_billion)}">${row.quoted ? formatNumber(row.amount_billion, 2) : ""}</td><td>${row.quoted ? "正常交易" : "当日无行情"}</td></tr>
`).join("");
animateRows(body);
bindStockRows(body);
}
/* PRESERVATION-SOURCE-END app.js:1958-2124 */
File diff suppressed because it is too large Load Diff
+316
View File
@@ -0,0 +1,316 @@
window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], {
enter: ["loadSentiment"],
});
/* PRESERVATION-SOURCE-BEGIN app.js:1207-1516 */
async function loadSentimentHistory(force = false) {
if (!state.dashboard || state.sentimentLoading) return;
const key = `${elements.tradeDate.value}:${state.sentimentRange}`;
if (!force && state.sentimentHistoryKey === key && state.sentimentHistory) {
renderSentimentHistory();
return;
}
state.sentimentLoading = true;
const notice = document.querySelector("#sentimentHistoryNotice");
notice.hidden = true;
try {
const query = new URLSearchParams({
trade_date: elements.tradeDate.value,
limit: String(state.sentimentRange),
});
state.sentimentHistory = await apiRequest(`/api/sentiment/history?${query}`);
state.sentimentHistoryKey = key;
renderSentimentHistory();
} catch (error) {
notice.textContent = error.message || "情绪周期数据加载失败";
notice.hidden = false;
showToast(notice.textContent);
} finally {
state.sentimentLoading = false;
}
}
function renderSentimentHistory() {
const payload = state.sentimentHistory;
if (!payload) return;
const rows = payload.rows || [];
const latest = rows[rows.length - 1];
const body = document.querySelector("#sentimentHistoryBody");
const empty = document.querySelector("#sentimentHistoryEmpty");
empty.hidden = rows.length > 0;
body.innerHTML = [...rows].reverse().map((row) => {
return `
<tr class="${row.trade_date === latest?.trade_date ? "latest-row" : ""}">
<td class="sentiment-date-cell">${escapeHtml(displayCompactDate(row.trade_date))}</td>
<td class="number sentiment-score-cell ${sentimentScoreClass(row.score)}">${number(row.score)}</td>
<td><span class="sentiment-phase-badge ${sentimentPhaseClass(row.phase)}">${escapeHtml(row.phase)}</span></td>
<td><span class="sentiment-direction ${trendClass(row.direction)}">${escapeHtml(row.direction)}</span></td>
<td class="number">${number(row.limit_up_count)}</td>
<td class="number">${number(row.first_board_count)}</td>
<td class="number">${number(row.second_board_count)}</td>
<td class="number">${number(row.three_plus_count)}</td>
<td class="number">${number(row.max_height)}板</td>
<td class="number">${number(row.broken_count)}</td>
<td class="number">${number(row.limit_down_count)}</td>
<td class="number">${number(row.previous_limit_count)}</td>
<td class="number">${number(row.previous_positive_count)}</td>
<td class="number">${formatNumber(row.previous_positive_rate, 1)}%</td>
</tr>
`;
}).join("");
if (!latest) {
setText("sentimentHistoryDateRange", "暂无历史数据");
return;
}
setText(
"sentimentHistoryDateRange",
`${displayCompactDate(rows[0].trade_date)}${displayCompactDate(latest.trade_date)}`,
);
setText("sentimentCycleScore", number(latest.score));
setText("sentimentCycleLabel", latest.label);
setText("sentimentCycleDate", displayCompactDate(latest.trade_date));
setText("sentimentCyclePhase", latest.phase);
setText("sentimentCycleDirection", latest.direction);
const dayChange = number(latest.day_change);
const confidence = sentimentPhaseConfidence(latest);
setText("sentimentPhaseConfidence", `置信度 ${confidence}%`);
setText("sentimentDayChange", `${dayChange > 0 ? "+" : ""}${formatNumber(dayChange, 1)}`);
setText("sentimentSealRate", `${formatNumber(latest.seal_rate, 1)}%`);
setText("sentimentLimitUp", number(latest.limit_up_count));
setText("sentimentBroken", number(latest.broken_count));
setText("sentimentPhaseAdvice", sentimentPhaseAdvice(latest.phase));
setText("sentimentCurrentTag", `当前 ${number(latest.score)} · ${latest.phase}`);
setText("sentimentComponentSummary", `五维加权 → 温度 ${number(latest.score)}`);
setText("sentimentPeriodNote", `${state.sentimentRange} 个交易日,当前展示 ${rows.length}`);
const changeElement = document.querySelector("#sentimentDayChange");
changeElement.className = changeClass(dayChange);
setText("sentimentPreviousPositive", `${number(latest.previous_positive_count)} / ${number(latest.previous_limit_count)}`);
setText("sentimentPreviousAverage", `红盘率 ${formatNumber(latest.previous_positive_rate, 1)}% · 平均 ${signed(latest.average_previous_change)}%`);
setText("sentimentHistoryDays", `${number(payload.available_days)} 个交易日`);
setText("sentimentNormalization", `${latest.normalization} · 当前展示 ${rows.length}`);
const marker = document.querySelector("#sentimentCycleScoreMarker");
marker.className = `sentiment-current-phase-badge ${sentimentPhaseClass(latest.phase)}`;
document.querySelector("#sentimentComponentList").innerHTML = Object.values(latest.components || {}).map((item) => `
<article class="sentiment-component-item">
<div class="sentiment-component-main">
<strong>${escapeHtml(item.label)}</strong>
<div class="sentiment-component-track" aria-hidden="true"><i data-component-score="${clamp(item.score, 0, 100)}" style="width:0%"></i></div>
<b>${formatNumber(item.score, 1)} <em>× ${number(item.weight)}%</em></b>
</div>
<small>${escapeHtml(item.summary)}</small>
</article>
`).join("");
requestAnimationFrame(() => {
animateSentimentComponents();
animateSentimentTrendChart(rows);
bindSentimentChartTooltip(rows);
});
animateRows(body);
}
function animateSentimentComponents() {
document.querySelectorAll("#sentimentComponentList [data-component-score]").forEach((bar, index) => {
const width = `${number(bar.dataset.componentScore)}%`;
if (!motionEnabled()) {
bar.style.width = width;
return;
}
setTimeout(() => { bar.style.width = width; }, index * 70);
});
}
function animateSentimentTrendChart(rows) {
if (sentimentChartAnimationFrame) cancelAnimationFrame(sentimentChartAnimationFrame);
if (!motionEnabled()) {
drawSentimentTrendChart(rows, 1);
return;
}
const startedAt = performance.now();
const duration = 780;
const frame = (now) => {
const rawProgress = Math.min(1, (now - startedAt) / duration);
const progress = 1 - (1 - rawProgress) ** 3;
drawSentimentTrendChart(rows, progress);
if (rawProgress < 1) sentimentChartAnimationFrame = requestAnimationFrame(frame);
else sentimentChartAnimationFrame = null;
};
sentimentChartAnimationFrame = requestAnimationFrame(frame);
}
function drawSentimentTrendChart(rows, progress = 1) {
const canvas = document.querySelector("#sentimentTrendChart");
if (!canvas || !rows.length || state.activeView !== "sentimentCycleView") return;
const rect = canvas.getBoundingClientRect();
if (!rect.width) return;
const width = Math.max(320, rect.width);
const height = Math.max(220, rect.height);
const ratio = window.devicePixelRatio || 1;
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
const context = canvas.getContext("2d");
const palette = currentChartPalette();
context.setTransform(ratio, 0, 0, ratio, 0, 0);
context.clearRect(0, 0, width, height);
context.fillStyle = palette.background;
context.fillRect(0, 0, width, height);
const padding = { top: 18, right: 18, bottom: 34, left: 42 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
const x = (index) => padding.left + (rows.length === 1 ? chartWidth / 2 : index / (rows.length - 1) * chartWidth);
const y = (score) => padding.top + (100 - clamp(score, 0, 100)) / 100 * chartHeight;
context.font = '10px "Microsoft YaHei UI", sans-serif';
context.textAlign = "right";
context.textBaseline = "middle";
for (let score = 0; score <= 100; score += 20) {
const lineY = y(score);
context.strokeStyle = score === 40 || score === 80 ? palette.zero : palette.grid;
context.lineWidth = 1;
context.beginPath();
context.moveTo(padding.left, lineY);
context.lineTo(width - padding.right, lineY);
context.stroke();
context.fillStyle = palette.axis;
context.fillText(String(score), padding.left - 8, lineY);
}
context.save();
context.beginPath();
context.rect(padding.left - 6, padding.top - 8, (chartWidth + 12) * clamp(progress, 0, 1), chartHeight + 18);
context.clip();
const finalPhase = rows[rows.length - 1]?.phase;
let phaseStart = rows.length - 1;
while (phaseStart > 0 && rows[phaseStart - 1]?.phase === finalPhase) phaseStart -= 1;
if (["退潮", "冰点"].includes(finalPhase)) {
const startX = phaseStart === 0 ? padding.left : (x(phaseStart - 1) + x(phaseStart)) / 2;
context.fillStyle = palette.alertArea;
context.fillRect(startX, padding.top, width - padding.right - startX, chartHeight);
context.fillStyle = palette.up;
context.font = '10px "Microsoft YaHei UI", sans-serif';
context.textAlign = "center";
context.textBaseline = "top";
context.fillText(finalPhase, (startX + width - padding.right) / 2, padding.top + 4);
}
const movingAverage = rows.map((_row, index) => {
const start = Math.max(0, index - 4);
const sample = rows.slice(start, index + 1);
return sample.reduce((sum, item) => sum + number(item.score), 0) / sample.length;
});
context.beginPath();
movingAverage.forEach((score, index) => {
if (index === 0) context.moveTo(x(index), y(score));
else context.lineTo(x(index), y(score));
});
context.strokeStyle = palette.movingAverage;
context.lineWidth = 1.5;
context.setLineDash([5, 4]);
context.stroke();
context.setLineDash([]);
context.beginPath();
rows.forEach((row, index) => {
const pointX = x(index);
const pointY = y(row.score);
if (index === 0) context.moveTo(pointX, pointY);
else context.lineTo(pointX, pointY);
});
context.lineTo(x(rows.length - 1), padding.top + chartHeight);
context.lineTo(x(0), padding.top + chartHeight);
context.closePath();
context.fillStyle = palette.area;
context.fill();
context.beginPath();
rows.forEach((row, index) => {
const pointX = x(index);
const pointY = y(row.score);
if (index === 0) context.moveTo(pointX, pointY);
else context.lineTo(pointX, pointY);
});
context.strokeStyle = palette.line;
context.lineWidth = 2.5;
context.lineJoin = "round";
context.lineCap = "round";
context.stroke();
rows.forEach((row, index) => {
context.beginPath();
context.arc(x(index), y(row.score), index === rows.length - 1 ? 4.5 : 3, 0, Math.PI * 2);
context.fillStyle = ["退潮", "冰点"].includes(row.phase) ? palette.up : row.phase === "修复" ? palette.repair : palette.line;
context.fill();
context.strokeStyle = palette.background;
context.lineWidth = 1.5;
context.stroke();
});
context.restore();
const labelStep = Math.max(1, Math.ceil(rows.length / 6));
context.textAlign = "center";
context.textBaseline = "top";
context.fillStyle = palette.axis;
rows.forEach((row, index) => {
if (index % labelStep !== 0 && index !== rows.length - 1) return;
const dateText = displayCompactDate(row.trade_date).slice(5);
context.fillText(dateText, x(index), height - padding.bottom + 10);
});
}
function bindSentimentChartTooltip(rows) {
const canvas = document.querySelector("#sentimentTrendChart");
const tooltip = document.querySelector("#sentimentChartTooltip");
if (!canvas || !tooltip || !rows.length) return;
canvas.onmousemove = (event) => {
const rect = canvas.getBoundingClientRect();
const padding = { left: 42, right: 18 };
const chartWidth = Math.max(1, rect.width - padding.left - padding.right);
const relativeX = clamp(event.clientX - rect.left - padding.left, 0, chartWidth);
const index = rows.length === 1 ? 0 : Math.round(relativeX / chartWidth * (rows.length - 1));
const row = rows[index];
tooltip.innerHTML = `${escapeHtml(displayCompactDate(row.trade_date))} · 温度 <b>${number(row.score)}</b> · ${escapeHtml(row.phase)}`;
tooltip.hidden = false;
const targetLeft = padding.left + (rows.length === 1 ? chartWidth / 2 : index / (rows.length - 1) * chartWidth);
tooltip.style.left = `${clamp(targetLeft + 10, 8, rect.width - tooltip.offsetWidth - 8)}px`;
tooltip.style.top = `${clamp(event.clientY - rect.top - 34, 8, rect.height - 34)}px`;
};
canvas.onmouseleave = () => { tooltip.hidden = true; };
}
function sentimentScoreClass(score) {
const value = number(score);
return value >= 60 ? "score-strong" : value < 40 ? "score-weak" : "score-neutral";
}
function sentimentPhaseClass(phase) {
return {
"冰点": "phase-ice",
"修复": "phase-repair",
"发酵": "phase-fermentation",
"高潮": "phase-climax",
"分化": "phase-divergence",
"退潮": "phase-retreat",
}[phase] || "phase-divergence";
}
function sentimentPhaseConfidence(row) {
const explicit = number(row?.confidence || row?.phase_confidence);
if (explicit > 0) return Math.round(clamp(explicit, 0, 100));
const historyEvidence = Math.min(12, number(row?.history_days) * 0.6);
const movementEvidence = Math.min(18, Math.abs(number(row?.day_change)) * 0.8);
return Math.round(clamp(62 + historyEvidence + movementEvidence, 60, 92));
}
function sentimentPhaseAdvice(phase) {
return {
"冰点": "情绪处于极弱区,先观察风险释放,允许没有候选结果。",
"修复": "风险开始收敛,关注率先转强的核心,小仓验证修复强度。",
"发酵": "主线与梯队正在形成,优先跟随核心,避免偏离主线。",
"高潮": "情绪与一致性已处高位,聚焦核心并主动降低后排暴露。",
"分化": "强弱开始分层,关注承接与回流,淘汰失去辨识度的方向。",
"退潮": "情绪指标继续走弱。",
}[phase] || "市场结构尚未形成清晰阶段,保持观察并等待确认。";
}
/* PRESERVATION-SOURCE-END app.js:1207-1516 */
+108
View File
@@ -0,0 +1,108 @@
window.XiaobaiPageModules.register("themes", ["themeLibraryView"], {
enter: ["loadThemes"],
});
/* PRESERVATION-SOURCE-BEGIN app.js:2482-2583 */
async function loadThemeLibrary(force = false) {
if (state.themeLoading) return;
state.themeLoading = true;
const button = document.querySelector("#themeRefreshButton");
button.disabled = true;
setText("themeDateLabel", "正在整理题材库");
try {
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
if (force) query.set("force", "1");
state.themeLibrary = await apiRequest(`/api/themes?${query}`);
renderThemeLibrary();
const available = (state.themeLibrary.items || []).some((item) => item.code === state.selectedThemeCode);
if (!available) state.selectedThemeCode = "";
const initialCode = state.selectedThemeCode || state.themeLibrary.items?.[0]?.code || "";
if (initialCode) await selectTheme(initialCode, true);
} catch (error) {
setText("themeDateLabel", error.message || "题材数据暂不可用");
renderEmptyState("themeDirectory", error.message || "题材数据加载失败");
showToast(error.message || "题材数据加载失败");
} finally {
state.themeLoading = false;
button.disabled = false;
}
}
function renderThemeLibrary() {
const payload = state.themeLibrary;
if (!payload) return;
const summary = payload.summary || {};
setText("themeDateLabel", `${payload.meta?.carried_forward ? "最近有效行情" : "行情日期"} ${payload.meta?.trade_date || "--"}`);
document.querySelector("#themeSummary").innerHTML = [
["收录题材", number(summary.theme_count), "个", ""],
["当日上涨", number(summary.up_count), "个", "up"],
["当日下跌", number(summary.down_count), "个", "down"],
["人气题材", number(summary.hot_count), "个", "warning"],
].map(([label, value, unit, tone]) => `<div><span>${label}</span><strong class="${tone}">${value}<small>${unit}</small></strong></div>`).join("");
renderThemeDirectory();
}
function renderThemeDirectory() {
let items = [...(state.themeLibrary?.items || [])];
if (state.themeQuery) {
items = items.filter((item) => `${item.code} ${item.name}`.toLocaleLowerCase("zh-CN").includes(state.themeQuery));
}
setText("themeResultCount", `${items.length}`);
document.querySelector("#themeDirectory").innerHTML = items.map((item, index) => {
const active = item.code === state.selectedThemeCode;
return `
<button type="button" class="theme-directory-item-v2 ${active ? "active" : ""}" data-theme-code="${escapeHtml(item.code)}" aria-pressed="${active}">
<span class="theme-rank-v2">${index + 1}</span>
<span class="theme-directory-copy-v2"><strong class="market-preview-trigger" data-market-preview-type="theme" data-market-preview-id="${escapeHtml(item.code)}" title="悬停预览题材行情">${escapeHtml(item.name)}</strong><small>${number(item.member_count)} 只成分${item.hot_rank ? ` · 人气第 ${number(item.hot_rank)}` : ""}</small></span>
<b class="${changeClass(item.change)}">${item.has_quote ? `${signed(item.change)}%` : "--"}</b>
</button>`;
}).join("") || emptyStateHtml("没有匹配的题材");
}
async function selectTheme(code, keepSelection = false) {
if (!code) return;
state.selectedThemeCode = code;
if (!keepSelection) renderThemeDirectory();
document.querySelector("#themeDetailEmpty").hidden = false;
document.querySelector("#themeDetailContent").hidden = true;
setText("themeDetailEmpty", "正在读取题材详情");
try {
const query = new URLSearchParams({ code, trade_date: elements.tradeDate.value });
state.themeDetail = await apiRequest(`/api/themes/detail?${query}`);
renderThemeDetail();
} catch (error) {
setText("themeDetailEmpty", error.message || "题材详情加载失败");
showToast(error.message || "题材详情加载失败");
}
}
function renderThemeDetail() {
const payload = state.themeDetail;
if (!payload) return;
const theme = payload.theme || {};
const summary = payload.summary || {};
document.querySelector("#themeDetailEmpty").hidden = true;
document.querySelector("#themeDetailContent").hidden = false;
setText("themeDetailName", theme.name || "--");
setText("themeDetailCode", `${theme.code || "--"} · ${payload.meta?.trade_date || "--"}`);
setText("themeDetailChange", `${signed(theme.change)}%`);
document.querySelector("#themeDetailChange").className = changeClass(theme.change);
document.querySelector("#themeDetailMetrics").innerHTML = [
["成分股", `${number(summary.member_count)}`, ""],
["有行情", `${number(summary.quoted_count)}`, ""],
["上涨", `${number(summary.up_count)}`, "up"],
["下跌", `${number(summary.down_count)}`, "down"],
["换手率", `${formatNumber(theme.turnover_rate, 2)}%`, ""],
].map(([label, value, tone]) => `<div><span>${label}</span><strong class="${tone}">${value}</strong></div>`).join("");
setText("themeMemberCount", `有行情 ${number(summary.quoted_count)} / ${number(summary.member_count)}`);
const body = document.querySelector("#themeMemberTableBody");
body.innerHTML = (payload.members || []).map((row, index) => `
<tr data-code="${escapeHtml(row.code)}"><td class="row-number num muted">${index + 1}</td>
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
<td class="number num ${changeClass(row.change)}">${row.has_quote ? signed(row.change) : ""}</td>
<td class="number num">${row.has_quote ? formatNumber(row.price, 2) : ""}</td><td class="number num">${row.has_quote ? formatNumber(row.amount_billion, 2) : ""}</td></tr>`).join("");
bindStockRows(body);
renderThemeDirectory();
}
/* PRESERVATION-SOURCE-END app.js:2482-2583 */
+143
View File
@@ -0,0 +1,143 @@
/* PRESERVATION-SOURCE-BEGIN app.js:8905-9051 */
function exportStocks() {
exportRows("涨停池", getVisibleStocks(), [
["股票代码", "code"], ["股票名称", "name"], ["连板", "streak"], ["涨幅%", "change"],
["价格", "price"], ["所属板块", "sector"], ["涨停原因", "reason"], ["首封", "first_time"],
["最后封板", "last_time"], ["开板次数", "open_times"], ["换手率%", "turnover_rate"],
["成交额亿", "amount_billion"], ["封单额万", "seal_amount_million"],
]);
}
function exportBroken() {
exportRows("炸板池", getVisibleBrokenRows(), [
["股票代码", "code"], ["股票名称", "name"], ["现价涨幅%", "change"], ["距涨停%", "limitGap"],
["价格", "price"], ["所属板块", "sector"], ["首次触板", "first_time"], ["开板次数", "open_times"],
["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"],
]);
}
function exportDown() {
exportRows("跌停板", getVisibleDownRows(), [
["股票代码", "code"], ["股票名称", "name"], ["跌幅%", "change"], ["价格", "price"],
["所属板块", "sector"], ["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"],
]);
}
function exportYesterday() {
exportRows("昨日涨停", getVisibleYesterdayRows(), [
["股票代码", "code"], ["股票名称", "name"], ["昨日高度", "prior_streak"],
["今日涨幅%", "current_change"], ["今日结果", "outcome"], ["当前高度", "current_streak"],
["所属板块", "sector"],
]);
}
function exportLadder() {
const rows = (state.dashboard?.ladders || []).flatMap((group) => (group.stocks || []).map((stock) => ({
level: group.label || group.level,
...stock,
})));
exportRows("市场天梯", rows, [
["梯队", "level"], ["股票代码", "code"], ["股票名称", "name"], ["所属板块", "sector"],
["封板时间", "first_time"], ["开板次数", "open_times"], ["封单额万", "seal_amount_million"], ["成交额亿", "amount_billion"],
]);
}
function exportRotation() {
const sectorMap = new Map((state.dashboard?.sectors || []).map((sector) => [sector.name, sector]));
const rows = (state.dashboard?.sector_rotation || []).map((row) => ({
...row,
average_change: sectorMap.get(row.name)?.change ?? 0,
}));
exportRows("板块轮动", rows, [
["排名", "rank"], ["板块", "name"], ["趋势", "trend"], ["今日涨停", "count"],
["昨日涨停", "previous_count"], ["变化", "delta"], ["强度", "strength"],
["最高板", "max_streak"], ["平均涨幅%", "average_change"],
["领涨股", "leader"], ["涨停股成交额亿", "amount_billion"],
]);
}
function exportSentimentHistory() {
const rows = state.sentimentHistory?.rows || [];
if (!rows.length) {
showToast("暂无可导出的情绪周期数据");
return;
}
const exportRowsData = rows.map((row) => ({
...row,
breadth_score: row.components?.breadth?.score,
limit_ecology_score: row.components?.limit_ecology?.score,
profit_effect_score: row.components?.profit_effect?.score,
ladder_structure_score: row.components?.ladder_structure?.score,
liquidity_score: row.components?.liquidity?.score,
}));
exportRows("情绪周期", exportRowsData, [
["交易日", "trade_date"], ["情绪温度", "score"], ["周期阶段", "phase"], ["方向", "direction"],
["涨停", "limit_up_count"], ["首板", "first_board_count"], ["二板", "second_board_count"],
["三板以上", "three_plus_count"], ["连板高度", "max_height"], ["炸板", "broken_count"],
["跌停", "limit_down_count"], ["昨日涨停", "previous_limit_count"],
["昨日涨停红盘", "previous_positive_count"], ["昨日涨停红盘率%", "previous_positive_rate"],
["市场宽度", "breadth_score"], ["涨停生态", "limit_ecology_score"],
["赚钱效应", "profit_effect_score"], ["连板结构", "ladder_structure_score"],
["成交活跃度", "liquidity_score"],
]);
}
function exportDragonTiger() {
const rows = (state.dragonTiger?.traders || []).flatMap((trader) => (
(trader.operations || []).map((operation) => ({
trader_name: trader.name,
identity_type: dragonIdentityLabel(trader.identity_type),
...operation,
}))
));
exportRows("游资龙虎榜", rows, [
["游资或席位", "trader_name"], ["身份", "identity_type"], ["股票代码", "code"],
["股票名称", "name"], ["方向", "direction"], ["涨幅%", "change"],
["买入百万元", "buy_million"], ["卖出百万元", "sell_million"], ["净额百万元", "net_buy_million"],
["关联席位", "seat_name"], ["上榜原因", "reason"],
]);
}
function exportHotMoneyProfiles() {
const rows = state.hotMoneyProfiles?.profiles || [];
if (!rows.length) {
showToast("暂无可导出的游资档案");
return;
}
downloadCsv(
`游资档案-${todayString()}.csv`,
["游资名称", "简介", "关联营业部", "席位数量"],
rows.map((profile) => [
profile.name,
profile.description,
(profile.organizations || []).join(""),
number(profile.organization_count),
]),
);
}
function exportRows(label, rows, columns) {
const headers = columns.map(([header]) => header);
const data = rows.map((row) => columns.map(([, key]) => row[key] ?? ""));
downloadCsv(`${label}-${state.dashboard.meta.trade_date}.csv`, headers, data);
}
function downloadCsv(filename, headers, rows) {
const lines = [headers, ...rows].map((row) => row.map(csvCell).join(","));
const blob = new Blob(["\ufeff", lines.join("\r\n")], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
showToast(`已导出 ${rows.length} 条数据`);
}
function csvCell(value) {
let text = String(value ?? "");
if (/^[=+\-@]/.test(text)) text = `'${text}`;
return `"${text.replaceAll('"', '""')}"`;
}
/* PRESERVATION-SOURCE-END app.js:8905-9051 */
+3 -1
View File
@@ -13,9 +13,11 @@ module.exports = defineConfig({
trace: "retain-on-failure",
},
webServer: {
command: "python -m http.server 8876 --bind 127.0.0.1 --directory static",
command: "python -m http.server 8876 --bind 127.0.0.1 --directory frontend",
url: "http://127.0.0.1:8876/index.html",
reuseExistingServer: true,
timeout: 15_000,
stdout: "ignore",
stderr: "ignore",
},
});
-9283
View File
File diff suppressed because it is too large Load Diff
-672
View File
@@ -1,672 +0,0 @@
(function exposeHeavenLoading(global) {
"use strict";
const PAPER = "#fdfcf8";
const PAPER_CENTER = "#f1e8d9";
const NODE_TEXT = "#fffaf0";
const INK = "#68493d";
const INK_BRIGHT = "#963f37";
const GOLD = "#80533e";
const GOLD_BRIGHT = "#b64e43";
const CINNABAR = "#b94038";
const DIM = "rgba(68,57,49,0.62)";
const PARTICLE_COLORS = ["#a94b42", "#456b62", "#506b85"];
const SERIF = '"Noto Serif SC","Songti SC","STSong","SimSun",serif';
const ELEMENT_COLORS = {
: "#4f7a4a",
: "#b3483d",
: "#96702c",
: "#70685b",
: "#496d92",
};
const QI6 = [
{ name: "厥阴风木", element: "木" },
{ name: "少阴君火", element: "火" },
{ name: "少阳相火", element: "火" },
{ name: "太阴湿土", element: "土" },
{ name: "阳明燥金", element: "金" },
{ name: "太阳寒水", element: "水" },
];
const STEP_RANGES = ["大寒 — 春分", "春分 — 小满", "小满 — 大暑", "大暑 — 秋分", "秋分 — 小雪", "小雪 — 大寒"];
const TRIGRAMS = [
{ name: "乾", bits: [1, 1, 1], angle: -90 },
{ name: "兑", bits: [1, 1, 0], angle: -135 },
{ name: "离", bits: [1, 0, 1], angle: 180 },
{ name: "震", bits: [1, 0, 0], angle: 135 },
{ name: "巽", bits: [0, 1, 1], angle: -45 },
{ name: "坎", bits: [0, 1, 0], angle: 0 },
{ name: "艮", bits: [0, 0, 1], angle: 45 },
{ name: "坤", bits: [0, 0, 0], angle: 90 },
];
const SIXIANG = [
{ name: "太阳", bits: [1, 1], dx: 0, dy: -1 },
{ name: "少阴", bits: [1, 0], dx: 1, dy: 0 },
{ name: "太阴", bits: [0, 0], dx: 0, dy: 1 },
{ name: "少阳", bits: [0, 1], dx: -1, dy: 0 },
];
const HEXAGRAM_NAMES = [
"坤", "剥", "比", "观", "豫", "晋", "萃", "否", "谦", "艮", "蹇", "渐", "小过", "旅", "咸", "遁",
"师", "蒙", "坎", "涣", "解", "未济", "困", "讼", "升", "蛊", "井", "巽", "恒", "鼎", "大过", "姤",
"复", "颐", "屯", "益", "震", "噬嗑", "随", "无妄", "明夷", "贲", "既济", "家人", "丰", "革", "同人", "临",
"损", "节", "中孚", "归妹", "睽", "兑", "履", "泰", "大畜", "需", "小畜", "大壮", "大有", "夬", "乾",
];
const HEX_TOTAL = 12500;
const FORTUNE_TOTAL = 12800;
const HEX_STAGES = [
[0, 1800, "太 极", "无极而太极,动而生阳"],
[1800, 3300, "两 仪", "一阴一阳之谓道"],
[3300, 4700, "四 象", "阴阳消长,太少相生"],
[4700, 6800, "八 卦", "天地定位,山泽通气"],
[6800, 10800, "六 十 四 卦", "卦者挂也,悬物象以示人"],
[10800, HEX_TOTAL, "归 一", "万物负阴而抱阳,冲气以为和"],
];
const clamp01 = (value) => Math.max(0, Math.min(1, value));
const smooth = (start, end, value) => {
const progress = clamp01((value - start) / Math.max(1, end - start));
return progress * progress * (3 - 2 * progress);
};
const easeOut = (value) => 1 - Math.pow(1 - clamp01(value), 3);
const hexBits = (index) => Array.from({ length: 6 }, (_, bit) => (index >> (5 - bit)) & 1);
const point = (cx, cy, radius, degrees) => {
const radians = degrees * Math.PI / 180;
return [cx + Math.cos(radians) * radius, cy + Math.sin(radians) * radius];
};
class HeavenLoadingCanvas {
constructor(canvas) {
this.canvas = canvas;
this.context = canvas.getContext("2d");
this.width = 0;
this.height = 0;
this.dpr = 1;
this.scene = "hexagram";
this.data = {};
this.startedAt = 0;
this.frameId = 0;
this.running = false;
this.completingAt = 0;
this.completionResolve = null;
this.completionTimer = 0;
this.resizeObserver = new ResizeObserver(() => this.resize());
this.reducedMotion = global.matchMedia("(prefers-reduced-motion: reduce)").matches;
this.stars = this.createStars(this.reducedMotion ? 48 : 150);
}
createStars(count) {
let seed = 24681357;
const random = () => {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 4294967296;
};
return Array.from({ length: count }, () => ({
x: random(),
y: random(),
radius: 0.3 + random() * 1.3,
phase: random() * Math.PI * 2,
speed: 0.00015 + random() * 0.0004,
colorIndex: Math.floor(random() * PARTICLE_COLORS.length),
}));
}
start(scene, data = {}) {
const nextScene = scene === "fortune" ? "fortune" : "hexagram";
if (this.running && this.scene === nextScene) {
this.data = data;
return;
}
this.stop();
this.scene = nextScene;
this.data = data;
this.startedAt = performance.now();
this.running = true;
this.canvas.dataset.scene = this.scene;
this.canvas.dataset.running = "true";
this.canvas.dataset.looping = "true";
this.resizeObserver.observe(this.canvas);
this.resize();
if (this.reducedMotion) {
this.draw(this.scene === "fortune" ? 10950 : 10600, performance.now());
} else {
this.frameId = requestAnimationFrame((now) => this.frame(now));
}
}
complete() {
if (!this.running || this.reducedMotion) {
this.stop();
return Promise.resolve();
}
if (this.completionResolve) return this.completionPromise;
this.completingAt = performance.now();
this.completionPromise = new Promise((resolve) => { this.completionResolve = resolve; });
this.completionTimer = global.setTimeout(() => this.stop(), 2200);
return this.completionPromise;
}
stop() {
if (this.frameId) cancelAnimationFrame(this.frameId);
this.frameId = 0;
this.running = false;
this.completingAt = 0;
if (this.completionTimer) global.clearTimeout(this.completionTimer);
this.completionTimer = 0;
this.resizeObserver.disconnect();
this.canvas.dataset.running = "false";
this.canvas.dataset.looping = "false";
if (this.completionResolve) this.completionResolve();
this.completionResolve = null;
this.completionPromise = null;
}
resize() {
const rect = this.canvas.getBoundingClientRect();
const width = Math.max(1, Math.round(rect.width));
const height = Math.max(1, Math.round(rect.height));
if (width === this.width && height === this.height) return;
this.width = width;
this.height = height;
this.dpr = Math.min(global.devicePixelRatio || 1, 2);
this.canvas.width = Math.round(width * this.dpr);
this.canvas.height = Math.round(height * this.dpr);
this.context.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
if (this.running && this.reducedMotion) {
this.draw(this.scene === "fortune" ? 10950 : 10600, performance.now());
}
}
frame(now) {
if (!this.running) return;
if (this.completingAt) {
const duration = this.scene === "fortune" ? 1800 : 1700;
const progress = clamp01((now - this.completingAt) / duration);
this.drawCompletion(progress, now);
if (progress >= 1) {
this.stop();
return;
}
} else {
const total = this.scene === "fortune" ? FORTUNE_TOTAL : HEX_TOTAL;
const elapsed = Math.max(0, now - this.startedAt);
const timeline = elapsed % total;
this.canvas.dataset.cycle = String(Math.floor(elapsed / total));
this.draw(timeline, now);
}
this.frameId = requestAnimationFrame((time) => this.frame(time));
}
draw(time, now) {
if (this.width <= 1 || this.height <= 1) return;
this.drawBackground(now);
if (this.scene === "fortune") this.drawFortune(time, now);
else this.drawHexagram(time, now);
}
drawBackground(now) {
const { context: ctx, width, height } = this;
const cx = width / 2;
const cy = height * 0.44;
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(width, height) * 0.75);
gradient.addColorStop(0, PAPER_CENTER);
gradient.addColorStop(0.52, "#faf7ef");
gradient.addColorStop(1, PAPER);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
for (const star of this.stars) {
const twinkle = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(star.phase + now * 0.0012));
const alpha = twinkle * 0.5;
ctx.globalAlpha = alpha;
ctx.fillStyle = PARTICLE_COLORS[star.colorIndex];
const y = ((star.y + now * star.speed) % 1) * height;
ctx.fillRect(star.x * width, y, star.radius, star.radius);
}
ctx.globalAlpha = 1;
}
label(text, x, y, size, color = INK, alpha = 1, weight = "", maxWidth) {
if (!text || alpha <= 0) return;
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
ctx.font = `${weight ? `${weight} ` : ""}${size}px ${SERIF}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
if (maxWidth) ctx.fillText(text, x, y, maxWidth);
else ctx.fillText(text, x, y);
ctx.restore();
}
node(x, y, radius, color, alpha = 1, glow = 0) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
ctx.shadowColor = color;
ctx.shadowBlur = glow;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
line(x1, y1, x2, y2, color, alpha = 1, width = 1) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.restore();
}
curvedArrow(x1, y1, x2, y2, mx, my, color, alpha) {
if (alpha <= 0) return;
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.quadraticCurveTo(mx, my, x2, y2);
ctx.stroke();
const angle = Math.atan2(y2 - my, x2 - mx);
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - 7 * Math.cos(angle - 0.42), y2 - 7 * Math.sin(angle - 0.42));
ctx.lineTo(x2 - 7 * Math.cos(angle + 0.42), y2 - 7 * Math.sin(angle + 0.42));
ctx.closePath();
ctx.fill();
ctx.restore();
}
drawYao(cx, cy, width, lineWidth, yang, alpha, glow = 0) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = INK;
ctx.shadowColor = GOLD;
ctx.shadowBlur = glow;
if (yang) {
ctx.fillRect(cx - width / 2, cy - lineWidth / 2, width, lineWidth);
} else {
const gap = width * 0.18;
ctx.fillRect(cx - width / 2, cy - lineWidth / 2, (width - gap) / 2, lineWidth);
ctx.fillRect(cx + gap / 2, cy - lineWidth / 2, (width - gap) / 2, lineWidth);
}
ctx.restore();
}
drawGua(cx, cy, width, lineWidth, bits, alpha, glow = 0) {
const gap = lineWidth * 1.7;
const top = cy - (bits.length - 1) * gap / 2;
bits.forEach((bit, index) => {
this.drawYao(cx, top + (bits.length - 1 - index) * gap, width, lineWidth, bit === 1, alpha, glow);
});
}
stageAlpha(time, start, end, fade = 300, hold = false) {
const enter = smooth(start, start + fade, time);
return hold ? enter : enter * (1 - smooth(end - fade, end, time));
}
fortuneStages() {
const sixQi = this.data.sixQi || {};
const pillar = this.data.yearPillar || "岁运";
const movement = this.data.movement || "中运合参";
const sitian = sixQi.sitian || "司天气候";
return [
[0, 2100, "五 运", "木火土金水,五运相袭,周而复始"],
[2100, 3900, "十 干 化 运", "甲己土 · 乙庚金 · 丙辛水 · 丁壬木 · 戊癸火"],
[3900, 5800, "十 二 支 化 气", "子午少阴 · 丑未太阴 · 寅申少阳 · 卯酉阳明 · 辰戌太阳 · 巳亥厥阴"],
[5800, 7900, "六 气 环 布", "风寒暑湿燥火,分主六步,以应岁时"],
[7900, 11000, "岁 运 合 参", `${pillar}年 · 中运${movement} · ${sitian}司天`],
[11000, FORTUNE_TOTAL, "归 一", "谨守病机,无失气宜"],
];
}
drawFooter(time, now, total, stages, scene) {
const { context: ctx, width, height } = this;
const stage = [...stages].reverse().find((item) => time >= item[0]) || stages[0];
const labelAlpha = smooth(stage[0], stage[0] + 300, time)
* (1 - smooth(stage[1] - 250, stage[1], time));
this.label(stage[2], width / 2, height - 108, 19, GOLD, 0.55 + 0.45 * labelAlpha, "600");
this.label(stage[3], width / 2, height - 84, 12.5, DIM, (0.4 + 0.4 * labelAlpha) * (scene === "fortune" ? 0.85 : 0.8), "", width - 32);
const baseSlotWidth = 34;
const baseSlotHeight = 5;
const baseSlotGap = 12;
const baseTotalWidth = baseSlotWidth * 6 + baseSlotGap * 5;
const fit = Math.min(1, (width - 28) / baseTotalWidth);
const slotWidth = baseSlotWidth * fit;
const slotHeight = baseSlotHeight * fit;
const slotGap = baseSlotGap * fit;
const totalWidth = slotWidth * 6 + slotGap * 5;
const filled = Math.min(6, Math.floor(time / (total / 6)));
for (let index = 0; index < 6; index += 1) {
const x = width / 2 - totalWidth / 2 + index * (slotWidth + slotGap);
const y = height - 56;
const color = scene === "fortune" ? ELEMENT_COLORS[QI6[index].element] : GOLD;
ctx.save();
ctx.globalAlpha = 0.16;
ctx.strokeStyle = GOLD;
ctx.lineWidth = 1;
ctx.strokeRect(x, y, slotWidth, slotHeight);
ctx.restore();
if (index < filled) {
ctx.save();
ctx.globalAlpha = 0.9;
ctx.fillStyle = color;
ctx.shadowColor = color;
ctx.shadowBlur = 8;
ctx.fillRect(x, y, slotWidth, slotHeight);
ctx.restore();
} else if (index === filled) {
ctx.save();
ctx.globalAlpha = 0.35 + 0.3 * Math.sin(now / 200);
ctx.fillStyle = color;
const progress = (time % (total / 6)) / (total / 6);
ctx.fillRect(x, y, slotWidth * progress, slotHeight);
ctx.restore();
}
}
const dots = ".".repeat(1 + Math.floor(now / 450) % 3);
const loadingText = scene === "fortune" ? "推 演 运 气 · 加 载 中" : "推 演 天 机 · 加 载 中";
this.label(`${loadingText}${dots}`, width / 2, height - 32, 13, GOLD, 0.75);
}
drawTrigramRing(cx, cy, radius, width, lineWidth, alpha, now, entering, time) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha * 0.13;
ctx.strokeStyle = GOLD;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
const breath = 1 + 0.006 * Math.sin(now / 620);
TRIGRAMS.forEach((trigram, index) => {
const progress = entering ? easeOut((time - 4700 - index * 130) / 700) : 1;
if (progress <= 0) return;
const [x, y] = point(cx, cy, radius * breath * progress, trigram.angle);
this.drawGua(x, y, width, lineWidth, trigram.bits, alpha * progress, alpha * progress * 8);
const nameAlpha = entering ? alpha * clamp01((time - 4700 - index * 130 - 480) / 500) : alpha;
this.label(trigram.name, x, y + lineWidth * 5.2, 13, GOLD, nameAlpha * (0.55 + 0.2 * Math.sin(now / 700 + index)));
});
}
drawHexagram(time, now) {
const { width, height } = this;
const cx = width / 2;
const cy = height * 0.44;
const scale = Math.min(width, height);
if (time < 1800) {
const alpha = this.stageAlpha(time, 0, 1800);
this.node(cx, cy, 5.5 * (1 + 0.12 * Math.sin(now / 260)), GOLD_BRIGHT, alpha, 34);
for (let ring = 0; ring < 3; ring += 1) {
const progress = ((now / 1500) + ring / 3) % 1;
const ctx = this.context;
ctx.save();
ctx.globalAlpha = (1 - progress) * 0.22 * alpha;
ctx.strokeStyle = GOLD;
ctx.beginPath();
ctx.arc(cx, cy, 8 + progress * scale * 0.13, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
}
if (time >= 1800 && time < 3300) {
const alpha = this.stageAlpha(time, 1800, 3300);
const progress = easeOut((time - 1850) / 850);
const yaoWidth = scale * 0.19 * progress;
const yaoLine = Math.max(scale * 0.013, 5);
this.drawYao(cx, cy - yaoLine * 2.6, yaoWidth, yaoLine, true, alpha, 14);
this.drawYao(cx, cy + yaoLine * 2.6, yaoWidth, yaoLine, false, alpha, 14);
this.node(cx, cy, 4, GOLD_BRIGHT, alpha * (1 - progress) * 0.9);
}
if (time >= 3300 && time < 4700) {
const alpha = this.stageAlpha(time, 3300, 4700);
const distance = scale * 0.085;
const yaoWidth = Math.max(scale * 0.055, 28);
const yaoLine = Math.max(scale * 0.009, 3.5);
SIXIANG.forEach((symbol, index) => {
const progress = easeOut((time - 3330 - index * 160) / 520);
if (progress <= 0) return;
const x = cx + symbol.dx * distance;
const y = cy + symbol.dy * distance;
this.drawGua(x, y, yaoWidth * progress, yaoLine, symbol.bits, alpha * progress, 10);
this.label(symbol.name, x, y + yaoLine * 5.4, 12, GOLD, alpha * progress * 0.55);
});
}
const trigramRadius = scale * 0.215;
const trigramWidth = Math.max(scale * 0.052, 26);
const trigramLine = Math.max(scale * 0.0075, 3);
if (time >= 4700 && time < 6800) {
this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth, trigramLine, this.stageAlpha(time, 4700, 6800), now, true, time);
}
if (time >= 6800 && time < 10800) {
const alpha = this.stageAlpha(time, 6800, 10800, 350);
this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth * 0.85, trigramLine * 0.85, alpha * 0.42, now, false, time);
const ringRadius = scale * 0.365;
const hexWidth = Math.max(scale * 0.026, 13);
const hexLine = Math.max(scale * 0.0042, 1.6);
const count = Math.floor(clamp01((time - 7000) / 3600) * 64);
for (let index = 0; index < 64; index += 1) {
const [x, y] = point(cx, cy, ringRadius, -90 + index * 360 / 64);
this.node(x, y, 1.4, GOLD, alpha * 0.14);
if (index < count) {
const freshness = Math.max(0, 1 - (count - 1 - index) / 5);
if (freshness > 0) {
const ctx = this.context;
const gradient = ctx.createLinearGradient(cx, cy, x, y);
gradient.addColorStop(0, "rgba(128,83,62,0)");
gradient.addColorStop(1, GOLD);
this.line(cx, cy, x, y, gradient, alpha * freshness * 0.35);
}
this.drawGua(x, y, hexWidth, hexLine, hexBits(index), alpha * (0.55 + 0.45 * freshness), freshness * 9);
}
}
if (count > 0) {
const current = count - 1;
const popTime = clamp01((time - (7000 + current * 3600 / 64)) / 130);
const pop = 1 + 0.22 * (1 - popTime);
this.drawGua(cx, cy - scale * 0.028, scale * 0.085 * pop, Math.max(scale * 0.011, 4.5), hexBits(current), alpha, 16);
this.label(HEXAGRAM_NAMES[current], cx, cy + scale * 0.062, Math.max(20, scale * 0.042), GOLD_BRIGHT, alpha, "600");
this.label(`${current + 1}`, cx, cy + scale * 0.105, 13, GOLD, alpha * 0.55);
}
}
if (time >= 10800) {
const alpha = this.stageAlpha(time, 10800, HEX_TOTAL, 420);
const progress = easeOut((time - 10850) / 1150);
const radius = scale * 0.365 * (1 - progress);
for (let index = 0; index < 64 && radius >= 8; index += 1) {
const [x, y] = point(cx, cy, radius, -90 + index * 360 / 64);
this.drawGua(x, y, Math.max(scale * 0.026, 13), Math.max(scale * 0.0042, 1.6), hexBits(index), (1 - progress) * 0.7 * alpha);
}
this.node(cx, cy, 3 + progress * 6, GOLD_BRIGHT, alpha * (0.3 + 0.7 * progress), 12 + progress * 40);
}
this.drawFooter(time, now, HEX_TOTAL, HEX_STAGES, "hexagram");
}
drawFortune(time, now) {
const { width, height } = this;
const cx = width / 2;
const cy = height * 0.44;
const scale = Math.min(width, height);
if (time < 2100) this.drawFiveMovements(time, now, cx, cy, scale);
if (time >= 2100 && time < 3900) this.drawStems(time, cx, cy, scale);
if (time >= 3900 && time < 5800) this.drawBranches(time, cx, cy, scale);
if (time >= 5800 && time < 7900) this.drawSixQi(time, now, cx, cy, scale);
if (time >= 7900 && time < 11000) this.drawAnnualQi(time, now, cx, cy, scale);
if (time >= 11000) {
const alpha = this.stageAlpha(time, 11000, FORTUNE_TOTAL, 420);
const progress = easeOut((time - 11050) / 1200);
const radius = scale * 0.30 * (1 - progress);
QI6.forEach((qi, index) => {
const [x, y] = point(cx, cy, radius, -90 + index * 60);
if (radius > 8) this.node(x, y, Math.max(scale * 0.011, 6), ELEMENT_COLORS[qi.element], (1 - progress) * 0.8 * alpha, 8);
});
this.node(cx, cy, 3 + progress * 6, GOLD_BRIGHT, alpha * (0.3 + 0.7 * progress), 12 + progress * 40);
}
this.drawFooter(time, now, FORTUNE_TOTAL, this.fortuneStages(), "fortune");
}
drawFiveMovements(time, now, cx, cy, scale) {
const alpha = this.stageAlpha(time, 0, 2100);
const radius = scale * 0.17;
const nodeRadius = Math.max(scale * 0.018, 9);
const elements = [
["木", 180], ["火", -90], ["金", 0], ["水", 90], ["土", null],
];
const positions = {};
this.node(cx, cy, 5 + 1.5 * Math.sin(now / 260), GOLD_BRIGHT, alpha * (1 - easeOut((time - 200) / 800)), 30);
elements.forEach(([element, degrees], index) => {
const progress = easeOut((time - 500 - index * 170) / 500);
if (progress <= 0) return;
const x = degrees === null ? cx : cx + Math.cos(degrees * Math.PI / 180) * radius * progress;
const y = degrees === null ? cy : cy + Math.sin(degrees * Math.PI / 180) * radius * progress;
positions[element] = [x, y];
this.node(x, y, nodeRadius * progress, ELEMENT_COLORS[element], alpha * progress, 16);
this.label(element, x, y + 0.5, Math.round(nodeRadius * 1.15), NODE_TEXT, alpha * progress, "600");
const direction = element === "土" ? "中央土" : { : "东方木", : "南方火", : "西方金", : "北方水" }[element];
this.label(direction, x, y + nodeRadius + 14, 12, ELEMENT_COLORS[element], alpha * progress * 0.75);
});
const order = ["木", "火", "土", "金", "水"];
order.forEach((element, index) => {
const from = positions[element];
const to = positions[order[(index + 1) % order.length]];
if (!from || !to) return;
const progress = smooth(1450 + index * 130, 1700 + index * 130, time);
const mx = (from[0] + to[0]) / 2 + (cx - (from[0] + to[0]) / 2) * 0.25;
const my = (from[1] + to[1]) / 2 + (cy - (from[1] + to[1]) / 2) * 0.25;
this.curvedArrow(from[0], from[1], to[0], to[1], mx, my, GOLD, alpha * progress * 0.4);
});
}
drawStems(time, cx, cy, scale) {
const alpha = this.stageAlpha(time, 2100, 3900);
const stems = "甲乙丙丁戊己庚辛壬癸";
const movements = ["土", "金", "水", "木", "火"];
const radius = scale * 0.30;
for (let index = 0; index < 10; index += 1) {
const progress = smooth(2150 + index * 90, 2450 + index * 90, time);
if (progress <= 0) continue;
const [x, y] = point(cx, cy, radius, -90 + index * 36);
const element = movements[index % 5];
this.node(x, y, 3, ELEMENT_COLORS[element], alpha * progress, 8);
this.label(stems[index], x, y - 14, 15, ELEMENT_COLORS[element], alpha * progress, "600");
}
for (let index = 0; index < 5; index += 1) {
const progress = smooth(3150 + index * 110, 3450 + index * 110, time);
const angle = -90 + index * 36;
const [x1, y1] = point(cx, cy, radius, angle);
const [x2, y2] = point(cx, cy, radius, -90 + (index + 5) * 36);
this.line(x1, y1, x2, y2, ELEMENT_COLORS[movements[index]], alpha * progress * 0.45);
const [labelX, labelY] = point(cx, cy, scale * 0.055, angle + 90);
this.label(movements[index], labelX, labelY, 16, ELEMENT_COLORS[movements[index]], alpha * progress, "600");
}
}
drawBranches(time, cx, cy, scale) {
const alpha = this.stageAlpha(time, 3900, 5800);
const branches = "子丑寅卯辰巳午未申酉戌亥";
const qiNames = ["少阴君火", "太阴湿土", "少阳相火", "阳明燥金", "太阳寒水", "厥阴风木"];
const radius = scale * 0.31;
const branchAngle = (index) => -90 + ((index - 6 + 12) % 12) * 30;
for (let index = 0; index < 12; index += 1) {
const progress = smooth(3950 + index * 70, 4220 + index * 70, time);
const [x, y] = point(cx, cy, radius, branchAngle(index));
this.node(x, y, 2.5, GOLD, alpha * progress, 6);
this.label(branches[index], x, y - 13, 14, GOLD, alpha * progress * 0.9);
}
qiNames.forEach((name, index) => {
const progress = smooth(4900 + index * 130, 5200 + index * 130, time);
const [x1, y1] = point(cx, cy, radius, branchAngle(index));
const [x2, y2] = point(cx, cy, radius, branchAngle(index + 6));
const element = QI6.find((item) => item.name === name)?.element || "土";
this.line(x1, y1, x2, y2, ELEMENT_COLORS[element], alpha * progress * 0.4);
const [labelX, labelY] = point(cx, cy, radius + scale * 0.055, branchAngle(index));
this.label(name, labelX, labelY, 12, ELEMENT_COLORS[element], alpha * progress, "600");
});
}
drawSixQi(time, now, cx, cy, scale) {
const alpha = this.stageAlpha(time, 5800, 7900);
const radius = scale * 0.27;
const drift = now * 0.004;
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha * 0.13;
ctx.strokeStyle = GOLD;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
QI6.forEach((qi, index) => {
const progress = easeOut((time - 5850 - index * 180) / 550);
const [x, y] = point(cx, cy, radius * progress, -90 + index * 60 + drift);
const nodeRadius = Math.max(scale * 0.015, 8) * progress;
this.node(x, y, nodeRadius, ELEMENT_COLORS[qi.element], alpha * progress, 14);
this.label(qi.name, x, y - nodeRadius - 12, 13, ELEMENT_COLORS[qi.element], alpha * progress, "600");
this.label(["初之气", "二之气", "三之气", "四之气", "五之气", "终之气"][index], x, y + nodeRadius + 12, 10.5, DIM, alpha * progress * 0.9);
});
this.node(cx, cy, 4 + Math.sin(now / 300), GOLD_BRIGHT, alpha * 0.9, 24);
}
drawAnnualQi(time, now, cx, cy, scale) {
const alpha = this.stageAlpha(time, 7900, 11000, 350);
const sixQi = this.data.sixQi || {};
const pillar = this.data.yearPillar || "岁运";
const movement = this.data.movement || "中运合参";
const sitian = sixQi.sitian || "司天气候";
const zaiquan = sixQi.zaiquan || "在泉气化";
const currentStep = Math.max(1, Math.min(6, Number(sixQi.step) || 1));
const qiElement = (name) => QI6.find((item) => item.name === name)?.element || "土";
const movementElement = ["木", "火", "土", "金", "水"].find((element) => movement.includes(element)) || "土";
this.label("司 天", cx, cy - scale * 0.212, 11, DIM, alpha * smooth(7950, 8450, time));
this.label(sitian, cx, cy - scale * 0.178, 17, ELEMENT_COLORS[qiElement(sitian)], alpha * smooth(7950, 8450, time), "600");
this.label(zaiquan, cx, cy + scale * 0.178, 17, ELEMENT_COLORS[qiElement(zaiquan)], alpha * smooth(8200, 8700, time), "600");
this.label("在 泉", cx, cy + scale * 0.212, 11, DIM, alpha * smooth(8200, 8700, time));
this.label(pillar, cx, cy - scale * 0.012, Math.max(22, scale * 0.052), GOLD_BRIGHT, alpha * smooth(8500, 9100, time), "600");
this.label(`${pillar}年 · 中运${movement}`, cx, cy + scale * 0.052, 14, ELEMENT_COLORS[movementElement], alpha * smooth(8500, 9100, time), "600", scale * 0.62);
const radius = scale * 0.30;
QI6.forEach((qi, index) => {
const progress = smooth(9200 + index * 260, 9480 + index * 260, time);
const [x, y] = point(cx, cy, radius, -90 + index * 60);
const current = index + 1 === currentStep;
const pulse = current ? 0.5 + 0.5 * Math.sin(now / 230) : 0;
this.node(x, y, Math.max(scale * 0.011, 6) + (current ? 2.5 : 0), ELEMENT_COLORS[qi.element], alpha * progress, 12 + pulse * 14);
if (current) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha * (0.35 + pulse * 0.35);
ctx.strokeStyle = CINNABAR;
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(x, y, Math.max(scale * 0.02, 11) + pulse * 3, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
this.label("当今", x, y - Math.max(scale * 0.038, 21), 10.5, CINNABAR, alpha * progress, "600");
}
const stepName = `${index + 1 === 6 ? "终" : ["初", "二", "三", "四", "五"][index]}之气`;
this.label(`${stepName} · ${qi.name}`, x, y + Math.max(scale * 0.03, 17), 11.5, current ? GOLD_BRIGHT : ELEMENT_COLORS[qi.element], alpha * progress * (current ? 1 : 0.85), current ? "600" : "");
if (current) this.label(STEP_RANGES[index], x, y + Math.max(scale * 0.052, 33), 10, DIM, alpha * progress);
});
}
drawCompletion(progress, now) {
this.drawBackground(now);
if (this.scene === "fortune") {
this.drawFortune(11000 + progress * (FORTUNE_TOTAL - 11000), now);
} else {
this.drawHexagram(10800 + progress * (HEX_TOTAL - 10800), now);
}
}
}
global.HeavenLoadingCanvas = HeavenLoadingCanvas;
})(window);
-4
View File
@@ -1,4 +0,0 @@
window.XiaobaiPageModules.register("auction", ["auctionView"], {
enter: ["loadAuction"],
leave: ["clearAuction"],
});
-3
View File
@@ -1,3 +0,0 @@
window.XiaobaiPageModules.register("dragon_tiger", ["dragonView"], {
enter: ["loadDragonTiger"],
});
-4
View File
@@ -1,4 +0,0 @@
window.XiaobaiPageModules.register("heaven", ["heavenView"], {
enter: ["loadHeaven"],
leave: ["stopHeaven"],
});
-1
View File
@@ -1 +0,0 @@
window.XiaobaiPageModules.register("ladder", ["ladderView"]);
-3
View File
@@ -1,3 +0,0 @@
window.XiaobaiPageModules.register("mentor", ["mentorView"], {
enter: ["loadMentor"],
});
-7
View File
@@ -1,7 +0,0 @@
window.XiaobaiPageModules.register("pools", [
"limitPool",
"brokenView",
"downView",
"yesterdayView",
"performanceView",
]);
-3
View File
@@ -1,3 +0,0 @@
window.XiaobaiPageModules.register("popularity", ["popularityView"], {
enter: ["loadPopularity"],
});
-3
View File
@@ -1,3 +0,0 @@
window.XiaobaiPageModules.register("review", ["reviewWorkspaceView"], {
enter: ["loadReview"],
});
-3
View File
@@ -1,3 +0,0 @@
window.XiaobaiPageModules.register("rotation", ["rotationView"], {
enter: ["loadRotation"],
});
-5
View File
@@ -1,5 +0,0 @@
window.XiaobaiPageModules.register("screener", ["screenerView"], {
enter: ["loadScreener"],
});
window.XiaobaiPageModules.register("screener", ["screenerTrackingView"]);
-3
View File
@@ -1,3 +0,0 @@
window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], {
enter: ["loadSentiment"],
});
-3
View File
@@ -1,3 +0,0 @@
window.XiaobaiPageModules.register("themes", ["themeLibraryView"], {
enter: ["loadThemes"],
});
+1
View File
@@ -0,0 +1 @@
"""Test support package for the preserved application."""
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
import ast
import hashlib
import re
from pathlib import Path
APP_ROOT = Path(__file__).resolve().parents[1]
ORIGINAL_ROOT = APP_ROOT.parent
FRONTEND_ROOT = APP_ROOT / "frontend"
ORIGINAL_STATIC = ORIGINAL_ROOT / "static"
SOURCE_RANGE = re.compile(
r"/\* PRESERVATION-SOURCE-BEGIN app\.js:(\d+)-(\d+) \*/\n"
r"(.*?)"
r"/\* PRESERVATION-SOURCE-END app\.js:\1-\2 \*/\n?",
re.DOTALL,
)
# These exact original app.js line ranges were retired in slice 11 after the
# definition-only symbols passed static, runtime, and compatibility review.
RETIRED_FRONTEND_SOURCE_RANGES = (
(3537, 3540),
(4139, 4145),
(4973, 4989),
(9022, 9027),
(9052, 9055),
)
AUDITED_FRONTEND_SOURCE_LINE_COUNT = 9283
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def function_contract(path: Path, name: str) -> tuple[str, str]:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
function = next(
node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == name
)
body = ast.Module(body=function.body, type_ignores=[])
return (
ast.dump(function.args, include_attributes=False),
ast.dump(body, include_attributes=False),
)
def module_contract(
path: Path,
*,
excluded_definitions: set[str] | None = None,
excluded_import_modules: set[str] | None = None,
exclude_imports: bool = False,
) -> str:
excluded_definitions = excluded_definitions or set()
excluded_import_modules = excluded_import_modules or set()
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
tree.body = [
node
for node in tree.body
if not (exclude_imports and isinstance(node, (ast.Import, ast.ImportFrom)))
and not (
isinstance(node, ast.ImportFrom)
and node.module in excluded_import_modules
)
and getattr(node, "name", None) not in excluded_definitions
]
return ast.dump(tree, include_attributes=False)
def reassembled_frontend_runtime() -> str:
chunks: dict[tuple[int, int], str] = {}
for path in FRONTEND_ROOT.rglob("*.js"):
for match in SOURCE_RANGE.finditer(path.read_text(encoding="utf-8")):
source_range = (int(match.group(1)), int(match.group(2)))
if source_range in chunks:
raise AssertionError(f"duplicate app.js source range: {source_range}")
chunks[source_range] = match.group(3)
assembled: list[str] = []
next_line = 1
for (start, end), content in sorted(chunks.items()):
if start != next_line:
raise AssertionError(
f"app.js source coverage gap: expected line {next_line}, got {start}"
)
assembled.append(content)
next_line = end + 1
if next_line != AUDITED_FRONTEND_SOURCE_LINE_COUNT + 1:
raise AssertionError(
"app.js source coverage ended at "
f"{next_line - 1}, expected {AUDITED_FRONTEND_SOURCE_LINE_COUNT}"
)
return "".join(assembled)
def original_runtime_after_audited_retirements() -> str:
lines = (ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8").splitlines(
keepends=True
)
retired = {
line_number
for start, end in RETIRED_FRONTEND_SOURCE_RANGES
for line_number in range(start, end + 1)
}
return "".join(
line for line_number, line in enumerate(lines, start=1) if line_number not in retired
)
def assert_frontend_runtime_matches_audited_baseline(testcase) -> None:
testcase.assertEqual(
reassembled_frontend_runtime(),
original_runtime_after_audited_retirements(),
)
def assert_moved_asset_matches(
testcase,
original_relative: str,
frontend_relative: str | None = None,
) -> None:
target_relative = frontend_relative or original_relative
testcase.assertEqual(
sha256(FRONTEND_ROOT / target_relative),
sha256(ORIGINAL_STATIC / original_relative),
original_relative,
)
def assert_page_prefix_matches(testcase, page_relative: str) -> None:
original = (ORIGINAL_STATIC / page_relative).read_text(encoding="utf-8")
migrated = (FRONTEND_ROOT / page_relative).read_text(encoding="utf-8")
testcase.assertTrue(
migrated.startswith(original.rstrip("\n") + "\n\n"),
page_relative,
)
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
import unittest
from pathlib import Path
from database import ReviewDatabase
APP_ROOT = Path(__file__).resolve().parents[1]
FRONTEND_ROOT = APP_ROOT / "frontend"
class CleanupContractTests(unittest.TestCase):
def test_retired_files_stay_absent(self) -> None:
self.assertFalse((APP_ROOT / "demo_data.py").exists())
self.assertFalse((FRONTEND_ROOT / "heaven-loading.js").exists())
def test_only_active_heaven_loading_asset_is_loaded(self) -> None:
html = (FRONTEND_ROOT / "index.html").read_text(encoding="utf-8")
self.assertIn('src="/pages/heaven/loading-v2.js', html)
self.assertNotIn('src="/heaven-loading.js', html)
def test_audited_definition_only_functions_stay_absent(self) -> None:
runtime = "\n".join(
path.read_text(encoding="utf-8")
for path in FRONTEND_ROOT.rglob("*.js")
if "vendor" not in path.parts
)
for symbol in (
"commonReviewColumns",
"outcomeClass",
"screenerResultMatchesSelection",
"selectRegime",
"showHeartRitualCurtain",
):
with self.subTest(symbol=symbol):
self.assertNotIn(symbol, runtime)
def test_wencai_history_compatibility_is_retained(self) -> None:
for method in (
"list_wencai_saved_queries",
"save_wencai_query",
"delete_wencai_saved_query",
):
with self.subTest(method=method):
self.assertTrue(hasattr(ReviewDatabase, method))
if __name__ == "__main__":
unittest.main()
+13 -13
View File
@@ -6,14 +6,14 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
STATIC = ROOT / "static"
STATIC = ROOT / "frontend"
TOKENS = STATIC / "shared" / "tokens.css"
LEGACY_STYLESHEETS = (
"styles.css",
"renovation.css",
"redesign-v2.css",
"design-system.css",
"theme.css",
"styles/styles.css",
"styles/renovation.css",
"styles/redesign-v2.css",
"styles/design-system.css",
"styles/theme.css",
)
@@ -26,12 +26,12 @@ class CssGovernanceTests(unittest.TestCase):
def test_token_layer_loads_before_application_styles(self) -> None:
expected_order = (
"/shared/tokens.css",
"/styles.css",
"/renovation.css",
"/redesign-v2.css",
"/design-system.css",
"/theme.css",
"/wentian-v2.css",
"/styles/styles.css",
"/styles/renovation.css",
"/styles/redesign-v2.css",
"/styles/design-system.css",
"/styles/theme.css",
"/pages/heaven/page.css",
)
positions = [self.html.index(path) for path in expected_order]
self.assertEqual(positions, sorted(positions))
@@ -63,7 +63,7 @@ class CssGovernanceTests(unittest.TestCase):
def test_wentian_tokens_remain_isolated(self) -> None:
self.assertNotRegex(self.tokens, r"--wt-[a-z0-9-]+\s*:")
wentian = (STATIC / "wentian-v2.css").read_text(encoding="utf-8")
wentian = (STATIC / "pages" / "heaven" / "page.css").read_text(encoding="utf-8")
self.assertRegex(wentian, r"--wt-[a-z0-9-]+\s*:")
def test_compatibility_aliases_cover_historical_layers(self) -> None:
+17 -6
View File
@@ -4,10 +4,13 @@ import json
import re
import unittest
from pathlib import Path
from unittest.mock import patch
from tests.preservation_helpers import reassembled_frontend_runtime
ROOT = Path(__file__).resolve().parents[1]
STATIC = ROOT / "static"
STATIC = ROOT / "frontend"
class FrontendBoundaryTests(unittest.TestCase):
@@ -22,7 +25,7 @@ class FrontendBoundaryTests(unittest.TestCase):
def test_shared_dependencies_load_before_application(self) -> None:
html = (STATIC / "index.html").read_text(encoding="utf-8")
ui_position = html.index('/ui-core.js')
ui_position = html.index('/shared/ui-core.js')
components_position = html.index('/shared/components.js')
pages_position = html.index('/pages.config.js')
runtime_position = html.index('/pages/runtime.js')
@@ -40,10 +43,18 @@ class FrontendBoundaryTests(unittest.TestCase):
self.assertLess(shell_position, app_position)
def test_application_state_is_created_through_shared_boundary(self) -> None:
app = (STATIC / "app.js").read_text(encoding="utf-8")
app = reassembled_frontend_runtime()
self.assertIn("const state = window.XiaobaiState.create({", app)
self.assertNotIn("const state = {", app)
def test_candidate_runtime_reassembly_does_not_require_original_static(self) -> None:
with patch(
"tests.preservation_helpers.ORIGINAL_STATIC",
ROOT / "missing-original-static",
):
app = reassembled_frontend_runtime()
self.assertIn("function openView(", app)
def test_runtime_page_registry_matches_governance_registry(self) -> None:
expected = json.loads(
(ROOT / "config" / "pages.config.json").read_text(encoding="utf-8")
@@ -70,7 +81,7 @@ class FrontendBoundaryTests(unittest.TestCase):
self.assertEqual(actual, expected)
def test_shell_owns_navigation_and_page_mounting(self) -> None:
app = (STATIC / "app.js").read_text(encoding="utf-8")
app = reassembled_frontend_runtime()
shell = (STATIC / "shared" / "shell.js").read_text(encoding="utf-8")
self.assertNotIn("function syncNavigationState", app)
self.assertNotIn("function initializeApplicationShell", app)
@@ -109,7 +120,7 @@ class FrontendBoundaryTests(unittest.TestCase):
self.assertEqual(actual, expected)
def test_page_lifecycle_is_owned_outside_application_monolith(self) -> None:
app = (STATIC / "app.js").read_text(encoding="utf-8")
app = reassembled_frontend_runtime()
runtime = (STATIC / "pages" / "runtime.js").read_text(encoding="utf-8")
start = app.index("function openView(")
end = app.index("\nfunction initializeAutoTableSorting", start)
@@ -122,7 +133,7 @@ class FrontendBoundaryTests(unittest.TestCase):
def test_shared_empty_state_component_is_used_by_multiple_features(self) -> None:
components = (STATIC / "shared" / "components.js").read_text(encoding="utf-8")
app = (STATIC / "app.js").read_text(encoding="utf-8")
app = reassembled_frontend_runtime()
self.assertIn("function emptyStateHtml(message, options = {})", components)
self.assertIn("function renderEmptyState(target, message, options = {})", components)
self.assertGreaterEqual(app.count("renderEmptyState("), 8)
+11 -9
View File
@@ -5,8 +5,10 @@ import unittest
from html.parser import HTMLParser
from pathlib import Path
from tests.preservation_helpers import reassembled_frontend_runtime
STATIC_DIR = Path(__file__).resolve().parents[1] / "static"
STATIC_DIR = Path(__file__).resolve().parents[1] / "frontend"
class IdCollector(HTMLParser):
@@ -22,11 +24,11 @@ class FrontendContractTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
cls.script = (STATIC_DIR / "app.js").read_text(encoding="utf-8")
cls.script = reassembled_frontend_runtime()
cls.shell = (STATIC_DIR / "shared" / "shell.js").read_text(encoding="utf-8")
cls.ui_core = (STATIC_DIR / "ui-core.js").read_text(encoding="utf-8")
cls.design_system = (STATIC_DIR / "design-system.css").read_text(encoding="utf-8")
cls.theme = (STATIC_DIR / "theme.css").read_text(encoding="utf-8")
cls.ui_core = (STATIC_DIR / "shared" / "ui-core.js").read_text(encoding="utf-8")
cls.design_system = (STATIC_DIR / "styles" / "design-system.css").read_text(encoding="utf-8")
cls.theme = (STATIC_DIR / "styles" / "theme.css").read_text(encoding="utf-8")
cls.tokens = (STATIC_DIR / "shared" / "tokens.css").read_text(encoding="utf-8")
collector = IdCollector()
collector.feed(cls.html)
@@ -158,7 +160,7 @@ class FrontendContractTests(unittest.TestCase):
self.assertIn("必需数据已完整,本日没有股票同时满足", self.script)
def test_dialogs_and_dark_table_hover_have_shared_safety_constraints(self):
redesign = (STATIC_DIR / "redesign-v2.css").read_text(encoding="utf-8")
redesign = (STATIC_DIR / "styles" / "redesign-v2.css").read_text(encoding="utf-8")
self.assertIn(".settings-dialog:not(.heaven-reading-dialog)[open] { margin: auto; }", redesign)
self.assertIn("max-height: min(760px, calc(100dvh - 28px));", redesign)
self.assertIn('#reviewWorkspaceView .data-table tbody tr:hover td', self.theme)
@@ -166,8 +168,8 @@ class FrontendContractTests(unittest.TestCase):
self.assertIn('#screenerView .screener-result-frame tbody tr:hover td:last-child', self.theme)
def test_global_toast_has_one_owner_and_cannot_stretch_between_insets(self):
styles = (STATIC_DIR / "styles.css").read_text(encoding="utf-8")
wentian = (STATIC_DIR / "wentian-v2.css").read_text(encoding="utf-8")
styles = (STATIC_DIR / "styles" / "styles.css").read_text(encoding="utf-8")
wentian = (STATIC_DIR / "pages" / "heaven" / "page.css").read_text(encoding="utf-8")
self.assertIn("#toast.toast {", styles)
self.assertIn("top: auto;", styles)
self.assertIn("left: auto;", styles)
@@ -183,7 +185,7 @@ class FrontendContractTests(unittest.TestCase):
def test_shared_ui_core_loads_before_application(self):
self.assertLess(
self.html.index('<script src="/ui-core.js"'),
self.html.index('<script src="/shared/ui-core.js"'),
self.html.index('<script src="/app.js'),
)
for function_name in (
+3 -2
View File
@@ -4,6 +4,7 @@ import unittest
from pathlib import Path
from server import DashboardService
from tests.preservation_helpers import reassembled_frontend_runtime
class SearchDatabaseStub:
@@ -77,9 +78,9 @@ class GlobalSearchTests(unittest.TestCase):
)
def test_frontend_reuses_full_stock_detail_and_renders_market_daily_k(self):
static_dir = Path(__file__).resolve().parents[1] / "static"
static_dir = Path(__file__).resolve().parents[1] / "frontend"
html = (static_dir / "index.html").read_text(encoding="utf-8")
script = (static_dir / "app.js").read_text(encoding="utf-8")
script = reassembled_frontend_runtime()
self.assertIn('id="globalSearchButton"', html)
self.assertIn('id="globalSearchDialog"', html)
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
import unittest
from backend.application import (
AUTHENTICATED_POST_HANDLERS,
PUBLIC_POST_HANDLERS,
RequestHandler,
)
class HttpDispatchContractTests(unittest.TestCase):
@staticmethod
def handler(path: str, calls: list[str]) -> RequestHandler:
handler = RequestHandler.__new__(RequestHandler)
handler.path = path
handler.require_auth = lambda: calls.append("auth") or True
handler.require_csrf = lambda: calls.append("csrf") or True
handler.require_access = lambda method, route: (
calls.append(f"access:{method}:{route}") or True
)
return handler
def test_named_handlers_are_real_registered_post_routes(self) -> None:
all_handlers = {**PUBLIC_POST_HANDLERS, **AUTHENTICATED_POST_HANDLERS}
self.assertEqual(
set(PUBLIC_POST_HANDLERS) & set(AUTHENTICATED_POST_HANDLERS), set()
)
for path, handler_name in all_handlers.items():
with self.subTest(path=path):
route = RequestHandler.route_registry.resolve("POST", path)
self.assertIsNotNone(route)
self.assertTrue(callable(getattr(RequestHandler, handler_name)))
expected_access = "public" if path in PUBLIC_POST_HANDLERS else None
if expected_access:
self.assertEqual(route.access, expected_access)
else:
self.assertNotEqual(route.access, "public")
def test_public_post_dispatches_without_authentication(self) -> None:
for path, handler_name in PUBLIC_POST_HANDLERS.items():
with self.subTest(path=path):
calls: list[str] = []
handler = self.handler(path, calls)
handler.require_auth = lambda: (_ for _ in ()).throw(
AssertionError("public route required authentication")
)
setattr(handler, handler_name, lambda: calls.append(handler_name))
RequestHandler.do_POST(handler)
self.assertEqual(calls, [handler_name])
def test_authenticated_post_preserves_guard_order(self) -> None:
for path, handler_name in AUTHENTICATED_POST_HANDLERS.items():
with self.subTest(path=path):
calls: list[str] = []
handler = self.handler(path, calls)
setattr(handler, handler_name, lambda: calls.append(handler_name))
RequestHandler.do_POST(handler)
self.assertEqual(
calls,
["auth", "csrf", f"access:POST:{path}", handler_name],
)
def test_failed_access_never_dispatches_protected_handler(self) -> None:
path, handler_name = next(iter(AUTHENTICATED_POST_HANDLERS.items()))
calls: list[str] = []
handler = self.handler(path, calls)
handler.require_access = lambda method, route: (
calls.append(f"access:{method}:{route}") or False
)
setattr(handler, handler_name, lambda: calls.append(handler_name))
RequestHandler.do_POST(handler)
self.assertEqual(calls, ["auth", "csrf", f"access:POST:{path}"])
if __name__ == "__main__":
unittest.main()
+187
View File
@@ -0,0 +1,187 @@
from __future__ import annotations
import io
import json
import unittest
import urllib.error
from pathlib import Path
from unittest.mock import patch
from assistant_agent import ReviewAssistantError, stream_review_assistant
from backend.llm import transport
from heaven_agent import HeavenAgentError, interpret_heaven
from llm_strategy import LLMCompilerError, test_llm_connection
from mentor_agent import MentorAgentError, MentorSkill, stream_with_mentor
ROOT = Path(__file__).resolve().parents[1]
class FakeResponse:
def __init__(self, *, payload: bytes = b"", lines: list[bytes] | None = None) -> None:
self.payload = payload
self.lines = lines or []
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
return False
def read(self) -> bytes:
return self.payload
def __iter__(self):
return iter(self.lines)
class OpenAITransportTests(unittest.TestCase):
def test_chat_completion_builds_one_openai_compatible_request(self) -> None:
captured = {}
response = FakeResponse(
payload=json.dumps(
{"choices": [{"message": {"content": "OK"}}]}
).encode("utf-8")
)
def open_request(request, timeout):
captured["url"] = request.full_url
captured["headers"] = request.headers
captured["payload"] = json.loads(request.data.decode("utf-8"))
captured["timeout"] = timeout
return response
with patch("backend.llm.transport.urllib.request.urlopen", side_effect=open_request):
result = transport.chat_completion(
api_key="secret",
base_url="https://example.test/v1/",
model="model",
messages=[{"role": "user", "content": "ping"}],
timeout=17,
user_agent="XiaobaiReviewWeb/test",
)
self.assertEqual(result.content, "OK")
self.assertGreaterEqual(result.latency_ms, 0)
self.assertEqual(captured["url"], "https://example.test/v1/chat/completions")
self.assertEqual(captured["payload"]["stream"], False)
self.assertEqual(captured["headers"]["Authorization"], "Bearer secret")
self.assertEqual(captured["timeout"], 17)
def test_stream_completion_parses_deltas_and_ignores_final_snapshot(self) -> None:
response = FakeResponse(
lines=[
b'data: {"choices":[{"delta":{"content":"first"}}]}\n',
b'data: {"choices":[{"delta":{"content":" second"}}]}\n',
b'data: {"choices":[{"message":{"content":"first second"}}]}\n',
b"data: [DONE]\n",
]
)
with patch("backend.llm.transport.urllib.request.urlopen", return_value=response):
chunks = list(
transport.stream_chat_completion(
api_key="secret",
base_url="https://example.test/v1",
model="model",
messages=[],
timeout=17,
user_agent="XiaobaiReviewWeb/test",
)
)
self.assertEqual(chunks, ["first", " second"])
def test_empty_stream_has_a_stable_transport_error(self) -> None:
response = FakeResponse(lines=[b"data: [DONE]\n"])
with patch("backend.llm.transport.urllib.request.urlopen", return_value=response):
with self.assertRaises(transport.OpenAIEmptyResponseError):
list(
transport.stream_chat_completion(
api_key="secret",
base_url="https://example.test/v1",
model="model",
messages=[],
timeout=17,
user_agent="XiaobaiReviewWeb/test",
)
)
def test_http_error_keeps_code_and_sanitized_provider_detail(self) -> None:
error = urllib.error.HTTPError(
"https://example.test/v1/chat/completions",
429,
"rate limited",
{},
io.BytesIO(b'{"error":{"message":"capacity"}}'),
)
self.addCleanup(error.close)
with patch("backend.llm.transport.urllib.request.urlopen", side_effect=error):
with self.assertRaises(transport.OpenAIHTTPError) as caught:
transport.chat_completion(
api_key="secret",
base_url="https://example.test/v1",
model="model",
messages=[],
timeout=17,
user_agent="XiaobaiReviewWeb/test",
)
self.assertEqual(caught.exception.code, 429)
self.assertEqual(
caught.exception.describe("模型调用失败"),
"模型调用失败(HTTP 429):capacity",
)
def test_feature_agents_have_no_direct_provider_transport(self) -> None:
paths = (
"backend/features/mentor/agent.py",
"backend/features/heaven/agent.py",
"backend/features/review/agent.py",
"backend/features/screener/compiler.py",
)
for relative in paths:
source = (ROOT / relative).read_text(encoding="utf-8")
with self.subTest(path=relative):
self.assertNotIn("urllib.request", source)
self.assertNotIn("/chat/completions", source)
self.assertIn("llm_transport.", source)
class FeatureErrorMappingTests(unittest.TestCase):
def test_feature_specific_http_messages_are_preserved(self) -> None:
error = transport.OpenAIHTTPError(429, "capacity")
skill = MentorSkill(
skill_id="test",
name="测试老师",
description="",
tagline="",
focus=(),
content="",
path=Path("SKILL.md"),
)
with patch(
"mentor_agent.llm_transport.stream_chat_completion", side_effect=error
):
with self.assertRaisesRegex(
MentorAgentError, "问师模型调用失败(HTTP 429):capacity"
):
list(stream_with_mentor(skill, {}, "问题", [], "key", "https://x", "m"))
with patch("heaven_agent.llm_transport.chat_completion", side_effect=error):
with self.assertRaisesRegex(
HeavenAgentError, "问天模型调用失败(HTTP 429):capacity"
):
interpret_heaven("heart", {}, "key", "https://x", "m")
with patch(
"assistant_agent.llm_transport.stream_chat_completion", side_effect=error
):
with self.assertRaisesRegex(
ReviewAssistantError, "智能解读服务暂不可用(429"
):
list(stream_review_assistant({}, "问题", [], "key", "https://x", "m"))
with patch("llm_strategy.llm_transport.chat_completion", side_effect=error):
with self.assertRaisesRegex(
LLMCompilerError, "模型连接测试失败(HTTP 429):capacity"
):
test_llm_connection("key", "https://x", "m")
if __name__ == "__main__":
unittest.main()
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from tools.build_api_registry import build as build_api_registry
from tools.build_architecture_inventory import (
build as build_architecture_inventory,
source_metrics,
)
from tools.verify_baseline import python_test_command
ROOT = Path(__file__).resolve().parents[1]
class MaintenanceToolTests(unittest.TestCase):
def test_generated_candidate_registries_are_current(self) -> None:
expected_api = json.loads(
(ROOT / "config" / "api.config.json").read_text(encoding="utf-8")
)
expected_architecture = json.loads(
(ROOT / "config" / "architecture-inventory.json").read_text(
encoding="utf-8"
)
)
self.assertEqual(expected_api, build_api_registry())
self.assertEqual(expected_architecture, build_architecture_inventory())
def test_baseline_verifier_uses_the_candidate_frontend(self) -> None:
source = (ROOT / "tools" / "verify_baseline.py").read_text(encoding="utf-8")
self.assertIn('FRONTEND_ROOT = ROOT / "frontend"', source)
self.assertIn('FRONTEND_ROOT.rglob("*.js")', source)
self.assertIn("start_e2e_server()", source)
self.assertIn("stop_e2e_server(server)", source)
self.assertNotIn('"static/app.js"', source)
def test_standalone_verifier_excludes_only_migration_comparison_modules(self) -> None:
repository_command = python_test_command(preservation_baseline=True)
standalone_command = python_test_command(preservation_baseline=False)
self.assertIn("discover", repository_command)
self.assertTrue(any(item == "tests.test_frontend_contract" for item in standalone_command))
self.assertFalse(any("test_preservation_" in item for item in standalone_command))
def test_architecture_metrics_are_independent_of_checkout_line_endings(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
lf = root / "lf.js"
crlf = root / "crlf.js"
lf.write_bytes(b"const a = 1;\nconst b = 2;\n")
crlf.write_bytes(b"const a = 1;\r\nconst b = 2;\r\n")
self.assertEqual(source_metrics(lf), source_metrics(crlf))
def test_every_tool_has_a_non_mutating_help_path(self) -> None:
for path in sorted((ROOT / "tools").glob("*.py")):
if path.name.startswith("_"):
continue
with self.subTest(tool=path.name):
result = subprocess.run(
[sys.executable, str(path), "--help"],
cwd=ROOT,
capture_output=True,
text=True,
timeout=15,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
def test_migration_only_tools_are_explicitly_classified(self) -> None:
readme = (ROOT / "tools" / "README.md").read_text(encoding="utf-8")
for name in (
"build_preservation_manifest.py",
"move_class_methods.py",
"split_frontend_runtime.py",
):
self.assertIn(name, readme)
manifest = (ROOT / "tools" / "build_preservation_manifest.py").read_text(
encoding="utf-8"
)
self.assertNotIn('TARGET = ROOT / "app"', manifest)
self.assertIn('required=True', manifest)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,26 @@
from __future__ import annotations
import unittest
from backend.bootstrap.config import tushare_code
from backend.features.market import charts
class MarketSymbolNormalizationTests(unittest.TestCase):
def test_chart_and_market_services_share_one_suffix_converter(self) -> None:
self.assertIs(charts._stock_market_code, tushare_code)
def test_existing_exchange_mapping_is_preserved(self) -> None:
cases = {
"000001": "000001.SZ",
"600000": "600000.SH",
"430047": "430047.BJ",
"830799": "830799.BJ",
}
for code, expected in cases.items():
with self.subTest(code=code):
self.assertEqual(charts._stock_market_code(code), expected)
if __name__ == "__main__":
unittest.main()
+4 -4
View File
@@ -44,7 +44,7 @@ class MentorStreamTests(unittest.TestCase):
captured["accept"] = request.headers.get("Accept")
return FakeStreamResponse(self.lines)
with patch("mentor_agent.urllib.request.urlopen", side_effect=open_request):
with patch("backend.llm.transport.urllib.request.urlopen", side_effect=open_request):
chunks = list(
stream_with_mentor(
self.skill, {"data_trade_date": "20260723"}, "怎么看?", [],
@@ -58,7 +58,7 @@ class MentorStreamTests(unittest.TestCase):
def test_non_streaming_compatibility_wrapper_collects_chunks(self):
with patch(
"mentor_agent.urllib.request.urlopen",
"backend.llm.transport.urllib.request.urlopen",
return_value=FakeStreamResponse(self.lines),
):
result = chat_with_mentor(
@@ -74,7 +74,7 @@ class MentorStreamTests(unittest.TestCase):
b"data: [DONE]\n",
]
with patch(
"mentor_agent.urllib.request.urlopen",
"backend.llm.transport.urllib.request.urlopen",
return_value=FakeStreamResponse(lines),
):
chunks = list(
@@ -92,7 +92,7 @@ class MentorStreamTests(unittest.TestCase):
b"data: [DONE]\n",
]
with patch(
"mentor_agent.urllib.request.urlopen",
"backend.llm.transport.urllib.request.urlopen",
return_value=FakeStreamResponse(lines),
):
chunks = list(
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
import math
import unittest
from backend.data.numbers import finite_number, non_nan_number
from backend.data.providers import tushare_client
from backend.features.market import insights
from backend.features.screener import engine as screener_engine
from backend.features.sentiment import engine as sentiment_engine
class NumericNormalizationTests(unittest.TestCase):
def test_consumers_use_their_declared_shared_policy(self) -> None:
self.assertIs(tushare_client._number, finite_number)
self.assertIs(screener_engine._number, finite_number)
self.assertIs(insights._number, non_nan_number)
self.assertIs(sentiment_engine._number, non_nan_number)
def test_finite_policy_preserves_existing_results(self) -> None:
self.assertEqual(finite_number("12.5"), 12.5)
self.assertEqual(finite_number(None), 0.0)
self.assertEqual(finite_number("invalid", 7.0), 7.0)
self.assertEqual(finite_number(math.nan, 7.0), 7.0)
self.assertEqual(finite_number(math.inf, 7.0), 7.0)
self.assertEqual(finite_number(-math.inf, 7.0), 7.0)
def test_non_nan_policy_keeps_infinity_but_rejects_nan(self) -> None:
self.assertEqual(non_nan_number("12.5"), 12.5)
self.assertEqual(non_nan_number(None), 0.0)
self.assertEqual(non_nan_number("invalid", 7.0), 7.0)
self.assertEqual(non_nan_number(math.nan, 7.0), 7.0)
self.assertEqual(non_nan_number(math.inf, 7.0), math.inf)
self.assertEqual(non_nan_number(-math.inf, 7.0), -math.inf)
if __name__ == "__main__":
unittest.main()
@@ -23,7 +23,7 @@ class AccountSliceStructureTests(unittest.TestCase):
def test_runtime_paths_still_point_at_app_root(self) -> None:
self.assertEqual(APP_DIR, Path(__file__).resolve().parents[1])
self.assertEqual(STATIC_DIR, APP_DIR / "static")
self.assertEqual(STATIC_DIR, APP_DIR / "frontend")
def test_account_persistence_and_http_transport_have_single_owners(self) -> None:
for method in (
@@ -0,0 +1,96 @@
from __future__ import annotations
import re
import unittest
from backend.bootstrap.config import APP_DIR, STATIC_DIR
from tests.preservation_helpers import (
FRONTEND_ROOT,
ORIGINAL_STATIC,
assert_frontend_runtime_matches_audited_baseline,
assert_moved_asset_matches,
assert_page_prefix_matches,
)
class FrontendPreservationSliceTests(unittest.TestCase):
def test_split_runtime_matches_original_except_audited_retirements(self) -> None:
assert_frontend_runtime_matches_audited_baseline(self)
def test_index_diff_is_limited_to_asset_relocation_and_split_loading(self) -> None:
migrated = (FRONTEND_ROOT / "index.html").read_text(encoding="utf-8")
restored = migrated
for current, original in (
("/styles/styles.css", "/styles.css"),
("/styles/renovation.css", "/renovation.css"),
("/styles/redesign-v2.css", "/redesign-v2.css"),
("/styles/design-system.css", "/design-system.css"),
("/styles/theme.css", "/theme.css"),
("/pages/heaven/page.css", "/wentian-v2.css"),
("/shared/ui-core.js", "/ui-core.js"),
("/pages/heaven/loading-v2.js", "/heaven-loading-v2.js"),
):
restored = restored.replace(current, original)
restored = restored.replace(
' <script src="/pages/market/runtime.js?v=20260731-1" defer></script>\n',
"",
)
restored = restored.replace(
' <script src="/shared/export.js?v=20260731-1" defer></script>\n',
"",
)
self.assertEqual(
restored,
(ORIGINAL_STATIC / "index.html").read_text(encoding="utf-8"),
)
def test_complete_stylesheet_stack_is_byte_identical_after_relocation(self) -> None:
for original, migrated in (
("shared/tokens.css", "shared/tokens.css"),
("styles.css", "styles/styles.css"),
("renovation.css", "styles/renovation.css"),
("redesign-v2.css", "styles/redesign-v2.css"),
("design-system.css", "styles/design-system.css"),
("theme.css", "styles/theme.css"),
("wentian-v2.css", "pages/heaven/page.css"),
):
with self.subTest(asset=original):
assert_moved_asset_matches(self, original, migrated)
def test_shared_vendor_and_animation_assets_are_byte_identical(self) -> None:
for original, migrated in (
("ui-core.js", "shared/ui-core.js"),
("heaven-loading-v2.js", "pages/heaven/loading-v2.js"),
("vendor/lucide.min.js", "vendor/lucide.min.js"),
("pages.config.js", "pages.config.js"),
("pages/runtime.js", "pages/runtime.js"),
("shared/api.js", "shared/api.js"),
("shared/components.js", "shared/components.js"),
("shared/shell.js", "shared/shell.js"),
("shared/state.js", "shared/state.js"),
):
with self.subTest(asset=original):
assert_moved_asset_matches(self, original, migrated)
def test_original_page_registration_prefixes_are_preserved(self) -> None:
for path in sorted((ORIGINAL_STATIC / "pages").glob("*/page.js")):
relative = path.relative_to(ORIGINAL_STATIC).as_posix()
with self.subTest(page=relative):
assert_page_prefix_matches(self, relative)
def test_frontend_is_the_only_served_static_root(self) -> None:
self.assertEqual(STATIC_DIR, APP_DIR / "frontend")
self.assertFalse((APP_DIR / "static").exists())
def test_shared_api_remains_the_only_fetch_exit(self) -> None:
consumers = []
for path in FRONTEND_ROOT.rglob("*.js"):
if "vendor" in path.parts:
continue
if re.search(r"\bfetch\s*\(", path.read_text(encoding="utf-8")):
consumers.append(path.relative_to(FRONTEND_ROOT).as_posix())
self.assertEqual(consumers, ["shared/api.js"])
if __name__ == "__main__":
unittest.main()
+6 -5
View File
@@ -91,11 +91,12 @@ class HeavenSliceSourceEquivalenceTests(unittest.TestCase):
for name in sorted(expected - (adapted or set())):
self.assertEqual(migrated[name], original[name], name)
def test_heaven_agent_is_an_exact_file(self) -> None:
self.assertEqual(
sha256(ORIGINAL_ROOT / "heaven_agent.py"),
sha256(APP_ROOT / "backend" / "features" / "heaven" / "agent.py"),
)
def test_heaven_agent_uses_shared_transport(self) -> None:
source = (
APP_ROOT / "backend" / "features" / "heaven" / "agent.py"
).read_text(encoding="utf-8")
self.assertIn("llm_transport.chat_completion", source)
self.assertNotIn("urllib.request", source)
def test_heaven_engine_definitions_are_exact_original_ast(self) -> None:
self.assertEqual(
@@ -5,6 +5,12 @@ import hashlib
import unittest
from pathlib import Path
from tests.preservation_helpers import (
assert_frontend_runtime_matches_audited_baseline,
assert_moved_asset_matches,
assert_page_prefix_matches,
)
APP_ROOT = Path(__file__).resolve().parents[1]
ORIGINAL_ROOT = APP_ROOT.parent
@@ -73,19 +79,14 @@ class LadderRotationSliceSourceEquivalenceTests(unittest.TestCase):
)
def test_api_and_frontend_assets_are_unchanged(self) -> None:
for relative in (
"config/api.config.json",
"static/index.html",
"static/app.js",
"static/styles.css",
"static/pages/ladder/page.js",
"static/pages/rotation/page.js",
):
self.assertEqual(
sha256(APP_ROOT / relative),
sha256(ORIGINAL_ROOT / relative),
relative,
)
self.assertEqual(
sha256(APP_ROOT / "config/api.config.json"),
sha256(ORIGINAL_ROOT / "config/api.config.json"),
)
assert_frontend_runtime_matches_audited_baseline(self)
assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
for page in ("pages/ladder/page.js", "pages/rotation/page.js"):
assert_page_prefix_matches(self, page)
if __name__ == "__main__":
+36 -16
View File
@@ -9,10 +9,17 @@ import chart_data_provider
import ifind_client
import realtime_aggregator
import tushare_client
from backend.bootstrap import config as bootstrap_config
from backend.data import realtime
from backend.data.numbers import finite_number
from backend.data.providers import ifind_client as canonical_ifind
from backend.data.providers import tushare_client as canonical_tushare
from backend.features.market import charts
from tests.preservation_helpers import (
assert_frontend_runtime_matches_audited_baseline,
assert_moved_asset_matches,
function_contract,
)
APP_ROOT = Path(__file__).resolve().parents[1]
@@ -137,30 +144,43 @@ class MarketSliceSourceEquivalenceTests(unittest.TestCase):
)
for original, migrated in exact_moves:
self.assertEqual(sha256(ORIGINAL_ROOT / original), sha256(APP_ROOT / migrated))
original_tushare = top_level_definitions(ORIGINAL_ROOT / "tushare_client.py")
original_tushare.pop("_number")
self.assertEqual(
top_level_definitions(ORIGINAL_ROOT / "tushare_client.py"),
original_tushare,
top_level_definitions(APP_ROOT / "backend/data/providers/tushare_client.py"),
)
self.assertEqual(
top_level_definitions(ORIGINAL_ROOT / "chart_data_provider.py"),
function_contract(ORIGINAL_ROOT / "tushare_client.py", "_number"),
function_contract(APP_ROOT / "backend/data/numbers.py", "finite_number"),
)
self.assertIs(canonical_tushare._number, finite_number)
original_charts = top_level_definitions(ORIGINAL_ROOT / "chart_data_provider.py")
original_charts.pop("_stock_market_code")
self.assertEqual(
original_charts,
top_level_definitions(APP_ROOT / "backend/features/market/charts.py"),
)
self.assertEqual(
function_contract(
ORIGINAL_ROOT / "chart_data_provider.py", "_stock_market_code"
),
function_contract(
APP_ROOT / "backend/bootstrap/config.py", "tushare_code"
),
)
self.assertIs(charts._stock_market_code, bootstrap_config.tushare_code)
def test_unchanged_frontend_assets_match_the_original(self) -> None:
for relative in (
"index.html",
"app.js",
"styles.css",
"renovation.css",
"redesign-v2.css",
"theme.css",
"wentian-v2.css",
def test_relocated_frontend_preserves_original_market_runtime_and_styles(self) -> None:
assert_frontend_runtime_matches_audited_baseline(self)
for original, migrated in (
("styles.css", "styles/styles.css"),
("renovation.css", "styles/renovation.css"),
("redesign-v2.css", "styles/redesign-v2.css"),
("theme.css", "styles/theme.css"),
("wentian-v2.css", "pages/heaven/page.css"),
):
self.assertEqual(
sha256(APP_ROOT / "static" / relative),
sha256(ORIGINAL_ROOT / "static" / relative),
relative,
)
assert_moved_asset_matches(self, original, migrated)
if __name__ == "__main__":
@@ -6,7 +6,14 @@ import unittest
from pathlib import Path
import market_insights
from backend.data.numbers import non_nan_number
from backend.features.market import insights as canonical_insights
from tests.preservation_helpers import (
assert_frontend_runtime_matches_audited_baseline,
assert_moved_asset_matches,
assert_page_prefix_matches,
function_contract,
)
APP_ROOT = Path(__file__).resolve().parents[1]
@@ -101,6 +108,11 @@ class MarketInsightsSliceSourceEquivalenceTests(unittest.TestCase):
MARKET_INSIGHT_METHODS,
)
self.assertIs(market_insights.MarketInsightsService, canonical_insights.MarketInsightsService)
self.assertEqual(
function_contract(ORIGINAL_ROOT / "market_insights.py", "_number"),
function_contract(APP_ROOT / "backend/data/numbers.py", "non_nan_number"),
)
self.assertIs(canonical_insights._number, non_nan_number)
def test_dashboard_service_methods_are_exact_original_ast(self) -> None:
original = ORIGINAL_ROOT / "server.py"
@@ -174,21 +186,19 @@ class MarketInsightsSliceSourceEquivalenceTests(unittest.TestCase):
self.assertEqual(migrated[name], original[name], name)
def test_api_and_frontend_assets_are_unchanged(self) -> None:
for relative in (
"config/api.config.json",
"static/index.html",
"static/app.js",
"static/styles.css",
"static/pages/auction/page.js",
"static/pages/themes/page.js",
"static/pages/popularity/page.js",
"static/pages/dragon-tiger/page.js",
self.assertEqual(
sha256(APP_ROOT / "config/api.config.json"),
sha256(ORIGINAL_ROOT / "config/api.config.json"),
)
assert_frontend_runtime_matches_audited_baseline(self)
assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
for page in (
"pages/auction/page.js",
"pages/themes/page.js",
"pages/popularity/page.js",
"pages/dragon-tiger/page.js",
):
self.assertEqual(
sha256(APP_ROOT / relative),
sha256(ORIGINAL_ROOT / relative),
relative,
)
assert_page_prefix_matches(self, page)
if __name__ == "__main__":
@@ -105,11 +105,12 @@ class MentorLLMSliceSourceEquivalenceTests(unittest.TestCase):
for name in sorted(expected):
self.assertEqual(migrated[name], original[name], name)
def test_mentor_agent_and_stream_accumulator_are_exact_files(self) -> None:
self.assertEqual(
sha256(ORIGINAL_ROOT / "mentor_agent.py"),
sha256(APP_ROOT / "backend" / "features" / "mentor" / "agent.py"),
def test_mentor_agent_uses_shared_transport_and_stream_accumulator_is_exact(self) -> None:
mentor_source = (APP_ROOT / "backend" / "features" / "mentor" / "agent.py").read_text(
encoding="utf-8"
)
self.assertIn("llm_transport.stream_chat_completion", mentor_source)
self.assertNotIn("urllib.request", mentor_source)
self.assertEqual(
sha256(ORIGINAL_ROOT / "llm_stream.py"),
sha256(APP_ROOT / "backend" / "llm" / "stream.py"),
@@ -109,11 +109,12 @@ class ReviewAlertsSliceSourceEquivalenceTests(unittest.TestCase):
for name in sorted(expected):
self.assertEqual(migrated[name], original[name], name)
def test_review_assistant_agent_is_an_exact_file(self) -> None:
self.assertEqual(
sha256(ORIGINAL_ROOT / "assistant_agent.py"),
sha256(APP_ROOT / "backend" / "features" / "review" / "agent.py"),
)
def test_review_assistant_agent_uses_shared_transport(self) -> None:
source = (
APP_ROOT / "backend" / "features" / "review" / "agent.py"
).read_text(encoding="utf-8")
self.assertIn("llm_transport.stream_chat_completion", source)
self.assertNotIn("urllib.request", source)
def test_review_assistant_compatibility_module_is_canonical(self) -> None:
self.assertIs(assistant_agent, canonical_agent)
+35 -28
View File
@@ -9,8 +9,16 @@ import advanced_strategies
import llm_strategy
import screener
import strategy_tracking
from backend.data.numbers import finite_number
from backend.features.screener import compiler, engine, strategies, tracking
from backend.features.screener import service as screener_service
from tests.preservation_helpers import (
assert_frontend_runtime_matches_audited_baseline,
assert_moved_asset_matches,
assert_page_prefix_matches,
function_contract,
module_contract,
)
APP_ROOT = Path(__file__).resolve().parents[1]
@@ -86,14 +94,6 @@ def top_level_definition(path: Path, name: str) -> str:
return ast.dump(node, include_attributes=False)
def module_without_imports(path: Path) -> str:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
tree.body = [
node for node in tree.body if not isinstance(node, (ast.Import, ast.ImportFrom))
]
return ast.dump(tree, include_attributes=False)
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
@@ -149,11 +149,22 @@ class ScreenerSliceSourceEquivalenceTests(unittest.TestCase):
def test_engine_and_tracking_logic_match_the_original(self) -> None:
self.assertEqual(
module_without_imports(ORIGINAL_ROOT / "screener.py"),
module_without_imports(
APP_ROOT / "backend" / "features" / "screener" / "engine.py"
module_contract(
ORIGINAL_ROOT / "screener.py",
excluded_definitions={"_number"},
exclude_imports=True,
),
module_contract(
APP_ROOT / "backend" / "features" / "screener" / "engine.py",
excluded_definitions={"_number"},
exclude_imports=True,
),
)
self.assertEqual(
function_contract(ORIGINAL_ROOT / "screener.py", "_number"),
function_contract(APP_ROOT / "backend/data/numbers.py", "finite_number"),
)
self.assertIs(engine._number, finite_number)
self.assertEqual(
class_methods(
ORIGINAL_ROOT / "backend" / "features" / "screener" / "tracking.py",
@@ -165,12 +176,16 @@ class ScreenerSliceSourceEquivalenceTests(unittest.TestCase):
),
)
def test_library_and_compiler_files_are_exact_copies(self) -> None:
for original, migrated in (
("advanced_strategies.py", "backend/features/screener/strategies.py"),
("llm_strategy.py", "backend/features/screener/compiler.py"),
):
self.assertEqual(sha256(ORIGINAL_ROOT / original), sha256(APP_ROOT / migrated))
def test_library_is_exact_and_compiler_uses_shared_transport(self) -> None:
self.assertEqual(
sha256(ORIGINAL_ROOT / "advanced_strategies.py"),
sha256(APP_ROOT / "backend/features/screener/strategies.py"),
)
compiler_source = (
APP_ROOT / "backend/features/screener/compiler.py"
).read_text(encoding="utf-8")
self.assertEqual(compiler_source.count("llm_transport.chat_completion"), 2)
self.assertNotIn("urllib.request", compiler_source)
def test_compatibility_modules_export_the_canonical_objects(self) -> None:
self.assertIs(screener, engine)
@@ -182,17 +197,9 @@ class ScreenerSliceSourceEquivalenceTests(unittest.TestCase):
)
def test_screener_frontend_assets_are_unchanged(self) -> None:
for relative in (
"static/index.html",
"static/app.js",
"static/styles.css",
"static/pages/screener/page.js",
):
self.assertEqual(
sha256(APP_ROOT / relative),
sha256(ORIGINAL_ROOT / relative),
relative,
)
assert_frontend_runtime_matches_audited_baseline(self)
assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
assert_page_prefix_matches(self, "pages/screener/page.js")
if __name__ == "__main__":
@@ -6,7 +6,15 @@ import unittest
from pathlib import Path
import sentiment_engine
from backend.data.numbers import non_nan_number
from backend.features.sentiment import engine as canonical_engine
from tests.preservation_helpers import (
assert_frontend_runtime_matches_audited_baseline,
assert_moved_asset_matches,
assert_page_prefix_matches,
function_contract,
module_contract,
)
APP_ROOT = Path(__file__).resolve().parents[1]
@@ -89,25 +97,32 @@ class SentimentPoolSliceSourceEquivalenceTests(unittest.TestCase):
def test_sentiment_engine_is_exact_original_with_legacy_alias(self) -> None:
self.assertEqual(
sha256(ORIGINAL_ROOT / "sentiment_engine.py"),
sha256(APP_ROOT / "backend" / "features" / "sentiment" / "engine.py"),
module_contract(
ORIGINAL_ROOT / "sentiment_engine.py",
excluded_definitions={"_number"},
),
module_contract(
APP_ROOT / "backend" / "features" / "sentiment" / "engine.py",
excluded_definitions={"_number"},
excluded_import_modules={"backend.data.numbers"},
),
)
self.assertEqual(
function_contract(ORIGINAL_ROOT / "sentiment_engine.py", "_number"),
function_contract(APP_ROOT / "backend/data/numbers.py", "non_nan_number"),
)
self.assertIs(sentiment_engine, canonical_engine)
self.assertIs(canonical_engine._number, non_nan_number)
def test_api_and_frontend_assets_are_unchanged(self) -> None:
for relative in (
"config/api.config.json",
"static/index.html",
"static/app.js",
"static/styles.css",
"static/pages/sentiment/page.js",
"static/pages/pools/page.js",
):
self.assertEqual(
sha256(APP_ROOT / relative),
sha256(ORIGINAL_ROOT / relative),
relative,
)
self.assertEqual(
sha256(APP_ROOT / "config/api.config.json"),
sha256(ORIGINAL_ROOT / "config/api.config.json"),
)
assert_frontend_runtime_matches_audited_baseline(self)
assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
for page in ("pages/sentiment/page.js", "pages/pools/page.js"):
assert_page_prefix_matches(self, page)
if __name__ == "__main__":
+3 -3
View File
@@ -31,7 +31,7 @@ class ReviewAssistantStreamingTests(unittest.TestCase):
b'data: [DONE]\n',
]
)
with patch("assistant_agent.urllib.request.urlopen", return_value=response):
with patch("backend.llm.transport.urllib.request.urlopen", return_value=response):
chunks = list(
stream_review_assistant({}, "question", [], "key", "https://example.test/v1", "model")
)
@@ -39,7 +39,7 @@ class ReviewAssistantStreamingTests(unittest.TestCase):
def test_empty_stream_is_rejected(self):
response = StreamingResponse([b"data: [DONE]\n"])
with patch("assistant_agent.urllib.request.urlopen", return_value=response):
with patch("backend.llm.transport.urllib.request.urlopen", return_value=response):
with self.assertRaises(ReviewAssistantError):
list(
stream_review_assistant({}, "question", [], "key", "https://example.test/v1", "model")
@@ -54,7 +54,7 @@ class ReviewAssistantStreamingTests(unittest.TestCase):
b"data: [DONE]\n",
]
)
with patch("assistant_agent.urllib.request.urlopen", return_value=response):
with patch("backend.llm.transport.urllib.request.urlopen", return_value=response):
chunks = list(
stream_review_assistant(
{}, "question", [], "key", "https://example.test/v1", "model"
+2 -2
View File
@@ -46,7 +46,7 @@ class RealtimeClientStub:
class FixedMarketDatetime(datetime):
fixed_now = datetime.now().astimezone().replace(hour=10, minute=30, second=0, microsecond=0)
fixed_now = datetime(2026, 7, 31, 10, 30).astimezone()
@classmethod
def now(cls, tz=None):
@@ -54,7 +54,7 @@ class FixedMarketDatetime(datetime):
class FixedPreopenDatetime(datetime):
fixed_now = datetime.now().astimezone().replace(hour=8, minute=45, second=0, microsecond=0)
fixed_now = datetime(2026, 7, 31, 8, 45).astimezone()
@classmethod
def now(cls, tz=None):
+43
View File
@@ -0,0 +1,43 @@
# Candidate tools
Run these commands from `webapp/app/`. The tools are separated by responsibility so a
maintenance command cannot be mistaken for a historical migration rewrite.
## Normal maintenance
- `python tools/verify_baseline.py`: candidate unit tests, registry checks, every frontend
JavaScript syntax check, Git whitespace check, and read-only SQLite integrity check.
- `python tools/verify_baseline.py --e2e`: the same checks plus Playwright. The verifier owns
the local static-server lifecycle so the command exits cleanly on Windows.
- `python tools/build_api_registry.py [--check]`: generate or verify
`config/api.config.json` from `backend/application.py`.
- `python tools/build_architecture_inventory.py [--check]`: generate or verify
`config/architecture-inventory.json` from the candidate source tree.
Inside the canonical `webapp/app/` checkout, `verify_baseline.py` runs the full preservation
suite against the retained original baseline and enforces `git diff --check`. In a standalone
`app/` export where that baseline and Git checkout do not exist, the same command runs all
candidate-owned tests, skips only `test_preservation_*` comparison modules, and reports the Git
check as skipped. Product, registry, JavaScript, database, and optional Playwright checks remain
active in both modes.
## Acceptance and differential checks
- `run_preservation_runtime.py`: start an isolated original or candidate runtime with an
explicitly selected data directory and port.
- `compare_preservation_apis.py`: compare authenticated responses from two isolated runtimes.
- `compare_preservation_databases.py`: compare schema and selected table contents from two
SQLite copies.
These tools require explicit paths and do not select the production database automatically.
## Migration-only tools
- `build_preservation_manifest.py`: builds an exact-copy manifest for a specified source and
target. `--output` is mandatory so committed historical evidence is not overwritten.
- `move_class_methods.py`: mechanically moves named class methods between explicit files.
- `split_frontend_runtime.py`: reproduces the one-time Slice 10 split. It refuses to write
unless `--apply` is supplied and is not a normal maintenance command.
The migration-only tools are retained for audit and reproducibility. They are not part of
application startup, normal testing, or future feature development.
+26
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import argparse
import ast
import json
import re
import sys
@@ -59,8 +60,32 @@ def _role(method: str, path: str) -> str:
return required_role(method, sample)
def _mapped_paths(text: str) -> dict[str, set[str]]:
paths = {method: set() for method in ("GET", "POST", "DELETE")}
tree = ast.parse(text)
for node in tree.body:
if not isinstance(node, ast.Assign) or len(node.targets) != 1:
continue
target = node.targets[0]
if not isinstance(target, ast.Name):
continue
match = re.fullmatch(
r"(?:PUBLIC_|AUTHENTICATED_)?(GET|POST|DELETE)_HANDLERS", target.id
)
if not match:
continue
mapping = ast.literal_eval(node.value)
if not isinstance(mapping, dict) or not all(
isinstance(path, str) and path.startswith("/api/") for path in mapping
):
raise ValueError(f"Invalid route handler map: {target.id}")
paths[match.group(1)].update(mapping)
return paths
def build() -> dict:
text = (ROOT / "backend" / "application.py").read_text(encoding="utf-8")
mapped_paths = _mapped_paths(text)
method_matches = list(re.finditer(r"^ def do_(GET|POST|DELETE)\(", text, re.MULTILINE))
routes = []
for index, match in enumerate(method_matches):
@@ -68,6 +93,7 @@ def build() -> dict:
end = method_matches[index + 1].start() if index + 1 < len(method_matches) else len(text)
block = text[match.start():end]
exact_paths = set(re.findall(r'parsed\.path\s*==\s*"(/api/[^"]+)"', block))
exact_paths.update(mapped_paths[method])
patterns = set(
re.findall(
r're\.(?:fullmatch|match)\(\s*r?["\']([^"\']*?/api/[^"\']+)["\']\s*,\s*parsed\.path',
+77 -44
View File
@@ -9,7 +9,7 @@ from typing import Any
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "docs" / "governance" / "architecture-inventory.json"
OUTPUT = ROOT / "config" / "architecture-inventory.json"
def relative(path: Path) -> str:
@@ -37,22 +37,28 @@ def page_inventory(html: str) -> list[dict[str, str]]:
def api_inventory(server: str) -> dict[str, list[str]]:
exact = sorted(set(re.findall(r'parsed\.path\s*==\s*"(/api/[^"]+)"', server)))
routes = json.loads(source("config/api.config.json"))["routes"]
exact = sorted(
{item["path"] for item in routes if item["match"] == "exact"}
)
prefixes = sorted(
set(re.findall(r'parsed\.path\.startswith\(\s*"(/api/[^"]+)"', server))
)
patterns = sorted(
set(
item
for item in re.findall(r'r?["\']([^"\']*?/api/[^"\']+)["\']', server)
if "\\d" in item or ".+" in item or "(?P" in item
)
{item["path"] for item in routes if item["match"] == "regex"}
)
return {"exact": exact, "prefixes": prefixes, "patterns": patterns}
def database_inventory(database: str) -> list[str]:
return re.findall(r"CREATE TABLE IF NOT EXISTS\s+([a-zA-Z0-9_]+)", database)
def database_inventory(sources: list[str]) -> list[str]:
tables: list[str] = []
for database in sources:
tables.extend(
re.findall(
r"CREATE TABLE IF NOT EXISTS\s+([a-zA-Z0-9_]+)", database
)
)
return list(dict.fromkeys(tables))
def python_functions(path: str, prefixes: tuple[str, ...]) -> list[str]:
@@ -68,44 +74,59 @@ def css_layers(html: str) -> list[str]:
return re.findall(r'<link[^>]+rel="stylesheet"[^>]+href="([^"]+)"', html)
def source_metrics(path: Path) -> dict[str, int]:
text = path.read_text(encoding="utf-8")
return {
"bytes": len(text.encode("utf-8")),
"lines": len(text.splitlines()),
}
def code_hotspots() -> list[dict[str, Any]]:
candidates = [
"server.py",
"backend/application.py",
"database.py",
"screener.py",
"market_insights.py",
"tushare_client.py",
"static/index.html",
"static/app.js",
"static/styles.css",
"static/redesign-v2.css",
"static/renovation.css",
"static/theme.css",
"static/wentian-v2.css",
"backend/features/screener/engine.py",
"backend/features/market/insights.py",
"backend/data/providers/tushare_client.py",
"backend/features/heaven/service.py",
"backend/features/heaven/engine.py",
"frontend/index.html",
"frontend/app.js",
"frontend/styles/styles.css",
"frontend/styles/redesign-v2.css",
"frontend/styles/renovation.css",
"frontend/styles/theme.css",
"frontend/pages/heaven/page.css",
"frontend/pages/heaven/page.js",
"frontend/pages/market/runtime.js",
]
rows = []
for name in candidates:
path = ROOT / name
rows.append(
{
"path": name,
"bytes": path.stat().st_size,
"lines": len(path.read_text(encoding="utf-8").splitlines()),
}
)
if not path.is_file():
continue
rows.append({"path": name, **source_metrics(path)})
return sorted(rows, key=lambda item: item["bytes"], reverse=True)
def build() -> dict[str, Any]:
html = source("static/index.html")
server = source("server.py")
database = source("database.py")
html = source("frontend/index.html")
server = source("backend/application.py")
database_sources = [source("database.py")]
database_sources.extend(
path.read_text(encoding="utf-8")
for path in sorted((ROOT / "backend" / "database" / "migrations").glob("m*.py"))
)
database_sources.append(
source("backend/database/migrations/runner.py")
)
pages = page_inventory(html)
api = api_inventory(server)
tables = database_inventory(database)
tables = database_inventory(database_sources)
return {
"schema_version": 1,
"captured_from": "governed source tree",
"captured_from": "app accepted modular runtime",
"runtime": {
"http_server": "http.server.ThreadingHTTPServer",
"application_processes": 1,
@@ -124,21 +145,30 @@ def build() -> dict[str, Any]:
"api": api,
"database_tables": tables,
"background_job_methods": python_functions(
"server.py", ("_background", "_run_background", "_schedule_", "run_automatic")
"backend/application.py",
("_background", "_run_background", "_schedule_", "run_automatic"),
),
"external_data_adapters": [
{"provider": "tushare", "path": "tushare_client.py", "runtime_role": "primary deterministic market data"},
{"provider": "ifind", "path": "ifind_client.py", "runtime_role": "realtime, charts, snapshots, enrichment"},
{"provider": "eastmoney", "path": "chart_data_provider.py", "runtime_role": "display chart fallback"},
{"provider": "eastmoney", "path": "realtime_aggregator.py", "runtime_role": "isolated realtime observation"},
{"provider": "tencent", "path": "realtime_aggregator.py", "runtime_role": "index observation fallback"},
{"provider": "tushare", "path": "backend/data/providers/tushare_client.py", "runtime_role": "primary deterministic market data"},
{"provider": "ifind", "path": "backend/data/providers/ifind_client.py", "runtime_role": "realtime, charts, snapshots, enrichment"},
{"provider": "eastmoney", "path": "backend/features/market/charts.py", "runtime_role": "display chart fallback"},
{"provider": "eastmoney", "path": "backend/data/realtime.py", "runtime_role": "isolated realtime observation"},
{"provider": "tencent", "path": "backend/data/realtime.py", "runtime_role": "index observation fallback"},
],
"numeric_normalization": [
{"function": "finite_number", "path": "backend/data/numbers.py"},
{"function": "non_nan_number", "path": "backend/data/numbers.py"},
],
"llm_entrypoints": [
{"function": "stream_with_mentor", "path": "mentor_agent.py"},
{"function": "interpret_heaven", "path": "heaven_agent.py"},
{"function": "stream_review_assistant", "path": "assistant_agent.py"},
{"function": "compile_strategy_with_llm", "path": "llm_strategy.py"},
{"function": "test_llm_connection", "path": "llm_strategy.py"},
{"function": "stream_with_mentor", "path": "backend/features/mentor/agent.py"},
{"function": "interpret_heaven", "path": "backend/features/heaven/agent.py"},
{"function": "stream_review_assistant", "path": "backend/features/review/agent.py"},
{"function": "compile_strategy_with_llm", "path": "backend/features/screener/compiler.py"},
{"function": "test_llm_connection", "path": "backend/features/screener/compiler.py"},
],
"llm_transport": [
{"function": "chat_completion", "path": "backend/llm/transport.py"},
{"function": "stream_chat_completion", "path": "backend/llm/transport.py"},
],
"css_layers": css_layers(html),
"code_hotspots": code_hotspots(),
@@ -152,7 +182,10 @@ def main() -> int:
rendered = json.dumps(build(), ensure_ascii=False, indent=2) + "\n"
if args.check:
if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != rendered:
raise SystemExit("architecture inventory is stale; run tools/build_architecture_inventory.py")
raise SystemExit(
"architecture inventory is stale; run "
"tools/build_architecture_inventory.py"
)
print("Architecture inventory is current.")
return 0
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
+64 -21
View File
@@ -1,14 +1,13 @@
from __future__ import annotations
import argparse
import hashlib
import json
from datetime import datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TARGET = ROOT / "app"
OUTPUT = ROOT / "docs" / "migration" / "原版资产清单.json"
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
DIRECTORIES = (
"backend",
@@ -69,33 +68,57 @@ def digest(path: Path) -> str:
return checksum.hexdigest()
def source_files() -> list[Path]:
files = [ROOT / name for name in ROOT_FILES]
def display_path(path: Path, base: Path) -> str:
try:
return path.relative_to(base).as_posix() or "."
except ValueError:
return path.as_posix()
def source_files(source_root: Path) -> list[Path]:
files = [source_root / name for name in ROOT_FILES]
for directory in DIRECTORIES:
root = source_root / directory
if not root.is_dir():
continue
files.extend(
path
for path in (ROOT / directory).rglob("*")
for path in root.rglob("*")
if path.is_file() and "__pycache__" not in path.parts
)
return sorted(set(files), key=lambda path: path.relative_to(ROOT).as_posix())
return sorted(
set(files), key=lambda path: path.relative_to(source_root).as_posix()
)
def build_manifest() -> dict[str, object]:
def build_manifest(
source_root: Path,
target_root: Path,
source_commit: str,
) -> dict[str, object]:
assets = []
mismatches = []
for source in source_files():
relative = source.relative_to(ROOT)
target = TARGET / relative
source_hash = digest(source)
for source in source_files(source_root):
relative = source.relative_to(source_root)
target = target_root / relative
source_exists = source.is_file()
source_hash = digest(source) if source_exists else ""
target_hash = digest(target) if target.is_file() else ""
status = "identical" if source_hash == target_hash else "mismatch"
if not source_exists:
status = "missing_source"
elif not target.is_file():
status = "missing_target"
elif source_hash == target_hash:
status = "identical"
else:
status = "mismatch"
if status != "identical":
mismatches.append(relative.as_posix())
assets.append(
{
"source": relative.as_posix(),
"target": f"app/{relative.as_posix()}",
"bytes": source.stat().st_size,
"target": display_path(target, REPOSITORY_ROOT),
"bytes": source.stat().st_size if source_exists else 0,
"sha256": source_hash,
"disposition": "original_copy_pending_move",
"status": status,
@@ -104,9 +127,9 @@ def build_manifest() -> dict[str, object]:
return {
"schema_version": 1,
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"source_commit": "41329943c4878fc09ed82ec376eb93ab151e4092",
"source_root": ".",
"target_root": "app",
"source_commit": source_commit,
"source_root": display_path(source_root, REPOSITORY_ROOT),
"target_root": display_path(target_root, REPOSITORY_ROOT),
"excluded": [
"next/",
"data/review.db and SQLite sidecars",
@@ -123,14 +146,34 @@ def build_manifest() -> dict[str, object]:
def main() -> int:
manifest = build_manifest()
OUTPUT.write_text(
parser = argparse.ArgumentParser(
description=(
"Build an exact-copy manifest for a preservation migration stage. "
"This is a migration-only tool; it does not describe the final moved layout."
)
)
parser.add_argument("--source-root", type=Path, default=REPOSITORY_ROOT)
parser.add_argument("--target-root", type=Path, default=REPOSITORY_ROOT / "app")
parser.add_argument("--source-commit", default="")
parser.add_argument(
"--output",
type=Path,
required=True,
help="write to a new audit path; do not overwrite committed slice evidence",
)
args = parser.parse_args()
source_root = args.source_root.resolve()
target_root = args.target_root.resolve()
output = args.output.resolve()
manifest = build_manifest(source_root, target_root, args.source_commit)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(
f"assets={manifest['asset_count']} mismatches={manifest['mismatch_count']} "
f"output={OUTPUT}"
f"output={output}"
)
return 1 if manifest["mismatch_count"] else 0
+32 -4
View File
@@ -27,9 +27,21 @@ def schema(connection: sqlite3.Connection) -> list[dict[str, Any]]:
return [dict(row) for row in rows]
def table_rows(connection: sqlite3.Connection, table: str) -> list[dict[str, Any]]:
def table_rows(
connection: sqlite3.Connection,
table: str,
excluded_columns: set[str] | None = None,
) -> list[dict[str, Any]]:
quoted = '"' + table.replace('"', '""') + '"'
rows = [dict(row) for row in connection.execute(f"SELECT * FROM {quoted}").fetchall()]
excluded_columns = excluded_columns or set()
rows = [
{
key: value
for key, value in dict(row).items()
if key not in excluded_columns
}
for row in connection.execute(f"SELECT * FROM {quoted}").fetchall()
]
return sorted(rows, key=lambda row: json.dumps(row, ensure_ascii=False, sort_keys=True, default=str))
@@ -38,9 +50,23 @@ def main() -> None:
parser.add_argument("--original", type=Path, required=True)
parser.add_argument("--migrated", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument(
"--exclude-column",
action="append",
default=[],
metavar="TABLE.COLUMN",
help="exclude a nondeterministic column from one table comparison",
)
parser.add_argument("tables", nargs="+")
args = parser.parse_args()
excluded_by_table: dict[str, set[str]] = {}
for item in args.exclude_column:
table, separator, column = item.partition(".")
if not separator or not table or not column:
parser.error("--exclude-column must use TABLE.COLUMN")
excluded_by_table.setdefault(table, set()).add(column)
original = sqlite3.connect(args.original)
migrated = sqlite3.connect(args.migrated)
original.row_factory = sqlite3.Row
@@ -51,8 +77,9 @@ def main() -> None:
tables = []
all_equal = original_schema == migrated_schema
for table in args.tables:
original_rows = table_rows(original, table)
migrated_rows = table_rows(migrated, table)
excluded_columns = excluded_by_table.get(table, set())
original_rows = table_rows(original, table, excluded_columns)
migrated_rows = table_rows(migrated, table, excluded_columns)
equal = original_rows == migrated_rows
all_equal = all_equal and equal
tables.append(
@@ -63,6 +90,7 @@ def main() -> None:
"original_sha256": digest(original_rows),
"migrated_sha256": digest(migrated_rows),
"equal": equal,
"excluded_columns": sorted(excluded_columns),
}
)
result = {
+199
View File
@@ -0,0 +1,199 @@
from __future__ import annotations
import argparse
import hashlib
import json
import re
from pathlib import Path
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
ORIGINAL_STATIC = REPOSITORY_ROOT / "static"
FRONTEND_ROOT = REPOSITORY_ROOT / "app" / "frontend"
EVIDENCE_PATH = (
REPOSITORY_ROOT
/ "docs"
/ "migration"
/ "evidence"
/ "slice-10"
/ "frontend-source-map.json"
)
EXPECTED_SOURCE_SHA256 = "c020d0bc68f8a96b352a0ffa62c913a0ea3f5ca0afc41f70969f833e447166a6"
RUNTIME_RANGES: dict[str, list[tuple[int, int]]] = {
"pages/sentiment/page.js": [(1207, 1516)],
"pages/pools/page.js": [(1517, 1915)],
"pages/market/runtime.js": [(1916, 1957), (6893, 7719), (7969, 8426)],
"pages/rotation/page.js": [(1958, 2124)],
"pages/ladder/page.js": [(2125, 2216)],
"pages/auction/page.js": [(2217, 2481)],
"pages/themes/page.js": [(2482, 2583)],
"pages/popularity/page.js": [(2584, 2659)],
"pages/dragon-tiger/page.js": [(2660, 3028)],
"pages/review/page.js": [(3029, 3470), (7720, 7968)],
"pages/screener/page.js": [(3490, 4336), (6668, 6892)],
"pages/mentor/page.js": [(4337, 4827)],
"pages/heaven/page.js": [(4828, 6667)],
"shared/export.js": [(8905, 9051)],
}
def digest(content: str) -> str:
return hashlib.sha256(content.encode("utf-8")).hexdigest()
def marker(start: int, end: int, kind: str) -> str:
return f"/* PRESERVATION-SOURCE-{kind} app.js:{start}-{end} */"
def wrapped_chunk(lines: list[str], start: int, end: int) -> str:
content = "".join(lines[start - 1 : end])
return f"{marker(start, end, 'BEGIN')}\n{content}{marker(start, end, 'END')}\n"
def complement_ranges(total: int, moved: set[int]) -> list[tuple[int, int]]:
ranges: list[tuple[int, int]] = []
start = 0
for number in range(1, total + 1):
if number in moved:
if start:
ranges.append((start, number - 1))
start = 0
elif not start:
start = number
if start:
ranges.append((start, total))
return ranges
def original_prefix(relative: str) -> str:
source = ORIGINAL_STATIC / relative
if not source.is_file():
return ""
return source.read_text(encoding="utf-8").rstrip("\n") + "\n\n"
def extract_written_chunks(paths: list[Path]) -> dict[tuple[int, int], str]:
pattern = re.compile(
r"/\* PRESERVATION-SOURCE-BEGIN app\.js:(\d+)-(\d+) \*/\n"
r"(.*?)"
r"/\* PRESERVATION-SOURCE-END app\.js:\1-\2 \*/\n?",
re.DOTALL,
)
chunks: dict[tuple[int, int], str] = {}
for path in paths:
content = path.read_text(encoding="utf-8")
for match in pattern.finditer(content):
key = (int(match.group(1)), int(match.group(2)))
if key in chunks:
raise RuntimeError(f"Duplicate preserved range {key}")
chunks[key] = match.group(3)
return chunks
def main() -> None:
parser = argparse.ArgumentParser(
description="Reproduce the one-time Slice 10 frontend source split"
)
parser.add_argument(
"--apply",
action="store_true",
help="perform the historical split; this rewrites frontend runtime files",
)
args = parser.parse_args()
if not args.apply:
parser.error(
"this migration-only tool rewrites files; pass --apply only when "
"reproducing Slice 10 from its documented pre-split checkpoint"
)
source_path = ORIGINAL_STATIC / "app.js"
target_path = FRONTEND_ROOT / "app.js"
source_bytes = source_path.read_bytes()
source_sha256 = hashlib.sha256(source_bytes).hexdigest()
if source_sha256 != EXPECTED_SOURCE_SHA256:
raise RuntimeError(
f"Original app.js changed: expected {EXPECTED_SOURCE_SHA256}, got {source_sha256}"
)
if not target_path.is_file():
raise RuntimeError(f"Missing target runtime: {target_path}")
if hashlib.sha256(target_path.read_bytes()).hexdigest() != EXPECTED_SOURCE_SHA256:
raise RuntimeError("Target app.js is not the exact pre-split original")
source = source_bytes.decode("utf-8")
lines = source.splitlines(keepends=True)
moved_lines: set[int] = set()
for ranges in RUNTIME_RANGES.values():
for start, end in ranges:
overlap = moved_lines.intersection(range(start, end + 1))
if overlap:
raise RuntimeError(f"Overlapping source ranges at line {min(overlap)}")
moved_lines.update(range(start, end + 1))
core_ranges = complement_ranges(len(lines), moved_lines)
target_path.write_text(
"\n".join(wrapped_chunk(lines, start, end).rstrip("\n") for start, end in core_ranges)
+ "\n",
encoding="utf-8",
)
written_paths = [target_path]
for relative, ranges in RUNTIME_RANGES.items():
path = FRONTEND_ROOT / relative
path.parent.mkdir(parents=True, exist_ok=True)
chunks = "\n".join(
wrapped_chunk(lines, start, end).rstrip("\n") for start, end in ranges
)
path.write_text(original_prefix(relative) + chunks + "\n", encoding="utf-8")
written_paths.append(path)
extracted = extract_written_chunks(written_paths)
expected_ranges = {
source_range for ranges in RUNTIME_RANGES.values() for source_range in ranges
} | set(core_ranges)
if set(extracted) != expected_ranges:
raise RuntimeError("Written source ranges do not cover the original runtime exactly")
reassembled = [""] * len(lines)
for (start, end), content in extracted.items():
chunk_lines = content.splitlines(keepends=True)
if len(chunk_lines) != end - start + 1:
raise RuntimeError(f"Line count changed in preserved range {start}-{end}")
reassembled[start - 1 : end] = chunk_lines
reassembled_source = "".join(reassembled)
if reassembled_source != source:
raise RuntimeError("Split runtime cannot be reassembled byte-for-byte")
manifest = {
"source": "static/app.js",
"source_sha256": source_sha256,
"source_line_count": len(lines),
"reassembled_sha256": digest(reassembled_source),
"all_source_lines_preserved": True,
"core": {
"target": "app/frontend/app.js",
"ranges": [{"start": start, "end": end} for start, end in core_ranges],
},
"modules": [
{
"target": f"app/frontend/{relative}",
"ranges": [
{
"start": start,
"end": end,
"sha256": digest("".join(lines[start - 1 : end])),
}
for start, end in ranges
],
}
for relative, ranges in RUNTIME_RANGES.items()
],
}
EVIDENCE_PATH.parent.mkdir(parents=True, exist_ok=True)
EVIDENCE_PATH.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(json.dumps(manifest, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
+120 -9
View File
@@ -5,10 +5,15 @@ import shutil
import sqlite3
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
FRONTEND_ROOT = ROOT / "frontend"
E2E_URL = "http://127.0.0.1:8876/index.html"
def run(label: str, command: list[str]) -> None:
@@ -16,6 +21,39 @@ def run(label: str, command: list[str]) -> None:
subprocess.run(command, cwd=ROOT, check=True)
def python_test_command(preservation_baseline: bool | None = None) -> list[str]:
if preservation_baseline is None:
preservation_baseline = (ROOT.parent / "static" / "app.js").is_file()
if preservation_baseline:
return [sys.executable, "-m", "unittest", "discover", "-s", "tests"]
modules = [
f"tests.{path.stem}"
for path in sorted((ROOT / "tests").glob("test_*.py"))
if not path.stem.startswith("test_preservation_")
]
if not modules:
raise RuntimeError("no standalone candidate tests found")
return [sys.executable, "-m", "unittest", *modules]
def verify_git_diff() -> None:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
cwd=ROOT,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
print("\n[patch] skipped: standalone export is not a Git checkout")
return
repository_root = Path(result.stdout.strip()).resolve()
if ROOT != repository_root / "app":
print("\n[patch] skipped: standalone export is outside the canonical app path")
return
run("patch", ["git", "diff", "--check"])
def verify_database() -> None:
database = ROOT / "data" / "review.db"
if not database.exists():
@@ -28,8 +66,63 @@ def verify_database() -> None:
print(f"\n[database] integrity_check=ok size={database.stat().st_size}")
def url_reachable(url: str) -> bool:
try:
with urllib.request.urlopen(url, timeout=1) as response:
return response.status == 200
except (OSError, urllib.error.URLError):
return False
def start_e2e_server() -> subprocess.Popen[bytes] | None:
if url_reachable(E2E_URL):
print(f"\n[e2e-server] reusing {E2E_URL}")
return None
creation_flags = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
process = subprocess.Popen(
[
sys.executable,
"-m",
"http.server",
"8876",
"--bind",
"127.0.0.1",
"--directory",
"frontend",
],
cwd=ROOT,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=creation_flags,
)
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
if process.poll() is not None:
raise RuntimeError("Playwright static server exited before becoming ready")
if url_reachable(E2E_URL):
print(f"\n[e2e-server] started {E2E_URL}")
return process
time.sleep(0.1)
stop_e2e_server(process)
raise RuntimeError("Playwright static server did not become ready within 15 seconds")
def stop_e2e_server(process: subprocess.Popen[bytes] | None) -> None:
if process is None:
return
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
print("\n[e2e-server] stopped")
def main() -> int:
parser = argparse.ArgumentParser(description="Verify the governance regression baseline")
parser = argparse.ArgumentParser(
description="Verify the modular preservation candidate"
)
parser.add_argument(
"--e2e",
action="store_true",
@@ -37,20 +130,38 @@ def main() -> int:
)
args = parser.parse_args()
run("python", [sys.executable, "-m", "unittest", "discover", "-s", "tests"])
run("python", python_test_command())
run(
"api-registry",
[sys.executable, "tools/build_api_registry.py", "--check"],
)
run(
"architecture-inventory",
[sys.executable, "tools/build_architecture_inventory.py", "--check"],
)
node = shutil.which("node")
if not node:
raise RuntimeError("node is required for JavaScript syntax checks")
for script in ("static/app.js", "static/heaven-loading-v2.js"):
run("javascript", [node, "--check", script])
run("patch", ["git", "diff", "--check"])
scripts = sorted(FRONTEND_ROOT.rglob("*.js"))
if not scripts:
raise RuntimeError(f"no JavaScript files found under {FRONTEND_ROOT}")
for script in scripts:
run(
"javascript",
[node, "--check", script.relative_to(ROOT).as_posix()],
)
verify_git_diff()
verify_database()
if args.e2e:
npm = shutil.which("npm.cmd" if sys.platform == "win32" else "npm")
if not npm:
raise RuntimeError("npm is required for the Playwright suite")
run("playwright", [npm, "run", "test:e2e"])
npx = shutil.which("npx.cmd" if sys.platform == "win32" else "npx")
if not npx:
raise RuntimeError("npx is required for the Playwright suite")
server = start_e2e_server()
try:
run("playwright", [npx, "playwright", "test", "--reporter=dot"])
finally:
stop_e2e_server(server)
print("\nBaseline verification passed.")
return 0
+121
View File
@@ -0,0 +1,121 @@
# `app/`代码减法账本
> 基线:`xiaobai-preservation-complete-20260801`
> 工作目录:只允许修改`webapp/app/`;原版根目录和冻结的`next/`只读
> 目标:删除重复实现和历史补丁,不改变功能、视觉、交互、动画、计算、权限、API或数据行为
## 固定规则
1. 每批只处理一个明确边界,先证明重复或无消费者,再修改。
2. 新共享实现必须在同一提交删除全部被替代实现;禁止只加一层包装。
3. 运行代码总量原则上不得增加;测试和证据代码单独统计。
4. 迁移期源码相等测试不得简单删除。发生已批准的结构重构时,必须替换成行为、错误语义和
唯一所有权契约。
5. 每批通过领域测试、全量候选测试、独立导出测试和受影响的浏览器流程后才建立Git检查点。
6. CSS最后处理;没有逐页日间、夜间和多视口截图证据,不删除视觉规则。
## 批次记录
| 批次 | 边界 | 基线问题 | 目标 | 状态 |
|---|---|---|---|---|
| CR-01 | LLM供应商传输 | 问师、问天、复盘助手和策略编译各自构造HTTP请求、解析响应和读取错误 | 只保留`backend/llm/transport.py`一个网络出口 | 已完成 |
| CR-02 | HTTP精确POST委托 | 27个端点重复使用“比较路径、调用无参数处理器、返回”三行分支 | 用公开/受保护两张显式映射统一委托,同时保留复杂路由的原控制流 | 已完成 |
| CR-03 | 股票市场后缀转换 | Tushare业务与iFinD图表各保留一份完全相同的沪深京代码转换函数 | 图表复用`bootstrap/config.py::tushare_code`,只保留一份函数体 | 已完成 |
| CR-04 | 数值归一化策略 | 四个业务模块分别保留两组完全相同的数值转换函数体 | 由`backend/data/numbers.py`集中拥有两种既有语义,消费者保留原局部别名 | 已完成 |
## CR-01验收口径
- 四个功能模块不得包含`urllib.request``/chat/completions`
- 非流式与流式请求的URL、鉴权、User-Agent、SSE累积和空响应行为保持不变。
- 各功能原有错误类型和用户可见错误文案保持不变。
- `LLMGateway`的会员、额度、主辅回退、首字后不中途换模型和审计规则保持不变。
- 架构清单必须登记唯一传输入口;完整测试和真实主模型最小调用通过。
## CR-01结果
- 四个功能模块的运行代码由572行降至422行;新增唯一传输实现129行,生产代码净减少21行、
约1.4 KB。行数不是主要收益,关键是5处`/chat/completions`请求只剩1处。
- 三套重复HTTP错误正文解析合并为一套;各功能原有错误类型和用户可见文案由专项测试固定。
- 迁移期4个“Agent文件逐字相等”断言没有直接删除,而是替换为共享传输唯一所有权、提示词模块
归属、SSE行为和兼容模块对象契约。
- 候选311项、纯`app/`导出248项、45项Playwright通过;24个JavaScript文件、架构/API注册表和
SQLite完整性检查通过。
- 使用候选数据库中加密保存的主模型完成真实非流式与流式最小调用,分别成功返回完整响应和
4个流式分片。调用未输出密钥或模型正文。
- 本批不修改页面、CSS、提示词、业务计算、会员额度、模型回退、数据库或部署。
回档基线为`xiaobai-preservation-complete-20260801`;本批检查点为
`xiaobai-reduction-01-llm-transport-20260801`
## CR-02验收口径
- 仅纳入没有路径参数、请求体解析或专属异常分支的精确POST端点;其余路由保持原样。
- 公开注册/登录端点继续在鉴权前分发;受保护端点继续严格执行登录、CSRF、注册表权限、处理器。
- 27个映射路径必须都由权威API注册表解析,处理方法必须真实存在,公开与受保护集合不得重叠。
- API路径、功能归属、访问角色、状态码、错误正文和静态页面绕过鉴权行为保持不变。
- API清单生成器必须结构化读取显式映射;架构清查复用API清单,不再维护第二套路由发现规则。
## CR-02结果
- 2个公开端点和25个受保护端点改为显式委托映射,原来的79行重复分支被43行映射、分发与调用替代;
`backend/application.py`净减少36行,规范化源码约减少1.0 KB。
- 带正则路径参数、请求体读取、查询参数转换或特殊异常语义的GET、POST、DELETE端点未改动。
- 架构清查删除了自行扫描精确/正则API路径的第二套规则,改为消费权威`api.config.json`API注册表的
53个精确路径、11个正则路径、功能归属和权限均未变化。
- 原版231项、候选315项、纯`app/`导出252项、45项Playwright通过;24个JavaScript文件、
API/架构注册表、Git空白检查和SQLite完整性检查通过。
- 本批不修改前端、CSS、业务计算、数据源、数据库结构、LLM、会员规则或部署。
本批基线为`xiaobai-reduction-01-llm-transport-20260801`;检查点为
`xiaobai-reduction-02-http-dispatch-20260801`
## CR-03验收口径
- 图表模块不再定义第二份市场后缀转换函数,仍保留原局部名称和两个调用点。
- 深市、沪市、北交所的既有映射结果保持不变;不借本批修正或扩展代码规则。
- 原图表文件除该函数外的所有顶层定义继续与原版AST逐项相等。
- 原版`_stock_market_code`函数的参数和函数体必须与唯一共享实现AST相等,运行时别名必须指向
同一个函数对象。
## CR-03结果
- 删除`backend/features/market/charts.py`中第二份10行定义,以1行导入别名复用共享实现,生产代码
净减少9行;全仓后端只剩一份沪深京后缀转换函数体。
- 未合并实时聚合、东方财富图表、iFinD和Tushare的HTTP传输;它们的缓存、错误、重试和降级语义
不同,仅有外形相似,证据不足以安全抽象。
- 原有迁移期整文件相等断言被等价范围断言、共享函数AST断言和唯一对象断言替代,没有降低门禁。
- 原版231项、候选317项、纯`app/`导出254项、45项Playwright通过;API/架构注册表、
24个JavaScript文件、Git空白检查和SQLite完整性检查通过。
- 本批不修改图表请求、数据来源、缓存、时间范围、行情计算、前端、CSS、数据库或部署。
本批基线为`xiaobai-reduction-02-http-dispatch-20260801`;检查点为
`xiaobai-reduction-03-market-symbol-20260801`
## CR-04验收口径
- 只合并参数、函数体和运行结果完全一致的数值转换函数,不借本批改变任何业务计算或异常默认值。
- `finite_number`继续拒绝`NaN`与正负无穷;`non_nan_number`继续只拒绝`NaN`并保留正负无穷。
- Tushare与智能选股必须复用有限数策略;市场洞察与情绪引擎必须复用非NaN策略,并继续暴露原局部
`_number`名称以保持兼容。
- 实时行情和图表转换器的空值、默认值或参数签名语义不同,必须继续独立保留,不能因名称相同而合并。
- 原版函数参数和函数体分别与共享实现AST相等;四个消费者的局部别名必须指向对应的唯一函数对象。
## CR-04结果
- 删除Tushare、智能选股、市场洞察和情绪引擎中的四份重复函数体,新建两种明确命名的共享策略;生产
代码净减少约12行,全仓AST扫描不再发现完全相同的函数定义。
- 将可复用的函数及模块AST契约归入测试辅助层,原有迁移保持性测试改为“未改范围保持相等、被替换
函数与共享实现相等、运行时唯一对象”三重断言,没有降低门禁。
- 实时行情`backend/data/realtime.py::_number`与图表`backend/features/market/charts.py::_number`
被明确保留;它们不是本批重复实现,也未改变行为。
- 58项定向测试、41项保持性/治理测试、原版231项、候选320项、纯`app/`导出257项和45项
Playwright通过;24个JavaScript文件、API/架构注册表、Git空白检查和SQLite完整性检查通过。
- 本批不修改前端、CSS、接口、数据来源、行情口径、选股条件、数据库、LLM、权限或部署。
本批基线为`xiaobai-reduction-03-market-symbol-20260801`;检查点为
`xiaobai-reduction-04-numeric-normalization-20260801`
## 人工验收记录
- 2026-08-01:用户检查CR-02与CR-03运行结果,确认未发现明显异常。本记录仅表示本轮可见功能与
页面使用未发现明显回归,不替代后续批次各自的自动测试和人工抽查。
+2
View File
@@ -7,5 +7,7 @@
3. [`保真迁移状态.json`](保真迁移状态.json):机器可读当前状态和下一步。
4. [`保真迁移账本.md`](保真迁移账本.md):连续检查点、资产处置和决策记录。
5. [`next失败冻结记录.md`](next失败冻结记录.md):失败实现的隔离边界。
6. [`人工维护与本地切换指南.md`](人工维护与本地切换指南.md):迁移版目录、验证、数据边界、人工验收、切换与回退。
7. [`evidence/slice-11/README.md`](evidence/slice-11/README.md):当前候选的试删审计和全量自动验收结果。
`重建迁移章程.md``next/`内阶段文档均是失败过程历史记录,不再指导后续实施。
@@ -0,0 +1,70 @@
# 切片 10:前端 Shell、页面、样式与移动端职责归位
> 基线:`38de3de`(切片 09
> 回档标签:`xiaobai-preservation-slice-10-20260731`
> 结论:源码、静态资产、API、数据库、桌面/移动页面及动画差分通过;最终视觉仍等待全站人工验收
## 1. 原实现归位
本切片只移动和拆分原版前端源码,没有从`next/`取用代码,也没有重写页面、样式、动画、交互或响应式规则。
| 原位置 | 新位置 | 处理方式 |
|---|---|---|
| `static/index.html` | `app/frontend/index.html` | 原 DOM 骨架,仅调整静态资源路径和拆分脚本加载顺序 |
| `static/app.js` | `app/frontend/app.js``pages/*/page.js``pages/market/runtime.js``shared/export.js` | 按连续源码行机械拆分 |
| `static/styles.css`等公共样式 | `app/frontend/styles/` | 字节级移动 |
| `static/wentian-v2.css` | `app/frontend/pages/heaven/page.css` | 字节级移动 |
| `static/ui-core.js` | `app/frontend/shared/ui-core.js` | 字节级移动 |
| `static/heaven-loading-v2.js` | `app/frontend/pages/heaven/loading-v2.js` | 字节级移动 |
| `static/pages/``shared/``vendor/` | `app/frontend/`同职责目录 | 字节级移动 |
`app/backend/bootstrap/config.py`现在只把静态根目录从`app/static/`切换到`app/frontend/`。原`app/static/`已不再存在,避免形成两套可被误改的前端实现。
## 2. 源码与静态资产等价
-`static/app.js`共 9,283 行,SHA-256 为`c020d0bc68f8a96b352a0ffa62c913a0ea3f5ca0afc41f70969f833e447166a6`
- 所有拆分文件中的保真源码片段按原行号重组后,SHA-256仍为同一值,9,283行无遗漏、无重排、无内容变化。
- 七层原版样式、问天样式、加载动画、共享脚本、页面注册脚本及Lucide供应商文件在移动后保持字节一致。
- `index.html`的差异仅限资源新路径和两个机械拆分脚本入口;反向替换后与原版逐字符一致。
- 浏览器请求仍只有`shared/api.js`一个`fetch`出口。
- 完整行号映射见`frontend-source-map.json`;拆分工具为`app/tools/split_frontend_runtime.py`,可重复验证但不用于运行时构建。
## 3. API 与数据库差分
- 同一管理员账号和同一固定日期下,对原版与迁移版执行21个全站只读请求。
- 请求覆盖账号、行情、情绪、轮动、竞价、题材、人气、龙虎榜、选股、问师、问天、复盘与提醒;状态码和规范化业务JSON全部一致。
- 两个数据库副本均包含62个schema对象,schema哈希一致。
- 21张关键表的记录数、顺序和规范化内容逐行一致;`screener_strategies`仅排除每次初始化可能更新的`updated_at`
- 完整结果见`api-requests.json``api-diff.json``database-diff.json`,两份差分文件的`all_equal`均为`true`
## 4. 真实浏览器差分
- 1920×1080日间模式检查情绪周期、智能选股、观势、观气、观心介绍及观心呼吸。
- 1920×1080夜间模式检查情绪周期的背景、字体、表格、几何和横向溢出。
- 390×844检查情绪周期与观势:移动Shell、底部五入口、页面纵向滚动、无横向溢出及问天特效均一致。
- 观势星空节点90个、观气100个、观心110个;三页八卦节点均为2个。
- 观势与观气保留`wt-tw``wt-spin`动画;观心呼吸保留`heart-incense-burn``heart-incense-glow`及动态波纹。
- 观气五行行业为5组且默认折叠;观心介绍到呼吸流程可正常进入。
- 原版和迁移版检查流程均未产生新增控制台error或warn。
- 动画帧、焦点框和动态状态文字属于采样瞬时状态,因此截图文件哈希不要求相同;可见布局几何、计算样式、节点、动画名称和交互结果必须一致,本次均通过。
- 两个服务使用相同主机名、不同端口时会共享并覆盖登录Cookie;曾导致迁移版切页被误判为失效。逐服务重新登录后行为一致,该问题属于并行验收环境限制,不是产品回归。
- 2026-08-01像素复核发现`frontend-migrated-auction-light-1920x1080`实际截取了登录状态失效页,
不能证明集合竞价视觉等价,文件已重命名为`INVALID-login-session`。集合竞价仍有切片05真实页面、
本切片源码/样式保真、API及Playwright证据,但最终视觉明确留待人工验收,不以替代证据冒充
本切片截图差分。
- 机器可读记录见`browser-acceptance.json`,截图均保存在本目录。
## 5. 自动验证与保留边界
| 验证 | 结果 |
|---|---:|
| 前端专项测试 | 85项通过 |
| 原版`python -m unittest discover -s tests -q` | 231项通过 |
| 迁移版`python -m unittest discover -s tests -q` | 294项通过 |
| `npx.cmd playwright test --reporter=dot` | 45项通过(2.3分钟) |
| 全部拆分后JavaScript执行`node --check` | 通过 |
| `git diff --check` | 通过 |
- `demo_data.py``frontend/heaven-loading.js`、五个疑似无引用前端函数和`wencai_saved_queries`继续保留到切片11逐项审计。
- 没有修改`next/`、正式数据库、Docker/NAS或`8765`服务。
- 自动差分只能证明已检查范围一致;依据迁移总纲,未经用户最终人工确认不得宣称全站视觉已经完全等价。

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